1. 嵌入式C语言进阶:指针、关键字与寄存器映射

大家好,我是你们这趟实战之旅的领路人。咱们第一节课,不急着敲板子、点灯。先把C语言这把刀磨快。嵌入式开发,说白了就是跟寄存器、内存、硬件引脚打交道。C语言玩不转,后面移植RTOS、调低功耗,处处是坑。

我个人习惯,拿到一个新平台,第一件事不是看原理图,而是先看芯片头文件里的寄存器定义。那里藏着硬件工程师和软件工程师的“契约”。今天我们就来拆解这份契约里的几个核心语法。

1.1 指针:硬件的“遥控器”

指针是什么?很多人背过“指针就是地址”。但在我眼里,指针就是一根直接连到硬件寄存器的导线。你通过指针写数据,就是在给硬件发指令。

看个最经典的例子,操作GPIO输出高电平:

// 假设GPIOB端口的输出数据寄存器地址为 0x40010C0C
#define GPIOB_ODR  (*(volatile unsigned int *)0x40010C0C)

void LED_On(void) {
    GPIOB_ODR |= (1 << 5);  // 将PB5引脚置高
}

这里 (volatile unsigned int *)0x40010C0C 是什么意思?

  • 0x40010C0C:一个赤裸裸的物理地址。
  • (unsigned int *):把它强制转换成指向32位整数的指针。
  • volatile:告诉编译器,这个地址的值随时可能被硬件改变,别给我优化掉。
  • 最外层的 *:解引用,就是通过这个指针去操作那个地址里的内容。
我的小习惯: 定义寄存器宏时,我习惯把类型转换和volatile写在一起,一眼就能看出这是个硬件寄存器,不是普通变量。

1.2 volatile与const:编译器的“紧箍咒”

这两个关键字,面试常考,项目里也常栽跟头。

volatile:别瞎优化!

编译器为了效率,会把变量值暂存在CPU寄存器里。但硬件寄存器的值,或者中断服务程序里修改的变量,随时会变。编译器如果用了“缓存”的值,程序就错了。

我曾经调试一个串口接收程序,数据死活收不全。查了两天,发现接收标志位没加volatile。编译器觉得那个变量在循环里没被修改,就直接从寄存器里读旧值,根本不理会硬件更新的内存。加上volatile,问题秒解。

避坑指南: 所有被中断和主循环共享的全局变量,以及所有指向硬件寄存器的指针,都必须加volatile。这是铁律。

const:别乱改!

const在嵌入式里有两个妙用:

  1. 定义只读参数表: 比如正弦波表、校准系数,放在Flash里省RAM。
  2. 修饰函数参数: 告诉调用者,这个函数不会修改你传进来的数据。比如:void UART_Send(const unsigned char *data, int len);

const和volatile可以同时使用。比如读取硬件状态寄存器:

// 状态寄存器,硬件会改,但软件只读
#define USART_SR  (*(const volatile unsigned int *)0x40013800)

嗯,这里要注意,const修饰的是指针指向的内容,不是指针本身。硬件可以改,但你的代码不能写它。

1.3 位操作:寄存器的“手术刀”

寄存器操作,无非就是置位、清零、读取、翻转。C语言提供了&、|、^、~、<<、>>这几把手术刀。

我整理了一个常用操作速查表:

操作 表达式 说明
置位某位 REG |= (1 << n); 将第n位写1,其他位不变
清零某位 REG &= ~(1 << n); 将第n位写0,其他位不变
读取某位 bit = (REG >> n) & 1; 获取第n位的值(0或1)
翻转某位 REG ^= (1 << n); 将第n位取反
连续位赋值 REG &= ~(0xF << 4);
REG |= (value & 0xF) << 4;
先清零bit[7:4],再写入新值
实战经验: 操作连续位时,一定要先清零再赋值。我见过有人直接 REG |= (value << 4);,结果value的某些位和原来的位叠加了,配置全乱套。

1.4 函数指针:驱动架构的“灵魂”

函数指针,就是指向函数的指针。在驱动里,它最大的用处是实现“回调”和“多态”。

你想想看,如果我们要写一个I2C驱动,它可能挂载不同的设备:温度传感器、加速度计、EEPROM。每个设备的读写流程不一样,但驱动框架是一样的。这时候,函数指针就派上用场了。

// 定义一个I2C设备结构体
typedef struct {
    void (*init)(void);                // 设备初始化函数指针
    int  (*read)(uint8_t reg, uint8_t *buf, int len); // 读函数指针
    int  (*write)(uint8_t reg, uint8_t *buf, int len);// 写函数指针
} i2c_device_t;

// 温度传感器的具体实现
static void temp_init(void) { /* 配置温度传感器 */ }
static int  temp_read(uint8_t reg, uint8_t *buf, int len) { /* 读取温度 */ }
static int  temp_write(uint8_t reg, uint8_t *buf, int len) { /* 写配置 */ }

// 实例化一个温度传感器设备
i2c_device_t temp_sensor = {
    .init  = temp_init,
    .read  = temp_read,
    .write = temp_write
};

// 驱动层统一调用
void i2c_driver_handle(i2c_device_t *dev) {
    dev->init();
    dev->read(0x00, buffer, 2);
}

这样做的好处是,驱动层代码完全不用改。换一个传感器,只需要换一个结构体实例。这就是面向对象思想在C语言里的体现。

1.5 知识体系总览

为了让大家有个整体印象,我画了张图,把今天讲的内容串起来:

嵌入式C语言进阶知识体系 嵌入式C语言进阶核心 指针 硬件寄存器遥控器 volatile / const 编译器的紧箍咒 位操作 寄存器的手术刀 函数指针 驱动架构的灵魂 最终目标:寄存器映射实战 将上述知识点融合,实现高效、可移植的硬件驱动代码 GPIO寄存器映射 UART寄存器配置 定时器中断控制

1.6 寄存器映射实战:点亮一盏灯

理论说完了,咱们来点实际的。以STM32F103为例,点亮PC13引脚上的LED。整个过程就是:找到寄存器地址 → 用指针操作它。

// 1. 定义寄存器基地址和偏移
#define RCC_BASE      0x40021000
#define RCC_APB2ENR   (*(volatile unsigned int *)(RCC_BASE + 0x18))

#define GPIOC_BASE    0x40011000
#define GPIOC_CRH     (*(volatile unsigned int *)(GPIOC_BASE + 0x04))
#define GPIOC_ODR     (*(volatile unsigned int *)(GPIOC_BASE + 0x0C))

// 2. 初始化函数
void LED_Init(void) {
    // 使能GPIOC时钟(APB2总线上的外设)
    RCC_APB2ENR |= (1 << 4);
    
    // 配置PC13为推挽输出,最大速度50MHz
    // CRH寄存器控制高8位引脚(Pin8~Pin15)
    // PC13对应CRH的bit[23:20],设置为0010 (0x2)
    GPIOC_CRH &= ~(0xF << 20);  // 先清零
    GPIOC_CRH |= (0x2 << 20);   // 设置为推挽输出
}

// 3. 控制LED
void LED_Toggle(void) {
    GPIOC_ODR ^= (1 << 13);  // 翻转PC13
}
注意: 配置外设时钟是第一步,也是最容易忘的一步。我刚开始学的时候,寄存器配置得完美,但就是没反应。查了半天,发现时钟没开。记住:在Cortex-M3上,几乎所有外设的时钟默认都是关闭的。