Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Объектные файлы, ELF и символы

senior~290 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Объектные файлы, ELF и символы

С девятого урока ты кормишь свой zt disasm файлами с расширением .o и не спрашиваешь, что в них лежит кроме машинного кода. Ты нашёл там секцию .text и на этом остановился. Сегодня мы открываем файл целиком. В нём окажется больше служебных таблиц, чем кода: список секций, список имён, список мест, которые кто-то должен будет поправить. Весь этот урок и четыре следующих про одну программу, которую ты запускал тысячи раз и ни разу не видел: про компоновщик. К концу урока zt научится печатать таблицу секций и таблицу имён так, что diff с настоящим readelf будет пустым, а твой дизассемблер впервые подпишет функции по именам.

Цели урока

  • Объяснить, что zig build-exe и gcc это драйверы, и назвать программы, которые они запускают по очереди.
  • Сформулировать две задачи статической компоновки: разрешение имён и перемещение.
  • Читать заголовок ELF по байтам и находить по нему таблицу секций.
  • Знать, что лежит в .text, .rodata, .data, .bss, .symtab, .strtab, .rela.text, и почему .bss не занимает места в файле.
  • Предсказывать, в какую секцию Zig положит const, var, export, extern и объявление с linksection.
  • Разбирать запись Elf64_Sym по полям и отличать глобальные имена, внешние и локальные.
  • Понимать три псевдосекции: UND, ABS и COM.
  • Читать буквы nm и выводить их из флагов секции.
  • Дописать в zt читатель ELF64, подкоманды readelf и nm и подписи функций в disasm.

Кто на самом деле собирает программу

Возьмём программу из двух файлов. Она понадобится нам до конца блока про компоновку, поэтому рассмотри её внимательно. Первый файл складывает два вектора и считает, сколько раз его звали:

//! Фикстура компоновщика: сложение векторов, как `addvec` из книги.
//! Счётчик вызовов лежит в `.bss`, потому что инициализирован нулём.
//!
//! Собирается так:
//!   zig build-obj fixtures/link/addvec.zig -O ReleaseSmall \
//!       -target x86_64-linux -femit-bin=fixtures/link/addvec.o

export var addcnt: i64 = 0;

export fn addvec(x: [*]const i64, y: [*]const i64, z: [*]i64, n: usize) void {
    addcnt += 1;
    var i: usize = 0;
    while (i < n) : (i += 1) z[i] = x[i] + y[i];
}

Второй файл это главная программа. В ней нет main, нет стандартной библиотеки и нет libc: только _start, с которого ядро начинает выполнение, и функция run. Так в объектнике не окажется ничего лишнего, каждый байт в нём будет наш.

//! Фикстура компоновщика: главная программа без libc.
//!
//! `_start` зовёт `run`, а тот складывает векторы через внешний `addvec`
//! и возвращает сумму результата, 4 + 6 = 10. Это число становится кодом
//! возврата процесса через системный вызов `exit`, так что тест может
//! запустить собранный бинарник и проверить его, не читая вывод.
//!
//! Собирается так:
//!   zig build-obj fixtures/link/main.zig -O ReleaseSmall \
//!       -target x86_64-linux -femit-bin=fixtures/link/main.o

extern fn addvec(x: [*]const i64, y: [*]const i64, z: [*]i64, n: usize) void;

var x: [2]i64 = .{ 1, 2 };
var y: [2]i64 = .{ 3, 4 };
var z: [2]i64 = .{ 0, 0 };

export fn _start() callconv(.naked) noreturn {
    asm volatile (
        \\call run
        \\movl %eax, %edi
        \\movl $60, %eax
        \\syscall
    );
}

export fn run() i32 {
    addvec(&x, &y, &z, 2);
    return @intCast(z[0] + z[1]);
}

Приём с callconv(.naked) и голым syscall ты видел в уроке про ассемблерные вставки: номер 60 это exit, а код возврата берётся из %edi. Программа ничего не печатает, её ответ это код возврата: 4 плюс 6, то есть 10.

Обычно ты собрал бы это одной командой и не думал. Сегодня соберём в два приёма, и второй приём попросим рассказать, что он делает. Все команды урока выполнены в контейнере linux/amd64 с Zig 0.16.0; на Apple Silicon он работает через Rosetta.

$ zig build-obj main.zig   -O ReleaseSmall -target x86_64-linux
$ zig build-obj addvec.zig -O ReleaseSmall -target x86_64-linux
$ ls -la *.o
-rw-r--r-- 1 runner runner 1128 addvec.o
-rw-r--r-- 1 runner runner 1488 main.o
$ zig build-exe main.o addvec.o -target x86_64-linux -femit-bin=prog --verbose-link
ld.lld --error-limit=0 -mllvm -float-abi=hard --entry _start -z stack-size=16777216
    --build-id=none --image-base=16777216 --eh-frame-hdr -znow -m elf_x86_64 -static
    -o prog main.o addvec.o .../libubsan_rt.a --as-needed .../libcompiler_rt.a
$ ./prog; echo $?
10

Строка, которую напечатал --verbose-link, это и есть ответ на вопрос из заголовка. Команда zig build-exe сама ничего не компонует. Она драйвер: разбирает твои ключи, решает, кого позвать, и зовёт. У GCC цепочка длинная и состоит из отдельных программ: препроцессор cpp делает из main.c текст без макросов, компилятор cc1 делает из него ассемблерный main.s, ассемблер as превращает его в main.o, и в конце ld склеивает объектники в исполняемый файл. У Zig первые три шага живут внутри одного процесса: компилятор сам порождает машинный код (своим бэкендом или через LLVM) и сам пишет объектник. Но последний шаг остался отдельным. Для ELF его выполняет ld.lld, компоновщик проекта LLVM, встроенный в бинарник zig.

Обрати внимание на хвост команды. Ты просил скомпоновать два файла, а драйвер добавил ещё два архива: libcompiler_rt.a и libubsan_rt.a. В первом лежат функции, которые компилятор вправе позвать без твоего ведома, например деление 128-битных чисел. Что такое архив и почему из него в prog не попало ни байта, разберём в следующем уроке. Пока запомни наблюдение: готовая программа всегда собирается из большего числа файлов, чем ты написал.

Теперь сломаем сборку. Уберём из команды addvec.o:

$ zig build-exe main.o -target x86_64-linux -femit-bin=prog2
error: ld.lld: undefined symbol: addvec
    note: referenced by main
    note:               main.o:(run)

Ошибку выдал не компилятор. Компилятор свою работу закончил раньше, когда писал main.o, и его всё устроило: ты объявил extern fn addvec, он поверил на слово. Жалуется ld.lld, и жалуется удивительно точно: он знает имя, которого не хватает, знает, в каком файле на него ссылаются, и даже знает, из какой функции. Исходников у него при этом нет, есть только main.o. Значит, все эти сведения записаны внутри объектника. Осталось найти где.

Зачем компоновщик вообще нужен

Можно представить мир без него: вся программа лежит в одном исходном файле, компилятор читает его и пишет готовый бинарник. Так работают многие учебные компиляторы, и твой JIT устроен именно так. У этого мира три беды.

Пересборка. Изменил одну строку, пересобираешь всё. Ядро Linux это около сорока миллионов строк; сборка с нуля на хорошей рабочей станции занимает минуты, а на ноутбуке десятки минут. С раздельной компиляцией после правки одного файла пересобирается один объектник, а остальные несколько тысяч берутся готовыми.

Чужой код. Библиотеку нельзя было бы поставлять иначе чем исходниками, причём на том же языке и под тот же компилятор. Объектный файл это общий формат: в него пишут Zig, C, Rust, Fortran и ассемблер, и компоновщику всё равно, кто автор. Именно поэтому ты мог звать C из Zig в шестом уроке.

Размер. Всё, что нужно хотя бы одной программе, пришлось бы копировать в каждую. К этой беде мы вернёмся в уроке про разделяемые библиотеки.

Компоновка решает все три. Её можно делать в три разных момента: при сборке (статическая, этот урок и два следующих), при запуске программы (динамическая, урок 45) и даже во время работы (dlopen, там же). Начинаем с самого простого случая.

Две задачи статического компоновщика

На входе у компоновщика несколько перемещаемых объектных файлов, на выходе один исполняемый. Каждый входной файл компилятор писал, ничего не зная об остальных. Отсюда две проблемы, и у каждой своё решение.

Первая: компилятор не знает, где лежит чужое. В main.o есть вызов addvec, но самой addvec там нет. Компоновщик должен для каждой ссылки на имя найти ровно одно определение этого имени среди всех входных файлов. Это разрешение имён. Если определения нет, ты получишь undefined symbol, как минуту назад. Если определений два, начинаются правила и тонкости, им посвящён следующий урок.

Вторая: компилятор не знает, где окажется своё. Каждый объектник написан так, будто его код начинается с адреса ноль, и данные тоже с нуля. В готовой программе с нуля не начинается ничего, а run и addvec не могут лежать по одному адресу. Компоновщик раскладывает секции всех файлов по итоговым адресам, а потом проходит по всем местам в коде и данных, где записан адрес, и вписывает туда правильное число. Это перемещение. Сам компоновщик при этом не понимает машинный код и не ищет адреса глазами: компилятор заранее составил для него список таких мест. Этот список мы сегодня увидим, а считать по нему научимся в уроке 44.

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

Три вида объектных файлов

Словом “объектный файл” называют три разные вещи, и формат у них общий:

ВидКто пишетЧто внутриРасширение по привычке
перемещаемыйкомпилятор или ассемблерсекции с адресами от нуля, незаполненные ссылки.o
исполняемыйкомпоновщиксегменты с итоговыми адресами, можно отображать в память и запускатьнет
разделяемыйкомпоновщиккак исполняемый, но готов загрузиться по любому адресу и отдать свои имена другим.so

На Linux и почти на всех остальных Unix общий формат называется ELF. У Windows свой формат, PE, потомок COFF. У macOS свой, Mach-O. Идеи во всех трёх одни и те же: секции, таблица имён, записи перемещения. Различаются числа и названия. Мы работаем с ELF64 для x86-64, и это удачный выбор: формат описан в открытой спецификации на четыре десятка страниц, и в нём нет ни одного поля, которое мы не сможем объяснить.

Сегодня нас интересует только первый вид. Исполняемый файл разберём в уроке 44, разделяемый в уроке 45.

Карта перемещаемого файла

Перемещаемый ELF состоит из четырёх частей, и лежат они в таком порядке:

смещение 0      +-----------------------------+
                | заголовок ELF, 64 байта     |  кто я и где таблица секций
смещение 0x40   +-----------------------------+
                | .text                       |
                | .data                       |  содержимое секций,
                | .eh_frame                   |  куски байтов подряд
                | .rela.text                  |
                | .symtab, .strtab, .shstrtab |
                +-----------------------------+
                | таблица заголовков секций   |  по 64 байта на секцию:
                | [0] [1] [2] ... [10]        |  имя, тип, смещение, размер
конец файла     +-----------------------------+

Заголовок стоит в начале, потому что с него читатель начинает. Таблица секций стоит в конце, и на то есть причина: компилятор пишет файл за один проход и не знает, сколько секций у него получится и какого размера, пока не закончит. Когда всё содержимое уже в файле, он дописывает оглавление и возвращается в начало, чтобы вписать в заголовок одно число: смещение оглавления.

Посмотрим на заголовок своим же инструментом из шестого урока:

$ zt hexdump main.o | head -5
00000000  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  |.ELF............|
00000010  01 00 3e 00 01 00 00 00 00 00 00 00 00 00 00 00  |..>.............|
00000020  00 00 00 00 00 00 00 00 10 03 00 00 00 00 00 00  |................|
00000030  00 00 00 00 40 00 00 00 00 00 40 00 0b 00 09 00  |....@.....@.....|
00000040  55 48 89 e5 6a 02 59 bf 00 00 00 00 be 00 00 00  |UH..j.Y.........|

Первые четыре байта это подпись: 7f, потом буквы E, L, F. По ней файл узнают и file, и ядро, и твой zt. Следующие байты тоже говорящие: 02 значит 64-битный формат, 01 значит little endian, ещё 01 это версия. На этом 16-байтное поле e_ident заканчивается, остаток забит нулями.

Дальше идут обычные числа, и ты уже умеешь читать их в little endian. По смещению 0x10 лежит 01 00, это тип файла: 1 значит перемещаемый. За ним 3e 00, это машина: 0x3e равно 62, так в ELF зовут x86-64. По смещению 0x28 восемь байтов 10 03 00 00 00 00 00 00, это 0x310, то есть 784: столько байтов от начала файла до таблицы секций. В последней строке заголовка видны 40 00 (размер записи таблицы секций, 64 байта), 0b 00 (записей одиннадцать) и 09 00 (имена секций лежат в секции номер девять). Проверим арифметику: 784 плюс 11 раз по 64 это 1488, ровно размер файла. Таблица секций действительно стоит последней.

То же самое, но словами, печатает readelf -h:

$ readelf -h main.o
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:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          784 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         11
  Section header string table index: 9

Три строки здесь нулевые, и это не случайность. Entry point address ноль, потому что перемещаемый файл нельзя запустить, входа у него нет. Start of program headers и Number of program headers нулевые, потому что заголовки программы описывают, как отображать файл в память, а отображать пока нечего: адресов ещё нет. Эти поля оживут в уроке 44, когда zt ld выдаст первый исполняемый файл.

Сразу за заголовком, с байта 0x40, в дампе видны 55 48 89 e5. Это pushq %rbp и movq %rsp, %rbp: пролог функции run. Секция .text лежит в файле самой первой.

Секции: оглавление файла

Секция это единица, которой мыслит компоновщик. Он не переставляет функции и не двигает переменные по одной: он берёт секцию .text из каждого входного файла и кладёт их друг за другом в выходную .text. Поэтому первым делом в любом объектнике смотрят оглавление:

$ readelf -S -W main.o
There are 11 section headers, starting at offset 0x310:

Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            0000000000000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        0000000000000000 000040 000037 00  AX  0   0  4
  [ 2] .rela.text        RELA            0000000000000000 0000e8 0000a8 18   I  8   1  8
  [ 3] .bss              NOBITS          0000000000000000 000077 000010 00  WA  0   0  8
  [ 4] .data             PROGBITS        0000000000000000 000078 000020 00  WA  0   0  8
  [ 5] .eh_frame         X86_64_UNWIND   0000000000000000 000098 000050 00   A  0   0  8
  [ 6] .rela.eh_frame    RELA            0000000000000000 000190 000030 18   I  8   5  8
  [ 7] .note.GNU-stack   PROGBITS        0000000000000000 0001c0 000000 00      0   0  1
  [ 8] .symtab           SYMTAB          0000000000000000 0001c0 0000d8 18     10   6  8
  [ 9] .shstrtab         STRTAB          0000000000000000 000298 000060 00      0   0  1
  [10] .strtab           STRTAB          0000000000000000 0002f8 000018 00      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),
  D (mbind), l (large), p (processor specific)

Одиннадцать строк, и каждая это 64-байтная запись Elf64_Shdr из хвоста файла. Пройдём по колонкам, потому что через час ты будешь печатать их сам.

  • Name. В записи лежит не имя, а число: смещение в секции строк .shstrtab. Зачем так сложно, разберём чуть ниже.
  • Type. Что это за секция по существу. PROGBITS значит “байты, смысл которых знает программа, а не компоновщик”: код и данные. NOBITS значит “место нужно, байтов нет”. SYMTAB это таблица имён, STRTAB это строки, RELA это записи перемещения. Тип это закон, а имя только подсказка человеку: компоновщик ищет таблицу имён по типу SYMTAB, и назови ты её хоть .banana, он её найдёт.
  • Address. Адрес секции в памяти. У перемещаемого файла в этой колонке сплошные нули: адресов ещё никто не назначал. Вот откуда нулевые адреса в выводе твоего дизассемблера.
  • Off и Size. Где содержимое секции лежит в файле и сколько занимает. .text начинается с 0x40 (сразу за заголовком, как мы и видели в дампе) и занимает 0x37, то есть 55 байтов.
  • ES. Размер одной записи, если секция это таблица. У .symtab и .rela.text здесь 18, то есть 24 байта на запись.
  • Flg. Флаги: A значит “эта секция попадёт в память процесса”, W значит “в неё можно писать”, X значит “её можно выполнять”. У .text флаги AX, у .data и .bss флаги WA. У .symtab флагов нет совсем: таблица имён нужна компоновщику и отладчику, а работающей программе она ни к чему, и в память её не грузят.
  • Lk и Inf. Два поля, смысл которых зависит от типа секции. У .symtab в Lk стоит 10: это номер секции со строками для имён, .strtab. У .rela.text в Lk стоит 8 (в какой таблице имён искать символы), а в Inf стоит 1 (какую секцию эти записи правят, то есть .text).
  • Al. Выравнивание: адрес секции обязан делиться на это число.

