译文: C 不是一个底层编程语言,你的计算机也并不是 PDP-11
翻译自: https://queue.acm.org/detail.cfm?id=3212479
来源于 ACM 的一篇文章,如果介意翻译转载,需要删除掉,可以选择以一些我能看到的方式(如评论)告知我
在 Meltdown 和 Spectre 漏洞出现后,我们应该花一些时间去研究其根本原因。这两个漏洞都涉及处理器推测执行绕过某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞的特性,以及其他一些类似特性,都是为了让 C 语言的程序员继续相信他们是在用一种底层编程语言编程,而实际上,这种情况已经不复存在了几十年。
处理器供应商并不是导致这一切的罪魁祸首。我们这些从事 C/C++ 编译器开发的人分了一杯羹。
Spectre 和 Meltdown 是大部分现代 CPU 都存在的漏洞。
现代 CPU 都会利用分支预测和推测执行来提高性能。这里说的推测执行(speculative execution)是程序在执行到条件判断语句(如 if),并且该条件需要通过从内存中读取数据时,CPU 会先尝试执行某个分支的代码,并且这会绕过一些安全性检查。如果读取了内存的值发现真的要执行这个分支,就接着往后走。如果发现并不应该执行这个分支,就把状态恢复到之前的样子,并执行应该执行的分支。
虽然我们希望执行错误的分支后应该恢复到执行前的状态,但实际上可能会有一些副作用残留。Spectre 利用了这一点,将一些不可读的数据提前加载到缓存中,方便后续读取。
Spectre 不易修复,它指代的是一类的漏洞,直到今年(也就是 2024 年),依旧可以看到它。Spectre 依赖于对芯片行为的研究,开发者需要诱导 CPU 认为应该执行分支里的代码。
如果想了解更多,可以参考 Meltdown 和 Spectre 的论文: Meltdown 和 Spectre Attacks: Exploiting Speculative Execution
什么是底层编程语言
计算机科学先驱 Alan Perlis 是这样定义底层编程语言的:
“当编程语言的程序需要关注无关紧要的内容时,它就是底层编程语言。”
A programming language is low level when its programs require attention to the irrelevant
虽然这个定义确实适用于 C,但它并没有回应人们对底层编程语言的期望。有很多方面会使人们将一种语言视为底层编程语言。可以将编程语言想象为一个连续体,一端是汇编语言,另一端是《星际迷航》中星舰计算机的接口。底层编程语言“接近硬件”,而高级语言更接近人类的思维方式。
要使一种语言“接近硬件”,它必须提供一种抽象机器,这种抽象能够轻松映射到目标平台所暴露的抽象上。可以很容易地说,C 对于 PDP-11 来说是一种底层编程语言。两者都描述了一种模型:程序按顺序执行,内存是一个平坦的空间,甚至前置和后置自增操作符都与 PDP-11 的寻址模式完美契合。
PDP-11 仿真器
Spectre 和 Meltdown 漏洞的根本原因在于,处理器架构师试图构建的不仅仅是高效的处理器,而是能够暴露与 PDP-11 相同抽象机器的高效处理器。这一点至关重要,因为它让 C 程序员能够继续相信他们的语言与底层硬件非常接近。
C 代码提供了一个大部分是串行的抽象机器(直到 C11,如果排除非标准供应商扩展,它是一个完全串行的机器)。创建新线程是一个已知开销较大的操作,因此希望保持执行单元忙碌运行 C 代码的处理器依赖于 ILP(指令级并行性)。它们检查相邻的操作并并行发出独立的指令。这为允许程序员编写主要是顺序的代码增加了大量复杂性(和功耗)。相比之下,GPU 在没有任何这些逻辑的情况下实现了非常高的性能,代价是需要显式并行的程序。
对高 ILP 的追求是 Spectre 和 Meltdown 漏洞的直接原因。现代 Intel 处理器一次最多可以有 180 条指令在执行(与顺序 C 抽象机器形成鲜明对比,后者期望每个操作在下一个开始之前完成)。对于 C 代码的典型启发式方法是,每七条指令就有一个分支。如果你希望通过单个线程保持一个 pipeline 满载的情况,那么你必须猜测接下来 25 个分支的目标。这再次增加了复杂性;这也意味着错误的猜测会导致工作被执行后又被丢弃,这对于功耗并不理想。这些被丢弃的工作会产生可见的副作用,Spectre 和 Meltdown 攻击正是利用了这些副作用。
在现代处理器中,寄存器重命名引擎(register rename engine)是最大的芯片面积和功耗消耗者之一。更糟糕的是,在任何指令运行时,它不能被关闭或断电,这使得它在暗硅时代变得不方便,因为晶体管便宜,但有电的晶体管却是昂贵的资源。这个单元在 GPU 上显著缺失,在 GPU 上并行性来自多个线程,而不是试图从本质上是标量的代码中提取指令级并行性。如果指令之间没有需要重排的依赖关系,那么寄存器重命名就不是必需的。
再考虑 C 抽象机器内存模型的另一个核心部分:flat memory。这种情况已经有二十多年不再成立。现代处理器通常在寄存器和主内存之间有三个级别的缓存,这些缓存试图隐藏延迟。
缓存是对程序员隐藏的,因此在 C 语言中是不可见的。高效利用缓存是让代码在现代处理器上运行快速的最重要方法之一,但这一点完全被隐藏,程序员必须依赖于了解缓存的实现细节(例如,两个 64 字节对齐的值可能会最终位于同一缓存行)来编写高效的代码。
C 的优化
底层编程语言的一个常见特性是它们很快。特别是,它们应该能够轻松地转化为运行高效的代码,而不需要特别复杂的编译器。在谈论其他语言时,C 的支持者常常否定“足够聪明的编译器能够使某个编程语言的代码变得高效”这一论点。
不幸的是,简单的翻译并不能为 C 提供高效的代码。尽管处理器架构师在设计能够高效运行 C 代码的芯片方面付出了巨大的努力,但 C 程序员期望的性能水平只有通过极其复杂的编译器转换才能实现。Clang 编译器,包括 LLVM 的相关部分,约有 200 万行代码。仅仅是为了让 C 运行得更快所需的分析和转换阶段就已累积近 20 万行代码(不包括注释和空行)。
例如,在 C 中,处理大量数据意味着编写一个按顺序处理每个元素的循环。为了在现代 CPU 上优化运行,编译器首先必须确定循环迭代是独立的。C 中的 restrict
关键字可以在这里提供帮助。它保证通过一个指针写入的数据不会干扰通过另一个指针读取的数据(或者如果干扰发生,程序员可以接受程序给出意外结果)。相比于 Fortran 等语言,这些信息要有限得多,这也是 C 没能取代 Fortran 在高性能计算中地位的一个重要原因。
一旦编译器确定了循环迭代是独立的,下一步就是尝试对结果进行向量化,因为现代处理器在向量代码中的吞吐量是标量代码的四到八倍。对于这种处理器,底层编程语言将具有任意长度的原生向量类型。LLVM IR恰好具备这一特性,因为将一个大的向量操作拆分成更小的操作总比构造更大的向量操作要容易。
在这一阶段,优化器必须与 C 的内存布局保证进行斗争。C 保证具有相同前缀的结构可以互换使用,并且它将结构字段的偏移暴露给语言。这意味着编译器不能自由地重新排列字段或插入填充以提高向量化(例如,将结构体数组转换为数组的结构体,或反之)。这对于底层编程语言来说不一定是问题,因为对数据结构布局的细粒度控制本身就是一种特性,但这确实使得让 C 变得更快变得更加困难。
C 还要求在结构体的末尾进行填充,因为它保证数组中没有填充。填充是 C 规范中一个特别复杂的部分,并且与语言的其他部分互动不佳。例如,你必须能够使用与类型无关的比较方法(例如,memcmp
)比较两个结构体,因此一个结构体的副本必须保留其填充。在一些实验中,发现某些工作负载的总运行时间有相当一部分是用来复制填充(这些填充通常尺寸不合适且对齐不理想)。
考虑 C 编译器执行的两项核心优化:SROA(聚合体的标量替换,scalar replacement of aggregates)和循环展开。SROA 尝试将结构体(以及具有固定长度的数组)替换为单独的变量。这样,编译器就可以将访问视为独立的,并且如果能够证明结果永远不可见,它就可以完全省略操作。这在某些情况下会删除填充,但在其他情况下则不会。
这里说的替换指的是把结构的成员提出来,把它们视作单独的对象处理
第二项优化,循环展开,将包含条件语句的循环转换为两条路径中各自带有循环的条件语句。这改变了控制流,违反了程序员知道底层编程语言代码运行时将执行哪些代码的假设。它还可能导致 C 中关于未指定值和未定义行为的概念出现显著问题。
在 C 中,从未初始化的变量读取值是一个未指定的值,每次读取时都允许返回任何值。这一点很重要,因为它允许像懒回收页面这样的行为:例如,在 FreeBSD 上,操作系统被告知某些页面当前未使用,而操作系统将第一次写入页面作为提示,表明该页面不再为空。对新分配内存的读取可能最初读取到之前的值;然后操作系统可能会重用底层物理页面;接着在写入页面中另一个位置时,将其替换为新零化的页面。然后从相同位置的第二次读取将返回零值。
如果未指定的值用于流程控制(例如,作为 if
语句中的条件),则结果是未定义行为:允许任何情况发生。考虑循环展开优化,这次是在循环执行零次的情况下。在原始版本中,循环的整个主体是死代码。在展开的版本中,现在有了基于变量的分支,而该变量可能未初始化。一些死代码现在变成了未定义行为。这只是 C 语义的详细调查揭示的不可靠的优化之一。
总之,确实可以让 C 代码运行得很快,但这仅通过花费数千个人年的时间来构建一个足够聪明的编译器才能实现 —— 即使如此,前提是你违反了某些语言规则。编译器开发者让 C 程序员假装他们正在编写“接近硬件”的代码,但如果他们希望 C 程序员继续相信他们在使用一种高效语言,就必须生成具有非常不同行为的机器代码。
理解 C
底层编程语言的一个关键特性是程序员能够轻松理解语言的抽象机器如何映射到底层物理机器。这在 PDP-11 上确实是如此,C 表达式每次都能简单地映射为一到两条指令。类似地,编译器将局部变量直接映射到栈槽,并将基本类型映射为 PDP-11 可以原生操作的东西。
从那之后,C 的实现变得越来越复杂,以维持 C 可以轻松映射到底层硬件并生成高效代码的假象。2015 年的一项调查涉及 C 程序员、编译器开发者和标准委员会成员,提出了几个关于 C 可理解性的问题。例如,C 允许实现向结构体中插入填充(但不允许在数组中插入填充),以确保所有字段都有适合目标的对齐方式。如果你将一个结构体置零,然后设置一些字段,填充位会被置零吗?根据调查结果,36% 的人确信它们会被置零,29% 的人不确定。根据不同的编译器(以及优化级别),它们可能会或可能不会被置零。
这是一个相当简单的例子,但相当一部分程序员要么相信错误的内容,要么不确定。当你引入指针时,C 的语义变得更加复杂。BCPL 模型相当简单:值就是字。每个字要么是某些数据,要么是某些数据的地址。内存是一个由地址索引的存储单元的平面数组。
相比之下,C 的模型旨在允许在各种目标平台上实现,包括分段架构(其中指针可能是段 ID 和偏移量)甚至是垃圾回收的虚拟机。C 规范小心地限制了指针上的有效操作,以避免此类系统出现问题。对缺陷报告 2601 的回应包括在指针定义中引入指针来源的概念:
“实现允许追踪位模式的来源,并将代表不确定值的位模式与代表确定值的位模式区分开来。即使这些指针在位级上是相同的,基于不同来源的指针也可以被视为不同的。”
“Implementations are permitted to track the origins of a bit pattern and treat those representing an indeterminate value as distinct from those representing a determined value. They may also treat pointers based on different origins as distinct even though they are bitwise identical.”
不幸的是,“provenance” 这个词在 C11 规范中并没有出现,因此由编译器开发者决定它的含义。例如,GCC 和 Clang 在指针转换为整数再转换回指针时,是否保留其来源存在差异。编译器可以自由决定,即使指针的按位比较显示它们描述的是相同的地址,指向不同结果或栈分配的两个指针也总是被判定为不相等。
这些误解并非纯粹是学术性的。例如,已经观察到由于有符号整数溢出(C 中的未定义行为)以及在进行空指针检查之前解引用指针所导致的安全漏洞,这表明编译器会认为该指针不可能为空,因为在 C 中解引用空指针是未定义行为,因此可以假设不会发生。https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2009-1897
鉴于这些问题,很难辩称程序员能够完全理解 C 程序如何映射到底层架构。
对非 C 处理器的想象
Spectre 和 Meltdown 的提议修复方案带来了显著的性能损失,基本抵消了过去十年微架构的进展。也许是时候停止努力让 C 代码变得更快,而是思考一下在设计为高效的处理器上,编程模型会是什么样子。
我们有一些设计的例子,它们没有专注于传统的 C 代码,提供了一些灵感。例如,高度多线程的芯片,如 Sun/Oracle 的 UltraSPARC Tx 系列,不需要那么多的缓存来保持执行单元的满载。研究处理器已将这一概念扩展到大量硬件调度的线程。这些设计背后的关键思想是,通过足够的高级并行性,你可以暂停等待内存数据的线程,并用来自其他线程的指令填充执行单元。这些设计的问题在于,C 程序往往只有很少的活跃线程。
ARM 的 SVE(标量向量扩展,Scalar Vector Extensions)—— 以及伯克利的类似工作 —— 提供了程序与硬件之间更好接口的另一个视角。传统的向量单元暴露固定大小的向量操作,并期望编译器尝试将算法映射到可用的单元大小。相比之下,SVE 接口期望程序员描述可用的并行度,并依赖硬件将其映射到可用的执行单元数量。从 C 语言中使用这一接口比较复杂,因为自动向量化器必须从循环结构中推断出可用的并行度。从函数式编程风格的映射操作生成代码是微不足道的:映射数组的长度即为可用并行度。
缓存很大,但它们复杂性的原因不仅仅是其大小。缓存一致性协议是现代 CPU 中最难同时实现快速与正确的部分之一。大多数相关的复杂性来自于支持一种语言,其中数据默认是既共享又可变的。相比之下,考虑一下 Erlang 风格的抽象机,其中每个对象要么是线程局部的,要么是不可变的(Erlang 对此做了简化,每个线程只有一个可变对象)。这种系统的缓存一致性协议将只有两种情况:可变或共享。一个软件线程迁移到另一个处理器时,需要显式地使其缓存无效,但这是一种相对不常见的操作。
一个仅仅为了速度设计的处理器,而不是在速度和 C 支持之间做出妥协的处理器,可能会支持大量线程,拥有宽大的向量单元,并且具有更简单的内存模型。在这样的系统上运行 C 代码会遇到问题,因此,考虑到世界上有大量的遗留 C 代码,它不太可能成为商业上的成功。
在软件开发中有一个常见的误区,那就是并行编程很难。对于 Alan Kay 来说,这将是一个惊讶,他能够教年轻孩子使用一个演员模型语言,并让他们编写拥有超过 200 个线程的工作程序。对于 Erlang 程序员来说,这也同样令人惊讶,他们通常编写包含成千上万并行组件的程序。更准确的说法是,在具有类似 C 抽象机的语言中并行编程很困难,而考虑到并行硬件的普及,从多核 CPU 到多核心 GPU,这也只是说 C 语言与现代硬件的匹配度较差。