学习18 分钟阅读编辑本文 ↗

Standard C++ 基础回炉|第 1 课(1-2):悬空指针与 AddressSanitizer 实验

通过返回局部变量地址的实验,理解对象生命周期、悬空指针、未定义行为,并使用 ASan 定位 stack-use-after-return。

Standard C++ 基础回炉|第 1 课(1-2):悬空指针与 AddressSanitizer 实验

1. 实验目标

通过实际代码理解:

  • 局部变量的生命周期
  • 悬空指针(Dangling Pointer)
  • 未定义行为(Undefined Behavior)
  • 为什么"指针非空"不等于"指针有效"
  • ASan 的用途与基本原理
  • stack-use-after-return 报告如何阅读
  • Shadow Memory、Redzone 的基本概念
  • ASan 与 UBSan 的区别

2. 第一版:直接返回局部变量地址

#include <iostream>

int* make_dangling_pointer() {
    int local_value = 42;

    std::cout << "local address: "
              << static_cast<void*>(&local_value)
              << '\n';

    return &local_value;
}

int main() {
    int* pointer = make_dangling_pointer();

    std::cout << "returned address: "
              << static_cast<void*>(pointer)
              << '\n';

    std::cout << "value: " << *pointer << '\n';
}

编译:

g++ -std=c++17 -O0 -g \
  -Wall -Wextra -Wpedantic \
  experiment_b.cpp -o without_asan

编译器警告:

warning: address of local variable ‘local_value’ returned
[-Wreturn-local-addr]

local_value 是局部自动变量。函数结束时,它的生命周期也随之结束:

进入函数
  ↓
创建 local_value = 42
  ↓
取得 &local_value
  ↓
函数 return
  ↓
local_value 生命周期结束

因此函数返回后,即使某个指针仍保存原来的地址,这个指针也已经成为悬空指针。

3. 第一版程序的运行现象

实际运行出现:

local address: 0x7e87cedc
returned address: 0
段错误

不能据此认为:

悬空指针会自动变成 nullptr。

这种错误涉及未定义行为,具体表现不能依赖。不同编译器、平台和优化级别下可能表现为崩溃、旧值、垃圾值或其他结果。

4. 野指针、悬空指针和空指针

野指针通常指未初始化、内容不确定的指针:

int* p;

悬空指针是曾经指向合法对象,但该对象生命周期已经结束:

int* p = nullptr;

{
    int value = 42;
    p = &value;
}

// value 已经不存在,p 悬空

空指针:

int* p = nullptr;

明确表示当前不指向对象。

需要特别记住:

p != nullptr 只能说明它不是空指针,不能证明它指向的对象仍然有效。

5. 第二版:让地址真正返回到 main

#include <iostream>

__attribute__((noinline))
int* hide_pointer(int* pointer) {
    asm volatile("" : "+r"(pointer));
    return pointer;
}

int* make_dangling_pointer() {
    int local_value = 42;

    std::cout << "local address: "
              << static_cast<void*>(&local_value)
              << '\n';

    return hide_pointer(&local_value);
}

int main() {
    int* pointer = make_dangling_pointer();

    std::cout << "returned address: "
              << static_cast<void*>(pointer)
              << '\n';

    std::cout << "value: " << *pointer << '\n';
}

__attribute__((noinline)) 是 GCC/Clang 扩展,用来阻止函数内联。

asm volatile("" : "+r"(pointer));

是实验用的空内联汇编,用于阻碍编译器简单追踪这个指针的来源。它不是正常业务代码的推荐写法。

6. 开启 ASan

实验编译命令:

g++ -std=c++17 -O0 -g \
  -Wall -Wextra -Wpedantic \
  -fsanitize=address,undefined \
  -fsanitize-address-use-after-scope \
  -fno-omit-frame-pointer \
  experiment_b.cpp -o with_asan

运行:

LD_PRELOAD="$LIBASAN" \
ASAN_OPTIONS=detect_stack_use_after_return=1:halt_on_error=1 \
./with_asan

实际首先得到:

local address: 0x73800020
returned address: 0x73800020

这次地址数值完全相同,但 local_value 已经死亡。

所以:

