Embedded CPointer notes · one page
单页连排 · 批注可同步

Embedded C / pointer notes

嵌入式 C 指针学习指南

14 章连排 · 同一页 面向 STM32F103 + 标准库 批注默认存本机

从地址与三个符号,一路到指针类型、数组、函数指针、结构体句柄和寄存器映射。全文连排在同一页,顶部章节条随时跳转,代码窗可折叠、可复制。

读法:每章末尾有「过关」两三个问题,答不出就回本章重看。标 ⚠️ 的是会直接导致编译失败、HardFault 或"看着对其实错"的地方。 环境对应:STM32F103C8T6(Cortex-M3,32 位,小端)、Keil + StdPeriph V3.5、MicroLIB。

目录 / Contents14 章 · 37 节
  1. 01地址、指针与三个符号
  2. 1.1 两个概念
  3. 1.2 三个符号
  4. 1.3 指针自己也是变量
  5. 02指针类型、强制转换与 volatile
  6. 2.1 指针类型决定什么
  7. 2.2 强制类型转换:操作硬件的核心语法
  8. 2.3 为什么必须有 volatile
  9. 2.4 位操作三件套
  10. 03指针运算与 sizeof 陷阱
  11. 3.1 加减:按类型步长偏移
  12. 3.2 相减与比较
  13. 3.3 数组下标就是指针偏移
  14. 3.4 sizeof 陷阱(最高频)
  15. 04指针与数组
  16. 4.1 数组名什么时候不退化
  17. 4.2 指针数组、数组指针、函数指针的读法
  18. 4.3 字符数组与字符串
  19. 4.4 遍历的三种写法
  20. 05指针与函数
  21. 5.1 做输入参数
  22. 5.2 做输出参数(多返回值)
  23. 5.3 二级指针:函数要改"指针本身"
  24. 5.4 做返回值
  25. 06const 修饰指针的三种形态
  26. 07函数指针与回调
  27. 7.1 声明与调用
  28. 7.2 结构体里放函数指针 = 回调(你敲过的按钮驱动)
  29. 7.3 中断向量表就是一张函数指针数组
  30. 7.4 表驱动:用函数指针数组替掉大 switch
  31. 7.5 Bootloader 跳转应用
  32. 08结构体指针与句柄
  33. 8.1 定义与访问
  34. 8.2 结构体大小与对齐(原稿说"不会有填充"是错的)
  35. 8.3 为什么都传结构体指针
  36. 8.4 句柄模式
  37. 8.5 结构体数组批量初始化
  38. 8.6 三个注意事项
  39. 09用结构体指针映射寄存器
  40. 9.1 原理
  41. 9.2 三条硬性要求
  42. 9.3 为什么优先用 BSRR 而不是 ODR
  43. 9.4 位带(进阶,了解)
  44. 10内存布局:指针到底指向什么
  45. 11文件指针 FILE*(PC 侧,STM32 上只会遇到一次)
  46. 12高频坑点清单
  47. 13编译报错与调试对照
  48. 13.1 常见报错 → 病因
  49. 13.2 Keil 里怎么看指针
  50. 14面试与自测
  51. 附:三句话记住整篇

第 1 章 地址、指针与三个符号

1.1 两个概念

  • 地址:内存中每个字节的编号,本质是一个无符号整数。STM32 上常见三段:0x08000000 起是 Flash,0x20000000 起是 SRAM,0x40000000 起是外设寄存器。
  • 指针:专门存地址的变量。指针的值是一个地址。

1.2 三个符号

写法 名字 含义
&x 取地址 得到变量 x 自己的地址
*p 解引用 顺着 p 里的地址去读写那个单元
p 指针本身 一个地址值
C6 lines
int a = 10;
int *p = &a; /* p 的值是 a 的地址 */
printf("%d", a); /* 直接访问 */
printf("%d", *p); /* 间接访问,结果一样 */
*p = 20; /* 等价于 a = 20 */

1.3 指针自己也是变量

