Анатомия двоичного исполняемого файла

Перевод | Автор оригинала: Matt Oswalt

Несмотря на то, что я занимаюсь разработкой программного обеспечения уже несколько лет, я всегда думал об одном вопросе, и до сих пор у меня не было ни времени, ни терпения, чтобы ответить на него: что такое бинарный исполняемый файл в любом случае? ?

Для этого примера я написал очень простую программу на Rust, которая включает функцию «sum» для сложения двух целых чисел, и я вызываю ее из main():

fn main() {
    println!("{}", sum(5, 8));
}

pub fn sum(a: i32, b: i32) -> i32 {
    a + b
}

Мой код на Rust всегда структурирован по принципу Cargo, поэтому я могу скомпилировать свою программу, запустив сборку Cargo, и это создаст для меня двоичный файл в каталоге target / debug /. Я назвал свой крэйт rbin, так что это имя двоичного файла, который создается в этом месте:

~$ cargo build
   Compiling rbin v0.1.0 (/home/mierdin/Code/rbin)
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s

~$ ls -lha target/debug/rbin
-rwxrwxr-x 2 mierdin mierdin 3.1M Nov  3 22:46 target/debug/rbin

В наши дни действительно легко принимать такие вопросы как должное, но если вам интересно, вы можете спросить:

"Но что это за файл?"

Я имею в виду, что мы все обычно знаем, что это «исполняемый файл», поскольку мы его запускаем, и наша программа выполняется. Но что это значит? Что содержится в этом файле, что означает, что наш компьютер автоматически просто знает, как его запустить? И как это возможно, что программа с 7 строками кода может занимать более 3 мегабайт дискового пространства?!?

Оказывается, чтобы создать исполняемый файл для этой до смешного простой программы, компилятор Rust должен включать в себя довольно много дополнительного программного обеспечения, чтобы это стало возможным.

Форматы файлов и заголовки файлов

Что ж, оказывается, существует широко распространенный формат для этих вещей, называемый «Executable and Linkable Format» или ELF!

Обратите внимание, что я не буду здесь всесторонне описывать ELF (есть много других ресурсов, многие из которых я буду ссылаться) - скорее, это исследование того, что входит в двоичный файл Rust с простейшими настройками по умолчанию и некоторыми наблюдения по поводу того, что мне кажется интересным.

ELF - широко известный и популярный формат, особенно в мире Linux, но есть и другие. Операционные системы, такие как Windows и macOS, имеют свой собственный формат, и это большая причина, по которой при компиляции (или просто загрузке) программного обеспечения вы должны указать операционную систему, в которой вы хотите его запустить. Это верно, несмотря на то, что базовый машинный код, который выполняет вашу программу, может быть одинаковым на всех из них (например, x86_64).

Исключительный визуальный фрагмент формата ELF можно найти по ссылке на страницу википедии ELF выше, я обнаружил, что постоянно возвращаюсь к нему, когда пишу этот пост:

Схема исполняемого и связываемого форматов ELF, автор Анж Альбертини

Обычно исполняемые форматы, подобные этому, указывают магическое число в самом начале файла, чтобы формат можно было легко идентифицировать. Он занимает первые четыре байта в заголовке файла. Это очень важное поле, потому что до тех пор, пока мы не сможем правильно идентифицировать файл ELF, мы не можем ожидать, что с ним сделаем что-нибудь «ELF-y». Мы знаем, где должны быть определенные биты информации в файле ELF, но сначала мы должны определить, используя эти байты, что это то, чего мы можем ожидать.

Утилита readelf чрезвычайно полезна для печати всех видов полезных метаданных и связанных таблиц информации, содержащихся в файле ELF. Однако эта утилита ожидает - естественно, - что читаемый файл на самом деле является файлом ELF, и даже дает полезную подсказку о том, что ожидаемые «магические байты» для файла, отличного от ELF, не установлены должным образом при использовании с файлом, отличным от ELF. файл, поэтому он не пытается прочитать остальное:

~$ readelf -l .gitignore

