编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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 指针为什么是C的灵魂
        • 3. 地址与类型
          • 3.1 指针即数字
          • 3.2 类型决定宽度
          • 3.3 指针运算步长
          • 3.4 sizeof与指针的类型依赖性
        • 4. 解引用的汇编翻译
          • 4.1 *p 的完整汇编生命周期
          • 4.2 写操作的完整路径
          • 4.3 为什么要区分 int 与 char
        • 5. & 与 * 的对称世界
          • 5.1 对称性证明
          • 5.2 & 和 & 的经典搅局
          • 5.3 可修改门牌号
        • 6. 多级指针逐层闯关
          • 6.1 int* 的内存四层模型
          • 6.2 为什么需要多级指针
          • 6.3 多级指针与二维数据
          • 6.4 常见多级指针误用
        • 7. void* 类型擦除的艺术
          • 7.1 void* 的本质
          • 7.2 不可对void*做算术运算
          • 7.3 void设计哲学
        • 8. 指针的安全边界
          • 8.1 野指针三宗罪
          • 8.2 空指针解引用
          • 8.3 安全指针编程规范
        • 9. 数据结构应用
          • 9.1 链表连接
          • 9.2 函数指针表
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 指针访问对比
          • 10.3 设计哲学回扣
          • 10.4 指针速查表
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

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

指针本质与多级解引

# 03.指针本质与多级解引

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 指针的九宫格全景
    • 2.2 指针为什么是C的灵魂
  • 3. 地址与类型
    • 3.1 指针即数字
    • 3.2 类型决定宽度
    • 3.3 指针运算步长
    • 3.4 sizeof与指针的类型依赖性
  • 4. 解引用的汇编翻译
    • 4.1 *p 的完整汇编生命周期
    • 4.2 写操作的完整路径
    • 4.3 为什么要区分 int* 与 char*
  • 5. & 与 * 的对称世界
    • 5.1 对称性证明
    • 5.2 &* 和 *& 的经典搅局
    • 5.3 可修改门牌号
  • 6. 多级指针逐层闯关
    • 6.1 int*** 的内存四层模型
    • 6.2 为什么需要多级指针
    • 6.3 多级指针与二维数据
    • 6.4 常见多级指针误用
  • 7. void* 类型擦除的艺术
    • 7.1 void* 的本质
    • 7.2 不可对void*做算术运算
    • 7.3 void设计哲学
  • 8. 指针的安全边界
    • 8.1 野指针三宗罪
    • 8.2 空指针解引用
    • 8.3 安全指针编程规范
  • 9. 数据结构应用
    • 9.1 链表连接
    • 9.2 函数指针表
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 指针访问对比
    • 10.3 设计哲学回扣
    • 10.4 指针速查表

# 1. 案例引入

# 1.1 一段崩在哪

看一段在网络封装库里的代码——这条代码被 code review 了两次都说没问题,在上线第三天后,某个客户报告"SDK 随机崩溃":

// packet_builder.c —— 网络包构造器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

typedef struct {
    uint16_t magic;
    uint16_t length;
    uint32_t seq_id;
    char     payload[];
} Packet;

Packet *build_packet(const char *data, size_t data_len) {
    size_t total_size = sizeof(Packet) + data_len;
    Packet *pkt = malloc(total_size);
    if (!pkt) return NULL;

    pkt->magic  = 0xBEEF;
    pkt->length = total_size;
    pkt->seq_id = 0;
    memcpy(pkt->payload, data, data_len);

    return pkt;
}

/* 上层:管理数据包的双重链表 */
typedef struct {
    Packet *pkt;          /* ← 指向堆上的 Packet */
    void   *next;         /* ← 故意用 void* 存指针 */
    void   *prev;
} PacketNode;

void link_nodes(PacketNode *a, PacketNode *b) {
    a->next = b;                   /* ← Line A */
    b->prev = a;                   /* ← Line B */
}

PacketNode *get_next(PacketNode *node) {
    return (PacketNode *)node->next;   /* ← Line C: void* → PacketNode* */
}

int main(void) {
    PacketNode n1 = {0}, n2 = {0}, n3 = {0};

    n1.pkt = build_packet("hello", 5);
    n2.pkt = build_packet("world", 5);
    n3.pkt = build_packet("crash", 5);

    link_nodes(&n1, &n2);
    link_nodes(&n2, &n3);

    /* 遍历——在 n1→n2→n3 之间跳转 */
    PacketNode *cur = &n1;
    for (int i = 0; i < 5; i++) {
        Packet *pkt = cur->pkt;
        printf("payload len: %d\n", pkt->length);  /* ← 崩溃点! */
        cur = get_next(cur);
    }
    return 0;
}
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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

现象:

  • 前两次循环正常(n1, n2 的 payload 打印正确)
  • 第二次 get_next(n2) 返回后,cur->pkt 解引用崩溃——pkt 指向了一个完全无效的地址 0x00000000_0000BEEF
  • 崩溃地址的低 16 位恰好是 0xBEEF —— Packet.magic 的值

第一反应:n3 没被关联?但 link_nodes(&n2, &n3) 明明调用了。为什么会拿到一个看起来像 Packet 内容的随机指针?

# 1.2 顺藤摸到根因

在 gdb 里用 watch 追踪 n3.prev 的变化:

(gdb) p &n2
$1 = (PacketNode *) 0x7ffd12345620
(gdb) p &n2.next
$2 = (void **) 0x7ffd12345630   ← n2.next 的地址
(gdb) x/1gx 0x7ffd12345630
0x7ffd12345630: 0x00007ffd12345600   ← n2.next 指向 n3 —— 正常

