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

Структуры, перечисления, объединения и раскладка в памяти

middle~80 мин

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

Структуры, перечисления, объединения и раскладка в памяти

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

Цели урока

  • Писать структуры с методами, значениями по умолчанию и константами в пространстве имён, пользоваться анонимными литералами .{}.
  • Задавать enum с явным тегом и явными значениями, конвертировать его в число и обратно, объявлять открытые перечисления с _.
  • Разбирать union(enum) через switch с захватом, понимать, чем «голый» union опасен и почему Debug ловит чтение не того поля.
  • Читать четыре встроенные функции: @sizeOf, @alignOf, @bitSizeOf, @offsetOf, и объяснять любое их значение через выравнивание.
  • Выбирать между struct, extern struct и packed struct по задаче: своя память, чужой ABI, битовое поле.

Идея

В прошлом уроке ты видел, что указатель это адрес плюс знание о выравнивании. Сегодня тот же вопрос задаём составным типам: где именно в памяти лежит каждое поле структуры и сколько байт занимает целое. Ответ зависит от того, кто раскладывает: компилятор, соглашение C или ты сам, бит за битом.

Начнём с языка, потому что без методов, перечислений и объединений системный код не написать, а затем спустимся к байтам, где Zig честнее большинства языков: у него нет «структуры вообще», есть три вида структур с разными гарантиями.

Структура: поля, методы, пространство имён

Структура в Zig это тип с полями и, необязательно, с объявлениями внутри: функциями, константами, вложенными типами. Функция, у которой первый параметр имеет тип структуры или указателя на неё, вызывается через точку, это и есть метод. Никакого скрытого this: параметр self объявлен явно, и по его типу видно, меняет метод значение или только читает.

const std = @import("std");

const Point = struct {
    x: i32,
    y: i32 = 0,

    pub const origin: Point = .{ .x = 0, .y = 0 };

    pub fn init(x: i32, y: i32) Point {
        return .{ .x = x, .y = y };
    }

    pub fn manhattan(self: Point) u32 {
        return @abs(self.x) + @abs(self.y);
    }

    pub fn shift(self: *Point, dx: i32, dy: i32) void {
        self.x += dx;
        self.y += dy;
    }
};

test "методы и self" {
    var p = Point.init(3, -4);
    try std.testing.expectEqual(@as(u32, 7), p.manhattan());

    p.shift(1, 1);
    try std.testing.expectEqual(@as(i32, 4), p.x);
    try std.testing.expectEqual(@as(u32, 7), Point.manhattan(p));
}

test "значение по умолчанию и константа в пространстве имён" {
    const q: Point = .{ .x = 5 };
    try std.testing.expectEqual(@as(i32, 0), q.y);
    try std.testing.expectEqual(@as(u32, 0), Point.origin.manhattan());
}

test "анонимная структура" {
    const cfg = .{ .port = 8080, .host = "localhost", .debug = true };
    try std.testing.expectEqual(8080, cfg.port);
    try std.testing.expectEqualStrings("localhost", cfg.host);
    try std.testing.expect(cfg.debug);
}

Четыре вещи, на которые стоит посмотреть внимательно.

manhattan(self: Point) получает копию, shift(self: *Point) получает указатель. Вызов p.shift(1, 1) сам берёт адрес p, потому что p объявлена через var. Для const p компилятор откажет: нельзя взять изменяемый указатель на константу. Это та же дисциплина, что у &mut в 23-rust/04 · Заимствование, ссылки и срезы, только без borrow checker: правило про один изменяемый указатель Zig не проверяет.

p.manhattan() и Point.manhattan(p) это одна и та же запись. Метод это функция в пространстве имён типа, синтаксис с точкой подставляет первый аргумент. Отсюда следует, что структура без полей это нормальный способ собрать функции и константы под одним именем: const math = struct { pub fn sq(x: i32) i32 { return x * x; } }; и дальше math.sq(3). Каждый файл .zig тоже структура, и @import возвращает её.

Поле y: i32 = 0 имеет значение по умолчанию, поэтому литерал .{ .x = 5 } компилируется. Поле без умолчания пропустить нельзя, компилятор скажет missing struct field. Это лучше, чем ноль по умолчанию для всего: забытое поле ловится на этапе компиляции, а не в проде.

