C/C++ 存儲類別



本文介紹 C/C++ 中的存儲類別。所謂的“存儲類別”究竟是什么意思? 存儲類別主要指在內存中存儲數據的方式,其大致牽涉到變量的三個方面 —— 作用域、鏈接性和存儲期,也就是說這三個方面決定了存儲類別。下面先解釋這三個概念,再介紹在 C/C++ 中的表示形式。



存儲類別定義

  • 作用域 (scope) 描述程序中可訪問變量的區域,主要有塊作用域 (block scope) 變量和 文件作用域 (file scope) 變量,平常我們也分別用局部變量和全局變量來指代這兩者。這里需要注意的是,在 C/C++ 中一個源文件通常包含一個或多個頭文件 (.h 擴展名),在實際編譯之前的預處理階段會將頭文件內容替換源文件中的 #include 指令,所以源代碼文件和所有的頭文件都被看成是一個包含信息的單獨文件,這整個文件被稱為翻譯單元 (translation unit)。描述一個具有文件作用域的變量時,其實際可見范圍是整個翻譯單元。

  • 鏈接性 (linkage) 描述變量能否跨文件訪問,而按上面的定義,更准確地講應該是能否 “跨翻譯單元訪問”。主要分為三種:

    • 外部鏈接(external linkage):可以在多個翻譯單元中使用。
    • 內部鏈接(internal linkage): 只能在一個翻譯單元中使用,即只能在被聲明的文件中使用。
    • 無鏈接(no linkage): 只能被定義塊所私有,不能被程序其他部分引用。比如函數定義由塊包圍,其內部的參數、變量都是無鏈接變量。

    由此可見作用域和鏈接性共同描述了變量的可見性。

  • 存儲期 (storage duration) 指變量在內存中保留了多長時間。通常分為四種:

    • 靜態存儲期 (static storage duration): 在程序執行期間一直存在。文件作用域變量都具有靜態存儲期。
    • 線程存儲期 (thread storage duration):具有線程存儲期的對象,從被聲明到線程結束一直存在。以關鍵字 _Thread_local 聲明一個變量時,每個線程都獲得該變量的私有備份。
    • 自動存儲期 (auto storage duration): 塊作用域的變量通常具有自動存儲期,當程序進入定義變量的塊時,為這些變量分配內存;當退出這個塊時,釋放剛才為變量分配的內存。
    • 動態分配存儲期 (allocated storage duration): 使用 mallocnew 函數創建而成的指針變量具有動態分配存儲期,需要使用者手動使用 freedelete 釋放占用的內存。任何可以訪問該指針的函數均可以訪問這塊內存。例如,一個函數可以把這個指針的值返回給另一個函數,那么另一個函數也可以訪問該指針指向的內存。




存儲類別在 C/C++ 中的表示形式

在 C/C++ 中如何表示上述的這些概念? 一句話概括就是在合適的位置使用合適的關鍵字就可以了,這些關鍵字有 autoregisterexternstatic_Thread_local (C11) ,下表進行了總結:

存儲類別 作用域 鏈接性 存儲期 聲明方式
自動 塊作用域 無鏈接 自動存儲期 在塊中 (可選使用關鍵字 auto)
寄存器 塊作用域 無鏈接 自動存儲期 在塊中,使用關鍵字 register
靜態、外部鏈接 文件作用域 外部鏈接 靜態存儲期 在所有函數外部
靜態、內部鏈接 文件作用域 內部鏈接 靜態存儲期 在所有函數外部,使用關鍵字 static
靜態、無鏈接 塊作用域 無鏈接 靜態存儲期 在塊中,使用關鍵字 static
線程、外部鏈接 文件作用域 外部鏈接 線程存儲期 在所有函數外部,使用關鍵字 _Thread_local
線程、內部鏈接 文件作用域 內部鏈接 線程存儲期 在所有函數外部,使用關鍵字 static_Thread_local
線程、無鏈接 塊作用域 無鏈接 線程存儲期 在塊中,使用關鍵字 static_Thread_local

1、自動存儲類別

屬於自動存儲類別的變量具有塊作用域、無鏈接性和自動存儲期,默認情況下,聲明在塊或函數頭的任何變量都屬於自動變量,可以使用 auto 關鍵字進行強調,也可以不使用,如下面的 a 和 b 都是自動變量:

int main(void)
{
    int a;
    auto int b;
}