(gdb) c
(gdb) p/x n3.pkt
$3 = 0x00007ffd56789000    ← n3.pkt 是合法堆地址

(gdb) x/4bx n3.pkt
0x00007ffd56789000: 0xef 0xbe 0x05 0x00   ← Packet 内容 (little-endian)

(gdb) x/1gx &n3.pkt
0x7ffd12345640: 0x00007ffd56789000   ← n3.pkt = 合法指针值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

n3.pkt 本身是合法的。但当代码跳到第三次循环时:

(gdb) p cur
$4 = (PacketNode *) 0x7ffd12345638   ← 这个值看起来不太对...
(gdb) x/8bx cur
0x7ffd12345638: 0x00 0x90 0x78 0x56 0xfd 0x7f 0x00 0x00
                                      ↑
                    这是 n3.pkt 的低位字节——cur 指向了 n3.pkt 的内存!
1
2
3
4
5
6

根因浮出水面:get_next(n2) 返回了 n3(正确),但 cur 的地址出了问题。进一步追踪发现——

n2.next = &n3;     // void* 存了 PacketNode* 的值

// 但 n2.next 和 n3.pkt 在内存里紧挨着!
//  n2.next  在 [栈地址 + 0]
//  n3.pkt   在 [栈地址 + 8](因为栈向下生长,n3 的低地址是 pkt)
1
2
3
4
5

当 get_next(cur) 返回 (PacketNode*)n2.next 后,cur->pkt 指向的是 n2.next 偏移后的一个位置——但这是 n3 结构体中 pkt 字段的位置,其内容恰好被 Packet 的 magic 0xBEEF 覆盖了!

简化而言:void* 的类型擦除 + 紧凑栈布局 + magic 数字巧合,导致了读到一个被 0xBEEF 污染的"幽灵指针"。这是一个关于"指针即数字,但类型决定了编译器如何看待这个数字"的经典事故。

这段代码藏着 7 个关于指针本质的深度问题:

① int* 和 char* 在汇编层面有什么区别?                     → 第 3 章
② *p 到底是怎么变成一段汇编指令的?                         → 第 4 章
③ & 和 * 是对称的吗?能互相抵消吗?                          → 第 5 章
④ int*** 的内存是什么样子的?                               → 第 6 章
⑤ void* 失去了什么?为什么不能对 void* 做 p++?               → 第 7 章
⑥ 野指针到底有多危险?能在设备上造成什么后果?                → 第 8 章
⑦ 指针在真实数据结构(链表、函数表)中如何体现其威力?       → 第 9 章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个案例就是本篇的主线。我们从"指针到底是什么"出发,从汇编级别分析 *p 是怎么变成 CPU 指令的,再到多级指针和 void*,最后在第 10 章回到这个案例,展示"类型安全地使用 void*"的正确姿势。

本篇路线:

指针的九宫格全景 (第 2 章)
   ↓
地址+类型的双重身份 (第 3 章) ─→ 解开①
   ↓
解引用的汇编翻译 (第 4 章)     ─→ 解开②
   ↓
& 和 * 的对称世界 (第 5 章)    ─→ 解开③
   ↓
多级指针逐层闯关 (第 6 章)     ─→ 解开④
   ↓
void* 类型擦除 (第 7 章)        ─→ 解开⑤
   ↓
安全边界 (第 8 章)             ─→ 解开⑥
   ↓
实际应用→综合案例 (第 9-10 章) ─→ 解开⑦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:第 01-02 篇讲了"数据在地址空间的哪一段""栈和堆怎么工作",本篇把 C 语言最核心的抽象——指针——拆到每一条 CPU 指令。后续的所有文章(数组与指针的纠葛、函数指针、结构体指针),都在本篇的基础上展开。

# 2. 架构概览

# 2.1 指针的九宫格全景

把 C 语言指针家族铺开成一张全景图:

         指向什么类型?
         ┌────────────┬────────────┬────────────┬────────────┐
         │   数据      │   函数      │    void     │    struct  │
         │   int*     │ int(*)(int)│   void*    │  Node*    │
单级指针 ─┤────────────│────────────│────────────│────────────│
多级指针 │  int**     │  --         │  void**   │  Node**   │
         │  int***    │  --         │  void***  │  --       │
         └────────────┴────────────┴────────────┴────────────┘

指针参与的四种角色:
┌──────────────┬────────────────────────────────┐
│ 浅层角色      │ 存储另一个变量的地址              │
│ 中层角色      │ 直接与内存对话,绕过变量名         │
│ 深层角色      │ 实现数据结构(链表/树/图)         │
│ 架构角色      │ 函数指针实现策略模式/回调/插件     │
└──────────────┴────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

指针的核心二元组——无类型不成指针:

指针 = 地址(数字) + 类型(编译器元数据)
  ↑                  ↑
  运行时存在         仅编译期存在
  占 8 字节(64位)    不占额外内存
1
2
3
4

这就是指针最深刻的设计:类型是编译期的幻觉,运行时只剩下一个 64 位无符号整数。

# 2.2 指针为什么是C的灵魂

疑惑:Java/Python 也有引用/对象指针,为什么说指针是 C 语言的灵魂?

论证:

// Java:一切都是引用
int[] arr = new int[10];
arr[0] = 5;  // 引用解引用——透明
1
2
3
// C:指针是显式的、裸露的
int *arr = malloc(10 * sizeof(int));
arr[0] = 5;            // 等价于 *(arr + 0) = 5
*(arr + 1) = 10;       // 可以手动计算偏移
*((char*)arr + 4) = 0; // 甚至可以用不同类型的指针"偷窥"同一块内存
1
2
3
4
5

