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 值仅表示相对位置,所以这可能表示常量都存储在另外的一个区域,而且与全局/静态变量相隔较远。





/

posted @ 2020-03-02 19:44  massquantity  阅读(2055)  评论(0编辑  收藏  举报