百度地图|C语言:整合数据实现的过程( 二 )


为了验证这个问题 , 我们在左侧 C 代码的第 12 行 , 通过 sizeof 运算符将结构 S 的大小打印了出来 。 按照结构 S 的定义方式和我们对“连续”一词的理解 , 它在 x86-64 平台上的大小应该为 17 字节 。 其中 , 字符指针 8 字节、字符 1 字节 , 最后的长整型数值 8 字节 。 但查看右侧黄框内的汇编代码后 , 你会发现事实并非如此:每一个结构 S 的对象竟然占用了多达 24 字节的内存 。 那这是为什么呢?
通过整理对象 s 在初始化时使用的汇编代码 , 我们可以得到其内部各个成员字段在栈内存中的实际布局情况 。 经过整理后 , 可以得到下面这张图:

从左至右 , 这张图代表着栈内存的增长方向(高地址 -> 低地址) 。 其中 , 寄存器 rsp 指向栈顶的低地址 , 而 rbp 寄存器则指向栈帧开始处的高地址 。 按照汇编代码中的指令 , 字符指针 p 位于 [rbp-32
处 , 并占用 8 个字节;字符 c 位于 [rbp-24
处 , 并占用 1 个字节 。 而长整型变量 x 则位于 [rbp-16
处 , 并占用 8 个字节 。
可以看到 , 编译器实际上并没有按照严格连续的方式来“摆放”这三个数据值 , 其中 , [rbp-25
到 [rbp-16
中间的 7 个字节并没有存放任何数据 。 而编译器这样做的一个重要目的 , 便是为了“数据对齐” 。
内存数据对齐对于现代计算机而言 , 当内存中需要被读写的数据 , 其所在地址满足“自然对齐”时 , CPU 通常能够以最高的效率进行数据操作 。 而所谓自然对齐 , 是指被操作数据的所在地址为该数据大小的整数倍 。 比如在 x86-64 架构中 , 若一个 int 类型的变量 , 其值在内存中连续存放 , 且最低有效位字节(LSB)的所在地址为 4 的整数倍 , 那我们就可以说该变量的值在内存中是对齐的 。
自然对齐为什么能够发挥 CPU 最大的内存读取效率呢?这实际上与 CPU 和 MMU(内存管理单元)等内存读写相关核心硬件发展过程中的诸多限制性因素有关 。 比如 , 对于某些古老的 Sun SPARC 和 ARM 处理器来说 , 它们只能访问位于特定地址上的对齐数据 , 而对于非对齐数据的访问 , 则会产生异常 。 相反 , 有些处理器则能够支持对非对齐数据的访问 , 但由于设计工艺上的限制 , 对这些数据的访问需要花费更多的时钟周期 。
因此 , 为了让代码适应不同处理器的“风格” , 保证内存中的数据满足自然对齐要求 , 就成了大多数编译器在生成机器指令时达成的一个默认共识 。 哪怕在如今的现代 x86-64 处理器上 , 访问非对齐数据所产生的性能损耗在大多数情况下已微不足道 。
填充字节让我们再回到之前那个例子 。 可以看到的是 , 为了确保对象 s 中所有成员字段在栈内存中都满足自然对齐的要求 , 编译器会插入额外的“填充字节” , 来动态调整结构对象中各个字段对应数据的起始位置 。
除此之外 , 在某些情况下 , 即使结构对象内各个数据成员都满足自然对齐的要求 , 额外的填充字节也可能会被添加 。 比如下面这个例子:
struct Foo {
char *p; // 8 bytes.
char c; // 1 bytes.
// (padding): 7 bytes.
;
这里可以看到 , 结构 Foo 中的两个成员字段在默认情况下已经满足自然对齐的要求(假设字符指针 p 的存放起始位置满足 8 字节对齐) 。 但实际上 , 在通过 sizeof 运算符对它进行求值时 , 我们会得到 16 字节大小的结果 , 而非直观的 9 字节 。
之所以会出现这样的现象 , 就是因为编译器想要保证这一点:当结构对象被连续存放时(比如通过数组) , 前一个对象的结束位置正好可以满足后一个对象作为起始位置时的自然对齐要求 。 而这也就要求结构对象本身的大小必须是其内部最大成员大小的整数倍 。 因此 , 编译器会在结构最后一个成员的后面再填充适当字节 , 以满足这个条件 。 可以说 , 在这种情况下的结构对象 , 已经满足了在不同场景下的自然对齐条件 , 因此 , 此时的结构大小也会被作为 sizeof 运算符的最终计算结果 。
联合最后 , 我们再来看看 C 语言中的第三种功能强大的数据类型 , “联合(Union)” 。 联合与“结构”在语法上的使用方式十分类似 , 只不过要把对应的语法关键字从 struct 更换为 union。
除此之外 , 二者还有一个较大的区别 , 我们可以从“联合”这个名字谈起 。 顾名思义 , “联合”就意味着定义在该结构内的所有数据字段 , 将会联合起来共享同一块内存区域 。 还是先来看一段代码:

这里 , 在左侧的 C 代码中 , 我们使用 “Tagged Union” 的模式对联合进行了封装 。 与结构不同 , 对于每一个单独的联合对象来说 , 在某一时刻其内部哪一个字段正在生效 , 我们无从得知 。 因此 , Tagged Union 的使用方式要求我们为每一个联合设置单独的“标签” , 用来明确指出当前联合内部正在生效的字段 。 在这种情况下 , 我们便需要将这个标签与联合进行封装 , 来将它们进行“绑定” 。