C 指针的独特之处:

  1. 透明性——你看到一个指针,就看到了地址。printf("%p", p) 直接在屏幕上打印它的值。Java 引用不能直接打印为内存地址。

  2. 自由运算——p + 1 跳到下一个元素,p - 1 回到上一个。这种"把内存当数组"的能力,是操作系统、驱动程序、嵌入式开发的根基。

  3. 类型重解释——*(int*)p 和 *(char*)p 读取的是同一块物理内存的不同解释——这就是 C 语言"与硬件对话"的终极能力(也是危险的来源)。

  4. 反向论证——如果没有指针,C 语言就没有动态分配(malloc 的返回值就是一个指针)、没有链表、没有函数回调、没有 mmap。指针是 C 语言的血液循环系统——没有它,数据只能在栈上短暂存活,永远无法在函数间自由流动。

结论:C 的指针之所以是"灵魂",是因为它把计算机最底层的两样东西——地址和数据宽度——直接暴露给了程序员。Java/Python 把这两层封装在引用/对象背后,让编程更安全,但也让你无法在硬件层面进行操作。

# 3. 地址与类型

# 3.1 指针即数字

疑惑:指针的值到底是多少?为什么 printf("%p", p) 打出来的是 0x7ffd... 这样的大数字?

论证:

int  x = 42;
int *p = &x;

printf("x 的地址: %p\n",  p);        // 0x7ffd12345678
printf("p 的值(十进制): %lu\n", (unsigned long)p);  // 140725920000000
printf("p 自身在栈上的地址: %p\n", &p);             // 0x7ffd12345670
1
2
3
4
5
6

内存布局:

栈 (高地址 → 低地址)
  ┌─────────────────┐
  │ x = 42           │ ← 地址 0x7ffd12345678
  ├─────────────────┤
  │ p = 0x7ffd...678 │ ← 地址 0x7ffd12345670
  └─────────────────┘

  p 这个变量本身占 8 字节,它的值(内容)是 x 的地址
  *p 等价于"以 p 的值为地址,去读那 4 个字节,按 int 解释"
1
2
3
4
5
6
7
8
9

结论:指针本身是一个 64 位无符号整数(在 64 位系统上),这个整数的值是另一个变量的地址。类型信息(int* 中的 int)在运行时不存在——它只存在于编译器的符号表中,用于生成正确的访存指令。

# 3.2 类型决定宽度

int    x = 0x12345678;
int   *pi = &x;
char  *pc = (char *)&x;
short *ps = (short *)&x;
1
2
3
4

假设 x 在地址 0x1000,并假设小端序(x86 默认):

         0x1000  0x1001  0x1002  0x1003
内容:   [0x78]  [0x56]  [0x34]  [0x12]

*pi → 从 0x1000 读 4 字节,按 int 解释 → 0x12345678
*pc → 从 0x1000 读 1 字节,按 char 解释 → 0x78
*ps → 从 0x1000 读 2 字节,按 short 解释 → 0x5678
1
2
3
4
5
6

汇编层面:

; *pi (int* 解引用)
mov    eax, DWORD PTR [pi]      ; DWORD = 4 字节

; *pc (char* 解引用)
movzx  eax, BYTE PTR [pc]       ; BYTE = 1 字节,高位零扩展

; *ps (short* 解引用)  
movsx  eax, WORD PTR [ps]       ; WORD = 2 字节,有符号扩展
1
2
3
4
5
6
7
8

同一个地址 0x1000,不同的指针类型产生不同的汇编指令——读不同长度的数据。这就是类型在指针中的作用:它告诉编译器"解引用时读几字节,怎么解释这些字节"。

# 3.3 指针运算步长

int    *pi = (int *)0x1000;
char   *pc = (char *)0x1000;
double *pd = (double *)0x1000;

printf("pi + 1 = %p\n", pi + 1);   // 0x1004  (跳过 1 个 int   = 4 字节)
printf("pc + 1 = %p\n", pc + 1);   // 0x1001  (跳过 1 个 char  = 1 字节)
printf("pd + 1 = %p\n", pd + 1);   // 0x1008  (跳过 1 个 double= 8 字节)
1
2
3
4
5
6
7

汇编层面:

; pi + 1
lea    rax, [pi + 4]          ; 加 sizeof(int) = 4

; pc + 1
lea    rax, [pc + 1]          ; 加 sizeof(char) = 1

; pd + 1
lea    rax, [pd + 8]          ; 加 sizeof(double) = 8
1
2
3
4
5
6
7
8

结论:p + n 的汇编形式是 p + n × sizeof(*p)。类型决定了乘数——这是一个纯编译期的魔法。

# 3.4 sizeof与指针的类型依赖性

int    x = 42;
int   *pi = &x;
char  *pc = (char *)&x;
int  **ppi = &pi;

/* 所有指针变量本身的大小相同 */
printf("sizeof(pi):  %zu\n", sizeof(pi));    // 8 (64位系统)
printf("sizeof(pc):  %zu\n", sizeof(pc));    // 8
printf("sizeof(ppi): %zu\n", sizeof(ppi));   // 8

/* 但解引用后的大小由类型决定 */
printf("sizeof(*pi):  %zu\n", sizeof(*pi));   // 4 (int)
printf("sizeof(*pc):  %zu\n", sizeof(*pc));   // 1 (char)
printf("sizeof(*ppi): %zu\n", sizeof(*ppi));  // 8 (int*)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