地址还在
≠
对象还活着

7. ASan 是什么

ASan 全称 AddressSanitizer,通常读作 A-San。

它是 GCC、Clang 等编译器支持的运行时内存错误检测工具。

常见错误包括:

问题 ASan 常见报告


堆内存释放后继续访问 heap-use-after-free 堆数组越界 heap-buffer-overflow 栈数组越界 stack-buffer-overflow 函数返回后访问原栈对象 stack-use-after-return 局部作用域结束后继续访问 stack-use-after-scope

8. 本实验的核心 ASan 报告

ERROR: AddressSanitizer: stack-use-after-return

意思是:

函数已经返回,但程序仍然访问该函数中生命周期已经结束的栈对象。

报告还指出:

READ of size 4 at 0x73800020 thread T0

READ 表示发生非法读取。

size 4 是因为:

*pointer

解引用的是 int*,当前平台上的 int 为 4 字节。

9. 如何阅读 ASan 报告

建议按固定顺序读。

第一步:错误类型

ERROR: AddressSanitizer: stack-use-after-return

先确定属于哪类问题。

第二步:READ / WRITE 和大小

READ of size 4

判断是非法读取还是非法写入,以及访问了多少字节。

第三步:看第一条属于自己代码的调用栈

#0 ... in main
experiment_b.cpp:26

本实验对应:

std::cout << "value: " << *pointer << '\n';

真正触发非法访问的是 *pointer。

第四步:追踪这块内存原来属于谁

ASan 报告:

Address 0x73800020 is located in stack of thread T0

并指出:

in frame
make_dangling_pointer()

随后:

This frame has 1 object(s):
    [32, 36) 'local_value'

因此证据链非常清晰:

main 解引用 pointer
  ↓
访问 0x73800020
  ↓
地址属于 make_dangling_pointer 的栈帧
  ↓
对应 local_value
  ↓
函数已经返回
  ↓
local_value 生命周期结束
  ↓
stack-use-after-return

10. Shadow Memory

ASan 的核心机制之一是 Shadow Memory(影子内存)。

可以简单理解为:

程序真实内存
+
ASan 维护的一张“内存状态地图”

本实验中出现:

f5

报告图例说明:

f5 = Stack after return

因此当程序访问 pointer 时,ASan 检查对应 Shadow Memory,发现该区域已经属于"函数返回后的失效栈区域",于是立即报告错误。

当前阶段不需要背全部 Shadow Byte,知道几个典型值即可:

00 = Addressable
fd = Freed heap region
f5 = Stack after return
f8 = Stack use after scope

11. Redzone

ASan 会在对象周围设置不可正常访问的保护区域,即 Redzone。

例如:

int data[10];

可以概念化为:

Redzone | data[10] | Redzone

正常访问:

data[0];
...
data[9];

如果访问:

data[10];

越过对象边界进入 Redzone,ASan 就可以检测到 buffer-overflow。

12. ASan 的基本原理

当前阶段记住两个关键词即可:

编译器插桩
+
Shadow Memory

开启:

-fsanitize=address

后,编译器会在大量内存访问位置加入检查逻辑。

概念上:

int value = *p;

会多出类似:

检查 p 对应内存是否合法
  ↓
合法 → 继续读取
非法 → ASan 报告错误

13. ASan 与 UBSan

实验中:

-fsanitize=address,undefined

同时开启了:

address   → ASan
undefined → UBSan

ASan 主要检测内存访问问题,例如:

use-after-free
buffer-overflow
stack-use-after-return

UBSan,即 UndefinedBehaviorSanitizer,主要检测部分未定义行为,例如有符号整数溢出、非法移位、部分对齐错误等。

开发阶段二者经常一起使用:

-fsanitize=address,undefined

14. -g 与 -fno-omit-frame-pointer

-g

保留调试信息,有利于报告显示:

experiment_b.cpp:26
-fno-omit-frame-pointer

保留 Frame Pointer,通常有利于获得更可靠的调用栈。

15. detect_stack_use_after_return

本实验:

ASAN_OPTIONS=detect_stack_use_after_return=1

用于增强函数返回后继续访问栈对象这类问题的检测。