Секция номер ноль всегда пустая, у неё тип NULL. Она нужна, чтобы ноль мог означать “никакая секция”, и мы скоро увидим, где это пригождается.

Кто есть кто

СекцияЧто в нейОткуда берётся
.textмашинный кодтела функций
.rodataданные только для чтенияконстанты, строковые литералы, таблицы переходов switch
.dataизменяемые данные с начальным значениемvar x: i64 = 42 на верхнем уровне
.bssизменяемые данные, которые начинаются с нулейvar x: i64 = 0, большие обнулённые буферы
.symtabтаблица имёнвсё, у чего есть имя, видимое компоновщику
.strtabстроки для .symtabсами имена, через нулевой байт
.shstrtabстроки для таблицы секцийимена секций
.rela.textзаписи перемещения для .textкаждое место в коде, где нужен адрес
.rela.dataзаписи перемещения для .dataглобальная переменная, которая хранит указатель на другую
.eh_frameтаблицы раскрутки стекакомпилятор, по ним работают трассы ошибок и отладчик
.note.GNU-stackпустая меткапросьба компоновщику сделать стек неисполняемым
.debug_*отладочная информация DWARFсборка без -fstrip

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

Почему .bss ничего не весит

Посмотри на две соседние строки оглавления ещё раз:

  [ 3] .bss              NOBITS          0000000000000000 000077 000010 00  WA  0   0  8
  [ 4] .data             PROGBITS        0000000000000000 000078 000020 00  WA  0   0  8

У .bss размер 0x10, шестнадцать байтов: это массив z из main.zig, два числа по восемь байтов. Начинается она со смещения 0x77. Следующая секция начинается с 0x78. Шестнадцать байтов в один не влезают, и это не ошибка: у секции типа NOBITS содержимого в файле нет. Хранить шестнадцать нулей незачем, достаточно записать “здесь нужно шестнадцать байтов, и пусть они будут нулями”. Настоящую память под .bss выделит загрузчик при запуске, а нулями её заполнит ядро: оно и так обязано отдавать процессам чистые страницы.

На шестнадцати байтах экономия смешная. На буфере в сто мегабайт она превращается в разницу между бинарником на сто мегабайт и бинарником на сто килобайт. Историческое название расшифровывается как “block started by symbol” и пришло из ассемблера для IBM 704 пятидесятых годов. Запоминать расшифровку бесполезно, а вот мнемоника из книги хороша: .bss это “better save space”, лучше сэкономим место.

Массивы x и y начинаются не с нулей, поэтому они в .data: размер 0x20, четыре числа по восемь байтов, и все тридцать два байта честно лежат в файле.

Зачем отдельные секции строк

Имена в ELF нигде не хранятся на месте. Запись таблицы секций имеет фиксированный размер 64 байта, запись таблицы имён 24 байта, а имя может быть любой длины: у функции из стандартной библиотеки Zig оно легко занимает полсотни знаков. Поэтому все строки сложены в отдельную секцию подряд, каждая закрыта нулевым байтом, а в записях стоит смещение начала строки. Таблица остаётся массивом одинаковых записей, по которому можно ходить индексом, а строки не тратят места на выравнивание.

Таких секций в объектнике две: .shstrtab для имён секций и .strtab для имён из таблицы символов. Ноль в поле имени значит “имени нет”: по нулевому смещению в любой таблице строк лежит нулевой байт, то есть пустая строка.

Что Zig кладёт в какую секцию

Хватит рассматривать чужое. Напишем модуль, в котором есть объявление на каждый случай, и проверим, куда что попадёт:

//! Что Zig кладёт в какую секцию объектника.

/// Имя, которое обещает кто-то другой: в таблице оно будет UND.
extern fn log_value(value: i64) void;

/// Константа: байты только для чтения, секция `.rodata`.
export const limits: [4]i64 = .{ 10, 20, 30, 40 };

/// Переменная с начальным значением: секция `.data`, байты лежат в файле.
export var counter: i64 = 42;

/// Переменная с нулём: секция `.bss`, в файле только размер.
export var scratch: [64]i64 = @splat(0);

/// Своя секция по имени: так размечают таблицы, которые потом ищет загрузчик.
export var boot_flag: u32 linksection(".zt_boot") = 1;

/// Без `export` имя остаётся внутри модуля: LOCAL либо исчезает совсем.
var calls: i64 = 0;

fn bump(step: i64) i64 {
    calls += step;
    return calls;
}

export fn total(n: usize) i64 {
    var sum: i64 = 0;
    for (limits[0..@min(n, limits.len)]) |value| sum += value;
    counter += bump(1);
    scratch[0] = sum;
    log_value(sum);
    return sum + @as(i64, boot_flag);
}

Собираем так же, как раньше, и смотрим оглавление:

$ zig build-obj sections.zig -O ReleaseSmall -target x86_64-linux
$ readelf -S -W sections.o
There are 13 section headers, starting at offset 0x3e0:

Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            0000000000000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        0000000000000000 000040 000061 00  AX  0   0  4
  [ 2] .rela.text        RELA            0000000000000000 000118 0000a8 18   I 10   1  8
  [ 3] .rodata.cst32     PROGBITS        0000000000000000 0000a8 000020 20  AM  0   0  8
  [ 4] .data             PROGBITS        0000000000000000 0000c8 000008 00  WA  0   0  8
  [ 5] .zt_boot          PROGBITS        0000000000000000 0000d0 000004 00  WA  0   0  4
  [ 6] .bss              NOBITS          0000000000000000 0000d4 000208 00  WA  0   0  8
  [ 7] .eh_frame         X86_64_UNWIND   0000000000000000 0000d8 000040 00   A  0   0  8
  [ 8] .rela.eh_frame    RELA            0000000000000000 0001c0 000018 18   I 10   7  8
  [ 9] .note.GNU-stack   PROGBITS        0000000000000000 0001d8 000000 00      0   0  1
  [10] .symtab           SYMTAB          0000000000000000 0001d8 000150 18     12   8  8
  [11] .shstrtab         STRTAB          0000000000000000 000328 000077 00      0   0  1
  [12] .strtab           STRTAB          0000000000000000 00039f 00003b 00      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),
  D (mbind), l (large), p (processor specific)

Сверяем с ожиданиями.

counter попал в .data. Размер секции 8 байтов, и внутри лежит число 42. Проверить легко, objdump -s печатает содержимое секций:

$ objdump -s -j .rodata.cst32 -j .data -j .zt_boot sections.o
sections.o:     file format elf64-x86-64

