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

Перемещение и загрузка

senior~220 мин

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

Перемещение и загрузка

В прошлом уроке твой zt ld научился отвечать на вопрос, какое определение стоит за каждым именем. На этом он останавливался: список объектников есть, таблица определений есть, файла нет. Сегодня компоновщик доделывает работу. Он раскладывает секции по адресам, вписывает эти адреса в дырки, которые компилятор оставил в машинном коде, пишет заголовки, понятные ядру, и выдаёт файл. Мы посчитаем каждое вписанное число руками, сверим с objdump, запустим результат и досмотрим историю до конца: что делает ядро с этим файлом, что лежит в памяти процесса к моменту первой инструкции, и сколько всего происходит между _start и твоим main в программе на C и в программе на Zig. А JIT твоего zl получит прямой вызов в пять байтов вместо двенадцати, и окажется, что это то же самое перемещение.

Цели урока

  • Читать запись Elf64_Rela по полям и понимать, почему в ней стоит имя секции, а не имя переменной.
  • Считать руками R_X86_64_PC32, R_X86_64_PLT32 и R_X86_64_32 и объяснять, откуда в слагаемом минус четыре и когда там не минус четыре.
  • Объяснять, что такое модель кода, чем малая отличается от большой и почему перемещение может не влезть в поле.
  • Отличать секции от сегментов, читать readelf -l и понимать, зачем FileSiz и MemSiz разные.
  • Рассказать, что делает ядро по execve, как устроен образ процесса и что лежит на стеке в момент первой инструкции.
  • Пройти путь от _start до main в программе с libc и в программе на Zig без неё.
  • Дописать zt ld до конца: размещение, перемещения, запись исполняемого файла, который запускается.
  • Закодировать call rel32 в JIT по формуле компоновщика и понимать, когда он не дотянется.

Что осталось сделать компоновщику

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

Работа компоновщика после разрешения имён состоит из двух шагов, и оба сегодня твои.

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

Правка ссылок. Компоновщик проходит по запискам компилятора и вписывает в каждую дырку число, посчитанное из уже известных адресов. Эти записки и называются записями перемещения, а весь шаг в книге зовётся relocation. Слово неудачное: ничего никуда не перемещается, байты кода остаются где были. Перемещается точка зрения: код, написанный для секции с адресом ноль, начинает работать по адресу 0x401000.

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

Подопытный объектник

Две функции в двух файлах, без libc, без стандартной библиотеки Zig. Если остались файлы с прошлого урока, возьми их: это те же main.zig и addvec.zig.

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]);
}
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];
}

Функция _start написана на ассемблере в три инструкции: позвать run, переложить результат в %edi и сделать системный вызов номер 60, то есть exit. Результат run становится кодом возврата процесса. Это удобный приём для компоновщика, который ещё ничего не умеет печатать: собрал, запустил, посмотрел echo $?. Правильный ответ тут 4 + 6 = 10. Почему точка входа устроена именно так и что на этом месте стоит у настоящих программ, разберём в конце урока.

Собираем оба файла в объектники под Linux. Хост при этом может быть любым, Zig умеет собирать под чужую платформу из коробки.

$ zig build-obj main.zig -O ReleaseSmall -target x86_64-linux
$ zig build-obj addvec.zig -O ReleaseSmall -target x86_64-linux

Всё, что ниже снято утилитами readelf и objdump, снято в контейнере linux/amd64 с Debian 12 и GNU binutils 2.40. Сами объектники получаются байт в байт одинаковыми и на macOS, и на Linux: кросс-компиляция у Zig воспроизводимая.

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

$ readelf -rW 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

Семь записок для секции .text. Имя секции с записками всегда строится как .rela плюс имя секции, которую правят, а номер этой секции лежит в поле sh_info заголовка. Твой zt readelf -r из урока про ELF печатает ту же таблицу, и структуру ты уже описывал:

pub const Rela = extern struct {
    offset: u64,
    info: u64,
    addend: i64,
};

Три поля по восемь байтов, двадцать четыре байта на запись. Разберём первую строку.

offset равен 0x8. Это смещение дырки от начала секции .text этого объектника. Не инструкции, а именно поля внутри неё: инструкция movl $0, %edi начинается с 0x7, её первый байт это опкод bf, а четыре байта непосредственного значения идут с 0x8.

info равно 0x000000040000000a, и это два числа в одном. Старшие тридцать два бита это номер символа в таблице символов, здесь четвёртый. Младшие тридцать два это тип перемещения, здесь десять, то есть R_X86_64_32. Утилита расшифровала оба: в колонке Type стоит имя типа, в последней колонке имя символа.

addend равен 0x10. Это слагаемое, которое надо прибавить к адресу символа. Отдельное поле под него есть только в записях вида Rela. Бывают ещё записи Rel без этого поля, у них слагаемое лежит прямо в дырке, на месте будущего адреса. На тридцатидвухбитном x86 так и сделано, а ABI для x86-64 выбрал Rela: дырка всегда заполнена нулями, и слагаемое не ограничено шириной поля.

Теперь про имя символа. В исходнике написано &x, а в записке стоит .data + 10. Переменная x не экспортирована, снаружи файла её не видно, и компилятор не стал заводить для неё глобальное имя. Вместо этого он сослался на символ типа SECTION, который означает начало секции .data этого объектника, и положил смещение x внутри секции в слагаемое. Посмотри на порядок: y лежит по смещению ноль, x по смещению 0x10, компилятор переставил их как ему удобно. Для компоновщика это ничего не меняет, он считает одинаково: адрес символа плюс слагаемое. А вот addvec и run названы по имени, потому что это глобальные символы и стоять за ними может определение из любого объектника. Какое именно, решило разрешение имён.

Дырки в машинном коде

Флаг -r у objdump печатает записки прямо под инструкциями, к которым они относятся. Это лучший способ их читать.

$ objdump -dr main.o

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
			8: R_X86_64_32	.data+0x10
   c:	be 00 00 00 00       	mov    $0x0,%esi
			d: R_X86_64_32	.data
  11:	ba 00 00 00 00       	mov    $0x0,%edx
			12: R_X86_64_32	.bss
  16:	e8 00 00 00 00       	call   1b <run+0x1b>
			17: R_X86_64_PLT32	addvec-0x4
  1b:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 21 <run+0x21>
			1d: R_X86_64_PC32	.bss+0x4
  21:	03 05 00 00 00 00    	add    0x0(%rip),%eax        # 27 <run+0x27>
			23: R_X86_64_PC32	.bss-0x4
  27:	5d                   	pop    %rbp
  28:	c3                   	ret

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

Семь дырок, семь записок. Пока дырки не заполнены, дизассемблер показывает смешное: call 1b, то есть вызов следующей же инструкции, потому что смещение ноль от конца call это он и есть. И mov 0x0(%rip),%eax читает собственные байты. Запускать это нельзя, но для объектника это нормальное состояние.

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

$ objdump -dr addvec.o

0000000000000000 <addvec>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 ff 05 00 00 00 00 	incq   0x0(%rip)        # b <addvec+0xb>
			7: R_X86_64_PC32	.bss-0x4
   b:	31 c0                	xor    %eax,%eax
   ...

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

Шаг первый: размещение

Прежде чем что-то вписывать, надо решить, где что лежит. Выпишем секции обоих объектников, которые попадают в память. Это те, у которых в readelf -S стоит флаг A.

объектниксекцияразмервыравнивание
main.o.text0x374
main.o.data0x208
main.o.bss0x108
addvec.o.text0x254
addvec.o.bss0x088

Есть ещё .eh_frame с таблицами раскрутки стека. Их читает обработчик исключений C++ и отладчик, а в программе без libc читать некому, поэтому zt ld их выбрасывает. Настоящий ld оставляет, увидишь разницу ниже.

Правила размещения у нас такие.

  1. Выходных секций четыре, в таком порядке: .text, .rodata, .data, .bss. Куда идёт входная секция, решают её флаги, а не имя: исполняемая идёт в .text, доступная на запись в .data, только для чтения в .rodata, а тип NOBITS означает .bss.
  2. Внутри выходной секции куски идут в порядке командной строки, каждый выравнивается по своему требованию.
  3. Статический бинарник x86-64 по традиции начинается с адреса 0x400000. Первая страница отдана заголовкам, поэтому .text встаёт на 0x401000.
  4. Каждая следующая выходная секция начинается с новой страницы, потому что у неё другие права доступа, а права выдаются страницами. Исключение одно: .bss встаёт сразу за .data, права у них одинаковые.

Считаем. .text из main.o ложится по смещению ноль и занимает 0x37 байтов. Следующий кусок должен быть выровнен на четыре, ближайшее кратное четырём после 0x37 это 0x38. Туда встаёт .text из addvec.o, и выходная секция получается длиной 0x38 + 0x25 = 0x5d. Один байт между кусками ничей.

.rodata пустая, её пропускаем. .data встаёт на следующую страницу, 0x402000, длина 0x20. .bss мы выравниваем на 64 байта: конец .data это 0x402020, ближайшая граница 0x402040. В ней сначала шестнадцать байтов z из main.o, потом восемь байтов addcnt из addvec.o, всего 0x18.

выходная секцияадресразмерчто внутри
.text0x4010000x5dmain.o с 0x401000, addvec.o с 0x401038
.data0x4020000x20y с 0x402000, x с 0x402010
.bss0x4020400x18z с 0x402040, addcnt с 0x402050

Теперь у каждого символа есть адрес: адрес выходной секции, плюс место входной секции внутри неё, плюс смещение символа во входной секции. run стоит в начале .text объектника main.o, значит его адрес 0x401000. _start лежит там же по смещению 0x29, получается 0x401029. addvec стоит в начале своего куска, 0x401038. Это и есть вся подготовка: дальше только арифметика.

Шаг второй: правка ссылок

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

  • S это адрес символа, на который ссылается записка. Тот самый, что мы только что посчитали.
  • A это слагаемое из записки, поле addend.
  • P это адрес самой дырки: адрес, по которому оказалась правимая секция, плюс offset из записки. В книге он называется refaddr.

Типов перемещений у x86-64 четыре десятка, но статической программе хватает двух формул.

типформулаполе
R_X86_64_32S + Aчетыре байта, расширяется нулями
R_X86_64_32SS + Aчетыре байта, расширяется знаком
R_X86_64_64S + Aвосемь байтов
R_X86_64_PC32S + A - Pчетыре байта со знаком
R_X86_64_PLT32S + A - Pчетыре байта со знаком

Абсолютный адрес: S + A

Первая записка: offset = 0x8, тип R_X86_64_32, символ .data, слагаемое 0x10. Секция .data объектника main.o оказалась по адресу 0x402000, значит

S + A = 0x402000 + 0x10 = 0x402010

Это адрес x. Вписываем четыре байта младшим вперёд: 10 20 40 00. Инструкция bf 00 00 00 00 превращается в bf 10 20 40 00, то есть mov $0x402010, %edi. Две соседние записки считаются так же: 0x402000 для y и 0x402040 для z.

Здесь есть тонкость, из-за которой типов два. Инструкция movl $imm32, %edi пишет в тридцатидвухбитную половину регистра, и процессор сам обнуляет старшую (это правило ты помнишь из урока про пересылки). В %rdi окажется верный адрес, только если он меньше четырёх гигабайт. Для таких мест служит R_X86_64_32. А инструкция вроде movq $imm32, %rax расширяет константу знаком, и для неё адрес должен помещаться в знаковые тридцать два бита, то есть быть меньше двух гигабайт. Это R_X86_64_32S. Компоновщик обязан проверить именно то условие, которое соответствует типу, и отказаться, если адрес не влезает. Молча обрезать нельзя: программа получится, запустится и прочитает чужую память.

Относительный адрес: S + A - P

Четвёртая записка: offset = 0x17, тип R_X86_64_PLT32, символ addvec, слагаемое минус четыре. Секция .text объектника main.o лежит по адресу 0x401000.

P         = 0x401000 + 0x17        = 0x401017
S + A - P = 0x401038 - 4 - 0x401017 = 0x1d

В дырку идёт 1d 00 00 00, инструкция становится e8 1d 00 00 00. Проверим глазами процессора. Он выполняет call по адресу 0x401016, инструкция занимает пять байтов, значит счётчик команд к моменту перехода уже показывает на 0x40101b. К нему прибавляется смещение: 0x40101b + 0x1d = 0x401038. Это addvec.

Вот откуда минус четыре. Процессор считает смещение от адреса следующей инструкции, а компоновщик знает только адрес дырки. Дырка у call занимает последние четыре байта инструкции, поэтому следующая инструкция начинается через четыре байта после начала дырки. Формула без слагаемого дала бы расстояние от дырки до цели, а нужно расстояние от конца дырки. Компилятор знает устройство инструкции, которую сам выписал, и кладёт поправку в addend. Компоновщику знать систему команд не нужно: он складывает и вычитает.

