我们知道,字节是 CPU 内存寻址的最小单位,但并非 CPU 内存寻址的最有效单位。现代 CPU 都要求访问长度大于一字节的数据对象要对齐(aligned)。何为对齐?定义:若数据对象 O 的内存地址 A 和其大小 s 满足 A mod s = 0,则称为对该数据对象的访问是对齐的。(请参阅参考文献1)- 对2字节(半字,16位)数据的访问,所有偶数地址都是对齐的;
- 对4字节(整字,32位)数据的访问,所有 4 的倍数的地址都是对齐的;
- 对8字节(双字,64位)数据的访问,所有 8 的倍数的地址都是对齐的。
以上这些数据对象涵盖了大部分 Delphi 的数据类型:- 整型(ShortInt, SmallInt, FixedInt, Integer, Int64, Byte, Word, FixedUInt, Cardinal, UInt64)
- 布尔型(Boolean, ByteBool, WordBool, LongBool)
- 字符型(AnsiChar, WideChar, UCS2Char, UCS4Char)
- 浮点型(Single, Double, Real, Comp, Currency)
有些 CPU(譬如 Intel 系列)允许对非对齐数据对象的访问,但访问效率降低了。因为访问对齐的数据对象,只需要一个访问周期,而访问非对齐的数据对象,则需要两个访问周期。也有些 CPU(譬如 ARM)禁止非对其的数据访问,若访问非对齐的数据对象,则导致崩溃。Delphi 提供了一个编译指令用来控制对齐方式:${ALIGN},简写为 {$A}- {$ALIGN 1},{$A1},{$A-},{$ALIGN OFF}:1字节对齐,相当于无对齐
- {$ALIGN 2},{$A2}:2字节对齐,偶数地址对齐
- {$ALIGN 4},{$A4}:4字节对齐,4的倍数地址对齐
- {$ALIGN 8},{$A8},{$A+},{$ALIGN ON}:8字节对齐,8的倍数地址对齐,也是 Delphi 的默认对齐方式
- {$ALIGN 16},${A16}:16字节对齐,16的倍数地址对齐(OS X 应用要求栈是 16 字节对齐)
type TMyRec = record i: Integer; c: AnsiChar; d: Double; a: array[0..1] ofInteger; p: ^Integer;end;
上面我们定义了一个记录类型 TMyRec,其包含一个整型、一个单字节字符型、一个双精度浮点型、一个长度为2的整型数组,一个指向整型的指针。下面我们写些代码来显示这个记录类型中各个字段的长度以及相对于记录起始地址的偏移量,从而测试不同对齐方式的影响。可以看出,各个字段的 Offset 与其 Size 都完美地匹配文章开头给出的对齐定义所要求的条件:A mod s = 0。之所以在默认情况下就给出了完美的对齐方式,是因为 Delphi 编译器的默认设置就是 {$A8}:在不改变编译器设置并且不增加编译指令的情况下,修改记录的类型定义:type TMyRec = packed record i: Integer; c: AnsiChar; d: Double; a: array[0..1] ofInteger; p: ^Integer;end;
在 record 前面增加 packed 关键字,则表示以紧凑方式编排记录的各个字段,相当于使用 {$ALIGN OFF} 关闭了对齐方式。此时的运行结果如下:我们看到,除了字段 i 和 c 满足对齐之外,c 之后的所有字段都不满足对齐要求,也就是说,c 之后的所有字段(双精度浮点数 d、整型数组 a、整型指针 p)都不再对齐。在 Intel CPU 系列的机器上运行,不会影响结果,但会影响性能。如果在其他 CPU 上运行,或许会导致程序崩溃。r 的起始位置是 $0095158C,结束位置是 $009515A4,正好是 25 个字节。以下是各字段的地址、字段、长度以及值:- $0095158C,r.i,4字节,$00000A(整型数 10)
- $00951590,r.c,1字节,$43(字符 'C')
- $00951591,r.d,8字节,$400B333333333333(双精度浮点数 3.4)
- $00951599,r.a,4字节,$00(a[0])
- $009515A1,r.p,4字节,$0095158C(指向 r.i 即 r 的起始位置的指针)
如果记录 r 像上面这样没有对齐,则 CPU 访问 r.i 和 r.c 都正常,但访问后续的字段都很麻烦,譬如访问 r.d,因为 r.d 是 Double 类型,需要8字节对齐,而 r.d 的地址 $00951591 mod 8 = 1,因此访问 r.d 是非对齐访问,需要两次才能完整取到 r.d 的值。对于允许非对齐访问的 CPU,取到 r.d 的值需要两次访问,对于不允许非对齐访问的 CPU,则直接导致程序崩溃。解决的办法是:去掉 packed 关键字(从而使用编译器默认的对齐方式),或者手工添加填充物,以满足对齐的要求,如下所示:type TMyRec = packed record i: Integer; c: AnsiChar; _Padding: array[0..2] of Byte; d: Double; a: array[0..1] ofInteger; p: ^Integer;end;
上述修改过的代码,是在字段 c 的后面添加 3 字节的填充物,以使得 d 的 Offset = 8,从而满足了对齐的要求。这样修改的代码,不论是 $ALIGN 编译器指令如何设置,也不论是 32 位还是 64 位,TMyRec 中各字段的偏移量都是一致的。测试结果如下:32 位程序和 64 位程序的唯一区别是,r.p.Size 在 32 位是 4 字节长,而在 64 位时则是 8 字节长,但 r.p.Offset 都是一样的。所以,不论是 32 位还是 64 位,对 TMyRec 记录的访问都是对齐的。在上一篇文章中,我们曾谈到如下的 TFieldInfo 类型:type TFieldInfo = packed record TypeInfo: PPTypeInfo;caseIntegerof0: (Offset: Cardinal);1: (_Dummy: NativeUInt);end;
对于 32 位程序,PPTypeInfo 指针的长度是 4 字节,Cardinal 和 NativeUInt 也都是 4 字节,所以 TFieldInfo 的长度是 8 字节。对于 64 位程序,PPTypeInfo 指针的长度是 8 字节,Cardinal 是 4 字节,NativeUInt 是 8 字节,所以 TFieldInfo 的长度是 16 字节。TFieldInfo 记录类型这样设计,是为了保证 TFieldTable 类型中的 TFields 字段(TFieldInfo 类型的数组)中数组元素(TFieldInfo)的 8 字节对齐。Delphi 记录类型(以及 C/C++ 中的 struct 类型)中的字段对齐是一个重要问题,需要引起重视。依赖于编译器的设置并不总是可靠的,况且 Delphi 并没有判断当前对齐方式的编译器指令,最可靠的办法是使用 packed record,然后手工引入对齐填充物。但这样做需要特别小心,对于个字段的偏移量要心中有数,并且对 32 位和 64 位的编译结果都要进行充分的测试。1. John L. Hennessy, David A. Patterson, Computer Architecture - A Quantitative Approach, Sixth Edition, Morgan Kaufmann, 2019