Contents of section .rodata.cst32:
 0000 0a000000 00000000 14000000 00000000  ................
 0010 1e000000 00000000 28000000 00000000  ........(.......
Contents of section .data:
 0000 2a000000 00000000                    *.......        
Contents of section .zt_boot:
 0000 01000000                             ....            

2a это 42. Рядом видна и наша собственная секция .zt_boot с единицей внутри. Компоновщик отнесётся к ней как к любой другой: соберёт одноимённые секции из всех файлов в одну выходную. На этом приёме держатся таблицы, которые заполняются из разных модулей и читаются одним циклом: списки тестов, обработчики прерываний в прошивках, инициализаторы в ядре Linux.

scratch попал в .bss. Размер секции 0x208, то есть 520 байтов. Массив занимает 512, откуда ещё восемь? Это calls: переменная без export, но тоже изменяемая и тоже нулевая. Она лежит в начале секции, а scratch начинается со смещения 8. Весь этот полукилобайт занимает в файле ноль байтов: следующая секция начинается с 0xd8, через четыре байта выравнивания после 0xd4.

limits попал не в .rodata, а в .rodata.cst32. Это первая неожиданность, и она полезная. Посмотри на флаги: AM, и размер записи 20, то есть 32 байта. Буква M значит “merge”: секция состоит из констант одинакового размера, и компоновщик вправе склеить одинаковые константы из разных файлов в одну. Компилятор увидел неизменяемые 32 байта и положил их в секцию для 32-байтных констант. В итоговом бинарнике всё равно получится одна .rodata: компоновщик объединяет все секции, имя которых начинается с .rodata.. Вывод: имена секций в объектнике подробнее, чем в готовой программе, и полагаться на точное имя нельзя. Полагаться можно на флаги.

Утилита size считает именно по флагам:

$ size sections.o
   text	   data	    bss	    dec	    hex	filename
    193	     12	    520	    725	    2d5	sections.o

В колонку text попало всё, что грузится в память и не допускает записи: код (97 байтов), константы (32) и .eh_frame (64), вместе 193. В data попали .data и .zt_boot, 8 плюс 4. В bss 520 байтов, которых в файле нет. Сумма 725 байтов это память, которую займёт модуль в процессе. Файл при этом весит 1824 байта: остальное это таблицы для компоновщика.

Куда делись bump и calls

Теперь таблица имён того же файла:

$ readelf -s -W sections.o
Symbol table '.symtab' contains 14 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS sections
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 .data
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 .zt_boot
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 .bss
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 .eh_frame
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 .rodata.cst32
     8: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND log_value
     9: 0000000000000000    97 FUNC    GLOBAL DEFAULT    1 total
    10: 0000000000000000    32 OBJECT  GLOBAL DEFAULT    3 limits
    11: 0000000000000000     8 OBJECT  GLOBAL DEFAULT    4 counter
    12: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    5 boot_flag
    13: 0000000000000008   512 OBJECT  GLOBAL DEFAULT    6 scratch

Функции bump нет. Переменной calls нет. Оптимизатор подставил тело bump прямо в total, и отдельной функции не осталось. А calls осталась как восемь байтов в начале .bss, но без имени: код обращается к ней как к “секции .bss плюс ноль”. Никому снаружи это имя не нужно, и в режиме ReleaseSmall Zig такие имена вычищает.

Попросим их оставить ключом -fno-strip:

$ zig build-obj sections.zig -O ReleaseSmall -fno-strip -target x86_64-linux
$ nm sections.o
0000000000000000 D boot_flag
0000000000000000 D counter
0000000000000000 R limits
                 U log_value
0000000000000008 B scratch
0000000000000000 d sections.boot_flag
0000000000000000 b sections.calls
0000000000000000 d sections.counter
0000000000000000 r sections.limits
0000000000000008 b sections.scratch
0000000000000000 t sections.total
0000000000000000 T total

Имена появились, и каждое в двух экземплярах: sections.total со строчной буквой и total с заглавной, по одному адресу. Первое это настоящее имя объявления внутри Zig, с именем модуля через точку. Второе это псевдоним, который создал export. Так export и работает: он не меняет функцию, он добавляет ей второе, глобальное имя без префикса, понятное любому языку. У calls псевдонима нет, потому что export мы на неё не ставили.

В режиме Debug картина та же, но объектник раздувается до неприличия: у нас получилось 9,8 мегабайта, 31 секция и 3124 имени. Дело в том, что zig build-obj в отладочном режиме кладёт в объектник свою часть стандартной библиотеки (обработчик паники, печать трасс) и полную отладочную информацию. Вот почему учебные объектники этого блока собраны в ReleaseSmall: в них остаётся только то, что написал ты.

Сводка: объявление Zig, секция, имя

ОбъявлениеСекцияЗапись в .symtab
export fn f() void {}.textFUNC GLOBAL
fn f() void {}.text, если не подставлена целикомFUNC LOCAL с именем модуль.f, в ReleaseSmall исчезает
extern fn f() void;никакаяNOTYPE GLOBAL UND, и только если f где-то вызвана
export var x: i64 = 42;.dataOBJECT GLOBAL
export var x: i64 = 0;.bssOBJECT GLOBAL
export const x: i64 = 42;.rodata или .rodata.cstNOBJECT GLOBAL
var x linksection(".s") = 1;.sкак у обычной var
threadlocal var x: i64 = 1;.tdata (с нулём .tbss)TLS
@export(&f, .{ .name = "g", .linkage = .weak }).textFUNC WEAK с именем g
pub fn f() void {}как у обычной fnкак у обычной fn
var x: i64 внутри функциистек или регистрзаписи нет

Предпоследняя строка отдельно важна для тех, кто пришёл из других языков. Слово pub в Zig управляет видимостью между файлами Zig во время компиляции, через @import. Компоновщик о нём не знает ничего. Все файлы Zig, связанные через @import, компилируются в один объектник, и их pub-функции зовут друг друга без всякого компоновщика. Чтобы имя стало видно снаружи объектника, нужен export. В C всё наоборот: там каждая функция глобальна, пока ты не напишешь static. Умолчание Zig безопаснее: случайно занять глобальное имя нельзя.

Имена: что компоновщик знает о твоём коде

Компоновщики называют именованную сущность словом символ. С точки зрения одного модуля m все имена делятся на три вида.

Глобальные, определённые в m. Их могут использовать другие модули. В main.o это run и _start, в addvec.o это addvec и addcnt. В Zig такое имя создаёт export.

Внешние: на них m ссылается, а определяет кто-то другой. В main.o это addvec. В Zig такое имя создаёт extern.

Локальные: определены в m и видны только внутри m. В Zig это любая функция и любая переменная верхнего уровня без export. Компоновщик не станет связывать ссылку из другого файла с локальным именем, даже если имена совпадут, поэтому в двух объектниках могут спокойно жить две разные helper.

Не путай локальные имена компоновщика с локальными переменными программы. Переменная sum внутри total это не символ: у неё нет постоянного адреса, она живёт в регистре %rbx, и в объектнике от неё не остаётся ни следа. А вот переменная верхнего уровня без export это символ, просто локальный: у неё есть постоянное место в .data или .bss. В C в ту же категорию попадает static-переменная внутри функции: она объявлена в теле, но живёт в .data, и компилятор даёт ей имя вроде x.1, чтобы две функции с static int x не столкнулись.

Elf64_Sym: двадцать четыре байта на имя

Каждая запись таблицы имён это структура из шести полей. В заголовочном файле elf.h она выглядит так:

typedef struct {
    uint32_t      st_name;   /* смещение имени в .strtab */
    unsigned char st_info;   /* тип (младшие 4 бита) и сила (старшие 4) */
    unsigned char st_other;  /* видимость (младшие 2 бита) */
    uint16_t      st_shndx;  /* номер секции, где имя определено */
    uint64_t      st_value;  /* смещение внутри секции */
    uint64_t      st_size;   /* размер объекта в байтах */
} Elf64_Sym;

Порядок полей выглядит странно: почему value и size в конце, а не рядом с именем? Потому что так структура занимает ровно 24 байта без единого байта выравнивания: четыре, один, один, два, восемь, восемь. В 32-битном ELF порядок другой (name, value, size, info, other, shndx), и при переходе на 64 бита поля переставили именно ради плотной упаковки. Ты видел эту логику в уроке про выравнивание структур.

Прочитаем таблицу имён main.o и сопоставим колонки с полями:

$ readelf -s -W main.o
Symbol table '.symtab' contains 9 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 .bss
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 .data
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 .eh_frame
     6: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND addvec
     7: 0000000000000000    41 FUNC    GLOBAL DEFAULT    1 run
     8: 0000000000000029    14 FUNC    GLOBAL DEFAULT    1 _start
  • Value это st_value. У перемещаемого файла это смещение от начала секции: run лежит в .text с нуля, _start со смещения 0x29. У исполняемого файла в том же поле будет уже настоящий адрес.
  • Size это st_size. run занимает 41 байт, _start занимает 14. Сложи: 55, ровно размер .text. Именно по этому полю дизассемблер и отладчик понимают, где функция кончается.
  • Type это младшая половина st_info. FUNC для кода, OBJECT для данных, NOTYPE для имени, про которое ничего не известно, SECTION и FILE для служебных записей.
  • Bind это старшая половина st_info, сила имени: LOCAL, GLOBAL или WEAK.
  • Vis это st_other. Видимость играет роль только в разделяемых библиотеках, до урока 45 здесь всегда DEFAULT.
  • Ndx это st_shndx: номер секции из оглавления. У run стоит 1, и в оглавлении под номером 1 значится .text.
  • Name это строка из .strtab по смещению st_name.

Запись номер ноль пустая всегда, по той же причине, что и нулевая секция: чтобы ноль мог значить “никакое имя”. Запись 1 с типом FILE хранит имя исходного модуля. Записи со 2 по 5 это символы секций: безымянные записи, по одной на секцию, на которую кто-то ссылается. Они нужны записям перемещения: обращение к локальным данным записывается как “секция .data плюс смещение”, и чтобы сослаться на секцию, ей нужен символ. readelf из вежливости печатает для них имя секции, в самой записи st_name равен нулю.

И обрати внимание на порядок: сначала все LOCAL, потом все GLOBAL. Это требование формата. Номер первого нелокального имени записан в поле Inf секции .symtab: в оглавлении main.o там стоит 6, и запись 6 это действительно первая глобальная, addvec. Компоновщику при разрешении имён локальные записи не нужны, и он пропускает их одним прыжком.

Три псевдосекции

У addvec в колонке Ndx стоит не число, а UND. Секции с таким названием в оглавлении нет. Поле st_shndx может содержать одно из трёх особых значений, которые секциями не являются:

NdxЗначение st_shndxЧто значит
UND0имя не определено в этом файле, на него только ссылаются
ABS0xfff1значение абсолютное, перемещать его не надо
COM0xfff2неинициализированная переменная, которой ещё не выделено место

UND это и есть та запись, по которой ld.lld узнал, чего ему не хватает. UND равен нулю, то есть номеру пустой нулевой секции: вот зачем она была нужна. У такого имени нет ни адреса, ни размера, а тип NOTYPE: компилятор не записал даже, функция это или переменная. Отсюда неприятное следствие. Если в одном файле написать extern fn counter() void, а в другом export var counter: i64, компоновщик молча свяжет одно с другим, и программа прыгнет в данные. Проверять типы через границу объектников некому.

ABS в наших файлах стоит только у записи FILE: имя исходника не лежит ни в какой секции. Изредка так описывают константы, заданные сценарием компоновщика, например адрес аппаратного регистра в прошивке.

COM это наследство C. В C глобальную переменную без начального значения можно объявить в нескольких файлах сразу (int counter; в каждом), и это не ошибка: компоновщик сам выберет одно место для всех. Чтобы он мог это сделать, компилятор не кладёт такую переменную в .bss, а помечает как COMMON: “место выделит компоновщик”. Zig так не делает никогда, у него каждая переменная определена ровно в одном месте. GCC с десятой версии тоже перестал: ключ -fno-common стал умолчанием, потому что молчаливое слияние одноимённых переменных породило слишком много багов. Но старый код и старые библиотеки так собраны, и компоновщик обязан это понимать. Чтобы получить такой символ из Zig, придётся попросить ассемблер:

//! Фикстура компоновщика: символ COMMON.
//!
//! Zig и LLVM сами COMMON не порождают, поэтому он объявлен ассемблерной
//! директивой `.comm`: восемь байт с выравниванием восемь без секции.
//! Компоновщик должен выделить под него место в `.bss`.
//!
//! Собирается так:
//!   zig build-obj fixtures/link/common.zig -O ReleaseSmall \
//!       -target x86_64-linux -femit-bin=fixtures/link/common.o

comptime {
    asm (".comm shared_counter,8,8");
}

export fn bump(counter: *i64) void {
    counter.* += 1;
}
$ readelf -s -W common.o
Symbol table '.symtab' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS common
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 .text
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 .eh_frame
     4: 0000000000000008     8 OBJECT  GLOBAL DEFAULT  COM shared_counter
     5: 0000000000000000     9 FUNC    GLOBAL DEFAULT    2 bump

У shared_counter в колонке Ndx стоит COM, и поле Value у него значит не то, что у всех: это не смещение, а требуемое выравнивание, восемь. Смещения у него быть не может, ведь секции ещё нет.

Остался третий вид силы. Имя можно определить как слабое: “вот определение, но если у кого-то найдётся получше, берите его”.

//! Фикстура компоновщика: слабое определение `addvec`.
//!
//! Рядом с сильным `addvec` из `addvec.o` компоновщик обязан выбрать
//! сильное. Без него берётся это, и оно заполняет результат семёрками:
//! так по коду возврата видно, какое определение попало в бинарник.
//!
//! Собирается так:
//!   zig build-obj fixtures/link/weak.zig -O ReleaseSmall \
//!       -target x86_64-linux -femit-bin=fixtures/link/weak.o

fn weakAddvec(x: [*]const i64, y: [*]const i64, z: [*]i64, n: usize) callconv(.c) void {
    _ = x;
    _ = y;
    var i: usize = 0;
    while (i < n) : (i += 1) z[i] = 7;
}

comptime {
    @export(&weakAddvec, .{ .name = "addvec", .linkage = .weak });
}
$ readelf -s -W weak.o
Symbol table '.symtab' contains 5 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS weak
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 .eh_frame
     4: 0000000000000000    26 FUNC    WEAK   DEFAULT    1 addvec

В колонке Bind стоит WEAK. Что компоновщик делает, когда встречает слабое и сильное определение одного имени, два сильных или сильное и COMMON, это тема следующего урока. Сегодня нам достаточно уметь это прочитать.

nm: та же таблица в одну букву

readelf -s печатает всё. Когда нужен быстрый ответ на вопрос “что этот файл определяет и чего просит”, берут nm:

$ nm main.o
0000000000000029 T _start
                 U addvec
0000000000000000 T run
$ nm addvec.o
0000000000000000 B addcnt
0000000000000000 T addvec
$ nm common.o
0000000000000000 T bump
0000000000000008 C shared_counter
$ nm weak.o
0000000000000000 W addvec

Три колонки: значение, буква, имя. Служебные записи (FILE, SECTION, пустую нулевую) nm не показывает, строки сортирует по имени. Вся соль в букве:

БукваЧто значитКак получается
Tкодсекция с флагом X
Dданные с начальным значениемсекция с флагами WA и байтами в файле
Bданные с нулямисекция с флагами WA типа NOBITS
Rданные только для чтениясекция с флагом A без W и без X
Uимя не определеноNdx равен UND
CCOMMONNdx равен COM
Aабсолютное значениеNdx равен ABS
Wслабое определение функцииBind равен WEAK
Vслабое определение объектаBind равен WEAK, Type равен OBJECT
wслабая ссылка без определенияWEAK и UND разом

Заглавная буква значит глобальное имя, строчная локальное. Ты видел строчные минуту назад в выводе с -fno-strip: t sections.total и b sections.calls.

Правая колонка таблицы это почти готовый алгоритм, и он поучителен. Буква выводится из флагов секции, а не из её имени. Поэтому boot_flag из нашей собственной секции .zt_boot получил честную D: секция записываемая, байты в файле есть. А limits из .rodata.cst32 получил R, хотя секцию зовут не .rodata. nm, написанный через сравнение имён секций, ошибся бы в обоих случаях.

Строчка U addvec из вывода nm main.o и строчка T addvec из вывода nm addvec.o это вся задача разрешения имён в миниатюре. Когда сборка падает с undefined symbol, первым делом запускают nm по всем входным файлам и ищут, у кого это имя стоит с буквой T или D. Обычно оказывается, что ни у кого, или что имя там чуть другое.

Разгляди сам

В виджете ниже два объектника: main.o и swap.o, собранные из маленькой программы на Zig по мотивам примера из книги. Слева оглавление секций, справа таблица имён. Щёлкни по секции и увидишь её байты и имена, которые в ней живут. Щёлкни по имени и увидишь, из какой строки исходника оно взялось, какой у него вид (глобальное, внешнее, локальное), в какой секции лежит и какие записи перемещения на него ссылаются. Кнопка “служебные” показывает то, что nm скрывает.

Попробуй ответить, не заглядывая в подсказки виджета. Почему у swap в main.o секция UND, а в swap.o число? Почему у bufp0 в swap.o есть запись перемещения в .rela.data, хотя это данные, а не код? И почему в таблице swap.o одна и та же функция записана дважды, как swap.swap и как swap, с одним адресом и разной силой?

Заплатки: первое знакомство с .rela.text

Одну секцию мы пока обходили. Вернись к выводу дизассемблера для main.o, каким его печатает objdump -d:

$ objdump -d main.o
main.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <run>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	6a 02                	push   $0x2
   6:	59                   	pop    %rcx
   7:	bf 00 00 00 00       	mov    $0x0,%edi
   c:	be 00 00 00 00       	mov    $0x0,%esi
  11:	ba 00 00 00 00       	mov    $0x0,%edx
  16:	e8 00 00 00 00       	call   1b <run+0x1b>
  1b:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 21 <run+0x21>
  21:	03 05 00 00 00 00    	add    0x0(%rip),%eax        # 27 <run+0x27>
  27:	5d                   	pop    %rbp
  28:	c3                   	ret

0000000000000029 <_start>:
  29:	e8 00 00 00 00       	call   2e <_start+0x5>
  2e:	89 c7                	mov    %eax,%edi
  30:	b8 3c 00 00 00       	mov    $0x3c,%eax
  35:	0f 05                	syscall

Посмотри на байты вызова по смещению 0x16: e8 00 00 00 00. Опкод e8 это call с 32-битным смещением относительно следующей инструкции, и смещение нулевое. Дизассемблер честно считает: следующая инструкция по адресу 0x1b, плюс ноль, получается 0x1b, и пишет call 1b <run+0x1b>. То есть функция run якобы зовёт собственную середину. Это, конечно, неправда. Компилятор не знал, где окажется addvec, и оставил на месте смещения четыре нулевых байта. То же самое с тремя mov $0x0 выше: это адреса массивов x, y и z, которых пока нет, и с двумя 0x0(%rip) ниже.

Правду хранит секция .rela.text:

$ readelf -r -W main.o
Relocation section '.rela.text' at offset 0xe8 contains 7 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000000008  000000040000000a R_X86_64_32            0000000000000000 .data + 10
000000000000000d  000000040000000a R_X86_64_32            0000000000000000 .data + 0
0000000000000012  000000030000000a R_X86_64_32            0000000000000000 .bss + 0
0000000000000017  0000000600000004 R_X86_64_PLT32         0000000000000000 addvec - 4
000000000000001d  0000000300000002 R_X86_64_PC32          0000000000000000 .bss + 4
0000000000000023  0000000300000002 R_X86_64_PC32          0000000000000000 .bss - 4
000000000000002a  0000000700000004 R_X86_64_PLT32         0000000000000000 run - 4

Relocation section '.rela.eh_frame' at offset 0x190 contains 2 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000000020  0000000200000002 R_X86_64_PC32          0000000000000000 .text + 0
0000000000000040  0000000200000002 R_X86_64_PC32          0000000000000000 .text + 29

Каждая строка это запись перемещения, просьба компоновщику: “в секции .text по такому смещению впиши адрес такого имени, посчитанный таким способом”. Четвёртая строка как раз про наш вызов: смещение 0x17 это байт сразу за опкодом e8, имя addvec. Настоящая цель вызова записана здесь, а не в коде. Пятая и шестая строки ссылаются не на имя переменной, а на символ секции: .bss + 4. Это массив z, а точнее “секция .bss этого файла со смещением”. Вот зачем в таблице имён нужны записи типа SECTION.

Теперь ясно, откуда ld.lld знал, что на addvec ссылаются из функции run файла main.o. Запись перемещения назвала имя и смещение 0x17, таблица имён сказала, что смещение 0x17 попадает внутрь run (она занимает байты с 0 по 40), а имя файла лежит в записи FILE.

Что значат R_X86_64_PLT32 и R_X86_64_32, откуда берётся слагаемое - 4 и как из всего этого получается число, которое встанет на место нулей, это целый урок 44. Сегодня мы научим zt эти записи читать и печатать, а считать по ним он начнёт через два урока.

Шаг проекта: zt readelf, zt nm и подписи в zt disasm

Теперь своими руками. В девятом уроке ты написал src/elf.zig на сто двадцать строк: он умел ровно одно, найти секцию по имени, и читал поля заголовка по смещениям вроде 0x28 и 0x3c. Для одной секции это было разумно. Сегодня полей станет полсотни, и читать их по смещениям значит утонуть в магических числах. Читатель переписываем целиком, и план такой:

ФайлЧто в нём
src/elf/format.zigчисла формата и их имена, как их печатает readelf
src/elf/file.zigзаголовок файла, таблица секций, строки
src/elf/symbols.zigтаблица имён, Elf64_Sym
src/elf/relocs.zigзаписи перемещения, Elf64_Rela
src/elf.zigвход в читатель, старая findSection поверх нового кода
src/readelf.zigколонки -h, -S, -s, -r
src/nm.zigбуква и сортировка
src/labels.zigкакому адресу какое имя соответствует
src/disasm.zigразбор всех секций с кодом и подписи

Главное проектное решение одно, и оно определяет весь код: читатель ничего не копирует и ничего не выделяет. Файл целиком уже лежит в памяти одним срезом байтов. Секция это срез того же среза, имя это срез того же среза, а запись таблицы это 24 байта того же среза, прочитанные в структуру. Аллокатор понадобится только тем, кто строит что-то поверх читателя: nm сортирует строки, а дизассемблер собирает список подписей.

Числа и их имена

Начнём с самого скучного файла, потому что на него опираются все остальные. Здесь нет логики: только константы спецификации и функции, превращающие число в слово.

//! Константы формата ELF64 и их имена, как их печатает GNU readelf.
//!
//! Числа взяты из спецификации System V ABI и из `elf.h`. Каждому набору
//! констант положен перечислимый тип с открытым хвостом (`_`): файл может
//! содержать значение, которого мы не знаем, и читатель обязан его пережить,
//! а не упасть.

const std = @import("std");

/// Размеры структур ELF64 в байтах: заголовок файла, заголовок программы,
/// заголовок секции, запись таблицы символов, запись перемещения.
pub const header_size = 64;
pub const program_header_size = 56;
pub const section_header_size = 64;
pub const symbol_size = 24;
pub const rela_size = 24;

/// Первые четыре байта любого ELF: 0x7F и буквы E, L, F.
pub const magic = "\x7fELF";

/// Особые номера секций в поле `st_shndx` символа.
pub const shn_undef: u16 = 0;
pub const shn_abs: u16 = 0xfff1;
pub const shn_common: u16 = 0xfff2;

/// Поле `e_type`: что это за файл.
pub const FileType = enum(u16) {
    none = 0,
    relocatable = 1,
    executable = 2,
    shared = 3,
    core = 4,
    _,

    pub fn name(kind: FileType) []const u8 {
        return switch (kind) {
            .none => "NONE (None)",
            .relocatable => "REL (Relocatable file)",
            .executable => "EXEC (Executable file)",
            .shared => "DYN (Shared object file)",
            .core => "CORE (Core file)",
            _ => "<unknown>",
        };
    }
};

/// Поле `e_machine`. Нас интересует одна машина, остальные печатаем числом.
pub const machine_x86_64: u16 = 62;

pub fn machineName(machine: u16) []const u8 {
    return if (machine == machine_x86_64) "Advanced Micro Devices X86-64" else "<unknown>";
}

/// Поле `sh_type` заголовка секции.
pub const SectionType = enum(u32) {
    null = 0,
    progbits = 1,
    symtab = 2,
    strtab = 3,
    rela = 4,
    hash = 5,
    dynamic = 6,
    note = 7,
    nobits = 8,
    rel = 9,
    dynsym = 11,
    init_array = 14,
    fini_array = 15,
    gnu_hash = 0x6ffffff6,
    verneed = 0x6ffffffe,
    versym = 0x6fffffff,
    x86_64_unwind = 0x70000001,
    _,

    pub fn name(kind: SectionType) []const u8 {
        return switch (kind) {
            .null => "NULL",
            .progbits => "PROGBITS",
            .symtab => "SYMTAB",
            .strtab => "STRTAB",
            .rela => "RELA",
            .hash => "HASH",
            .dynamic => "DYNAMIC",
            .note => "NOTE",
            .nobits => "NOBITS",
            .rel => "REL",
            .dynsym => "DYNSYM",
            .init_array => "INIT_ARRAY",
            .fini_array => "FINI_ARRAY",
            .gnu_hash => "GNU_HASH",
            .verneed => "VERNEED",
            .versym => "VERSYM",
            .x86_64_unwind => "X86_64_UNWIND",
            _ => "<unknown>",
        };
    }
};

/// Биты поля `sh_flags`.
pub const shf_write: u64 = 0x1;
pub const shf_alloc: u64 = 0x2;
pub const shf_execinstr: u64 = 0x4;
pub const shf_merge: u64 = 0x10;
pub const shf_strings: u64 = 0x20;
pub const shf_info_link: u64 = 0x40;
pub const shf_link_order: u64 = 0x80;
pub const shf_os_nonconforming: u64 = 0x100;
pub const shf_group: u64 = 0x200;
pub const shf_tls: u64 = 0x400;

/// Буквы флагов в том порядке, в каком их печатает readelf: `WAXMSILOGT`.
const flag_letters = [_]struct { bit: u64, letter: u8 }{
    .{ .bit = shf_write, .letter = 'W' },
    .{ .bit = shf_alloc, .letter = 'A' },
    .{ .bit = shf_execinstr, .letter = 'X' },
    .{ .bit = shf_merge, .letter = 'M' },
    .{ .bit = shf_strings, .letter = 'S' },
    .{ .bit = shf_info_link, .letter = 'I' },
    .{ .bit = shf_link_order, .letter = 'L' },
    .{ .bit = shf_os_nonconforming, .letter = 'O' },
    .{ .bit = shf_group, .letter = 'G' },
    .{ .bit = shf_tls, .letter = 'T' },
};

/// Записывает буквы флагов секции в буфер и отдаёт заполненную часть.
pub fn sectionFlagLetters(buffer: *[flag_letters.len]u8, flags: u64) []const u8 {
    var length: usize = 0;
    for (flag_letters) |flag| {
        if (flags & flag.bit == 0) continue;
        buffer[length] = flag.letter;
        length += 1;
    }
    return buffer[0..length];
}

/// Младшие четыре бита поля `st_info` символа.
pub const SymbolType = enum(u4) {
    notype = 0,
    object = 1,
    func = 2,
    section = 3,
    file = 4,
    common = 5,
    tls = 6,
    _,

    pub fn name(kind: SymbolType) []const u8 {
        return switch (kind) {
            .notype => "NOTYPE",
            .object => "OBJECT",
            .func => "FUNC",
            .section => "SECTION",
            .file => "FILE",
            .common => "COMMON",
            .tls => "TLS",
            _ => "<unknown>",
        };
    }
};

/// Старшие четыре бита поля `st_info`: сила определения.
pub const SymbolBinding = enum(u4) {
    local = 0,
    global = 1,
    weak = 2,
    _,

    pub fn name(binding: SymbolBinding) []const u8 {
        return switch (binding) {
            .local => "LOCAL",
            .global => "GLOBAL",
            .weak => "WEAK",
            _ => "<unknown>",
        };
    }
};

/// Младшие два бита поля `st_other`: видимость символа между модулями.
pub const SymbolVisibility = enum(u2) {
    default = 0,
    internal = 1,
    hidden = 2,
    protected = 3,

    pub fn name(visibility: SymbolVisibility) []const u8 {
        return switch (visibility) {
            .default => "DEFAULT",
            .internal => "INTERNAL",
            .hidden => "HIDDEN",
            .protected => "PROTECTED",
        };
    }
};

/// Типы перемещений x86-64 из поля `r_info`. Здесь только те, что
/// встречаются в наших объектниках и исполняемых файлах.
pub const RelocationType = enum(u32) {
    none = 0,
    abs64 = 1,
    pc32 = 2,
    got32 = 3,
    plt32 = 4,
    copy = 5,
    glob_dat = 6,
    jump_slot = 7,
    relative = 8,
    gotpcrel = 9,
    abs32 = 10,
    abs32s = 11,
    gotpcrelx = 41,
    rex_gotpcrelx = 42,
    _,

    pub fn name(kind: RelocationType) []const u8 {
        return switch (kind) {
            .none => "R_X86_64_NONE",
            .abs64 => "R_X86_64_64",
            .pc32 => "R_X86_64_PC32",
            .got32 => "R_X86_64_GOT32",
            .plt32 => "R_X86_64_PLT32",
            .copy => "R_X86_64_COPY",
            .glob_dat => "R_X86_64_GLOB_DAT",
            .jump_slot => "R_X86_64_JUMP_SLOT",
            .relative => "R_X86_64_RELATIVE",
            .gotpcrel => "R_X86_64_GOTPCREL",
            .abs32 => "R_X86_64_32",
            .abs32s => "R_X86_64_32S",
            .gotpcrelx => "R_X86_64_GOTPCRELX",
            .rex_gotpcrelx => "R_X86_64_REX_GOTPCRELX",
            _ => "<unknown>",
        };
    }
};

/// Поле `p_type` заголовка программы и биты `p_flags`.
pub const SegmentType = enum(u32) {
    null = 0,
    load = 1,
    dynamic = 2,
    interp = 3,
    note = 4,
    phdr = 6,
    tls = 7,
    gnu_eh_frame = 0x6474e550,
    gnu_stack = 0x6474e551,
    gnu_relro = 0x6474e552,
    _,

    pub fn name(kind: SegmentType) []const u8 {
        return switch (kind) {
            .null => "NULL",
            .load => "LOAD",
            .dynamic => "DYNAMIC",
            .interp => "INTERP",
            .note => "NOTE",
            .phdr => "PHDR",
            .tls => "TLS",
            .gnu_eh_frame => "GNU_EH_FRAME",
            .gnu_stack => "GNU_STACK",
            .gnu_relro => "GNU_RELRO",
            _ => "<unknown>",
        };
    }
};

pub const pf_x: u32 = 0x1;
pub const pf_w: u32 = 0x2;
pub const pf_r: u32 = 0x4;

test "имена повторяют readelf" {
    try std.testing.expectEqualStrings("REL (Relocatable file)", FileType.relocatable.name());
    try std.testing.expectEqualStrings("X86_64_UNWIND", SectionType.x86_64_unwind.name());
    try std.testing.expectEqualStrings("R_X86_64_PLT32", RelocationType.plt32.name());
    try std.testing.expectEqualStrings("<unknown>", @as(SectionType, @enumFromInt(0x12345)).name());
}

test "буквы флагов идут в порядке readelf" {
    var buffer: [flag_letters.len]u8 = undefined;
    try std.testing.expectEqualStrings("AX", sectionFlagLetters(&buffer, shf_alloc | shf_execinstr));
    try std.testing.expectEqualStrings("WA", sectionFlagLetters(&buffer, shf_write | shf_alloc));
    try std.testing.expectEqualStrings("", sectionFlagLetters(&buffer, 0));
}

Два приёма Zig здесь работают на нас. Первый это перечисление с открытым хвостом, _ в конце enum. Обычный enum считает любое незнакомое значение ошибкой, и @enumFromInt на нём в безопасной сборке паникует. Файл с диска это чужие данные, и в нём может стоять что угодно: тип секции, который придумали после выхода нашей программы, или просто мусор. Открытый хвост разрешает перечислению хранить любое число своего базового типа, а switch заставляет нас написать ветку _ => и решить, что делать с незнакомцем. Мы печатаем <unknown> и идём дальше.

Второй приём это ширина базового типа как документация. SymbolType объявлен как enum(u4), потому что в st_info типу отведено четыре бита, а SymbolVisibility как enum(u2) без открытого хвоста: в двух битах помещаются четыре значения, и все четыре описаны спецификацией. Ошибиться шириной не даст компилятор.

В таблице типов перемещений и в SegmentType есть имена, которые в этом уроке не встретятся: JUMP_SLOT, GLOB_DAT, PT_LOAD. Они понадобятся в уроках 44 и 45, а таблицу имён удобнее завести один раз.

Заголовок и секции

//! Читатель ELF64: заголовок файла, таблица секций, строки.
//!
//! Структуры ELF описаны в спецификации как структуры C с фиксированным
//! расположением полей. В Zig это `extern struct`: порядок и размеры полей
//! гарантированы, поэтому кусок файла превращается в структуру одним
//! `bytesToValue`, без разбора по смещениям.
//!
//! Читатель ничего не копирует и не выделяет: `File` это срез байтов файла
//! плюс заголовок, а секции, символы и строки достаются срезами того же файла.

const std = @import("std");
const builtin = @import("builtin");

pub const format = @import("format.zig");

pub const Error = error{
    NotElf,
    NotElf64,
    NotLittleEndian,
    Truncated,
    SectionNotFound,
};

/// Заголовок файла, `Elf64_Ehdr`.
pub const Header = extern struct {
    ident: [16]u8,
    type: u16,
    machine: u16,
    version: u32,
    entry: u64,
    phoff: u64,
    shoff: u64,
    flags: u32,
    ehsize: u16,
    phentsize: u16,
    phnum: u16,
    shentsize: u16,
    shnum: u16,
    shstrndx: u16,
};

/// Заголовок секции, `Elf64_Shdr`.
pub const SectionHeader = extern struct {
    name: u32,
    type: u32,
    flags: u64,
    addr: u64,
    offset: u64,
    size: u64,
    link: u32,
    info: u32,
    addralign: u64,
    entsize: u64,

    pub fn kind(header: SectionHeader) format.SectionType {
        return @enumFromInt(header.type);
    }
};

comptime {
    // Если размеры разойдутся со спецификацией, узнаем об этом при сборке.
    std.debug.assert(@sizeOf(Header) == format.header_size);
    std.debug.assert(@sizeOf(SectionHeader) == format.section_header_size);
}

/// Читает структуру `T` по смещению в файле. ELF x86-64 всегда little endian,
/// и на машине с другим порядком байтов поля надо перевернуть.
pub fn readStruct(comptime T: type, bytes: []const u8, offset: u64) Error!T {
    if (offset > bytes.len or bytes.len - offset < @sizeOf(T)) return error.Truncated;
    var value = std.mem.bytesToValue(T, bytes[@intCast(offset)..][0..@sizeOf(T)]);
    if (builtin.cpu.arch.endian() != .little) std.mem.byteSwapAllFields(T, &value);
    return value;
}

/// Строка в таблице строк: байты от смещения до нулевого байта.
pub fn stringAt(strings: []const u8, offset: u32) []const u8 {
    if (offset >= strings.len) return "";
    return std.mem.sliceTo(strings[offset..], 0);
}

pub const File = struct {
    bytes: []const u8,
    header: Header,

    pub fn parse(bytes: []const u8) Error!File {
        if (!std.mem.startsWith(u8, bytes, format.magic)) return error.NotElf;
        if (bytes.len < format.header_size) return error.Truncated;
        // e_ident[EI_CLASS] == 2 значит 64 бита, e_ident[EI_DATA] == 1 значит little endian.
        if (bytes[4] != 2) return error.NotElf64;
        if (bytes[5] != 1) return error.NotLittleEndian;
        return .{ .bytes = bytes, .header = try readStruct(Header, bytes, 0) };
    }

    pub fn sectionCount(file: File) u16 {
        return file.header.shnum;
    }

    /// Заголовок секции с данным номером.
    pub fn section(file: File, index: u32) Error!SectionHeader {
        if (index >= file.header.shnum) return error.Truncated;
        const offset = file.header.shoff + @as(u64, index) * file.header.shentsize;
        return readStruct(SectionHeader, file.bytes, offset);
    }

    /// Байты секции как срез файла. У `.bss` байтов в файле нет вовсе.
    pub fn sectionData(file: File, header: SectionHeader) Error![]const u8 {
        if (header.kind() == .nobits) return &.{};
        if (header.offset > file.bytes.len or file.bytes.len - header.offset < header.size)
            return error.Truncated;
        return file.bytes[@intCast(header.offset)..@intCast(header.offset + header.size)];
    }

    /// Имена секций лежат в отдельной секции строк, её номер стоит в заголовке.
    pub fn sectionName(file: File, header: SectionHeader) Error![]const u8 {
        const names = try file.sectionData(try file.section(file.header.shstrndx));
        return stringAt(names, header.name);
    }

    /// Номер первой секции с таким именем.
    pub fn findSection(file: File, name: []const u8) Error!u32 {
        var index: u32 = 0;
        while (index < file.header.shnum) : (index += 1) {
            const header = try file.section(index);
            if (std.mem.eql(u8, try file.sectionName(header), name)) return index;
        }
        return error.SectionNotFound;
    }

    /// Номер первой секции данного типа: так ищут `.symtab` и `.dynamic`,
    /// потому что имя секции это подсказка человеку, а тип это закон.
    pub fn findSectionByType(file: File, kind: format.SectionType) Error!u32 {
        var index: u32 = 0;
        while (index < file.header.shnum) : (index += 1) {
            if ((try file.section(index)).kind() == kind) return index;
        }
        return error.SectionNotFound;
    }
};

test "мусор вместо ELF отвергается" {
    try std.testing.expectError(error.NotElf, File.parse("не elf вовсе, но длинный" ** 4));
    try std.testing.expectError(error.Truncated, File.parse("\x7fELF"));
    try std.testing.expectError(error.NotElf64, File.parse("\x7fELF\x01\x01" ++ "\x00" ** 58));
}

Вся хитрость файла в двух словах: extern struct. У обычной структуры Zig порядок полей в памяти не определён, компилятор вправе переставить их ради плотной упаковки, как ты видел в четвёртом уроке. extern struct обещает раскладку по правилам C: поля в порядке объявления, каждое на своём выравнивании. Спецификация ELF описывает свои структуры именно как структуры C, поэтому Header в нашем коде и Elf64_Ehdr в файле совпадают байт в байт, и std.mem.bytesToValue превращает 64 байта файла в структуру одним копированием.

Блок comptime с двумя assert это страховка. Если ты опечатаешься в типе поля и напишешь u32 вместо u64, размер структуры разойдётся со спецификацией, и сборка упадёт сразу, а не выдаст мусор на третьей секции.

Обрати внимание, как написана проверка границ в readStruct: offset > bytes.len or bytes.len - offset < @sizeOf(T). Привычное offset + size > bytes.len здесь не годится. Смещение приходит из файла, это 64-битное число, и злонамеренный файл может записать туда 0xffffffffffffffff. Сумма переполнится, в безопасной сборке Zig это паника, а в ReleaseFast проверка молча пройдёт. Вычитание после сравнения переполниться не может. Читатель формата это всегда граница доверия: всё, что пришло из файла, считается враждебным, пока не проверено.

sectionData для NOBITS отдаёт пустой срез, и это ровно тот случай с .bss, который мы разбирали: размер есть, байтов нет. Без этой ветки читатель отдал бы под видом .bss шестнадцать байтов чужой секции.

Таблица имён

//! Таблица символов: секция `.symtab` у объектника и `.dynsym` у того,
//! что компонуется динамически. Формат у них один, `Elf64_Sym`.

const std = @import("std");

const file_mod = @import("file.zig");
const format = @import("format.zig");

const File = file_mod.File;

/// Запись таблицы символов, `Elf64_Sym`.
pub const Symbol = extern struct {
    name: u32,
    /// Старшие четыре бита это сила определения, младшие это тип.
    info: u8,
    other: u8,
    /// Номер секции, в которой символ определён, или особое значение:
    /// `shn_undef`, `shn_abs`, `shn_common`.
    shndx: u16,
    /// У объектника это смещение внутри секции, у исполняемого файла адрес.
    value: u64,
    size: u64,

    pub fn kind(symbol: Symbol) format.SymbolType {
        return @enumFromInt(@as(u4, @truncate(symbol.info)));
    }

    pub fn binding(symbol: Symbol) format.SymbolBinding {
        return @enumFromInt(@as(u4, @truncate(symbol.info >> 4)));
    }

    pub fn visibility(symbol: Symbol) format.SymbolVisibility {
        return @enumFromInt(@as(u2, @truncate(symbol.other)));
    }

    pub fn isUndefined(symbol: Symbol) bool {
        return symbol.shndx == format.shn_undef;
    }
};

comptime {
    std.debug.assert(@sizeOf(Symbol) == format.symbol_size);
}

pub const SymbolTable = struct {
    /// Имя секции, из которой таблица прочитана: `.symtab` или `.dynsym`.
    section_name: []const u8,
    entries: []const u8,
    /// Таблица строк с именами. Её номер лежит в поле `sh_link` секции символов.
    strings: []const u8,
    /// Номер первого нелокального символа, поле `sh_info`: локальные символы
    /// всегда идут в таблице первыми.
    first_global: u32,

    /// Таблица символов данного типа: `.symtab` или `.dynsym`.
    pub fn find(file: File, kind: format.SectionType) file_mod.Error!SymbolTable {
        const header = try file.section(try file.findSectionByType(kind));
        return .{
            .section_name = try file.sectionName(header),
            .entries = try file.sectionData(header),
            .strings = try file.sectionData(try file.section(header.link)),
            .first_global = header.info,
        };
    }

    pub fn count(table: SymbolTable) u32 {
        return @intCast(table.entries.len / format.symbol_size);
    }

    pub fn get(table: SymbolTable, index: u32) file_mod.Error!Symbol {
        return file_mod.readStruct(Symbol, table.entries, @as(u64, index) * format.symbol_size);
    }

    pub fn name(table: SymbolTable, symbol: Symbol) []const u8 {
        return file_mod.stringAt(table.strings, symbol.name);
    }
};

Методы kind и binding распаковывают st_info: @truncate до u4 оставляет младшие четыре бита, сдвиг на четыре вправо достаёт старшие. SymbolTable.find ищет секцию по типу, а не по имени, и берёт строки из секции, на которую указывает поле link: ровно та связь Lk, которую мы видели в оглавлении. Благодаря параметру kind тот же код в уроке 45 прочитает .dynsym, таблицу имён разделяемой библиотеки: формат у неё тот же.

Записи перемещения

//! Записи перемещений, `Elf64_Rela`. Каждая запись это просьба компоновщику:
//! «по такому смещению впиши адрес такого символа, посчитанный так-то».

const std = @import("std");

const file_mod = @import("file.zig");
const format = @import("format.zig");

/// Запись перемещения с явным слагаемым, `Elf64_Rela`.
pub const Rela = extern struct {
    /// Куда писать: смещение внутри секции у объектника, адрес у готового файла.
    offset: u64,
    /// Старшие 32 бита это номер символа, младшие это тип перемещения.
    info: u64,
    addend: i64,

    pub fn symbolIndex(rela: Rela) u32 {
        return @intCast(rela.info >> 32);
    }

    pub fn kind(rela: Rela) format.RelocationType {
        return @enumFromInt(@as(u32, @truncate(rela.info)));
    }
};

comptime {
    std.debug.assert(@sizeOf(Rela) == format.rela_size);
}

pub fn count(entries: []const u8) u32 {
    return @intCast(entries.len / format.rela_size);
}

pub fn get(entries: []const u8, index: u32) file_mod.Error!Rela {
    return file_mod.readStruct(Rela, entries, @as(u64, index) * format.rela_size);
}

Поле info упаковано так же, как st_info, только крупнее: старшие 32 бита это номер записи в таблице имён, младшие 32 это тип. Посмотри на колонку Info в выводе readelf -r: 0000000600000004 это имя номер 6 (addvec) и тип 4 (PLT32).

Вход в читатель

//! Вход в читатель ELF64. Сам разбор живёт в каталоге `elf/`:
//! `format.zig` знает числа и их имена, `file.zig` читает заголовок и секции,
//! `symbols.zig` таблицу символов, `relocs.zig` записи перемещений.

const std = @import("std");

pub const format = @import("elf/format.zig");
pub const relocs = @import("elf/relocs.zig");

const file_mod = @import("elf/file.zig");
const symbols_mod = @import("elf/symbols.zig");

pub const Error = file_mod.Error;
pub const File = file_mod.File;
pub const Header = file_mod.Header;
pub const SectionHeader = file_mod.SectionHeader;
pub const Symbol = symbols_mod.Symbol;
pub const SymbolTable = symbols_mod.SymbolTable;
pub const Rela = relocs.Rela;
pub const readStruct = file_mod.readStruct;
pub const stringAt = file_mod.stringAt;

pub const Section = struct {
    name: []const u8,
    /// Адрес, по которому секция окажется в памяти. У объектника до
    /// компоновки он нулевой, и адреса дизассемблера начинаются с нуля.
    address: u64,
    data: []const u8,
};

/// Короткая дорога для того, кому нужна одна секция: ищет её по имени
/// и отдаёт байты как срез входного файла.
pub fn findSection(bytes: []const u8, name: []const u8) Error!Section {
    const file = try File.parse(bytes);
    const header = try file.section(try file.findSection(name));
    return .{
        .name = try file.sectionName(header),
        .address = header.addr,
        .data = try file.sectionData(header),
    };
}

test {
    std.testing.refAllDecls(@This());
    _ = file_mod;
    _ = symbols_mod;
}

Старая findSection осталась с той же сигнатурой, но теперь это пять строк поверх File. Тесты шагов с 9 по 12 зовут именно её, и они обязаны остаться зелёными: так ты узнаешь, что новый читатель понимает файлы не хуже старого.

Колонки readelf

//! `zt readelf`: заголовок файла, таблица секций, таблица символов и
//! перемещения в том же виде, в каком их печатает `readelf` из LLVM.
//!
//! Весь разбор уже сделан в `elf/`, здесь только колонки. Ширины колонок и
//! пустые строки между блоками подобраны так, чтобы `diff` с выводом
//! `llvm-readelf` был пустым. Блоки символов и перемещений начинаются с
//! пустой строки, даже когда печатаются одни: так делает оригинал.

const std = @import("std");

const elf = @import("elf.zig");

const format = elf.format;
const Writer = std.Io.Writer;

/// Флаг `-h`: заголовок файла.
pub fn printHeader(out: *Writer, file: elf.File) !void {
    const header = file.header;
    try out.writeAll("ELF Header:\n  Magic:  ");
    for (header.ident) |byte| try out.print(" {x:0>2}", .{byte});
    try out.writeByte('\n');

    try out.writeAll("  Class:                             ELF64\n");
    try out.writeAll("  Data:                              2's complement, little endian\n");
    try out.writeAll("  Version:                           1 (current)\n");
    try out.writeAll("  OS/ABI:                            UNIX - System V\n");
    try out.print("  ABI Version:                       {d}\n", .{header.ident[8]});
    const kind: format.FileType = @enumFromInt(header.type);
    try out.print("  Type:                              {s}\n", .{kind.name()});
    try out.print("  Machine:                           {s}\n", .{format.machineName(header.machine)});
    try out.print("  Version:                           0x{x}\n", .{header.version});
    try out.print("  Entry point address:               0x{x}\n", .{header.entry});
    try out.print("  Start of program headers:          {d} (bytes into file)\n", .{header.phoff});
    try out.print("  Start of section headers:          {d} (bytes into file)\n", .{header.shoff});
    try out.print("  Flags:                             0x{x}\n", .{header.flags});
    try out.print("  Size of this header:               {d} (bytes)\n", .{header.ehsize});
    try out.print("  Size of program headers:           {d} (bytes)\n", .{header.phentsize});
    try out.print("  Number of program headers:         {d}\n", .{header.phnum});
    try out.print("  Size of section headers:           {d} (bytes)\n", .{header.shentsize});
    try out.print("  Number of section headers:         {d}\n", .{header.shnum});
    try out.print("  Section header string table index: {d}\n", .{header.shstrndx});
}

/// Флаг `-S`: таблица секций.
pub fn printSections(out: *Writer, file: elf.File) !void {
    try out.print("There are {d} section headers, starting at offset 0x{x}:\n\n", .{
        file.sectionCount(),
        file.header.shoff,
    });
    try out.writeAll("Section Headers:\n");
    try out.writeAll("  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al\n");

    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        const name = try file.sectionName(header);
        const kind = header.kind().name();
        var letters: [10]u8 = undefined;

        // Имя и тип делят одну полосу в 33 знака: длинное имя, вроде
        // `.rela.debug_pubnames`, отъедает место у колонки типа.
        try out.print("  [{d: >2}] {s: <17} {s}", .{ index, name, kind });
        const used = @max(name.len, 17) + 1 + kind.len;
        if (used < 33) try out.splatByteAll(' ', 33 - used);

        try out.print(" {x:0>16} {x:0>6} {x:0>6} {x:0>2} {s: >3} {d: >2} {d: >3} {d: >2}\n", .{
            header.addr,
            header.offset,
            header.size,
            header.entsize,
            format.sectionFlagLetters(&letters, header.flags),
            header.link,
            header.info,
            header.addralign,
        });
    }

    try out.writeAll(
        \\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),
        \\  R (retain), l (large), p (processor specific)
        \\
    );
}

