2、嵌入式C语言基础回顾:指针与数组的纠缠、结构体对齐、volatile关键字、位操作技巧
各位同学,咱们今天聊点实在的。做嵌入式,尤其是搞ADAS,C语言基础不牢,后面写代码就是给自己挖坑。我见过太多人,指针数组搞混,结构体对齐踩雷,volatile乱用,位操作写得像天书。今天这一课,咱们就把这些老生常谈但极其关键的点,掰开揉碎了讲清楚。
2.1 指针与数组:剪不断理还乱
先说指针和数组。很多人觉得数组就是指针,指针就是数组。其实不然。数组名是个常量指针,指向首元素,但你不能给它赋值。指针是个变量,可以指向别处。
举个例子:
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
printf("%d\n", arr[2]); // 3
printf("%d\n", *(p + 2)); // 3
printf("%d\n", 2[arr]); // 3 —— 这玩意儿也能编译通过,你信不信?
嗯,2[arr] 这种写法,C标准是允许的。因为 arr[2] 本质上就是 *(arr + 2),加法可交换,所以 2[arr] 等价于 *(2 + arr)。我个人习惯从来不用这种写法,太容易让人懵了。
我在项目中遇到过一个问题:一个同事把数组名当指针传进函数,然后在函数里对数组名做了自增操作。结果编译报错,他查了半天没搞明白。说白了,数组名不是左值,不能自增。你得用个指针变量去接它。
核心区别:
sizeof(arr)返回整个数组大小(20字节,假设int占4字节)sizeof(p)返回指针大小(4或8字节,取决于平台)&arr的类型是int (*)[5],指向整个数组&p的类型是int **,指向指针变量
你想想看,如果搞混了 &arr 和 &arr[0],在ADAS的传感器数据处理中,可能就会读到错误的内存区域。后果嘛,轻则数据异常,重则系统崩溃。
2.2 结构体对齐:编译器偷偷帮你做了事
结构体对齐,说白了就是编译器为了让CPU访问更快,在结构体成员之间塞了一些“填充字节”。你定义的结构体,实际占用的内存可能比你想象的大。
看个例子:
struct Example {
char a; // 1字节
int b; // 4字节
char c; // 1字节
};
你觉得这个结构体多大?1 + 4 + 1 = 6?错。在我的32位平台上,它占12字节。为什么?因为 int b 需要4字节对齐,所以 a 后面会填充3个字节,c 后面再填充3个字节,让整个结构体大小是4的倍数。
我曾经在写CAN报文解析模块时,直接拿结构体去映射接收缓冲区。结果因为对齐问题,读出来的数据全是错的。排查了一下午才发现是结构体对齐在作怪。从那以后,我养成了一个习惯:凡是和硬件寄存器或通信协议相关的结构体,一定加上 __attribute__((packed))。
避坑指南:
- 使用
#pragma pack(1)或__attribute__((packed))取消对齐 - 但注意:取消对齐后,访问未对齐的成员可能会降低性能,甚至在某些ARM平台上触发异常
- 最佳实践:手动调整成员顺序,把大的成员放前面,小的放后面,减少填充
优化后的结构体:
struct ExampleOptimized {
int b; // 4字节
char a; // 1字节
char c; // 1字节
// 填充2字节
};
// 大小:8字节
你看,只是调整了顺序,就从12字节降到了8字节。在嵌入式系统中,内存寸土寸金,这种优化很有意义。
2.3 volatile关键字:别让编译器“优化”掉你的逻辑
volatile 这个关键字,我把它叫做“防优化声明”。它告诉编译器:这个变量的值可能会被意想不到地改变,每次使用都必须从内存中重新读取,不能使用寄存器中的缓存值。
什么时候用?三种典型场景:
- 硬件寄存器映射:比如GPIO状态寄存器,值可能被硬件改变
- 中断服务程序中修改的全局变量:主循环和中断共享的变量
- 多线程/多任务共享的变量:在RTOS中,一个任务写,另一个任务读
举个例子:
// 错误写法
int flag = 0;
void interrupt_handler() {
flag = 1;
}
void main_loop() {
while (!flag) {
// 编译器可能优化成:读取一次flag到寄存器,然后死循环
// 因为编译器认为flag在循环中没有被修改
}
// 处理事件
}
加上 volatile 就对了:
volatile int flag = 0;
我记得有一次调试ADAS的紧急制动功能,刹车指令就是发不出去。查了半天,发现是一个状态标志没加 volatile,编译器把它优化成了常量。嗯,从那以后,所有中断和主循环共享的变量,我第一件事就是加 volatile。
注意:
volatile不能保证原子性。对volatile变量的自增操作(flag++)仍然可能被中断打断- 如果需要原子操作,还得配合关中断或使用原子指令
- 不要滥用
volatile,它会阻止编译器的很多优化,降低性能
2.4 位操作技巧:嵌入式工程师的“瑞士军刀”
位操作,说白了就是直接操作二进制位。在嵌入式领域,寄存器配置、状态标志、数据压缩,处处离不开它。我敢说,不会位操作的嵌入式工程师,不是合格的嵌入式工程师。
常用操作:
| 操作 | 表达式 | 说明 |
|---|---|---|
| 置位 | reg |= (1 << n) |
将第n位置1 |
| 清零 | reg &= ~(1 << n) |
将第n位清0 |
| 取反 | reg ^= (1 << n) |
将第n位取反 |
| 读取 | (reg >> n) & 1 |
读取第n位的值 |
| 连续位操作 | reg = (reg & ~mask) | (value << shift) |
将某连续位域设置为指定值 |
实战技巧:
// 技巧1:用宏定义让代码更可读
#define SET_BIT(reg, bit) ((reg) |= (1U << (bit)))
#define CLEAR_BIT(reg, bit) ((reg) &= ~(1U << (bit)))
#define READ_BIT(reg, bit) (((reg) >> (bit)) & 1U)
// 技巧2:位域操作,常用于寄存器配置
#define MASK(width, shift) (((1U << (width)) - 1) << (shift))
#define GET_FIELD(reg, mask, shift) (((reg) & (mask)) >> (shift))
#define SET_FIELD(reg, mask, shift, val) ((reg) = ((reg) & ~(mask)) | ((val) << (shift)))
// 使用示例:配置GPIO模式寄存器
// 假设MODE位域从bit4开始,宽度2位
#define GPIO_MODE_MASK MASK(2, 4) // 0x30
SET_FIELD(GPIO_MODER, GPIO_MODE_MASK, 4, 0x02); // 设置为输出模式
我在项目中遇到过一个问题:用位操作配置DMA传输方向时,因为没注意掩码的宽度,把相邻的位也给改了。结果DMA传输方向对了,但数据宽度配置错了。排查起来特别痛苦,因为寄存器值看起来“差不多对”。
我的建议:
- 写位操作时,多用宏封装,别到处写裸的
|=和&= - 使用
1U而不是1,避免符号位扩展带来的问题 - 复杂位域操作,先画个位图,再写代码,不容易出错
- 调试时,把寄存器值打印出来,用二进制格式看,一目了然
好了,这一章的内容就这些。指针数组、结构体对齐、volatile、位操作,这四个点看似基础,但每一个都是嵌入式开发中的“坑王”。把这些搞扎实了,后面写车道保持的代码,你才会觉得得心应手。
下一章,咱们开始聊模块化设计,怎么把代码组织得既清晰又高效。到时候见。