编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 指针运算四象限全景
          • 2.2 指针类型化
        • 3. 类型决定步长
          • 3.1 p+1 的汇编等价形式
          • 3.2 步长对比
          • 3.3 结构体步长
          • 3.4 p++ 与 ++p 的语义与汇编差异
        • 4. 下标即指针运算
          • 4.1 arr[i] 的完整翻译链
          • 4.2 形参丢大小
          • 4.3 sizeof(arr)/sizeof(arr[0]) 的失效场景
        • 5. 三等价终极证明
          • 5.1 arr[i] ↔ *(arr+i) 汇编证明
          • 5.2 i[arr] 的反直觉等价
          • 5.3 三等价可读性
        • 6. 指针减法
          • 6.1 指针减法语义
          • 6.2 无符号减法的陷阱
          • 6.3 ptrdiff_t 的存在理由
        • 7. 指针比较的合法边界
          • 7.1 同数组比较
          • 7.2 跨对象比较
          • 7.3 NULL比较安全
        • 8. 越界指针与UB
          • 8.1 只可指向数组末尾+1
          • 8.2 编造指针值的完全UB
          • 8.3 空指针检删除
        • 9. void算术禁令
          • 9.1 为何 void* 不可做算术
          • 9.2 GNU C 扩展的危险宽容
          • 9.3 用 char 替代 void 做字节级操作
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 指针运算生涯
          • 10.3 设计哲学回扣
          • 10.4 指针运算速查
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

指针运算底层真相

# 04.指针运算底层真相

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 指针运算四象限全景
    • 2.2 指针类型化
  • 3. 类型决定步长
    • 3.1 p+1 的汇编等价形式
    • 3.2 步长对比
    • 3.3 结构体步长
    • 3.4 p++ 与 ++p 的语义与汇编差异
  • 4. 下标即指针运算
    • 4.1 arr[i] 的完整翻译链
    • 4.2 形参丢大小
    • 4.3 sizeof(arr)/sizeof(arr[0]) 的失效场景
  • 5. 三等价终极证明
    • 5.1 arr[i] ↔ *(arr+i) 汇编证明
    • 5.2 i[arr] 的反直觉等价
    • 5.3 三等价可读性
  • 6. 指针减法
    • 6.1 指针减法语义
    • 6.2 无符号减法的陷阱
    • 6.3 ptrdiff_t 的存在理由
  • 7. 指针比较的合法边界
    • 7.1 同数组比较
    • 7.2 跨对象比较
    • 7.3 NULL比较安全
  • 8. 越界指针与UB
    • 8.1 只可指向数组末尾+1
    • 8.2 编造指针值的完全UB
    • 8.3 空指针检删除
  • 9. void算术禁令
    • 9.1 为何 void* 不可做算术
    • 9.2 GNU C 扩展的危险宽容
    • 9.3 用 char* 替代 void* 做字节级操作
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 指针运算生涯
    • 10.3 设计哲学回扣
    • 10.4 指针运算速查

# 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... */
}
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

现象:

  • 固件版本 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        ← ← 签名地址?不对!
1
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
1

但期望的不是这样! 期望是 body 往后跳 len 个字节到有效载荷的末尾,然后签名在那儿。但实际代码里——

再细看 Line B:

uint8_t *signature = body + len;   /* body 是 void* */
1

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 字节 */
1
2
3
4
5
6
7

而上层调用者(没跟着更新)仍然用老的偏移量来构建数据包,导致 hdr->length 读到的不是长度,而是被 timestamp 覆盖后的垃圾值。但这不是指针运算的问题——真正的指针运算 bug 在另一个地方。

重新审视 Line A:

void *body = (void *)(hdr + 1);  /* hdr 是 PktHeader* */
1

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——结合两个事实:

  1. body 是 void*(丢失了类型信息)
  2. 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 章
1
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
1
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 → 唯一跨对象安全比较
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

指针运算的黄金规则:

一切指针算术的字节偏移量 = 操作数 × sizeof(*指针)

p ± N 的汇编形式 = p 的地址 ± N × sizeof(*p)
1
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 个字节——乱成一锅粥 */
1
2
3
4
5

如果 C 语言退化为"指针运算全按字节算":

// 假设指针运算是字节级(不乘以类型大小)
int arr[5];
int *p = arr;

p + 1;   // 只前进 1 字节 → 指向 arr[0] 的中间!
         // 你想跳过 1 个 int? 手动写 p + 4
         // 你想跳过 5 个 int? 手动写 p + 20
1
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 元素"
1
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 */
}
1
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 字节
1
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* 的步长)
// 同一个地址,不同的指针类型 → 不同的步长
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13

汇编(gcc -O2):

; p + 1
lea    rax, [rdi + 16]         ; 16 = sizeof(Record)
1
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
1
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
1
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 that E1[E2] is identical to (*((E1)+(E2))).

用代码验证:

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

int x = arr[2];      // 30
int y = *(arr + 2);   // 30
1
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
1
2
3
4
5
6
7
8
9

在 -O0 下,arr[2] 直接产生 [基址+偏移] 指令,*(arr+2) 多了一步 lea。但在 -O2 下,两者编译为完全相同的指令:

