В третьей статье мы продолжим разбирать условия. В прошлый раз мы так и не посмотрели оптимизированные версии if-else.
Возможно, из-за этого и возникли справедливые вопросы, например, почему компилятор вот тут:
сгенерировал код с двумя переходами, а не с одним.
Действительно, мы тут и сами немного странно написали код, ведь, очевидно, что его можно было написать и так:
Следовало бы спрашивать у программиста, зачем ему else, а не у компилятора. Но если мы посмотрим на оптимизированную версию первого варианта, то увидим, что компилятор не так глуп:
Сразу стоит заметить, что переходов здесь вообще нет. А способ, которым разобрался clang с нашим условием, мы уже видели. Для второго варианта будет сгенерирован точно такой же оптимизированный код.
Теперь давайте вспомним про это:
Было немного страшно в прошлый раз? Оптимизированная версия может несколько сократить объем кода и количество меток (я пропущу scanf):
Вплоть до метки .LBB0_2 код соответствует самому условию. Обратите внимание, что и в оптимизированной версии логика: «второй операнд «или» рассчитываться не будет, если первый — истина» не нарушается. Ну еще бы, она нарушалась…
Однако здесь есть существенное отличие от неоптимизированной версии. Давайте пройдемся по коду, чтобы все поняли, что происходит:
В первой строке в ecx попадает значение переменной «а».
Во второй строке в eax попадает а + 1
Далее идет сравнение с нулем, чтобы инструкцией js перейти на метку с номером 2, если число было отрицательным.
В четвертой строке в переменную «а» попадает а + 1 из eax.
В неоптимизированной версии мы брали снова значение переменной и помещали его в eax, но сейчас, мы берем старое значение, оно до сих пор в ecx. Далее мы делаем а + 2 и сохраняем в eax.
А сравниваем число до инкремента с 5, чтобы инструкцией jl, перейти к третьей метке, если значение меньше, тем самым, не попадать в условие.
У вас должен возникнуть справедливый вопрос: «Что же clang выдаст два разных результата для оптимизированной и неоптимизированной версии программы?»
Нет, штука в том, что уход в else происходит при меньше, чем 5, а не при меньше либо равно. Компилятор учел сомнительный результат, когда переменная равнялась бы 5, и построил условие. В итоге сэкономил на дополнительном обращении к памяти.
В оптимизированной версии:
5 >= 5 => 5 + 3
В неоптимизированной
7 > 5 => 7 + 1
Иначе в оптимизированной версии просто можно добавить 4 к результату и закинуть его в eax. Вот такие чудеса.
Почему я сравниваю неоптимизированную версию gcc с оптимизированную clang? Ну, gcc делает тоже самое, только не очень красиво. А в clang код выглядит последовательным и компактным.
Нужно упомянуть, что компилятор в VS2015 на полной оптимизации оставил всего одну метку:
В промежутках код, который не относится к нашему, так что я просто убрал его. Как можете заметить, условия построены так, чтобы пропустить else, поэтому только одна метка. Но стоит добавить код после else, и метки также будет две. При этом код будет напоминать неоптимизированную версию тем фактом, что сначала мы присвоим одно значение, но если выполниться условие, то значение перепишется.
Ладно, а что там с оператором switch? Чем батарея if-ов будет отличаться от этого «красивого» оператора. Давайте выясним на примере следующего кода:
и его альтернативе:
Мы не будем рассматривать подробно неоптимизированную версию. Я думаю все и так понимают, что различия если и будут, то минимальны. Но забавно, что именно для switch в итоге генерируется больше кода (gcc 7.2):
В случае с if, вы уже должны представлять код: сравнение — действие — переход. А здесь вы видите, что действия откладываются на потом.
В оптимизированной версии gcc для switch сгенерирует код короче, а в clang разницы вообще не будет. Поэтому давайте рассмотрим только switch в gcc. Но прежде подумайте, как вы бы могли вообще убрать switch в данном коде?
Итак, если придумали, то раскрываем спойлер и смотрим:
Для 0 ('e') результат будет 2,
Для 12 ('q') результат будет 0,
Для 18 ('w') результат будет 1,
В остальных случаях будет 4.
У нас сильно оторванный пример от жизни, вряд ли, так кто-то, вообще, switch использовал. Что если идут какие-то действия? Возможно, вы удивитесь, но в целом ничего не меняется. Рассмотрим такой пример:
Кода много, поэтому сконцентрируйтесь на главном. Сейчас, вместо перемещения числа в eax, осуществляется переход на определенную метку. Но сама идея не меняется.
Кажется, с условиями мы разобрались окончательно. Если у кого-то были сомнения насчет switch, то можно теперь его не бояться. В следующей статье мы посмотрим на циклы.
Предыдущая статья
Возможно, из-за этого и возникли справедливые вопросы, например, почему компилятор вот тут:
int x = 10;
scanf("%i", &x);
int c = 0;
if (x < 4)
{
c = 3;
}
else
{
c = 2;
}
return c;
сгенерировал код с двумя переходами, а не с одним.
Действительно, мы тут и сами немного странно написали код, ведь, очевидно, что его можно было написать и так:
int x = 10;
scanf("%i", &x);
int c = 2;
if (x < 4)
{
c = 3;
}
return c;
Следовало бы спрашивать у программиста, зачем ему else, а не у компилятора. Но если мы посмотрим на оптимизированную версию первого варианта, то увидим, что компилятор не так глуп:
sub esp, 12
mov dword ptr [esp + 8], 10
sub esp, 8
lea eax, [esp + 16]
push eax
push .L.str
call scanf
add esp, 16
xor eax, eax ; обнуление регистра eax
cmp dword ptr [esp + 8], 4 ; сравниваем переменную и число 4
setl al ; если переменная меньше, то выставляем al в 1
or eax, 2 ; добавляем к результату 2
add esp, 12
ret
.L.str:
.asciz "%i"
Сразу стоит заметить, что переходов здесь вообще нет. А способ, которым разобрался clang с нашим условием, мы уже видели. Для второго варианта будет сгенерирован точно такой же оптимизированный код.
Теперь давайте вспомним про это:
int main(void)
{
int a = -1;
scanf("%i", &a);
if (a++ < 0 || a++ > 5)
{
a++;
}
else
{
a+=2;
}
return a;
}
Было немного страшно в прошлый раз? Оптимизированная версия может несколько сократить объем кода и количество меток (я пропущу scanf):
mov ecx, dword ptr [esp + 8]
lea eax, [ecx + 1]
test ecx, ecx
mov dword ptr [esp + 8], eax
js .LBB0_2
lea eax, [ecx + 2]
cmp ecx, 5
mov dword ptr [esp + 8], eax
jl .LBB0_3
.LBB0_2:
inc eax
add esp, 12
ret
.LBB0_3:
add ecx, 4
mov eax, ecx
add esp, 12
ret
Вплоть до метки .LBB0_2 код соответствует самому условию. Обратите внимание, что и в оптимизированной версии логика: «второй операнд «или» рассчитываться не будет, если первый — истина» не нарушается. Ну еще бы, она нарушалась…
Однако здесь есть существенное отличие от неоптимизированной версии. Давайте пройдемся по коду, чтобы все поняли, что происходит:
В первой строке в ecx попадает значение переменной «а».
Во второй строке в eax попадает а + 1
Далее идет сравнение с нулем, чтобы инструкцией js перейти на метку с номером 2, если число было отрицательным.
В четвертой строке в переменную «а» попадает а + 1 из eax.
В неоптимизированной версии мы брали снова значение переменной и помещали его в eax, но сейчас, мы берем старое значение, оно до сих пор в ecx. Далее мы делаем а + 2 и сохраняем в eax.
А сравниваем число до инкремента с 5, чтобы инструкцией jl, перейти к третьей метке, если значение меньше, тем самым, не попадать в условие.
У вас должен возникнуть справедливый вопрос: «Что же clang выдаст два разных результата для оптимизированной и неоптимизированной версии программы?»
Нет, штука в том, что уход в else происходит при меньше, чем 5, а не при меньше либо равно. Компилятор учел сомнительный результат, когда переменная равнялась бы 5, и построил условие. В итоге сэкономил на дополнительном обращении к памяти.
В оптимизированной версии:
5 >= 5 => 5 + 3
В неоптимизированной
7 > 5 => 7 + 1
Иначе в оптимизированной версии просто можно добавить 4 к результату и закинуть его в eax. Вот такие чудеса.
Почему я сравниваю неоптимизированную версию gcc с оптимизированную clang? Ну, gcc делает тоже самое, только не очень красиво. А в clang код выглядит последовательным и компактным.
Нужно упомянуть, что компилятор в VS2015 на полной оптимизации оставил всего одну метку:
mov eax, DWORD PTR _a$[ebp] ;a > eax
add esp, 8
mov ecx, eax
inc eax
test ecx, ecx
js SHORT $LN4@main ; if (a<0)
mov ecx, eax
inc eax
cmp ecx, 5
jg SHORT $LN4@main ; if (a>5)
add eax, 2 ;else a+=2
...
$LN4@main:
...
inc eax ;a++ in if
...
В промежутках код, который не относится к нашему, так что я просто убрал его. Как можете заметить, условия построены так, чтобы пропустить else, поэтому только одна метка. Но стоит добавить код после else, и метки также будет две. При этом код будет напоминать неоптимизированную версию тем фактом, что сначала мы присвоим одно значение, но если выполниться условие, то значение перепишется.
switch
Ладно, а что там с оператором switch? Чем батарея if-ов будет отличаться от этого «красивого» оператора. Давайте выясним на примере следующего кода:
char c = getchar();
int a;
if (c == 'q')
{
a = 0;
}
else if (c == 'w')
{
a = 1;
}
else if (c == 'e')
{
a = 2;
}
else
{
a = 4;
}
return a;
и его альтернативе:
char c = getchar();
int a;
switch(c)
{
case 'q': a = 0; break;
case 'w': a = 1; break;
case 'e': a = 2; break;
default:
a = 4;
}
return a;
Мы не будем рассматривать подробно неоптимизированную версию. Я думаю все и так понимают, что различия если и будут, то минимальны. Но забавно, что именно для switch в итоге генерируется больше кода (gcc 7.2):
movsx eax, BYTE PTR [ebp-13] ;преобразует операнд со знаком в эквивалентный ему операнд со знаком большей размерности
cmp eax, 113
je .L3
cmp eax, 119
je .L4
cmp eax, 101
je .L5
jmp .L8
В случае с if, вы уже должны представлять код: сравнение — действие — переход. А здесь вы видите, что действия откладываются на потом.
В оптимизированной версии gcc для switch сгенерирует код короче, а в clang разницы вообще не будет. Поэтому давайте рассмотрим только switch в gcc. Но прежде подумайте, как вы бы могли вообще убрать switch в данном коде?
Итак, если придумали, то раскрываем спойлер и смотрим:
asm код
Идея очень проста: сопоставить код символа и итоговое число в «а». Этого можно добиться тем, что мы отнимаем от результата наименьший код символа из switch, в нашем случае — это 'e' или 101.
Затем мы помещаем в eax 4, потому что по else у нас будет именно это.
Далее мы сравниваем содержимое edx и 18 (только берем младший байт из этого регистра, наподобие al, только для edx – это dl). И если значение больше (ja), то переходим к метке .L1. Если возник вопрос: «почему 18?», то выполните 'w' – 101.
Теперь, если результат лежит между 0 и 18, то работает формула: mov eax, DWORD PTR CSWTCH.3[0+edx*4], что означает просто взять результат, на основе числа в edx, смещение 4 отделяет одно число от другого в памяти (4 байта).
push DWORD PTR stdin
call _IO_getc
lea edx, [eax-101]
add esp, 16
mov eax, 4
cmp dl, 18
ja .L1
movzx edx, dl
mov eax, DWORD PTR CSWTCH.3[0+edx*4]
.L1:
mov ecx, DWORD PTR [ebp-4]
leave
lea esp, [ecx-4]
ret
CSWTCH.3:
.long 2
.long 4
.long 4
.long 4
.long 4
.long 4
.long 4
.long 4
.long 4
.long 4
.long 4
.long 4
.long 0
.long 4
.long 4
.long 4
.long 4
.long 4
.long 1
Идея очень проста: сопоставить код символа и итоговое число в «а». Этого можно добиться тем, что мы отнимаем от результата наименьший код символа из switch, в нашем случае — это 'e' или 101.
Затем мы помещаем в eax 4, потому что по else у нас будет именно это.
Далее мы сравниваем содержимое edx и 18 (только берем младший байт из этого регистра, наподобие al, только для edx – это dl). И если значение больше (ja), то переходим к метке .L1. Если возник вопрос: «почему 18?», то выполните 'w' – 101.
Теперь, если результат лежит между 0 и 18, то работает формула: mov eax, DWORD PTR CSWTCH.3[0+edx*4], что означает просто взять результат, на основе числа в edx, смещение 4 отделяет одно число от другого в памяти (4 байта).
Для 0 ('e') результат будет 2,
Для 12 ('q') результат будет 0,
Для 18 ('w') результат будет 1,
В остальных случаях будет 4.
У нас сильно оторванный пример от жизни, вряд ли, так кто-то, вообще, switch использовал. Что если идут какие-то действия? Возможно, вы удивитесь, но в целом ничего не меняется. Рассмотрим такой пример:
#include <stdio.h>
#include <math.h>
int main(void)
{
start:
char c = getchar();
switch (c)
{
case 'T':
case 't': printf("Talk\n"); break;
case 'W':
case 'w': printf("%f\n", sin(0)); break;
case 'Q':
case 'q': return 0;
default:
printf("wrong command\n");
}
goto start;
}
asm код
.LC0:
.string "Talk"
.LC2:
.string "%f\n"
.LC3:
.string "wrong command"
main:
...
.L2: ;start
sub esp, 12
push DWORD PTR stdin
call _IO_getc
sub eax, 81
add esp, 16
cmp al, 38
ja .L3
movzx eax, al
jmp [DWORD PTR .L5[0+eax*4]]
.L5:
.long .L9
.long .L3
.long .L3
.long .L6
.long .L3
.long .L3
.long .L7
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L3
.long .L9
.long .L3
.long .L3
.long .L6
.long .L3
.long .L3
.long .L7
.L7: ;sin
sub esp, 4
push 0
push 0
push OFFSET FLAT:.LC2
call printf
add esp, 16
jmp .L2
.L6: ;Talk
sub esp, 12
push OFFSET FLAT:.LC0
call puts
add esp, 16
jmp .L2
.L9: ;return 0;
mov ecx, DWORD PTR [ebp-4]
xor eax, eax
leave
lea esp, [ecx-4]
ret
.L3: ;default
sub esp, 12
push OFFSET FLAT:.LC3
call puts
add esp, 16
jmp .L2
Кода много, поэтому сконцентрируйтесь на главном. Сейчас, вместо перемещения числа в eax, осуществляется переход на определенную метку. Но сама идея не меняется.
Заключение
Кажется, с условиями мы разобрались окончательно. Если у кого-то были сомнения насчет switch, то можно теперь его не бояться. В следующей статье мы посмотрим на циклы.
Предыдущая статья
Ellem
Спасибо за статью. Это реально помогает лучше понимать, то что раньше было пустым провалом между кодом на Си и кодом на ассемблере. Картина в голове становиться более цельной, а это всегда приятно.