Литерал .{ ... } без имени типа это анонимный структурный литерал. Когда тип известен из контекста, как в const q: Point = .{ .x = 5 }, он превращается в Point. Когда контекста нет, как в cfg, компилятор создаёт анонимный тип с полями port, host, debug. Так устроены аргументы print: кортеж .{ a, b } это анонимная структура с полями @"0" и @"1".

Перечисление с явным тегом

enum в Zig это набор именованных значений с целочисленным тегом. Тип тега пишется в скобках: enum(u8). Если у перечисления есть жизнь за пределами программы (байт в протоколе, код инструкции, код ошибки ОС), тег и значения задают явно. Тогда @intFromEnum и @enumFromInt переводят между именем и числом без таблиц.

const std = @import("std");

const Opcode = enum(u8) {
    nop = 0x00,
    load = 0x10,
    store = 0x11,
    add = 0x20,
    halt = 0xff,

    pub fn touchesMemory(self: Opcode) bool {
        return switch (self) {
            .load, .store => true,
            .nop, .add, .halt => false,
        };
    }
};

test "явные значения и преобразования" {
    try std.testing.expectEqual(@as(u8, 0x20), @intFromEnum(Opcode.add));
    try std.testing.expectEqual(Opcode.store, @as(Opcode, @enumFromInt(0x11)));
    try std.testing.expectEqual(@as(usize, 1), @sizeOf(Opcode));
    try std.testing.expectEqualStrings("halt", @tagName(Opcode.halt));
}

test "switch должен покрыть все варианты" {
    try std.testing.expect(Opcode.load.touchesMemory());
    try std.testing.expect(!Opcode.halt.touchesMemory());
}

const Signal = enum(u8) {
    hup = 1,
    int = 2,
    kill = 9,
    term = 15,
    _,

    pub fn describe(self: Signal) []const u8 {
        return switch (self) {
            .hup => "перечитать конфиг",
            .int, .term => "завершиться мягко",
            .kill => "умереть немедленно",
            _ => "неизвестный сигнал",
        };
    }
};

test "non-exhaustive enum принимает любое число" {
    const s: Signal = @enumFromInt(42);
    try std.testing.expectEqualStrings("неизвестный сигнал", s.describe());
    try std.testing.expectEqual(@as(u8, 42), @intFromEnum(s));
    try std.testing.expectEqualStrings("завершиться мягко", Signal.term.describe());
}

switch по перечислению обязан перечислить все варианты. Добавишь в Opcode новую инструкцию, и каждый switch без else перестанет компилироваться, пока ты не решишь, что с ней делать. Это главная причина писать .nop, .add, .halt => false вместо else => false: else молча проглотит новый вариант.

Signal объявлен с _ в конце. Это открытое перечисление: значение 42 для него законно, @enumFromInt(42) проходит без проверки, а в switch появляется ветка _ для всего, что не названо. Так описывают чужие протоколы, где набор значений может расшириться: сигналы ОС, коды HTTP, поля файловых форматов.

С закрытым перечислением всё строже. Что будет, если прочитать байт из сети и сделать @enumFromInt на Opcode? Проверим: программа принимает число из аргумента, чтобы значение не было известно на этапе компиляции.

const std = @import("std");

const Opcode = enum(u8) { nop = 0x00, load = 0x10, halt = 0xff };

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    const raw = try std.fmt.parseInt(u8, args[1], 0);
    const op: Opcode = @enumFromInt(raw);
    std.debug.print("{s}\n", .{@tagName(op)});
}
$ zig build-exe enum_from_int.zig
$ ./enum_from_int 0x10
load
$ ./enum_from_int 0x42
thread 47608072 panic: invalid enum value
enum_from_int.zig:8:24: 0x1005b5b07 in main (enum_from_int)

$ zig build-exe -O ReleaseFast enum_from_int.zig
$ ./enum_from_int 0x42
halt

В Debug и ReleaseSafe преобразование проверяется, и это одна из тех проверок, ради которых курс всё время собирает в двух режимах. В ReleaseFast проверки нет: значение 0x42 никакому варианту не соответствует, поведение @tagName для него не определено, в этом запуске он напечатал «halt», и программа пошла дальше с ложью в руках. Если значение приходит снаружи, пиши открытое перечисление или проверяй число до преобразования. Если бы 0x42 стояло в коде литералом, компилятор отказал бы ещё на этапе компиляции: enum 'enum_from_int.Opcode' has no tag with value '66'.

