一个四十年还没解决的问题:C++ 内存安全技术发展史
2022 年,美国国家安全局(NSA)发了一份文件,点名建议「别再用 C/C++ 写新代码」。理由是:微软和谷歌各自统计发现,自家产品里约 70% 的安全漏洞是内存安全问题。可 C++ 明明为内存安全折腾了四十年——RAII、智能指针、Valgrind、Sanitizer、FORTIFY、各种注解……为什么还是被点名?这篇文章把这四十年的技术一件件摆出来,你会看到一条清晰的主线:C++ 的内存安全,是一部「打补丁」史——每一块补丁都堵住了一个洞,但它们有一个共同的命门。
众所周知广泛应用于需要极致 C++ 性能的场景,但是它的安全性问题也一直被人诟病。这个问题并非没人想解决。这些年来我用过 Valgrind、gperftools、Sanitizers、_GLIBCXX_DEBUG、strcpy_s、FORTIFY、各种代码检查工具,也确实被它们救过。但是 C++ 的安全问题依然没有彻底解决,并且还在发展之中,本文就从头盘点一下 C++ 内存安全技术的发展历史。
引子:当 NSA 开始劝你别用 C++
先把那份「判决书」说清楚。
2022 年 11 月,NSA 发布了一份《Software Memory Safety》信息简报,白纸黑字建议组织从 C/C++ 这类「内存不安全」语言迁移到内存安全语言(点名了 Rust、Go、C#、Java、Swift 等)。紧接着 2023 年 CISA(美国网络安全和基础设施安全局)推出《内存安全路线图》,2024 年 2 月白宫国家网络总监办公室(ONCD)更是发了篇《回到基石》报告,几乎是在国家层面把「内存安全」定成了软件安全的头等议题。
它们引用的核心数据来自微软和谷歌各自的独立统计:产品线里大约 70% 的高危漏洞,根子都是内存安全问题——缓冲区溢出、释放后使用(use-after-free)、越界读写这些。不是逻辑 bug,就是这几类「C/C++ 允许你干、但你不该干」的底层内存错误。
这份判决刺痛了整个 C++ 社区,但也逼出一个诚实的问题:C++ 为内存安全做的努力,一点都不少,怎么就不够? 要回答它,得先弄清「内存安全」到底包含哪几件事。
内存安全的全景:不是一个问题,是一类问题
「内存安全」是个筐,里面装着好几种性质不同的错误。把它们分清楚,后面每个技术在解决什么才看得明白:
| 类别 | 典型错误 | 一句话 |
|---|---|---|
| 空间安全(spatial) | 缓冲区溢出、数组越界、野指针解引用 | 访问了「不该访问的地址」 |
| 时间安全(temporal) | 释放后使用(UAF)、重复释放、悬垂指针 | 访问了「已经不该存在的对象」 |
| 初始化安全 | 读未初始化内存 | 读到了「还没写过的值」 |
| 资源泄漏 | 内存泄漏、句柄泄漏 | 该还的没还(严格说不算「不安全」,但同源) |
| 线程安全 | 数据竞争(data race)、撕裂读写 | 多个线程「同时踩同一块内存」 |
这五类里,空间安全和时间安全是漏洞的重灾区(NSA 那 70% 主要就是它们);泄漏更多是稳定性问题;线程安全在多核时代单独成了一座大山。后面你会看到,C++ 的技术演进基本是沿着这几条线分头推进的——有的语言层面根治,有的运行时兜底,有的编译期拦截。
下面按「四条路」来讲:语言抽象、运行时检测、编译器/库加固、以及最新的「默认安全」之争。它们大致也是按时间顺序叠上来的。
第一条路:用抽象,让你根本没机会犯错
最治本的思路是:把容易出错的手动操作,藏进一个不会出错的抽象后面。 你不写 malloc/free、不碰裸指针、不手算数组长度,自然就不会在这些地方出错。这是 C++ 相对于 C 最大的底气,也是 Bjarne Stroustrup 一以贯之的主张。
RAII:C++ 内存安全的地基
RAII(Resource Acquisition Is Initialization,资源获取即初始化) 是 Stroustrup 在 C++ 早期就确立的核心惯用法,也是后面几乎一切的地基。思想极简:把资源的生命周期绑定到一个栈对象的生命周期上——构造时获取,析构时释放。对象离开作用域,编译器保证调用析构,资源自动归还,哪怕中途抛异常也不漏。
1
2
3
4
{
std::lock_guard<std::mutex> lk(mtx); // 构造即加锁
// ... 中间随便 return / throw
} // 析构即解锁,绝不漏
RAII 把「记得释放」这件靠人自觉的事,变成了编译器强制的机械保证。C++ 里一切「自动管理」的东西——容器、字符串、智能指针、锁守卫——本质都是 RAII。
容器与 string:干掉裸数组和 char*
内存安全最古老的伤口,是 C 风格的裸数组和 char* 字符串:长度要自己记、边界要自己查、strcpy 一不小心就溢出。C++ 的答案是用 STL 容器(std::vector 等)和 std::string 取而代之——它们自己管理内存、自己记长度、自动增长,strcpy 溢出这类经典漏洞在 std::string 的世界里根本不存在。
「能用 vector/string 就别用裸数组」是 C++ 内存安全最朴素也最有效的一条准则——可惜遗留代码里裸数组遍地都是,这也正是问题至今没根除的原因之一。
智能指针:把「谁来 delete」写进类型
裸指针的两大原罪——忘了 delete(泄漏)和delete 之后还用(UAF)——催生了智能指针,用 RAII 管理堆对象的所有权:
auto_ptr(C++98):第一次尝试,但它的「拷贝即转移所有权」语义反直觉又危险,是公认的反面教材,C++11 弃用、C++17 移除。unique_ptr(C++11):独占所有权,move-only,零开销,是今天的默认选择。shared_ptr(C++11,源自 Boost/TR1):引用计数的共享所有权,计数归零自动释放。weak_ptr:配合shared_ptr打破循环引用——引用计数天生治不了环,weak_ptr就是那把剪刀。
1
2
auto p = std::make_unique<Widget>(); // 独占,出作用域自动 delete
auto s = std::make_shared<Config>(); // 共享,计数归零才 delete
「用智能指针表达所有权」——现代 C++ 里几乎不该再出现裸 new/delete。但要注意:智能指针治的是所有权/生命周期,治不了悬垂引用(你完全可以拿一个指向已析构对象的裸引用或 .get() 指针)——这个洞留到后面「默认安全」那节才有人认真去堵。
optional 与 variant:把「空」和「多态」也变类型安全
两个更年轻的抽象,堵的是另一类经典坑:
std::optional(C++17):取代「用空指针 /-1/ 魔法值表示『没有』」的老套路。「可能没有值」被编码进类型,逼你在用之前先判断,从源头上减少了空指针解引用。std::variant(C++17):类型安全的联合体,取代裸union。裸union是内存安全的老雷区——你可以按一个类型写、按另一个类型读,variant记住「当前是哪个类型」,用std::get读错类型会抛std::bad_variant_access异常,而不是给你一段乱码内存。
它们的共同点,和前面一脉相承:把「运行时才会爆的错误」搬到「类型系统里」去表达,让编译器帮你盯着。
领域自建 GC:当所有权关系太复杂
有些场景对象关系是一张复杂的图(游戏世界里成千上万互相引用的实体),手工或引用计数都力不从心,于是有人在 C++ 里自建垃圾回收。最有名的是 Unreal Engine 的 UObject 系统:所有 UObject 派生类通过 UPROPERTY 反射登记引用关系,引擎跑一个标记-清除(mark-and-sweep)的 GC 定期回收不可达对象。COM / ATL 的 CComPtr、很多脚本引擎的绑定层,也都是各自领域的「自建生命周期管理」。
值得一提的是,C++ 标准本身也认真尝试过拥抱 GC:C++11 加入了一组「最小垃圾回收支持」API(std::declare_reachable、std::pointer_safety、std::get_pointer_safety 等,源自 Hans Boehm 等人的提案 N2670),本意是给可选的垃圾回收器和「基于可达性的泄漏检测」留出标准钩子。但几乎没有任何实现真正用它,最终在 C++23 被整个移除(提案 P2186)。这段「引入又删除」的插曲本身就说明问题:GC 从没能成为 C++ 的主流方向——社区用脚投票,选了 RAII + 智能指针这条「确定性析构」的路。
这说明一件事:C++ 没有内置 GC,但它给了你造 GC 的自由。 代价是每个大项目都在重新发明一套生命周期管理,彼此不通用——这种「灵活但各自为政」的气质,贯穿 C++ 的方方面面。
线程安全:把锁也 RAII 化
多核时代,数据竞争成了内存安全的新前线。C++ 的应对同样是 RAII + 类型:
std::lock_guard/std::scoped_lock(C++17)/unique_lock:把加锁解锁绑进作用域,绝不漏解锁、异常安全。scoped_lock还能一次锁多个互斥量并避免死锁。std::atomic(C++11):无锁原子操作,从语言层面提供跨线程可见性保证。- C++11 内存模型:这是里程碑——C++ 第一次在标准里定义了多线程语义,明确「数据竞争是未定义行为」,也给了
atomic的 memory order 一套严谨语义。在此之前,多线程全靠平台约定和运气。 std::jthread(C++20):自动 join 的线程,又是一次 RAII 化。
但线程安全的难处在于:RAII 能保证「锁被释放」,却保证不了「你在该加锁的地方加了锁」。 漏加锁、加错锁导致的数据竞争,纯语言层面拦不住。这个洞有两层补法:
编译期:Clang Thread Safety Analysis。 Clang 提供一套线程安全注解(源自 Google,Chromium / abseil 大量在用):用
GUARDED_BY(mu)标明「这个字段必须持有mu才能访问」,用REQUIRES(mu)/EXCLUDES(mu)标明函数对锁的要求,把互斥量建模成一种「能力(capability)」。开-Wthread-safety后,编译器就能在编译期静态查出「访问了GUARDED_BY的字段却没持有锁」这类错误——零运行时开销,还能拦住测试根本没跑到的路径。1 2 3 4 5 6 7
class BankAccount { std::mutex mu_; int balance_ GUARDED_BY(mu_); // 声明:碰 balance_ 必须持 mu_ void deposit(int n) { balance_ += n; // 编译告警:没加锁就动 balance_ } };
运行时:ThreadSanitizer。 注解要人去标,也覆盖不了所有模式;标不到、查不出的漏网数据竞争,再靠 TSan 在运行时抓现行(见下文)。
编译期静态查 + 运行时动态抓,两层配合,才把「漏加锁」这个洞尽量堵严。
一个必须澄清的误区:回到 C 不是退路
每逢内存安全被拿出来说事,总有一种声音:「C++ 太复杂、坑太多,不如退回 C,简单就是安全。」这是把「特性少」错当成「更安全」了——恰恰相反,绝大多数情况下 C 比 C++ 更不安全。
道理就在这一整节里:C++ 用来提升安全的那些抽象,C 一样都没有——
- 没有
std::string/std::vector,只能裸char*+ 裸数组,长度自己记、边界自己查,strcpy溢出是家常便饭; - 没有 RAII 和智能指针,资源全靠手动
malloc/free配对,漏一个就泄漏、错一个就 UAF; - 没有
optional/variant、没有模板带来的类型安全,void*和裸union满天飞; - 没有构造函数保证初始化、没有析构函数保证清理。
也就是说,C 把 C++ 好不容易建起来的每一道防线都拆光了,只剩最原始的裸内存操作。 「简单」是真的,「安全」恰恰相反。
唯一值得一提的例外,是 C++ 的 lambda 按引用捕获([&]):闭包捕获了一个局部变量的引用,一旦闭包的生命周期超过了那个变量,就成了悬垂引用——这类坑 C 反而没有(因为 C 压根没有闭包)。但这是极少数 C++ 比 C 多出来的陷阱,拿它来论证「C 更安全」是以偏概全。
顺带澄清一个常被问到的问题:那已发现的漏洞里,C 和 C++ 各占多少? 老实说,很难干净地分开,而且不同口径结论还会打架。 微软的「70% CVE 是内存安全问题」、Chromium 的 70%、Android 曾高达约 76%,统计时都是把 C 和 C++ 合在一起算的;CVE/NVD 数据库里根本没有「实现语言」字段。少数把两者分开统计的(如 Mend 的开源漏洞报告)显示:按总量 C 遥遥领先(约占一半),但按高危漏洞占比 C++ 反而略高于 C——而这些数字都被「C 存量代码最老最多」严重干扰,也没按代码量归一化,说明不了「谁天生更危险」。所以任何「C 占 X%、C++ 占 Y%」的精确拆分,都别太当真。
还得泼一盆冷水在一个流行说法上——「C++ 的内存漏洞主要出在它继承自 C 的那部分(裸指针、
strcpy)」:这话只对了一半。 C 子集确实是重灾区,但现代 C++ 特性会制造自己的内存 bug:string_view/span悬垂、迭代器失效、[&]捕获悬垂,以及占比最高的 use-after-free——很多都发生在智能指针和引用之间,而非裸指针。连 Herb Sutter 都坦承,没有数据能证明「光靠 RAII + 智能指针」就足够安全。这恰恰把界线指向了本文的终点:真正划分安不安全的,不是「C 还是 C++」,甚至不全是「老写法还是新写法」,而是「安全有没有被默认强制」——也就是下一节 Profiles / Safe C++ 要啃的硬骨头。
结论呼应本文的大主线:解决内存安全靠的是「往前走」——加更强的抽象、更严的检查、更安全的默认值;而不是「往回退」,把已有的防线一并拆掉。 后面要讲的 Rust、Safe C++,全都是「向前」的方向。
抽象能防住「新写的规范代码」,但拦不住已经写下的 bug 和遗留代码。于是另一条路是:让程序带着「监工」跑起来,一旦碰内存就当场抓现行。
Purify:鼻祖
这条路的祖师爷是 Rational Purify(1991 年,Pure Software,作者之一 Reed Hastings——对,后来创立 Netflix 那位)。它用目标码插桩,在每次内存访问前后插入检查,抓越界、UAF、未初始化读、泄漏。它几乎是凭一己之力开创了「内存排错工具」这个品类,在 1990 年代是 C/C++ 开发者的标配神器。
它更深远的贡献是那套核心思想:给每一块内存维护一份「状态」(已分配 / 已释放 / 已初始化 / 不可访问),每次访问都对照状态检查。 这个「影子状态」的思路,正是后来 Valgrind 的 V-bits、以及 AddressSanitizer 的影子内存(shadow memory)的直接祖先——三十年后的现代工具,骨子里还在用 Purify 立下的范式。
Electric Fence 一族:用虚拟内存当陷阱
一个更轻巧的思路是利用 MMU 硬件:Electric Fence(efence,Bruce Perens) 把每次分配单独放一页,并在紧邻处放一个不可访问的保护页——一旦越界,直接触发段错误,调试器停在犯罪现场那一行,而不是等到很久以后堆损坏才崩。同族的还有 DUMA、macOS 的 Guard Malloc(libgmalloc)、Windows 的 PageHeap / Application Verifier。特点是精准定位越界的那一刻,代价是内存开销巨大(每次分配至少占一页)。
Valgrind:不用重编译的重武器
Valgrind(2002,Julian Seward) 是划时代的:它基于动态二进制翻译,把程序跑在一个合成 CPU 上,不需要重新编译就能上,核心工具 Memcheck 能抓几乎所有内存错误——越界、UAF、未初始化读、泄漏,一网打尽。代价是慢,约 20~30 倍。但「拿来就能用、什么都能抓」让它成了二十多年来的事实标准,直到 Sanitizer 出现才被部分取代。
Debug 迭代器:STL 自带的安检
STL 用错了同样会内存越界(迭代器失效、越界下标)。于是标准库实现自带了「调试模式」,给容器和迭代器加运行时检查:
- libstdc++:
-D_GLIBCXX_DEBUG - MSVC STL:
_ITERATOR_DEBUG_LEVEL(取代了老的_SECURE_SCL/_HAS_ITERATOR_DEBUGGING) - libc++:并入了后面要讲的 hardening 模式
打开后,vector::operator[] 越界、用失效的迭代器、给算法传错区间,都会当场报错而不是给你一段未定义行为。调试构建默认开、发布构建默认关——又是一次「安全 vs 性能」的取舍。
gperftools:顺带管泄漏
Google 的 gperftools 以 tcmalloc(高性能内存分配器)为核心,捆了一套 heap profiler / heap checker——能画出「内存都被谁吃了」,也能在程序退出时报告泄漏。它和 Valgrind 的泄漏检测互补:开销小得多,适合挂在长跑的服务上。
Sanitizer:编译器插桩,现代主力
2011 年前后,Google 的一批工程师(Konstantin Serebryany 等)在 LLVM 里搞出了 AddressSanitizer(ASan),彻底改变了格局。它不再靠二进制翻译,而是在编译期插桩——编译器在每次内存访问前插入检查,配合「影子内存(shadow memory)」和「红区(redzone)」记录每块内存的可访问状态。只慢约 2 倍(Valgrind 是几十倍),精度还更高。一家人越来越壮:
| Sanitizer | 抓什么 | 基本原理 | 大致开销 |
|---|---|---|---|
| ASan | 越界、UAF、重复释放(空间+时间) | 影子内存(每 8 字节配 1 字节影子记状态)+ 分配两侧插「红区」+ 释放后进「隔离区」延迟复用 | CPU ~2×,内存 ~2–3× |
| LSan | 内存泄漏 | 程序退出时做一次可达性扫描(类似保守式 GC),扫不到的已分配块即泄漏 | 近乎零(只在退出时跑) |
| TSan | 数据竞争 | 影子内存记录每次访问的历史 + 向量时钟推导 happens-before 关系 | CPU ~5–15×,内存 ~5–10× |
| MSan | 读未初始化内存 | 逐位影子记录「初始化没」,并随计算传播这个「脏」状态 | CPU ~3×;要求所有代码(含依赖库)都插桩 |
| UBSan | 各类未定义行为(整数溢出、错位、空指针解引用……) | 在特定运算处内联插入轻量检查,就地判断 | 低(视开启的检查项而定) |
| HWASan | 同 ASan | 借 ARM「地址高位忽略(TBI)」把 tag 塞进指针高位,与内存 tag 比对 | 内存远低于 ASan,CPU 适中;主要限 ARM64 |
一句话概括它们的共性与差异:ASan/TSan/MSan/HWASan 都靠「影子内存」记录每块内存的额外状态,区别只在记的是什么(可访问性 / 访问历史 / 初始化状态);UBSan 不用影子,而是就地插检查;LSan 最轻,只在退出时扫一遍。开销上 TSan 最重、UBSan/LSan 最轻,也决定了用法:ASan/UBSan 适合天天挂 CI,TSan 一般单独针对并发代码专门跑。
Sanitizer 今天是 C++ 内存排错的绝对主力,-fsanitize=address,undefined 几乎是 CI 标配。但注意它的定位:它是「测试期动态检测」,不是「生产期防护」——你得让测试真的覆盖到出错的路径,它才抓得到。没跑到的代码,它一无所知。这是所有动态检测的共同天花板。
模糊测试(Fuzzing):自动喂出「会让它崩」的输入
上面那个天花板——「得先跑到」——正是 Fuzzing(模糊测试) 要解决的。思路是自动生成海量畸形 / 随机输入去猛灌程序,撞出崩溃和异常。这个词是 Barton Miller 1988 年在威斯康星大学提出的,早期就是「盲目乱灌」(dumb fuzzing),效率有限。
真正的飞跃是覆盖率引导(coverage-guided):AFL(American Fuzzy Lop,Michał Zalewski,2013) 用编译期插桩记录每个输入走过的代码路径,再用类似遗传算法的思路留下能探到新路径的输入、继续变异,像进化一样一步步钻进程序深处。libFuzzer(LLVM,进程内)是同思路的库化版本。
Fuzzing 和 Sanitizer 是天作之合:Fuzzer 负责「找到会触发 bug 的输入」(广度),Sanitizer(ASan/UBSan/MSan)负责「把那一刻的内存错误精确抓出来」(精度)——一个探路、一个定罪。这套组合是今天挖内存漏洞的主力打法。Google 更把它工业化成了 OSS-Fuzz(2016):给几百个重要开源项目挂上 7×24 的持续 fuzzing,累计找出了数以万计的漏洞(其中大量是内存安全问题)。同类还有社区维护的 AFL++、Google 的内核 fuzzer syzkaller、honggfuzz 等。
Fuzzing 补的正是 Sanitizer「得跑到」的洞,但它也不是万能:它擅长「有明确输入入口」的解析器、编解码器、协议、文件格式,对那些需要复杂前置状态或特定业务逻辑才能触发的深层路径,依旧力不从心。
第三条路:编译器与库加固——静态拦截 + 运行时兜底
第三条路不改你的代码结构,而是让编译器和标准库更聪明地帮你挡:有些在编译期就报警,有些悄悄插入运行时检查。
注解:把「契约」告诉编译器
编译器默认不知道你的函数「不接受空指针」「返回值必须用」。注解就是把这些契约标出来,让编译器帮你静态检查:
- GCC/Clang 属性:
__attribute__((nonnull))(参数非空)、__attribute__((warn_unused_result))(返回值必须用,比如别忽略错误码)、__attribute__((cleanup))(给 C 也加个 RAII)、__attribute__((malloc))。 - 标准 attribute:
[[nodiscard]](C++17,warn_unused_result的标准版)。 - 微软 SAL(Source-code Annotation Language):一套丰富的注解(
_In_、_Out_、_When_、缓冲区大小标注_In_reads_等),描述参数的方向、可空性、缓冲区大小,喂给 MSVC 的静态分析器(/analyze)去查越界和空指针。SAL 是注解方向做得最系统的一个,Windows 头文件里铺得到处都是。
注解的价值是把人脑里的隐含约定变成机器可检查的约束,缺点是要人去标——遗留代码不会自己长出注解。
安全函数:给危险 API 加个「带长度」的版本
针对 strcpy/sprintf/gets 这些「不带边界」的危险函数,微软搞了一套安全 CRT——strcpy_s、sprintf_s 等,强制传入目标缓冲区大小,越界就触发处理程序。它后来被提进 C11 标准的 Annex K(Bounds-checking interfaces)。
但这里有段失败史值得记:Annex K 基本被非微软阵营抵制了。glibc 从没实现它;WG14 的报告 N1967《Field Experience With Annex K》直言其 API 设计有缺陷、几乎没有可移植的实现、真实世界采用寥寥,建议从标准里删掉。所以今天你在 Windows 上看到满屏 _s 函数,换到 Linux 上却基本用不了——这是一次「好意但设计欠佳、又没跨平台共识」的标准化尝试的教训。 微软自己还提供了 banned.h(把危险函数标成编译错误)和 strsafe.h 作为配套。
FORTIFY_SOURCE:编译期算得出大小,就替你插检查
一个很巧的思路:很多缓冲区的大小,编译器其实在编译期就能算出来。glibc 的 _FORTIFY_SOURCE 就利用这点——用 -D_FORTIFY_SOURCE=2 -O2 开启后(它必须配合优化,因为要靠优化器算缓冲区大小),编译器把 memcpy、strcpy、sprintf 等改写成带检查的 __memcpy_chk、__strcpy_chk 变体,用 __builtin_object_size 算出目标缓冲区的大小,运行时一旦发现要写超了就立即中止,而不是让它默默溢出。
1
2
3
char buf[10];
strcpy(buf, src); // 开 _FORTIFY_SOURCE 后 → __strcpy_chk(buf, src, 10)
// src 超过 10 字节?直接 abort,而非溢出
它的妙处是几乎零成本、对源码零侵入——重编一下就有,能算出大小的地方自动加保护,算不出的地方原样放行。_FORTIFY_SOURCE 有 1/2/3 三档,级别越高检查越严。这是发行版默认给系统软件加的一道「免费护栏」。
系统级加固:CPU + OS + 链接器联手,让漏洞「打不穿」
还有一大类技术不去「消除」漏洞,而是让漏洞即使存在也难以被利用成功——它们是一整套「利用缓解(exploit mitigation)」:CPU 提供硬件机制,操作系统 / 加载器 / 编译器协同启用。这是内存安全里最依赖硬件的一层,值得专门看看「谁提供、谁管理、谁受益」的配合关系:
不可执行 + 地址随机化(地基两件套):CPU 提供 NX / XD / XN 位(页面「不可执行」),OS 据此把数据页标成不可执行,就是 DEP / W^X——注入的 shellcode 跑不起来。同时 OS 做 ASLR(配合 PIE,随机化栈、堆、库和可执行文件基址)让攻击者猜不到地址,内核那份叫 KASLR。这两样是 1990 年代末以来现代系统的默认底座。(ASLR 依赖 PIE,PIE 又牵涉重定位——见《链接器发展简史》 里的 RELR 一节。)
- 守住控制流——前向边与后向边:溢出最经典的目的是改返回地址或劫持函数指针,于是有一整套护栏:
- 后向边(返回地址):编译器插栈金丝雀(canary,StackGuard,Crispin Cowan 1998 / ProPolice 的思路,随机值由 OS 经
AT_RANDOM提供,-fstack-protector-strong是今天常规);更硬的是 Intel CET 影子栈(CPU 另存一份返回地址副本、返回时比对,Windows 叫「硬件强制堆栈保护」)和 ARM PAC(指针认证)(用密钥给指针签名,改一下就失配)。 - 前向边(间接调用 / 跳转):软件方案是 Clang 的
-fsanitize=cfi;硬件方案是 Intel CET 的 IBT(ENDBRANCH落地点)、ARM BTI 和 Windows 的 CFG / XFG(控制流保护)——限制间接跳转只能落到合法目标,堵死「跳进 gadget 中段」。这些都要 CPU 出指令、编译器插标记、OS/加载器标页三方配合。
- 后向边(返回地址):编译器插栈金丝雀(canary,StackGuard,Crispin Cowan 1998 / ProPolice 的思路,随机值由 OS 经
内核 / 用户隔离:Intel SMEP/SMAP、ARM PXN/PAN——CPU 禁止内核态直接执行或读写用户态内存,OS 打开开关。这堵死了「内核漏洞跳去执行用户态预布好的代码/数据」这一大类提权套路。
加载期锁死入口:链接器 + 加载器合作的 RELRO(Relocation Read-Only)+ BIND_NOW,在启动时把 GOT 等元数据解析完就设为只读,堵掉「改 GOT 劫持函数调用」的经典手法。还有内存保护键(Intel MPK/PKU、ARM POE),让同一进程内划出多个保护域,做轻量的进程内隔离。
- 内存标签与能力(最接近治本的硬件方向):ARM MTE(内存标签扩展) 给每 16 字节内存和每个指针打 4 位 tag,访问时硬件比对、不匹配就异常——相当于把 ASan 做进硬件、开销低到能上生产;但它要 OS 内核处理 tag 异常 + 分配器给内存打 tag 才跑得起来。Pixel 8(2023) 是首款把 MTE 开放给用户的量产机型,不过目前默认关闭、需在开发者选项里手动打开(约 5% 开销)。更彻底的 CHERI(剑桥大学与 SRI International 提出)用「能力指针」把边界和权限焊进指针本身,从硬件强制空间与时间安全,ARM 据此做出了 Morello 原型板(配 CheriBSD)。
要分清两件事:Sanitizer / Fuzzing 是「找出 bug」(开发期),这一层是「就算有 bug 也别被打穿」(生产期)。 它们不修复漏洞,只层层抬高利用门槛——和真正消除漏洞的抽象、检测互补,共同兜底那些漏网之鱼。
静态分析:不运行也能查
还有一整路是静态分析——不跑程序,直接分析代码找可疑模式。它和 Sanitizer 正好互补:Sanitizer 要「跑到」才抓得到,静态分析不依赖执行、能覆盖测试跑不到的路径(当然代价是误报)。这条路上工具很多,从轻到重、从免费到商业:
- 编译器自带的告警:
-Wall -Wextra(乃至-Werror)是最该先开、最便宜的一道静态防线,能拦住一大批未初始化、比较符号错误、可疑类型转换等问题。GCC 从 10 起还内置了专门的-fanalyzer(做符号执行式的深度分析,查 UAF、双重释放、泄漏),MSVC 则有/analyze(配合前面说的 SAL 注解)。 - Clang Static Analyzer:Clang 的路径敏感分析器,用符号执行沿各条路径推演,能查空指针解引用、UAF、内存泄漏、未初始化读等。它看的是「跨语句的执行路径」,比单条规则强,但也更慢。
- clang-tidy:基于 Clang AST 的可扩展 linter,拥有成百上千条可开关的检查,按类分组特别适合内存安全:
bugprone-*(易错模式)、cppcoreguidelines-*(直接对应 Core Guidelines,如「别用裸new」「用gsl::span」)、clang-analyzer-*(复用上面的静态分析器)、concurrency-*(并发问题)等。它能融进 CI 和编辑器,是今天团队里落地最广的一个,还能自动修复(--fix)部分问题。 - cppcheck:老牌开源静态分析器,独立于编译器、上手快,越界/泄漏/未初始化都查,常作为 clang-tidy 的补充。
- Facebook Infer:跨过程(interprocedural)分析,擅长查跨函数的空指针和资源泄漏,规模化能力强。
- 商业重器:Coverity、PVS-Studio、Klocwork 等,分析更深、跨过程跨模块能力更强、误报控制更好,是大型/关键项目常用的选择。
静态分析的共同软肋是误报(假阳性)——规则越激进,噪音越大;以及对复杂跨函数、跨模块的场景力不从心(指针到底指向谁,静态推断天生有极限)。所以现实里的最佳实践是分层叠加:编译器告警打底 → clang-tidy 进 CI → 关键项目再上商业工具,和动态的 Sanitizer 一起,形成「静态 + 动态」的双保险。
AI 加持:让机器像审查者一样读代码
近两年最新的变量,是 AI(大语言模型) 进入了这条链路。它不像传统静态分析那样死磕规则,而是能像人类审查者一样「读懂」代码的意图,因此在几个方向上补强了前面的手段:
- AI 代码审查 / 分析:GitHub 的 Copilot 代码审查 / Copilot Autofix,以及各类接入大模型的安全审查工具,能在 PR 阶段指出可疑的空指针、越界、生命周期问题,甚至直接给出修复补丁。它对「这段代码到底想干嘛」「跨函数的意图」的理解,往往比纯规则的静态分析更接近人的判断,给出的解释也更好懂。
- AI 驱动的 Fuzzing:写 fuzz 目标(harness)一直是苦力活,Google 的 OSS-Fuzz-Gen 用大模型自动生成 fuzz 目标,大幅降低了给一个新项目接上 fuzzing 的门槛——相当于把上一节的 Fuzzing 也自动化了。
- AI 漏洞挖掘 agent:更前沿的是让大模型像安全研究员一样自主分析代码找漏洞。Google 的 Big Sleep(前身 Project Naptime)在 2024 年底用这种方式,在 SQLite 里挖出了一个真实、可利用的 0-day,被视为「AI agent 找到真实漏洞」的标志性案例。
但要清醒:AI 现在是「加速器」,不是「替代品」。 它会「幻觉」出并不存在的 bug、也会漏掉真的 bug,产出必须经人或其他工具复核。把它当成「读得快、覆盖广、会解释的初审」,再压上 Sanitizer、Fuzzing、静态分析和人工把关,才是当下靠谱的用法。
硬化标准库:给容器加「生产级」检查
前面的 debug 迭代器太重、只适合调试。近年 libc++ 引入了分级的 hardening 模式(_LIBCPP_HARDENING_MODE,取代旧的 _LIBCPP_ENABLE_ASSERTIONS):从 fast(低开销、只查最关键的越界)到 debug(全量检查),让你能在发布构建里也开一档轻量边界检查。配合 C++20 的 std::span(带长度的数组视图)、C++17 的 std::string_view、以及一直都在的 at()(带边界检查的下标),标准库这些年在「默认更安全」上明显在使劲。
第四条路(进行时):把「默认安全」写进语言
走到这里,你应该已经看出那个共同的命门了:前面所有技术,几乎都是 opt-in(可选加入)的。
RAII 你得主动用、智能指针你得主动选、Sanitizer 你得主动开、注解你得主动标、FORTIFY 你得主动编译。只要有人写一行裸指针、裸数组、漏一个锁——洞就在那儿。 而 Rust 的杀手锏恰恰相反:默认安全,不安全要显式写 unsafe。 NSA 点名 C++ 的,根本不是「C++ 没有安全工具」,而是「C++ 默认不安全」。
于是最新的战场,是把安全变成默认。而把这个议题从「C++ 圈内的自我改进」推成「关乎存亡的紧迫大事」的,是一个外部变量——Rust。
Rust 的冲击:安全不再需要牺牲性能
C++ 社区几十年里有一个心照不宣的信念:「内存安全是有代价的——要么上 GC(像 Java),要么忍受运行时检查开销。C++ 选择性能,所以只能默认不安全。」 这个信念是 C++「默认不安全」最大的挡箭牌。
Rust(2015 年 1.0)把这块挡箭牌打碎了。 它用所有权 + 借用检查器(borrow checker),在编译期就根治了时间安全和数据竞争(几乎零开销);空间安全(数组越界)则靠编译期分析 + 少量运行时边界检查兜底(实测通常是个位数百分比的开销)。整个过程既没有 GC,性能又和 C++ 同档。它的模型和 C++ 正好反过来:默认安全,你想干危险的事必须显式写 unsafe 把它圈起来。 这一下就证明了:「安全」和「高性能」不是二选一——是 C++ 过去的设计没做到,而不是做不到。
比「证明可行」更有杀伤力的是真实战果:
- Google Android:随着新代码转向 Rust 等内存安全语言,内存安全漏洞占比从 2019 年的约 76% 一路降到 2024 年的约 24%;而且报告称 Rust 写的新代码里内存安全漏洞几乎为零——用数据说明了「换语言」比「打补丁」见效快得多。
- 落地版图:Linux 内核自 2022 年起接纳 Rust;微软在用 Rust 重写 Windows 部分组件;Android、Chromium、AWS、Cloudflare 纷纷把关键路径迁往 Rust。
- 政府背书:前面 NSA、CISA、白宫的文件里,Rust 几乎是被点名推荐的头号「内存安全语言」。
这才是真正的压力来源。在 Rust 之前,「不安全但快」的 C++ 没有对手,那句挡箭牌无人能反驳;Rust 之后,存在一个可信的、安全的、无 GC 的、同样高性能的替代品,还带着国家背书——「Rewrite it in Rust」从一句玩笑变成了很多团队的真实决策。
正是这股冲击,逼出了 C++ 下面这两条自救路线——它们本质上都是在回答同一个问题:C++ 能不能在不背叛自己(性能、向后兼容)的前提下,也做到「默认安全」?
路线一:Core Guidelines + Profiles(渐进加固)
Stroustrup 和 Herb Sutter 2015 年推出 C++ Core Guidelines,配套 GSL(Guidelines Support Library) 提供 gsl::span(→ 已进标准成 std::span)、gsl::not_null 等类型,还有一个雄心勃勃的 Lifetime profile——想靠局部静态分析在编译期抓悬垂指针。
近年 Stroustrup/Sutter 力推 Profiles(一组可开关的安全子集,如类型安全、边界安全、生命周期安全 profile):思路是不改语言、不搞大重写,通过分级开启的规则和检查,让现有代码渐进地变安全。卖点是兼容存量代码。
路线二:Safe C++(引入借用检查器)
另一派认为渐进加固不够彻底。Sean Baxter(Circle 编译器作者)2024 年提出 Safe C++(提案 P3390):直接给 C++ 移植一套 Rust 式的借用检查器(borrow checker),引入 safe/unsafe 上下文和一套安全标准库 std2,目标是可证明的内存安全,而非「尽量少出错」。它更彻底,但代价是要引入一套新方言和新标准库,和存量代码的融合是巨大挑战。
两条路线之争,本质是 C++ 的灵魂之问:是守住向后兼容、渐进打补丁(Profiles),还是壮士断腕、引入借用检查换取真正的安全保证(Safe C++)?截至目前委员会更倾向前者,但争论远未结束。而背景音里,是 Rust 的持续施压和各国政府的 memory-safety mandate——这一次,外部压力是真的大。
最新前沿:这两三年正在发生的
前面四条路是主干,最后补一组这两三年才真正落地或成型的新东西——它们几乎都在做同一件事:把过去「可选」的安全,往「默认打开」再推一步。
默认初始化,消灭「未初始化读」:编译器新增
-ftrivial-auto-var-init=zero/pattern,自动把栈变量清零或填入毒值,Linux 内核、Android、Chrome 都已默认开启。C++26 更进一步引入 「erroneous behavior(错误行为)」(P2795):读未初始化变量不再是纯粹的未定义行为,而是「有明确定义的错误行为」,可被工具稳定诊断——从语言层面给这一类 bug 判了刑。生产级、低开销的检测与缓解:Sanitizer 太重、上不了线上,于是有了 GWP-ASan——采样式的堆错误检测器,开销低到能挂在真实用户流量上,靠概率长期捞 bug。配套还有强化型分配器 Scudo(LLVM,Android 默认)、hardened_malloc(GrapheneOS)。Chrome 则用 MiraclePtr(
raw_ptr<T>/ BackupRefPtr) 把 use-after-free 变成不可利用的安全崩溃——不追求消灭 UAF,而是让它「即使发生也打不穿」。给 C/C++ 直接补上边界安全:Apple 在 Clang 里推
-fbounds-safety和__counted_by注解,让 C 的裸指针也带上长度、做边界检查,已用在 XNU 内核等代码上——相当于把「胖指针」的思路低成本塞进现有 C。语言标准自己发力(C++26):除了上面的 erroneous behavior,C++26 还带来 Contracts(契约,P2900)——把前置/后置条件写进语言,可在运行时检查空指针、越界等;以及标准库强制硬化的方向,把 libc++ 那种「发布版也开边界检查」的模式往标准里推。
硬件安全真正量产:ARM MTE 不再只是纸面特性——Google Pixel 8(2023) 起在消费级设备上提供了内存标签(目前是可选开关、非默认开启);学术界的 CHERI(剑桥大学 + SRI International)也有了 ARM 造的 Morello 原型板,把「胖指针即能力」的硬件方案推到了可实测阶段。
务实的中间路线:C++/Rust 互操作:既然全量重写不现实,就让两种语言共存——Google 的 Crubit、社区的 cxx、autocxx 做双向绑定,让新模块用 Rust 写、旧 C++ 逐步替换。这也是当前各大厂最现实的迁移姿势。
更激进的实验:也有人干脆造「安全的 C」——比如 Fil-C,用垃圾回收 + 能力指针把 C/C++ 变得内存安全的编译器(代价是性能),2024 年起颇受关注。这类项目未必成主流,但代表了「不换语言、直接让 C 安全」的另一种想象。
一句话收口:这些新潮流方向惊人地一致——要么把安全的「默认值」往上拧,要么在不重写的前提下给存量 C/C++ 加装护栏。 这恰好是下一节这条主线的注脚。
一条主线:四十年,一直在补,但总差一层
把这四十年摊平了看,主线极其清晰:
C++ 的内存安全技术,是一层一层叠上来的补丁,每一层都精准地堵一类洞:
- 抽象(RAII / 容器 / 智能指针 / optional / variant)——从源头上让你少犯错;
- 动态检测(Purify / efence / Valgrind / debug 迭代器 / Sanitizer / Fuzzing)——把已经犯的错在运行时抓现行;
- 编译器/库加固(注解 / 安全函数 / FORTIFY / canary / ASLR / MTE / 静态分析 / AI 审查 / 硬化 STL)——静态拦一批,运行时兜一批,实在漏了也让它难以被利用;
- 默认安全(Core Guidelines / Profiles / Safe C++)——正在试图补上那最关键、也最难的一层。
每一层都真实有效,叠起来威力也确实可观——认真用满这套组合拳的 C++ 项目,内存安全水平其实相当高。 但它们有一个共同的命门,也正是 NSA 判决书的核心:这一切都是 opt-in 的,而安全一旦可选,就总有人不选、总有一行代码漏掉。 四十年的补丁再多,也没能把 C++ 的默认状态从「不安全」翻成「安全」。
这就是今天这场「Profiles vs Safe C++」之争的全部分量——它不是又一块补丁,而是在试图改写 C++ 的默认值。C++ 能不能在守住兼容性的同时,把最后这一层补上,大概是它未来十年最重要的一仗。
理解了「一层层打补丁、但始终差『默认安全』这一层」这条主线,再回头看 RAII、Sanitizer、FORTIFY、Safe C++ 这一堆眼花缭乱的名词,就都对上号了——它们不是各自为战,而是同一场四十年战争里的一波波攻势。
推荐阅读
- NSA, Software Memory Safety(2022)—— 那份「判决书」,理解整件事的起点。
- 白宫 ONCD, Back to the Building Blocks: A Path Toward Secure and Measurable Software(2024)—— 国家层面对内存安全的定调。
- C++ Core Guidelines —— Stroustrup / Sutter 主编,现代 C++ 安全写法的权威准则集。
- Sean Baxter, Safe C++(P3390) —— 借用检查器路线的完整提案,想看「另一种可能的 C++」必读。
- AddressSanitizer 论文与文档 —— 现代内存排错的基础设施,Serebryany 等人的原始设计。
- Clang Thread Safety Analysis —— 编译期查漏加锁的注解系统(
GUARDED_BY/REQUIRES),线程安全「静态防线」的代表。 - Microsoft, Security-enhanced versions of CRT functions ——
_s安全函数的官方清单,对照 Annex K 的失败史看更有意思。 - OSS-Fuzz 与 AFL++ / libFuzzer —— 覆盖率引导模糊测试的工程化代表,配合 Sanitizer 是挖内存漏洞的主力打法。
- Google Project Zero, From Naptime to Big Sleep —— AI agent 自主挖出真实 0-day 的标志性案例,看「AI + 漏洞挖掘」到了哪一步。
- Valgrind 与 glibc FORTIFY_SOURCE 文档 —— 两件最该在生产项目里用起来的工具。
- 本站相关:《链接器发展简史》(讲到 ASLR/RELR 等加固与链接的关系)、《C++ 全局优化技术简史》,可对照着看这条工具链上的其他环节。