"指针类型只影响解引用和算术"——这是理解指针全部行为的通关密语。

# 4. 解引用的汇编翻译

# 4.1 *p 的完整汇编生命周期

以一个简单的读操作为例:

int x = 42;
int *p = &x;
int y = *p;
1
2
3

编译为汇编(godbolt: gcc 14.1 -O0):

; ① 变量声明与赋值
mov    DWORD PTR [rbp-4], 42      ; x = 42 (栈上 [rbp-4] 位置)

; ② 取地址
lea    rax, [rbp-4]               ; rax = &x (加载栈偏移的有效地址)
mov    QWORD PTR [rbp-16], rax    ; p = rax (p 存到栈上 [rbp-16])

; ③ 解引用——读指针指向的值
mov    rax, QWORD PTR [rbp-16]    ; rax = p (从栈上读出指针的值)
mov    eax, DWORD PTR [rax]       ; eax = *p (用这个值作为地址,读4字节)
mov    DWORD PTR [rbp-20], eax    ; y = eax
1
2
3
4
5
6
7
8
9
10
11

解引用的三步流水线:

*p
 │
 ├─ Step 1: 从 p 所在的内存位置读出 p 的值
 │          mov rax, QWORD PTR [p的栈位置]
 │
 ├─ Step 2: 以这个值作为地址,按类型宽度去读
 │          mov eax, DWORD PTR [rax]   ← 读 4 字节(int)
 │
 └─ Step 3: 将读取的值用于后续操作
            mov [y的栈位置], eax
1
2
3
4
5
6
7
8
9
10

关键观察:解引用是两次内存访问——先读指针的值(获得目标地址),再以目标地址去读数据。

# 4.2 写操作的完整路径

int x = 0;
int *p = &x;
*p = 99;
1
2
3
; ① 初始化和取地址(同读操作)
mov    DWORD PTR [rbp-4], 0       ; x = 0
lea    rax, [rbp-4]
mov    QWORD PTR [rbp-16], rax    ; p = &x

; ② 解引用写——两步:取地址,写值
mov    rax, QWORD PTR [rbp-16]    ; rax = p → 取地址
mov    DWORD PTR [rax], 99        ; *rax = 99 → 以 rax 为地址,写 4 字节
1
2
3
4
5
6
7
8

与直接写 x 的对比:

x = 99;  // 直接写
// mov DWORD PTR [rbp-4], 99      ← 1条指令,直接写已知偏移

*p = 99;  // 间接写
// mov rax, [rbp-16]              ← 先读出指针值
// mov [rax], 99                   ← 再以指针值为地址写
1
2
3
4
5
6

间接写多了一步"读指针值"——这是指针比直接变量访问慢的物理原因(多一次内存访问,可能多一次 cache miss)。

# 4.3 为什么要区分 int* 与 char*

疑惑:既然任何指针在 64 位系统上都是 8 字节的整数,为什么编译器要区分 int* 和 char*?

论证:

int  x = 0;
int *pi = &x;
// char *pc = &x;  // ❌ 编译器警告:不兼容的指针类型
char *pc = (char *)&x;  // ✅ 强制转换,但语义完全不同

*pi = 0x12345678;        // 写 4 字节
*pc = 0xFF;              // 写 1 字节,只覆盖 x 的最低字节
1
2
3
4
5
6
7

如果没有类型的区分,编译器不知道 *p 应该读多少字节,p + 1 应该跳过多少字节。类型区分让编译器在编译期就决定了这些——这就是 C 的静态类型系统在指针上的体现。

如果 C 语言只有 void*(无类型指针)会怎样?

// 假设 C 只有 void*(没有类型区分)
void *p = &x;
// *p = 42;          // ← 编译器:不知道解引用宽度是多少!编译失败!
// p + 1;            // ← 编译器:不知道步长是多少!编译失败!

// 每次使用都要手动指定大小——回到汇编时代
*(int *)p = 42;       // "请用 4 字节的宽度解释这片内存"
1
2
3
4
5
6
7

结论:int*、char* 等类型化指针,本质是把"地址"和"访存宽度"打包成一个编译期合约——编译器负责生成正确宽度的指令,程序员不必每次手动指定。类型是关于指针的使用说明书。

# 5. & 与 * 的对称世界

# 5.1 对称性证明

int x = 42;

/* 基本对称 */
int *p = &x;     // &x 的类型:int*
int  y = *p;     // *p 的类型:int

/* 组合验证 */
int  z  = *&x;   // &x → int*, *&x → int → z = x
int *q  = &*p;   // *p → int, &*p → int* → q = p (同值)
1
2
3
4
5
6
7
8
9

对称性定理:

& 和 * 是互逆操作:
  1. &*p == p          (如果 p 是一个合法指针)
  2. *&x == x          (如果 x 是一个左值)
1
2
3

汇编验证——&*p 在 -O2 优化下完全消除:

; int *q = &*p;
; 编译器直接优化为:
mov    rax, QWORD PTR [rbp-16]    ; rax = p
mov    QWORD PTR [rbp-24], rax    ; q = rax
;  &*p 被优化为 p 本身——零额外指令
1
2
3
4
5

而 *&x 同样被优化:

; int z = *&x;
; 优化为 z = x —— 无需取地址再解引用
mov    eax, DWORD PTR [rbp-4]     ; eax = x
1
2
3

# 5.2 &* 和 *& 的经典搅局

/* 场景 A:&*p 当 p 为 NULL */
int *p = NULL;
int *q = &*p;  // 这行安全吗?
1
2
3

