第二章 嵌入式C语言基础复习:指针、结构体、位运算、内存管理——这些在驱动开发中怎么用?
说实话,很多初学者觉得C语言基础就是背语法。但到了嵌入式驱动开发,你会发现——指针就是你的手,结构体就是你的工具箱,位运算就是你的手术刀。内存管理?那是你的命根子。
这一章,我不跟你讲枯燥的理论。咱们直接聊:这些玩意儿在驱动里到底怎么用。
2.1 指针:不只是地址,是硬件的遥控器
指针是什么?说白了就是一个装着地址的变量。但在嵌入式里,它直接指向硬件寄存器。
我记得刚做第一个项目时,要操作一个GPIO输出高电平。数据手册上写着:GPIO输出寄存器地址是0x40020C14。怎么搞?
// 直接操作寄存器地址
#define GPIO_OUTPUT_REG (*(volatile uint32_t *)0x40020C14)
// 然后就可以像普通变量一样赋值
GPIO_OUTPUT_REG |= (1 << 5); // 让第5脚输出高电平
这里有个关键点:volatile。为什么加它?因为编译器太聪明了,它会优化掉看似没用的读取。但硬件寄存器每次读都可能不同,不加volatile,你可能会读到缓存里的旧值。
指针的另一个经典用法是回调函数。比如你要给定时器注册一个中断处理函数:
typedef void (*timer_callback_t)(void);
void timer_register_callback(timer_callback_t cb) {
// 把函数指针存入中断向量表
interrupt_vector_table[TIMER_IRQ] = cb;
}
// 使用
void my_timer_handler(void) {
// 处理定时器中断
}
timer_register_callback(my_timer_handler);
你想想看,没有指针,你怎么把函数当参数传递?
2.2 结构体:把散落的寄存器组织起来
一个外设通常有十几个寄存器。如果每个都定义成宏,代码会乱成一锅粥。结构体就是用来干这个的。
比如一个典型的UART外设:
typedef struct {
volatile uint32_t DR; // 数据寄存器 (偏移0x00)
volatile uint32_t SR; // 状态寄存器 (偏移0x04)
volatile uint32_t CR1; // 控制寄存器1 (偏移0x08)
volatile uint32_t CR2; // 控制寄存器2 (偏移0x0C)
volatile uint32_t BRR; // 波特率寄存器 (偏移0x10)
} UART_TypeDef;
// 假设UART1基地址是0x40011000
#define UART1 ((UART_TypeDef *)0x40011000)
// 使用
UART1->DR = 'A'; // 发送字符A
while(!(UART1->SR & (1 << 7))); // 等待发送完成
这里有个小技巧:结构体成员的顺序必须和硬件寄存器地址偏移完全一致。中间不能有空洞。如果硬件设计有保留地址,用uint32_t reserved[2]占位。
2.3 位运算:用最少的指令干最多的活
嵌入式里,寄存器通常只有32位,但可能要控制十几个功能。位运算就是用来精确操作某一位或某几位的。
常用的几个操作:
| 操作 | 写法 | 说明 |
|---|---|---|
| 置位 | REG |= (1 << n) |
把第n位设为1 |
| 清位 | REG &= ~(1 << n) |
把第n位设为0 |
| 读取位 | if(REG & (1 << n)) |
判断第n位是否为1 |
| 翻转位 | REG ^= (1 << n) |
把第n位取反 |
| 批量置位 | REG |= (0x0F << 4) |
把第4-7位设为1 |
举个例子,配置一个GPIO为推挽输出模式:
// 假设GPIO控制寄存器:位0-1控制模式,位2-3控制输出类型
#define GPIO_MODER (*(volatile uint32_t *)0x40020C00)
// 设置第5脚为输出模式(模式值=01)
GPIO_MODER &= ~(0x3 << (5*2)); // 先清空这两位
GPIO_MODER |= (0x1 << (5*2)); // 再设置模式值
为什么要先清再设?因为寄存器是读-改-写操作。不清零直接或运算,会把原来的值混进去。
REG |= (1 << 3),结果发现其他位也被改了。后来才意识到,那个寄存器的位3之前可能已经被别的代码设过值。正确的做法是先清再设。
2.4 内存管理:在资源受限的环境下生存
嵌入式系统的RAM通常只有几十KB到几MB。malloc/free?能用,但要非常小心。
我一般遵循几个原则:
- 静态分配优先:能全局定义的,就别动态分配。编译时就确定好大小。
- 栈上分配:小数据(几百字节以内)用局部变量,函数退出自动释放。
- 动态分配要节制:如果非要用malloc,一定要检查返回值,而且要有对应的free。
看一个典型的驱动初始化代码:
// 静态分配——安全可靠
static uint8_t rx_buffer[1024];
static uint8_t tx_buffer[1024];
// 动态分配——要小心
typedef struct {
uint8_t *data;
uint32_t size;
} Buffer_t;
Buffer_t* create_buffer(uint32_t size) {
Buffer_t *buf = (Buffer_t *)malloc(sizeof(Buffer_t));
if(buf == NULL) {
return NULL; // 分配失败
}
buf->data = (uint8_t *)malloc(size);
if(buf->data == NULL) {
free(buf); // 别忘了释放外层
return NULL;
}
buf->size = size;
return buf;
}
void destroy_buffer(Buffer_t *buf) {
if(buf) {
free(buf->data);
free(buf);
}
}
你可能会问:为什么不用C++的new/delete?因为很多嵌入式编译器不支持C++,或者支持得不好。C语言的内存管理虽然原始,但可控性最强。
2.5 综合案例:用指针+结构体+位运算实现GPIO驱动
把前面学的串起来,写一个完整的GPIO输出驱动:
// gpio.h
typedef struct {
volatile uint32_t MODER; // 模式寄存器
volatile uint32_t OTYPER; // 输出类型寄存器
volatile uint32_t OSPEEDR; // 输出速度寄存器
volatile uint32_t PUPDR; // 上下拉寄存器
volatile uint32_t IDR; // 输入数据寄存器
volatile uint32_t ODR; // 输出数据寄存器
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000)
#define GPIOB ((GPIO_TypeDef *)0x40020400)
// gpio.c
void gpio_set_output(GPIO_TypeDef *gpio, uint8_t pin) {
// 设置模式为输出(01)
gpio->MODER &= ~(0x3 << (pin * 2));
gpio->MODER |= (0x1 << (pin * 2));
// 设置推挽输出
gpio->OTYPER &= ~(1 << pin);
// 设置速度为中等
gpio->OSPEEDR &= ~(0x3 << (pin * 2));
gpio->OSPEEDR |= (0x1 << (pin * 2));
}
void gpio_write_pin(GPIO_TypeDef *gpio, uint8_t pin, uint8_t level) {
if(level) {
gpio->ODR |= (1 << pin);
} else {
gpio->ODR &= ~(1 << pin);
}
}
// main.c
int main(void) {
gpio_set_output(GPIOA, 5); // PA5设为输出
while(1) {
gpio_write_pin(GPIOA, 5, 1); // 输出高电平
delay(1000);
gpio_write_pin(GPIOA, 5, 0); // 输出低电平
delay(1000);
}
}
你看,指针让我们能直接操作硬件地址,结构体把散乱的寄存器组织成清晰的外设对象,位运算精确控制每一位。这三样东西配合起来,驱动开发就变得有条理了。
嗯,这一章就到这里。下一章我们聊聊中断和定时器——这两个东西在嵌入式里就像呼吸一样自然,但用不好就会窒息。