Pull to refresh

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

Reading time 8 min
Views 54K
Вдохновением послужила эта статья: Разбираемся в С, изучая ассемблер. Продолжение так и не вышло, хотя тема интересная. Многие бы хотели писать код и понимать, как он работает. Поэтому я запущу цикл статей о том, как выглядит Си-код после декомпиляции, попутно разбирая основные структуры кода.

От читающих потребуются хотя бы базовые знания в следующих вещах:

  • регистры процессора
  • стек
  • представление чисел в компьютере
  • синтаксис ассемблера и Си

Но если у вас их нет, а тема вам интересна, то все это можно быстро загуглить в процессе чтения статьи. Статья не рассчитана совсем уж на новичков, но я старательно разжевывал многие простые вещи, чтобы новичкам было от чего отталкиваться.

Что будем использовать?


  1. Нам понадобится компилятор Си, который поддерживает современный стандарт. Можно воспользоваться онлайн компилятором на сайте ideone.com.
  2. Так же нам нужен декомпилятор, опять же, можно воспользоваться онлайн декомпилятором на сайте godbolt.org.
  3. Можно так же взять компилятор для ассемблера, который есть на ideone по ссылке выше.

Почему у нас все онлайн? Потому что это удобно для разрешения спорных ситуаций из-за различных версий и операционных систем. Компиляторов много, декомпиляторов так же хватает, не хотелось бы в дискуссии учитывать особенности каждого.

При более основательном подходе к изучению, лучше пользоваться оффлайн версиями компиляторов, можете взять связку из актуального gcc, OllyDbg и NASM. Отличия должны быть минимальны.

Простейшая программа


Эта статья не стремится повторить ту, которую я приводил в самом начале. Но начинать нужно с азов, поэтому часть материала будет вынуждено пересекаться. Надеюсь на понимание.

Первое, что нужно усвоить, компилятор даже при оптимизации нулевого уровня (-O0), может вырезать код, написанный программистом. Поэтому код следующего вида:

int main(void) 
{
	5 + 3;
	return 0;
}

Ничем не будет отличаться от:

int main(void) 
{
	return 0;
}

Поэтому придется писать таким образом, чтобы при декомпиляции мы, все же, увидели превращение нашего кода во что-то осмысленное, поэтому примеры могут выглядеть, как минимум странно.

Второе, нам нужны флаги компиляции. Достаточно двух: -O0 и -m32. Этим мы задаем нулевой уровень оптимизации и 32-битный режим. С оптимизаций должно быть очевидно: нам не хочется видеть интерпретацию нашего кода в asm, а не оптимизированного. С режимом тоже должно быть очевидно: меньше регистров — больше внимания к сути. Хотя эти флаги я буду периодически менять, чтобы углубляться в материал.

Таким образом, если вы пользуетесь gcc, то компиляция может выглядеть так:

gcc source.c -O0 -m32 -o source

Соответственно, если вы пользуетесь godbolt, то вам нужно указать эти флаги в строку ввода рядом с выбором компилятора. (Первые примеры я демонстрирую на gcc 4.4.7, потом поменяю на более поздний)

Теперь, можно посмотреть первый пример:

int main(void) 
{
	register int a = 1; //записываем в регистровую переменную 1
	return a; //возвращаем значение из регистровой переменной
}

Итак, следующий код соответствует этому:

push 		ebp
mov 		ebp, esp

push 		ebx
mov 		ebx, 1
mov 		eax, ebx

pop 		ebx
pop 		ebp
ret

Первые две строчки соответствую прологу функции (точнее три, но третью хочу пояснить сейчас), и мы их разберем в статье о функциях. Сейчас просто не обращайте на них внимание, тоже самое касается последних 3х строчек. Если вы не знаете asm, давайте смотреть, что означают эти команды.

Инструкции ассемблера имеют вид:

mnemonic dst, src
т. е.

инструкция получатель, источник

