2、C语言基础与进阶:指针与内存管理、结构体与联合体、位运算与嵌入式优化
嵌入式开发,说白了就是跟硬件打交道。而C语言,就是我们跟芯片对话的“普通话”。很多初学者觉得C语言语法简单,刷刷题就能过。但真到了嵌入式项目里,你会发现——坑全在细节里。
我个人习惯把C语言分成两个阶段:能写和会优化。今天聊的这几个主题,就是帮你从“能写”跨到“会优化”的关键。
2.1 指针:不只是“指向地址”那么简单
指针这东西,教科书上说“指针就是地址”。对,但也不全对。在嵌入式里,指针是操作硬件的直接手段。
2.1.1 指针的本质与类型
我见过不少新手写代码,定义一个 uint32_t *p,然后直接 p = 0x40020000。编译报警告?不管。运行出错了?一脸懵。
嗯,这里要注意:指针的类型决定了它怎么解读内存。同样是地址0x40020000,uint32_t * 会读取4个字节,而 uint8_t * 只读1个字节。你想想看,如果寄存器是32位的,你用 uint8_t * 去读,读到的数据肯定不对。
核心原则:指针的类型不是摆设,它告诉编译器“从这块内存里取出多少数据”。
2.1.2 指针与数组的“暧昧关系”
数组名是不是指针?是,也不是。数组名是一个常量指针,你不能给它赋值。但你可以用指针的方式去访问数组元素。
uint8_t buffer[64];
uint8_t *p = buffer; // 等价于 p = &buffer[0]
// 下面三种写法完全等价
buffer[5] = 0xAA;
*(buffer + 5) = 0xAA;
p[5] = 0xAA; // 指针也能用下标!
我在项目中遇到过一个问题:用 sizeof(buffer) 得到64,但用 sizeof(p) 只得到4(32位系统)。这就是数组名和指针的区别——sizeof 对数组名返回整个数组大小,对指针只返回指针本身大小。
2.1.3 函数指针:回调机制的基石
嵌入式里经常用回调函数,比如定时器中断、按键扫描。函数指针就是实现回调的核心。
// 定义一个函数指针类型
typedef void (*button_callback_t)(uint8_t pin_state);
// 注册回调
void button_register_callback(button_callback_t cb) {
// 保存回调函数地址
}
// 使用回调
void button_isr_handler(void) {
if (cb_function) {
cb_function(read_pin());
}
}
我个人习惯在驱动层大量使用函数指针。这样上层应用只需要注册回调,底层驱动完全解耦。改硬件?换一个回调函数就行,不用动主逻辑。
小技巧:函数指针的声明看起来复杂,可以用 typedef 把它“包装”成一个普通类型名,代码会清爽很多。
2.2 内存管理:嵌入式里的“精打细算”
PC上内存不够?加条内存条就行。嵌入式里?Flash和RAM都是按KB算的。你浪费1KB,可能就得换更贵的芯片。
2.2.1 栈、堆、全局区的“地盘划分”
| 区域 | 特点 | 典型问题 |
|---|---|---|
| 栈(Stack) | 自动分配释放,大小固定(通常几KB) | 栈溢出(递归太深、局部变量太大) |
| 堆(Heap) | 手动分配释放(malloc/free) | 内存碎片、忘记释放 |
| 全局区(静态区) | 程序启动分配,程序结束释放 | 多线程/中断重入问题 |
我曾经在一个项目中,因为一个局部数组 uint8_t buf[2048] 直接写在函数里,导致系统跑着跑着就死机。查了半天,发现栈大小默认只有1KB,数组直接溢出了。从那以后,大缓冲区我全用全局变量或者动态分配。
2.2.2 动态内存:用还是不用?
很多嵌入式老手说“嵌入式里别用malloc”。这话有道理,但不绝对。
- 裸机或RTOS下:尽量不用。malloc可能产生碎片,而且不确定耗时。
- Linux嵌入式:随便用,有MMU帮你管理。
- 非要用的场景:比如协议栈解析,数据包大小不确定。这时候可以用,但一定要检查返回值!
避坑指南:我曾经在FreeRTOS里用malloc分配内存,结果任务调度一频繁,系统就随机崩溃。后来发现FreeRTOS有自己的 pvPortMalloc,用这个才稳定。记住:RTOS环境下,用RTOS自己的内存管理函数。
2.2.3 内存对齐:性能与兼容性的隐形杀手
ARM Cortex-M3/M4 要求32位数据必须4字节对齐,否则会触发硬件异常。结构体里字段顺序没排好,可能白白浪费空间。
// 不好的写法:浪费空间
typedef struct {
uint8_t a; // 1字节
uint32_t b; // 4字节,需要从偏移4开始
uint8_t c; // 1字节
} __attribute__((packed)) BadStruct; // 总大小:9字节(但访问b可能不对齐)
// 好的写法:按大小排序
typedef struct {
uint32_t b; // 4字节
uint8_t a; // 1字节
uint8_t c; // 1字节
} GoodStruct; // 总大小:8字节(自然对齐)
我个人习惯在定义结构体时,把大的成员放前面,小的放后面。这样既省空间,又避免对齐问题。
2.3 结构体与联合体:数据组织的“瑞士军刀”
2.3.1 结构体:把相关数据“打包”
结构体在嵌入式里太常用了。一个传感器,有温度、湿度、状态,用结构体一包,清晰又好维护。
typedef struct {
int16_t temperature; // 温度,单位0.1°C
uint16_t humidity; // 湿度,单位0.1%
uint8_t status; // 状态位
uint32_t timestamp; // 时间戳
} sensor_data_t;
// 使用
sensor_data_t data;
data.temperature = 255; // 25.5°C
2.3.2 联合体:同一内存,不同解读
联合体是嵌入式里的“变脸”高手。同一块内存,你可以把它当字节数组,也可以当结构体。这在解析通信协议时特别有用。
typedef union {
uint32_t value; // 当作32位整数
uint8_t bytes[4]; // 当作4个字节
struct {
uint8_t b0;
uint8_t b1;
uint8_t b2;
uint8_t b3;
} byte_field; // 当作结构体
} reg32_t;
// 应用场景:读写32位寄存器
reg32_t reg;
reg.value = 0x12345678;
// 现在 reg.bytes[0] = 0x78(小端模式)
// reg.byte_field.b2 = 0x34
我在项目中遇到过一个问题:用联合体解析串口收到的数据包。数据包是4字节的,但不同帧类型含义不同。用联合体,同一块缓冲区,按不同结构体去解读,代码量直接减半。
注意:联合体的大小等于最大成员的大小。所有成员共享同一块内存,修改一个成员会影响其他成员。这是特性,也是陷阱。
2.4 位运算:嵌入式优化的“杀手锏”
位运算在嵌入式里无处不在。设置寄存器、打包数据、状态标志……用好了,代码又小又快。
2.4.1 基本位操作:与、或、异或、取反、移位
| 操作 | 运算符 | 典型用途 |
|---|---|---|
| 置位 | |= |
设置某位为1 |
| 清位 | &= ~ |
设置某位为0 |
| 翻转 | ^= |
切换某位状态 |
| 移位 | << / >> |
乘2、除2、提取位域 |
// 实际例子:操作GPIO输出寄存器
#define GPIO_OUT_REG (*(volatile uint32_t *)0x40020C14)
#define LED_PIN (1 << 5) // 第5位
// 点亮LED(置位)
GPIO_OUT_REG |= LED_PIN;
// 熄灭LED(清位)
GPIO_OUT_REG &= ~LED_PIN;
// 翻转LED
GPIO_OUT_REG ^= LED_PIN;
2.4.2 位域:结构体里的“位级操作”
C语言允许在结构体里定义位域,精确控制每个成员占几位。这在操作硬件寄存器时特别方便。
// 定义一个控制寄存器位域
typedef struct {
uint32_t enable : 1; // bit0: 使能
uint32_t mode : 2; // bit1-2: 模式选择
uint32_t speed : 3; // bit3-5: 速度
uint32_t reserved : 26; // bit6-31: 保留
} ctrl_reg_t;
// 使用
ctrl_reg_t *ctrl = (ctrl_reg_t *)0x40021000;
ctrl->enable = 1;
ctrl->mode = 2;
嗯,这里要注意:位域的顺序和内存布局是编译器相关的。不同编译器可能不一样。如果你要写跨平台代码,最好用宏定义加移位操作,别依赖位域。
2.4.3 位运算优化:用空间换时间
你想想看,一个 if (x % 2 == 0) 和 if (x & 1),哪个快?肯定是位运算快。因为取模运算在底层要调用除法指令,而位运算一条指令就搞定。
// 常见优化技巧
// 1. 判断奇偶
if (value & 1) // 奇数
// 2. 乘2/除2
value <<= 1; // 乘2
value >>= 1; // 除2(注意:有符号数右移是算术移位)
// 3. 取模2的幂
value & 0x07; // 等价于 value % 8
// 4. 交换两个变量(不用临时变量)
a ^= b; b ^= a; a ^= b;
避坑指南:我曾经在优化一个音频算法时,用 >> 1 代替 / 2。结果有符号负数右移后,符号位扩展了,数值完全不对。记住:有符号数的右移是算术移位,会保留符号位。无符号数才是逻辑移位。
2.5 嵌入式优化:从“能跑”到“跑得漂亮”
代码能跑,和代码跑得高效,中间差着十万八千里。嵌入式资源有限,优化是必修课。
2.5.1 编译优化选项
GCC的 -O1、-O2、-Os 不是摆设。我建议开发阶段用 -O0 方便调试,发布版本用 -Os 或 -O2。
-O0:不优化,调试方便-O1:基本优化,减少代码大小-O2:更多优化,提高速度-Os:以减小代码体积为目标
2.5.2 用const和static减少RAM占用
常量数据放在Flash里,别占RAM。查表、字符串、配置参数,统统加 const。
// 错误:占用RAM
uint8_t sin_table[256] = {0, 1, 2, ...};
// 正确:放在Flash
const uint8_t sin_table[256] = {0, 1, 2, ...};
我个人习惯把所有不会变的数据都加上 const。这样编译器会把它放到Flash段,RAM就能省下来给动态数据用。
2.5.3 循环展开与查表法
对于一些固定次数的循环,手动展开可以减少循环开销。对于复杂计算,提前算好存成表,运行时直接查。
// 查表法:计算CRC
const uint8_t crc8_table[256] = { /* 预计算好的值 */ };
uint8_t crc8_calc(uint8_t *data, uint16_t len) {
uint8_t crc = 0;
while (len--) {
crc = crc8_table[crc ^ *data++];
}
return crc;
}
小技巧:查表法是典型的“空间换时间”。如果你的Flash够用,多用查表法。我在做LED渐变效果时,就把正弦波值预计算好存成表,MCU只需要查表输出PWM,省去了实时计算的开销。
2.6 本章小结
指针、内存、结构体、位运算——这四个东西,是嵌入式C语言的“四根柱子”。
- 指针让你直接操作硬件,但要注意类型和越界。
- 内存管理是嵌入式开发的“紧箍咒”,省着点用。
- 结构体与联合体帮你把数据组织得井井有条。
- 位运算是优化利器,用好了代码又快又小。
下一章我们会聊到“中断与定时器:系统响应的核心机制”。到时候你会发现,今天学的指针和位运算,在中断处理里会频繁用到。打好基础,后面才走得稳。