c语言指针、内存、系统编程深入笔记与练习
C 语言指针、内存与系统编程深入笔记
学习主线:K&R《The C Programming Language》Chapter 5(Pointers and Arrays)+ CS:APP 中的机器级表示、数据对齐、链接、虚拟内存与 x86-64 调用约定。
本笔记把前面已经讨论过的知识重新组织成一条完整链路:对象 → 地址 → 指针 → 数组 → 字符串 → 多级指针 → 函数指针 → 泛型指针 → 复合类型 → 内存布局 → 存储期 → ELF → UB → ABI。
特别说明:
offsetof是标准 C;container_of是常见系统编程技巧,Linux 内核版本还使用 GNU C 扩展,不属于 ISO C 语言本身。.text/.rodata/.data/.bss和具体寄存器调用约定属于常见 ELF / x86-64 系统实现,不是 C 标准强制规定的。
目录
- 建立统一的内存模型
- 指针与地址:
&、*、指针变量 - 指针与函数参数:C 的值传递
- 数组与指针:相似但绝不相同
- 指针算术、边界与 one-past 指针
- 字符串、字符数组与字符指针
- 指针数组与二级指针
- 多维数组与“数组指针”
- 命令行参数
argc/argv - 函数指针与回调
- 复杂声明的阅读方法
void *:通用对象指针const:对象只读与指针只读struct / union / enum- Padding / Alignment:结构体到底怎么排
offsetof / container_of- Macro / Preprocessor
- Scope / Storage Duration / Linkage
- Stack / Heap / Global / Static
.text / .rodata / .data / .bss- 动态内存:
malloc / calloc / realloc / free - Undefined Behavior:C 最重要的边界
- Basic ABI Intuition:函数在二进制层如何合作
- 编译器、调试器与 Sanitizer
- 一张总图:从 C 源码到机器
- 编程练习(不附答案)
1. 建立统一的内存模型
学习 C 时最重要的一次认知升级,是不要只把变量理解成“一个名字对应一个值”。更接近机器的模型是:
1 | 对象(object) |
例如:
1 | int x = 42; |
可以先把它想象成:
1 | 地址 内存内容 |
x 是对象;42 是它当前的值;&x 是这个对象的地址。
1 | int *p = &x; |
此时又创建了一个独立对象 p:
1 | p 自己也是变量,也占内存: |
因此必须始终区分:
1 | p :指针变量中保存的地址 |
这是后面几乎所有内容的根。
2. 指针与地址:&、*、指针变量
2.1 指针是什么
最实用的定义:
指针是一个具有指针类型的对象,它的值用来指示另一个对象(或函数)的位置。
1 | int x = 10; |
声明:
1 | int *p; |
最好读成:
*p的类型是int,所以p是“指向 int 的指针”。
不要把 * 单纯看成“属于类型左边”的符号。C 的声明语法是故意设计成接近表达式使用方式的。
2.2 &:取地址
1 | p = &x; |
&x 产生 x 的地址。
常见形式:
1 | int x; |
&a 是“整个数组的地址”,类型是“指向 10 个 int 的数组的指针”。数值地址通常和 a 相同,但类型不同,指针算术的步长也不同。
2.3 *:解引用
1 | int x = 10; |
如果 p 指向 x,那么 *p 在很多表达式里就可以像 x 一样使用:
1 | *p += 1; |
但:
1 | *p++ |
根据运算符优先级实际是:
1 | *(p++) |
也就是“先取旧 p 指向的对象,然后让 p 向后移动”。
而:
1 | (*p)++ |
才是“增加所指对象的值”。
必须形成的习惯
遇到复杂表达式不要靠感觉:
1 | *p++ |
拆开:
1 | *p++ == *(p++) |
2.4 空指针不是“地址 0 的普通对象”
1 | int *p = NULL; |
表示 p 当前不指向一个有效 int 对象。
可以:
1 | if (p == NULL) { ... } |
不能:
1 | *p = 10; // UB |
现代 C 代码应该使用标准头文件中提供的 NULL,或者在明确类型上下文中用 0。不要把任意整数地址强行当指针使用。
3. 指针与函数参数:C 的值传递
3.1 C 只有值传递
这点必须彻底掌握。
1 | void bad_swap(int a, int b) |
调用:
1 | int x = 1, y = 2; |
x、y 不会交换,因为函数拿到的是它们的副本:
1 | caller: x=1, y=2 |
改变 a/b 不等于改变 x/y。
3.2 为什么传指针可以修改调用者对象
1 | void swap(int *a, int *b) |
调用:
1 | swap(&x, &y); |
仍然是值传递,只不过现在复制的是“地址”:
1 | caller: |
所以更精确的说法是:
C 没有“引用传递”;我们通过按值传递地址来间接修改调用者对象。
3.3 指针参数也可以模拟多个返回值
1 | int divmod(int a, int b, int *quotient, int *remainder) |
函数自身返回“是否成功”,两个结果通过输出参数返回。
这种 API 设计在 C 中非常常见。
4. 数组与指针:相似但绝不相同
4.1 真正的数组对象
1 | int a[5] = {10, 20, 30, 40, 50}; |
内存中是 5 个连续的 int:
1 | 低地址 → 高地址 |
数组是真实对象,它的总大小是:
1 | sizeof a == 5 * sizeof(int) |
只要 a 在当前作用域中真的是数组而不是函数参数。
4.2 数组表达式通常会转换成首元素指针
很多表达式中:
1 | a |
会转换为:
1 | &a[0] |
因此:
1 | int *p = a; |
等价于:
1 | int *p = &a[0]; |
并有核心等式:
1 | a[i] == *(a + i) |
以及:
1 | &a[i] == a + i |
更进一步,因为下标操作定义上是指针运算:
1 | p[i] == *(p + i) |
这也是为什么:
1 | 2[a] |
虽然极不推荐,但语义上等价于:
1 | a[2] |
因为本质都是 *(a + 2)。
4.3 数组不是指针
这是最重要的纠错之一。
1 | int a[10]; |
a 是:
1 | 一个包含 10 个 int 的数组对象 |
p 是:
1 | 一个保存 int 地址的指针对象 |
所以:
1 | p++; // 合法,p 是可修改变量 |
但:
1 | a++; // 非法,数组名不是可修改指针变量 |
并且:
1 | sizeof(a) // 整个数组字节数 |
在典型 64 位机器上可能分别是 40 与 8,但不要把具体数字写死到可移植逻辑中。
4.4 数组参数为什么“退化成指针”
1 | void f(int a[]) |
作为函数形参时等价于:
1 | void f(int *a) |
因此:
1 | void f(int a[100]) |
这里 sizeof a 是指针大小,而不是 100 个 int 的大小。
这也是为什么函数如果需要数组长度,通常必须额外传入:
1 | void f(int *a, size_t n); |
4.5 数组不会在所有上下文中退化
最重要的例外:
1 | sizeof a |
此时数组仍按“整个数组对象”处理。
例如:
1 | int a[10]; |
注意:
1 | a + 1 |
通常前进一个 int;
1 | &a + 1 |
前进整个 int[10]。
5. 指针算术、边界与 one-past 指针
5.1 p + 1 不是“地址数字 + 1”
1 | int *p; |
若 sizeof(int) == 4,则语义上:
1 | p + 1 |
指向“下一个 int 对象”,物理地址通常增加 4 字节。
如果:
1 | double *p; |
p + 1 则跨过一个 double。
所以指针类型不仅告诉编译器“怎么解引用”,也告诉编译器“指针算术每一步跨多大”。
5.2 合法指针算术具有对象边界
对于:
1 | int a[5]; |
以下地址可以用于遍历/比较:
1 | &a[0] |
可以:
1 | int *end = a + 5; |
不可以:
1 | *(a + 5); // UB |
“可以形成尾后一位指针”与“可以访问尾后一位对象”是两件完全不同的事。
5.3 指针相减
同一个数组中的两个指针可以相减:
1 | ptrdiff_t d = &a[4] - &a[1]; // 3 |
结果表示相差多少个元素,不是相差多少字节。
类型使用:
1 |
|
不要假设任意两个完全无关对象的指针都能有意义地相减或进行 </> 比较。
6. 字符串、字符数组与字符指针
6.1 C 字符串的物理本质
C 没有内建“字符串对象类型”。所谓 C 字符串,本质是:
一串
char,最后用\0(数值 0)作为终止标记。
1 | char s[] = "cat"; |
实际有 4 个字符:
1 | 'c' 'a' 't' '\0' |
所以:
1 | sizeof s == 4 |
sizeof 看对象占多少字节;strlen 遍历直到第一个 \0。
6.2 两种看似相似的写法完全不同
1 | char a[] = "hello"; |
第一种:
1 | a 是数组对象 |
所以:
1 | a[0] = 'H'; // 可以 |
第二种:
1 | p 是指针对象 |
字符串字面量不能被程序修改:
1 | p[0] = 'H'; // 不允许通过 const;强行去 const 再改会导致 UB |
即使旧式代码写:
1 | char *p = "hello"; |
也不要修改字面量内容。现代代码优先使用 const char * 表达意图。
6.3 strcpy 为什么不是 s = t
1 | s = t; |
如果二者是指针,只是复制地址,不会复制字符。
真正字符串复制要逐字符复制,包括最终 \0:
1 | void my_strcpy(char *dst, const char *src) |
但工程代码中不要无条件模仿不带长度限制的 strcpy;必须保证目标缓冲区足够大。
6.4 *p++ 这个经典模式
1 | *dst++ = *src++; |
等价于概念步骤:
1 | *dst = *src; |
不是:
1 | (*dst)++ |
掌握运算符结合关系后再使用这种紧凑写法。
7. 指针数组与二级指针
7.1 char *lines[100]
1 | char *lines[100]; |
读法:
lines是一个有 100 个元素的数组,每个元素类型是char *。
内存概念:
1 | lines: |
优点是交换文本时只交换地址:
1 | char *tmp = lines[i]; |
而不是搬动整段字符数据。
7.2 char **p 到底是什么
1 | char **p; |
从变量名向外读:
1 | p |
所以 p 是“指向 char * 的指针”。
如果:
1 | char *s = "hello"; |
关系:
1 | pp --> s --> 'h' 'e' 'l' 'l' 'o' '\0' |
二级指针常见用途:
1 | 1. 修改一个指针变量本身 |
7.3 为什么修改指针变量要传 T **
1 | void alloc_int(int **out) |
调用:
1 | int *p = NULL; |
如果函数形参只是 int *out,那么改变 out 只是改变函数内部地址副本;想改变调用者的 p,必须把 &p 传进去,所以形参是 int **。
8. 多维数组与“数组指针”
8.1 二维数组实际上是“数组的数组”
1 | int a[3][4]; |
准确类型:
1 | a 是 array[3] of array[4] of int |
因此:
1 | a[0] |
不是一个 int * 对象,而是一个 int[4] 数组;在大多数表达式中它再转换为指向首个 int 的指针。
内存仍连续:
1 | row 0: a[0][0] a[0][1] a[0][2] a[0][3] |
C 通常采用 row-major(按行连续)布局。
8.2 a + 1 为什么跳过一整行
在表达式中,a 转换成:
1 | int (*)[4] |
即“指向 int[4] 的指针”。
因此:
1 | a + 1 |
不是前进一个 int,而是前进一个完整 int[4]。
核心关系:
1 | a[i][j] |
第一步跨行;第二步在该行内跨元素。
8.3 二维数组形参为什么必须知道列数
1 | void f(int a[][4], size_t rows) |
等价核心类型是:
1 | void f(int (*a)[4], size_t rows) |
因为编译器要计算:
1 | a + 1 |
必须知道一行多大。
只有第一维通常可以省略,因为传入函数的是“指向一行的指针”;后续维度决定步长。
8.4 int *a[4] 与 int (*a)[4]
这是经典区别:
1 | int *a[4]; |
由于 [] 优先级高于 *:
a是“4 个int *组成的数组”。
而:
1 | int (*a)[4]; |
括号让 *a 先结合:
a是“指向int[4]数组的指针”。
这两个类型完全不同。
9. 命令行参数 argc/argv
标准形式:
1 | int main(int argc, char *argv[]) |
形参里的:
1 | char *argv[] |
会调整成:
1 | char **argv |
概念上:
1 | argv |
因此:
1 | argv[i] // char *,第 i 个字符串 |
处理命令行时应优先写清晰版本,而不是为了炫技写:
1 | *++argv |
能拆成两三步时,维护性通常更重要。
10. 函数指针与回调
10.1 最基本声明
1 | int add(int a, int b); |
函数指针:
1 | int (*fp)(int, int) = add; |
读法:
1 | fp |
调用:
1 | int x = fp(2, 3); |
也可以写:
1 | int x = (*fp)(2, 3); |
现代 C 中通常直接 fp(...) 更清楚。
10.2 为什么函数指针有价值
假设排序算法本身不应该关心“按数字排”还是“按字典序排”。
可以把比较策略作为函数传入:
1 | typedef int (*CompareFn)(const void *, const void *); |
这样:
1 | 算法 = 固定框架 |
这就是回调(callback)的基本思想。
常见领域:
1 | qsort |
10.3 函数指针和“返回指针的函数”不要混淆
1 | int *f(void); |
是:
f是函数,返回int *。
而:
1 | int (*f)(void); |
是:
f是指针,指向返回int的函数。
根本原因:
1 | () / [] 的结合优先级高于 * |
11. 复杂声明的阅读方法
不要尝试从左到右硬读。
建议规则:
先找变量名,从变量名向外,根据括号和运算符优先级逐层解释。
优先级核心:
1 | () [] 高 |
例 1:
1 | int *a[10]; |
1 | a |
所以:
10 个“指向 int 的指针”的数组。
例 2:
1 | int (*a)[10]; |
1 | a |
所以:
指向“10 个 int 的数组”的指针。
例 3:
1 | int (*handlers[8])(const char *); |
从 handlers 开始:
1 | handlers[8] |
所以:
handlers是 8 个函数指针组成的数组,每个函数接收const char *,返回int。
工程上遇到太复杂的声明应优先 typedef:
1 | typedef int (*Handler)(const char *); |
可读性明显更高。
12. void *:通用对象指针
12.1 void * 保存地址,但没有目标对象类型信息
1 | int x = 100; |
p 知道一个地址,但仅凭 void *,编译器不知道这个地址应该按什么类型解引用。
所以 ISO C 中:
1 | *p // 不成立,void 不是可解引用的对象类型 |
必须先恢复具体对象指针类型:
1 | int *ip = p; |
或:
1 | printf("%d\n", *(int *)p); |
12.2 C 中对象指针和 void * 的转换
在 C 中,对象指针可以隐式转换到 void *,也可以从 void * 转回相应对象指针类型。
所以:
1 | int *p = malloc(10 * sizeof *p); |
在 C 中不需要写:
1 | (int *)malloc(...) |
而且不建议为了“看起来明确”强制转换,因为若忘记:
1 |
强制转换可能掩盖真正的声明问题。
注意:这里讨论的是对象指针。不要想当然地认为函数指针与 void * 之间也具有完全相同的标准保证。
12.3 为什么 malloc 返回 void *
1 | void *malloc(size_t size); |
malloc 只负责:
返回一块满足大小与对齐要求的原始存储。
它并不知道你准备存:
1 | int |
所以 void * 正适合表达“通用对象存储地址”。
12.4 标准 C 不允许对 void * 做普通指针算术
1 | void *p; |
因为编译器不知道“一个 void 对象有多大”。
需要按字节移动时,常转换为字符指针:
1 | unsigned char *q = p; |
字符类型大小定义为 1 字节,因此适合查看对象表示。
GNU C 把 void * 算术作为扩展支持,但不要把扩展误当标准 C。
13. const:对象只读与指针只读
const 最大的误区是把它粗暴理解成“这个东西绝对不会发生任何变化”。
更准确的理解是:
const是类型系统施加的修改约束;它说明通过具有相应限定类型的表达式不能修改对象。
13.1 四种经典组合
1 | int *p; |
A. int *p
1 | p 可以改指向 |
B. const int *p
也可写:
1 | int const *p; |
含义:
1 | p 可以改指向 |
例:
1 | int a = 1, b = 2; |
但若原对象本来不是 const:
1 | a = 3; // 可以 |
所以 const 限制的是这条访问路径。
C. int *const p
1 | p 自己不能换地址 |
D. const int *const p
1 | p 不能换地址 |
13.2 一个阅读技巧
从变量名 p 开始往外读:
1 | const int *const p |
靠近 p 的 const 修饰指针本身:
1 | p 是 const pointer |
左边的 const int 修饰目标对象:
1 | pointer to const int |
最终:
const pointer to const int。
13.3 去掉 const 的危险
1 | const int x = 10; |
强制转换只是在类型系统层面压住诊断,它不会让“原本定义为 const 的对象”突然合法可写。
如果原对象本身不是 const:
1 | int x = 10; |
最终修改的是一个本来可写的 int 对象,这与上面的情况不同。但实际 API 设计中仍应尽量避免随意 cast away const。
13.4 const 不等于 .rodata
1 | const int x = 10; |
C 类型语义告诉你“不能通过普通可修改左值修改 x”。
它是否最终被放入:
1 | .rodata |
是编译器和平台实现问题。
所以:
1 | const = 语言类型约束 |
不要混淆。
14. struct / union / enum
14.1 struct:把多个成员组成一个对象
1 | struct Student { |
使用:
1 | struct Student s = {1001, 'A', 96.5}; |
如果有结构体指针:
1 | struct Student *p = &s; |
成员访问:
1 | p->id |
等价于:
1 | (*p).id |
.:左边已有结构体对象。
->:左边是结构体/联合体指针。
14.2 自引用结构体为什么必须使用指针
错误思想:
1 | struct Node { |
如果合法,sizeof(struct Node) 必须包含另一个完整 struct Node,后者又包含另一个,永远无法确定大小。
正确:
1 | struct Node { |
因为一个指针本身大小有限且已知。
这构成链表/树/图的基础。
14.3 union:多个成员共享同一片存储
1 | union Number { |
与 struct 不同:
1 | struct: 每个成员有各自位置 |
因此 union 大小至少足以容纳最大成员,并满足成员所需对齐。
1 | union Number n; |
写 n.d 会覆盖同一片存储;不能把它想成同时拥有独立的 i 和 d 两份值。
14.4 Tagged Union:C 中实现“一个值可能有多种类型”
1 | enum ValueKind { |
使用:
1 | struct Value v = { |
读取时先看标签:
1 | switch (v.kind) { |
这种模式在解释器 AST、虚拟机值、协议消息、系统 API 中非常常见。
14.5 不要随意用 union 做可移植“位重解释”
很多底层代码会:
1 | union { |
然后写 f、读 u 来观察位模式。
不同标准版本和实现对 inactive member / object representation 的细节需要谨慎处理;如果你追求高度可移植的“按位复制”,优先考虑:
1 | memcpy(&u, &f, sizeof u); |
前提是两者大小满足你的设计要求。
14.6 enum:用名字表示离散状态
1 | enum State { |
默认从 0 递增:
1 | STATE_INIT = 0 |
也可以手动指定:
1 | enum Error { |
比“魔法数字”更可读:
1 | if (state == STATE_RUNNING) |
明显好于:
1 | if (state == 1) |
但不要把 sizeof(enum X) 或 ABI 表示大小想当然固定成某个字节数;在跨 ABI / 网络协议 / 文件格式时应明确使用固定宽度整数类型。
15. Padding / Alignment:结构体到底怎么排
15.1 对齐是什么
某个类型常常希望起始地址满足一定倍数关系。
概念例子:
1 | char alignment 1 |
这是典型值,不是 C 标准要求所有机器都如此。
若 int 要 4 字节对齐,那么地址最好满足:
1 | address % 4 == 0 |
15.2 结构体成员 padding
1 | struct S { |
假设 int 4 字节、对齐 4:
1 | offset |
于是:
1 | sizeof(struct S) == 12 |
而不是:
1 | 4 + 1 + 4 == 9 |
padding 是编译器为了满足布局约束插入的无名空间。
15.3 为什么结构体尾部也可能有 padding
1 | struct S { |
成员结束在 offset 9,但结构体数组必须让每个下一个元素仍满足整体对齐。
若总大小只为 9:
1 | arr[0] at 0 |
第二个元素中的 int 可能失去 4 字节对齐。
因此常补到:
1 | sizeof(struct S) == 12 |
使:
1 | arr[0] 0 |
15.4 成员顺序会影响空间
1 | struct Bad { |
典型 8 字节对齐 ABI 下,可能布局为:
1 | c1 |
而:
1 | struct Better { |
通常能明显减小空洞。
经验:
如果没有外部 ABI / 文件格式约束,把高对齐、大成员放在前面,常可减少 padding。
但不要为了省内存随便重排公共 ABI 结构、网络协议结构或磁盘格式结构。
15.5 _Alignof / alignof
C11 提供:
1 | _Alignof(int) |
若使用 <stdalign.h>,在相应标准环境中可以使用:
1 | alignof(int) |
它查询类型的对齐要求。
不要把:
1 | sizeof(T) |
与:
1 | alignof(T) |
当成同一个概念。
16. offsetof / container_of
16.1 offsetof
标准头文件:
1 |
使用:
1 | offsetof(struct S, member) |
表示:
member相对于整个结构体对象起始位置的字节偏移。
例:
1 | struct S { |
典型布局:
1 | offsetof(struct S, i) == 0 |
这比自己硬编码 8 安全得多,因为真正偏移由类型布局决定。
16.2 container_of 的数学本质
假设:
1 | struct Student { |
若:
1 | Student 起始地址 = B |
则:
1 | M = B + O |
所以:
1 | B = M - O |
这就是 container_of。
教学版:
1 |
为什么转 char *?
因为希望减去的是字节偏移量。
16.3 前提条件非常严格
只有在 ptr 真正指向某个有效 type 对象中的 member 时,反推才有正确语义。
不能把任意地址扔进去:
1 | container_of(random_ptr, struct Student, node) |
这不是“万能找对象”的魔法。
Linux 内核真实 container_of 还会利用 GNU typeof 等扩展进行类型检查,本笔记中的版本只用于理解核心原理。
16.4 为什么侵入式链表喜欢它
普通链表:
1 | struct StudentNode { |
侵入式链表则把通用链表节点嵌入业务对象:
1 | struct ListNode { |
通用链表只操作 ListNode *。
业务层拿到 link 后通过 container_of 恢复 Student *。
好处:
1 | 一个通用链表实现可服务许多不同业务类型 |
这是一种典型系统编程设计。
17. Macro / Preprocessor
17.1 C 源码并不是直接交给编译器
典型 GCC 工具链概念:
1 | source.c |
预处理器负责:
1 | #include |
它发生在真正 C 语法/类型编译之前。
17.2 对象式宏
1 |
它不是变量。
预处理阶段看到 token BUFFER_SIZE 时会进行替换。
对于有类型需求的常量,很多情况下更推荐:
1 | enum { BUFFER_SIZE = 4096 }; |
或适当的 const 对象,具体取决于是否需要编译期整型常量表达式、地址、作用域等。
17.3 函数式宏不是函数
错误:
1 |
调用:
1 | SQUARE(1 + 2) |
展开:
1 | 1 + 2 * 1 + 2 |
结果不是 9。
至少写:
1 |
但仍然有副作用问题:
1 | int i = 3; |
展开会出现多次 i++,这可能产生未定义行为或至少完全违背你的预期。
结论:
只靠多加括号不能让宏变成真正安全的函数。
17.4 多语句宏使用 do { ... } while (0)
错误写法:
1 |
在:
1 | if (cond) |
可能破坏语法结构。
常见模式:
1 |
使整个宏在语法上像一个单语句。
仍要注意参数是否多次求值。
17.5 #:字符串化
1 |
1 | SHOW(a + b); |
宏展开中的:
1 |
会产生类似:
1 | "a + b" |
特别适合 debug/log 宏。
17.6 ##:token 拼接
1 |
1 | MAKE_NAME(counter_, 1) |
形成 token:
1 | counter_1 |
17.7 Include Guard
1 |
|
第一次 include 时定义宏;后续再 include 时跳过内容。
也常见:
1 |
但它历史上不是 ISO C 核心语言标准的一部分;现代编译器广泛支持。
17.8 条件编译不是运行时 if
1 |
|
DEBUG 未定义时,这段代码甚至不会进入后续编译阶段。
与:
1 | if (debug) { ... } |
完全不同:后者是运行时控制流。
18. Scope / Storage Duration / Linkage
这是理解 static/global/local 的真正钥匙。
不要只问:
“这个变量在栈还是全局?”
应该拆成三个独立问题:
1 | 1. Scope(作用域):这个名字在哪里可见? |
18.1 Scope:名字在哪里可见
典型:
1 | block scope |
例如:
1 | void f(void) |
18.2 Storage Duration:对象存在多久
主要可以先掌握:
1 | automatic storage duration |
普通局部变量:
1 | void f(void) |
x 通常具有 automatic storage duration:每次进入相应块产生相应实例,离开后生命周期结束。
局部 static:
1 | void f(void) |
count 的名字是局部的,但对象具有 static storage duration,整个程序期间一直存在。
18.3 Linkage:名字是否跨声明连接
文件级:
1 | int global_value; |
通常具有 external linkage。
1 | static int private_value; |
具有 internal linkage,只在当前 translation unit 内连接。
这就是文件级 static 最关键的作用。
18.4 Translation Unit 是什么
可以粗略理解为:
一个
.c文件经过#include、宏展开等预处理后形成的编译单元。
所以“file-scope static 只在当前 .c 文件”是便于初学者的说法;更严谨是:
它具有 internal linkage,实体局限于当前 translation unit。
19. Stack / Heap / Global / Static
先强调:
ISO C 标准描述存储期和对象语义,并不要求所有实现必须真的使用名为“stack/heap”的机器区域。
但在常见 Linux/x86-64 系统中,下面的模型非常有用。
19.1 自动局部对象:通常在栈或寄存器
1 | void f(void) |
在没有被优化掉的典型机器代码中,它们可能放在当前函数的 stack frame。
但编译器可能:
1 | 把 x 放寄存器 |
所以不要写:
“所有局部变量都一定在栈上。”
正确表达:
普通局部对象具有 automatic storage duration;常见实现会在需要真实内存位置时利用运行时栈。
19.2 栈帧的角色
典型函数调用可能需要栈保存:
1 | 返回地址 |
x86-64 System V 下栈通常向低地址增长。
但是现代优化函数经常不需要完整传统“frame pointer 栈帧”。
19.3 返回局部变量地址为什么错
1 | int *bad(void) |
函数返回后 x 的生命周期结束。
得到的地址可能数值还存在,但它不再指向一个活着的 int x 对象。
后续解引用属于 UB。
19.4 Heap:动态存储
1 | int *p = malloc(sizeof *p); |
这里至少要分清两个对象概念:
1 | p 自己:局部指针变量,可能在栈/寄存器 |
函数返回后,局部 p 消失不代表动态对象自动释放。
必须保留某个有效指针并最终:
1 | free(p); |
19.5 Global 与 static
1 | int g1 = 1; |
这些都具有 static storage duration。
区别主要在 linkage:
1 | g1/g2 通常 external linkage |
局部 static:
1 | void f(void) |
同样具有 static storage duration,但名字只有块作用域。
所以:
static不是“放在某个叫 static 的内存区”。
20. .text / .rodata / .data / .bss
这些属于典型 ELF 目标文件/可执行文件组织概念。
20.1 .text
主要放机器指令。
1 | int add(int a, int b) |
编译后的函数机器码主要落在 .text 一类代码 section 中。
运行时通常映射为可执行、不可随意写的页面。
20.2 .rodata
常见只读数据:
1 | 字符串字面量 |
例如:
1 | printf("hello\n"); |
字符串字面量常出现在 .rodata。
但不要把 C 标准语义直接等同于某个 ELF section。
20.3 .data
典型存放已初始化的、可写的 static-storage 对象:
1 | int g = 42; |
常进入 .data 或相关可写 data section。
20.4 .bss
典型存放未显式初始化或零初始化的 static-storage 对象:
1 | int g; |
这些对象运行时必须是零初始化。
.bss 的优势是:
可执行文件不需要真的存放数百万个零字节,只需要记录运行时需要多大的零初始化区域。
在 ELF 中 .bss 常表现为 NOBITS 类型 section。
20.5 Section 与 Segment 不一样
Section 更偏向:
1 | 编译器 / 汇编器 / 链接器如何组织目标文件中的内容 |
Segment 更偏向:
1 | loader 在运行时把哪些区间映射到内存,以及赋予什么权限 |
例如:
1 | .text + .rodata + headers |
可能被某个只读/可执行 LOAD segment 覆盖;
1 | .data + .bss |
可能进入读写 LOAD segment。
不要把 .text section 和“整个 text segment”机械当成同义词。
20.6 一个典型进程虚拟地址空间直觉
简化图(不是精确地址图):
1 | 高地址 |
实际系统受 ASLR、PIE、动态链接、mmap、线程等影响,地址不应该写死。
21. 动态内存:malloc / calloc / realloc / free
21.1 malloc
1 | int *a = malloc(n * sizeof *a); |
优点:
1 | sizeof *a |
自动跟随 a 的目标类型,不必重复写:
1 | sizeof(int) |
如果以后 a 改成别的类型,更不容易出错。
应检查失败:
1 | if (a == NULL) { |
21.2 calloc
1 | int *a = calloc(n, sizeof *a); |
分配 n 个元素对应的存储,并把所有字节置零。
注意“所有 bits 为 0”与“所有可能类型的语义零值”之间在极端平台上不是一个完全泛化的概念;对于普通整数和现代常见平台指针习惯上经常符合期望,但写底层可移植库时要理解对象表示问题。
21.3 realloc 的正确模式
危险:
1 | p = realloc(p, new_size); |
如果失败返回 NULL,你会丢掉旧 p,从而内存泄漏。
更稳妥:
1 | void *tmp = realloc(p, new_size); |
注意 realloc 成功时可能移动对象,旧地址可能不再有效。
21.4 free
1 | free(p); |
释放后:
1 | *p // UB |
free 不会自动把变量 p 设成 NULL。
常见局部习惯:
1 | free(p); |
可以帮助避免同一变量的再次误用,但不能解决所有 alias:
1 | int *p = malloc(...); |
21.5 所有权思维
C 没有语言级自动所有权系统,所以你必须人为约定:
1 | 谁分配? |
写 API 时把 ownership 说清楚,比单纯会 malloc/free 更重要。
22. Undefined Behavior:C 最重要的边界
22.1 UB 不是“随机返回一个值”
Undefined Behavior 的准确直觉:
当程序执行到标准规定为 UB 的情况时,C 语言不再对整个执行结果提供你原先依赖的保证。
因此可能:
1 | 看起来正常 |
“我电脑上运行了 100 次没崩”不能证明没有 UB。
22.2 常见 UB
数组越界
1 | int a[3]; |
空指针解引用
1 | int *p = NULL; |
use-after-free
1 | int *p = malloc(sizeof *p); |
返回局部对象地址后解引用
1 | int *bad(void) |
有符号整数溢出
1 | int x = INT_MAX; |
无符号整数溢出则按模 2^N 运算,这是定义良好的行为。
整数除零
1 | int x = 1 / 0; // UB |
非法移位
例如移位数量为负,或大于等于提升后左操作数类型位宽。
修改字符串字面量
1 | char *p = "abc"; |
某些未排序副作用
1 | i = i++ + ++i; // 不要写;涉及对同一标量对象的冲突副作用 |
非法对齐的解引用
把某个不满足 int 对齐要求的地址强转为 int * 后解引用,也可能导致 UB。
22.3 为什么优化器会让 UB 看起来“反直觉”
1 | int f(int x) |
你可能想:
1 | x == INT_MAX 时 x+1 变成负数 |
但 signed overflow 是 UB。
编译器可以在优化时基于:
“符合 C 语义的执行不会发生 signed overflow。”
于是推断:
1 | x + 1 > x |
总为真,并直接优化成:
1 | return 1; |
所以 UB 的核心危险不是“CPU 会随机”,而是:
你的源代码推理前提与编译器依据标准进行的推理发生断裂。
22.4 Undefined / Implementation-defined / Unspecified
三者必须区分。
Undefined behavior
标准不要求结果。
Implementation-defined behavior
实现可以选择一种行为,但需要文档说明。
典型例子之一:某些整数转换、类型尺寸/表示的具体选择依赖实现。
Unspecified behavior
标准允许若干可能结果,实现不要求每次固定选择同一个,也未必需要记录选择。
不要把它们都叫“未定义”。
22.5 Strict Aliasing / Effective Type:高级但必须知道存在
编译器通常假设某些不相关类型的指针不会指向同一个有效对象。
危险思想:
1 | float f = 1.0f; |
这类直接通过不兼容类型指针重解释对象可能违反别名/有效类型规则,并且还涉及对齐等问题。
观察原始对象字节,unsigned char * / char * 是特殊允许的常用路径;做固定大小按位复制时优先考虑 memcpy。
这是高优化级别下“Debug 好好的,Release 出问题”的一个重要来源。
23. Basic ABI Intuition:函数在二进制层如何合作
23.1 ABI 是什么
ABI = Application Binary Interface。
源码 API 说:
1 | long add(long a, long b); |
ABI 要进一步规定:
1 | 参数放哪个寄存器/栈位置? |
如果不同模块不遵守同一 ABI,即使函数名和 C 声明“看起来一样”,二进制也无法正确合作。
23.2 x86-64 System V:整数/指针参数直觉
Linux/macOS 等常见 x86-64 System V 家族环境中,前六个整数/指针类参数通常依次使用:
1 | 1 %rdi |
整数/指针返回值常用:
1 | %rax |
第 7 个及之后的参数通常需要栈参与。
例如:
1 | long add(long a, long b) |
概念机器层:
1 | %rdi = a |
注意:浮点参数通常走 XMM 寄存器,结构体参数和返回值还有更复杂的分类规则。本节只建立直觉。
23.3 Caller-saved / Callee-saved
System V x86-64 中常见 callee-saved:
1 | %rbx |
意思:
被调用函数如果要破坏这些寄存器,必须在返回之前恢复。
其他大部分通用寄存器是 caller-saved:
调用者若还需要旧值,应在调用前自己保存。
这解释了为什么汇编里常出现:
1 | pushq %rbx |
23.4 Stack Alignment
现代 SIMD 指令和 ABI 对栈对齐有要求。
在 System V x86-64 的常见规则中,调用边界需要维持 16 字节相关的栈对齐约定。
由于 call 本身会把 8 字节返回地址压栈,所以理解汇编时要区分:
1 | call 之前的 %rsp 对齐状态 |
不要只背“rsp 永远是 16 的倍数”这种错误简化。
23.5 Windows x64 ABI 与 System V 不一样
Windows x64 前几个整数参数典型使用:
1 | %rcx, %rdx, %r8, %r9 |
并有 shadow space 等不同约定。
这说明:
调用约定是 ABI,不是 x86-64 CPU 天生规定的唯一方式。
同一个 CPU ISA 上可以存在不同 ABI。
23.6 Struct Layout 也是 ABI 问题
如果库 A 认为:
1 | member x at offset 8 |
调用程序 B 却认为:
1 | member x at offset 4 |
那么:
1 | library_function(&obj); |
双方会读写不同字节。
因此这些都可能成为 ABI 契约:
1 | sizeof(struct) |
这也是为什么公开 ABI 结构一旦发布后不能轻易重排成员。
24. 编译器、调试器与 Sanitizer
24.1 学 C 时请主动打开警告
推荐学习期:
1 | gcc -std=c11 -Wall -Wextra -Wpedantic -Wconversion -g main.c -o main |
-Wconversion 有时会比较吵,可以在理解后决定是否长期开启。
不要把 warning 当“可忽略通知”。很多 C bug 在 warning 阶段就已经暴露。
24.2 AddressSanitizer / UBSan
调内存问题:
1 | gcc -std=c11 -Wall -Wextra -g \ |
常能抓到:
1 | heap-buffer-overflow |
Sanitizer 不能数学证明“程序无 UB”,但对学习 C 极其有价值。
24.3 GDB 应该学会的最小集合
1 | break |
特别是:
1 | p &x |
能让“地址、指针、字节”从抽象概念变成可观察事实。
24.4 ELF / 汇编观察工具
Linux 常用:
1 | readelf -S ./a.out |
分别可以帮助观察:
1 | sections |
学习系统 C 时,不要只停留在源代码层。
25. 一张总图:从 C 源码到机器
把所有知识连起来:
1 | ┌─────────────────────────────────────────────┐ |
真正的主问题一直没变:
某个表达式指向哪个对象?这个对象是什么类型?还活着吗?这个操作在 C 规则中合法吗?编译后它会如何映射到机器?
如果每次写指针代码都能回答这五个问题,C 会变得非常可控。
26. 编程练习(不附答案)
下面的题不是单纯刷语法,而是按照“指针 → 数组 → 字符串 → 泛型 → 数据布局 → 动态内存 → 系统层”的顺序递进。
建议编译参数:
1 | gcc -std=c11 -Wall -Wextra -Wpedantic -g \ |
除非题目明确允许,否则不要为了通过测试关闭 warning 或 sanitizer。
Level 1:指针基本功
题 1:swap 与值传递
实现:
1 | void swap_int(int *a, int *b); |
要求:
1 | 1. 不能返回结构体或使用全局变量。 |
测试:
1 | 1, 2 → 2, 1 |
考点:值传递、&、*。
题 2:一次函数返回 min 和 max
实现:
1 | int minmax(const int *a, size_t n, int *out_min, int *out_max); |
约定:
1 | 成功返回 1 |
要求只遍历一次数组。
考点:const 指针、输出参数、边界检查。
题 3:只用指针写 strlen
实现:
1 | size_t my_strlen(const char *s); |
限制:
1 | 不能使用 [] |
尝试两个版本:
1 | A. 计数器版本 |
思考:为什么返回值应该是 size_t,而不是 int?
题 4:strend
实现:
1 | int strend(const char *s, const char *suffix); |
如果 suffix 是 s 的后缀返回 1,否则 0。
测试:
1 | "hello", "lo" → 1 |
限制:不调用 strstr。
Level 2:数组、字符串、二级指针
题 5:原地反转字符串
实现:
1 | void reverse_string(char *s); |
要求:
1 | 1. O(n) |
不要传字符串字面量进去修改。
题 6:删除数组中的目标值
实现:
1 | size_t remove_value(int *a, size_t n, int target); |
将所有不等于 target 的元素稳定压到数组前部,返回新长度。
例如:
1 | [1,2,3,2,4], target=2 |
要求:不能申请新数组。
考点:读指针 / 写指针(two-pointer pattern)。
题 7:二级指针创建整数对象
实现:
1 | int make_int(int value, int **out); |
要求:
1 | 成功:动态分配一个 int,写入 value,设置 *out,返回 1 |
在 main 中正确释放。
考点:int **、所有权、malloc/free。
题 8:复制字符串数组
实现:
1 | char **clone_strings(const char *const *src, size_t n); |
要求:
1 | 1. 动态创建 n 个 char * |
考点:二级指针、const、错误路径清理、ownership。
Level 3:多维数组与复杂类型
题 9:固定列数矩阵统计
定义:
1 |
实现:
1 | void row_sums(const int (*a)[COLS], size_t rows, int *out); |
其中:
1 | out[i] = 第 i 行所有元素的和 |
要求:
1 | 1. 明确解释为什么参数是 int (*)[COLS] |
题 10:比较“二维数组”和“指针数组”
分别构建:
1 | int matrix[3][4]; |
和:
1 | int *rows[3]; |
为后者动态分配每一行。
程序打印:
1 | sizeof(matrix) |
然后写一段分析:
1 | 哪些内存一定连续? |
这是一道“代码 + 解释题”。
Level 4:函数指针与泛型
题 11:函数指针计算器
定义:
1 | typedef double (*BinaryOp)(double, double); |
实现:
1 | double add(double, double); |
然后使用函数指针数组:
1 | BinaryOp ops[] = { ... }; |
实现命令:
1 | ./calc add 1.2 3.4 |
要求不要用一长串重复计算逻辑;命令解析后通过函数指针 dispatch。
题 12:泛型 swap
实现:
1 | void generic_swap(void *a, void *b, size_t size); |
要求:
1 | 1. 能交换 int / double / struct |
可使用临时动态内存,之后再挑战一个不 malloc、逐字节交换的版本。
思考:为什么需要把 void * 转成 unsigned char *?
题 13:自己的泛型查找
实现:
1 | void *linear_find( |
约定:找到则返回元素地址,找不到返回 NULL。
分别测试:
1 | int 数组 |
考点:void *、字节步进、回调、const。
Level 5:Struct / Union / Enum / Layout
题 14:Tagged Union 值系统
实现:
1 | enum ValueKind { |
实现函数:
1 | struct Value value_int(int x); |
要求:
1 | VAL_STRING 必须 deep copy |
考点:tagged union、生命周期、ownership。
题 15:手算结构体布局,再用程序验证
给定:
1 | struct Record { |
先不要运行程序,手算你机器上最可能的:
1 | 每个成员 offset |
然后写程序打印:
1 | sizeof(...) |
最后解释手算与实际结果是否一致。
题 16:最小化结构体 padding
把以下成员重新排序:
1 | char a; |
要求:
1 | 1. 原始顺序定义 struct Original |
解释:为什么“减少 padding”与“强制 packed”不是一回事?
Level 6:offsetof / container_of 与链表
题 17:自己实现侵入式双向链表
定义:
1 | struct ListNode { |
实现:
1 | void list_init(struct ListNode *head); |
再定义教学版 container_of,遍历时把 ListNode * 恢复成 Task *。
要求:
1 | 1. 使用哨兵 head |
这是本套题中非常重要的一题。
Level 7:Macro / Preprocessor
题 18:Debug 宏
实现:
1 | DBG_INT(expr) |
调用:
1 | DBG_INT(a + b); |
在定义 DEBUG 时输出类似:
1 | [file.c:42] a + b = 17 |
未定义 DEBUG 时不产生运行时代码。
提示:研究:
1 |
|
要求宏在:
1 | if (...) DBG_INT(x); else ...; |
中仍然语法安全。
题 19:找出危险宏并修复到“尽可能安全”
分析:
1 |
对每个宏:
1 | 1. 给出至少一个会出错/违背预期的调用 |
不要用 GNU statement expression;先只讨论标准 C 宏能力的边界。
Level 8:动态内存工程题
题 20:动态数组 Vector
实现:
1 | struct IntVector { |
API:
1 | int vec_init(struct IntVector *v); |
要求:
1 | capacity 不够时使用 realloc 扩容 |
加分:实现:
1 | int vec_reserve(struct IntVector *v, size_t cap); |
题 21:任意长度输入行
实现:
1 | char *read_line(FILE *fp); |
要求:
1 | 1. 不设固定 MAXLINE |
这是理解动态内存最值得写的一题。
题 22:Arena / Bump Allocator
实现一个最小 arena:
1 | struct Arena { |
API:
1 | int arena_init(struct Arena *a, size_t capacity); |
要求:
1 | 1. arena_alloc 返回满足 alignment 的地址 |
先只支持 alignment 为 2 的幂。
这题把:
1 | void * |
全部串起来。
Level 9:UB 排雷题
题 23:找出所有 bug / UB
不要先运行,先静态分析:
1 |
|
任务:
1 | 1. 标出每一个问题 |
题 24:边界正确的 pointer loop
分别写 3 个版本求和:
1 | long sum_array(const int *a, size_t n); |
版本:
1 | A. 下标 |
然后故意写一个 off-by-one 版本,让 sanitizer 抓出来。
解释:
1 | 为什么 end = a + n 可以合法形成? |
Level 10:ABI / ELF 实验
题 25:观察参数寄存器
写:
1 | long f(long a, long b, long c, |
编译:
1 | gcc -O0 -fno-omit-frame-pointer -S abi.c |
观察汇编并回答:
1 | 前 6 个参数在哪里? |
再用:
1 | gcc -O2 -S abi.c |
比较优化前后。
注意:根据你的平台 ABI,结果可能与 Linux x86-64 System V 不同。
题 26:观察 .text/.rodata/.data/.bss
写:
1 |
|
使用:
1 | readelf -S ./program |
写分析报告:
1 | g_init 大致在哪? |
推荐做题顺序
不要一次把 26 题全写完。按下面节奏最有效:
1 | 第一阶段:1 → 2 → 3 → 4 → 5 → 6 |
每道题提交时建议同时给出:
1 | 1. .c 源码 |
这样检查的不只是“程序能不能跑”,而是:
1 | 类型是否正确 |
最后:你目前最应该形成的 12 条条件反射
- 看到
p,马上区分p / &p / *p。 - 看到数组,问它此刻是“真实数组对象”还是“已经转换成首元素指针”。
- 看到
a[i],能想到*(a+i),但不为了炫技强行改写。 - 看到函数参数
T a[],知道它本质上被调整成T *a。 - 看到
T **,问清楚究竟是“指向指针”还是某种动态二维结构,不能默认就是二维数组。 - 看到
const,分别问“指针能改吗?”、“目标能通过它改吗?”。 - 看到
malloc,马上问“谁拥有?失败怎么办?最后谁 free?”。 - 看到结构体,考虑 alignment / padding / ABI,而不是直接相加成员大小。
- 看到宏参数,先问“这个参数会被求值几次?”。
- 看到地址操作,检查对象是否还活着、指针是否在合法边界内、对齐是否满足。
- 看到“程序运行正常”,不要自动推出“没有 UB”。
- 看到 C 源码,逐渐学会同时想象它的对象布局、栈/寄存器、ELF section 和 ABI。
当这 12 条变成条件反射,你就已经从“会写 C 语法”进入“能做可靠系统级 C 推理”的阶段。
参考学习范围
本笔记的主要知识主线来自:
- Kernighan & Ritchie, The C Programming Language, 2nd Edition:Chapter 5(Pointers and Arrays),以及关于
static、预处理器、结构体/联合体/枚举的相关章节。 - Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective (3e):Chapter 3(Machine-Level Representation of Programs)、Chapter 7(Linking)、Chapter 9(Virtual Memory),以及 x86-64 procedure / data alignment / ELF layout 的相关部分。
- 补充系统编程知识:标准
offsetof、侵入式数据结构中的container_of思路、现代 GCC/Clang Sanitizer 与常见 ABI 对比。
建议不要只背笔记。每个概念都要用“小程序 + 地址打印 + debugger + sanitizer + objdump/readelf”亲自验证一次。
- 标题: c语言指针、内存、系统编程深入笔记与练习
- 作者: Dapeng
- 创建于 : 2026-09-13 15:59:54
- 更新于 : 2026-09-13 15:59:54
- 链接: https://dapengblog.com/2026/09/13/C语言指针_内存_系统编程深入笔记与练习/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。