《嵌入式工程师的软硬件修仙之路》第 2 期|C 语言筑基:指针与内存模型

《嵌入式工程师的软硬件修仙之路》第 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);?拆解:

  1. 0x40020014 是 GPIOA 的 ODR 寄存器地址(整数);
  2. (uint32_t*) 把它强制转换成"指向 32 位无符号整数的指针"(技巧:地址→类型化指针);
  3. volatile 告诉编译器"这块内存会被硬件改变,禁止优化读写";
  4. * 解引用得到该寄存器的"化身",|= 读-改-写位 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,编译期就能拦截低级错误。

五、指针的工程形态

  1. 函数指针(回调):RTOS 定时器、中断回调、按键扫描全部依赖它:
c 复制代码
typedef void (*TimerCb)(void);
void on_tick(void) { /* 定时器到期处理 */ }
TimerCb cb = on_tick;   // 函数名就是它的地址
cb();                    // 等价于 on_tick();
  1. 指针数组 / 数组指针 :char *cmds[] 命令表、uint8_t *bufs[N] 多通道缓冲;
  2. 二级指针:链表、队列的"指向指针的指针",用于修改头节点本身。

六、横向延展: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 内存分区 + 结构体对齐是工程细节的重灾区。

思考题

  1. char c = 0xAB; 在小端 STM32 上,c 占 1 字节,若取 &c 后按 uint32_t* 读取会读到什么?为什么这是未定义行为?
  2. uint8_t buf[8]; uint16_t v = *(uint16_t*)&buf[2]; 若 buf 起始地址为奇数,会发生什么?动手实验验证。
  3. 写一个函数指针数组,实现 4 种"按键事件"分发,再想想如果用 switch-case 写,差别在哪里。

下期预告

第 3 期「数据结构筑基:环形缓冲区与链表」------串口接收缓存、命令队列、内存池背后的数据结构,以及它们如何在嵌入式里落地。

参考资源

相关推荐
开开心心就好1 小时前
安卓PDF处理工具,免费无广告零弹窗
android·服务器·开发语言·算法·pdf·ocr
Pniubi1 小时前
HashMap扩容全流程
java·开发语言·数据结构
白杨尚青3 小时前
C++进阶篇(二):多态——虚函数、重写、虚函数表与三个经典坑(附完整代码仓库)
开发语言·c++·多态·虚函数·重写·动态绑定
WJ.Polar21 小时前
Java封装、继承、多态
java·开发语言
摇滚侠21 小时前
Java 中如何表示负数,有符号和无符号的区别,补码的计算规则,char byte short int long 的范围
java·开发语言
weixin_3077791321 小时前
C++代码实现MATLAB中的fitrtree函数功能
开发语言·c++·算法·matlab
爱读书的小胖21 小时前
Codewars:Rot13(C)解题分析
c语言·开发语言
鸽鸽程序猿21 小时前
【项目】【在线判题系统】b端题目增删改
开发语言·python
Lhappy嘻嘻1 天前
读懂 JVM:内存模型、类加载机制、分代回收
java·开发语言·jvm