第 1 章 地址、指针与三个符号
1.1 两个概念
- 地址:内存中每个字节的编号,本质是一个无符号整数。STM32 上常见三段:
0x08000000起是 Flash,0x20000000起是 SRAM,0x40000000起是外设寄存器。 - 指针:专门存地址的变量。指针的值是一个地址。
1.2 三个符号
| 写法 | 名字 | 含义 |
|---|---|---|
&x |
取地址 | 得到变量 x 自己的地址 |
*p |
解引用 | 顺着 p 里的地址去读写那个单元 |
p |
指针本身 | 一个地址值 |
1.3 指针自己也是变量
- 在 Cortex-M3 上,任何数据指针都是 4 字节:
sizeof(int*) == sizeof(char*) == sizeof(void*) == 4。指针类型不影响它自己占多大,只影响"怎么看它指向的东西"。 - ⚠️ 打印指针别用
%d。用%p,或者在串口打印里写My_USART_Printf("p=0x%08X\r\n", (uint32_t)p);(MicroLIB 对%p支持不完整,转成整数打更稳)。
过关:int *p = &a; 里,p、*p、&p、sizeof(p) 分别是什么?
第 2 章 指针类型、强制转换与 volatile
2.1 指针类型决定什么
- 解引用的宽度:
char*读写 1 字节,uint16_t*读写 2 字节,uint32_t*读写 4 字节。 - 运算的步长:
p+1实际加的是sizeof(指向的类型)字节。 - 成员的解释方式:同一个地址,按
GPIO_TypeDef*看是寄存器组,按uint8_t*看就是一串字节。
不决定的:指针自身的大小(永远是 4 字节)。
2.2 强制类型转换:操作硬件的核心语法
(目标指针类型) 地址数值
把 #define RCC_APB2ENR (*(volatile uint32_t *)0x40021018) 拆开读,从里往外三步:
| 步骤 | 片段 | 作用 |
|---|---|---|
| ① | (uint32_t *)0x40021018 |
把常数地址变成"指向 32 位量的指针" |
| ② | (volatile ...) |
声明这块内存随时会被外部改变,禁止编译器优化掉读写 |
| ③ | *(...) |
解引用,于是这个宏用起来就像一个普通变量 |
2.3 为什么必须有 volatile
三个必加的场景:硬件寄存器、中断与主循环共享的变量、可能被信号/异步事件改变的量。标准库把寄存器结构体成员全写成 __IO,而 __IO 就是 volatile(__I = volatile const,__O = volatile)。
2.4 位操作三件套
- ⚠️ F103 的 APB2ENR:bit0 是 AFIO,bit1 保留,bit2 才是 GPIOA,bit3 是 GPIOB。别按"bit0=GPIOA"往下推,库宏
RCC_APB2Periph_GPIOA = 0x00000004可以直接印证。 - ⚠️
(1 << 31)在int上移位有符号溢出风险,涉及最高位写(1U << 31)。
过关:*(volatile uint32_t *)0x40021018 |= 4; 这条语句做了哪三件事?为什么 while(USART_GetFlagStatus(...)==RESET); 少了 volatile 就可能永远出不来?
第 3 章 指针运算与 sizeof 陷阱
3.1 加减:按类型步长偏移
新地址 = 原地址 + 偏移数 × sizeof(指针指向的类型)
| 指针类型 | sizeof(类型) | p+1 地址增量 |
|---|---|---|
unsigned char * |
1 | +1 |
uint16_t * |
2 | +2 |
uint32_t * / int * |
4 | +4 |
GPIO_TypeDef * |
28(7 个寄存器) | +28 |
- ⚠️ 演示写操作要用 SRAM 地址(
0x2000xxxx)。0x000000F1落在 Flash/别名区,只能读,写会触发 HardFault。
3.2 相减与比较
- 同数组内两个指针相减,结果是元素个数(类型
ptrdiff_t),不是字节数:(p3 - p0) == 3。 - 求字节数要
sizeof(*p) * (q - p)。 <、>比较只在同一数组内部有意义,跨对象的比较结果是未定义的。void *不能++、不能解引用(不知道宽度);但它可以在任何对象指针之间自由赋值,不需要强转。
3.3 数组下标就是指针偏移
3.4 sizeof 陷阱(最高频)
- ⚠️ 数组一旦传进函数就变成指针,
sizeof立刻失效,必须额外传长度。 - ⚠️
memset(buf, 0, sizeof(buf))只有当buf是数组本体时才对;如果它是形参或指针,只清了 4 个字节。
过关:uint32_t *p; p += 3; 地址加了几?int a[10]; sizeof(a) 和传进函数后的 sizeof(a) 各是多少?
第 4 章 指针与数组
4.1 数组名什么时候不退化
数组名在表达式里通常退化为「首元素地址」,只有两个例外:
&a 和 &a[0] 数值相同、类型不同,这是面试常考、也是二维数组踩坑的根源。
4.2 指针数组、数组指针、函数指针的读法
看变量名旁边先遇到什么:先遇到 [] 就是数组,先遇到 () 就是函数,* 表示指针。
| 声明 | 是什么 | 元素/返回 |
|---|---|---|
int *p[4]; |
指针数组(4 个) | 每个元素是 int* |
int (*p)[4]; |
数组指针(1 个) | 指向含 4 个 int 的数组 |
int (*p)(void); |
函数指针 | 指向 int f(void) |
int *f(int); |
函数 | 返回值是 int* |
int (*af[3])(int); |
函数指针数组(3 个) | 每个指向 int f(int) |
4.3 字符数组与字符串
sizeof(s1) == 4,strlen(s1) == 3。- ⚠️ 字符串少了
'\0',strlen、printf("%s")会一路读到越界。 - ⚠️
s2 = "XYZ";只是把指针改指向别处,不是把内容改掉。
4.4 遍历的三种写法
过关:int *p[4] 和 int (*p)[4] 各占多少内存?char *s = "ABC"; s[0] = 'X'; 会发生什么?
第 5 章 指针与函数
5.1 做输入参数
数组、结构体这类"大块数据"一律传地址,不传副本;只读就加 const。
- ⚠️ 传
const int *给一个形参是int *的函数,编译器会报"去掉 const",这是故意的,别用强转糊过去。
5.2 做输出参数(多返回值)
⚠️ 优先级坑:
(*count)++—— 解引用后让数据 +1 ✅*count++—— 等价于*(count++),指针后移、数据没动 ❌(++优先级高于*)
5.3 二级指针:函数要改"指针本身"
传 int *p 只能改 *p(指针指向的内容),改不了 p 自己(因为传的是副本)。要改指针,必须传它的地址:
同一道理:驱动里"传入句柄指针的地址、由函数填好句柄"就是二级指针的典型用法。
5.4 做返回值
⚠️ 返回局部变量地址是致命错误:
安全做法:返回全局/静态变量地址、调用者传进来的缓冲区、或 malloc 的内存(后者要求调用者 free,裸机驱动里一般禁用)。
过关:为什么 void Init(SI2C_TypeDef *h) 里给 h 重新赋值,外面看不到?(*count)++ 和 *count++ 区别在哪?
第 6 章 const 修饰指针的三种形态
口诀:const 离谁近就修饰谁;更稳的方法是从右往左读。
| 写法 | 从右往左读 | 能改指向 | 能改内容 | 典型场景 |
|---|---|---|---|---|
const int *p |
p 是指针,指向 const int | ✅ | ❌ | 只读输入参数 |
int * const p |
p 是 const 指针 | ❌ | ✅ | 固定地址的外设指针 |
const int * const p |
const 指针,指向 const int | ❌ | 只读且地址固定的映射 |
两个补充:
- 只读寄存器写成
const volatile uint32_t *:const表示"你不许写",volatile表示"它会自己变"。 const只在编译期起作用,挡不住硬件层面的写:*(volatile uint32_t *)0x40021018 = 1;绕过 const 照样能改寄存器。
过关:void f(const int *p) 里能不能写 p = q;?能不能写 *p = 1;?
第 7 章 函数指针与回调
这是原稿缺得最多的一章:你敲过的按钮驱动、启动文件里的中断向量表、Bootloader 跳转,全都是函数指针。
7.1 声明与调用
带参数就写全签名:void (*cb)(uint8_t clicks);
用 typedef 让代码能看:
7.2 结构体里放函数指针 = 回调(你敲过的按钮驱动)
button.h 里的原样定义:
驱动内部只负责"检测到事件",具体做什么由使用者填:
这就是解耦:驱动不认识 LED,LED 逻辑也不认识按键。
7.3 中断向量表就是一张函数指针数组
启动文件里那段 DCD 就是在填表:
Stack_Size EQU 0x00001000 ; 铁头山羊模板 4KB(ST 出厂模板常是 0x400)
DCD 0 ; 0x00000000 初始栈顶指针
DCD Reset_Handler ; 0x00000004 复位入口
DCD NMI_Handler ; 0x00000008
DCD HardFault_Handler ; 0x0000000C
...
DCD EXTI0_IRQHandler ; IRQn=6 → 0x00000040 + 4*6 = 0x00000058
- 第 n 个中断的处理函数地址 =
0x00000040 + 4 × IRQn。 - 你在 .c 里写同名函数能顶掉启动文件里的空函数,靠的是启动文件里的
[WEAK]弱定义——这条你已经在中断章用过了。
7.4 表驱动:用函数指针数组替掉大 switch
命令分发、状态机(每个状态一个处理函数)都是这个套路。
7.5 Bootloader 跳转应用
((void (*)(void))(...))() 这一串是函数指针强转的极限练习:先转成"无参无返回值函数指针",再解引用调用。
过关:void (*cb)(uint8_t) 里 cb 是什么?调用前不判空会怎样?中断向量表第 20 号 IRQ 的入口地址是多少?
第 8 章 结构体指针与句柄
8.1 定义与访问
⚠️ 变量用 .、指针用 ->,混用会报 request for member ... in something not a structure or union。
8.2 结构体大小与对齐(原稿说"不会有填充"是错的)
上面这个结构体在 Cortex-M3 上占 20 字节,不是 18:
| 成员 | 偏移 | 大小 |
|---|---|---|
SCL_Port(指针) |
0 | 4 |
SCL_Pin |
4 | 2 |
| —— 填充 | 6 | 2 |
SDA_Port |
8 | 4 |
SDA_Pin |
12 | 2 |
| —— 填充 | 14 | 2 |
Speed |
16 | 4 |
规则:每个成员的起始地址必须是它自身对齐值(=sizeof 基本类型)的整数倍。把同类成员排在一起就能省掉填充:
- 寄存器映射用的结构体(成员全是
uint32_t)确实没有填充——因为全是 4 字节,不是因为"结构体天生无填充"。 - 混合类型又想紧凑:Keil 用
__packed struct,GCC 用__attribute__((packed))。Cortex-M3 上非对齐访问会直接 Fault,慎用。
8.3 为什么都传结构体指针
传值会整份拷贝进栈:结构体越大开销越大、栈越容易溢,而且函数内改不到外面。传指针只压 4 字节地址,还能改原数据——这就是驱动初始化的标准写法。
⚠️ 真实的标准库 GPIO_Init 签名是 void GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *GPIO_InitStruct),第三个参数不是一个模式值。原稿里的三参数写法编译不过。正确形态:
只读的函数写成 uint8_t SI2C_ReadByte(const SI2C_TypeDef *si2c);。
8.4 句柄模式
把外设的状态、配置、引脚全塞进一个结构体,它的指针就叫句柄(Handle)。外部只拿指针操作,不关心内部。
- ⚠️ 写成
static单例,就只能创建一个实例。要支持多路总线,用结构体数组 + 传下标,或者由调用者提供内存(SI2C_Create(SI2C_TypeDef *h, ...)),后者更干净。 - 你之前代码里的
&si2c、&oled就是这个用法:OLED 驱动拿 I2C 句柄操作总线,不需要知道引脚。
8.5 结构体数组批量初始化
8.6 三个注意事项
- 野指针:未初始化的指针不要解引用;定义时不确定就先
= NULL。 - 浅拷贝:
si2c_b = si2c_a;只复制成员值,指针成员复制的是地址,两个结构体指向同一块内存。 - 越界:
p[i]超出数组或寄存器区间,在 STM32 上轻则改坏相邻变量,重则 HardFault。
过关:SI2C_TypeDef 多大?为什么调成员时 . 和 -> 不能混?传结构体和传结构体指针差在哪?
第 9 章 用结构体指针映射寄存器
这是标准库和 HAL 库的底层原理。
9.1 原理
外设的寄存器在地址空间里连续、按偏移排布,和结构体成员的内存布局天然对应。定义一个顺序、宽度都一致的结构体,再把基地址强转成它的指针,就能用 -> 访问所有寄存器。
stm32f10x.h 里就是这么写的:
自己写一份完全等价:
GPIOA->ODR 展开后就是 *(volatile uint32_t *)(0x40010800 + 0x0C)。
9.2 三条硬性要求
- 成员顺序必须和数据手册的偏移完全一致,错一个全体错位。
- 成员宽度必须和寄存器一致,32 位寄存器就用
uint32_t。 - 不能有填充:全
uint32_t天然对齐;一旦混进uint16_t/uint8_t,就要按 8.2 的规则手算偏移,或者用寄存器手册里的"保留位"补一个占位成员。
9.3 为什么优先用 BSRR 而不是 ODR
ODR |= (1<<0) 是读—改—写三条总线操作。如果中途被中断打断,而中断里也改了同一个端口,就会把中断的修改覆盖掉。BSRR/BRR 是只写寄存器,写 1 有效、写 0 无作用,一条写操作完成,天然原子。
9.4 位带(进阶,了解)
Cortex-M3 支持把一个比特映射到一个字地址,写那个字等价于改一位:
别名地址 = 0x42000000 + (字节偏移 × 32) + (位号 × 4)
例:GPIOA.ODR 的第 0 位 → 0x42000000 + 0x1080C×32 + 0 = 0x42210180。等价于 BSRR 的效果,HAL 库的 BIT_BAND_PERIPH 宏就是这个。
过关:GPIOA->BSRR = (1<<0); 和 GPIOA->ODR |= (1<<0); 差别在哪?结构体映射寄存器最容易错在哪一步?
第 10 章 内存布局:指针到底指向什么
| 区域 | STM32F103C8T6 地址 | 放什么 | 掉电 |
|---|---|---|---|
| Flash | 0x08000000 起 64KB |
代码 .text、常量 .rodata(含字符串字面量)、.data 的初值 |
不丢 |
| SRAM | 0x20000000 起 20KB |
.data(已初始化全局)、.bss(未初始化全局,启动时清 0)、堆、栈 |
丢 |
| 外设 | 0x40000000 起 |
寄存器,读写有副作用 | — |
- 栈从高地址向低地址生长。局部变量、函数参数、返回地址都在栈帧里;函数返回,这块栈空间立刻被下一个调用复用。这就是"返回局部数组地址会变野指针"的真正原因——不是内存被"回收",而是内容随时会被别人盖掉。
- 栈大小在启动文件里定义:铁头山羊模板
Stack_Size EQU 0x00001000(4KB),ST 出厂模板常见 0x400(1KB)。几百字节的大数组放局部变量里,栈就崩了(表现为莫名其妙的 HardFault),改成全局或static。 - 堆(
malloc)在裸机上默认很小,且碎片不可控,驱动层一般禁用;要用就静态分配。 - 常量字符串在 Flash:
char *s = "ABC";写s[0]='X'是往 Flash 写,直接 Fault。
过关:为什么返回局部数组地址不一定马上出错,但一定不可靠?static 变量存在哪一段?
第 11 章 文件指针 FILE*(PC 侧,STM32 上只会遇到一次)
FILE * 是标准库文件句柄,指向描述文件的结构体。裸机 STM32 没有文件系统,fopen 依赖半主机或外挂 FatFS,Keil 开 MicroLIB 后半主机会被禁用,所以这一章按 PC 程序理解即可。
| 模式 | 含义 |
|---|---|
"r" |
只读,文件必须存在 |
"w" |
只写,不存在则新建,存在则清空 |
"a" |
追加,不清空 |
"rb"/"wb" |
二进制方式,Windows 下写非文本文件必须加 b |
在 STM32 上你唯一会遇到 FILE * 的地方,是串口重定向(UART 章写过):
这里的 FILE *f 只是个占位形参,我们借的是 fputc 这个函数名,把输出目的地换成 USART1——和文件没关系。
过关:fputc 重定向里为什么要等 TXE 而不是 TC?(提示:等 TC 是"发完这一位都走完",逐字节等 TC 会慢,但等 TXE 更顺。)
第 12 章 高频坑点清单
编译期就能查出来的:
- 变量用
.、指针用->混用。 int *p = 0x40010800;—— 整数变指针缺强转,写(uint32_t *)0x40010800。- 数组传参后还在用
sizeof(a)求长度。 - 把
const指针传给非const形参。 - 结构体只声明没定义就
#include,报dereferencing pointer to incomplete type。 - Keil(ARMCC5)默认 C90,
for (uint8_t i = 0; ...)报#268—— 开 C99 或把声明提到函数开头。
只有跑起来才炸的:
- 野指针:未初始化就解引用;定义时先
= NULL。 - 空指针访问:
fopen、malloc、SI2C_Create的返回值必须判空再用。 - 返回栈地址:局部数组/局部变量的地址被带出函数。
- 函数指针不判空就调用:跳到 0 地址 → HardFault。
- 越界:
p[i]超出数组或寄存器区间,改坏相邻内存或触发 Fault。 *p++vs(*p)++:++优先级高于*。- 少 volatile:寄存器、中断共享变量被优化掉,表现为"死循环出不来""值永远是旧的"。
- 结构体映射错位:成员顺序/宽度不对,或混类型产生填充。
memset(p, 0, sizeof(p))在指针上只清 4 字节。- 二级指针:想在函数里改调用者的指针却只传了一级。
- 浅拷贝:结构体赋值只拷指针值,两份"句柄"共用一块内存。
- 大数组放栈上:栈只有几 KB,改全局或
static。 - ODR 读改写与中断并发冲突,改用 BSRR/BRR。
- 往 Flash 写:
char *s="ABC"; s[0]='X';。
第 13 章 编译报错与调试对照
13.1 常见报错 → 病因
| 报错关键词 | 真实原因 |
|---|---|
makes pointer from integer without a cast |
把整数/函数返回值直接赋给指针,缺 (类型 *) |
assignment makes integer from pointer |
反过来,把指针赋给了整数 |
request for member 'x' in something not a structure or union |
用 . 访问了指针,应改 -> |
incompatible type for argument 1 |
传了结构体变量却声明要指针,或数组维度不匹配 |
dereferencing pointer to incomplete type |
只 struct X; 声明过,定义没被 include |
#268 declaration may not appear after executable statement |
C90 不允许在语句后声明变量,开 C99 |
13.2 Keil 里怎么看指针
- Watch 窗口同时加三个表达式:
p(地址值)、*p(内容)、p[0]、p[1](数组前几个)。指针为0x0800xxxx说明指到 Flash 了,0x2000xxxx是 SRAM,NULL就是没赋上。 - Memory 窗口(
View → Memory Windows → Memory 1)里敲地址,例如0x40010800,能直接看到 GPIOA 七个寄存器的当前值——和GPIOA->CRL对照最直观。 - 小端存储:
0x12345678在内存里按78 56 34 12排。用uint8_t *去看一个uint32_t就能亲眼验证。 - 串口打点:
My_USART_Printf("p=0x%08X v=%d\r\n", (uint32_t)p, *p);
过关:request for member ... not a structure or union 通常是什么错?怎么确认一个指针真的指向 SRAM?
第 14 章 面试与自测
1. 指针和地址的区别? 地址是一个数值编号;指针是一种变量类型,存地址,并带有类型信息(决定解引用宽度和步长)。
2. 指针类型影响指针自身的大小吗? 不影响。同一平台上所有对象指针都是 4 字节(32 位机);类型只影响解引用和运算。
3. sizeof 一个指针和它指向的数组,分别得到什么?
指针 4 字节;数组名在 sizeof 里不退化,得到整个数组字节数。数组一旦作为函数参数就退化成指针,sizeof 变 4。
4. 数组名和 &a 的区别?
数值相同;a 退化后类型 int*,&a 类型 int(*)[10],&a+1 跳整个数组。
5. const int *p 与 int * const p?
前者指向的内容不可改、指针可改;后者指针不可改、内容可改。口诀:const 离谁近修饰谁。
6. 为什么函数不能修改调用者的指针? C 是值传递,形参是指针的副本;要改指针本身必须传二级指针。
7. 为什么不能返回局部变量的地址?
局部变量在栈上,函数返回后栈帧被复用,内容随时被覆盖,指针变成野指针。用全局、static 或调用者提供的缓冲区。
8. volatile 的作用?三个必须用的场景?
禁止编译器优化掉对该地址的读写。硬件寄存器、中断共享变量、多线程/DMA 改写的缓冲。
9. *p++ 和 (*p)++ 的区别?
++ 优先级高于 *:前者指针后移、数据不变;后者数据 +1。
10. 用结构体映射寄存器要满足什么? 成员顺序与手册偏移一致、宽度与寄存器一致、不能有填充;成员全 4 字节时天然对齐。
11. 函数指针能干什么? 回调(把行为交给使用者)、中断向量表、表驱动状态机/命令分发、Bootloader 跳转。
12. 中断向量表在 STM32 上是什么? Flash 起始处一张地址表:0x0 是初始 MSP,0x4 是复位入口,0x40 起第 n 个是 IRQ n 的处理函数地址。
13. GPIOA->ODR |= 1; 有什么隐患?
读改写非原子,被中断打断时可能覆盖中断的修改;改用只写的 BSRR/BRR。
14. 结构体成员顺序影响大小吗? 影响。按对齐值从大到小排列可消除填充;示例里 20 字节的结构体重排后 16 字节。
附:三句话记住整篇
- 指针 = 带类型的地址;类型决定读多宽和走多大步。
- 一切寄存器访问都是
*(volatile 类型 *)(基地址 + 偏移),结构体只是把这条式子写得能读。 - 指针的坑九成来自两件事:生命周期(指向的东西还活着吗)和边界(这个地址归你管吗)。