第 13 章:存储
本章介绍:
- 通过动态分配创建对象;
- 存储与初始化规则;
- 理解对象的生存期;
- 处理自动存储。
到目前为止,程序中处理的对象大多是通过常规声明引入的具名对象:它们具有确定的类型,并由一个标识符来引用。有时定义与声明位于代码中的不同位置,但定义同样通过类型和标识符来引用对象。还有一类较少见的对象有类型却没有标识符,即第 5.6.4 节介绍的复合字面量。
这些具名对象和复合字面量都有生存期,它由程序的语法结构决定。对象的生存期和标识符的可见性,可能贯穿整个程序执行过程(全局对象、全局字面量以及用 static 声明的对象就是如此),也可能局限于函数内部的某个语句块。[1]
我们还看到,在递归函数中声明的某些对象,必须区分其不同实例:递归调用链上的每一次调用,都拥有该对象的一个独立实例。因此,有必要再区分一种与对象不完全相同的实体:存储实例。
本章将介绍另一种创建对象的机制:动态分配(第 13.1 节)。严格地说,这套机制创建的存储实例起初只是字节数组,并不被看作对象;只有在其中存入内容之后,它们才获得类型。
有了这一机制,各种情形就基本齐全了,可以讨论存储期、对象生存期和标识符可见性的各项规则(第 13.2 节)。本章还会深入讨论初始化规则(第 13.4 节),因为以不同方式创建的对象,初始化方式差别很大。
此外还有两段插叙。第一段更细致地考察对象生存期,由此可以在 C 代码中一些出人意料的位置访问对象(第 13.3 节)。第二段一窥某种具体体系结构怎样实现内存模型(第 13.5 节),尤其是具体机器可能怎样处理自动存储。
13.1 malloc 及其同伴
如果程序要处理不断增长的数据集,目前见过的这些对象类型就太受限制了。面对数量不定的用户输入、Web 查询、大型交互图等不规则数据,以及大矩阵和音频流,比较方便的做法是:在运行时按需为对象申请存储实例,不再需要时再释放。这种做法称为动态分配,有时简称为分配。
头文件 <stdlib.h> 提供以下函数,作为分配存储的接口:
#include <stdlib.h>
void* malloc(size_t size);
void free(void* ptr);
void* calloc(size_t nmemb, size_t size);
void* realloc(void* ptr, size_t size);
void* aligned_alloc(size_t alignment, size_t size);2
3
4
5
6
7
前两个函数 malloc(memory allocate,内存分配)和 free 最为常用。顾名思义,malloc 在运行时为我们创建存储实例,free 随后将其销毁。另外三个函数是 malloc 的专用版本:calloc(clear allocate,清零分配)把新存储的所有位设为 0;realloc 扩大或缩小存储;aligned_alloc 确保存储按非默认的方式对齐。
要点 13.1 #1
调用分配函数时,只能请求严格大于 0 的大小。
详细讨论这些函数之前,先来看前文简要提到过的两个函数,它们是 C23 从 POSIX 引入的:
#include <string.h>
char* strdup(char const s[static 1]);
char* strndup(char const s[static 1], size_t n);2
3
4
它们专门用于字符串,把分配和复制方便地合在一起。可以认为 strdup 的定义如下:
char* strdup(char const s[static 1]) {
/* 为终止字符 0 多留一个位置。 */
char* ret = malloc(strlen(s) + 1);
return ret ? strcpy(ret, s) : nullptr;
}2
3
4
5
这个实现假定 s 非空,并且指向的确实是字符串;否则 strlen 的扫描就会出错。
这里,malloc 要么分配所请求的存储空间并返回指针,要么在没有可用存储时返回空指针。代码考虑到了这一点,只在分配成功后才复制内容。
要点 13.1 #2
分配失败会产生空指针。
strndup 的约束比 strdup 稍宽。它只假定 s 所指缓冲区的前 n 个字节中存在值 0,或者缓冲区至少有 n 字节长:
char* strndup(char const s[static 1], size_t n) {
char const* pos = memchr(s, 0, n);
n = pos ? (pos - s) + 1 : n;
char* ret = malloc(n);
if (ret) {
memcpy(ret, s, n - 1);
ret[n - 1] = 0;
}
return ret;
}2
3
4
5
6
7
8
9
10
如果函数返回了缓冲区,那么它一定是字符串,因为最后一个字节必定写入了值 0。因此,只要知道字符串大小的上界,就应优先使用这个接口。
要点 13.1 #3
优先使用 strndup,而不是 strdup。
<stdlib.h> 中的这些函数操作的是 void*,即不带已知类型信息的指针。能为这组函数指定这样一种“非类型”,很可能正是整个 void* 指针机制存在的理由。有了它,这些函数就能通用于所有类型。下面的示例为一个 double 向量分配一大块存储,地球上每个活着的人对应一个元素:
size_t length = living_people();
double* large_vec = malloc(length * sizeof *large_vec);
for (size_t i = 0; i < length; ++i) {
large_vec[i] = 0.0;
}
/* ... */
free(large_vec);2
3
4
5
6
7
8
9
练习 37
不要真的执行这次分配,而是算一算在你的平台上需要多少存储。你的平台有可能分配出这样一个向量吗?
malloc 既不知道这块存储将来作何用途,也不知道对象是什么类型,所以存储大小以字节为单位指定。上面的惯用写法只在一处给出类型信息,即 large_vec 的指针类型。在 malloc 的实参中使用 sizeof *large_vec,能保证分配的字节数正确;即使以后把 large_vec 改成 size_t*,分配也会自动随之调整。
另一种常见的惯用写法是直接取要创建对象的类型大小,这里是由 length 个 double 元素组成的数组:
double* large_vec = malloc(sizeof(double[length]));强制转换(即显式转换)已经让我们吃过苦头。务必注意,malloc 调用应当保持原样:从它的返回类型 void* 到目标类型的转换是自动完成的,无须任何干预。
要点 13.1 #4
不要强制转换 malloc 及其同伴的返回值。
这种强制转换不仅多余,而且如果忘了包含 <stdlib.h>,显式转换还会适得其反。旧式 C 编译器可能会这样假定:
/* 若忘记包含 stdlib.h,许多编译器仍会假定: */
int malloc(); // 错误的函数接口!
double* large_vec = (void*)malloc(sizeof(double[length]));2
3
4
编译器会认为它返回 int,于是执行一次错误的从 int 到指针类型的转换。作者见过许多由此引发的崩溃和隐蔽缺陷,在听从了糟糕建议的初学者代码中尤其常见。
前面代码的下一步,是通过赋值初始化刚分配的存储,这里把所有元素都设为 0.0。只有完成这些赋值之后,large_vec 的各个元素才成为“对象”。这些赋值为它们赋予了有效类型和值。
要点 13.1 #5
通过 malloc 分配的存储没有初始化,也没有类型。
13.1.1 数组大小可变的完整示例
下面来看一个示例:与简单的数组对象相比,用 malloc 分配动态数组更加灵活。以下接口描述了一个存放 double 值的环形缓冲区,名为 circular。
circular:double 值环形缓冲区
这种数据结构可以在尾部加入 double 值,并从头部取出值。每个结构可存储的元素数量都有上限。
typedef struct circular circular;缓冲区起初有 0 个元素;只要已存元素数量不超过上限,就可以向其中追加新元素,或从头部移除元素。
/* 把值 value 作为新元素追加到缓冲区 c。
成功时返回 c,否则返回空指针。 */
circular* circular_append(circular* c, double value);
/* 从 c 中移除最旧元素并返回其值。
元素存在时返回该元素,否则返回 0.0。 */
double circular_pop(circular* c);
/* 返回缓冲区 c 中位置 pos 的指针。
成功时返回相应元素指针,否则返回空指针。 */
double* circular_element(circular const* c, size_t pos);2
3
4
5
6
7
8
9
10
11
由于 circular 需要为环形缓冲区分配和释放空间,必须为该类型的实例提供一致的初始化与销毁函数。这项功能由两对函数提供。
第一对函数作用于已有的存储。它们接收指向结构体的指针,负责为数据成员分配或释放空间:
/* 用最多 cap 个元素初始化环形缓冲区 c。
只能用于未初始化的缓冲区。
用本函数初始化的每个缓冲区,都必须调用 circular_destroy 销毁。 */
circular* circular_init(circular* c, size_t cap);
/* 销毁环形缓冲区 c。
c 必须已经通过 circular_init 初始化。 */
void circular_destroy(circular* c);2
3
4
5
6
7
8
第二对函数还会分配或释放结构体本身的存储。由于它们不直接访问结构体成员,可以定义为 inline:
/* 分配并初始化最多容纳 len 个元素的环形缓冲区。
用本函数分配的每个缓冲区,都必须调用 circular_delete 删除。 */
[[nodiscard("pointer to allocated data dropped")]]
[[__gnu__::__malloc__, __gnu_free__(circular_delete)]]
inline circular* circular_new(size_t len) {
return circular_init(malloc(sizeof(circular)), len);
}
/* 删除环形缓冲区 c。
c 必须已经通过 circular_new 分配。 */
inline void circular_delete(circular* c) {
circular_destroy(c);
free(c);
}2
3
4
5
6
7
8
9
10
11
12
13
14
如果使用普通数组对象,circular 能存储的最大元素数量在创建时就固定了。为了更加灵活,可以用 circular_resize 提高或降低这一上限,用 circular_getlength 查询元素数量:
/* 把容量调整为 cap。
返回的指针将取代函数实参。 */
[[nodiscard("returned pointer replaces function argument")]]
circular* circular_resize(circular* c, size_t cap);
/* 返回已经存储的元素数量。 */
size_t circular_getlength(circular const* c);2
3
4
5
6
7
circular_new 和 circular_resize 用到了一个此前没见过的属性 [[nodiscard]]。它表示不应忽略函数的返回值,还可以附带一条消息;一旦忽略,编译器就会发出警告。这对当前用法格外重要:一般来说,传给函数的指针在函数返回后就会失效,必须改用返回的指针值,而它可能已经变了。
此外,circular_new 使用了 GNU 专用属性 [[__gnu__::__malloc__]],表示函数返回的指针指向此前从未出现过的数据;第二种形式还指明应当用 circular_delete 删除这些数据。
借助 circular_element,该类型用起来近似 double 数组:传入当前长度范围内的位置,就能得到该位置所存元素的地址。
在本书上一版以及 C23 以前的实现中,结构体定义藏在 .c 文件里,使用者只能通过提供的函数来访问。如今可以用 [[deprecated]] 把所有成员标为弃用,这样一来,结构体定义也能放进头文件。下文将展示这种设计如何运作。
struct circular {
size_t start [[deprecated("privat")]]; /* 第一个元素。 */
size_t len [[deprecated("privat")]]; /* 元素数量。 */
size_t cap [[deprecated("privat")]]; /* 最大容量。 */
double* tab [[deprecated("privat")]]; /* 数据数组。 */
};2
3
4
5
6
思路是:指针成员 tab 始终指向长度为 cap 的数组对象。在任一时刻,缓冲区中的元素都从 start 开始,成员 len 记录缓冲区中存储的元素数量。数组 tab 内的位置按 cap 取模计算。
下表展示了一个 cap = 10、start = 2、len = 4 的 circular 实例:
| 数组索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 缓冲内容 | 未定 | 未定 | 6.0 | 7.7 | 81.0 | 99.0 | 未定 | 未定 | 未定 | 未定 |
| 缓冲位置 | 0 | 1 | 2 | 3 |
四个数 6.0、7.7、81.0 和 99.0 连续存放在 tab 所指的数组对象中。
下表中仍是这四个数,但元素的存储跨过了数组末端:
| 数组索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 缓冲内容 | 81.0 | 99.0 | 未定 | 未定 | 未定 | 未定 | 未定 | 未定 | 6.0 | 7.7 |
| 缓冲位置 | 2 | 3 | 0 | 1 |
初始化这种数据结构时,需要调用 malloc 为成员 tab 提供内存:
[[deprecated("implementation")]]
circular* circular_init(circular* c, size_t cap) {
if (c) {
if (cap) {
*c = (circular){
.cap = cap,
.tab = malloc(sizeof(double[cap])),
};
/* 分配失败。 */
if (!c->tab) {
c->cap = 0;
}
} else {
*c = (circular){};
}
}
return c;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
该函数总会检查指针形参 c 是否有效;条件语句的两个分支都用复合字面量赋值,从而保证其余成员都初始化为 0。
库函数 malloc 可能因多种原因失败。例如,之前的调用可能已经耗尽了内存系统,或者请求分配的大小实在太大。在通用系统上(你用来学习的系统很可能就属于这一类),除非刻意为之,这种失败并不常见;但检查是否失败依然是好习惯。
要点 13.1.1 #1
malloc 通过返回空指针值表示失败。
销毁这种对象更加简单:只需检查指针,然后无条件释放成员 tab。
[[deprecated("implementation")]]
void circular_destroy(circular* c) {
if (c) {
free(c->tab);
circular_init(c, 0);
}
}2
3
4
5
6
7
库函数 free 有一个贴心的性质:它接受空指针形参,这时什么也不做。
其他几个函数借助一个内部函数来计算缓冲区中的“环形”位置。该函数声明为 static,因此只对这些函数可见,不会污染标识符命名空间(要点 9.2 #3):
[[deprecated("implementation")]]
static size_t circular_getpos(circular const c[static 1], size_t pos) {
if (c->cap) {
pos += c->start;
pos %= c->cap;
}
return pos;
}2
3
4
5
6
7
8
取得缓冲区元素指针就很简单:
[[deprecated("implementation")]]
double* circular_element(circular const* c, size_t pos) {
double* ret = nullptr;
if (c && pos < c->cap) {
pos = circular_getpos(c, pos);
ret = &c->tab[pos];
}
return ret;
}2
3
4
5
6
7
8
9
有了这些信息,你现在应该能实现除一个接口之外的所有函数了。[2] 比较难的是 circular_resize 函数。它先计算几个相关的长度,再根据请求是扩大还是缩小数组分别处理。这里采用一种命名约定:以 o(代表 old,旧的)开头的对象名表示修改前的属性,以 n(代表 new,新的)开头的对象名表示修改后的值。在函数末尾,用各个分支中算出的值,通过复合字面量构造一个新的结构体:
[[nodiscard("returned pointer replaces function argument")]]
[[deprecated("implementation")]]
circular* circular_resize(circular* c, size_t nlen) {
if (c) {
size_t len = c->len;
if (len > nlen) {
return nullptr;
}
size_t olen = c->cap;
if (nlen != olen) {
size_t ostart = circular_getpos(c, 0);
size_t nstart = ostart;
double* otab = c->tab;
double* ntab;
if (nlen > olen) {
/* 扩大分支见下文。 */
}
/* 缩小分支留作练习。 */
*c = (circular){
.cap = nlen,
.start = nstart,
.len = len,
.tab = ntab,
};
}
}
return c;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
先来填补前面代码中的空缺,处理扩大对象这第一种情况。关键在于调用 realloc:
ntab = realloc(c->tab, sizeof(double[nlen]));
if (!ntab) {
return nullptr;
}2
3
4
realloc 接收指向现有对象的指针,以及重定位后的新大小。它要么返回一个指针,指向具有所需大小的新对象,要么返回空指针。紧随其后的代码检查后一种情况:如果无法重定位对象,就终止函数。
realloc 有一些值得注意的性质:
- 若原指针为空,它会像
malloc一样分配缓冲区。 - 返回的指针可能与实参相同,也可能不同。运行时系统会自行判断能否原地调整(例如对象后面是否还有空间),还是必须提供新对象。无论返回的指针是否相同,结果都视为一个数据相同的新对象;特别是,从原对象派生的所有指针都会失效。
- 如果实参指针与返回的指针不同,也就是对象已被复制,那么无须、也不应再对旧指针做任何处理;旧对象已经处理妥当。
- 对象的现有内容会尽可能保留:
- 扩大对象时,与旧大小对应的起始部分保持不变;
- 缩小对象时,重定位后的对象包含调用前的起始部分。
- 若返回空指针,即运行时系统无法满足重定位请求,旧对象保持不变,不会丢失任何内容。
确认新对象已具有所需大小后,还必须保证 tab 仍能表示环形缓冲区。如果缓冲元素原本像第一张表那样连续存放,就无须处理,所有数据都已妥善保留。
若环形缓冲区跨越末端,则需要调整:
/* 缓冲区非空,而且原本已经分成两段。 */
if (ostart + len > olen) {
size_t ulen = olen - ostart;
size_t llen = len - ulen;
if (llen <= (nlen - olen)) {
/* 把下半段复制到旧末端之后。 */
memcpy(ntab + olen, ntab, llen * sizeof(double));
} else {
/* 把上半段移到新数组末端。 */
nstart = nlen - ulen;
memmove(ntab + nstart,
ntab + ostart,
ulen * sizeof(double));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
第一个子情况中,原来位于低索引处的部分可以完整装入新增加的空间:
| 阶段 | 数组布局 |
|---|---|
| 旧布局(索引 0—9) | 81.0, 99.0, 未定, 未定, 未定, 未定, 未定, 未定, 6.0, 7.7 |
| 新布局(索引 0—12) | 81.0, 99.0, 未定, 未定, 未定, 未定, 未定, 未定, 6.0, 7.7, 81.0, 99.0, 未定 |
| 新缓冲位置 | 索引 8、9、10、11 分别是位置 0、1、2、3 |
另一种情况下,低索引处的部分装不进新增空间,于是把缓冲区的上半段移向新数组末端。
两种处理方式之间有一个细微差别。第一种情况使用 memcpy,这是安全的,因为复制的源元素与目标元素不可能重叠。另一种情况下,源元素和目标元素可能重叠,因此必须使用限制较少的 memmove。[3]
你可能已经注意到,前面这些函数的定义(而非声明)也带有 [[deprecated]] 属性。这样一来,这些函数内部使用 circular 的结构体成员时,就不会因为成员已弃用而产生诊断;否则,编译 circular.c 时会冒出大量无用的诊断。只包含头文件的代码看不到函数定义上的 [[deprecated]],因此调用这些函数不会产生诊断,只有直接访问结构体成员时才会。
因此,使用者的代码仍然可以定义 circular 类型的局部对象:
#include "circular.h"
circular circ;
circular_init(&circ, 100);
/* ... */
circular_destroy(&circ);2
3
4
5
6
7
8
如果结构体定义完全藏在 .c 源文件中,就不可能这样写。
在本书写作时(2024 年 1 月),并非所有编译器都完全按 C 标准对该属性的意图来实现。因此,源文件开头加了一条编译器专用的 #pragma;等相关编译器修正问题、更严格地遵循 C 标准后,就可以删掉它:
#if __GNUC__ > 4 && __GNUC__ <= 14
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#endif2
3
13.1.2 保证动态分配一致
前面两个代码示例都遵循同一条原则:malloc、realloc、free 等分配函数的调用应当成对出现。它们不必位于同一个函数中,但在大多数情况下,分别数一数二者的出现次数,结果应当相同。
要点 13.1.2 #1
每次分配都必须对应一次释放。
否则就可能出现内存泄漏,即丢失已分配的对象。这可能耗尽平台资源,表现为性能低下或随机崩溃。
要点 13.1.2 #2
每次调用 free,都必须对应一次 malloc、calloc、aligned_alloc 或 realloc。
但要注意,realloc 很容易打乱这种简单计数:以现有对象调用它时,它会同时释放旧对象并分配新对象。
内存分配系统力求简单,因此 free 只接受分配函数返回的指针或空指针。
要点 13.1.2 #3
只能把 malloc、calloc、aligned_alloc 或 realloc 原样返回的指针传给 free。
传入的指针不得:
- 指向通过其他方式分配的对象,例如具名对象或复合字面量;
- 指向已经释放的对象;
- 只指向已分配对象内部较小的一部分。
否则程序会崩溃。说真的,这会彻底破坏本次程序执行的内存,是最糟糕的崩溃类型之一。
13.1.3 柔性数组成员
前面的 circular 展示了怎样把动态分配的数组(成员 tab)与元数据(例如成员 len)结合起来。C 还提供另一种方式,能把数组的信息与数组本身更直接地绑在一起,这就是柔性数组成员(flexible array member,FAM)。这种数组必须是结构体的最后一个成员,并定义为不完整数组:
typedef struct ua32 ua32;
struct ua32 {
size_t length;
uint32_t data[]; // 柔性数组成员。
};2
3
4
5
6
思路是让成员 data 和 length 始终保持一致:对于指向该类型对象的指针值 ap,ap->data 始终表示一个 uint32_t[ap->length] 类型的数组:
size_t len = 32;
size_t size = offsetof(ua32, data) + sizeof(uint32_t[len]);
/* 大小过小时作出调整。 */
if (size < sizeof(ua32)) {
size = sizeof(ua32);
}
ua32* ap = calloc(size, 1);
/* 保证长度成员与对象大小一致。 */
ap->length = len;
/* ap->data 主要作为数组使用。 */
for (size_t i = 0; i < ap->length; ++i) {
printf("ap->data[%zu] is %w32u\n", i, ap->data[i]);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
请注意,柔性数组成员 data 不一定紧跟在结构体本身之后,其偏移量可能使它的起点落在结构体的常规大小之内。因此,必须始终保证分配的存储足以访问整个结构体。
要点 13.1.3 #1
带有柔性数组成员的结构体对象,必须拥有足以访问整个结构体的存储。
遗憾的是,标准没有提供能够分配这种结构体并自动初始化 length 等成员值的工具。
要点 13.1.3 #2
长度成员与柔性数组成员之间的一致性必须手工维护。
13.2 存储期、生存期与可见性
我们已经多次看到,标识符的可见性与它所引用对象的可访问性并不是同一件事。清单 13.1 中名为 x 的对象就是一个简单示例。
清单 13.1:局部标识符遮蔽示例
#include "c23-fallback.h"
void square_it(double* p) [[__unsequenced__]] {
*p *= *p;
}
int main(void) {
double x = 35.0;
double* xp = &x;
{
square_it(&x); // 引用 double x。
/* ... */
[[maybe_unused]] int x = 0; // 遮蔽 double x。
/* ... */
square_it(xp); // 仍然有效地使用 double x。
/* ... */
}
/* ... */
square_it(&x); // 引用 double x。
/* ... */
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
第 7 行声明的标识符 x,其可见范围从该行开始,一直延伸到函数 main 末尾,但中间有一段明显的中断:从第 12 行到第 16 行,它被另一个同名标识符 x 遮蔽了。
要点 13.2 #1
标识符只在其作用域内可见,而且可见性从声明处开始。
要点 13.2 #2
标识符的可见性可能被从属作用域中的同名标识符遮蔽。
标识符的可见性与它所表示的对象能否使用,并不是一回事。首先,所有 square_it 调用使用的都是 double x 对象,尽管在定义该函数的地方看不到标识符 x。其次,在第 14 行,尽管标识符已被遮蔽,我们仍然把 double x 的地址传给了 square_it。
另一个示例涉及带存储类 extern 的声明。这类声明总是指代具有静态存储期的对象,这种对象应当在文件作用域中定义;[4] 见清单 13.2。
清单 13.2:extern 标识符遮蔽示例
#include <stdio.h>
unsigned i = 1;
int main(void) {
unsigned i = 2; // 一个新对象。
if (i) {
extern unsigned i; // 一个已有对象。
printf("%u\n", i); // 打印 1。
} else {
printf("%u\n", i); // 打印 2。
}
}2
3
4
5
6
7
8
9
10
11
12
13
该程序有三个名为 i 的声明,却只有两个定义:第 6 行的声明兼定义遮蔽了第 3 行的定义;随后,第 8 行的声明又遮蔽了第 6 行的定义,但它引用的正是第 3 行定义的那个对象。
练习 41
该程序会打印哪个值?
要点 13.2 #3
具名对象的每一项定义都会创建一个彼此不同的新对象。
因此在下例中,字符数组 A 和 B 标识的是地址不同的两个对象,表达式 A == B 必定为假:
char const A[] = {'e', 'n', 'd', '\0'};
char const B[] = {'e', 'n', 'd', '\0'};
char const* c = "end";
char const* d = "end";
char const* e = "friend";
char const* f = (char const[]){'e', 'n', 'd', '\0'};
char const* g = (char const[]){'e', 'n', 'd', '\0'};2
3
4
5
6
7
但这里一共有多少个不同的数组对象?答案并不确定,编译器有很大的选择余地。
要点 13.2 #4
只读对象字面量可以相互重叠。
前例包含三个字符串字面量和两个复合字面量。它们都是只读的对象字面量:字符串字面量按定义是只读的,两个复合字面量则受 const 限定。其中四个的基础类型和内容完全相同,都是 {'e', 'n', 'd', '\0'},所以四个指针 c、d、f 和 g 完全可能都初始化为同一个字符数组的地址。编译器甚至可以更节省内存:由于 end 位于 friend 的末尾,这个地址可能恰好就是 &e[3]。
这些示例表明,对象是否可用,不仅取决于标识符的词法性质或字面量定义的位置,还取决于程序的执行状态。对象的生存期有起点,也有终点。
要点 13.2 #5
对象具有生存期;在生存期之外不能访问该对象。
要点 13.2 #6
如果程序执行在对象生存期之外引用该对象,则执行失败。
对象生存期的起点和终点如何界定,取决于创建对象所用的工具。C 区分四种对象存储期:
- 静态存储期: 在编译时确定;
- 自动存储期: 在运行时自动确定;
- 分配存储期: 由
malloc及其同伴的函数调用显式确定; - 线程存储期: 绑定到特定执行线程。
下表概括了声明及其存储类、初始化、链接、存储期和生存期之间的复杂关系。“暂定”表示只有在没有其他带初始化器的定义时,才隐式构成定义;“诱导”表示:如果此前遇到过内部链接的声明,就采用内部链接,否则采用外部链接。复合字面量视其所在的块(如果有)遵循类似的规则。
| 存储类或形式 | 作用域 | 定义 | 链接 | 存储期 | 生存期 |
|---|---|---|---|---|---|
| 带初始化器 | 文件 | 是 | 外部 | 静态 | 整次执行 |
extern,带初始化器 | 文件 | 是 | 外部 | 静态 | 整次执行 |
| 字符串字面量 | 任意 | 是 | 不适用 | 静态 | 整次执行 |
static,带初始化器 | 任意 | 是 | 内部 | 静态 | 整次执行 |
constexpr | 任意 | 是 | 内部 | 静态 | 整次执行 |
| 未初始化 | 文件 | 暂定 | 外部 | 静态 | 整次执行 |
extern,未初始化 | 任意 | 否 | 诱导 | 静态 | 整次执行 |
static,未初始化 | 任意 | 暂定 | 内部 | 静态 | 整次执行 |
thread_local | 文件 | 是 | 外部 | 线程 | 整个线程 |
extern thread_local | 任意 | 否 | 外部 | 线程 | 整个线程 |
static thread_local | 任意 | 是 | 内部 | 线程 | 整个线程 |
非 VLA,无存储类、auto 或 register | 块 | 是 | 无 | 自动 | 定义所在块 |
| VLA | 块 | 是 | 无 | 自动 | 从定义到块末尾 |
| 带数组的函数返回表达式 | 块 | 是 | 无 | 自动 | 到完整表达式末尾 |
这张表还没有涉及全部细节,却已经表明:关键字的用法与底层术语很容易混淆。
首先,与名称给人的印象相反,存储类 extern 既可能对应具有外部链接的标识符,也可能对应具有内部链接的标识符。[5] 具有链接的标识符除了由编译器处理,通常还由另一个外部程序(链接器)管理。这类标识符在程序启动时、甚至在进入 main 之前就完成初始化,由链接器负责保证这一点。来自不同目标文件的标识符需要具有外部链接,才能都访问同一个对象或函数,并由链接器建立它们之间的对应关系。
我们见过的一类重要的外部链接标识符是 C 库函数。它们位于系统库中(通常名为 libc.so 之类),而不在你创建的目标文件里。与其他目标文件无关的全局文件作用域对象或函数,应当具有内部链接。其余标识符都没有链接。[6]
其次,静态存储期与用存储类 static 声明具名对象不是一回事。后者只是强制该具名对象或函数具有内部链接。这样的对象可以在文件作用域(全局)声明,也可以在块作用域(局部)声明。
前三种存储期我们已经见过许多示例。线程存储期从 C23 起写作 thread_local,此前写作 _Thread_local;它与第 20 章将介绍的 C 线程 API 相关。
分配存储期比较简单:这种对象的生存期始于创建它的 malloc、calloc、realloc 或 aligned_alloc 调用,终于销毁它的 free 或 realloc 调用;如果没有这样的调用,则持续到程序执行结束。
另外两种存储期需要更多解释,下面分别讨论。从 C23 开始,复合字面量的类型中也可以带有存储类;相应规则较为简单。
要点 13.2 #7
复合字面量的生存期,与在同一语境中按相同存储类声明的具名对象相同。
唯一可能的例外,是块作用域中受 const 限定、但没有任何存储类说明的复合字面量。它们甚至不必对应唯一的对象,而可以引用在整个程序执行期间都可用的存储。
13.2.1 静态存储期
具有静态存储期的对象可以通过不同方式定义:
- 在文件作用域中定义且未声明为
thread_local的对象,具名对象和复合字面量均可; - 在块内定义、带存储类说明符
static且没有附加thread_local的具名对象和复合字面量; - 字符串字面量;它们是
char或宽字符类型数组,始终具有静态存储期。
这些对象的生存期贯穿整个程序执行过程。由于在任何应用代码执行之前它们就已视为存活,所以只能用编译时已知的表达式,或者系统的进程启动过程能够解析的表达式来初始化。例如:
double A = 37;
double* p = &(static double){1.0};
int main(void) {
static double B;
}2
3
4
5
6
这里定义了四个具有静态存储期的对象:由 A、p 和 B 标识的三个对象,以及第 2 行定义的复合字面量。其中三个对象具有 double 类型,一个具有 double* 类型。
这四个对象从一开始就得到了正确初始化:其中三个显式初始化,B 隐式初始化为 0。
要点 13.2.1 #1
具有静态存储期的对象始终会初始化。
p 的初始化需要一点超出编译器自身能力的魔法,因为它用到了另一个对象的地址。这样的地址通常只能在执行启动时计算;这正是大多数 C 实现需要链接器这一概念的原因。
B 的示例说明,即使对象的生存期贯穿整个程序执行过程,它的名称也不一定在整个程序中可见。前面的 extern 示例则表明,在别处定义的静态存储期对象,可以在较小的作用域内变得可见。
13.2.2 自动存储期
这是最复杂的情况。自动存储期的规则大多是隐式的,因此最需要解释。以下几类显式或隐式定义的对象属于这一类别:
- 块作用域中未声明为
static的具名对象和复合字面量;它们可能没有存储类,也可能带有传统存储类auto、register或constexpr;[7] - 函数调用返回的某些临时对象。
当自动对象不是变长数组(VLA)时,其生存期规则最简单,也最常见。
要点 13.2.2 #1
除 VLA 或临时对象外,自动对象的生存期与定义所在块的执行相对应。
也就是说,大多数局部对象会在程序执行进入其定义所在作用域时创建,在离开该作用域时销毁。不过,由于递归,同一个对象的多个实例可能同时存在。
要点 13.2.2 #2
每次递归调用都会为自动对象创建一个新的局部实例。
具有自动存储期的对象为优化提供了很大便利:编译器通常能看到具名对象的全部用法,因而可以据此判断它是否可能形成别名。用传统的 auto、constexpr 和 register 声明的对象之间的差别,正是在这里体现出来。
要点 13.2.2 #3
& 运算符不能用于以 register 声明的对象。
这样就不可能无意间取得 register 对象的地址(要点 12.3 #2),由此直接得出下一条规则。
要点 13.2.2 #4
以 register 声明的对象不能形成别名。
借助 register 声明,可以迫使编译器指出我们在哪里取了地址,从而找出一些有优化潜力的地方。对于既不是数组、也不包含数组的对象,这种方法效果很好。
要点 13.2.2 #5
在性能关键代码中,把非数组的局部对象声明为 register。
数组在这里有特殊地位:几乎在所有语境中,数组都会退化为首元素的地址,所以必须能取得数组的地址。
要点 13.2.2 #6
存储类为 register 的数组没有用处。
数组的存在还导致另一种情况需要特殊处理。某些函数返回值是真正的“奇美拉”:具有临时生存期的对象。函数通常返回的是值,而值本身无法寻址;但如果返回类型包含数组类型,就必须能隐式取得其地址,[] 运算符才有明确定义。因此,下面这个函数的返回结果是临时对象,可以通过成员指示符 .ory[0] 隐式取得其地址:
struct demo {
unsigned ory[1];
};
struct demo mem(void);
printf("mem().ory[0] is %u\n", mem().ory[0]);2
3
4
5
6
7
C 之所以有临时生存期对象,唯一的原因就是要访问这类函数返回值的成员。不要把它们挪作他用。
要点 13.2.2 #7
具有临时生存期的对象只读。
要点 13.2.2 #8
临时生存期终于外围完整表达式结束之时。
也就是说,包含临时对象的表达式一旦求值完毕,临时对象的生命也就随之结束。在前例中,一构造完 printf 的实参,临时对象就不复存在。复合字面量则不同:若在此处定义复合字面量,它会一直存活到 printf 所在的外围作用域结束。
13.3 插叙:在定义前使用对象
本节进一步讨论自动对象怎样获得生命(或者没能获得生命)。内容稍有难度,如果现在没有精力,可以先跳过,以后再回来看。要理解第 13.5 节的具体机器模型(那同样是一段插叙),需要用到本节的知识。本节还会引入 goto 和标签,第 15.6 节处理错误时还要用到它们。
回到普通自动对象的生存期规则(要点 13.2.2 #1)。仔细想想就会发现,这条规则相当特别:这类对象的生存期始于进入其定义所在的作用域,而不是更符合直觉的“执行首次遇到定义时”。
为了看清其中的差别,来看清单 13.3,它改编自 C 标准文档中的一个示例。
清单 13.3:有意构造的复合字面量示例
void fgoto(unsigned n) {
unsigned j = 0;
unsigned* p = nullptr;
unsigned* q;
AGAIN:
if (p) {
printf("%u: p and q are %s, *p is %u\n",
j,
(q == p) ? "equal" : "unequal",
*p);
}
q = p;
p = &((unsigned){j});
++j;
if (j <= n) {
goto AGAIN;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
我们特别关心调用 fgoto(2) 时打印出的内容。在作者的计算机上,输出如下:
1: p and q are unequal, *p is 0
2: p and q are equal, *p is 12
这段代码显然是刻意拼凑的。它用到了一个我们还没实际见过的新构造 goto。顾名思义,这是一条跳转语句,在本例中它让计算机从标签 AGAIN 处继续执行。后文会看到更适合使用 goto 的场合;这里只是用它演示怎样跳过第 14 行复合字面量的定义。
当 n == 2 时,程序执行会三次遇到 printf 所在的行;但由于 p 初始为空,第一次会跳过该调用。各次的状态如下:
j | p | q | printf |
|---|---|---|---|
| 0 | 空 | 不确定 | 跳过 |
| 1 | 值为 0 的字面量地址 | 空 | 打印 |
| 2 | 值为 1 的字面量地址 | 值为 0 的字面量地址 | 打印 |
当 j == 2 时,p 和 q 保存的地址来自不同的迭代。为什么输出却说二者地址相等?这只是巧合吗?还是说,在词法上位于复合字面量定义之前的地方使用它,触发了未定义行为?
C 标准规定,必须产生这里展示的输出。特别是,当 j == 2 时,p 和 q 的值相等且有效,它们所指对象的值为 1。换句话说,本例中对 *p 的使用是明确定义的,尽管从词法上看,对 *p 的求值出现在对象定义之前。此外,这里只有一个复合字面量,所以 j == 2 时两个地址相等。
要点 13.3 #1
对于非 VLA 对象,生存期始于进入定义所在作用域,终于离开该作用域。
要点 13.3 #2
每次执行遇到定义时,都会对自动对象和复合字面量的初始化器求值。
本例三次经过复合字面量,依次把它设成 0、1 和 2。
VLA 的生存期遵循不同规则。
要点 13.3 #3
VLA 的生存期始于执行遇到定义,终于离开可见作用域。
因此,前面借助 goto 的奇怪技巧对 VLA 不起作用:即使仍处于同一个块中,也不能在定义之前的代码中使用指向 VLA 的指针。之所以对 VLA 特殊处理,是因为它的大小是一项运行时性质,在进入声明所在的块时根本无法为它分配空间。
13.4 初始化
第 5.5 节讨论过初始化的重要性:要让程序从明确定义的状态开始,并在整个执行期间保持这种状态,初始化至关重要。对象的存储期决定了它的初始化方式。
要点 13.4 #1
具有静态或线程存储期的对象会默认初始化。
你可能还记得,默认初始化相当于把对象的所有成员初始化为 0(算术类型)或 nullptr(指针类型)。特别是,有些基础类型的 {} 值可能具有非平凡的表示(即指针类型和浮点类型),对它们而言,默认初始化同样能正确工作。
而对于自动对象和分配对象,则必须主动初始化。
要点 13.4 #2
具有自动或分配存储期的对象必须显式初始化。
最简单的办法是使用初始化器,让具名对象与复合字面量一进入可见范围就处于明确定义的状态。从 C23 开始,即使是以 VLA 形式分配的数组,只要对整个数组采用默认初始化,也能这样做。
对于动态分配的数组,如果知道把所有位设为零就是有效的初始化(基本上所有整数类型都是如此),就应当用 calloc 而不是 malloc 来分配。如果不能这样做,例如类型包含浮点数据或指针,就必须通过赋值来初始化。原则上,每次分配这类对象时都可以手工初始化;但初始化代码可能在视觉上把定义与使用隔开,使代码难以阅读和维护。避免这个问题最简单的方式,是把初始化封装进函数。
要点 13.4 #3
系统地为每一种数据类型提供初始化函数。
这里的重点在于“系统地”:初始化函数怎样工作、怎样命名,都应当遵循一致的约定。回头看 rat 数据类型的初始化函数 rat_init,它为这类函数实现了一套具体的 API:
- 对于类型
toto,初始化函数命名为toto_init; _init函数的第一个实参,是待初始化对象的指针;- 若该对象指针为空,函数什么也不做;
- 可以提供其他实参,为特定成员传入初始值;
- 函数返回收到的对象指针;若发生错误,则返回空指针。
有了这些性质,这类函数可以很方便地用在指针的初始化器中:
rat const* my_rat = rat_init(malloc(sizeof(rat)), 13, 7);这样做有几个好处:
- 若
malloc调用失败并返回空指针,唯一效果是把my_rat初始化为空,所以my_rat始终处于明确定义状态; - 如果不希望之后改变对象,可以从一开始就用
const限定指针所指的目标;对新对象的所有修改都在右侧的初始化表达式内完成。
这种初始化会出现在许多位置,因此还可以封装进另一个函数:
rat* rat_new(long long numerator,
unsigned long long denominator) {
return rat_init(malloc(sizeof(rat)),
numerator,
denominator);
}2
3
4
5
6
通过该函数初始化就变成:
rat const* my_rat = rat_new(13, 7);像作者这样痴迷宏的人,甚至可以轻松定义一个类型泛型宏,一劳永逸地完成这层封装:
#define P99_NEW(T, ...) T##_init(malloc(sizeof(T)), __VA_ARGS__)于是,前面的初始化可以写成:
rat const* my_rat = P99_NEW(rat, 13, 7);它的可读性至少不逊于 rat_new 版本,而且无须为定义的每种类型再额外声明一个函数。
许多人不赞成这类宏定义,因此有些项目不会把它当作通用策略;但你至少应当知道有这种可能。它用到了两个此前还没遇到过的宏特性:
##运算符拼接记号。这里的T##_init把实参T和_init融合成一个记号;T为rat时得到rat_init,为toto时得到toto_init;- 构造
...提供长度可变的实参列表。第一个实参之后传入的所有实参,都能在宏展开内部通过__VA_ARGS__访问。因此,可以按照相应_init函数的需要,把任意数量的实参传给P99_NEW。
如果必须用 for 循环初始化数组,代码会更难看;这同样可以轻松地封装成函数:
rat* rat_vinit(size_t n, rat p[n]) {
if (p) {
for (size_t i = 0; i < n; ++i) {
rat_init(p + i, 0, 1);
}
}
return p;
}2
3
4
5
6
7
8
借助该函数,初始化又变得简单直接了:
rat* my_rat_vec = rat_vinit(44, malloc(sizeof(rat[44])));这里确实更适合封装成函数,因为重复书写大小很容易引入错误:
rat* rat_vnew(size_t size) {
return rat_vinit(size, malloc(sizeof(rat[size])));
}2
3
13.5 插叙:机器模型
到目前为止,我们主要立足于 C 代码本身展开论证,用语言自身的内在逻辑描述所发生的事情。本节是一段可选的插叙,暂且换个角度,一窥某种具体体系结构的机器模型。我们会更仔细地观察一个简单函数怎样翻译到该模型上,尤其关注自动存储期是怎样实现的。如果现在实在吃不消,可以先跳过;否则请记住:不要慌张,大胆钻进去。
传统上,计算机体系结构用冯·诺依曼模型来描述。[8] 在这个模型中,处理单元拥有数量有限的硬件寄存器,可以保存整数值;主存储器既保存程序也保存数据,并且可以线性寻址;一套有限的指令集则描述了能对这些组成部分执行哪些操作。
有一类中间程序设计语言,通常用来以 CPU 能理解的方式描述机器指令,统称为汇编程序或汇编语言,它们至今仍大体建立在冯·诺依曼模型之上。汇编语言不像 C 那样有一种适用于所有平台的统一语言,而是一整套方言,各自针对 CPU、编译器、操作系统等方面的具体特点。这里采用的是 GCC 编译器为 x86_64 处理器体系结构所用的方言。如果你不明白这意味着什么,也不必担心;它只是某种体系结构的一个示例。
练习 48
找出让编译器为你的平台生成汇编输出所需的实参。
清单 13.4 给出了清单 13.3 中 fgoto 函数的汇编输出。这类汇编代码用指令操作硬件寄存器和内存位置。例如,指令 movl $0, -16(%rbp) 把值 0 存入(移动到)寄存器 %rbp 所指位置之前 16 字节处的内存位置。
汇编程序还包含标签,用于标识程序中的特定位置。例如,fgoto 是函数的入口点,.L_AGAIN 则对应 C 中的 goto 标签 AGAIN。你可能已经猜到,右侧 # 字符之后的文字是注释,用来把各条汇编指令与对应的 C 代码联系起来。
这个汇编函数使用以下硬件寄存器:
%eax %ecx %edi %edx %esi %rax %rbp %rcx %rdx %rsp这份列表比最初的冯·诺依曼机器所拥有的寄存器丰富得多,但核心思想依然不变:若干通用寄存器表示程序执行状态中的值,另有两个寄存器扮演非常特殊的角色,即 %rbp(基指针)和 %rsp(栈指针)。
函数在内存中有一块保留区域,通常称为栈,用来存放局部对象和复合字面量。该区域的“上”端由寄存器 %rbp 标示,各对象通过相对于它的负偏移来访问。例如,形参 n 位于 %rbp 之前 36 字节处,编码为 -36(%rbp)。
下表展示了为函数 fgoto 保留的内存块的布局,以及函数执行到三个不同位置时其中存储的值。
| 位置 | -48 | -36 | -28 | -24 | -16 | -8 | -4 | %rbp |
|---|---|---|---|---|---|---|---|---|
| 含义 | n | 复合字面量 | q | p | j | |||
| 初始化后 | 未定 | 2 | 未定 | 未定 | 0 | 未定 | 0 | |
| 第 0 次迭代后 | 未定 | 2 | 0 | 0 | %rbp-28 | 未定 | 1 | |
| 第 1 次迭代后 | 未定 | 2 | 1 | %rbp-28 | %rbp-28 | 未定 | 2 |
这个示例特别适合用来了解自动对象,以及执行进入函数时怎样建立它们。在这台特定的机器上,进入 fgoto 时,有三个寄存器保存着本次调用的信息:%edi 保存函数实参 n;%rbp 指向调用方函数的基地址;%rsp 指向一块内存的顶端地址,本次 fgoto 调用可以用这块内存存储数据。
现在来看清单 13.4 中的汇编代码怎样安排这些内容。fgoto 一开始就执行三条指令,正确建立起自己的“世界”:先保存 %rbp,因为要把这个寄存器挪作己用;再把 %rsp 的值移入 %rbp;最后把 %rsp 减去 48。这里的 48 是编译器为 fgoto 所需的全部自动对象算出的字节数。
由于建立过程如此简单,这一步保留的空间并未初始化,里面是未定的内容。接下来的三条指令初始化了三个自动对象 n、j 和 p,其余对象则保持未初始化,留待稍后处理。
清单 13.4:fgoto 函数的汇编版本
.type fgoto, @function
fgoto:
pushq %rbp # 保存基指针。
movq %rsp, %rbp # 装入栈指针。
subq $48, %rsp # 调整栈指针。
movl %edi, -36(%rbp) # fgoto 实参 0 -> n。
movl $0, -4(%rbp) # 初始化 j。
movq $0, -16(%rbp) # 初始化 p。
.L_AGAIN:
cmpq $0, -16(%rbp) # if (p)
je .L_ELSE
movq -16(%rbp), %rax # p -> rax。
movl (%rax), %edx # *p -> edx。
movq -24(%rbp), %rax # q 是否等于 p?
cmpq -16(%rbp), %rax
jne .L_YES
movl $.L_STR_EQ, %eax # 是。
jmp .L_NO
.L_YES:
movl $.L_STR_NE, %eax # 否。
.L_NO:
movl -4(%rbp), %esi # j -> printf 实参 1。
movl %edx, %ecx # *p -> printf 实参 3。
movq %rax, %rdx # equal/unequal -> printf 实参 2。
movl $.L_STR_FRMT, %edi # 格式 -> printf 实参 0。
movl $0, %eax # 清零 eax。
call printf
.L_ELSE:
movq -16(%rbp), %rax # p
movq %rax, -24(%rbp) # -> q。
movl -4(%rbp), %eax # j
movl %eax, -28(%rbp) # -> 复合字面量。
leaq -28(%rbp), %rax # &复合字面量
movq %rax, -16(%rbp) # -> p。
addl $1, -4(%rbp) # ++j。
movl -4(%rbp), %eax # if (j
cmpl -36(%rbp), %eax # <= n)
jbe .L_AGAIN # goto AGAIN。
leave # 恢复栈。
ret # return 语句。2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
完成这些安排后,函数便已准备就绪。特别是,它可以轻松调用另一个函数:%rsp 现在指向一块新内存区域的顶部,供被调用函数使用。标签 .L_NO 之后的中间部分就体现了这一点,它实现了对 printf 的调用:先依次把函数应当接收的四个实参放入寄存器 %edi、%esi、%ecx 和 %rdx,然后清零 %eax 并调用函数。
总而言之,无论函数实际用了多少个自动对象,为非 VLA 自动对象建立内存区域都只需要几条指令。若函数有更多对象,只需把魔数 48 换成该区域的新大小。
这种实现方式直接带来两个结果:
- 自动对象通常从函数或作用域开始处就已经可用;
- 不会强制初始化自动对象。
这与 C 中自动对象的生存期及初始化规则十分吻合。
前面的汇编输出最多只讲了故事的一半。为了展示代码生成背后的基本假定,生成时没有启用优化。启用优化后,“如同”规则(要点 5.1.3 #3)允许编译器大幅重组代码。在完整优化下,作者的编译器生成的代码大致如清单 13.5 所示。
清单 13.5:经过优化的 fgoto 汇编版本
.type fgoto, @function
fgoto:
pushq %rbp # 保存基指针。
pushq %rbx # 保存 rbx 寄存器。
subq $8, %rsp # 调整栈指针。
movl %edi, %ebp # fgoto 实参 0 -> n。
movl $1, %ebx # 初始化 j,从 1 开始。
xorl %ecx, %ecx # 0 -> printf 实参 3。
movl $.L_STR_NE, %edx # "unequal" -> printf 实参 2。
testl %edi, %edi # if (n > 0)
jne .L_N_GT_0
jmp .L_END
.L_AGAIN:
movl %eax, %ebx # j + 1 -> j。
.L_N_GT_0:
movl %ebx, %esi # j -> printf 实参 1。
movl $.L_STR_FRMT, %edi # 格式 -> printf 实参 0。
xorl %eax, %eax # 清零 eax。
call printf
leal 1(%rbx), %eax # j + 1 -> eax。
movl $.L_STR_EQ, %edx # "equal" -> printf 实参 2。
movl %ebx, %ecx # j -> printf 实参 3。
cmpl %ebp, %eax # if (j <= n)
jbe .L_AGAIN # goto AGAIN。
.L_END:
addq $8, %rsp # 回卷栈。
popq %rbx # 恢复 rbx。
popq %rbp # 恢复 rbp。
ret # return 语句。2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
编译器彻底重组了代码。新代码再现了原代码的效果,输出也相同;但它不使用内存中的对象,不比较指针是否相等,复合字面量也完全不见踪影。例如,它根本没有实现 j = 0 那次迭代,因为这次迭代没有任何效果,直接省略即可。对于其余迭代,它先单独处理 j = 1 的情形,此时已知 C 程序中的指针 p 和 q 不同;一般情形下则只是递增 j,并相应设置 printf 的实参。
练习 49
利用 p 每次都得到同一个值这一事实,编写一个更接近优化后汇编版本的 C 程序。
练习 50
即便是优化后的版本仍有改进余地,循环内部还可以再缩短。编写一个 C 程序,在启用完整优化编译时发掘这一潜力。
以上讨论都针对不使用 VLA 的代码。有了 VLA,情况就不同了:所需内存的大小不再是常量,就不能简单地用一个常数修改 %rsp。程序必须在执行期间根据 VLA 各项边界的实际值计算大小,随即相应调整 %rsp,并在执行离开 VLA 定义所在的作用域时撤销对 %rsp 的修改。因此,这里 %rsp 的调整量无法在编译时算出,必须在程序执行期间确定。
本章小结
- 大量对象或大型对象所需的存储可以动态分配和释放,但必须仔细追踪这些存储。
- 标识符的可见性与存储期是两回事。
- 初始化应当针对每种类型,按一致的策略系统地完成。
- C 对局部对象的分配策略,与函数栈的底层处理方式十分契合。
这里略有简化,后文很快会揭示繁复的细节。 ↩︎
练习 38:补全缺失函数的实现。 ↩︎
练习 39:实现数组缩小。务必在调用
realloc前重新组织数组内容。 ↩︎严格地说,这种对象也可以在另一翻译单元的文件作用域中定义。 ↩︎
链接是标识符的性质,而不是标识符所表示对象的性质。对
extern而言,更好的关键字或许是linkage。 ↩︎你很可能从未直接调用过平台的链接器。它通常藏在你所调用的编译器前端背后;动态链接器甚至可能到程序启动时才开始工作,而你毫无察觉。这里的
static若改叫internal或许更恰当,不过要明白,任何形式的链接都意味着静态存储期。 ↩︎关键字
auto还参与类型推导,第 18 章会完整介绍。 ↩︎该模型约于 1945 年由 J. Presper Eckert 和 John William Mauchly 为 ENIAC 项目发明,并由现代科学先驱之一 John von Neumann(1903—1957,亦称 Neumann János Lajos 和 Johann Neumann von Margitta)首次在 von Neumann [1945] 中描述。 ↩︎