4、UART串口驱动:从协议到中断与DMA

UART,说白了就是串口。这东西在嵌入式开发里太常见了,几乎每个板子都会引出几个串口。我刚开始做RISC-V开发时,第一个调通的外设就是UART——毕竟调试信息全靠它打印出来。

这一章我们就把UART串口驱动从头到尾捋一遍。从协议基础,到波特率怎么配,再到中断和DMA两种收发模式。嗯,内容不少,但都很实用。

4.1 UART协议基础

UART是异步串行通信,没有时钟线。收发双方靠约定的波特率来同步。一帧数据通常包含:起始位(1位低电平)、数据位(5~8位)、可选的校验位、停止位(1~2位高电平)。

我见过不少新手在调试时,两边波特率明明配的一样,但数据就是乱码。后来发现是停止位位数或者数据位长度没对齐。所以,通信双方的所有帧格式参数必须完全一致。

参数 常见值 说明
波特率 9600, 115200, 921600 每秒传输的符号数
数据位 8 最常用,兼容ASCII
校验位 None, Even, Odd 无校验最常用
停止位 1, 2 1位停止位最常用
我的习惯:调试阶段一律用 115200-8-N-1。这个组合兼容性好,速度也够快。等产品定型了再根据需求调整。

4.2 波特率配置

波特率配置是UART驱动的第一道坎。RISC-V芯片里的UART外设,通常有一个波特率发生器,核心是一个分频计数器。

计算公式很简单:

波特率 = 时钟频率 / (16 × 分频值)

反过来:

分频值 = 时钟频率 / (16 × 目标波特率)

举个例子。假设系统时钟是50MHz,想要115200波特率:

分频值 = 50000000 / (16 × 115200) ≈ 27.13

取整后是27。实际波特率 = 50000000 / (16 × 27) ≈ 115740。误差只有0.47%,完全在可接受范围内(通常要求误差 < 2%)。

我曾经踩过的坑:有次用921600高速波特率,分频值算出来是3.39,取整3后误差超过5%。结果数据丢包严重。后来改用1.8432MHz专用晶振才解决。所以,高速波特率一定要算误差,别想当然。

在RISC-V平台上,配置代码大致是这样:

// 假设UART基地址为 0x10000000
// 寄存器偏移:DLL(0x00), DLH(0x04), LCR(0x0C)

void uart_set_baudrate(uint32_t base, uint32_t clk_hz, uint32_t baud) {
    uint32_t divisor = clk_hz / (16 * baud);
    
    // 设置DLAB位( Divisor Latch Access Bit)
    uint8_t lcr = read8(base + 0x0C);
    write8(base + 0x0C, lcr | 0x80);
    
    // 写分频值
    write8(base + 0x00, divisor & 0xFF);       // DLL
    write8(base + 0x04, (divisor >> 8) & 0xFF); // DLH
    
    // 清除DLAB位
    write8(base + 0x0C, lcr & ~0x80);
}

4.3 收发实现——轮询模式

轮询模式最简单。发送时不断检查发送保持寄存器是否为空,接收时不断检查接收数据是否就绪。

void uart_send_byte(uint32_t base, uint8_t data) {
    // 等待发送缓冲区空
    while (!(read8(base + 0x14) & 0x20));
    // 写数据
    write8(base + 0x00, data);
}

uint8_t uart_recv_byte(uint32_t base) {
    // 等待接收数据就绪
    while (!(read8(base + 0x14) & 0x01));
    // 读数据
    return read8(base + 0x00);
}

轮询模式在裸机或RTOS的初始化阶段够用。但一旦进入正常业务逻辑,CPU一直被占着等数据,效率太低了。这时候就该中断上场了。

4.4 中断驱动模式

中断驱动的思路是:收到数据或发送完成时,硬件自动触发中断,CPU在中断服务程序里处理。

配置步骤:

  1. 使能UART的接收/发送中断
  2. 设置中断控制器(PLIC或CLINT)使能对应中断源
  3. 编写中断服务程序
  4. 在中断服务程序里读取数据或填充发送数据

