Chapter 06

指针与切片:安全的底层操作

理解 Zig 丰富的指针类型体系,掌握切片的安全操作

Zig 的指针类型体系

Zig 有多种指针类型,每种在类型层面表达了不同的语义,让指针使用更安全可预测:

*T
单元素指针。指向恰好一个 T 类型的值。不能进行指针算术。解引用用 ptr.*
*[N]T
指向固定大小数组的指针。可以自动强制转换为切片 []T
[*]T
多元素指针(C 风格指针)。支持指针算术,但没有长度信息,不安全。通常配合长度参数使用。
[]T
切片:[*]T + length 的胖指针。携带长度,访问会检查边界(debug 模式)。推荐优先使用。
[:0]T
sentinel-terminated 切片,以指定哨兵值(通常是 0)结尾。用于 C 字符串兼容。
?*T
可选指针,可以为 null。相当于 C 的 nullable 指针,但访问前必须解包检查。
*const T
指向不可变值的指针,不能通过此指针修改值。

单元素指针 *T

var x: i32 = 42;
const ptr: *i32 = &x;  // 取地址

// 解引用
std.debug.print("value: {d}\n", .{ptr.*});  // 42

// 通过指针修改值
ptr.* = 100;
std.debug.print("modified: {d}\n", .{x});  // 100

// 结构体字段指针
const Point = struct { x: f32, y: f32 };
var p = Point{ .x = 1.0, .y = 2.0 };
const px: *f32 = &p.x;
px.* += 10.0;
std.debug.print("p.x = {d}\n", .{p.x});  // 11.0

多元素指针 [*]T

var arr = [_]i32{ 10, 20, 30, 40, 50 };
const many_ptr: [*]i32 = &arr;  // 或 arr[0..]

// 指针算术
std.debug.print("{d}\n", .{many_ptr[0]});  // 10
std.debug.print("{d}\n", .{many_ptr[2]});  // 30

// [*]T 没有边界检查!越界是未定义行为
// 通常配合长度使用,或转换为切片
const slice: []i32 = many_ptr[0..3];  // 创建切片(有边界)
_ = slice;

切片 []T

切片的本质

切片是一个胖指针(fat pointer),包含两个字段:指向数据的指针([*]T)和长度(usize)。在 64 位系统上占 16 字节。

// 从数组创建切片
var arr = [_]u8{ 1, 2, 3, 4, 5 };
const full: []u8 = arr[0..];         // 全部
const mid:  []u8 = arr[1..4];        // [1,4) 即索引 1,2,3
const tail: []u8 = arr[2..];         // 从索引2到末尾
const head: []u8 = arr[0..3];        // 前3个元素

std.debug.print("len={d}, [0]={d}\n", .{ mid.len, mid[0] });  // 3, 2

// 字符串字面量是 []const u8
const hello: []const u8 = "Hello, Zig!";
std.debug.print("len={d}\n", .{hello.len});

// 切片操作
const std_mem = std.mem;
const idx = std_mem.indexOf(u8, hello, "Zig");  // ?usize
if (idx) |i| std.debug.print("Zig at index {d}\n", .{i});
_ = full; _ = tail; _ = head;

切片常用操作

const std = @import("std");

pub fn sliceOps() void {
    var buf = [_]u8{ 5, 3, 1, 4, 2 };
    const s: []u8 = &buf;

    // 排序
    std.mem.sort(u8, s, {}, std.sort.asc(u8));
    std.debug.print("sorted: {any}\n", .{s});  // [1,2,3,4,5]

    // 比较
    const equal = std.mem.eql(u8, s[0..2], &[_]u8{ 1, 2 });
    std.debug.print("equal: {}\n", .{equal});  // true

    // 填充
    std.mem.set(u8, s, 0);  // 全部设为 0
    std.debug.print("zeroed: {any}\n", .{s});
}

sentinel-terminated 切片

// [:0]u8 — null-terminated 字节切片,兼容 C 字符串
const c_str: [:0]const u8 = "Hello C";  // 字符串字面量自带 null 终止
std.debug.print("len={d}\n", .{c_str.len});  // 7(不含 null)

// 传递给 C 函数(需要 [*:0]u8)
extern fn puts(s: [*:0]const u8) c_int;
// puts(c_str);  // 会打印 "Hello C" 到 stdout

// 从普通切片创建 null-terminated 切片(需要确保有 null 终止符)
var buf = [_]u8{ 72, 105, 0 };  // "Hi\0"
const nt: [*:0]u8 = buf[0..2 :0];
_ = nt;