Из этого следует, что минус четыре это не закон. Это частный случай инструкции, у которой дырка стоит в самом конце. Вот как GCC компилирует counter++ для глобальной переменной:

   4:	48 83 05 00 00 00 00 	addq   $0x1,0x0(%rip)        # c <tick+0xc>
   b:	01
			7: R_X86_64_PC32	counter-0x5

После четырёх байтов смещения у инструкции есть ещё один байт, константа $0x1. От начала дырки до следующей инструкции пять байтов, и слагаемое равно минус пяти.

Посмотри теперь на пятую и шестую записки нашего main.o: .bss + 4 и .bss - 4. Это чтение z[1] и z[0]. Слагаемое сложено из двух частей: смещение элемента внутри секции (восемь и ноль) и та же поправка минус четыре. Компилятор сложил их заранее, и по записке уже не видно, где что. Считаем пятую:

P         = 0x401000 + 0x1d        = 0x40101d
S + A - P = 0x402040 + 4 - 0x40101d = 0x1027

Проверка: инструкция mov 0x1027(%rip), %eax стоит по адресу 0x40101b и занимает шесть байтов, %rip равен 0x401021, и 0x401021 + 0x1027 = 0x402048. Это z + 8, второй элемент. Сходится.

Последняя записка интереснее остальных, потому что цель лежит раньше вызова: _start зовёт run.

P         = 0x401000 + 0x2a        = 0x40102a
S + A - P = 0x401000 - 4 - 0x40102a = -0x2e

Минус 0x2e в дополнительном коде на тридцать два бита это 0xffffffd2, в байтах d2 ff ff ff. Проверка: 0x40102e - 0x2e = 0x401000.

Зачем PLT32, если PLT нет

Тип R_X86_64_PLT32 означает буквально следующее: впиши смещение до записи PLT для этого символа. PLT это таблица переходников для вызова функций из разделяемых библиотек, она появится в следующем уроке. Компилятор ставит этот тип на каждый call внешней функции, потому что не знает, будет программа статической или динамической. В статической сборке переходников нет, и ABI прямо разрешает компоновщику считать PLT32 так же, как PC32: вызов идёт прямо в функцию. Современные версии компиляторов ставят PLT32 даже на вызов функции из того же файла, как у нас с run. В книге ты встретишь PC32 на месте call, потому что её примеры сняты на старом GCC.

Попробуй сам

В виджете три записки из демо-объектника в духе примеров книги: вызов swap, чтение counter через %rip и абсолютный адрес array. Адреса секций можно менять. Пройди все три по шагам, потом сдвинь ADDR(.text) на страницу вперёд и посмотри, какие поля изменились, а какие нет. Потом вбей в ADDR(.data) адрес выше четырёх гигабайт.

Сдвиг .text меняет все относительные поля, которые смотрят в другие секции, и не меняет абсолютные. А если сдвинуть все секции разом на одну и ту же величину, относительные поля не изменятся вовсе, зато изменятся все абсолютные. Запомни это наблюдение: на нём стоит весь позиционно-независимый код следующего урока.

Сверка

Соберём программу своим компоновщиком (его код ниже, в шаге проекта) и посмотрим, что вписал он.

$ zt ld -o prog main.o addvec.o
$ ./prog; echo $?
10
$ objdump -d prog

0000000000401000 <run>:
  401000:	55                   	push   %rbp
  401001:	48 89 e5             	mov    %rsp,%rbp
  401004:	6a 02                	push   $0x2
  401006:	59                   	pop    %rcx
  401007:	bf 10 20 40 00       	mov    $0x402010,%edi
  40100c:	be 00 20 40 00       	mov    $0x402000,%esi
  401011:	ba 40 20 40 00       	mov    $0x402040,%edx
  401016:	e8 1d 00 00 00       	call   401038 <addvec>
  40101b:	8b 05 27 10 00 00    	mov    0x1027(%rip),%eax        # 402048 <addvec+0x1010>
  401021:	03 05 19 10 00 00    	add    0x1019(%rip),%eax        # 402040 <addvec+0x1008>
  401027:	5d                   	pop    %rbp
  401028:	c3                   	ret

0000000000401029 <_start>:
  401029:	e8 d2 ff ff ff       	call   401000 <run>
  40102e:	89 c7                	mov    %eax,%edi
  401030:	b8 3c 00 00 00       	mov    $0x3c,%eax
  401035:	0f 05                	syscall
  401037:	cc                   	int3

0000000000401038 <addvec>:
  401038:	55                   	push   %rbp
  401039:	48 89 e5             	mov    %rsp,%rbp
  40103c:	48 ff 05 0d 10 00 00 	incq   0x100d(%rip)        # 402050 <addcnt>
  ...

Все семь чисел на месте: 0x402010, 0x402000, 0x402040, 0x1d, 0x1027, 0x1019 и d2 ff ff ff. Подписи вроде <addvec+0x1010> у адресов данных не пугайся: objdump ищет ближайший символ снизу, а в таблице символов нашего файла нет z, он был локальным.

По адресу 0x401037 стоит тот самый ничей байт между кусками .text. Наш компоновщик забивает такие промежутки байтом 0xcc, инструкцией int3. Нули там опасны вдвойне: дизассемблер прочитал бы 00 55 48 как одну инструкцию и съел начало addvec, а процессор, залетев в промежуток по ошибке, молча поехал бы дальше. GNU ld кладёт туда nop.

Для сравнения то же самое настоящим компоновщиком:

$ ld -o prog-gnu main.o addvec.o
$ ./prog-gnu; echo $?
10
$ objdump -d prog-gnu
  401007:	bf 10 30 40 00       	mov    $0x403010,%edi
  40100c:	be 00 30 40 00       	mov    $0x403000,%esi
  401011:	ba 20 30 40 00       	mov    $0x403020,%edx
  401016:	e8 1d 00 00 00       	call   401038 <addvec>
  40101b:	8b 05 07 20 00 00    	mov    0x2007(%rip),%eax        # 403028 <__bss_start+0x8>

Код лежит там же, и call совпал до байта: 1d 00 00 00. А данные уехали на страницу дальше, на 0x403000, потому что GNU ld сохранил .eh_frame и отдал ей страницу 0x402000. Все поля, которые смотрят в данные, изменились соответственно. Размещение у каждого компоновщика своё, арифметика у всех одна.

Модели кода: когда четырёх байтов мало

Все дырки в нашем примере четырёхбайтные, хотя адреса в x86-64 восьмибайтные. Это осознанная экономия, и у неё есть имя: модель кода. Компилятор не знает адресов, но должен выбрать инструкцию уже сейчас, и от выбора зависит, сколько байтов под адрес в ней есть. Поэтому он исходит из обещания о будущем размещении.

Малая модель (small, она же по умолчанию) обещает: весь код и все данные программы лежат в нижних двух гигабайтах адресного пространства. Тогда любой адрес влезает в тридцать два бита со знаком, и любые два символа лежат ближе двух гигабайт друг к другу. Значит, годятся и movl $адрес, %edi с перемещением R_X86_64_32, и адресация через %rip с R_X86_64_PC32, и call rel32. Инструкции короткие, и почти весь мир живёт в этой модели.

Большая модель (large) не обещает ничего. Вот тот же исходник на C, собранный дважды:

long counter;
void bump(void);

void tick(void) {
    counter++;
    bump();
}
$ gcc -O1 -fno-pie -mcmodel=small -c model.c && objdump -dr model.o
   4:	48 83 05 00 00 00 00 	addq   $0x1,0x0(%rip)        # c <tick+0xc>
   b:	01
			7: R_X86_64_PC32	counter-0x5
   c:	e8 00 00 00 00       	call   11 <tick+0x11>
			d: R_X86_64_PLT32	bump-0x4

$ gcc -O1 -fno-pie -mcmodel=large -c model.c && objdump -dr model.o
   4:	48 b8 00 00 00 00 00 	movabs $0x0,%rax
   b:	00 00 00
			6: R_X86_64_64	counter
   e:	48 83 00 01          	addq   $0x1,(%rax)
  12:	48 b8 00 00 00 00 00 	movabs $0x0,%rax
  19:	00 00 00
			14: R_X86_64_64	bump
  1c:	ff d0                	call   *%rax

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

Средняя модель (medium) это компромисс для программ с гигантскими массивами: код и обычные данные живут по правилам малой, а большие объекты уезжают в отдельные секции .ldata и .lbss и адресуются по правилам большой.

Что будет, если обещание нарушить? Ровно то, что ты видел в виджете: число не влезет в поле. Настоящий компоновщик в этот момент печатает relocation truncated to fit: R_X86_64_32 against symbol ... и файла не создаёт. Наш тоже откажется, своими словами. Встретить эту ошибку в жизни можно, если объявить статический массив на несколько гигабайт или собрать чужой объектник с нестандартным скриптом компоновки.

Есть ещё одно измерение, не про размер, а про место: позиционно-независимый код. GCC в современных дистрибутивах по умолчанию собирает именно его (-fpie), поэтому в примере выше стоит -fno-pie. В таком коде абсолютных четырёхбайтных адресов нет вообще, всё адресуется через %rip, и программу можно загрузить по любому адресу. Наш main.o собран Zig без этого режима: R_X86_64_32 в нём есть, значит, программа привязана к своим адресам намертво. Для статического бинарника это нормально. Подробности в следующем уроке.

Исполняемый файл глазами ядра

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

Первое лежит в заголовке ELF, в поле e_entry. Туда мы пишем адрес символа _start. Имя это договорённость компоновщиков, а не ядра: ядро читает число. У GNU ld точку входа можно сменить флагом -e.

$ readelf -h prog
  Type:                              EXEC (Executable file)
  Entry point address:               0x401029
  Start of program headers:          64 (bytes into file)
  Number of program headers:         4

Тип файла сменился: у объектника было REL, у готовой программы EXEC. Для второго нужна новая таблица, которой у объектника не было вовсе.

Секции и сегменты

До сих пор мы смотрели на файл как на набор секций. Это взгляд компоновщика: секций много, у каждой имя и смысл. Ядру такие подробности не нужны. Ему всё равно, где кончается .data и начинается .bss и как называется код. Оно смотрит на файл через таблицу заголовков программы, и каждая запись в ней описывает сегмент: непрерывный кусок с одними правами. Секции с одинаковыми правами склеиваются в один сегмент.

$ readelf -lW prog

Elf file type is EXEC (Executable file)
Entry point 0x401029
There are 4 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  LOAD           0x000000 0x0000000000400000 0x0000000000400000 0x000120 0x000120 R   0x1000
  LOAD           0x001000 0x0000000000401000 0x0000000000401000 0x00005d 0x00005d R E 0x1000
  LOAD           0x002000 0x0000000000402000 0x0000000000402000 0x000020 0x000058 RW  0x1000
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0

 Section to Segment mapping:
  Segment Sections...
   00
   01     .text
   02     .data .bss
   03

Запись типа LOAD читается так: возьми FileSiz байтов файла начиная с Offset, положи их в память по адресу VirtAddr, дай области права Flg и сделай её длиной MemSiz. Пройдём по строкам.

Второй сегмент, код. 0x5d байтов со смещения 0x1000 ложатся на 0x401000 с правами на чтение и исполнение. Записи нет: попытка поменять собственный код кончится сигналом.

Третий сегмент, данные. Смотри на две колонки размеров. В файле сегмент занимает 0x20 байтов, это .data. В памяти 0x58: от 0x402000 до конца .bss на 0x402058. Хвост, которого нет в файле, ядро заполняет нулями. В этом и состоит весь смысл .bss: массив нулей на сто мегабайт занимает в файле ноль байтов и одно число в заголовке. Прав на исполнение у данных нет.

Правило страницы. У каждого LOAD смещение в файле и адрес в памяти дают одинаковый остаток от деления на размер страницы: 0x1000 и 0x401000, 0x2000 и 0x402000. Это жёсткое требование, и причина в том, как ядро грузит файл. Оно ничего не копирует. Оно отображает страницы файла в память через тот же механизм, что и mmap, а страница файла может лечь только на границу страницы памяти. Мы выполняем правило самым простым способом: каждая секция начинается с границы страницы и там и там. Платим за это нулями в файле: программа из девяноста трёх байтов кода весит 8872 байта. Настоящие компоновщики умеют плотнее, и в упражнениях ты посчитаешь как.

