Как стать автором
Обновить

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

Время на прочтение 8 мин
Количество просмотров 18K
Вот и вторая часть часть цикла. В ней мы будем разбирать условия. В этот раз попробуем другие уровни оптимизации, и посмотрим, как это может повлиять на код.

Стоит указать цель этих статей, чтобы не было недопонимания. Я не буду разбирать каждый компилятор Си в отдельности. Это долго и нудно. Вместо этого, я хочу увлечь читателей разбором интересных интерпретаций Си-кода, чтобы люди осознавали, как их код может меняться и исполняться процессором. А так же развеять некоторые мифы, которые ходят среди начинающих программистов. Например, есть, правда, те, кто считает, что если складывать числа в цикле, то это будет быстрее, чем просто умножить одно число на другое. Статья не разбирает конкретно 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
Комментарии 18
Комментарии Комментарии 18

Публикации

Истории

Работа

Программист С
42 вакансии

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн