Chapter 1 · Introduction

书目:Richard Reese,《Understanding and Using C Pointers》, O’Reilly 2013, ISBN 978-1-449-34418-4 本章范围:PDF p.1–32(全文行 485–1726,共约 1240 行) 阅读日期:2026-09-01


一、第一性原理思考

指针 = 类型化的内存地址 + 一组被语言层限制的操作(解引用、算术、比较)。理解了这一点,后续所有「指针难懂」的具体场景—— void * 算术、const 四种组合、double indirection——都可以分解为两条独立问题:

  1. 地址层:这个变量持有多大、按什么 stride 移动?
  2. 权限层:通过这个地址,我被允许做哪些操作?

作者在 ch1 把这两层一并引入(第 1–14 页讲地址与类型,第 27–31 页讲 const 四种组合的权限矩阵),正是为了让读者先把「指针就是地址」这一层放下,再去拼装后续章节的内存模型。

对嵌入式工程师的现实意义:

  • 内存对齐、字节序、平台字长——都只是「地址层」的具体表现。
  • 只读、只写、不可修改、不可重指向——都是「权限层」的具体表现。
  • BSW 模块里常见的 const PduInfoType * / PduInfoType *const 形参(CanIf、Com、PduR 接口),其本质就是 ch1 §「Constants and Pointers」的同一矩阵。

二、章节概述

  1. 三类内存分区:Static 与 Global(整个程序期)、Automatic(函数栈帧期)、Dynamic(从 heap 显式 alloc 与 free),按 scope 与 lifetime 列表对比(表 1-1)。

  2. 指针声明的语法:星号的三种语义(声明、dereference、乘法)与排版偏好(int *piint* piint * pi 等价)。

  3. 声明的倒读法:从变量名往左读,先解读后修饰;复杂声明要画图。

  4. 地址运算符 &:num = 0; pi = # 是经典初始化;pi = num; 会编译报错(int 隐式转换为 int * 被禁)。

  5. 指针值的打印:%p 优先;%d%x%o 仅做演示;虚拟操作系统下输出的永远是 virtual address,而非物理地址。

  6. 解引用 * 作 lvalue:*pi = 200; 可改原值;if (pi) 测试 NULL 等价于 if (pi != NULL)

  7. Null 概念矩阵(容易混淆的五个东西):null concept、null pointer constant、NULL 宏、ASCII NUL(\0)、null string、null statement。两个 null pointer 永远相等;uninitialized pointer 不会

  8. void * 的两特性:与 char * 同尺寸同对齐;两个非 NULL void * 永不相等(GNU 扩展除外);可与任意指针互转;sizeof(void) 非法,sizeof(void *) 合法。

  9. Global 与 static 指针自动 NULL:图 1-6 显示 static 与 global 在 BSS 与 data segment 上方,堆在它们之下。

  10. 指针尺寸与内存模型:LP64(Unix 64)、LLP64(Win64)、ILP32(Win32)、LP32(DOS)等表 1-3;同 OS 下 data pointer 与 function pointer 可能尺寸不同

  11. 指针相关内置类型:size_t(容量,%zu)、ptrdiff_t(差值)、intptr_tuintptr_t(可逆存地址)。不要把 size_t 当指针存;要存地址请用 intptr_t

  12. 指针算术:pi + n 实际地址增 n * sizeof(*pi);void * 算术 GCC 报 -Wpointerarith 警告;两指针相减得 ptrdiff_t,单位是 element。

  13. 指针比较:仅对指向同一数组(或同一块)的指针有意义;比较任意两地址得 unspecified。

  14. 多级间接:char **bestBooks[3] 当 best-books 索引,避免 string 重复存储。间接级数越多越难维护,无内在上限

  15. const 四种组合(重点表):

    类型指针可改数据可改
    T *p (默认)
    const T *p (pointer to const)
    T *const p (const pointer)
    const T *const p (const to const)

    const int *pciint const *pci 等价。

  16. 指针到(const 指针到 const):const int * const * pcpci;——多级间接与 const 可任意组合。