Первый сегмент, заголовки. Он отображает в память начало файла: сам заголовок ELF и эту самую таблицу, всего 0x120 байтов. Ядру Linux он не обязателен, и первая версия нашего компоновщика его не писала. На настоящем Linux всё работало. А под Rosetta, которая исполняет linux/amd64 контейнеры на маках с процессором Apple, бинарник падал на старте: эмулятор рассчитывает найти заголовки в памяти процесса. Так делают все компоновщики, на это же полагаются отладчики, и libc находит свои заголовки программы через вспомогательный вектор. Вывод на будущее: когда пишешь файл в чужом формате, делай как все, даже если спецификация разрешает иначе.

GNU_STACK. Это не кусок файла, а записка ядру: стек нужен с правами RW, без исполнения. Если записи нет, ядро ради совместимости с древними программами делает стек исполняемым. Одна запись из пятидесяти шести байтов закрывает целый класс атак, который ты видел в уроке про переполнение буфера.

У GNU ld для той же программы получилось пять записей: добавился сегмент только для чтения с .eh_frame. У программы с libc их больше десятка, к ним вернёмся в следующем уроке.

Таблица секций в исполняемом файле необязательна. Ядро её не читает, и команда strip с нужными флагами её убирает. Мы её пишем вместе с таблицей символов по одной причине: чтобы собранный файл открывался твоими же zt readelf, zt nm и zt disasm.

Загрузка

Ты набираешь ./prog. Оболочка делает fork, и потомок зовёт execve("./prog", argv, envp). Про эти вызовы будет отдельный урок, сейчас важно, что делает ядро внутри execve. Этот код в ядре называется загрузчиком.

  1. Читает первые байты файла и по сигнатуре 7f 45 4c 46 узнаёт ELF. Проверяет архитектуру и тип.
  2. Выбрасывает старое адресное пространство процесса целиком: код, данные, кучу и стек программы, которая вызвала execve.
  3. Проходит по таблице заголовков программы и для каждого LOAD создаёт отображение файла в память с нужными правами. Для хвоста MemSiz - FileSiz добавляет анонимные нулевые страницы.
  4. Создаёт стек у вершины пользовательского адресного пространства и кладёт на него аргументы, окружение и вспомогательный вектор.
  5. Если в файле есть запись INTERP, грузит ещё и указанный там динамический компоновщик и стартует с его точки входа. У нас её нет.
  6. Ставит счётчик команд на e_entry и возвращается в пользовательский режим.

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

Образ процесса

К моменту первой инструкции адресное пространство процесса выглядит как на классической схеме из книги. Снизу вверх: код, данные только для чтения, данные для записи и .bss, за ними куча, которая растёт вверх. Посередине область для отображений mmap и разделяемых библиотек. Наверху стек, который растёт вниз. Выше пользовательской половины живёт ядро, туда программе хода нет.

Посмотрим на живом процессе. Программа печатает по одному адресу из каждой области.

const std = @import("std");

var counter: u64 = 0;

pub fn main(init: std.process.Init) !void {
    var buf: [256]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;

    var local: u64 = 0;
    const heap = try init.gpa.create(u64);
    defer init.gpa.destroy(heap);

    try out.print("код     {x:0>12}\n", .{@intFromPtr(&main)});
    try out.print("данные  {x:0>12}\n", .{@intFromPtr(&counter)});
    try out.print("куча    {x:0>12}\n", .{@intFromPtr(heap)});
    try out.print("стек    {x:0>12}\n", .{@intFromPtr(&local)});
    try out.flush();
}
$ zig build-exe where.zig -target x86_64-linux -O ReleaseSmall -fno-strip
$ ./where
код     00000100ffe6
данные  00000104c010
куча    7fffff700000
стек    7ffffffff2b0
$ ./where
код     00000100ffe6
данные  00000104c010
куча    7fffff700000
стек    7ffffffff280

Снято в контейнере linux/amd64 под Rosetta. Код и данные стоят в самом низу и от запуска к запуску не двигаются: программа не позиционно-независимая, и её адреса вписаны в неё компоновщиком. Zig начинает образ с 0x1000000, а не с 0x400000, это выбор его компоновщика. Стек стоит у самой вершины пользовательских адресов, 0x7fff..., и его адрес немного гуляет: ядро случайно сдвигает стек при каждом запуске, чтобы атакующему было труднее угадать адрес. Куча у стандартного аллокатора Zig берётся через mmap, поэтому она оказалась не над .bss, как на схеме из книги, а в области отображений под стеком. На настоящем ядре Linux адреса отображений будут другими и тоже случайными, под Rosetta они стабильны. Полную карту областей с правами ты научишься читать в уроке про управление памятью, и там же zt получит подкоманду pmap.

Что лежит на стеке в момент старта

Ядро передаёт программе аргументы не через регистры, как при вызове функции, а через стек. В момент первой инструкции %rsp показывает на такую картину, снизу вверх по адресам:

%rsp →  argc
        argv[0]
        ...
        argv[argc - 1]
        0
        envp[0]
        ...
        0
        auxv[0].type, auxv[0].value
        ...
        AT_NULL, 0
        ... сами строки аргументов и окружения ...

Сначала число аргументов, потом указатели на строки, ноль, потом указатели на строки окружения, ноль, потом вспомогательный вектор. Это не кадр вызова: адреса возврата под argc нет, возвращаться некуда. Поэтому _start не может быть обычной функцией, и поэтому он никогда не делает ret.

От _start до main

Наш _start занимает три инструкции и игнорирует всё, что лежит на стеке. У программы на C с libc точка входа тоже называется _start, но писал её не ты. Она лежит в объектнике crt1.o, который драйвер gcc молча добавляет в командную строку компоновщика, и разрешение имён находит её так же, как любой другой символ.

$ gcc -O1 -o hello hello.c
$ objdump -d hello | grep -A12 '<_start>:'
0000000000001050 <_start>:
    1050:	31 ed                	xor    %ebp,%ebp
    1052:	49 89 d1             	mov    %rdx,%r9
    1055:	5e                   	pop    %rsi
    1056:	48 89 e2             	mov    %rsp,%rdx
    1059:	48 83 e4 f0          	and    $0xfffffffffffffff0,%rsp
    105d:	50                   	push   %rax
    105e:	54                   	push   %rsp
    105f:	45 31 c0             	xor    %r8d,%r8d
    1062:	31 c9                	xor    %ecx,%ecx
    1064:	48 8d 3d ce 00 00 00 	lea    0xce(%rip),%rdi        # 1139 <main>
    106b:	ff 15 4f 2f 00 00    	call   *0x2f4f(%rip)        # 3fc0 <__libc_start_main@GLIBC_2.34>
    1071:	f4                   	hlt

Читается легко, если помнить картинку стека. xor %ebp, %ebp обнуляет указатель кадра: это метка для отладчика, что глубже кадров нет. pop %rsi снимает со стека argc, и после этого %rsp показывает на argv[0], его и кладут в %rdx. Стек выравнивается на шестнадцать, как требует соглашение о вызовах. В %rdi идёт адрес main. Дальше вызов __libc_start_main(main, argc, argv, ...), а за ним hlt на случай, если он вернётся. Он не вернётся.

Функция __libc_start_main живёт в libc и делает всё то, что должно случиться до main: находит окружение и вспомогательный вектор за argv, настраивает локальную память потоков и канарейку стека (её значение берётся из AT_RANDOM), готовит стандартные потоки ввода и вывода, выполняет конструкторы из .init_array, регистрирует деструкторы. Потом зовёт main(argc, argv, envp), а его результат передаёт в exit, которая сбрасывает буферы stdio, выполняет обработчики atexit и только потом делает системный вызов. Вот почему return 0 из main завершает программу, хотя main это обычная функция и вернуться ей, казалось бы, некуда.

Что делает Zig вместо этого

Программа на Zig без -lc не содержит ни crt1.o, ни libc. Точку входа даёт стандартная библиотека, файл lib/std/start.zig. Когда в корневом файле есть pub fn main, std.start экспортирует символ _start сам:

$ zig build-exe hello.zig -target x86_64-linux -O ReleaseSmall -fno-strip
$ nm hello | grep -i start
000000000100feb8 T _start
000000000100fec6 t start.posixCallMainAndExit
$ objdump -d hello | grep -A4 '<_start>:'
000000000100feb8 <_start>:
 100feb8:	31 ed                	xor    %ebp,%ebp
 100feba:	48 89 e7             	mov    %rsp,%rdi
 100febd:	48 83 e4 f0          	and    $0xfffffffffffffff0,%rsp
 100fec1:	e8 00 00 00 00       	call   100fec6 <start.posixCallMainAndExit>

Четыре инструкции, и первые три тебе уже знакомы. В исходнике это функция с callconv(.naked) и ассемблерной вставкой, ровно как наш _start. Посмотри на call: смещение 00 00 00 00. Это не дырка, а честный ноль: цель стоит сразу за вызовом, S + A - P дало ноль.

Дальше всё на Zig. Функция posixCallMainAndExit получает исходный %rsp аргументом и разбирает ту самую картинку стека: читает argc, строит срезы argv и envp, находит за ними вспомогательный вектор. Из него она берёт адрес заголовков программы (AT_PHDR) и проходит по ним, как ядро: ищет сегмент PT_TLS, чтобы настроить локальную память потоков, и PT_GNU_STACK, чтобы узнать желаемый размер стека (компоновщик Zig пишет туда шестнадцать мегабайт). Для позиционно-независимой программы она ещё и применяет к самой себе перемещения, потому что динамического компоновщика рядом нет. Потом собирает std.process.Init с аргументами, окружением, аллокатором и io, зовёт твой main и завершает процесс системным вызовом exit_group с кодом, который зависит от результата: ноль при успехе, единица и трасса, если main вернул ошибку.

Разница с C не в количестве работы, а в том, где она лежит. У C это библиотека, собранная кем-то когда-то, и её _start приходит готовым объектником. У Zig это обычный исходник, который компилируется вместе с твоей программой: его можно прочитать, и оптимизатор видит его целиком. Если же собрать Zig-программу с -lc, то std.start отступает: экспортирует обычный main, а точку входа даёт libc.

$ zig build-exe hello.zig -target x86_64-linux -O ReleaseSmall -fno-strip -lc
$ nm hello | grep -i ' _start\| main\|libc_start_main$'
000000000104bb28 T __libc_start_main
0000000001013330 T _start
0000000001013346 T _start_c
0000000001013364 T main

Здесь Zig статически прилинковал musl, и цепочка стала классической: _start, _start_c, __libc_start_main, main.

Теперь понятно, почему наш учебный _start выглядит так, как выглядит. Он naked, потому что обычный пролог функции испортил бы стек, на котором лежит argc. Он зовёт run через call, а не содержит его тело, потому что компилятору нужна нормальная функция с нормальным кадром. Он делает exit сам, потому что больше некому. А стек он не выравнивает, потому что верит ядру: ABI обещает, что в момент старта %rsp кратен шестнадцати, call кладёт восемь байтов адреса возврата, и run получает стек ровно в том виде, какого требует соглашение о вызовах. И libc, и Zig на слово не верят и выравнивают сами: одна инструкция and дешевле, чем отладка падения на первой же инструкции SSE под необычным загрузчиком.

Шаг проекта: zt ld выдаёт файл

После прошлого урока компоновщик остановился на полпути. В src/ld.zig функция link зовёт resolve.resolve и возвращает разрешитель со списком ошибок, но поле image у результата всегда null. Вместо файла подкоманда печатает отчёт временной функцией report: кто вошёл в E и откуда взято каждое имя из D. Флага -o у zt ld ещё нет, потому что писать было нечего.

Сегодня это меняется. Что убираем: функцию report из src/ld.zig и её вызов из cmdLd. Отчёт своё отслужил, теперь на те же вопросы отвечает zt nm по готовому файлу. Не спутай её с тёзками: в resolve.zig с прошлого урока и в новом relocate.zig есть свои закрытые report(resolver, ...), и смысл у них другой, они кладут строку с ошибкой в diagnostics. Они остаются, уходит только публичная ld.report, которая печатала E и D. Что добавляем: размещение, перемещения, заголовки программы с записью файла и флаг -o.

src/elf/program.zig    заголовок программы Elf64_Phdr (новый)
src/elf.zig            одна строка: модуль program
src/link/layout.zig    размещение: выходные секции и адреса (новый)
src/link/relocate.zig  формулы перемещений и их применение (новый)
src/link/output.zig    запись исполняемого файла (новый)
src/ld.zig             четыре шага по порядку, report убран (переписан)
src/main.zig           cmdLd принимает -o и пишет файл (переписана функция)
build.zig              44 в списке project_steps
tests/step_44.zig      тесты шага (новый)

archive.zig, resolve.zig и tests/step_43.zig остаются как были, и тесты прошлого шага обязаны остаться зелёными. Один из них проверяет, что без _start поле image равно null: это по-прежнему правда, только теперь по другой причине.

Вся компоновка остаётся чистой функцией: байты объектников на входе, байты исполняемого файла на выходе, память из арены. Поэтому почти все тесты идут на любой машине, включая macOS, и только запуск результата требует x86-64 Linux.

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

Числа формата для этого шага ты завёл заранее, в уроке про ELF: в src/elf/format.zig уже лежат SegmentType с именами вроде load и gnu_stack, биты прав pf_r, pf_w, pf_x и константа program_header_size = 56. Не хватает самой структуры, она живёт в новом файле. Это extern struct, раскладка полей совпадает с файлом байт в байт, и comptime-проверка размера ловит опечатку в типе поля раньше любого теста. Порядок полей не такой, как в тридцатидвухбитном ELF: флаги переехали на второе место, чтобы восьмибайтные поля легли без дыр выравнивания.

//! Заголовки программы, `Elf64_Phdr`: взгляд на файл глазами ядра.
//!
//! Таблица секций нужна компоновщику, а ядру она безразлична. Ядро читает
//! заголовки программы: каждый описывает сегмент, то есть кусок файла,
//! который надо положить в память по такому-то адресу с такими-то правами.

const std = @import("std");

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

const File = file_mod.File;

/// Заголовок программы, `Elf64_Phdr`. Описывает один сегмент.
pub const ProgramHeader = extern struct {
    type: u32,
    flags: u32,
    offset: u64,
    vaddr: u64,
    paddr: u64,
    filesz: u64,
    memsz: u64,
    alignment: u64,

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

comptime {
    std.debug.assert(@sizeOf(ProgramHeader) == format.program_header_size);
}

pub fn programHeader(file: File, index: u16) file_mod.Error!ProgramHeader {
    if (index >= file.header.phnum) return error.Truncated;
    const offset = file.header.phoff + @as(u64, index) * file.header.phentsize;
    return file_mod.readStruct(ProgramHeader, file.bytes, offset);
}

В src/elf.zig рядом с остальными модулями добавь строку pub const program = @import("elf/program.zig");.

Размещение

Главная структура здесь Layout: для каждой входной секции она помнит, в какую выходную та попала и по какому смещению, а для каждой выходной хранит размер, адрес в памяти и смещение в файле. Из этого складывается единственный вопрос, который задают остальные части компоновщика: какой адрес у символа. На него отвечает symbolAddress, и в ней стоит посмотреть на ветку для глобальных имён. Значение глобального символа берётся не из того объектника, где встретилась ссылка, а из таблицы D, которую построило разрешение имён. Если в main.o лежит своё слабое определение addvec, а победило сильное из addvec.o, вызов должен уйти в сильное.

//! Вторая половина компоновщика, шаг первый: размещение.
//!
//! У каждого объектника свои `.text`, `.data` и `.bss`, и все они начинаются
//! с нуля. Компоновщик склеивает одноимённые по смыслу секции в четыре
//! выходных и раздаёт им адреса. После этого у каждого символа появляется
//! настоящий адрес: адрес выходной секции, плюс место входной секции внутри
//! неё, плюс смещение символа во входной секции.

const std = @import("std");

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

const format = elf.format;

/// Выходные секции в том порядке, в каком они лягут в память.
pub const Kind = enum(u8) {
    text,
    rodata,
    data,
    bss,

    pub const count = 4;

    pub fn name(kind: Kind) []const u8 {
        return switch (kind) {
            .text => ".text",
            .rodata => ".rodata",
            .data => ".data",
            .bss => ".bss",
        };
    }

    /// Куда идёт входная секция, решают её флаги, а не имя. `null` значит,
    /// что секция в память не попадает: отладочная информация, заметки,
    /// таблицы раскрутки стека, которые без libc читать некому.
    pub fn of(header: elf.SectionHeader) ?Kind {
        if (header.flags & format.shf_alloc == 0) return null;
        if (header.kind() == .nobits) return .bss;
        if (header.kind() != .progbits) return null;
        if (header.flags & format.shf_execinstr != 0) return .text;
        return if (header.flags & format.shf_write != 0) .data else .rodata;
    }
};

/// Адрес, с которого традиционно начинается статический бинарник x86-64.
pub const base_address: u64 = 0x400000;
pub const page_size: u64 = 0x1000;

/// Входная секция и её место в выходной.
pub const Placement = struct {
    object: u32,
    section: u32,
    kind: Kind,
    /// Смещение от начала выходной секции.
    offset: u64,
};

pub const Layout = struct {
    placements: []const Placement,
    /// Символы COMMON: имя и смещение в выходной `.bss`.
    commons: std.StringArrayHashMapUnmanaged(u64),
    sizes: [Kind.count]u64,
    addresses: [Kind.count]u64,
    /// Смещение байтов выходной секции в файле. У `.bss` байтов в файле нет.
    file_offsets: [Kind.count]u64,

    pub fn size(layout: Layout, kind: Kind) u64 {
        return layout.sizes[@intFromEnum(kind)];
    }

    pub fn address(layout: Layout, kind: Kind) u64 {
        return layout.addresses[@intFromEnum(kind)];
    }

    pub fn fileOffset(layout: Layout, kind: Kind) u64 {
        return layout.file_offsets[@intFromEnum(kind)];
    }

    /// Где оказалась входная секция. `null`, если её не размещали.
    pub fn find(layout: Layout, object: u32, section: u32) ?Placement {
        for (layout.placements) |placement| {
            if (placement.object == object and placement.section == section) return placement;
        }
        return null;
    }

    pub fn sectionAddress(layout: Layout, object: u32, section: u32) ?u64 {
        const placement = layout.find(object, section) orelse return null;
        return layout.address(placement.kind) + placement.offset;
    }

    /// Окончательный адрес символа, каким его видит объектник `object`.
    pub fn symbolAddress(
        layout: Layout,
        resolver: *const resolve.Resolver,
        object: u32,
        symbol: elf.Symbol,
    ) error{Unplaced}!u64 {
        if (symbol.binding() != .local) {
            // Глобальное имя значит то, что выбрало разрешение имён,
            // даже если в этом объектнике лежит своё, слабое определение.
            const name = resolver.objects.items[object].symbols.name(symbol);
            const definition = resolver.defined.get(name) orelse return 0; // слабая ссылка без определения
            if (definition.strength == .common) return layout.address(.bss) + layout.commons.get(name).?;
            return layout.definedAddress(definition.object, definition.symbol);
        }
        return layout.definedAddress(object, symbol);
    }

    fn definedAddress(layout: Layout, object: u32, symbol: elf.Symbol) error{Unplaced}!u64 {
        if (symbol.shndx == format.shn_abs) return symbol.value;
        const start = layout.sectionAddress(object, symbol.shndx) orelse return error.Unplaced;
        return start + symbol.value;
    }
};

/// Раскладывает секции всех объектников из E и раздаёт адреса.
pub fn place(arena: std.mem.Allocator, resolver: *const resolve.Resolver) !Layout {
    var placements: std.ArrayList(Placement) = .empty;
    var sizes = [_]u64{0} ** Kind.count;

    // Проход по видам снаружи, по объектникам внутри: внутри выходной секции
    // куски идут в порядке командной строки.
    for (std.enums.values(Kind)) |kind| {
        for (resolver.objects.items, 0..) |object, object_index| {
            var section: u32 = 0;
            while (section < object.file.sectionCount()) : (section += 1) {
                const header = try object.file.section(section);
                if (Kind.of(header) != kind or header.size == 0) continue;

                const total = &sizes[@intFromEnum(kind)];
                const offset = std.mem.alignForward(u64, total.*, @max(header.addralign, 1));
                try placements.append(arena, .{
                    .object = @intCast(object_index),
                    .section = section,
                    .kind = kind,
                    .offset = offset,
                });
                total.* = offset + header.size;
            }
        }
    }

    // Под COMMON места нет ни в одном объектнике: выделяем его в конце `.bss`.
    // У такого символа поле value хранит не адрес, а требуемое выравнивание.
    var commons: std.StringArrayHashMapUnmanaged(u64) = .empty;
    var defined = resolver.defined.iterator();
    while (defined.next()) |entry| {
        const definition = entry.value_ptr.*;
        if (definition.strength != .common) continue;
        const total = &sizes[@intFromEnum(Kind.bss)];
        const offset = std.mem.alignForward(u64, total.*, @max(definition.symbol.value, 1));
        try commons.put(arena, entry.key_ptr.*, offset);
        total.* = offset + definition.symbol.size;
    }

    var layout: Layout = .{
        .placements = placements.items,
        .commons = commons,
        .sizes = sizes,
        .addresses = undefined,
        .file_offsets = undefined,
    };
    assignAddresses(&layout);
    return layout;
}

/// Первая страница файла отдана заголовкам. Дальше каждая выходная секция
/// начинается с границы страницы и в файле, и в памяти, причём смещение в
/// файле и адрес отличаются ровно на `base_address`. Так ядру проще всего:
/// оно отображает страницы файла в память как есть. Исключение одно:
/// `.bss` встаёт вплотную за `.data`, потому что они делят один сегмент.
fn assignAddresses(layout: *Layout) void {
    var offset: u64 = page_size;
    for ([_]Kind{ .text, .rodata, .data }) |kind| {
        layout.file_offsets[@intFromEnum(kind)] = offset;
        layout.addresses[@intFromEnum(kind)] = base_address + offset;
        offset = std.mem.alignForward(u64, offset + layout.size(kind), page_size);
    }

    // ponytail: `.bss` выравниваем на 64 байта, этого хватает любому входному
    // выравниванию до 64. Больше понадобится: бери максимум по размещённым секциям.
    const data_end = layout.fileOffset(.data) + layout.size(.data);
    layout.file_offsets[@intFromEnum(Kind.bss)] = std.mem.alignForward(u64, data_end, 64);
    layout.addresses[@intFromEnum(Kind.bss)] = base_address + layout.fileOffset(.bss);
}

test "вид выходной секции определяют флаги" {
    var header = std.mem.zeroes(elf.SectionHeader);
    header.type = @intFromEnum(format.SectionType.progbits);
    header.flags = format.shf_alloc | format.shf_execinstr;
    try std.testing.expectEqual(@as(?Kind, .text), Kind.of(header));
    header.flags = format.shf_alloc;
    try std.testing.expectEqual(@as(?Kind, .rodata), Kind.of(header));
    header.flags = format.shf_alloc | format.shf_write;
    try std.testing.expectEqual(@as(?Kind, .data), Kind.of(header));
    header.type = @intFromEnum(format.SectionType.nobits);
    try std.testing.expectEqual(@as(?Kind, .bss), Kind.of(header));
    header.flags = 0;
    try std.testing.expectEqual(@as(?Kind, null), Kind.of(header));
}

Три места стоят отдельного слова.

Kind.of решает судьбу секции по флагам, а не по имени. Компилятор Zig с флагом -ffunction-sections кладёт каждую функцию в свою секцию .text.имя, и сравнение имён с .text их бы потеряло. Флаги не врут: исполняемое идёт к исполняемому.

Символы COMMON, которые ты встречал в прошлом уроке как неинициализированные глобальные переменные C, не имеют места ни в одном объектнике. У них в поле value лежит не адрес, а требуемое выравнивание. Место им выделяет компоновщик, в конце .bss, после того как победитель среди одноимённых определён.

assignAddresses держит инвариант: смещение в файле плюс base_address равно адресу. С ним правило страницы выполняется само, а перевод адреса в место в файле, который нужен следующему шагу, становится вычитанием.

Перемещения

Файл из двух функций. compute это формулы в чистом виде: тип, три числа, на выходе заплатка нужной ширины или ошибка. apply обходит все секции типа RELA всех объектников, для каждой записки находит S, A и P и вписывает результат прямо в образ файла.

//! Вторая половина компоновщика, шаг второй: перемещения.
//!
//! Компилятор не знал адресов и оставил в коде нули, а рядом, в `.rela.text`,
//! записки: «сюда впиши адрес такого-то символа». Теперь адреса известны, и
//! записки исполняются. В формулах три буквы из спецификации ABI:
//!   S  адрес символа, на который ссылаются;
//!   A  слагаемое из записи (addend);
//!   P  адрес места, куда вписывается результат.

const std = @import("std");

const elf = @import("../elf.zig");
const layout_mod = @import("layout.zig");
const resolve = @import("resolve.zig");

const RelocationType = elf.format.RelocationType;

pub const Error = error{ Unsupported, Overflow };

/// Что и какой ширины вписать на место перемещения.
pub const Patch = union(enum) {
    word32: u32,
    word64: u64,
};

/// Формулы перемещений. Чистая функция: три числа на входе, байты на выходе.
pub fn compute(kind: RelocationType, s: u64, a: i64, p: u64) Error!Patch {
    // Арифметика адресов идёт по модулю 2^64, поэтому сложение с переносом.
    const absolute = s +% @as(u64, @bitCast(a));
    return switch (kind) {
        // Абсолютный адрес целиком, восемь байтов.
        .abs64 => .{ .word64 = absolute },

        // Абсолютный адрес в четырёх байтах. Процессор расширит его нулями
        // (`movl $x, %edi`) или знаком (`movq $x, %rax`), и после расширения
        // должен получиться тот же адрес. Поэтому проверки разные.
        .abs32 => .{ .word32 = std.math.cast(u32, absolute) orelse return error.Overflow },
        .abs32s => blk: {
            const signed = std.math.cast(i32, @as(i64, @bitCast(absolute))) orelse return error.Overflow;
            break :blk .{ .word32 = @bitCast(signed) };
        },

        // Расстояние от места записи до символа. У статического бинарника
        // PLT нет, и вызов «через PLT» превращается в обычный прямой вызов.
        .pc32, .plt32 => blk: {
            const distance: i64 = @bitCast(absolute -% p);
            const signed = std.math.cast(i32, distance) orelse return error.Overflow;
            break :blk .{ .word32 = @bitCast(signed) };
        },

        else => error.Unsupported,
    };
}

/// Исполняет все перемещения размещённых секций прямо в образе файла.
/// Ошибки складывает в диагностику разрешителя, чтобы показать все сразу.
pub fn apply(
    image: []u8,
    resolver: *resolve.Resolver,
    layout: layout_mod.Layout,
) !void {
    for (resolver.objects.items, 0..) |object, object_index| {
        var index: u32 = 0;
        while (index < object.file.sectionCount()) : (index += 1) {
            const header = try object.file.section(index);
            if (header.kind() != .rela) continue;

            // Поле sh_info секции перемещений это номер секции, которую правим.
            // Если её не размещали (`.eh_frame`), то и править нечего.
            const target = layout.find(@intCast(object_index), header.info) orelse continue;
            const target_address = layout.address(target.kind) + target.offset;
            const target_offset = layout.fileOffset(target.kind) + target.offset;

            const entries = try object.file.sectionData(header);
            var number: u32 = 0;
            while (number < elf.relocs.count(entries)) : (number += 1) {
                const rela = try elf.relocs.get(entries, number);
                const symbol = try object.symbols.get(rela.symbolIndex());
                const s = layout.symbolAddress(resolver, @intCast(object_index), symbol) catch {
                    try report(resolver, "{s}: перемещение ссылается на секцию, которой нет в бинарнике", .{object.name});
                    continue;
                };

                const patch = compute(rela.kind(), s, rela.addend, target_address + rela.offset) catch |err| {
                    try report(resolver, "{s}: перемещение {s} по смещению 0x{x}: {s}", .{
                        object.name,
                        rela.kind().name(),
                        rela.offset,
                        switch (err) {
                            error.Unsupported => "такой тип zt ld не умеет",
                            error.Overflow => "адрес не влезает в четыре байта",
                        },
                    });
                    continue;
                };

                const place: usize = @intCast(target_offset + rela.offset);
                switch (patch) {
                    .word32 => |value| std.mem.writeInt(u32, image[place..][0..4], value, .little),
                    .word64 => |value| std.mem.writeInt(u64, image[place..][0..8], value, .little),
                }
            }
        }
    }
}

fn report(resolver: *resolve.Resolver, comptime fmt: []const u8, args: anytype) !void {
    const text = try std.fmt.allocPrint(resolver.arena, fmt, args);
    try resolver.diagnostics.append(resolver.arena, text);
}

test "пример из учебника: вызов sum из main через PC32" {
    // call по адресу 0x4004de, четыре байта смещения лежат с 0x4004df,
    // sum стоит по адресу 0x4004e8, слагаемое -4.
    const patch = try compute(.pc32, 0x4004e8, -4, 0x4004df);
    try std.testing.expectEqual(@as(u32, 0x5), patch.word32);
}

test "пример из учебника: абсолютный адрес array через 32" {
    const patch = try compute(.abs32, 0x601018, 0, 0x4004da);
    try std.testing.expectEqual(@as(u32, 0x601018), patch.word32);
}

test "вызов назад даёт отрицательное смещение" {
    const patch = try compute(.plt32, 0x401000, -4, 0x401020);
    try std.testing.expectEqual(@as(u32, @bitCast(@as(i32, -0x24))), patch.word32);
}

test "адрес выше четырёх гигабайт не влезает в R_X86_64_32" {
    try std.testing.expectError(error.Overflow, compute(.abs32, 0x1_0000_0000, 0, 0));
    try std.testing.expectError(error.Unsupported, compute(.gotpcrel, 0, 0, 0));
}

Арифметика идёт в u64 с операторами +% и -%, которые заворачиваются по модулю два в шестьдесят четвёртой, как регистры процессора. Отрицательное слагаемое превращается в беззнаковое через @bitCast, и сложение даёт верный ответ по тем же причинам, по которым это работает в железе: ты разбирал их в уроке про биты и целые. Знак возвращается в самом конце, когда расстояние проверяется на вместимость через std.math.cast. Эта функция возвращает null, если значение не влезает в целевой тип, и это ровно та проверка, которую делает ld перед тем как напечатать relocation truncated to fit.

Секция, которую правят, находится через поле sh_info заголовка секции перемещений. Если её не размещали, как .eh_frame, то и записки к ней пропускаются: layout.find вернёт null. Ошибки не прерывают работу, а копятся в диагностике разрешителя: пользователь увидит все непоместившиеся перемещения разом, а не по одному за запуск.

Два теста в конце файла считают примеры из книги: вызов sum из main и абсолютный адрес array.

Запись файла

Самый длинный файл шага, и почти весь он про аккуратное заполнение структур. Работа идёт в два приёма. copySections готовит образ: нули нужной длины, int3 на месте кода, поверх них байты входных секций. К этому образу relocate.apply применяет заплатки. Потом finish дописывает в хвост таблицу символов, строки и таблицу секций, а в оставленную под них первую страницу кладёт заголовок ELF и заголовки программы.

//! Вторая половина компоновщика, шаг третий: запись исполняемого файла.
//!
//! Ядру от файла нужно немного: заголовок ELF с точкой входа и заголовки
//! программы, по одному PT_LOAD на каждый кусок памяти со своими правами.
//! Таблица секций и символы ядру безразличны, но мы их тоже пишем, чтобы
//! собранный бинарник можно было открыть своими же `zt readelf`, `zt nm`
//! и `zt disasm`.

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

const elf = @import("../elf.zig");
const layout_mod = @import("layout.zig");
const resolve = @import("resolve.zig");

const format = elf.format;
const Kind = layout_mod.Kind;
const Layout = layout_mod.Layout;
const ProgramHeader = elf.program.ProgramHeader;

comptime {
    // Структуры пишутся в файл как есть, байт в байт. Это верно, только если
    // порядок байтов машины совпадает с порядком байтов ELF x86-64.
    if (builtin.cpu.arch.endian() != .little) @compileError("zt ld пишет ELF только на little endian машине");
}

/// Образ файла до заголовков: нули нужной длины и байты входных секций
/// на своих местах. Перемещения применяются к нему следующим шагом.
pub fn copySections(arena: std.mem.Allocator, resolver: *const resolve.Resolver, layout: Layout) ![]u8 {
    const loaded_end = layout.fileOffset(.data) + layout.size(.data);
    const image = try arena.alloc(u8, @intCast(loaded_end));
    @memset(image, 0);
    // Промежутки между кусками кода, которые оставило выравнивание, забиваем
    // байтом 0xCC, инструкцией int3. Нули здесь опасны вдвойне: дизассемблер
    // читает `00 55 48` как одну инструкцию и съедает начало следующей
    // функции, а процессор, залетев в промежуток, молча поехал бы дальше.
    const text_start: usize = @intCast(layout.fileOffset(.text));
    @memset(image[text_start..][0..@intCast(layout.size(.text))], 0xcc);

    for (layout.placements) |placement| {
        if (placement.kind == .bss) continue;
        const object = resolver.objects.items[placement.object];
        const data = try object.file.sectionData(try object.file.section(placement.section));
        const start: usize = @intCast(layout.fileOffset(placement.kind) + placement.offset);
        @memcpy(image[start..][0..data.len], data);
    }
    return image;
}

/// Дописывает к образу таблицу символов и таблицу секций, а в начало кладёт
/// заголовок ELF и заголовки программы. Возвращает готовый файл.
pub fn finish(
    arena: std.mem.Allocator,
    image: []const u8,
    resolver: *const resolve.Resolver,
    layout: Layout,
    entry: u64,
) ![]u8 {
    var file: std.ArrayList(u8) = .empty;
    try file.appendSlice(arena, image);

    // Номера выходных секций в таблице: нулевая пустая, дальше только непустые.
    var section_numbers = [_]u16{0} ** Kind.count;
    var next_number: u16 = 1;
    for (std.enums.values(Kind)) |kind| {
        if (layout.size(kind) == 0) continue;
        section_numbers[@intFromEnum(kind)] = next_number;
        next_number += 1;
    }
    const symtab_number = next_number;
    const strtab_number = next_number + 1;
    const shstrtab_number = next_number + 2;

    // Таблица символов: нулевая запись и все глобальные имена из D.
    var strtab: std.ArrayList(u8) = .empty;
    try strtab.append(arena, 0);
    var symtab: std.ArrayList(elf.Symbol) = .empty;
    try symtab.append(arena, std.mem.zeroes(elf.Symbol));

    var defined = resolver.defined.iterator();
    while (defined.next()) |entry_ptr| {
        const definition = entry_ptr.value_ptr.*;
        var symbol = definition.symbol;
        symbol.name = @intCast(strtab.items.len);
        try strtab.appendSlice(arena, entry_ptr.key_ptr.*);
        try strtab.append(arena, 0);

        symbol.value = try layout.symbolAddress(resolver, definition.object, definition.symbol);
        if (definition.strength == .common) {
            symbol.shndx = section_numbers[@intFromEnum(Kind.bss)];
        } else if (symbol.shndx != format.shn_abs) {
            const placement = layout.find(definition.object, symbol.shndx).?;
            symbol.shndx = section_numbers[@intFromEnum(placement.kind)];
        }
        try symtab.append(arena, symbol);
    }

    // Имена секций и сами заголовки секций.
    var shstrtab: std.ArrayList(u8) = .empty;
    try shstrtab.append(arena, 0);
    var headers: std.ArrayList(elf.SectionHeader) = .empty;
    try headers.append(arena, std.mem.zeroes(elf.SectionHeader));

    for (std.enums.values(Kind)) |kind| {
        if (layout.size(kind) == 0) continue;
        var flags: u64 = format.shf_alloc;
        if (kind == .text) flags |= format.shf_execinstr;
        if (kind == .data or kind == .bss) flags |= format.shf_write;
        try headers.append(arena, .{
            .name = try addName(arena, &shstrtab, kind.name()),
            .type = @intFromEnum(if (kind == .bss) format.SectionType.nobits else format.SectionType.progbits),
            .flags = flags,
            .addr = layout.address(kind),
            .offset = layout.fileOffset(kind),
            .size = layout.size(kind),
            .link = 0,
            .info = 0,
            .addralign = if (kind == .bss) 64 else layout_mod.page_size,
            .entsize = 0,
        });
    }

    const symtab_offset = try appendAligned(arena, &file, std.mem.sliceAsBytes(symtab.items));
    try headers.append(arena, .{
        .name = try addName(arena, &shstrtab, ".symtab"),
        .type = @intFromEnum(format.SectionType.symtab),
        .flags = 0,
        .addr = 0,
        .offset = symtab_offset,
        .size = symtab.items.len * format.symbol_size,
        .link = strtab_number,
        // Номер первого нелокального символа: локальных у нас нет, кроме нулевого.
        .info = 1,
        .addralign = 8,
        .entsize = format.symbol_size,
    });
    std.debug.assert(headers.items.len - 1 == symtab_number);

    const strtab_offset = try appendAligned(arena, &file, strtab.items);
    try headers.append(arena, stringTable(try addName(arena, &shstrtab, ".strtab"), strtab_offset, strtab.items.len));

    const shstrtab_name = try addName(arena, &shstrtab, ".shstrtab");
    const shstrtab_offset = try appendAligned(arena, &file, shstrtab.items);
    try headers.append(arena, stringTable(shstrtab_name, shstrtab_offset, shstrtab.items.len));

    const section_table_offset = try appendAligned(arena, &file, std.mem.sliceAsBytes(headers.items));

    // Заголовки программы: по одному PT_LOAD на непустой кусок памяти.
    var segments: std.ArrayList(ProgramHeader) = .empty;
    try appendLoad(arena, &segments, layout, .text, layout.size(.text), format.pf_r | format.pf_x);
    try appendLoad(arena, &segments, layout, .rodata, layout.size(.rodata), format.pf_r);
    try appendReadWrite(arena, &segments, layout);
    // PT_GNU_STACK без бита X просит ядро сделать стек неисполняемым.
    try segments.append(arena, .{
        .type = @intFromEnum(format.SegmentType.gnu_stack),
        .flags = format.pf_r | format.pf_w,
        .offset = 0,
        .vaddr = 0,
        .paddr = 0,
        .filesz = 0,
        .memsz = 0,
        .alignment = 0,
    });

    // И ещё один сегмент, самый первый: он отображает в память сами
    // заголовки, с нулевого смещения файла. Ядру Linux он не обязателен, но
    // так делают все компоновщики, и на это полагаются отладчики и
    // эмуляторы: Rosetta без него бинарник запускать отказывается.
    const headers_size = format.header_size + (segments.items.len + 1) * format.program_header_size;
    try segments.insert(arena, 0, .{
        .type = @intFromEnum(format.SegmentType.load),
        .flags = format.pf_r,
        .offset = 0,
        .vaddr = layout_mod.base_address,
        .paddr = layout_mod.base_address,
        .filesz = headers_size,
        .memsz = headers_size,
        .alignment = layout_mod.page_size,
    });

    var header = std.mem.zeroes(elf.Header);
    @memcpy(header.ident[0..4], format.magic);
    header.ident[4] = 2; // ELFCLASS64
    header.ident[5] = 1; // little endian
    header.ident[6] = 1; // версия формата
    header.type = @intFromEnum(format.FileType.executable);
    header.machine = format.machine_x86_64;
    header.version = 1;
    header.entry = entry;
    header.phoff = format.header_size;
    header.shoff = section_table_offset;
    header.ehsize = format.header_size;
    header.phentsize = format.program_header_size;
    header.phnum = @intCast(segments.items.len);
    header.shentsize = format.section_header_size;
    header.shnum = @intCast(headers.items.len);
    header.shstrndx = shstrtab_number;

    // Первая страница файла оставлена под заголовки, так что они точно влезают.
    const segment_bytes = std.mem.sliceAsBytes(segments.items);
    std.debug.assert(format.header_size + segment_bytes.len <= layout_mod.page_size);
    @memcpy(file.items[0..format.header_size], std.mem.asBytes(&header));
    @memcpy(file.items[format.header_size..][0..segment_bytes.len], segment_bytes);
    return file.items;
}

fn appendLoad(
    arena: std.mem.Allocator,
    segments: *std.ArrayList(ProgramHeader),
    layout: Layout,
    kind: Kind,
    size: u64,
    flags: u32,
) !void {
    if (size == 0) return;
    try segments.append(arena, .{
        .type = @intFromEnum(format.SegmentType.load),
        .flags = flags,
        .offset = layout.fileOffset(kind),
        .vaddr = layout.address(kind),
        .paddr = layout.address(kind),
        .filesz = size,
        .memsz = size,
        .alignment = layout_mod.page_size,
    });
}

/// `.data` и `.bss` живут в одном сегменте: в файле только байты `.data`,
/// а в памяти сегмент длиннее, и хвост ядро заполняет нулями. В этом и
/// состоит экономия `.bss`: миллион нулей в файле не занимает ничего.
fn appendReadWrite(arena: std.mem.Allocator, segments: *std.ArrayList(ProgramHeader), layout: Layout) !void {
    const memory_end = if (layout.size(.bss) > 0)
        layout.address(.bss) + layout.size(.bss)
    else
        layout.address(.data) + layout.size(.data);
    const memory_size = memory_end - layout.address(.data);
    if (memory_size == 0) return;

    try segments.append(arena, .{
        .type = @intFromEnum(format.SegmentType.load),
        .flags = format.pf_r | format.pf_w,
        .offset = layout.fileOffset(.data),
        .vaddr = layout.address(.data),
        .paddr = layout.address(.data),
        .filesz = layout.size(.data),
        .memsz = memory_size,
        .alignment = layout_mod.page_size,
    });
}

fn stringTable(name: u32, offset: u64, size: usize) elf.SectionHeader {
    return .{
        .name = name,
        .type = @intFromEnum(format.SectionType.strtab),
        .flags = 0,
        .addr = 0,
        .offset = offset,
        .size = size,
        .link = 0,
        .info = 0,
        .addralign = 1,
        .entsize = 0,
    };
}

/// Кладёт строку в таблицу строк и отдаёт её смещение.
fn addName(arena: std.mem.Allocator, strings: *std.ArrayList(u8), name: []const u8) !u32 {
    const offset: u32 = @intCast(strings.items.len);
    try strings.appendSlice(arena, name);
    try strings.append(arena, 0);
    return offset;
}

/// Дописывает байты в конец файла с выравниванием на восемь.
fn appendAligned(arena: std.mem.Allocator, file: *std.ArrayList(u8), bytes: []const u8) !u64 {
    const offset = std.mem.alignForward(usize, file.items.len, 8);
    try file.appendNTimes(arena, 0, offset - file.items.len);
    try file.appendSlice(arena, bytes);
    return offset;
}

В таблицу символов готового файла идут только глобальные имена из D, уже с настоящими адресами и с номерами выходных секций вместо входных. Поле sh_info у .symtab по правилам формата хранит номер первого нелокального символа, у нас это единица: локальных, кроме обязательной нулевой записи, нет.

Проверка порядка байтов в начале файла это плата за простоту. Структуры пишутся в файл через std.mem.asBytes и sliceAsBytes, то есть как лежат в памяти. ELF для x86-64 всегда little endian, и на машине с обратным порядком такой код молча написал бы мусор. Лучше пусть не соберётся. И на x86-64, и на Apple Silicon порядок тот, что нужен.

Четыре шага вместе

src/ld.zig переписывается целиком и теперь читается как оглавление. Сравни с прошлой версией: импортов стало пять вместо двух, report исчезла, а в link после разрешения имён появились три вызова и настоящий image. Порядок шагов жёсткий: без адресов нечего вписывать, без образа некуда вписывать, без вписанных адресов рано писать заголовки.

//! `zt ld`: статический компоновщик для x86-64 Linux.
//!
//! Работа идёт в четыре шага, по файлу на шаг:
//!   link/resolve.zig   какие объектники берём и какое определение у имени;
//!   link/layout.zig    где какая секция окажется в памяти;
//!   link/relocate.zig  вписать адреса в оставленные компилятором дырки;
//!   link/output.zig    заголовок ELF, заголовки программы, таблица символов.
//! Ни libc, ни динамической компоновки: на входе объектники со своим
//! `_start`, на выходе файл, который ядро запускает без посредников.

const std = @import("std");

pub const archive = @import("link/archive.zig");
pub const layout = @import("link/layout.zig");
pub const output = @import("link/output.zig");
pub const relocate = @import("link/relocate.zig");
pub const resolve = @import("link/resolve.zig");

pub const Input = resolve.Input;

pub const Result = struct {
    /// Готовый исполняемый файл или `null`, если были ошибки.
    image: ?[]const u8,
    /// Сообщения об ошибках, по строке на каждую.
    diagnostics: []const []const u8,
    resolver: resolve.Resolver,
};

/// Компонует файлы в порядке командной строки. Вся память берётся из арены:
/// результат живёт, пока жива она.
pub fn link(arena: std.mem.Allocator, inputs: []const Input) !Result {
    var resolver = try resolve.resolve(arena, inputs);

    const entry_definition = resolver.defined.get("_start");
    if (entry_definition == null) {
        try resolver.diagnostics.append(arena, "нет точки входа: никто не определил _start");
    }
    // С неразрешёнными именами адреса считать бессмысленно.
    if (resolver.failed()) return failure(resolver);

    const placed = try layout.place(arena, &resolver);
    const image = try output.copySections(arena, &resolver, placed);
    try relocate.apply(image, &resolver, placed);
    if (resolver.failed()) return failure(resolver);

    const definition = entry_definition.?;
    const entry = try placed.symbolAddress(&resolver, definition.object, definition.symbol);
    return .{
        .image = try output.finish(arena, image, &resolver, placed, entry),
        .diagnostics = &.{},
        .resolver = resolver,
    };
}

fn failure(resolver: resolve.Resolver) Result {
    return .{ .image = null, .diagnostics = resolver.diagnostics.items, .resolver = resolver };
}

test {
    std.testing.refAllDecls(@This());
}

Проверка _start переехала из прошлой версии почти без изменений, только теперь определение запоминается: после размещения из него получится адрес для e_entry. Если имена не сошлись, до размещения дело не доходит: адреса считать бессмысленно. Вторая проверка failed() стоит после перемещений, потому что и они умеют отказывать.

В src/main.zig функция cmdLd тоже переписывается. Цикл по аргументам учится флагу -o с именем выходного файла, без него команда отказывается работать. Вызов zt.ld.report в конце заменяется записью результата на диск с правом на исполнение. Если компоновка не удалась, файла не будет, а код возврата равен единице, как у настоящего ld. В тексте usage поправь строку на zt ld -o <выход> <файл.o или архив.a>....

fn cmdLd(
    init: std.process.Init,
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    args: []const []const u8,
) !void {
    var output: ?[]const u8 = null;
    var inputs: std.ArrayList(zt.ld.Input) = .empty;

    var index: usize = 0;
    while (index < args.len) : (index += 1) {
        const arg = args[index];
        if (std.mem.eql(u8, arg, "-o")) {
            index += 1;
            if (index == args.len) return fail(out, "zt ld: после -o нужно имя файла\n");
            output = args[index];
        } else if (std.mem.startsWith(u8, arg, "-")) {
            try out.print("zt ld: неизвестный флаг {s}\n", .{arg});
            return error.BadUsage;
        } else {
            // Порядок файлов важен, поэтому читаем и складываем как написано.
            const bytes = try std.Io.Dir.cwd().readFileAlloc(init.io, arg, gpa, file_limit);
            try inputs.append(gpa, .{ .name = arg, .bytes = bytes });
        }
    }

    const path = output orelse return fail(out, "zt ld: нужен флаг -o с именем выходного файла\n");
    if (inputs.items.len == 0) return fail(out, "zt ld: нечего компоновать\n");

    const result = zt.ld.link(gpa, inputs.items) catch |err| {
        try out.print("zt ld: входной файл не читается как объектник ELF64 ({t})\n", .{err});
        return error.BadUsage;
    };
    const image = result.image orelse {
        for (result.diagnostics) |line| try out.print("zt ld: {s}\n", .{line});
        // Код 1, как у настоящего ld: ошибка во входных файлах, а не в ключах.
        try out.flush();
        std.process.exit(1);
    };

    try std.Io.Dir.cwd().writeFile(init.io, .{
        .sub_path = path,
        .data = image,
        .flags = .{ .permissions = .executable_file },
    });
}

Тесты шага

Добавь 44 в список project_steps в build.zig, после 43. Объектники для тестов те же, что в прошлом уроке, из fixtures/link/.

//! Шаг 44: `zt ld`, размещение, перемещения и запись исполняемого файла.
//!
//! Компоновка это чистые вычисления над байтами, поэтому сборка бинарника и
//! проверка его содержимого идут на любой машине. Запустить результат можно
//! только там, где он родной: на x86-64 Linux. На остальных машинах тесты
//! запуска пропускаются.

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

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

const elf = zt.elf;
const layout = zt.ld.layout;
const Input = zt.ld.Input;

const main_o: Input = .{ .name = "main.o", .bytes = fixtures.link.main.bytes };
const addvec_o: Input = .{ .name = "addvec.o", .bytes = fixtures.link.addvec.bytes };
const weak_o: Input = .{ .name = "weak.o", .bytes = fixtures.link.weak.bytes };
const common_o: Input = .{ .name = "common.o", .bytes = fixtures.link.common.bytes };
const libvector_a: Input = .{ .name = "libvector.a", .bytes = fixtures.link.libvector };

fn linkOk(arena: std.mem.Allocator, inputs: []const Input) ![]const u8 {
    const result = try zt.ld.link(arena, inputs);
    for (result.diagnostics) |line| std.debug.print("zt ld: {s}\n", .{line});
    return result.image orelse error.LinkFailed;
}

/// Адрес символа из таблицы символов готового файла.
fn symbolAddress(file: elf.File, name: []const u8) !u64 {
    const table = try elf.SymbolTable.find(file, .symtab);
    var index: u32 = 0;
    while (index < table.count()) : (index += 1) {
        const symbol = try table.get(index);
        if (std.mem.eql(u8, table.name(symbol), name)) return symbol.value;
    }
    return error.SymbolNotFound;
}

test "заголовок: исполняемый файл с точкой входа на _start" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o }));

    try std.testing.expectEqual(@intFromEnum(elf.format.FileType.executable), file.header.type);
    try std.testing.expectEqual(try symbolAddress(file, "_start"), file.header.entry);
    // run занимает первые 0x29 байтов `.text` из main.o, за ним стоит _start.
    try std.testing.expectEqual(layout.base_address + layout.page_size + 0x29, file.header.entry);
}

test "сегменты: заголовки, код, данные вместе с .bss, стек" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o }));

    try std.testing.expectEqual(@as(u16, 4), file.header.phnum);

    const headers = try elf.program.programHeader(file, 0);
    try std.testing.expectEqual(elf.format.SegmentType.load, headers.kind());
    try std.testing.expectEqual(@as(u64, 0), headers.offset);
    try std.testing.expectEqual(layout.base_address, headers.vaddr);

    const code = try elf.program.programHeader(file, 1);
    try std.testing.expectEqual(elf.format.pf_r | elf.format.pf_x, code.flags);
    try std.testing.expectEqual(@as(u64, 0x401000), code.vaddr);
    // Смещение в файле и адрес обязаны совпадать по модулю страницы.
    try std.testing.expectEqual(code.offset % layout.page_size, code.vaddr % layout.page_size);

    const data = try elf.program.programHeader(file, 2);
    try std.testing.expectEqual(elf.format.pf_r | elf.format.pf_w, data.flags);
    // В памяти сегмент длиннее, чем в файле: хвост это `.bss`.
    try std.testing.expect(data.memsz > data.filesz);

    const stack = try elf.program.programHeader(file, 3);
    try std.testing.expectEqual(elf.format.SegmentType.gnu_stack, stack.kind());
    try std.testing.expectEqual(@as(u32, 0), stack.flags & elf.format.pf_x);
}

