C 语言重拾【七】存储类别、链接和内存管理

C 语言能让程序员恰到好处的控制程序,这是它的优势之一。程序员通过 C 的内存管理系统指定变量的作用域和生命周期,实现对程序的控制。合理使用内存存储数据是设计程序的一个要点。

存储类别

C 提供了多种不同的模型或存储类别在内存中存储数据。要理解这些存储类别,我们先来了解一些概念和术语。

程序中的大部分数据都是存储在内存中。从硬件方面来看,被存储的每个值都占用一定的物理内存,C 语言把这样的一块内存称为 对象(objc)。这里所指的对象和我们面向对象编程所说的对象是不同的,要注意(面向对象中的对象指的是类对象)。这里所说的对象实际指的是一块可以存储数据的内存。对象可以存储一个或多个值。一个对象可能并未存储实际的值,但是它在存储适当的值时一定具有相应的大小 。

从软件方面来看,程序需要一种方法访问对象。这可以通过声明变量来完成:

1
int entity = 3;

该声明创建了一个 entity 标识符。标识符是一个名称,可以用来指定特定对象的内容。变量名不是指定对象的唯一途径。考虑下面两个声明:

1
2
int *pt = &entity;
int ranks[10];

pt 是一个指针,并不是一个标识符。ranks 的声明创建了一个可容纳 10 个 int 类型元素的对象,该数组的每个元素也是一个对象。

可以用 存储期 描述对象,所谓存储期是指对象在内存中保留了多长时间。标识符用于访问对象,可以用 作用域链接 描述标识符,标识符的 作用域链接 表明了程序的哪些部分可以使用它。不同的存储类别具有不同的 存储期作用域链接

通俗一点的描述就是,存储期可以理解为一个对象的声明周期,也即是内存的创建到销毁。链接可以理解为访问控制,是对于文件来说。作用域是对于变量来说的访问控制。

作用域

一个 C 变量的作用域可以是 块作用域函数作用域函数原型作用域文件作用域。块作用域其实就是两个大括号之间的作用域。举个例子:

1
2
3
4
5
6
7
8
9
10
11
double blocky(double cleo) {
double patrick = 0.0;
int i;
for (i = 0; i < 10; i++) {
double q = cleo * i; // q 的作用域开始
// ......
patrick *= q;
} // q 的作用域结束
// ......
return patrick;
}

函数作用域仅用于 goto 语句的标签。

函数原型作用域用于函数原型中的形参名,如下所示:

1
int mighty(int mouse, double large);

其范围是从形参定义处到原型声明结束。这意味着,编译起在处理函数原型中的形参时只关心它的类型,而形参名(如果有的话)通常无关紧要。而且,即使有形参名,也不必与函数定义中的形参名相匹配。只有在变长数组中,形参名才有用:

1
void use_a_VLA(int n, int m, ar[n][m]);

方括号中必须使用在函数原型中已声明的名称。

链接

C 语言具有 3 中链接属性:外部链接、内部链接或无链接。具有块作用域、函数作用域或函数原型作用域的变量都是无链接变量。具有文件作用域的变量可以是外部链接或内部链接。外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用。

正式和非正式术语

C 标准用“内部链接的文件作用域”描述仅限于一个翻译单元(即一个源代码文件和它所包含的头文件)的作用域,用“外部链接的文件作用域”描述可延伸至其他翻译单元的作用域。但是,对程序员而言这些术语太长了。一些程序员把“内部链接的文件作用域”简称为“文件作用域”,把“外部链接的文件作用域”简称为“全局作用域”或“程序作用域”。

如何知道文件作用域变量是内部链接还是外部链接?可以查看外部定义中是否使用了存储类别说明符 static

1
2
3
4
5
6
int giants = 5;
static int dodgers = 3;
int main() {
// ...
}
// ...

该文件和同一程序的其他文件都可以使用变量 giants。而变量 dodgers 属文件私有,该文件中的任意函数都可使用它。

存储期

作用域和链接描述了标识符的可见性。存储期描述了通过这些标识符访问的对象的生存期。C 对象有4种存储期:静态存储期、线程存储期、自动存储期、动态分配存储期。

如果对象具有静态存储期,那么它在程序的执行期间一直存在。文件作用域变量具有静态存储期。注意,对于文件作用域变量,关键字 static表明了其链接属性,而非存储期。以 static 声明的文件作用域变量具有内部链接。但是无论是内部链接还是外部链接,所有的文件作用域变量都具有静态存储期。

