18.4 结构体的成员封装
结构体做的一个重要的事情就是封装了成员,让我们看看简单的例子:
#include <stdio.h>
struct s
{
char a;
int b;
char c;
int d;
};
void f(struct s s)
{
printf ("a=%d; b=%d; c=%d; d=%d", s.a, s.b, s.c, s.d);
};
如我们所看到的,我们有2个char成员(每个1字节),和两个int类型的数据(每个4字节)。
18.4.1 x86
编译后得到:
_s$ = 8 ; size = 16
?f@@YAXUs@@@Z PROC ; f
push ebp
mov ebp, esp
mov eax, DWORD PTR _s$[ebp+12]
push eax
movsx ecx, BYTE PTR _s$[ebp+8]
push ecx
mov edx, DWORD PTR _s$[ebp+4]
push edx
movsx eax, BYTE PTR _s$[ebp]
push eax
push OFFSET $SG3842
call _printf
add esp, 20 ; 00000014H
pop ebp
ret 0
?f@@YAXUs@@@Z ENDP ; f
_TEXT ENDS
如我们所见,每个成员的地址都按4字节对齐了,这也就是为什么char也会像int一样占用4字节。为什么?因为对齐后对CPU来说更容易读取数据。
但是,这么看明显浪费了一些空间。 让我们能用/Zp1(/Zp[n]代表结构体边界值为n字节)来编译它:
清单18.12: MSVC /Zp1
_TEXT SEGMENT
_s$ = 8 ; size = 10
?f@@YAXUs@@@Z PROC ; f
push ebp
mov ebp, esp
mov eax, DWORD PTR _s$[ebp+6]
push eax
movsx ecx, BYTE PTR _s$[ebp+5]
push ecx
mov edx, DWORD PTR _s$[ebp+1]
push edx
movsx eax, BYTE PTR _s$[ebp]
push eax
push OFFSET $SG3842
call _printf
add esp, 20 ; 00000014H
pop ebp
ret 0
?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
#if ! (defined(lint) || defined(RC_INVOKED))
#if ( _MSC_VER >= 800 && !defined(_M_I86)) || defined(_PUSHPOP_SUPPORTED)
#pragma warning(disable:4103)
#if !(defined( MIDL_PASS )) || defined( __midl )
#pragma pack(push,1)
#else
#pragma pack(1)
#endif
#else
#pragma pack(1)
#endif
#endif /* ! (defined(lint) || defined(RC_INVOKED)) */
这就是#pragma pack处理结构体大小的方法。
18.4.2 ARM+优化Keil+thumb模式
清单18.16
.text:0000003E exit ; CODE XREF: f+16
.text:0000003E 05 B0 ADD SP, SP, #0x14
.text:00000040 00 BD POP {PC}
.text:00000280 f
.text:00000280
.text:00000280 var_18 = -0x18
.text:00000280 a = -0x14
.text:00000280 b = -0x10
.text:00000280 c = -0xC
.text:00000280 d = -8
.text:00000280
.text:00000280 0F B5 PUSH {R0-R3,LR}
.text:00000282 81 B0 SUB SP, SP, #4
.text:00000284 04 98 LDR R0, [SP,#16] ; d
.text:00000286 02 9A LDR R2, [SP,#8] ; b
.text:00000288 00 90 STR R0, [SP]
.text:0000028A 68 46 MOV R0, SP
.text:0000028C 03 7B LDRB R3, [R0,#12] ; c
.text:0000028E 01 79 LDRB R1, [R0,#4] ; a
.text:00000290 59 A0 ADR R0, aADBDCDDD ; "a=%d; b=%d; c=%d; d=%d
"
.text:00000292 05 F0 AD FF BL __2printf
.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模式
var_C = -0xC
PUSH {R7,LR}
MOV R7, SP
SUB SP, SP, #4
MOV R9, R1 ; b
MOV R1, R0 ; a
MOVW R0, #0xF10 ; "a=%d; b=%d; c=%d; d=%d
"
SXTB R1, R1 ; prepare a
MOVT.W R0, #0
STR R3, [SP,#0xC+var_C] ; place d to stack for printf()
ADD R0, PC ; format-string
SXTB R3, R2 ; prepare c
MOV R2, R9 ; b
BLX _printf
ADD SP, SP, #4
POP {R7,PC}
SXTB(Singned Extend Byte,有符号扩展字节)和x86的MOVSX(见13.1.1节)差不多,但是它不是对内存操作的,而是对一个寄存器操作的,至于剩余的——都一样。