第4章 C语言在嵌入式中的应用:位操作、指针与数组、结构体与联合体、内存管理、volatile关键字
嵌入式C语言,说白了就是“贴着硬件写代码”。你在PC上写个死循环顶多CPU飙高,在嵌入式里写错一个位操作,外设可能直接罢工。我这些年带项目,见过太多“PC上跑得好好的,下到板子就崩”的案例,十有八九都是本章这几个点没吃透。
咱们一个一个来拆。
4.1 位操作:嵌入式工程师的“肌肉记忆”
位操作是嵌入式的基本功。寄存器配置、状态标志判断、中断屏蔽……哪哪都离不开它。我个人习惯,拿到一个新MCU,第一件事就是翻头文件里那些位掩码宏定义怎么写。
4.1.1 常用位操作运算符
| 运算符 | 名称 | 典型用途 |
|---|---|---|
| & | 按位与 | 清位、取指定位 |
| | | 按位或 | 置位 |
| ^ | 按位异或 | 翻转位 |
| ~ | 按位取反 | 构造掩码 |
| << | 左移 | 乘2、构造位掩码 |
| >> | 右移 | 除2、提取高位 |
核心原则:读-改-写三步走。先读寄存器当前值,用掩码修改指定位,再写回去。千万别直接赋值,否则会清掉其他位的配置。
4.1.2 实战:GPIO输出配置
假设你要把GPIOB端口的第5脚设为推挽输出。STM32的GPIO配置寄存器长这样:
// 方式1:直接赋值(不推荐,会破坏其他引脚配置)
GPIOB->CRL = 0x00200000; // 危险!
// 方式2:读-改-写(推荐)
GPIOB->CRL &= ~(0xF << 20); // 先清PB5的4位配置
GPIOB->CRL |= (0x3 << 20); // 再设为推挽输出50MHz
我在项目中遇到过有人图省事直接赋值,结果调试了三天才发现把PB0~PB3的配置全冲掉了。嗯,从那以后我团队里谁写直接赋值,代码Review直接打回。
小技巧:多用宏定义把位掩码封装好,比如 #define GPIO_PIN5_MASK (0xF << 20),代码可读性会好很多。
4.2 指针与数组:C语言的灵魂
指针这东西,初学者觉得玄乎,老手觉得顺手。但在嵌入式里,指针用不好就是段错误的温床。你想想看,一个野指针指到外设寄存器地址上,后果是什么?
4.2.1 数组名与指针的关系
数组名就是首元素地址,这个大家都知道。但有个坑:sizeof(数组名) 返回整个数组大小,而 sizeof(指针) 只返回指针本身大小(4或8字节)。
uint8_t buffer[64];
uint8_t *ptr = buffer;
sizeof(buffer); // 64
sizeof(ptr); // 4(32位MCU上)
我曾经在DMA配置时,把 sizeof(buffer) 写成了 sizeof(ptr),结果DMA只传输了4个字节。查了两天才发现……
4.2.2 指针与const的搭配
| 写法 | 含义 | 典型场景 |
|---|---|---|
const uint8_t *p | 指向常量的指针(数据不可改) | 字符串常量、查表数据 |
uint8_t * const p | 常量指针(地址不可改) | 外设寄存器基地址 |
const uint8_t * const p | 常指针指向常量 | 只读外设寄存器 |
注意:在RTOS的任务栈里,局部指针变量默认在栈上分配。如果任务栈太小,指针指向的缓冲区可能溢出。我建议大缓冲区用静态分配或动态分配,别放栈上。
4.3 结构体与联合体:数据组织的利器
结构体用来打包不同类型的数据,联合体用来复用同一段内存。两者结合,在协议解析、寄存器映射场景下特别好用。
4.3.1 结构体对齐问题
这是嵌入式面试高频题。结构体成员按最大对齐字节数对齐,中间可能有填充字节。
typedef struct {
uint8_t a; // 偏移0
uint32_t b; // 偏移4(不是1!)
uint8_t c; // 偏移8
} __attribute__((packed)) MyStruct; // 加packed取消对齐
// 不加packed:sizeof = 12
// 加packed:sizeof = 6
我在做Bootloader时,结构体没加packed,结果解析固件头总是错位。后来发现是编译器自动填充了3个字节。嗯,从此以后,所有通信协议相关的结构体,我必加 __attribute__((packed))。
4.3.2 联合体妙用:寄存器位域访问
typedef union {
uint32_t value;
struct {
uint32_t pin0 : 1;
uint32_t pin1 : 1;
uint32_t pin2 : 1;
uint32_t pin3 : 1;
uint32_t reserved : 28;
} bits;
} GPIO_ODR_Type;
GPIO_ODR_Type *odr = (GPIO_ODR_Type *)0x40020C14;
odr->bits.pin0 = 1; // 只操作第0脚
这样写,代码意图一目了然。但要注意:位域的顺序是编译器相关的,跨平台移植时要小心。
4.4 内存管理:嵌入式系统的命脉
嵌入式内存就那么几百K甚至几十K,用错了就是死机。我见过最惨的一次,是产品量产10K后才发现内存泄漏,召回成本够买一套房了。
4.4.1 嵌入式内存分区
| 区域 | 特点 | 典型用途 |
|---|---|---|
| 栈(Stack) | 自动分配释放,大小固定 | 局部变量、函数调用 |
| 堆(Heap) | 动态分配,需手动释放 | malloc/free,慎用 |
| 全局/静态区 | 程序生命周期内存在 | 全局变量、static变量 |
| 代码区 | 只读,Flash中 | 程序指令、常量 |
4.4.2 动态内存分配:能用静态就别用动态
在RTOS里,我建议尽量用静态分配。为什么?
- 确定性:静态分配在编译时就确定了大小,不会运行时失败。
- 无碎片:动态分配反复malloc/free会产生内存碎片。
- 可预测:栈溢出可以通过MPU检测,堆溢出很难定位。
我的原则:任务栈、消息队列、信号量等RTOS对象,全部静态创建。只有运行时才知道大小的数据(比如变长协议包),才考虑动态分配,而且必须加超时和失败处理。
4.4.3 内存泄漏检测小技巧
如果你非要用动态分配,我建议封装一层:
// 带计数器的malloc封装
void *my_malloc(size_t size) {
void *ptr = malloc(size);
if (ptr) {
malloc_count++;
}
return ptr;
}
void my_free(void *ptr) {
free(ptr);
malloc_count--;
}
// 在空闲任务中打印malloc_count
// 如果持续增长,说明有泄漏
4.5 volatile关键字:告诉编译器“别优化我”
volatile是嵌入式里最容易忽略、也最容易出问题的关键字。它的作用就一句话:告诉编译器,这个变量的值可能被“意料之外”的方式修改,每次访问都必须从内存读取。
4.5.1 什么时候必须用volatile?
- 外设寄存器:硬件可能随时改变寄存器值。
- 中断服务程序中的全局变量:主循环和中断共享的变量。
- RTOS中多任务共享的全局变量:一个任务写,另一个任务读。
4.5.2 血泪教训:不加volatile的后果
// 错误示例:中断标志位没加volatile
uint8_t flag = 0;
void ISR_Handler(void) {
flag = 1; // 中断里置位
}
void main_loop(void) {
while (!flag) {
// 编译器可能优化成:读取一次flag到寄存器,然后死循环
// 因为编译器认为flag永远不会被修改
}
// 处理事件
}
我曾经在电机控制项目里,就因为这个bug,电机死活不响应急停中断。加了volatile之后,问题秒解。你想想看,要是量产了才发现,那得召回多少台?
注意:volatile不能保证原子性。如果多个任务同时读写一个变量,即使加了volatile,也可能出现数据竞争。这时候需要加临界区或互斥锁。
4.6 本章知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当成一个“嵌入式C语言避坑地图”。
本章小结:位操作是基本功,指针是灵魂,结构体联合体是组织数据的利器,内存管理是命脉,volatile是安全锁。把这五个点吃透了,嵌入式C语言这块就算真正入门了。下一章咱们开始聊RTOS的任务管理,到时候你会发现,今天讲的这些知识点,每一个都会反复用到。