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——都可以分解为两条独立问题:
- 地址层:这个变量持有多大、按什么 stride 移动?
- 权限层:通过这个地址,我被允许做哪些操作?
作者在 ch1 把这两层一并引入(第 1–14 页讲地址与类型,第 27–31 页讲 const 四种组合的权限矩阵),正是为了让读者先把「指针就是地址」这一层放下,再去拼装后续章节的内存模型。
对嵌入式工程师的现实意义:
- 内存对齐、字节序、平台字长——都只是「地址层」的具体表现。
- 只读、只写、不可修改、不可重指向——都是「权限层」的具体表现。
- BSW 模块里常见的
const PduInfoType */PduInfoType *const形参(CanIf、Com、PduR 接口),其本质就是 ch1 §「Constants and Pointers」的同一矩阵。
二、章节概述
-
三类内存分区:Static 与 Global(整个程序期)、Automatic(函数栈帧期)、Dynamic(从 heap 显式 alloc 与 free),按 scope 与 lifetime 列表对比(表 1-1)。
-
指针声明的语法:星号的三种语义(声明、dereference、乘法)与排版偏好(
int *pi、int* pi、int * pi等价)。 -
声明的倒读法:从变量名往左读,先解读后修饰;复杂声明要画图。
-
地址运算符
&:num = 0; pi = #是经典初始化;pi = num;会编译报错(int 隐式转换为 int * 被禁)。 -
指针值的打印:
%p优先;%d、%x、%o仅做演示;虚拟操作系统下输出的永远是 virtual address,而非物理地址。 -
解引用
*作 lvalue:*pi = 200;可改原值;if (pi)测试 NULL 等价于if (pi != NULL)。 -
Null 概念矩阵(容易混淆的五个东西):null concept、null pointer constant、
NULL宏、ASCII NUL(\0)、null string、null statement。两个 null pointer 永远相等;uninitialized pointer 不会。 -
void *的两特性:与char *同尺寸同对齐;两个非 NULLvoid *永不相等(GNU 扩展除外);可与任意指针互转;sizeof(void)非法,sizeof(void *)合法。 -
Global 与 static 指针自动 NULL:图 1-6 显示 static 与 global 在 BSS 与 data segment 上方,堆在它们之下。
-
指针尺寸与内存模型:LP64(Unix 64)、LLP64(Win64)、ILP32(Win32)、LP32(DOS)等表 1-3;同 OS 下 data pointer 与 function pointer 可能尺寸不同。
-
指针相关内置类型:
size_t(容量,%zu)、ptrdiff_t(差值)、intptr_t与uintptr_t(可逆存地址)。不要把size_t当指针存;要存地址请用intptr_t。 -
指针算术:
pi + n实际地址增n * sizeof(*pi);void *算术 GCC 报-Wpointerarith警告;两指针相减得ptrdiff_t,单位是 element。 -
指针比较:仅对指向同一数组(或同一块)的指针有意义;比较任意两地址得 unspecified。
-
多级间接:
char **bestBooks[3]当 best-books 索引,避免 string 重复存储。间接级数越多越难维护,无内在上限。 -
const四种组合(重点表):类型 指针可改 数据可改 T *p(默认)✓ ✓ const T *p(pointer to const)✓ ✗ T *const p(const pointer)✗ ✓ const T *const p(const to const)✗ ✗ const int *pci与int const *pci等价。 -
指针到(const 指针到 const):
const int * const * pcpci;——多级间接与const可任意组合。
三、核心 Takeaways
| # | 是什么 | 为什么 | 解决了什么 | 适用场景 |
|---|---|---|---|---|
| T1 | 指针就是「类型化的地址」 | 类型决定 stride 与 deref 语义 | 让编译器替你完成 sizeof-aware 的寻址 | 所有 C 代码 |
| T2 | 三种内存分区 | Scope 与 lifetime 由声明位置决定 | 解释为什么 free 之前指针可能失效 | 全局变量、局部、heap |
| T3 | NULL 与 uninitialized 完全不同 | NULL 是确定的无效地址;未初始化是栈上的 garbage | 早期崩溃(段错误)对比后期异常数据 | 函数入口防御 |
| T4 | void * 是「无类型指针」 | 与 char * 同尺寸,可与任意 T * 互转 | 写泛型函数(memcpy、qsort 比较器) | 通用容器、序列化 |
| T5 | size_t 不是指针大小 | size_t 只是「足够大的无符号整数」 | 64-bit 平台 size_t 可能 64-bit,地址可能 32-bit(MCU) | 跨平台 size 表达 |
| T6 | 指针算术按 sizeof(T) 缩放 | 编译器知道你指向的类型 | 让 *(p+i) 与 p[i] 等价 | 数组遍历 |
| T7 | const 决定「权限」,不决定「指向对象」 | pci 可以重指向 num 或 limit | API 写者声明「我承诺不修改此入参」 | 函数形参保护 |
| T8 | 多级间接(T **)用于共享引用 | 数组的元素是指针而非副本 | 让分类与索引数组零拷贝共享同一组字符串 | 分类表、订阅表 |
| T9 | 内存模型(LP64、LLP64)决定字长 | C 标准不强求同尺寸 | 跨平台代码必须查表 1-3 校对 sizeof(long) 等 | 跨平台移植 |
| T10 | intptr_t 是可逆存地址的安全类型 | 跨平台兼容;标准定义于 <stdint.h> | 把指针存进文件、网络包、哈希 key | 序列化与 IPC |
四、工程实践视角(领域:嵌入式 / 汽车电子)
落地
- AUTOSAR 接口形参的
const模式:CanIf_Transmit(PduIdType TxPduId, const PduInfoType *PduInfoPtr),PduInfoPtr自身可改(PduInfoPtr = &local之类),但*PduInfoPtr指向的 payload 缓冲区在 CanIf 内部不允许改写——这正是 ch1const 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;是「无类型指针 → 类型化」的经典过渡;memcpy、memmove之类的实现正是依赖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是指针,pj是int。星号只修饰一个变量——与 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 与未初始化、知道
malloc要free,知道%p打印地址,知道不能 deref NULL。 - 中级:能读懂
const T *与T *const矩阵;理解多级间接T **的赋值语义(对argv形参、qsort比较器形参不再发怵);知道跨平台size_t、ptrdiff_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 想奠定的认知。
六、实践行动项
-
[必做] 在你机器上复现 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 复盘」。 -
[推荐] 写一个
const四种组合的迷你程序,把T *、const T *、T *const、const T *const各声明一个并尝试所有赋值组合,把编译器的报错复制到笔记。 -
[推荐] 在 NeuSAR 工程(若有 Dcm、PduR 模块源码)搜一处
const PduInfoType *形参的函数定义,用 ch1 的 4-格矩阵标注每一处const的「指针可改与数据可改」,贴到笔记附录。
七、值得深入思考的问题
- Q1:为什么 C 标准不强求所有数据指针同尺寸?——与「冯·诺依曼对比哈佛」架构选择强相关(8051 SDCC 是经典反例,data、idata、pdata、code 各 1–4 字节)。这对 SOC 多核异构(MCU 内核与 DSP)有什么暗示?
- Q2:
void *算术为什么 GCC 给警告?——因为 C 标准说 void 是 incomplete type,无法决定 stride;但 POSIX、GNU 扩展允许「以 char 大小」。这条边界在不同 RTOS(AUTOSAR OS 对比 Zephyr)里如何对齐? - Q3:
const T *pci真的能保护 limit 不被改吗?——通过int *hack = (int *)&limit; *hack = ...;仍可绕过;const只是「编译器 + 类型系统的承诺」,不是「硬件级只读」。这把边界划在哪?volatile const的意义在哪? - Q4:多级间接的「内在上限」是无,工程上为什么通常 ≤ 3?——与可读性、可验证性、cache locality 强相关;BSW 模块里
PduIdType、PduInfoType *通常到 2 级就不再加深,为什么? - 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)。