Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Процедуры и стековые кадры
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Процедуры и стековые кадры
Функция это не строчка языка, а протокол между двумя кусками кода. Одна инструкция кладёт адрес возврата на стек и прыгает, другая снимает его и прыгает обратно. Аргументы едут по строгому списку регистров, результат возвращается в оговорённом месте, а часть регистров вызываемый обязан вернуть нетронутыми. В этом уроке ты снимешь ассемблер обычных вызовов, вызовов с восемью аргументами, рекурсии и передачи структур по значению, и увидишь, что за каждым из них стоит один и тот же контракт.
Цели урока
- Понять, что делают
callиret: одна кладёт адрес возврата на стек, другая снимает и прыгает по нему. - Прочитать по ассемблеру стековый кадр: где сохранённый
%rbp, где адрес возврата, где локальные переменные. - Запомнить шесть целочисленных регистров аргументов System V и увидеть, как седьмой и восьмой аргументы уходят на стек.
- Отличать регистры, которые вызываемый обязан сохранить, от тех, что он волен затирать, и находить пары
pushиpopв прологе и эпилоге. - Объяснить, зачем
%rspвыравнивают на 16 передcallи как компилятор этого добивается. - Увидеть, что рекурсия это обычные кадры: десять вызовов дают десять одинаковых кадров, а оптимизатор иногда сворачивает хвост в цикл.
- Разобрать передачу и возврат структур по значению: маленькая едет в регистрах, большая через скрытый указатель.
- Восстановить рекурсивную функцию по её дизассемблеру.
Идея: вызов это контракт
Ты уже видел стек в уроке про операнды и mov: push уменьшает %rsp и пишет значение на вершину, pop читает и увеличивает %rsp. Стек растёт вниз, к младшим адресам. Теперь добавим к нему две инструкции, вокруг которых крутится весь механизм вызова.
Когда одна функция зовёт другую, им нужно договориться о четырёх вещах: куда положить аргументы, куда положить результат, куда вернуться после вызова и какие регистры переживут вызов, а какие нет. Договорённость называется соглашением о вызовах. На Linux x86-64 оно называется System V AMD64 ABI, и его соблюдает компилятор Zig ровно так же, как компилятор C: именно поэтому Zig-функцию с пометкой export можно позвать из C, как ты делал в уроке про comptime и C.
Ассемблер в этом уроке снят с реального Zig-кода командой zig build-obj file.zig -O ReleaseFast -target x86_64-linux -femit-bin=file.o и напечатан через objdump -d file.o. Синтаксис AT&T, как в книге: приёмник справа, источник слева, суффикс размера (q это 8 байт, l это 4), регистры с %, непосредственные значения с $. Ты уже читал такой в уроке про машинный код в Rust.
call и ret: два конца одной нити
Инструкция call target делает ровно две вещи: кладёт на стек адрес следующей за собой инструкции (это и есть адрес возврата) и прыгает на target. Инструкция ret снимает адрес возврата с вершины стека и прыгает по нему. Всё. call это push адреса плюс jmp, а ret это pop во внутренний счётчик команд.
Из этой пары вырастает дисциплина стека. Пока каждый call находит свой ret, а каждый push свой pop, стек после возврата функции выглядит ровно так же, как до вызова. Функция работает на своём участке стека, который называется стековым кадром, а %rbp часто указывает на его начало.
Виджет ниже прогоняет вызов square(5) по шагам. Жми «шаг» и следи за тремя вещами: как call кладёт адрес возврата на стек, как пролог сохраняет %rbp вызывающего и заводит новый кадр, как эпилог всё это снимает. Адреса на стеке убывают сверху вниз, вершина всегда на младшем адресе.
Обрати внимание на две пары. Первая: call кладёт адрес возврата, ret его снимает. Вторая: push %rbp в прологе сохраняет базу кадра вызывающего, pop %rbp в эпилоге её возвращает. Между ними mov %rsp, %rbp заводит новую базу, а sub $N, %rsp выделяет место под локальные. Кадр square не пересекается с кадром main, и именно поэтому вложенные вызовы, включая рекурсию, не мешают друг другу.
Шесть регистров аргументов и rax для результата
Первые шесть целочисленных аргументов (числа и указатели) едут в регистрах, в строгом порядке: %rdi, %rsi, %rdx, %rcx, %r8, %r9. Результат возвращается в %rax. Это надо помнить наизусть, потому что ты будешь узнавать этот порядок в каждом листинге.
Что происходит с седьмым и восьмым аргументом? Регистры кончились, и они едут на стек. Возьмём функцию, которая складывает восемь чисел, и посмотрим, откуда она их берёт.
export fn sum8(a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, g: u64, h: u64) u64 {
return a +% b +% c +% d +% e +% f +% g +% h;
}
# objdump -d args8.o (собрано -O ReleaseFast -target x86_64-linux)
sum8:
pushq %rbp
movq %rsp, %rbp
addq %rdi, %rsi # a + b
leaq (%rdx,%rcx), %rax # c + d, lea складывает без флагов
addq %rsi, %rax # + (a + b)
addq %r8, %rax # + e
addq %r9, %rax # + f
addq 0x10(%rbp), %rax # + g, седьмой аргумент со стека
addq 0x18(%rbp), %rax # + h, восьмой аргумент со стека
popq %rbp
retq
Шесть аргументов пришли в %rdi, %rsi, %rdx, %rcx, %r8, %r9, ровно по списку. А g и h функция читает по адресам 0x10(%rbp) и 0x18(%rbp). Почему такие смещения? После pushq %rbp и movq %rsp, %rbp внутри кадра сложилась известная раскладка: по адресу (%rbp) лежит сохранённый %rbp вызывающего, по 0x8(%rbp) адрес возврата, который положил call, а дальше вверх идут аргументы, что не поместились в регистры: 0x10(%rbp) это g, 0x18(%rbp) это h. Стек их туда положил вызывающий.
Теперь взгляд с другой стороны. Вот функция, которая зовёт sum8 с восемью константами, и её ассемблер показывает, как аргументы раскладываются перед вызовом.
extern fn sum8(a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, g: u64, h: u64) u64;
export fn caller() u64 {
return sum8(1, 2, 3, 4, 5, 6, 7, 8);
}
# objdump -d caller.o
caller:
pushq %rbp
movq %rsp, %rbp
movl $0x1, %edi # a в rdi (запись в edi обнуляет старшие 32 бита rdi)
movl $0x2, %esi # b в rsi
movl $0x3, %edx # c в rdx
movl $0x4, %ecx # d в rcx
movl $0x5, %r8d # e в r8
movl $0x6, %r9d # f в r9
pushq $0x8 # h на стек
pushq $0x7 # g на стек, ниже по адресу, чем h
callq sum8
addq $0x10, %rsp # снять два аргумента со стека
popq %rbp
retq
Шесть аргументов легли в те же шесть регистров. Седьмой и восьмой вызывающий положил на стек через pushq, причём в обратном порядке: сначала h, потом g, так что g оказался на младшем адресе, ближе к вершине. После call эти два слова видны вызываемому как 0x10(%rbp) и 0x18(%rbp). Заметь addq $0x10, %rsp сразу после возврата: вызывающий сам убирает со стека то, что туда положил, шестнадцать байт под два аргумента. Это часть контракта System V: аргументы на стеке чистит вызывающий.
Ещё одна мелочь, которая пугает новичков. Компилятор пишет movl $0x1, %edi, а не movq $0x1, %rdi, хотя аргумент восьмибайтовый. Причина в том, что любая запись в 32-битную половину регистра (%edi) обнуляет старшие 32 бита полного %rdi. Запись короче на байты, а результат тот же. Ты уже видел это правило в уроке про операнды.
Виджет ниже раскладывает произвольный список аргументов по регистрам и стеку. Добавляй целые и числа с плавающей точкой, меняй тип возврата, попробуй готовые примеры. Главное, что стоит покрутить: смешать целые и float и увидеть, что у них две отдельные очереди регистров.
Целые и указатели заполняют очередь из шести регистров, а числа с плавающей точкой отдельную очередь из восьми (%xmm0 до %xmm7). Очереди двигаются независимо: функция f(a: u64, b: f64, c: u64, d: f64) положит a в %rdi, c в %rsi, b в %xmm0, d в %xmm1. Это не выдумка виджета, это реальная раскладка, к ней вернёмся в разделе про структуры.
Кто хранит регистры: callee-saved против caller-saved
Регистров мало, а функций в цепочке вызовов много, и все хотят ими пользоваться. Чтобы не затирать чужое, System V делит регистры на две группы.
callee-saved регистры (%rbx, %rbp, %r12, %r13, %r14, %r15) вызываемая функция обязана вернуть нетронутыми. Хочешь ими пользоваться, сначала сохрани на стек, перед выходом верни. Их ещё называют сохраняемыми вызываемым.
caller-saved регистры (регистры аргументов плюс %rax, %r10, %r11) вызываемая функция вправе затирать как угодно. Если их значение понадобится после вызова, сохранять их обязан вызывающий, до call.
Правило видно в ассемблере невооружённым глазом. Возьмём функцию, которой нужно удержать один из аргументов через чужой вызов.
extern fn helper(x: u64) u64;
export fn keepAcross(a: u64, b: u64) u64 {
const h = helper(a);
return h +% b;
}
Аргумент b пришёл в %rsi. Но %rsi это caller-saved регистр, и call helper вправе его затереть. Значит, b надо где-то спрятать на время вызова. Смотри, куда компилятор его убрал.
# objdump -d keep.o
keepAcross:
pushq %rbp
movq %rsp, %rbp
pushq %rbx # сохранить callee-saved rbx: мы будем им пользоваться
pushq %rax # выровнять стек на 16 перед call (см. ниже)
movq %rsi, %rbx # b переехал в callee-saved rbx, он переживёт вызов
callq helper # a уже в rdi; helper вернёт результат в rax
addq %rbx, %rax # h (в rax) + b (в rbx)
addq $0x8, %rsp # отменить выравнивающий push
popq %rbx # вернуть rbx вызывающему в целости
popq %rbp
retq
Разбери по шагам. Компилятор перекладывает b из caller-saved %rsi в callee-saved %rbx, потому что %rbx по контракту переживёт call helper, а %rsi нет. Но взять %rbx просто так нельзя: он принадлежит вызывающему. Поэтому в прологе стоит pushq %rbx, а в эпилоге popq %rbx, эта пара честно возвращает регистр владельцу. Аргумент a остался в %rdi и поехал в helper без пересадки. Результат helper пришёл в %rax, а сложение %rbx с %rax дало h + b.
Выравнивание стека на 16
В том же листинге есть на первый взгляд бессмысленная инструкция: pushq %rax, значение которой никто не читает, и парная ей addq $0x8, %rsp. Это выравнивание стека.
System V требует: в момент выполнения call значение %rsp должно быть кратно 16. Выравнивание на 16 нужно, чтобы вызываемая функция могла класть на стек векторные значения SSE, которым нужен адрес, кратный 16, и вообще чтобы раскладка её кадра была предсказуемой.
Посчитаем %rsp по шагам. При входе в keepAcross сам call из вызывающего уже положил восьмибайтовый адрес возврата, поэтому %rsp кратен 16 минус 8. Дальше pushq %rbp вычитает 8, и %rsp становится кратен 16. pushq %rbx снова вычитает 8, %rsp кратен 16 минус 8. Если бы теперь пошёл call helper, контракт был бы нарушен. Поэтому компилятор добавляет ещё один pushq (значение неважно, взяли %rax), возвращая %rsp к кратному 16. После вызова addq $0x8, %rsp убирает этот фиктивный push. Пары нечётного числа push компилятор добивает до чётного именно так.
Это же правило объясняет subq $N, %rsp в прологах, где N всегда подобрано так, чтобы удержать выравнивание. Забудешь про него в рукописном ассемблере, и первый же call в функцию со movaps упадёт.
Локальные переменные: в регистре или на стеке
Не каждая локальная переменная попадает на стек. Если компилятору хватает регистров и у переменной никогда не берут адрес, она живёт в регистре и на стеке её нет вовсе. На стек переменная переезжает по одной из трёх причин: у неё берут адрес (&x), она слишком большая для регистра (массив, большая структура), или регистры закончились и что-то надо временно вытеснить в память. Последнее называют сбросом в память.
Ты уже видел это в листинге keepAcross: значение b жило в регистре %rbx, на стек не попало ни на миг. А в рекурсии ниже увидишь обратное: аргумент придётся сбросить на стек, потому что рекурсивный call затрёт регистр, в котором он лежал.
Практический вывод. Когда в Zig ты пишешь var x: u32 = 5; и нигде не берёшь &x, скорее всего никакого «места в памяти» под x не будет: оптимизатор оставит её в регистре. Как только появляется &x или ты кладёшь адрес в срез, переменная обязана иметь адрес, и компилятор выделяет ей ячейку кадра. Это прямая цена операции взятия адреса, о которой полезно помнить.
Рекурсия это обычный кадр
Про рекурсию любят говорить как про что-то особенное. На уровне машины ничего особенного в ней нет: рекурсивный вызов это такой же call, а каждый заход в функцию получает свой кадр. Десять вложенных вызовов это десять кадров, наставленных друг на друга на стеке, и они не мешают друг другу ровно потому, что у каждого свой участок.
Возьмём факториал. Чтобы кадр и вызов были видны, соберём его в отладочном режиме, где компилятор не разворачивает и не сворачивает код.
export fn fact(n: u64) u64 {
if (n <= 1) return 1;
return n *% fact(n -% 1);
}
# objdump -d fact.o (собрано без -O, отладочная раскладка)
fact:
pushq %rbp
movq %rsp, %rbp
subq $0x20, %rsp # завести кадр
movq %rdi, (%rsp) # сбросить n в кадр: rdi не переживёт call
cmpq $0x1, %rdi
ja .Lrec # if (n > 1) в рекурсию
movl $0x1, %eax # база: return 1
jmp .Lepi
.Lrec:
movq %rdi, %rax
subq $0x1, %rax # n - 1
movq %rax, 0x8(%rsp)
movq %rdi, 0x10(%rsp) # сохранить n на время вызова
movq 0x8(%rsp), %rdi # аргумент рекурсии = n - 1
callq fact # тот же call, что и любой другой
imulq 0x10(%rsp), %rax # n * fact(n - 1), n взяли из кадра
.Lepi:
movq %rbp, %rsp # эпилог: снять кадр
popq %rbp
retq
Ничего нового. Пролог заводит кадр (subq $0x20, %rsp), тело проверяет базу (cmpq $0x1), а рекурсивный вызов это callq fact, буквально та же инструкция, которой caller звал sum8. Один момент стоит внимания: перед call компилятор сбрасывает n в кадр по адресу 0x10(%rsp), а после вызова читает его оттуда для imulq. Почему? Потому что n лежал в %rdi, а %rdi это caller-saved регистр аргументов, и callq fact его затрёт (внутренний вызов положит туда свой n - 1). Значение, которое нужно после вызова, обязано пережить его либо в callee-saved регистре, либо в кадре. Здесь компилятор выбрал кадр.
Отсюда и цена рекурсии: каждый незавершённый вызов держит свой кадр на стеке, и стек не бесконечен. Тысячи вложенных вызовов упрутся в его предел, и программа получит переполнение стека. Это не абстракция, а тот же счётчик %rsp, который уезжает вниз на каждый пролог.
Интересно, что для некоторых функций компилятор рекурсию убирает. Соберём в режиме с оптимизацией не факториал, а функцию, где результат вызова сразу складывается с аргументом:
export fn rfun(x: u64) u64 {
if (x == 0) return 0;
return x +% rfun(x >> 2);
}
# objdump -d rfun.o (собрано -O ReleaseSafe)
rfun:
pushq %rbp
movq %rsp, %rbp
xorl %eax, %eax # acc = 0
testq %rdi, %rdi
je .Ldone # if (x == 0)
movq %rdi, %rcx # затравка: rcx = x
nop
.Lloop:
shrq $0x2, %rcx # rcx >>= 2
addq %rdi, %rax # acc += x
cmpq $0x4, %rdi
movq %rcx, %rdi # x = rcx
jae .Lloop # петля вместо call
.Ldone:
popq %rbp
retq
Ни одного call. Оптимизатор заметил, что сложение можно накапливать в %rax и переписал рекурсию циклом. Это законно, пока видимое поведение то же, и связано с тем, о чём был урок про циклы и switch: цикл и хвостовая рекурсия это две записи одного и того же графа переходов. Отладочная сборка честно рекурсивна, оптимизированная свёрнута в петлю, а результат одинаковый.
Реверс: восстановить функцию по её кадру
Настоящее умение читать ассемблер это восстановить исходник по листингу, как в упражнениях книги с 3.5 по 3.35. Возьмём дизассемблер рекурсивной функции и разберём его построчно, а потом ты повторишь тот же приём в код-задаче.
# objdump -d mystery.o (отладочная раскладка)
mystery:
pushq %rbp
movq %rsp, %rbp
subq $0x20, %rsp
movq %rdi, (%rsp) # сохранить аргумент x
cmpq $0x1, %rdi
ja .Lrec # if (x > 1) в рекурсию
movq %rdi, %rax # база: return x
jmp .Lepi
.Lrec:
movq %rdi, %rax
andq $0x1, %rax # x & 1, посчитали до вызова
shrq %rdi # x >> 1 (сдвиг на один бит)
movq %rax, 0x8(%rsp) # сохранить (x & 1) на время вызова
movq %rdi, 0x10(%rsp)
movq 0x10(%rsp), %rdi # аргумент рекурсии = x >> 1
callq mystery
addq %rax, 0x8(%rsp) # (x & 1) + mystery(x >> 1)
movq 0x8(%rsp), %rax
.Lepi:
movq %rbp, %rsp
popq %rbp
retq
Читаем сверху вниз. Функция сохраняет x, сравнивает с единицей: при x <= 1 возвращает сам x (ветка сразу после cmpq). Иначе берёт младший бит x (andq $0x1), сдвигает x вправо на бит (shrq %rdi это сдвиг на один), зовёт себя от сдвинутого значения, а результат вызова складывает с сохранённым младшим битом. То есть функция считает число единичных битов в x: младший бит плюс число единиц в остатке. На Zig это
fn mystery(x: u64) u64 {
if (x <= 1) return x;
return (x & 1) +% mystery(x >> 1);
}
Приём всегда один: найди базу (условие выхода без рекурсии), найди аргумент рекурсивного вызова (что лежит в %rdi перед call), найди, как результат вызова комбинируется с сохранённым значением после возврата. Три вопроса, три строчки исходника.
Структуры по значению: регистры или скрытый указатель
Аргумент не обязан быть одним числом. Что происходит, когда в функцию передают структуру целиком, по значению? Ответ System V зависит от размера, и граница проходит по 16 байтам.
Чтобы у структуры была предсказуемая раскладка в памяти, помечаем её extern struct: тогда поля лежат подряд, в порядке объявления, как в C. Обычный struct в Zig компилятор волен переставлять и не гарантирует раскладку, поэтому через границу C-совместимого вызова его передавать нельзя (компилятор так и скажет: у структуры с автоматической раскладкой нет гарантированного представления в памяти). Возьмём две структуры: одну на 16 байт, другую на 24.
const Small = extern struct { a: u64, b: u64 };
const Big = extern struct { a: u64, b: u64, c: u64 };
export fn small_sum(s: Small) u64 {
return s.a +% s.b;
}
export fn big_sum(s: Big) u64 {
return s.a +% s.b +% s.c;
}
# objdump -d structs.o
small_sum:
pushq %rbp
movq %rsp, %rbp
leaq (%rdi,%rsi), %rax # s.a в rdi, s.b в rsi: два поля в двух регистрах
popq %rbp
retq
big_sum:
pushq %rbp
movq %rsp, %rbp
movq 0x18(%rbp), %rax # s.b
addq 0x10(%rbp), %rax # + s.a
addq 0x20(%rbp), %rax # + s.c
popq %rbp
retq
Small это 16 байт, и функция получила её в двух регистрах: s.a в %rdi, s.b в %rsi. Никакой памяти, структуру разложили по регистрам как два отдельных аргумента. Big это 24 байта, больше 16, и такую структуру System V передаёт через стек: вызывающий копирует её на стек, а big_sum читает поля по 0x10(%rbp), 0x18(%rbp), 0x20(%rbp), ровно там, где были бы аргументы, не влезшие в регистры.
С возвратом та же граница, но большую структуру возвращают иначе: через скрытый указатель.
export fn make_small(x: u64) Small {
return .{ .a = x, .b = x +% 1 };
}
export fn make_big(x: u64) Big {
return .{ .a = x, .b = x +% 1, .c = x +% 2 };
}
# objdump -d structs.o
make_small:
pushq %rbp
movq %rsp, %rbp
movq %rdi, %rax # вернуть a в rax
leaq 0x1(%rdi), %rdx # вернуть b в rdx
popq %rbp
retq
make_big:
pushq %rbp
movq %rsp, %rbp
movq %rdi, %rax # rdi это адрес места под результат (скрытый первый аргумент)
movq %rsi, (%rdi) # a (настоящий x приехал в rsi, сдвинулся на один регистр)
leaq 0x1(%rsi), %rcx
movq %rcx, 0x8(%rdi) # b
addq $0x2, %rsi
movq %rsi, 0x10(%rdi) # c
popq %rbp
retq
make_small возвращает 16 байт в паре регистров %rax и %rdx. А make_big возвращает 24 байта через скрытый указатель: вызывающий заранее выделяет место под результат и кладёт его адрес в %rdi как невидимый нулевой аргумент. Из-за этого настоящий x сдвинулся с %rdi на %rsi, а функция пишет три поля по этому адресу и возвращает сам адрес в %rax. Это домашнее задание книги про структуры по значению: увидеть, где проходит граница между регистрами и памятью, и что возврат большой структуры это лишний указатель и лишнее копирование.
Правило целиком: аргумент или возврат до 16 байт едет в регистрах (целые поля в целых регистрах, поля с плавающей точкой в %xmm), больше 16 байт аргумент едет на стеке, а возврат через скрытый указатель в %rdi. Отсюда практический совет системщика: маленькие структуры можно спокойно передавать по значению, они бесплатны, а большие лучше передавать по указателю осознанно, чтобы не платить за копию.
Шаг проекта: zt учится вызовам и именам
Весь этот урок держится на одной инструкции, call, а твой дизассемблер её до сих пор не знает. Косвенный callq *%rax он читает с прошлого урока, но обычный вызов по смещению, байт e8, ему незнаком. Вот функция ztSumSquares из фикстуры этого шага (две квадратичные суммы через вспомогательную square) в выводе zt после урока 13:
$ ./zig-out/bin/zt disasm fixtures/step_14.o | sed -n '/ 4f:/,/ 71:/p'
4f: 55 pushq %rbp
50: 48 89 e5 movq %rsp, %rbp
53: 41 56 pushq %r14
55: 53 pushq %rbx
56: 48 89 f3 movq %rsi, %rbx
59: e8 (bad)
5a: 13 00 adcl (%rax), %eax
5c: 00 00 addb %al, (%rax)
5e: 49 89 c6 movq %rax, %r14
61: 48 89 df movq %rbx, %rdi
64: e8 (bad)
65: 08 00 orb %al, (%rax)
67: 00 00 addb %al, (%rax)
69: 4c 01 f0 addq %r14, %rax
6c: 5b popq %rbx
6d: 41 5e popq %r14
6f: 5d popq %rbp
70: c3 retq
71: 55 pushq %rbp
Картина знакомая по прошлым шагам: неизвестный байт, за ним смещение, прочитанное как арифметика. Но здесь видна и вторая беда, и её одним новым опкодом не вылечить. Даже когда call разберётся, строка выйдет callq 0x71, и из неё непонятно, кого зовут. objdump печатает callq 0x71 <ztSumSquares+0x22>, и с этой подписью листинг читается как текст. Шаг поэтому из двух частей: новый опкод и подписи адресов.
call по смещению
Кодирование call ты уже знаешь, не зная об этом. Это e8 и четыре байта смещения со знаком, отсчитанного от конца инструкции, ровно как у jmp с опкодом e9. Разница только в том, что процессор перед прыжком кладёт на стек адрес следующей инструкции, но дизассемблеру до этого дела нет. В src/x86/ops_branch.zig рядом с jump:
/// Вызов по смещению, опкод 0xE8. Цель считается так же, как у `jmp`,
/// а адрес следующей инструкции процессор кладёт на стек как адрес возврата.
pub fn call(cursor: *Cursor) ?Instruction {
const displacement = cursor.readSigned(4) orelse return null;
return cursor.one("call", 'q', .{ .target = cursor.relativeTarget(displacement) });
}
И строка таблицы в src/x86/decoder.zig, перед безусловными переходами:
// Вызов и безусловные переходы по смещению.
0xe8 => branch.call(cursor),
0xe9 => branch.jump(cursor, 4),
0xeb => branch.jump(cursor, 1),
Суффикс q у вызова стоит по той же причине, что у push: на стек уходит восемь байтов. Короткой формы с однобайтным смещением у call нет, и это разумно: вызываемая функция почти никогда не лежит в пределах 127 байтов. А ret твой декодер знает с урока 9: c3 без операндов, снимает адрес со стека.
Откуда взять имена
Имя функции в машинном коде не записано. Оно лежит в таблице символов объектника, отдельно от кода, и zt читать её пока не умеет: разбор ELF у него сводится к поиску секции .text по имени. Полноценный читатель ELF появится в уроке про объектные файлы. До тех пор сделаем честный обходной путь: таблицу прочитает nm, а zt возьмёт его вывод готовым.
$ nm fixtures/step_14.o
000000000000001f T ztFib
U ztLog
0000000000000000 T ztLogTwice
000000000000004f T ztSumSquares
Каждая строка это адрес, буква и имя. Большая T значит «код, видный снаружи», маленькая t значит «код, только для этого файла», U значит «имя нужно, но определено где-то ещё», и адреса у такого имени нет. Функции square в списке нет вовсе. Фикстура собрана с -O ReleaseSmall, и в этом режиме Zig вычищает имена, которые не видны снаружи файла. Для отладчика и дизассемблера square стала безымянным куском ztSumSquares, и её адрес objdump подписывает от ближайшего имени слева: 0x71 это ztSumSquares плюс 0x22.
Отсюда правило подписи. Для адреса цели ищем имя с наибольшим адресом, который не больше цели. Совпал точно, пишем <имя>. Не совпал, пишем <имя+0xсмещение>.
Модуль подписей
Всё, что касается имён, живёт в новом файле src/labels.zig:
//! Подписи для дизассемблера: какому адресу какое имя соответствует.
//!
//! Таблицу символов дизассемблер пока не читает сам: её передают снаружи
//! готовым выводом `nm`. Подпись цели это ближайшее имя не правее адреса,
//! плюс расстояние до него: `<ztSumSquares+0x22>`.
const std = @import("std");
pub const Label = struct {
/// Номер секции с кодом. Пока секция одна, и номер всегда 0.
section: u32,
address: u64,
name: []const u8,
/// Чем больше, тем охотнее берём имя, когда на одном адресе их несколько:
/// глобальное важнее локального.
rank: u8,
fn lessThan(_: void, a: Label, b: Label) bool {
if (a.section != b.section) return a.section < b.section;
if (a.address != b.address) return a.address < b.address;
return a.rank > b.rank;
}
};
pub const Found = struct { name: []const u8, offset: u64 };
/// Имя функции и её адрес, как их печатает `nm`: `000000000000001f T ztFib`.
pub const Symbol = struct {
name: []const u8,
address: u64,
/// Большая буква у `nm`: имя видно из других файлов.
global: bool,
};
/// Разбирает вывод `nm`. Берёт только код, буквы `T` и `t`: у неопределённых
/// имён адреса нет, а данные дизассемблеру не нужны. Строки указывают в `text`.
pub fn parseNm(gpa: std.mem.Allocator, text: []const u8) ![]Symbol {
var list: std.ArrayList(Symbol) = .empty;
errdefer list.deinit(gpa);
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.next()) |line| {
// У неопределённого имени колонка адреса пустая, и полей будет два.
var fields = std.mem.tokenizeScalar(u8, line, ' ');
const address = fields.next() orelse continue;
const letter = fields.next() orelse continue;
const name = fields.next() orelse continue;
if (letter.len != 1 or std.ascii.toLower(letter[0]) != 't') continue;
try list.append(gpa, .{
.name = name,
.address = try std.fmt.parseInt(u64, address, 16),
.global = letter[0] == 'T',
});
}
return list.toOwnedSlice(gpa);
}
pub const Labels = struct {
arena: std.heap.ArenaAllocator,
/// Отсортированы по секции, адресу и убыванию ранга.
code: []const Label,
/// Подписи из готового списка. Весь код считается одной секцией с
/// номером 0: у куска байтов других секций и нет.
pub fn fromSymbols(gpa: std.mem.Allocator, symbols: []const Symbol) !Labels {
var arena: std.heap.ArenaAllocator = .init(gpa);
errdefer arena.deinit();
const list = try arena.allocator().alloc(Label, symbols.len);
for (symbols, list) |symbol, *label| label.* = .{
.section = 0,
.address = symbol.address,
.name = symbol.name,
.rank = if (symbol.global) 2 else 1,
};
std.mem.sort(Label, list, {}, Label.lessThan);
return .{ .arena = arena, .code = list };
}
pub fn deinit(labels: *Labels) void {
labels.arena.deinit();
}
/// Имя, стоящее ровно на этом адресе: с него начинается функция.
pub fn exact(labels: Labels, section: u32, address: u64) ?[]const u8 {
for (labels.code) |label| {
if (label.section == section and label.address == address) return label.name;
}
return null;
}
/// Адрес следующей подписи в секции после данного адреса.
pub fn nextAfter(labels: Labels, section: u32, address: u64) ?u64 {
for (labels.code) |label| {
if (label.section == section and label.address > address) return label.address;
}
return null;
}
/// Ближайшее имя не правее адреса и расстояние до него.
pub fn nearest(labels: Labels, section: u32, address: u64) ?Found {
var best: ?Found = null;
for (labels.code) |label| {
if (label.section != section or label.address > address) continue;
// При равных адресах первым в списке стоит имя с большим рангом.
if (best != null and best.?.offset == address - label.address) continue;
best = .{ .name = label.name, .offset = address - label.address };
}
return best;
}
};
test "из вывода nm берутся только функции" {
const gpa = std.testing.allocator;
const symbols = try parseNm(gpa,
\\000000000000001f T ztFib
\\ U ztLog
\\0000000000000000 t helper
\\0000000000000010 D table
\\
);
defer gpa.free(symbols);
try std.testing.expectEqual(@as(usize, 2), symbols.len);
try std.testing.expectEqualStrings("ztFib", symbols[0].name);
try std.testing.expectEqual(@as(u64, 0x1f), symbols[0].address);
try std.testing.expect(symbols[0].global);
try std.testing.expect(!symbols[1].global);
}
test "цель без своего имени подписывается от ближайшего слева" {
var labels = try Labels.fromSymbols(std.testing.allocator, &.{
.{ .name = "b", .address = 0x40, .global = true },
.{ .name = "a", .address = 0x0, .global = true },
});
defer labels.deinit();
const inside = labels.nearest(0, 0x5a).?;
try std.testing.expectEqualStrings("b", inside.name);
try std.testing.expectEqual(@as(u64, 0x1a), inside.offset);
try std.testing.expectEqualStrings("a", labels.exact(0, 0).?);
}
Несколько решений в этом файле сделаны на вырост, и о них стоит сказать сразу.
- Строки имён не копируются.
parseNmотдаёт срезы исходного текста, поэтому текст должен жить не меньше списка. Вmain.zigтак и выходит: файл читается один раз и не освобождается до конца программы. - Поле
sectionсейчас всегда ноль. Оно здесь потому, что в объектнике бывает несколько секций с кодом, и у каждой адреса начинаются с нуля: адрес0x10в одной и0x10в другой это разные места. Когдаztнаучится читать ELF сам, номер секции станет настоящим, и менять придётся только то место, где подписи собираются. - Ранг решает спор двух имён на одном адресе. В сборке
-O ReleaseFastZig оставляет у экспортированной функции два имени, локальноеstep_14.ztFibи глобальноеztFib, и печатать хочется то, по которому функцию зовут снаружи. Сортировка ставит на одном адресе имя с большим рангом первым, аnearestпри равном расстоянии оставляет первое найденное. - Поиск линейный. Имён в объектнике десятки, строк в листинге сотни, и сортированный массив с двоичным поиском здесь ничего не даст, кроме лишних строк. Если когда-нибудь понадобится дизассемблировать мегабайты кода, это первое место, которое стоит переписать.
Строка с подписью
В src/disasm.zig строка дизассемблера получает подписи. У printLine два новых параметра: подписи, которых может и не быть, и номер секции.
/// Одна строка дизассемблера. `section` это номер секции, в которой лежит
/// инструкция: цель перехода ищем в той же секции.
pub fn printLine(
out: *std.Io.Writer,
instruction: Instruction,
labels: ?*const Labels,
section: u32,
) !void {
try out.print("{x: >8}: ", .{instruction.address});
var width: usize = 0;
for (instruction.bytes, 0..) |byte, position| {
if (position > 0) {
try out.writeByte(' ');
width += 1;
}
try out.print("{x:0>2}", .{byte});
width += 2;
}
if (width < bytes_column) try out.splatByteAll(' ', bytes_column - width);
try out.writeByte('\t');
try formatter.format(out, instruction);
if (labels) |known| try printTargetLabel(out, instruction, known, section);
if (formatter.hasComment(instruction)) {
try out.writeAll(" ");
try formatter.formatComment(out, instruction);
}
try out.writeByte('\n');
}
/// Подпись цели перехода или вызова: `<ztFib>`, `<ztSumSquares+0x22>`.
fn printTargetLabel(out: *std.Io.Writer, instruction: Instruction, labels: *const Labels, section: u32) !void {
for (instruction.operandSlice()) |operand| {
const target = switch (operand) {
.target => |address| address,
else => continue,
};
const found = labels.nearest(section, target) orelse return;
if (found.offset == 0) {
try out.print(" <{s}>", .{found.name});
} else {
try out.print(" <{s}+0x{x}>", .{ found.name, found.offset });
}
}
}
Подпись получают только операнды вида .target, то есть цели jmp, jcc и call, у которых адрес посчитан декодером. У jmpq *%rax цели в инструкции нет, и подписывать нечего.
Старая disassemble передаёт printLine пустые подписи: try printLine(out, instruction, null, 0);. А рядом появляются две новые функции:
const labels_mod = @import("labels.zig");
pub const Labels = labels_mod.Labels;
/// Разбирает кусок кода и подписывает его именами из готового списка,
/// например из вывода `nm`. Весь кусок считается одной секцией.
pub fn disassembleNamed(
gpa: std.mem.Allocator,
out: *std.Io.Writer,
code: []const u8,
base: u64,
symbols: []const labels_mod.Symbol,
) !void {
var labels = try Labels.fromSymbols(gpa, symbols);
defer labels.deinit();
try disassembleSection(out, code, base, &labels, 0);
}
/// Одна секция с подписями: заголовок перед каждой функцией, имя у каждой
/// цели перехода и вызова.
fn disassembleSection(
out: *std.Io.Writer,
code: []const u8,
base: u64,
labels: *const Labels,
section: u32,
) !void {
var offset: usize = 0;
while (offset < code.len) {
// Инструкция не может перешагнуть начало следующей функции. Если
// перед функцией лежит мусор или добивка, разбор сбился бы и съел
// её первые байты, поэтому декодеру показываем байты только до
// ближайшей подписи. Так же поступает objdump.
const address = base + offset;
const limit = if (labels.nextAfter(section, address)) |next| @min(code.len, next - base) else code.len;
const instruction = decoder.decode(code[offset..@intCast(limit)], address);
if (labels.exact(section, instruction.address)) |name| {
try out.print("\n{x:0>16} <{s}>:\n", .{ instruction.address, name });
}
try printLine(out, instruction, labels, section);
offset += instruction.length();
}
}
Перед первой инструкцией каждой функции печатается заголовок в формате objdump: пустая строка, адрес на шестнадцать цифр, имя в угловых скобках. А переменная limit отвечает на вопрос, который без имён даже не возникал. Между функциями лежит добивка до выравнивания, и если в ней окажется байт, похожий на начало длинной инструкции, декодер прочитает эту «инструкцию» через границу и съест пролог следующей функции. Начало функции это единственное место в секции, где граница инструкции известна точно, поэтому дальше неё декодеру байтов не показываем.
Флаг --syms
Осталось передать таблицу из командной строки. В cmdDisasm из src/main.zig новый флаг и новая ветка перед обычным разбором:
} else if (std.mem.startsWith(u8, arg, "--syms=")) {
syms_path = arg["--syms=".len..];
// Таблица символов снаружи: вывод nm, снятый с того же файла.
if (syms_path) |syms| {
const text = try std.Io.Dir.cwd().readFileAlloc(init.io, syms, gpa, file_limit);
const symbols = try zt.labels.parseNm(gpa, text);
const code: zt.elf.Section = if (raw)
.{ .name = "", .address = base, .data = bytes }
else
try zt.elf.findSection(bytes, ".text");
try zt.disasm.disassembleNamed(gpa, out, code.data, code.address, symbols);
return;
}
Флаг работает и с --raw: для сырого куска кода nm не поможет, но файл с подписями можно написать руками, в том же формате. В строку usage добавь [--syms=файл] и пояснение к флагу, а в src/root.zig строку pub const labels = @import("labels.zig");, чтобы тесты дотянулись до модуля.
Фикстура и тесты шага
Три функции фикстуры покрывают три вида подписей: вызов безымянной функции, рекурсию и вызов функции из другого файла.
//! Фикстура шага 14: call, ret и подписи целей по таблице символов.
//!
//! Собирается так:
//! zig build-obj fixtures/step_14.zig -O ReleaseSmall \
//! -target x86_64-linux-musl -femit-bin=fixtures/step_14.o
//! objdump -d fixtures/step_14.o > fixtures/step_14.objdump.txt
//! nm fixtures/step_14.o > fixtures/step_14.nm.txt
//!
//! ReleaseSmall выбран нарочно: в этом режиме Zig вычищает имена функций,
//! которые не видны снаружи, и цель вызова `square` подписывается как
//! смещение от ближайшего имени, `<ztSumSquares+0x22>`.
/// Функция, которую нельзя встроить: иначе от вызова ничего не останется.
noinline fn square(x: u64) u64 {
return x *% x;
}
/// Два вызова одной функции. Значение, которое нужно после вызова, живёт
/// в регистре, который вызываемый обязан сохранить.
export fn ztSumSquares(a: u64, b: u64) u64 {
return square(a) +% square(b);
}
/// Число Фибоначчи в лоб. Один из двух рекурсивных вызовов оптимизатор
/// превращает в цикл, второй остаётся настоящим `call` на начало функции.
export fn ztFib(n: u64) u64 {
if (n < 2) return n;
return ztFib(n - 1) +% ztFib(n - 2);
}
/// Функция из другого объектника. Её адрес узнает только компоновщик,
/// поэтому на месте смещения в `call` пока нули.
extern fn ztLog(value: u64) void;
export fn ztLogTwice(value: u64) void {
ztLog(value);
ztLog(value +% 1);
}
У этого шага три файла эталона вместо двух, и fixtures/root.zig получает вывод nm отдельной константой:
/// У шага 14 рядом лежит ещё вывод `nm`: это таблица символов, которую
/// дизассемблер получает снаружи, пока не умеет читать её из ELF сам.
pub const step_14 = Fixture{
.object = @embedFile("step_14.o"),
.objdump = @embedFile("step_14.objdump.txt"),
};
pub const step_14_nm = @embedFile("step_14.nm.txt");
Сверка с подписями строже прежней, поэтому в tests/support.zig появляется вторая проверка. Старая expectMatchesObjdump отрезает у эталона всё после < и #, новая сравнивает строки целиком, вместе с заголовками функций, и убирает только комментарии:
/// Проверка шага 14 и дальше: вывод с подписями, которые взяты из вывода
/// `nm`, совпадает с objdump вместе с заголовками функций и именами целей.
/// Убираем шапку эталона до названия секции и комментарии после решётки.
pub fn expectMatchesObjdumpNamed(fixture: Fixture, nm: []const u8) !void {
const gpa = std.testing.allocator;
const symbols = try zt.labels.parseNm(gpa, nm);
defer gpa.free(symbols);
const text = try zt.elf.findSection(fixture.object, ".text");
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
try zt.disasm.disassembleNamed(gpa, &out.writer, text.data, text.address, symbols);
const header = "Disassembly of section .text:\n";
const body_start = (std.mem.indexOf(u8, fixture.objdump, header) orelse return error.NoText) + header.len;
const expected = try withoutComments(gpa, fixture.objdump[body_start..]);
defer gpa.free(expected);
const actual = try withoutComments(gpa, out.written());
defer gpa.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}
/// Текст без комментариев после решётки и без хвостовых пустых строк.
pub fn withoutComments(gpa: std.mem.Allocator, text: []const u8) ![]u8 {
var out: std.Io.Writer.Allocating = .init(gpa);
errdefer out.deinit();
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.next()) |line| {
const end = std.mem.indexOfScalar(u8, line, '#') orelse line.len;
try out.writer.writeAll(std.mem.trimEnd(u8, line[0..end], " \t"));
try out.writer.writeByte('\n');
}
// Хвостовые пустые строки у нас и у objdump разные, на суть они не влияют.
const trimmed = std.mem.trimEnd(u8, out.written(), "\n").len;
out.shrinkRetainingCapacity(trimmed);
return out.toOwnedSlice();
}
Тесты в tests/step_14.zig:
//! Шаг 14: `call`, `ret` и подписи целей по таблице символов.
//!
//! Цель вызова записана так же, как цель `jmp`: смещением от конца
//! инструкции. Новое в шаге то, что голый адрес получает имя. Таблицу
//! символов дизассемблер пока не читает сам, её передают снаружи выводом
//! `nm`, а подпись ищется как ближайшее имя не правее адреса.
const std = @import("std");
const fixtures = @import("fixtures");
const zt = @import("zt");
const support = @import("support.zig");
const decode = zt.x86.decoder.decode;
test "фикстура шага разбирается целиком" {
try support.expectNoBadInstructions(fixtures.step_14);
}
test "вывод совпадает с objdump построчно" {
try support.expectMatchesObjdump(fixtures.step_14);
}
test "с таблицей из nm совпадают и подписи" {
try support.expectMatchesObjdumpNamed(fixtures.step_14, fixtures.step_14_nm);
}
test "предыдущие шаги остались зелёными" {
try support.expectMatchesObjdump(fixtures.step_09);
try support.expectMatchesObjdump(fixtures.step_10);
try support.expectMatchesObjdump(fixtures.step_11);
try support.expectMatchesObjdump(fixtures.step_12);
try support.expectMatchesObjdump(fixtures.step_13);
}
test "цель call считается от конца инструкции, как у jmp" {
// e8 e4 ff ff ff по адресу 0x36: конец 0x3b, смещение -0x1c, цель 0x1f.
const back = decode(&.{ 0xe8, 0xe4, 0xff, 0xff, 0xff }, 0x36);
try std.testing.expectEqual(@as(u64, 0x1f), back.operandSlice()[0].target);
try std.testing.expectEqualStrings("call", back.mnemonic);
try std.testing.expectEqual(@as(?u8, 'q'), back.suffix);
// Нули на месте смещения: цель это следующая инструкция. Так в
// объектнике выглядит вызов функции, которую найдёт компоновщик.
const external = decode(&.{ 0xe8, 0x00, 0x00, 0x00, 0x00 }, 0x9);
try std.testing.expectEqual(@as(u64, 0xe), external.operandSlice()[0].target);
}
test "ret снимает адрес возврата и операндов не имеет" {
const ret = decode(&.{0xc3}, 0);
try std.testing.expectEqualStrings("ret", ret.mnemonic);
try std.testing.expectEqual(@as(usize, 0), ret.operandSlice().len);
}
test "подпись: точное имя или ближайшее слева плюс смещение" {
const gpa = std.testing.allocator;
const symbols = try zt.labels.parseNm(gpa, fixtures.step_14_nm);
defer gpa.free(symbols);
var labels = try zt.labels.Labels.fromSymbols(gpa, symbols);
defer labels.deinit();
// Рекурсивный вызов попадает ровно в начало функции.
try std.testing.expectEqualStrings("ztFib", labels.exact(0, 0x1f).?);
// У square имени нет: ReleaseSmall его вычистил. Подпись берётся от
// ближайшего имени слева, и адрес 0x71 это ztSumSquares плюс 0x22.
const helper = labels.nearest(0, 0x71).?;
try std.testing.expectEqualStrings("ztSumSquares", helper.name);
try std.testing.expectEqual(@as(u64, 0x22), helper.offset);
}
test "строка с подписью цели" {
const gpa = std.testing.allocator;
var labels = try zt.labels.Labels.fromSymbols(gpa, &.{
.{ .name = "ztFib", .address = 0x1f, .global = true },
});
defer labels.deinit();
var buffer: [128]u8 = undefined;
var out: std.Io.Writer = .fixed(&buffer);
const call = decode(&.{ 0xe8, 0xe4, 0xff, 0xff, 0xff }, 0x36);
try zt.disasm.printLine(&out, call, &labels, 0);
try std.testing.expectEqualStrings(
" 36: e8 e4 ff ff ff \tcallq\t0x1f <ztFib>\n",
out.buffered(),
);
}
И 14 в project_steps в build.zig.
Прогон
$ zig build test -Dstep=14 --summary all
Build Summary: 3/3 steps succeeded; 8/8 tests passed
test success
+- run test 8 pass (8 total) 17ms MaxRSS:3M
А теперь главное: вывод с подписями против objdump. Первые четыре строки эталона это имя файла и название секции, их отрезаем:
$ nm fixtures/step_14.o > step_14.nm
$ ./zig-out/bin/zt disasm --syms=step_14.nm fixtures/step_14.o > zt14.txt
$ objdump -d fixtures/step_14.o | tail -n +5 > od14.txt
$ diff od14.txt zt14.txt && echo совпало
совпало
Впервые за блок diff пуст целиком, вместе с заголовками функций и подписями. Вот что вышло:
$ ./zig-out/bin/zt disasm --syms=step_14.nm fixtures/step_14.o
0000000000000000 <ztLogTwice>:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 53 pushq %rbx
5: 50 pushq %rax
6: 48 89 fb movq %rdi, %rbx
9: e8 00 00 00 00 callq 0xe <ztLogTwice+0xe>
e: 48 ff c3 incq %rbx
11: 48 89 df movq %rbx, %rdi
14: 48 83 c4 08 addq $0x8, %rsp
18: 5b popq %rbx
19: 5d popq %rbp
1a: e9 00 00 00 00 jmp 0x1f <ztFib>
000000000000001f <ztFib>:
1f: 55 pushq %rbp
20: 48 89 e5 movq %rsp, %rbp
23: 41 56 pushq %r14
25: 53 pushq %rbx
26: 48 89 fb movq %rdi, %rbx
29: 45 31 f6 xorl %r14d, %r14d
2c: 48 83 fb 01 cmpq $0x1, %rbx
30: 76 12 jbe 0x44 <ztFib+0x25>
32: 48 8d 7b ff leaq -0x1(%rbx), %rdi
36: e8 e4 ff ff ff callq 0x1f <ztFib>
3b: 49 01 c6 addq %rax, %r14
3e: 48 83 c3 fe addq $-0x2, %rbx
42: eb e8 jmp 0x2c <ztFib+0xd>
44: 4c 01 f3 addq %r14, %rbx
47: 48 89 d8 movq %rbx, %rax
4a: 5b popq %rbx
4b: 41 5e popq %r14
4d: 5d popq %rbp
4e: c3 retq
000000000000004f <ztSumSquares>:
4f: 55 pushq %rbp
50: 48 89 e5 movq %rsp, %rbp
53: 41 56 pushq %r14
55: 53 pushq %rbx
56: 48 89 f3 movq %rsi, %rbx
59: e8 13 00 00 00 callq 0x71 <ztSumSquares+0x22>
5e: 49 89 c6 movq %rax, %r14
61: 48 89 df movq %rbx, %rdi
64: e8 08 00 00 00 callq 0x71 <ztSumSquares+0x22>
69: 4c 01 f0 addq %r14, %rax
6c: 5b popq %rbx
6d: 41 5e popq %r14
6f: 5d popq %rbp
70: c3 retq
71: 55 pushq %rbp
72: 48 89 e5 movq %rsp, %rbp
75: 48 89 f8 movq %rdi, %rax
78: 48 0f af c7 imulq %rdi, %rax
7c: 5d popq %rbp
7d: c3 retq
Прочитай его, держа в голове правила урока, и в каждой функции найдётся своё.
ztSumSquares держит b через вызов в %rbx, а результат первого вызова в %r14. Оба регистра callee-saved: square обязана их не портить, поэтому значения переживут вызов. Но для своего вызывающего ztSumSquares тоже вызываемая, и те же два регистра она сама сохраняет в прологе парой push и восстанавливает в эпилоге. Сама square видна сразу за retq на 0x71: имени нет, но по подписи ясно, что это отдельная функция со своим прологом, а не хвост ztSumSquares.
ztFib показывает, что оптимизатор сделал с рекурсией. Вызов fib(n - 1) остался настоящим callq 0x1f <ztFib>, на самого себя. А fib(n - 2) превратился в цикл: %r14 копит сумму, %rbx уменьшается на два, и jmp на 0x2c возвращает к проверке. Та же история, что с rfun выше: после второго вызова оставалось только сложение, и компилятор свернул его в петлю с накопителем.
ztLogTwice самая поучительная, потому что две её подписи врут. callq 0xe <ztLogTwice+0xe> не зовёт середину самой себя, а jmp 0x1f <ztFib> не прыгает в ztFib. У обеих инструкций смещение нулевое, потому что ztLog живёт в другом файле и её адрес неизвестен, а нулевое смещение указывает на следующую инструкцию. objdump печатает ту же неправду, и zt повторяет её честно. Правду знают перемещения:
$ objdump -dr fixtures/step_14.o | grep -A1 -E '^ +(9|1a):'
9: e8 00 00 00 00 callq 0xe <ztLogTwice+0xe>
000000000000000a: R_X86_64_PLT32 ztLog-0x4
--
1a: e9 00 00 00 00 jmp 0x1f <ztFib>
000000000000001b: R_X86_64_PLT32 ztLog-0x4
Компоновщик впишет в четыре байта по адресу 0xa значение ztLog - 4 - 0xa. Минус четыре появляется потому, что смещение считается от конца инструкции, а заплатка стоит за четыре байта до конца. Читать перемещения zt научится в уроке про объектные файлы. А второй вызов ztLog стал jmp, а не call: это вызов в хвостовой позиции, после него ztLogTwice уже нечего делать. Функция снимает свой кадр и прыгает в ztLog, и та вернётся прямо к тому, кто звал ztLogTwice, сэкономив один ret.
В уроке про объектные файлы zt научится читать таблицу символов сам, флаг --syms станет не нужен, а модуль подписей получит ещё два источника имён. Всё, что написано сегодня, там пригодится без изменений.
Шаг проекта: zl говорит на языке C
В прошлом уроке байткод-машина научилась звать примитивы через primitives.call, обычную функцию Zig. Как именно Zig передаёт ей *Vm, Value и ошибку, знает только компилятор текущей версии. Собственное соглашение Zig (.auto) нигде не записано и вправе меняться от версии к версии, а объединение ошибки со значением вообще не имеет смысла за пределами Zig. Пока все вызовы делает код на Zig, это неважно. Но через несколько уроков мы начнём порождать машинный код сами, в уроке про JIT, и этот код должен уметь позвать примитив. Машинный код знает одно соглашение: то самое System V, которое ты весь урок читал по листингам.
Значит, у каждого примитива должен появиться вход с известным контрактом: машина в %rdi, список аргументов дальше, результат в %rax. Это шаг проекта. К нему два вопроса, на которые урок уже ответил: что можно передать через такую границу и что должно пережить вызов. И третий, про наш собственный вызов: как устроен кадр замыкания в байткод-машине.
Граница C не пропускает тегированное объединение
Первая попытка очевидна: объявить вход с callconv(.c) и ту же подпись.
pub const Native = *const fn (vm: *Vm, args: Value) callconv(.c) Value;
fn nativeCar(vm: *Vm, args: Value) callconv(.c) Value {
return primCar(vm, args) catch .nil;
}
$ zig build -Dtarget=x86_64-linux
src/primitives.zig:27:23: error: parameter of type 'value.Value' not allowed in function with calling convention 'x86_64_sysv'
fn nativeCar(vm: *Vm, args: Value) callconv(.c) Value {
^~~~~~~~~~~
src/primitives.zig:27:23: note: union with automatic layout has no guaranteed in-memory representation
src/value.zig:36:19: note: union declared here
pub const Value = union(enum) {
^~~~~
Та же причина, по которой Small в разделе про структуры пришлось объявить extern struct. У тегированного объединения Zig раскладка автоматическая: где лежит тег, сколько байт он занимает и в каком порядке с нагрузкой, решает компилятор. Соглашение C раскладывает аргумент по регистрам по его байтам, а байты у такого типа не обещаны. В шапке value.zig с самого начала записано, что представление значения поменяется дважды, сначала на C-совместимое, потом на тегированный указатель в одно слово. Первая смена случается сейчас.
value.zig: значение из двух слов
Value становится extern struct из вида и полезной нагрузки. Перечень Tag получает явный тип u8, без него перечень тоже нельзя положить в extern struct:
pub const Tag = enum(u8) { nil, fixnum, symbol, cons, closure, primitive };
/// Значение, которое можно передать через границу C: `extern struct` из двух
/// слов, поля лежат ровно в порядке объявления. Первое слово это вид, второе
/// полезная нагрузка: число, номер символа или примитива, адрес ячейки.
/// Тегированное объединение Zig через эту границу не пройдёт: у него нет
/// обещанной раскладки, и компилятор откажется ставить его в сигнатуру
/// функции с соглашением C.
pub const Value = extern struct {
kind: Tag,
bits: u64,
pub const nil: Value = .{ .kind = .nil, .bits = 0 };
pub fn tag(self: Value) Tag {
return self.kind;
}
pub fn fromFixnum(n: i64) Value {
return .{ .kind = .fixnum, .bits = @bitCast(n) };
}
pub fn fromSymbol(id: SymbolId) Value {
return .{ .kind = .symbol, .bits = id };
}
pub fn fromCell(cell: *Cell) Value {
return .{ .kind = .cons, .bits = @intFromPtr(cell) };
}
pub fn fromClosure(cl: *Closure) Value {
return .{ .kind = .closure, .bits = @intFromPtr(cl) };
}
pub fn fromPrimitive(index: PrimIndex) Value {
return .{ .kind = .primitive, .bits = index };
}
pub fn isNil(self: Value) bool {
return self.tag() == .nil;
}
pub fn isCons(self: Value) bool {
return self.tag() == .cons;
}
/// Атом это всё, что не пара. nil тоже атом, как у Маккарти.
pub fn isAtom(self: Value) bool {
return self.tag() != .cons;
}
pub fn asFixnum(self: Value) i64 {
std.debug.assert(self.kind == .fixnum);
return @bitCast(self.bits);
}
pub fn asSymbol(self: Value) SymbolId {
std.debug.assert(self.kind == .symbol);
return @intCast(self.bits);
}
pub fn asCell(self: Value) *Cell {
std.debug.assert(self.kind == .cons);
return @ptrFromInt(self.bits);
}
pub fn asClosure(self: Value) *Closure {
std.debug.assert(self.kind == .closure);
return @ptrFromInt(self.bits);
}
pub fn asPrimitive(self: Value) PrimIndex {
std.debug.assert(self.kind == .primitive);
return @intCast(self.bits);
}
/// Всё, что можно поставить в голову формы.
pub fn isCallable(self: Value) bool {
return switch (self.tag()) {
.closure, .primitive => true,
else => false,
};
}
/// Тождество в смысле `eq`: числа и символы сравниваются по значению,
/// пары и замыкания по адресу. В этом представлении всё это одно и то же
/// сравнение двух слов: у nil нагрузка всегда нулевая.
pub fn eql(a: Value, b: Value) bool {
return a.kind == b.kind and a.bits == b.bits;
}
};
Раскладка теперь обещана: байт вида по смещению 0, семь байт выравнивания, восемь байт нагрузки по смещению 8, всего 16. Размер тот же, что был у объединения, и тест размер значения на этом шаге в value.zig не меняется (поправь в нём только комментарий: теперь это вид плюс нагрузка).
Подписи методов не изменились ни одной, и поэтому не изменился ни один файл, кроме этого. Ради такого момента в value.zig с первого дня действует правило: остальной код ходит к значению только через методы и не разбирает его сам. Методы as* получили std.debug.assert: у объединения чтение чужого поля в Debug ловил сам язык, у двух слов эту проверку приходится писать руками. А eql упростился до сравнения двух слов, потому что у каждого вида в bits лежит ровно то, по чему eq сравнивает: число, номер или адрес.
Что это значит для регистров, ты уже знаешь из раздела про структуры. Value на 16 байт это два восьмибайтовых слова целого класса, и System V раскладывает их по двум целочисленным регистрам подряд, как Small. Вход fn (vm: *Vm, args: Value) callconv(.c) Value получает машину в %rdi, вид списка аргументов в %rsi (в младшем байте, %sil), адрес его первой ячейки в %rdx. Результат на 16 байт возвращается парой %rax и %rdx, как у make_small.
primitives.zig: вход по соглашению C
Писать каждый примитив сразу с callconv(.c) неудобно: внутри примитива хочется try, а ошибки Zig через границу C не проходят. Поэтому примитивы остаются Zig-функциями с ошибками, а входы для них печатает компилятор. Fn становится типом функции, а не указателя (так его можно передать как comptime-параметр), рядом появляется Native:
/// Сигнатура примитива на Zig: машина и список уже вычисленных аргументов.
pub const Fn = fn (vm: *Vm, args: Value) Error!Value;
/// Тот же примитив по соглашению C. Машина едет в `%rdi`, список аргументов
/// в паре `%rsi` и `%rdx` (значение это два слова), результат возвращается
/// в паре `%rax` и `%rdx`. Ошибки Zig через эту границу не проходят, поэтому
/// ошибка остаётся в машине, а наружу уходит nil.
pub const Native = *const fn (vm: *Vm, args: Value) callconv(.c) Value;
Старый call с inline for уступает место таблице адресов:
/// Вход по соглашению C для примитива на Zig. Компилятор печатает по одному
/// такому входу на каждую строку таблицы.
fn native(comptime f: Fn) Native {
return struct {
fn entry(vm: *Vm, args: Value) callconv(.c) Value {
return f(vm, args) catch |err| vm.fail(err);
}
}.entry;
}
/// Адреса входов в порядке таблицы: номер примитива это индекс в массиве.
pub const natives: [count]Native = blk: {
var out: [count]Native = undefined;
for (&out, 0..) |*slot, i| slot.* = native(table[i][1]);
break :blk out;
};
/// Вызвать примитив по номеру. Вызов идёт через вход по соглашению C, той же
/// дорогой, какой его позовёт машинный код, а ошибку забираем из машины.
pub fn call(vm: *Vm, index: Index, args: Value) Error!Value {
const result = natives[index](vm, args);
if (vm.takeFailure()) |err| return err;
return result;
}
native это функция, которая возвращает функцию. Каждый вызов с новым f порождает новую анонимную структуру, а в ней новую функцию entry с callconv(.c): так в Zig пишут замыкание времени компиляции. natives собирается в блоке, который целиком вычисляется при компиляции, и в бинарник ложится готовым массивом адресов. Шапка файла тоже меняется: вызов больше не разворачивается inline for, это чтение адреса из массива и косвенный call.
vm.zig: где ждёт ошибка
Соглашение C не знает ошибок, поэтому ошибка едет в обход регистров: вход кладёт её в машину и возвращает nil, а вызывающий забирает. Это приём errno из libc, только поле своё. В vm.zig импортируется errors.zig, в Vm появляется поле и два метода:
/// Ошибка примитива, позванного по соглашению C. Вернуть ошибку Zig такой
/// вызов не может, поэтому она ждёт здесь, пока вызывающий её не заберёт.
failure: ?errors.Error = null,
/// Запомнить ошибку и вернуть nil: его отдаст наружу вход по соглашению C.
pub fn fail(self: *Vm, err: errors.Error) Value {
self.failure = err;
return .nil;
}
/// Забрать ошибку последнего вызова, если она была.
pub fn takeFailure(self: *Vm) ?errors.Error {
defer self.failure = null;
return self.failure;
}
eval, bytecode.zig и сами примитивы не меняются: все они и так звали primitives.call.
Что видно в ассемблере
Соберём программу под x86_64-linux в режиме оптимизации и найдём входы:
$ zig build asm -Dtarget=x86_64-linux -Doptimize=ReleaseFast
$ nm zig-out/bin/zl-llvm | grep primitives.native | sort | tail -3
0000000001074a30 t primitives.native__struct_27676.entry
0000000001074ac0 t primitives.native__struct_27668.entry
0000000001074b00 t primitives.native__struct_27659.entry
Имена говорят, откуда функции взялись: entry внутри анонимной структуры, которую вернул native. Самая младшая структура, 27659, это car, первая строка таблицы:
<primitives.native__struct_27659.entry>:
pushq %rbp
movq %rsp, %rbp
movw $0x20, %ax # код ошибки ArityMismatch наготове
cmpb $0x3, %sil # args.kind == cons? вид списка в младшем байте rsi
jne <...+0x2a>
cmpb $0x0, 0x10(%rdx) # rdx = args.bits, адрес ячейки; cdr.kind == nil?
jne <...+0x2a> # аргумент не один: ArityMismatch
movw $0x1f, %ax # теперь наготове NotAPair
cmpb $0x3, (%rdx) # car.kind: сам аргумент пара?
jne <...+0x2a>
movq 0x8(%rdx), %rcx # адрес этой пары
movq (%rcx), %rax # её car: вид в rax
movq 0x8(%rcx), %rdx # нагрузка в rdx
popq %rbp
retq
movw %ax, 0xbc(%rdi) # vm.failure = код ошибки, rdi это машина
xorl %eax, %eax # вернуть nil: вид 0
xorl %edx, %edx # нагрузка 0
popq %rbp
retq
Весь контракт на одном экране. Машина пришла в %rdi и понадобилась только на пути ошибки. Список аргументов пришёл двумя регистрами: вид в %sil, адрес ячейки в %rdx. Раскладка ячейки видна по смещениям: car занимает байты с 0 по 15 (вид по 0, нагрузка по 8), cdr с 16 (вид по 0x10). Результат ушёл парой %rax и %rdx. Ошибка ушла записью двух байт в 0xbc(%rdi): это поле failure, опциональная ошибка Zig, у которой ноль означает «ошибки нет». Весь try из primCar и takeOne сложился в три сравнения.
Теперь с другой стороны, у вызывающего. Вот конец bytecode.Machine.call в ветке примитива:
<bytecode.Machine.call>:
pushq %rbp
movq %rsp, %rbp
pushq %r15 # пять callee-saved регистров:
pushq %r14 # функция будет ими пользоваться
pushq %r13 # и обязана вернуть их нетронутыми
pushq %r12
pushq %rbx
subq $0x58, %rsp
movl %esi, %ecx
movq %rdi, %rbx # rbx = self, машина байткода
...
movq (%rbx), %r15 # r15 = self.vm
movq %r15, %rdi # vm в rdi
movq %rax, %rdx # args.bits в rdx (args.kind уже в esi)
callq *0x1013db8(,%r8,8) # natives[index]: косвенный call по таблице
movzwl 0xbc(%r15), %esi # vm.failure: r15 пережил вызов
movw $0x0, 0xbc(%r15) # takeFailure гасит поле
testw %si, %si
jne <bytecode.Machine.call+0x1c0> # была ошибка: наружу
movq %r12, 0x28(%rbx) # stack.items.len = base: rbx и r12 тоже пережили
movq 0x20(%rbx), %rcx # stack.items.ptr
movq %rax, (%rcx,%r14) # результат на место функции: вид
movq %rdx, 0x8(%rcx,%r14) # и нагрузка
Здесь ответ на вопрос «что должно пережить вызов». После callq машине нужны четыре вещи: сама байткод-машина, её Vm, номер base и смещение слота функции. Компилятор заранее разложил их по %rbx, %r15, %r12 и %r14, то есть только по callee-saved регистрам, и заплатил за это пятью парами push и pop в прологе и эпилоге, ровно как keepAcross платил за один %rbx. Вход примитива вправе испортить %rdi, %rsi, %rdx, %rcx, %r8, и никто не пострадает. callq *0x1013db8(,%r8,8) это тот же косвенный переход по таблице, что и jmpq в switch, только с возвратом: natives лежит в .rodata, номер примитива служит индексом.
Когда JIT начнёт звать примитивы, ему придётся выполнить ту же половину контракта, что здесь выполнил компилятор Zig: положить машину в %rdi, значение в предписанные регистры, выровнять стек на 16 и после вызова не рассчитывать ни на один caller-saved регистр.
Кадр замыкания
Примитив зовётся по чужому соглашению, а замыкание по нашему собственному, и оно устроено по тому же образцу, что машинный кадр. Вот как выглядят стек значений и кадры в момент, когда (fib 20) из прошлого урока только что позвала себя с n = 19:
стек значений кадры
[0] #<closure> fib { chunk: тело fib, pc: 16, base: 1 }
[1] 20 n вызывающего
[2] #<+> сложение ждёт
[3] #<closure> fib { chunk: тело fib, pc: 0, base: 4 }
[4] 19 n вызванного
Нижний кадр когда-то принадлежал форме (fib 20): её tail_call положил fib в слот 0 на место заглушки и занял её запись. pc: 16 у вызывающего это команда сразу за call 1 из листинга fib, туда машина вернётся. 19 в слоте 4 лёг на место функции -, которую перед этим позвали и сняли.
Параллель прямая.
| Машинный кадр x86-64 | Кадр байткод-машины |
|---|---|
call кладёт адрес возврата | pc вызывающего остаётся в его записи Frame, уже сдвинутым на следующую команду |
%rbp указывает на кадр | base указывает на первый аргумент |
| аргументы в регистрах и над адресом возврата | аргументы в слотах base, base + 1 и дальше |
результат в %rax | результат в слоте base - 1, на месте функции |
| callee-saved регистры возвращаются нетронутыми | вызванный не трогает ничего ниже своего base - 1, поэтому слоты вызывающего переживают вызов сами |
хвостовой вызов это jmp вместо call | tail_call переписывает текущую запись вместо новой |
И то же самое глазами компилятора: ветка замыкания в bytecode.Machine.call собирает запись Frame в памяти и зовёт pushFrame:
movq -0x80(%rbp), %rax
movq %rax, -0x70(%rbp) # frame.chunk: тело, которое вернул functionOf
movl $0x0, -0x68(%rbp) # frame.pc = 0
movl %r12d, -0x64(%rbp) # frame.base = base, всё ещё в r12
leaq -0x70(%rbp), %rsi
movq %rbx, %rdi
callq <bytecode.Machine.pushFrame>
Запись на 16 байт (указатель и два u32) передаётся в pushFrame адресом, хотя по размеру прошла бы и в регистрах: pushFrame не callconv(.c), и соглашение для неё выбирает Zig. Это ещё одна иллюстрация, почему для машинного кода нужна граница с обещанным контрактом.
Тесты шага
В build.zig номер шага: const steps = [_][]const u8{ "05", "06", "13", "14" };.
//! Шаг 14: соглашение о вызовах. Примитивы видны снаружи как функции
//! `fn (*Vm, Value) callconv(.c) Value`, значение проходит через границу C
//! по значению, ошибка примитива переезжает через машину.
const std = @import("std");
const zl = @import("zl");
const bytecode = zl.bytecode;
const primitives = zl.primitives;
const printer = zl.printer;
const reader = zl.reader;
const Value = zl.Value;
const Vm = zl.Vm;
const testing = std.testing;
fn expectPrinted(vm: *Vm, v: Value, expected: []const u8) !void {
const text = try printer.toOwned(testing.allocator, vm, v);
defer testing.allocator.free(text);
try testing.expectEqualStrings(expected, text);
}
test "вход примитива объявлен с соглашением C" {
const info = @typeInfo(@typeInfo(primitives.Native).pointer.child).@"fn";
try testing.expect(info.calling_convention.eql(std.builtin.CallingConvention.c));
try testing.expectEqual(primitives.count, primitives.natives.len);
}
test "примитив зовётся через адрес из таблицы, как его позовёт машинный код" {
var vm: Vm = try .init(testing.allocator);
defer vm.deinit();
const plus = primitives.natives[primitives.indexOf("+")];
const args = try reader.readOne(&vm, "(40 2)");
try testing.expectEqual(@as(i64, 42), plus(&vm, args).asFixnum());
try testing.expectEqual(@as(?zl.errors.Error, null), vm.takeFailure());
const cons = primitives.natives[primitives.indexOf("cons")];
try expectPrinted(&vm, cons(&vm, try reader.readOne(&vm, "(1 (2 3))")), "(1 2 3)");
}
test "ошибка примитива переходит границу через машину" {
var vm: Vm = try .init(testing.allocator);
defer vm.deinit();
const car = primitives.natives[primitives.indexOf("car")];
const result = car(&vm, try reader.readOne(&vm, "(5)"));
// Наружу ушёл nil, а сама ошибка ждёт в машине.
try testing.expect(result.isNil());
try testing.expectEqual(@as(?zl.errors.Error, error.NotAPair), vm.takeFailure());
try testing.expectEqual(@as(?zl.errors.Error, null), vm.takeFailure());
// `primitives.call` забирает ошибку сам и возвращает её как ошибку Zig.
const idx = primitives.indexOf("+");
try testing.expectError(error.NotANumber, primitives.call(&vm, idx, try reader.readOne(&vm, "(a 1)")));
try testing.expectEqual(@as(?zl.errors.Error, null), vm.failure);
}
/// Функция с соглашением C, которая возвращает свой аргумент. Если значение
/// переживает дорогу через регистры туда и обратно, раскладка у вызывающего
/// и у вызываемого одна и та же.
fn echo(vm: *Vm, v: Value) callconv(.c) Value {
_ = vm;
return v;
}
test "значение любого вида проходит через границу C по значению" {
var vm: Vm = try .init(testing.allocator);
defer vm.deinit();
const pair = try reader.readOne(&vm, "(1 . 2)");
const values = [_]Value{
.nil,
.fromFixnum(-5),
.fromFixnum(std.math.maxInt(i32)),
try vm.symbolValue("hello"),
pair,
.fromPrimitive(primitives.indexOf("car")),
};
const through: *const fn (*Vm, Value) callconv(.c) Value = &echo;
for (values) |v| {
const back = through(&vm, v);
try testing.expect(back.eql(v));
try testing.expectEqual(v.tag(), back.tag());
}
try testing.expectEqual(pair.asCell(), through(&vm, pair).asCell());
}
test "обход дерева и байткод зовут примитив через один вход и видят одну ошибку" {
var vm: Vm = try .init(testing.allocator);
defer vm.deinit();
var m: bytecode.Machine = .init(&vm);
defer m.deinit();
try expectPrinted(&vm, try zl.eval.evalSource(&vm, "(+ 1 2)"), "3");
try expectPrinted(&vm, try m.runSource("(+ 1 2)", .loop), "3");
try testing.expectError(error.NotAPair, m.runSource("(cdr 7)", .labeled));
try testing.expectError(error.NotAPair, zl.eval.evalSource(&vm, "(cdr 7)"));
try testing.expectEqual(@as(?zl.errors.Error, null), vm.failure);
}
test "кадр замыкания: функция под аргументами, результат на её месте" {
var vm: Vm = try .init(testing.allocator);
defer vm.deinit();
var m: bytecode.Machine = .init(&vm);
defer m.deinit();
// Три аргумента, два из них выражения: всё считается в слотах стека.
try expectPrinted(&vm, try m.runSource(
\\(define f (lambda (a b c) (list c b a)))
\\(f 1 (+ 1 1) (f 'x 'y 'z))
, .loop), "((z y x) 2 1)");
// Кадры сняты, стек пуст: каждый результат лёг на место своей функции.
try testing.expectEqual(@as(usize, 0), m.stack.items.len);
try testing.expectEqual(@as(usize, 2), m.peak_frames);
}
Тесты нарочно не проверяют размер значения. Они проверяют то, что обязано остаться верным при любом представлении: вход объявлен с соглашением C, значение любого вида переживает дорогу через регистры туда и обратно, ошибка доходит до вызывающего, а кадр замыкания оставляет стек чистым. Следующий шаг сожмёт значение в одно слово, и все эти тесты должны пройти без правки.
Прогон
$ zig build test --summary all
Build Summary: 11/11 steps succeeded; 40/40 tests passed
test success
+- run test 7 pass (7 total) 320ms MaxRSS:2M тесты внутри модулей
+- run test 12 pass (12 total) 608ms MaxRSS:3M шаг 05
+- run test 10 pass (10 total) 1s MaxRSS:10M шаг 06
+- run test 5 pass (5 total) 2s MaxRSS:29M шаг 13
+- run test 6 pass (6 total) 1s MaxRSS:3M шаг 14
Прогон на Apple M4 проверяет тот же контракт в соглашении AArch64, где значение едет в x1 и x2. Чтобы увидеть именно System V, прогони те же тесты под amd64, как у zt и в JIT:
$ docker run --rm --platform linux/amd64 -v "$PWD":/work -w /work \
ghcr.io/bondiano/runner-zig:dev-amd64 \
zig build test --summary all --cache-dir /work/.zc --global-cache-dir /work/.zg
Build Summary: 11/11 steps succeeded; 40/40 tests passed
Практика
Пора самому восстановить функцию по её кадру. В задаче лежит дизассемблер рекурсивной rfun, снятый с реального Zig-кода: она сохраняет аргумент, на нуле возвращает ноль, иначе сдвигает аргумент вправо на два бита, зовёт себя и складывает результат с исходным значением. Твоя работа написать pub fn rfun(x: u64) u64 с тем же поведением. Тесты сверяют твою функцию с независимой петлёй на широком диапазоне, включая большие числа, где сумма оборачивается. Читай листинг по трём вопросам из разбора выше: где база, что уходит в рекурсию, как складывается результат.
Упражнения
Итоги
call targetкладёт адрес возврата на стек и прыгает;retснимает адрес и прыгает по нему. Этоpushплюсjmpиpopв счётчик команд.- Стековый кадр это участок стека одного вызова: сохранённый
%rbp, адрес возврата по0x8(%rbp), локальные ниже, аргументы вызываемых функций ещё ниже. Пролог его заводит, эпилог снимает. - Первые шесть целочисленных аргументов едут в
%rdi,%rsi,%rdx,%rcx,%r8,%r9, результат в%rax. Седьмой и дальше ложатся на стек, их читают по0x10(%rbp),0x18(%rbp)и так далее. - Числа с плавающей точкой едут своей очередью в
%xmm0до%xmm7, независимо от целочисленной очереди. - callee-saved регистры (
%rbx,%rbp,%r12до%r15) вызываемый обязан вернуть нетронутыми, сохраняя их на стек паройpushиpop. caller-saved (регистры аргументов,%rax,%r10,%r11) он вправе затирать. - Значение, которое нужно после вызова, обязано пережить его в callee-saved регистре или в кадре, потому что регистры аргументов после
callв общем случае мусор. - Перед
callзначение%rspобязано делиться на 16. Компилятор добивает нечётное числоpushфиктивнымpushи подбираетsubв прологе под это правило. - Локальная переменная живёт в регистре, пока у неё не берут адрес и она влезает в регистр.
&x, большой размер или нехватка регистров отправляют её на стек. - Рекурсия это обычные кадры: каждый незавершённый вызов держит свой кадр, а бесконечная рекурсия упирается в предел стека. Оптимизатор иногда сворачивает хвостовую рекурсию в цикл.
- Структура до 16 байт передаётся и возвращается в регистрах, больше 16 байт передаётся на стеке, а возвращается через скрытый указатель в
%rdi. Через C-совместимую границу передаётся толькоextern structс гарантированной раскладкой. - Реверс функции по ассемблеру сводится к трём вопросам: где база, что уходит аргументом в рекурсию, как результат вызова комбинируется с сохранённым значением.
- В шаге проекта
zt disasmнаучился прямомуcallи подписям: имена берутся из выводаnm, цель подписывается ближайшим именем не правее адреса,<ztSumSquares+0x22>, а нулевое смещение в объектнике это ещё не вписанный компоновщиком адрес. - В шаге проекта значение
zlсталоextern structиз двух слов, а у каждого примитива появился входfn (*Vm, Value) callconv(.c) Value: машина в%rdi, значение в%rsiи%rdx, результат в%raxи%rdx, ошибка в поле машины. Кадр замыкания в байткод-машине устроен как машинный: функция под аргументами,baseвместо%rbp, результат на месте функции.
Дальше
Ты научился читать вызов: регистры аргументов, стековый кадр, сохранённые регистры, выравнивание и возврат. Дальше в разделе мы посмотрим, как в кадре лежат составные данные: массивы и структуры, адресная арифметика по индексу, выравнивание полей и дырки между ними. А ещё через урок вернёмся к кадру с неприятной стороны: что происходит, когда запись в локальный массив выходит за его границы и затирает тот самый адрес возврата, который кладёт call.
домашка