/// Флаг `-s`: таблица символов, `.symtab` у объектника.
pub fn printSymbols(out: *Writer, file: elf.File) !void {
    const table = try elf.SymbolTable.find(file, .symtab);
    try out.print("\nSymbol table '{s}' contains {d} entries:\n", .{ table.section_name, table.count() });
    try out.writeAll("   Num:    Value          Size Type    Bind   Vis       Ndx Name\n");

    var index: u32 = 0;
    while (index < table.count()) : (index += 1) {
        const symbol = try table.get(index);
        var number: [8]u8 = undefined;
        try out.print("{d: >6}: {x:0>16} {d: >5} {s: <7} {s: <6} {s: <8} {s: >4} {s}\n", .{
            index,
            symbol.value,
            symbol.size,
            symbol.kind().name(),
            symbol.binding().name(),
            symbol.visibility().name(),
            sectionIndexName(&number, symbol.shndx),
            try symbolName(file, table, symbol),
        });
    }
}

/// Флаг `-r`: перемещения всех секций типа RELA.
pub fn printRelocations(out: *Writer, file: elf.File) !void {
    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (header.kind() == .rela) try printRelocationSection(out, file, header);
    }
}

fn printRelocationSection(out: *Writer, file: elf.File, header: elf.SectionHeader) !void {
    const entries = try file.sectionData(header);
    try out.print("\nRelocation section '{s}' at offset 0x{x} contains {d} entries:\n", .{
        try file.sectionName(header),
        header.offset,
        elf.relocs.count(entries),
    });
    try out.writeAll("    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend\n");

    // Поле sh_link секции перемещений это номер её таблицы символов.
    const symbols_header = try file.section(header.link);
    const table: elf.SymbolTable = .{
        .section_name = try file.sectionName(symbols_header),
        .entries = try file.sectionData(symbols_header),
        .strings = try file.sectionData(try file.section(symbols_header.link)),
        .first_global = symbols_header.info,
    };

    var index: u32 = 0;
    while (index < elf.relocs.count(entries)) : (index += 1) {
        const rela = try elf.relocs.get(entries, index);
        const symbol = try table.get(rela.symbolIndex());
        try out.print("{x:0>16}  {x:0>16} {s: <22} {x:0>16} {s} {c} {x}\n", .{
            rela.offset,
            rela.info,
            rela.kind().name(),
            symbol.value,
            try symbolName(file, table, symbol),
            @as(u8, if (rela.addend < 0) '-' else '+'),
            @abs(rela.addend),
        });
    }
}