RISC-V的中断处理与ARM不太一样。它没有中断向量表自动跳转,需要软件在异常入口处读取mcause或scause来判断中断源。不过,UART外设本身的中断寄存器设计是类似的。

// 中断服务程序(简化版)
void uart_isr(uint32_t base) {
    uint8_t iir = read8(base + 0x08); // 中断标识寄存器
    
    if (iir & 0x04) { // 接收中断
        uint8_t data = read8(base + 0x00);
        // 把数据放入环形缓冲区
        ringbuf_put(&rx_ring, data);
    }
    
    if (iir & 0x02) { // 发送中断
        // 从发送缓冲区取数据发送
        if (ringbuf_get(&tx_ring, &data)) {
            write8(base + 0x00, data);
        } else {
            // 没有数据了,关发送中断
            uart_disable_tx_interrupt(base);
        }
    }
}
关键点:发送中断不要一直开着。只在有数据要发送时才使能,发完立刻关闭。否则会不断触发空中断,浪费CPU。

4.5 DMA模式

中断模式虽然比轮询好,但每个字节都触发一次中断,高频通信时CPU开销还是很大。DMA模式可以彻底解放CPU——数据搬运由DMA控制器完成,CPU只在传输完成时收到一次中断。

UART+DMA的典型工作流程:

  • 发送DMA:CPU配置好源地址(内存缓冲区)、目的地址(UART数据寄存器)、长度,启动DMA。DMA自动把数据逐个写入UART发送寄存器。
  • 接收DMA:DMA自动从UART接收寄存器读取数据,写入内存缓冲区。当接收完指定字节数或发生超时时,触发中断通知CPU。

配置DMA时要注意几个细节:

  • UART的DMA请求信号要正确连接到DMA控制器
  • DMA传输宽度要与UART数据寄存器宽度匹配(通常是8位)
  • 接收DMA建议配合超时机制,避免收不到完整帧时一直等待
// DMA发送配置示例(伪代码)
void uart_dma_send(uint32_t uart_base, uint8_t *buf, uint32_t len) {
    // 1. 配置DMA源地址 = buf
    dma_set_source( DMA_CH0, (uint32_t)buf );
    // 2. 配置DMA目的地址 = UART数据寄存器
    dma_set_dest( DMA_CH0, uart_base + 0x00 );
    // 3. 配置传输长度
    dma_set_count( DMA_CH0, len );
    // 4. 配置传输宽度为字节
    dma_set_width( DMA_CH0, DMA_WIDTH_8BIT );
    // 5. 使能UART的DMA发送请求
    uart_enable_dma_tx(uart_base);
    // 6. 启动DMA
    dma_start( DMA_CH0 );
    // 7. 等待DMA完成中断(或轮询)
}
我曾经遇到的坑:有次用DMA接收不定长数据,DMA设置成固定长度接收。结果对方发的数据长度不固定,DMA要么收不够不触发中断,要么多收了垃圾数据。后来改用DMA+空闲中断的方式——DMA一直开着,检测到总线空闲超过一个字节时间就认为帧结束。这才解决问题。

4.6 三种模式对比

模式 CPU占用 实时性 适用场景
轮询 高(一直占用) 中 初始化、低速率、简单调试
中断 中(每字节一次) 高 中低速通信、交互式应用
DMA 低(整批一次) 高 高速通信、大数据量、低功耗

我个人建议:项目初期先用中断模式,稳定后再考虑是否升级到DMA。别一上来就上DMA,调试起来麻烦得多。

UART驱动三种模式对比流程图 UART驱动初始化 轮询模式 CPU循环检查状态位 发送/接收都阻塞等待 CPU占用率:高 中断模式 硬件触发中断 ISR处理收发数据 CPU占用率:中 DMA模式 DMA控制器搬运数据 传输完成才中断CPU CPU占用率:低 根据场景选择合适的模式 调试阶段:轮询 → 功能验证后:中断 → 性能优化时:DMA 循序渐进,不要一步到位

嗯,UART串口驱动的内容就这些。从协议到配置,从轮询到中断再到DMA,每一步都有它的道理。你在实际项目中,可以根据通信速率和CPU负载灵活选择。记住一点:驱动没有银弹,适合场景的才是最好的。