首先看下面的例子:
例 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
函數的局部作用域各定義了一些變數,並且引入一些新的關鍵字const
、static
、register
來修飾變數,那麼這些變數的存儲空間是怎麼分配的呢?我們編譯之後用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
段中有三個變數,a
,b
和a.1589
。
a
是一個GLOBAL
的符號,而b
被static
關鍵字修飾了,導致它成為一個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
段。
現在還剩下函數中的b
和c
這兩個變數沒有分析。上一節我們講過函數的參數和局部變數是分配在棧上的,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
的存儲空間,如下圖所示。
注意,雖然棧是從高地址向低地址增長的,但數組總是從低地址向高地址排列的,按從低地址到高地址的順序依次是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語言的作用域分為以下幾類:
函數作用域(Function Scope),標識符在整個函數中都有效。只有語句標號屬於函數作用域。標號在函數中不需要先聲明後使用,在前面用一個goto
語句也可以跳轉到後面的某個標號,但僅限于同一個函數之中。
檔案作用域(File Scope),標識符從它聲明的位置開始直到這個程序檔案[30]的末尾都有效。例如上例中main
函數外面的A
、a
、b
、c
,還有main
也算,printf
其實是在stdio.h
中聲明的,被包含到這個程序檔案中了,所以也算檔案作用域的。
塊作用域(Block Scope),標識符位於一對{}括號中(函數體或語句塊),從它聲明的位置開始到右}括號之間有效。例如上例中main
函數里的a
、b
、c
。此外,函數定義中的形參也算塊作用域的,從聲明的位置開始到函數末尾之間有效。
函數原型作用域(Function Prototype Scope),標識符出現在函數原型中,這個函數原型只是一個聲明而不是定義(沒有函數體),那麼標識符從聲明的位置開始到在這個原型末尾之間有效。例如int foo(int a, int b);
中的a
和b
。
對屬於同一命名空間(Name Space)的重名標識符,內層作用域的標識符將覆蓋外層作用域的標識符,例如局部變數名在它的函數中將覆蓋重名的全局變數。命名空間可分為以下幾類:
語句標號單獨屬於一個命名空間。例如在函數中局部變數和語句標號可以重名,互不影響。由於使用標號的語法和使用其它標識符的語法都不一樣,編譯器不會把它和別的標識符弄混。
struct
,enum
和union
(下一節介紹union
)的類型Tag屬於一個命名空間。由於Tag前面總是帶struct
,enum
或union
關鍵字,所以編譯器不會把它和別的標識符弄混。
struct
和union
的成員名屬於一個命名空間。由於成員名總是通過.
或->
運算符來訪問而不會單獨使用,所以編譯器不會把它和別的標識符弄混。
所有其它標識符,例如變數名、函數名、宏定義、typedef
的類型名、enum
成員等等都屬於同一個命名空間。如果有重名的話,宏定義覆蓋所有其它標識符,因為它在預處理階段而不是編譯階段處理,除了宏定義之外其它幾類標識符按上面所說的規則處理,內層作用域覆蓋外層作用域。
外部連結(External Linkage),如果最終的執行檔由多個程序檔案連結而成,一個標識符在任意程序檔案中即使聲明多次也都代表同一個變數或函數,則這個標識符具有External Linkage。具有External Linkage的標識符編譯後在符號表中是GLOBAL
的符號。例如上例中main
函數外面的a
和c
,main
和printf
也算。
內部連結(Internal Linkage),如果一個標識符在某個程序檔案中即使聲明多次也都代表同一個變數或函數,則這個標識符具有Internal Linkage。例如上例中main
函數外面的b
。如果有另一個foo.c
程序和main.c
連結在一起,在foo.c
中也聲明一個static int b;
,則那個b
和這個b
不代表同一個變數。具有Internal Linkage的標識符編譯後在符號表中是LOCAL
的符號,但main
函數里面那個a
不能算Internal Linkage的,因為即使在同一個程序檔案中,在不同的函數中聲明多次,也不代表同一個變數。
無連結(No Linkage)。除以上情況之外的標識符都屬於No Linkage的,例如函數的局部變數,以及不表示變數和函數的其它標識符。
存儲類修飾符(Storage Class Specifier)有以下幾種關鍵字,可以修飾變數或函數聲明:
static
,用它修飾的變數的存儲空間是靜態分配的,用它修飾的檔案作用域的變數或函數具有Internal Linkage。
auto
,用它修飾的變數在函數調用時自動在棧上分配存儲空間,函數返回時自動釋放,例如上例中main
函數里的b
其實就是用auto
修飾的,只不過auto
可以省略不寫,auto
不能修飾檔案作用域的變數。
register
,編譯器對於用register
修飾的變數會儘可能分配一個專門的寄存器來存儲,但如果實在分配不開寄存器,編譯器就把它當auto
變數處理了,register
不能修飾檔案作用域的變數。現在一般編譯器的優化都做得很好了,它自己會想辦法有效地利用CPU的寄存器,所以現在register
關鍵字也用得比較少了。
extern
,上面講過,連結屬性是根據一個標識符多次聲明時是不是代表同一個變數或函數來分類的,extern
關鍵字就用於多次聲明同一個標識符,下一章再詳細介紹它的用法。
typedef
,在第 2.4 節 “sizeof運算符與typedef類型聲明”講過這個關鍵字,它並不是用來修飾變數的,而是定義一個類型名。在那一節也講過,看typedef
聲明怎麼看呢,首先去掉typedef
把它看成變數聲明,看這個變數是什麼類型的,那麼typedef
就定義了一個什麼類型,也就是說,typedef
在語法結構中出現的位置和前面幾個關鍵字一樣,也是修飾變數聲明的,所以從語法(而不是語義)的角度把它和前面幾個關鍵字歸類到一起。
注意,上面介紹的const
關鍵字不是一個Storage Class Specifier,雖然看起來它也修飾一個變數聲明,但是在以後介紹的更複雜的聲明中const
在語法結構中允許出現的位置和Storage Class Specifier是不完全相同的。const
和以後要介紹的restrict
和volatile
關鍵字屬於同一類語法元素,稱為類型限定符(Type Qualifier)。
變數的生存期(Storage Duration,或者Lifetime)分為以下幾類:
靜態生存期(Static Storage Duration),具有外部或內部連結屬性,或者被static
修飾的變數,在程序開始執行時分配和初始化一次,此後便一直存在直到程序結束。這種變數通常位於.rodata
,.data
或.bss
段,例如上例中main
函數外的A
,a
,b
,c
,以及main
函數里的a
。
自動生存期(Automatic Storage Duration),連結屬性為無連結並且沒有被static
修飾的變數,這種變數在進入塊作用域時在棧上或寄存器中分配,在退出塊作用域時釋放。例如上例中main
函數里的b
和c
。
動態分配生存期(Allocated Storage Duration),以後會講到調用malloc
函數在進程的堆空間中分配內存,調用free
函數可以釋放這種存儲空間。
[30] 為了容易閲讀,這裡我用了“程序檔案”這個不嚴格的叫法。如果有檔案a.c
包含了b.h
和c.h
,那麼我所說的“程序檔案”指的是經過預處理把b.h
和c.h
在a.c
中展開之後生成的代碼,在C標準中稱為編譯單元(Translation Unit)。每個編譯單元可以分別編譯成一個.o
目標檔案,最後這些目標檔案用連結器連結到一起,成為一個執行檔。C標準中大量使用一些非常不通俗的名詞,除了編譯單元之外,還有編譯器叫Translator,變數叫Object,本書不會採用這些名詞,因為我不是在寫C標準。