test "PLT32: вызов addvec указывает ровно на addvec" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o }));

    const text = try file.section(try file.findSection(".text"));
    const code = try file.sectionData(text);
    // В main.o инструкция call стоит по смещению 0x16 от начала run.
    const call = zt.x86.decoder.decode(code[0x16..], text.addr + 0x16);
    try std.testing.expectEqualStrings("call", call.mnemonic);
    try std.testing.expectEqual(try symbolAddress(file, "addvec"), call.operandSlice()[0].target);

    // А call из _start на run ссылается назад, смещение отрицательное.
    const start_call = zt.x86.decoder.decode(code[0x29..], text.addr + 0x29);
    try std.testing.expectEqual(try symbolAddress(file, "run"), start_call.operandSlice()[0].target);
}

test "R_X86_64_32: в movl вписаны абсолютные адреса массивов" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o }));

    const text = try file.section(try file.findSection(".text"));
    const data = try file.section(try file.findSection(".data"));
    const bss = try file.section(try file.findSection(".bss"));
    const code = try file.sectionData(text);

    // Перемещения main.o: `.data + 0x10` по смещению 0x8, `.data + 0` по 0xd, `.bss + 0` по 0x12.
    try std.testing.expectEqual(data.addr + 0x10, std.mem.readInt(u32, code[0x8..][0..4], .little));
    try std.testing.expectEqual(data.addr, std.mem.readInt(u32, code[0xd..][0..4], .little));
    try std.testing.expectEqual(bss.addr, std.mem.readInt(u32, code[0x12..][0..4], .little));
}