readelf: Error: Not an ELF file - it has the wrong magic bytes at the start

После идентификации весь остальной файл может быть идентифицирован с помощью байтовых смещений (то есть количества байтов от нуля).

Для тех, кто привык смотреть на захват сетевых пакетов, все это должно показаться вам очень знакомым, поскольку именно так мы узнаем, где расположены определенные поля в заголовке пакета. Кадры Ethernet имеют предсказуемую преамбулу и ограничитель начала кадра. В Ethernet также есть поле, называемое «Ethertype», которое дает представление о том, какой протокол содержится в кадре Ethernet (что позволяет компьютерам также анализировать это поле). Так же, как Ethernet имеет стандартный набор байтовых смещений, которые указывают, где должны быть представлены различные поля, формат ELF определяет свои собственные смещения для всех полей, предоставляя полезную идентификационную информацию и информацию о выполнении в заголовке файла, который затем указывает на другие важные места. внутри файла.

В этом заголовке содержится вся полезная информация, но, в частности, поле e_entry в заголовке файла указывает на место смещения, с которого должно начинаться выполнение. Это «точка входа» для программы. Мы определенно будем немного следить за этим в кроличьей норе.

Мы снова можем использовать readelf, на этот раз для правильного файла ELF (наша программа на Rust), а также использовать флаг -h для отображения заголовка файла:

~$ readelf -h target/debug/rbin

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x5070
  Start of program headers:          64 (bytes into file)
  Start of section headers:          3195368 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         12
  Size of section headers:           64 (bytes)
  Number of section headers:         42
  Section header string table index: 41

Итак, «магическое число» позволяет нам анализировать, по крайней мере, остальную часть заголовка файла, которая содержит не только информацию о файле, но и расположение байтовых смещений для других важных частей файла. Один из них - «Начало заголовков программы», которое начинается после 64 байтов.

Заголовки программ

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

Утилита readelf также позволяет нам читать заголовки программ с помощью флага -l:

~$ readelf -l target/debug/rbin

Elf file type is DYN (Shared object file)
Entry point 0x5070
There are 12 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002a0 0x00000000000002a0  R      0x8
  INTERP         0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000004ed8 0x0000000000004ed8  R      0x1000
  LOAD           0x0000000000005000 0x0000000000005000 0x0000000000005000
                 0x0000000000030571 0x0000000000030571  R E    0x1000
  LOAD           0x0000000000036000 0x0000000000036000 0x0000000000036000
                 0x000000000000be44 0x000000000000be44  R      0x1000
  LOAD           0x0000000000042520 0x0000000000043520 0x0000000000043520
                 0x0000000000002b18 0x0000000000002cf8  RW     0x1000
  DYNAMIC        0x0000000000044740 0x0000000000045740 0x0000000000045740
                 0x0000000000000230 0x0000000000000230  RW     0x8
  NOTE           0x00000000000002fc 0x00000000000002fc 0x00000000000002fc
                 0x0000000000000044 0x0000000000000044  R      0x4
  TLS            0x0000000000042520 0x0000000000043520 0x0000000000043520
                 0x0000000000000000 0x00000000000000d8  R      0x20
  GNU_EH_FRAME   0x000000000003aa8c 0x000000000003aa8c 0x000000000003aa8c
                 0x0000000000000d84 0x0000000000000d84  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000042520 0x0000000000043520 0x0000000000043520
                 0x0000000000002ae0 0x0000000000002ae0  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   03     .init .plt .plt.got .text .fini 
   04     .rodata .debug_gdb_scripts .eh_frame_hdr .eh_frame .gcc_except_table 
   05     .init_array .fini_array .data.rel.ro .dynamic .got .data .bss 
   06     .dynamic 
   07     .note.gnu.build-id .note.ABI-tag 
   08     .tbss 
   09     .eh_frame_hdr 
   10     
   11     .init_array .fini_array .data.rel.ro .dynamic .got 