/// У символа типа SECTION своего имени нет: readelf подставляет имя секции.
pub fn symbolName(file: elf.File, table: elf.SymbolTable, symbol: elf.Symbol) ![]const u8 {
    if (symbol.kind() == .section and symbol.name == 0)
        return file.sectionName(try file.section(symbol.shndx));
    return table.name(symbol);
}

/// Колонка Ndx: номер секции или одно из трёх особых значений.
fn sectionIndexName(buffer: *[8]u8, shndx: u16) []const u8 {
    return switch (shndx) {
        format.shn_undef => "UND",
        format.shn_abs => "ABS",
        format.shn_common => "COM",
        else => std.fmt.bufPrint(buffer, "{d}", .{shndx}) catch unreachable,
    };
}

Здесь нет ни одной мысли про ELF, только ширины колонок, и подобраны они не на глаз. Эталоном служит llvm-readelf, а не GNU readelf, по той же причине, по которой эталоном дизассемблера с девятого урока служит objdump из LLVM: он одинаковый на Linux и на macOS. Два readelf печатают одни и те же данные чуть по-разному. У LLVM колонка Ndx на знак шире, в легенде флагов стоит R (retain) вместо D (mbind), а в заголовке перемещений всегда entries, даже когда запись одна. Вывод в теоретической части урока снят GNU readelf из binutils 2.40, и там эти отличия видны.

Одна тонкость стоит внимания: symbolName. У символа типа SECTION поле st_name нулевое, имени у него нет. Но человеку строка .bss + 4 говорит больше, чем пустота плюс четыре, и оба readelf подставляют имя секции, на которую символ указывает. Мы делаем так же.

Буква nm

//! `zt nm`: имена из таблицы символов, по одной строке на имя.
//!
//! Строка это адрес, буква и имя. Буква отвечает на вопрос «где это лежит»:
//! `T` код, `D` данные, `B` обнулённые данные, `R` константы, `U` имя, которое
//! файл просит у других, `C` символ COMMON, `W` слабое определение. Строчная
//! буква значит локальный символ, которого компоновщик другим файлам не покажет.

const std = @import("std");

const elf = @import("elf.zig");

const format = elf.format;

const Line = struct {
    name: []const u8,
    value: u64,
    letter: u8,

    /// nm сортирует по имени, а одинаковые имена по адресу.
    fn lessThan(_: void, a: Line, b: Line) bool {
        return switch (std.mem.order(u8, a.name, b.name)) {
            .lt => true,
            .gt => false,
            .eq => a.value < b.value,
        };
    }
};

pub fn print(gpa: std.mem.Allocator, out: *std.Io.Writer, file: elf.File) !void {
    const table = try elf.SymbolTable.find(file, .symtab);

    var lines: std.ArrayList(Line) = .empty;
    defer lines.deinit(gpa);

    // Нулевая запись таблицы всегда пустая, с неё начинать незачем.
    var index: u32 = 1;
    while (index < table.count()) : (index += 1) {
        const symbol = try table.get(index);
        // Имя файла и символы секций нужны отладчику, а не человеку с nm.
        if (symbol.kind() == .file or symbol.kind() == .section) continue;
        try lines.append(gpa, .{
            .name = table.name(symbol),
            .value = symbol.value,
            .letter = try letter(file, symbol),
        });
    }

    std.mem.sort(Line, lines.items, {}, Line.lessThan);

    for (lines.items) |line| {
        if (line.letter == 'U' or line.letter == 'w') {
            // У неопределённого символа адреса нет: колонка остаётся пустой.
            try out.splatByteAll(' ', 16);
        } else {
            try out.print("{x:0>16}", .{line.value});
        }
        try out.print(" {c} {s}\n", .{ line.letter, line.name });
    }
}

/// Буква символа. Сначала особые случаи, потом секция, в которой он лежит.
pub fn letter(file: elf.File, symbol: elf.Symbol) !u8 {
    const weak = symbol.binding() == .weak;
    if (symbol.isUndefined()) return if (weak) 'w' else 'U';
    if (symbol.shndx == format.shn_common) return 'C';
    if (weak) return if (symbol.kind() == .object) 'V' else 'W';

    const upper: u8 = if (symbol.shndx == format.shn_abs) 'A' else try sectionLetter(file, symbol.shndx);
    return if (symbol.binding() == .local) std.ascii.toLower(upper) else upper;
}

/// Букву определяют флаги секции, а не её имя: исполняемая это код,
/// записываемая это данные, без байтов в файле это `.bss`.
fn sectionLetter(file: elf.File, shndx: u16) !u8 {
    const header = try file.section(shndx);
    if (header.flags & format.shf_alloc == 0) return 'N';
    if (header.flags & format.shf_execinstr != 0) return 'T';
    if (header.flags & format.shf_write == 0) return 'R';
    return if (header.kind() == .nobits) 'B' else 'D';
}

Функция letter это таблица из теоретической части, записанная кодом: сначала особые случаи по st_shndx и силе, потом флаги секции. Порядок проверок важен. Слабое неопределённое имя должно получить w, а не U, поэтому слабость проверяется внутри ветки isUndefined. COMMON проверяется раньше слабости, потому что у COMMON своя буква при любой силе.

Здесь впервые в шаге нужен аллокатор: строки надо собрать в список, чтобы отсортировать.

Подписи для дизассемблера

С урока 14 zt disasm умеет печатать перед функцией 0000000000000029 <_start>: и подписывать цель перехода как <run+0x1b>, но таблицу имён ему приходилось передавать снаружи, выводом nm через флаг --syms. Теперь он читает её сам. Модуль подписей получает второй конструктор, collect: имена функций он берёт из .symtab, а там, где у начала секции имени нет, подставляет имя самой секции. Файл целиком:

//! Подписи для дизассемблера: какому адресу какое имя соответствует.
//!
//! Источников два. Таблица символов даёт имена функций. Секция без символа
//! в начале подписывается своим именем, как `<.text>`.
//!
//! Таблицу символов можно передать и снаружи, готовым выводом `nm`: так
//! дизассемблер подписывал вызовы, пока не умел читать ELF сам.

const std = @import("std");

const elf = @import("elf.zig");

pub const Label = struct {
    section: u32,
    address: u64,
    name: []const u8,
    /// Чем больше, тем охотнее берём имя, когда на одном адресе их несколько:
    /// глобальный символ важнее локального, а тот важнее имени секции.
    rank: u8,

    fn lessThan(_: void, a: Label, b: Label) bool {
        if (a.section != b.section) return a.section < b.section;
        if (a.address != b.address) return a.address < b.address;
        return a.rank > b.rank;
    }
};

