Chapter 37Illegal Behavior And Safety Modes

非法行为与安全模式

概览

完成风格调优后,我们认识到:若不在失败时明确告警,不变量将失去意义(36)。本章说明 Zig 如何将这些失败形式化为“非法行为”,以及工具链如何在状态被破坏前捕获大多数问题。#illegal behavior

接下来将深入命令行工具,因此在脚本为我们切换优化模式之前,需先架设好运行时护栏。38

学习目标

  • 区分安全检查和非检查类别的非法行为。
  • 检查当前优化模式并推断 Zig 将发出哪些运行时检查。
  • 围绕 @setRuntimeSafetyunreachablestd.debug.assert 构建契约,确保不变量在每次构建中都可证明。

Refs: 4

Zig 中的非法行为

非法行为是 Zig 对语言拒绝定义的操作的统称,范围从整数溢出到解引用无效指针。我们已经依赖切片和可选类型的边界检查;本节整合这些规则,以便即将进行的 CLI 工作继承可预测的失败处理机制。3

安全检查路径 vs 非检查路径

带安全检查的非法行为涵盖编译器可在运行时插桩的情形(溢出、哨兵不匹配、访问错误的联合字段);而非检查情形对安全插桩不可见(通过错误指针类型别名、外部代码导致的布局违规)。

Debug 与 ReleaseSafe 默认保留护栏。ReleaseFast 与 ReleaseSmall 假定你为性能放弃了这些陷阱,因此任何越过不变量的行为在实践中都变为未定义。

示例:守护未检查算术

如下助手先用@addWithOverflow证明一次加法安全,然后对最终的+禁用运行期安全,以避免冗余检查,并在异常输入下将结果饱和到类型最大值。#setruntimesafety

Zig
const std = @import("std");

/// 执行带溢出检测和饱和的加法。
/// 如果发生溢出,返回最大值u8而不是环绕。
/// 在非溢出路径中使用@setRuntimeSafety(false)以提高性能。
fn guardedUncheckedAdd(a: u8, b: u8) u8 {
    // 使用内置溢出检测检查加法是否会溢出
    const sum = @addWithOverflow(a, b);
    const overflow = sum[1] == 1;
    // 溢出时饱和到最大值
    if (overflow) return std.math.maxInt(u8);

    // 安全路径:为此加法禁用运行时安全检查
    // 因为我们已经验证不会发生溢出
    return blk: {
        @setRuntimeSafety(false);
        break :blk a + b;
    };
}

/// 执行不带运行时安全检查的加法。
/// 这允许操作在溢出时环绕(安全模式中的未定义行为)。
/// 演示完全禁用函数作用域的安全性。
fn wrappingAddUnsafe(a: u8, b: u8) u8 {
    // 禁用整个函数的所有运行时安全检查
    @setRuntimeSafety(false);
    return a + b;
}

// 验证guardedUncheckedAdd正确处理正常加法和溢出饱和场景。
test "guarded unchecked addition saturates on overflow" {
    // 正常情况:120 + 80 = 200(无溢出)
    try std.testing.expectEqual(@as(u8, 200), guardedUncheckedAdd(120, 80));
    // 溢出情况:240 + 30 = 270 > 255,应饱和到255
    try std.testing.expectEqual(std.math.maxInt(u8), guardedUncheckedAdd(240, 30));
}

// 演示wrappingAddUnsafe在溢出时产生与@addWithOverflow相同的环绕结果。
test "wrapping addition mirrors overflow tuple" {
    // @addWithOverflow返回[wrapped_result, overflow_bit]
    const sum = @addWithOverflow(@as(u8, 250), @as(u8, 10));
    // 验证发生了溢出(250 + 10 = 260 > 255)
    try std.testing.expect(sum[1] == 1);
    // 验证环绕结果与未检查加法匹配(260 % 256 = 4)
    try std.testing.expectEqual(sum[0], wrappingAddUnsafe(250, 10));
}
运行
Shell
$ zig test 01_guarded_runtime_safety.zig
输出
Shell
All 2 tests passed.

使用 -OReleaseFast 运行相同的测试,验证即使全局运行时安全缺失,守护机制仍会继续饱和而非崩溃。

按优化模式的安全默认项