C3 lines
int a = 10;
int *p = &a; /* p = &a,*p = 10,&p = 指针自己的地址 */
int **pp = &p; /* 二级指针:存的是 p 的地址,*pp = p,**pp = 10 */
  • 在 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 指针类型决定什么

  1. 解引用的宽度:char* 读写 1 字节,uint16_t* 读写 2 字节,uint32_t* 读写 4 字节。
  2. 运算的步长:p+1 实际加的是 sizeof(指向的类型) 字节。
  3. 成员的解释方式:同一个地址,按 GPIO_TypeDef* 看是寄存器组,按 uint8_t* 看就是一串字节。

不决定的:指针自身的大小(永远是 4 字节)。

2.2 强制类型转换:操作硬件的核心语法

(目标指针类型) 地址数值

把 #define RCC_APB2ENR (*(volatile uint32_t *)0x40021018) 拆开读,从里往外三步:

步骤 片段 作用
① (uint32_t *)0x40021018 把常数地址变成"指向 32 位量的指针"
② (volatile ...) 声明这块内存随时会被外部改变,禁止编译器优化掉读写
③ *(...) 解引用,于是这个宏用起来就像一个普通变量

2.3 为什么必须有 volatile

C7 lines
uint8_t flag = 0; /* 在中断里被置 1 */
void main_loop(void)
{
while (flag == 0) {;} /* 没加 volatile:编译器认为循环内 flag 不会变,
优化成 while(1) 死循环,永远等不到中断 */
}

三个必加的场景:硬件寄存器、中断与主循环共享的变量、可能被信号/异步事件改变的量。标准库把寄存器结构体成员全写成 __IO,而 __IO 就是 volatile(__I = volatile const,__O = volatile)。

2.4 位操作三件套

C3 lines
RCC->APB2ENR |= (1 << 2); /* 置位:开 GPIOA 时钟 */
RCC->APB2ENR &= ~(1 << 2); /* 清位 */
RCC->APB2ENR ^= (1 << 2); /* 翻转 */
  • ⚠️ 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
C4 lines
volatile unsigned char *p = (volatile unsigned char *)0x200000F1;
*p; /* 0x200000F1 */
*(p + 1); /* 0x200000F2 */
*(p + 2); /* 0x200000F3 */
  • ⚠️ 演示写操作要用 SRAM 地址(0x2000xxxx)。0x000000F1 落在 Flash/别名区,只能读,写会触发 HardFault。

3.2 相减与比较

  • 同数组内两个指针相减,结果是元素个数(类型 ptrdiff_t),不是字节数:(p3 - p0) == 3。
  • 求字节数要 sizeof(*p) * (q - p)。
  • <、> 比较只在同一数组内部有意义,跨对象的比较结果是未定义的。
  • void * 不能 ++、不能解引用(不知道宽度);但它可以在任何对象指针之间自由赋值,不需要强转。

3.3 数组下标就是指针偏移

C1 lines
array[i] == *(array + i) /* 两种写法编译结果完全相同 */

3.4 sizeof 陷阱(最高频)

C11 lines
void f(int a[10])
{
/* 这里 a 已经是指针:sizeof(a) == 4,不是 40 */
}
int main(void)
{
int a[10];
sizeof(a); /* 40:数组名在 sizeof 里不退化 */
sizeof(a)/sizeof(a[0]); /* 10:这是求元素个数的标准写法 */
}
  • ⚠️ 数组一旦传进函数就变成指针,sizeof 立刻失效,必须额外传长度。
  • ⚠️ memset(buf, 0, sizeof(buf)) 只有当 buf 是数组本体时才对;如果它是形参或指针,只清了 4 个字节。

过关:uint32_t *p; p += 3; 地址加了几?int a[10]; sizeof(a) 和传进函数后的 sizeof(a) 各是多少?


第 4 章 指针与数组

4.1 数组名什么时候不退化

数组名在表达式里通常退化为「首元素地址」,只有两个例外:

C5 lines
int a[10];
sizeof(a); /* 例外 1:不退化,得到整个数组 40 字节 */
&a; /* 例外 2:不退化,类型是 int (*)[10],&a + 1 跳 40 字节 */
&a[0]; /* 类型是 int*,+1 跳 4 字节 */

