18.4 结构体的成员封装

结构体做的一个重要的事情就是封装了成员,让我们看看简单的例子:

  1. #include <stdio.h>
  2. struct s
  3. {
  4. char a;
  5. int b;
  6. char c;
  7. int d;
  8. };
  9. void f(struct s s)
  10. {
  11. printf ("a=%d; b=%d; c=%d; d=%d", s.a, s.b, s.c, s.d);
  12. };

如我们所看到的,我们有2个char成员(每个1字节),和两个int类型的数据(每个4字节)。

18.4.1 x86

编译后得到:

  1. _s$ = 8 ; size = 16
  2. ?f@@YAXUs@@@Z PROC ; f
  3. push ebp
  4. mov ebp, esp
  5. mov eax, DWORD PTR _s$[ebp+12]
  6. push eax
  7. movsx ecx, BYTE PTR _s$[ebp+8]
  8. push ecx
  9. mov edx, DWORD PTR _s$[ebp+4]
  10. push edx
  11. movsx eax, BYTE PTR _s$[ebp]
  12. push eax
  13. push OFFSET $SG3842
  14. call _printf
  15. add esp, 20 ; 00000014H
  16. pop ebp
  17. ret 0
  18. ?f@@YAXUs@@@Z ENDP ; f
  19. _TEXT ENDS

如我们所见,每个成员的地址都按4字节对齐了,这也就是为什么char也会像int一样占用4字节。为什么?因为对齐后对CPU来说更容易读取数据。

但是,这么看明显浪费了一些空间。 让我们能用/Zp1(/Zp[n]代表结构体边界值为n字节)来编译它:

清单18.12: MSVC /Zp1

  1. _TEXT SEGMENT
  2. _s$ = 8 ; size = 10
  3. ?f@@YAXUs@@@Z PROC ; f
  4. push ebp
  5. mov ebp, esp
  6. mov eax, DWORD PTR _s$[ebp+6]
  7. push eax
  8. movsx ecx, BYTE PTR _s$[ebp+5]
  9. push ecx
  10. mov edx, DWORD PTR _s$[ebp+1]
  11. push edx
  12. movsx eax, BYTE PTR _s$[ebp]
  13. push eax
  14. push OFFSET $SG3842
  15. call _printf
  16. add esp, 20 ; 00000014H
  17. pop ebp
  18. ret 0
  19. ?f@@YAXUs@@@Z ENDP ; f

现在,结构体只用了10字节,而且每个char都占用1字节。我们得到了最小的空间,但是反过来看,CPU却无法用最优化的方式存取这些数据。 可以容易猜到的是,如果这个结构体在很多源代码和对象中被使用的话,他们都需要用同一种方式来编译起来。 除了MSVC /Zp选项,还有一个是#pragma pack编译器选项可以在源码中定义边界值。这个语句在MSVC和GCC中均被支持。 回到SYSTEMTIME结构体中的16位成员,我们的编译器怎么才能把它们按1字节边界来打包? WinNT.h有这么个代码:

清单18.13:WINNT.H

#include "pshpack1.h"

和这个:

清单18.14:WINNT.H

#include "pshpack4.h" // 4 byte packing is the default

文件PshPack1.h看起来像

清单18.15: PSHPACK1.H

  1. #if ! (defined(lint) || defined(RC_INVOKED))
  2. #if ( _MSC_VER >= 800 && !defined(_M_I86)) || defined(_PUSHPOP_SUPPORTED)
  3. #pragma warning(disable:4103)
  4. #if !(defined( MIDL_PASS )) || defined( __midl )
  5. #pragma pack(push,1)
  6. #else
  7. #pragma pack(1)
  8. #endif
  9. #else
  10. #pragma pack(1)
  11. #endif
  12. #endif /* ! (defined(lint) || defined(RC_INVOKED)) */

这就是#pragma pack处理结构体大小的方法。

18.4.2 ARM+优化Keil+thumb模式

清单18.16

  1. .text:0000003E exit ; CODE XREF: f+16
  2. .text:0000003E 05 B0 ADD SP, SP, #0x14
  3. .text:00000040 00 BD POP {PC}
  4. .text:00000280 f
  5. .text:00000280
  6. .text:00000280 var_18 = -0x18
  7. .text:00000280 a = -0x14
  8. .text:00000280 b = -0x10
  9. .text:00000280 c = -0xC
  10. .text:00000280 d = -8
  11. .text:00000280
  12. .text:00000280 0F B5 PUSH {R0-R3,LR}
  13. .text:00000282 81 B0 SUB SP, SP, #4
  14. .text:00000284 04 98 LDR R0, [SP,#16] ; d
  15. .text:00000286 02 9A LDR R2, [SP,#8] ; b
  16. .text:00000288 00 90 STR R0, [SP]
  17. .text:0000028A 68 46 MOV R0, SP
  18. .text:0000028C 03 7B LDRB R3, [R0,#12] ; c
  19. .text:0000028E 01 79 LDRB R1, [R0,#4] ; a
  20. .text:00000290 59 A0 ADR R0, aADBDCDDD ; "a=%d; b=%d; c=%d; d=%d
  21. "
  22. .text:00000292 05 F0 AD FF BL __2printf
  23. .text:00000296 D2 E6 B exit

我们可以回忆到的是,这里它直接用了结构体而不是指向结构体的指针,而且因为ARM里函数的前4个参数是通过寄存器传递的,所以结构体其实是通过R0-R3寄存器传递的。

LDRB指令将内存中的一个字节载入,然后把它扩展到32位,同时也考虑它的符号。这和x86架构的MOVSX(参考13.1.1节)基本一样。这里它被用来传递结构体的a、c两个成员。

还有一个我们可以容易指出来的是,在函数的末尾处,这里它没有使用正常的函数尾该有的指令,而是直接跳转到了另一个函数的末尾! 的确,这是一个相当不同的函数,而且跟我们的函数没有任何关联。但是,他却有着相同的函数结尾(也许是因为他也有5个本地变量(5 x 4 = 0x14))。而且他就在我们的函数附近(看看地址就知道了)。事实上,函数结尾并不重要,只要函数好好执行就行了嘛。显然,Keil决定要重用另一个函数的一部分,原因就是为了优化代码大小。普通函数结尾需要4字节,而跳转指令只要2个字节。

18.4.3 ARM+优化XCode(LLVM)+thumb-2模式

清单18.17: 优化的Xcode (LLVM)+thumb-2模式

  1. var_C = -0xC
  2. PUSH {R7,LR}
  3. MOV R7, SP
  4. SUB SP, SP, #4
  5. MOV R9, R1 ; b
  6. MOV R1, R0 ; a
  7. MOVW R0, #0xF10 ; "a=%d; b=%d; c=%d; d=%d
  8. "
  9. SXTB R1, R1 ; prepare a
  10. MOVT.W R0, #0
  11. STR R3, [SP,#0xC+var_C] ; place d to stack for printf()
  12. ADD R0, PC ; format-string
  13. SXTB R3, R2 ; prepare c
  14. MOV R2, R9 ; b
  15. BLX _printf
  16. ADD SP, SP, #4
  17. POP {R7,PC}

SXTB(Singned Extend Byte,有符号扩展字节)和x86的MOVSX(见13.1.1节)差不多,但是它不是对内存操作的,而是对一个寄存器操作的,至于剩余的——都一样。