Skip to content

Day 14: 指针基础与安全

欢迎来到第十四天!今天我们将深入探讨一个在系统编程中既强大又危险的概念:指针(Pointers)。指针存储的是一个变量的内存地址。与C语言类似,Zig允许你直接操作内存地址,这对于底层编程、性能优化和与硬件交互至关重要。然而,与C不同的是,Zig在设计上引入了多种机制来增强指针的安全性,旨在消除空指针解引用、悬垂指针等常见错误。今天,我们将学习Zig中不同类型的指针、如何安全地使用它们,以及它们与切片等其他类型的关系。

Zig中有多种指针类型,它们各自有不同的保证和用途。

  • *T: 单项指针(Single-Item Pointer)。这是最常见的指针类型,它指向内存中单个 T 类型的值。编译器会优化假设它绝不指向一个数组的元素。

  • [*]T: 多项指针(Many-Item Pointer)。这种指针可以指向一个数组中的单个元素,也可以指向不属于任何数组的单个项。它允许进行指针算术运算。切片的 ptr 字段就是 [*]T 类型。

  • *const T / [*]const T: 常量指针(Const Pointers)。这些指针指向的数据是不可变的。你不能通过这些指针来修改所指向的内存。

获取指针与解引用:

  • & 操作符用于获取一个变量的地址(创建指针)。
  • .* 操作符用于解引用(Dereference)指针,即访问或修改指针所指向的内存中的值。
var x: i32 = 10;
// 创建一个指向x的单项指针
const ptr_x: *i32 = &x;
// 解引用指针来修改x的值
ptr_x.* = 20;
// 现在 x 的值是 20

指针算术只对多项指针 [*]T 有效。它允许你通过增加或减少指针的值来移动到相邻的内存位置。编译器知道 T 的大小,所以 ptr + 1 会将地址增加 @sizeOf(T) 个字节。

var array = [_]i32{ 10, 20, 30 };
const ptr_many: [*]i32 = &array;
const second_item_ptr = ptr_many + 1;
const second_item_value = second_item_ptr.*; // 值为 20

警告:指针算术很强大,但也容易出错。在 Debug 和 ReleaseSafe 模式下,切片的索引有边界检查,但原始指针的算术运算没有。不当的指针算术可能导致访问无效内存。

为了解决C语言中 NULL 指针引发的各种问题,Zig引入了可选类型与指针的结合,形成了可空指针。一个可空指针的类型是 ?*T。

var ptr_or_null: ?*i32 = null;
// ... 之后可能被赋值 ...
var x: i32 = 10;
ptr_or_null = &x;
// 必须先检查是否为null才能使用
if (ptr_or_null) |ptr| {
// 在这个块里,ptr是*i32类型,不再是?*i32
ptr.* = 100;
} else {
// 处理null的情况
}

这种设计强制你在编译时就处理 null 的情况,从而从根本上消除了空指针解引用错误。

Zig提供了更底层的内存控制关键字:

  • volatile: volatile 关键字告诉编译器,某个内存地址的值可能会在程序控制之外被改变(例如,被硬件或其他线程修改)。这会阻止编译器对该内存的读写操作进行优化(比如重排序或缓存)。

    const timer_register = @ptrFromInt(0x12345678, *volatile u32);
    const current_time = timer_register.*;
  • align(N): 这个关键字用于指定指针指向的内存地址必须是 N 的倍数。这对于SIMD操作或与有特定对齐要求的硬件交互非常重要。

    var data: [16]u8 align(16) = undefined;
    const ptr_aligned: *align(16) u8 = &data;

链表是演示指针用法的经典数据结构。每个节点包含一个值和一个指向下一个节点的(可空)指针。

const std = @import("std");
const Node = struct {
data: i32,
next: ?*Node, // 指向下一个节点的指针,可能是null
};
pub fn main() !void {
const allocator = std.heap.page_allocator;
// 创建节点
var node1 = try allocator.create(Node);
var node2 = try allocator.create(Node);
var node3 = try allocator.create(Node);
// 初始化并连接它们
node1.* = .{ .data = 10, .next = node2 };
node2.* = .{ .data = 20, .next = node3 };
node3.* = .{ .data = 30, .next = null };
// 遍历链表
var current: ?*Node = node1;
while (current) |node| {
std.debug.print("{d} -> ", .{node.data});
current = node.next;
}
std.debug.print("null\n", .{});
}

基于上面的链表示例,实现一个函数 insertAfter(node: *Node, new_node: *Node)。

这个函数应该将 new_node 插入到 node 的后面。你需要正确地处理 next 指针的链接关系。

// 函数签名
fn insertAfter(node: *Node, new_node: *Node) void {
// ... 实现逻辑 ...
}

问:什么是悬垂指针(Dangling Pointer)?

答:与悬垂切片类似,悬垂指针是指一个指针指向的内存已经被释放或超出了作用域。在Zig中,虽然类型系统提供了一些保护,但手动内存管理时仍然可能产生悬垂指针。使用Arena分配器或确保 defer free() 的正确使用是避免这类问题的关键。

问:指针和引用(Reference)有什么区别?

答:在很多语言中,“引用”是一个更抽象、更安全的概念,通常不允许算术运算,并且总被认为是有效的。在Zig的语境下,切片([]T)的行为非常类似于其他语言中的引用:它们有长度信息,并且在安全模式下有边界检查。而指针(*T, [*]T)则更底层,允许进行算术运算,并且(除了可空性检查外)对它们的操作通常需要程序员自己来保证安全。

今天,我们揭开了Zig中指针的神秘面纱。我们学习了单项指针和多项指针的区别,了解了如何通过可空类型 ?*T 来实现空安全,并接触了 volatile 和 align 等高级特性。指针是Zig赋予开发者的强大工具,它让我们能够编写高性能的底层代码。同时,Zig的设计哲学也通过类型系统和显式可空性,引导我们更安全地使用这份力量。

明天,我们将学习如何使用 struct 来组织和封装我们自己的复合数据类型。