Объединение с тегом

union(enum) это размеченное объединение: одно значение, несколько возможных форм, и тег, который говорит, какая форма сейчас внутри. В функциональных разделах курса ты называл это суммой типов, а в Rust это enum с данными из 23-rust/06 · Перечисления и pattern matching. Zig даёт к нему switch с захватом поля.

const std = @import("std");

const Token = union(enum) {
    number: i64,
    ident: []const u8,
    plus,
    eof,

    pub fn describe(self: Token, buf: []u8) ![]const u8 {
        return switch (self) {
            .number => |n| std.fmt.bufPrint(buf, "число {d}", .{n}),
            .ident => |name| std.fmt.bufPrint(buf, "имя {s}", .{name}),
            .plus => "плюс",
            .eof => "конец",
        };
    }
};

test "union(enum): switch с захватом" {
    var buf: [64]u8 = undefined;
    const tokens = [_]Token{ .{ .number = 42 }, .{ .ident = "x" }, .plus, .eof };

    try std.testing.expectEqualStrings("число 42", try tokens[0].describe(&buf));
    try std.testing.expectEqualStrings("имя x", try tokens[1].describe(&buf));
    try std.testing.expectEqualStrings("плюс", try tokens[2].describe(&buf));
}

test "тег доступен как enum" {
    const t: Token = .{ .number = 7 };
    try std.testing.expectEqual(std.meta.Tag(Token).number, std.meta.activeTag(t));
    try std.testing.expect(t == .number);
    try std.testing.expect(t != .plus);
}

test "размер union(enum): самое большое поле плюс тег" {
    try std.testing.expectEqual(@as(usize, 16), @sizeOf([]const u8));
    try std.testing.expectEqual(@as(usize, 24), @sizeOf(Token));
    try std.testing.expectEqual(@as(usize, 8), @alignOf(Token));
}

test "изменение активного поля через указатель" {
    var t: Token = .{ .number = 1 };
    switch (t) {
        .number => |*n| n.* += 10,
        else => {},
    }
    try std.testing.expectEqual(@as(i64, 11), t.number);
}

Варианты plus и eof объявлены без типа, у них тип void: полезной нагрузки нет, есть только тег. Захват |n| даёт копию поля, захват |*n| даёт указатель на него, через который поле можно изменить прямо внутри объединения. Сравнение t == .number сравнивает только тег, так проверяют форму без switch.

Посмотри на размер. Самое большое поле, срез []const u8, занимает 16 байт: указатель и длина. Тег для четырёх вариантов влез бы в u2, но ему нужен хотя бы байт, а объединение выравнивается по 8, поэтому 16 плюс 1 округляется до 24. Семь из последних восьми байт это дыра. Запомни эту арифметику, ниже она повторится для структур.

Голый union и почему Debug паникует

Объединение можно объявить и без тега: union { int: i32, float: f32 }. Тогда язык не знает, какое поле активно, и ответственность за это на тебе. Zig в безопасных режимах всё равно подстраховывает: он тайно добавляет тег и проверяет его при каждом чтении.

const std = @import("std");

const Raw = union {
    int: i32,
    float: f32,
};

pub fn main() void {
    var value: Raw = .{ .int = 1 };
    std.debug.print("int = {d}, @sizeOf(Raw) = {d}\n", .{ value.int, @sizeOf(Raw) });
    value = .{ .float = 1.5 };
    std.debug.print("float = {d}\n", .{value.float});
    std.debug.print("int = {d}\n", .{value.int});
}
$ zig build-exe bare_union.zig
$ ./bare_union
int = 1, @sizeOf(Raw) = 8
float = 1.5
thread 47604641 panic: access of union field 'int' while field 'float' is active
bare_union.zig:13:43: 0x102965d83 in main (bare_union)
    std.debug.print("int = {d}\n", .{value.int});
                                          ^

$ zig build-exe -O ReleaseFast bare_union.zig
$ ./bare_union
int = 1, @sizeOf(Raw) = 4
float = 1.5
int = 1069547520

