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

Зачем Zig системщику и путь hello от текста до процесса

middle~80 мин

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

Зачем 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: от текста до процессаzig 0.16.0 · снято на macOS arm64, целевая платформа x86_64-linux там, где подписано

Исходник

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

Клик по этапу или стрелки влево и вправо показывают артефакт этого этапа. Все фрагменты, кроме 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-linuxx86_64-linux с -fstrip
Debug2 046 00010 393 2293 197 560
ReleaseSafe457 5923 683 328324 288
ReleaseFast396 2643 784 040239 184
ReleaseSmall165 160145 024145 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 с диапазонами.

домашка

Домашка