线程存储期用于并发程序设计,程序执行可被分为多个线程。具有线程存储期的对象,从被声明时到线程结束一直存在。以关键字 _Thread_local 声明一个对象时,每个线程都获得该变量的私有备份。

块作用域的变量通常都具有自动存储期。当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。这种做法相当于把自动变量占用的内存视为一个可重复使用的工作区或暂存区。例如,一个函数调用结束后,其变量占用的内存可用于储存下一个被调用函数的变量。

变长数组稍有不同,它们的存储期从声明处到块的末尾,而不是从块的开始处到块的末尾。

块作用域变量也能具有静态存储期。为了创建这样的变量,要把变量声明在块中,且在声明前面加上关键字 static

1
2
3
4
5
6
void more(int number) {
int index;
static int ct = 0;
// ...
return 0;
}

这里,变量 ct 储存在静态内存中,它从程序被载入到程序结束期间都存在。但是,它的作用域定义在 more() 函数块中。只有在执行该函数时,程序才能使用 ct 访问它所指定的对象(但是,该函数可以给其他函数提供该存储区的地址以便间接访问该对象,例如通过指针形参或返回值)。

存储类别 存储期 作用域 链接 声明方式
自动 自动 块内
寄存器 自动 块内,使用关键字 register
静态外部链接 静态 文件 外部 所有函数外
静态内部链接 静态 文件 内部 所有函数外,使用关键字 static
静态无链接 静态 块内,使用关键字 static

寄存器变量

变量通常储存在计算机内存中。如果幸运的话,寄存器变量储存在CPU的寄存器中,或者概括地说,储存在最快的可用内存中。与普通变量相比,访问和处理这些变量的速度更快。由于寄存器变量储存在寄存器而非内存中,所以无法获取寄存器变量的地址。绝大多数方面,寄存器变量和自动变量都一样。也就是说,它们都是块作用域、无链接和自动存储期。使用存储类别说明符 register 便可声明寄存器变量:

1
2
3
4
int main(void) {
register int quick;
// ...
}

我们刚才说“如果幸运的话”,是因为声明变量为 register 类别与直接命令相比更像是一种请求。编译器必须根据寄存器或最快可用内存的数量衡量你的请求,或者直接忽略你的请求,所以可能不会如你所愿。在这种情况下,寄存器变量就变成普通的自动变量。即使是这样,仍然不能对该变量使用地址运算符。
在函数头中使用关键字 register,便可请求形参是寄存器变量:

1
void macho(register int n)

可声明为 register 的数据类型有限。例如,处理器中的寄存器可能没有足够大的空间来存储 double 类型的值。

存储类别说明符

读者可能已经注意到了,关键字 staticextern 的含义取决于上下文。C 语言有 6 个关键字作为存储类别说明符:autoregisterstaticextern_Thread_localtypedeftypedef 关键字与任何内存存储无关,把它归于此类有一些语法上的原因。尤其是,在绝大多数情况下,不能在声明中使用多个存储类别说明符,所以这意味着不能使用多个存储类别说明符作为 typedef 的一部分。唯一例外的是 _Thread_local,它可以和 staticextern 一起使用。

auto 说明符表明变量是自动存储期,只能用于块作用域的变量声明中。由于在块中声明的变量本身就具有自动存储期,所以使用 auto 主要是为了明确表达要使用与外部变量同名的局部变量的意图。

register 说明符也只用于块作用域的变量,它把变量归为寄存器存储类别,请求最快速度访问该变量。同时,还保护了该变量的地址不被获取。

static 说明符创建的对象具有静态存储期,载入程序时创建对象,当程序结束时对象消失。如果 static 用于文件作用域声明,作用域受限于该文件。如果 static 用于块作用域声明,作用域则受限于该块。因此,只要程序在运行对象就存在并保留其值,但是只有在执行块内的代码时,才能通过标识符访问。块作用域的静态变量无链接。文件作用域的静态变量具有内部链接。

extern 说明符表明声明的变量定义在别处。如果包含 extern 的声明具有文件作用域,则引用的变量必须具有外部链接。如果包含 extern 的声明具有块作用域,则引用的变量可能具有外部链接或内部链接,这接取决于该变量的定义式声明。

小结:存储类别

自动变量具有块作用域、无链接、自动存储期。它们是局部变量,属于其定义所在块(通常指函数)私有。寄存器变量的属性和自动变量相同,但是编译器会使用更快的内存或寄存器储存它们。不能获取寄存器变量的地址。