Тут нужно оговориться, что AT&T-синтаксис имеет другой порядок, и потом мы к нему еще вернемся, но сейчас нас интересует синтаксис схожий с NASM.

Начнем с инструкции mov. Эта инструкция перемещает из памяти в регистры или из регистров в память. В нашем случае она перемещает число 1 в регистр ebx.

Давайте кратко о регистрах: в архитектуре x86 восемь 32х битных регистров общего назначения, это значит, что эти регистры могут быть использованы программистом (в нашем случае компилятором) при написании программ. Регистры ebp, esp, esi и edi компилятор будет использовать в особых случаях, которые мы рассмотрим позже, а регистры eax, ebx, ecx и edx компилятор будет использовать для всех остальных нужд.

Таким образом mov ebx, 1, прямо соответствует строке register int a = 1;

И означает, что в регистр ebx было перемещено значение 1.

А строчка mov eax, ebx, будет означать, что в регистр eax будет перемещено значение из регистра ebx.

Есть еще две строчки push ebx и pop ebx. Если вы знакомы с понятием «стек», то догадываетесь, что сначала компилятор поместил ebx в стек, тем самым запомнил старое значение регистра, а после окончания работы программы, вернул из стека это значение обратно в регистр ebx.

Почему компилятор помещает значение 1 из регистра ebx в eax? Это связано с соглашением о вызовах функций языка Си. Там несколько пунктов, все они нас сейчас не интересуют. Важно то, что результат возвращается в eax, если это возможно. Таким образом понятно, почему единица в итоге оказывается в eax.

Но теперь логичный вопрос, а зачем понадобился ebx? Почему нельзя было написать сразу mov eax, 1? Все дело в уровне оптимизации. Я же говорил: компилятор не должен вырезать наш код, а мы написали не return 1, мы использовали регистровую переменную. Т. е. компилятор сначала поместил значение в регистр, а затем, следуя соглашению, вернул результат. Поменяйте уровень оптимизации на любой другой, и вы увидите, что регистр ebx, действительно, не нужен.

Кстати, если вы пользуетесь godbolt, то вы можете наводить мышкой на строку в Си, и вам подсветится соответствующий этой строке код в asm, при условии, что эта строка выделена цветом.

Стек


Усложним пример и перестанем пользоваться регистровыми переменными (Вы же их нечасто используете?). Посмотрим во что превратится такой код:

int main(void) 
{
	int a = 1; //записываем в переменную 1
	int b = a + 5; //прибавим к 'a' 5 и сораним в 'b'
	return b; //возвращаем значение из переменной
}

ASM:

push 		ebp
mov 		ebp, esp
sub 		esp, 16

mov 		DWORD PTR [ebp-8], 1
mov 		eax, DWORD PTR [ebp-8]
add 		eax, 5
mov 		DWORD PTR [ebp-4], eax
mov 		eax, DWORD PTR [ebp-4]

leave
ret

Опять же, пропустим верхние 3 строчки и нижние 2. Теперь у нас переменная а локальная, следовательно память ей выделяется на стеке. Поэтому мы видим следующую магию: DWORD PTR [ebp-8], что же она означает? DWORD PTR — это переменная типа двойного слова. Слово — это 16 бит. Термин получил распространение в эпоху 16-ти битных процессоров, тогда в регистр помещалось ровно 16 бит. Такой объем информации стали называть словом (word). Т. е. в нашем случае dword (double word) 2*16 = 32 бита = 4 байта (обычный int).

В регистре ebp содержится адрес на вершину стека для текущей функции (мы к этому еще вернемся, потом), поэтому он смещается на 4 байта, чтобы не затереть сам адрес и дописывает значение нашей переменной. Только, в нашем случае он смещается на 8 байт для переменной a. Но если вы посмотрите на код ниже, то увидите, что переменная b лежит со смещением в 4 байта. Квадратные скобки означают адрес. Т. е. это строка работает следующим образом: на основе адреса, хранящегося в ebp, компилятор помещает значение 1 по адресу ebp-8 размера 4 байта. Почему минус восемь, а не плюс. Потому что плюсу бы соответствовали параметры, переданные в эту функцию, но опять же, обсудим это позже.

Следующая строка перемещает значение 1 в регистр eax. Думаю, это не нуждается в подробных объяснениях.

Далее у нас новая инструкция add, которая осуществляет добавление (сложение). Т. е. к значению в eax (1) добавляется 5, теперь в eax находится значение 6.

После этого нужно переместить значение 6 в переменную b, что и делается следующей строкой (переменная b находится в стеке по смещению 4).

Наконец, нам нужно вернуть значение переменной b, следовательно нужно переместить
значение в регистр eax (mov eax, DWORD PTR [ebp-4]).

Если с предыдущим все понятно, то можно переходить, к более сложному.

Интересные и не очень очевидные вещи.


Что произойдет, если мы напишем следующее: int var = 2.5;

Каждый из вас, я думаю, ответит верно, что в var будет значение 2. Но что произойдет с дробной частью? Она отбросится, проигнорируется, будет ли преобразование типа? Давайте посмотрим:

ASM:
mov DWORD PTR [ebp-4], 2

Компилятор сам отбросил дробную часть за ненадобностью.

Что произойдет, если написать так: int var = 2 + 3;

ASM:
mov DWORD PTR [ebp-4], 5

И мы узнаем, что компилятор сам способен вычислять константы. А в данном случае: так как 2 и 3 являются константами, то их сумму можно вычислить на этапе компиляции. Поэтому можно не забивать себе голову вычислением таких констант, компилятор может сделать работу за вас. Например, перевод в секунды из часов можно записать, как hours * 60 * 60. Но скорее, в пример тут стоит поставить операции над константами, которые объявлены в коде.

Что произойдет, если напишем такой код:

int a = 1;
int b = a * 2;

mov 		DWORD PTR [ebp-8], 1
mov 		eax, DWORD PTR [ebp-8]
add 		eax, eax
mov 		DWORD PTR [ebp-4], eax

Интересно, не правда ли? Компилятор решил не пользоваться операцией умножения, а просто сложил два числа, что и есть — умножить на 2. (Я уже не буду подробно описывать эти строки, вы должны понять их, исходя из предыдущего материала)

Вы могли слышать, что операция «умножение» выполняется дольше, чем операция «сложение». Именно по этим соображениям компилятор оптимизирует такие простые вещи.

Но усложним ему задачу и напишем так:

int a = 1;
int b = a * 3;

ASM

mov DWORD PTR [ebp-8], 1
mov edx, DWORD PTR [ebp-8]
mov eax, edx
add eax, eax
add eax, edx
mov DWORD PTR [ebp-4], eax

Пусть вас не вводит в заблуждение использование нового регистра edx, он ничем не хуже eax или ebx. Может понадобиться время, но вы должны увидеть, что единица попадает в регистр edx, затем в регистр eax, после чего значение eax складывается само с собой и после уже добавляется еще одна единица из edx. Таким образом, мы получили 1+1+1.

Знаете, бесконечно он так делать не будет, уже на *4, компилятор выдаст следующее:

mov DWORD PTR [ebp-8], 1
mov eax, DWORD PTR [ebp-8]
sal eax, 2
mov DWORD PTR [ebp-4], eax
mov eax, 0

Итак, у нас новая инструкция sal, что же она делает? Это двоичный сдвиг влево. Эквивалентно следующему коду в Си:

int a = 1;
int b = a << 2;

Для тех, кто не очень понимает, как работает этот оператор:

0001 сдвигаем влево (или добавляем справа) на два нуля: 0100 (т. е. 4 в 10ой системе счисления). По своей сути сдвиг влево на 2 разряда — это умножение на 4.

Забавно, что если вы умножите на 5, то компилятор сделает один sal и один add, можете сами потестировать разные числа.

На 22, компилятор на godbolt.org сдается и использует умножение, но до этого числа он пытается выкрутиться самыми разными способами. Даже вычитание использует и еще некоторые инструкции, которые мы еще не обсуждали.

Ладно, это были цветочки, а что вы думаете по поводу следующего кода:

int a = 2;
int b = a / 2;

Если вы ожидаете вычитания, то увы — нет. Компилятор будет выдавать более изощренные методы. Операция «деление» еще медленнее умножения, поэтому компилятор будет также выкручиваться:

mov 		DWORD PTR [ebp-4], 2
mov 		eax, DWORD PTR [ebp-4]
mov 		edx, eax
shr 		edx, 31
add 		eax, edx
sar 		eax
mov 		DWORD PTR [ebp-8], eax

Следует сказать, что для этого кода я выбрал компилятор существенно более поздней версии (gcc 7.2), до этого я приводил в пример gcc 4.4.7. Для ранних примеров существенных отличий не было, для этого примера они используют разные инструкции в 5ой строчке кода. И пример, сгенерированный 7.2, мне сейчас легче вам объяснить.

Стоит обратить внимание, что теперь переменная a находится в стеке по смещению 4, а не 8 и сразу же забыть об этом незначительном отличии. Ключевые моменты начинаются с mov edx, eax. Но пока пропустим значение этой строки. Инструкция shr осуществляет двоичный сдвиг вправо (т. е. деление на 2, если бы было shr edx, 1). И тут некоторые смогут подумать, а почему, действительно, не написать shr edx, 1, это же то, что делает код в Си? Но не все так просто.

Давайте проведем небольшую оптимизацию и посмотрим на что это повлияет. В действительности, мы нашим кодом выполняем целочисленное деление. Так как переменная «a» является целочисленным типом и 2 константа типа int, то результат никак не может получиться дробным по логике Си. И это хорошо, так как делить целочисленные числа быстрее и проще, но у нас знаковые числа, а это значит, что отрицательное число при делении инструкцией shr может отличаться на единицу от правильного ответа. (Это все из-за того, что 0 влезает по середине диапазона для знаковых типов). Если мы заменим знаковое деление на unsigned:

unsigned int a = 2;
unsigned int b = a / 2;

То получим ожидаемое. Стоит учесть, что godbolt опустит единицу в инструкции shr, и это не скомпилируется в NASM, но она там подразумевается. Измените 2 на 4, и вы увидите второй операнд в виде 2.

Теперь посмотрим на предыдущий код. В нем мы видим sar eax, это то же самое, что и shr, только для знаковых чисел. Остальной же код просто учитывает эту единицу, когда мы делим отрицательное число (или на отрицательное число, хотя код немного изменится). Если вы знаете, как представляются отрицательные числа в компьютере, вам будет не трудно догадаться, почему мы делаем сдвиг вправо на 31 разряд и добавляем это значение к исходному числу.

С делением на большие числа, все еще проще. Там деление заменяется на умножение, в качестве второго операнда вычисляется константа. Если вам будет интересно как, можете поломать над этим голову самостоятельно, там нет ничего сложного. Нужно просто понимать, как представляются вещественные числа в памяти.

Заключение


Для первой статьи материала уже больше, чем достаточно. Пора закруглятся и подводить итоги. Мы ознакомились с базовым синтаксисом ассемблера, выяснили, что компилятор может брать на себя простейшие оптимизации при вычислениях. Увидели разницу между регистровыми и стековыми переменными. И некоторые другие вещи. Это была вводная статья, пришлось много времени уделять очевидным вещам, но они очевидны не для всех, в будущем мы постигнем больше тонкостей языка Си.

Часть 2
Tags:
Hubs:
+46
Comments 31
Comments Comments 31

Articles