&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 字符数组与字符串

C2 lines
char s1[] = "ABC"; /* 数组,占 4 字节(含 '\0'),可修改 */
char *s2 = "ABC"; /* 指针,指向常量区(STM32 上是 Flash),改它 = HardFault */
  • sizeof(s1) == 4,strlen(s1) == 3。
  • ⚠️ 字符串少了 '\0',strlen、printf("%s") 会一路读到越界。
  • ⚠️ s2 = "XYZ"; 只是把指针改指向别处,不是把内容改掉。

4.4 遍历的三种写法

C5 lines
int a[10] = {0};
for (int i = 0; i < 10; i++) a[i] = i; /* 下标 */
for (int i = 0; i < 10; i++) *(a + i) = i; /* 指针偏移 */
for (int *p = a; p < a + 10; p++) *p = 0; /* 指针自增,最快 */

过关:int *p[4] 和 int (*p)[4] 各占多少内存?char *s = "ABC"; s[0] = 'X'; 会发生什么?


第 5 章 指针与函数

5.1 做输入参数

数组、结构体这类"大块数据"一律传地址,不传副本;只读就加 const。

C1 lines
int FindMax(const int *array, int length); /* 函数内改不了数组内容 */
  • ⚠️ 传 const int * 给一个形参是 int * 的函数,编译器会报"去掉 const",这是故意的,别用强转糊过去。

5.2 做输出参数(多返回值)

C16 lines
void FindMaxAndCount(int *max, int *count, const int *array, int length)
{
if (array == NULL || length <= 0) return; /* 入参校验 */
*max = array[0];
*count = 1;
for (int i = 1; i < length; i++)
{
if (array[i] > *max) { *max = array[i]; *count = 1; }
else if (array[i] == *max) { (*count)++; } /* ⚠️ 括号不能省 */
}
}
/* 调用 */
int a[] = {13, 2, 30, 5, 30, 4}, Max, Count;
FindMaxAndCount(&Max, &Count, a, sizeof(a)/sizeof(a[0])); /* Max=30, Count=2 */

⚠️ 优先级坑:

  • (*count)++ —— 解引用后让数据 +1 ✅
  • *count++ —— 等价于 *(count++),指针后移、数据没动 ❌(++ 优先级高于 *)

5.3 二级指针:函数要改"指针本身"

传 int *p 只能改 *p(指针指向的内容),改不了 p 自己(因为传的是副本)。要改指针,必须传它的地址:

C8 lines
/* 错:外面 p 仍是 NULL */
void Alloc1(int *p, int n) { p = malloc(n * sizeof(int)); }
/* 对:通过二级指针改外面的 p */
void Alloc2(int **pp, int n) { *pp = malloc(n * sizeof(int)); }
int *buf = NULL;
Alloc2(&buf, 10); /* 现在 buf 才真的拿到地址 */

同一道理:驱动里"传入句柄指针的地址、由函数填好句柄"就是二级指针的典型用法。

5.4 做返回值

C2 lines
int Time[] = {23, 59, 55}; /* 全局数组,生命周期覆盖全程 */
int *GetTime(void) { return Time; }

⚠️ 返回局部变量地址是致命错误:

C5 lines
int *GetTime_Bad(void)
{
int t[] = {23, 59, 55}; /* 栈上,函数返回即失效 */
return t; /* 野指针 */
}

安全做法:返回全局/静态变量地址、调用者传进来的缓冲区、或 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 声明与调用

C6 lines
void Led_On(void);
void (*cb)(void) = Led_On; /* cb 是函数指针,指向 void f(void) */
cb(); /* 调用,等价于 (*cb)(); */
if (cb != NULL) cb(); /* ⚠️ 调用前必须判空,跳到 0 地址直接 HardFault */

带参数就写全签名:void (*cb)(uint8_t clicks);

用 typedef 让代码能看:

C2 lines
typedef void (*ClickCB)(uint8_t clicks);
ClickCB on_click; /* 比 void (*on_click)(uint8_t) 好读得多 */