; 两者都编译为:
mov    eax, DWORD PTR [rbp-24]
1
2

结论:arr[i] 和 *(arr+i) 在语义上完全等价,在优化的编译器中产生完全相同的机器码——下标只是语法糖,指针运算才是本质。

# 4.2 形参丢大小

void func(int arr[5]) {   /* 看似传了一个 5 元素数组 */
    printf("%zu\n", sizeof(arr));  /* 输出 8(指针大小),不是 20! */
}
1
2
3

C 语言标准规定:数组作为函数参数时,自动退化为指向首元素的指针。函数签名:

void func(int arr[5]);    /* 等价于 */
void func(int *arr);      /* 数组长度 5 是纯粹的注释,不参与类型系统 */
1
2

退化后 sizeof(arr) 的汇编:

; sizeof(arr) 在函数体内——arr 是指针
mov    eax, 8              ; 64 位系统的指针大小
1
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

数组不退化的情况(三个例外):

  1. sizeof(arr) 的操作数是数组名
  2. &arr 取整个数组的地址(得到 int (*)[10] 类型,不是 int**)
  3. 字符串字面量初始化字符数组: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);
}
1
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
1
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]              /* 标准转换逆 */
1
2
3

汇编验证——完全相同:

int test_i_arr(int *arr, int i) {
    return i[arr];              /* ← 反直觉但合法 */
}
1
2
3
test_i_arr:
    mov    eax, DWORD PTR [rdi + rsi*4]   ; i[arr] → arr[i]
    ret
1
2
3

这个等价不依赖任何"编译器特殊处理"——它是 C 语法定义的直接推论。E1[E2] 的核心不是"E1 是数组、E2 是下标",而是"E1+E2 然后解引用"。

/* 这些全都是等价的 */
arr[2]      // 最常见
2[arr]      // 合法但反人类
*(arr + 2)   // 指针形式
*(2 + arr)   // 更反人类
1
2
3
4
5

实际应用场景——IOCCC(国际C语言混乱代码大赛)风格:

/* 故意混淆——全是合法的 C 代码 */
for (int i = 0; i < 10; i++)
    2[arr + i] = i[arr + 2];
1
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 */
1
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(算术右移)→ 结果为有符号整数
1
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) */
1
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 位系统上的典型实现 */
1

它存在的理由是:两个指针的地址差可能超过 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 的指针比较 */ }
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 的结果是任意的 */
1
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 的指针可以安全比较,不会被误用 */
1
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——越过了数组起始 */
1
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;
}
1
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 */
1
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

在 -O2 下,if (p == NULL) 被完全删除。因为编译器推理链条为:

  1. *p 解引用了 p
  2. 如果 p 是 NULL,*p 就是 UB
  3. 编译器有权假设 UB 不会发生
  4. 因此 p 不可能是 NULL
  5. 因此 p == NULL 恒为假 → 删除整个 if 块

写代码的正确姿势:

void process_fixed(int *p) {
    if (p == NULL) {     /* ✅ 先检查 */
        return;
    }
    int x = *p;          /* ✅ 检查通过后再解引用 */
    do_something(p);
}
1
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) → 没有定义
   → 编译器不知道步长 → 拒绝运算 */
1
2
3
4
5
6
7
8

汇编层面的理由——如果强行让 void* 算术有效,步长应该是多少?

; 假设允许 void* + 1,哪条指令是对的?
add    rax, 1    ; 按字节 → 但 void 不是 char
add    rax, 4    ; 按 int → 但 void 不是 int
add    rax, ?    ; 没有正确答案
1
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 会报警 */
1
2
3
4
5

危险场景(回到第 1 章的固件 bug):

void *body = (void *)(hdr + 1);

/* GNU C 下这行能编译——但语义是"前进 len 字节" */
uint8_t *sig = body + len;
/* 标准 C 下这行编译失败——强制你用 (uint8_t*)body + len */
1
2
3
4
5

GNU C 的"方便"让第 1 章的 bug 更难发现——因为代码能编译通过,类型错误被隐藏了。

避免方式:

gcc -std=c11 -pedantic-errors    # 严格 C11,禁用 GNU 扩展
1

# 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;              /* 指向签名 */
1
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... */
}
1
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;
}
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

这个函数展示了指针运算的五项安全规范:

  1. pos + sizeof(RecordHeader) <= end:在跃进之前检查还有没有空间(末尾+1 比较)
  2. (const RecordHeader *)pos:类型安全的强转——从字节流到结构体
  3. out[count++]:下标操作——等价于 *(out + count)
  4. pos += sizeof(RecordHeader):char* 步长=1,显式写字节数
  5. 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]  ← 可读性优先
1
2
3
4
5

下一篇:指针运算让我们可以在数组上自由跳转,下一步进入 05.函数指针与回调机制——把函数也当成数据,用指针指向它、传递它、调用它。qsort 的源码里,base + i × size 这一行就是本章指针运算+下一篇函数指针的完美合体。

上次更新: 2026/06/11, 08:54:53
指针本质与多级解引
函数指针与回调机制

← 指针本质与多级解引 函数指针与回调机制→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式