官术网_书友最值得收藏!

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字節,協議中的字段排列非常“整齊”,沒有“錯位”的感覺,其實那就是無填充對齊的最形象展示。

主站蜘蛛池模板: 贵德县| 大足县| 高平市| 西畴县| 泊头市| 华安县| 通化市| 庄河市| 建宁县| 通城县| 三河市| 聂拉木县| 鹿邑县| 遵义县| 民乐县| 视频| 伽师县| 崇礼县| 玉龙| 磐安县| 壶关县| 汉中市| 平顶山市| 晋州市| 彭阳县| 高碑店市| 固始县| 布尔津县| 汾西县| 崇州市| 交口县| 崇州市| 北宁市| 宜君县| 邛崃市| 郓城县| 高平市| 中卫市| 八宿县| 长寿区| 彩票|