Два наблюдения из одного запуска. Первое: в Debug объединение из двух четырёхбайтных полей занимает 8 байт, а в ReleaseFast 4. Лишние четыре байта это скрытый тег плюс выравнивание, и именно он позволил напечатать осмысленную панику. Второе: в ReleaseFast тег исчез, чтение value.int вернуло 1069547520, то есть 0x3fc00000, битовое представление числа 1.5 в формате f32. Никакой ошибки, другая интерпретация тех же байт.

Отсюда правило. Если нужен вариант «одно из нескольких», бери union(enum): тег там официальный, switch его видит, размер одинаков во всех режимах. Голый union оставляй для случаев, когда ты действительно хочешь одну память под разными именами, и тогда пиши его как extern union.

extern union как переинтерпретация

extern union подчиняется раскладке C: никаких скрытых тегов ни в каком режиме, все поля начинаются с нулевого байта. Это законный способ посмотреть на одни и те же байты как на число с плавающей точкой, как на целое и как на массив байт одновременно.

const std = @import("std");

const FloatBits = extern union {
    f: f32,
    u: u32,
    bytes: [4]u8,
};

test "extern union: те же четыре байта под тремя именами" {
    const v: FloatBits = .{ .f = 1.0 };
    try std.testing.expectEqual(@as(u32, 0x3f800000), v.u);
    try std.testing.expectEqual([4]u8{ 0x00, 0x00, 0x80, 0x3f }, v.bytes);
    try std.testing.expectEqual(@as(usize, 4), @sizeOf(FloatBits));
}

test "@bitCast делает то же без union" {
    const bits: u32 = @bitCast(@as(f32, -2.0));
    try std.testing.expectEqual(@as(u32, 0xc0000000), bits);
    try std.testing.expectEqual(@as(f32, -2.0), @as(f32, @bitCast(bits)));
}

Единица в формате f32 это 0x3f800000, и массив байт показывает её задом наперёд, потому что x86-64 и Apple Silicon хранят младший байт первым, подробно в 23-rust/18 · Биты, байты и endianness. Для одноразового преобразования бери @bitCast: он требует, чтобы оба типа были одинаковой битовой ширины, и не создаёт лишнего типа. extern union выигрывает, когда одну память нужно читать под разными именами многократно, например в регистре устройства или в заголовке протокола, который приходит в нескольких версиях.

Четыре вопроса к компилятору

Теперь к главной теме. У любого типа можно спросить четыре числа.

  • @sizeOf(T): сколько байт занимает значение в памяти, включая внутренние и хвостовые дыры. Именно это число используется для шага по массиву [N]T.
  • @alignOf(T): адрес значения обязан делиться на это число. Для целых и чисел с плавающей точкой выравнивание равно размеру, для структуры это максимум по полям.
  • @bitSizeOf(T): сколько бит несут информацию. Для u3 это 3, хотя @sizeOf(u3) равен 1.
  • @offsetOf(T, "field"): на каком байте от начала структуры лежит поле.

Прежде чем смотреть на структуры, посмотри числа для примитивов. Ниже вывод короткой программы, которая печатает три числа для каждого типа; значения сняты на x86-64 Linux и совпадают с aarch64 macOS.

u1: size=1 align=1 bits=1
u3: size=1 align=1 bits=3
u8: size=1 align=1 bits=8
u9: size=2 align=2 bits=9
u16: size=2 align=2 bits=16
u24: size=4 align=4 bits=24
u32: size=4 align=4 bits=32
u48: size=8 align=8 bits=48
u64: size=8 align=8 bits=64
u128: size=16 align=16 bits=128
bool: size=1 align=1 bits=1
f32: size=4 align=4 bits=32
f64: size=8 align=8 bits=64
*u8: size=8 align=8 bits=64

Целое любой ширины в памяти округляется до следующей степени двойки байт: u9 занимает два байта, u24 четыре, u48 восемь. Это пригодится, когда дойдём до packed struct, потому что его размер это размер такого целого.

Теперь та же программа для структуры. @typeInfo возвращает описание типа, в котором есть список полей, а inline for разворачивает цикл на этапе компиляции, поэтому @offsetOf получает имя поля как строку-константу.

const std = @import("std");

const Auto = struct {
    a: u8,
    b: u32,
    c: u8,
};

const Extern = extern struct {
    a: u8,
    b: u32,
    c: u8,
};

