3. C语言在嵌入式中的核心用法:指针、结构体、位操作、volatile关键字、内存管理
嵌入式C语言,说白了就是一门「与硬件打交道」的语言。你写普通PC程序,操作系统帮你管内存、管外设。但在嵌入式里,你得亲自上阵。指针、结构体、位操作、volatile、内存管理——这五个东西,是嵌入式开发的看家本领。今天我就带你逐个过一遍。
3.1 指针:嵌入式里的「遥控器」
指针是什么?很多人觉得它难,其实它就是个地址。你想想看,你家门牌号就是指针,你本人就是数据。通过门牌号找到你,这就是指针的用法。
在嵌入式里,指针最常用的场景是——直接操作寄存器。比如STM32的GPIO输出寄存器,地址是0x40020C14。你想让它输出高电平,怎么写?
// 直接操作寄存器地址
#define GPIOB_ODR (*(volatile uint32_t *)0x40020C14)
// 置位第0位,让PB0输出高电平
GPIOB_ODR |= (1 << 0);
这里有个细节:(volatile uint32_t *)0x40020C14,先把一个数字强制转成指针,再用*解引用。我刚开始学的时候,总搞不清这个括号顺序。后来我习惯这么记:先告诉编译器「这是个地址」,再告诉它「我要读写这个地址的内容」。
3.2 结构体:把零散的数据「打包」
结构体在嵌入式里,最大的用处是——描述硬件寄存器组。比如一个UART外设,有数据寄存器、状态寄存器、控制寄存器……你一个个定义太麻烦。用结构体,一次搞定。
// 定义UART寄存器结构体
typedef struct {
uint32_t DR; // 数据寄存器,偏移0x00
uint32_t SR; // 状态寄存器,偏移0x04
uint32_t CR1; // 控制寄存器1,偏移0x08
uint32_t CR2; // 控制寄存器2,偏移0x0C
} UART_TypeDef;
// 假设UART1基地址是0x40013800
#define UART1 ((UART_TypeDef *)0x40013800)
// 使用:发送一个字节
UART1->DR = 'A';
为什么这样好用?因为结构体成员的地址偏移是编译器自动算的。你只要保证结构体成员的顺序和硬件寄存器偏移一致就行。我在项目中遇到过一个问题:结构体里插了个8位的变量,结果后面的32位变量地址没对齐,导致程序跑飞。嗯,这里要注意——结构体可能有填充字节,用__attribute__((packed))可以禁用填充。
__attribute__((packed)),确保没有填充。
3.3 位操作:用最少的指令干最多的事
嵌入式里,寄存器经常是32位的,但每一位都有独立含义。比如控制寄存器的第0位是使能位,第1位是中断使能……你不能直接写整个寄存器,否则会破坏其他位的状态。
常用的位操作技巧就这几个:
| 操作 | 写法 | 说明 |
|---|---|---|
| 置位某一位 | REG |= (1 << n); |
将第n位置1 |
| 清零某一位 | REG &= ~(1 << n); |
将第n位清0 |
| 读取某一位 | bit = (REG >> n) & 1; |
获取第n位的值 |
| 翻转某一位 | REG ^= (1 << n); |
将第n位取反 |
| 批量置位 | REG |= mask; |
将mask中为1的位都置1 |
我个人习惯用宏定义把常用操作封装起来:
#define SET_BIT(reg, bit) ((reg) |= (1 << (bit)))
#define CLR_BIT(reg, bit) ((reg) &= ~(1 << (bit)))
#define READ_BIT(reg, bit) (((reg) >> (bit)) & 1)
你想想看,如果不用位操作,你要怎么实现?用if-else判断?那代码又长又慢。位操作一条指令就搞定,效率极高。
3.4 volatile关键字:告诉编译器「别优化我」
volatile是嵌入式里最容易忽略、但最关键的关键字。它的作用是——告诉编译器,这个变量的值可能会被意外改变,每次使用时都必须重新读取。
什么时候用volatile?三种场景:
- 硬件寄存器:寄存器的值可能被外设硬件修改
- 中断服务程序中修改的全局变量:主程序和中断都可能访问
- RTOS中多个任务共享的变量:防止编译器优化导致读取旧值
举个例子:
// 错误写法:没有volatile
uint8_t flag = 0;
void interrupt_handler() {
flag = 1; // 中断里修改flag
}
void main_loop() {
while (flag == 0) {
// 等待中断
}
// 处理事件
}
没有volatile时,编译器可能把flag优化到寄存器里,循环永远读不到新值。程序就卡死了。加上volatile:
volatile uint8_t flag = 0; // 每次从内存读取
我曾经在一个电机控制项目里,漏掉了volatile,结果电机转速一直不对。查了两天才发现是编译器优化搞的鬼。从那以后,所有中断共享变量我都加volatile,成了肌肉记忆。
3.5 内存管理:嵌入式里的「精打细算」
嵌入式系统的内存通常很小,几KB到几MB。你不能像PC那样随便malloc。内存管理在嵌入式里,讲究的是「确定性」和「可控性」。
常用的内存分配方式有三种:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 静态分配 | 编译时确定,速度快,无碎片 | 全局变量、常量数组 |
| 栈分配 | 自动管理,函数返回即释放 | 局部变量、小数据 |
| 堆分配 | 灵活,但有碎片风险 | 动态大小数据、大型缓冲区 |
我个人建议:能用静态分配就别用堆。堆分配(malloc/free)在嵌入式里有很多坑:碎片化、分配时间不确定、内存泄漏。如果你非要用堆,可以考虑固定大小内存池,提前分配好固定大小的块,按需取用。
// 简单的内存池示例
#define POOL_SIZE 10
#define BLOCK_SIZE 64
static uint8_t pool[POOL_SIZE][BLOCK_SIZE];
static uint8_t used[POOL_SIZE] = {0};
void* pool_alloc() {
for (int i = 0; i < POOL_SIZE; i++) {
if (!used[i]) {
used[i] = 1;
return pool[i];
}
}
return NULL; // 没有可用块
}
void pool_free(void* ptr) {
for (int i = 0; i < POOL_SIZE; i++) {
if (pool[i] == ptr) {
used[i] = 0;
return;
}
}
}
这种内存池的好处是:分配和释放的时间是确定的,不会产生碎片。我在一个数据采集项目里用过,效果很好。
3.6 知识体系总览
这五个知识点不是孤立的。它们在实际项目中经常一起出现。比如操作寄存器时,你会用到指针+volatile+位操作;管理外设时,你会用到结构体+指针+内存管理。下面这张图帮你理清它们的关系:
这五个知识点,是嵌入式C语言的基石。指针让你能直接操控硬件,结构体帮你组织数据,位操作让你精细控制寄存器,volatile保证数据一致性,内存管理让你在有限资源下高效运行。每一个都是实战中绕不开的。
嗯,内容就到这里。记住,光看没用,得动手写。找块开发板,把今天讲的代码都跑一遍,遇到问题再回来翻翻。实践出真知。
公众号:蓝海资料掘金营,微信deep3321