裸机编程基础回顾:GPIO、定时器、中断、UART 等外设的裸机驱动编写

说实话,很多做单片机开发的朋友,一上来就怼 RTOS,觉得裸机太 low。但我个人的经验是——裸机编程才是嵌入式软件的根基。你连 GPIO 怎么点灯、定时器怎么溢出、中断现场怎么保护都没搞透,上层协议栈写得再花哨,跑起来也是一堆玄学 Bug。

这一章,咱们就把这几个最基础的外设驱动,掰开了揉碎了过一遍。嗯,都是干货,没有废话。

GPIO 驱动:不只是点灯

GPIO 是单片机和外界的“握手通道”。配置一个 GPIO,说白了就是三件事:时钟使能、模式选择、电平操作。

核心要点: 任何外设操作前,必须先开启对应 GPIO 端口的时钟。这是新手最容易忘的一步。

以 STM32F103 为例,一个标准的 GPIO 输出初始化流程是这样的:

// GPIO 输出初始化(PA0)
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;   // 开启 GPIOA 时钟
GPIOA->CRL    &= ~(0x0F << 0);        // 清空 CNF0+MODE0
GPIOA->CRL    |=  (0x01 << 0);        // 推挽输出,10MHz

这里有个坑——配置寄存器时一定要先清空再赋值。我见过有人直接写 GPIOA->CRL |= 0x01,结果其他引脚的模式被覆盖了,整个板子跑飞。你想想看,这种 Bug 查起来多痛苦。

输入模式稍微复杂一点,尤其是需要上拉/下拉的场景:

// GPIO 输入初始化(PA1,上拉输入)
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
GPIOA->CRL    &= ~(0x0F << 4);        // 清空 CNF1+MODE1
GPIOA->CRL    |=  (0x08 << 4);        // 上拉/下拉输入
GPIOA->ODR    |=  (0x01 << 1);        // 开启上拉
个人习惯: 我写 GPIO 驱动时,会把所有引脚配置封装成结构体,用枚举定义模式。这样代码可读性高,后期移植也方便。

定时器:单片机的心跳

定时器是裸机编程里最常用的外设之一。它的本质就是一个递增/递减计数器,时钟源驱动它计数,计到预设值就产生中断或事件。

我一般把定时器应用分成三类:

类型 典型用途 关键参数
基本定时器 周期性中断、软件定时 预分频、自动重装值
通用定时器 PWM 输出、输入捕获 占空比、捕获极性
高级定时器 电机控制、互补 PWM 死区时间、刹车功能

配置一个 1ms 中断的定时器,核心代码就这几行:

// TIM2 配置,1ms 中断(72MHz 主频)
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;   // 开启 TIM2 时钟
TIM2->PSC     = 71;                     // 预分频:72MHz / (71+1) = 1MHz
TIM2->ARR     = 999;                    // 自动重装:1MHz / (999+1) = 1kHz
TIM2->DIER   |= TIM_DIER_UIE;          // 使能更新中断
TIM2->CR1    |= TIM_CR1_CEN;           // 启动定时器
NVIC->ISER[0] = (1 << 28);            // 使能 TIM2 中断(中断号 28)
我曾经踩过的坑: 预分频器 PSC 和自动重装 ARR 都是 硬件值 = 期望值 - 1。如果你直接写 PSC=72,ARR=1000,实际频率会差很多。调试时用逻辑分析仪抓一下波形,一眼就能看出来。

中断:别让 CPU 空转

中断是裸机编程的“事件驱动”核心。没有中断,你的 CPU 就得轮询所有外设,效率低得可怜。

中断处理的核心流程,我总结为三步:

  1. 中断源配置:使能外设的中断标志位
  2. NVIC 配置:设置中断优先级、使能中断通道
  3. 中断服务函数:处理事件,清除中断标志

这里有个关键点——中断服务函数要短小精悍。我见过有人把几百行的业务逻辑写在中断里,结果中断嵌套把栈撑爆了。正确的做法是:中断里只做标志位设置或数据搬运,具体处理放到主循环。

// 中断服务函数示例(TIM2)
void TIM2_IRQHandler(void) {
    if (TIM2->SR & TIM_SR_UIF) {       // 检查更新中断标志
        TIM2->SR = ~TIM_SR_UIF;        // 清除标志(写0清除)
        g_tick++;                       // 系统滴答计数
        // 不要在这里做复杂运算!
    }
}
避坑指南: 中断标志的清除方式因外设而异。有的是写1清除,有的是写0清除,还有的是硬件自动清除。一定要看数据手册,别想当然。

UART:最朴素的通信接口

UART 是调试阶段的“生命线”。没有它,你连 printf 都用不了。UART 的裸机驱动,核心就是波特率生成、数据收发、中断处理。

配置一个 115200 波特率的 UART:

// USART1 配置,115200-8N1
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;  // 开启 USART1 时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;    // 开启 GPIOA 时钟

// PA9(TX) 推挽复用输出,PA10(RX) 浮空输入
GPIOA->CRH   &= ~(0xFF << 4);
GPIOA->CRH   |=  (0x0B << 4);          // TX: 复用推挽输出
GPIOA->CRH   |=  (0x04 << 8);          // RX: 浮空输入

USART1->BRR   = 0x2710;                 // 72MHz / 115200 = 625 = 0x0271
USART1->CR1   = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
NVIC->ISER[1] = (1 << 5);              // 使能 USART1 中断(中断号 37)

发送一个字节,我习惯用轮询方式,简单可靠:

void uart_send_byte(uint8_t data) {
    USART1->DR = data;                   // 写入数据寄存器
    while (!(USART1->SR & USART_SR_TC)); // 等待发送完成
}

接收则用中断,避免阻塞:

void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {   // 接收到数据
        uint8_t rx_data = USART1->DR;    // 读取数据(自动清除标志)
        // 放入环形缓冲区
        ring_buf_put(&uart_rx_buf, rx_data);
    }
}
注意: 读取 USART->DR 寄存器会自动清除 RXNE 标志。如果你先读 SR 再读 DR,顺序不能乱。我见过有人先读 DR 再读 SR,结果标志永远清不掉,中断一直触发。

知识体系总览

下面这张图,把裸机编程的基础外设驱动逻辑串起来了。你可以把它当作一个“思维导图”,每次写驱动前扫一眼,不容易漏步骤。

裸机外设驱动知识体系 裸机外设驱动 GPIO 定时器 中断 UART 时钟使能 模式配置 电平操作 预分频 PSC 自动重装 ARR 中断/事件 中断源使能 NVIC 配置 ISR 处理 波特率生成 数据收发 中断/轮询 核心原则:先时钟,后配置,再操作;中断服务函数要短小,标志清除要确认 调试工具:逻辑分析仪、串口助手、示波器

写在最后

裸机编程看似简单,但每一个细节都藏着坑。时钟没开、标志清错、中断优先级配乱——这些我都经历过。嗯,说白了,写驱动就是在和硬件“对话”,你得尊重它的时序,理解它的寄存器。

这一章的内容,是后续所有协议栈和内存管理的基础。你把这些外设驱动写熟了,后面学 I2C、SPI、DMA 就会轻松很多。记住:基础不牢,地动山摇。