指针转换

// @ptrCast:重新解释指针类型(不检查安全性,谨慎使用)
var x: u32 = 0xDEADBEEF;
const byte_ptr: *[4]u8 = @ptrCast(&x);
std.debug.print("bytes: {x} {x} {x} {x}\n",
    .{ byte_ptr[0], byte_ptr[1], byte_ptr[2], byte_ptr[3] });

// @alignCast:调整指针对齐声明
fn readU32(bytes: [*]const u8) u32 {
    // 假设 bytes 已对齐到 4 字节边界
    const aligned: *const u32 = @ptrCast(@alignCast(bytes));
    return aligned.*;
}

// @intFromPtr / @ptrFromInt:指针与整数互转
const addr = @intFromPtr(&x);
std.debug.print("address: 0x{x}\n", .{addr});

内存对齐

// @alignOf:获取类型的对齐要求(字节数)
std.debug.print("u8 align: {d}\n",  .{@alignOf(u8)});   // 1
std.debug.print("u32 align: {d}\n", .{@alignOf(u32)});  // 4
std.debug.print("u64 align: {d}\n", .{@alignOf(u64)});  // 8
std.debug.print("f64 align: {d}\n", .{@alignOf(f64)});  // 8

// 带对齐的指针类型
var buf: [16]u8 align(16) = undefined;  // 16 字节对齐(SIMD 用)
const aligned_ptr: *align(16) [16]u8 = &buf;
_ = aligned_ptr;
指针安全最佳实践

Zig 指针系统的底层原理

为什么指针类型如此繁多?

Zig 拥有多种指针类型(*T[*]T[]T[*:0]T*[N]T),这不是设计繁琐,而是将 C 指针系统中隐藏的语义显式化:

单值指针 *T(Single-Item Pointer)
指向单个值的指针,编译器知道只有一个元素,不能做指针算术(ptr + 1 是编译错误)。C 中的 int *p = &x; 在 Zig 中对应 const p: *i32 = &x;。永远不为 null(null 安全由 ?*T 处理)。
多值指针 [*]T(Many-Item Pointer)
指向不知道长度的数组,允许指针算术(ptr[i]ptr + n)。等价于 C 的 int *arr = malloc(n * sizeof(int));。没有边界检查——越界是未定义行为(与 C 相同)。主要用于 C 互操作。
切片 []T(Slice)
胖指针:包含指针 + 长度两个字段(内存中是 16 字节 on 64bit)。所有下标访问自动边界检查(Debug/ReleaseSafe 模式触发 panic,ReleaseFast 删除检查)。这是 Zig 中最常用的"数组参数"类型,相当于 Rust 的 &[T]
哨兵终止指针 [*:0]T(Sentinel-Terminated Pointer)
C 字符串(null 终止)的 Zig 等价物。哨兵值可以是任意编译期常量,不只是 0。Zig 标准库的字符串字面量类型是 *const [N:0]u8(数组字面量带哨兵),传递给 C 函数时会隐式转换为 [*:0]const u8

内存对齐的深层原因

为什么 CPU 需要对齐的内存访问?

现代 CPU 从内存总线读取数据时,是按固定大小的块(通常 8 字节)读取的。

对齐的访问(u32 在 4 字节对齐地址):
  地址 0x1000: [00 00 00 42]  ← 一次读取,提取 4 字节
  结果正确,一个 CPU 周期

未对齐的访问(u32 跨越 8 字节边界):
  地址 0x1006: [xx xx 00 00 | 00 42 xx xx]  ← 需要两次读取
  x86: 硬件处理(性能损失 ~10-40%)
  ARM: 可能产生对齐错误(SIGBUS)
  嵌入式/RISC-V: 通常直接崩溃

Zig 的处理方式:
  - 默认所有指针必须满足类型的对齐要求
  - @ptrCast 跨对齐时需要 @alignCast 显式声明
  - packed struct 可以关闭对齐(用于位字段、协议包解析)
  - SIMD 操作通常需要 16/32/64 字节对齐

Zig 指针 vs C 指针 vs Rust 引用对比

特性            C (*T)      Zig (*T)     Rust (&T)
null 安全       否(隐患)    是(用 ?*T)   是(不存在 null)
悬空指针        运行时崩溃  运行时检测   编译时禁止
别名规则        无约束      无约束       借用检查器强制
边界检查        无          可选(切片)   可选(slice)
指针算术        允许        受限         不允许
C 互操作        原生        原生         需要 unsafe