当前优化模式通过@import("builtin").mode公开,无需查阅构建脚本即可知晓某制品包含哪些运行时检查。#compile variables 下表概述在你手动选择启用/禁用检查之前,各模式的默认契约。

模式运行时安全典型意图
DebugEnabled具有最大诊断信息和堆栈跟踪的开发构建。
ReleaseSafeEnabled生产构建,仍然倾向于可预测的陷阱而非静默损坏。
ReleaseFastDisabled高性能二进制文件,假定不变量已在其他地方得到证明。
ReleaseSmallDisabled大小受限的交付物,其中每个注入的陷阱都会成为负担。

在运行时插桩安全

该探针打印当前模式及其隐含的安全默认项,并比较一次带检查的加法与未检查的加法,使你可观察在关闭检查后哪些行为仍然成立。

Zig
const std = @import("std");
const builtin = @import("builtin");

// Extract the compile-time type of the optimization mode enum
// 提取优化模式枚举的编译时类型
const ModeType = @TypeOf(builtin.mode);

// / 捕获活动优化模式及其默认安全行为
const ModeInfo = struct {
    mode: ModeType,
    safety_default: bool,
};

// / 确定给定模式是否默认启用运行时安全检查。
// / Debug 和 ReleaseSafe 模式保留安全检查;ReleaseFast 和 ReleaseSmall 禁用它们。
fn defaultSafety(mode: ModeType) bool {
    return switch (mode) {
        // 这些模式优先考虑正确性,带有运行时检查
        .Debug, .ReleaseSafe => true,
        // 这些模式优先考虑性能/大小,通过移除检查
        .ReleaseFast, .ReleaseSmall => false,
    };
}

// / 执行检查加法,无需 panic 即可检测溢出。
// / 返回包装结果和溢出标志。
fn sampleAdd(a: u8, b: u8) struct { result: u8, overflowed: bool } {
    // @addWithOverflow 返回一个元组:[包装结果, 溢出位]
    const pair = @addWithOverflow(a, b);
    return .{ .result = pair[0], .overflowed = pair[1] == 1 };
}

// / 通过显式禁用运行时安全来执行未检查的加法。
// / 在 Debug/ReleaseSafe 模式下,这避免了溢出时的 panic。
// / 在 ReleaseFast/ReleaseSmall 模式下,安全已关闭,因此这是多余但无害的。
fn uncheckedAddStable(a: u8, b: u8) u8 {
    return blk: {
        // 仅为此块临时禁用运行时安全
        @setRuntimeSafety(false);
        // 不带溢出检查的原始加法;溢出时静默环绕
        break :blk a + b;
    };
}

pub fn main() void {
    // 捕获当前构建模式及其隐含的安全设置
    const info = ModeInfo{
        .mode = builtin.mode,
        .safety_default = defaultSafety(builtin.mode),
    };

    // 报告编译此二进制文件时使用的优化模式
    std.debug.print("optimize-mode: {s}\n", .{@tagName(info.mode)});
    // 显示此模式下是否默认开启运行时安全
    std.debug.print("runtime-safety-default: {}\n", .{info.safety_default});

    // 演示检查过的加法,报告溢出而不崩溃
    const checked = sampleAdd(200, 80);
    std.debug.print("checked-add result={d} overflowed={}\n", .{ checked.result, checked.overflowed });

    // 演示未检查的加法,静默环绕(24 = (200+80) % 256)
    const unchecked = uncheckedAddStable(200, 80);
    std.debug.print("unchecked-add result={d}\n", .{unchecked});
}
运行
Shell
$ zig run 02_mode_probe.zig
输出
Shell
optimize-mode: Debug
runtime-safety-default: true
checked-add result=24 overflowed=true
unchecked-add result=24

使用 -OReleaseFast 重新运行探针,观察默认安全标志翻转为 false,同时检查路径仍报告溢出,帮助您记录发布构建中可能需要的功能标志或遥测数据。

契约、崩溃与恢复

在启用安全的构建中触发 unreachable 时,堆栈跟踪会平静地令人恐惧。将它们视为断言和错误联合体耗尽优雅退出后的最后一道防线。#reaching unreachable code

将这种纪律与前几章的错误处理技术相结合,可在不牺牲确定性的情况下保持失败模式的可调试性。4

示例:断言数字字符转换

