第二章 嵌入式C语言基础复习:指针、结构体、位运算、内存管理——这些在驱动开发中怎么用?

说实话,很多初学者觉得C语言基础就是背语法。但到了嵌入式驱动开发,你会发现——指针就是你的手,结构体就是你的工具箱,位运算就是你的手术刀。内存管理?那是你的命根子。

这一章,我不跟你讲枯燥的理论。咱们直接聊:这些玩意儿在驱动里到底怎么用。

2.1 指针:不只是地址,是硬件的遥控器

指针是什么?说白了就是一个装着地址的变量。但在嵌入式里,它直接指向硬件寄存器。

我记得刚做第一个项目时,要操作一个GPIO输出高电平。数据手册上写着:GPIO输出寄存器地址是0x40020C14。怎么搞?

// 直接操作寄存器地址
#define GPIO_OUTPUT_REG  (*(volatile uint32_t *)0x40020C14)

// 然后就可以像普通变量一样赋值
GPIO_OUTPUT_REG |= (1 << 5);  // 让第5脚输出高电平

这里有个关键点:volatile。为什么加它?因为编译器太聪明了,它会优化掉看似没用的读取。但硬件寄存器每次读都可能不同,不加volatile,你可能会读到缓存里的旧值。

我曾经踩过的坑: 有一次调试UART,数据死活收不到。查了两天,发现是忘了加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]占位。

我的个人习惯: 每个结构体成员都加volatile。虽然麻烦点,但能避免很多诡异的bug。你永远不知道编译器什么时候会自作聪明。

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));   // 再设置模式值

为什么要先清再设?因为寄存器是读-改-写操作。不清零直接或运算,会把原来的值混进去。

避坑指南: 我曾经在SPI驱动里,用位运算配置时钟极性。手册上说CPOL=1表示空闲时高电平。我直接写了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语言的内存管理虽然原始,但可控性最强。

我曾经犯过的错: 在一个RTOS项目里,我在中断服务函数中调用了malloc。结果系统随机死机。查了三天,才发现中断里不能调用非重入函数。malloc就不是重入的。从那以后,我规定:中断里绝对不允许动态分配内存。

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);
    }
}

你看,指针让我们能直接操作硬件地址,结构体把散乱的寄存器组织成清晰的外设对象,位运算精确控制每一位。这三样东西配合起来,驱动开发就变得有条理了。

嗯,这一章就到这里。下一章我们聊聊中断和定时器——这两个东西在嵌入式里就像呼吸一样自然,但用不好就会窒息。