Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Указатели, срезы и массивы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Указатели, срезы и массивы
В C указатель один, и он не знает ничего: ни сколько за ним элементов, ни где конец строки, ни выровнен ли адрес. В Zig пять видов указателей, и каждый честно записывает в тип ровно то, что знает. Отсюда и проверки рантайма, и их цена, и то, что в ReleaseFast от них не остаётся ни одной инструкции.
Цели урока
- Различать
*T,*[N]T,[]T,[*]Tи[*:0]T: что каждый знает о длине, завершителе и выравнивании. - Понять, почему массив в Zig это значение, а срез это пара из адреса и длины, и увидеть эти 8 и 16 байт в
@sizeOf. - Увидеть проверку границ в действии: паника в Debug и ReleaseSafe, чтение чужой памяти в ReleaseFast, и отсутствие проверки у
[*]Tв любом режиме. - Освоить работу с байтами:
@ptrCastи@alignCast,align(1),std.mem.asBytes,bytesAsValue,readIntс явным порядком байтов. - Прочитать первый ассемблер: где в
sumSliceживёт проверка границ и почему её нет вsumPtr.
Идея: пять взглядов на одну память
Память это байты по адресам, ничего больше. Всё остальное, длина, тип элемента, завершающий ноль, выравнивание, живёт не в памяти, а в голове у того, кто по ней ходит. В C этой головой был программист: char *s мог оказаться строкой с нулём на конце, массивом из четырёх байт или указателем на одну структуру, и компилятор верил на слово. Zig переносит знание из головы в тип. Один и тот же адрес можно держать пятью способами, и каждый способ фиксирует, что именно про эту память известно:
| Тип | Что знает | Индексация | Размер указателя |
|---|---|---|---|
*T | ровно один элемент | нет, только p.* | 8 |
*[N]T | N элементов, N в типе | p[i], проверка по N | 8 |
[]T | длина в рантайме | s[i], проверка по s.len | 16 |
[*]T | ничего, кроме адреса | p[i], без проверки | 8 |
[*:0]T | где-то дальше лежит ноль | p[i], без проверки | 8 |
Разница в размерах в последней колонке не декоративная. Всё, что тип знает на этапе компиляции, в указателе не хранится: *[8]u16 занимает те же 8 байт, что и *u16, потому что восьмёрка записана в типе. А у среза длина известна только в рантайме, поэтому её приходится носить рядом с адресом, и указатель становится толстым. Проверим @sizeOf на реальном компиляторе, а не на слово:
const std = @import("std");
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 out.print("*u16 {d}\n", .{@sizeOf(*u16)});
try out.print("*[8]u16 {d}\n", .{@sizeOf(*[8]u16)});
try out.print("[*]u16 {d}\n", .{@sizeOf([*]u16)});
try out.print("[*:0]u8 {d}\n", .{@sizeOf([*:0]u8)});
try out.print("[]u16 {d}\n", .{@sizeOf([]u16)});
try out.print("[:0]u8 {d}\n", .{@sizeOf([:0]u8)});
try out.print("?*u16 {d}\n", .{@sizeOf(?*u16)});
try out.print("[8]u16 {d}\n", .{@sizeOf([8]u16)});
try out.flush();
}
*u16 8
*[8]u16 8
[*]u16 8
[*:0]u8 8
[]u16 16
[:0]u8 16
?*u16 8
[8]u16 16
Две строки заслуживают отдельного взгляда. ?*u16 весит столько же, сколько *u16: обычный указатель в Zig не бывает нулевым, поэтому у опционала есть свободное значение, адрес 0, и отдельный байт под «нет значения» не нужен. Это ровно тот трюк, на котором держатся Option<&T> в Rust и NonNull. А [8]u16 это 16 байт, то есть сам массив, не указатель на него. Массив в Zig это значение, как u64 или структура. С этого и начнём.
Массив это значение, *[N]T это адрес массива
В C имя массива при любом удобном случае «распадается» в указатель на первый элемент, и это источник половины путаницы вокруг sizeof. В Zig ничего не распадается. [4]u16 это 8 байт, которые лежат на стеке или в глобальной памяти, присваивание копирует все восемь, а передача в функцию по значению тоже копирует. Чтобы говорить об адресе массива, нужен явный &, и его тип не *u16, а *[4]u16: указатель на весь массив, с длиной в типе.
const std = @import("std");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
test "массив это значение, присваивание копирует" {
const a: [4]u16 = .{ 1, 2, 3, 4 };
var b = a; // копия всех 8 байт
b[0] = 100;
try expectEqual(@as(u16, 1), a[0]);
try expectEqual(@as(u16, 100), b[0]);
}
test "&массив это *[N]T, длина в типе" {
var a: [4]u16 = .{ 1, 2, 3, 4 };
const p: *[4]u16 = &a;
p[0] = 100; // через указатель меняем оригинал
try expectEqual(@as(u16, 100), a[0]);
try expectEqual(@as(usize, 4), p.len); // len известен компилятору
try expect(@TypeOf(p) == *[4]u16);
}
test "приведение *[N]T в []T бесплатно" {
var a: [4]u16 = .{ 1, 2, 3, 4 };
const s: []u16 = &a; // указатель на массив превращается в срез
try expectEqual(@as(usize, 4), s.len);
try expect(s.ptr == @as([*]u16, &a));
s[3] = 40;
try expectEqual(@as(u16, 40), a[3]);
}
test "срезом можно взять часть" {
var a: [8]u16 = .{ 0, 1, 2, 3, 4, 5, 6, 7 };
const mid = a[2..5]; // *[3]u16: границы известны на этапе компиляции
try expect(@TypeOf(mid) == *[3]u16);
var from: usize = 2;
from += 0; // делаем from значением времени выполнения
const dyn = a[from..5]; // []u16: длина известна только в рантайме
try expect(@TypeOf(dyn) == []u16);
try expectEqual(@as(usize, 3), dyn.len);
try expectEqual(@as(u16, 2), dyn[0]);
}
test "функция берёт срез, а не массив" {
var a: [4]u16 = .{ 10, 20, 30, 40 };
var b: [2]u16 = .{ 5, 5 };
try expectEqual(@as(u32, 100), sum(&a));
try expectEqual(@as(u32, 10), sum(&b));
try expectEqual(@as(u32, 50), sum(a[1..3]));
}
fn sum(xs: []const u16) u32 {
var total: u32 = 0;
for (xs) |x| total += x;
return total;
}
1/5 arrays.test.массив это значение, присваивание копирует...OK
2/5 arrays.test.&массив это *[N]T, длина в типе...OK
3/5 arrays.test.приведение *[N]T в []T бесплатно...OK
4/5 arrays.test.срезом можно взять часть...OK
5/5 arrays.test.функция берёт срез, а не массив...OK
All 5 tests passed.
Три вещи из этого листинга будут встречаться в каждом следующем уроке раздела.
Первая: *[N]T неявно приводится к []T без единой инструкции по смыслу и с одной по факту. Компилятор знает N, поэтому кладёт его во второе слово среза. Поэтому функция sum принимает []const u16, а вызывающий пишет &a: одна сигнатура для массивов любой длины, и никакой копии массива при вызове. Правило для всего раздела: функция, которая читает данные, принимает срез, а массив остаётся у владельца.
Вторая: тип результата a[x..y] зависит от того, известны ли границы на этапе компиляции. a[2..5] это *[3]u16, компилятор уже посчитал длину и проверил, что 5 не больше 8. a[from..5] с переменной from это []u16, длина считается в рантайме, и проверка границ тоже переезжает в рантайм. Одна и та же синтаксическая форма, два разных типа, и @TypeOf в тесте показывает, какой именно.
Третья: p.len у *[4]u16 это константа времени компиляции. Из неё, а не из памяти, компилятор берёт число, когда проверяет p[i]. С литеральным индексом ошибка будет ещё до запуска: p[9] для *[8]u16 даёт error: index 9 outside array of length 8.
*T и [*]T: один элемент против адреса без длины
Указатель на один элемент это самый строгий из пяти. У него нет индексации, нет арифметики, есть только разыменование через .*. Компилятор не разрешит p + 1 для *u16 по одной причине: тип не обещает, что по соседнему адресу лежит ещё один u16. Если соседи нужны, надо сказать об этом типом, и это [*]T, многоэлементный указатель. Он ближе всего к указателю C: голый адрес, индексация в любую сторону, арифметика в единицах элемента, и никакого знания, где память кончается.
const std = @import("std");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
test "*T: один элемент, разыменование через .*" {
var x: u16 = 7;
const p: *u16 = &x;
p.* += 1; // пишем по адресу
try expectEqual(@as(u16, 8), x);
try expectEqual(@as(u16, 8), p.*);
// p[0] или p + 1 для *T не компилируются: у одного элемента нет соседей
}
test "?*T: указатель или ничего, без лишнего байта" {
var x: u16 = 1;
var maybe: ?*u16 = null;
try expectEqual(@sizeOf(*u16), @sizeOf(?*u16)); // null это адрес 0
maybe = &x;
if (maybe) |p| p.* = 2;
try expectEqual(@as(u16, 2), x);
}
test "[*]T: арифметика адресов в единицах элемента" {
var a: [4]u16 = .{ 10, 20, 30, 40 };
var p: [*]u16 = &a;
try expectEqual(@as(u16, 10), p[0]);
p += 1; // сдвиг на один элемент, то есть на 2 байта
try expectEqual(@as(u16, 20), p[0]);
try expectEqual(@intFromPtr(&a) + 2, @intFromPtr(p));
const q = p + 2; // адрес a[3]
try expectEqual(@as(u16, 40), q[0]);
}
test "срез = [*]T + len, и обратно" {
var a: [4]u16 = .{ 10, 20, 30, 40 };
const s: []u16 = &a;
const p: [*]u16 = s.ptr; // отрываем длину
var len: usize = s.len;
len -= 1;
const back: []u16 = p[0..len]; // пришиваем свою: 3 элемента
try expectEqual(@as(usize, 3), back.len);
try expectEqual(@as(u16, 30), back[2]);
try expect(@TypeOf(p[0..2]) == *[2]u16); // константные границы дают *[N]T
}
1/4 pointers.test.*T: один элемент, разыменование через .*...OK
2/4 pointers.test.?*T: указатель или ничего, без лишнего байта...OK
3/4 pointers.test.[*]T: арифметика адресов в единицах элемента...OK
4/4 pointers.test.срез = [*]T + len, и обратно...OK
All 4 tests passed.
Последний тест и есть определение среза: []T это [*]T плюс usize. Поле s.ptr отдаёт голый адрес, а p[0..len] собирает срез обратно, с той длиной, которую ты назвал. Заметь, что компилятор при этом ни о чём не спрашивает: [*]T не знает, сколько за ним памяти, поэтому проверить len ему не по чему. Ответственность за правильную длину в этот момент целиком на тебе, и это ровно то место, откуда в системном коде берутся переполнения буфера. Каждый раз, когда пишешь ptr[0..n], спроси себя, откуда взялось n.
[]u16, 16 байт [*]u16, 8 байт
+------------+------------+ +------------+
| ptr | len = 4 | | ptr | длины нет
+-----+------+------------+ +-----+------+
| |
v v
10 20 30 40 ... 10 20 30 40 ...
@intFromPtr в третьем тесте превращает указатель в число, и на нём видно, что p += 1 сдвинул адрес на два байта, а не на один: арифметика многоэлементного указателя измеряется в элементах, как и в C. Обратная операция @ptrFromInt тоже существует, и в блоке про виртуальную память она понадобится, чтобы разложить адрес на номер страницы и смещение.
Демонстрация: пять указателей на одной памяти
Прежде чем смотреть на проверки границ в коде, покрути их руками. Внизу один массив [8]u16 из 16 байт, и пять способов на него указывать. Выбери вид указателя, введи индекс и переключи режим сборки. Обрати внимание на три вещи: что тип знает о длине, какие байты читаются, и в каком режиме индекс за концом массива это паника, а в каком чтение чужой памяти.
Числа в виджете это те же байты, которые печатает std.mem.asBytes(&arr) для этого массива: { 11, 0, 22, 0, 33, 0, 44, 0, 55, 0, 66, 0, 77, 0, 0, 0 }, младший байт каждого u16 идёт первым, потому что x86-64 и arm64 хранят числа от младшего байта к старшему.
Что проверяет ReleaseSafe и чего не проверяет ReleaseFast
Теперь то же самое в коде. Программа берёт один и тот же адрес двумя способами, срезом и голым указателем, и читает индекс 12 при длине 8. Индекс лежит в глобальной var, чтобы компилятор не свернул его в константу и не поймал ошибку на этапе компиляции: нам нужен именно рантайм.
const std = @import("std");
var big: [32]u16 = undefined;
var index: usize = 12; // глобальная var: значение известно только в рантайме
pub fn main() void {
for (&big, 0..) |*cell, i| cell.* = @intCast(i * 100);
const xs: []u16 = big[0..8]; // срез знает: длина 8
const p: [*]u16 = xs.ptr; // тот же адрес, длины нет
std.debug.print("p[{d}] = {d}\n", .{ index, p[index] });
std.debug.print("xs[{d}] = {d}\n", .{ index, xs[index] });
}
Массив big намеренно больше среза: срез покрывает первые 8 элементов из 32, и по индексу 12 в памяти лежит число 1200. Это важно для чистоты эксперимента: чтение за границей среза здесь не упирается в чужую страницу, оно просто читает соседнюю часть того же массива. Три запуска, три режима:
$ zig run bounds.zig
p[12] = 1200
thread 45243465 panic: index out of bounds: index 12, len 8
bounds.zig:13:52: 0x1005cdd9f in main (bounds)
std.debug.print("xs[{d}] = {d}\n", .{ index, xs[index] });
^
$ zig run -O ReleaseSafe bounds.zig
p[12] = 1200
thread 45243882 panic: index out of bounds: index 12, len 8
$ zig run -O ReleaseFast bounds.zig
p[12] = 1200
xs[12] = 1200
Читать это надо по строкам. p[12] через [*]u16 печатает 1200 во всех трёх режимах: у типа нет длины, проверять нечего, и Debug здесь ничем не безопаснее ReleaseFast. xs[12] через срез в Debug и ReleaseSafe останавливает программу с внятным сообщением, в котором есть и индекс, и длина. А в ReleaseFast та же строка печатает 1200, как будто ничего не случилось. Формально это неопределённое поведение: в нашем эксперименте память за срезом принадлежала тому же массиву, поэтому вышло аккуратное число, но в настоящей программе там может лежать соседняя переменная, кусок стека с адресом возврата или страница без прав на чтение.
Отсюда практическое правило раздела. Разрабатывай и тестируй в Debug, нагрузочные тесты гоняй в ReleaseSafe, чтобы поймать редкие ошибки на реальных данных, и только когда проверки перестают срабатывать, переключай горячий код в ReleaseFast. Список того, что безопасные режимы проверяют, короткий и стоит его помнить: выход за границы среза и массива, переполнение целых в обычной арифметике, разыменование null через .?, неверный тег объединения, обращение к undefined в некоторых случаях, несовпадение завершителя при [0..n :0], невыровненный адрес при @alignCast. Последние два увидим прямо сейчас.
Завершитель: [*:0]T, строки C и std.mem.span
Строка в C это адрес первого байта плюс договор: где-то дальше лежит ноль, и он не часть строки. Договор не записан нигде, кроме документации, поэтому strlen на массиве без нуля уходит гулять по памяти. Zig записывает договор в тип: [*:0]const u8 это многоэлементный указатель, который обещает завершитель. Длины у него по-прежнему нет, но её можно восстановить обходом, и стандартная библиотека делает это функцией std.mem.span.
const std = @import("std");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
const expectEqualStrings = std.testing.expectEqualStrings;
test "строковый литерал это *const [N:0]u8" {
const hello = "hello";
try expect(@TypeOf(hello) == *const [5:0]u8);
try expectEqual(@as(usize, 5), hello.len);
try expectEqual(@as(u8, 0), hello[5]); // завершитель доступен по индексу len
}
test "[*:0]const u8 это C-строка: длины нет, есть договор о нуле" {
const c_str: [*:0]const u8 = "hello";
// std.mem.span идёт по памяти до первого нуля и возвращает срез
const s: [:0]const u8 = std.mem.span(c_str);
try expectEqual(@as(usize, 5), s.len);
try expectEqualStrings("hello", s);
try expectEqual(@as(usize, 5), std.mem.len(c_str));
}
test "ptr[0..len :0] проверяет завершитель в безопасных режимах" {
var buf: [6]u8 = .{ 'h', 'i', 0, 'x', 'y', 0 };
const p: [*]u8 = &buf;
const with_sentinel: [:0]u8 = p[0..2 :0]; // buf[2] == 0, проверка проходит
try expectEqualStrings("hi", with_sentinel);
const plain: []u8 = p[0..4]; // без :0 завершитель никого не волнует
try expectEqual(@as(usize, 4), plain.len);
}
test "срез с завершителем приводится к срезу без него, но не наоборот" {
const s: [:0]const u8 = "abc";
const plain: []const u8 = s; // забыть про ноль всегда можно
try expectEqual(@as(usize, 3), plain.len);
const c: [*:0]const u8 = s; // и в C-указатель тоже
try expectEqual(@as(u8, 'a'), c[0]);
}
1/4 sentinel.test.строковый литерал это *const [N:0]u8...OK
2/4 sentinel.test.[*:0]const u8 это C-строка: длины нет, есть договор о нуле...OK
3/4 sentinel.test.ptr[0..len :0] проверяет завершитель в безопасных режимах...OK
4/4 sentinel.test.срез с завершителем приводится к срезу без него, но не наоборот...OK
All 4 tests passed.
Строковый литерал "hello" имеет тип *const [5:0]u8: указатель на массив из пяти байт, за которым гарантированно лежит ноль. Отсюда он приводится к чему угодно: в []const u8 для функций Zig, в [*:0]const u8 для функций C, в [:0]const u8, когда нужны и длина, и договор о нуле. Обратно, из []const u8 в [*:0]const u8, пути нет, потому что обычный срез ничего про ноль не обещает. Это и есть причина, по которой передача строки Zig в C-функцию требует либо литерала, либо явного копирования с добавлением нуля, например через allocator.dupeZ.
У формы p[0..len :0] две работы. Она делает срез из голого указателя и одновременно утверждает, что по индексу len лежит ноль. В Debug и ReleaseSafe это утверждение проверяется:
const std = @import("std");
var buf: [4]u8 = .{ 'a', 'b', 'c', 'd' }; // нуля нет
var len: usize = 2;
pub fn main() void {
const p: [*]u8 = &buf;
const s = p[0..len :0]; // требуем buf[2] == 0, а там 'c'
std.debug.print("{s}\n", .{s});
}
$ zig run sentinel_panic.zig
thread 45248028 panic: sentinel mismatch: expected 0, found 99
sentinel_panic.zig:8:16: 0x1043c9cc7 in main (sentinel_panic)
const s = p[0..len :0]; // требуем buf[2] == 0, а там 'c'
^
$ zig run -O ReleaseFast sentinel_panic.zig
ab
Число 99 в сообщении это код символа c. В ReleaseFast проверки нет, срез создаётся, print выводит два байта, и никто не узнает, что дальше лежит не ноль, пока эту строку не отдадут C-функции, которая пойдёт искать завершитель за пределами buf.
Выравнивание живёт в типе указателя
Процессор читает u32 за одну инструкцию, если адрес делится на 4. Это и называется выравнивание. Zig записывает его в тип указателя: *u32 на самом деле означает *align(4) u32, и когда выравнивание естественное, компилятор его в типе не показывает. Показывать приходится, когда оно другое: *align(1) u32 это указатель на четыре байта по любому адресу, и компилятор для него сгенерирует чтение, которое не требует выровненного адреса.
Отсюда правило приведения. @ptrCast меняет тип элемента, но не трогает выравнивание. Из *[4]u8, у которого выравнивание 1, нельзя сделать *u32 одним @ptrCast: компилятор откажет с сообщением @ptrCast increases pointer alignment, потому что из 1 в 4 переход невозможен. Нужен @alignCast, который в безопасных режимах проверяет адрес в рантайме.
const std = @import("std");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
test "выравнивание живёт в типе указателя" {
try expectEqual(@as(usize, 4), @alignOf(u32));
// у *u32 выравнивание не записано (null), подразумевается естественное
try expect(@typeInfo(*u32).pointer.alignment == null);
try expect(@typeInfo(*align(1) u32).pointer.alignment == 1);
try expect(*u32 != *align(1) u32); // это разные типы
const p: *align(4) u32 = undefined;
const q: *u32 = p; // а вот в *u32 указатель с align(4) переходит без каста
_ = q;
}
test "@ptrCast между *[4]u8 и *u32 требует @alignCast" {
var bytes: [4]u8 align(4) = .{ 0x78, 0x56, 0x34, 0x12 };
const raw: *[4]u8 = &bytes; // выравнивание 1 в типе
const p: *u32 = @ptrCast(@alignCast(raw));
try expectEqual(@as(u32, 0x12345678), p.*); // little-endian на x86-64 и arm64
}
test "align(1) читает по любому адресу" {
var bytes: [8]u8 = .{ 0, 0x78, 0x56, 0x34, 0x12, 0, 0, 0 };
const p: *align(1) u32 = @ptrCast(bytes[1..5]); // адрес нечётный
try expectEqual(@as(u32, 0x12345678), p.*);
}
test "std.mem.asBytes и bytesAsValue, без @ptrCast руками" {
var value: u32 = 0x12345678;
const bytes = std.mem.asBytes(&value);
// выравнивание исходного *u32 сохранилось в типе байтов
try expect(@TypeOf(bytes) == *align(4) [4]u8);
try expectEqual(@as(u8, 0x78), bytes[0]); // младший байт первый
bytes[3] = 0xAA; // меняем старший байт прямо в памяти value
try expectEqual(@as(u32, 0xAA345678), value);
// обратно: из байтов в значение, @ptrCast внутри, выравнивание тоже из типа
const back = std.mem.bytesAsValue(u32, bytes);
try expect(@TypeOf(back) == *align(4) u32); // читается как обычный *u32
try expectEqual(value, back.*);
// а из *[4]u8 без align получится *align(1) u32, и это честно
const loose: *[4]u8 = bytes;
try expect(@TypeOf(std.mem.bytesAsValue(u32, loose)) == *align(1) u32);
}
test "std.mem.readInt: порядок байтов задаём явно" {
const bytes: [4]u8 = .{ 0x12, 0x34, 0x56, 0x78 };
try expectEqual(@as(u32, 0x78563412), std.mem.readInt(u32, &bytes, .little));
try expectEqual(@as(u32, 0x12345678), std.mem.readInt(u32, &bytes, .big));
var out: [2]u8 = undefined;
std.mem.writeInt(u16, &out, 0xBEEF, .big);
try expectEqual([2]u8{ 0xBE, 0xEF }, out);
}
1/5 align.test.выравнивание живёт в типе указателя...OK
2/5 align.test.@ptrCast между *[4]u8 и *u32 требует @alignCast...OK
3/5 align.test.align(1) читает по любому адресу...OK
4/5 align.test.std.mem.asBytes и bytesAsValue, без @ptrCast руками...OK
5/5 align.test.std.mem.readInt: порядок байтов задаём явно...OK
All 5 tests passed.
Разберём, что здесь происходит с байтами, потому что это замена приёма show_bytes из CS:APP, где значение печатают через приведение к unsigned char *.
std.mem.asBytes(&value) возвращает указатель на массив байт того же размера, что и значение, и ничего не копирует: запись bytes[3] = 0xAA меняет value на месте. В типе результата, *align(4) [4]u8, видно, что функция бережно перенесла выравнивание исходного указателя. Это не педантизм: благодаря ему обратный std.mem.bytesAsValue(u32, bytes) даёт указатель с выравниванием 4, который читается одной обычной инструкцией. Стоит только сузить тип до *[4]u8, как в переменной loose, и обратное приведение вернёт *align(1) u32: компилятор больше не может доказать, что адрес делится на 4, и переходит на чтение, безопасное для любого адреса.
Байты 0x78, 0x56, 0x34, 0x12 собираются в 0x12345678, потому что и x86-64, и arm64 хранят числа младшим байтом вперёд, как в 23-rust/18 · Биты, байты и endianness. Когда порядок байтов задаёт не процессор, а формат файла или сетевой протокол, @ptrCast не подходит совсем: он читает так, как удобно машине. Для этого есть std.mem.readInt и writeInt с явным аргументом .little или .big, и последний тест показывает, что одни и те же четыре байта дают два разных числа в зависимости от него. В блоке про сети readInt(u16, ..., .big) станет способом прочитать порт из заголовка TCP.
Осталось увидеть, что делает @alignCast, когда адрес и правда не делится на 4:
const std = @import("std");
var bytes: [8]u8 = .{ 0, 0x78, 0x56, 0x34, 0x12, 0, 0, 0 };
var offset: usize = 1;
pub fn main() void {
const raw: *[4]u8 = bytes[offset..][0..4]; // адрес bytes + 1, нечётный
const p: *u32 = @ptrCast(@alignCast(raw));
std.debug.print("0x{x}\n", .{p.*});
}
$ zig run align_panic.zig
thread 45248661 panic: incorrect alignment
align_panic.zig:8:21: 0x100fadce3 in main (align_panic)
const p: *u32 = @ptrCast(@alignCast(raw));
^
$ zig run -O ReleaseFast align_panic.zig
0x12345678
В ReleaseFast программа напечатала правильное число, и это худший из возможных исходов: на arm64 и x86-64 невыровненное чтение обычного регистра работает, поэтому ошибка не проявится до того дня, когда компилятор заменит его на SIMD-инструкцию с требованием выравнивания или код запустят на процессоре, где такое чтение это аппаратное исключение. Если данные приходят из байтового буфера и выравнивание не гарантировано, честный вариант один: *align(1) в типе или std.mem.readInt, а не @alignCast с надеждой.
Первый ассемблер: где живёт проверка границ
Пора посмотреть, во что проверка границ превращается на уровне машины. Возьмём две функции, которые складывают первые n элементов: одна получает срез, вторая многоэлементный указатель. Третья складывает срез целиком через for. Функции помечены noinline, чтобы компилятор не растворил их в вызывающем коде, а export-обёртка нужна, потому что срез не проходит через C ABI (соглашение о том, как функции получают аргументы, его придерживаются C-компиляторы) и без обёртки функции не попали бы в объектный файл.
/// Сумма первых n элементов среза. Срез знает свою длину,
/// поэтому каждое xs[i] в безопасном режиме сверяется с xs.len.
noinline fn sumSlice(xs: []const u32, n: usize) u32 {
var total: u32 = 0;
var i: usize = 0;
while (i < n) : (i += 1) {
total +%= xs[i];
}
return total;
}
/// То же самое через многоэлементный указатель: длины нет, верим n.
noinline fn sumPtr(p: [*]const u32, n: usize) u32 {
var total: u32 = 0;
var i: usize = 0;
while (i < n) : (i += 1) {
total +%= p[i];
}
return total;
}
/// Сумма всего среза: граница цикла и граница среза совпадают.
noinline fn sumAll(xs: []const u32) u32 {
var total: u32 = 0;
for (xs) |x| total +%= x;
return total;
}
// export требует C ABI, а срез в C ABI не передашь: заворачиваем.
export fn entry(p: [*]const u32, len: usize, n: usize) u32 {
return sumSlice(p[0..len], n) +% sumPtr(p, n) +% sumAll(p[0..len]);
}
Собираем объектный файл под Linux x86-64 прямо с macOS, компилятор Zig кросс-компилирует без дополнительных инструментов. Флаг -femit-asm пишет ассемблер в файл, -fno-emit-bin отключает сам объектный файл, он нам не нужен:
zig build-obj sums.zig -O ReleaseSafe -target x86_64-linux -femit-asm=safe.s -fno-emit-bin
zig build-obj sums.zig -O ReleaseFast -target x86_64-linux -femit-asm=fast.s -fno-emit-bin
Файл для ReleaseSafe получается на 636 тысяч строк, потому что в него попадает обработчик паники со всем стеком вызовов стандартной библиотеки. Нас интересует одна функция. Синтаксис Intel, регистры по соглашению System V ABI: rdi это xs.ptr, rsi это xs.len, rdx это n. Вот sumSlice в ReleaseSafe, с выброшенным телом цикла:
sums.sumSlice:
test rdx, rdx ; n == 0?
je .LBB1_1 ; да: вернуть 0
lea rax, [rdx - 1] ; rax = n - 1, последний индекс цикла
cmp rsi, rax ; xs.len <= n - 1 ?
jbe .LBB1_11 ; да: последний индекс за границей, паника
mov ecx, edx
and ecx, 7
cmp rdx, 8
jae .LBB1_5 ; цикл, развёрнутый по 8 элементов
...
.LBB1_6:
add eax, dword ptr [rdi + 4*rsi]
add eax, dword ptr [rdi + 4*rsi + 4]
...
.LBB1_11:
push rbp
mov rbp, rsp
mov rdi, rsi
call "debug.FullPanic((function 'defaultPanic')).outOfBounds"
Первое, что стоит заметить: проверки нет внутри цикла. Компилятор увидел, что индекс монотонно растёт от 0 до n - 1, и вынес одно сравнение перед циклом: если n - 1 не меньше xs.len, значит последняя итерация вылезет за границу, и можно паниковать сразу, ещё ничего не сложив. Одна инструкция cmp и один условный переход на всю функцию. Это типичная картина для ReleaseSafe: проверки есть, но оптимизатор двигает и склеивает их, и цена оказывается гораздо ниже, чем «сравнение на каждой итерации», которого боятся по привычке из старых учебников.
Второе: сам цикл. Восемь add подряд с адресами rdi + 4*rsi, +4, +8 и дальше, это тот же цикл, развёрнутый по восемь элементов, и остаток из n mod 8 элементов добирается отдельным циклом ниже. sumPtr в том же файле выглядит ровно так же, только без lea, cmp и jbe: проверка на n == 0 осталась, а сравнивать n с длиной не с чем.
Теперь ReleaseFast. Файл на 817 строк, стандартной библиотеки в нём нет вовсе, потому что паниковать больше некому. И вот что осталось от entry:
sums.entry:
push rbp
mov rbp, rsp
...
mov rsi, rdx
call sums.sumPtr ; вместо sumSlice
mov r12d, eax
mov rdi, r15
mov rsi, rbx
call sums.sumPtr ; сам sumPtr
mov ebx, eax
add ebx, r12d
mov rdi, r15
mov rsi, r14
call sums.sumAll
add eax, ebx
...
ret
Функции sumSlice в файле больше нет. Как только проверка границ выпала, тело sumSlice совпало с телом sumPtr инструкция в инструкцию, и LLVM склеил их в одну: обёртка теперь вызывает sumPtr дважды. Это лучшее из возможных доказательств: в ReleaseFast срез не стоит ничего по сравнению с голым указателем, а длина, которую он носит во втором регистре, просто не используется, если код её не читает.
А sumAll одинаков в обоих файлах, и проверки в нём нет даже в ReleaseSafe: for (xs) идёт ровно до xs.len, компилятор это видит и не вставляет сравнение, которое никогда не сработает. Отсюда практический вывод для всего раздела: пиши обход через for по срезу или через while (i < xs.len), и безопасный режим будет стоить столько же, сколько небезопасный. Проверка появляется там, где граница цикла приходит извне, как n в sumSlice, и там она защищает от настоящей ошибки.
Практика
Задача про то, ради чего в этом уроке столько разговоров про длину: разобрать строку с числами в буфер, который дал вызывающий, без единой аллокации. Функция parseInts получает []const u8 на вход и []i32 под результат, пишет числа в переданный срез и возвращает его укороченную версию, out[0..count], тот же адрес и новую длину. Три ошибки из набора ParseError обозначены в заготовке; std.mem.splitScalar разрежет строку по запятым, std.fmt.parseInt вернёт Overflow и InvalidCharacter сам, а NoSpace проверяешь ты, до записи в буфер. Функции sum и max дальше работают уже над готовым срезом.
Упражнения
Итоги
- Массив
[N]Tэто значение, оно копируется присваиванием.&arrдаёт*[N]Tс длиной в типе, и этот указатель бесплатно приводится к срезу[]T. - Срез это два слова:
ptrтипа[*]Tиlen. Отсюда@sizeOf([]T)равен 16, а у всех остальных указателей 8.ptr[0..len]собирает срез обратно, и правильностьlenпри этом на тебе. [*]Tне знает длины, поэтому индексация через него не проверяется ни в одном режиме.[*:0]Tдобавляет договор о завершителе,std.mem.spanвосстанавливает по нему длину, аp[0..n :0]проверяет ноль в безопасных режимах.- Debug и ReleaseSafe ловят выход за границы среза, несовпадение завершителя и невыровненный
@alignCastпаникой с внятным сообщением. ReleaseFast убирает проверки целиком, и чтение за границей превращается в неопределённое поведение. - Выравнивание записано в типе указателя:
*u32это*align(4) u32,@ptrCastего не меняет,@alignCastпроверяет адрес. Для байтовых буферов честнее*align(1)илиstd.mem.readIntс явным порядком байтов. - В ассемблере ReleaseSafe проверка границ это одно сравнение перед циклом, а не на каждой итерации. В ReleaseFast
sumSliceиsumPtrкомпилируются в одинаковый код, и LLVM склеивает их в одну функцию.
Дальше
Пять указателей закрывают одиночные значения и последовательности. Следующий шаг это составные типы: как компилятор раскладывает поля структуры в памяти, зачем ему право переставлять их, чем extern struct отличается от packed struct, и как @sizeOf, @alignOf и @offsetOf показывают дырки между полями. Всё это в уроке про структуры, перечисления, объединения и раскладку. А если хочется сравнить сегодняшний материал с тем, как то же самое устроено в Rust, перечитай урок про заимствование, ссылки и срезы: толстые указатели там те же 16 байт.
домашка