注意 auto 關鍵字在 C++ 11 中有完全不同的用法。如果編寫 C/C++ 兼容的程序,最好不要使用 auto 作為存儲類別說明符。


2、寄存器存儲類別

顧名思義,寄存器變量通常直接存儲在 CPU 的寄存器中,那么其訪問速度會比在普通內存中存儲快很多。下面的存儲器層次結構顯示寄存器的訪問速度遠快於主存 (越往上速度越快):

當然使用 register 關鍵字聲明並不意味着該變量一定會被存儲在寄存器中。在實際中,編譯器並不一定會這樣做(比如,可能沒有足夠數量的寄存器供編譯器使用)。實際上,現在性能優越的編譯器能夠識別出經常使用的變量,並將其放在寄存器中,而無需給出 register 聲明。下面用一個例子測試 (編譯需支持 C++ 11):

#include <iostream>
#include <chrono>
using namespace std;
using namespace chrono;
#define TIME 1000000000

int main()
{
    register int a, b = TIME;  /* 寄存器變量 */
    int x, y = TIME;           /* 自動變量   */

    auto start1 = steady_clock::now();
    for (a = 0; a < b; a++);
    auto end1 = steady_clock::now();
    auto dur1 = duration_cast<nanoseconds>(end1 - start1);
    cout << "寄存器變量: " << double(dur1.count()) * nanoseconds::period::num / nanoseconds::period::den << "秒" << endl;

    auto start2 = steady_clock::now();
    for (x = 0; x < y; x++);
    auto end2 = steady_clock::now();
    auto dur2 = duration_cast<nanoseconds>(end2 - start2);
    cout << "自動變量: " << double(dur2.count()) * nanoseconds::period::num / nanoseconds::period::den << "秒" << endl;
}

// 寄存器變量: 0.659秒
// 自動變量: 2.742秒

可以看到在這個例子中使用寄存器變量快了不少。


3、靜態存儲類別

使用 static 聲明的變量都具有靜態存儲類別,所謂的 “靜態” ,意思是變量在內存中的位置不變,而不是其值不變。靜態存儲類別都具有靜態存儲期, 即在程序執行期間一直存在,且只能被初始化一次,不論是局部變量還是全局變量。

3.1 塊作用域的靜態變量

聲明局部變量時用 static 修飾,可以在函數調用之間保持該變量的值,而不需要在每次它進入和離開作用域時進行創建和銷毀,即具有塊作用域、無鏈接、靜態存儲期。

3.2 外部鏈接的靜態變量

外部鏈接的靜態變量具有文件作用域、外部鏈接和靜態存儲期。由於全局變量默認就是靜態存儲類別,所以不需要使用 static 關鍵字進行聲明。因其具有外部鏈接,所以可以在別的文件中使用這個變量,但需要進行引用式聲明,即在前面加上 extern 關鍵字。同樣對於函數而言,也可以通過 extern 關鍵字使用別的文件的函數。

3.3 內部鏈接的靜態變量

內部鏈接的靜態變量具有文件作用域、內部鏈接和靜態存儲期,也就是用 static 修飾的全局變量,由於其內部鏈接屬性,只能在同一個文件中使用。同樣用 static 修飾的函數也是只能在同一文件中使用。容易讓人混淆的是,這里的 static 改變的是全局變量的鏈接屬性,而不是存儲期。因為全局變量默認就是靜態存儲類別,不需要特意使用 static 修飾。


對於上述三種靜態變量的區別,來看一個例子更清楚,機器學習中常用到的指定隨機數種子進行訓練初始化。有兩個文件 ( main.crand.c ):

/********  main.c  ********/
#include <stdio.h>
extern int count;		                        // 聲明外部變量
extern void srandom(unsigned int);              // 聲明外部函數
extern void generateRandom(int n);              // 聲明外部函數

int main(void)
{
    unsigned int seed;
    int n;
    printf("請輸入種子和數量,按 q 退出: \n");
    while (scanf("%u %d", &seed, &n) == 2)
    {
        srandom(seed);
        generateRandom(n);
        printf("\n");
    }
    printf("總共生成了%d個隨機數。\n", count);
}


/********  rand.c  ********/
#include <stdio.h>
static unsigned int next = 1;                    // 內部鏈接的靜態變量
int count = 0;				     	             // 外部鏈接的靜態變量

static unsigned int random(void)                 // 內部鏈接的函數,該文件私有的私有函數
{
    next = next * 1103515245 + 12345;
    return (unsigned int) (next / 65536) % 32768;
}