三、核心 Takeaways

#是什么为什么解决了什么适用场景
T1指针就是「类型化的地址」类型决定 stride 与 deref 语义让编译器替你完成 sizeof-aware 的寻址所有 C 代码
T2三种内存分区Scope 与 lifetime 由声明位置决定解释为什么 free 之前指针可能失效全局变量、局部、heap
T3NULL 与 uninitialized 完全不同NULL 是确定的无效地址;未初始化是栈上的 garbage早期崩溃(段错误)对比后期异常数据函数入口防御
T4void * 是「无类型指针」char * 同尺寸,可与任意 T * 互转写泛型函数(memcpyqsort 比较器)通用容器、序列化
T5size_t 不是指针大小size_t 只是「足够大的无符号整数」64-bit 平台 size_t 可能 64-bit,地址可能 32-bit(MCU)跨平台 size 表达
T6指针算术按 sizeof(T) 缩放编译器知道你指向的类型*(p+i)p[i] 等价数组遍历
T7const 决定「权限」,不决定「指向对象」pci 可以重指向 numlimitAPI 写者声明「我承诺不修改此入参」函数形参保护
T8多级间接(T **)用于共享引用数组的元素是指针而非副本让分类与索引数组零拷贝共享同一组字符串分类表、订阅表
T9内存模型(LP64、LLP64)决定字长C 标准不强求同尺寸跨平台代码必须查表 1-3 校对 sizeof(long)跨平台移植
T10intptr_t 是可逆存地址的安全类型跨平台兼容;标准定义于 <stdint.h>把指针存进文件、网络包、哈希 key序列化与 IPC

四、工程实践视角(领域:嵌入式 / 汽车电子)

落地

  • AUTOSAR 接口形参的 const 模式:CanIf_Transmit(PduIdType TxPduId, const PduInfoType *PduInfoPtr),PduInfoPtr 自身可改(PduInfoPtr = &local 之类),但 *PduInfoPtr 指向的 payload 缓冲区在 CanIf 内部不允许改写——这正是 ch1 const T * 模式的活样本。
  • uint8 *const SduDataPtr = PduInfoPtr->SduDataPtr;:函数内部对 payload 指针做「固化」——局部 const pointer 防止手抖改了上游指针(ch1 §「Constant pointers to nonconstants」)。
  • void * 用于 generic memory copy:MemCpy(Dst, Src, n) 的内部 uint8 *d = (uint8 *)Dst; 是「无类型指针 → 类型化」的经典过渡;memcpymemmove 之类的实现正是依赖 void * 不变大小。
  • size_t 在 DMA 配置中的陷阱:DMA 描述符的 length 字段 32-bit MCU 上是 uint32,不是 size_t(在某些 64-bit host 编译时是 64-bit)——一旦混用,跨平台移植就会爆。

误区

  • M1 误把 int* pi; int* pj; 当链式声明——C 不是 C++、Java,int* pi, pj;pi 是指针,pjint。星号只修饰一个变量——与 ch1 强调的「each declaration is independent」一致。
  • M2 *pi = (int *) malloc(...); 把 malloc 的返回值 deref 再赋值——ch1 已用 sidebar 警示:你会把堆首地址写到 pi 指向的 garbage 地址,而不是把堆地址写进 pi
  • M3 char *name; scanf("%s", name);——未分配就直接写入栈 garbage 区域,典型 buffer overflow。
  • M4%d 打印 size_t——64-bit 平台会高位截断;若赋了 -5 还能输出 4294967291(ch1 §「Understanding size_t」亲测过的坑)。
  • M5 假设 sizeof(void *) == sizeof(int)——64-bit Unix LP64 下是 8 与 4,常见于把指针存进 int 字段的旧代码。

初中高工程师视角

  • 初中级:能识别星号三种语义、能区分 NULL 与未初始化、知道 mallocfree,知道 %p 打印地址,知道不能 deref NULL。
  • 中级:能读懂 const T *T *const 矩阵;理解多级间接 T ** 的赋值语义(对 argv 形参、qsort 比较器形参不再发怵);知道跨平台 size_tptrdiff_t 选用规则。
  • 高级:能根据「权限声明」反推 API 设计意图;能识别 intptr_t 滥用为 hash key 的反模式;能在 review 时一眼看出 static T *p = malloc(...) 的语法错误并知道替代写法。

五、AI 时代视角

  • LLM 生成 C 代码的常见 bug:LLM 写出 int* a, b; 然后 b 用作指针;写 *p = malloc(...);;写 if (p == NULL) return; 但忘了 free;写 strcmp 不 cast 到 const char *。ch1 的所有 sidebar 几乎都是 LLM 高频犯错的复现集。
  • Copilot 提示工程:写形参时显式声明 const(尤其 const uint8 *payload, uint16_t len 这类)能显著降低 Copilot 生成「在函数内意外改 payload」的概率。
  • 静态分析替代人工 review:clang-tidy --checks=clang-analyzer-core.NullDereference,bugprone-sizeof-expression 几乎是 ch1 §「Security Risks」一节的 CI 实现;MISRA-C 工具链(EB tresos、GreenHills)进一步用 lint rule 强制 const 矩阵。
  • LLM 时代的「读 C」价值:当 Copilot、Codeium 自动写出指针代码后,工程师必须仍能从一段声明反推「权限 + stride + 所有权」,否则 review 形同虚设——这正是 ch1 想奠定的认知。

六、实践行动项

  1. [必做] 在你机器上复现 ch1 三个错例并贴出真实输出

    • *p = malloc(...); 错例 → 段错误现场
    • size_t s = -5; printf("%d", s);printf("%zu", s); 对比
    • char *name; scanf("%s", name); 段错误

    落档:code-exercises/ch01_pointer_misuse.c + 附录「Action #1 复盘」。

  2. [推荐] 写一个 const 四种组合的迷你程序,把 T *const T *T *constconst T *const 各声明一个并尝试所有赋值组合,把编译器的报错复制到笔记。

  3. [推荐] 在 NeuSAR 工程(若有 Dcm、PduR 模块源码)搜一处 const PduInfoType * 形参的函数定义,用 ch1 的 4-格矩阵标注每一处 const 的「指针可改与数据可改」,贴到笔记附录。


七、值得深入思考的问题

  1. Q1:为什么 C 标准不强求所有数据指针同尺寸?——与「冯·诺依曼对比哈佛」架构选择强相关(8051 SDCC 是经典反例,data、idata、pdata、code 各 1–4 字节)。这对 SOC 多核异构(MCU 内核与 DSP)有什么暗示?
  2. Q2:void * 算术为什么 GCC 给警告?——因为 C 标准说 void 是 incomplete type,无法决定 stride;但 POSIX、GNU 扩展允许「以 char 大小」。这条边界在不同 RTOS(AUTOSAR OS 对比 Zephyr)里如何对齐?
  3. Q3:const T *pci 真的能保护 limit 不被改吗?——通过 int *hack = (int *)&limit; *hack = ...; 仍可绕过;const 只是「编译器 + 类型系统的承诺」,不是「硬件级只读」。这把边界划在哪?volatile const 的意义在哪?
  4. Q4:多级间接的「内在上限」是无,工程上为什么通常 ≤ 3?——与可读性、可验证性、cache locality 强相关;BSW 模块里 PduIdTypePduInfoType * 通常到 2 级就不再加深,为什么?
  5. Q5:intptr_t 与「指针存进哈希表」的反模式如何取舍?——跨进程共享同一指针语义不安全(同一虚拟地址指向不同物理页);存 hash key 应改存「对象 ID」+ 进程级表。NeuSAR 里 Com 信号与 CanIf HRH 是怎么做的?

附录 · Action #1 复盘 · ch1 三错例编译运行

复现路径

源文件:code-exercises/ch01_misuse.c(单文件,3 个 case)

$ gcc -O0 -g -Wall -Wextra -o ch01_misuse ch01_misuse.c
$ ./ch01_misuse 1   # 跑 case1
$ ./ch01_misuse 2   # 跑 case2(无崩溃,纯 printf)
$ ./ch01_misuse 3   # 跑 case3
$ ./ch01_misuse     # 跑全部

