跳至内容

理解与避免未定义行为

了解什么是未定义行为、它为什么存在、编译器如何利用它进行优化,以及如何使用净化器和编译器警告来检测它。

什么是未定义行为,它为什么存在?

未定义行为(UB) 意味着 C++ 标准对程序的行为不做任何要求。程序可能崩溃、产生错误结果、看起来正确运行,或者格式化你的硬盘──所有这些都是「有效」的结果。

UB 存在是因为它允许激进的优化。当编译器知道某些情况「不可能发生」(因为它们是 UB)时,它可以通过省略对这些情况的检查来生成更快的代码。例如:

- 有符号整数溢出是 UB 允许编译器假设 x + 1 > x 总是为真,从而启用循环优化。
- 解引用空指针是 UB 允许编译器在解引用之后消除空指针检查。
- 越界访问内存是 UB 允许编译器省略边界检查,使数组访问与原始内存访问一样快。

根本的交易是:C++ 信任程序员避免 UB,以换取最大性能。当这种信任被违反时,一切都不确定了。

未定义行为的常见来源

以下是真实 C++ 代码中最频繁出现的 UB 来源。每个 C++ 程序员都会遇到这些。

common_ub.cpp
#include <vector>
#include <iostream>
#include <cstdint>

// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
    int x = INT32_MAX;
    int y = x + 1;        // UB! Signed overflow is undefined
    // unsigned overflow is well-defined (wraps around)
    unsigned u = UINT32_MAX;
    unsigned v = u + 1;   // OK: v == 0 (well-defined wrap)
}

// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
    int val = *ptr;       // UB if ptr is null
    if (ptr == nullptr) { // Compiler may remove this check!
        // The compiler reasons: "ptr was already dereferenced above,
        // which would be UB if null. Since UB cannot happen in a
        // correct program, ptr must be non-null. This check is dead code."
        std::cout << "null!\n";
    }
}

// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
    int arr[5] = {1, 2, 3, 4, 5};
    int bad = arr[10];    // UB! No bounds checking on raw arrays
    // std::vector::operator[] is also UB if out of bounds
    // Use .at() for bounds-checked access (throws std::out_of_range)
}

// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
    int local = 42;
    return local;         // UB! Reference to destroyed local
}

// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++;    // UB if concurrent with Thread 2
// Thread 2: shared++;    // UB! Data race on non-atomic variable

「时间旅行」问题

UB 最违反直觉的方面之一是它可以影响 UB 点之前的代码。编译器基于「UB 在正确程序中永远不会发生」的假设进行优化,这些优化可以重新排序或消除出现在 UB 之前的代码。

考虑:

bool checked = false;
void process(int* p) {
    int value = *p;        // UB if p is null
    checked = true;        // This line is BEFORE the use of 'value'
    if (p == nullptr) {    // Compiler: "p was dereferenced, so it's not null"
        abort();           // Compiler: "This is dead code, remove it"
    }
    use(value);
}

编译器可能在解引用之前设置 checked = true(为流水线重新排序),然后完全移除空指针检查。结果是:即使 p 为空,checked 也被设置为 true,空指针解引用在没有 abort 的情况下继续进行。UB「穿越时间」破坏了 checked 标志。

这不是编译器的错误──它是 UB 赋予优化自由的直接后果。

使用净化器检测 UB

净化器是编译器提供的工具,它们对你的代码进行插桩以在运行时检测 UB。它们添加运行时检查,用清晰的错误消息捕获 UB。始终在启用净化器的情况下运行你的测试套件。

sanitizers.sh
# AddressSanitizer (ASan) — detects:
#   - Buffer overflow (stack, heap, global)
#   - Use-after-free, use-after-return
#   - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program

# UndefinedBehaviorSanitizer (UBSan) — detects:
#   - Signed integer overflow
#   - Null pointer dereference
#   - Out-of-bounds array access
#   - Misaligned memory access
#   - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program