7.2 结构体里放函数指针 = 回调(你敲过的按钮驱动)

button.h 里的原样定义:

C14 lines
typedef struct
{
GPIO_TypeDef *GPIOx;
uint16_t GPIO_Pin;
void (*button_pressed_cb)(void); /* 按下 */
void (*button_released_cb)(void); /* 松开 */
void (*button_clicked_cb)(uint8_t clicks); /* 敲击次数 */
void (*button_long_pressed_cb)(uint8_t ticks);
uint32_t LongPressTime;
uint32_t LongPressTickInterval;
uint32_t ClickInterval;
} Button_InitTypeDef;

驱动内部只负责"检测到事件",具体做什么由使用者填:

C6 lines
void My_Click(uint8_t n) { /* 单击就切灯 */ }
Button_InitTypeDef btn = {0};
btn.GPIOx = GPIOA;
btn.GPIO_Pin = GPIO_Pin_0;
btn.button_clicked_cb = My_Click; /* 把函数地址交给驱动 */

这就是解耦:驱动不认识 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

C8 lines
typedef void (*CmdCB)(void);
static const CmdCB CmdTable[4] = { Led_Toggle, Buzzer_Beep, Oled_Clear, Sys_Reset };
void RunCmd(uint8_t id)
{
if (id < 4 && CmdTable[id] != NULL) CmdTable[id]();
}

命令分发、状态机(每个状态一个处理函数)都是这个套路。

7.5 Bootloader 跳转应用

C9 lines
#define APP_BASE 0x08004000
void JumpToApp(void)
{
/* 1. 把应用的初始栈顶装进 MSP */
__set_MSP(*(uint32_t *)APP_BASE);
/* 2. 取应用的复位入口地址,转成函数指针并调用 */
((void (*)(void))(*((uint32_t *)(APP_BASE + 4))))();
}

((void (*)(void))(...))() 这一串是函数指针强转的极限练习:先转成"无参无返回值函数指针",再解引用调用。

过关:void (*cb)(uint8_t) 里 cb 是什么?调用前不判空会怎样?中断向量表第 20 号 IRQ 的入口地址是多少?


第 8 章 结构体指针与句柄

8.1 定义与访问

C13 lines
typedef struct {
GPIO_TypeDef *SCL_Port;
uint16_t SCL_Pin;
GPIO_TypeDef *SDA_Port;
uint16_t SDA_Pin;
uint32_t Speed;
} SI2C_TypeDef;
SI2C_TypeDef si2c; /* 变量:用 . */
SI2C_TypeDef *psi2c = &si2c; /* 指针:用 -> */
si2c.SCL_Pin = GPIO_Pin_6;
psi2c->SCL_Pin = GPIO_Pin_6; /* 完全等价于 (*psi2c).SCL_Pin */

⚠️ 变量用 .、指针用 ->,混用会报 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 基本类型)的整数倍。把同类成员排在一起就能省掉填充:

C7 lines
typedef struct { /* 3 个指针 + 2 个 uint16_t = 16 字节 */
GPIO_TypeDef *SCL_Port;
GPIO_TypeDef *SDA_Port;
uint32_t Speed;
uint16_t SCL_Pin;
uint16_t SDA_Pin;
} SI2C_TypeDef;
  • 寄存器映射用的结构体(成员全是 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),第三个参数不是一个模式值。原稿里的三参数写法编译不过。正确形态:

C20 lines
void My_SI2C_Init(SI2C_TypeDef *si2c)
{
GPIO_InitTypeDef g = {0};
g.GPIO_Pin = si2c->SCL_Pin;
g.GPIO_Mode = GPIO_Mode_Out_OD; /* 软件 I2C:开漏,靠外部上拉 */
g.GPIO_Speed = GPIO_Speed_10MHz;
GPIO_Init(si2c->SCL_Port, &g);
g.GPIO_Pin = si2c->SDA_Pin;
GPIO_Init(si2c->SDA_Port, &g);
}
int main(void)
{
SI2C_TypeDef si2c = {0};
si2c.SCL_Port = GPIOB; si2c.SCL_Pin = GPIO_Pin_6;
si2c.SDA_Port = GPIOB; si2c.SDA_Pin = GPIO_Pin_7;
My_SI2C_Init(&si2c); /* 传地址 */
}

