《嵌入式工程师的软硬件修仙之路》第 2 期|C 语言筑基:指针与内存模型
导读 :第 1 期我们用"点亮一颗 LED"看清了软硬件全链路,也见到了
*(volatile uint32_t*)0x40020014这种"魔法代码"。它到底是什么?凭什么就能操作寄存器?这一期我们从"变量在内存里长什么样"讲起,一步步推出指针的本质、数组与指针的关系、const/volatile 的用途,最后画出 Cortex-M 的内存全景。读完你会明白:指针就是地址的"盒子",读懂它,寄存器操作、DMA、回调、RTOS 全都一通百通。
一、先回答一个问题:变量到底存在哪里
假设你在 STM32 里写了这样一行:
c
int a = 0x12345678;
编译器会为 a 分配 4 字节(STM32 上 int 为 32 位),位置在 RAM 的某个地址,比如 0x20000000。那么"变量 a"由三件事共同定义:起始地址 、类型(决定占几个字节) 、值 。用 &a 可以取出它的地址,用 sizeof(a) 得到 4。
关键问题:这 4 个字节在内存里怎么排?STM32 是小端(little-endian)------低位字节存低地址:
地址 内容(每格 1 字节)
0x20000000 | 0x78 | ← a 的最低字节(低位)
0x20000001 | 0x56 |
0x20000002 | 0x34 |
0x20000003 | 0x12 | ← a 的最高字节(高位)
推导技巧(逐字节展开) :0x12345678 从右往左每两位一个字节 → 78、56、34、12;小端规则 → 低字节(78) 放最低地址。这解释了为什么调试器里看内存是 78 56 34 12,而不是 12 34 56 78。

图2-1 变量 a 的小端内存布局:低字节存低地址;指针 p 是装着 a 的地址的另一个变量,解引用 *p 按 int 宽度读/写 4 字节。
二、指针的本质:装地址的盒子
指针就是存放地址的变量:
c
int *p = &a; // p 里存的是 a 的地址 0x20000000
*p = 0x11111111; // 解引用:往 p 指向的地址写 4 字节
p本身也是变量,32 位 MCU 上占 4 字节,它也有自己的地址&p;*p的含义是"读取 p 里的地址,按 p 的类型访问那片内存";- 类型决定了读写宽度 :
int *p解引用读/写 4 字节,char *q只读/写 1 字节,uint16_t *r读/写 2 字节。同一条地址线,声明成不同指针类型,操作的内存宽度完全不同------这是嵌入式里最常出错的点之一。
为什么第 1 期的代码要写 *(volatile uint32_t*)0x40020014 |= (1U << 12);?拆解:
0x40020014是 GPIOA 的 ODR 寄存器地址(整数);(uint32_t*)把它强制转换成"指向 32 位无符号整数的指针"(技巧:地址→类型化指针);volatile告诉编译器"这块内存会被硬件改变,禁止优化读写";*解引用得到该寄存器的"化身",|=读-改-写位 12。
类型转换是这里的关键技巧:CPU 不知道 0x40020014 是什么,类型化指针告诉它"按 4 字节访问、当作无符号整数处理"。
三、数组名与指针算术
数组和指针是嵌入式新手最容易混淆的一对。记住一条铁律:
a[i] ≡ *(a + i)
其中 a 是数组名,可以看作"指向首元素的指针"(注意 sizeof(a) 例外------它返回整个数组的大小)。
推导 :a + i 不是"地址加 i",而是加 i 个元素宽度:
地址(a + i) = 地址(a) + i × sizeof(元素类型)
例:uint16_t arr[4] 起始于 0x20000100:
arr[0] → 0x20000100 arr[1] → 0x20000102
arr[2] → 0x20000104 arr[3] → 0x20000106
推导技巧(宽度乘法) :因为元素是 2 字节,所以 arr + 1 真实偏移是 1 × 2 = 2 字节,而不是 1 字节。同理 p++ 移动 sizeof(*p) 个字节------这在遍历数组、DMA 搬运、帧解析时无处不在。