fn report(out: *std.Io.Writer, comptime T: type) !void {
    try out.print("{s}: @sizeOf={d} @alignOf={d}\n", .{ @typeName(T), @sizeOf(T), @alignOf(T) });
    inline for (@typeInfo(T).@"struct".fields) |field| {
        try out.print("  {s:<2} {s:<4} @offsetOf={d}\n", .{ field.name, @typeName(field.type), @offsetOf(T, field.name) });
    }
}

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

    try report(out, Auto);
    try report(out, Extern);
    try out.flush();
}
$ zig build-exe layout.zig && ./layout
layout.Auto: @sizeOf=8 @alignOf=4
  a  u8   @offsetOf=4
  b  u32  @offsetOf=0
  c  u8   @offsetOf=5
layout.Extern: @sizeOf=12 @alignOf=4
  a  u8   @offsetOf=0
  b  u32  @offsetOf=4
  c  u8   @offsetOf=8

Одни и те же три поля, шесть байт полезной нагрузки, а размеры 8 и 12. Разберём оба.

extern struct: раскладка C

extern struct обещает раскладку ABI целевой платформы. Правил два, и по ним можно посчитать любую структуру руками.

  1. Поля идут строго в порядке объявления. Перед каждым полем курсор округляется вверх до выравнивания поля.
  2. После последнего поля размер округляется вверх до выравнивания всей структуры, а это максимум выравниваний полей.

Применим к Extern. Поле a: u8 ложится на байт 0. Поле b: u32 требует адрес, кратный 4, поэтому байты 1, 2, 3 остаются дырой, и b занимает 4..7. Поле c: u8 ложится на байт 8. Полезная нагрузка кончилась на байте 9, но структура выравнена по 4, значит размер округляется до 12. Три байта дыры внутри, три в хвосте, итого шесть байт пустоты на шесть байт данных.

Хвостовая дыра нужна не из вежливости. Массив [2]Extern кладёт второй элемент сразу за первым, и его поле b должно снова оказаться на адресе, кратном 4. Без хвостового округления второй элемент начался бы с байта 9, а его b с байта 13, что нарушает выравнивание.

Вот структура, которую мог бы объявить C-код, и тест, который проверяет все смещения, а заодно читает структуру из байтов.

const std = @import("std");

// Такой заголовок мог бы объявить C-код:
//   struct frame { uint8_t kind; uint32_t seq; uint16_t len; uint8_t crc; };
const Frame = extern struct {
    kind: u8,
    seq: u32,
    len: u16,
    crc: u8,
};

test "extern struct: смещения как в C, дыры на месте" {
    try std.testing.expectEqual(@as(usize, 0), @offsetOf(Frame, "kind"));
    try std.testing.expectEqual(@as(usize, 4), @offsetOf(Frame, "seq"));
    try std.testing.expectEqual(@as(usize, 8), @offsetOf(Frame, "len"));
    try std.testing.expectEqual(@as(usize, 10), @offsetOf(Frame, "crc"));
    try std.testing.expectEqual(@as(usize, 12), @sizeOf(Frame));
    try std.testing.expectEqual(@as(usize, 4), @alignOf(Frame));
}

test "сумма полей меньше размера: остальное padding" {
    const payload = @sizeOf(u8) + @sizeOf(u32) + @sizeOf(u16) + @sizeOf(u8);
    try std.testing.expectEqual(@as(usize, 8), payload);
    try std.testing.expectEqual(@as(usize, 4), @sizeOf(Frame) - payload);
}

test "структуру с фиксированной раскладкой можно прочитать из байтов" {
    const bytes = [12]u8{
        0x07, 0xaa, 0xaa, 0xaa, // kind = 7, три байта дыры
        0x01, 0x00, 0x00, 0x00, // seq = 1 (little-endian)
        0x10, 0x00, // len = 16
        0x5c, 0xaa, // crc = 0x5c, байт дыры
    };
    const frame = std.mem.bytesToValue(Frame, &bytes);
    try std.testing.expectEqual(@as(u8, 7), frame.kind);
    try std.testing.expectEqual(@as(u32, 1), frame.seq);
    try std.testing.expectEqual(@as(u16, 16), frame.len);
    try std.testing.expectEqual(@as(u8, 0x5c), frame.crc);
}

