c语言指针、内存、系统编程深入笔记与练习

Dapeng

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 标准强制规定的。


目录

  1. 建立统一的内存模型
  2. 指针与地址:&、*、指针变量
  3. 指针与函数参数:C 的值传递
  4. 数组与指针:相似但绝不相同
  5. 指针算术、边界与 one-past 指针
  6. 字符串、字符数组与字符指针
  7. 指针数组与二级指针
  8. 多维数组与“数组指针”
  9. 命令行参数 argc/argv
  10. 函数指针与回调
  11. 复杂声明的阅读方法
  12. void *:通用对象指针
  13. const:对象只读与指针只读
  14. struct / union / enum
  15. Padding / Alignment:结构体到底怎么排
  16. offsetof / container_of
  17. Macro / Preprocessor
  18. Scope / Storage Duration / Linkage
  19. Stack / Heap / Global / Static
  20. .text / .rodata / .data / .bss
  21. 动态内存:malloc / calloc / realloc / free
  22. Undefined Behavior:C 最重要的边界
  23. Basic ABI Intuition:函数在二进制层如何合作
  24. 编译器、调试器与 Sanitizer
  25. 一张总图:从 C 源码到机器
  26. 编程练习(不附答案)

1. 建立统一的内存模型

学习 C 时最重要的一次认知升级,是不要只把变量理解成“一个名字对应一个值”。更接近机器的模型是:

1
2
3
4
5
6
对象(object)
├── 有类型 type
├── 有大小 size
├── 在生命周期内占据某些存储
├── 通常可以具有地址 address
└── 地址处的一串 bit 按类型解释成值 value

例如:

1
int x = 42;

可以先把它想象成:

1
2
3
4
地址            内存内容
0x1000 2a 00 00 00 // 假设 int 4 字节、小端机器
↑
x

x 是对象;42 是它当前的值;&x 是这个对象的地址。

1
int *p = &x;

此时又创建了一个独立对象 p:

1
2
3
4
5
6
7
8
9
10
11
12
p 自己也是变量,也占内存:

地址 0x2000
┌───────────────────┐
│ 0x1000 │ p 保存 x 的地址
└───────────────────┘
│
▼
地址 0x1000
┌───────────────────┐
│ 42 │ x
└───────────────────┘

因此必须始终区分:

1
2
3
p      :指针变量中保存的地址
&p :指针变量 p 自己的地址
*p :p 指向的对象

这是后面几乎所有内容的根。


2. 指针与地址:&、*、指针变量

2.1 指针是什么

最实用的定义:

指针是一个具有指针类型的对象,它的值用来指示另一个对象(或函数)的位置。

1
2
int x = 10;
int *p = &x;

声明:

1
int *p;

最好读成:

*p 的类型是 int,所以 p 是“指向 int 的指针”。

不要把 * 单纯看成“属于类型左边”的符号。C 的声明语法是故意设计成接近表达式使用方式的。


2.2 &:取地址

1
p = &x;

&x 产生 x 的地址。

常见形式:

1
2
3
4
5
6
int x;
int a[10];

&x // int *
&a[0] // int *
&a // int (*)[10],注意不是 int *

&a 是“整个数组的地址”,类型是“指向 10 个 int 的数组的指针”。数值地址通常和 a 相同,但类型不同,指针算术的步长也不同。


2.3 *:解引用

1
2
3
4
5
int x = 10;
int *p = &x;

printf("%d\n", *p); // 10
*p = 20; // 修改 x

如果 p 指向 x,那么 *p 在很多表达式里就可以像 x 一样使用:

1
2
3
*p += 1;
++*p;
(*p)++;

但:

1
*p++

根据运算符优先级实际是:

1
*(p++)

也就是“先取旧 p 指向的对象,然后让 p 向后移动”。

而:

1
(*p)++

才是“增加所指对象的值”。

必须形成的习惯

遇到复杂表达式不要靠感觉:

1
2
3
4
*p++
*++p
++*p
(*p)++

拆开:

1
2
3
4
*p++   == *(p++)
*++p == *(++p)
++*p == ++(*p)
(*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
2
3
4
5
6
void bad_swap(int a, int b)
{
int t = a;
a = b;
b = t;
}

调用:

1
2
int x = 1, y = 2;
bad_swap(x, y);

x、y 不会交换,因为函数拿到的是它们的副本:

1
2
3
4
caller: x=1, y=2
│ │
▼ ▼
callee: a=1, b=2

改变 a/b 不等于改变 x/y。


3.2 为什么传指针可以修改调用者对象

1
2
3
4
5
6
void swap(int *a, int *b)
{
int t = *a;
*a = *b;
*b = t;
}

调用:

1
swap(&x, &y);

仍然是值传递,只不过现在复制的是“地址”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
caller:
x y
┌───┐ ┌───┐
│ 1 │ │ 2 │
└───┘ └───┘
▲ ▲
│ │
&x &y
\ /
\ copied by value /
▼ ▼
callee:
a --------------------> x
b --------------------> y

所以更精确的说法是:

C 没有“引用传递”;我们通过按值传递地址来间接修改调用者对象。


3.3 指针参数也可以模拟多个返回值

1
2
3
4
5
6
7
8
9
int divmod(int a, int b, int *quotient, int *remainder)
{
if (b == 0)
return 0;

*quotient = a / b;
*remainder = a % b;
return 1;
}

函数自身返回“是否成功”,两个结果通过输出参数返回。

这种 API 设计在 C 中非常常见。


4. 数组与指针:相似但绝不相同

4.1 真正的数组对象

1
int a[5] = {10, 20, 30, 40, 50};

内存中是 5 个连续的 int:

1
2
3
4
5
6
7
低地址 → 高地址

+0 +4 +8 +12 +16 (假设 int 4B)
┌────────┬────────┬────────┬────────┬────────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │
└────────┴────────┴────────┴────────┴────────┘
a[0] a[1] a[2] a[3] a[4]

数组是真实对象,它的总大小是:

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
2
int a[10];
int *p = a;

a 是:

1
一个包含 10 个 int 的数组对象

p 是:

1
一个保存 int 地址的指针对象

所以:

1
p++;      // 合法,p 是可修改变量

但:

1
a++;      // 非法,数组名不是可修改指针变量

并且:

1
2
sizeof(a)     // 整个数组字节数
sizeof(p) // 一个指针本身的大小

在典型 64 位机器上可能分别是 40 与 8,但不要把具体数字写死到可移植逻辑中。


4.4 数组参数为什么“退化成指针”

1
void f(int a[])

作为函数形参时等价于:

1
void f(int *a)

因此:

1
2
3
4
void f(int a[100])
{
printf("%zu\n", sizeof a);
}

这里 sizeof a 是指针大小,而不是 100 个 int 的大小。

这也是为什么函数如果需要数组长度,通常必须额外传入:

1
void f(int *a, size_t n);

4.5 数组不会在所有上下文中退化

最重要的例外:

1
2
sizeof a
&a

此时数组仍按“整个数组对象”处理。

例如:

1
2
3
4
int a[10];

sizeof a; // 10 * sizeof(int)
&a; // int (*)[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
2
3
4
5
6
&a[0]
&a[1]
&a[2]
&a[3]
&a[4]
&a[5] // one-past,只能作为边界,不能解引用

可以:

1
2
3
int *end = a + 5;
for (int *p = a; p != end; ++p)
printf("%d\n", *p);

不可以:

1
*(a + 5);   // UB

“可以形成尾后一位指针”与“可以访问尾后一位对象”是两件完全不同的事。


5.3 指针相减

同一个数组中的两个指针可以相减:

1
ptrdiff_t d = &a[4] - &a[1];  // 3

结果表示相差多少个元素,不是相差多少字节。

类型使用:

1
2
#include <stddef.h>
ptrdiff_t

不要假设任意两个完全无关对象的指针都能有意义地相减或进行 </> 比较。


6. 字符串、字符数组与字符指针

6.1 C 字符串的物理本质

C 没有内建“字符串对象类型”。所谓 C 字符串,本质是:

一串 char,最后用 \0(数值 0)作为终止标记。

1
char s[] = "cat";

实际有 4 个字符:

1
'c' 'a' 't' '\0'

所以:

1
2
sizeof s == 4
strlen(s) == 3

sizeof 看对象占多少字节;strlen 遍历直到第一个 \0。


6.2 两种看似相似的写法完全不同

1
2
char a[] = "hello";
const char *p = "hello";

第一种:

1
2
a 是数组对象
字符内容被复制到 a 的存储里

所以:

1
a[0] = 'H';     // 可以

第二种:

1
2
p 是指针对象
p 指向字符串字面量

字符串字面量不能被程序修改:

1
p[0] = 'H';     // 不允许通过 const;强行去 const 再改会导致 UB

即使旧式代码写:

1
char *p = "hello";

也不要修改字面量内容。现代代码优先使用 const char * 表达意图。


6.3 strcpy 为什么不是 s = t

1
s = t;

如果二者是指针,只是复制地址,不会复制字符。

真正字符串复制要逐字符复制,包括最终 \0:

1
2
3
4
5
void my_strcpy(char *dst, const char *src)
{
while ((*dst++ = *src++) != '\0')
;
}

但工程代码中不要无条件模仿不带长度限制的 strcpy;必须保证目标缓冲区足够大。


6.4 *p++ 这个经典模式

1
*dst++ = *src++;

等价于概念步骤:

1
2
3
*dst = *src;
dst++;
src++;

不是:

1
(*dst)++

掌握运算符结合关系后再使用这种紧凑写法。


7. 指针数组与二级指针

7.1 char *lines[100]

1
char *lines[100];

读法:

lines 是一个有 100 个元素的数组,每个元素类型是 char *。

内存概念:

1
2
3
4
5
6
7
8
lines:
┌──────────────┐
│ char * ------┼----> "first line"
├──────────────┤
│ char * ------┼----> "abc"
├──────────────┤
│ char * ------┼----> "very long line ..."
└──────────────┘

优点是交换文本时只交换地址:

1
2
3
char *tmp = lines[i];
lines[i] = lines[j];
lines[j] = tmp;

而不是搬动整段字符数据。


7.2 char **p 到底是什么

1
char **p;

从变量名向外读:

1
2
3
p
*p 是 char *
**p 是 char

所以 p 是“指向 char * 的指针”。

如果:

1
2
char *s = "hello";
char **pp = &s;

关系:

1
pp --> s --> 'h' 'e' 'l' 'l' 'o' '\0'

二级指针常见用途:

1
2
3
4
5
1. 修改一个指针变量本身
2. 指针数组/argv
3. 动态二维数据结构
4. 链表插入/删除接口
5. 返回动态分配出来的地址

7.3 为什么修改指针变量要传 T **

1
2
3
4
void alloc_int(int **out)
{
*out = malloc(sizeof **out);
}

调用:

1
2
int *p = NULL;
alloc_int(&p);

如果函数形参只是 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
2
3
row 0: a[0][0] a[0][1] a[0][2] a[0][3]
row 1: a[1][0] a[1][1] a[1][2] a[1][3]
row 2: a[2][0] a[2][1] a[2][2] a[2][3]

C 通常采用 row-major(按行连续)布局。


8.2 a + 1 为什么跳过一整行

在表达式中,a 转换成:

1
int (*)[4]

即“指向 int[4] 的指针”。

因此:

1
a + 1

不是前进一个 int,而是前进一个完整 int[4]。

核心关系:

1
2
a[i][j]
== *(*(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
2
3
4
5
6
7
8
9
10
11
12
argv
│
▼
┌──────────┐
│ argv[0] -┼----> "./program\0"
├──────────┤
│ argv[1] -┼----> "hello\0"
├──────────┤
│ argv[2] -┼----> "world\0"
├──────────┤
│ NULL │ argv[argc] == NULL
└──────────┘

因此:

1
2
3
4
argv[i]       // char *,第 i 个字符串
argv[i][j] // char,第 i 个参数的第 j 个字符
*argv // argv[0]
**argv // argv[0][0]

处理命令行时应优先写清晰版本,而不是为了炫技写:

1
2
3
*++argv
**++argv
*++argv[0]

能拆成两三步时,维护性通常更重要。


10. 函数指针与回调

10.1 最基本声明

1
int add(int a, int b);

函数指针:

1
int (*fp)(int, int) = add;

读法:

1
2
3
4
fp
(*fp) 是函数
(*fp)(...) 返回 int
参数为 (int, int)

调用:

1
int x = fp(2, 3);

也可以写:

1
int x = (*fp)(2, 3);

现代 C 中通常直接 fp(...) 更清楚。


10.2 为什么函数指针有价值

假设排序算法本身不应该关心“按数字排”还是“按字典序排”。

可以把比较策略作为函数传入:

1
2
3
typedef int (*CompareFn)(const void *, const void *);

void sort(void *base, size_t n, size_t elem_size, CompareFn cmp);

这样:

1
2
算法 = 固定框架
策略 = 传进来的函数

这就是回调(callback)的基本思想。

常见领域:

1
2
3
4
5
6
7
qsort
事件循环
GUI 回调
驱动注册
状态机
插件系统
线程入口函数

10.3 函数指针和“返回指针的函数”不要混淆

1
int *f(void);

是:

f 是函数,返回 int *。

而:

1
int (*f)(void);

是:

f 是指针,指向返回 int 的函数。

根本原因:

1
() / [] 的结合优先级高于 *

11. 复杂声明的阅读方法

不要尝试从左到右硬读。

建议规则:

先找变量名,从变量名向外,根据括号和运算符优先级逐层解释。

优先级核心:

1
2
()  []    高
*

例 1:

1
int *a[10];
1
2
3
4
a
→ a[10]
→ *a[10]
→ int *a[10]

所以:

10 个“指向 int 的指针”的数组。

例 2:

1
int (*a)[10];
1
2
3
4
a
→ (*a)
→ (*a)[10]
→ int (*a)[10]

所以:

指向“10 个 int 的数组”的指针。

例 3:

1
int (*handlers[8])(const char *);

从 handlers 开始:

1
2
3
4
handlers[8]
→ *handlers[8]
→ (*handlers[8])(...)
→ returns int

所以:

handlers 是 8 个函数指针组成的数组,每个函数接收 const char *,返回 int。

工程上遇到太复杂的声明应优先 typedef:

1
2
typedef int (*Handler)(const char *);
Handler handlers[8];

可读性明显更高。


12. void *:通用对象指针

12.1 void * 保存地址,但没有目标对象类型信息

1
2
int x = 100;
void *p = &x;

p 知道一个地址,但仅凭 void *,编译器不知道这个地址应该按什么类型解引用。

所以 ISO C 中:

1
*p        // 不成立,void 不是可解引用的对象类型

必须先恢复具体对象指针类型:

1
2
int *ip = p;
printf("%d\n", *ip);

或:

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
#include <stdlib.h>

强制转换可能掩盖真正的声明问题。

注意:这里讨论的是对象指针。不要想当然地认为函数指针与 void * 之间也具有完全相同的标准保证。


12.3 为什么 malloc 返回 void *

1
void *malloc(size_t size);

malloc 只负责:

返回一块满足大小与对齐要求的原始存储。

它并不知道你准备存:

1
2
3
4
int
double
struct Node
数组

所以 void * 正适合表达“通用对象存储地址”。


12.4 标准 C 不允许对 void * 做普通指针算术

1
2
void *p;
p + 1; // ISO C 不定义这种运算

因为编译器不知道“一个 void 对象有多大”。

需要按字节移动时,常转换为字符指针:

1
2
unsigned char *q = p;
q += 10;

字符类型大小定义为 1 字节,因此适合查看对象表示。

GNU C 把 void * 算术作为扩展支持,但不要把扩展误当标准 C。


13. const:对象只读与指针只读

const 最大的误区是把它粗暴理解成“这个东西绝对不会发生任何变化”。

更准确的理解是:

const 是类型系统施加的修改约束;它说明通过具有相应限定类型的表达式不能修改对象。


13.1 四种经典组合

1
2
3
4
int *p;
const int *p;
int *const p = &x;
const int *const p = &x;

A. int *p

1
2
p 可以改指向
*p 可以改值

B. const int *p

也可写:

1
int const *p;

含义:

1
2
p 可以改指向
不能通过 p 修改 *p

例:

1
2
3
4
5
int a = 1, b = 2;
const int *p = &a;

p = &b; // OK
//*p = 3; // error

但若原对象本来不是 const:

1
a = 3;    // 可以

所以 const 限制的是这条访问路径。

C. int *const p

1
2
p 自己不能换地址
*p 可以修改

D. const int *const p

1
2
p 不能换地址
*p 也不能通过 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
2
3
const int x = 10;
int *p = (int *)&x;
*p = 20; // UB

强制转换只是在类型系统层面压住诊断,它不会让“原本定义为 const 的对象”突然合法可写。

如果原对象本身不是 const:

1
2
3
4
int x = 10;
const int *cp = &x;
int *p = (int *)cp;
*p = 20;

最终修改的是一个本来可写的 int 对象,这与上面的情况不同。但实际 API 设计中仍应尽量避免随意 cast away const。


13.4 const 不等于 .rodata

1
const int x = 10;

C 类型语义告诉你“不能通过普通可修改左值修改 x”。

它是否最终被放入:

1
2
3
4
.rodata
stack
register
甚至完全优化掉

是编译器和平台实现问题。

所以:

1
2
const = 语言类型约束
.rodata = 目标文件/加载布局概念

不要混淆。


14. struct / union / enum

14.1 struct:把多个成员组成一个对象

1
2
3
4
5
struct Student {
int id;
char grade;
double score;
};

使用:

1
2
3
4
5
struct Student s = {1001, 'A', 96.5};

s.id;
s.grade;
s.score;

如果有结构体指针:

1
struct Student *p = &s;

成员访问:

1
p->id

等价于:

1
(*p).id

.:左边已有结构体对象。

->:左边是结构体/联合体指针。


14.2 自引用结构体为什么必须使用指针

错误思想:

1
2
3
4
struct Node {
int value;
struct Node next;
};

如果合法,sizeof(struct Node) 必须包含另一个完整 struct Node,后者又包含另一个,永远无法确定大小。

正确:

1
2
3
4
struct Node {
int value;
struct Node *next;
};

因为一个指针本身大小有限且已知。

这构成链表/树/图的基础。


14.3 union:多个成员共享同一片存储

1
2
3
4
union Number {
int i;
double d;
};

与 struct 不同:

1
2
struct: 每个成员有各自位置
union : 各成员通常从同一 offset 0 开始,共享存储

因此 union 大小至少足以容纳最大成员,并满足成员所需对齐。

1
2
3
union Number n;
n.i = 10;
n.d = 3.14;

写 n.d 会覆盖同一片存储;不能把它想成同时拥有独立的 i 和 d 两份值。


14.4 Tagged Union:C 中实现“一个值可能有多种类型”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum ValueKind {
VALUE_INT,
VALUE_DOUBLE,
VALUE_STRING
};

struct Value {
enum ValueKind kind;
union {
int i;
double d;
const char *s;
} as;
};

使用:

1
2
3
4
struct Value v = {
.kind = VALUE_INT,
.as.i = 42
};

读取时先看标签:

1
2
3
4
5
6
7
8
9
10
11
switch (v.kind) {
case VALUE_INT:
printf("%d\n", v.as.i);
break;
case VALUE_DOUBLE:
printf("%f\n", v.as.d);
break;
case VALUE_STRING:
printf("%s\n", v.as.s);
break;
}

这种模式在解释器 AST、虚拟机值、协议消息、系统 API 中非常常见。


14.5 不要随意用 union 做可移植“位重解释”

很多底层代码会:

1
2
3
4
union {
float f;
uint32_t u;
} x;

然后写 f、读 u 来观察位模式。

不同标准版本和实现对 inactive member / object representation 的细节需要谨慎处理;如果你追求高度可移植的“按位复制”,优先考虑:

1
memcpy(&u, &f, sizeof u);

前提是两者大小满足你的设计要求。


14.6 enum:用名字表示离散状态

1
2
3
4
5
enum State {
STATE_INIT,
STATE_RUNNING,
STATE_STOPPED
};

默认从 0 递增:

1
2
3
STATE_INIT    = 0
STATE_RUNNING = 1
STATE_STOPPED = 2

也可以手动指定:

1
2
3
4
5
6
enum Error {
ERR_OK = 0,
ERR_IO = 100,
ERR_TIMEOUT,
ERR_MEMORY = 200
};

比“魔法数字”更可读:

1
if (state == STATE_RUNNING)

明显好于:

1
if (state == 1)

但不要把 sizeof(enum X) 或 ABI 表示大小想当然固定成某个字节数;在跨 ABI / 网络协议 / 文件格式时应明确使用固定宽度整数类型。


15. Padding / Alignment:结构体到底怎么排

15.1 对齐是什么

某个类型常常希望起始地址满足一定倍数关系。

概念例子:

1
2
3
4
5
char   alignment 1
short alignment 2
int alignment 4
double alignment 8
pointer alignment 8

这是典型值,不是 C 标准要求所有机器都如此。

若 int 要 4 字节对齐,那么地址最好满足:

1
address % 4 == 0

15.2 结构体成员 padding

1
2
3
4
5
struct S {
int i;
char c;
int j;
};

假设 int 4 字节、对齐 4:

1
2
3
4
5
offset
0..3 i
4 c
5..7 padding
8..11 j

于是:

1
sizeof(struct S) == 12

而不是:

1
4 + 1 + 4 == 9

padding 是编译器为了满足布局约束插入的无名空间。


15.3 为什么结构体尾部也可能有 padding

1
2
3
4
5
struct S {
int i;
int j;
char c;
};

成员结束在 offset 9,但结构体数组必须让每个下一个元素仍满足整体对齐。

若总大小只为 9:

1
2
arr[0] at 0
arr[1] at 9

第二个元素中的 int 可能失去 4 字节对齐。

因此常补到:

1
sizeof(struct S) == 12

使:

1
2
3
arr[0] 0
arr[1] 12
arr[2] 24

15.4 成员顺序会影响空间

1
2
3
4
5
struct Bad {
char c1;
double d;
char c2;
};

典型 8 字节对齐 ABI 下,可能布局为:

1
2
3
4
5
c1
7B padding
double
c2
7B tail padding

而:

1
2
3
4
5
struct Better {
double d;
char c1;
char c2;
};

通常能明显减小空洞。

经验:

如果没有外部 ABI / 文件格式约束,把高对齐、大成员放在前面,常可减少 padding。

但不要为了省内存随便重排公共 ABI 结构、网络协议结构或磁盘格式结构。


15.5 _Alignof / alignof

C11 提供:

1
2
_Alignof(int)
_Alignof(struct S)

若使用 <stdalign.h>,在相应标准环境中可以使用:

1
alignof(int)

它查询类型的对齐要求。

不要把:

1
sizeof(T)

与:

1
alignof(T)

当成同一个概念。


16. offsetof / container_of

16.1 offsetof

标准头文件:

1
#include <stddef.h>

使用:

1
offsetof(struct S, member)

表示:

member 相对于整个结构体对象起始位置的字节偏移。

例:

1
2
3
4
5
struct S {
int i;
char c;
int j;
};

典型布局:

1
2
3
offsetof(struct S, i) == 0
offsetof(struct S, c) == 4
offsetof(struct S, j) == 8

这比自己硬编码 8 安全得多,因为真正偏移由类型布局决定。


16.2 container_of 的数学本质

假设:

1
2
3
4
struct Student {
int id;
struct ListNode node;
};

若:

1
2
3
Student 起始地址 = B
node 的 offset = O
node 地址 = M

则:

1
M = B + O

所以:

1
B = M - O

这就是 container_of。

教学版:

1
2
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))

为什么转 char *?

因为希望减去的是字节偏移量。


16.3 前提条件非常严格

只有在 ptr 真正指向某个有效 type 对象中的 member 时,反推才有正确语义。

不能把任意地址扔进去:

1
container_of(random_ptr, struct Student, node)

这不是“万能找对象”的魔法。

Linux 内核真实 container_of 还会利用 GNU typeof 等扩展进行类型检查,本笔记中的版本只用于理解核心原理。


16.4 为什么侵入式链表喜欢它

普通链表:

1
2
3
4
struct StudentNode {
struct Student student;
struct StudentNode *next;
};

侵入式链表则把通用链表节点嵌入业务对象:

1
2
3
4
5
6
7
8
9
10
struct ListNode {
struct ListNode *prev;
struct ListNode *next;
};

struct Student {
int id;
char name[32];
struct ListNode link;
};

通用链表只操作 ListNode *。

业务层拿到 link 后通过 container_of 恢复 Student *。

好处:

1
2
3
一个通用链表实现可服务许多不同业务类型
不需要额外包装节点
可以把一个对象嵌入多个不同链表(多个 link 成员)

这是一种典型系统编程设计。


17. Macro / Preprocessor

17.1 C 源码并不是直接交给编译器

典型 GCC 工具链概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
source.c
│
▼ preprocessor
source.i
│
▼ compiler
source.s
│
▼ assembler
source.o
│
▼ linker
executable

预处理器负责:

1
2
3
4
5
#include
#define
#if / #ifdef / #ifndef
宏展开
条件编译

它发生在真正 C 语法/类型编译之前。


17.2 对象式宏

1
#define BUFFER_SIZE 4096

它不是变量。

预处理阶段看到 token BUFFER_SIZE 时会进行替换。

对于有类型需求的常量,很多情况下更推荐:

1
enum { BUFFER_SIZE = 4096 };

或适当的 const 对象,具体取决于是否需要编译期整型常量表达式、地址、作用域等。


17.3 函数式宏不是函数

错误:

1
#define SQUARE(x) x * x

调用:

1
SQUARE(1 + 2)

展开:

1
1 + 2 * 1 + 2

结果不是 9。

至少写:

1
#define SQUARE(x) ((x) * (x))

但仍然有副作用问题:

1
2
int i = 3;
int x = SQUARE(i++);

展开会出现多次 i++,这可能产生未定义行为或至少完全违背你的预期。

结论:

只靠多加括号不能让宏变成真正安全的函数。


17.4 多语句宏使用 do { ... } while (0)

错误写法:

1
#define LOG_AND_INC(x) printf("%d\n", x); x++

在:

1
2
3
4
if (cond)
LOG_AND_INC(x);
else
foo();

可能破坏语法结构。

常见模式:

1
2
3
4
5
#define LOG_AND_INC(x)        \
do { \
printf("%d\n", (x)); \
(x)++; \
} while (0)

使整个宏在语法上像一个单语句。

仍要注意参数是否多次求值。


17.5 #:字符串化

1
#define SHOW(x) printf(#x " = %d\n", (x))
1
SHOW(a + b);

宏展开中的:

1
#x

会产生类似:

1
"a + b"

特别适合 debug/log 宏。


17.6 ##:token 拼接

1
#define MAKE_NAME(a, b) a ## b
1
MAKE_NAME(counter_, 1)

形成 token:

1
counter_1

17.7 Include Guard

1
2
3
4
5
6
#ifndef MY_MODULE_H
#define MY_MODULE_H

// declarations

#endif

第一次 include 时定义宏;后续再 include 时跳过内容。

也常见:

1
#pragma once

但它历史上不是 ISO C 核心语言标准的一部分;现代编译器广泛支持。


17.8 条件编译不是运行时 if

1
2
3
#ifdef DEBUG
fprintf(stderr, "x=%d\n", x);
#endif

DEBUG 未定义时,这段代码甚至不会进入后续编译阶段。

与:

1
if (debug) { ... }

完全不同:后者是运行时控制流。


18. Scope / Storage Duration / Linkage

这是理解 static/global/local 的真正钥匙。

不要只问:

“这个变量在栈还是全局?”

应该拆成三个独立问题:

1
2
3
1. Scope(作用域):这个名字在哪里可见?
2. Storage Duration(存储期):这个对象活多久?
3. Linkage(链接属性):别处出现的同名声明是否指同一个实体?

18.1 Scope:名字在哪里可见

典型:

1
2
3
4
block scope
file scope
function prototype scope
function scope(主要与 label 有关)

例如:

1
2
3
4
5
6
7
8
void f(void)
{
int x = 1;
{
int y = 2;
}
// y 在这里不可见
}

18.2 Storage Duration:对象存在多久

主要可以先掌握:

1
2
3
4
automatic storage duration
static storage duration
allocated storage(动态分配对象的生命周期由 malloc/free 控制)
thread storage duration(多线程环境)

普通局部变量:

1
2
3
4
void f(void)
{
int x;
}

x 通常具有 automatic storage duration:每次进入相应块产生相应实例,离开后生命周期结束。

局部 static:

1
2
3
4
void f(void)
{
static int count;
}

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
2
3
4
5
void f(void)
{
int x = 10;
int a[100];
}

在没有被优化掉的典型机器代码中,它们可能放在当前函数的 stack frame。

但编译器可能:

1
2
3
把 x 放寄存器
完全常量传播掉 x
优化掉整个数组

所以不要写:

“所有局部变量都一定在栈上。”

正确表达:

普通局部对象具有 automatic storage duration;常见实现会在需要真实内存位置时利用运行时栈。


19.2 栈帧的角色

典型函数调用可能需要栈保存:

1
2
3
4
5
6
返回地址
部分局部对象
保存的寄存器
超过寄存器数量的参数
临时 spill 值
对齐空间

x86-64 System V 下栈通常向低地址增长。

但是现代优化函数经常不需要完整传统“frame pointer 栈帧”。


19.3 返回局部变量地址为什么错

1
2
3
4
5
int *bad(void)
{
int x = 42;
return &x;
}

函数返回后 x 的生命周期结束。

得到的地址可能数值还存在,但它不再指向一个活着的 int x 对象。

后续解引用属于 UB。


19.4 Heap:动态存储

1
int *p = malloc(sizeof *p);

这里至少要分清两个对象概念:

1
2
p 自己:局部指针变量,可能在栈/寄存器
*p 对应的动态对象:malloc 得到的存储,通常在 heap 区域

函数返回后,局部 p 消失不代表动态对象自动释放。

必须保留某个有效指针并最终:

1
free(p);

19.5 Global 与 static

1
2
3
4
int g1 = 1;
int g2;
static int s1 = 2;
static int s2;

这些都具有 static storage duration。

区别主要在 linkage:

1
2
g1/g2    通常 external linkage
s1/s2 internal linkage

局部 static:

1
2
3
4
void f(void)
{
static int count;
}

同样具有 static storage duration,但名字只有块作用域。

所以:

static 不是“放在某个叫 static 的内存区”。


20. .text / .rodata / .data / .bss

这些属于典型 ELF 目标文件/可执行文件组织概念。


20.1 .text

主要放机器指令。

1
2
3
4
int add(int a, int b)
{
return a + b;
}

编译后的函数机器码主要落在 .text 一类代码 section 中。

运行时通常映射为可执行、不可随意写的页面。


20.2 .rodata

常见只读数据:

1
2
3
字符串字面量
某些只读常量表
编译器生成的只读数据

例如:

1
printf("hello\n");

字符串字面量常出现在 .rodata。

但不要把 C 标准语义直接等同于某个 ELF section。


20.3 .data

典型存放已初始化的、可写的 static-storage 对象:

1
2
int g = 42;
static int s = 7;

常进入 .data 或相关可写 data section。


20.4 .bss

典型存放未显式初始化或零初始化的 static-storage 对象:

1
2
int g;
static int array[1000000];

这些对象运行时必须是零初始化。

.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
高地址
┌────────────────────────────┐
│ user stack │
│ ↓ │
├────────────────────────────┤
│ mmap / shared libraries │
├────────────────────────────┤
│ │
│ ↑ │
│ heap │
├────────────────────────────┤
│ .bss / zero-init data │
├────────────────────────────┤
│ .data │
├────────────────────────────┤
│ .rodata │
├────────────────────────────┤
│ .text │
└────────────────────────────┘
低地址

实际系统受 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
2
3
if (a == NULL) {
// handle allocation failure
}

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
2
3
4
5
6
void *tmp = realloc(p, new_size);
if (tmp != NULL) {
p = tmp;
} else {
// p 仍然有效
}

注意 realloc 成功时可能移动对象,旧地址可能不再有效。


21.4 free

1
free(p);

释放后:

1
2
*p        // UB
p[0] // UB

free 不会自动把变量 p 设成 NULL。

常见局部习惯:

1
2
free(p);
p = NULL;

可以帮助避免同一变量的再次误用,但不能解决所有 alias:

1
2
3
4
5
6
int *p = malloc(...);
int *q = p;
free(p);
p = NULL;

*q = 1; // q 仍然悬空

21.5 所有权思维

C 没有语言级自动所有权系统,所以你必须人为约定:

1
2
3
4
5
谁分配?
谁负责释放?
返回的指针是借用还是转移所有权?
字符串参数函数会不会保存这个地址?
对象能活到什么时候?

写 API 时把 ownership 说清楚,比单纯会 malloc/free 更重要。


22. Undefined Behavior:C 最重要的边界

22.1 UB 不是“随机返回一个值”

Undefined Behavior 的准确直觉:

当程序执行到标准规定为 UB 的情况时,C 语言不再对整个执行结果提供你原先依赖的保证。

因此可能:

1
2
3
4
5
6
7
看起来正常
崩溃
输出奇怪值
Debug 正常 / Release 错
换编译器后不同
开 -O2 后行为变化
被优化器完全重写

“我电脑上运行了 100 次没崩”不能证明没有 UB。


22.2 常见 UB

数组越界

1
2
int a[3];
a[3] = 10; // UB

空指针解引用

1
2
int *p = NULL;
*p = 1; // UB

use-after-free

1
2
3
int *p = malloc(sizeof *p);
free(p);
*p = 10; // UB

返回局部对象地址后解引用

1
2
3
4
5
int *bad(void)
{
int x = 10;
return &x;
}

有符号整数溢出

1
2
int x = INT_MAX;
x + 1; // UB

无符号整数溢出则按模 2^N 运算,这是定义良好的行为。

整数除零

1
int x = 1 / 0;  // UB

非法移位

例如移位数量为负,或大于等于提升后左操作数类型位宽。

修改字符串字面量

1
2
char *p = "abc";
p[0] = 'A'; // UB

某些未排序副作用

1
i = i++ + ++i;  // 不要写;涉及对同一标量对象的冲突副作用

非法对齐的解引用

把某个不满足 int 对齐要求的地址强转为 int * 后解引用,也可能导致 UB。


22.3 为什么优化器会让 UB 看起来“反直觉”

1
2
3
4
int f(int x)
{
return x + 1 > 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
2
float f = 1.0f;
uint32_t u = *(uint32_t *)&f;

这类直接通过不兼容类型指针重解释对象可能违反别名/有效类型规则,并且还涉及对齐等问题。

观察原始对象字节,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
2
3
4
5
6
7
参数放哪个寄存器/栈位置?
返回值放哪里?
哪些寄存器必须保存?
stack 如何对齐?
struct 如何布局和传递?
浮点参数怎么传?
符号如何链接?

如果不同模块不遵守同一 ABI,即使函数名和 C 声明“看起来一样”,二进制也无法正确合作。


23.2 x86-64 System V:整数/指针参数直觉

Linux/macOS 等常见 x86-64 System V 家族环境中,前六个整数/指针类参数通常依次使用:

1
2
3
4
5
6
1  %rdi
2 %rsi
3 %rdx
4 %rcx
5 %r8
6 %r9

整数/指针返回值常用:

1
%rax

第 7 个及之后的参数通常需要栈参与。

例如:

1
2
3
4
long add(long a, long b)
{
return a + b;
}

概念机器层:

1
2
3
4
5
6
%rdi = a
%rsi = b

compute

%rax = result

注意:浮点参数通常走 XMM 寄存器,结构体参数和返回值还有更复杂的分类规则。本节只建立直觉。


23.3 Caller-saved / Callee-saved

System V x86-64 中常见 callee-saved:

1
2
3
4
5
6
%rbx
%rbp
%r12
%r13
%r14
%r15

意思:

被调用函数如果要破坏这些寄存器,必须在返回之前恢复。

其他大部分通用寄存器是 caller-saved:

调用者若还需要旧值,应在调用前自己保存。

这解释了为什么汇编里常出现:

1
2
3
4
5
pushq %rbx
...
call some_function
...
popq %rbx

23.4 Stack Alignment

现代 SIMD 指令和 ABI 对栈对齐有要求。

在 System V x86-64 的常见规则中,调用边界需要维持 16 字节相关的栈对齐约定。

由于 call 本身会把 8 字节返回地址压栈,所以理解汇编时要区分:

1
2
3
call 之前的 %rsp 对齐状态
函数刚进入后的 %rsp 对齐状态
函数为局部变量再次 sub %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
2
3
4
5
6
7
sizeof(struct)
alignment
member offsets
bit-field layout
calling convention
integer type sizes
endianness(在外部二进制格式中尤其重要)

这也是为什么公开 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
2
3
gcc -std=c11 -Wall -Wextra -g \
-fsanitize=address,undefined \
main.c -o main

常能抓到:

1
2
3
4
5
6
heap-buffer-overflow
stack-buffer-overflow
use-after-free
部分 signed overflow
部分 invalid shift
部分 alignment UB

Sanitizer 不能数学证明“程序无 UB”,但对学习 C 极其有价值。


24.3 GDB 应该学会的最小集合

1
2
3
4
5
6
7
8
9
10
break
run
next
step
continue
print
x
info registers
backtrace
disassemble

特别是:

1
2
3
4
p &x
p p
p *p
x/16xb address

能让“地址、指针、字节”从抽象概念变成可观察事实。


24.4 ELF / 汇编观察工具

Linux 常用:

1
2
3
4
5
6
readelf -S ./a.out
readelf -l ./a.out
nm -n ./a.out
objdump -d ./a.out
objdump -s -j .rodata ./a.out
size ./a.out

分别可以帮助观察:

1
2
3
4
5
6
sections
segments
symbols
machine code
.rodata 内容
text/data/bss 大小

学习系统 C 时,不要只停留在源代码层。


25. 一张总图:从 C 源码到机器

把所有知识连起来:

1
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
41
42
43
44
┌─────────────────────────────────────────────┐
│ C 类型系统 │
│ int / char / pointer / const / struct ... │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 对象与表达式 │
│ & / * / [] / pointer arithmetic │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 聚合与布局 │
│ array / struct / union / enum │
│ sizeof / alignof / offsetof / padding │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 生命周期与可见性 │
│ scope / storage duration / linkage │
│ automatic / static / malloc │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 编译与链接 │
│ preprocessor → compiler → assembler → linker │
│ .text/.rodata/.data/.bss │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 运行时 │
│ virtual address space / stack / heap │
│ loader / shared libraries │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ ABI / machine code │
│ registers / stack alignment / call convention│
└─────────────────────────────────────────────┘

真正的主问题一直没变:

某个表达式指向哪个对象?这个对象是什么类型?还活着吗?这个操作在 C 规则中合法吗?编译后它会如何映射到机器?

如果每次写指针代码都能回答这五个问题,C 会变得非常可控。


26. 编程练习(不附答案)

下面的题不是单纯刷语法,而是按照“指针 → 数组 → 字符串 → 泛型 → 数据布局 → 动态内存 → 系统层”的顺序递进。

建议编译参数:

1
2
3
gcc -std=c11 -Wall -Wextra -Wpedantic -g \
-fsanitize=address,undefined \
your_file.c -o your_file

除非题目明确允许,否则不要为了通过测试关闭 warning 或 sanitizer。


Level 1:指针基本功

题 1:swap 与值传递

实现:

1
void swap_int(int *a, int *b);

要求:

1
2
3
1. 不能返回结构体或使用全局变量。
2. main 中打印交换前后的地址和值。
3. 用注释解释为什么 swap_int(a,b) 不够,必须传 &a/&b(这里 a/b 指 main 中的 int 变量)。

测试:

1
2
3
1, 2      → 2, 1
0, 0 → 0, 0
-3, 100 → 100, -3

考点:值传递、&、*。


题 2:一次函数返回 min 和 max

实现:

1
int minmax(const int *a, size_t n, int *out_min, int *out_max);

约定:

1
2
成功返回 1
n == 0 或任一必须参数为 NULL 返回 0

要求只遍历一次数组。

考点:const 指针、输出参数、边界检查。


题 3:只用指针写 strlen

实现:

1
size_t my_strlen(const char *s);

限制:

1
2
不能使用 []
不能调用 strlen

尝试两个版本:

1
2
A. 计数器版本
B. 保存起始指针,用 p - s 得长度

思考:为什么返回值应该是 size_t,而不是 int?


题 4:strend

实现:

1
int strend(const char *s, const char *suffix);

如果 suffix 是 s 的后缀返回 1,否则 0。

测试:

1
2
3
4
5
"hello", "lo"      → 1
"hello", "hello" → 1
"hello", "" → 1
"hello", "world" → 0
"hi", "hello" → 0

限制:不调用 strstr。


Level 2:数组、字符串、二级指针

题 5:原地反转字符串

实现:

1
void reverse_string(char *s);

要求:

1
2
3
4
1. O(n)
2. O(1) 额外空间
3. 使用两个指针从两端向中间移动
4. 正确处理空串和 1 字符串

不要传字符串字面量进去修改。


题 6:删除数组中的目标值

实现:

1
size_t remove_value(int *a, size_t n, int target);

将所有不等于 target 的元素稳定压到数组前部,返回新长度。

例如:

1
2
3
[1,2,3,2,4], target=2
→ [1,3,4,...]
return 3

要求:不能申请新数组。

考点:读指针 / 写指针(two-pointer pattern)。


题 7:二级指针创建整数对象

实现:

1
int make_int(int value, int **out);

要求:

1
2
成功:动态分配一个 int,写入 value,设置 *out,返回 1
失败:返回 0,并保证调用者不会拿到伪造的有效指针

在 main 中正确释放。

考点:int **、所有权、malloc/free。


题 8:复制字符串数组

实现:

1
2
char **clone_strings(const char *const *src, size_t n);
void free_strings(char **arr, size_t n);

要求:

1
2
3
4
1. 动态创建 n 个 char *
2. 每个字符串都做 deep copy
3. 中途任何一次 malloc 失败都不能泄漏前面已经分配的内存
4. 最终 free_strings 完整释放

考点:二级指针、const、错误路径清理、ownership。


Level 3:多维数组与复杂类型

题 9:固定列数矩阵统计

定义:

1
#define COLS 4

实现:

1
void row_sums(const int (*a)[COLS], size_t rows, int *out);

其中:

1
out[i] = 第 i 行所有元素的和

要求:

1
2
3
1. 明确解释为什么参数是 int (*)[COLS]
2. 写一个版本使用 a[i][j]
3. 再写一个版本使用 *(*(a+i)+j)

题 10:比较“二维数组”和“指针数组”

分别构建:

1
int matrix[3][4];

和:

1
int *rows[3];

为后者动态分配每一行。

程序打印:

1
2
3
4
sizeof(matrix)
sizeof(rows)
&matrix[i][j]
&rows[i][j]

然后写一段分析:

1
2
3
哪些内存一定连续?
为什么 matrix + 1 与 rows + 1 的含义不同?
为什么 int ** 不能直接替代 int (*)[4]?

这是一道“代码 + 解释题”。


Level 4:函数指针与泛型

题 11:函数指针计算器

定义:

1
typedef double (*BinaryOp)(double, double);

实现:

1
2
3
4
double add(double, double);
double sub(double, double);
double mul(double, double);
double divide(double, double);

然后使用函数指针数组:

1
BinaryOp ops[] = { ... };

实现命令:

1
2
./calc add 1.2 3.4
./calc mul 5 6

要求不要用一长串重复计算逻辑;命令解析后通过函数指针 dispatch。


题 12:泛型 swap

实现:

1
void generic_swap(void *a, void *b, size_t size);

要求:

1
2
3
4
1. 能交换 int / double / struct
2. 不假定对象只有 4 或 8 字节
3. 按字节处理
4. 正确处理 size == 0

可使用临时动态内存,之后再挑战一个不 malloc、逐字节交换的版本。

思考:为什么需要把 void * 转成 unsigned char *?


题 13:自己的泛型查找

实现:

1
2
3
4
5
6
7
void *linear_find(
const void *base,
size_t n,
size_t elem_size,
const void *key,
int (*cmp)(const void *, const void *)
);

约定:找到则返回元素地址,找不到返回 NULL。

分别测试:

1
2
int 数组
Student 结构体数组(按 id 查)

考点:void *、字节步进、回调、const。


Level 5:Struct / Union / Enum / Layout

题 14:Tagged Union 值系统

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum ValueKind {
VAL_INT,
VAL_DOUBLE,
VAL_STRING
};

struct Value {
enum ValueKind kind;
union {
int i;
double d;
char *s;
} as;
};

实现函数:

1
2
3
4
5
struct Value value_int(int x);
struct Value value_double(double x);
struct Value value_string(const char *s);
void value_print(const struct Value *v);
void value_destroy(struct Value *v);

要求:

1
2
3
VAL_STRING 必须 deep copy
value_destroy 只能释放真正拥有动态内存的分支
destroy 后将状态改成你设计的安全状态

考点:tagged union、生命周期、ownership。


题 15:手算结构体布局,再用程序验证

给定:

1
2
3
4
5
6
7
struct Record {
char tag;
double score;
short flags;
int count;
char *name;
};

先不要运行程序,手算你机器上最可能的:

1
2
3
4
每个成员 offset
sizeof(struct Record)
alignof(struct Record)
padding 在哪里

然后写程序打印:

1
2
3
sizeof(...)
_Alignof(...)
offsetof(...)

最后解释手算与实际结果是否一致。


题 16:最小化结构体 padding

把以下成员重新排序:

1
2
3
4
5
6
7
char a;
double b;
short c;
char d;
int e;
void *f;
char g;

要求:

1
2
3
4
5
1. 原始顺序定义 struct Original
2. 优化顺序定义 struct PackedBetter
3. 比较 sizeof
4. 使用 offsetof 打印每个成员位置
5. 不允许 #pragma pack / __attribute__((packed))

解释:为什么“减少 padding”与“强制 packed”不是一回事?


Level 6:offsetof / container_of 与链表

题 17:自己实现侵入式双向链表

定义:

1
2
3
4
5
6
7
8
9
10
struct ListNode {
struct ListNode *prev;
struct ListNode *next;
};

struct Task {
int id;
char name[32];
struct ListNode link;
};

实现:

1
2
3
4
void list_init(struct ListNode *head);
void list_insert_after(struct ListNode *pos, struct ListNode *node);
void list_remove(struct ListNode *node);
int list_empty(const struct ListNode *head);

再定义教学版 container_of,遍历时把 ListNode * 恢复成 Task *。

要求:

1
2
3
1. 使用哨兵 head
2. 删除节点后把 node->prev/node->next 置为 NULL(便于调试)
3. 不允许链表库知道 Task 的具体字段

这是本套题中非常重要的一题。


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
2
3
4
#expr
__FILE__
__LINE__
#ifdef

要求宏在:

1
if (...) DBG_INT(x); else ...;

中仍然语法安全。


题 19:找出危险宏并修复到“尽可能安全”

分析:

1
2
3
#define MAX(a,b) a > b ? a : b
#define SET_ZERO(p) *p = 0
#define TWO_STEPS(x) x++; x++

对每个宏:

1
2
3
4
1. 给出至少一个会出错/违背预期的调用
2. 解释宏展开结果
3. 修复括号/多语句结构问题
4. 指出仍然无法彻底消除的“参数重复求值”问题

不要用 GNU statement expression;先只讨论标准 C 宏能力的边界。


Level 8:动态内存工程题

题 20:动态数组 Vector

实现:

1
2
3
4
5
struct IntVector {
int *data;
size_t size;
size_t capacity;
};

API:

1
2
3
4
int vec_init(struct IntVector *v);
int vec_push(struct IntVector *v, int x);
int vec_pop(struct IntVector *v, int *out);
void vec_destroy(struct IntVector *v);

要求:

1
2
3
4
5
capacity 不够时使用 realloc 扩容
建议按 2 倍增长
必须处理 size/capacity 的溢出风险
realloc 失败不能破坏旧 vector
vec_destroy 后恢复空状态

加分:实现:

1
int vec_reserve(struct IntVector *v, size_t cap);

题 21:任意长度输入行

实现:

1
char *read_line(FILE *fp);

要求:

1
2
3
4
5
6
7
1. 不设固定 MAXLINE
2. 初始申请一小块 buffer
3. 空间不足 realloc 扩容
4. EOF 且没有读到字符时返回 NULL
5. 返回的字符串必须以 \0 结尾
6. 调用者拥有返回值并负责 free
7. 所有失败路径都不能泄漏

这是理解动态内存最值得写的一题。


题 22:Arena / Bump Allocator

实现一个最小 arena:

1
2
3
4
5
struct Arena {
unsigned char *base;
size_t capacity;
size_t used;
};

API:

1
2
3
4
int arena_init(struct Arena *a, size_t capacity);
void *arena_alloc(struct Arena *a, size_t size, size_t alignment);
void arena_reset(struct Arena *a);
void arena_destroy(struct Arena *a);

要求:

1
2
3
4
1. arena_alloc 返回满足 alignment 的地址
2. 检查 size/offset 加法溢出
3. 不够空间返回 NULL
4. reset 一次性回收全部逻辑对象,不逐个 free

先只支持 alignment 为 2 的幂。

这题把:

1
2
3
4
5
6
void *
pointer arithmetic
alignment
size_t
overflow
heap

全部串起来。


Level 9:UB 排雷题

题 23:找出所有 bug / UB

不要先运行,先静态分析:

1
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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int *make_value(void)
{
int x = 42;
return &x;
}

int main(void)
{
char *s = "hello";
s[0] = 'H';

int a[3] = {1, 2, 3};
printf("%d\n", a[3]);

int *p = malloc(sizeof *p);
*p = 100;
free(p);
printf("%d\n", *p);

int *q = make_value();
printf("%d\n", *q);

int x = 1;
printf("%d %d\n", x++, x++);

return 0;
}

任务:

1
2
3
4
1. 标出每一个问题
2. 分类:编译约束问题 / UB / portability / ownership
3. 修复
4. 用 ASan/UBSan 比较修复前后

题 24:边界正确的 pointer loop

分别写 3 个版本求和:

1
long sum_array(const int *a, size_t n);

版本:

1
2
3
A. 下标
B. 指针 + end 指针
C. 指针 + 计数器

然后故意写一个 off-by-one 版本,让 sanitizer 抓出来。

解释:

1
2
为什么 end = a + n 可以合法形成?
为什么 *end 不合法?

Level 10:ABI / ELF 实验

题 25:观察参数寄存器

写:

1
2
3
4
5
6
long f(long a, long b, long c,
long d, long e, long f,
long g, long h)
{
return a + b + c + d + e + f + g + h;
}

编译:

1
gcc -O0 -fno-omit-frame-pointer -S abi.c

观察汇编并回答:

1
2
3
前 6 个参数在哪里?
第 7、8 个在哪里?
返回值放哪个寄存器?

再用:

1
gcc -O2 -S abi.c

比较优化前后。

注意:根据你的平台 ABI,结果可能与 Linux x86-64 System V 不同。


题 26:观察 .text/.rodata/.data/.bss

写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

int g_init = 123;
int g_zero;
static int s_init = 456;
static int s_zero;
const char message[] = "hello section";

int add(int a, int b)
{
return a + b;
}

int main(void)
{
static int local_static = 789;
int local_auto = 42;
printf("%s %d\n", message,
add(g_init, local_auto) + local_static + s_init + s_zero + g_zero);
}

使用:

1
2
3
4
5
readelf -S ./program
nm -n ./program
objdump -d ./program
objdump -s -j .rodata ./program
size ./program

写分析报告:

1
2
3
4
5
6
7
g_init 大致在哪?
g_zero 大致在哪?
s_init/s_zero 呢?
add 的机器码在哪?
字符串在哪?
local_auto 为什么通常不会成为普通全局 symbol?
local_static 为什么与 local_auto 生命周期不同?

推荐做题顺序

不要一次把 26 题全写完。按下面节奏最有效:

1
2
3
4
5
6
7
8
第一阶段:1 → 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

每道题提交时建议同时给出:

1
2
3
4
1. .c 源码
2. 编译命令
3. warning / sanitizer 输出(如果有)
4. 你自己对关键指针/对象生命周期的解释

这样检查的不只是“程序能不能跑”,而是:

1
2
3
4
5
6
7
类型是否正确
边界是否正确
生命周期是否正确
所有权是否正确
是否存在 UB
API 是否清晰
机器层理解是否匹配

最后:你目前最应该形成的 12 条条件反射

  1. 看到 p,马上区分 p / &p / *p。
  2. 看到数组,问它此刻是“真实数组对象”还是“已经转换成首元素指针”。
  3. 看到 a[i],能想到 *(a+i),但不为了炫技强行改写。
  4. 看到函数参数 T a[],知道它本质上被调整成 T *a。
  5. 看到 T **,问清楚究竟是“指向指针”还是某种动态二维结构,不能默认就是二维数组。
  6. 看到 const,分别问“指针能改吗?”、“目标能通过它改吗?”。
  7. 看到 malloc,马上问“谁拥有?失败怎么办?最后谁 free?”。
  8. 看到结构体,考虑 alignment / padding / ABI,而不是直接相加成员大小。
  9. 看到宏参数,先问“这个参数会被求值几次?”。
  10. 看到地址操作,检查对象是否还活着、指针是否在合法边界内、对齐是否满足。
  11. 看到“程序运行正常”,不要自动推出“没有 UB”。
  12. 看到 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 进行许可。
评论
目录
c语言指针、内存、系统编程深入笔记与练习