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

Указатели, срезы и массивы

middle~70 мин

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

Указатели, срезы и массивы

В 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]TN элементов, N в типеp[i], проверка по N8
[]Tдлина в рантаймеs[i], проверка по s.len16
[*]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 байт.

домашка

Домашка