本文作者 John Viega 拥有 25 年安全和开发经验,喜欢研究编译器,是 AES-GCM 加密算法的联合作者,曾是 McAfee 首席安全架构师、SaaS 业务部门的 CTO。并在纽约大学担任兼职教授至今。撰写了多本关于安全的书籍,包括《安全神话》、《构建安全软件》和《使用 OpenSSL 进行网络安全》等。是 IEEE 安全与隐私杂志的前主编。
他的这篇文章围绕行业长期存在的,关于 C/C++ 安全性不足及其应该被 Rust 等语言替代的话题展开了讨论,并发表了一些独特而有价值的观点。原文内容较长,译文删减了一部分不重要的内容以方便阅读。
前不久我又看到了一篇非常轻视内存安全性,觉得这方面没什么必要做改变的文章,然后我又看到一些安全专家对这篇文章回应说,想要保障安全、负起责任就得尽快放弃 C 和 C++ 才行。这篇文章就是我对这个话题的分析,我会尽可能覆盖所有方面,尽量让行业内的读者都能理解我的意思。
太长不看版本
安全问题确实比很多人想的要严重得多,很多人应该立刻拒绝在新项目中使用 C/C++ ,理由也不仅是安全性考虑。但是!
让我们的应用程序摆脱已有的所有 C 代码的成本和风险远高于许多人的想象;某些关键软件的替代品需要十年或更久才能成为主流,而且新软件的整体效益并不明显。
安全性这个领域有许多隐藏的复杂性,所以“Rust 比 C 更安全”这句话可能是正确的,但因为那些复杂性,实际情况没那么简单。
选择编程语言这种事情看似简单,经济层面实际上非常复杂。安全性不是唯一的非功能性考虑因素,而且无论你做什么,总会在某个地方存在内存不安全的代码(只要底层架构本身是不安全的),而且试图快速摆脱 C 代码会带来许多负面后果。
系统语言被过度使用了;C vs Rust 的二选一其实是伪命题,因为像 Go 这样的编译语言在经济上往往是一个更好的全方位答案。特别是 Go 有足够好的性能,足以满足绝大多数用例,可以是安全的,并且可以很好地访问底层系统 API。
一些安全人员已经怒气冲冲了
我曾看到一位安全人员和业务部门争吵不休,于是我问他:
如果你认为安全是首要问题,那你为什么还要使用电脑?
人们每天都愿意承担风险。我们知道,只要我们外出,就有可能感染某种病毒。我们知道,只要我们上车,就有可能发生事故并丧命。
但众所周知,我们倾向于大大高估或低估自己的风险水平。
一般来说,安全行业可能认为普通人大大低估了风险。过去这个观点可能是正确的,但现在的世界已经不同了。曾几何时,大多数行业都大大低估了风险,网络连接可能被轻易篡改、代码可能被轻易攻破,没有例行补丁,也没有广泛的隔离或其他有效的权限机制。
但由于整个安全行业的辛勤工作,其他科技界从业人员最终承认他们错了。安全行业产生了巨大的影响力,从硬件架构到网络协议,再到编程语言设计都开始重视安全性。这条路走下来并不轻松,因为我们常常无法真正理解来自其他领域的专业观点。
如果我们能牢记这一点,行业就能取得更快的进步,获得更好的信誉。我们需要这样做,因为正如我们所看到的,要做的事情还有很多,而且还有很多重要的变化根本无法快速实现。
内存安全问题有多严重?
内存安全往往被认为是最严重的漏洞类别,因为这个层面的漏洞经常可以获得最高权限。漏洞经常可以被远程利用,有时甚至不需要任何身份验证。
但有观点认为内存安全漏洞数量众多,老练的攻击者很容易找到并利用它们,这种说法是错误的。
世纪初的时候情况的确是那样,但现在不一样了。从安全角度来看,内存不安全代码的影响肯定还是非常大,但还没有高到你要顶着经济压力为它换成内存安全语言的程度。
风险状况已经变了
我坚决承认相比 C/C++,其他语言本质上更安全;我只是在质疑“它们的安全性到底强多少?尤其考虑到我们已有的很多安全措施时。”
世界发生了许多变化,直接影响了风险水平(双向),包括:
我们使用的硬件架构和操作系统都很好地帮助我们阻止了漏洞利用行为,同时又不牺牲太多性能。
C++ 专心围绕其标准库打造用户界面,试图确保普通 C++ 用户不太可能使用内存安全可能有问题的 API。另一方面,虽然 C 已经发展成为一种语言,但在这方面却更加保守。
全面披露运动发生了,漏洞研究成为一个职业领域,结果最常见的 C 组件受到了相当多的审查,大大提高了 C 程序员对这类问题的认知。
学术界不再教授 C++,转向 Java,然后是 Python。
越来越多适合做系统语言的新语言出现了,其中较为关注内存安全的 Rust 最引人注目,还有 Zig、Nim 和其他几种语言也比较重视内存安全。
向云端迁移的趋势以及现代技术栈的其他很多组成部分都是很好的抽象,但它们确实会增加攻击面。另一方面,它们也让分区隔离更加常见,从而减少了攻击影响。由于我们不再将那么多可逆二进制代码交到人们手中,专有代码往往会因为对外部没那么透明而获得更好的安全性(尽管容错设计确实经常让攻击者可以根据需要自动执行尽可能多的“攻击”)。
考虑上述情况:
C 和 C++ 代码中的许多内存问题被报告为可利用的,即使在实践中确实存在相关漏洞无法被利用的情况。很久以前,如果我看到一个明显的内存错误,那么它不仅很有可能被利用,而且我还可以很容易地构建一个有效的漏洞。我同意,对于我们这个行业来说,最好只是假设我们发现的任何内存问题都是可以利用的。然而,想要有效利用漏洞变得非常困难,并且已经从一种轻松获得的技能变成了非常罕见的技能。
漏洞研究领域的经济学往往会扭曲风险认知。这是因为此类错误在经济层面是最有价值的,但也意味着对比不同语言的 CVE 可能没什么意义。此外,有价值的漏洞要么出现在不太可能被利用的地方,要么会进入“负责任的披露”周期,被修补从而消除风险。
有时,人们之所以写 C 代码是有自己理由的。理解这些理由很重要。很少有新手觉得自己必须学 C 才能成为出色的程序员,但在嵌入式系统等场合,C 往往是更为实用的选择。
为什么对比不同语言的 CVE 数量容易误导人呢?举个例子——Linux 内核最近正式获得了为自己的代码库发布 CVE 的能力。但在他们看来,任何错误都可能带来他们不了解的安全隐患,因此 Linux 内核中发现的每一个错误现在都有自己的 CVE,尽管它们大多不是可利用的内存问题。
漏洞的利用可能性降低了
一方面,找到好的漏洞变得越来越难,这一事实并不重要,因为如果你不再用 C,这类问题就不再是问题了。
另一方面,漏洞研究人员比从前更努力,找到的漏洞却少很多,这表明实际的风险比以前更低了(尤其是在有良好的补偿控制措施的情况下)。如今,我倾向于认为许多 C 程序中存在问题的几率很高,但如果你有正确的设计,并花钱请合适的人来审查你的代码,那么找到下一个错误的经济成本就足够高了。
我刚开始进入这个领域时,工作往往很简单。如果你能找到一个数组形式的局部变量,那么你很可能可以诱使程序在数组之外写入。而且内存布局很容易预测,因此那时候我们很容易利用这种情况。
具体来说,局部变量经常保存在程序栈中。当你进入一个函数时,数据会被推送到堆栈上,你退出时,数据会从堆栈中弹出。这与函数调用后仍存在的长期内存分配(堆存储)是不一样的。
例如,过去我们经常看到这样的代码:
对外行来说上述代码可能看起来没什么问题。它创建一个数组,该数组初始化为零,其大小为操作系统支持的路径的最大大小。然后,它将某个 base_path 名复制到该数组中,最后将文件名附加到该数组的末尾。但即使在今天的系统上,如果攻击者可以控制这个函数或 base_path 的输入,也很容易让程序崩溃。
部分原因是 C 不跟踪内容的长度。在 C 中,strcpy 逐字节复制,直到遇到值为 0 的字节(所谓的 NULLbyte)。类似地,strcat 的工作方式是向前扫描到 full_path 中的第一个空字节,并从 filename 中复制,直到找到 NULL。在任何情况下,这两个函数都不会根据 fullpath 的长度来检查它们正在做什么。因此,如果你可以传入超过 PATH_MAX 减去 len(base_path) 个字符,你就能写入超出缓冲区末尾的数据。
过去,程序栈将自己的运行时数据与用户的数据混合在一起,这就是传统的栈溢出如此容易实现的原因所在。
每次调用一个新函数时,堆栈都会获取程序应该返回的位置的内存地址。因此一旦你发现了其中一个条件,你要做的就是制作一个利用漏洞的有效载荷(你的恶意数据),让它覆盖这个返回地址,用指向你自己的有效载荷的指针替换它……载荷里一般还包括可以执行任何操作的可执行指令。有效载荷的可执行部分通常称为 shell 代码,尽管获得交互式登录权限(即 shell)不一定是你的目标。无论用哪种方式,当漏洞成功被利用时,攻击者往往能够从这里运行他们想要的任何代码。
从技术角度来看,当时最复杂的东西是 shell 代码本身,因为它一般至少需要汇编级知识。但你用不着自己编写 shell 代码,有很多现成代码可用。
为什么不总是执行边界检查?
好问题。有人可能会认为,我们只需让编程语言每时每刻都生成代码来检查所有访问边界,就能解决问题。
但如果到处都做代码检查,绝对会对性能产生显著影响,而且肯定会有些领域是无法忽视这种影响的。
例如,如果你是 CDN,并试图以经济高效的方式处理大量连接,那么代码检查的额外硬件成本很可能是无法承受的。
而且,用 Python 编写的单个应用程序经常“足够快”,但如果 Python 运行的每一段代码都经过完全的边界检查,它还会那么快吗?
我们使用的大多数软件都大量使用 C 或 C++ 编写的底层系统代码。不仅操作系统是用这样的语言编写,常见的编程语言在运行时利用的许多库也是如此。
当然,你可以“用 Rust 重写它”。但即使我们应该这样做,显然这也是一段漫长而艰巨的旅程。
请注意,Rust 能够接近 C 的速度,部分原因是编译器可以在编译时“证明”它可以跳过大多数边界检查。
实际上,基于 C 设计 API 并不难,严格使用的话同样可以避免内存错误,同时最大限度地减少生成的运行时代码。例如,在上面的例子中,很容易为字符串提供一个不一样的 C API,始终跟踪长度并进行全面检查。我们可以为数组和其他数据类型提供类似的 API。我们甚至可以为指针提供这样的严格性。
这实际上是 C++ 做的很成功的事情。从 API 文档来看,它确实很容易做到永远不出现内存问题。
但实际上,经济因素通常是软件领域最重要的考虑因素,经济上的投资更关心的是软件使用者的体验(比如说有些情况下使用者最关心的是性能)。
因此,如果你正在编写底层系统代码,那么它必须有广泛的适用性,并且在大规模使用的场景中成本不能很高。所以我们需要权衡成本和风险,经济因素无法被忽略。
越界内存错误:缓解措施的发展历史
我们应该愿意接受多大的风险这个问题,引出了下一个问题“我们目前接受多大的风险?”
因为如果答案是“不多”,那么我们就需要考虑是否值得付出代价来增加边界检查。
实际的风险水平很难准确量化。共识是,世纪初的 C/C++ 程序绝对满身筛子。但漏洞缓解措施经过了四分之一世纪的发展,世界已经完全不同了。证明一个漏洞可以被利用变得非常困难,标准确实越来越高了。
拿前面提到的内存漏洞代码示例来说,虽然它的确是内存错误,但这并不意味着它很容易被利用,连有可能被利用都不一定。尽管上面的代码很糟糕,但 StackGuard 早在 1998 年就很好地解决了这个问题。其基本思想是,当程序启动时,选择一个至少 64 位的随机数。每次调用函数时将其推送到堆栈上。然后,每次从函数返回时,检查随机金丝雀值是否完好无损。如果不是,则崩溃而不是返回。除非程序以某种方式泄露了它的金丝雀,否则漏洞就不会被轻松利用了。
软件开发社区(包括“坏人”和漏洞研究社区)不得不更加努力地研究绕过技术,但如果情况合适,他们至少会找到一些可以绕过某些缓解措施的情况。例如,如果内存是动态分配的,上述缓解措施就不起作用,因为这块内存是单独保存在堆中的。
当然,程序不会将函数返回地址保存在堆中。但许多真实程序,尤其是使用 C++ 动态调度的程序,会将指向函数的指针保存在堆中,并使用它们动态选择要调用的函数。
地址空间布局随机化(ALSR)是一种更有效和广为人知的防御措施,它是在操作系统级别实现的。基本上,每次程序启动时,操作系统都会尽可能随机化数据所在的位置。有了 ASLR,如果注入了足够的随机性,攻击成功的概率将非常低,可能需要像宇宙中的原子一样多的尝试次数才能成功。
你可能会问两个重要的问题:
系统难道不应该让用户的程序数据远离其内部状态吗?
如果有效载荷必须存在于堆或堆栈中(其他内存通常不可写),我们难道不能阻止这些区域运行代码吗?
对于问题 1,每个线程一个堆栈不仅更容易实现,而且通常速度更快,因为硬件通常会直接支持程序栈。最后,虽然进程有虚拟地址空间可以保护其免受其他进程的影响,但在一个进程中,进程中的任何代码都可以寻址进程中的任何内存单元。
不过,注入随机性还是很有价值的。例如,在堆溢出中,函数指针是诱人的目标。将所有函数指针存储在静态分配的表或单独的内存堆中,绝对比将函数指针随意散布在任何地方的典型方法要好。
至于第二个问题,在系统级别上绝对可以防止代码在堆栈或堆外执行,这是一种非常值得采取的缓解措施。但是,某些环境(包括某些完整的编程语言)需要使用其中一种区域来实现自己的动态功能(例如 lambda 函数,它们是闭包)。不过对于大多数程序而言,这种缓解措施基本上是免费的,并且进一步提高了安全性。
如今,当出现内存问题时,攻击者通常不能指望自己可以直接运行代码。但想象一下你正在攻击用 Python 编写的程序,你能以某种方式利用 Python 底层 C 实现中的内存错误来写入内存中的任意位置。在底层,Python 实现了一个虚拟机。堆或堆栈中可以存在一些“指令”,这些指令由 Python 的内置代码检查,并且该代码根据指令执行不同的操作。
事实证明,当我们谈论内存“不可执行”时,实际上指的只是直接在底层系统处理器上执行的内容,而不是在应用程序级虚拟机中发生的事情。因此,即使你攻击的程序的可执行代码段不可写,并且你可以写入的所有数据都不可执行,你仍可以更改控制可执行代码执行操作的数据。
作为攻击者,如果没有虚拟机,你可以使用一种称为“返回导向编程”(又名 ROP)的技术自己创建一个。基本上,你可以利用内存错误尝试整理程序的数据,让它在程序的内存中跳转,执行你希望它执行的操作(通常目的是让它生成登录 shell,然后你可以合法地再次运行任何你想要的操作)。
ROP 不简单,因为它通常要求攻击者在堆栈和堆上整理数据,这本身就很难。添加地址布局随机化后,你会发现大多数涉及越界写入的内存错误实际上都难以利用,通常需要将多个错误链接在一起,并且往往还需要应用 ROP 才能用上漏洞。
最近,英特尔推出了控制流完整性(CFI)这个选项来显式阻止 ROP。我们说过将返回地址移出堆栈通常是没有意义的。英特尔认为让大家在实践中不再这样做太难了,但相反,如果将返回地址复制到一个影子堆栈上;然后,当函数返回时,它会确保返回位置是一致的——这显然可以有效防止堆栈溢出。
如果攻击者不去直接写入堆栈怎么办?例如,使用 ROP 时,人们通常只会以一种导致跳转失败的方式操纵数据,这将运行他们喜欢的代码。当该代码遇到“返回”语句时,它可能会返回到 CFI 期望的位置上。但 CFI 还可以验证调用站点。ROP 经常会跳转到函数中间,而 CFI 可以阻止这种情况。而且,它可以确保函数只从应该被调用的地方被调用。
CFI 不会阻止我们对 Python 虚拟机的攻击。但对于没有嵌入虚拟机的程序,使用 CFI 很可能会让 ROP 式攻击变得更加困难。
总之,虽然我们可能无法很好地量化现代缓解措施阻止了多少百分比的内存漏洞,但如果程序应用了所有容易获得的系统缓解措施,很可能很多这样的错误根本就无法利用了(大多数应用程序中的许多缓解措施都是默认启用的,但 CFI 足够新,并且没有广泛使用,有些地方没有应用这些缓解措施)。
的确也有水平很高、拥有创新精神的专家可以绕开缓解措施,但他们中的大多数要么会向政府提供漏洞信息,要么会负责任地披露漏洞,这通常意味着如果你能及时打补丁,风险就可以得到很好的缓解。
我预计随着时间的推移,硬件平台将继续提高标准。如果我们足够幸运,再过十年,这些缓解措施的效果甚至可能达到与完整边界检查一样好的水平,但成本却要低得多。而在此之前,人们有理由相信,尽管这些问题确实令人担忧,但补偿控制方面的投资已经非常充足了。
其他内存错误
在 C 和 C++ 中,越界访问并不是唯一符合“内存错误”定义的情况。
这些语言需要用户手动承担分配和释放内存的责任。它们确实带有用于帮助用户管理内存的库,但与其他许多语言不同,你仍然需要决定何时以及如何释放堆内存。
除了纯粹的数组边界错误之外,还有许多问题,包括:
如果你释放了仍在被其他对象使用的内存,则可能会泄露敏感信息(例如指向 ROP 目标的指针),部分原因是 C 不会将内存清零或确保这些内存在使用前写入内容。这类“释放后使用”错误也很容易导致任意数据写入。
如果你可以让程序释放已经释放的内存,就可以破坏内部的内存管理数据结构,这也可能带来严重后果。在分配内存之前,只需在同一内存上调用两次 free() 即可结束游戏。
如果程序要做数学运算来确定要分配多少内存,并且攻击者可以强制这个运算得出足够大的结果,那么这个数字可能会“回绕”,导致分配的内存比需要的小,并强制缓冲区溢出(整数溢出问题)。
此类问题在代码中相当常见,因为我们通常很难推断何时释放内存,而手动执行这种操作的 C 程序员经常会出错。自动内存管理(例如垃圾收集)通常有助于解决大多数此类问题……除非你可以利用这种内存管理机制来发起攻击。确实,垃圾收集语言中的内存管理器往往非常复杂,并且有多个垃圾收集器出过几次严重漏洞,其中包括了大多数浏览器 JavaScript 引擎。
现代缓解措施
如上所述,C++ 已经做了很多工作来帮助程序员避开上述问题:
标准库的编写方式是,当函数返回时,大多数资源(特别是堆栈分配的内存)将自动释放,而无需用户执行任何操作。
类似地,C++ 的库完全避免使用原始指针,并使用了通过“引用计数”自动管理内存的包装器。
C++ 标准数据结构的 API 可防止数组边界错误。
C++ 有一个深层类型系统,对于这些类型,我们通常可以对程序的安全性充满信心。这意味着如果你一直处于这些类型的防护范围内,就可以捕获所有类型错误。
C++ 也早有垃圾回收了。Boehm Collector 于 1988 年问世,至今仍在维护。
C++ 有大量静态分析工具可以识别代码何时不遵循 C++ 核心指南。
许多 C++ 程序员都从上述措施中受益。相比之下,C 程序员往往没有这些好处可以享用。虽然 C 标准与 C++ 非常像,但 C 在更改方面更加保守,并且不会像 C++ 那样添加很多细节。
C 程序员也有能用的:
C 有垃圾回收器。其实 C++ 的 Boehm 垃圾收集器在 C 里一样好用。
C 有相当不错的静态分析工具,主要以 Clang 编译器生态系统为中心。
但 C 更多将其定位为一种可移植的汇编语言。我们稍后会回到这个问题。
为什么你的观点如此偏颇?
对于局外人来说,本文目前的结论可能与你听到的“证据”相悖。例如,我听到很多人声称“大多数 CVE 都是由内存安全问题引起的”。我最近又读到一篇文章给出了一个有趣的统计数据:当时(2024 年 3 月),C/C++ 代码中有 61 个 CVE,但 Rust 代码中只有 6 个 CVE。
当然,所有这些数据都是真的。但它们并不能准确反映风险水平:
我们已经确定,内存错误与可利用的内存错误是不一样的,后者更为重要。
C/C++ 就像小强一样,你可能不会每天都看到它,但如果你知道在哪儿找小强,你会发现它无处不在。虽然 C/C++ 一直不是最受欢迎的语言,但旧的构建块仍被广泛使用和维护。而且这两种语言对于新的系统级代码来说仍然很常见。因此,对于我们使用的大多数软件,无论它是用什么语言编写的,底层可能都存在用 C/C++ 编写的关键隐藏依赖关系。因此,如果说 Rust 的 CVE 总数是 C/C++ 的 10%,实际上这个数字感觉要么对 Rust 来说高得惊人,要么对 C/C++ 来说低得惊人,因为与 C 和 C++ 相比,Rust 的生产代码实际上非常少。
即使所有存在 CVE 的 C/C++ 错误都很容易被利用,由于经济因素,数据也肯定会偏向于内存漏洞。
玩这个游戏的漏洞研究人员通常只用一个漏洞就能赚到普通技术工人一年以上的薪水。而且绝对有人每年能卖出不止一个这样的漏洞。
一般来说,如果我是购买漏洞的一方,对我来说最重要的考虑因素有:
可靠性。由于很多因素(包括随机性),许多漏洞只有在极少数情况下才会起作用。然而,有些漏洞绝对每次都能奏效。
普及性。我会关心要攻击的目标主要使用哪些软件。
隐身。一般来说,我希望任务不被发现,并将丢失工具的风险降至最低。
执行。一般来说,完全执行能力是必须的。如果我有完整的执行能力,就可以做各种各样的事情。
持久性。理想情况下,错误应该是不太可能被发现和修复的。
有一些错误类型(比如命令注入攻击漏洞)会影响大多数编程语言,它们在上述许多方面得分都很高。但一个真正好的内存错误通常会得分更高。由于内存错误的固有价值,以及实际查找和利用此类错误的技术挑战,内存错误是漏洞研究界最负盛名的错误类型。因此,它们比更平凡的问题受到更多的关注。这意味着在其他语言编写的代码中很容易出现大量相当容易实现的漏洞,你更有可能看到这种漏洞出现在你正在使用的代码中,让你面临更大的风险。
人们为什么不换成其他选项呢?
到目前为止,我们已经看到 C 确实比任何高级语言都更容易受到内存错误的影响。虽然缓解措施相当有效,但它们还不足以成为人们不换语言的唯一原因。那么阻止人们切换语言的关键因素是什么?
为了讨论,我们会像经济学家一样假设人们是理性的。
首先,即使每个人都同意 C 应该消亡,这也需要很长时间。连 COBOL 应用程序都还在继续使用,很多公司会觉得替换 COBOL 程序要付出大量成本、面临很大风险,经济因素让这种语言继续存在下去。而 C/C++ 比 COBOL 更普及,近 50 年来它们一直是最重要的软件技术基石。
每个主流操作系统都是主要用 C 语言编写的。我们每天使用的大多数网络服务主要用 C 或 C++ 实现。在嵌入式系统中,使用 C 或 C++ 以外的语言几乎闻所未闻。即使在 Rust 领域,你也会发现一些 C。
其中一些原因包括:
多年来,许多 C 代码已被广泛使用和信任,世界将不得不承受巨大的痛苦才能迁移。
在更安全的语言中,许多底层任务需要做的工作超出必需,特别是因为它们更具防御性。
许多嵌入式环境的约束太大,以至于将更多具有大量依赖关系的编译系统语言引入桌面是不可行的。与 Rust 相比,C 非常紧凑和简单。
谈到市场上有资格进行底层编程的开发人员时,你会发现 C 和 C++ 程序员是最多的。
Rust 不是特别容易阅读或编写,而且目前还没有其他值得一提的选项。要求那些花了 20-30 年时间磨练 C 或 C++ 技能的人们抛弃自己的知识积累是不现实的。
如果你是谷歌或微软,你会抛弃几十年来一直表现得很强大的代码,并且相信替代方案可以涵盖过去几十年的所有极端情况吗?尤其是你还知道新方案并没有很好的知识库积累?我们今天运行的软件已经建立了一些信任度。当然,在糟糕的过去,Sendmail 很受欢迎,但漏洞百出,因为它不是防御性编写的。在 Unix 方面,Postfix 已经积极开发了 25 年,并且是用 C 编写的。虽然它的安全记录并不完美,但它一直很好用,并且从一开始就充分利用了最小特权原则。
尽管 Postfix 从一开始就更加注重安全,而且更容易配置和使用,但它花了至少十年(可能更长)的时间才基本取代 Sendmail。它不仅花了很长时间重新发现那些可能破坏业务的重要异常,还用了很久向担心业务中断的人们证明它足够成熟,不会造成这种风险。
因此,为了“用 Rust 重写它”,并成功取代 Postfix(更不用说 Exchange),我们必须:
从头开始构建符合所有标准的东西。
获得足够的实际用户群,以找到并处理大量在成熟的软件中已经解决掉的“陷阱”。
构建足够的可用性,让人们可以考虑大规模使用它。
跟上邮件世界中的一大堆新趋势(多年来已经有很多新增功能,特别是在安全领域)。
慢慢说服世界迁移过来。
在最好的情况下,这个过程也需要很久,结果要十年后才能看到。人们很难对一个长达十年的项目感到兴奋,因为它取代的东西并没有那么大的风险。上述论点也同样适用于大量用 C 编写的传统基础设施。
C 的长期优势
在我看来,Rust 走进内核是一项令人印象深刻的成就。多年来,许多人一直在游说,希望大家允许 C++ 进入内核,但这个目标从未实现。反对它的论点很简单——C++ 有太多的抽象。内核基本上位于软件栈的底部,不应产生任何不必要的成本,因此内核团队需要能够推理性能问题,这意味着他们需要能够看到 C 代码如何映射到生成的汇编代码上。
从经验上讲,Rust 在这方面确实更接近 C。然而,Rust 在这方面肯定不比 C 好,而且在某些任务上 Rust 非常碍事。
许多任务足够底层,是真正的系统级任务,例如内存管理、设备驱动程序、公开新硬件功能等。是的,你可以在 Rust 中做这些事情,但与 C 相比这很费力,而且经常需要利用 Rust 的“不安全”功能,于是会承担同样的风险。那么为什么不用 C 来编写呢?
此外,就像 Linux 内核不包含标准 C API(因为它们在这种情况下没有意义;它们在需要时提供自己的内部 API)一样,Rust 不能使用自己的 API;它必须使用内核的。
我们使用的硬件架构在指令级别提供的内置安全性非常少。当然,即使是 C 的可怕的基本类型系统也比直接向架构写入代码要好得多。在任何现实软件系统中,如果你深入到最底层,总会有代码需要针对这些不安全的平台来编写。
此外,绝大多数嵌入式系统都只用 C。资源有限的不仅是 CPU:
内存可能非常宝贵,包括堆栈空间、磁盘空间、缓存、寄存器等。编译后的可执行文件的大小可能是一个问题,运行时垃圾或肥胖抽象(如果存在)占用的任何不必要的空间也可能是一个问题。
这种环境的工具通常不适合其他语言。仅仅获得一个为平台生成代码的编译器可能就是一个巨大挑战,更不用说提供专门针对该平台的工具了。
由于许多因素,包括空间限制和测试周期可能涉及的难度,这样的环境通常几乎没有外部依赖项。即使是这些环境的标准库也可能完全缺乏线程等基础内容。如今,较新的系统语言一直在努力让它们的标准库瘦身,但它们还是太大了(而且构建时间也太长,尤其是 Rust),在嵌入式领域“不够好”。
C 在这个世界中蓬勃发展,这是 C 标准积极瘦身常用 API 的众多原因之一,它在这方面的努力和成果远超其他语言。
不幸的是,嵌入式世界往往不支持许多让 C 变得安全的常见缓解措施。从安全角度来看,它们还有其他缺点,例如没有足够的资源来做基础的加密工作,升级打补丁也没那么容易。然而,这些限制更多是商业权衡。大部分嵌入式软件出于某种原因都运行在低端硬件上,如果它们承担得太多,产品就没竞争力了。而且更强大的硬件需要更多的电量,对于可穿戴设备或其他可能需要长时间使用电池供电的设备来说,能耗比安全性可能更重要。而 C 语言实际上是唯一一种愿意正确服务此类环境的非汇编语言(而 C++ 是唯一一种在嵌入式领域真正有吸引力的替代选项)。
人们确实喜欢称 C 为“便携式汇编程序”。但在我看来,C 比汇编高级得多。只有无法用 C 正确完成(或无法轻松正确完成)时,我才会使用汇编语言,但这时一般也就涉及几条指令。当我不得不使用汇编语言时,我也会浪费很多时间,因为这种情况很少见(而且每个现代架构都非常复杂),我不得不在文档上花费更多时间。
C 比汇编语言更高级,但比其他系统编程语言(C++ 和 Rust)都更底层。它基本上介于两者之间,抽象出了许多平台可移植性问题,但仍然足够基础。如果你了解架构和编译器,就可以通过查看 C 源代码来可靠地预测它将生成什么代码。
有很多任务最好在这个级别完成。用 Rust 完成此类任务不会像汇编语言那样困难,但无论如何,换掉“不安全”的代码块仍需要很多额外的工作。对此类“不安全”块的替代需求越多,越有可能亏本。
我认为我们最好使用有意义的轴来对语言进行分类,尽可能定义它们。通常,使用 Rust 或 C 的人们非常关心性能。在我看来,轴的一端是性能,另一端是用户体验。按照这个方法来分:
汇编语言 本质上是底层的,缺乏有用的抽象,因此我认为人们应该只在少数特殊情况下直接处理它。
预汇编语言 试图让所有东西尽可能靠近硬件,同时让程序员尽可能多地控制性能。在这里,我们可能会在性能上稍微牺牲一点,换取可移植性和可维护性。但我们仍然应该在提供一些基本功能的地方编写代码。操作系统内核肯定属于这一类,也许一些基本的底层库设施也算,但大多数任务可能不需要在这个级别处理。实际上这个类别只有 C。Rust 可能有一天会达到这个水平,但我觉得它现在还远远不够。
系统语言。对我来说,这些语言专注于提供尽可能合理的性能,同时在大多数情况下仍提供安全性(允许对不安全机制进行受保护的访问)。通常,这些语言会竭尽全力避免传统的垃圾收集(或有选择地退出)。这意味着你可能需要在内存管理方面做一些手动工作。这已成为语言开发的“热门”类别,其中包括 C++、Rust、Zig、D 和 Nim 以及许多鲜为人知的语言。
编译语言。这些语言也会在意性能,但同样会重视整体开发体验。这里不需要担心内存管理,并且应该有一个相当丰富的生态系统,通常也非常重视类型安全,并有不错的类型系统。Go、Java 和 C# 是这一类别中的长期中坚力量,我还会把 Typescript 放在这里。
脚本语言。在这些语言中,性能在优先级列表中相当低。快速开发受到高度重视。通常人们不想等待结果慢慢编译出来,甚至可能希望在不重新运行程序的情况下就能看到变化。在这里,动态特性往往比类型安全更受重视,尽管这种情况已经开始发生变化。
C 和汇编语言在内存安全性方面肯定处于绝对劣势,但正如我们一直在讨论的那样,安全性差距并不像人们想象的那么大,因为:
在桌面 / 服务器世界中,安全性更有可能是高优先级,通常有非常有效的缓解措施可以减轻风险。。
其他系统级语言仍会暴露很多不安全的特性,最后也会出问题。
大多数语言都有依赖项(通常包括编译器),这些依赖项会用内存不安全的语言编写。
此外,正如我们将在最后讨论的那样,上面这条至少是其他语言往往比 C 更不安全的一个非常重要的原因。
也就是说,我们很难想象在未来几年内,你选择的操作系统和浏览器会完全用 Rust 重写,同时完全解决安全隐患。但可以肯定的是,人们会付出大量努力慢慢转向这一方向。我们已经看到,不仅 Linux 接受了 Rust,微软也非常重视它。
语言的过早优化
我一直在说,C 在生态系统中的地位很有说服力,而且它不会很快消失,因为没有合适的东西可以取代它。我认为更令人担忧的是,许多程序员系统性地高估了性能的重要性。我的观察:
尽管 Python 在大部分运行时间里比 C 慢 50-80 倍(而系统和编译类别中的语言通常在 2 倍到 5 倍范围内,很少超过 10 倍),但它还是非常受欢迎。
大多数语言设计决策制定时都没有性能参考数据。
事实上,人们很少收集性能数据,尽管以性能为名做出的决策很常见。
你经常会听到人们说“最好信任编译器”,但许多人前脚这么说,后脚就忘了这条原则。
我认为任何诚实的系统程序员都会说过早优化是一个巨大的陷阱,而且他们已经多次陷入其中。人们在估计性能(和风险)水平方面表现非常差,对于那些会高估性能需求的人来说,他们最终会选择系统语言(或 C,尤其在他们不怎么在乎风险的时候)。事实上,在很多情况下,即使是 Python 也很好用。例如,Dropbox 的大多数关键任务都用 Python,但表现也非常出色。我认为作为一个行业,我们应该更关心人们在性能(而非安全性)方面做出的错误选择,因为:
如果你能让人们不要高估对性能的需求,那么安全性自然会变得更好。
高估性能带来的间接经济损失对企业的影响可能远大于低估安全性的成本。例如,与编译语言甚至最高级别的语言相比,C 和 Rust 程序员都倾向于在内存管理上花费大量时间,并且发现自己在出现底层问题时花费了更多时间试图理解这些问题。你真的需要放弃垃圾收集器吗?因为仅凭这一点就会对开发时间产生重大不利影响,更不用说在标准库中提供更丰富的抽象可以降低成本的好处了。
这并不是说系统语言甚至预汇编语言不是正确的选择。我只是认为我们应该经常思考这个问题,并让人们客观地思考影响他们决策的方方面面:
我们真的可以证明我们很需要性能吗?如果是这样,请证明这一点。
否则,我们可能应该考虑迁移到高性能语言的成本是否值得。
如果我们没法证明,那么应该考虑其他哪些重要目标?构建速度?代码质量?
我们依旧需要学习 C 语言的人才
在人们选择系统语言时,大多数情况下我们应该推荐他们选择更高级的语言,而不是在没有数据的情况下过早为性能做优化。然而,整个行业确实需要继续以某种方式培养 C 程序员,因为:
信不信由你,尽管 COBOL 已经存在多年,但它比 C 语言的级别高得多,也更容易理解,所以要让用 C 写的关键系统在 50 年后还能继续维护会是更困难的事情。
毫无疑问,C 仍将得到广泛使用(很可能包括操作系统内核)。
它有助于推动系统级的创新。
我最后一点的意思是,50 年后,我们仍然需要能够成为底层架构专家的人才,帮助软件开发人员利用各种硬件改进。
在过去 30 多年里,大量新人涌入编程领域,其中一些人已经走上了这条道路。但是:
我们还为人们提供了比 30 年前更高级别的语言抽象。Javascript、Python、Go 和 Java 在抽象硬件细节方面做得非常出色,只有对学习系统内容非常感兴趣的人才能做到这一点。
如果人工智能辅助开发广泛流行,那么大多数开发人员会对系统编程非常陌生(这不全是坏事)。
如果我们以某种方式消灭大多数的 C 语言开发项目,并在没有任何合适替代品的情况下阻止大多数人学习它(我向你保证 Rust 不是合适的替代品),我们将大大加深系统语言和实际硬件之间的鸿沟。
C 语言虽然很可怕,但相对其他语言而言,它绝对是从编程角度理解底层架构的更好的基石。如果没有这个垫脚石,那么愿意一路向下推进来促进软件利用硬件改进的专家会快速减少,因为学习基础知识和完成简单任务所需的努力程度,最终会高到让更多感兴趣的人们要么认为他们没有能力,要么不想经历那些痛苦,然后放弃。人类是以目标为导向的生物,为了我们自己的心理健康,我们倾向于不追求那些自己认为太难实现的目标。
如果有一种预汇编语言可以纠正 C 语言的一些最严重的错误(在我看来最大的错误是 C 语言对数组的处理),并且在开发过程中始终执行尽可能多的分析(不仅是通过 Clang 项目提供的清理程序,还包括 Valgrind 等运行时安全工具),我会感到更安心。
Rust 目前是最接近这种替代品的语言,但在我看来,过分强调函数式范式会影响它更好地阐明底层的冯·诺依曼架构。
人们不选择 Rust 的理由
总体而言,人们谈论的理由有:
“我们的应用程序不需要用系统级语言编写;我们对垃圾收集很满意。”
“我们对自己熟悉的语言和生态系统很满意,不想花太多时间学习新的生态系统。”
“我们觉得 Rust 的学习曲线很陡,很难编写。”
“我们没有足够的人才熟练掌握它(大家一起学习它似乎是在浪费资源)。”
“其他人用 Rust 编写的代码往往很难理解。”
“构建时间往往非常长,Rust 经常有太多外部依赖项。”
我意识到 Rust 在很短的时间内就变得非常受极客欢迎,这是有充分理由的。我非常欣赏 Rust 的一些成就,尽管如此,我已经亲身感受了上面列出的一些因素。三年前,我读了一篇写得很差的学术论文,想看看是否有人已经实现了它。结果我发现了同一篇论文的两种不同实现,但只有两种,而且它们恰好都是用 Rust 编写的。这两种实现都非常简洁,很难理解。如果我不知道它们实现了相同的算法,我永远也不会猜到它们做的是一样的事情,因为它们使用了截然不同的习语,而且看起来一点也不像。
我已经编写了足够多的 Rust 代码,并与许多 Rust 开发人员交流过,我可以自信地说,相当多的人会发现它很难采用,而且需要很长时间才能感觉到它与他们目前选择的语言相比一样高效。
谷歌的一篇博客文章试图反驳 Rust“难以学习”的论点。我读过这篇文章,但除了他们没有分享任何真实数据之外,这篇文章还存在一些问题:
那些发现自己“在 Rust 中同样高效”的人们的情绪被谷歌员工严重扭曲了,这些谷歌人习惯构建内部了 C++ 工具栈,这些工具栈有足够的复杂性和多年来积累的控制力,只要摆脱这些限制,他们就会感到高效。
调查用户情绪(而不是围绕代码本身的指标客观分析)会带来难以纠正的隐性偏见。谷歌员工不想在调查他们对 Rust 看法的人们面前显得软弱。
即使数据有点用处,也无法与其他语言直接对比。那篇文章声称他们的结果与其他语言相同,但他们明确使用了“轶事”一词。
谷歌的员工也往往是最熟练的。说“我们谷歌人觉得很容易”没什么代表性。
从我所看到的情况来看,Rust 主要只在极客圈子流行。我觉得 Rust 明确针对极客,也就是那些对数学函数和递归有直观理解的人们打造,这一事实让我无法接受它。
我希望编程更加平等。我认为世界上有很多聪明、能干的人不在我们的专业圈子里,如果他们能更轻松地将想法转化为计算机代码,就能为世界做出惊人的贡献。我非常希望降低编程的入门门槛(在我看来,Python 在这方面为世界做出了最大的贡献)。从根本上讲,Rust 是一种很棒的语言,熟悉它的人应该在有意义的地方使用它。然而,我认为人们应该尽量更诚实地对待经济学:
我们应该更全面地思考各种选项的经济要素,尽可能地使用编译语言而非系统语言,因为前者的总体经济效益在大多数情况下可能更好。
有预汇编需求时,我们不应该逼人们放弃使用 C/C++。对这种选择的质疑是可以的,但世界的那么多需求永远不会只用一种编程语言就能满足,即使在系统领域也是如此。
我们应该促进 Zig 等系统语言的发展,甚至推动有潜力真正取代 C 的语言崛起,这样有一天我们也许能够用一种在 COBOL 时代做不到的方式给 C 的棺材钉上钉子。
目前,Zig 及其生态系统在满足许多系统编程需求方面远远落后 Rust,但前者对问题采取了更加平等的态度。相比 Rust,Zig 将是一种更容易被大多数使用编译甚至脚本语言编写程序的人们接受的语言。
部分原因是 Rust 的根基牢牢扎根于函数式编程世界,其基础原则是围绕数学的纯函数构建的。有些人可以凭直觉理解这些东西,但他们往往是有着深厚的数学背景。
相比之下,Zig 依旧是一种常规的命令式语言。它的基础原则本质上是“给某人详细的指示”,小孩都能理解。事实上,我见过的每一个成功的预编程项目(例如 Scratch)都是命令式的。
函数式编程被普遍认为是晦涩难懂的,并且在过去 65 年中它都没能流行起来的事实表明,每类编程语言都应该有一种强大的过程语言。
但是,我对函数式范式和函数式语言大多持积极态度,因为它们确实有自己的巨大优势,尤其是它们可以更好地鼓励程序员编写更可靠、更易分析的代码。函数式范式的价值非常大,我相信在每个抽象级别(直到预汇编语言)上都应该有一个好的、流行的函数式语言。
另一方面,我认为面向对象编程范式的实用性要小得多,它最好完全消失,或者最多成为一个不那么突出的特性。
Rust(目前)可能存在
比 C 更大的安全风险
当知名的安全思想领袖发表“用非内存安全语言构建关键应用程序是不负责任的”这样的言论时,我感到很失望。这不仅仅是因为他们忽略了经济复杂性,将复杂的决策简单化,还因为即使我们只考虑安全性也能发现,C 中的内存安全问题虽然很严重,但风险并不一定比其他语言更严重。
具体来说,C 程序一般只有少量外部依赖项,而这些依赖项往往是最常用的软件(例如 C 标准库)。其他多数语言中,程序员更容易利用其他人的工作成果。从商业角度来看这是一件好事。但从安全角度来看,更多的依赖项不仅会增加我们的攻击面,还会让我们更容易受到供应链攻击。xz 事件是此类供应链攻击的最新知名案例之一,但它绝非孤例。
Rust 很容易引入外部依赖,就像在 JavaScript 生态系统中一样,它似乎鼓励人们做出来大量很小的依赖项。这使问题监控和管理起来更困难了。Rust 的情况还比大多数语言更糟糕,因为核心 Rust 库(Rust 项目正式维护的主要库)大量使用第三方依赖。这个项目需要承担起该负的责任,监督他们使用的库。对我来说,这一直是软件中最大的风险之一。我可以编写颇具防御性的 C 代码,但我很难信任自己使用的任何依赖项,更不用说大规模使用了。
正确保护依赖项供应链比编写安全的 C 代码要困难得多。在这方面,C 比 Rust 好很多,但并不是特别好。部分原因是 C 标准库用得没那么多。编写大量 C 代码的程序员总会自己构建一些代码,维护上几十年。
我个人一直更关心尽量减少依赖项的主题,而非缓冲区溢出。有一些简单的方法可以尽可能减少内存安全问题,而且在大多数应用程序中它们并不难使用。但深入研究每一个依赖项?即使是供应链安全领域的从业者迄今为止做出的最大努力,也无法很好地帮助我们应对最近的 xz 事件等攻击。一旦开发人员建立了对某些下游依赖项的信任,那么以一种可能被视为意外错误的方式引入后门并不难。比如 xz 事件中,后门并没有直接进入源代码树,因此它更像是一个后门。但我们早就知道,隐秘的后门与错误是无法区分的。尽管我们比以前更加注重同行评审文化,但很多“经过评审”的代码并没有得到那些严谨的人们的充分审查。
此外,代码审查比编写要难得多,这也是我不指望在不久的将来使用基于 LLM 的代码生成工具的原因之一——它将程序员变成了编写需求的“产品经理”,以及代码审查员。目前,我觉得“只”做一名工程师更容易。
无论如何,依赖关系越多,隐含信任圈就越大,攻击面就越大,你承担的供应链风险就越大。这使得 Rust 在供应链安全方面的风险特别大,而 C 在这方面得分相当高。考虑到所有经济因素,选择 Rust 可能还是更明智的,但我认为安全性的理由还不够有说服力。我认为 Rust(以及几乎所有编程语言)如果能有自己的标准库,那就再好不过了。它们应该引入所有依赖项,并愿意承担责任。
此外,我一般会主张语言将更多功能纳入其标准库,尽管最近的趋势恰恰相反。是的,从安全角度来看,这在技术上增加了你的攻击面。但事实并非如此:
如果人们觉得他们可能需要从外部引入某些通用功能,那么无论标准库是否导出它,他们都很可能会这样做。
删除语言标准库中实际上没有用到的部分并不难(特别是有了链接时优化),这会将攻击面降低到大致相同的程度(尽管许多非系统语言并不担心链接时优化)。
通过承担责任,语言维护者不仅可以更专注于正确审查人们可能使用的解决方案中的安全风险,他们也更可能找到有助于减少攻击面的架构设计。
Rust 这样的语言应该对人们可能需要的功能负责,尤其是当安全性被视为人们使用它的主要动机之一时。Go 和 Python 这样的语言有丰富的标准库,由语言维护者负责,这实际上是最好的情况。
的确,Python 已经变得如此流行,以至于很多人都使用外部依赖项,而且有几种流行的包管理器。但从供应链的角度来看,它在这方面还是比 JavaScript 强。
建 议
虽然我认为如今供应链问题可能让 C 比 Rust 更胜一筹,但 Rust 很容易让这种优势消失。当这种情况发生时,C 将面临内存管理方面的担忧。我的目的不是要说 C 比 Rust 更好,而是要表明围绕语言选择的决策远比人们情绪化的结论要复杂许多。
对于团队
探索各种选项的整体经济效益。在选择技术时,思考它们各自更广泛的经济影响,并尝试获得支持你假设的数字证据。人们往往会凭直觉行事,经常高估性能的重要性。
如果你不想预先分析,请不要先考虑系统语言。相反,请先看看 Go、Swift、Java、C# 或其他优秀的编译语言。如果你感觉应该提高性能,那么 Python 对于各种用例都有可接受的性能,并且所有编译语言的效率都会远高于 Python。
一定要考虑安全性。你选择了 Rust 并不意味着你没有代价。坏事仍可能发生在你身上。
避免不必要的依赖。你需要思考并判断所有经济因素。但请注意,更少的依赖关系通常还有其他好处,包括更短的构建时间、更少的测试面、更少的 API 更改风险或下游依赖项的错误。
注意你已有的依赖项。除了尝试掌握依赖项扫描工具提供的传递图之外,还应该深入研究工具没发现的东西。你的运行时中可能链接了一些 C 库,当下一次 xz 事件出现时,这有助于你更轻松、更诚实地评估风险。
尝试确保外部安全审查。对于企业软件来说,这几乎是理所当然的,因为大买家会要求提供证据。但大家都应该考虑如何定期进行此类审查,并将其视为一个机会,而不仅仅是一个需要勾选的框。
如果你选择 C,请提供文档支持你的决策。我这样说是因为安全问题绝对是真正要考虑的问题,人们应该知道你在这方面做得很好。
如果你选择 C,请主动有效地解决内存安全问题。
请注意,许多 C 开发人员选择使用不安全原语的主要原因不是他们缺乏安全风险方面的教育,而是他们通常有一些大型依赖项(例如 OpenSSL 或其他加密库),并且不清楚如何以简单、可移植的方式将 Boehm 垃圾收集器应用于这些第三方库。
对于安全行业
最重要的是要记住,仅仅因为你个人认为这是一个糟糕的安全决策,并不意味着它总体上真的是一个糟糕的决策。试着倾听非安全人士的意见,了解他们的优先事项。你可能反对“FUD”(恐惧、不确定性和怀疑),但过度简化非平凡问题,不惜一切代价推动安全增强措施本质上就是在传播 FUD。请记住,即使是安全影响也比我们现在可能想到的要复杂和微妙得多。
确保行业考虑更广泛的经济因素。当然,在行业之外,安全性远不如在行业内部重要。但我认为问题要深远得多——如果安全行业试图将过多的工作推给行业的其他部分,我们至少会破坏可信度,还可能会造成很大的经济损失。比如说,如果小型企业和个人的成本和责任风险太高,他们就会完全忽略它了。
为解决我们遗留的软件问题做出贡献。“用安全的语言重写所有内容,并尽快迁移所有内容”这样的口号并不是真正的解决方案。它充其量只是一种理想,从风险管理的角度来看完全不切实际。我们可以取得进展,但需要循序渐进,我们必须更加务实。
对于其他行业
一般来说,软件行业的其他行业应该与安全行业合作应对风险。具体来说:
帮助我们弄清楚如何才能留住一批能够理解软件和硬件界限的新鲜人才。这意味着,我们最终需要让熟练的人员有效地完成预组装任务,而这种方式的风险要比现在用 C 语言完成任务低得多。如果答案不是“另一种编程语言”,那肯定很好,但如果我们需要另一种语言,双方都需要认真对待这个决策。
请推动其他人在更多涉及经济因素的决策中展示他们的工作,不要以为什么事只靠“老人言”就能搞定。这是一个快速变化的世界。
语言设计者,除了你已经为安全性做的工作外,还请更多地考虑和关注第三方依赖项的影响,意识到平衡贡献与安全性是很难的。我们需要更务实的解决方案。
在所有情况下,试着假设你所做的各种估计(比如性能和安全性)都是错的。假设你在任何一个方向上都可能是错的!现在,试着收集一些硬数据,让你更好地了解现实到底是什么情况。
反 馈
我很乐意讨论这个话题或接受任何反馈。正如我所说,我很乐意继续学习和重新评估我的观点。所以请联系我,但我不会很快回复。
原文链接:
https://medium.com/@john_25313/c-isnt-a-hangover-rust-isn-t-a-hangover-cure-580c9b35b5ce
评论 2 条评论