平台:Linux 6.17, gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1),x86-64 LP64。size_t 是 8 字节,指针 8 字节,%zu 自然可用。

Case 1 真实输出(预期 SIGSEGV,实际触发)

=== case1: *pi = (int *)malloc(...) on uninitialized pi ===
case1 unexpected success: pi=0x72005c0b4000
 
  >> SIGSEGV caught as expected (教材级错例触发)
exit=139

现象:编译期 gcc 已经警告两条——assignment to 'int' from 'int *' makes integer from pointer without a cast'pi' is used uninitialized。运行时第一行 *pi = (int *)malloc(...); 没有立刻 segv,因为:

  • 栈上未初始化的 pi 是某「看起来合法」的地址(本次是 0x72005c0b4000,符合 Linux x86-64 用户态高地址空间的典型布局)。
  • *pi = 地址值; 这一步是 deref 写入而非 deref 读取,碰巧 命中可写页。
  • 第二步 *pi = 5; 时因为 pi 已被覆盖为堆地址 0x72005c0b4000,而该地址尚未通过 mmap 给本进程,触发 SIGSEGV。

踩到的坑:*pi = (int *)malloc(sizeof(int)); 同时犯了两个错——deref 一个未初始化指针 int *int 赋值。如果只写第二行,可能永远不会 segv(纯写 garbage 地址,只是污染了某随机页)。这正印证 ch1 p.7 sidebar 强调的「后果是 implementation-dependent,可能跑很久不出问题,某天突然崩溃」的论点。

Case 2 真实输出(无崩溃,纯 printf 演示)

=== case2: size_t %%d vs %%zu, sizeof(void*) vs sizeof(void) ===
case2 size_t as %d  -> -5
case2 size_t as %zu -> 18446744073709551611
case2 sizeof(void*) = 8  bytes
exit=0

对照 ch1 p.18:

  • Reese 在 32-bit 上看到 %zu 输出 4294967291(= 2³² − 5)。
  • 本机 LP64:%zu 输出 18446744073709551611(= 2⁶⁴ − 5)。
  • 同一段代码,跨平台输出值差异巨大,正是 ch1 §「Understanding size_t」强调「format specifier 必须与 size_t 配套」的实证。

sizeof(void *) = 8 验证:与 ch1 p.14「A pointer to void will have the same representation as a pointer to char」一致——两者都是 8 字节对齐。

Case 3 真实输出(预期崩溃,实际未崩)

=== case3: write to malloc(0) buffer ===
case3 unexpected success
exit=0

现象:这是「未崩」的案例,记录在案。

  • 现代 glibc malloc(0) 返回合法非 NULL 指针(最小分配单元通常 16 字节,只是可用长度为 0)。
  • 写入 1 字节到 0 大小分配的地址未必触发 abort——glibc 的 tcache、fastbin 路径可能「吃掉」这次写入。
  • ch1 原文提到的 char *name; scanf("%s", name); 才是真崩溃复现路径,本程序用 malloc(0) 替代是为了避免污染 stdio 与终端缓冲,但代价是丢失了崩溃的确定性。

结论:ch1 强调的「后果不可预测」在 case 3 完美反向印证——同一个错例,旧 libc 会立刻 segv,新 libc 安静放过。这就是为什么书中反复强调静态分析、valgrind、ASan 的原因

一行 repro(可在你机器上复现)

cd /home/liyahong/dev/read/notes/understanding-using-c-pointers/code-exercises
gcc -O0 -g -Wall -Wextra -o ch01_misuse ch01_misuse.c && ./ch01_misuse

剩余未做的事

  • 用 AddressSanitizer 重新编译一次,观察 case1、case3 在 ASan 下的诊断信息(后续章节复盘会更精彩,本节只跑 baseline)。
  • 在 NeuSAR 工程中搜索 const PduInfoType * 形参的函数并画一份「指针可改与数据可改」矩阵(Action #3)。