只读的函数写成 uint8_t SI2C_ReadByte(const SI2C_TypeDef *si2c);。

8.4 句柄模式

把外设的状态、配置、引脚全塞进一个结构体,它的指针就叫句柄(Handle)。外部只拿指针操作,不关心内部。

C10 lines
static SI2C_TypeDef si2c1; /* 静态:避免返回栈地址 */
SI2C_TypeDef *SI2C_Create(GPIO_TypeDef *scl_p, uint16_t scl_n,
GPIO_TypeDef *sda_p, uint16_t sda_n)
{
si2c1.SCL_Port = scl_p; si2c1.SCL_Pin = scl_n;
si2c1.SDA_Port = sda_p; si2c1.SDA_Pin = sda_n;
My_SI2C_Init(&si2c1);
return &si2c1;
}
  • ⚠️ 写成 static 单例,就只能创建一个实例。要支持多路总线,用结构体数组 + 传下标,或者由调用者提供内存(SI2C_Create(SI2C_TypeDef *h, ...)),后者更干净。
  • 你之前代码里的 &si2c、&oled 就是这个用法:OLED 驱动拿 I2C 句柄操作总线,不需要知道引脚。

8.5 结构体数组批量初始化

C10 lines
SI2C_TypeDef i2c_list[2] = {
{ GPIOB, GPIO_Pin_6, GPIOB, GPIO_Pin_7 }, /* 少写的 Speed 自动补 0 */
{ GPIOA, GPIO_Pin_0, GPIOA, GPIO_Pin_1 },
};
void SI2C_InitAll(void)
{
for (SI2C_TypeDef *p = i2c_list; p < i2c_list + 2; p++)
My_SI2C_Init(p); /* p++ 一次跳 sizeof(SI2C_TypeDef) 字节 */
}

8.6 三个注意事项

  1. 野指针:未初始化的指针不要解引用;定义时不确定就先 = NULL。
  2. 浅拷贝:si2c_b = si2c_a; 只复制成员值,指针成员复制的是地址,两个结构体指向同一块内存。
  3. 越界:p[i] 超出数组或寄存器区间,在 STM32 上轻则改坏相邻变量,重则 HardFault。

过关:SI2C_TypeDef 多大?为什么调成员时 . 和 -> 不能混?传结构体和传结构体指针差在哪?


第 9 章 用结构体指针映射寄存器

这是标准库和 HAL 库的底层原理。

9.1 原理

外设的寄存器在地址空间里连续、按偏移排布,和结构体成员的内存布局天然对应。定义一个顺序、宽度都一致的结构体,再把基地址强转成它的指针,就能用 -> 访问所有寄存器。

stm32f10x.h 里就是这么写的:

C11 lines
typedef struct {
__IO uint32_t CRL; /* 0x00 */
__IO uint32_t CRH; /* 0x04 */
__IO uint32_t IDR; /* 0x08 */
__IO uint32_t ODR; /* 0x0C */
__IO uint32_t BSRR; /* 0x10 */
__IO uint32_t BRR; /* 0x14 */
__IO uint32_t LCKR; /* 0x18 */
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE) /* GPIOA_BASE = 0x40010800 */

自己写一份完全等价:

C7 lines
typedef struct { uint32_t CRL, CRH, IDR, ODR, BSRR, BRR, LCKR; } GPIO_t;
#define MY_GPIOA ((volatile GPIO_t *)0x40010800)
MY_GPIOA->ODR |= (1 << 0); /* PA0 置高(读改写) */
MY_GPIOA->BSRR = (1 << 0); /* PA0 置高(原子写,推荐) */
MY_GPIOA->BRR = (1 << 0); /* PA0 置低 */
MY_GPIOA->CRL = 0x33333333; /* 低 8 个引脚:推挽输出 50MHz */