test "PC32: обращение к z считается от конца поля" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o }));

    const text = try file.section(try file.findSection(".text"));
    const bss = try file.section(try file.findSection(".bss"));
    const code = try file.sectionData(text);

    // `movl z+8(%rip), %eax` по адресу 0x1b: декодер сам сложит смещение
    // с адресом следующей инструкции, и должен получиться адрес z[1].
    const load = zt.x86.decoder.decode(code[0x1b..], text.addr + 0x1b);
    try std.testing.expectEqual(@as(?u64, bss.addr + 8), load.operandSlice()[0].memory.rip_target);
}

test "данные объектников скопированы, а секции идут в порядке командной строки" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o }));

    const data = try file.sectionData(try file.section(try file.findSection(".data")));
    // x = {1, 2}, y = {3, 4}: в main.o сначала лежит y, потом x.
    var values: [4]i64 = undefined;
    for (&values, 0..) |*value, index| value.* = std.mem.readInt(i64, data[index * 8 ..][0..8], .little);
    std.mem.sort(i64, &values, {}, std.sort.asc(i64));
    try std.testing.expectEqualSlices(i64, &.{ 1, 2, 3, 4 }, &values);

    // `.bss` main.o (z, 16 байтов) стоит раньше `.bss` addvec.o (addcnt).
    const bss = try file.section(try file.findSection(".bss"));
    try std.testing.expectEqual(bss.addr + 16, try symbolAddress(file, "addcnt"));
}

test "COMMON получает место в конце .bss" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, addvec_o, common_o }));

    const bss = try file.section(try file.findSection(".bss"));
    const counter = try symbolAddress(file, "shared_counter");
    try std.testing.expect(counter >= bss.addr + 24);
    try std.testing.expectEqual(bss.addr + bss.size, counter + 8);
    try std.testing.expectEqual(@as(u64, 0), counter % 8);
}

test "собранный файл читается своими же инструментами" {
    const gpa = std.testing.allocator;
    var arena: std.heap.ArenaAllocator = .init(gpa);
    defer arena.deinit();
    const file = try elf.File.parse(try linkOk(arena.allocator(), &.{ main_o, libvector_a }));

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try zt.nm.print(gpa, &out.writer, file);
    try std.testing.expectEqualStrings(
        \\0000000000401029 T _start
        \\0000000000402050 B addcnt
        \\0000000000401038 T addvec
        \\0000000000401000 T run
        \\
    , out.written());

    out.clearRetainingCapacity();
    try zt.disasm.disassembleFile(gpa, &out.writer, file);
    try std.testing.expect(std.mem.indexOf(u8, out.written(), "callq\t0x401038 <addvec>") != null);
    try std.testing.expect(std.mem.indexOf(u8, out.written(), "callq\t0x401000 <run>") != null);
    // Между `.text` двух объектников остался байт выравнивания. Он забит int3,
    // и разбор не съедает начало addvec: подпись функции на месте.
    try std.testing.expect(std.mem.indexOf(u8, out.written(), "int3\n\n0000000000401038 <addvec>:\n  401038: 55") != null);
}

test "ошибка разрешения имён не даёт файла" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const result = try zt.ld.link(arena.allocator(), &.{ libvector_a, main_o });
    try std.testing.expectEqual(@as(?[]const u8, null), result.image);
    try std.testing.expectEqual(@as(usize, 1), result.diagnostics.len);
}

/// Записывает бинарник во временный каталог, запускает и отдаёт код возврата.
fn run(arena: std.mem.Allocator, image: []const u8) !u8 {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{
        .sub_path = "prog",
        .data = image,
        .flags = .{ .permissions = .executable_file },
    });
    const path = try tmp.dir.realPathFileAlloc(io, "prog", arena);

    const result = try std.process.run(arena, io, .{ .argv = &.{path} });
    return switch (result.term) {
        .exited => |code| code,
        else => error.Crashed,
    };
}

const can_run = builtin.os.tag == .linux and builtin.cpu.arch == .x86_64;

test "бинарник из двух объектников запускается и возвращает 10" {
    if (!can_run) return error.SkipZigTest;
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    // addvec сложил {1, 2} и {3, 4}, run вернул 4 + 6.
    try std.testing.expectEqual(@as(u8, 10), try run(arena.allocator(), try linkOk(arena.allocator(), &.{ main_o, addvec_o })));
    try std.testing.expectEqual(@as(u8, 10), try run(arena.allocator(), try linkOk(arena.allocator(), &.{ main_o, libvector_a })));
}

test "по коду возврата видно, какое определение addvec победило" {
    if (!can_run) return error.SkipZigTest;
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    // Слабый addvec заполняет результат семёрками: 7 + 7.
    try std.testing.expectEqual(@as(u8, 14), try run(arena.allocator(), try linkOk(arena.allocator(), &.{ main_o, weak_o })));
    try std.testing.expectEqual(@as(u8, 10), try run(arena.allocator(), try linkOk(arena.allocator(), &.{ main_o, weak_o, addvec_o })));
}

Посмотри, как проверяются перемещения. Тест не сравнивает байты с заранее записанным ответом, он декодирует инструкцию твоим же дизассемблером из первых уроков проекта и спрашивает, куда она показывает. call по смещению 0x16 должен показывать ровно на адрес addvec из таблицы символов, а mov z+8(%rip) ровно на .bss + 8. Если завтра размещение изменится, тесты останутся зелёными, пока арифметика верна. Два компонента проекта проверяют друг друга.

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

$ zig build test -Dstep=44 --summary all
Build Summary: 3/3 steps succeeded; 9/11 tests passed (2 skipped)
$ zig build test --summary all
Build Summary: 21/21 steps succeeded; 129/131 tests passed (2 skipped)

Это на macOS: два теста запуска пропущены, все прошлые шаги зелёные. В контейнере linux/amd64 проходят все одиннадцать. Исходники в контейнер удобно монтировать только для чтения и копировать внутрь, потому что тесты запуска пишут бинарник во временный каталог рядом с кэшем сборки:

$ docker run --rm --platform linux/amd64 -v "$PWD":/w:ro <образ с zig 0.16> sh -c '
    mkdir /tmp/p && cd /w && cp -r build.zig src tests fixtures /tmp/p/ && cd /tmp/p &&
    zig build test -Dstep=44 --summary all --cache-dir /tmp/p/.zig-cache --global-cache-dir /tmp/g'
Build Summary: 3/3 steps succeeded; 11/11 tests passed

И главное, ради чего всё затевалось:

$ zig build run -- ld -o prog fixtures/link/main.o fixtures/link/libvector.a
$ zt nm prog
0000000000401029 T _start
0000000000402050 B addcnt
0000000000401038 T addvec
0000000000401000 T run
$ zt disasm prog | grep callq
  401016: e8 1d 00 00 00               	callq	0x401038 <addvec>
  401029: e8 d2 ff ff ff               	callq	0x401000 <run>

Файл собран твоим компоновщиком из объектников и архива, прочитан твоим nm и разобран твоим дизассемблером. На Linux он ещё и запускается. На маке собери его так же и выполни ./prog; echo $? в контейнере.

Шаг проекта: call rel32 в JIT zl

В уроке про JIT твой компилятор звал примитивы так:

48 b8 28 00 04 01 00 00 00 00    movabs $0x1040028, %rax
ff d0                            call   *%rax

Десять байтов на загрузку адреса и два на косвенный вызов, двенадцать байтов на каждое обращение к примитиву. Сегодня ты видел эту пару в выводе GCC: так выглядит вызов в большой модели кода. Тогда выбор был вынужденным, и в уроке было сказано почему: прямой call достаёт только на два гигабайта, а расстояние между страницей от mmap и образом программы никто не обещал. Теперь у тебя есть всё, чтобы сделать как в малой модели: e8 и четыре байта смещения, пять байтов вместо двенадцати. Кроме размера выигрывает и предсказатель переходов: у прямого вызова цель известна уже на этапе декодирования, а косвенный приходится угадывать по истории.

JIT при этом играет роль и компилятора, и компоновщика. Дырку оставлять незачем, адрес примитива известен прямо сейчас: @intFromPtr(call.prim). Остаётся посчитать S + A - P, где S это адрес примитива, A равно минус четырём, а P это адрес поля. И вот тут единственная тонкость: P это адрес поля там, где код будет исполняться, а не там, где он собирается. В уроке 19 байты копились в Buf на стеке и потом копировались в страницу от mmap. Для абсолютного movabs это было безразлично. Относительное смещение зависит от места, поэтому энкодеру нужен адрес будущего дома.

Шаг оформлен самостоятельным модулем без зависимостей от остального zl: его удобно проверить отдельно, а потом подключить.

//! Прямой вызов `call rel32` для JIT: пять байтов вместо двенадцати.
//!
//! Это то же перемещение R_X86_64_PC32, только без компоновщика: адрес цели
//! известен прямо сейчас, и поле заполняется в момент генерации кода.

const std = @import("std");

pub const Error = error{ OutOfRange, NoSpace };

/// Длина инструкции: опкод `E8` и четыре байта смещения.
pub const call_len = 5;

/// Значение поля rel32 по формуле компоновщика `S + A - P`.
/// S это адрес цели, P это адрес самого поля, A всегда минус четыре:
/// процессор считает переход от конца поля, то есть от следующей инструкции.
pub fn rel32(target: u64, field: u64) Error!i32 {
    const addend: i64 = -4;
    const distance: i64 = @bitCast(target +% @as(u64, @bitCast(addend)) -% field);
    return std.math.cast(i32, distance) orelse error.OutOfRange;
}