pub const Found = struct { name: []const u8, offset: u64 };

/// Имя функции и её адрес, как их печатает `nm`: `000000000000001f T ztFib`.
pub const Symbol = struct {
    name: []const u8,
    address: u64,
    /// Большая буква у `nm`: имя видно из других файлов.
    global: bool,
};

/// Разбирает вывод `nm`. Берёт только код, буквы `T` и `t`: у неопределённых
/// имён адреса нет, а данные дизассемблеру не нужны. Строки указывают в `text`.
pub fn parseNm(gpa: std.mem.Allocator, text: []const u8) ![]Symbol {
    var list: std.ArrayList(Symbol) = .empty;
    errdefer list.deinit(gpa);

    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        // У неопределённого имени колонка адреса пустая, и полей будет два.
        var fields = std.mem.tokenizeScalar(u8, line, ' ');
        const address = fields.next() orelse continue;
        const letter = fields.next() orelse continue;
        const name = fields.next() orelse continue;
        if (letter.len != 1 or std.ascii.toLower(letter[0]) != 't') continue;
        try list.append(gpa, .{
            .name = name,
            .address = try std.fmt.parseInt(u64, address, 16),
            .global = letter[0] == 'T',
        });
    }
    return list.toOwnedSlice(gpa);
}

/// Секция с машинным кодом: у неё есть байты в файле и флаг исполнения.
pub fn isCode(header: elf.SectionHeader) bool {
    return header.kind() == .progbits and header.flags & elf.format.shf_execinstr != 0;
}

pub const Labels = struct {
    arena: std.heap.ArenaAllocator,
    /// Файл, из которого собраны подписи. Нет его, когда список пришёл снаружи.
    file: ?elf.File,
    /// Отсортированы по секции, адресу и убыванию ранга.
    code: []const Label,

    pub fn collect(gpa: std.mem.Allocator, file: elf.File) !Labels {
        var arena: std.heap.ArenaAllocator = .init(gpa);
        errdefer arena.deinit();
        const allocator = arena.allocator();

        var list: std.ArrayList(Label) = .empty;

        var index: u32 = 0;
        while (index < file.sectionCount()) : (index += 1) {
            const header = try file.section(index);
            if (!isCode(header)) continue;
            try list.append(allocator, .{
                .section = index,
                .address = header.addr,
                .name = try file.sectionName(header),
                .rank = 0,
            });
        }

        // Таблицы символов может и не быть: её срезает `strip`.
        if (elf.SymbolTable.find(file, .symtab)) |table| {
            var number: u32 = 1;
            while (number < table.count()) : (number += 1) {
                const symbol = try table.get(number);
                if (symbol.kind() != .func and symbol.kind() != .notype) continue;
                if (symbol.shndx >= file.sectionCount() or table.name(symbol).len == 0) continue;
                if (!isCode(try file.section(symbol.shndx))) continue;
                try list.append(allocator, .{
                    .section = symbol.shndx,
                    .address = symbol.value,
                    .name = table.name(symbol),
                    .rank = if (symbol.binding() == .local) 1 else 2,
                });
            }
        } else |err| if (err != error.SectionNotFound) return err;

        std.mem.sort(Label, list.items, {}, Label.lessThan);
        return .{ .arena = arena, .file = file, .code = list.items };
    }

    /// Подписи из готового списка, без файла. Весь код считается одной
    /// секцией с номером 0: у куска байтов других секций и нет.
    pub fn fromSymbols(gpa: std.mem.Allocator, symbols: []const Symbol) !Labels {
        var arena: std.heap.ArenaAllocator = .init(gpa);
        errdefer arena.deinit();

        const list = try arena.allocator().alloc(Label, symbols.len);
        for (symbols, list) |symbol, *label| label.* = .{
            .section = 0,
            .address = symbol.address,
            .name = symbol.name,
            .rank = if (symbol.global) 2 else 1,
        };
        std.mem.sort(Label, list, {}, Label.lessThan);
        return .{ .arena = arena, .file = null, .code = list };
    }

    pub fn deinit(labels: *Labels) void {
        labels.arena.deinit();
    }

    /// Имя, стоящее ровно на этом адресе: с него начинается функция.
    pub fn exact(labels: Labels, section: u32, address: u64) ?[]const u8 {
        for (labels.code) |label| {
            if (label.section == section and label.address == address) return label.name;
        }
        return null;
    }

    /// Адрес следующей подписи в секции после данного адреса.
    pub fn nextAfter(labels: Labels, section: u32, address: u64) ?u64 {
        for (labels.code) |label| {
            if (label.section == section and label.address > address) return label.address;
        }
        return null;
    }

    /// Ближайшее имя не правее адреса и расстояние до него.
    /// `from_section` это секция, из которой на адрес ссылаются.
    pub fn nearest(labels: Labels, from_section: u32, address: u64) ?Found {
        // У объектника все секции начинаются с нуля, и адрес понятен только
        // внутри своей секции. У готового файла секцию находим по адресу.
        // Список снаружи это одна секция, её номер уже передан.
        const executable = if (labels.file) |file| file.header.type != @intFromEnum(elf.format.FileType.relocatable) else false;
        const section = if (executable)
            (sectionOf(labels.file.?, address) catch null) orelse return null
        else
            from_section;

        var best: ?Found = null;
        for (labels.code) |label| {
            if (label.section != section or label.address > address) continue;
            // При равных адресах первым в списке стоит имя с большим рангом.
            if (best != null and best.?.offset == address - label.address) continue;
            best = .{ .name = label.name, .offset = address - label.address };
        }
        return best;
    }
};

/// Номер секции с кодом, в которую попадает адрес.
fn sectionOf(file: elf.File, address: u64) !?u32 {
    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (!isCode(header)) continue;
        if (address >= header.addr and address < header.addr + header.size) return index;
    }
    return null;
}

test "из вывода nm берутся только функции" {
    const gpa = std.testing.allocator;
    const symbols = try parseNm(gpa,
        \\000000000000001f T ztFib
        \\                 U ztLog
        \\0000000000000000 t helper
        \\0000000000000010 D table
        \\
    );
    defer gpa.free(symbols);

    try std.testing.expectEqual(@as(usize, 2), symbols.len);
    try std.testing.expectEqualStrings("ztFib", symbols[0].name);
    try std.testing.expectEqual(@as(u64, 0x1f), symbols[0].address);
    try std.testing.expect(symbols[0].global);
    try std.testing.expect(!symbols[1].global);
}

test "цель без своего имени подписывается от ближайшего слева" {
    var labels = try Labels.fromSymbols(std.testing.allocator, &.{
        .{ .name = "b", .address = 0x40, .global = true },
        .{ .name = "a", .address = 0x0, .global = true },
    });
    defer labels.deinit();

    const inside = labels.nearest(0, 0x5a).?;
    try std.testing.expectEqualStrings("b", inside.name);
    try std.testing.expectEqual(@as(u64, 0x1a), inside.offset);
    try std.testing.expectEqualStrings("a", labels.exact(0, 0).?);
}

Что изменилось по сравнению с уроком 14, и почему.

Ранг. Он появился ещё в уроке 14, а теперь у него три ступени. На одном адресе может оказаться несколько имён, ты видел это в выводе с -fno-strip: локальное sections.total и глобальное total указывают на один байт. Здесь objdump печатает глобальное, и мы тоже: сортировка ставит имя с большим рангом первым, а exact и nearest берут первое подходящее. Новое в том, что имя секции получает нулевой ранг и идёт в ход, только когда на адресе нет ни одного символа.

Секция как часть адреса. Поле section, которое в уроке 14 всегда было нулём, теперь настоящее. У перемещаемого файла все секции начинаются с нуля, поэтому адрес 0x10 сам по себе не значит ничего: в .text это одна функция, в .text.startup другая. Подпись хранит пару из номера секции и адреса, и цель перехода ищется в той же секции, откуда прыгают. У готового файла адреса уже уникальны, и там секцию находим по адресу через sectionOf. Поле file необязательное: у подписей из nm файла нет, и nearest считает их одной секцией, как раньше.

Арена. Список подписей живёт ровно столько, сколько идёт разбор файла. ArenaAllocator из пятого урока позволяет освободить всё одним deinit и не думать, кто владеет каким срезом.

В дизассемблере новая функция disassembleFile, остальное пришло из урока 14. Файл целиком:

//! Дизассемблер как подкоманда: идёт по байтам, зовёт декодер и печатает
//! строки ровно в том виде, в каком их печатает `objdump -d` из LLVM.
//!
//! Формат строки: адрес, выровненный вправо по восьми позициям, двоеточие,
//! пробел, байты инструкции через пробел, добитые пробелами до фиксированной
//! ширины, табуляция, мнемоника, табуляция, операнды.

const std = @import("std");

const decoder = @import("x86/decoder.zig");
const elf = @import("elf.zig");
const formatter = @import("x86/formatter.zig");
const labels_mod = @import("labels.zig");

pub const Labels = labels_mod.Labels;

pub const Instruction = decoder.Instruction;

/// Ширина колонки байтов: десять байтов по три знака минус последний пробел.
/// Столько же отводит objdump, поэтому строки совпадают посимвольно.
const bytes_column = 29;

/// Разбирает и печатает весь кусок кода без подписей. `base` это адрес
/// первого байта: для сырого файла то, что попросили флагом `--base`.
pub fn disassemble(out: *std.Io.Writer, code: []const u8, base: u64) !void {
    var offset: usize = 0;
    while (offset < code.len) {
        const instruction = decoder.decode(code[offset..], base + offset);
        try printLine(out, instruction, null, 0);
        offset += instruction.length();
    }
}

/// Разбирает все секции с кодом из файла ELF и подписывает функции именами
/// из таблицы символов, как это делает `objdump -d`.
pub fn disassembleFile(gpa: std.mem.Allocator, out: *std.Io.Writer, file: elf.File) !void {
    var labels = try Labels.collect(gpa, file);
    defer labels.deinit();

    var index: u32 = 0;
    while (index < file.sectionCount()) : (index += 1) {
        const header = try file.section(index);
        if (!labels_mod.isCode(header)) continue;
        const code = try file.sectionData(header);

        try out.print("\nDisassembly of section {s}:\n", .{try file.sectionName(header)});
        try disassembleSection(out, code, header.addr, &labels, index);
    }
}

/// Разбирает кусок кода и подписывает его именами из готового списка,
/// например из вывода `nm`. Весь кусок считается одной секцией.
pub fn disassembleNamed(
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    code: []const u8,
    base: u64,
    symbols: []const labels_mod.Symbol,
) !void {
    var labels = try Labels.fromSymbols(gpa, symbols);
    defer labels.deinit();
    try disassembleSection(out, code, base, &labels, 0);
}

/// Одна секция с подписями: заголовок перед каждой функцией, имя у каждой
/// цели перехода и вызова.
fn disassembleSection(
    out: *std.Io.Writer,
    code: []const u8,
    base: u64,
    labels: *const Labels,
    section: u32,
) !void {
    var offset: usize = 0;
    while (offset < code.len) {
        // Инструкция не может перешагнуть начало следующей функции. Если
        // перед функцией лежит мусор или добивка, разбор сбился бы и съел
        // её первые байты, поэтому декодеру показываем байты только до
        // ближайшей подписи. Так же поступает objdump.
        const address = base + offset;
        const limit = if (labels.nextAfter(section, address)) |next| @min(code.len, next - base) else code.len;
        const instruction = decoder.decode(code[offset..@intCast(limit)], address);
        if (labels.exact(section, instruction.address)) |name| {
            try out.print("\n{x:0>16} <{s}>:\n", .{ instruction.address, name });
        }
        try printLine(out, instruction, labels, section);
        offset += instruction.length();
    }
}

/// Одна строка дизассемблера. `section` это номер секции, в которой лежит
/// инструкция: у объектника адреса разных секций пересекаются, и цель
/// перехода ищем в той же секции.
pub fn printLine(
    out: *std.Io.Writer,
    instruction: Instruction,
    labels: ?*const Labels,
    section: u32,
) !void {
    try out.print("{x: >8}: ", .{instruction.address});

    var width: usize = 0;
    for (instruction.bytes, 0..) |byte, position| {
        if (position > 0) {
            try out.writeByte(' ');
            width += 1;
        }
        try out.print("{x:0>2}", .{byte});
        width += 2;
    }
    if (width < bytes_column) try out.splatByteAll(' ', bytes_column - width);

    try out.writeByte('\t');
    try formatter.format(out, instruction);

    if (labels) |known| try printTargetLabel(out, instruction, known, section);

    if (formatter.hasComment(instruction)) {
        try out.writeAll("  ");
        try formatter.formatComment(out, instruction);
    }
    try out.writeByte('\n');
}

/// Подпись цели перехода или вызова: `<run+0x1b>`, `<puts@plt>`.
fn printTargetLabel(out: *std.Io.Writer, instruction: Instruction, labels: *const Labels, section: u32) !void {
    for (instruction.operandSlice()) |operand| {
        const target = switch (operand) {
            .target => |address| address,
            else => continue,
        };
        const found = labels.nearest(section, target) orelse return;
        if (found.offset == 0) {
            try out.print(" <{s}>", .{found.name});
        } else {
            try out.print(" <{s}+0x{x}>", .{ found.name, found.offset });
        }
    }
}

test "строка дизассемблера повторяет колонки objdump" {
    var buffer: [128]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    try disassemble(&out, &.{ 0x41, 0x57, 0xc3 }, 0);

    try std.testing.expectEqualStrings(
        "       0: 41 57                        \tpushq\t%r15\n" ++
            "       2: c3                           \tretq\n",
        out.buffered(),
    );
}

disassembleFile обходит все секции с кодом, а не одну .text: признак кода это флаг X, а не имя. Каждую секцию она отдаёт disassembleSection из урока 14, теперь с настоящим номером секции. Отсечка по limit там же: декодер получает байты только до ближайшей следующей подписи, чтобы добивка между функциями не съела пролог соседа. С подписями из файла это правило работает на всех функциях сразу, а не только на тех, что попали в вывод nm.

Старая disassemble для сырых файлов осталась: у файла с ключом --raw таблицы имён нет, и printLine получает null вместо подписей. Осталась и disassembleNamed для флага --syms: подписать сырой кусок кода по своему списку по-прежнему можно.

Одна новая инструкция

Почти всё, что встречается в main.o, декодер уже знает: косвенные переходы с урока 13, call с урока 14, syscall с урока 18, а (%rip) без нуля в смещении форматтер печатает с урока 17. Не хватает одного: в run стоит pushq $0x2, push с числом, которого в фикстурах тех уроков не было. Без него тест на совпадение с objdump не пройдёт:

--- a/src/x86/decoder.zig
+++ b/src/x86/decoder.zig
@@ -77,6 +77,10 @@
         0x50...0x57 => data.pushPop(cursor, "push", opcode - 0x50),
         0x58...0x5f => data.pushPop(cursor, "pop", opcode - 0x58),
 
+        // push с непосредственным значением: четыре байта или один со знаком.
+        0x68 => pushImmediate(cursor, .dword),
+        0x6a => pushImmediate(cursor, .byte),
+
         // Знаковое расширение четырёх байтов в восемь.
         0x63 => data.extend(cursor, "movsl", .dword, .qword),
 
@@ -220,6 +224,13 @@
     return alu.incrementDecrement(cursor, modrm.digit, modrm.rm, size);
 }
 
