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

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

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

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

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


  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 разряд и добавляем это значение к исходному числу.

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

Заключение


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

Комментарии (31)


  1. lgorSL
    17.12.2017 14:31
    +2

    Ещё вместо флага O0 можно попробовать Os — будет сгенерирован максимально короткий код без лишних инструкций.


  1. DrZlodberg
    17.12.2017 15:45
    +2

    можете взять связку из актуального gcc, OllyDbg и NASM.

    GCC вполне умеет сам сохранять asm код (кажись ключ -S) Диалект по умолчанию AT&T но можно переключить на masm (не помню ключ). В коде тоже можно асм использовать (правда только AT&T вроде)
    Ну а визуальный отладчик есть в CodeBlocks (который интегрирован с GCC), что вполне удобно. Под вындой вообще достаточно поставить CodeBlocks и получить сразу всё комплектом.


    1. Sdima1357
      17.12.2017 15:47
      +1

      Если Вы пользуетесь gcc то gcc source.c -O0 -masm=intel -S -o source.s


  1. abcdsash
    17.12.2017 15:58
    +1

    надеюсь, что продолжение будет.
    Потому что уже достаточно случаев, когда заявлялись циклы статей, но после первой/реже второй все останавливалось.
    так что, надеюсь, что автор не бросит писать )


  1. tyomitch
    17.12.2017 16:06
    +1

    Что в этом материале относится к языку Си?
    Суть статьи — «постигаем глубже конкретную версию компилятора gcc с конкретными флагами компиляции, используя ассемблер».


    1. SendMess Автор
      17.12.2017 17:35

      если вы назовете компилятор, на котором будет принципиальная разница в коде для этих простейших примеров, я с удовольствием опишу эту разницу в следующей статье)


      1. tyomitch
        17.12.2017 17:45
        +1

        Чтобы далеко не ходить, возьмите clang или MSVC, и свой первый же пример: переменная a окажется в стеке, несмотря на указание register; а в примере с умножением на два вместо сложения будет сдвиг влево.


        1. 32bit_me
          17.12.2017 17:58
          +2

          Чтобы совсем далеко не ходить, можно зайти на godbolt.org и сравнивать любой компилятор с любым.
          И да, рассматривать ассемблер таргета малоэффективно, потому что для другого таргета он будет совсем другим. Более информативно смотреть промежуточный код.


      1. tyomitch
        17.12.2017 17:54

        А в примере с делением на два эти три компилятора (gcc, clang, MSVC) выдают три разных реализации; в частности, clang — как раз лобовой idiv.


        1. 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


          1. tyomitch
            17.12.2017 18:10

            Само собой. Но автору-то

            хочется видеть интерпретацию нашего кода в asm, а не оптимизированного.


            Подозреваю, что автор считает, что для каждого фрагмента кода на Си есть некая «каноническая» трансляция в машкод x86, и с отключённой оптимизацией все компиляторы будут выдавать примерно одну и ту же «каноническую трансляцию».
            Так вот, это не так.


            1. 32bit_me
              17.12.2017 18:16

              Если честно, я не совсем понимаю смысла изучения ассеблерного кода, кроме тех случаев, когда мы изучаем работу непосредственно компилятора.
              В том же clang-е большое число проходов оптимизации, и общее количество изменений, которые они могут внести в код в разных обстоятельствах, достигает астрономических величин.
              Может быть интересно сравнивать разные реализации одной и той же функции, чтобы понять, какая из них более оптимальна, но пытаться выявить и запомнить все комбинации ассемблерных команд на выходе компилятора — бесполезное дело, имхо.


              1. tyomitch
                17.12.2017 18:30

                Мой комментарий в начале ветки — как раз об этом: что автор постигает конкретный компилятор с конкретными настройками, и выдаёт это за постижение Си :-)

                (Если что, в gcc тоже не одна сотня проходов трансляции/оптимизации, с самыми неожиданными взаимосвязями между проходами. Одной из целей создания llvm/clang как раз и было заменить эту кастрюлю спагетти чем-то более удобным в обслуживании.)


            1. SendMess Автор
              17.12.2017 18:28

              Подозреваю, что автор считает, что для каждого фрагмента кода на Си есть некая «каноническая» трансляция в машкод x86, и с отключённой оптимизацией все компиляторы будут выдавать примерно одну и ту же «каноническую трансляцию».

              нет, автор так не считает. Но рассматривать все и со всех сторон, еще и в одной статье, просто перебор.


              1. tyomitch
                17.12.2017 20:09

                Предлагаю в следующей статье рассматривать не способы удвоения целого числа, а какие-нибудь нетривиальные хитрости, которых наивный программист на Си от компилятора не ожидал бы — например, трансляцию условных выражений (типа x==4?3:2) в код без ветвлений.
                Там разница между компиляторами, действительно, будет куда интереснее, чем общие места.


                1. 32bit_me
                  17.12.2017 20:50

                  Спасибо за пример, посмотрел, красиво.
                  И gcc, и clang.


                  1. tyomitch
                    18.12.2017 12:19

                    Для этого примера — действительно всюду выходит одинаково.
                    Интересная разница между компиляторами начинается в примерах навроде x&4?4:2


  1. ruslan_sem
    17.12.2017 17:44

    еще не рассмотрел такой пример:
    int a = 1;
    int main(void)
    {
    return a;
    }
    переменная будет и не регистровая и не стековая


  1. Ellem
    17.12.2017 18:08

    Было интересно, пишите еще.


  1. AnutaU
    17.12.2017 20:13

    Так же нам нужен декомпилятор

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


  1. g0rd1as
    17.12.2017 22:45

    Буду с нетерпением ждать продолжения. Это головоломно, но интересно.


  1. aamonster
    18.12.2017 01:18

    Надеюсь, хоть какое-то количество программистов благодаря таким статьям узнает, какие вообще соглашения бывают у компиляторов — calling conventions, stack frames, вот это всё. А то люди считают stack trace какой-то магией.


    Конечно, лучше изучать на примере своего рабочего компилятора (поэтому странно, что вы 80x86 32 bit за основу взяли, а не 64), но и так пригодится.


    P.S. У самого на мониторе наклеена бумажка с перечнем регистров в Objective C. При отладке в глубинах библиотечных функций — очень выручает. Но Objective C тут попроще обычного — есть полноценная рефлексия и т.п.


    1. tyomitch
      18.12.2017 11:24

      Только не забыть упомянуть, что «calling conventions, stack frames, вот это всё» (собирательно называемое ABI) различаются в зависимости от ОС, процессора и т.д.; даже на x86 и на x64 они сильно разные.
      А то новичок из этого материала мог получить впечатление, что единственная разница между ними двумя — это «меньше регистров, больше внимания к сути».


  1. cypok
    18.12.2017 06:33

    push ebp
    mov ebp, esp

    push ebx
    mov ebx, 1
    mov eax, ebx

    pop ebx
    pop ebp
    ret
    Первые две строчки соответствую прологу функции, и мы их разберем в статье о функциях.

    Все-таки сохранение на стек волатильных регистров (ebx) является частью пролога. Его восстановление вы отнесли к эпилогу.


    1. SendMess Автор
      18.12.2017 08:25

      да, я зря не уточнил, что 3-яя тоже относится. Спасибо, я поправил это место.


  1. domix32
    18.12.2017 10:28

    Слово — это 16 бит.

    Стоило сделать ремарку что размер слова рознится в зависимости от платформы.


    1. Ghost_nsk
      18.12.2017 15:18

      И исторических привычек для этой платформы. Например для 32/64 битных x86 общепринято слово 16 бит, хотя оно 32 или 64 соответственно.


      1. tyomitch
        18.12.2017 15:43

        В тексте об этом сказано явно:

        Термин получил распространение в эпоху 16-ти битных процессоров, тогда в регистр помещалось ровно 16 бит. Такой объем информации стали называть словом (word).

        Но действительно стоило упомянуть, что на других процессорах традиции другие. Например, на ARM (даже 64-битных) словом считаются 32 бита.


  1. michael_vostrikov
    18.12.2017 10:39

    В принципе это все становится понятно за пару дней работы с отладчиком. Можно еще наоборот делать — переводить ассемблерный код в эквивалентный С-код, основываясь на действиях инструкций. Сначала напрямую, с goto и метками, потом думать, как это объединить в циклы и условия. C-код компактнее, так удобнее разбираться, что делает программа к которой нет исходников.


  1. Albom
    18.12.2017 11:10

    Подобный анализ отлично описан в книге Дениса Юричева «Reverse Engineering для начинающих». Рекомендую её всем.


  1. emusic
    19.12.2017 16:07

    Предлагаю параллельно с примерами на чистом C рассматривать и аналогичные по функциональности примеры на C++. Очень наглядно демонстрирует, что вменяемые компиляторы (тот же GCC) делают для них совершенно идентичный код (как и должно быть, собственно).

    Удивительно, но многие до сих пор считают, что программа на чистом C в общем случае дает более легкий и быстрый код, нежели функционально эквивалентная ей программа на C++. :)