裸机编程基础回顾:GPIO、定时器、中断、UART 等外设的裸机驱动编写
说实话,很多做单片机开发的朋友,一上来就怼 RTOS,觉得裸机太 low。但我个人的经验是——裸机编程才是嵌入式软件的根基。你连 GPIO 怎么点灯、定时器怎么溢出、中断现场怎么保护都没搞透,上层协议栈写得再花哨,跑起来也是一堆玄学 Bug。
这一章,咱们就把这几个最基础的外设驱动,掰开了揉碎了过一遍。嗯,都是干货,没有废话。
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); // 开启上拉
定时器:单片机的心跳
定时器是裸机编程里最常用的外设之一。它的本质就是一个递增/递减计数器,时钟源驱动它计数,计到预设值就产生中断或事件。
我一般把定时器应用分成三类:
| 类型 | 典型用途 | 关键参数 |
|---|---|---|
| 基本定时器 | 周期性中断、软件定时 | 预分频、自动重装值 |
| 通用定时器 | 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)
中断:别让 CPU 空转
中断是裸机编程的“事件驱动”核心。没有中断,你的 CPU 就得轮询所有外设,效率低得可怜。
中断处理的核心流程,我总结为三步:
- 中断源配置:使能外设的中断标志位
- NVIC 配置:设置中断优先级、使能中断通道
- 中断服务函数:处理事件,清除中断标志
这里有个关键点——中断服务函数要短小精悍。我见过有人把几百行的业务逻辑写在中断里,结果中断嵌套把栈撑爆了。正确的做法是:中断里只做标志位设置或数据搬运,具体处理放到主循环。
// 中断服务函数示例(TIM2)
void TIM2_IRQHandler(void) {
if (TIM2->SR & TIM_SR_UIF) { // 检查更新中断标志
TIM2->SR = ~TIM_SR_UIF; // 清除标志(写0清除)
g_tick++; // 系统滴答计数
// 不要在这里做复杂运算!
}
}
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);
}
}
知识体系总览
下面这张图,把裸机编程的基础外设驱动逻辑串起来了。你可以把它当作一个“思维导图”,每次写驱动前扫一眼,不容易漏步骤。
写在最后
裸机编程看似简单,但每一个细节都藏着坑。时钟没开、标志清错、中断优先级配乱——这些我都经历过。嗯,说白了,写驱动就是在和硬件“对话”,你得尊重它的时序,理解它的寄存器。
这一章的内容,是后续所有协议栈和内存管理的基础。你把这些外设驱动写熟了,后面学 I2C、SPI、DMA 就会轻松很多。记住:基础不牢,地动山摇。