4. UART串口驱动与DMA传输
串口,搞嵌入式的人天天打交道。但说真的,很多人用了好几年串口,还停留在“发个字符串、收个中断”的水平。我个人觉得,串口驱动写得好不好,直接决定了你整个系统的数据吞吐能力和CPU利用率。
这一章,咱们就把UART的轮询、中断、DMA三种模式掰开揉碎,再结合RT-Thread的串口设备驱动框架,手把手实现一个带DMA环形缓冲区的串口驱动。
4.1 UART三种工作模式:轮询、中断、DMA
先看一张对比表,心里有个底:
| 模式 | CPU占用 | 实时性 | 适用场景 | 我的评价 |
|---|---|---|---|---|
| 轮询 | 极高(忙等) | 差 | 调试打印、低速率 | 能用,但别认真 |
| 中断 | 中等 | 好 | 命令响应、小数据量 | 最常用,但高频时CPU扛不住 |
| DMA | 极低(几乎0) | 好 | 大数据流、高速率 | 工业级标配 |
4.1.1 轮询模式
说白了就是CPU死盯着状态寄存器不放。代码简单,但效率极低。
// 轮询发送一个字节
void uart_poll_send(USART_TypeDef *USARTx, uint8_t data)
{
while (!(USARTx->SR & USART_SR_TXE));
USARTx->DR = data;
}
// 轮询接收一个字节
uint8_t uart_poll_recv(USART_TypeDef *USARTx)
{
while (!(USARTx->SR & USART_SR_RXNE));
return (uint8_t)(USARTx->DR & 0xFF);
}
4.1.2 中断模式
中断模式是“有事你叫我,没事别烦我”。每收到一个字节,CPU暂停当前任务,跳进中断服务函数处理数据。
void USART1_IRQHandler(void)
{
if (USART1->SR & USART_SR_RXNE) {
uint8_t data = USART1->DR;
// 把data放入缓冲区
ringbuf_push(&rx_ring, data);
}
}
中断模式的问题在于:如果波特率很高(比如921600),每秒钟近10万个中断,CPU大部分时间都在进进出出中断。你想想看,这时候系统还能干别的吗?
4.1.3 DMA模式
DMA模式才是真正的“解放CPU”。DMA控制器直接在内存和外设之间搬运数据,搬完了才通知你一声。
// DMA配置示例(以STM32F4为例)
void uart_dma_init(void)
{
// 使能DMA时钟
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE);
// 配置DMA通道
DMA_InitTypeDef DMA_InitStruct;
DMA_InitStruct.DMA_Channel = DMA_Channel_4;
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)rx_dma_buf;
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralToMemory;
DMA_InitStruct.DMA_BufferSize = RX_DMA_BUF_SIZE;
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStruct.DMA_Mode = DMA_Mode_Circular; // 循环模式
DMA_InitStruct.DMA_Priority = DMA_Priority_High;
DMA_InitStruct.DMA_FIFOMode = DMA_FIFOMode_Disable;
DMA_Init(DMA2_Stream2, &DMA_InitStruct);
// 使能DMA传输完成中断
DMA_ITConfig(DMA2_Stream2, DMA_IT_TC, ENABLE);
// 使能DMA流
DMA_Cmd(DMA2_Stream2, ENABLE);
}
4.2 RT-Thread串口设备驱动框架
RT-Thread把串口抽象成了标准设备,你不需要直接操作寄存器。框架分三层:
- 设备管理层:提供统一的open/read/write/control接口
- 设备驱动框架层:实现串口设备的通用逻辑
- BSP驱动层:对接具体芯片的寄存器操作
我画了一张框架图,帮你理清关系:
核心数据结构是 struct rt_serial_device,里面包含了串口配置、环形缓冲区、DMA句柄等信息。你只需要实现 struct rt_serial_ops 里的几个回调函数:
struct rt_serial_ops {
rt_err_t (*configure)(struct rt_serial_device *serial,
struct serial_configure *cfg);
rt_err_t (*control)(struct rt_serial_device *serial,
int cmd, void *arg);
int (*putc)(struct rt_serial_device *serial, char c);
int (*getc)(struct rt_serial_device *serial);
rt_err_t (*dma_transmit)(struct rt_serial_device *serial,
rt_uint8_t *buf, rt_size_t size,
enum rt_serial_dma_dir dir);
};
4.3 实现串口驱动:从零开始写一个
咱们以STM32F4为例,实现一个支持DMA的串口驱动。我会把关键步骤拆开讲。
4.3.1 设备注册
static struct rt_serial_device stm32_uart1;
int rt_hw_uart_init(void)
{
struct serial_configure config = RT_SERIAL_CONFIG_DEFAULT;
// 配置参数:115200, 8N1
config.baud_rate = BAUD_RATE_115200;
config.data_bits = DATA_BITS_8;
config.stop_bits = STOP_BITS_1;
config.parity = PARITY_NONE;
config.bufsz = RT_SERIAL_RB_BUFSZ; // 默认256字节
// 注册串口设备
rt_hw_serial_register(&stm32_uart1,
"uart1",
RT_DEVICE_FLAG_RDWR |
RT_DEVICE_FLAG_INT_RX |
RT_DEVICE_FLAG_DMA_RX,
&uart1_ops);
return RT_EOK;
}
RT_DEVICE_FLAG_INT_RX,那就是中断接收;加上 RT_DEVICE_FLAG_DMA_RX,框架会自动切换到DMA模式。我建议你一开始先用中断模式调通,再切DMA,这样好排查问题。
4.3.2 实现ops回调
以 putc 和 getc 为例:
static int uart1_putc(struct rt_serial_device *serial, char c)
{
// 等待发送寄存器空
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = c;
return 1;
}
static int uart1_getc(struct rt_serial_device *serial)
{
if (USART1->SR & USART_SR_RXNE) {
return (int)(USART1->DR & 0xFF);
}
return -1; // 无数据
}
DMA传输的回调稍微复杂一点:
static rt_err_t uart1_dma_transmit(struct rt_serial_device *serial,
rt_uint8_t *buf, rt_size_t size,
enum rt_serial_dma_dir dir)
{
if (dir == DMA_DIR_TX) {
// 配置DMA发送
DMA_Cmd(DMA2_Stream7, DISABLE);
DMA_SetCurrDataCounter(DMA2_Stream7, size);
DMA_MemoryTargetConfig(DMA2_Stream7, (uint32_t)buf, DMA_Memory_0);
DMA_Cmd(DMA2_Stream7, ENABLE);
} else {
// DMA接收已经在循环模式中持续运行
// 这里只需要更新缓冲区指针
}
return RT_EOK;
}
4.4 DMA环形缓冲区设计与应用
这是本章的重头戏。DMA环形缓冲区,说白了就是让DMA在一个固定大小的内存圈里循环写数据,CPU在另一个位置读数据。两者互不干扰。
4.4.1 数据结构设计
typedef struct {
rt_uint8_t *buffer; // 缓冲区基地址
rt_size_t size; // 缓冲区大小
volatile rt_size_t read_index; // 读指针(CPU维护)
volatile rt_size_t write_index; // 写指针(DMA硬件维护)
} dma_ring_buffer_t;
这里有个关键点:write_index 不是由软件维护的,而是通过DMA的当前数据计数器(NDT)计算出来的。
// 获取DMA当前写位置
static rt_size_t dma_get_write_index(DMA_Stream_TypeDef *DMAx)
{
rt_size_t ndt = DMA_GetCurrDataCounter(DMAx);
return RX_DMA_BUF_SIZE - ndt;
}
4.4.2 数据读取流程
CPU读取数据的逻辑是这样的:
- 获取DMA当前写位置(write_index)
- 如果 write_index > read_index,说明有新数据,区间为 [read_index, write_index)
- 如果 write_index < read_index,说明发生了回绕,分两段读取
- 更新 read_index = write_index
rt_size_t dma_ring_read(dma_ring_buffer_t *ring, rt_uint8_t *out, rt_size_t len)
{
rt_size_t write_idx = dma_get_write_index(DMA2_Stream2);
rt_size_t avail;
rt_size_t first_len;
if (write_idx >= ring->read_index) {
// 没有回绕
avail = write_idx - ring->read_index;
if (avail > len) avail = len;
rt_memcpy(out, ring->buffer + ring->read_index, avail);
} else {
// 发生了回绕,分两段
first_len = ring->size - ring->read_index;
if (first_len > len) first_len = len;
rt_memcpy(out, ring->buffer + ring->read_index, first_len);
if (len > first_len) {
rt_size_t second_len = len - first_len;
if (second_len > write_idx) second_len = write_idx;
rt_memcpy(out + first_len, ring->buffer, second_len);
avail = first_len + second_len;
} else {
avail = first_len;
}
}
ring->read_index = (ring->read_index + avail) % ring->size;
return avail;
}
4.4.3 中断处理
DMA传输完成中断用来通知CPU“缓冲区半满”或“缓冲区全满”:
void DMA2_Stream2_IRQHandler(void)
{
if (DMA_GetITStatus(DMA2_Stream2, DMA_IT_TCIF2)) {
// 传输完成(一圈结束)
// 触发数据读取事件
rt_serial_dma_rx_done(&stm32_uart1, RX_DMA_BUF_SIZE);
DMA_ClearITPendingBit(DMA2_Stream2, DMA_IT_TCIF2);
}
if (DMA_GetITStatus(DMA2_Stream2, DMA_IT_HTIF2)) {
// 半传输完成(半圈结束)
// 可以提前处理前半部分数据
rt_serial_dma_rx_done(&stm32_uart1, RX_DMA_BUF_SIZE / 2);
DMA_ClearITPendingBit(DMA2_Stream2, DMA_IT_HTIF2);
}
}
为什么要用半传输中断?说白了就是“流水线处理”。DMA在写后半段的时候,CPU已经在处理前半段了。这样延迟减半,吞吐翻倍。
4.4.4 应用示例
最后,看看在RT-Thread里怎么用:
// 打开串口设备
rt_device_t uart_dev = rt_device_find("uart1");
struct serial_configure config = RT_SERIAL_CONFIG_DEFAULT;
config.bufsz = 1024;
rt_device_control(uart_dev, RT_DEVICE_CTRL_CONFIG, &config);
rt_device_open(uart_dev, RT_DEVICE_FLAG_DMA_RX);
// 接收数据(非阻塞)
rt_uint8_t buf[256];
rt_size_t len = rt_device_read(uart_dev, 0, buf, sizeof(buf));
if (len > 0) {
// 处理数据
}
// 发送数据(DMA发送)
rt_device_write(uart_dev, 0, "Hello DMA!\r\n", 12);
看到没?上层代码完全不知道底层是轮询、中断还是DMA。这就是RT-Thread设备框架的魅力——你只需要关注业务逻辑,驱动细节交给底层。
嗯,这一章的内容就到这里。串口驱动看似简单,但要把DMA环形缓冲区玩明白,还是需要下一番功夫的。建议你动手写一遍代码,调通之后再用逻辑分析仪看看CPU的负载变化——你会直观地感受到DMA带来的性能提升。