test "@offsetOf работает и для auto, но число выбрал компилятор" {
    const Auto = struct { kind: u8, seq: u32, len: u16, crc: u8 };
    try std.testing.expectEqual(@as(usize, 8), @sizeOf(Auto));
    try std.testing.expectEqual(@as(usize, 0), @offsetOf(Auto, "seq"));
    try std.testing.expectEqual(@as(usize, 6), @offsetOf(Auto, "kind"));
}

Таблица смещений для Frame целиком:

ПолеТипСмещениеБайтыДыра перед полем
kindu8010
sequ32443
lenu16820
crcu81010
хвост1

Третий тест показывает, ради чего нужна фиксированная раскладка: двенадцать байт из буфера превращаются в Frame одним вызовом bytesToValue, и каждое поле оказывается на своём месте. Байты дыр, здесь 0xaa, никто не читает. Но обрати внимание на две оговорки. Порядок байт внутри seq здесь little-endian, как у процессора, поэтому «прочитать структуру из сети» так нельзя без std.mem.readInt с явным порядком, об этом будет отдельный урок. И дыры это байты, которые ты отправляешь по сети зря: заголовок с восемью байтами смысла весит двенадцать.

Последний тест напоминает, что @offsetOf работает и для обычной структуры, только число там выбрал компилятор, и полагаться на него нельзя.

Обычный struct: компилятор вправе переставить

У struct без модификаторов раскладка не определена языком. Компилятор обязан лишь соблюдать выравнивание полей, а порядок и дыры на его усмотрение. Сейчас, в Zig 0.16, компилятор поступает так: сортирует поля по убыванию выравнивания, при равном выравнивании сохраняет порядок объявления и дальше укладывает их подряд по тем же двум правилам, что у extern. Это наблюдение за компилятором, а не обещание языка: следующая версия вправе выбрать другой порядок, и код не должен на него полагаться. Именно это ты видел в выводе layout.Auto: b: u32 ушло на нулевой байт, a и c легли за ним на 4 и 5, размер округлился до 8.

Ниже виджет делает это на любой структуре. Набери поля, переключи раскладку и посмотри на ленту байт. Для packed лента переключается на биты. Все четыре пресета сверены с реальным выводом @sizeOf, @alignOf, @bitSizeOf и @offsetOf из zig test на 0.16 для целей x86_64-linux и aarch64-macos, числа совпали во всех трёх режимах.

Самый важный вывод из виджета: auto никогда не больше extern на тех же полях, а часто меньше. Вот измеримый пример, запись датчика с четырьмя полями.

const std = @import("std");

const Sample = struct {
    ready: bool,
    timestamp: u64,
    channel: u16,
    kind: u8,
};

const SampleC = extern struct {
    ready: bool,
    timestamp: u64,
    channel: u16,
    kind: u8,
};

test "auto короче extern на той же записи" {
    try std.testing.expectEqual(@as(usize, 16), @sizeOf(Sample));
    try std.testing.expectEqual(@as(usize, 24), @sizeOf(SampleC));

    try std.testing.expectEqual(@as(usize, 0), @offsetOf(Sample, "timestamp"));
    try std.testing.expectEqual(@as(usize, 8), @offsetOf(Sample, "channel"));
    try std.testing.expectEqual(@as(usize, 10), @offsetOf(Sample, "ready"));
    try std.testing.expectEqual(@as(usize, 11), @offsetOf(Sample, "kind"));
}

test "на миллионе записей разница это восемь мегабайт" {
    const n = 1_000_000;
    try std.testing.expectEqual(@as(usize, 16_000_000), @sizeOf([n]Sample));
    try std.testing.expectEqual(@as(usize, 24_000_000), @sizeOf([n]SampleC));
}

const SampleByHand = extern struct {
    timestamp: u64,
    channel: u16,
    ready: bool,
    kind: u8,
};

test "переставить руками: extern догоняет auto" {
    try std.testing.expectEqual(@as(usize, 16), @sizeOf(SampleByHand));
    try std.testing.expectEqual(@sizeOf(Sample), @sizeOf(SampleByHand));
}

В SampleC поле ready: bool стоит первым, и за ним семь байт дыры до timestamp. Потом channel и kind занимают три байта, а хвост добивает до 24. Полезной нагрузки 12 байт, размер вдвое больше. Компилятор в Sample поставил самое выровненное поле первым и получил 16. Миллион записей в кольцевом буфере это восемь мегабайт разницы, и это не абстракция: при обходе массива кэш процессора читает строки по 64 байта, и чем плотнее записи, тем больше их помещается в строку. Что такое строка кэша и как это измерить, будет в блоке про иерархию памяти.