Каждый тип заголовка программы делает что-то свое с фрагментом памяти (сегментом). Рядом с каждым заголовком можно найти два 64-битных (это ведь 64-битный ELF) шестнадцатеричных значений. Как указано в верхней части вывода заголовка, верхнее значение - это смещение в памяти сегмента, на который ссылается заголовок (где он расположен). Значение ниже - это размер этого конкретного сегмента в файле.

Каждый сегмент подразделяется на разделы, о которых мы поговорим позже. А пока обратите внимание на таблицу «сопоставление разделов с сегментами» под заголовками программ. Видите, как они пронумерованы? Эти числа соответствуют положению заголовков программы выше. Итак, первый заголовок (который имеет тип PHDR) относится к сегменту 00, второй (который оказывается INTERP) - к 01 и так далее.

Полный обзор типов заголовков программы можно найти здесь, но краткое объяснение каждого сегмента нашей фактической программы и того, что указывает соответствующий тип заголовка, следует делать с этим сегментом, можно найти ниже:

Segment NumberHeader TypeExplanation
00PHDRУказывает расположение и размер самой таблицы заголовков программы
01INTERPУказывает расположение интерпретатора в системе, используемого для динамического связывания. Это позволяет нам просто использовать библиотеки в системе, вместо того, чтобы компилировать все эти библиотеки в двоичный файл (это называется статической компоновкой).
02-05LOADЭти сегменты загружаются в память. Обратите внимание, что в сегменте 03 установлен флаг E, который указывает, что именно здесь находится наш исполняемый код.
06DYNAMICПредоставляет информацию о динамической компоновке, например о том, какие библиотеки в системе интерпретатор должен предоставить доступ во время выполнения.
07NOTEЭто обычно используется для хранения таких вещей, как ABI (и версия), используемые в этой программе для связи с базовой операционной системой.
08TLSЛокальное хранилище потока
09GNU_EH_FRAMEКадр раскрутки информации. Используется для обработки исключений
10GNU_STACKИспользуется для явного запроса на выполнение стека (обратите внимание на вывод выше, этот бит не установлен)
11GNU_RELROЗадает область памяти, которая должна быть доступна только для чтения после загрузки (перемещения).

Теперь мы лучше понимаем, что, по мнению компилятора Rust, должно быть включено в таблицу заголовков программы - в частности, как Rust рекомендует, чтобы наш компьютер готовился к запуску скомпилированной программы - опять же, в простейшем случае по умолчанию. Некоторые выводы из этого:

Таблица заголовков разделов

Таблица заголовков разделов обычно находится ближе к концу файла ELF, и ее основная задача - предоставлять информацию для целей связывания, но я также счел полезным понять содержимое каждого раздела - в частности, размер каждого:

~$ readelf -S target/debug/rbin
There are 42 section headers, starting at offset 0x30c1e8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         00000000000002e0  000002e0
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.gnu.build-i NOTE             00000000000002fc  000002fc
       0000000000000024  0000000000000000   A       0     0     4
  [ 3] .note.ABI-tag     NOTE             0000000000000320  00000320
       0000000000000020  0000000000000000   A       0     0     4
  [ 4] .gnu.hash         GNU_HASH         0000000000000340  00000340
       0000000000000024  0000000000000000   A       5     0     8
  [ 5] .dynsym           DYNSYM           0000000000000368  00000368
       0000000000000720  0000000000000018   A       6     1     8
  [ 6] .dynstr           STRTAB           0000000000000a88  00000a88
       000000000000052d  0000000000000000   A       0     0     1
  [ 7] .gnu.version      VERSYM           0000000000000fb6  00000fb6
       0000000000000098  0000000000000002   A       5     0     2
  [ 8] .gnu.version_r    VERNEED          0000000000001050  00001050
       00000000000000f0  0000000000000000   A       6     4     8
  [ 9] .rela.dyn         RELA             0000000000001140  00001140
       0000000000003d50  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             0000000000004e90  00004e90
       0000000000000048  0000000000000018  AI       5    26     8
  [11] .init             PROGBITS         0000000000005000  00005000
       000000000000001b  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000005020  00005020
       0000000000000040  0000000000000010  AX       0     0     16
  [13] .plt.got          PROGBITS         0000000000005060  00005060
       0000000000000008  0000000000000008  AX       0     0     8
  [14] .text             PROGBITS         0000000000005070  00005070
       00000000000304f3  0000000000000000  AX       0     0     16
  [15] .fini             PROGBITS         0000000000035564  00035564
       000000000000000d  0000000000000000  AX       0     0     4
  [16] .rodata           PROGBITS         0000000000036000  00036000
       0000000000004a69  0000000000000000   A       0     0     16
  [17] .debug_gdb_script PROGBITS         000000000003aa69  0003aa69
       0000000000000022  0000000000000001 AMS       0     0     1
  [18] .eh_frame_hdr     PROGBITS         000000000003aa8c  0003aa8c
       0000000000000d84  0000000000000000   A       0     0     4
  [19] .eh_frame         PROGBITS         000000000003b810  0003b810
       00000000000049e0  0000000000000000   A       0     0     8
  [20] .gcc_except_table PROGBITS         00000000000401f0  000401f0
       0000000000001c54  0000000000000000   A       0     0     4
  [21] .tbss             NOBITS           0000000000043520  00042520
       00000000000000d8  0000000000000000 WAT       0     0     32
  [22] .init_array       INIT_ARRAY       0000000000043520  00042520
       0000000000000010  0000000000000008  WA       0     0     8
  [23] .fini_array       FINI_ARRAY       0000000000043530  00042530
       0000000000000008  0000000000000008  WA       0     0     8
  [24] .data.rel.ro      PROGBITS         0000000000043538  00042538
       0000000000002208  0000000000000000  WA       0     0     8
  [25] .dynamic          DYNAMIC          0000000000045740  00044740
       0000000000000230  0000000000000010  WA       6     0     8
  [26] .got              PROGBITS         0000000000045970  00044970
       0000000000000678  0000000000000008  WA       0     0     8
  [27] .data             PROGBITS         0000000000046000  00045000
       0000000000000038  0000000000000000  WA       0     0     8
  [28] .bss              NOBITS           0000000000046038  00045038
       00000000000001e0  0000000000000000  WA       0     0     8
  [29] .comment          PROGBITS         0000000000000000  00045038
       000000000000002a  0000000000000001  MS       0     0     1
  [30] .debug_aranges    PROGBITS         0000000000000000  00045062
       0000000000008700  0000000000000000           0     0     1
  [31] .debug_pubnames   PROGBITS         0000000000000000  0004d762
       00000000000479a9  0000000000000000           0     0     1
  [32] .debug_info       PROGBITS         0000000000000000  0009510b
       00000000000ba088  0000000000000000           0     0     1
  [33] .debug_abbrev     PROGBITS         0000000000000000  0014f193
       000000000000102c  0000000000000000           0     0     1
  [34] .debug_line       PROGBITS         0000000000000000  001501bf
       000000000005f7cd  0000000000000000           0     0     1
  [35] .debug_frame      PROGBITS         0000000000000000  001af990
       00000000000001f0  0000000000000000           0     0     8
  [36] .debug_str        PROGBITS         0000000000000000  001afb80
       00000000000ce162  0000000000000001  MS       0     0     1
  [37] .debug_pubtypes   PROGBITS         0000000000000000  0027dce2
       000000000000068b  0000000000000000           0     0     1
  [38] .debug_ranges     PROGBITS         0000000000000000  0027e36d
       000000000007e400  0000000000000000           0     0     1
  [39] .symtab           SYMTAB           0000000000000000  002fc770
       00000000000055f8  0000000000000018          40   642     8
  [40] .strtab           STRTAB           0000000000000000  00301d68
       000000000000a2cb  0000000000000000           0     0     1
  [41] .shstrtab         STRTAB           0000000000000000  0030c033
       00000000000001b1  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

Если вы преобразуете размер каждого раздела из шестнадцатеричного в десятичный и сложите их, вы получите 3190428 (байтов), что действительно близко к общему размеру файла 3198056, как сообщает ls. Итак, с помощью этой таблицы разделов мы можем начать понимать, откуда берутся все эти данные, и понимать, где искать дальше.

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

