Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Зачем Zig системщику и путь hello от текста до процесса
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Зачем Zig системщику и путь hello от текста до процесса
Программа это байты, которые процессор читает по протоколу. Чтобы увидеть протокол, нужен язык, который ничего не прячет: ни вызовов, ни памяти, ни проверок. Сегодня ставим Zig, пишем
hello.zigи проходим с ним весь путь от текста в файле до процесса в памяти, заглядывая на каждой остановке.
Цели урока
- Понять три обещания Zig (ни скрытого потока управления, ни скрытых аллокаций, один бинарник с кросс-компилятором C) и почему они важны именно системщику.
- Поставить Zig 0.16, собрать и запустить первую программу, разобрать, что в ней делает каждая строка.
- Пройти путь
hello.zigпо шагам: токены, дерево разбора, ZIR, AIR (два промежуточных представления компилятора, познакомимся ниже), машинный код, объектный файл, ELF, загрузка, процесс. На каждом шаге посмотреть на артефакт своими глазами. - Научиться пользоваться флагами
-femit-asm,--verbose-linkи-target. - Разобрать четыре режима сборки и увидеть, как одна и та же программа с переполнением падает в Debug и молча заворачивает в ReleaseFast.
Идея: язык, который ничего не прячет
В разделе про Rust ты видел язык, который защищает от ошибок памяти через систему типов. Zig решает другую задачу. Он не пытается доказать, что программа корректна. Он делает так, чтобы всё, что программа делает с машиной, было видно в тексте. Для системного программирования это ценнее, чем кажется: когда ты читаешь дизассемблер, считаешь такты или ищешь, где утекло, единственное, чего ты хочешь от языка, это чтобы между строкой кода и инструкциями процессора не было сюрпризов.
Три обещания Zig стоит проговорить прямо, потому что весь раздел на них опирается.
Никакого скрытого потока управления
В C++ или Java строка a = b + c может вызвать перегруженный оператор, а тот бросить исключение, которое размотает стек до обработчика тремя функциями выше. В Zig этого нет: ни перегрузки операторов, ни исключений, ни деструкторов, ни макросов. Если управление куда-то уходит, в строке есть вызов функции, try, return, break или defer. Ошибка в Zig это значение из именованного набора, и она возвращается через обычный return:
const std = @import("std");
const ParseError = error{ Empty, NotANumber };
fn parsePort(text: []const u8) ParseError!u16 {
if (text.len == 0) return error.Empty;
return std.fmt.parseInt(u16, text, 10) catch error.NotANumber;
}
fn portOrDefault(text: []const u8) u16 {
return parsePort(text) catch 8080;
}
test "ошибка это значение, а не прыжок" {
try std.testing.expectEqual(@as(u16, 4321), try parsePort("4321"));
try std.testing.expectError(error.Empty, parsePort(""));
try std.testing.expectError(error.NotANumber, parsePort("port"));
try std.testing.expectEqual(@as(u16, 8080), portOrDefault("oops"));
}
Тип ParseError!u16 читается как «либо u16, либо одна из ошибок ParseError». Оператор try это сокращение для «если ошибка, верни её вызывающему», catch подставляет значение или обрабатывает. Мы вернёмся к ошибкам подробно в уроке про ошибки, defer и аллокаторы, сейчас важно одно: в машинном коде try это сравнение и условный переход, и ты увидишь их в листинге уже сегодня.
Никаких скрытых аллокаций
Ни одна функция стандартной библиотеки Zig не берёт память из кучи сама по себе. Если функции нужна куча, она принимает аллокатор параметром, и это видно в сигнатуре:
const std = @import("std");
fn squares(gpa: std.mem.Allocator, count: usize) ![]u32 {
const out = try gpa.alloc(u32, count);
for (out, 0..) |*slot, i| slot.* = @intCast(i * i);
return out;
}
fn collectEven(gpa: std.mem.Allocator, limit: u32) ![]u32 {
var list: std.ArrayList(u32) = .empty;
errdefer list.deinit(gpa);
var n: u32 = 0;
while (n < limit) : (n += 2) try list.append(gpa, n);
return list.toOwnedSlice(gpa);
}
test "кто выделил, тот и освобождает" {
const gpa = std.testing.allocator;
const sq = try squares(gpa, 5);
defer gpa.free(sq);
try std.testing.expectEqualSlices(u32, &.{ 0, 1, 4, 9, 16 }, sq);
const even = try collectEven(gpa, 10);
defer gpa.free(even);
try std.testing.expectEqualSlices(u32, &.{ 0, 2, 4, 6, 8 }, even);
}
Обрати внимание на std.ArrayList(u32): в Zig 0.16 список не хранит аллокатор внутри, а получает его в каждом вызове append и deinit. Это выглядит как бойлерплейт, пока ты не начнёшь считать аллокации в горячем цикле: тогда становится ясно, что каждое место, где память может уйти в кучу, помечено словом gpa. Тестовый аллокатор к тому же следит за утечками. Убери defer gpa.free(sq) и тест упадёт:
const std = @import("std");
test "утечка видна тестовому аллокатору" {
const gpa = std.testing.allocator;
const buf = try gpa.alloc(u8, 16);
_ = buf;
}
1/1 leak.test.утечка видна тестовому аллокатору...OK
[DebugAllocator] (err): memory address 0x1004a0000 leaked:
leak.zig:5:30: 0x100351843 in test.утечка видна тестовому аллокатору (test)
const buf = try gpa.alloc(u8, 16);
^
All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1
Тест прошёл, а прогон провалился: DebugAllocator под тестами помнит каждое выделение и после теста печатает стек того, что не вернули.
Один бинарник с кросс-компилятором C
Третье обещание про инструмент, а не про язык. Файл zig содержит компилятор Zig, компилятор C и C++ (clang внутри), линковщик и заголовки libc для десятков целей. Команда zig cc это готовый кросс-компилятор:
zig cc -target x86_64-linux-musl -Os hello.c -o hello-c-linux
zig cc -Os hello.c -o hello-c-macos
Один и тот же hello.c собран на macOS с Apple Silicon в статический ELF под Linux x86-64 на 4728 байт и в родной Mach-O на 49 760 байт, без единой установленной библиотеки и без тулчейна. В уроке про comptime и сборку мы будем подключать C-код к Zig именно так. Пока запомни: если тебе нужен ELF под Linux, а под рукой макбук, zig его соберёт. Запустить его локально не выйдет, но песочница курса и docker run --platform linux/amd64 умеют.
Чего в Zig нет, тоже стоит перечислить, чтобы не искать: сборщика мусора, RAII с деструкторами, исключений, перегрузки операторов, макросов препроцессора, неявных преобразований чисел с потерей точности и нулевых указателей вне явных опционалов.
Ставим Zig 0.16 и пишем hello
Курс написан под Zig 0.16.0, текущую стабильную версию. На macOS проще всего через Homebrew, на Linux бери архив с ziglang.org/download и положи распакованную папку в PATH:
brew install zig # macOS
zig version
0.16.0
Команда zig env покажет, где лежит стандартная библиотека. Это важно: когда сигнатура в документации не совпадает с тем, что просит компилятор, правду знает папка std установленной версии, а не память и не поисковик. Открывай lib/zig/std/Io/File.zig и читай.
Первая программа выглядит длиннее, чем ты ожидаешь от hello:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [64]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
try out.print("hello, world\n", .{});
try out.flush();
}
zig build-exe hello.zig
./hello
hello, world
Разберём по строкам, потому что здесь уже спрятаны все три обещания.
mainпринимаетstd.process.Init. Это структура, которую собирает стартовый код стандартной библиотеки: аргументы, окружение, аллокаторgpaиio. В Zig 0.16 ввод-вывод, как и память, передаётся явно:init.ioэто значение, через которое идёт любое чтение и запись. Нет глобальногоstdout. Есть файл с дескриптором 1, и писать в него ты просишь черезio.var buf: [64]u8 = undefinedэто буфер записи. Он лежит на стеке, его размер выбрал ты, и никакой скрытой аллокации под буферизацию нет.stdout().writer(init.io, &buf)создаётIo.File.Writer, а&w.interfaceдаёт указатель на общий интерфейсIo.Writer, у которого естьprint,writeAllиflush.try out.print(...)кладёт байты в буфер. Если бы запись могла упасть, ошибка вернулась бы изmainчерезtry, и стартовый код напечатал бы её имя.try out.flush()реально зовёт системный вызовwrite. Забудешьflush, и программа завершится с пустым выводом: байты останутся вbufна стеке. Это самая частая ошибка новичка в 0.16, и она хорошо иллюстрирует принцип: буфер видно, значит, и момент записи видно.
Тип !void у main означает «либо ничего, либо ошибка». zig run hello.zig собирает и запускает за один шаг, zig build-exe оставляет бинарник рядом.
Демонстрация: путь hello от текста до процесса
В первой главе CS:APP программа hello.c проходит через препроцессор, компилятор, ассемблер и линковщик, затем загружается и становится процессом. В Zig препроцессора нет, а компилятор внутри устроен как конвейер из двух промежуточных представлений, ZIR и AIR. Мы пройдём этот конвейер по этапам и на каждом посмотрим настоящий артефакт. Виджет ниже собирает их вместе, а дальше в тексте команда и программа для каждого.
Исходник
hello.zig, 9 строк, 257 байт
Пока это просто байты в файле. Компилятор ещё ничего о них не знает, кроме того, что это UTF-8.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [64]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
try out.print("hello, world\n", .{});
try out.flush();
}
Токены
std.zig.Tokenizer, снято программой dump_tokens.zig из урока
Лексер режет текст на 85 токенов. Пробелы и переводы строк исчезли, каждая лексема получила тег и позицию.
keyword_const const
identifier std
equal =
builtin @import
l_paren (
string_literal "std"
r_paren )
semicolon ;
keyword_pub pub
keyword_fn fn
identifier main
l_paren (
identifier init
colon :
identifier std
period .
identifier process
period .
identifier Init
r_paren )
bang !
identifier void
l_brace {
keyword_var var
identifier buf
colon :
l_bracket [
number_literal 64
r_bracket ]
identifier u8
... (всего 85 токенов, последний eof)
Дерево разбора
std.zig.Ast.parse, снято программой dump_ast.zig из урока
42 узла. Из плоского списка токенов парсер собрал дерево: объявление функции, блок, объявления переменных, вызовы, доступ к полям.
root
simple_var_decl const std = @import("std")
builtin_call_two @import("std")
string_literal "std"
fn_decl pub fn main(init: std.process.Init) !void {
fn_proto_simple pub fn main(init: std.process.Init) !void
block_semicolon {
simple_var_decl var buf: [64]u8 = undefined
array_type [64]u8
identifier undefined
simple_var_decl var w = std.Io.File.stdout().writer(init.io, &buf)
call std.Io.File.stdout().writer(init.io, &buf)
field_access std.Io.File.stdout().writer
call_one std.Io.File.stdout()
field_access std.Io.File.stdout
field_access std.Io.File
field_access std.Io
identifier std
field_access init.io
identifier init
address_of &buf
identifier buf
simple_var_decl const out = &w.interface
address_of &w.interface
field_access w.interface
identifier w
try try out.print("hello, world\n", .{})
call out.print("hello, world\n", .{})
field_access out.print
identifier out
string_literal "hello, world\n"
struct_init_dot_two .{}
try try out.flush()
call_one out.flush()
field_access out.flush
identifier out
ZIR, нетипизированный IR
std.zig.AstGen.generate, снято программой dump_zir.zig из урока
Дерево стало линейным списком из 66 инструкций без единого типа: alloc, store, field_call, try. Типы появятся только на следующем этапе.
zir instructions: 66
%0 extended (main_struct: корень файла)
%1 declaration (const std)
%2 import ("std")
%4 declaration (pub fn main)
%9 param (init)
%10 ret_type (!void)
%12 int (64)
%13 array_type ([64]u8)
%14 alloc_mut (var buf)
%15 store_node (= undefined)
%18 alloc_inferred_mut (var w, тип пока неизвестен)
%19 decl_ref (std)
%21 field_ptr (.Io)
%23 field_ptr (.File)
%25 field_call (.stdout())
%28 field_call (.writer(init.io, &buf))
%35 store_to_inferred_ptr
%36 resolve_inferred_alloc
%39 field_ptr (w.interface)
%40 validate_const (const out)
%45 field_call (out.print(...))
%46 str ("hello, world\n")
%48 struct_init_empty_result (.{})
%50 try
%56 field_call (out.flush())
%57 try
%63 ret_implicit
%64 func_inferred (main)
... (dbg_stmt и break_inline опущены, в полном дампе их 20)
Sema и AIR, типизированный IRсхема
схема, не дамп: релизная сборка zig не печатает AIR (нужен компилятор с debug extensions)
Семантический анализ выполняет comptime, подставляет типы, инстанцирует дженерики из std и раскрывает try в проверку ошибки и ветвление. На выходе AIR: инструкции с типами, уже без field_call и anytype.
%0 = arg(init: std.process.Init)
%1 = alloc(*[64]u8) ; var buf
%2 = call(Io.File.stdout, []) ; -> Io.File
%3 = struct_field_val(%0, "io") ; init.io : Io
%4 = call(Io.File.writer, [%2, %3, %1]) ; -> Io.File.Writer
%5 = alloc(*Io.File.Writer)
%6 = store(%5, %4) ; var w
%7 = struct_field_ptr(%5, "interface") ; out : *Io.Writer
%8 = call(Io.Writer.writeAll, [%7, "hello, world\n"]) ; -> Io.Writer.Error!void
%9 = is_non_err(%8)
cond_br(%9, then: %10, else: %13)
%10 = call(Io.Writer.flush, [%7]) ; -> Io.Writer.Error!void
%11 = is_non_err(%10)
cond_br(%11, then: %12, else: %14)
%12 = ret(void)
%13 = ret(unwrap_errunion_err(%8)) ; try вернул ошибку print
%14 = ret(unwrap_errunion_err(%10)) ; try вернул ошибку flush
Машинный код
zig build-exe hello.zig -O ReleaseSmall -target x86_64-linux -femit-asm=hello.s -fno-emit-bin (main помечен noinline, иначе он растворяется в _start)
Кодогенератор раскладывает Writer на стек (буфер на 64 байта, указатель на vtable, счётчик), зовёт writeAll с длиной 13 и делает flush косвенным вызовом через таблицу функций.
hello.main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 168
mov byte ptr [rbp - 12], 0
movups xmm0, xmmword ptr [rdi + 56] ; init.io
lea rbx, [rbp - 72] ; &w
movaps xmmword ptr [rbx - 24], xmm0
and qword ptr [rbx - 8], 0
mov qword ptr [rbx], offset .L__anon_14710 ; vtable Writer
lea rax, [rbp - 160] ; &buf
mov qword ptr [rbx + 8], rax ; w.interface.buffer.ptr
mov qword ptr [rbx + 16], 64 ; w.interface.buffer.len
and qword ptr [rbx + 24], 0 ; end = 0
mov qword ptr [rbx + 32], 1
and dword ptr [rbx + 40], 0
and word ptr [rbx + 44], 0
mov byte ptr [rbx + 46], 1
push 13 ; len("hello, world\n")
pop rdx
mov esi, offset .L__anon_4411 ; "hello, world\n"
mov rdi, rbx
call .LIo.Writer.writeAll
test ax, ax ; try: ошибка?
jne .LBB1_2
mov rax, qword ptr [rbp - 72]
mov rdi, rbx
call qword ptr [rax + 16] ; flush через vtable
.LBB1_2:
add rsp, 168
pop rbx
pop rbp
ret
Объектный файл
zig build-obj hello.zig -O ReleaseSmall -target x86_64-linux, затем objdump -h, nm, objdump -r
Перемещаемый ELF на 200 КБ: секции с кодом и данными по нулевым адресам, таблица символов и записи перемещений, которые линковщик заполнит настоящими адресами.
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
Sections:
Idx Name Size VMA
1 .text 00015ac1 0000000000000000 TEXT
2 .rela.text 00003480 0000000000000000
6 .rodata 00003a20 0000000000000000 DATA
9 .bss 0000a100 0000000000000000 BSS
10 .tbss 0004001c 0000000000000000 BSS
12 .data 00004a70 0000000000000000 DATA
19 .symtab 000008e8 0000000000000000
21 .strtab 00000527 0000000000000000
Символы (nm, 81 строка, здесь именованные):
0000000000000000 T _start
000000000001549c W getauxval
U memcpy
U memmove
U memset
U strlen
U __divti3
Перемещения (objdump -r, шесть из 2297):
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000076 R_X86_64_PC32 .bss+0x2004
00000000000000e7 R_X86_64_PC32 .bss-0x4
0000000000000118 R_X86_64_32 .bss+0x1000
0000000000000210 R_X86_64_REX_GOTPCRELX __init_array_end-0x4
0000000000000241 R_X86_64_PC32 .data+0x49cc
0000000000000290 R_X86_64_PC32 .rodata+0x1fe4
ELF после линковки
zig build-exe hello.zig -O ReleaseSmall -target x86_64-linux --verbose-link, затем objdump -f и objdump -p
Линковщик ld.lld склеил объектный файл с compiler_rt, положил секции по адресам от базы 0x1000000, выбросил мусор (--gc-sections) и записал точку входа _start. Файл статический, 145 024 байта.
ld.lld --error-limit=0 -O2 --entry _start -z stack-size=16777216
--image-base=16777216 --gc-sections --eh-frame-hdr -s -znow
-m elf_x86_64 -static -o hello hello_zcu.o --as-needed libcompiler_rt.a
hello: ELF 64-bit LSB executable, x86-64, statically linked, stripped
architecture: x86_64
start address: 0x0000000001009630 (это _start, не main)
Program Header (что загрузчик положит в память):
PHDR off 0x000040 vaddr 0x1000040 filesz 0x0001f8 memsz 0x0001f8 flags r--
LOAD off 0x000000 vaddr 0x1000000 filesz 0x008630 memsz 0x008630 flags r-- ; заголовки + .rodata
LOAD off 0x008630 vaddr 0x1009630 filesz 0x01625e memsz 0x01625e flags r-x ; .text
LOAD off 0x01e890 vaddr 0x1020890 filesz 0x000000 memsz 0x000770 flags rw-
LOAD off 0x01e890 vaddr 0x1021890 filesz 0x004a70 memsz 0x00f870 flags rw- ; .data + .bss
TLS off 0x01e890 vaddr 0x101f890 filesz 0x000000 memsz 0x04001c flags r--
STACK memsz 0x1000000 flags rw-
Секции:
.rodata 00004850 0000000001000240 DATA
.text 0001625e 0000000001009630 TEXT
.data 00004a70 0000000001021890 DATA
.bss 0000a100 0000000001027000 BSS
Загрузка
программа maps.zig из урока печатает свой /proc/self/maps; собрана под x86_64-linux, запущена в docker --platform linux/amd64 на macOS (строки [rosetta] от эмулятора)
execve читает program header и отображает сегменты LOAD по тем самым адресам из ELF: r--p для заголовков и констант, r-xp для кода, rw-p для данных, плюс анонимные страницы под .bss и стек.
01000000-0101a000 r--p 00000000 00:24 2784 /w/maps-linux ; заголовки, .rodata
0101a000-01076000 r-xp 00019000 00:24 2784 /w/maps-linux ; .text
01076000-01077000 rw-p 00074000 00:24 2784 /w/maps-linux ; .data
01077000-0107d000 rw-p 00074000 00:24 2784 /w/maps-linux
0107d000-01088000 rw-p 00000000 00:00 0 ; .bss (анонимные страницы)
7fffff760000-7fffff7b0000 rw-p 00000000 00:00 0
800000000000-800000026000 r--p 00000000 00:25 2 /mnt/rv/[rosetta]
800000026000-800000098000 r-xp 00026000 00:25 2 /mnt/rv/[rosetta]
ffff841a8000-ffff841ac000 r--p 00000000 00:00 0 [vvar]
ffff841ac000-ffff841ae000 r-xp 00000000 00:00 0 [vdso]
fffffde2a000-fffffde4b000 rw-p 00000000 00:00 0 [stack]
Процесс
./hello; echo $? (x86_64-linux сборка в docker, macOS сборка нативно: вывод одинаковый)
Ядро прыгает на _start. Дальше std.start собирает Init (аллокатор, Io, аргументы, окружение), зовёт main, а после возврата делает системный вызов exit_group с кодом 0.
$ ./hello
hello, world
$ echo $?
0
Цепочка вызовов внутри процесса:
_start ; точка входа из ELF, std/start.zig
posixCallMainAndExit ; разбирает argc/argv/envp, TLS
callMain ; собирает std.process.Init
hello.main(init) ; наш код
Io.Writer.writeAll ; кладёт 13 байт в буфер
Io.Writer.flush ; write(1, "hello, world\n", 13)
exit_group(0) ; процесс завершён
Клик по этапу или стрелки влево и вправо показывают артефакт этого этапа. Все фрагменты, кроме AIR, сняты с реальных команд из урока: их можно повторить у себя.
Токены
Компилятор начинает с лексера: он режет текст на лексемы и присваивает каждой тег. Стандартная библиотека Zig содержит тот же токенизатор, что и компилятор, поэтому дамп можно снять своей программой:
const std = @import("std");
const source: [:0]const u8 = @embedFile("hello.zig");
pub fn main() void {
var tokenizer = std.zig.Tokenizer.init(source);
while (true) {
const token = tokenizer.next();
std.debug.print("{s:<18} {s}\n", .{
@tagName(token.tag),
source[token.loc.start..token.loc.end],
});
if (token.tag == .eof) break;
}
}
@embedFile вклеивает hello.zig в бинарник как строку с нулём в конце, std.debug.print пишет в stderr без буфера и без io, поэтому здесь main без параметров. Запусти рядом с hello.zig:
zig run dump_tokens.zig
keyword_const const
identifier std
equal =
builtin @import
l_paren (
string_literal "std"
r_paren )
semicolon ;
keyword_pub pub
keyword_fn fn
identifier main
l_paren (
identifier init
colon :
identifier std
period .
identifier process
period .
identifier Init
r_paren )
bang !
identifier void
l_brace {
...
Всего 85 токенов. Пробелы и переводы строк исчезли, зато у каждого токена есть тег и позиция в исходнике: по ним компилятор потом печатает «строка 7, столбец 17» в сообщении об ошибке.
Дерево разбора
Парсер собирает из плоского списка токенов дерево. В Zig оно хранится не как объекты с указателями, а как массив узлов с тегами и индексами, и std.zig.Ast даёт к нему доступ. Программа ниже печатает дерево для hello.zig; она знает только те виды узлов, которые в hello встречаются, для других веток остановится на первом уровне:
const std = @import("std");
const Ast = std.zig.Ast;
const source: [:0]const u8 = @embedFile("hello.zig");
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
var tree = try Ast.parse(gpa, source, .zig);
defer tree.deinit(gpa);
std.debug.print("tokens: {d}, nodes: {d}\n", .{ tree.tokens.len, tree.nodes.len });
std.debug.print("root\n", .{});
for (tree.rootDecls()) |decl| printNode(tree, decl, 1);
}
fn printNode(tree: Ast, node: Ast.Node.Index, depth: usize) void {
const first_line = std.mem.sliceTo(tree.getNodeSource(node), '\n');
std.debug.print("{s}{s} {s}\n", .{ indent(depth), @tagName(tree.nodeTag(node)), first_line });
var buf: [2]Ast.Node.Index = undefined;
var buf1: [1]Ast.Node.Index = undefined;
switch (tree.nodeTag(node)) {
.fn_decl => {
const proto_and_body = tree.nodeData(node).node_and_node;
printNode(tree, proto_and_body[0], depth + 1);
printNode(tree, proto_and_body[1], depth + 1);
},
.block, .block_semicolon, .block_two, .block_two_semicolon => {
for (tree.blockStatements(&buf, node).?) |stmt| printNode(tree, stmt, depth + 1);
},
.simple_var_decl, .global_var_decl, .local_var_decl, .aligned_var_decl => {
const decl = tree.fullVarDecl(node).?;
if (decl.ast.type_node.unwrap()) |type_node| printNode(tree, type_node, depth + 1);
if (decl.ast.init_node.unwrap()) |init_node| printNode(tree, init_node, depth + 1);
},
.call, .call_comma, .call_one, .call_one_comma => {
const call = tree.fullCall(&buf1, node).?;
printNode(tree, call.ast.fn_expr, depth + 1);
for (call.ast.params) |param| printNode(tree, param, depth + 1);
},
.field_access => printNode(tree, tree.nodeData(node).node_and_token[0], depth + 1),
.@"try", .address_of => printNode(tree, tree.nodeData(node).node, depth + 1),
.builtin_call_two, .builtin_call_two_comma => {
const args = tree.nodeData(node).opt_node_and_opt_node;
if (args[0].unwrap()) |arg| printNode(tree, arg, depth + 1);
if (args[1].unwrap()) |arg| printNode(tree, arg, depth + 1);
},
else => {},
}
}
fn indent(depth: usize) []const u8 {
const spaces = " ";
return spaces[0..@min(depth * 2, spaces.len)];
}
tokens: 85, nodes: 42
root
simple_var_decl const std = @import("std")
builtin_call_two @import("std")
string_literal "std"
fn_decl pub fn main(init: std.process.Init) !void {
fn_proto_simple pub fn main(init: std.process.Init) !void
block_semicolon {
simple_var_decl var buf: [64]u8 = undefined
array_type [64]u8
identifier undefined
simple_var_decl var w = std.Io.File.stdout().writer(init.io, &buf)
call std.Io.File.stdout().writer(init.io, &buf)
field_access std.Io.File.stdout().writer
call_one std.Io.File.stdout()
field_access std.Io.File.stdout
field_access std.Io.File
field_access std.Io
identifier std
field_access init.io
identifier init
address_of &buf
identifier buf
simple_var_decl const out = &w.interface
address_of &w.interface
field_access w.interface
identifier w
try try out.print("hello, world\n", .{})
call out.print("hello, world\n", .{})
field_access out.print
identifier out
string_literal "hello, world\n"
struct_init_dot_two .{}
try try out.flush()
call_one out.flush()
field_access out.flush
identifier out
Здесь main уже принимает Init, потому что парсеру нужен аллокатор: дерево живёт в куче, и defer tree.deinit(gpa) освобождает его перед выходом. Сорок два узла, и в них уже видна структура: цепочка field_access для std.Io.File.stdout, два try в конце блока, .{} как struct_init_dot_two. Типов пока нет: парсер не знает, что такое Init, и не должен.
ZIR: нетипизированный промежуточный код
Дальше начинается то, чего в главе 1 книги нет, потому что там компилятор чёрный ящик. Zig превращает дерево в ZIR, линейный список инструкций без типов. Его тоже можно снять из std:
const std = @import("std");
const source: [:0]const u8 = @embedFile("hello.zig");
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
var tree = try std.zig.Ast.parse(gpa, source, .zig);
defer tree.deinit(gpa);
var zir = try std.zig.AstGen.generate(gpa, tree);
defer zir.deinit(gpa);
const tags = zir.instructions.items(.tag);
std.debug.print("zir instructions: {d}\n", .{tags.len});
for (tags, 0..) |tag, index| {
std.debug.print("%{d:<3} {s}\n", .{ index, @tagName(tag) });
}
}
zir instructions: 66
%0 extended
%1 declaration
%2 import
%3 break_inline
%4 declaration
...
%12 int
%13 array_type
%14 alloc_mut
%15 store_node
%16 dbg_var_ptr
%17 dbg_stmt
%18 alloc_inferred_mut
%19 decl_ref
...
%25 field_call
...
%28 field_call
...
%45 field_call
%46 str
%47 break_inline
%48 struct_init_empty_result
%49 break_inline
%50 try
%51 err_union_code
...
%56 field_call
%57 try
...
%63 ret_implicit
%64 func_inferred
%65 break_inline
Прочитай это как рецепт: alloc_mut под buf, alloc_inferred_mut под w (тип переменной пока неизвестен, он выведется из правой части), field_call для каждого вызова метода, str для строки, try для каждого try. Инструкции dbg_stmt и dbg_var_ptr это привязка к строкам исходника для отладчика. ZIR строится один раз на файл и не зависит от целевой платформы, поэтому он кэшируется, а zig ast-check hello.zig находит ошибки вроде неиспользуемой переменной, вообще не занимаясь типами.
Sema и AIR: типы, comptime и инстанцирование
Следующий этап называется Sema, семантический анализ. Он идёт по ZIR функции main и вычисляет типы: std это модуль, Init это структура, stdout() возвращает Io.File, writer с такими аргументами возвращает Io.File.Writer, а out.print это вызов дженерика Io.Writer.print с типом аргументов struct{}. Здесь же выполняется весь comptime: строка формата в print разбирается компилятором, и в машинный код попадает уже готовый вызов writeAll с тринадцатью байтами. Результат Sema это AIR, типизированное представление одной функции.
Дамп AIR наружу релизный zig не отдаёт: флаг -t у ast-check и отладочные дампы доступны только в отладочной сборке самого компилятора. Поэтому в виджете AIR подписан как схема. Важно, что ты про него знаешь: try в AIR уже превратился в is_non_err плюс cond_br, а anytype и comptime исчезли, потому что всё, что от них зависело, вычислено. Один и тот же try out.flush() на двух этапах:
ZIR (без типов) AIR (после Sema)
%56 field_call flush %a call Io.Writer.flush(...) -> E!void
%57 try %b is_non_err %a -> bool
%c cond_br %b, then: продолжить, else: вернуть ошибку
Машинный код
AIR уходит в кодогенератор. У Zig их несколько: собственный x86-64 бэкенд для Debug, LLVM для релизных режимов. Флаг -femit-asm просит компилятор выдать ассемблер вместо объектного файла (собственный бэкенд этого не умеет: в Debug под x86_64-linux файл .s появится, только если добавить -fllvm), -fno-emit-bin отключает сборку бинарника, а -target выбирает цель. Все листинги раздела мы снимаем под Linux x86-64, как договорились в плане курса:
zig build-exe hello.zig -O ReleaseSmall -target x86_64-linux -femit-asm=hello.s -fno-emit-bin
Есть одна ловушка: в релизных режимах main встраивается в стартовый код и как отдельная функция в листинге не появляется. Чтобы увидеть её целиком, для этого снимка main объявлена как pub noinline fn main. Вот тело функции из hello.s, без директив .cfi. Метка начинается с .L: в релизных режимах main это локальный символ, в таблицу символов он не попадает:
.Lhello.main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 168
mov byte ptr [rbp - 12], 0
movups xmm0, xmmword ptr [rdi + 56]
lea rbx, [rbp - 72]
movaps xmmword ptr [rbx - 24], xmm0
and qword ptr [rbx - 8], 0
mov qword ptr [rbx], offset .L__anon_14710
lea rax, [rbp - 160]
mov qword ptr [rbx + 8], rax
mov qword ptr [rbx + 16], 64
and qword ptr [rbx + 24], 0
mov qword ptr [rbx + 32], 1
and dword ptr [rbx + 40], 0
and word ptr [rbx + 44], 0
mov byte ptr [rbx + 46], 1
push 13
pop rdx
mov esi, offset .L__anon_4411
mov rdi, rbx
call .LIo.Writer.writeAll
test ax, ax
jne .LBB1_2
mov rax, qword ptr [rbp - 72]
mov rdi, rbx
call qword ptr [rax + 16]
.LBB1_2:
add rsp, 168
pop rbx
pop rbp
ret
Читать x86-64 мы будем учиться десять уроков, но кое-что видно уже сейчас. sub rsp, 168 резервирует кадр: там лежат и buf (адрес rbp - 160, и ровно 64 записывается как длина буфера), и структура Writer (адрес в rbx), первое поле которой, указатель на таблицу функций .L__anon_14710. push 13 и pop rdx это длина строки "hello, world\n", esi получает её адрес, дальше вызов writeAll. Строка test ax, ax с jne это наш try: если вернулся ненулевой код ошибки, прыгаем мимо flush. Сам flush это call qword ptr [rax + 16], косвенный вызов через таблицу функций, потому что Io.Writer не знает, в файл он пишет или в память. Никаких вызовов, которых нет в исходнике, в листинге нет. Это и есть первое обещание, увиденное в железе.
Объектный файл
Ассемблер превращает текст в байты и складывает их в перемещаемый объектный файл. Команда zig build-obj останавливается на этом шаге:
zig build-obj hello.zig -O ReleaseSmall -target x86_64-linux
file hello.o
objdump -h hello.o
nm hello.o
objdump -r hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
Sections:
Idx Name Size VMA
1 .text 00015ac1 0000000000000000 TEXT
2 .rela.text 00003480 0000000000000000
6 .rodata 00003a20 0000000000000000 DATA
9 .bss 0000a100 0000000000000000 BSS
12 .data 00004a70 0000000000000000 DATA
19 .symtab 000008e8 0000000000000000
Все секции лежат по нулевому адресу: объектный файл ещё не знает, куда его положат. В таблице символов _start определён (T), а memcpy, memset и strlen помечены U, не определены: их принесёт библиотека compiler_rt на следующем шаге. Секции .rela.* хранят 2297 записей перемещений вида «по смещению 0x76 в .text впиши адрес .bss + 0x2004 относительно текущей инструкции» (560 из них в .rela.text, остальные в .rela.rodata, .rela.eh_frame и других .rela.*). Линковщик заполнит их, когда узнает настоящие адреса. К этому мы вернёмся в блоке про компоновку, а objdump и nm на macOS есть из коробки в составе Xcode и понимают ELF.
ELF после линковки
Линковщик собирает объектные файлы в исполняемый файл. Флаг --verbose-link печатает команду, которой Zig зовёт встроенный линковщик lld из проекта LLVM:
zig build-exe hello.zig -O ReleaseSmall -target x86_64-linux --verbose-link
ld.lld --error-limit=0 -mllvm -float-abi=hard -O2 --entry _start -z stack-size=16777216 --build-id=none
--image-base=16777216 --gc-sections --eh-frame-hdr -s -znow -m elf_x86_64 -static
-o hello hello_zcu.o --as-needed libcompiler_rt.a
Читай флаги как список решений: точка входа _start, а не main; стек 16 МБ; база образа 0x1000000; --gc-sections выбрасывает код, до которого нет пути от точки входа; -s убирает символы (в ReleaseSmall это по умолчанию); -static означает, что никаких .so при запуске не понадобится; compiler_rt даёт memcpy и остальные U из прошлого шага. Результат это ELF на 145 024 байта:
file hello
objdump -f hello
objdump -p hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
start address: 0x0000000001009630
Program Header:
LOAD off 0x000000 vaddr 0x1000000 filesz 0x008630 memsz 0x008630 flags r--
LOAD off 0x008630 vaddr 0x1009630 filesz 0x01625e memsz 0x01625e flags r-x
LOAD off 0x01e890 vaddr 0x1020890 filesz 0x000000 memsz 0x000770 flags rw-
LOAD off 0x01e890 vaddr 0x1021890 filesz 0x004a70 memsz 0x00f870 flags rw-
STACK memsz 0x1000000 flags rw-
Теперь у секций есть адреса: .text начинается с 0x1009630, и это же адрес точки входа. Записи LOAD это инструкция для загрузчика: какой кусок файла (off, filesz) положить по какому адресу (vaddr) с какими правами. Обрати внимание на последний LOAD: в файле 0x4a70 байт, а в памяти 0xf870. Разница это .bss, неинициализированные данные, которые в файле не хранятся, а в памяти обнуляются.
Загрузка и процесс
Когда ты набираешь ./hello, оболочка делает fork, а в дочернем процессе execve. Ядро читает заголовок программы (program header), отображает сегменты LOAD по их адресам, выделяет стек, кладёт на него аргументы и окружение и передаёт управление на start address. Увидеть результат можно изнутри: программа ниже печатает собственную карту памяти из /proc/self/maps. Файл этот есть только в Linux, поэтому собираем под x86_64-linux и запускаем в контейнере:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var buf: [16 * 1024]u8 = undefined;
const text = try std.Io.Dir.cwd().readFile(init.io, "/proc/self/maps", &buf);
std.debug.print("{s}", .{text});
}
zig build-exe maps.zig -O ReleaseFast -target x86_64-linux
docker run --rm --platform linux/amd64 -v "$PWD":/w alpine /w/maps
01000000-0101a000 r--p 00000000 00:24 2784 /w/maps
0101a000-01076000 r-xp 00019000 00:24 2784 /w/maps
01076000-01077000 rw-p 00074000 00:24 2784 /w/maps
01077000-0107d000 rw-p 00074000 00:24 2784 /w/maps
0107d000-01088000 rw-p 00000000 00:00 0
7fffff760000-7fffff7b0000 rw-p 00000000 00:00 0
800000000000-800000026000 r--p 00000000 00:25 2 /mnt/rv/[rosetta]
ffff841ac000-ffff841ae000 r-xp 00000000 00:00 0 [vdso]
fffffde2a000-fffffde4b000 rw-p 00000000 00:00 0 [stack]
Первые четыре строки это те самые сегменты LOAD из ELF, только уже в памяти: r--p для заголовков и констант, r-xp для кода, rw-p для данных. Пятая, без имени файла, это .bss, анонимные обнулённые страницы. Строки [rosetta] появились потому, что снимок сделан на Apple Silicon через эмулятор; на настоящем x86-64 их не будет, всё остальное будет таким же. [vdso] и [stack] добавило ядро.
Дальше ядро прыгает на _start. Это не твой код, а std/start.zig: он разбирает argc, argv и окружение со стека, настраивает поточно-локальную память, собирает std.process.Init с аллокатором и io, зовёт main, а после возврата делает системный вызов exit_group с кодом 0. Ровно поэтому точка входа в ELF указывает не на main: main в Zig это обычная функция, которую вызывает стандартная библиотека.
./hello; echo $?
hello, world
0
Путь пройден: 257 байт текста стали 85 токенами, 42 узлами, 66 инструкциями ZIR, типизированным AIR, тремя десятками строк ассемблера, объектным файлом с 2297 перемещениями, статическим ELF на 145 КБ, четырьмя сегментами в памяти и процессом, который написал тринадцать байт в дескриптор 1 и вернул ноль.
Четыре режима сборки
Флаг -O выбирает режим сборки, и это не только «быстро или с отладкой». Режим определяет, какие проверки компилятор вставляет в код, а значит, что произойдёт при переполнении, выходе за границы среза или обращении к undefined. Таблица:
| Режим | Оптимизация | Проверки безопасности | Отладочная информация | Для чего |
|---|---|---|---|---|
Debug | нет | все: переполнение, границы, unreachable, undefined, защита стека (stack protector) | полная, в бинарнике | разработка, стек паники со строками |
ReleaseSafe | полная | все, как в Debug | есть, но код переставлен, строки в трассе приблизительные | продакшен, где безопасность важнее скорости |
ReleaseFast | полная | выключены: переполнение заворачивает, выход за границы это неопределённое поведение | есть, -fstrip уберёт | замеры и горячий код |
ReleaseSmall | размер | выключены, как в ReleaseFast | символы сняты (-s у линковщика) | прошивки, wasm, минимальные образы |
Разница между двумя первыми и двумя последними режимами видна на программе с переполнением. Переменная counter типа u8 стартует с числа из аргумента командной строки и сто раз получает +1:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
var args = init.minimal.args.iterate();
_ = args.next();
const text = args.next() orelse "200";
const start = try std.fmt.parseInt(u8, text, 10);
var counter: u8 = start;
var i: u32 = 0;
while (i < 100) : (i += 1) {
counter += 1;
}
std.debug.print("start = {d}, counter = {d}\n", .{ start, counter });
}
Число берётся из аргументов специально: если написать var counter: u8 = 200 прямо в коде, компилятор вычислит переполнение на этапе Sema и откажется собирать. Собираем в двух режимах и запускаем с аргументом 200:
zig build-exe overflow.zig -O Debug -femit-bin=overflow-debug
zig build-exe overflow.zig -O ReleaseFast -femit-bin=overflow-fast
./overflow-debug 200
./overflow-fast 200
thread 45250167 panic: integer overflow
overflow.zig:12:17: 0x102615b2b in main (overflow)
counter += 1;
^
std/start.zig:737:30: 0x102615fff in callMain (overflow)
return wrapMain(root.main(.{
^
start = 200, counter = 44
В Debug программа падает на первом же +1 после 255 с паникой, стеком и точной строкой. В ReleaseFast тот же код молча заворачивает: 200 плюс 100 равно 300, а 300 по модулю 256 равно 44. Ни ошибки, ни предупреждения, только неверное число, как в C. ReleaseSafe тоже паникует с сообщением integer overflow, но строка в трассе может указать не туда, потому что код переставлен оптимизатором. Если переполнение нужно по смыслу, в Zig есть операторы +% для заворачивания и +| для насыщения: они работают одинаково во всех режимах, и о них следующий урок.
Режим влияет и на размер. Одна и та же hello.zig, собранная на macOS с Apple Silicon под две цели (размеры в байтах, снято на zig 0.16.0; первые три строки плавают на несколько байт от каталога, где лежит исходник, потому что путь к нему попадает в отладочную информацию):
| Режим | aarch64-macos (родная) | x86_64-linux | x86_64-linux с -fstrip |
|---|---|---|---|
Debug | 2 046 000 | 10 393 229 | 3 197 560 |
ReleaseSafe | 457 592 | 3 683 328 | 324 288 |
ReleaseFast | 396 264 | 3 784 040 | 239 184 |
ReleaseSmall | 165 160 | 145 024 | 145 024 |
Десять мегабайт у Debug под Linux это отладочная информация DWARF для всей стандартной библиотеки, которая попала в сборку: -fstrip режет её до трёх. Разрыв между ReleaseFast и ReleaseFast с -fstrip той же природы. А ReleaseSmall одинаков в обеих колонках, потому что он и так снимает символы. Для macOS цифры меньше, потому что отладочная информация там по умолчанию живёт в отдельном файле, а не в бинарнике.
@breakpoint: остановка без отладочной информации
Таблица говорит, что в ReleaseFast символы можно снять флагом -fstrip, и после этого отладчик перестаёт понимать, где какая строка. Но останавливаться он не перестаёт. Встроенная функция @breakpoint() компилируется в инструкцию точки останова процессора: int3 на x86-64, brk #1 на AArch64. Вместе с std.debug.print перед ней получается отладка оптимизированного кода без символов, приём из заметки Нило Столте о Zig: печатаешь всё, что хочешь видеть, останавливаешься, смотришь, продолжаешь.
const std = @import("std");
pub fn main() void {
var sum: u32 = 0;
for (1..6) |i| {
sum += @intCast(i * i);
if (i == 3) {
std.debug.print("i = {d}, sum = {d}\n", .{ i, sum });
@breakpoint();
}
}
std.debug.print("итог {d}\n", .{sum});
}
zig build-exe bp.zig -O ReleaseFast -fstrip -femit-bin=bp
./bp
echo $?
i = 3, sum = 14
zsh: trace trap ./bp
133
Без отладчика точка останова смертельна: ядро посылает процессу SIGTRAP, и он завершается с кодом 133, то есть 128 плюс номер сигнала 5. Под отладчиком та же инструкция становится паузой:
$ lldb ./bp
(lldb) run
i = 3, sum = 14
Process 74911 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x100000bb4)
frame #0: 0x0000000100000bb8 bp`main + 1104
(lldb) continue
итог 55
Process 74911 exited with status = 0 (0x00000000)
В gdb те же две команды пишутся r и c, а пустой Enter повторяет последнюю, так что цикл «остановился, прочитал числа, продолжил» это один палец на одной клавише. Символов у бинарника нет, поэтому отладчик показывает адрес main + 1104 вместо строки, но нужные числа уже напечатаны строкой выше. Отладочную информацию Debug этот приём не заменяет, зато позволяет заглянуть внутрь той сборки, которая ведёт себя иначе, чем отладочная, а такое в разделе случится. Одно правило: @breakpoint() не должен доехать до продакшена, иначе первый же пользователь получит trace trap. Если точку нужно оставить в коде, оберни её в if (builtin.mode == .Debug), где builtin это @import("builtin").
Кросс-компиляция через -target
Флаг -target принимает тройку <arch>-<os>-<abi>, полный список даёт zig targets. Никаких дополнительных установок:
zig build-exe hello.zig -O ReleaseSmall -target x86_64-linux -femit-bin=hello-linux
zig build-exe hello.zig -O ReleaseSmall -target aarch64-macos -femit-bin=hello-macos
file hello-linux hello-macos
hello-linux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
hello-macos: Mach-O 64-bit executable arm64
Обрати внимание на statically linked у Linux-варианта: по умолчанию Zig не линкует libc, стандартная библиотека сама делает системные вызовы, и бинарник запустится на любом Linux с этой архитектурой, хоть в alpine, хоть в scratch. Если libc нужна (например, ради C-библиотеки), добавь -lc, и -target x86_64-linux-musl даст статическую musl, а -target x86_64-linux-gnu динамическую glibc. Все ассемблерные снимки раздела мы будем делать через -target x86_64-linux, а запускать в песочнице курса или через docker run --platform linux/amd64.
Практика
Вспомним урок про биты и байты: в памяти нет чисел, есть байты, а тип это договорённость о том, как их читать. В Zig эту договорённость можно снять одной функцией. std.mem.asBytes берёт указатель на значение и возвращает указатель на массив его байт, без копирования и без приведения типов в стиле C:
const std = @import("std");
test "одно значение, четыре байта, порядок little-endian" {
const value: u32 = 0x11223344;
const bytes = std.mem.asBytes(&value);
try std.testing.expectEqual(4, bytes.len);
try std.testing.expectEqualSlices(u8, &.{ 0x44, 0x33, 0x22, 0x11 }, bytes);
const one: f32 = 1.0;
try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x00, 0x80, 0x3f }, std.mem.asBytes(&one));
}
Байт 0x44 лежит первым, хотя в записи числа он последний: x86-64 и Apple Silicon хранят младший байт по младшему адресу, это little-endian. Единица типа f32 это 0x3f800000, и в памяти она развёрнута так же.
Задача ниже это show_bytes из второй главы CS:APP, переписанная под Zig. Напиши функцию showBytes(buf, value), которая берёт значение любого типа, проходит по его байтам через std.mem.asBytes и записывает их в буфер в шестнадцатеричном виде через пробел. Тесты проверяют u8, u16, u32, отрицательные i32, f32 и f64, а также ошибку NoSpaceLeft для маленького буфера. Аллокатор не нужен: вся память уже передана тебе срезом.
Упражнения
Итоги
- Zig не прячет поток управления (ошибки это значения,
tryэто сравнение и переход), не прячет аллокации (аллокатор всегда в сигнатуре) и приносит с собой кросс-компилятор C. - В Zig 0.16 ввод-вывод, как и память, передаётся явно:
mainполучаетstd.process.Init, писатель берётinit.ioи буфер, а безflushбайты остаются в буфере. - Путь
hello.zig: токены, дерево разбора, ZIR (без типов, кэшируется), Sema и AIR (типы, comptime, инстанцирование), машинный код, объектный файл с перемещениями, ELF со списком сегментовLOAD, загрузка по адресам из ELF, процесс, начинающийся с_start. -femit-asmпоказывает ассемблер,--verbose-linkкоманду линковщика,-targetсобирает под другую платформу без установки тулчейна.DebugиReleaseSafeловят переполнение паникой,ReleaseFastиReleaseSmallмолча заворачивают; режим определяет, какой код ты на самом деле запускаешь.
Дальше
Мы прошли путь от текста до процесса и увидели, что режим сборки решает, какой код на самом деле выполняется. Следующий урок про то, из чего этот код состоит: целые любой ширины вроде u3 и i5, явные преобразования @as, @intCast и @truncate, четыре способа сложить два числа (+, +%, +| и @addWithOverflow) и что каждый значит для бита переноса, опционалы вместо нулевых указателей и switch с диапазонами.
домашка