# ThreadSanitizer (TSan) — detects:
#   - Data races between threads
#   - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program

# MemorySanitizer (MSan) — detects:
#   - Reads of uninitialized memory
#   (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program

# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
    -Wall -Wextra -Werror program.cpp -o program

# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)

编译器警告作为安全网

现代编译器可以在编译时检测许多潜在的 UB 情况。将警告视为错误(-Werror)在 CI 中确保没有警告被忽略。

warnings.sh
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
    -Wall           # Enable most warnings \
    -Wextra         # Enable additional warnings \
    -Wpedantic      # Strict ISO C++ compliance \
    -Werror         # Treat warnings as errors \
    -Wconversion    # Warn on implicit narrowing conversions \
    -Wshadow        # Warn when a variable shadows another \
    -Wnon-virtual-dtor  # Warn on classes with virtual functions but non-virtual dtor \
    -Wold-style-cast    # Warn on C-style casts \
    -Wcast-align    # Warn on potentially misaligned casts \
    -Wunused        # Warn on unused variables/functions \
    -Woverloaded-virtual  # Warn when a derived class hides a base virtual function \
    -Wnull-dereference   # Warn on potential null dereferences (GCC) \
    -Wdouble-promotion   # Warn when float is implicitly promoted to double \
    -Wformat=2      # Warn on printf/scanf format string issues \
    program.cpp -o program

# For Clang, add:
#   -Weverything    # Enable ALL warnings (then selectively disable)
#   -Wno-c++98-compat  # Disable C++98 compatibility warnings
最佳实践

遵循以下准则以最小化代码库中的 UB。

  • 启用所有警告并将其视为错误:-Wall -Wextra -Wpedantic -Werror
  • 在 CI 中运行净化器:在每次测试运行中组合 ASan + UBSan;对多线程代码使用 TSan
  • 在开发期间使用 .at() 代替 [] 进行带边界检查的容器访问
  • 使用智能指针(unique_ptr、shared_ptr)代替裸 new/delete 以消除释放后使用
  • 对线程间共享的变量使用 std::atomic──永远不要对跨线程数据使用普通类型
  • 位操作优先使用无符号类型,但算术运算优先使用有符号类型(无符号算术会回绕,可能在循环边界中导致微妙错误)
常见陷阱

最危险的 UB 是在测试中看起来完美运行但在生产中失败的代码。原因如下。

  • UB 可以「正常工作」多年,然后在你升级编译器、更改优化级别或在不同 CPU 上运行时崩溃
  • 循环中的有符号溢出:for (int i = 0; i < n; i++)──如果 n 是 INT_MAX,i++ 会溢出。编译器可能假设循环会终止并据此优化
  • 读取未初始化变量:值不是「随机的」──编译器可能完全优化掉该变量或传播寄存器中恰好存在的任何位
  • 严格别名违反:将 int* 转换为 float* 并解引用是 UB(通过 char* 或 std::byte* 除外)。使用 std::bit_cast(C++20)进行类型双关
  • std::string_view 比字符串存活更久:返回一个悬空视图,在内存被重用之前看起来正常工作
关键要点
  • 未定义行为的存在是为了启用激进的编译器优化──C++ 以安全性换取性能
  • UB 可以「时间旅行」──编译器可能重新排序或消除 UB 点之前的代码
  • 最常见的 UB 来源是有符号溢出、空指针解引用、越界访问、释放后使用和数据竞争
  • 净化器(ASan、UBSan、TSan、MSan)在运行时检测 UB──在每个 CI 管道中运行它们
  • 编译器警告(-Wall -Wextra -Werror)在编译时捕获许多潜在的 UB 情况
  • 今天「正常工作」的 UB 可能在新编译器、优化级别或平台下静默崩溃

测验 — 检验你的知识

(15 经验值)

1. 为什么有符号整数溢出在 C++ 中是未定义行为?

2. AddressSanitizer(ASan)检测什么?

3. 什么是未定义行为的「时间旅行」问题?