void generateRandom(int n)
{
    int subCount = 0;
    static int round = 1;                        // 塊作用域的靜態變量
    while (n--)
    {
        next = random();
        printf("%u ", next);
        subCount++;
    }
    printf("\n第%d輪生成了%d個隨機數。\n", round, subCount);
    round++;
    count += subCount;
}

void srandom(unsigned int seed)
{
    next = seed;
}

我們知道,計算機模擬出來的都不是真正的隨機數,而是一種“偽隨機數”,如果指定相同的隨機數種子 (seed),那么每次都可以得到相同的結果:

gcc -o static main.c rand.c     # 編譯
./static		                # 運行

請輸入種子和數量,按 q 退出: 
1 5			                    # 種子1生成5個隨機數
16838 14666 10953 11665 7451 
第1輪生成了5個隨機數。

1 8                             # 種子1生成8個隨機數
16838 14666 10953 11665 7451 26316 27974 27550 
第2輪生成了8個隨機數。

5 10                            # 種子5生成10個隨機數
18655 4557 22274 26675 10846 12206 7471 2634 16995 4072 
第3輪生成了10個隨機數。

q
總共生成了23個隨機數。

可以看到,兩次使用種子1生成的前幾個隨機數都是一樣的,這里的關鍵是rand.c 中的 next 是一個靜態全局變量,因而每次只要通過 srandom 函數將其設定成一樣,就會生成一樣的隨機數。 rand.c 中的round 被聲明為塊作用域的靜態變量,其不會像一般的局部變量那樣每次進入和離開作用域時都會創建和銷毀,所以能記錄輪數。而 count 為外部鏈接的靜態變量,所以可以在 main.c 中使用,前提是先用 extern 聲明。




C/C++ 的內存管理

這一節來深究一下原理,先來關注存儲期。本文開頭定義了存儲類別主要指在內存中存儲數據的方式,那么具體究竟是什么樣的方式?概括起來就類似於哲學的三大命題:

  • 我存儲於內存中的什么位置 (我是誰?)
  • 我何時被創建於這個位置 (我從哪里來?)
  • 我何時會從這個位置釋放 (我到哪里去?)

首先看第一個問題,翻開任何一本操作系統教科書,都會告訴你,C/C++ 中內存大致分為這么幾個區域:棧、堆、靜態存儲區,見下圖:

那么存儲在什么位置就明了了,自動存儲期變量存放在棧中,動態分配存儲期變量存放在堆中,靜態存儲期變量存放在靜態存儲區。而對於后面兩個問題,則與這幾個區域的運作方式有關,下面分別闡述:

1、棧 (stack): 棧被用以實現函數調用。在程序運行期間執行函數調用的時候,會向下增加棧幀 (stack frame),幀中存儲局部變量 (自動變量) 以及該函數的返回地址。大部分編程語言都只允許使用位於棧中最下方的幀 ,而不允許調用其它的幀,所以在一個函數中無法調用其它函數的局部變量,這對應於塊作用域的屬性。在函數結束調用返回時,會從棧中彈出相應的棧幀,那么幀上的局部變量也會自動銷毀,這對應了自動存儲期的屬性。所以對於第二第三個問題,自動存儲期變量隨着相應棧幀的出現而創建,又隨着棧幀的銷毀而釋放,整個過程由操作系統控制,不需要使用者手動操作,這就是其“自動”的含義。

2、堆 (heap):和棧一樣,堆 (heap) 也可以用來為變量分配內存,二者的一個不同點是棧上要分配的空間大小在編譯時就已確定,而堆上分配的空間大小在運行時才會確定,這樣的好處是當不知道需要多少空間時,不用在程序中預先指定大小。在堆中分配內存需要使用 mallocnew 函數,但使用完后不會自動釋放,而是需要使用者手動使用 freedelete 釋放占用的內存, 這就是動態分配存儲期變量的創建和釋放。

3、靜態存儲區: 堆和棧被統稱為動態存儲區,因為在運行期間都會動態地擴展和收縮,而與之相對的就是靜態存儲區,里面存放着全局變量和靜態變量,這些變量在程序運行期間都會一直存在。靜態存儲區可細分為兩個部分 —— .data 段用於存放已初始化的全局和靜態變量,.bss 段用於存放未初始化的全局和靜態變量。值得一提的是,靜態存儲區中的未顯式初始化變量在運行時會被統一初始化為 0,而未顯式初始化的 (在棧中的) 局部變量在運行時的初始值卻是不確定的,來看一個例子:

#include<iostream>
using namespace std;

void first()
{
    int a;
    int c;
    cout << "第一個函數中 a = " << a << endl;
    cout << "第一個函數中 c = " << c << endl;
}

void second()
{
    int b;
    int c;
    cout << "第二個函數中 b = " << b << endl;
    cout << "第二個函數中 c = " << c << endl;
}

int main()
{
    first();
    second();
}
第一個函數中 a = 4201275
第一個函數中 c = 2686824
第二個函數中 b = 4201275
第二個函數中 c = 2686824

結果顯示兩個函數中不論變量的名稱是啥,第一個變量 (a 和 b) 的默認初始化值都一樣,第二個變量 (都是 c) 也是如此。這是因為函數結束調用后,雖然棧幀銷毀了,但只要程序依然在運行,則棧也會一直存在,接下來調用其它函數時依然會向棧中同一片地址方向增長,未顯式初始化的局部變量其默認值是之前分配給這同一個地址的值 (通常不會是 0),因而棧相當於一個可復用的暫存區。

666、線程局部存儲:這種類型的變量具有線程存儲期,顧名思義從被聲明到線程結束一直存在於某個線程中,相當於和這個線程關聯了起來。我們知道全局變量位於靜態存儲區,同一進程中各個線程都可以訪問,因此它們存在多線程讀寫問題。而對於線程存儲期變量,每個線程擁有其私有備份,不能被別的線程訪問,這樣可以有效避免競爭。從 C11 起要聲明此類變量,需要添加 _Thread_local 關鍵字,下面使用 Pthreads API 創建一個例子,可以看到對於同樣的變量 tl ,兩個線程對其遞增互不干擾,最后打印出不同的值:

#include <stdio.h>
#include <pthread.h>

_Thread_local static int tl = 0;				// 聲明為靜態線程局部存儲變量

struct arg
{
    char * name;
    int num;
};

void increase_tl(void * t)
{
    struct arg * a = (struct arg *)t;
    for (int i = 0; i < a->num; ++i)
        tl++;									// 每個線程獲得tl的私有備份
    printf("%s 中的 tl = %d\n", a->name, tl);
}

int main(void)
{
    pthread_t thread1, thread2;
    struct arg arg1 = {"線程1", 5};
    struct arg arg2 = {"線程2", 10};
    pthread_create(&thread1, NULL, (void *)&increase_tl, (void *)&arg1);
    pthread_create(&thread2, NULL, (void *)&increase_tl, (void *)&arg2);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    pthread_exit(NULL);
}

// 線程1 中的 tl = 5
// 線程2 中的 tl = 10

實際上每個線程共享同一進程中的靜態存儲區、堆,但有各自獨立的棧和線程存儲期變量。綜合上幾段的描述,內存管理圖也許可以更新成這樣:




鏈接

上一節說的存儲期和內存管理更多的是一種運行時概念,也就是說在程序實際運行時內存被划分為不同的區域,而正在運行的程序有個更為人熟知的名字,那就是進程 (process)。相對應的,鏈接 (linking) 是一個鏈接時概念,主要作用是將各種代碼和數據片段收集和組合成為一個可執行文件。在第一節中提到有三種鏈接屬性:外部鏈接、內部鏈接、無鏈接。外部鏈接的變量可以跨文件訪問,內部鏈接的變量只能在文件內使用,而無鏈接變量只能在塊中使用。既然有些變量可以在文件外訪問,有些不能,那么文件中必然存在某種標示,標記了各變量的可訪問區域。這里我們不過多牽涉鏈接的具體原理,而是重點關注不同鏈接屬性的標示究竟是什么樣的?

當然首先要說並不是任何文件都可以被用來鏈接的,可以用來鏈接的文件被稱為 “可重定位目標文件”,通常以 .o 為擴展名。每個這類文件中都有一個符號表 (symbol table) ,記錄了各種屬性包括鏈接屬性,下面用例子來說明,脫胎於上文中的隨機數例子:

/********  rand.c  ********/
#include <stdio.h>  // gcc -c rand.c -o rand.o   readelf -s rand.o

static unsigned int next = 1;           // 第一個內部鏈接的靜態變量
static unsigned int next2 = 1;          // 第二個內部鏈接的靜態變量
int count = 0;				            // 第一個外部鏈接的靜態變量
int count2 = 0;                         // 第二個外部鏈接的靜態變量
const int CONSTANT = 100;               // 外部鏈接的常量
static const int CONSTANT2 = 100;       // 內部鏈接的常量

