try ai
科普
编辑
分享
反馈
  • 数据填充

数据填充

SciencePedia玻尔百科
核心要点
  • 编译器插入数据填充是为了确保数据字段与 CPU 内存访问边界对齐,这对性能至关重要。
  • 程序员可以通过将结构体字段按其对齐要求从大到小排序,来最大限度地减少内部填充并提高内存效率。
  • 未初始化的填充字节可能会在内核和用户空间等信任边界之间泄露敏感信息,从而造成严重的安全漏洞。
  • 在并行计算中,填充被有意用来解决“伪共享”等性能问题,其方法是强制将数据分配到不同的缓存行上。

引言

在数字世界中,效率至关重要。我们努力编写运行速度快且节省内存的代码,然而一个隐藏的现象却在不断地发挥作用,默默地影响着这两者:数据填充。数据填充常被编译器视为纯粹的“浪费空间”,但实际上,它是软件与硬件之间基本契约的一个重要结果。本文旨在弥合“仅知晓填充存在”与“真正理解其对性能、正确性和安全性的深远影响”之间的知识鸿沟。首先,“原理与机制”一章将揭示为何需要填充、编译器如何实现填充以及它所带来的隐藏成本。随后,“应用与跨学科联系”一章将探讨这一概念如何被有意地应用于高性能计算、操作系统乃至密码学中,揭示其惊人的多功能性。这次探索将阐明,我们数据中那些看不见的空间与数据本身同样至关重要。

原理与机制

看不见的架构师:为何计算机要求秩序

想象一下,走进一个巨大的图书馆,里面从最薄的小册子到最重的巨著,每一本书都被扔在地板上的一个巨大书堆里。房间被尽可能高效地填满,没有任何浪费的空间。现在,请帮我找到《计算机程序设计艺术》的第三版。这是一项不可能完成的任务,是在混乱中搜寻。我们不是这样建造图书馆的,计算机也不是这样组织其内存的。

计算机的内存,就像一个组织良好的图书馆,依赖于一个由书架和地址组成的系统。而这个系统的核心,在于一条由硬件本身规定的简单而强大的规则:​​对齐(alignment)​​。现代中央处理器(CPU)并非一次读取一个字节的内存。它的设计旨在提高效率,以固定大小的数据块——通常一次 444、888 甚至 161616 个字节——来抓取数据。为了高效地完成这一任务,它要求这些数据块的起始内存地址必须是其大小的倍数。一个 444 字节的整数应该起始于一个能被 444 整除的地址(如地址 0,4,8,...0, 4, 8, ...0,4,8,...)。一个 888 字节的浮点数应该起始于一个能被 888 整除的地址(如地址 0,8,16,...0, 8, 16, ...0,8,16,...)。

为何如此严格?不妨将其想象成流水线上的一台专用机器,设计用来拾取整齐摆放在 2×22 \times 22×2 托盘中的物品。如果物品在传送带上完美对齐,机器可以一次性迅速抓取整个托盘。但如果某个物品未对齐——跨越了两个托盘位置的边界——机器就必须停下来,执行两次独立的拾取,然后小心地将各部分拼接在一起。这正是 CPU 在处理未对齐数据时必须做的事情。一次本应只花费一个时钟周期的访问,可能突然需要两个或三个周期,外加重新组装数据的额外逻辑。因此,对齐并非一条随意的规则;它是硬件与软件之间的一项基本契约,一个以速度之名订立的盟约。

秩序的代价:填充的诞生

这种对秩序的要求带来了一个有趣且常常令人惊讶的后果。假设我们正在设计一个容器,即 C 语言中的一个 struct,用来存放几个不同的信息片段。这在几乎所有的软件中都是一项常见任务。假设我们想存储一个字符(如 'A'),它占用 111 个字节,然后是一个精确的科学测量值,这是一个 888 字节的 double 双精度浮点数。

编译器作为架构师,开始在内存中布局我们的容器。我们从内存地址 000 开始。

  1. char(1 字节)被放置在偏移量为 000 的位置。它的对齐要求是 111,000 是 111 的倍数。完美。它占据了第一个字节。
  2. 下一个可用的位置是偏移量 111。但接下来是 double,它的大小为 888 字节,并要求 888 字节对齐。它必须起始于一个 888 的倍数的偏移量。地址 111 不符合要求。2,3,…,72, 3, \dots, 72,3,…,7 同样不符合。