图2-2 数组与指针算术:a[i] ≡ *(a+i),指针加减按 sizeof(*p) 移动------arr+2 真实偏移 4 字节(2 个元素 × 2 字节)。
工程例子:串口收到一帧 8 字节数据存入 uint8_t buf[8],想解析第 3、4 字节组成的 16 位长度字段,直接:
c
uint16_t len = *(uint16_t*)&buf[2]; // 强制转换 + 解引用
注意这里隐含"地址要 2 字节对齐"的前提(见第六节)。
四、const/volatile:寄存器操作的血缘
volatile:防止优化 。编译器看到*(uint32_t*)0x40020014连续读两次,可能自作聪明合并成一次;加了 volatile 后每次都真实访问。凡是"内存内容会被中断/硬件/DMA 改变"的地方,都要 volatile。const:防止误改 。const int *p表示"p 指向的值不能通过 p 修改",但地址本身可变。
两者不冲突,常一起出现:volatile const 表示"这块内存只读、且会被外部改变"------典型是只读状态寄存器。技巧 :声明外设寄存器时,读寄存器用 volatile const,写寄存器用 volatile,编译期就能拦截低级错误。
五、指针的工程形态
- 函数指针(回调):RTOS 定时器、中断回调、按键扫描全部依赖它:
c
typedef void (*TimerCb)(void);
void on_tick(void) { /* 定时器到期处理 */ }
TimerCb cb = on_tick; // 函数名就是它的地址
cb(); // 等价于 on_tick();
- 指针数组 / 数组指针 :
char *cmds[]命令表、uint8_t *bufs[N]多通道缓冲; - 二级指针:链表、队列的"指向指针的指针",用于修改头节点本身。
六、横向延展:Cortex-M 内存模型与结构体对齐
Cortex-M 的存储映射(以 STM32F4 为例)如图 2-3 所示,可粗分为:

图2-3 Cortex-M 存储映射:Flash 掉电不丢(代码/只读常量/向量表),RAM 掉电即丢(数据段/BSS/栈/堆)。
- Flash(0x08000000 起):代码段、只读常量、启动向量表;
- RAM(0x20000000 起) :数据段(已初始化全局)、BSS 段(未初始化全局)、栈 (向低地址生长)、堆(malloc 使用)。
嵌入式铁律:能静态分配就别 malloc------堆带来碎片化、不确定性,RTOS 任务栈更是必须静态规划。
结构体对齐(必考推导):
c
struct S { char c; int i; };
直觉 sizeof = 5?错。因 int 需 4 字节对齐,i 不能从偏移 1 开始,编译器在 c 后插入 3 字节填充:
偏移 0: c (1 字节)
偏移 1~3: 填充(对齐 padding)
偏移 4~7: i
sizeof(struct S) = 8
推导技巧(对齐规则) :每个成员偏移 = 自身对齐值的最小倍数;结构体总大小 = 最大成员对齐值的倍数。嵌入式中结构体对齐直接影响:协议帧打包、DMA 缓冲区、CAN 报文映射------用 #pragma pack(1) 或 __attribute__((packed)) 可强制紧凑,但会牺牲访问效率,需权衡。
小结
- 变量 = 地址 + 类型 + 值;小端机低字节存低地址;
- 指针是装地址的盒子,类型决定读写宽度;
a[i] ≡ *(a+i),指针加减按sizeof(*p)移动;- volatile 防优化、const 防误改,寄存器操作两者常并用;
- 函数指针/二级指针是回调与链表的基石;
- Cortex-M 内存分区 + 结构体对齐是工程细节的重灾区。
思考题
char c = 0xAB;在小端 STM32 上,c占 1 字节,若取&c后按uint32_t*读取会读到什么?为什么这是未定义行为?uint8_t buf[8]; uint16_t v = *(uint16_t*)&buf[2];若buf起始地址为奇数,会发生什么?动手实验验证。- 写一个函数指针数组,实现 4 种"按键事件"分发,再想想如果用 switch-case 写,差别在哪里。
下期预告
第 3 期「数据结构筑基:环形缓冲区与链表」------串口接收缓存、命令队列、内存池背后的数据结构,以及它们如何在嵌入式里落地。
参考资源
- 《C 程序设计语言》(K&R)第 5 章:指针与数组
- 《C Primer Plus》指针章节
- 《嵌入式 C 语言自我修养:从芯片、编译器到操作系统》第 2 章:内存模型
- ARM Cortex-M3 权威指南:存储器映射 https://developer.arm.com/documentation/dui0552/latest/
- STM32F4 参考手册 RM0090:Flash/RAM 地址映射 https://www.st.com/resource/en/reference_manual/dm00031020-stm32f405-415-stm32f407-417-stm32f427-437-and-stm32f429-439-advanced-armbased-32bit-mcus-stmicroelectronics.pdf
- coolshell 酷壳《C 语言中指针的指针》经典博客
- 野火《零死角玩转STM32---F103指南者》寄存器操作章节