void generateRandom(int n)
{
    int subCount = 0;
    static int round = 1;               	// 第一個塊作用域的靜態變量
		static const int CONSTANT3 = 100;   // 塊作用域的內部鏈接的常量
		const int CONSTANT4 = 100;          // 無鏈接的常量
    while (n--)
        subCount++;
    printf("\n第%d輪生成了%d個隨機數。\n", round, subCount);
    round++;
    count += subCount;
}

void generateRandom222(int n)
{
    int subCount = 0;
    static int round = 1;                   // 第二個塊作用域的靜態變量(與generateRandom中的round同名)
		static int round2 = 1;              // 第三個塊作用域的靜態變量
    while (n--)
        subCount++;
    printf("\n第%d輪生成了%d個隨機數。\n", round, subCount);
    round++;
    count += subCount;
}

這里面的變量有點多,我特意加了幾個常量,看它們的表示有何不同。下表進行了總結:


全局 (外部鏈接) 內部鏈接靜態 無鏈接靜態 局部 (自動)
變量 count count2 next next2 round round2 subCount
常量 CONSTANT CONSTANT2 CONSTANT3 CONSTANT4

下面使用 gcc -c rand.c 生成名為 rand.o 的可重定位目標文件,再使用命令 readelf -s rand.o 可查看該文件中的符號表:

符號表中包含着豐富的信息,因為這里既有變量又有常量,所以要描述先得找個統一的稱謂,正好在符號表的 Type 一列中顯示這些量都是 OBJECT (對象),這不是巧合,在 C 中這些占用內存的量確實被稱為對象,所以后文也遵循這種名稱,和面向對象編程里的對象是不一樣的。

符號表中的對象按鏈接類型划分為三個部分,分別對應外部鏈接對象,內部鏈接對象和無鏈接對象,如下圖。注意代碼中的自動變量 (subCountCONSTANT4) 並不包含在符號表中,因為鏈接器對此類對象不感興趣。

首先看 Bind 這一列,只有兩種類型,LOCALGLOBAL ,可以看到外部鏈接的對象如 count, CONSTANT 都是 GLOBAL 屬性,而內部鏈接對象和無鏈接對象則是 LOCAL 屬性。自然從語義上看,GLOBAL 對應於所有文件都可以訪問,LOCAL 對應於只有單個文件訪問。

那么內部鏈接靜態對象和無鏈接靜態對象的區別是什么?從 Name 這一列可以看出,無鏈接的靜態對象 (如 round, CONSTANT3 ) 的名稱后面都被加上了幾個數字,如 round2.2304 ,而且代碼中顯示,兩個generateRandom 函數中實際上都定義了同名的 round 對象,而符號表中則在各自名稱后面加上了不同的數字。這反映了兩個 round 是不同的無鏈接靜態對象,不能超出各自函數外訪問它們。

從這里可以看出內部鏈接靜態對象和無鏈接靜態對象和 Java 中的靜態變量有一些相似。Java 也通過 static 關鍵字在類中聲明為靜態變量,這些變量也只會被初始化一次,且只能在類內部起作用,這顯示了其面向對象的特點,C++ 在這方面與之類似。而與之對應的是 C 中的內部鏈接靜態對象只能在單個文件中使用,無鏈接靜態對象只能在塊中使用,二者和 Java 的靜態變量一樣會被存放在固定的區域。

最后看 Value 列,這里面的值可以理解為內存中的各對象的相對位置,同樣用 16 進制表示。如果我們把這些對象的位置從小到大排列 (限於空間這里只取最后兩位),就會發現一些有趣的現象。

對象 next next2 round.2293 round.2303 round.2304 .... count count2
Value 00 04 08 0c 10 .... 00 04

0c 在十六進制中代表12, 10 代表16,那么這說明前 5 個對象的位置是連續的,因為都是 int 類型,所以是 4 個字節的間隔,而后兩個 countcount 也是連續的。這說明內部鏈接靜態對象和無鏈接靜態對象是井然有序地存儲在一起的,而外部鏈接靜態對象則統一在另一片位置。另外幾個常量的位置都很詭異,CONSTANTcount 的 Value 一樣,而CONSTANTnext 的 Value 一樣。因為 Value 值僅表示相對位置,所以這可能表示常量都存儲在另外的一個區域,而且與全局/靜態變量相隔較遠。





/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM