老“码”识途
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.5 无法沟通——对齐的错误

1.5.1 结构体对齐

在1.4节我们了解了结构体的内存布局,简单地说,就是依照定义顺序依次为每个成员变量分配空间。下面从一个小例子展开:

struct Person{
  char c;
  short a;
}
…
printf(“size of Person=%d\n”, sizeof(Person));

其结果是什么?按照前面的结论,就是sizeof(short)+sizeof(char)=3字节。可是实证的结果是4字节。这1字节是从哪里冒出来的?代码如下:

Person p;
p.a = 1;
p.c = 2;

图1.51

在p.a=1处设置断点,执行。在监视界面中输入&p.c和&p.a的值:0x0012ff78和0x0012ff7a。其对应内存布局见图1.51。

c和a之间相差了2字节,而理论上c只占用了1字节,那多余的1字节是在哪里?有何用处?首先我们要确定多余字节的位置。观察对变量c赋值的反汇编:

p.c = 2;
    mov  byte ptr [ebp-4], 2

它对ebp–4地址赋值了2,而且从指令看,赋值长度是1字节(mov byte ptr)。在监视器中输入(void *)(ebp-4),可知其值是0x0012ff78,因此成员变量c处于结构体首部,占用了1字节。为了进一步确认,我们在p.c=2处设置断点,执行到该处中断,并将地址0x0012ff78输入到内存窗体,并故意将0x0012ff78的前2字节修改为0,然后看执行mov指令后到底修改了哪些字节,见图1.52。单步执行,结果见图1.53。

图1.52

图1.53

从结果看,只有第1字节被修改为02了,第2字节没有动,可知对于Person而言,第一个成员变量c虽然占用了2字节,但第2字节似乎并没有用到。我们需要弄清这种现象的原因。

对齐和结构体成员变量多了一些字节有何联系呢?我们假定刚才的Person变量p放在偶数地址,其第1字节存放char成员变量c,如果第2字节存放a,而第2字节是奇数地址,存放short类型的a,奇数地址无法被2整除(a是两字节大小),就造成成员变量a存放的位置没有对齐。当然,如果p放在奇数地址,似乎也可以满足对齐条件。但当我们将所有变量整体来考察,必须将一个结构体内部按照某种方式排列,所有的变量就能够满足对齐要求。这是什么规律?在分析它之前,先看看编译器的另外一个编译选项,即选择按多少字节对齐,见图1.54。

对齐:基本数据(如单字节、双字节、四字节整数)存放处的地址必须能被自己数据类型的大小整除。比如,双字节整数存放的地址必须被2整除,四字节整数存放的地址要被4整除。如果不满足要求,不同体系结构的CPU反应不同。有的会直接结束进程的执行,有的可以容忍,但访问速度变慢(如x86)。

图1.54

先来看看它的作用,选择按16字节对齐,Person的大小是4。选择8字节、4字节、2字节对齐,结果都是4。选择1字节对齐,结果是3,即没有无效填充字节,所见即所得!

不妨来探索一下结构体对齐的规律。从前面来看,当对齐方式选为1字节时,影响了对齐结果,那么不妨将成员变量改成int类型,来看在不同对齐方式下的影响。

struct Person {
  char c;
  int a;
}

在1、2、4、8、16字节的对齐方式下,其大小分别为5、6、8、8、8,而成员变量a相对结构体头部的偏移量分别是1、2、4、4、4。可知不同的对齐方式影响到了填充字节的多少。计算填充后c所占长度,见表1.1。

首先,变量c所占的长度不会大于结构体中最长的字段a的大小,a为short时,c所占长度不大于2;a为int时,c所占长度不大于4。其次,占用长度能否达到最大值还取决于对齐的长度,似乎当对齐长度小于成员变量的最大长度时,c所占长度就是对齐的长度。如a为short时,如果对齐选1,那么c所占长度为1(1<2),而a为int时,对齐长度选1,c所占长度为1(1<4),对齐长度选2,c所占长度为2(2<4)。所以,c所占长度 = min{max{sizeof(成员变量)}, 对齐长度}。其中, min和max代表取最小和最大值,max{sizeof(成员变量)}代表取所有成员变量大小的最大值。

为了证实这个猜想,我们将结构体修改为:

struct Person{
  char c;
  double a;
}

下面来看在成员变量的最大长度为8时的状况,见表1.2。

表1.1 成员变量c所占长度(一)

表1.2 成员变量c所占长度(二)

a变成double时(占8字节),其规律确实如我们所推断。现在的问题是,每个成员变量都会填充吗?从道理上讲不应该这样。我们再做实验来验证:

struct Person{
  char c1;
  char c2;
  short a;
}

