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;
指针安全最佳实践
- 优先使用切片
[]T而非[*]T,切片有内置边界检查 - 避免在不必要的地方使用
@ptrCast,它绕过了类型系统的保护 - 需要 C 互操作时才使用
[*]T或[*:0]T - 使用
?*T而非手动检查地址是否为 0 来表示可空指针
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/writeInt 和 std.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});
}
本章核心要点
- Zig 将 C 指针的隐式语义显式化:*T(单值,不能算术)、[*]T(多值无界,可算术)、[]T(切片含长度,有边界检查)、[*:0]T(哨兵终止,C字符串)——每种类型传达不同的使用意图
- 切片 []T 是函数参数的最佳选择:胖指针(指针+长度),Debug/ReleaseSafe 模式自动边界检查,数组可以隐式强制转换为切片传入
- 内存对齐影响性能和正确性:未对齐访问在 ARM/嵌入式上触发 SIGBUS;@ptrCast 跨对齐时必须加 @alignCast;SIMD 运算通常需要 16/32/64 字节对齐
- std.mem 是操作切片的工具箱:copyForwards/copyBackwards/set 用于内存操作;readInt/writeInt 处理字节序转换;indexOf/startsWith/eql 用于内容搜索
- @sizeOf/@offsetOf 支持精确内存布局控制:配合 extern struct 可以精确解析网络协议包、硬件寄存器映射等需要精确内存布局的场景