Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Структуры, перечисления, объединения и раскладка в памяти
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Структуры, перечисления, объединения и раскладка в памяти
Три байта полезной нагрузки могут занять двенадцать. 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 целевой платформы. Правил два, и по ним можно посчитать любую структуру руками.
- Поля идут строго в порядке объявления. Перед каждым полем курсор округляется вверх до выравнивания поля.
- После последнего поля размер округляется вверх до выравнивания всей структуры, а это максимум выравниваний полей.
Применим к 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 целиком:
| Поле | Тип | Смещение | Байты | Дыра перед полем |
|---|---|---|---|---|
kind | u8 | 0 | 1 | 0 |
seq | u32 | 4 | 4 | 3 |
len | u16 | 8 | 2 | 0 |
crc | u8 | 10 | 1 | 0 |
| хвост | 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, интерфейс аллокатора и первый взгляд на то, что аллокатор это структура с таблицей функций, то есть ровно то, из чего сделаны интерфейсы в языке без классов.
домашка