近年来向开发者建议放弃 C++、使用内存安全语言的声音越来越多。面对这个情况,本文作者 Sean Baxter 提出了解决问题的关键:并非完全转向 Rust,而是要创建一个包含严格安全子集的 C++ 超集——Circle C++ 由此诞生。
截至目前,Circle 编译器在 GitHub 已拥有 2.3k+ Star,在 HN 上也有许多开发者对此表示支持。
原文链接:https://www.circle-lang.org/site/intro/
声明:未经允许,禁止转载。
在过去两年中,美国政府愈发迫切地警告开发者不要采用内存不安全的编程语言。据了解,在美国许多关键基础设施都依赖于用 C 和 C++ 编写的软件,但其内存并不安全,政府认为这将导致系统很容易被对手利用:
2022 年 11 月 10 日,NSA 发布《关于如何防范软件内存安全问题指南》;
2023 年 9 月 20 日,发布《软件产品内存安全的迫切需》要;
2023 年 12 月 6 日,CISA 发布《软件制造商联合指南》:内存安全路线图案例;
2024 年 2 月 26 日,发布《未来软件应具有内存安全性》;
2024 年 5 月 7 日,发布《国家网络安全战略实施计划》
以上这些政府文件得到了许多行业研究的支持。例如微软的漏洞遥测显示,70% 的漏洞可通过内存安全的编程语言来阻止;谷歌研究也发现,68% 的 0day 漏洞与内存损坏漏洞有关。
于是乎,不少安全专业人士大声疾呼,要求项目摒弃 C++,开始使用内存安全语言——但这绝不是喊喊口号而已。
要知道,由 C++ 所支持的产品创造了数万亿美元的价值,如今有大量的 C++ 程序员和 C++ 代码。鉴于 C 和 C++ 代码的广泛传播,业界究竟能做些什么来提高软件质量和减少漏洞?在现有项目中引入新的内存安全代码并加固现有软件的方案又有哪些?
有一种系统级/非垃圾回收语言能提供严格的内存安全性,那就是 Rust。但是,C++ 和 Rust 却大相径庭,互操作能力也有限,因此从 C++ 向 Rust 的增量迁移是一个缓慢而艰苦的过程。
Rust 缺乏函数重载、模板和继承,C++ 则缺少 traits、重定位和生命周期参数。这些差异造成了两种语言接口时的“阻抗失配”。大多数跨语言绑定的代码生成器都没有尝试用一种语言来表示另一种语言的特征。它们通常会确定一些特殊的词汇类型,具有一流的特性但也限制了其他语言的功能。
对于职业 C++ 开发者来说,Rust 是一种陌生的语言,加上缺乏互操作工具,想要用 Rust 来重写关键部分以加固 C++ 应用是非常困难的。所以说,为什么在语言内没有一个能解决内存安全问题的方法?为什么没有一个安全的 C++(Safe C++)呢?
一、为安全而扩展 C++
我的目标是创建一个包含严格安全子集的 C++ 超集。无论是启动一个新项目,还是在现有项目中编写安全代码,都可以在 C++ 中实现——这样编写的 C++ 代码,将与用 Rust 编写的安全代码一样,具有强大的安全保证。事实上,生命周期安全是通过借用检查(borrow checking)静态执行的,这正是由 Rust 首次引入的签名安全技术。
选择 Rust 的理由是:这是一种为安全而设计的全新语言。
选择 Safe C++ 的理由是:它提供了与 Rust 同样严格的安全保证,但由于它是对 C++ 的扩展,因此可以无缝地与现有代码互操作。
我们的目标是编写稳健且可靠的软件。虽然 Rust 已被证明是实现这一目标的有效工具,但 Safe C++ 也能成为另一种可行的选择——不可行的,是继续添加不安全、漏洞百出的代码。
你可能要问了,Safe C++ 具体有哪些特点?
(1)一种包含安全子集的 C++ 超集。在安全子集中,禁止出现未定义的行为。
(2)语言的安全部分和不安全部分界限分明,用户必须明确需离开安全部分才能进行不安全操作。
(3)安全子集必须保持实用性。如果我们去除了一些关键的不安全技术,比如联合或指针,就必须提供一个安全的替代方案,比如选择类型或借用。如果一种语言过于缺乏表达能力,即便它非常安全也无法完成工作,那它也无用的。
(4)新系统不能破坏现有代码。如果用 Safe C++ 编译器编译现有的 C++ 代码,那这些代码就必须能够正常编译,用户也可以选择是否使用新的安全机制。一定要记住,Safe C++ 是对 C++ 的扩展,而不是一种新语言。
int main() safe {
std2::vector<int> vec { 11, 15, 20 };
for(int x : vec) {
// Ill-formed. mutate of vec invalidates iterator in ranged-for.
if(x % 2)
vec^.push_back(x);
unsafe printf("%d\n", x);
}
}
$ circle iter3.cxx
safety: iter3.cxx:10:10
vec^.push_back(x);
^
mutable borrow of vec between its shared borrow and its use
loan created at iter3.cxx:7:15
for(int x : vec) {
^
考虑上面这个用 Safe C++ 写的示例,它可以捕捉到迭代器失效,而通常迭代器失效会导致 use-after-free 错误。接下来让我们逐行分析:
第 1 行:#feature on safety - 在当前文件中启用与安全相关的新关键字。而翻译单元中的其他文件不受影响,这就是 Safe C++ 避免破坏现有代码的方式——-所有内容都是选择性的,包括新的关键字和语法。这个安全特性改变了函数定义的对象模型,使其支持对象重定位、部分初始化和延迟初始化。它将函数定义转换为中级中间表示(MIR),并在此基础上进行借用检查,以标记检查引用中可能潜在的 use-after-free 漏洞。
第 2 行:#include "std2.h" - 引入新的安全容器和算法。加强安全性就是要减少你对不安全 API 的依赖,当前的标准库中充满了各种不安全的 API,而命名空间 std2 中的新标准库会提供相同的基本功能,但其容器将具备生命周期感知和类型安全的特性。
第 4 行:int main() safe - 新的 safe 说明符是函数类型的一部分,类似于 noexcept 说明符。对于调用者来说,这个函数被标记为安全,因此可以在安全的上下文中调用。main 的定义开始于一个安全的上下文,因此不允许执行不安全的操作,例如取消引用指针或调用不安全的函数等。Rust 的函数默认是安全的,而 C++ 的函数默认是不安全的,但这只是语法上的区别。一旦在 C++ 中使用 safe 说明符进入安全上下文,就会得到与 Rust 同样严格的安全保证。
第 5 行:std2::vector<int> vec { 11, 15, 20 }; - 一个内存安全向量的列表初始化。该向量能够感知生命周期参数,因此借用检查将扩展到有生命周期的元素类型。该向量的构造函数没有使用 std::initializer_list<int>,因为这种类型有两个问题:首先,用户会得到指向参数数据的指针,而从指针读取数据是不安全的;其次,std::initializer_list 不拥有其数据,无法进行重定位。基于这些原因,Safe C++ 引入了 std2::initializer_list<T>,它可以在安全上下文中使用,并支持我们的所有权对象模型。
第 7 行:for(int x : vec) - 对向量进行基于范围的 for 循环。标准机制返回一对迭代器,它们实际上是用类封装的指针。C++ 的迭代器并不安全,总以 begin 和 end 成对出现,但不共享共同的生命周期参数,因此对它们进行借用检查不太现实。而 Safe C++ 版本使用切片迭代器,类似于 Rust 的迭代器。这些安全类型使用生命周期参数,因此可以很好地防止迭代器失效。
第 10 行:vec^.push_back(x); - 向向量中添加一个值。这里的 ^ 是什么意思?这是一个后缀对象操作符,表示对成员函数调用的对象参数进行可变借用。当启用了 #feature on safety 时,所有的修改操作都是显式的,这样在选择对象的共享借用还是可变借用时更精确。Rust 不支持函数重载,因此会隐式借用(可变或共享)成员函数的对象;而 C++ 支持函数重载,所以需要显式指定以获取我们想要的重载。
第 12 行:unsafe printf("%d\n", x); - 调用 printf。这是一个非常不安全的函数,但由于我们在安全的上下文中,所以必须用 unsafe 关键字来转义。Safe C++ 不会锁定 C++ 语言的任何部分,你可以用 unsafe 关键字,但前提是要明确声明。用了 unsafe 意味着你承诺遵守函数的前置条件,而不是依赖编译器来确保这些前置条件。
如果 main 在语法上通过了检查,其 AST 会被下放到 MIR,并在那里进行借用检查。为 ranged-for 循环提供动力的隐藏迭代器,在循环执行期间保持初始化状态。push_back 通过修改迭代器的约束位置(即向量),使迭代器失效。当下次从迭代器中加载值 x 时,借用检查器会报错:在共享借用和使用之间,vec 存在可变借用。借用检查器程序可以防止 Circle 编译出可能存在未定义行为的程序——所有这些都是在编译时完成的,不会影响程序的大小或速度。
上面这个示例虽然只有几行,但我引入了许多新的机制和类型。近年来安全专家不断提醒我们说 C++ 非常不安全,这的确是事实。因此