3. GPIO驱动开发
UART串口,说白了就是嵌入式世界的"通用翻译官"。不管你是调试打印、跟传感器聊天,还是和Wi-Fi模块传数据,十有八九都得靠它。我这些年经手的项目,几乎每个板子第一件事就是把串口调通——就像人要先学会说话一样。
这一节,咱们把USART从里到外拆开看。从寄存器怎么配,到轮询、中断、DMA三种收发模式怎么玩,最后再搞定printf重定向。嗯,内容不少,但都是硬通货。
3.1 USART框图与核心寄存器
先看整体结构。我习惯把USART框图分成三块:发送通路、接收通路、控制与状态。
这张图我建议你多看两眼。发送和接收是两条独立的通路,各有一个移位寄存器干活。控制寄存器决定怎么干,状态寄存器告诉你干完了没有。
核心寄存器速记口诀:
- CR1 — 使能开关(UE, TE, RE),中断使能(RXNEIE, TCIE)
- CR2 — 停止位长度,同步模式配置
- CR3 — DMA使能(DMAT, DMAR),流控(RTS/CTS)
- SR/ISR — 查状态:能不能发?有没有收到?
- DR — 数据进出的唯一通道
- BRR — 波特率 = 时钟 / (16 × USARTDIV)
3.2 轮询模式收发
轮询模式,说白了就是CPU死等。代码简单,但效率低。适合调试阶段或者低速通信。
发送一个字节的套路:
// 等待发送数据寄存器空
while (!(USART1->SR & USART_SR_TXE));
// 写入数据
USART1->DR = (data & 0xFF);
接收一个字节的套路:
// 等待接收数据寄存器非空
while (!(USART1->SR & USART_SR_RXNE));
// 读取数据
uint8_t data = USART1->DR & 0xFF;
我曾经踩过的坑:读DR寄存器时,RXNE位会自动清零。但如果你先读SR再读DR,中间插了别的操作,可能会丢数据。我的习惯是:读SR判断RXNE后,立即读DR,中间不要有任何其他代码。
轮询收发完整函数:
void uart_send_byte(USART_TypeDef *USARTx, uint8_t data)
{
while (!(USARTx->SR & USART_SR_TXE));
USARTx->DR = data;
}
uint8_t uart_recv_byte(USART_TypeDef *USARTx)
{
while (!(USARTx->SR & USART_SR_RXNE));
return (uint8_t)(USARTx->DR & 0xFF);
}
void uart_send_string(USART_TypeDef *USARTx, const char *str)
{
while (*str)
{
uart_send_byte(USARTx, *str++);
}
}
你想想看,如果CPU一边发数据一边还要处理其他任务,轮询模式就把CPU锁死了。这时候就该中断上场了。
3.3 中断模式收发
中断模式的核心思想:让CPU干正事,串口准备好了再通知你。
配置步骤其实就三步:
- 使能USART中断(RXNEIE或TCIE)
- 使能NVIC中的USART中断通道
- 编写中断服务函数
发送中断的典型写法:
// 发送一个字节(启动中断)
void uart_send_byte_it(USART_TypeDef *USARTx, uint8_t data)
{
// 把数据暂存到全局变量
g_tx_data = data;
// 使能发送完成中断
USARTx->CR1 |= USART_CR1_TCIE;
}
// 中断服务函数
void USART1_IRQHandler(void)
{
if (USART1->SR & USART_SR_TC)
{
// 发送完成,关闭中断
USART1->CR1 &= ~USART_CR1_TCIE;
// 可以在这里设置标志位,通知主循环
g_tx_done = 1;
}
}
我的个人习惯:发送中断我一般用TC(发送完成)而不是TXE(发送空)。为什么?TXE只是说数据从DR移到了移位寄存器,但还没发完。TC才是真正发完了。如果你紧接着要关串口或切模式,用TC更安全。
接收中断更常用:
void USART1_IRQHandler(void)
{
if (USART1->SR & USART_SR_RXNE)
{
uint8_t data = USART1->DR & 0xFF;
// 把数据放入环形缓冲区
ringbuf_put(&g_rx_ring, data);
}
}
这里我强烈建议用环形缓冲区。我在项目里吃过亏——中断来得太快,主循环来不及处理,数据就丢了。环形缓冲区能很好地解耦中断和主循环的速度差异。
3.4 DMA模式收发
DMA模式,说白了就是让DMA控制器帮你搬数据,CPU彻底解放。适合大批量数据传输,比如用串口刷日志、传文件。
配置DMA发送的要点:
// 配置DMA发送通道
void uart_dma_tx_config(void)
{
// 1. 使能DMA时钟
RCC->AHB1ENR |= RCC_AHB1ENR_DMA1EN;
// 2. 配置DMA通道
DMA1_Stream3->CR = 0; // 先复位
DMA1_Stream3->PAR = (uint32_t)&USART1->DR; // 外设地址
DMA1_Stream3->M0AR = (uint32_t)g_tx_buf; // 内存地址
DMA1_Stream3->NDTR = TX_BUF_SIZE; // 传输数量
// 3. 配置方向:内存到外设
DMA1_Stream3->CR |= DMA_SxCR_DIR_0; // 内存到外设
DMA1_Stream3->CR |= DMA_SxCR_MINC; // 内存地址递增
DMA1_Stream3->CR |= DMA_SxCR_TCIE; // 传输完成中断
// 4. 使能USART的DMA发送
USART1->CR3 |= USART_CR3_DMAT;
}
启动一次DMA发送:
void uart_dma_send(uint8_t *data, uint16_t len)
{
// 等待上一次传输完成
while (DMA1_Stream3->CR & DMA_SxCR_EN);
// 设置源地址和长度
DMA1_Stream3->M0AR = (uint32_t)data;
DMA1_Stream3->NDTR = len;
// 启动DMA
DMA1_Stream3->CR |= DMA_SxCR_EN;
}
我曾经遇到的坑:DMA传输完成后,USART可能还没把移位寄存器里的最后一个字节发完。如果你立刻关串口或进低功耗模式,最后一个字节就丢了。我的做法是:DMA传输完成中断里,再等一下USART的TC标志位,确认真的发完了再收工。
三种模式的对比:
| 模式 | CPU占用 | 实时性 | 适合场景 | 代码复杂度 |
|---|---|---|---|---|
| 轮询 | 100%(阻塞) | 差 | 调试、低速单次通信 | 低 |
| 中断 | 仅中断时占用 | 好 | 交互式通信、不定长数据 | 中 |
| DMA | 几乎为0 | 最好 | 批量数据、高速传输 | 高 |
3.5 printf重定向实现
printf重定向,说白了就是让C库的printf函数把数据吐到你的串口上。调试利器,没有之一。
在STM32上,标准做法是重写_write函数(ARMCC)或__io_putchar(GCC)。
ARMCC(Keil)环境:
// 在工程设置中勾选"Use MicroLIB"
int fputc(int ch, FILE *f)
{
// 轮询发送一个字节
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = (ch & 0xFF);
return ch;
}
// 如果要用DMA方式重定向
int fputc(int ch, FILE *f)
{
// 用DMA发送,需要自己实现队列
uart_dma_putchar(ch);
return ch;
}
GCC(STM32CubeIDE)环境:
int __io_putchar(int ch)
{
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = (ch & 0xFF);
return ch;
}
// 还需要实现_sys_write(新版本GCC)
int _write(int file, char *ptr, int len)
{
for (int i = 0; i < len; i++)
{
__io_putchar(*ptr++);
}
return len;
}
我的经验:printf默认是阻塞的,如果你在中断里调用printf,可能会死锁。我一般会做一个非阻塞版printf:先把要打印的字符串格式化到缓冲区,然后通过DMA或中断异步发送。这样既不影响实时性,又能愉快地打日志。
非阻塞printf的简单实现思路:
// 用vsnprintf格式化到缓冲区
void debug_printf(const char *fmt, ...)
{
va_list args;
va_start(args, fmt);
vsnprintf(g_debug_buf, DEBUG_BUF_SIZE, fmt, args);
va_end(args);
// 启动DMA发送(非阻塞)
uart_dma_send((uint8_t *)g_debug_buf, strlen(g_debug_buf));
}
嗯,到这里UART串口驱动的四种模式就全了。从轮询到中断再到DMA,每一步都是性能和复杂度的权衡。实际项目中,我通常这样搭配:
- 调试阶段:轮询printf,简单粗暴
- 正常通信:中断接收 + 环形缓冲区,DMA发送
- 大批量数据:DMA收发全开,配合双缓冲
串口驱动写好了,后面移植RTOS时,你就能体会到非阻塞通信的好处了——任务不会被串口阻塞,系统响应才够快。