Раздел выполнения

Ну что теперь? Что на самом деле выполняется?

Хотя у readelf есть несколько флагов для проверки содержимого этих разделов, вместо этого мы будем использовать инструмент под названием objdump, который намного лучше показывает разбивку содержимого каждого раздела как в необработанных шестнадцатеричных кодах операций и аргументах, так и в интерпретируемая сборка:

objdump -d -C target/debug/rbin -EL -M intel --insn-width=8 | less

Некоторые указатели на флаги, которые я передаю:

Флаг -S чередует исходный код Rust внутри сборки, поэтому мы можем точно видеть, какие строки Rust привели к каким строкам машинного кода. Я пропустил это, потому что я сам буду объяснять соответствующие строки, и это делает примеры более простыми, но определенно используйте этот флаг самостоятельно, так как это было очень полезно для меня.

В заголовке файла мы видели ссылку на точку входа в память. Напоминаем, что это был 0x5070. После того, как заголовок файла и заголовки программы будут проанализированы и сегменты загружены в память, компьютер начнет выполнение инструкций, начиная с этой позиции. Итак, давайте перейдем к этой позиции в выводе objdump и будем использовать ее в качестве отправной точки. Обратите внимание, что это можно найти в разделе .text, который мы только что рассмотрели:

Разборка раздела .text:

0000000000005070 <_start>:
    5070:       f3 0f 1e fa                     endbr64 
    5074:       31 ed                           xor    ebp,ebp
    5076:       49 89 d1                        mov    r9,rdx
    5079:       5e                              pop    rsi
    507a:       48 89 e2                        mov    rdx,rsp
    507d:       48 83 e4 f0                     and    rsp,0xfffffffffffffff0
    5081:       50                              push   rax
    5082:       54                              push   rsp
    5083:       4c 8d 05 b6 04 03 00            lea    r8,[rip+0x304b6]        # 35540 <__libc_csu_fini>
    508a:       48 8d 0d 3f 04 03 00            lea    rcx,[rip+0x3043f]        # 354d0 <__libc_csu_init>
    5091:       48 8d 3d 38 03 00 00            lea    rdi,[rip+0x338]        # 53d0 <main>
    5098:       ff 15 92 0b 04 00               call   QWORD PTR [rip+0x40b92]        # 45c30 <__libc_start_main@GLIBC_2.2.5>
    509e:       f4                              hlt    
    509f:       90                              nop

В среднем столбце (начиная с endbr64) показана инструкция x86_64, выполняемая в этой строке. Крайний правый столбец содержит параметры для каждой из этих операций. Ближе к концу мы видим, что компилятор Rust предоставил несколько полезных подсказок о том, куда будет двигаться выполнение дальше. По адресу 0x5091 мы видим инструкцию с интересным комментарием справа: # 53d0

. Это Rust, который дает нам понять, что выполнение перемещается на смещение памяти 0x53d0. Прокручивая вниз, мы видим, где именно это происходит:

00000000000053d0 <main>:
    53d0:       48 83 ec 18                     sub    rsp,0x18
    53d4:       8a 05 8f 56 03 00               mov    al,BYTE PTR [rip+0x3568f]        # 3aa69 <__rustc_debug_gdb_scripts_section__>
    53da:       48 63 cf                        movsxd rcx,edi
    53dd:       48 8d 3d 0c ff ff ff            lea    rdi,[rip+0xffffffffffffff0c]        # 52f0 <rbin::main>
    53e4:       48 89 74 24 10                  mov    QWORD PTR [rsp+0x10],rsi
    53e9:       48 89 ce                        mov    rsi,rcx
    53ec:       48 8b 54 24 10                  mov    rdx,QWORD PTR [rsp+0x10]
    53f1:       88 44 24 0f                     mov    BYTE PTR [rsp+0xf],al
    53f5:       e8 e6 fd ff ff                  call   51e0 <std::rt::lang_start>
    53fa:       48 83 c4 18                     add    rsp,0x18
    53fe:       c3                              ret    
    53ff:       90                              nop