Почему же тогда не всегда auto? Потому что перестановка полей это свобода компилятора, а не гарантия. Другая версия Zig, другая цель, включённая оптимизация могут дать другой порядок, и код, который считает байты через @offsetOf для обычной структуры, обязан пересчитывать их каждый раз. Правило выбора:

  • Структура живёт только внутри твоей программы: struct. Компилятор упакует не хуже тебя.
  • Структура пересекает границу: C-функция, системный вызов, файл на диске, память устройства, другой процесс: extern struct, и порядок полей ты задаёшь сам, как в SampleByHand.
  • Нужны поля уже байта или структура должна занимать ровно столько бит, сколько в протоколе: packed struct.

packed struct: биты вплотную

packed struct не имеет дыр вообще. Поля ложатся в целое-контейнер бит за битом, первое поле в младшие биты. Пятый вопрос к компилятору для таких структур это @bitOffsetOf: то же, что @offsetOf, но в битах. Ширина контейнера пишется в скобках, и она обязана точно совпасть с суммой ширин полей, иначе ошибка компиляции.

const std = @import("std");

const Header = packed struct(u16) {
    version: u3,
    flags: u4,
    length: u9,
};

test "packed struct: биты вплотную, младшее поле в младших битах" {
    try std.testing.expectEqual(@as(usize, 2), @sizeOf(Header));
    try std.testing.expectEqual(@as(usize, 16), @bitSizeOf(Header));
    try std.testing.expectEqual(@as(usize, 0), @bitOffsetOf(Header, "version"));
    try std.testing.expectEqual(@as(usize, 3), @bitOffsetOf(Header, "flags"));
    try std.testing.expectEqual(@as(usize, 7), @bitOffsetOf(Header, "length"));
}

test "@bitCast в целое и обратно" {
    const h: Header = .{ .version = 0b101, .flags = 0b0011, .length = 0b1_0000_0001 };
    const raw: u16 = @bitCast(h);
    // length << 7 | flags << 3 | version
    try std.testing.expectEqual(@as(u16, 0b1_0000_0001_0011_101), raw);
    try std.testing.expectEqual(@as(u16, 0x809d), raw);

    const back: Header = @bitCast(raw);
    try std.testing.expectEqual(@as(u9, 257), back.length);
    try std.testing.expectEqual(h, back);
}

test "поле packed struct это обычное значение" {
    var h: Header = @bitCast(@as(u16, 0));
    h.length = 511;
    h.version = 7;
    try std.testing.expectEqual(@as(u16, 0xff87), @as(u16, @bitCast(h)));

    // а вот указатель на поле уже не байтовый: его тип помнит смещение в битах
    const p = &h.flags;
    try std.testing.expectEqual(*align(2:3:2) u4, @TypeOf(p));
}

test "байты в памяти: little-endian, как у целого u16" {
    const h: Header = .{ .version = 1, .flags = 0, .length = 2 };
    const bytes = std.mem.asBytes(&h);
    try std.testing.expectEqual(@as(u16, 0x0101), @as(u16, @bitCast(h)));
    try std.testing.expectEqual([2]u8{ 0x01, 0x01 }, bytes.*);
}

Число 0x809d в двоичной записи это 1000 0000 1001 1101. Читай справа налево: три младших бита 101 это version = 5, следующие четыре 0011 это flags = 3, оставшиеся девять 1 0000 0001 это length = 257. @bitCast в обе стороны ничего не вычисляет, это переименование тех же шестнадцати бит, поэтому decode(encode(h)) всегда возвращает h.

Поле packed struct читается и пишется как обычное число: h.length = 511 компилируется в сдвиг и маску. А вот указатель на такое поле особенный. Тип *align(2:3:2) u4 говорит: указатель на четыре бита, которые начинаются с третьего бита внутри двухбайтного контейнера, выровненного по 2. Обычный *u4 из него не сделать, потому что адрес с точностью до бита не существует. Это редко мешает, но объясняет, почему функцию fn bump(x: *u4) нельзя вызвать с &h.flags.