编译器能做什么呢?它不能将 double 放置在偏移量 111 的位置。那将违反硬件的首要规则,并导致一次缓慢的、“未对齐”的访问。于是,编译器做了一件简单而意义深远的事:它留下了一段空白。它插入了空的、未使用的字节,以便将 double 的起始位置推到下一个有效的位置。在这种情况下,它在我们的单个字符后插入了 777 个空字节。然后 double 就可以顺利地放置在偏移量 888 的位置。

这些被插入的空字节被称为​​数据填充(data padding)​​。它们是编译器为了维护神圣的对齐规则而添加到我们数据结构中的沉默、无形的空白。这就是秩序的代价。为了让我们的程序运行得更快,我们在数据中引入了微小的浪费空间。这种现象,我们可以称之为内部碎片,正持续不断地发生在你日常使用的软件深处。

打包的艺术:驯服浪费

这是否意味着我们注定要浪费这些空间?完全不是。在这里,我们发现了一个美妙的原则,即程序员的理解可以超越编译器的默认行为。让我们重新审视那个包含一个 char 和一个 double 的结构体。如果我们以相反的顺序声明它们会怎样?

  1. double(8 字节)被首先放置,在偏移量 000 的位置。其 888 字节的对齐要求得到满足。它占据了字节 000 到 777。
  2. 下一个可用的位置是偏移量 888。现在我们放置 char(1 字节)。它只需要 111 字节的对齐,888 是 111 的倍数。它完美地放入。

看看发生了什么!仅仅通过重新排列我们声明中的字段顺序,填充就消失了。我们的有效数据总大小为 1+8=91+8=91+8=9 字节。在第一种情况下,该结构体占用了 161616 字节(111 字节数据,777 字节填充,888 字节数据)。在第二种情况下,它只占用了 999 字节(或者为了数组对齐,可能会向上取整到 161616 字节,但内部填充已经没有了)。这引出了一条强大的高效编程经验法则:​​在定义结构体时,按照字段的对齐要求降序声明它们。​​ 先放 8 字节类型,然后是 4 字节类型,接着是 2 字节,最后是 1 字节类型。通过这样做,你将限制最严格的项组合在一起,创建出一个自然满足其后较小、更灵活项对齐要求的布局。

这还不是故事的全部。结构体经常用于数组中。为了确保数组中的每一个元素都正确对齐,结构体本身的总大小必须是其最严格对齐要求的倍数。这可能导致​​尾部填充(tail padding)​​,即在结构体的最末端添加额外的字节。即使是我们“优化后”的结构体,其总大小也很可能被填充到 161616 字节,但关键在于我们已经消除了内部填充,这通常是浪费的最主要来源。

隐藏的成本:超越空间浪费

人们很容易将填充视为这里或那里浪费的几个字节而不屑一顾。但其后果会波及整个系统,不仅影响空间,还影响时间和能源。

首先,考虑​​带宽浪费​​。当你的程序需要一块不在 CPU 快速本地缓存中的数据时,它必须从慢得多的主内存中获取。它不会只获取你请求的那一个字节;它会获取一整条​​缓存行(cache line)​​,比如一个 646464 字节的数据块。现在,想象一条数据记录,其设计恰好能放入一个 646464 字节的缓存行。如果由于内部填充,该记录的 25%25\%25% 都是填充物,那么每当你从内存中获取该记录时,你都在将宝贵的内存带宽的四分之一用于传输……什么也没有。填充是死重,是堵塞 CPU 和内存之间数据高速公路的幽灵。

其次,这种浪费会以巨大的规模累积。操作系统的内存分配器为正在运行的应用程序划分出数百万个小内存块。每个块可能有自己的元数据头,而其中的有效载荷可能是一个带有内部填充的结构体。即使每个块平均只有少量填充,当乘以数百万次分配时,也可能导致整个系统中千兆字节(GB)级别的内存浪费。同样,垃圾回收器(其工作是查找和回收未使用的内存)也必须尊重对齐。当它将存活的对象复制到一个新的、干净的内存空间时,它必须在它们之间插入填充,这降低了空间的有效密度,并可能引发更频繁、代价高昂的垃圾回收。

机器中的幽灵:填充与正确性

填充字节到底是什么?它是内存中一个被编译器预留出来,但你的程序从未明确写入过的字节。它包含着……垃圾。它是该内存位置之前恰好存在的任何数据的幽灵。这对程序的正确性有着深刻而微妙的影响。