Опять же, у нас есть еще одна подсказка: # 52f0 rbin::main. На этот раз мы прокручиваем вверх, чтобы найти этот адрес и, в свою очередь, фактический машинный код, представляющий нашу функцию main():

00000000000052f0 <rbin::main>:
    52f0:       48 83 ec 78                     sub    rsp,0x78
    52f4:       48 8b 05 8d e2 03 00            mov    rax,QWORD PTR [rip+0x3e28d]        # 43588 <__do_global_dtors_aux_fini_array_entry+0>
    52fb:       bf 05 00 00 00                  mov    edi,0x5
    5300:       be 08 00 00 00                  mov    esi,0x8
    5305:       48 89 44 24 18                  mov    QWORD PTR [rsp+0x18],rax
    530a:       e8 81 00 00 00                  call   5390 <rbin::sum>
    530f:       89 44 24 6c                     mov    DWORD PTR [rsp+0x6c],eax
    5313:       48 8d 35 46 f4 02 00            lea    rsi,[rip+0x2f446]        # 34760 <core::fmt::num::imp::<impl core::fmt::Display for >
    531a:       48 8d 44 24 6c                  lea    rax,[rsp+0x6c]
    531f:       48 89 44 24 60                  mov    QWORD PTR [rsp+0x60],rax
    5324:       48 8b 44 24 60                  mov    rax,QWORD PTR [rsp+0x60]
    5329:       48 89 44 24 70                  mov    QWORD PTR [rsp+0x70],rax
    532e:       48 89 c7                        mov    rdi,rax
    5331:       e8 4a fe ff ff                  call   5180 <core::fmt::ArgumentV1::new>
    5336:       48 89 44 24 10                  mov    QWORD PTR [rsp+0x10],rax
    533b:       48 89 54 24 08                  mov    QWORD PTR [rsp+0x8],rdx
    5340:       48 8b 44 24 10                  mov    rax,QWORD PTR [rsp+0x10]
    5345:       48 89 44 24 50                  mov    QWORD PTR [rsp+0x50],rax
    534a:       48 8b 4c 24 08                  mov    rcx,QWORD PTR [rsp+0x8]
    534f:       48 89 4c 24 58                  mov    QWORD PTR [rsp+0x58],rcx
    5354:       48 8d 54 24 50                  lea    rdx,[rsp+0x50]
    5359:       48 8d 7c 24 20                  lea    rdi,[rsp+0x20]
    535e:       48 8b 74 24 18                  mov    rsi,QWORD PTR [rsp+0x18]
    5363:       41 b8 02 00 00 00               mov    r8d,0x2
    5369:       48 89 14 24                     mov    QWORD PTR [rsp],rdx
    536d:       4c 89 c2                        mov    rdx,r8
    5370:       48 8b 0c 24                     mov    rcx,QWORD PTR [rsp]
    5374:       41 b8 01 00 00 00               mov    r8d,0x1
    537a:       e8 c1 00 00 00                  call   5440 <core::fmt::Arguments::new_v1>
    537f:       48 8d 7c 24 20                  lea    rdi,[rsp+0x20]
    5384:       ff 15 16 0b 04 00               call   QWORD PTR [rip+0x40b16]        # 45ea0 <_GLOBAL_OFFSET_TABLE_+0x530>
    538a:       48 83 c4 78                     add    rsp,0x78
    538e:       c3                              ret    
    538f:       90                              nop

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

Если вы новичок в чтении ассемблера, как и я, хорошая новость заключается в том, что существует множество руководств, которые могут помочь вам понять, что здесь происходит. Я нашел это руководство и это резюме полезными, но есть много других, которые тоже работают хорошо - просто помните о синтаксисе сборки, которую вы используете (помните, что я использую Intel для этого сообщения).

