Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Объектные файлы, ELF и символы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Объектные файлы, 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 {} | .text | FUNC GLOBAL |
fn f() void {} | .text, если не подставлена целиком | FUNC LOCAL с именем модуль.f, в ReleaseSmall исчезает |
extern fn f() void; | никакая | NOTYPE GLOBAL UND, и только если f где-то вызвана |
export var x: i64 = 42; | .data | OBJECT GLOBAL |
export var x: i64 = 0; | .bss | OBJECT GLOBAL |
export const x: i64 = 42; | .rodata или .rodata.cstN | OBJECT GLOBAL |
var x linksection(".s") = 1; | .s | как у обычной var |
threadlocal var x: i64 = 1; | .tdata (с нулём .tbss) | TLS |
@export(&f, .{ .name = "g", .linkage = .weak }) | .text | FUNC 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 | Что значит |
|---|---|---|
UND | 0 | имя не определено в этом файле, на него только ссылаются |
ABS | 0xfff1 | значение абсолютное, перемещать его не надо |
COM | 0xfff2 | неинициализированная переменная, которой ещё не выделено место |
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 |
C | COMMON | Ndx равен 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 и сделает первый из двух шагов настоящего компоновщика.
домашка