3. 變數的存儲佈局

首先看下面的例子:

例 19.2. 研究變數的存儲佈局

#include <stdio.h>

const int A = 10;
int a = 20;
static int b = 30;
int c;

int main(void)
{
	static int a = 40;
	char b[] = "Hello world";
	register int c = 50;

	printf("Hello world %d\n", c);

	return 0;
}

我們在全局作用域和main函數的局部作用域各定義了一些變數,並且引入一些新的關鍵字conststaticregister來修飾變數,那麼這些變數的存儲空間是怎麼分配的呢?我們編譯之後用readelf命令看它的符號表,瞭解各變數的地址分佈。注意在下面的清單中我把符號表按地址從低到高的順序重新排列了,並且只截取我們關心的那幾行。

$ gcc main.c -g
$ readelf -a a.out
...
    68: 08048540     4 OBJECT  GLOBAL DEFAULT   15 A
    69: 0804a018     4 OBJECT  GLOBAL DEFAULT   23 a
    52: 0804a01c     4 OBJECT  LOCAL  DEFAULT   23 b
    53: 0804a020     4 OBJECT  LOCAL  DEFAULT   23 a.1589
    81: 0804a02c     4 OBJECT  GLOBAL DEFAULT   24 c
...

變數A用const修飾,表示A是隻讀的,不可修改,它被分配的地址是0x8048540,從readelf的輸出可以看到這個地址位於.rodata段:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
...
  [13] .text             PROGBITS        08048360 000360 0001bc 00  AX  0   0 16
...
  [15] .rodata           PROGBITS        08048538 000538 00001c 00   A  0   0  4
...
  [23] .data             PROGBITS        0804a010 001010 000014 00  WA  0   0  4
  [24] .bss              NOBITS          0804a024 001024 00000c 00  WA  0   0  4
...

它在檔案中的地址是0x538~0x554,我們用hexdump命令看看這個段的內容:

$ hexdump -C a.out
...
00000530  5c fe ff ff 59 5b c9 c3  03 00 00 00 01 00 02 00  |\...Y[..........|
00000540  0a 00 00 00 48 65 6c 6c  6f 20 77 6f 72 6c 64 20  |....Hello world |
00000550  25 64 0a 00 00 00 00 00  00 00 00 00 00 00 00 00  |%d..............|
...

其中0x540地址處的0a 00 00 00就是變數A。我們還看到程序中的字元串字面值"Hello world %d\n"分配在.rodata段的末尾,在第 4 節 “字元串”說過字元串字面值是隻讀的,相當於在全局作用域定義了一個const數組:

const char helloworld[] = {'H', 'e', 'l', 'l', 'o', ' ',
		 	'w', 'o', 'r', 'l', 'd', ' ', '%', 'd', '\n', '\0'};

程序加載運行時,.rodata段和.text段通常合併到一個Segment中,操作系統將這個Segment的頁面只讀保護起來,防止意外的改寫。這一點從readelf的輸出也可以看出來:

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame 
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag 
   06     
   07     .ctors .dtors .jcr .dynamic .got 

注意,像A這種const變數在定義時必須初始化。因為只有初始化時才有機會給它一個值,一旦定義之後就不能再改寫了,也就是不能再賦值了。

從上面readelf的輸出可以看到.data段從地址0x804a010開始,長度是0x14,也就是到地址0x804a024結束。在.data段中有三個變數,aba.1589

a是一個GLOBAL的符號,而bstatic關鍵字修飾了,導致它成為一個LOCAL的符號,所以static在這裡的作用是聲明b這個符號為LOCAL的,不被連結器處理,在下一章我們會看到,如果把多個目標檔案連結在一起,LOCAL的符號只能在某一個目標檔案中定義和使用,而不能定義在一個目標檔案中卻在另一個目標檔案中使用。一個函數定義前面也可以用static修飾,表示這個函數名符號是LOCAL的。

還有一個a.1589是什麼呢?它就是main函數中的static int a。函數中的static變數不同於以前我們講的局部變數,它並不是在調用函數時分配,在函數返回時釋放,而是像全局變數一樣靜態分配,所以用“static”(靜態)這個詞。另一方面,函數中的static變數的作用域和以前講的局部變數一樣,只在函數中起作用,比如main函數中的a這個變數名只在main函數中起作用,在別的函數中說變數a就不是指它了,所以編譯器給它的符號名加了一個尾碼,變成a.1589,以便和全局變數a以及其它函數的變數a區分開。

.bss段從地址0x804a024開始(緊挨着.data段),長度為0xc,也就是到地址0x804a030結束。變數c位於這個段。從上面的readelf輸出可以看到,.data.bss在加載時合併到一個Segment中,這個Segment是可讀可寫的。.bss段和.data段的不同之處在於,.bss段在檔案中不占存儲空間,在加載時這個段用0填充。所以我們在第 4 節 “全局變數、局部變數和作用域”講過,全局變數如果不初始化則初值為0,同理可以推斷,static變數(不管是函數里的還是函數外的)如果不初始化則初值也是0,也分配在.bss段。

現在還剩下函數中的bc這兩個變數沒有分析。上一節我們講過函數的參數和局部變數是分配在棧上的,b是數組也一樣,也是分配在棧上的,我們看main函數的反彙編代碼:

$ objdump -dS a.out
...
        char b[]="Hello world";
 8048430:       c7 45 ec 48 65 6c 6c    movl   $0x6c6c6548,-0x14(%ebp)
 8048437:       c7 45 f0 6f 20 77 6f    movl   $0x6f77206f,-0x10(%ebp)
 804843e:       c7 45 f4 72 6c 64 00    movl   $0x646c72,-0xc(%ebp)
        register int c = 50;
 8048445:       b8 32 00 00 00          mov    $0x32,%eax

        printf("Hello world %d\n", c);
 804844a:       89 44 24 04             mov    %eax,0x4(%esp)
 804844e:       c7 04 24 44 85 04 08    movl   $0x8048544,(%esp)
 8048455:       e8 e6 fe ff ff          call   8048340 <printf@plt>
...

可見,給b初始化用的這個字元串"Hello world"並沒有分配在.rodata段,而是直接寫在指令裡了,通過三條movl指令把12個位元組寫到棧上,這就是b的存儲空間,如下圖所示。

圖 19.4. 數組的存儲佈局

數組的存儲佈局

注意,雖然棧是從高地址向低地址增長的,但數組總是從低地址向高地址排列的,按從低地址到高地址的順序依次是b[0]b[1]b[2]……這樣,

數組元素b[n]的地址 = 數組的基地址(b做右值就表示這個基地址) + n × 每個元素的位元組數

當n=0時,元素b[0]的地址就是數組的基地址,因此數組下標要從0開始而不是從1開始。

變數c並沒有在棧上分配存儲空間,而是直接存在eax寄存器裡,後面調用printf也是直接從eax寄存器裡取出c的值當參數壓棧,這就是register關鍵字的作用,指示編譯器儘可能分配一個寄存器來存儲這個變數。我們還看到調用printf時對於"Hello world %d\n"這個參數壓棧的是它在.rodata段中的首地址,而不是把整個字元串壓棧,所以在第 4 節 “字元串”中說過,字元串在使用時可以看作數組名,如果做右值則表示數組首元素的地址(或者說指向數組首元素的指針),我們以後講指針還要繼續討論這個問題。

以前我們用“全局變數”和“局部變數”這兩個概念,主要是從作用域上區分的,現在看來用這兩個概唸給變數分類太籠統了,需要進一步細分。我們總結一下相關的C語法。

作用域(Scope)這個概念適用於所有標識符,而不僅僅是變數,C語言的作用域分為以下幾類:

對屬於同一命名空間(Name Space)的重名標識符,內層作用域的標識符將覆蓋外層作用域的標識符,例如局部變數名在它的函數中將覆蓋重名的全局變數。命名空間可分為以下幾類:

標識符的連結屬性(Linkage)有三種:

存儲類修飾符(Storage Class Specifier)有以下幾種關鍵字,可以修飾變數或函數聲明:

注意,上面介紹的const關鍵字不是一個Storage Class Specifier,雖然看起來它也修飾一個變數聲明,但是在以後介紹的更複雜的聲明中const在語法結構中允許出現的位置和Storage Class Specifier是不完全相同的。const和以後要介紹的restrictvolatile關鍵字屬於同一類語法元素,稱為類型限定符(Type Qualifier)

變數的生存期(Storage Duration,或者Lifetime)分為以下幾類:



[30] 為了容易閲讀,這裡我用了“程序檔案”這個不嚴格的叫法。如果有檔案a.c包含了b.hc.h,那麼我所說的“程序檔案”指的是經過預處理把b.hc.ha.c中展開之後生成的代碼,在C標準中稱為編譯單元(Translation Unit)。每個編譯單元可以分別編譯成一個.o目標檔案,最後這些目標檔案用連結器連結到一起,成為一個執行檔。C標準中大量使用一些非常不通俗的名詞,除了編譯單元之外,還有編譯器叫Translator,變數叫Object,本書不會採用這些名詞,因為我不是在寫C標準。