Сначала мы замечаем, что в верхней части кода этой функции мы видим, что первая операция требует 120 (0x78) байт пространства стека. Обычно в верхней части блока машинного кода каждой функции можно увидеть:

52f0:       48 83 ec 78                     sub    rsp,0x78

Напомним, наша программа определяет два целочисленных литерала (5 и 8) в качестве параметров нашей функции суммы, поэтому компилятор Rust перемещает оба этих значения в регистры edi и esi:

52fb:       bf 05 00 00 00                  mov    edi,0x5
5300:       be 08 00 00 00                  mov    esi,0x8

Мы почти готовы вызвать нашу функцию суммы, но прежде чем мы сможем это сделать, нам нужно что-то сделать с регистром rax:

По соглашению% rax используется для хранения возвращаемого значения функции, если оно существует.

Значение, возвращаемое суммой, в конечном итоге будет сохранено в регистре rax, но если вы посмотрите внимательно, мы уже переместили значение в rax в нашей основной функции. Итак, прежде чем мы вызовем сумму, мы должны переместить это значение в безопасное место.

Если %rax содержит значение, которое вызывающий абонент хочет сохранить, вызывающий должен скопировать это значение в «безопасное» место перед выполнением вызова.

Наш компилятор знает, что наша функция суммы будет использовать 18 байтов пространства стека (как мы увидим, когда посмотрим на код функции суммы), поэтому мы можем безопасно переместить это значение в область памяти, которая смещена на 18 байтов от текущий указатель стека:

5305:       48 89 44 24 18                  mov    QWORD PTR [rsp+0x18],rax   #TODO Why does this happen before the function call?

Наконец, мы можем вызвать нашу функцию суммы:

530a:       e8 81 00 00 00                  call   5390 <rbin::sum>

Давайте посмотрим на эту ячейку памяти (5390) - я снова предоставлю всю сборку для этой функции, а затем подробно изучу соответствующие инструкции:

0000000000005390 <rbin::sum>:
    5390:       48 83 ec 18                     sub    rsp,0x18
    5394:       89 7c 24 10                     mov    DWORD PTR [rsp+0x10],edi
    5398:       89 74 24 14                     mov    DWORD PTR [rsp+0x14],esi
    539c:       01 f7                           add    edi,esi
    539e:       0f 90 c0                        seto   al
    53a1:       a8 01                           test   al,0x1
    53a3:       89 7c 24 0c                     mov    DWORD PTR [rsp+0xc],edi
    53a7:       75 09                           jne    53b2 <rbin::sum+0x22>
    53a9:       8b 44 24 0c                     mov    eax,DWORD PTR [rsp+0xc]
    53ad:       48 83 c4 18                     add    rsp,0x18
    53b1:       c3                              ret    
    53b2:       48 8d 3d 57 0c 03 00            lea    rdi,[rip+0x30c57]        # 36010 <str.0>
    53b9:       48 8d 15 d0 e1 03 00            lea    rdx,[rip+0x3e1d0]        # 43590 <__do_global_dtors_aux_fini_array_entry+0x60>
    53c0:       48 8d 05 b9 ad 02 00            lea    rax,[rip+0x2adb9]        # 30180 <core::panicking::panic>
    53c7:       be 1c 00 00 00                  mov    esi,0x1c
    53cc:       ff d0                           call   rax
    53ce:       0f 0b                           ud2    

Как это было сделано с функцией main() и как уже упоминалось ранее, эта функция выделяет 18 байт пространства стека:

5390:       48 83 ec 18                     sub    rsp,0x18

Затем мы перемещаем значения в регистры edi и esi, которые, если вы помните из основной функции, хранят параметры для нашей функции суммы. Здесь следует отметить несколько моментов: смещения памяти rsp + 0x10 и rsp + 0x14 находятся в выделенном нами пространстве стека. Фактически, для каждого местоположения достаточно места для хранения 4-байтового значения. Это имеет смысл, потому что оба должны быть именно такими - 32-битными целыми числами!

5394:       89 7c 24 10                     mov    DWORD PTR [rsp+0x10],edi
5398:       89 74 24 14                     mov    DWORD PTR [rsp+0x14],esi

