2. 嵌入式C语言进阶(一):指针与内存管理、结构体与联合体在寄存器编程中的应用、位操作技巧、volatile与const关键字深度解析
嵌入式C语言,说白了就是跟硬件打交道的语言。你写的那几行代码,最后都要变成寄存器里的0和1。很多从应用层转过来的工程师,刚开始都会觉得嵌入式C就是C语言的一个子集,语法差不多嘛。但真到了项目里,一个指针用错,系统就挂了。一个volatile漏掉,编译器优化能把你的逻辑吃得渣都不剩。
我个人习惯把这一章叫做「嵌入式C的硬核基础」。今天咱们就掰开揉碎,把指针、结构体、位操作、volatile和const这几个关键点讲透。
2.1 指针与内存管理:嵌入式开发的「双刃剑」
指针这东西,用好了是神器,用不好就是定时炸弹。在嵌入式环境里,资源就那么点,RAM可能只有几十KB,你一个野指针写下去,系统直接hard fault。
2.1.1 指针的本质与硬件地址
指针存的是什么?说白了就是地址。在ARM Cortex-M内核里,地址总线是32位的,所以指针就是4个字节。你写 int *p = (int *)0x40020000;,就是把0x40020000这个地址赋给了p。
我记得刚做汽车ECU开发那会儿,有个同事直接写 int *p = 0x40020000;,编译器报warning他也没管。结果跑起来之后,莫名其妙地往0x40020000写数据,那个地址刚好是某个外设的控制寄存器,整个模块直接罢工。嗯,这里要注意:嵌入式里指针一定要强制类型转换,别偷懒。
// 正确的做法
#define GPIOA_BASE (0x40020000U)
#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x14))
// 使用
GPIOA_ODR |= (1 << 5); // 设置PA5输出高电平
2.1.2 函数指针:回调机制的灵魂
在RTOS里,任务函数、中断回调、定时器回调,底层全是函数指针。你想想看,操作系统怎么知道你的任务函数入口在哪?就是通过函数指针传进去的。
// 定义任务函数指针类型
typedef void (*task_func_t)(void *arg);
// RTOS创建任务
void os_task_create(task_func_t task, void *arg, uint32_t stack_size);
// 使用
void my_task(void *arg) {
while(1) {
// 任务代码
}
}
os_task_create(my_task, NULL, 512);
2.1.3 内存对齐与结构体填充
结构体在内存里不是紧挨着存的。编译器会在成员之间插入填充字节,保证每个成员都对齐到它的自然边界。比如一个uint32_t要4字节对齐,uint16_t要2字节对齐。
| 成员类型 | 大小 | 对齐要求 |
|---|---|---|
| uint8_t | 1字节 | 1字节 |
| uint16_t | 2字节 | 2字节 |
| uint32_t | 4字节 | 4字节 |
| uint64_t | 8字节 | 8字节 |
我曾经在项目里遇到过一个坑:定义了一个结构体用来映射CAN报文的数据域,结果因为成员顺序没排好,结构体大小比预期多了4个字节。报文解析的时候,数据全对不上了。排查了半天才发现是编译器填充搞的鬼。
// 不好的顺序:有填充
typedef struct {
uint8_t id; // 1字节
uint32_t data; // 4字节,前面填充3字节
uint16_t crc; // 2字节,后面填充2字节
} __attribute__((packed)) CanFrameBad; // 大小 = 1 + 3 + 4 + 2 + 2 = 12
// 好的顺序:无填充
typedef struct {
uint32_t data; // 4字节
uint16_t crc; // 2字节
uint8_t id; // 1字节,后面填充1字节
} CanFrameGood; // 大小 = 4 + 2 + 1 + 1 = 8
__attribute__((packed))可以强制取消填充,但会牺牲访问效率。在ARM Cortex-M上,非对齐访问会触发异常。能用对齐解决的,就别用packed。
2.2 结构体与联合体:寄存器编程的「瑞士军刀」
寄存器编程是嵌入式开发的日常。每个外设都有一堆寄存器,每个寄存器又有不同的位域。怎么优雅地操作它们?结构体和联合体就是最好的工具。
2.2.1 结构体映射寄存器组
把一组连续的寄存器映射成一个结构体,这是最常用的手法。比如STM32的GPIO寄存器组:
typedef struct {
volatile uint32_t MODER; // 0x00
volatile uint32_t OTYPER; // 0x04
volatile uint32_t OSPEEDR; // 0x08
volatile uint32_t PUPDR; // 0x0C
volatile uint32_t IDR; // 0x10
volatile uint32_t ODR; // 0x14
volatile uint32_t BSRR; // 0x18
volatile uint32_t LCKR; // 0x1C
volatile uint32_t AFR[2]; // 0x20 - 0x24
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000)
#define GPIOB ((GPIO_TypeDef *)0x40020400)
// 使用
GPIOA->MODER |= (0x01 << 10); // 设置PA5为输出模式
你看,这样写代码多清晰。每个寄存器都有名字,不用记那些偏移地址了。而且编译器会帮你做地址计算,效率一点不差。
2.2.2 联合体位域解析
联合体让同一个内存地址可以被不同方式解读。这在解析协议帧、状态寄存器时特别有用。
// CAN状态寄存器
typedef union {
uint32_t word; // 整体访问
struct {
uint32_t rx_pending : 1; // bit0
uint32_t tx_pending : 1; // bit1
uint32_t error : 1; // bit2
uint32_t bus_off : 1; // bit3
uint32_t reserved : 28; // bit4-bit31
} bits;
} CAN_StatusReg;
CAN_StatusReg status;
status.word = CAN->SR; // 读取整个寄存器
if (status.bits.error) {
// 处理错误
}
2.3 位操作技巧:用最少的指令做最多的事
嵌入式里,位操作是基本功。一个优秀的嵌入式工程师,写位操作就像呼吸一样自然。为什么?因为寄存器操作本质上就是位操作。
2.3.1 常用位操作宏
我建议你把下面这些宏定义成通用的,每个项目都能用:
// 设置某位
#define BIT_SET(reg, bit) ((reg) |= (1UL << (bit)))
// 清除某位
#define BIT_CLEAR(reg, bit) ((reg) &= ~(1UL << (bit)))
// 读取某位
#define BIT_READ(reg, bit) (((reg) >> (bit)) & 0x01)
// 翻转某位
#define BIT_TOGGLE(reg, bit) ((reg) ^= (1UL << (bit)))
// 设置多位
#define BITS_SET(reg, mask) ((reg) |= (mask))
// 清除多位
#define BITS_CLEAR(reg, mask) ((reg) &= ~(mask))
// 读取多位
#define BITS_READ(reg, mask) ((reg) & (mask))
2.3.2 实战:配置GPIO复用功能
以STM32的GPIO复用功能配置为例,AFR寄存器每个引脚占4位,需要先清除再设置:
// 设置PA5的复用功能为AF2(比如SPI1_SCK)
#define GPIO_AFRL_AFSEL5_POS (20) // 第5个引脚,偏移5*4=20
#define GPIO_AFRL_AFSEL5_MASK (0x0F << GPIO_AFRL_AFSEL5_POS)
GPIOA->AFR[0] &= ~GPIO_AFRL_AFSEL5_MASK; // 先清除
GPIOA->AFR[0] |= (0x02 << GPIO_AFRL_AFSEL5_POS); // 设置AF2
2.4 volatile与const:编译器的「紧箍咒」
这两个关键字,看起来简单,但用错了后果很严重。volatile告诉编译器「这个变量随时可能变,别给我优化掉」。const告诉编译器「这个变量只读,别让我写」。
2.4.1 volatile的三大应用场景
- 硬件寄存器:寄存器的值可能被外设硬件修改,编译器不知道。
- 中断服务程序中的变量:主循环和中断共享的变量,中断改了,主循环要能读到新值。
- RTOS中的任务间共享变量:一个任务写,另一个任务读,不加volatile可能读到缓存值。
// 错误示例:没有volatile
uint8_t flag = 0;
void ISR_Handler(void) {
flag = 1; // 中断里设置标志
}
void main_loop(void) {
while (!flag) {
// 编译器可能优化成:读取一次flag到寄存器,然后死循环
// 因为编译器认为flag永远不会变
}
// 处理事件
}
// 正确示例:加volatile
volatile uint8_t flag = 0;
void ISR_Handler(void) {
flag = 1;
}
void main_loop(void) {
while (!flag) {
// 每次循环都从内存读取flag
}
}
2.4.2 const的多种用法
const在嵌入式里不只是「只读」那么简单。它还能帮你把数据放到Flash里,节省宝贵的RAM。
// 1. 常量放在Flash
const uint32_t lookup_table[256] = { /* 数据 */ };
// 2. 指针本身不可变
uint32_t * const reg_ptr = (uint32_t *)0x40020000;
*reg_ptr = 0x01; // 可以
reg_ptr++; // 编译错误
// 3. 指向的内容不可变
const uint32_t *data_ptr = lookup_table;
data_ptr++; // 可以
*data_ptr = 0; // 编译错误
// 4. 两者都不可变
const uint32_t * const fixed_ptr = (const uint32_t *)0x08000000;
我记得有个项目,Bootloader里需要把应用程序的向量表地址写到SCB->VTOR寄存器。那个地址是固定的,我用了 const uint32_t * const 来定义,既保证了地址不被改,又保证了指向的内容不被意外写入。代码审查的时候,架构师看了直点头。
2.4.3 volatile和const的组合
寄存器编程里,这两个关键字经常一起出现:
// 只读状态寄存器
#define STATUS_REG (*(const volatile uint32_t *)0x40010000)
// 读写控制寄存器
#define CTRL_REG (*(volatile uint32_t *)0x40010004)
uint32_t status = STATUS_REG; // 读,没问题
STATUS_REG = 0; // 编译错误,const保护
const volatile这个组合,看着矛盾,其实很合理:const是说「你不能通过这个指针写」,volatile是说「这个值可能被硬件改」。嗯,这里要注意:读状态寄存器一定要加volatile,否则编译器可能从缓存里读旧值。