Pull to refresh

Постигаем Си глубже, используя ассемблер. Часть 3

AssemblerDebuggingC
Tutorial
В третьей статье мы продолжим разбирать условия. В прошлый раз мы так и не посмотрели оптимизированные версии if-else.

Возможно, из-за этого и возникли справедливые вопросы, например, почему компилятор вот тут:

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 код
	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, то можно теперь его не бояться. В следующей статье мы посмотрим на циклы.

Предыдущая статья
Tags:cassemblerотладкаgccclangкомпиляторы
Hubs: Assembler Debugging C
Total votes 19: ↑18 and ↓1+17
Views9.5K

Popular right now