Стоит указать цель этих статей, чтобы не было недопонимания. Я не буду разбирать каждый компилятор Си в отдельности. Это долго и нудно. Вместо этого, я хочу увлечь читателей разбором интересных интерпретаций Си-кода, чтобы люди осознавали, как их код может меняться и исполняться процессором. А так же развеять некоторые мифы, которые ходят среди начинающих программистов. Например, есть, правда, те, кто считает, что если складывать числа в цикле, то это будет быстрее, чем просто умножить одно число на другое. Статья не разбирает конкретно gcc с -m32 -O0, некоторые не совсем поняли идею. Если будет реальный смысл, то я поменяю и компилятор, и ключи.
Т. е. что я хочу сказать? Рассмотрим два старых примера:
int main(void)
{
register int a = 1; //записываем в регистровую переменную 1
return a; //возвращаем значение из регистровой переменной
}
и
int a = 1;
int b = a * 2;
Действительно, clang в первом случае определяет переменную в стек, но насколько это интересно или существенно для нас? Те, кто знаком со спецификатором register, читали/знают, что это, всего лишь, рекомендация. Поэтому компилятор может просто проигнорировать спецификатор. К тому же, цель примера была познакомить читателя с регистрами, взяв простейший пример. Подошел идеально для этого gcc. Второй пример еще проще, в нем clang сразу же делает сдвиг, и сдается уже при умножении на 3, выдавая imul. Честно, не очень понимаю, что в данном примере любопытного, поэтому также привел код для gcc, который извращается до числа 22. Мы все и так знаем, что в стандарте языка не прописано, как реализовывать ту или иную вещь. И разработчики компилятора вольны делать свои реализации, лишь бы они не нарушали стандарт. Поэтому и имеем разную интерпретацию кода в зависимости от компилятора. Но, простите меня, разбирать каждую? В чем практичность данного материала? Заморочить всем голову? Как правильно было замечено, если интересует конкретный компилятор, то можно просто посидеть с отладчиком. И это уже не будет так страшно для тех, кто читает эти статьи.
Итак, продолжим.
Простейшее условие
Для начала сравним переменную и число:
int a = 0;
if (a < 5)
{
return 1;
}
return 0;
АСМ (gcc 7.2):
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-4], 4
jg .L2
mov eax, 1
jmp .L3
.L2:
mov eax, 0
.L3:
leave
ret
В первой строке, компилятор добавляет в стек значение переменной «а». Во второй у нас новая инструкция cmp. Не трудно догадаться, что эта инструкция сравнивает два значения. В нашем случае: значение из стека и 4.
Но как она работает? Просто отнимает от первого операнда второй. Похожим образом работает и инструкция sub, но в случае с cmp результат не сохраняется. Однако флаги в регистре EFLAGS/RFLAGS устанавливаются в соответствии с этим результатом. Не вдаваясь в подробности, мы можем узнать был ли положительный результат, отрицательный или ноль. Как раз следующая команда условного перехода jg срабатывает в том случае, если результат был положительным (jump if greater).
Если вы это осмыслили, то мог возникнуть справедливый вопрос: а почему больше, если знак был меньше? Действительно, написали мы если а < 5, но это превратилось в делай что-то, если а > 4. Но логика программы не нарушилась. Ведь, если a > 4, то происходит return 0. Тут может возникнуть еще один справедливый вопрос, а если написать условие: if (a > 4) return 0, как изменится сам код?
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-4], 4
jle .L2
mov eax, 0
jmp .L3
.L2:
mov eax, 1
.L3:
leave
ret
И мы снова получаем разворот условия: jle, как вы догадались, меньше либо равно (jump if less or equal)
Тут все дело в том, что return завершает программу, поэтому нужно еще выполнить две последние инструкции, именно поэтому строчка jmp .L3 не меняется в обоих примерах. Это инструкция безусловного перехода. В нашем случае: она пропускает строчку следующую за условием, где в регистр eax должно записаться совсем другое число.
Т. е. компилятор проверяет обратное условие, чтобы при оригинальном false отослать по коду вниз, но если оригинальное условие – true, то выполняется код, который идет непосредственно за cmp и условным переходом. Давайте для наглядности отметим цифрами ветки условия:
int a = 0;
if (a > 5)
{
//#0
return 1;
}
//#1
return 0;
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-4], 4
jle .L2
;#0
mov eax, 0
jmp .L3
.L2:
mov eax, 1
;#1
.L3:
leave
ret
Как видите, структура программы не нарушается, но если мы заменим условный переход, то:
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-4], 5
jg .L2
;#1
mov eax, 1
jmp .L3
.L2:
mov eax, 0
;#0
.L3:
leave
ret
Т. е. внутренняя часть условия падает вниз программы, что не очень хорошо: допустим, после условия (в секции #1) есть еще куча строчек, тогда, чтобы увидеть секцию #0, мы будем крутить листинг очень далеко вниз. (Забыл уточнить, что затем надо будет еще и вернуться для продолжения выполнения кода после if. Т. е. еще одна метка и еще один переход.)
unsigned
Мы только что рассмотрели сравнение чисел, имеющих знак (signed), а что если мы будем сравнивать беззнаковые числа (unsigned)?
unsigned int a = 0;
if (a > 5)
{
return 1;
}
return 0;
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-4], 5
jbe .L2
mov eax, 1
jmp .L3
.L2:
mov eax, 0
.L3:
leave
ret
Ничего не изменилось, кроме инструкции условного перехода: вместо jle теперь jbe (jump below or equal). Зачем две различные инструкции для сравнения знаковых и беззнаковых чисел?
int a = 0 – 1; //-1
unsigned int a = 0 – 1; //4294967295
Хотя по факту, в памяти, все равно, будет 4294967295. Это всего лишь способ отображения, вы можете написать в Си:
unsigned int a = 0 - 1;
printf("%i", a); //-1
Но при инструкции cmp устанавливается не один флаг, а несколько. Инструкция jbe проверяет флаг переполнения при вычитании, а jle проверяет флаг, который равен значению старшего бита результата (т. е. при отрицательном результате там 1). В реальности все чуточку сложнее: JBE (CF=1 or ZF=1), JLE (ZF=1 or SF<>OF), но нам можно на этом не зацикливаться. Перейдем к более интересным вещам:
unsigned int a = 0;
if (a < 0)
{
return 1;
}
return 0;
будет преобразовано в:
mov DWORD PTR [ebp-4], 0
mov eax, 0
leave
ret
Здорово, правда? По логике нашего кода переменная «a» не будет никогда меньше нуля, поэтому условие можно просто выкинуть.
А что с этим:
unsigned int a = 0;
if (a > 0)
{
return 1;
}
return 0;
АСМ:
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-4], 0
je .L2
mov eax, 1
jmp .L3
.L2:
mov eax, 0
.L3:
leave
ret
Инструкция je осуществляет переход, если результат сравнения равен нулю (jump if equal).
”<” быстрее, чем ”<=”? Или чем ”< || =”?
Мы уже рассмотрели несколько инструкций условного перехода: jle, jbe, jg и je. Таких инструкций немногим больше для всех случаев, также есть и обратные: например jne – не нуль или не равно или jnbe – не ниже и не равно. Т. е. для любого сравнения чисел мы получим две инструкции cmp (или test) и jcc (условный переход). Таким образом можно сделать вывод, что, например, нет разницы в количестве инструкций для < и <=.
А вот для
if (a < 0 || a == 0)
разница будет, но только при -O0.
Давайте взглянем на следующую программу:
#include <stdio.h>
int main()
{
int a = 0;
scanf("%d", &a);
if (a < 0 || a == 0)
{
return 10;
}
return 20;
}
На этот раз я использую clang 5.0.0 -O3 -m32, так как генерируется меньше asm кода, и по этому примеру будет легче объяснить, что происходит:
sub esp, 12
mov dword ptr [esp + 8], 0
;scanf
sub esp, 8
lea eax, [esp + 16]
push eax
push .L.str
call scanf
add esp, 16
;end scanf
;интересующий нас код
cmp dword ptr [esp + 8], 0 ;#1
mov ecx, 10 ;#2
mov eax, 20 ;#3
cmovle eax, ecx ;#4
;эпилог функции
add esp, 12
ret
.L.str:
.asciz "%d"
#1: сравнение переменной «а» с нулем
#2: в регистре ecx теперь 10
#3: в регистре eax теперь 20
#4: cmovle аналогичен jle, только он перемещает значение при условии. Таким образом, если a<=0, то в eax попадает значение из ecx (10), иначе, просто, останется 20.
Вы уже понимаете, что если в Си-коде заменить на a<=0, то ничего не изменится, но можете проверить, если есть желание.
Когда условия перестают быть условиями
Представьте ситуацию: в вашем коде есть условие, но при отладке вы не можете найти условные инструкции. Интересно?
Взглянем на следующий код:
int x = 10;
scanf("%d", &x);
if (x < 0)
{
return 3;
}
return 2;
Вы могли бы ожидать cmp и метки, могли ожидать даже более нетривиальные вещи, как setx, но получили следующее (clang 5.0.0 -O3 -m32):
mov eax, dword ptr [esp + 8]
shr eax, 31
or eax, 2
add esp, 12
ret
Ну, и что это? Давайте разберемся. С первой строкой все понятно: в eax перенесли значение переменной x.
Следующую строчку вы должны помнить по прошлой статье. Это сдвиг вправо на 31 бит. Т. е. по факту у нас остается только первый бит от всего числа.
Далее идет побитовая операция «или». Т. е. у нас получается в результате или 10, или 11 (в двоичной системе счисления). На этом все, следующие строки относятся к эпилогу функции.
Что интересно, догадаться написать такой код, особо труда не составляет. Мы же просто прибавляем к двойке знак числа, находящийся в переменной x.
По этой же логике, но немного не так действует, например, gcc 4.8.5:
sar eax, 31
not eax
add eax, 3
sar — это тоже сдвиг вправо, но работает он несколько иначе, самый старший бит, т. е. знак, он не сдвигает.
[1000] shr [0100] shr [0010] shr [0001]
[1000] sar [1100] sar [1110] sar [1111]
Т. е. если у нас будут все единицы, то число было отрицательным, мы инвертируем все биты, получаем нуль, добавляем 3, ровно то, что мы хотели. А если число положительное, то будут все нули, после инвертирования станут единицами. По факту — это -1, добавив к нему 3, получим 2.
MSVC -O2 при этом поступает более ожидаемо:
cmp DWORD PTR _x$[ebp], eax ; x < 0
setl al ; less ? mov al, 1
add eax, 2 ; eax + 2
Очень кратко: младший байт регистра eax выставляется в единицу при условии, что результат сравнения оказался отрицательным, затем добавляется 2. Это классика. Компиляторы очень любят данный прием, надеюсь, мы его еще повстречаем.
Оператор else
Я думаю, все понимают, что для else не проверяется обратное условие. В коде просто появляется еще одна метка, вот и все особенности. Давайте убедимся в этом и рассмотрим следующий код:
int x = 10;
int c = 0;
if (x < 4)
{
c = 3;
}
else
{
c = 2;
}
return c;
АСМ:
mov DWORD PTR [ebp-8], 10
mov DWORD PTR [ebp-4], 0
cmp DWORD PTR [ebp-8], 3
jg .L2
mov DWORD PTR [ebp-4], 3
jmp .L3
.L2:
mov DWORD PTR [ebp-4], 2
.L3:
mov eax, DWORD PTR [ebp-4]
Как видите, отличие только в том, что после выполнения кода внутри if, мы перескакиваем через внутренности блока else (метка .L2). Надеюсь, подробный анализ не нужен, вроде бы, все очевидно.
Логические операции в if
Давайте посмотрим на немного нестандартный пример:
int main(void)
{
int a = -1;
if (a++ < 0 || a++ > 5)
{
a++;
}
else
{
a+=2;
}
return 0;
}
Сначала попробуйте дать ответ: какое значение будет в переменной «a»? Если для вас это было не сложно, то вы уже примерно представляете, как будет выглядеть asm код.
mov DWORD PTR [ebp-4], -1
mov eax, DWORD PTR [ebp-4] ;#1
lea edx, [eax+1] ;#1
mov DWORD PTR [ebp-4], edx ;#1
test eax, eax ;#1
js .L2 ;#1
mov eax, DWORD PTR [ebp-4] ;#2
lea edx, [eax+1] ;#2
mov DWORD PTR [ebp-4], edx ;#2
cmp eax, 5 ;#2
jle .L3 ;#2
.L2:
mov eax, 1 ;#3
jmp .L4 ;#3
.L3:
mov eax, 0 ;#4
.L4:
test al, al ;#5
je .L5 ;#5
add DWORD PTR [ebp-4], 1 ;#6
jmp .L6 ;#6
.L5:
add DWORD PTR [ebp-4], 2 ;#7
.L6:
mov eax, 0 ;#8
Итак, пробуем разобраться. Я выделил операции номерами, чтобы проще было различать, что к чему относится.
#1: a++ < 0. При этом в eax записывается значение до инкремента, именно его и нужно сравнить с нулем. Инструкция test работает по принципу and, но не изменяет сами операнды, только флаги. В нашем случае после test мы проверяем знак числа, если он равен 1, то осуществляем переход. Также в стек возвращается значение, увеличенное на единицу. Увеличение делается инструкцией lea edx, [eax + 1]. Инструкция lea используется для загрузки эффективного адреса. В нашем случае, она заменяет сразу две инструкции: mov edx, eax и add edx, 1.
#2: a++ > 5. Фактически, происходит тоже самое, только переход на метку .L3 в случае, если a <= 5. Т. е. на метку .L2 мы попадаем, если выполняется первое условие или второе. При этом обратите внимание, что второе условие не будет вычисляться, если выполнилось первое. Но вы это и так должны были знать.
#3: В регистре eax оказывается 1
#4: В регистре eax оказывается 0
#5: Проверяем младший байт регистра eax на нуль, если равен, то переходим на метку .L5
#6: Иначе добавляем в переменную «a» 1. Переходим в конец программы.
#7: Если младший байт в eax был равен нулю, то добавляем переменной «а» 2
Т. е. #3 отвечает за то, что оба условия выполнены и ставится «флаг», затем осуществляется проверка в #5, если «флаг» стоит, то добавили 1, иначе 2.
Стоит также отметить, что при if (a < 0 && a < -5), если первое условие не выполняется, то второе также не будет вычисляться.
Заключение
Мы кратко рассмотрели условия в Си. Вы увидели, что компилятор может, как слегка модифицировать код, так и менять его до неузнаваемости при оптимизации.
К сожалению, статья вышла настолько длинной, что не удалось рассмотреть оператор switch, поэтому, если хотите, можно это сделать в следующей статье. Заодно, можно рассмотреть и оптимизированные программы с else.
Предыдущая статья
Комментарии (18)
chnav
25.12.2017 14:06Т. е. внутренняя часть условия падает вниз программы, что не очень хорошо: допустим, после условия (в секции #1) есть еще куча строчек, тогда, чтобы увидеть секцию #0, мы будем крутить листинг очень далеко вниз.
Причина другая. Обычно используется короткий условный переход, всего команда занимает 2 байта, из которых один байт задаёт смещение (со знаком) относительно следующей команды, т.е. условный переход может быть не далее 127 байт. Если тело не помещается в 127 байт то делается этот трюк с дополнительным длинным переходом.SendMess Автор
25.12.2017 14:13В смысле, расчет идет на то, что тело обычно короткое? И то, что при опускании тела вниз, нужен дополнительный переход назад, это вообще не важно? Хммммм
yesasha
25.12.2017 20:13Я раньше писал на Fasm. Потом понял, что это не перспективно и не кроссплатформенно, и перешёл на javascript, чтоб уж наверняка) Дело в том, что ассемблер — это не только x86. А я более менее разобрался только в нём. А всё остальное (mmx, sse) оказалось слишком сложным. А от одного x86 смысла не так много, любой компилятор сделает это не так уж и плохо и при этом кроссплатформенно! А с распространением arm всё стало совсем плохо.
old_bear
25.12.2017 21:55Да что там сложного? Двоичная математика везде одинаковая. И идея SIMD тоже вполне себе универсальна.
Просто надо asm в правильных местах применять.yesasha
26.12.2017 01:45Забыл что есть ещё x86-64. SIMD идея красивая, так же как и VLIW. Только не для человека! Я не осилил даже MMX, хотя он достаточно прост. А SSE… ну дело ведь не в двоичной математике, а в какой-то каши из сотен мнемоник, об которые можно сломать и мозг, и глаза, и язык… А полюбил я ассемблер за простой и однозначный синтаксис, без непредсказуемого поведения. Тогда я не знал, что это только верхушка айсберга.
А эти правильные места где нужно применять ассемблер как-то мне сейчас не попадаются…old_bear
26.12.2017 16:40у дело ведь не в двоичной математике, а в какой-то каши из сотен мнемоник, об которые можно сломать и мозг, и глаза, и язык…
Это решается небольшой справочной табличкой и опытом применения. Достаточно помнить, что «есть инструкция, которая делает что-то такое», а дальше уже можно в документацию подглядеть. У меня, например, перманентно открыт интеловский гайд по инструкциям. Как раз чтобы мозг не загружать заучиванием мнемоник. И хотя десяток наиболее часто употребляемых SIMD-инструкций всё равно уже осели в голове, я не сильно расстроюсь, если забуду их точное написание после праздников. Ведь я всё равно помню, что они делают.
А полюбил я ассемблер за простой и однозначный синтаксис, без непредсказуемого поведения.
Это да, за этого его у любят — что будет сделано именно то, что написано в коде. Хотя есть и обратная сторона — никто не будет оптимизировать ваш код за вас.
А эти правильные места где нужно применять ассемблер как-то мне сейчас не попадаются…
Обычно все эти места находятся внутри библиотек, которые вы наверняка активно используете в своей деятельности. А вот если вы сами пишете библиотеки для вычислительно-нагруженных алгоритмов, то без asm-а тяжело обойтись.
Ellem
25.12.2017 22:25Вообще не понял, зачем на ассамблере в условии аж два перехода. Неужели не проще сначала записать одно значение в регистр, а уж потом если условие выполнилось, другое. В случае невыполнения условия нам нужен всего лишь один переход пропускающий второе присваивание. Зачем целых два перехода?
old_bear
26.12.2017 16:31Я вам открою одну тайну: в этом куске кода вообще не нужны переходы, достаточно воспользоваться чудесной инструкцией cmov (с нужным вариантом условия), которая как раз делает то, что вы хотите, — заменяет значение в регистре при соблюдении условия.
И тогда вообще не нужно дёргать конвейер лишний раз, всё исполняется линейно.
SendMess Автор
26.12.2017 21:46если про код с else, то это же не оптимизированный код. В Си мы не записывает какое-то число в переменную, поэтому компилятор и не делает таких смелых предположений. А вот с включенной оптимизацией вообще переходов не будет =)
vin2809
Только я бы подавал этот материал не в контексте описания работы компиляторов, а как рекомендации разработчикам по написанию кода на Assembler. Ведь запуская на компиляцию уже оптимизированный код, мы ускоряем процесс отладки и экономим память.