论证:&*p 按照 C 标准,*p 对 NULL 解引用是未定义行为。但在所有实际编译器中,&*p 在未实际访问内存时不会崩溃,因为编译器把它优化成了 p:

  • 标准说法:UB
  • 实际行为:不崩溃,q = p = NULL

但不要依赖这个行为——它是实现定义的边缘地带。

/* 场景 B:取不了非左值的地址 */
// int *r = &(x + 1);  // ❌ 编译错误:x+1 不是左值,不能取地址
int *s = &x;
int *t = &(*s);       // ✅ OK:*s 是左值(它在内存中有位置)
1
2
3
4

& 只能作用于左值——在内存中有确定位置的表达式。x + 1 是临时值,存放在寄存器中,没有地址。

# 5.3 可修改门牌号

int a = 10, b = 20;
int *p = &a;

printf("%d\n", *p);  // 10

p = &b;              // ← 修改指针的指向,而不修改指向的数据
printf("%d\n", *p);  // 20

*p = 99;             // ← 修改指向的数据,而不修改指针本身
printf("%d\n", b);   // 99
1
2
3
4
5
6
7
8
9
10

这与 C++ 的引用有本质区别:

// C++ 引用一旦绑定,终身不可更改指向
int &r = a;
r = b;  // 这不是让 r "指向" b,而是把 b 的值赋给 a(即 *r = b)
1
2
3

C 的指针是一等公民——它既可以被修改(改变指向),也可以通过它修改目标。指针就像一个可擦写的门牌号:你可以拿到门牌号去找房间(解引用),也可以把门牌号换成另一个房间(修改指针值)。

# 6. 多级指针逐层闯关

# 6.1 int*** 的内存四层模型

疑惑:int ***p 在内存中是什么样子?

论证:

int val = 42;
int *p1  = &val;      // p1 存 val 的地址
int **p2 = &p1;       // p2 存 p1 的地址
int ***p3 = &p2;      // p3 存 p2 的地址
1
2
3
4

内存布局(假设地址从小向大增长):

地址       内容                  变量名
──────────────────────────────────────────
0x400:     42                    val (int)
           ──────────────────
0x408:     0x00000000_00000400   p1  (int*, 指向 0x400)
           ──────────────────
0x410:     0x00000000_00000408   p2  (int**, 指向 0x408)
           ──────────────────
0x418:     0x00000000_00000410   p3  (int***, 指向 0x410)
1
2
3
4
5
6
7
8
9

解引用链:

p3 的值 = 0x410         → 指向 p2
*p3  = p2 的值 = 0x408  → 指向 p1
**p3 = p1 的值 = 0x400  → 指向 val
***p3 = val = 42        → 最终数据
1
2
3
4

每次 * 操作都是一次额外的指针跟随——在汇编中就是一次 mov rax, [rax] 的连锁:

; ***p3 的汇编(gcc -O0)
mov    rax, QWORD PTR [rbp-32]   ; rax = p3
mov    rax, QWORD PTR [rax]      ; rax = *p3 (即 p2)
mov    rax, QWORD PTR [rax]      ; rax = **p3 (即 p1)
mov    eax, DWORD PTR [rax]      ; eax = ***p3 (即 val)
1
2
3
4
5

每一级解引用都是一次内存访问——三级指针意味着访问最终数据之前,需要先跑过三个中间指针。

# 6.2 为什么需要多级指针

场景 1:函数需要修改调用者的指针

/* 错误:传值——无法修改调用者的 ptr */
void init_bad(int *ptr) {
    ptr = malloc(100 * sizeof(int));  /* ← 只修改了局部副本 */
}

/* 正确:传指针的地址 → 二级指针 */
void init_good(int **ptr) {
    *ptr = malloc(100 * sizeof(int));  /* ← 修改调用者持有的指针 */
}