std.mem 内存操作工具

常用 std.mem 函数

标准库的 std.mem 模块提供了丰富的内存和切片操作函数,是日常 Zig 开发中最常用的工具集之一:

const std = @import("std");
const mem = std.mem;

pub fn memOpsDemo() void {
    var a = [_]u8{ 1, 2, 3, 4, 5 };
    var b = [_]u8{ 0, 0, 0, 0, 0 };

    // 复制内存(src 和 dst 不能重叠)
    mem.copyForwards(u8, &b, &a);
    std.debug.print("copy: {any}\n", .{b});  // [1,2,3,4,5]

    // 反向复制(适用于重叠区域)
    mem.copyBackwards(u8, a[1..], a[0..4]);  // 安全地将 a 整体右移一位

    // 填充(将切片所有元素设为同一值)
    mem.set(u8, &b, 0xFF);
    std.debug.print("filled: {any}\n", .{b});  // [255,255,255,255,255]

    // 清零(memset 0)
    mem.set(u8, &b, 0);

    // 字节级比较
    const eq = mem.eql(u8, &a, &b);  // false
    _ = eq;

    // 字典序比较
    const order = mem.order(u8, "abc", "abd");  // .lt
    _ = order;

    // 查找子序列
    const haystack = "hello world";
    const pos = mem.indexOf(u8, haystack, "world");  // ?usize = 6
    _ = pos;

    // 以某前缀/后缀开头/结尾
    const starts = mem.startsWith(u8, haystack, "hello");  // true
    const ends   = mem.endsWith(u8, haystack, "world");    // true
    _ = starts; _ = ends;
}

pub fn numericOps() void {
    // 字节序转换(大端/小端)
    const val: u32 = 0xDEAD_BEEF;

    // 转为大端字节序(网络字节序)
    const be = mem.nativeToBig(u32, val);
    // 转为小端字节序
    const le = mem.nativeToLittle(u32, val);
    _ = be; _ = le;

    // 从字节数组读取整数(按指定字节序)
    const bytes = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF };
    const from_be = mem.readInt(u32, &bytes, .big);    // 0xDEADBEEF
    const from_le = mem.readInt(u32, &bytes, .little); // 0xEFBEADDE
    _ = from_be; _ = from_le;

    // 将整数写入字节数组
    var out_bytes: [4]u8 = undefined;
    mem.writeInt(u32, &out_bytes, 0x12345678, .big);
    std.debug.print("bytes: {x}\n", .{out_bytes});  // 12 34 56 78
}
字节序(Endianness)的重要性

网络协议(TCP/IP、HTTP、DNS 等)使用大端字节序(big-endian);x86/ARM 处理器通常使用小端字节序(little-endian)。处理网络数据包时必须进行字节序转换。std.mem.readInt/writeIntstd.mem.nativeToBig 是跨平台安全的字节序操作方式,避免了手动位移运算的错误风险。

@sizeOf 与 @offsetOf

const Packet = extern struct {
    magic: u16,     // 偏移 0
    length: u16,    // 偏移 2
    checksum: u32,  // 偏移 4
    payload: [8]u8, // 偏移 8
};

pub fn sizeDemo() void {
    // @sizeOf:类型占用字节数
    std.debug.print("Packet size: {d}\n", .{@sizeOf(Packet)});  // 16
    std.debug.print("u64 size: {d}\n",     .{@sizeOf(u64)});    // 8
    std.debug.print("bool size: {d}\n",    .{@sizeOf(bool)});   // 1

    // @offsetOf:字段在结构体中的偏移量
    std.debug.print("magic offset: {d}\n",    .{@offsetOf(Packet, "magic")});    // 0
    std.debug.print("checksum offset: {d}\n", .{@offsetOf(Packet, "checksum")}); // 4
    std.debug.print("payload offset: {d}\n",  .{@offsetOf(Packet, "payload")});  // 8

    // 用于按字段偏移操作原始内存(低级网络/协议解析)
    var raw = [_]u8{0} ** @sizeOf(Packet);
    const pkt: *Packet = @ptrCast(&raw);
    pkt.magic = 0xCAFE;
    pkt.length = 8;
    pkt.checksum = 0xDEAD_BEEF;
    std.debug.print("pkt.magic = 0x{X}\n", .{pkt.magic});
}
本章核心要点