Последний тест показывает байты в памяти. Контейнер это u16, а u16 на наших платформах хранится младшим байтом вперёд, поэтому 0x0101 лежит как два байта 0x01, 0x01. Для заголовка сетевого протокола, где биты описаны от старшего к младшему, а байты идут в сетевом порядке, packed struct придётся комбинировать с @byteSwap, и это одна из задач домашней работы.

Посмотрим, во что компилятор превращает доступ к битовым полям. Три функции экспортированы, чтобы их не заинлайнил ReleaseFast, ассемблер снят для x86-64 Linux.

const Header = packed struct(u16) {
    version: u3,
    flags: u4,
    length: u9,
};

export fn headerLength(raw: u16) u16 {
    const h: Header = @bitCast(raw);
    return h.length;
}

export fn headerFlags(raw: u16) u8 {
    const h: Header = @bitCast(raw);
    return h.flags;
}

export fn withVersion(raw: u16, v: u8) u16 {
    var h: Header = @bitCast(raw);
    h.version = @truncate(v);
    return @bitCast(h);
}
$ zig build-obj -O ReleaseFast -fomit-frame-pointer -target x86_64-linux \
    -femit-asm=header.s -fno-emit-bin header.zig

header.headerLength:
	mov	eax, edi
	shr	eax, 7
	ret

header.headerFlags:
	mov	eax, edi
	shr	al, 3
	and	al, 15
	ret

header.withVersion:
	and	edi, -8
	and	esi, 7
	lea	eax, [rsi + rdi]
	ret

Ни одной лишней инструкции. length это старшие девять бит, поэтому достаточно сдвига на 7. flags это сдвиг на 3 и маска 15, то есть 0b1111. withVersion очищает три младших бита старого значения через and edi, -8 (это маска ...11111000), обрезает новое значение до трёх бит через and esi, 7 и складывает через lea, потому что сложение с непересекающимися битами это то же, что or. Битовое поле в Zig стоит ровно столько, сколько стоило бы, напиши ты сдвиги руками, только имена полей остаются в коде.

Три ограничения packed struct, о которых нужно помнить. Указатель внутри него запрещён: компилятор скажет pointers cannot be directly bitpacked и предложит usize с @intFromPtr, виджет выше показывает это же сообщение. Размер равен размеру контейнера: структура на 25 бит имеет контейнер u25, а @sizeOf(u25) это 4, так что три байта полезной информации занимают четыре. И выравнивание тоже берётся у контейнера, packed struct(u48) выровнен по 8, хотя занимает шесть байт смысла.

Практика

Задача повторяет Header из урока, но собирать его тебе. В заготовке поля объявлены в обратном порядке, и тесты на битовые смещения это ловят: исправь порядок, затем напиши encode и decode через @bitCast, а withFlag через |= и @intFromEnum. Тест на круговой обход проверяет все 65536 значений u16, так что любая ошибка в ширине поля всплывёт.

Упражнения

Итоги

  • Метод это функция в пространстве имён типа с явным self; тип self показывает, копия это или изменяемый указатель. .{} это литерал с выводом типа из контекста.
  • enum(u8) с явными значениями это способ описать байт протокола; switch без else ловит новые варианты на этапе компиляции; _ открывает перечисление для чужих значений, а закрытое @enumFromInt проверяется только в безопасных режимах.
  • union(enum) хранит тег официально и одинаково во всех режимах. Голый union получает скрытый тег только в Debug и ReleaseSafe, в ReleaseFast чтение не того поля молча даёт другие байты. extern union это честная переинтерпретация.
  • Размер структуры это поля плюс дыры. extern struct считает по двум правилам ABI: округлить курсор до выравнивания поля, округлить итог до выравнивания структуры. Обычный struct компилятор вправе переставить; в 0.16 он сортирует поля по убыванию выравнивания, но это деталь реализации, а не гарантия.
  • packed struct кладёт биты вплотную в целое-контейнер, первое поле в младшие биты, @bitCast переводит в целое и обратно бесплатно, а доступ к полю компилируется в сдвиг и маску.

Дальше

Структуры научились лежать в памяти правильно, но пока живут на стеке. В следующем уроке они переедут в кучу: наборы ошибок, try и defer, интерфейс аллокатора и первый взгляд на то, что аллокатор это структура с таблицей функций, то есть ровно то, из чего сделаны интерфейсы в языке без классов.

домашка

Домашка