具有静态存储期的变量可以具有外部链接、内部链接或无链接。在同一个文件所有函数的外部声明的变量是外部变量,具有文件作用域、外部链接和静态存储期。如果在这种声明前面加上关键字 static,那么其声明的变量具有文件作用域、内部链接和静态存储期。如果在函数中用 static 声明一个变量,则该变量具有块作用域、无链接、静态存储期。

具有自动存储期的变量,程序在进入该变量的声明所在块时才为其分配内存,在退出该块时释放之前分配的内存。如果未初始化,自动变量中是垃圾值。程序在编译时为具有静态存储期的变量分配内存,并在程序的运行过程中一直保留这块内存。如果未初始化,这样的变量会被设置为 0。

具有块作用域的变量是局部的,属于包含该声明的块私有。具有文件作用域的变量对文件(或翻译单元)中位于其声明后面的所有函数可见。具有外部链接的文件作用域变量,可用于该程序的其他翻译单元。具有内部链接的文件作用域变量,只能用于其声明所在的文件内。

分配内存:malloc()free()

首先,回顾一下内存分配。所有程序都必须预留足够的内存来储存程序使用的数据。这些内存中有些是自动分配的。例如,以下声明:

1
2
float x;
char place[] = "Dancing Oxen Creek"

为一个 float 类型的值和一个字符串预留了足够的内存,或者可以显式指定分配一定数量的内存:

1
int plates[100];

该声明预留了 100 个内存位置,每个位置都用于储存int类型的值。声明还为内存提供了一个标识符。因此,可以使用 xplace 识别数据。回忆一下,静态数据在程序载入内存时分配,而自动数据在程序执行块时分配,并在程序离开该块时销毁。

C 能做的不止这些。可以在程序运行时分配更多的内存。主要的工具是 malloc() 函数,该函数接受一个参数:所需的内存字节数。malloc() 函数会找到合适的空闲内存块,这样的内存是匿名的。也就是说, malloc() 分配内存,但是不会为其赋名。然而,它确实返回动态分配内存块的首字节地址。因此,可以把该地址赋给一个指针变量,并使用指针访问这块内存。因为 char 表示 1 字节,malloc() 的返回类型通常被定义为指向char的指针。然而,从 ANSI C 标准开始,C 使用一个新的类型:指向 void 的指针。该类型相当于一个“通用指针”。malloc() 函数可用于返回指向数组的指针、指向结构的指针等,所以通常该函数的返回值会被强制转换为匹配的类型。在 ANSI C 中,应该坚持使用强制类型转换,提高代码的可读性。然而,把指向 void 的指针赋给任意类型的指针完全不用考虑类型匹配的问题。如果 malloc() 分配内存失败,将返回空指针。

我们试着用 malloc() 创建一个数组。除了用 malloc() 在程序运行时请求一块内存,还需要一个指针记录这块内存的位置。例如,考虑下面的代码:

1
2
double *pt;
ptd = (double *)malloc(30 * sizeof(double));

以上代码为 30 个 double 类型的值请求内存空间,并设置 ptd 指向该位置。注意,指针 ptd 被声明为指向一个 double 类型,而不是指向内含 30 个 double 类型值的块。回忆一下,数组名是该数组首元素的地址。因此,如果让 ptd 指向这个块的首元素,便可像使用数组名一样使用它。也就是说,可以使用表达式 ptd[0] 访问该块的首元素,ptd[1] 访问第 2 个元素,以此类推。根据前面所学的知识,可以使用数组名来表示指针,也可以用指针来表示数组。

现在,我们有 3 种创建数组的方法。

  • 声明数组时,用常量表达式表示数组的维度,用数组名访问数组的元素。可以用静态内存或自动内存创建这种数组。
  • 声明变长数组(C99 新增的特性)时,用变量表达式表示数组的维度,用数组名访问数组的元素。具有这种特性的数组只能在自动内存中创建。
  • 声明一个指针,调用 malloc(),将其返回值赋给指针,使用指针访问数组的元素。该指针可以是静态的或自动的。

使用第 2 种和第 3 种方法可以创建动态数组(dynamic array)。这种数组和普通数组不同,可以在程序运行时选择数组的大小和分配内存。

通常,malloc() 要与 free() 配套使用。free() 函数的参数是之前 malloc() 返回的地址,该函数释放之前 malloc() 分配的内存。因此,动态分配内存的存储期从调用 malloc() 分配内存到调用 free() 释放内存为止。设想 malloc()free() 管理着一个内存池。每次调用 malloc() 分配内存给程序使用,每次调用 free() 把内存归还内存池中,这样便可重复使用这些内存。free() 的参数应该是一个指针,指向由 malloc() 分配的一块内存。不能用 free() 释放通过其他方式(如,声明一个数组)分配的内存。malloc()free() 的原型都在 stdlib.h 头文件中。

