От читающих потребуются хотя бы базовые знания в следующих вещах:
- регистры процессора
- стек
- представление чисел в компьютере
- синтаксис ассемблера и Си
Но если у вас их нет, а тема вам интересна, то все это можно быстро загуглить в процессе чтения статьи. Статья не рассчитана совсем уж на новичков, но я старательно разжевывал многие простые вещи, чтобы новичкам было от чего отталкиваться.
Что будем использовать?
- Нам понадобится компилятор Си, который поддерживает современный стандарт. Можно воспользоваться онлайн компилятором на сайте ideone.com.
- Так же нам нужен декомпилятор, опять же, можно воспользоваться онлайн декомпилятором на сайте godbolt.org.
- Можно так же взять компилятор для ассемблера, который есть на 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 разряд и добавляем это значение к исходному числу.
С делением на большие числа, все еще проще. Там деление заменяется на умножение, в качестве второго операнда вычисляется константа. Если вам будет интересно как, можете поломать над этим голову самостоятельно, там нет ничего сложного. Нужно просто понимать, как представляются вещественные числа в памяти.
Заключение
Для первой статьи материала уже больше, чем достаточно. Пора закруглятся и подводить итоги. Мы ознакомились с базовым синтаксисом ассемблера, выяснили, что компилятор может брать на себя простейшие оптимизации при вычислениях. Увидели разницу между регистровыми и стековыми переменными. И некоторые другие вещи. Это была вводная статья, пришлось много времени уделять очевидным вещам, но они очевидны не для всех, в будущем мы постигнем больше тонкостей языка Си.
Комментарии (31)
DrZlodberg
17.12.2017 15:45+2можете взять связку из актуального gcc, OllyDbg и NASM.
GCC вполне умеет сам сохранять asm код (кажись ключ -S) Диалект по умолчанию AT&T но можно переключить на masm (не помню ключ). В коде тоже можно асм использовать (правда только AT&T вроде)
Ну а визуальный отладчик есть в CodeBlocks (который интегрирован с GCC), что вполне удобно. Под вындой вообще достаточно поставить CodeBlocks и получить сразу всё комплектом.
abcdsash
17.12.2017 15:58+1надеюсь, что продолжение будет.
Потому что уже достаточно случаев, когда заявлялись циклы статей, но после первой/реже второй все останавливалось.
так что, надеюсь, что автор не бросит писать )
tyomitch
17.12.2017 16:06+1Что в этом материале относится к языку Си?
Суть статьи — «постигаем глубже конкретную версию компилятора gcc с конкретными флагами компиляции, используя ассемблер».SendMess Автор
17.12.2017 17:35если вы назовете компилятор, на котором будет принципиальная разница в коде для этих простейших примеров, я с удовольствием опишу эту разницу в следующей статье)
tyomitch
17.12.2017 17:45+1Чтобы далеко не ходить, возьмите clang или MSVC, и свой первый же пример: переменная
a
окажется в стеке, несмотря на указаниеregister
; а в примере с умножением на два вместо сложения будет сдвиг влево.32bit_me
17.12.2017 17:58+2Чтобы совсем далеко не ходить, можно зайти на godbolt.org и сравнивать любой компилятор с любым.
И да, рассматривать ассемблер таргета малоэффективно, потому что для другого таргета он будет совсем другим. Более информативно смотреть промежуточный код.
tyomitch
17.12.2017 17:54А в примере с делением на два эти три компилятора (gcc, clang, MSVC) выдают три разных реализации; в частности, clang — как раз лобовой
idiv
.32bit_me
17.12.2017 18:03+1Только с -O0.
При -O1 и больше имеем:
foo(int): # @foo(int) mov eax, edi shr eax, 31 lea eax, [rax + rdi] sar eax ret
tyomitch
17.12.2017 18:10Само собой. Но автору-то
хочется видеть интерпретацию нашего кода в asm, а не оптимизированного.
Подозреваю, что автор считает, что для каждого фрагмента кода на Си есть некая «каноническая» трансляция в машкод x86, и с отключённой оптимизацией все компиляторы будут выдавать примерно одну и ту же «каноническую трансляцию».
Так вот, это не так.32bit_me
17.12.2017 18:16Если честно, я не совсем понимаю смысла изучения ассеблерного кода, кроме тех случаев, когда мы изучаем работу непосредственно компилятора.
В том же clang-е большое число проходов оптимизации, и общее количество изменений, которые они могут внести в код в разных обстоятельствах, достигает астрономических величин.
Может быть интересно сравнивать разные реализации одной и той же функции, чтобы понять, какая из них более оптимальна, но пытаться выявить и запомнить все комбинации ассемблерных команд на выходе компилятора — бесполезное дело, имхо.tyomitch
17.12.2017 18:30Мой комментарий в начале ветки — как раз об этом: что автор постигает конкретный компилятор с конкретными настройками, и выдаёт это за постижение Си :-)
(Если что, в gcc тоже не одна сотня проходов трансляции/оптимизации, с самыми неожиданными взаимосвязями между проходами. Одной из целей создания llvm/clang как раз и было заменить эту кастрюлю спагетти чем-то более удобным в обслуживании.)
SendMess Автор
17.12.2017 18:28Подозреваю, что автор считает, что для каждого фрагмента кода на Си есть некая «каноническая» трансляция в машкод x86, и с отключённой оптимизацией все компиляторы будут выдавать примерно одну и ту же «каноническую трансляцию».
нет, автор так не считает. Но рассматривать все и со всех сторон, еще и в одной статье, просто перебор.tyomitch
17.12.2017 20:09Предлагаю в следующей статье рассматривать не способы удвоения целого числа, а какие-нибудь нетривиальные хитрости, которых наивный программист на Си от компилятора не ожидал бы — например, трансляцию условных выражений (типа
x==4?3:2
) в код без ветвлений.
Там разница между компиляторами, действительно, будет куда интереснее, чем общие места.
ruslan_sem
17.12.2017 17:44еще не рассмотрел такой пример:
int a = 1;
int main(void)
{
return a;
}
переменная будет и не регистровая и не стековая
AnutaU
17.12.2017 20:13Так же нам нужен декомпилятор
Простите за занудство, но вы используете дизассемблер, а не декомпилятор.
А про то, что можно сразу генерировать ассемблерные листинги при компиляции, выше уже написали.
aamonster
18.12.2017 01:18Надеюсь, хоть какое-то количество программистов благодаря таким статьям узнает, какие вообще соглашения бывают у компиляторов — calling conventions, stack frames, вот это всё. А то люди считают stack trace какой-то магией.
Конечно, лучше изучать на примере своего рабочего компилятора (поэтому странно, что вы 80x86 32 bit за основу взяли, а не 64), но и так пригодится.
P.S. У самого на мониторе наклеена бумажка с перечнем регистров в Objective C. При отладке в глубинах библиотечных функций — очень выручает. Но Objective C тут попроще обычного — есть полноценная рефлексия и т.п.
tyomitch
18.12.2017 11:24Только не забыть упомянуть, что «calling conventions, stack frames, вот это всё» (собирательно называемое ABI) различаются в зависимости от ОС, процессора и т.д.; даже на x86 и на x64 они сильно разные.
А то новичок из этого материала мог получить впечатление, что единственная разница между ними двумя — это «меньше регистров, больше внимания к сути».
cypok
18.12.2017 06:33push ebp
mov ebp, esp
push ebx
mov ebx, 1
mov eax, ebx
pop ebx
pop ebp
ret
Первые две строчки соответствую прологу функции, и мы их разберем в статье о функциях.Все-таки сохранение на стек волатильных регистров (ebx) является частью пролога. Его восстановление вы отнесли к эпилогу.
SendMess Автор
18.12.2017 08:25да, я зря не уточнил, что 3-яя тоже относится. Спасибо, я поправил это место.
domix32
18.12.2017 10:28Слово — это 16 бит.
Стоило сделать ремарку что размер слова рознится в зависимости от платформы.Ghost_nsk
18.12.2017 15:18И исторических привычек для этой платформы. Например для 32/64 битных x86 общепринято слово 16 бит, хотя оно 32 или 64 соответственно.
tyomitch
18.12.2017 15:43В тексте об этом сказано явно:
Термин получил распространение в эпоху 16-ти битных процессоров, тогда в регистр помещалось ровно 16 бит. Такой объем информации стали называть словом (word).
Но действительно стоило упомянуть, что на других процессорах традиции другие. Например, на ARM (даже 64-битных) словом считаются 32 бита.
michael_vostrikov
18.12.2017 10:39В принципе это все становится понятно за пару дней работы с отладчиком. Можно еще наоборот делать — переводить ассемблерный код в эквивалентный С-код, основываясь на действиях инструкций. Сначала напрямую, с goto и метками, потом думать, как это объединить в циклы и условия. C-код компактнее, так удобнее разбираться, что делает программа к которой нет исходников.
Albom
18.12.2017 11:10Подобный анализ отлично описан в книге Дениса Юричева «Reverse Engineering для начинающих». Рекомендую её всем.
emusic
19.12.2017 16:07Предлагаю параллельно с примерами на чистом C рассматривать и аналогичные по функциональности примеры на C++. Очень наглядно демонстрирует, что вменяемые компиляторы (тот же GCC) делают для них совершенно идентичный код (как и должно быть, собственно).
Удивительно, но многие до сих пор считают, что программа на чистом C в общем случае дает более легкий и быстрый код, нежели функционально эквивалентная ей программа на C++. :)
lgorSL
Ещё вместо флага O0 можно попробовать Os — будет сгенерирован максимально короткий код без лишних инструкций.