假设你有两个结构体 A 和 B,它们在逻辑上是相同的。你都将 id 字段设为 42,name 字段设为 "Feynman"。它们相等吗?逻辑上,是的。但如果你要求计算机用原始的、逐字节的内存比较(例如 C 语言中的 memcmp 函数)来比较它们呢?比较操作会检查 id 字段、name 字段,以及填充字节中的垃圾。如果 A 的填充中的随机垃圾与 B 的填充中的随机垃圾不同,memcmp 会报告它们不相等!

这揭示了计算机科学中的一个关键区别:对象的​​逻辑值(logical value)​​与其​​物理表示(physical representation)​​之间的差异。逻辑值是我们关心的、存储在命名字段中的信息。物理表示是内存中完整的字节块,包括不确定的填充。简单的内存比较作用于物理表示,因此是不正确的。真正的逻辑相等性只能通过仔细的、逐字段的比较来检查,完全忽略填充。

看不见的后门:填充与安全

这种“垃圾”不仅是正确性方面的一个麻烦;它还可能是一个巨大的安全漏洞。想象一个在操作系统内核中运行的敏感函数。它在其私有内存区域(栈)上创建一个结构体,以保存有关用户任务的一些信息——比如说,一个进程 ID 和一个开始时间。它填充了这两个字段。然后,为了返回这些信息,它将整个结构体——包括数据字段和填充——复制给用户程序 [@problem_gmid:3686257]。

在这个函数被调用之前,内核的栈上有什么?可能是另一个用户密码的片段、一块加密密钥,或者一个关键的内存地址。这些本应受到保护的数据,现在驻留在未初始化的填充字节中。当结构体被复制时,这些敏感信息就从受信任的内核跨越保护边界泄露给了不受信任的用户程序。这是一种经典的被称为​​信息泄露(information leak)​​的漏洞类型。

那些看似能解决这个问题的编译器选项,比如强制结构体“紧凑”(packed)而不进行填充,是一个陷阱。它们可能导致在某些 CPU 上性能严重下降甚至程序崩溃,并且它们违反了 ABI(应用程序二进制接口)——这是让不同编译代码片段协同工作的共享契约。

唯一真正稳健的解决方案非常简单:​​净化你的数据​​。在写入任何将跨越信任边界共享的结构体的字段之前,你必须首先将整个内存块擦除干净,通常是通过用零填充。这一行为将填充字节中不确定的、危险的垃圾转变为可预测、安全且确定性的状态。它关闭了后门。

统一视角:编译器的困境

这段穿越填充世界的旅程,最终归结为编译器自身所面临的复杂挑战。编译器是优化的专家。其最聪明的技巧之一是​​聚合体的标量替换(Scalar Replacement of Aggregates, SRA)​​,即尝试将一个结构体拆开,并将其单个字段保存在 CPU 的超高速寄存器中,而不是慢速内存里。

只要程序只逐个访问字段,这一招就非常有效。但如果程序对整个结构体执行原始的逐字节复制呢? 编译器现在陷入了困境。它将字段分散在各个寄存器中,但它必须正确模拟一个包含不存在的填充的内存操作。

它不能简单地复制这些字段,因为那样的字节数会不同。它也不能凭空制造填充(比如用零填充),因为那可能会改变程序的可观察行为——我们之前 memcmp 的例子现在可能错误地返回 true。必须保留原始程序的行为,即允许因填充垃圾不同而导致不等的可能性。

解决方案是一个优雅的抽象。编译器可以在内部将结构体建模为其标量字段集合外加一个​​不透明的填充块(opaque padding blob)​​。这个“块”是一个抽象的占位符。编译器不知道它里面是什么,但知道它存在并且有一定的大小。当访问一个字段时,这个块被忽略。但当请求进行整对象复制时,编译器知道它必须复制字段和这个不透明的块,从而为该对象实例保留其未知但稳定的内容。

从一个为速度而设计的简单硬件规则出发,我们揭示了一个贯穿性能、内存效率、程序正确性和系统安全的概念,最终对构建我们软件的编译器本身提出了一个微妙而优美的挑战。数据填充不仅仅是一个实现细节;它是我们代码的逻辑世界与机器的物理现实之间桥梁的根本性结果。

应用与跨学科联系

在我们对科学的探索中,我们常常发现,最深刻的原理也是最通用的。它们以我们从未预料到的面貌出现,跨越不同学科,揭示出一种深刻的、潜在的统一性。看似平淡无奇的数据填充概念亦是如此。你可能认为填充仅仅是“浪费的空间”,是数字世界里的包装泡沫——只是为了填补空隙。但这将是一个深刻的误判。实际上,填充是计算机科学家工具箱中最关键、最微妙的工具之一。它是软件的纯粹逻辑与硬件的物理现实之间的沉默谈判者。它是对性能不懈追求中的关键参与者,是操作系统稳健架构的基础元素,并且,正如我们将看到的,在密码学世界中是一把双刃剑。

