3、C语言基础回顾(上):指针与内存管理、结构体与联合体、位运算技巧
各位同学,咱们今天聊聊C语言里几个硬核的东西。做嵌入式,说白了就是跟内存、寄存器、数据打包打交道。这三样玩不转,后面算法移植基本寸步难行。我当年刚入行时,就因为一个指针没处理好,让整个系统跑飞了,找bug找了整整两天……嗯,从那以后,我对这些基础就再也不敢马虎了。
3.1 指针与内存管理:嵌入式开发的“命根子”
指针这东西,很多初学者觉得玄乎。其实你把它想象成“门牌号”就行。变量是房子,指针就是门牌号。你拿着门牌号,就能找到房子,往里面放东西或者拿东西。
但在嵌入式里,指针的意义远不止于此。我们经常要直接操作寄存器地址、管理动态内存、处理数据缓冲区。这些场景,指针是绕不开的。
3.1.1 指针的本质与声明
指针变量存的是地址。32位单片机里,地址是4字节;64位里是8字节。声明时,*告诉编译器:“我要存地址”。
int a = 10;
int *p = &a; // p存的是a的地址
*p = 20; // 通过p修改a的值,现在a=20
我个人习惯,声明指针时一定初始化,要么给有效地址,要么给NULL。野指针是嵌入式的大忌。
int *p = NULL;。
3.1.2 指针与数组的“暧昧关系”
数组名就是首元素地址。所以 arr 和 &arr[0] 是等价的。但注意,arr 是常量指针,不能自增自减。
int arr[5] = {1,2,3,4,5};
int *p = arr; // p指向arr[0]
p++; // 现在p指向arr[1]
printf("%d", *p); // 输出2
你想想看,遍历数组时用指针比下标快吗?其实编译器优化后差别不大。但有些场景,比如操作DMA缓冲区、处理协议栈数据包,指针的灵活性就体现出来了。
3.1.3 动态内存管理:malloc/free的“正确打开方式”
嵌入式里,动态内存分配要慎用。尤其是资源受限的MCU,频繁malloc/free会导致内存碎片。我建议:
- 能用静态分配就别用动态:全局数组、静态缓冲区,简单可靠。
- 如果非要用,记得检查返回值:malloc可能返回NULL。
- 谁分配谁释放:避免野指针和内存泄漏。
int *buf = (int *)malloc(100 * sizeof(int));
if (buf == NULL) {
// 处理错误,不能继续往下走
return -1;
}
// 使用buf...
free(buf);
buf = NULL; // 释放后置空,防止野指针
3.2 结构体与联合体:数据打包的艺术
传感器数据往往不是单一类型。比如一个IMU数据,有加速度、角速度、温度。用结构体打包,清晰又高效。
3.2.1 结构体的内存对齐
这是个大坑。结构体成员不是紧挨着存的,编译器会按对齐规则填充空白。为什么?因为CPU访问对齐数据更快。
| 成员类型 | 对齐字节数 |
|---|---|
| char | 1 |
| short | 2 |
| int | 4 |
| 指针 | 4(32位)/ 8(64位) |
struct SensorData {
char id; // 1字节
// 这里会填充3字节
int value; // 4字节
short flag; // 2字节
// 这里会填充2字节
}; // 总大小12字节,而不是1+4+2=7
我在项目中遇到过,用结构体直接跟硬件寄存器映射,结果因为对齐问题,读出来的数据全是错的。后来加了 __attribute__((packed)) 才解决。
// 取消对齐,按实际大小排列
struct __attribute__((packed)) SensorData {
char id;
int value;
short flag;
}; // 现在大小是7字节
3.2.2 联合体:同一内存的不同“视角”
联合体所有成员共享同一块内存。大小由最大成员决定。这在解析协议数据、处理寄存器位域时特别好用。
union Data {
uint32_t word;
uint8_t bytes[4];
};
union Data d;
d.word = 0x12345678;
// d.bytes[0] = 0x78 (小端模式)
// d.bytes[1] = 0x56
// d.bytes[2] = 0x34
// d.bytes[3] = 0x12
你看,一个联合体,既能当32位整数用,又能按字节拆开。我经常用它来解析传感器返回的原始数据包,省去了手动移位拼接的麻烦。
3.3 位运算技巧:嵌入式优化的“瑞士军刀”
位运算直接操作二进制位,效率极高。在寄存器配置、状态标志管理、数据压缩中无处不在。
3.3.1 基本操作:置位、清零、翻转、提取
uint8_t reg = 0b00001111;
// 置位第3位(从0开始)
reg |= (1 << 3); // reg = 0b00001111 | 0b00001000 = 0b00001111
// 清零第0位
reg &= ~(1 << 0); // reg = 0b00001111 & 0b11111110 = 0b00001110
// 翻转第2位
reg ^= (1 << 2); // reg = 0b00001110 ^ 0b00000100 = 0b00001010
// 提取第1-3位
uint8_t bits = (reg >> 1) & 0x07; // 取第1、2、3位
这些操作,说白了就是单片机编程的“日常”。配置GPIO、设置定时器、读写外设寄存器,全是这套路。
3.3.2 位域:结构体里的“位操作”
C语言允许在结构体里按位定义成员。这在描述硬件寄存器时非常直观。
struct StatusReg {
uint8_t enable : 1; // 位0
uint8_t mode : 2; // 位1-2
uint8_t flag : 1; // 位3
uint8_t reserved : 4; // 位4-7
};
struct StatusReg sr;
sr.enable = 1;
sr.mode = 2;
不过要注意,位域的顺序和内存布局是编译器相关的。跨平台移植时,我建议还是用宏定义加移位操作更稳妥。
#define SET_BIT(reg, bit) ((reg) |= (1 << (bit)))。这样代码可读性强,而且移植到不同编译器都没问题。
3.3.3 位运算的实用技巧
- 判断奇偶:
if (x & 1)比if (x % 2)快。 - 交换两数:
a ^= b; b ^= a; a ^= b;不用临时变量。 - 计算2的幂:
1 << n就是2的n次方。 - 取模2的幂:
x & (n-1)等价于x % n(n为2的幂)。
这些技巧看着简单,但在循环里用上,性能提升是实打实的。我记得有一次优化一个传感器数据滤波算法,把取模运算换成位与,整个处理时间降了30%。
小结
指针、结构体、位运算,是嵌入式C语言的三大基石。指针让你掌控内存,结构体帮你组织数据,位运算让你高效操作硬件。这三样东西,光看书不行,得多写多练。我建议你找个开发板,对着寄存器手册,用指针操作外设,用结构体打包数据,用位运算配置寄存器。实践几回,自然就熟了。
下一节咱们接着聊C语言的其他重点,函数指针、链表、回调机制,这些都是算法移植中常用的。到时候见。