calloc() 函数

分配内存还可以使用 calloc(),典型的用法如下:

1
2
long *newmem;
newmem = (long *)calloc(100, sizeof(long));

malloc() 类似,在 ANSI 之前,calloc() 也返回指向 char 的指针;在 ANSI 之后,返回指向 void 的指针。如果要储存不同的类型,应使用强制类型转换运算符。calloc() 函数接受两个无符号整数作为参数(ANSI 规定是 size_t 类型)。第 1 个参数是所需的存储单元数量,第 2 个参数是存储单元的大小(以字节为单位)。在该例中,long 为 4 字节,所以,前面的代码创建了 100 个 4 字节的存储单元,总共 400 字节。

sizeof(long) 而不是 4,提高了代码的可移植性。这样,在其他 long 不是 4 字节的系统中也能正常工作。

calloc() 函数还有一个特性:它把块中的所有位都设置为 0(注意,在某些硬件系统中,不是把所有位都设置为 0 来表示浮点值 0)

free() 函数也可用于释放 calloc() 分配的内存。

小结

内存用于存储程序中的数据,由存储期、作用域和链接表征。存储期可以是静态的、自动的或动态分配的。如果是静态存储期,在程序开始执行时分配内存,并在程序运行时都存在。如果是自动存储期,在程序进入变量定义所在块时分配变量的内存,在程序离开块时释放内存。如果是动态分配存储期,在调用 malloc()(或相关函数)时分配内存,在调用 free() 函数时释放内存。

作用域决定程序的哪些部分可以访问某数据。定义在所有函数之外的变量具有文件作用域,对位于该变量声明之后的所有函数可见。定义在块或作为函数形参内的变量具有块作用域,只对该块以及它包含的嵌套块可见。

链接描述定义在程序某翻译单元中的变量可被链接的程度。具有块作用域的变量是局部变量,无链接。具有文件作用域的变量可以是内部链接或外部链接。内部链接意味着只有其定义所在的文件才能使用该变量。外部链接意味着其他文件使用也可以使用该变量。

下面是 C 的 5 种存储类别(不包括线程的概念)。

  • 自动——在块中不带存储类别说明符或带 auto 存储类别说明符声明的变量(或作为函数头中的形参)属于自动存储类别,具有自动存储期、块作用域、无链接。如果未初始化自动变量,它的值是未定义的。
  • 寄存器——在块中带 register 存储类别说明符声明的变量(或作为函数头中的形参)属于寄存器存储类别,具有自动存储期、块作用域、无链接,且无法获取其地址。把一个变量声明为寄存器变量即请求编译器将其储存到访问速度最快的区域。如果未初始化寄存器变量,它的值是未定义的。
  • 静态、无链接——在块中带 static 存储类别说明符声明的变量属于“静态、无链接”存储类别,具有静态存储期、块作用域、无链接。只在编译时被初始化一次。如果未显式初始化,它的字节都被设置为 0。
  • 静态、外部链接——在所有函数外部且没有使用 static 存储类别说明符声明的变量属于“静态、外部链接”存储类别,具有静态存储期、文件作用域、外部链接。只能在编译器被初始化一次。如果未显式初始化,它的字节都被设置为 0。
  • 静态、内部链接——在所有函数外部且使用了 static 存储类别说明符声明的变量属于“静态、内部链接”存储类别,具有静态存储期、文件作用域、内部链接。只能在编译器被初始化一次。如果未显式初始化,它的字节都被设置为 0。

动态分配的内存由 malloc()(或相关)函数分配,该函数返回一个指向指定字节数内存块的指针。这块内存被 free() 函数释放后便可重复使用,free() 函数以该内存块的地址作为参数。

类型限定符 constvolatilerestrict_Atomicconst 限定符限定数据在程序运行时不能改变。对指针使用 const 时,可限定指针本身不能改变或指针指向的数据不能改变,这取决于 const 在指针声明中的位置。volatile 限定符表明,限定的数据除了被当前程序修改外还可以被其他进程修改。该限定符的目的是警告编译器不要进行假定的优化。restrict 限定符也是为了方便编译器设置优化方案。restrict 限定的指针是访问它所指向数据的唯一途径。

参考文献

  • C Primer Plus