int main() {
    int *data = NULL;
    init_good(&data);   /* 传 data 的地址 → int** */
    /* 现在 data 指向了分配的内存 */
    free(data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

场景 2:二维数组的动态分配

int rows = 3, cols = 4;
int **matrix = malloc(rows * sizeof(int *));
for (int i = 0; i < rows; i++)
    matrix[i] = malloc(cols * sizeof(int));

matrix[1][2] = 42;   /* 等价于 *(*(matrix + 1) + 2) */

for (int i = 0; i < rows; i++)
    free(matrix[i]);
free(matrix);
1
2
3
4
5
6
7
8
9
10

场景 3:链表 API——插入/删除需要修改头指针

typedef struct Node {
    int data;
    struct Node *next;
} Node;

/* 需要二级指针才能在函数内修改头指针 */
void push_front(Node **head, int val) {
    Node *new_node = malloc(sizeof(Node));
    new_node->data = val;
    new_node->next = *head;
    *head = new_node;  /* 修改调用者的 head */
}

int main() {
    Node *head = NULL;
    push_front(&head, 1);
    push_front(&head, 2);
    /* head → 2 → 1 → NULL */
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 6.3 多级指针与二维数据

/* 静态二维数组——一块连续内存 */
int grid[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

/* 动态二维数组——指针数组,每行可能是分散的 */
int **dyn_grid = malloc(3 * sizeof(int *));
for (int i = 0; i < 3; i++)
    dyn_grid[i] = malloc(4 * sizeof(int));

/* 内存布局对比 */
静态 grid[3][4]:                 动态 dyn_grid(int**):
连续内存:                         指针数组 + 分散行:
[1][2][3][4][5][6][7][8]...      [ptr0][ptr1][ptr2]
                                   │      │      │
                                   ↓      ↓      ↓
                                  [1..4] [5..8] [9..12]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

第 09 篇会深入展开数组与指针的关系——本篇只讲多级指针自身的解引模型。

# 6.4 常见多级指针误用

/* 陷阱 1:分配了指针数组但忘了给每行分配 */
int **matrix = malloc(10 * sizeof(int *));
matrix[0][0] = 42;  /* ❌ matrix[0] 未初始化 → 野指针! */

/* 正确做法 */
for (int i = 0; i < 10; i++)
    matrix[i] = malloc(10 * sizeof(int));


/* 陷阱 2:释放顺序错误 */
for (int i = 0; i < 10; i++)
    free(matrix[i]);
free(matrix);   /* ✅ 先释放行,再释放指针数组 */


/* 陷阱 3:混淆二级指针和二维数组 */
int arr[3][4];
int **p = (int **)arr;  /* ❌ arr 不是指针数组,是一块连续内存! */
// p[1][2] = 42;  /* 会读 arr[0] 的第一个元素作为指针 → 崩溃 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

第 09 篇会从数组和指针关系的角度彻底理清这个混淆。

# 7. void* 类型擦除的艺术

# 7.1 void* 的本质

void* 是 C 语言的"通用指针"——它可以存储任何类型的指针值,而不携带任何类型信息:

int    x  = 42;
double y  = 3.14;
char   *s = "hello";

void *vp;

vp = &x;   // ✅ int* → void*  隐式转换
vp = &y;   // ✅ double* → void*
vp = s;    // ✅ char* → void*

/* 反过来必须显式转换 */
int    *pi = (int *)vp;     // 需要强制转换
double *pd = (double *)vp;  // 程序员自己保证类型正确
1
2
3
4
5
6
7
8
9
10
11
12
13

汇编层面——void 就是裸地址*:

void *vp = &x;
int  *pi = (int *)vp;
1
2
; void *vp = &x;
lea    rax, [rbp-4]              ; rax = &x
mov    QWORD PTR [rbp-16], rax   ; vp = rax

; int *pi = (int *)vp;
mov    rax, QWORD PTR [rbp-16]   ; rax = vp
mov    QWORD PTR [rbp-24], rax   ; pi = rax

; 注意:vp 和 pi 的赋值汇编完全一样!
; 区别仅在于编译器对后续 *pi 生成什么指令
1
2
3
4
5
6
7
8
9
10

结论:void* 是"丢失了类型标签的指针"。它的 8 字节值和 int* 没有区别——区别在于编译器看到 void* 时拒绝生成解引用和算术运算的代码。

# 7.2 不可对void*做算术运算

void *vp = malloc(100);

// vp + 1;        // ❌ 编译错误:不知道步长
// *vp;           // ❌ 编译错误:不知道解引用宽度

/* 必须先转换为有类型指针 */
char *cp = (char *)vp;
cp + 1;           // ✅ 步长 = sizeof(char) = 1
*cp = 'A';        // ✅ 解引用宽度 = 1 字节
1
2
3
4
5
6
7
8
9

这是 C 标准对类型安全的一道防线:void* 拒绝一切需要类型信息的操作。GNU C 扩展允许对 void* 做算术(步长当作 1),但这不是标准行为,不要依赖。

# 7.3 void设计哲学

场景 1:通用内存操作——memcpy/memset/malloc 的签名

void *memcpy(void *dest, const void *src, size_t n);
void *memset(void *s, int c, size_t n);
void *malloc(size_t size);
1
2
3

这些函数不关心你传的数据是什么类型——它们只关心"多少字节"。

场景 2:回调函数的上下文传递

typedef void (*callback_t)(void *user_data);

void register_callback(callback_t cb, void *ctx) {
    /* ctx 可以指向任何类型 */
}

void my_callback(void *ctx) {
    int *counter = (int *)ctx;
    (*counter)++;
}

int count = 0;
register_callback(my_callback, &count);  /* int* → void* */
1
2
3
4
5
6
7
8
9
10
11
12
13

场景 3:异质容器——用 void 指针数组存储不同类型*

typedef struct {
    void **items;
    int    capacity;
    int    count;
} ArrayList;

void list_put_int(ArrayList *list, int val) {
    int *p = malloc(sizeof(int));
    *p = val;
    list->items[list->count++] = p;   /* int* → void* */
}

void list_put_string(ArrayList *list, const char *s) {
    list->items[list->count++] = (void *)strdup(s);
}

/* 取出时自行保证类型安全 */
int *pi = (int *)list->items[0];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

void 的双面性*:

优点 代价
通用接口——一个函数接受所有指针类型 类型信息丢失——调用者自己保证正确性
减少 API 膨胀——不需要 int/memcpy_int, memcpy_double... 强制转换降低了可读性
实现回调/插件系统的基石 运行时崩溃(类型不匹配时编译器不报警)

void* 是 C 语言给你的"万能钥匙"——什么锁都能开,但开错锁的后果你也得自己承担。

# 8. 指针的安全边界

# 8.1 野指针三宗罪

罪一:未初始化指针

int *p;          /* 栈上的 p 包含随机值(上次该位置残留的数据) */
// *p = 42;      /* ❌ 把 42 写到一个随机地址 → 可能 SIGSEGV,也可能静默破坏数据 */
1
2

罪二:悬空指针——free 后仍使用

int *p = malloc(sizeof(int));
*p = 42;
free(p);
// *p = 99;      /* ❌ free 后的内存可能被分配给其他对象——读写会造成数据损坏 */
1
2
3
4

罪三:越界指针

int arr[5] = {1, 2, 3, 4, 5};
int *p = arr + 5;     /* ✅ p 指向 arr 的"末尾之外",合法但不解引用 */
// *p = 6;            /* ❌ 越界写 */
1
2
3

# 8.2 空指针解引用

int *p = NULL;
printf("%d\n", *p);   // 信号:SIGSEGV
1
2

从电路到信号的完整路径:

1. CPU 执行 mov eax, [NULL_addr]
   ↓
2. MMU 查找页表——虚拟地址 0x0 没有对应的物理页映射
   (在 Linux 上,0x0 开始的几 KB 被故意不映射——page 0 不存在)
   ↓
3. MMU 触发 #PF(页错误)
   ↓
4. CPU 跳到内核的 page_fault_handler
   ↓
5. 内核检查:这个地址在进程的 VMA 列表里吗?
   → 不在 → 这是一个非法的内存访问
   ↓
6. 内核发送 SIGSEGV 信号给进程
   ↓
7. 默认动作:进程终止,core dumped
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

"NULL 是 0 不是巧合"——Linux 故意把 0x0 开始的几 KB 页表项清空(不存在映射),就是为了让任何对 NULL 的访问都立即产生 SIGSEGV。这是操作系统级别的"fail fast"防御。

# 8.3 安全指针编程规范

/* 规则 1:定义指针时立即初始化 */
int *p = NULL;                // 或者 int *p = &existing_var;

/* 规则 2:malloc 后立即检查 */
int *p = malloc(N * sizeof(int));
if (p == NULL) {
    /* 处理分配失败 */
    return -1;
}

/* 规则 3:free 后立即置 NULL */
free(p);
p = NULL;                     // 防止悬空指针被误用

/* 规则 4:使用前检查非空 */
if (p != NULL) {
    *p = 42;
}

/* 规则 5:指针运算不越界 */
int arr[N];
int *p = arr;
int *end = arr + N;
while (p < end) {            // ✅ p 永远在合法范围内
    *p++ = 0;
}
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

额外防御:用 AddressSanitizer 在开发阶段捕获野指针:

gcc -fsanitize=address -g -o test main.c
./test  # ASan 会在野指针访问时立刻报告精确位置
1
2

# 9. 数据结构应用

# 9.1 链表连接

回到第 1 章的网络包案例,正确使用类型安全指针的链表设计:

typedef struct PacketNode {
    Packet          *pkt;
    struct PacketNode *next;   /* ✅ 类型安全的 next 指针 */
    struct PacketNode *prev;
} PacketNode;

/* 遍历——无需强制转换 */
void traverse(PacketNode *head) {
    for (PacketNode *cur = head; cur != NULL; cur = cur->next) {
        process_packet(cur->pkt);
    }
}

/* 插入——在 a 后面插入 new_node */
void insert_after(PacketNode *a, PacketNode *new_node) {
    new_node->next = a->next;
    new_node->prev = a;
    if (a->next) a->next->prev = new_node;
    a->next = new_node;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

对比原来用 void* 存 next/prev 的版本——每个 ->next 都需要强制转换,而且编译器无法帮你检查类型错误。

# 9.2 函数指针表

/* 定义操作接口 */
typedef int (*handler_t)(const char *input, char *output);

typedef struct {
    const char *op_name;
    handler_t   handler;
} OpEntry;

/* 具体操作实现 */
int encrypt_handler(const char *in, char *out) { /* ... */ }
int decrypt_handler(const char *in, char *out) { /* ... */ }
int compress_handler(const char *in, char *out) { /* ... */ }

/* 操作表——数据驱动 */
OpEntry ops[] = {
    {"encrypt",  encrypt_handler},
    {"decrypt",  decrypt_handler},
    {"compress", compress_handler},
    {NULL, NULL}
};

/* 调度——用字符串查表,O(n) */
handler_t find_handler(const char *name) {
    for (int i = 0; ops[i].op_name != NULL; i++) {
        if (strcmp(ops[i].op_name, name) == 0)
            return ops[i].handler;
    }
    return NULL;
}

int main() {
    handler_t h = find_handler("encrypt");
    if (h) {
        char output[256];
        h("hello", output);  /* 通过函数指针调用 */
    }
}
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

函数指针表的威力:添加新操作不需要修改调度逻辑——只须在 ops[] 数组中加一行。这是 C 语言实现策略模式/命令模式的标准手法。第 05 篇会深入函数指针。

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的网络包 crash,七个疑问逐条作答:

疑问 答案
① int* 和 char* 在汇编层面的区别? 第 3.2:区别在解引用指令——DWORD PTR vs BYTE PTR,以及算术运算的步长
② *p 怎么变成汇编指令? 第 4.1:先 mov rax, [p](读指针值),再 mov eax, [rax](以值作为地址去读)
③ & 和 * 对称吗? 第 5.1:&*p == p、*&x == x,编译期互逆
④ int*** 内存模型? 第 6.1:三层指针→两层指针→一层指针→数据,每层一次内存访问
⑤ void* 为什么不能 p++? 第 7.2:丢失了步长信息(sizeof(*p)),编译器无法生成正确的偏移量
⑥ 野指针的电路级后果? 第 8.2:MMU 页表缺少映射 → #PF → 内核送 SIGSEGV
⑦ 指针在数据结构中的威力? 第 9:链表连接离散节点,函数指针表实现数据驱动调度

修复方案——用类型安全指针替换 void*:

/* 修复前 */
typedef struct {
    Packet *pkt;
    void   *next;    /* ← 丢失类型信息 */
    void   *prev;
} PacketNode;

PacketNode *get_next(PacketNode *node) {
    return (PacketNode *)node->next;  /* ← 每次都是带风险的强制转换 */
}

/* 修复后 */
typedef struct PacketNode {
    Packet          *pkt;
    struct PacketNode *next;  /* ← 类型安全的指针 */
    struct PacketNode *prev;
} PacketNode;

/* get_next 变得平凡——无需转换 */
static inline PacketNode *get_next(PacketNode *node) {
    return node->next;      /* ← 编译器帮你检查:next 必须是 PacketNode* */
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

为什么原来的 crash 如此隐蔽?

栈内存布局:
  n2.next (void*) = &n3  → 正确
  n3.pkt  (Packet*)      → 合法堆地址

当 get_next 返回时,cur 指向 n3:
  cur->pkt → 正确读到 n3.pkt → 合法指针 → 正常

在第 5 次循环时,get_next(n2) → n3 被正确返回,
但某个栈变量被覆盖导致 cur 被错误偏移,
最终 cur->pkt 读到的是 n2.next 附近的垃圾值。
1
2
3
4
5
6
7
8
9
10

根治方案:

  1. 永远不要用 void 存业务数据指针*——用类型安全的结构体自引用指针
  2. 如果要通用容器,用 uintptr_t 做中间表示,访问时强制转回——至少能保证 8 字节完整
  3. 打开编译器的所有警告:-Wall -Wextra -Wpedantic

# 10.2 指针访问对比

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

/* 方式 1:下标 */
int a = arr[2];          // 30

/* 方式 2:指针 */
int b = *(arr + 2);      // 30

/* 方式 3:多级指针模拟 */
int *p1 = arr;           // p1 指向 arr[0]
int **p2 = &p1;          // p2 指向 p1
int c = *(*p2 + 2);      // 30

/* 三种方式的汇编对比 */
// a = arr[2]        → mov eax, [array_base + 8]
// b = *(arr + 2)    → 同上(编译器优化后一样)
// c = *(*p2 + 2)    → mov rax, [p2]; mov rax, [rax]; mov eax, [rax+8]
//                      ↑ 多了一次指针跟随
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

性能启示:每一级指针间接访问都是一次额外的内存读取。在性能敏感的内层循环中,减少指针层级是有效的优化。但常规代码中,清晰性比微小的性能差异更重要。

# 10.3 设计哲学回扣

哲学 1:地址与类型解耦——指针的力量与危险同源

C 语言把"内存地址"和"数据类型"拆成两个独立概念:指针的值是地址(8字节整数),指针的类型是编译器元数据(不占内存)。这种解耦让你可以用 (char *)p 重新解释同一块内存——这是操作硬件 MMIO 寄存器的基础能力,也是写出神秘 bug 的根源。

*哲学 2:间接即权力——每加一级 ,就多一层抽象

int x 是直接数据。int *p 是间接——你可以改变 p 的指向而不用移动数据。int **pp 是双重间接——你可以修改一个指针,而修改它的代码不需要知道那个指针最终指向了谁。每一层 * 都是对数据位置的去耦合。现代软件架构中的依赖注入、策略模式,本质上就是"指针间接"的泛化。

哲学 3:类型是使用说明书——void 是撕掉说明书的危险操作*

int* 告诉编译器:"解引用时读 4 字节,+1 跳 4 字节"。void* 把说明书撕了——"我不知道是什么类型,你自己看着办"。void* 是必要的(通用接口),但每用一次 void* 就等于向编译器声明:"我保证自己知道在做什么"。如果保证不了,不要撕说明书。

哲学 4:fail fast——空指针和守护页的共同选择

在第 02 篇我们看到了守护页——栈溢出立刻 SIGSEGV。本篇的空指针解引用同理——虚拟地址 0x0 被故意留空,访问即崩。如果空指针解引用不崩,程序可能会在错误的状态上继续跑几小时,产生难以追溯的逻辑错误。一个明确的崩溃,价值远超一个沉默的错误。

# 10.4 指针速查表

操作 含义 汇编形式(简化)
int *p = &x; 指针声明+初始化 lea rax, [x]; mov [p], rax
y = *p; 解引用读 mov rax, [p]; mov eax, [rax]
*p = 42; 解引用写 mov rax, [p]; mov [rax], 42
p + 1; 指针算术 lea rax, [p + 4](int*)
p++; 指针自增 add rax, 4
void *vp = p; 类型擦除 纯寄存器拷贝,无转换指令
int **pp = &p; 多级指针 lea rax, [p]; mov [pp], rax
**pp 二级解引用 mov rax, [pp]; mov rax, [rax]; mov eax, [rax]

常见类型转换的安全矩阵:

转换 安全性 说明
int* → void* ✅ 安全 隐式转换,类型信息丢失
void* → int* ⚠️ 需显式 强制转换,程序员保证原始类型正确
int* → char* ⚠️ 需显式 合法但可能触及 strict aliasing 规则
char* → int* ⚠️ 需显式 可能导致未对齐访问
int* → double* ❌ 危险 strict aliasing 违规,UB
函数指针 → void* ⚠️ 不可移植 POSIX 允许,C 标准不保证

指针自测三问(看到一个指针时问自己):

1. 它的值是什么?(指向谁?)
2. 它的类型是什么?(解引用后读几字节?+1 跳几字节?)
3. 它的生命周期是什么?(指向的对象还存在吗?)
1
2
3

下一篇:我们已经理解了指针对单个数据的间接访问,下一步进入 04.指针运算底层真相——把 p + 1、arr[i]、*(arr + i) 从汇编层面统一,证明它们为什么是等价的。

上次更新: 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
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式