最近,Herb Sutter 写了一篇关于 C++ 安全的精彩文章。他讨论了许多想法,我在这里总结一下他对中期内可以采取哪些措施来增强 C++ 安全性的看法。
| 在 C++ 中,默认强制执行…… | (A) 针对新代码/更新代码的解决方案(可以要求修改代码——不涉及链接/二进制变更) | (B) 针对现有代码的解决方案(只需重新编译——无需手动修改代码,不涉及链接/二进制变更) |
| 类型安全 | 禁止所有本质上不安全的强制转换和类型转换 | 让有安全替代方案的不安全转换执行安全的操作 |
| 边界安全 | 禁止指针算术运算;禁止未检查的迭代器算术运算 | 对所有允许的迭代器算术运算进行边界检查;对所有下标操作进行边界检查 |
| 初始化安全 | 要求所有变量都必须初始化(在声明时或在首次使用之前) | —— |
| 生命周期安全 | 静态诊断许多常见的指针/迭代器生命周期错误情形 | 对所有指针解引用进行非空检查 |
| 减少未定义行为 | 静态诊断已知的 UB/缺陷情形,使现有代码中的实际缺陷只需重新编译即可报错,且零误报: 禁止数学上不合法的比较链 (从 UB 附录审查中增加更多情形) | 自动修复已知的 UB/缺陷情形,使现有代码中的当前缺陷只需重新编译即可真正变正确,且零误报: 定义数学上合法的比较链 对返回 C& 的 C 赋值运算符默认返回 *this; (从 UB 附录审查中增加更多情形) |
但目前有哪些选项可用于实现这一目标?
类型安全
要在 C++ 中强制执行类型安全,您可以使用以下编译命令,通过各种标志启用严格的类型检查和其他安全特性:
g++ -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Werror -o output_file source_file.cpp
以下是这些标志的作用解析:
-Wall:启用所有常用警告。-Wextra:启用额外的警告。-Wpedantic:强制执行严格的 ISO C++ 合规。-Wconversion:对可能改变数值的隐式转换发出警告。-Wsign-conversion:对有符号和无符号类型之间的隐式转换发出警告。-Wshadow:当变量声明遮蔽了外层作用域中的变量时发出警告。-Werror:将所有警告视为错误,只要存在警告就停止编译。
边界安全
要在 C++ 中强制执行边界安全,您可以将-fsanitize=bounds选项与gcc或clang一起使用。该标志启用对数组越界访问的运行时检查。以下是一个示例编译命令:
g++ -fsanitize=bounds -o my_program my_program.cpp
该命令将在启用边界安全检查的情况下编译my_program.cpp,并生成名为my_program的可执行文件。如果在执行期间遇到任何越界访问,检查器(sanitizer)会报告它。
初始化安全
要在 C++ 中强制执行初始化安全,您可以使用多个编译器标志来帮助捕获未初始化变量及其他相关问题。以下是 GCC 和 Clang 的命令:
GCC
g++ -Wall -Wextra -Wuninitialized -Wmaybe-uninitialized -o your_program your_program.cpp
Clang
clang++ -Wall -Wextra -Wuninitialized -o your_program your_program.cpp
说明
-Wall:启用所有常用的警告信息。-Wextra:启用一些未被-Wall包含的额外警告信息。-Wuninitialized:对未初始化的变量发出警告。-Wmaybe-uninitialized:对可能未初始化的变量发出警告。
生命周期安全
要在 C++ 中强制执行生命周期安全,您需要使用特定的编译器标志,并可能启用某些专门用于生命周期分析和安全检查的特性或工具。根据最新的进展,-fsanitize=address标志配合 Clang 或 GCC 编译器可以帮助检测生命周期问题。此外,您还可以使用 Clang 的静态分析器或 Visual Studio 中微软的工具进行更全面的检查。
以下是可用于 GCC 和 Clang 的一些命令:
GCC
g++ -fsanitize=address -fno-omit-frame-pointer -g -o your_program your_program.cpp
Clang
clang++ -fsanitize=address -fno-omit-frame-pointer -g -o your_program your_program.cpp
这些命令启用了 AddressSanitizer,它有助于检测各种内存错误,包括释放后使用(use-after-free)、返回后使用(use-after-return)和作用域外使用(use-after-scope)问题,这些对于强制执行生命周期安全至关重要。
未定义行为
要强制执行 C++ 未定义行为安全,您可以使用各种编译器标志和工具。以下是使用g++(GNU 编译器集合的一部分)的编译命令,其中包含帮助检测和防止未定义行为的常用标志:
g++ -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined -fstack-protector-all -O2 -g your_file.cpp -o your_program
以下是所用标志的解析:
-Wall:启用所有常用的警告信息。-Wextra:启用未被-Wall包含的额外警告信息。-Werror:将所有警告视为错误,强制您修复它们。-pedantic:强制执行严格的 ISO C++ 合规。-fsanitize=address,undefined:启用 AddressSanitizer 和 UndefinedBehaviorSanitizer,以在运行时检测内存错误和未定义行为。-fstack-protector-all:添加栈保护以检测栈缓冲区溢出。-O2:启用优化(2 级),这是性能与调试之间的良好平衡。-g:在二进制文件中包含调试信息,以便与调试器(例如gdb)配合使用。
C++ 检查器(Sanitizer)的问题
如我们所见,检查器可以解决 Herb Sutter 报告的部分问题。然而,它们也有几个缺点:
1- 性能开销
- 运行时性能:检查器会引入显著的运行时开销。例如,AddressSanitizer 可能使程序执行速度减慢两到三倍。
- 内存使用:检查器,特别是 AddressSanitizer,会大幅增加内存使用。这对于内存受限的环境来说可能是个问题。
2- 兼容性问题
- 平台支持:并非所有检查器都可在所有平台或编译器上使用。这可能限制它们在跨平台项目中的使用。
- 第三方库:将检查器与未按检查要求编译的第三方库一起使用,可能会导致兼容性问题或误报错误。
3- 构建与执行的复杂性
- 构建过程的复杂性:将检查器集成到构建过程中会使构建配置更加复杂,尤其是在处理多种构建类型(例如发布版与调试版)时。
- 特殊的执行环境:在检查器下运行测试通常需要特殊的执行环境和额外的设置,使过程更加繁琐。
4- 适用范围有限
- 特定类型的缺陷:检查器旨在捕获特定类型的缺陷(例如内存错误、未定义行为),它们可能无法捕获逻辑错误、算法低效或其他类型的缺陷。
5- 调试的复杂性
- 详尽的报告:虽然检查器的详细报告很有帮助,但对于大型复杂代码库,它们有时可能令人不知所措或难以解读。
- 可复现性:检查器报告的某些问题可能是非确定性的,难以复现,这使调试过程更加复杂。
显然,我们需要额外的机制来在不损害 C++ 性能的前提下提高安全性。这一目标已成为 C++ 委员会的高度优先事项,我们希望这个紧迫的问题能尽快得到最终解决。