/// Пишет `call target` в `code` по смещению `at`. `base` это адрес, по
/// которому байт `code[0]` окажется в исполняемой памяти: смещение зависит
/// от того, где код будет жить, а не от того, где он собирается.
/// Возвращает смещение следующей инструкции.
pub fn emitCall(code: []u8, at: usize, base: u64, target: u64) Error!usize {
    if (at + call_len > code.len) return error.NoSpace;
    const value = try rel32(target, base + at + 1);
    code[at] = 0xE8;
    std.mem.writeInt(i32, code[at + 1 ..][0..4], value, .little);
    return at + call_len;
}

/// Дотянется ли `call rel32` из любой точки куска `[base, base + len)`.
pub fn reaches(base: u64, len: usize, target: u64) bool {
    _ = rel32(target, base + 1) catch return false;
    _ = rel32(target, base + len - 4) catch return false;
    return true;
}

test "вызов вперёд: пример из книги" {
    // call стоит по адресу 0x4004de, sum по адресу 0x4004e8.
    var code = [_]u8{0x90} ** 8;
    const next = try emitCall(&code, 0, 0x4004de, 0x4004e8);
    try std.testing.expectEqual(@as(usize, 5), next);
    try std.testing.expectEqualSlices(u8, &.{ 0xE8, 0x05, 0x00, 0x00, 0x00, 0x90, 0x90, 0x90 }, &code);
}

test "вызов назад: смещение в дополнительном коде" {
    // Как call run из _start: поле по адресу 0x40102a, цель 0x401000.
    var code = [_]u8{0} ** 5;
    _ = try emitCall(&code, 0, 0x401029, 0x401000);
    try std.testing.expectEqualSlices(u8, &.{ 0xE8, 0xD2, 0xFF, 0xFF, 0xFF }, &code);
}

test "смещение считается от места в памяти, а не в буфере" {
    var code = [_]u8{0} ** 16;
    _ = try emitCall(&code, 8, 0x7000_0000, 0x7000_0100);
    // Поле лежит по адресу 0x70000009, конец поля 0x7000000d.
    try std.testing.expectEqual(@as(i32, 0x100 - 0xd), std.mem.readInt(i32, code[9..13], .little));
}

test "границы: ровно два гигабайта назад можно, вперёд нельзя" {
    const field: u64 = 0x1_0000_0000;
    const end = field + 4;
    try std.testing.expectEqual(@as(i32, std.math.maxInt(i32)), try rel32(end + 0x7fff_ffff, field));
    try std.testing.expectError(error.OutOfRange, rel32(end + 0x8000_0000, field));
    try std.testing.expectEqual(@as(i32, std.math.minInt(i32)), try rel32(end - 0x8000_0000, field));
    try std.testing.expectError(error.OutOfRange, rel32(end - 0x8000_0001, field));
}

test "страница у вершины адресного пространства не дотягивается до образа" {
    // Образ программы у 0x1000000, страница от mmap у 0x7f0000000000.
    try std.testing.expect(!reaches(0x7f00_0000_0000, 4096, 0x100_ff56));
    try std.testing.expect(reaches(0x4100_0000, 4096, 0x100_ff56));
    var code = [_]u8{0xCC} ** 5;
    try std.testing.expectError(error.OutOfRange, emitCall(&code, 0, 0x7f00_0000_0000, 0x100_ff56));
    // При ошибке буфер не тронут.
    try std.testing.expectEqualSlices(u8, &.{ 0xCC, 0xCC, 0xCC, 0xCC, 0xCC }, &code);
    try std.testing.expectError(error.NoSpace, emitCall(code[0..4], 0, 0, 0));
}
$ zig test src/callrel.zig
All 5 tests passed.

Функция rel32 это compute(.pc32, ...) из твоего компоновщика, только слагаемое зашито внутрь: у call оно всегда минус четыре. Второй тест повторяет call run из _start, который ты считал руками в начале урока, и байты обязаны совпасть: e8 d2 ff ff ff. Если диапазона не хватило, emitCall возвращает ошибку и не трогает буфер, так что вызывающий может спокойно откатиться на старую пару movabs и call *%rax.

Когда не дотянется

Теперь проверим на живом процессе, дотягивается ли call rel32 от страницы JIT до функции в образе программы.

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

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

fn answer() callconv(.c) u64 {
    return 42;
}

const Page = []align(std.heap.page_size_min) u8;

fn mapPage(hint: ?[*]align(std.heap.page_size_min) u8) !Page {
    return std.posix.mmap(
        hint,
        std.heap.pageSize(),
        .{ .READ = true, .WRITE = true, .EXEC = true },
        .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
        -1,
        0,
    );
}

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;

    const target = @intFromPtr(&answer);
    try out.print("answer          {x}\n", .{target});

    // Без подсказки ядро кладёт страницу туда, где ему удобно.
    const far = try mapPage(null);
    defer std.posix.munmap(far);
    try out.print("mmap(null)      {x}  rel32 дотянется: {}\n", .{
        @intFromPtr(far.ptr), callrel.reaches(@intFromPtr(far.ptr), far.len, target),
    });

    // С подсказкой: просим адрес в 64 МБ за своим кодом. Это просьба, а не
    // приказ, поэтому результат всё равно проверяем.
    const wish = std.mem.alignBackward(usize, target, std.heap.pageSize()) + (64 << 20);
    const near = try mapPage(@ptrFromInt(wish));
    defer std.posix.munmap(near);
    const base = @intFromPtr(near.ptr);
    try out.print("mmap(подсказка) {x}  rel32 дотянется: {}\n", .{ base, callrel.reaches(base, near.len, target) });

    if (builtin.cpu.arch == .x86_64 and callrel.reaches(base, near.len, target)) {
        // sub $8, %rsp; call answer; add $8, %rsp; ret
        @memcpy(near[0..4], &[_]u8{ 0x48, 0x83, 0xEC, 0x08 });
        const next = try callrel.emitCall(near, 4, base, target);
        @memcpy(near[next..][0..5], &[_]u8{ 0x48, 0x83, 0xC4, 0x08, 0xC3 });

        try out.print("код:", .{});
        for (near[0 .. next + 5]) |byte| try out.print(" {x:0>2}", .{byte});
        const body: *const fn () callconv(.c) u64 = @ptrCast(near.ptr);
        try out.print("\nвызов вернул {d}\n", .{body()});
    }
    try out.flush();
}
$ zig build-exe near.zig -target x86_64-linux -O ReleaseSmall -fno-strip && ./near
answer          1040028
mmap(null)      7fffff7bd000  rel32 дотянется: false
mmap(подсказка) 5040000  rel32 дотянется: true
код: 48 83 ec 08 e8 1f 00 00 fc 48 83 c4 08 c3
вызов вернул 42

Снято в контейнере linux/amd64. Без подсказки ядро отдало страницу у вершины адресного пространства, рядом со стеком. До функции answer по адресу 0x1040028 оттуда сто двадцать восемь терабайт, а поле вмещает два гигабайта. Отсюда и movabs в уроке 19.

Первый аргумент mmap это подсказка: где бы ты хотел получить память. Мы попросили адрес через шестьдесят четыре мегабайта после своего кода, и ядро согласилось, потому что место было свободно. Проверь поле глазами: оно лежит по адресу 0x5040005, кончается на 0x5040009, и 0x1040028 - 0x5040009 это минус 0x3ffffe1, в дополнительном коде 0xfc00001f, в байтах 1f 00 00 fc. Вокруг call стоят sub $8, %rsp и add $8, %rsp: выравнивание стека на шестнадцать перед вызовом никуда не делось.

Слово “подсказка” тут точное. Если по желаемому адресу что-то уже лежит, ядро молча выберет другой, и mmap вернёт успех. Есть флаг MAP_FIXED, который превращает просьбу в приказ, но он так же молча затирает то, что там было, и для JIT это плохая сделка. Поэтому порядок действий всегда один: попросить, проверить через reaches, и если не вышло, попробовать соседний адрес или вернуться к дальнему вызову. Так живут настоящие JIT: V8 и JVM резервируют под весь сгенерированный код одну непрерывную область, чтобы внутри неё любой вызов был коротким, а для целей снаружи ставят переходники с полным адресом, точь-в-точь как компоновщик для дальних вызовов.

И ещё одно наблюдение. У нас получился случай, когда размещение подгоняют под перемещения, а не наоборот. Статический компоновщик выбирает адреса сам и потом проверяет, что всё влезло. JIT получает адрес от ядра, и если хочет короткие вызовы, должен договариваться о нём заранее.

На macOS. Исполняемые файлы там в формате Mach-O, и слова другие при той же механике. Вместо таблицы заголовков программы идут команды загрузки: LC_SEGMENT_64 это родственник PT_LOAD с теми же полями (адрес, размер в памяти, смещение и размер в файле, права), внутри сегмента перечислены его секции, например __TEXT,__text и __DATA,__data. Точку входа задаёт команда LC_MAIN, и хранит она не адрес, а смещение main от начала файла: своего _start у программы нет вовсе, его роль играет динамический компоновщик dyld, который готовит процесс и сам зовёт main. Статических исполняемых файлов без dyld macOS обычным программам не разрешает, поэтому трюк с _start из трёх инструкций там не повторить. Посмотреть всё это можно командами otool -l prog и otool -rv file.o для перемещений. На Apple Silicon перемещения устроены иначе и по существу: инструкции ARM64 имеют фиксированную длину четыре байта, тридцатидвухбитное поле в них не помещается, и адрес собирается двумя инструкциями, adrp со страницей и add со смещением внутри неё, с отдельным перемещением на каждую. Вызов bl несёт двадцать шесть бит смещения в словах, то есть достаёт на сто двадцать восемь мегабайт, и компоновщик вставляет переходники, когда не хватает. Весь урок снят в контейнере linux/amd64, на маках с Apple Silicon он работает через Rosetta, и у неё свои причуды: она требует сегмент с заголовками в памяти, как ты уже знаешь, а на стандартный бинарник Zig со срезанной таблицей символов отвечает rosetta error: bss_size overflow, поэтому в командах урока стоит -fno-strip. Программа near.zig на macOS упадёт с AccessDenied на первом же mmap: страницу с правами на запись и исполнение разом там не дают без флага MAP_JIT. Зато zt ld, zt readelf и все тесты шага, кроме двух последних, работают на маке как есть: компоновка это арифметика над байтами, и ей всё равно, на чём считать.

Практика

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

Упражнения

Итоги

  • После разрешения имён компоновщик размещает секции и правит ссылки. Размещение даёт адрес каждому символу: адрес выходной секции, плюс место входной секции в ней, плюс смещение символа.
  • Запись Elf64_Rela это три поля: смещение дырки в секции, номер символа вместе с типом и слагаемое. Ссылки на локальные данные идут через символ секции, смещение прячется в слагаемом.
  • Формул две. Абсолютная S + A для R_X86_64_32, 32S и 64. Относительная S + A - P для PC32 и PLT32. В статической сборке PLT32 считается как PC32.
  • Минус четыре в слагаемом это расстояние от начала поля до следующей инструкции. Если за полем в инструкции есть ещё байты, слагаемое другое.
  • Малая модель кода обещает, что всё лежит в нижних двух гигабайтах, и этим покупает четырёхбайтные поля. Большая модель ничего не обещает и платит movabs с косвенным вызовом. Нарушенное обещание компоновщик обязан обнаружить и не молчать.
  • Ядро смотрит на файл через заголовки программы. Сегмент LOAD это кусок файла, адрес, права и два размера. Разница между MemSiz и FileSiz это .bss. Смещение и адрес совпадают по модулю страницы, потому что ядро отображает файл, а не копирует.
  • По execve ядро сносит старое адресное пространство, отображает сегменты, строит стек с argc, argv, окружением и вспомогательным вектором и прыгает на e_entry. Страницы подгружаются по мере обращения.
  • _start это не функция: возвращаться ему некуда. У программы на C он приходит из crt1.o и зовёт __libc_start_main, которая готовит libc, зовёт main и потом exit. У Zig без libc _start лежит в std.start, остальное написано на Zig и компилируется вместе с программой.
  • JIT, который хочет call rel32, сам себе компоновщик: считает S + A - P от адреса будущей страницы, проверяет диапазон и договаривается с ядром об адресе через подсказку в mmap.

Дальше

Наш бинарник знает все свои адреса до запуска. В следующем уроке это перестаёт быть правдой: addvec уезжает в разделяемую библиотеку, которая загружается неизвестно куда, и часть работы компоновщика переносится на момент запуска программы. Формула S + A - P и наблюдение про то, что относительные поля не меняются при общем сдвиге, окажутся там главным инструментом, а тип PLT32 наконец оправдает своё имя.

домашка

Домашка