第3章:C语言基础回顾:指针、结构体、位运算、内存管理,在嵌入式开发中的特殊用法
各位同学,咱们今天聊点实在的。C语言基础,你们在学校都学过。但嵌入式开发里的C语言,跟写PC软件完全是两码事。我当年从应用层转到嵌入式时,第一个感觉就是——这指针怎么到处乱飞?结构体还能这么用?
说白了,嵌入式C语言的核心就四个字:贴近硬件。你写的每一行代码,最终都要跟寄存器、内存地址、外设打交道。今天我就把指针、结构体、位运算、内存管理这四个硬骨头,结合轨道交通POS机的实际场景,给你们掰开揉碎了讲。
3.1 指针:不只是地址,更是硬件的遥控器
指针在嵌入式里,说白了就是直接操作内存地址。我见过很多新手,一看到指针就头疼。其实你换个角度想——指针就是遥控器,你按一下按钮,硬件就动一下。
3.1.1 直接访问寄存器
在轨道交通POS机里,你要控制串口、GPIO、定时器。这些外设都映射到固定的内存地址。比如,某款芯片的UART数据寄存器地址是0x40001000。你怎么读写它?
// 定义寄存器指针
#define UART_DR (*(volatile unsigned int *)0x40001000)
// 发送一个字节
void uart_send_char(char c) {
UART_DR = c; // 直接写寄存器
}
这里有个关键点:volatile。为什么加它?因为编译器会优化。你写UART_DR = c,编译器可能觉得这变量没用,直接给你优化掉了。但硬件寄存器不一样,你写一次,硬件就动作一次。所以必须告诉编译器:别动我的代码,老老实实执行。
3.1.2 函数指针:实现回调与状态机
POS机要处理刷卡、扫码、打印等多个任务。每个任务的处理函数不同。用函数指针,可以优雅地实现状态机。
// 定义函数指针类型
typedef void (*task_handler_t)(void);
// 任务表
task_handler_t task_table[] = {
handle_card_swipe, // 刷卡处理
handle_scan_code, // 扫码处理
handle_print_receipt // 打印处理
};
// 执行任务
void execute_task(int task_id) {
if (task_id < sizeof(task_table)/sizeof(task_table[0])) {
task_table[task_id](); // 通过指针调用函数
}
}
我个人习惯用函数指针数组来管理状态机。这样代码清晰,扩展也方便。加一个新任务,只需要在数组里加一项就行。
3.2 结构体:把硬件抽象成数据结构
结构体在嵌入式里,最大的用处就是打包硬件信息。你想想看,一个串口有多少寄存器?数据寄存器、状态寄存器、控制寄存器……如果每个都单独定义,代码会乱成一锅粥。
3.2.1 结构体封装寄存器组
// 定义UART寄存器结构体
typedef struct {
volatile uint32_t DR; // 数据寄存器
volatile uint32_t SR; // 状态寄存器
volatile uint32_t CR; // 控制寄存器
volatile uint32_t BRR; // 波特率寄存器
} UART_TypeDef;
// 映射到硬件地址
#define UART1 ((UART_TypeDef *)0x40001000)
// 使用结构体访问
void uart_init(void) {
UART1->CR = 0x01; // 使能UART
UART1->BRR = 0x1A; // 设置波特率
}
这样写,是不是比一堆宏定义清爽多了?结构体把相关的寄存器组织在一起,代码可读性大大提高。我在项目中,所有外设驱动都这么写——GPIO、TIM、I2C,统统用结构体封装。
3.2.2 位域:精确控制每一位
有些寄存器,一个32位里可能包含多个控制位。比如控制寄存器的bit0是使能位,bit1-3是模式选择。用位域可以精确控制。
// 定义控制寄存器的位域结构体
typedef struct {
volatile uint32_t enable : 1; // bit0: 使能
volatile uint32_t mode : 3; // bit1-3: 模式
volatile uint32_t reserved : 28; // 保留位
} CR_Bits_TypeDef;
// 联合体:既可以按位访问,也可以整体访问
typedef union {
volatile uint32_t word;
CR_Bits_TypeDef bits;
} CR_Union_TypeDef;
嗯,这里要注意:位域在不同编译器下,内存布局可能不一样。我建议你只在同一款芯片、同一个编译器下使用位域。跨平台时,老老实实用位运算。
3.3 位运算:嵌入式开发的瑞士军刀
位运算,说白了就是直接操作二进制位。在嵌入式里,这是基本功中的基本功。你想想看,一个寄存器32位,你要设置某一位为1,清除某一位为0,检查某一位是0还是1——这些操作,位运算最拿手。
3.3.1 常用位运算操作
// 设置第n位为1
#define SET_BIT(reg, n) ((reg) |= (1 << (n)))
// 清除第n位为0
#define CLEAR_BIT(reg, n) ((reg) &= ~(1 << (n)))
// 检查第n位是否为1
#define CHECK_BIT(reg, n) (((reg) >> (n)) & 0x01)
// 读取寄存器某字段的值(比如bit4-7)
#define GET_FIELD(reg, mask, shift) (((reg) & (mask)) >> (shift))
这些宏,我每个项目都会用。比如控制LED灯:
// 假设GPIOB的bit5控制LED
#define LED_PIN 5
#define GPIOB_ODR (*(volatile uint32_t *)0x40020414)
// 点亮LED
SET_BIT(GPIOB_ODR, LED_PIN);
// 熄灭LED
CLEAR_BIT(GPIOB_ODR, LED_PIN);
|=写成了=。结果把整个寄存器的值都覆盖了,导致其他位被清零。从那以后,我每次写完位操作,都会用逻辑分析仪抓一下波形,确认结果正确。
3.3.2 位运算在POS机中的应用
轨道交通POS机要处理多种支付方式。比如,用一个32位变量表示支付状态:
// 支付状态位定义
#define PAY_CARD (1 << 0) // 刷卡支付
#define PAY_SCAN (1 << 1) // 扫码支付
#define PAY_NFC (1 << 2) // NFC支付
#define PAY_CASH (1 << 3) // 现金支付
uint32_t payment_status = 0;
// 设置刷卡支付完成
payment_status |= PAY_CARD;
// 检查是否支持扫码支付
if (payment_status & PAY_SCAN) {
// 处理扫码逻辑
}
// 清除所有支付状态
payment_status = 0;
用位运算管理状态,既节省内存,又高效。一个32位变量,可以管理32个不同的状态。这在资源受限的嵌入式系统里,非常实用。
3.4 内存管理:别让堆和栈成为你的噩梦
嵌入式系统的内存,就那么几K到几M。用错了,系统就崩。我见过太多因为内存泄漏导致POS机死机的案例。
3.4.1 栈:局部变量的家
栈是自动管理的。函数调用时分配,返回时释放。但栈的大小是有限的。你想想看,如果你在函数里定义一个大的局部数组:
void process_data(void) {
char buffer[4096]; // 4KB的局部数组
// 处理数据...
}
如果栈总共才8KB,这一个数组就占了一半。再递归调用几次,栈就溢出了。系统直接死机。
3.4.2 堆:动态内存的双刃剑
malloc和free,在PC上随便用。但在嵌入式里,要慎之又慎。为什么?
- 碎片化:频繁分配释放,堆会变得支离破碎。最后明明有内存,却分配不出来。
- 不确定性:malloc的执行时间不确定。在实时性要求高的场景(比如刷卡响应),不能用。
- 内存泄漏:忘了free,内存就丢了。POS机连续运行几个月,内存慢慢耗尽。
我个人的习惯是:嵌入式里,尽量不用动态内存分配。所有内存都在编译时静态分配好。比如:
// 静态分配所有缓冲区
#define MAX_TRANSACTIONS 10
#define TRANSACTION_SIZE 256
static uint8_t transaction_buffer[MAX_TRANSACTIONS][TRANSACTION_SIZE];
static uint8_t buffer_used[MAX_TRANSACTIONS] = {0};
// 获取一个缓冲区
uint8_t* get_buffer(void) {
for (int i = 0; i < MAX_TRANSACTIONS; i++) {
if (!buffer_used[i]) {
buffer_used[i] = 1;
return transaction_buffer[i];
}
}
return NULL; // 没有可用缓冲区
}
// 释放缓冲区
void free_buffer(uint8_t* buf) {
for (int i = 0; i < MAX_TRANSACTIONS; i++) {
if (transaction_buffer[i] == buf) {
buffer_used[i] = 0;
return;
}
}
}
这样写,内存是静态分配的,不会碎片化,执行时间也是确定的。虽然灵活性差一点,但可靠性高得多。
3.4.3 const和static:嵌入式内存优化的利器
const修饰的变量,放在只读区域(Flash),不占RAM。static修饰的局部变量,生命周期是整个程序,但作用域只在函数内。
// 常量放在Flash,不占RAM
const char* welcome_msg = "欢迎使用轨道交通POS机";
// 静态局部变量,只初始化一次
void count_cards(void) {
static int card_count = 0; // 只初始化一次
card_count++;
// 处理...
}
我建议你把所有不会改变的常量都加上const。这样既能节省RAM,又能防止意外修改。至于static,我一般用在需要保持状态的函数里,比如计数器、状态机。
好了,这一章就讲到这里。下一章,我们会讲中断和定时器——这两个东西在POS机里天天用。到时候,我会结合刷卡超时处理、定时轮询等实际场景,给你们讲透。