让我们踏上这段穿越这些应用的旅程,看看这种“结构化的虚无”是多么地名副其实。

对速度的追求:为性能而填充

从本质上讲,现代计算机是一台速度惊人的引擎,但它有自己的怪癖。它不喜欢一次只“呷”一个字节的数据;它更喜欢大口、均匀地“吞”下数据块。这些数据块被称为缓存行(cache lines)。当单个芯片中的多个处理器——或“核心”——需要协同工作时,它们通过来回传递这些缓存行进行通信。现在,想象两位厨师在同一个厨房里工作。如果你只给他们一块小小的切菜板共享,用来处理他们所有的食材,他们将花费更多的时间互相等待,而不是真正地切菜。这正是​​伪共享(false sharing)​​的问题。当逻辑上独立、但被不同核心需要的数据片段,恰好位于同一条缓存行(即“切菜板”)上时,这些核心最终会争夺该缓存行的所有权。一个核心的写入会迫使另一个核心丢弃其副本并获取一个新的,即使它所关心的数据从未被触及。这种不断的来回传递,或称“乒乓效应”,可能会严重削弱并行程序的性能。

我们如何解决这个问题?我们给每个厨师自己的切菜板。我们可以通过策略性地插入填充,将每个核心的数据推到各自独立的缓存行上。如果一个变量是一个 8 字节的数字,而缓存行是 64 字节,我们可以在它后面添加 56 字节的填充,以确保下一个变量从一个新的缓存行开始。性能提升可能是巨大的,能将一个缓慢、充满竞争的程序转变为一个迅速、高效的程序。

但这个解决方案引入了一个有趣的权衡。虽然我们解决了“伪共享”的交通拥堵问题,但我们增加了内存占用。如果我们过于激进地填充,我们的数据结构就会变得臃肿。这些填充后结构体的整个数组可能再也无法装入处理器的高速缓存,导致因从较慢的主内存中获取数据而引起的另一种类型的减速。因此,一个聪明的编译器必须像一个谨慎的城市规划师一样行事,权衡一致性流量的成本与内存压力增加的成本,也许只在冲突严重且内存成本在预算范围内时才插入填充。

这种将数据对齐到硬件偏好的块大小的原则,其作用超出了仅仅避免冲突。现代处理器配备了​​SIMD(单指令多数据)​​单元,它们就像数据处理的多车道高速公路。它们可以同时对多个数据片段执行相同的操作——比如,两个数相加。但要使用这条高速公路,数据必须被完美地组织起来。这导致了数据布局上的一个根本选择:结构体数组(AoS)与数组结构体(SoA)。

在 AoS 布局中,你可能有一个“粒子”结构体的数组,每个结构体包含位置、速度和质量。为了满足每个结构体中快速 SIMD 向量字段的对齐要求,编译器可能需要插入大量的填充,从而使每个单独的结构体变得臃肿。然后,处理器必须加载这些笨重的结构体,包括所有的填充,即使它只需要一个字段。在 SoA 布局中,你有独立的、紧密打包的数组:一个用于所有位置,一个用于所有速度,等等。这种布局天然对 SIMD 处理友好,并且通常涉及的总填充量要少得多,从而导致内存碎片和性能上的巨大差异。即使在像科学计算这样处理巨大但大多为空(稀疏)矩阵的高度专业化领域,我们也能发现填充在起作用。为了加速计算,一个稀疏矩阵可能被存储为多个小而密的块的集合,而这些块中的每一个都被填充以与处理器的向量单元对齐,这代表了在存储开销和原始计算速度之间经过精心计算的权衡。

组织的艺术:系统软件中的填充

从硬件的底层向上,我们发现填充在操作系统的最基本任务中扮演着核心角色,尤其是在内存管理方面。当一个程序向操作系统请求一块内存时,​​动态内存分配器(dynamic memory allocator)​​便介入了。它的工作是找到一个符合请求的空闲内存块。但这并不像找到任何一个块那么简单。正如我们所见,请求可能带有对齐约束——例如,内存必须起始于一个 64 的倍数的地址,以便用于 SIMD 操作。