这里我们两次记录 ASCII 数字契约:一次使用断言解锁未检查的数学运算,一次使用错误联合体进行调用者友好的验证。debug.zig

Zig
// 此文件演示Zig中不同的安全模式以及如何处理
// 具有不同运行时检查级别的转换。

const std = @import("std");

/// 在不进行运行时安全检查的情况下将ASCII数字字符转换为其数值。
/// 此函数使用断言记录前置条件,输入必须是
/// 有效的ASCII数字字符('0'-'9')。@setRuntimeSafety(false)指令禁用
/// 减法和转换操作的运行时整数溢出检查。
///
/// 前置条件:字节必须在['0', '9']范围内
/// 返回:数值(0-9)作为u4
pub fn asciiDigitToValueUnchecked(byte: u8) u4 {
    // 断言记录约定:调用者必须提供有效的ASCII数字
    std.debug.assert(byte >= '0' and byte <= '9');

    // 禁用运行时安全的代码块,用于性能关键路径
    return blk: {
        // 禁用此转换的运行时溢出/下溢检查
        @setRuntimeSafety(false);
        // 安全的转换,因为前置条件保证结果适合u4(0-9)
        break :blk @intCast(byte - '0');
    };
}

/// 将ASCII数字字符转换为其数值,带错误处理。
/// 此函数在运行时验证输入,如果
/// 字节不是有效的ASCII数字,则返回错误,使其可以安全用于不受信任的输入。
///
/// 返回:数值(0-9)作为u4,如果无效则返回error.InvalidDigit
pub fn asciiDigitToValue(byte: u8) !u4 {
    // 验证输入在有效的ASCII数字范围内
    if (byte < '0' or byte > '9') return error.InvalidDigit;
    // 安全转换:验证确保结果在0-9范围内
    return @intCast(byte - '0');
}

// 验证未检查的转换对所有有效输入产生正确结果。
// 测试所有ASCII数字以确保基于断言的函数保持正确性
// 即使在内部禁用运行时安全。
test "assert-backed conversion stays safe across modes" {
    // 在编译时迭代所有有效的ASCII数字字符
    inline for ("0123456789") |ch| {
        // 验证未检查函数产生与直接转换相同的结果
        try std.testing.expectEqual(@as(u4, @intCast(ch - '0')), asciiDigitToValueUnchecked(ch));
    }
}

// 验证返回错误的转换正确拒绝无效输入。
// 确保错误路径正确工作并提供有意义的诊断。
test "error path preserves diagnosability" {
    // 验证非数字字符返回预期错误
    try std.testing.expectError(error.InvalidDigit, asciiDigitToValue('z'));
}
运行
Shell
$ zig test 03_unreachable_contract.zig
输出
Shell
All 2 tests passed.

在 ReleaseFast 中,断言支持的路径会编译为一次减法;但在 Debug 下传入非数字时仍会触发 panic。对不可信数据请保留返回错误的防御性变体。

注意与警示

  • 即使在 Debug 模式下,某些基于指针的错误仍保持未检查状态。当需要边界强制执行时,优先使用基于切片的 API。
  • @setRuntimeSafety(false) 的范围缩小到尽可能小的块,并在切换前证明前置条件。
  • 在开发中捕获 panic 堆栈跟踪,如果预计稍后需要分类 ReleaseSafe 崩溃,请提供符号文件。

练习

  • 扩展 guardedUncheckedAdd,在哨兵终止的切片将溢出目标缓冲区时发出诊断信息,然后测量启用安全和禁用安全构建之间的差异。#sentinel terminated arrays
  • 编写一个基准测试工具,循环执行数百万次安全加法,每次迭代切换 @setRuntimeSafety 以确认每种模式下守护机制的成本。
  • 增强模式探针,在即将进行的 CLI 项目中记录构建元数据,以便脚本在 ReleaseFast 二进制文件省略陷阱时发出警告。38

替代方案与边界情况

  • 在 ReleaseFast 中未能从 + 切换到 @addWithOverflow 会带来静默回绕的风险,这种风险仅在罕见的负载模式下显现。
  • 运行时安全不防御并发数据竞争。请将这些工具与本书后面介绍的同步原语配对使用。
  • 调用 C 代码时请记住,Zig 的检查止步于 FFI 边界;在信任不变量前,应先验证外部输入。33

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.