+/// Опкоды 0x68 и 0x6A. На стек всегда уходит восемь байтов: значение
+/// расширяется знаком.
+fn pushImmediate(cursor: *Cursor, size: Size) ?Instruction {
+    const value = cursor.readImmediate(size) orelse return null;
+    return cursor.one("push", 'q', .{ .immediate = value });
+}
+
 /// Выбор мнемоники по ширине операнда для инструкций, у которых суффикса нет.
 fn widthName(cursor: Cursor, word: []const u8, dword: []const u8, qword: []const u8) []const u8 {
     return switch (cursor.width()) {

Компилятор выбрал pushq $0x2 плюс popq %rcx вместо movl $0x2, %ecx ради размера: 6a 02 и 59 это три байта против пяти. Файл собран с -O ReleaseSmall, и там такие обмены в ходу.

Командная строка

В src/main.zig две новые подкоманды, а disasm без --raw и --syms теперь отдаёт файл целиком в disassembleFile вместо поиска одной секции. Общий разбор заголовка вынесен в parseElf: на не-ELF все три инструмента отвечают одинаковым понятным сообщением и кодом 2.

--- a/src/main.zig
+++ b/src/main.zig
@@ -10,6 +10,8 @@
     \\Использование:
     \\  zt hexdump [--words=N] [--big] <файл>
     \\  zt disasm [--raw] [--base=0x...] [--syms=файл] <файл>
+    \\  zt readelf [-h] [-S] [-s] [-r] <файл>
+    \\  zt nm <файл>
     \\
     \\Флаги hexdump:
     \\  --words=N   группировать по N байтов, N это 1, 2, 4 или 8
@@ -20,6 +22,12 @@
     \\  --base=A    адрес первого байта при --raw, по умолчанию 0
     \\  --syms=F    подписи из вывода nm в файле F, а не из таблицы символов
     \\
+    \\Флаги readelf:
+    \\  -h          заголовок файла
+    \\  -S          таблица секций
+    \\  -s          таблица символов
+    \\  -r          перемещения
+    \\
 ;
 
 /// Файл целиком читаем в память: учебные объектники маленькие,
@@ -68,6 +76,10 @@
         try cmdHexdump(init, gpa, out, rest);
     } else if (std.mem.eql(u8, command, "disasm")) {
         try cmdDisasm(init, gpa, out, rest);
+    } else if (std.mem.eql(u8, command, "readelf")) {
+        try cmdReadelf(init, gpa, out, rest);
+    } else if (std.mem.eql(u8, command, "nm")) {
+        try cmdNm(init, gpa, out, rest);
     } else {
         try out.print("zt: неизвестная подкоманда {s}\n\n", .{command});
         try out.writeAll(usage);
@@ -143,12 +155,71 @@
         return;
     }
 
-    // Пока zt не умеет ELF целиком, из объектника берём только секцию с кодом.
-    const text = zt.elf.findSection(bytes, ".text") catch |err| {
-        try out.print("zt disasm: {s} это не объектник ELF64 с секцией .text ({t})\n", .{ file, err });
+    const parsed = try parseElf(out, "disasm", file, bytes);
+    try zt.disasm.disassembleFile(gpa, out, parsed);
+}
+
+fn cmdReadelf(
+    init: std.process.Init,
+    gpa: std.mem.Allocator,
+    out: *std.Io.Writer,
+    args: []const []const u8,
+) !void {
+    var header = false;
+    var sections = false;
+    var symbols = false;
+    var relocations = false;
+    var path: ?[]const u8 = null;
+
+    for (args) |arg| {
+        if (std.mem.eql(u8, arg, "-h")) {
+            header = true;
+        } else if (std.mem.eql(u8, arg, "-S")) {
+            sections = true;
+        } else if (std.mem.eql(u8, arg, "-s")) {
+            symbols = true;
+        } else if (std.mem.eql(u8, arg, "-r")) {
+            relocations = true;
+        } else if (std.mem.startsWith(u8, arg, "-")) {
+            try out.print("zt readelf: неизвестный флаг {s}\n", .{arg});
+            return error.BadUsage;
+        } else {
+            if (path != null) return fail(out, "zt readelf: нужен ровно один файл\n");
+            path = arg;
+        }
+    }
+    if (!header and !sections and !symbols and !relocations)
+        return fail(out, "zt readelf: нужен хотя бы один флаг из -h, -S, -s, -r\n");
+
+    const name = path orelse return fail(out, "zt readelf: нужен ровно один файл\n");
+    const bytes = try std.Io.Dir.cwd().readFileAlloc(init.io, name, gpa, file_limit);
+    const file = try parseElf(out, "readelf", name, bytes);
+
+    // Блоки идут в том же порядке, что у настоящего readelf, как бы ни
+    // стояли флаги в командной строке.
+    if (header) try zt.readelf.printHeader(out, file);
+    if (sections) try zt.readelf.printSections(out, file);
+    if (relocations) try zt.readelf.printRelocations(out, file);
+    if (symbols) try zt.readelf.printSymbols(out, file);
+}
+
+fn cmdNm(
+    init: std.process.Init,
+    gpa: std.mem.Allocator,
+    out: *std.Io.Writer,
+    args: []const []const u8,
+) !void {
+    if (args.len != 1) return fail(out, "zt nm: нужен ровно один файл\n");
+    const bytes = try std.Io.Dir.cwd().readFileAlloc(init.io, args[0], gpa, file_limit);
+    try zt.nm.print(gpa, out, try parseElf(out, "nm", args[0], bytes));
+}
+
+/// Разбирает заголовок ELF, а неудачу превращает в понятное сообщение.
+fn parseElf(out: *std.Io.Writer, tool: []const u8, path: []const u8, bytes: []const u8) !zt.elf.File {
+    return zt.elf.File.parse(bytes) catch |err| {
+        try out.print("zt {s}: {s} это не файл ELF64 ({t})\n", .{ tool, path, err });
         return error.BadUsage;
     };
-    try zt.disasm.disassemble(out, text.data, text.address);
 }
 
 /// Печатает сообщение об ошибке использования и возвращает ошибку.

Порядок блоков в cmdReadelf жёсткий: заголовок, секции, перемещения, имена, как бы ни стояли флаги. Так печатает настоящий readelf, и тестам это важно, потому что эталонный файл снят одной командой со всеми флагами сразу.

В корне модуля прибавилось две строки:

--- a/src/root.zig
+++ b/src/root.zig
@@ -6,7 +6,9 @@
 pub const hexdump = @import("hexdump.zig");
 pub const elf = @import("elf.zig");
 pub const disasm = @import("disasm.zig");
 pub const labels = @import("labels.zig");
+pub const readelf = @import("readelf.zig");
+pub const nm = @import("nm.zig");
 pub const x86 = struct {
     pub const cursor = @import("x86/cursor.zig");
     pub const decoder = @import("x86/decoder.zig");

А в build.zig одно число в списке шагов:

--- a/build.zig
+++ b/build.zig
@@ -2,7 +2,7 @@
 
 /// Номера уроков курса, на которых проект вырос. Каждому шагу
 /// соответствует файл `tests/step_NN.zig`, и все они зелёные на финале.
-const project_steps = [_]u8{ 6, 7, 9, 10, 11, 12, 13, 14, 17, 18 };
+const project_steps = [_]u8{ 6, 7, 9, 10, 11, 12, 13, 14, 17, 18, 42 };
 
 pub fn build(b: *std.Build) void {
     const target = b.standardTargetOptions(.{});

Фикстуры

Объектники для тестов это те самые пять файлов, которые мы разглядывали весь урок: main.zig, addvec.zig, multvec.zig (близнец addvec с умножением, он понадобится в следующем уроке), weak.zig и common.zig. Положи их в fixtures/link/ и собери командой из шапки каждого файла. Кросс-компиляция Zig работает с любой машины, контейнер для этого не нужен.

Эталонные тексты снимаются тремя командами на файл:

$ cd fixtures/link
$ llvm-readelf -h -S -r -s main.o > main.readelf.txt
$ llvm-nm main.o > main.nm.txt
$ llvm-objdump -d main.o > main.objdump.txt

Мы проверили: LLVM 19.1.7 из Debian 13 (apt install llvm в образе debian:stable-slim) даёт тексты readelf и nm, которые совпадают с эталоном курса побайтно, а objdump отличается только строкой с путём к файлу, которую тест срезает. На macOS те же инструменты ставит brew install llvm. Более старый LLVM печатает колонку Ndx уже, и с ним тесты на readelf -s не сойдутся.

Кроме пяти объектников тесты берут объектник двенадцатого урока (в нём есть и локальные, и глобальные имена на одном адресе) и один готовый динамический файл hello_dyn: на нём читатель проверяется на таблице из трёх десятков секций с настоящими адресами. Он собирается из двух крошечных файлов на C командами из комментария ниже; сами файлы это void greet(const char *name) { printf("hello, %s\n", name); } в greet.c и main в hello.c, который зовёт malloc, strcpy, greet, free и puts. Эта пара ещё послужит нам в уроках 45 и 46.

//! Фикстуры шагов: дизассемблера и читателя ELF.
//!
//! Каждый шаг это объектник, собранный из соседнего `.zig` под
//! `x86_64-linux-musl`, и рядом снятый с него эталонный вывод `objdump -d`.
//! Оба файла вшиваются в тест через `@embedFile`, поэтому тесты не зависят
//! ни от рабочего каталога, ни от того, есть ли objdump на машине студента.
//!
//! Как пересобрать фикстуру шага NN:
//!   zig build-obj fixtures/step_NN.zig -O ReleaseFast \
//!       -target x86_64-linux-musl -femit-bin=fixtures/step_NN.o
//!   objdump -d fixtures/step_NN.o > fixtures/step_NN.objdump.txt
//! Шаг 14 собран с `-O ReleaseSmall`, почему, сказано в шапке `step_14.zig`.

pub const step_09 = Fixture{
    .object = @embedFile("step_09.o"),
    .objdump = @embedFile("step_09.objdump.txt"),
};

pub const step_10 = Fixture{
    .object = @embedFile("step_10.o"),
    .objdump = @embedFile("step_10.objdump.txt"),
};

pub const step_11 = Fixture{
    .object = @embedFile("step_11.o"),
    .objdump = @embedFile("step_11.objdump.txt"),
};

pub const step_12 = Fixture{
    .object = @embedFile("step_12.o"),
    .objdump = @embedFile("step_12.objdump.txt"),
};

pub const step_13 = Fixture{
    .object = @embedFile("step_13.o"),
    .objdump = @embedFile("step_13.objdump.txt"),
};

/// У шага 14 рядом лежит ещё вывод `nm`: это таблица символов, которую
/// дизассемблер получает снаружи, пока не умеет читать её из ELF сам.
pub const step_14 = Fixture{
    .object = @embedFile("step_14.o"),
    .objdump = @embedFile("step_14.objdump.txt"),
};
pub const step_14_nm = @embedFile("step_14.nm.txt");

pub const step_17 = Fixture{
    .object = @embedFile("step_17.o"),
    .objdump = @embedFile("step_17.objdump.txt"),
};

pub const step_18 = Fixture{
    .object = @embedFile("step_18.o"),
    .objdump = @embedFile("step_18.objdump.txt"),
};

/// Объектники для читателя ELF, каталог `link/`. Каждый собран из
/// соседнего `.zig` командой из его шапки, под `x86_64-linux` без libc.
/// Эталонный текст снят `llvm-readelf -h -S -r -s`, `llvm-nm` и
/// `llvm-objdump -d`.
pub const link = struct {
    pub const main = object("main");
    pub const addvec = object("addvec");
    pub const multvec = object("multvec");
    pub const weak = object("weak");
    pub const common = object("common");

    fn object(comptime name: []const u8) ElfFixture {
        return .{
            .bytes = @embedFile("link/" ++ name ++ ".o"),
            .readelf = @embedFile("link/" ++ name ++ ".readelf.txt"),
            .nm = @embedFile("link/" ++ name ++ ".nm.txt"),
            .objdump = @embedFile("link/" ++ name ++ ".objdump.txt"),
        };
    }
};

/// Готовый динамический файл, каталог `dyn/`: на нём читатель проверяется
/// на большой таблице секций с ненулевыми адресами. Собран так:
///   cd fixtures/dyn
///   zig cc -target x86_64-linux-gnu -O1 -shared -fPIC \
///       -Wl,-soname,libgreet.so -o libgreet.so greet.c
///   zig cc -target x86_64-linux-gnu -O1 -fno-builtin -o hello_dyn hello.c \
///       -L. -lgreet -Wl,-rpath,'$ORIGIN' -Wl,-z,lazy
/// Ключ `-z lazy` оставляет ленивое связывание, чтобы в ячейках GOT лежали
/// адреса заглушек PLT. Эталон `hello.readelf.txt` снят с флагами
/// `-h -S -l -d -r`, поэтому из него тесты берут только блок секций.
pub const dyn = struct {
    pub const hello: ElfFixture = .{
        .bytes = @embedFile("dyn/hello_dyn"),
        .readelf = @embedFile("dyn/hello.readelf.txt"),
        .nm = @embedFile("dyn/hello.nm.txt"),
        .objdump = @embedFile("dyn/hello.objdump.txt"),
    };
};

/// Объектник шага 12 вместе с эталоном readelf и nm: на нём читатель ELF
/// проверяется первым, потому что у него есть и локальные, и глобальные имена.
pub const step_12_elf: ElfFixture = .{
    .bytes = @embedFile("step_12.o"),
    .readelf = @embedFile("step_12.readelf.txt"),
    .nm = @embedFile("step_12.nm.txt"),
    .objdump = @embedFile("step_12.objdump.txt"),
};

pub const ElfFixture = struct {
    /// Файл ELF целиком.
    bytes: []const u8,
    /// Вывод `llvm-readelf`. Какие флаги, сказано у фикстуры.
    readelf: []const u8,
    /// Вывод `llvm-nm`.
    nm: []const u8,
    /// Вывод `llvm-objdump -d`.
    objdump: []const u8,
};

pub const Fixture = struct {
    /// Объектник ELF64 целиком.
    object: []const u8,
    /// Вывод `objdump -d` для него, вместе с заголовками.
    objdump: []const u8,
};

Тесты шага

Общая часть тестов вынесена в отдельный файл: она пригодится и компоновщику. Функцию withoutComments он берёт из tests/support.zig, где она живёт с урока 14.

//! Общая часть тестов читателя ELF и компоновщика: вырезать блок из
//! эталонного вывода readelf и снять вывод наших инструментов в строку.

const std = @import("std");

const zt = @import("zt");

const withoutComments = @import("support.zig").withoutComments;

/// Кусок текста от `start` включительно до `end` не включая. Эталон снят
/// одной командой с несколькими флагами, а тест проверяет блоки по одному.
pub fn between(text: []const u8, start: []const u8, end: ?[]const u8) []const u8 {
    const from = std.mem.indexOf(u8, text, start) orelse return "";
    const rest = text[from..];
    const marker = end orelse return rest;
    const length = std.mem.indexOf(u8, rest, marker) orelse rest.len;
    return rest[0..length];
}

pub const header_start = "ELF Header:";
pub const sections_start = "There are ";
pub const relocations_start = "\nRelocation section";
pub const symbols_start = "\nSymbol table";
/// Последняя строка блока секций: на ней кончается легенда флагов.
pub const sections_last_line = "p (processor specific)\n";

/// Блок секций эталона: от строки `There are` до конца легенды флагов.
pub fn sectionsBlock(reference: []const u8) []const u8 {
    const rest = between(reference, sections_start, null);
    const end = std.mem.indexOf(u8, rest, sections_last_line) orelse return rest;
    return rest[0 .. end + sections_last_line.len];
}

const Printer = fn (out: *std.Io.Writer, file: zt.elf.File) anyerror!void;

/// Прогоняет одну из функций `zt.readelf` и сверяет вывод с блоком эталона.
pub fn expectReadelf(comptime printer: Printer, bytes: []const u8, expected: []const u8) !void {
    var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try printer(&out.writer, try zt.elf.File.parse(bytes));
    try std.testing.expectEqualStrings(expected, out.written());
}

pub fn expectNm(bytes: []const u8, expected: []const u8) !void {
    var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try zt.nm.print(std.testing.allocator, &out.writer, try zt.elf.File.parse(bytes));
    try std.testing.expectEqualStrings(expected, out.written());
}

/// Вывод `zt disasm` совпадает с `objdump -d` вместе с подписями функций и
/// целей переходов. Убираем только шапку эталона с именем файла и
/// комментарии после решётки: их objdump выравнивает и подписывает иначе.
pub fn expectDisasm(bytes: []const u8, objdump: []const u8) !void {
    const gpa = std.testing.allocator;

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try zt.disasm.disassembleFile(gpa, &out.writer, try zt.elf.File.parse(bytes));

    const body_start = std.mem.indexOf(u8, objdump, "\nDisassembly of section") orelse 0;
    const expected = try withoutComments(gpa, objdump[body_start..]);
    defer gpa.free(expected);
    const actual = try withoutComments(gpa, out.written());
    defer gpa.free(actual);
    try std.testing.expectEqualStrings(expected, actual);
}
//! Шаг 42: читатель ELF64, `zt readelf`, `zt nm` и подписи в `zt disasm`.
//!
//! Эталон это вывод `llvm-readelf`, `llvm-nm` и `llvm-objdump -d`, снятый с
//! фикстур заранее. Сверка идёт посимвольно: колонки, пробелы, пустые строки.

const std = @import("std");

const fixtures = @import("fixtures");
const zt = @import("zt");

const support = @import("elf_support.zig");

const elf = zt.elf;

const objects = [_]fixtures.ElfFixture{
    fixtures.link.main,
    fixtures.link.addvec,
    fixtures.link.multvec,
    fixtures.link.weak,
    fixtures.link.common,
};

test "заголовок объектника читается одной структурой" {
    const file = try elf.File.parse(fixtures.link.main.bytes);
    try std.testing.expectEqual(@intFromEnum(elf.format.FileType.relocatable), file.header.type);
    try std.testing.expectEqual(elf.format.machine_x86_64, file.header.machine);
    try std.testing.expectEqual(@as(u16, 11), file.sectionCount());
    // У объектника нет ни точки входа, ни заголовков программы.
    try std.testing.expectEqual(@as(u64, 0), file.header.entry);
    try std.testing.expectEqual(@as(u16, 0), file.header.phnum);
}

test "секция находится и по имени, и по типу" {
    const file = try elf.File.parse(fixtures.link.main.bytes);
    const text = try file.section(try file.findSection(".text"));
    try std.testing.expectEqual(@as(u64, 0x37), text.size);
    try std.testing.expectEqual(elf.format.shf_alloc | elf.format.shf_execinstr, text.flags);

    const symtab = try file.section(try file.findSectionByType(.symtab));
    try std.testing.expectEqualStrings(".symtab", try file.sectionName(symtab));
    try std.testing.expectError(error.SectionNotFound, file.findSection(".нет_такой"));
}

test "у .bss есть размер, но нет байтов в файле" {
    const file = try elf.File.parse(fixtures.link.main.bytes);
    const bss = try file.section(try file.findSection(".bss"));
    try std.testing.expectEqual(@as(u64, 16), bss.size);
    try std.testing.expectEqual(@as(usize, 0), (try file.sectionData(bss)).len);
}

test "таблица символов: тип, сила и секция" {
    const file = try elf.File.parse(fixtures.link.main.bytes);
    const table = try elf.SymbolTable.find(file, .symtab);
    try std.testing.expectEqual(@as(u32, 9), table.count());
    // Локальные символы идут первыми, и граница записана в sh_info.
    try std.testing.expectEqual(@as(u32, 6), table.first_global);

    const start = try table.get(8);
    try std.testing.expectEqualStrings("_start", table.name(start));
    try std.testing.expectEqual(elf.format.SymbolType.func, start.kind());
    try std.testing.expectEqual(elf.format.SymbolBinding.global, start.binding());
    try std.testing.expectEqual(@as(u64, 0x29), start.value);

    const addvec = try table.get(6);
    try std.testing.expectEqualStrings("addvec", table.name(addvec));
    try std.testing.expect(addvec.isUndefined());
}

test "слабый символ и COMMON видны в таблице" {
    const weak_file = try elf.File.parse(fixtures.link.weak.bytes);
    const weak_table = try elf.SymbolTable.find(weak_file, .symtab);
    const weak = try weak_table.get(weak_table.count() - 1);
    try std.testing.expectEqualStrings("addvec", weak_table.name(weak));
    try std.testing.expectEqual(elf.format.SymbolBinding.weak, weak.binding());

    const common_file = try elf.File.parse(fixtures.link.common.bytes);
    const common_table = try elf.SymbolTable.find(common_file, .symtab);
    var found = false;
    var index: u32 = 0;
    while (index < common_table.count()) : (index += 1) {
        const symbol = try common_table.get(index);
        if (symbol.shndx != elf.format.shn_common) continue;
        try std.testing.expectEqualStrings("shared_counter", common_table.name(symbol));
        // У COMMON поле value это выравнивание, а не адрес.
        try std.testing.expectEqual(@as(u64, 8), symbol.value);
        try std.testing.expectEqual(@as(u64, 8), symbol.size);
        found = true;
    }
    try std.testing.expect(found);
}

test "обрезанный файл даёт ошибку, а не выход за границу" {
    const bytes = fixtures.link.main.bytes;
    const file = try elf.File.parse(bytes[0..200]);
    try std.testing.expectError(error.Truncated, file.section(1));
}

test "readelf -h совпадает с эталоном" {
    for (objects) |object| {
        const expected = support.between(object.readelf, support.header_start, support.sections_start);
        try support.expectReadelf(zt.readelf.printHeader, object.bytes, expected);
    }
}

test "readelf -S совпадает с эталоном" {
    for (objects) |object| {
        try support.expectReadelf(zt.readelf.printSections, object.bytes, support.sectionsBlock(object.readelf));
    }
    // Объектник с отладочной информацией: длинные имена секций сдвигают колонку типа.
    const debug = fixtures.step_12_elf;
    try support.expectReadelf(zt.readelf.printSections, debug.bytes, support.sectionsBlock(debug.readelf));
    // Готовый динамический файл: секций втрое больше, адреса ненулевые.
    const hello = fixtures.dyn.hello;
    try support.expectReadelf(zt.readelf.printSections, hello.bytes, support.sectionsBlock(hello.readelf));
}

test "readelf -s совпадает с эталоном" {
    for (objects) |object| {
        const expected = support.between(object.readelf, support.symbols_start, null);
        try support.expectReadelf(zt.readelf.printSymbols, object.bytes, expected);
    }
    const debug = fixtures.step_12_elf;
    try support.expectReadelf(
        zt.readelf.printSymbols,
        debug.bytes,
        support.between(debug.readelf, support.symbols_start, null),
    );
}

test "readelf -r совпадает с эталоном" {
    for (objects) |object| {
        const expected = support.between(object.readelf, support.relocations_start, support.symbols_start);
        try support.expectReadelf(zt.readelf.printRelocations, object.bytes, expected);
    }
}

test "nm совпадает с эталоном" {
    for (objects) |object| try support.expectNm(object.bytes, object.nm);
    try support.expectNm(fixtures.step_12_elf.bytes, fixtures.step_12_elf.nm);
    try support.expectNm(fixtures.dyn.hello.bytes, fixtures.dyn.hello.nm);
}

test "буква nm следует из флагов секции и силы символа" {
    const file = try elf.File.parse(fixtures.link.main.bytes);
    const table = try elf.SymbolTable.find(file, .symtab);
    try std.testing.expectEqual(@as(u8, 'U'), try zt.nm.letter(file, try table.get(6)));
    try std.testing.expectEqual(@as(u8, 'T'), try zt.nm.letter(file, try table.get(8)));
}

test "disasm подписывает функции и цели переходов, как objdump" {
    for (objects) |object| try support.expectDisasm(object.bytes, object.objdump);
    try support.expectDisasm(fixtures.step_12_elf.bytes, fixtures.step_12_elf.objdump);
}

test "из двух имён на одном адресе берётся глобальное" {
    const gpa = std.testing.allocator;
    var labels = try zt.labels.Labels.collect(gpa, try elf.File.parse(fixtures.step_12_elf.bytes));
    defer labels.deinit();
    // В объектнике есть и локальный `step_12.ztFarJump`, и глобальный `ztFarJump`.
    try std.testing.expectEqualStrings("ztFarJump", labels.exact(1, 0).?);
    const inside = labels.nearest(1, 0x68).?;
    try std.testing.expectEqualStrings("ztFarJump", inside.name);
    try std.testing.expectEqual(@as(u64, 0x68), inside.offset);
}

test "push с числом кладёт на стек восемь байтов" {
    const decode = zt.x86.decoder.decode;
    // 6a 02: число в одном байте, на стек уходит расширенное знаком до восьми.
    const short = decode(&.{ 0x6a, 0x02 }, 0);
    try std.testing.expectEqualStrings("push", short.mnemonic);
    try std.testing.expectEqual(@as(?u8, 'q'), short.suffix);
    try std.testing.expectEqual(@as(i64, 2), short.operandSlice()[0].immediate);
    const wide = decode(&.{ 0x68, 0x00, 0x10, 0x00, 0x00 }, 0);
    try std.testing.expectEqual(@as(i64, 0x1000), wide.operandSlice()[0].immediate);
}

test "старая короткая дорога findSection работает поверх нового читателя" {
    const text = try elf.findSection(fixtures.link.main.bytes, ".text");
    try std.testing.expectEqual(@as(usize, 0x37), text.data.len);
    try std.testing.expectEqual(@as(u8, 0x55), text.data[0]);
}

Тесты делятся на два слоя. Первые шесть проверяют читатель по существу: числа в заголовке, секцию по имени и по типу, пустоту .bss, поля записи таблицы имён, слабое имя и COMMON, обрезанный файл. Остальные сверяют вывод с эталоном посимвольно. Второй слой выглядит как придирка к пробелам, но именно он ловит настоящие ошибки: перепутанные link и info, знак слагаемого, забытую нулевую запись.

Запускаем:

$ zig build test -Dstep=42 --summary all
Build Summary: 3/3 steps succeeded; 16/16 tests passed
$ zig build test --summary all
Build Summary: 25/25 steps succeeded; 135/135 tests passed

Все старые шаги зелёные, и это важнее новых шестнадцати тестов: ты заменил читатель под работающим дизассемблером и ничего не сломал.

Смотрим на свой инструмент

Соберём zt под Linux (zig build -Dtarget=x86_64-linux-musl) и запустим рядом с настоящими инструментами. Печатать те же таблицы второй раз незачем, пусть за нас сравнит diff. Снято в контейнере linux/amd64 с Debian 13 и LLVM 19.1.7:

$ for f in main addvec multvec weak common sections; do
>   for flag in -h -S -s -r; do
>     zt readelf $flag $f.o | diff - <(llvm-readelf $flag $f.o) > /dev/null \
>       && echo "same  readelf $flag $f.o" || echo "DIFF  readelf $flag $f.o"
>   done
>   zt nm $f.o | diff - <(llvm-nm $f.o) > /dev/null && echo "same  nm $f.o" || echo "DIFF  nm $f.o"
> done
same  readelf -h main.o
same  readelf -S main.o
same  readelf -s main.o
same  readelf -r main.o
same  nm main.o
same  readelf -h addvec.o
...
same  readelf -r sections.o
same  nm sections.o

Тридцать сравнений, тридцать раз same. Среди файлов есть sections.o, которого нет в фикстурах и под который ты ничего не подгонял: инструмент работает, а не заучил ответы. С GNU readelf из начала урока diff пустым не будет: данные те же до последней цифры, но колонка Ndx у него на знак уже, и одна строка легенды флагов другая.

А вот дизассемблер:

$ zt disasm main.o
Disassembly of section .text:

0000000000000000 <run>:
       0: 55                           	pushq	%rbp
       1: 48 89 e5                     	movq	%rsp, %rbp
       4: 6a 02                        	pushq	$0x2
       6: 59                           	popq	%rcx
       7: bf 00 00 00 00               	movl	$0x0, %edi
       c: be 00 00 00 00               	movl	$0x0, %esi
      11: ba 00 00 00 00               	movl	$0x0, %edx
      16: e8 00 00 00 00               	callq	0x1b <run+0x1b>
      1b: 8b 05 00 00 00 00            	movl	(%rip), %eax  # 0x21
      21: 03 05 00 00 00 00            	addl	(%rip), %eax  # 0x27
      27: 5d                           	popq	%rbp
      28: c3                           	retq

0000000000000029 <_start>:
      29: e8 00 00 00 00               	callq	0x2e <_start+0x5>
      2e: 89 c7                        	movl	%eax, %edi
      30: b8 3c 00 00 00               	movl	$0x3c, %eax
      35: 0f 05                        	syscall

Имена в выводе те же, что в уроке 14 давал флаг --syms, только теперь без него: <run>: и <_start>: взяты из таблицы имён самого файла, <run+0x1b> посчитан через nearest. И подпись callq 0x1b <run+0x1b> обманывает так же, как в уроке 14: на месте смещения нули, а правда лежит в .rela.text, и теперь ты умеешь её прочитать. Научить дизассемблер печатать под такой инструкцией строку R_X86_64_PLT32 addvec-0x4, как это делает objdump -dr, это одно из упражнений ниже.

И проверка на дурака:

$ zt nm sections.zig
zt nm: sections.zig это не файл ELF64 (NotElf)
$ echo $?
2

На macOS. Zig на macOS по умолчанию пишет объектники в формате Mach-O, и zt readelf честно отвечает на них это не файл ELF64 (NotElf). Поэтому все объектники урока собраны с -target x86_64-linux: кросс-компиляция в Zig встроена, и твой zt, собранный под macOS, читает такие файлы без контейнера, ведь разбор ELF это чистые вычисления над байтами. Контейнер linux/amd64 нужен только там, где программу запускают (./prog; echo $?) или зовут GNU binutils; на Apple Silicon он идёт через Rosetta. Из штатных инструментов macOS с ELF не работает ни один: nm, otool и size из Xcode понимают только Mach-O, а /usr/bin/objdump это llvm-objdump, и он ELF читает. Остальное ставится через brew install llvm (llvm-readelf, llvm-nm) или brew install binutils. Сами идеи в Mach-O те же, меняются названия. Секции сгруппированы в сегменты, и имя секции это пара: __TEXT,__text вместо .text, __TEXT,__const вместо .rodata, __DATA,__data, __DATA,__bss. Из-за этого наш sections.zig на macOS 26 не собирается вовсе: LLVM ERROR: ... invalid section specifier '.zt_boot': mach-o section specifier requires a segment and section separated by a comma. С linksection("__DATA,__zt_boot") собирается, и size -m показывает ту же картину: 520 байтов __bss с пометкой zerofill. Второе отличие увидишь в nm: ко всем именам спереди добавлено подчёркивание, _total, _counter, _log_value. Это соглашение досталось Mach-O от старых Unix, и из-за него функцию total из ассемблерной вставки на macOS приходится звать как _total. Буквы у nm тоже чуть другие: константа и переменная из своей секции получают S, а не R и D.

Упражнения

Итоги

  • zig build-exe, gcc и clang это драйверы. Компиляция заканчивается объектным файлом, а исполняемый файл из объектников делает отдельная программа, компоновщик: у Zig для ELF это встроенный ld.lld.
  • У статического компоновщика две задачи. Разрешение имён: каждой ссылке найти ровно одно определение. Перемещение: разложить секции по итоговым адресам и вписать эти адреса во все места, перечисленные в записях перемещения.
  • Компоновщик не видит ни типов, ни тел функций. Он работает с секциями, таблицей имён и таблицей заплаток, и больше ничего о программе не знает.
  • Перемещаемый ELF это заголовок на 64 байта, содержимое секций и таблица секций в хвосте файла. Адреса всех секций в нём нулевые, точки входа и заголовков программы нет.
  • .text это код, .rodata это константы, .data это переменные с начальным значением, .bss это переменные с нулями. У .bss тип NOBITS: размер есть, байтов в файле нет.
  • Смысл секции определяют тип и флаги, а не имя. Компилятор вправе назвать секцию .rodata.cst32, ты вправе назвать свою через linksection, и инструменты обязаны понимать обе.
  • В Zig глобальное имя создаёт export, внешнее создаёт extern, всё остальное локально. Слово pub компоновщику не видно. В ReleaseSmall локальные имена вычищаются, -fno-strip их возвращает.
  • Elf64_Sym это 24 байта: имя как смещение в .strtab, тип и сила в одном байте st_info, номер секции, смещение в секции, размер. Локальные записи идут первыми, граница записана в поле Inf секции .symtab.
  • Три псевдосекции: UND для имён, которых в файле нет, ABS для значений без секции, COM для неинициализированных переменных C, которым место выделит компоновщик. У записи UND нет даже типа, поэтому типы через границу объектников не проверяет никто.
  • Буква nm выводится из st_shndx, силы имени и флагов секции. Заглавная значит глобальное имя, строчная локальное.
  • Нули на месте адресов в коде объектника это не адреса, а места для заплаток. Настоящая цель call записана в .rela.text.
  • Читатель формата это граница доверия: extern struct с проверкой размера при сборке, проверка границ без переполнения, перечисления с открытым хвостом для незнакомых значений.

Дальше

Мы научились читать то, что компилятор оставил компоновщику: секции, имена и заплатки. Пока у нас был один определяющий файл на каждое имя, и задача разрешения имён выглядела тривиальной. В следующем уроке станет интереснее: что делает компоновщик, когда addvec определён дважды, сильно и слабо? Что будет, если два файла объявили переменную с одним именем и разными типами? Почему порядок файлов в командной строке меняет результат сборки и что такое архив .a? Твой zt получит подкоманду ld и сделает первый из двух шагов настоящего компоновщика.

домашка

Домашка