为了满足这一点,分配器可能会找到一个大小完美的空闲块,但它的起始地址是错误的。分配器唯一的选择是跳过块开头的几个字节,以达到下一个对齐的地址,然后将指向该位置的指针交给程序。那些被跳过的字节就成了​​前缀填充(prefix padding)​​——一种*内部碎片*的形式,即已分配的内存没有被程序使用。分配器自身的元数据(管理块所需的头部和尾部)以及可能将分配总大小向上取整到某个对齐边界倍数的规则,进一步加剧了这个问题。你得到的内存总是比你请求的要多。

这看起来很浪费,但一些最优雅的分配器设计却将这个问题反过来利用。一个“对齐感知”的分配器可能会注意到,通过合并两个相邻的空闲块,它创建了一个大的新块。然后它可以施展一个巧妙的技巧:如果该块对于未来的请求是未对齐的,它可以预先将初始的、未对齐的部分分割成一个微小的空闲块,留下一个现在已经完美对齐的较大块。这将本可能成为未来填充的部分,转换成了一块小但可重用的内存,这是系统设计中远见卓识的一个漂亮例子。

对正确性与安全性的探求:协议中的填充

当我们超越单台机器,进入网络和密码学的世界时,填充又呈现出另一种特性。在这里,它不仅仅关乎速度或组织,而是关乎正确性和安全性。

考虑一个​​远程过程调用(RPC)​​,即一台计算机上的程序调用另一台计算机上的函数。这两台计算机可能完全不同——一台可能是大端序(big-endian),先存储数字的最高有效字节,而另一台则是小端序(little-endian)。它们的编译器对于在内存中填充结构体的规则也可能不同。如果发送方只是从其内存中复制数据结构的原始字节并通过网络发送,接收方很可能会视其为完全的乱码。此外,发送方内存中的填充字节是未初始化的;它们可能包含先前操作遗留下的敏感数据片段。发送它们是一种危险的信息泄露。

解决方案是一个称为​​编组(marshalling)​​的过程。一个独立于任何单一机器架构的、规范的“线路格式”被定义出来。发送方一丝不苟地只挑出有意义的数据字段,将它们转换为标准的字节序(例如,大端序,或称“网络字节序”),并在线路上将它们紧密地打包在一起。接收方则执行相反的操作。在这种情况下,目标是消除特定于主机的、不可预测的填充,以确保正确性和安全性。

然而,在密码学中,我们常常发现自己出于非常不同的原因,刻意地将填充加回去。块密码(Block ciphers),作为现代加密的主力,对固定大小的数据块进行操作(例如,AES 为 16 字节)。如果你的消息不是块大小的完美倍数,你必须将其填充到下一个完整的块。这种填充不是任意的;它必须以一种确定性的、可逆的方式进行,以便接收方在解密后能够移除它。这个要求可能带来令人惊讶的算法后果。一个原本可以*原地(in-place)操作(在同一个缓冲区中用密文覆盖明文)的加密例程,可能会变成非原地(out-of-place)*操作,因为填充后的密文比原始明文缓冲区更大,无法容纳。

然而,这种加密填充可能是一把双刃剑。它的规则,如果设计得不够极其小心,可能会产生微妙的漏洞。考虑经典的​​长度扩展攻击(length-extension attack)​​。许多早期的加密哈希函数是使用 Merkle-Damgård 结构构建的。你可以将其想象成一台逐块处理消息、不断更新内部状态的机器。最终的哈希值是处理完最后一个块(包含特殊的填充序列和消息的原始长度)后最终状态的函数。现在,假设一个天真的系统通过简单地哈希一个密钥与消息的串联来创建消息认证码(MAC):MAC = H(key || message)。一个捕获了有效消息及其 MAC 的攻击者,虽然不知道密钥,但他们知道哈希函数的输出。由于填充的工作方式,他们可以将这个已知的哈希值视为一个中间状态。然后他们可以在消息后面附加 key || message 的原始填充,再跟上他们自己的恶意命令(例如,“=Eve”)。通过从已知的中间状态继续哈希计算,他们可以成功地为这个新的、更长的、欺诈性的消息计算出一个有效的 MAC——所有这一切都无需知道密钥。填充方案本身成为了实现伪造的工具。正是这个漏洞促使了像 HMAC(基于哈希的消息认证码)这样更稳健的结构被发明出来,它以一种能免疫此类攻击的方式使用密钥。

从硬件的最底层到应用安全的最高层,数据填充是一个具有惊人深度和重要性的概念。它证明了一个事实:在计算中,如同在物理学中一样,事物之间的空间与事物本身同样重要。