指针运算底层真相
# 04.指针运算底层真相
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 类型决定步长
- 4. 下标即指针运算
- 5. 三等价终极证明
- 6. 指针减法
- 7. 指针比较的合法边界
- 8. 越界指针与UB
- 9. void算术禁令
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一段在嵌入式设备上跑了半年的固件代码——某天 OTA 升级后,设备开始"随机重启",日志只有一句 Data Abort:
// protocol_parser.c —— 自定义二进制协议解析器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#define MAX_PAYLOAD 4096
typedef struct __attribute__((packed)) {
uint8_t version;
uint8_t type;
uint16_t length; /* 有效载荷长度 */
uint32_t crc;
} PktHeader; /* sizeof = 8 字节 */
/* 在线升级包:header + 固件数据 + 签名 */
void parse_firmware_packet(void *raw_data, size_t total_len) {
PktHeader *hdr = (PktHeader *)raw_data;
void *body = (void *)(hdr + 1); /* ← Line A */
uint16_t len = hdr->length;
if (len > MAX_PAYLOAD) return;
uint8_t *signature = body + len; /* ← Line B: 这里崩了! */
/* 验证签名... */
uint32_t sig = *(uint32_t *)signature; /* ← 读到了垃圾数据或地址 */
if (sig != expected_sig) {
printf("固件签名校验失败\n");
return;
}
/* 写入 Flash... */
}
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
现象:
- 固件版本 v2.0(包头长度 8 字节):一切正常
- 固件版本 v3.0(包头加了
uint32_t timestamp字段 → 总 12 字节):上电即崩 - 崩溃地址
0x00000000_00003FFF或0x00000000_00000ABC,看起来像是把一个小的整数值(len)当成了地址
第一反应:len 来自网络数据包,可能是恶意数据?但抓包看到 len 是合法的 1024,不应该导致崩溃。
# 1.2 顺藤摸到根因
在 gdb 里断下来:
(gdb) p hdr
$1 = (PktHeader *) 0x20001000
(gdb) p hdr + 1
$2 = (PktHeader *) 0x20001008 ← v2.0 的 sizeof(PktHeader)=8,+1 跳了 8 字节 ✓
(gdb) p (void *)(hdr + 1)
$3 = (void *) 0x20001008 ← body 指向 payload 起始
(gdb) p body
$4 = (void *) 0x20001008
(gdb) p len
$5 = 1024
(gdb) p body + len
$6 = (void *) 0x20001408 ← ← 签名地址?不对!
2
3
4
5
6
7
8
9
10
11
12
13
body + len = 0x20001008 + 1024 = 0x20001408。看起来没错?但 signature 应该在这个地址之后,而不是等于这个地址。而且——等等,body + len 在 void* 上是非法的!
实际上代码能通过编译,是因为 -std=gnu11(GNU C 扩展允许对 void* 做算术)。GNU C 把 void* 当作步长为 1 来计算。所以:
body + len → 0x20001008 + 1024 × 1 = 0x20001408
但期望的不是这样! 期望是 body 往后跳 len 个字节到有效载荷的末尾,然后签名在那儿。但实际代码里——
再细看 Line B:
uint8_t *signature = body + len; /* body 是 void* */
body + len 在 GNU C 下 = (void*)((char*)body + len * 1) = 0x20001408——看起来对。
但问题出在 v3.0 的另一个变更——PktHeader 加了 timestamp:
typedef struct __attribute__((packed)) {
uint8_t version; /* 1 字节 */
uint8_t type; /* 1 字节 */
uint16_t length; /* 2 字节 */
uint32_t crc; /* 4 字节 */
uint32_t timestamp; /* 4 字节 ← v3.0 新增 */
} PktHeader; /* 共 12 字节 */
2
3
4
5
6
7
而上层调用者(没跟着更新)仍然用老的偏移量来构建数据包,导致 hdr->length 读到的不是长度,而是被 timestamp 覆盖后的垃圾值。但这不是指针运算的问题——真正的指针运算 bug 在另一个地方。
重新审视 Line A:
void *body = (void *)(hdr + 1); /* hdr 是 PktHeader* */
hdr + 1 在 PktHeader* 上 = (PktHeader*)((char*)hdr + sizeof(PktHeader))。v2.0 的 sizeof(PktHeader)=8,body = hdr + 8。v3.0 的 sizeof(PktHeader)=12,body = hdr + 12。这部分是正确的——编译器自动根据 PktHeader 的大小调整了偏移。
Bug 的真正位置在 Line B——结合两个事实:
body是void*(丢失了类型信息)body + len需要len是字节数
但如果 len 被 timestamp 字段污染(变成了一个很大的值比如 0x00003FFF),body + len 就会跳到一个完全非法的地址——这就是设备的随机崩溃位置。
而更深层的根因是:v3.0 的解析器应该把 hdr + 1 的结果直接转为 uint8_t*,而不是转为 void* 再做算术——如果 Line B 写成 (uint8_t*)body + len 而不是 body + len,即使 len 被污染,至少在类型上是清晰的,更容易排查。
这段代码藏着 7 个关于指针运算的深度问题:
① p+1 到底在汇编层面加了多大?为什么 int* +1 加了 4 字节? → 第 3 章
② arr[i] 和 *(arr+i) 是同一个东西吗?为什么能这么写? → 第 4-5 章
③ 为什么 i[arr] 也能用?这合法吗? → 第 5.2
④ 指针减法 p-q 返回的不是字节数,而是元素个数——为什么? → 第 6 章
⑤ 两个不同数组的指针可以做 < 比较吗? → 第 7 章
⑥ 指向数组 arr 末尾+1 的指针合法吗?再+1 呢? → 第 8 章
⑦ 为什么 void* 不能做 p+1?GNU C 允许它是好事还是坏事? → 第 9 章
2
3
4
5
6
7
# 1.3 我们要回答什么
这个案例就是本篇的主线。我们从 p+1 的汇编翻译开始,把 C 语言指针运算的所有规则一条一条从汇编级别证明,最后在第 10 章回到这个固件解析器,展示如何写出既正确又类型安全的指针运算代码。
本篇路线:
四象限全景 (第 2 章)
↓
类型决定步长:p+1 的汇编翻译 (第 3 章) ─→ 解开①
↓
下标即指针运算:arr[i] 的翻译链 (第 4 章) ─→ 解开②
↓
三等价证明:arr[i]/*(arr+i)/i[arr] (第 5 章) ─→ 解开③
↓
指针减法与比较 (第 6-7 章) ─→ 解开④~⑤
↓
越界指针与 UB (第 8 章) ─→ 解开⑥
↓
void* 算术禁令 (第 9 章) ─→ 解开⑦
↓
综合案例 (第 10 章) ─→ 案例剖开 + 类型安全 rewrite
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:第 03 篇讲了"指针是地址+类型的二元组",本篇把这个二进制组的最核心行为——运算——拆到汇编。后续所有用指针遍历数组、操作缓冲区、解析协议包体的代码,都建立在本篇的规则之上。
# 2. 架构概览
# 2.1 指针运算四象限全景
C 语言的指针运算可以放进四个象限:
加 / 自增 (+)
│
类型决定步长│ p + N → p 向后跳 N × sizeof(*p) 个字节
标量乘法 │ p++ → p 向后跳 sizeof(*p) 个字节
│
───────────┼───────────
│
减 / 自减 (-)
│
p - N → p 向前跳 N × sizeof(*p) 个字节
q - p → (q的地址 - p的地址) / sizeof(*p) → 元素个数
p-- → p 向前跳 sizeof(*p) 个字节
│
│
比较 (<, >, ==, !=)
│
同一数组内合法 │ p < q → 比较的是地址值本身
跨对象为 UB │ p == NULL → 唯一跨对象安全比较
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
指针运算的黄金规则:
一切指针算术的字节偏移量 = 操作数 × sizeof(*指针)
p ± N 的汇编形式 = p 的地址 ± N × sizeof(*p)
2
3
例如:
int *p;→p + 3→ 汇编:lea rax, [p + 12](3 × sizeof(int)=12)char *p;→p + 3→ 汇编:lea rax, [p + 3](3 × sizeof(char)=3)void *p;→p + 3→ 编译错误(sizeof(void) 无意义)
# 2.2 指针类型化
疑惑:为什么指针运算不直接操作字节,而要乘以 sizeof(*p)?
论证:
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
/* 如果 p+1 只加 1 个字节 */
/* p+1 会指向 arr[0] 的第 2 个字节——乱成一锅粥 */
2
3
4
5
如果 C 语言退化为"指针运算全按字节算":
// 假设指针运算是字节级(不乘以类型大小)
int arr[5];
int *p = arr;
p + 1; // 只前进 1 字节 → 指向 arr[0] 的中间!
// 你想跳过 1 个 int? 手动写 p + 4
// 你想跳过 5 个 int? 手动写 p + 20
2
3
4
5
6
7
这会让代码充满魔数——p + 24 到底是想跳过 24 个字节、还是 6 个 int、还是 3 个 double?读者要从上下文猜。
类型化运算的好处:
int *pi = arr;
double *pd = (double *)arr;
pi + 1; // 跳过 1 个 int = 4 字节 → "跳过一个 int 元素"
pd + 1; // 跳过 1 个 double = 8 字节 → "跳过一个 double 元素"
2
3
4
5
结论:指针的类型化运算,本质是把"在数组中移动"这种高频操作与元素大小自动绑定——程序员只需思考"移动几个元素",编译器自动算出需要多少字节。这是 C 语言对数组遍历的零开销抽象。
# 3. 类型决定步长
# 3.1 p+1 的汇编等价形式
疑惑:p + 1 到底在汇编层面干了什么?
论证——以四种常见类型为例,观察 godbolt 输出(gcc 14.1 -O2):
void demo() {
int *pi = (int *)0x1000;
char *pc = (char *)0x1000;
double *pd = (double *)0x1000;
short *ps = (short *)0x1000;
pi = pi + 1; /* 跳过一个 int */
pc = pc + 1; /* 跳过一个 char */
pd = pd + 1; /* 跳过一个 double */
ps = ps + 1; /* 跳过一个 short */
}
2
3
4
5
6
7
8
9
10
11
; pi = pi + 1 (int* → sizeof(int)=4)
add rax, 4 ; 加 4 字节
; pc = pc + 1 (char* → sizeof(char)=1)
add rax, 1 ; 加 1 字节
; pd = pd + 1 (double* → sizeof(double)=8)
add rax, 8 ; 加 8 字节
; ps = ps + 1 (short* → sizeof(short)=2)
add rax, 2 ; 加 2 字节
2
3
4
5
6
7
8
9
10
11
四个 p + 1,四种不同的 add 立即数。 步长完全由 sizeof(*p) 决定——这是纯编译期的计算,不产生任何运行时开销。
# 3.2 步长对比
| 指针类型 | sizeof(*p) | p+1 的字节偏移 | p+5 的字节偏移 |
|---|---|---|---|
char* | 1 | +1 | +5 |
short* | 2 | +2 | +10 |
int* | 4 | +4 | +20 |
long* | 8 | +8 | +40 |
float* | 4 | +4 | +20 |
double* | 8 | +8 | +40 |
void* | — | 编译错误 | 编译错误 |
struct{int a; char b;}* | 8(含 padding) | +8 | +40 |
int(*)[4](数组指针) | 16(4个int) | +16 | +80 |
关键细节——指针的步长不绑定变量名,绑定指针的声明类型:
int x = 42;
int *pi = &x;
char *pc = (char *)&x;
pi + 1; // 加 4 字节(int* 的步长)
pc + 1; // 加 1 字节(char* 的步长)
// 同一个地址,不同的指针类型 → 不同的步长
2
3
4
5
6
7
# 3.3 结构体步长
typedef struct {
int id;
char flag;
/* 3 字节 padding */
long timestamp;
} Record; /* sizeof = 16 */
Record records[100];
Record *p = records;
p + 0; // &records[0]
p + 1; // &records[1] = p + 16 ← 跳了整个结构体大小(含 padding)
p + 2; // &records[2] = p + 32
2
3
4
5
6
7
8
9
10
11
12
13
汇编(gcc -O2):
; p + 1
lea rax, [rdi + 16] ; 16 = sizeof(Record)
2
结构体指针运算自动包含 padding——不需要你手动算。这就是为什么 hdr + 1 在 v3.0 自动适配了 12 字节的结构体大小。
# 3.4 p++ 与 ++p 的语义与汇编差异
int arr[] = {10, 20, 30};
int *p = arr;
/* 场景 A:p++(后缀自增)*/
int a = *p++; // a = *p; 然后 p = p + 1 → a=10, p→arr[1]
/* 场景 B:++p(前缀自增)*/
int b = *++p; // p = p + 1; 然后 b = *p → p→arr[1], b=20
2
3
4
5
6
7
8
汇编(gcc -O0,展示语义差异):
; int a = *p++;
mov eax, DWORD PTR [p] ; 先取 *p 的值
mov DWORD PTR [a], eax ; 存入 a
add QWORD PTR [p], 4 ; 再让 p++(加 4)
; int b = *++p;
add QWORD PTR [p], 4 ; 先让 p++(加 4)
mov eax, DWORD PTR [p] ; 再取 *p 的值
mov DWORD PTR [b], eax ; 存入 b
2
3
4
5
6
7
8
9
性能提示:在 -O2 优化下,p++ 和 ++p 如果不需要临时保存旧值(*p++ 场景),编译器会优化为完全相同的指令。只有在旧值被使用时(如 *p++ 存到 a),才会有语义差异。
# 4. 下标即指针运算
# 4.1 arr[i] 的完整翻译链
疑惑:arr[i] 和 *(arr+i) 到底是不是同一个东西?
论证——C 标准(C11 §6.5.2.1)明确写道:
The definition of the subscript operator
[]is thatE1[E2]is identical to(*((E1)+(E2))).
用代码验证:
int arr[5] = {10, 20, 30, 40, 50};
int x = arr[2]; // 30
int y = *(arr + 2); // 30
2
3
4
三条指令链的对比(gcc -O0):
; int x = arr[2];
; arr 的基址假设在 [rbp-32]
mov eax, DWORD PTR [rbp-32+8] ; (rbp-32) + 2×4 = (rbp-24)
mov DWORD PTR [rbp-4], eax ; x = eax
; int y = *(arr + 2);
lea rax, [rbp-32] ; rax = &arr[0]
mov eax, DWORD PTR [rax+8] ; eax = *(arr + 2)
mov DWORD PTR [rbp-8], eax ; y = eax
2
3
4
5
6
7
8
9
在 -O0 下,arr[2] 直接产生 [基址+偏移] 指令,*(arr+2) 多了一步 lea。但在 -O2 下,两者编译为完全相同的指令:
; 两者都编译为:
mov eax, DWORD PTR [rbp-24]
2
结论:arr[i] 和 *(arr+i) 在语义上完全等价,在优化的编译器中产生完全相同的机器码——下标只是语法糖,指针运算才是本质。
# 4.2 形参丢大小
void func(int arr[5]) { /* 看似传了一个 5 元素数组 */
printf("%zu\n", sizeof(arr)); /* 输出 8(指针大小),不是 20! */
}
2
3
C 语言标准规定:数组作为函数参数时,自动退化为指向首元素的指针。函数签名:
void func(int arr[5]); /* 等价于 */
void func(int *arr); /* 数组长度 5 是纯粹的注释,不参与类型系统 */
2
退化后 sizeof(arr) 的汇编:
; sizeof(arr) 在函数体内——arr 是指针
mov eax, 8 ; 64 位系统的指针大小
2
这就是为什么 sizeof(arr)/sizeof(arr[0]) 在函数参数上失效的原因——arr 已经不是数组了,它是一个指针变量。
# 4.3 sizeof(arr)/sizeof(arr[0]) 的失效场景
/* ✅ 在声明 arr 的同一作用域内——有效 */
void test1() {
int arr[10];
size_t n = sizeof(arr) / sizeof(arr[0]); // 40 / 4 = 10 ✓
}
/* ❌ 作为函数参数传递后——失效 */
void test2(int arr[10]) {
size_t n = sizeof(arr) / sizeof(arr[0]); // 8 / 4 = 2 ✗
/* arr 退化成了 int*,sizeof(arr)=8(64位系统) */
}
/* ✅ 解决办法:显式传长度 */
void test3(int *arr, size_t len) {
for (size_t i = 0; i < len; i++)
arr[i] = i;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
数组不退化的情况(三个例外):
sizeof(arr)的操作数是数组名&arr取整个数组的地址(得到int (*)[10]类型,不是int**)- 字符串字面量初始化字符数组:
char s[] = "hello";不会退化为char*
# 5. 三等价终极证明
# 5.1 arr[i] ↔ *(arr+i) 汇编证明
用 godbolt(gcc 14.1 -O0)来对比:
int access_by_subscript(int *arr, int i) {
return arr[i];
}
int access_by_pointer(int *arr, int i) {
return *(arr + i);
}
2
3
4
5
6
7
access_by_subscript:
mov eax, DWORD PTR [rdi + rsi*4] ; arr[i] → [rdi + i*4]
ret
access_by_pointer:
mov eax, DWORD PTR [rdi + rsi*4] ; *(arr+i) → [rdi + i*4]
ret
2
3
4
5
6
7
完全相同的指令:mov eax, [rdi + rsi*4]。x86 的 SIB 寻址模式直接把 base + index × scale 编码进一条指令——C 语言的 arr[i] 和 x86 的寻址模式在设计上天然契合。
# 5.2 i[arr] 的反直觉等价
疑惑:i[arr] 也能编译?这不奇怪吗?
论证——根据 C 标准的 E1[E2] ↔ (*((E1)+(E2))) 定义,+ 运算满足交换律:
i[arr] ↔ (*((i) + (arr))) /* 标准转换 */
↔ (*((arr) + (i))) /* 加法交换律 */
↔ arr[i] /* 标准转换逆 */
2
3
汇编验证——完全相同:
int test_i_arr(int *arr, int i) {
return i[arr]; /* ← 反直觉但合法 */
}
2
3
test_i_arr:
mov eax, DWORD PTR [rdi + rsi*4] ; i[arr] → arr[i]
ret
2
3
这个等价不依赖任何"编译器特殊处理"——它是 C 语法定义的直接推论。E1[E2] 的核心不是"E1 是数组、E2 是下标",而是"E1+E2 然后解引用"。
/* 这些全都是等价的 */
arr[2] // 最常见
2[arr] // 合法但反人类
*(arr + 2) // 指针形式
*(2 + arr) // 更反人类
2
3
4
5
实际应用场景——IOCCC(国际C语言混乱代码大赛)风格:
/* 故意混淆——全是合法的 C 代码 */
for (int i = 0; i < 10; i++)
2[arr + i] = i[arr + 2];
2
3
# 5.3 三等价可读性
| 写法 | 可读性 | 使用场景 |
|---|---|---|
arr[i] | ⭐⭐⭐ 最优 | 遍历数组的基本方式,默认选择 |
*(arr + i) | ⭐⭐ 可接受 | 当你想强调"这是指针运算"时 |
*(p++) | ⭐⭐ 可接受 | 紧凑的指针遍历(while(*p++)) |
i[arr] | ⭐ 极度不推荐 | IOCCC/炫技,生产代码永远不要用 |
Golden Rule:arr[i] 是给人类读的,*(arr+i) 是给编译器读的——编译器看它们完全一样。
# 6. 指针减法
# 6.1 指针减法语义
疑惑:q - p 返回的是什么?为什么不是字节数?
论证:
int arr[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int *p = &arr[2]; /* 指向 arr[2] */
int *q = &arr[7]; /* 指向 arr[7] */
ptrdiff_t diff = q - p;
printf("q - p = %td\n", diff); /* 输出 5 */
2
3
4
5
6
汇编:
; q - p
mov rax, rdi ; rax = q
sub rax, rsi ; rax = q - p(字节差 = 20)
sar rax, 2 ; rax = rax / 4(除以 sizeof(int))
; 差值的类型:sar(算术右移)→ 结果为有符号整数
2
3
4
5
指针减法 = (地址差) / sizeof(*p):
- 字节差:7×4 - 2×4 = 20 字节
- 元素个数差:20 / 4 = 5 个 int
结论:指针减法返回的是"两个指针之间隔了几个元素",不是字节数。这与 p + N 的语义对称——p + N 跳过 N 个元素,q - p 返回隔了几个元素。
# 6.2 无符号减法的陷阱
int arr[10];
int *p = &arr[2];
int *q = &arr[7];
/* 如果 p > q,p - q 返回负值 */
ptrdiff_t d1 = p - q; /* -5 */
ptrdiff_t d2 = q - p; /* 5 */
/* ⚠️ 常见错误:将差值赋给无符号类型 */
size_t bad = p - q; /* size_t 是无符号 → 变成一个巨大的正数! */
printf("%zu\n", bad); /* 输出:18446744073709551611 (2^64 - 5) */
2
3
4
5
6
7
8
9
10
11
教训:指针差值用 ptrdiff_t 接收,不要用 size_t——差值可能为负。
# 6.3 ptrdiff_t 的存在理由
ptrdiff_t(定义在 <stddef.h>):
typedef long ptrdiff_t; /* 64 位系统上的典型实现 */
它存在的理由是:两个指针的地址差可能超过 int 的表示范围。在大内存应用(几十 GB 的数据集)中,两个数组元素的指针差轻松上亿——int(32位有符号,最大约 21 亿)可能不够,ptrdiff_t 保证能容纳任意两个指向同一数组元素的指针之差。
# 7. 指针比较的合法边界
# 7.1 同数组比较
int arr[100];
int *p = &arr[10];
int *q = &arr[90];
if (p < q) { /* ✅ 合法——p 和 q 指向同一数组的不同元素 */ }
if (p == q) { /* ✅ 合法 */ }
if (p <= q) { /* ✅ 合法 */ }
int *end = arr + 100; /* 指向 arr 的"末尾+1" */
if (p < end) { /* ✅ 合法——可以和末尾+1 的指针比较 */ }
2
3
4
5
6
7
8
9
10
末尾+1 指针的特殊地位:C 标准允许指针指向数组最后一个元素的下一位置(one-past-the-end),仅用于比较,不可解引用。
# 7.2 跨对象比较
int a = 1, b = 2;
int *pa = &a;
int *pb = &b;
/* ⚠️ 未定义行为——a 和 b 是不同的独立对象 */
if (pa < pb) { /* UB!没有标准规定 a 和 b 在内存中的相对顺序 */ }
/* 编译器可以假定独立对象不重叠,
这意味着 pa < pb 的结果是任意的 */
2
3
4
5
6
7
8
9
为什么是 UB? 编译器可能把 a 和 b 分配到寄存器而不是栈上,或者根据优化重排它们在栈上的顺序。跨对象的指针比较结果是不可预测的。
# 7.3 NULL比较安全
int *p = malloc(sizeof(int));
if (p == NULL) { /* ✅ 与 NULL 比较始终合法 */ }
if (p != NULL) { /* ✅ */ }
/* free 后的安全实践 */
free(p);
p = NULL; /* 指向 NULL 的指针可以安全比较,不会被误用 */
2
3
4
5
6
7
8
唯一保证可移植的指针比较:任何指针与 NULL(即 (void*)0)的 == 和 != 比较。NULL 是 C 语言中唯一一个"跨对象有效"的特殊指针值。
# 8. 越界指针与UB
# 8.1 只可指向数组末尾+1
int arr[5] = {1, 2, 3, 4, 5};
int *p0 = arr; /* ✅ 指向 arr[0] */
int *p1 = arr + 1; /* ✅ 指向 arr[1] */
int *p4 = arr + 4; /* ✅ 指向 arr[4](最后一个元素) */
int *p5 = arr + 5; /* ✅ 指向 arr 的"末尾+1"——合法但不解引用 */
int *p6 = arr + 6; /* ❌ UB——越过了"末尾+1" */
int *p_1 = arr - 1; /* ❌ UB——越过了数组起始 */
2
3
4
5
6
7
8
9
"末尾+1"指针的合法用途——用作循环的终止条件(迭代器模式):
int arr[100];
int *end = arr + 100; /* 末尾+1——合法 */
for (int *p = arr; p < end; p++) { /* ✅ p 在合法范围内 */
*p = 0;
}
2
3
4
5
6
这就是 STL vector::end() 迭代器的灵感来源——end() 返回的就是"末尾+1"的指针。
# 8.2 编造指针值的完全UB
/* ❌ 凭空捏造一个指针值 */
int *fake = (int *)0x12345678; /* 这个地址不属于任何已分配对象 */
// *fake = 42; /* UB——大概率 SIGSEGV */
/* ❌ 对"末尾+1"指针解引用 */
int arr[5];
int *end = arr + 5;
// *end = 0; /* ❌ UB——不能解引用末尾+1 */
/* ❌ 算术运算产生越界指针,即使不立即使用也是 UB */
int *bad = arr - 1; /* UB——指针的"形成"本身就是 UB */
2
3
4
5
6
7
8
9
10
11
关键点:不仅解引用越界指针是 UB——生成一个越界的指针本身就是 UB(除了末尾+1 这个唯一的例外)。编译器可以对 UB 做任何假设,包括"这个分支永远不会被执行"。
# 8.3 空指针检删除
这是 C 语言中因 UB 优化导致的最著名 bug 之一:
void process(int *p) {
int x = *p; /* ← 解引用 p */
if (p == NULL) { /* ← 编译器:你刚才已经解引用了 p,
如果是 NULL 就是 UB,
所以 UB 不会发生
→ p != NULL 恒成立
→ 这个 if 是死代码 */
return; /* ← 被优化删除! */
}
do_something(p);
}
2
3
4
5
6
7
8
9
10
11
12
13
在 -O2 下,if (p == NULL) 被完全删除。因为编译器推理链条为:
*p解引用了 p- 如果 p 是 NULL,
*p就是 UB - 编译器有权假设 UB 不会发生
- 因此 p 不可能是 NULL
- 因此
p == NULL恒为假 → 删除整个 if 块
写代码的正确姿势:
void process_fixed(int *p) {
if (p == NULL) { /* ✅ 先检查 */
return;
}
int x = *p; /* ✅ 检查通过后再解引用 */
do_something(p);
}
2
3
4
5
6
7
这个案例展示了"C 语言中 UB 不是'实现定义'——它是彻底地把整个执行路径标记为'不可能'"。
# 9. void算术禁令
# 9.1 为何 void* 不可做算术
疑惑:void* 既然也是 8 字节的指针,为什么不能 p + 1?
论证——void 是不完整类型,sizeof(void) 无意义:
void *vp = malloc(100);
// vp + 1; /* ❌ 编译错误:arithmetic on pointer to void */
// vp++; /* ❌ 编译错误 */
/* C 标准 §6.5.6:void 是不完整类型,
sizeof(*vp) = sizeof(void) → 没有定义
→ 编译器不知道步长 → 拒绝运算 */
2
3
4
5
6
7
8
汇编层面的理由——如果强行让 void* 算术有效,步长应该是多少?
; 假设允许 void* + 1,哪条指令是对的?
add rax, 1 ; 按字节 → 但 void 不是 char
add rax, 4 ; 按 int → 但 void 不是 int
add rax, ? ; 没有正确答案
2
3
4
# 9.2 GNU C 扩展的危险宽容
GNU C(-std=gnu11)允许对 void* 做算术——默认步长当 1(等同于 char*):
/* GNU C 扩展下这能编译通过! */
void *vp = malloc(100);
vp = vp + 16; /* GNU C: 等同于 (void*)((char*)vp + 16) —— 前进 16 字节 */
/* 这是非标准行为!用 -Wpedantic 会报警 */
2
3
4
5
危险场景(回到第 1 章的固件 bug):
void *body = (void *)(hdr + 1);
/* GNU C 下这行能编译——但语义是"前进 len 字节" */
uint8_t *sig = body + len;
/* 标准 C 下这行编译失败——强制你用 (uint8_t*)body + len */
2
3
4
5
GNU C 的"方便"让第 1 章的 bug 更难发现——因为代码能编译通过,类型错误被隐藏了。
避免方式:
gcc -std=c11 -pedantic-errors # 严格 C11,禁用 GNU 扩展
# 9.3 用 char* 替代 void* 做字节级操作
/* ❌ 依赖 GNU C 扩展——不可移植 */
void *buf = malloc(100);
void *pos = buf + 16; /* GNU C only */
/* ✅ 标准写法——用 char* 做字节级算术 */
void *buf = malloc(100);
char *pos = (char *)buf + 16; /* 显式转为 char* */
uint8_t *pos2 = (uint8_t *)buf + 16; /* 或用 uint8_t*——语义相同 */
/* ✅ 标准写法——用结构体指针做高级操作 */
PktHeader *hdr = (PktHeader *)buf;
uint8_t *body = (uint8_t *)(hdr + 1); /* 过 header,指向 payload */
uint16_t len = hdr->length;
uint8_t *sig = body + len; /* 指向签名 */
2
3
4
5
6
7
8
9
10
11
12
13
14
黄金法则:
- 用
char*或uint8_t*做字节级指针运算(标准 C 保证 sizeof(char)==1) - 永远不要让
void*参与算术(即使你的编译器能过——下一个编译器可能不能) - 结构体指针运算用于按元素遍历(
hdr+1自动适配结构体大小)
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的固件解析器,七个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① p+1 为什么 int* +1 加了 4 字节? | 第 3.1:编译器把 p+1 翻译为 p地址 + 1 × sizeof(*p),int 的 sizeof 是 4 |
| ② arr[i] 和 *(arr+i) 是同一个东西吗? | 第 4.1/5.1:C 标准定义 E1[E2] 就是 (*((E1)+(E2))),汇编完全一致 |
| ③ i[arr] 合法吗? | 第 5.2:合法——+ 交换律 + 标准定义的直接推论,汇编与 arr[i] 相同 |
| ④ q-p 为什么返回元素个数? | 第 6.1:编译器生成 (地址差) / sizeof(*p) 的机器码,÷4 得到元素数 |
| ⑤ 不同数组的指针可以比较吗? | 第 7.2:不可以——独立对象的内存位置是不确定的,比较结果是 UB |
| ⑥ 末尾+1 的指针合法吗? | 第 8.1:可以持有(用于循环终止),但不可解引用——超过末尾+1 即 UB |
| ⑦ 为什么 void* 不能 p+1? | 第 9.1:sizeof(void) 无定义,编译器不知道步长——GNU C 扩展破坏了这条规则 |
这个固件的类型安全 rewrite:
/* 修复后——类型安全的指针运算 */
void parse_firmware_packet_fixed(void *raw_data, size_t total_len) {
uint8_t *buf = (uint8_t *)raw_data; /* ← 字节级基指针 */
PktHeader *hdr = (PktHeader *)buf; /* ← 重叠在 buf 上 */
/* 验证总长度 */
if (total_len < sizeof(PktHeader)) return;
uint16_t len = hdr->length;
if (len > MAX_PAYLOAD || total_len < sizeof(PktHeader) + len + 4)
return;
/* 类型安全的指针运算 */
uint8_t *body = buf + sizeof(PktHeader); /* sizeof 自动适配 v2/v3 */
uint8_t *signature = body + len; /* char* 步长=1——明确这是字节偏移 */
uint32_t sig = *(uint32_t *)signature;
/* 校验签名 */
if (sig != expected_sig) {
printf("固件签名校验失败\n");
return;
}
/* 写入 Flash... */
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
关键改进:
| 原代码 | 问题 | 修复 |
|---|---|---|
void *body = (void *)(hdr+1) | hdr+1 的结构体步长是对的,但 body 丢了类型 | uint8_t *body = buf + sizeof(PktHeader) 显式用字节偏移 |
body + len | void* 算术依赖 GNU C 扩展 | body + len 在 uint8_t* 上步长=1,标准 C |
| 没有边界检查 | len 可能被污染,越界读 | if (len > MAX_PAYLOAD \|\| total_len < ...) 三重检查 |
*(uint32_t *)signature | 未检查 signature 是否在合法范围内 | 边界检查保证 signature 在 raw_data 内 |
# 10.2 指针运算生涯
以一个二进制协议解析为例,展示指针运算的全套用法:
#include <stdint.h>
#include <stddef.h>
#include <string.h>
typedef struct __attribute__((packed)) {
uint16_t id;
uint16_t flags;
uint32_t timestamp;
} RecordHeader;
/* 从原始字节流中提取所有 Record 的时间戳 */
size_t extract_timestamps(const uint8_t *data, size_t data_len,
uint32_t *out, size_t out_capacity) {
const uint8_t *pos = data; /* char* 步长=1 遍历字节 */
const uint8_t *end = data + data_len; /* 末尾+1:安全指针 */
size_t count = 0;
while (pos + sizeof(RecordHeader) <= end) { /* ← 指针比较:边界保护 */
const RecordHeader *hdr = (const RecordHeader *)pos;
if (count >= out_capacity) break;
out[count++] = hdr->timestamp; /* ← 下标:读时间戳 */
pos += sizeof(RecordHeader); /* ← 指针向前:跳过一个记录 */
/* pos 始终在合法内存内(while 条件已检查) */
}
return count;
}
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
这个函数展示了指针运算的五项安全规范:
pos + sizeof(RecordHeader) <= end:在跃进之前检查还有没有空间(末尾+1 比较)(const RecordHeader *)pos:类型安全的强转——从字节流到结构体out[count++]:下标操作——等价于*(out + count)pos += sizeof(RecordHeader):char*步长=1,显式写字节数pos和end:始终在data ~ data+data_len范围内
# 10.3 设计哲学回扣
哲学 1:类型化的步长——让代码自然表达意图
p + 1 跳过 1 个元素而非 1 个字节,是 C 语言对"遍历数组"场景的最大优化。程序员只需想"往前走 3 个元素"(p + 3),不关心这 3 个元素是 12 字节还是 24 字节。类型系统在编译期帮我们算好字节数,零运行时开销。好的抽象让你用领域的语言思考,而不是用实现的语言思考。
*哲学 2:语法糖也是糖——arr[i] 和 (arr+i) 是同一个东西
C 语言把 arr[i] 定义为 *(arr+i) 的语法糖——这不是"差不多",是 C 标准白纸黑字定义的等价。这个设计让程序员既可以用简洁的下标形式(arr[i]),也可以在需要强调指针语义时用 *(p+i)。两种风格编译为完全相同的机器码——语法糖不产生额外代价。
哲学 3:限制即安全——void 的算术禁令是防线不是限制*
void* 禁止算术运算,不是 C 标准委员会忘了加,而是刻意设计的类型安全底线。一旦允许 void* + N,就永远无法从类型上区分"N 是字节数还是元素数"。GNU C 的宽容违背了这条原则——它让代码"看起来"能编译,但语义可能和程序员预期不同。好的限制让你在编译期就发现错误,而不是在生产环境 SIGSEGV。
哲学 4:UB 不是可以"碰运气"的东西——编译器把它当成死代码
很多人以为 UB 是"做随机的事"——实际上现代编译器把 UB 当作"这个情况不可能发生"的断言,并基于此做激进优化。解引用 NULL 后检查 NULL——编译器会直接删掉检查。越过末尾+1 的指针——编译器可能将其用作越界优化的依据。UB 不是运行时的赌博,它是编译期的"删除令"。
# 10.4 指针运算速查
| 操作 | 含义 | 字节偏移 | 合法性条件 |
|---|---|---|---|
p + N | 向前跳 N 个元素 | N × sizeof(*p) | p 在数组内,p+N 不超过末尾+1 |
p - N | 向后跳 N 个元素 | N × sizeof(*p) | p 在数组内,p-N 不越数组首 |
p++ / ++p | 前进 1 个元素 | sizeof(*p) | 同 p+1 |
q - p | 两指针间的元素个数 | (q-p) / sizeof(*p) | p 和 q 指向同一数组 |
p > q | p 在 q 的高地址方? | — | p 和 q 同数组(或 == NULL) |
p[i] | 等价于 *(p+i) | i × sizeof(*p) | p+i 不越界 |
i[p] | 等价于 p[i] | 同上 | 合法但不要用 |
p = NULL | 空指针 | — | 始终合法 |
void* + N | — | — | 非法(标准C) |
p = (int*)0x1234 | — | — | UB——除非地址有效 |
指针运算安全自检清单:
1. p 指向某个已分配对象吗? ← 不是 → UB
2. p + N 后的指针还在数组边界内吗(含末尾+1)? ← 不是 → UB
3. q - p 的 q 和 p 指向同一数组吗? ← 不是 → UB
4. 指针类型是 void* 吗? ← 是 → 禁止算术
5. 如果能用 arr[i] 而不是 *(arr+i),优先 arr[i] ← 可读性优先
2
3
4
5
下一篇:指针运算让我们可以在数组上自由跳转,下一步进入 05.函数指针与回调机制——把函数也当成数据,用指针指向它、传递它、调用它。qsort 的源码里,
base + i × size这一行就是本章指针运算+下一篇函数指针的完美合体。