Затем мы складываем эти два значения. Эта операция работает путем добавления значения второго операнда к первому, поэтому после завершения этой операции edi больше не будет представлять один из наших параметров, а будет результатом нашей операции сложения:

539c:       01 f7                           add    edi,esi

Затем мы перемещаем результат этой операции, теперь хранящийся в edi, в пространство стека, а затем снова оттуда в регистр eax

53a3:       89 7c 24 0c                     mov    DWORD PTR [rsp+0xc],edi
53a9:       8b 44 24 0c                     mov    eax,DWORD PTR [rsp+0xc]

Наконец, мы можем вернуть пространство стека и вернуться к месту вызова в основной функции:

53ad:       48 83 c4 18                     add    rsp,0x18
53b1:       c3                              ret 

Вернемся к нашей основной функции, операции, следующие сразу за нашим вызовом sum, перемещают результат, сохраненный в eax, в пространство стека, а затем оттуда в регистр rax.

530f:       89 44 24 6c                     mov    DWORD PTR [rsp+0x6c],eax # eax is where our sum function placed the result of our addition
5313:       48 8d 35 46 f4 02 00            lea    rsi,[rip+0x2f446]        # 34760 <core::fmt::num::imp::<impl core::fmt::Display for >
531a:       48 8d 44 24 6c                  lea    rax,[rsp+0x6c]

Это был очень ограниченный взгляд на многочисленные операции, генерируемые компилятором Rust, которые на первый взгляд выглядят очень простой операцией. Представьте, как должно выглядеть сложное приложение! Было множество других случаев, которые мы не рассмотрели, например, те, которые приводят к сбою программы, поэтому, пожалуйста, найдите время, чтобы использовать команды, которые я показал выше, чтобы посмотреть на свой собственный код и изучить его.

Резюме

Это было небольшое путешествие, но, надеюсь, вы продолжили его и узнали столько же, сколько и я! В итоге:

  • Фактический формат исполняемого файла - ELF. Существует много разных форматов, но это очень распространенный в операционных системах на базе Linux. В исполняемом двоичном файле содержится огромное количество метаданных, которые легко проверять с помощью легко доступных инструментов.

  • Фактический машинный код - это машинный код x86. Этот набор команд поддерживается многими аппаратными платформами, и по этой причине считается «аппаратно-независимым». Это просто означает, что этот набор инструкций может использоваться не только на определенном типе оборудования, но и широко принят и поддерживается.

  • Интерпретация двоичных инструкций (как шестнадцатеричных кодов операций) - Intel. Это менее значимо для компьютера и более значимо для меня, поскольку на уровне машинного кода нет «intel vs att». Это всего лишь интерпретация, которая позволяет нам сделать машинный код более читаемым и доступным для записи.

  • Фактический исполняемый код, который помещается в двоичный файл, зависит от компилятора. Как мы видели, даже простой пример приводит к довольно небольшому количеству машинного кода. Мы проделали довольно простую операцию и работали только с выделениями памяти стека, которые довольно хорошо обрабатываются компилятором. Выделение кучи немного сложнее и требует согласования с операционной системой. Вы можете себе представить, что компилятор должен проделать много работы, чтобы просто получить жизнеспособную программу - не говоря уже о дополнительных проверках, которые делает что-то вроде компилятора Rust, чтобы уберечь нас от всех видов проблем с памятью. Попробуйте дизассемблировать более сложную программу и попытаться проследить различные ветви логики через базовую сборку!

Полезные ссылки

Я связался с некоторыми полезными ресурсами в этом посте, но вот несколько других, которые, по моему мнению, были полезными, но не вошли в содержание выше:

  • Документациф x86 ABI
  • Регистры в x86 ассемблере
  • Классный веб-дизассемблер
  • Как запускаются программы: двоичные файлы ELF
  • Magnifying Glasses для Rust ассемблера
  • cargo-asm: подкоманда cargo, которая отображает ассемблер или llvm-ir, сгенерированную для исходного кода Rust.