GPIOA->ODR 展开后就是 *(volatile uint32_t *)(0x40010800 + 0x0C)。

9.2 三条硬性要求

  1. 成员顺序必须和数据手册的偏移完全一致,错一个全体错位。
  2. 成员宽度必须和寄存器一致,32 位寄存器就用 uint32_t。
  3. 不能有填充:全 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 程序理解即可。

C10 lines
#include <stdio.h>
int main(void)
{
FILE *f = fopen("D:\\test.txt", "w");
if (f == NULL) { printf("打开失败\n"); return 1; } /* 判空是必须的 */
fputc('A', f);
fclose(f); /* 刷新缓冲,必须调 */
return 0;
}
模式 含义
"r" 只读,文件必须存在
"w" 只写,不存在则新建,存在则清空
"a" 追加,不清空
"rb"/"wb" 二进制方式,Windows 下写非文本文件必须加 b

在 STM32 上你唯一会遇到 FILE * 的地方,是串口重定向(UART 章写过):

C6 lines
int fputc(int ch, FILE *f) /* 强定义覆盖库里的弱实现 */
{
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
USART_SendData(USART1, (uint8_t)ch);
return ch;
}

这里的 FILE *f 只是个占位形参,我们借的是 fputc 这个函数名,把输出目的地换成 USART1——和文件没关系。

过关:fputc 重定向里为什么要等 TXE 而不是 TC?(提示:等 TC 是"发完这一位都走完",逐字节等 TC 会慢,但等 TXE 更顺。)


第 12 章 高频坑点清单

编译期就能查出来的:

  1. 变量用 .、指针用 -> 混用。
  2. int *p = 0x40010800; —— 整数变指针缺强转,写 (uint32_t *)0x40010800。
  3. 数组传参后还在用 sizeof(a) 求长度。
  4. 把 const 指针传给非 const 形参。
  5. 结构体只声明没定义就 #include,报 dereferencing pointer to incomplete type。
  6. Keil(ARMCC5)默认 C90,for (uint8_t i = 0; ...) 报 #268 —— 开 C99 或把声明提到函数开头。

只有跑起来才炸的:

  1. 野指针:未初始化就解引用;定义时先 = NULL。
  2. 空指针访问:fopen、malloc、SI2C_Create 的返回值必须判空再用。
  3. 返回栈地址:局部数组/局部变量的地址被带出函数。
  4. 函数指针不判空就调用:跳到 0 地址 → HardFault。
  5. 越界:p[i] 超出数组或寄存器区间,改坏相邻内存或触发 Fault。
  6. *p++ vs (*p)++:++ 优先级高于 *。
  7. 少 volatile:寄存器、中断共享变量被优化掉,表现为"死循环出不来""值永远是旧的"。
  8. 结构体映射错位:成员顺序/宽度不对,或混类型产生填充。
  9. memset(p, 0, sizeof(p)) 在指针上只清 4 字节。
  10. 二级指针:想在函数里改调用者的指针却只传了一级。
  11. 浅拷贝:结构体赋值只拷指针值,两份"句柄"共用一块内存。
  12. 大数组放栈上:栈只有几 KB,改全局或 static。
  13. ODR 读改写与中断并发冲突,改用 BSRR/BRR。
  14. 往 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 里怎么看指针

  1. Watch 窗口同时加三个表达式:p(地址值)、*p(内容)、p[0]、p[1](数组前几个)。指针为 0x0800xxxx 说明指到 Flash 了,0x2000xxxx 是 SRAM,NULL 就是没赋上。
  2. Memory 窗口(View → Memory Windows → Memory 1)里敲地址,例如 0x40010800,能直接看到 GPIOA 七个寄存器的当前值——和 GPIOA->CRL 对照最直观。
  3. 小端存储:0x12345678 在内存里按 78 56 34 12 排。用 uint8_t * 去看一个 uint32_t 就能亲眼验证。
  4. 串口打点: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 类型 *)(基地址 + 偏移),结构体只是把这条式子写得能读。
  • 指针的坑九成来自两件事:生命周期(指向的东西还活着吗)和边界(这个地址归你管吗)。