这时,不论选择多少对齐,整个长度就是4,每个成员字段所占的长度都是其长度本身,填充字节不见了。再将a的类型改为int:

struct Person{
  char c1;
  char c2;
  int a;
}

对齐为1、2、4、8、16时,结构体长度分别为6、6、8、8、8,a离头部的偏移量为2、2、4、4、4。也就是说,c1和c2一起所占长度分别为2、2、4、4、4字节。似乎c1和c2连续存放作为一体进行了填充,在对齐为4、8、16时,在它们后面填充了2字节。结合前面分析填充字节的规律,我们可以这样推断:

首先选定一个盒子,然后依序将字段往盒子中放,当盒子放不下后,再用另一个盒子存放,直至所有字段都存放完毕。

这里有几个要确定的要素:盒子的长度、放入盒子中的限制条件。从上面的例子看,这个盒子的长度如下计算:

盒子长度= min{ max{ sizeof(成员变量) }, 对齐长度 }

其中,min和max代表取最小值和最大值,max{ sizeof(成员变量) }代表取所有成员变量大小的最大值。

而放入盒子的限制条件会怎样?例如:

struct Person{
  char c1;
  short s;
  int a;
}

选中对齐为4时,盒子长度= {成员变量最大长度(4字节, int a), 对齐为4} = 4字节。我们打印后发现,该结构体的长度为8。而字段s距头部偏移量为2,不是直接放在c1后,在c1后有一个填充字节,见图1.55(a)。如果将结构体改为

图1.55

struct Person{
  char c1;
  char c2;
  short s;
  int a;
}

结构体长度依然是8,字段s依然在头部偏移2字节的地方,见图1.55(b)。

对比两图,我们发现,1字节的数据可任意放入盒子中,如c1、c2的放置,2字节的数据只能放在离盒子头部偏0和2字节的位置,如字段s只能放置偏2字节的地方。在图1.55(a)中,c1与s之间就产生了一个填充字节。由此,我们推断成员变量放入盒子的规则如下:

离盒子头部偏移字节数= n×sizeof(成员变量) (n=0,1,2,…)

比如,对于short类型的变量,如盒子长度为4,只能放置偏移0和2字节的地方,见图1.56(a);对于short类型的变量,如盒子长度为8,只能放置偏移0、2、4和6字节的地方,见图1.56(b);对于int类型的变量,如盒子长度为4,只能放置偏移0字节的地方,见图1.56(c);对于int类型的变量,如盒子长度为8,只能放置偏移0和4字节的地方,见图1.56(d)。

图1.56

对于这个推断,我们修改结构体定义来证明:

struct Person{
  char c1;
  short s;
  char c2;
  int a;
}

在对齐为4的情况下,盒子长4,字段c1放在首部,字段s只能放在偏2字节的地方,c1与s之间有一个填充字节,因此第一个盒子用完。分配第二个盒子,c2放在其首部,剩下3字节无法放字段a,因此只有再次分配第3个盒子,c2后的填充位占3字节,字段a放入第三个盒子。所以,总长为3×4=12字节。布局见图1.57。

图1.57

通过设置断点,在监视窗口中验证,见图1.58。

最后,我们正式得出对齐的规律:

首先选定一个盒子,然后依序将字段往盒子中放,当盒子放不下后,又用下一个盒子存放,直至所有字段都存放完毕。

图1.58

其中相关限制条件为:① 盒子长度 = min{max{sizeof(成员变量)}, 对齐长度},其中min和max代表取最小和最大值,max{sizeof(成员变量)}代表取所有成员变量大小的最大值;② 字段放入盒子的可放置位置如下:

离盒子头部偏移字节数 = n×sizeof(成员变量) (n=0,1,2,…)

1.5.2 无法沟通

结构体对齐影响我们什么?似乎什么也没有,要求取其大小时,用sizeof即可;平常我们也不需理解每个成员变量相对其首部的偏移量,访问一个字段时,编译器会自动计算偏移量进行访问。

下面来看一个网络编程中经常使用的技巧。报文一般有一个头部,在拿到头部协议格式后,我们会将它翻译成一个结构体。下面给出了简化的IP头部的结构体表示(从Linux简化):

struct ip_hdr {
  char version : 4;               //该字段为4位
  char ihl : 4;                   //该字段为4位
  unsigned char tos;
  unsigned short tot_len;
  unsigned short id;
  unsigned short frag_off;
  unsigned char ttl;
  unsigned char protocol;
  unsigned short check;
  unsigned int saddr;
  unsigned int daddr;
};

假定有如下协议:第1字节为标志位,代表报文类型;第2~5字节为整数,代表报文数据部分长度;其后是报文数据部分。

我们会将协议转换为一个结构体:

struct hdr{
  char flag;
  int len
};

下面是一网络程序伪代码,分为发送方和接收方代码。发送方的代码见DM1-26。

                                  DM1-26
1    char buf[128];
2    int * pdataLen;
3    buf[0] = 1;                 //设定标志类型
4    pdataLen = (int *)&buf[1];
5    * pdataLen = htonl(12);      //设定数据长度
6    //填充数据部分12字节
7    buf[5] = 1;
8    buf[6] = 2;
    ...
9    buf[16] = 12;
    ...
10   //将buf开始的17字节发送出去,包括头部5字节、数据12字节
11   send(sockethnd, buf, 5 + 12, 0);

第3行填写标志位flag,第4行将第2字节的地址强制为整数指针,第5行将数据长度12设定到第2字节开始的4字节。其中,为了让报文在大端机/小端机中处理整数的表示统一化,整数12将用htonl转换为网络顺序表示(网络顺序就是大端机顺序),报文接收方用ntohl将网络顺序再次转换为本地顺序。之后从buf偏移5字节处填写数据部分的内容。

这个协议头部很简单,只有两个字段,如果自己计算协议字段的偏移量还可以接受。但如果像上面IP协议那样复杂的头部,如此处理既麻烦又容易出错,因此我们常常采用一个技巧:将buffer的头部地址强制转换为代表协议头部的结构体指针,然后用该指针去访问结构体成员。该方法利用以下事实:访问结构体字段,将由编译器自动计算字段偏移量(见1.4.2节),不需我们手动加偏移,从而极大简化了编程。

接收方代码见DM1-27。第4行将从网络接收5字节数据,放入buf中,它是协议头部。第5行将收到的数据起始地址(buf的地址),强制转换为协议头部指针struct hdr *类型,这样就可以用phdr->len来访问任何一个协议成员字段了,非常简洁。第7行将收到的len字段的值用ntohl从网络顺序转换为本地顺序。

                                  DM1-27
1  struct hdr * phdr;
2  char buf[128];
3  int dataLen;
4  recv(sockethnd, buf, 5, 0);                           //接收协议头部5字节
5  phdr = (struct hdr *)buf;
6  printf("hdr flag is %d\n",phdr->flag);                //获取协议头部类型flag
7  dataLen = ntohl(phdr->len);                           //获取协议头部关于数据的长度信息
8  printf("data length is %d\n",dataLen);

可是当这两个程序联调时,结果是错误的。为什么?如果接收方用发送方的方法改写程序,即手动计算偏移量,buf[1]的地址为len的起始地址,程序又是正确的,则

recv(sockethnd, buf, 5, 0);                   //接收协议头部5字节
dataLen = ntohl(*((int *)&buf[1]));          //获取协议头部关于数据长度的信息
printf("data length is %d\n", dataLen);

为了方便演示和分析该问题,我们把它从网络编程中分离出来,变成一个本地程序,见“code\第1章\对齐的错误\alignerror”。其实这种简化体现了另一种可能出问题的地方:如内核和用户空间的程序交互时,也会定义协议,一方发出信息,一方接收处理信息,这时依然可能出现上述问题。本地版本的程序如下:

#include <stdio.h>
struct Hdr{
char flag;
  int len;
};
void decodeHdr1(char * buf){
  struct Hdr * phdr;
  phdr = (struct Hdr *)buf;
  printf("version1 len = %d\n", phdr->len);
}
void decodeHdr2(char * buf){
  int * pdataLen = (int *)&buf[1];
  printf("version2 len = %d\n", * pdataLen);
}
void main(){
  char buf[64];
  buf[0] = 1;                          //填写标志位
  *((int *)&buf[1]) = 12;             //填写数据长度为12
  decodeHdr1(buf);
  decodeHdr2(buf);
}

main()函数中将数据长度部分设为12(模拟发送方用手动求偏移方式构造报文),decodeHdr1和decodeHdr2接收填写好的数据的头部地址,分别用两种方式解析头部并打印出长度信息,打印结果分别为-858993664和12。明显,decodeHdr2的结果是正确的。为什么用结构体指针强制转换的decodeHdr1反而不正确呢?如果一时分析不清楚,直接面对最原始的信息(反汇编、内存等)是最简单也是最好的办法。在decodeHdr1处设置断点,反汇编跟踪,见DM1-28。

                                  DM1-28
1  phdr = (struct hdr *)buf;
2     mov  eax, dword ptr [ebp+8]
3     mov  dword ptr [ebp-8], eax
4  printf("version1 len=%d\n", phdr->len);
5     mov  si, esp
6     mov  ax, dword ptr [ebp-8]
7     mov  ecx, dword ptr [eax+4]
8     push  ecx
9  push        41573ch
10 call        dword ptr ds:[004182bch]

第2行是将buf指针指向的地址赋值给EAX。

第3行将eax赋值给指针phdr,phdr变量的地址是ebp–8,这时phdr也指向buf指向的内存。

第10行调用printf()函数,那么它的两个参数应该是通过压栈传递的。我们要关注的是phdr->len的传递,该参数是最右边的参数,在C语言调用方式下是第一个压栈。call之前第一个压栈的是第8行的“push ecx”指令,因此ECX中存放了len。这时只需分析第6~7行,看赋予了ECX什么值即可。

第6行将ebp–8指向内存中存的值赋给EAX。由第3行解释可知,ebp–8指向内存存放的是buf首地址。所以,该指令结束后eax指向buf首地址。

第7行中的eax+4说明将buf偏移4字节处存储的整数赋值给了ECX,然后在第8行压栈ECX传递给printf()函数。可知,偏移4字节是有问题的,我们的协议是第1字节为flag,第2字节开始是长度信息,不是偏移4字节!(哪里出了问题?)对比decodeHdr2函数的反汇编(见DM1-29)就会发现,它只从buf头部偏移了1字节。

                                  DM1-29
int * pdatalen = (int *)&buf[1];
2      mov  eax, dword ptr [ebp+8]
3      add  eax, 1
4      mov  dword ptr [ebp-8], eax
5   printf("version2 len=%d\n", *pdatalen);
6      mov  esi, esp
7      mov  eax, dword ptr [ebp-8]
8      mov  ecx, dword ptr [eax]
9      push  ecx
10     push  415754h
11     call   dword ptr ds:[004182bch]

第9行ecx的值被作为长度传递给了printf()函数。从第7、8行可以看到,将ebp–8指向内存中的值作为地址,该地址指向内存中的值赋给了ECX。那么,ebp–8指向内存中的值是什么?第2~4行对ebp–8内存进行了赋值。第2行将buf地址赋值给了EAX(ebp+8是参数buf的地址)。第3行将eax+1,即buf偏移1字节。因此,该代码是从buf偏移1字节获取的长度信息。

为什么decodeHdr1会从buf偏移4字节的地方获取长度信息?因为它用结构体指针来访问:

phdr->len;

结构体会对齐,而对齐会引入填充字节。那么,这个结构体有填充吗?

struct hdr{
  char flag;
  int len
};

不论当前是以4、8、16的方式对齐,“盒子”的大小都是4,flag放入后,就无法放入len(长4),那么len和flag之间就有3字节的填充位,所以len的地址离头部偏移了4字节。这正是前面反汇编分析看到的那个奇怪的“偏移4字节”。

知道原因后,怎样修改?

① 可以将项目的对齐方式设定为1,那么结构体的排列永远所见即所得,这个问题就解决了。但这必须要求双方都按一个模式选择对齐方式(大家请做实验验证一下),这从工程实践是很难满足的。

② 我们能否从协议设计出发,无论编程选择什么样的对齐方式,结构体都不会产生填充字节?例如:

struct hdr{
  int len
  char flag;
};

将两个字段交换,flag确实是在len之后,之间没有填充字节。但我们考虑问题要“瞻前顾后”。首先,这个结构体的大小是多少?它依然是8字节,而非5字节,那么flag后面就有3字节的填充。(这个填充我们不能视而不见,它会影响程序。)按照协议,读完头部后,就要定位数据部分的地址,最简单的做法是data=buf+sizeof(struct hdr),拿到头部地址加上协议头的大小(sizeof(struct hdr))。这就从缓冲头部偏移8字节,而不是按照协议的5字节。(在处理数据的时候,我们依然是错误的,少读3字节!)怎么办?只能调整字段的顺序,在没办法的情况下,甚至主动添加一些字段,防止填充情况发生。比如,将协议修改为:

struct hdr{
  char flag;
  char reserved[3];
  int len;
};

我们用reserved字段的3字节占据了可能发生的填充(选择4、8、16对齐时)。这样无论用户选择哪种对齐方式,都不会发生填充,都是所见即所得。更多的时候,我们应该尽量调整字段顺序,而不是主动添加填充字节来防止自动填充。比如,对如下协议头:

struct hdr{
  char flag;
  short len;
  char type;
};

应该调整为:

struct hdr{
  char flag;
  char type;
  short len;
};

参考本节前面的结构体ip_hdr,它的字段排列就是典型的“无填充”排列方式。如果有兴趣,请参考TCP/IP协议簇的参考书,其中有很多用图展示的协议:一行4字节,协议中的字段排列非常“整齐”,没有“错位”的感觉,其实那就是无填充对齐的最形象展示。