因为函数返回后原栈空间可能很快被复用,ASan 需要额外机制让失效对象保持可检测状态。

16. use-after-scope 与 use-after-return

stack-use-after-scope:

int* p = nullptr;

{
    int value = 42;
    p = &value;
}

std::cout << *p;

函数还没返回,但 value 已经离开自己的作用域。

stack-use-after-return:

int* func() {
    int value = 42;
    return hide_pointer(&value);
}

int* p = func();
std::cout << *p;

整个函数已经返回。

17. Warning、ASan 与调试器

它们不是互相替代,而是互补:

-Wall -Wextra
  ↓
编译阶段发现明显问题

ASan / UBSan
  ↓
运行测试阶段发现动态问题

gdb / core dump
  ↓
分析实际崩溃现场

第一版代码中,编译器已经能警告:

address of local variable returned

第二版通过 hide_pointer() 隐藏数据关系后,ASan 在实际执行 *pointer 时仍然抓到了问题。

18. ASan 是动态检测工具

ASan 只有在测试真正执行到问题路径时才能发现对应错误。

例如:

void bug() {
    int data[10];
    data[100] = 1;
}

如果本次程序运行根本没有调用 bug(),ASan 不会因为代码存在于源文件中就自动报告。

所以实际工程中应组合使用:

编译器 Warning
静态分析
ASan / UBSan
单元测试 / 集成测试
gdb / core dump

19. 本实验最重要的认知

指针只是保存地址,它并不知道该地址上的对象是否仍然存活。

例如:

pointer = 0x73800020
pointer != nullptr

仍然不能推出:

*pointer 合法

本实验:

pointer 保存 0x73800020
  ↓
local_value 生命周期结束
  ↓
pointer 成为悬空指针
  ↓
*pointer
  ↓
Undefined Behavior
  ↓
ASan: stack-use-after-return

所以一个指针能否安全解引用,真正关键的是:

指针是否指向一个生命周期仍然有效的对象,并且当前访问方式是否合法。

20. 面试回答

什么是悬空指针?

悬空指针是指针仍然保存某个对象原来的地址,但该对象生命周期已经结束。例如函数返回局部变量的地址。函数返回后局部变量已经不存在,即使指针非空、地址值没有变化,继续解引用仍属于未定义行为。

什么是 ASan?

ASan 是 AddressSanitizer,是 GCC、Clang 支持的运行时内存错误检测工具。它主要通过编译器插桩和 Shadow Memory 检测非法内存访问,例如 heap-use-after-free、buffer-overflow、stack-use-after-return 等。开发阶段可以通过 -fsanitize=address 开启,并搭配 -g 和 -fno-omit-frame-pointer 方便定位调用栈。

ASan 报告通常怎么看?

先看错误类型,然后看 READ/WRITE 和访问大小,再看第一条属于自己代码的调用栈定位触发位置,最后看 ASan 给出的对象来源和内存区域信息,判断这个地址为什么已经无效。

21. 核心结论

  1. 返回局部变量地址会产生生命周期问题。
  2. 悬空指针可能仍然保存一个看起来完全正常的地址。
  3. pointer != nullptr 不代表可以安全解引用。
  4. 解引用悬空指针属于未定义行为。
  5. 未定义行为不能根据一次运行结果推导固定规律。
  6. 编译器 Warning 可以发现部分明显问题。
  7. ASan 可以在运行阶段检测实际发生的非法内存访问。
  8. stack-use-after-return 表示函数返回后继续访问原栈对象。
  9. ASan 的两个核心概念是编译器插桩和 Shadow Memory。
  10. ASan 属于动态检测工具,测试覆盖不到的路径通常也检测不到。

22. 后续实验

可以继续验证三个典型错误。

heap-use-after-free

int* p = new int(42);
delete p;
std::cout << *p << '\n';

heap-buffer-overflow

int* data = new int[10];
data[10] = 42;
delete[] data;

stack-use-after-scope

int* pointer = nullptr;

{
    int value = 42;
    pointer = &value;
}

std::cout << *pointer << '\n';

把这三个实验跑完后,ASan 最常见的几类内存错误就基本串起来了。