4. UART驱动移植与实战(串口控制台)

好,咱们进入第四章。这一章可以说是整个课程里最实用的一章——串口。你想想看,做嵌入式开发,串口就是你的眼睛和嘴巴。没有串口,你连系统跑没跑起来都不知道。

我个人习惯,拿到一块新板子,第一件事就是把串口调通。为什么?因为后面所有的调试信息、交互命令,全得靠它。RT-Thread 的 FinSH 组件更是离不开串口。所以这一章,咱们把 STM32 的 UART 从底层到上层,彻底搞明白。

4.1 STM32 UART 基础与 DMA 配置

先说说 STM32 的 UART 外设。说白了,它就是一套串行通信接口,负责把并行数据转成串行比特流发出去,再把收到的串行比特流还原成数据。

STM32 的 USART(通用同步异步收发器)比普通 UART 多了同步功能,但咱们做串口控制台,用异步模式就够了。核心寄存器就那么几个:

寄存器 作用 我常用的配置
USART_SR 状态寄存器 查 TXE、TC、RXNE 标志位
USART_DR 数据寄存器 读/写 9 位数据
USART_BRR 波特率寄存器 根据时钟算分频系数
USART_CR1~CR3 控制寄存器 使能 UE、TE、RE、DMA 等

波特率怎么算?公式很简单:波特率 = fCK / (16 × USARTDIV)。其中 fCK 是外设时钟,USARTDIV 就是 BRR 寄存器里的值。我一般用 115200,这个速率稳定,而且大部分串口工具都支持。

小技巧: 配置波特率时,算出来的 USARTDIV 尽量取整。如果小数部分不是 0.5 的整数倍,实际波特率会有偏差。偏差超过 2% 就可能丢数据。我在项目里吃过这个亏,后来都先用计算器算一遍。

再说 DMA。为什么要用 DMA?因为传统的中断方式,每收发一个字节就要进一次中断。115200 波特率下,每秒约 11520 个字节,也就是 11520 次中断。CPU 光处理中断就忙不过来了。

DMA 的配置流程,我总结为四步:

  1. 使能 DMA 时钟 —— RCC 里打开对应 DMA 控制器时钟
  2. 配置 DMA 通道 —— 设置源地址、目标地址、传输方向、数据宽度
  3. 关联 USART 请求 —— USART_CR3 里使能 DMAT 和 DMAR
  4. 启动 DMA 传输 —— 使能 DMA 通道,开始干活

这里有个坑,我曾经踩过:DMA 的传输完成中断和 USART 的传输完成中断是两回事。DMA 把数据搬完了,不代表 USART 已经发完了。要等 USART 的 TC 标志位置位,才算真正发送完成。

4.2 RT-Thread Serial 设备驱动框架解析

RT-Thread 的 Serial 设备驱动框架,说白了就是一套标准接口。它把各种串口芯片的差异都封装起来了,你只需要实现几个回调函数,就能让你的 UART 被系统识别和使用。

框架的核心结构体是 struct rt_serial_device。它长这样:

struct rt_serial_device
{
    struct rt_device          parent;           // 继承自 rt_device
    const struct rt_uart_ops *ops;              // 操作函数集
    struct serial_configure   config;           // 配置参数

    void *serial_rx;                            // 接收缓冲区
    void *serial_tx;                            // 发送缓冲区

    rt_uint8_t rx_int;                          // 接收中断标志
    rt_uint8_t tx_int;                          // 发送中断标志
};

你注意看,它里面有个 ops 指针,指向 struct rt_uart_ops。这个结构体定义了所有你需要实现的函数:

函数 作用 说明
configure 配置串口参数 波特率、数据位、校验位、停止位
control 控制命令 如流控、DMA 使能/禁能
putc 发送一个字符 轮询模式用
getc 接收一个字符 轮询模式用
dma_transmit DMA 发送 非阻塞模式用
dma_receive DMA 接收 非阻塞模式用

框架的工作流程是这样的:上层应用调用 rt_device_write(),框架根据配置的模式(轮询/中断/DMA),调用对应的 ops 函数。你只需要把底层的硬件操作填进去就行。

核心思路: 驱动框架帮你处理了缓冲区管理、信号量同步、中断分发这些通用逻辑。你只需要关注硬件相关的部分——配置寄存器、读写数据寄存器、处理中断标志。

为了让你更直观地理解,我画了一张框架图:

RT-Thread Serial 设备驱动框架 应用层 (FinSH / 用户程序) 设备管理层 (rt_device_write/read/control) Serial 驱动框架 (rt_serial_device) 缓冲区管理 | 信号量同步 | 中断分发 rt_uart_ops 函数集 configure | control | putc | getc | dma_transmit | dma_receive 硬件层 (STM32 USART 寄存器) 数据流向 应用数据 → 框架 → ops → 硬件 中断/事件反向传递

4.3 编写 UART 驱动

好,理论说完了,咱们动手写代码。目标是让 STM32 的 USART1 被 RT-Thread 识别为一个 Serial 设备。

第一步,定义设备结构体。我习惯把硬件相关的信息都放在一个结构体里:

struct stm32_uart
{
    USART_TypeDef *instance;      // USART 基地址
    IRQn_Type irq;                // 中断号
    DMA_Stream_TypeDef *dma_tx;   // DMA 发送流
    DMA_Stream_TypeDef *dma_rx;   // DMA 接收流
    rt_uint32_t dma_tc_flag;      // DMA 传输完成标志
};

static struct stm32_uart uart1_dev =
{
    .instance = USART1,
    .irq = USART1_IRQn,
    .dma_tx = DMA2_Stream7,
    .dma_rx = DMA2_Stream5,
};

第二步,实现 ops 函数。以 configure 为例:

static rt_err_t uart_configure(struct rt_serial_device *serial,
                               struct serial_configure *cfg)
{
    struct stm32_uart *uart = (struct stm32_uart *)serial->parent.user_data;
    USART_TypeDef *usart = uart->instance;
    uint32_t baud_div;

    // 计算波特率分频值
    baud_div = HAL_RCC_GetPCLK2Freq() / (16 * cfg->baud_rate);
    usart->BRR = baud_div;

    // 配置数据位、停止位、校验位
    usart->CR1 = (usart->CR1 & ~0x3000) | (cfg->data_bits << 12);
    usart->CR2 = (usart->CR2 & ~0x3000) | (cfg->stop_bits << 12);
    usart->CR1 = (usart->CR1 & ~0x100) | (cfg->parity << 8);

    // 使能 USART
    usart->CR1 |= USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;

    return RT_EOK;
}

第三步,注册设备。在 rt_hw_usart_init() 函数里完成:

int rt_hw_usart_init(void)
{
    rt_err_t ret;
    struct serial_configure config = RT_SERIAL_CONFIG_DEFAULT;

    // 设置波特率为 115200
    config.baud_rate = BAUD_RATE_115200;

    // 注册 Serial 设备
    ret = rt_hw_serial_register(&serial1,
                                "uart1",
                                RT_DEVICE_FLAG_RDWR |
                                RT_DEVICE_FLAG_INT_RX |
                                RT_DEVICE_FLAG_DMA_TX,
                                &uart1_dev);
    if (ret != RT_EOK)
    {
        return ret;
    }

    // 配置串口参数
    rt_device_control(&serial1.parent, RT_DEVICE_CTRL_CONFIG, &config);

    return RT_EOK;
}
注意: 注册设备时,flag 参数决定了驱动框架使用哪种模式。如果你要支持 DMA 发送,必须加上 RT_DEVICE_FLAG_DMA_TX。如果只写 RT_DEVICE_FLAG_RDWR,框架默认用轮询模式,性能差很多。

第四步,实现中断处理。接收中断里,要把数据放入框架的接收缓冲区:

void USART1_IRQHandler(void)
{
    rt_interrupt_enter();

    if (USART1->SR & USART_SR_RXNE)
    {
        // 读取数据
        char ch = USART1->DR;

        // 通知框架有数据到达
        rt_hw_serial_isr(&serial1, RT_SERIAL_EVENT_RX_IND);
    }

    rt_interrupt_leave();
}

4.4 实战:串口控制台输出与 FinSH 组件集成

驱动写好了,怎么验证?最直接的办法——让串口输出 "Hello, RT-Thread!"。

首先,在 rtconfig.h 里打开 FinSH 组件:

#define RT_USING_FINSH
#define FINSH_USING_MSH
#define FINSH_THREAD_NAME   "tshell"
#define FINSH_USING_HISTORY
#define FINSH_HISTORY_LINES 5

然后,在 board.h 里把控制台设备名改成 "uart1":

#define RT_CONSOLE_DEVICE_NAME "uart1"

编译、下载、上电。打开串口工具,你应该能看到 RT-Thread 的启动信息,然后出现 msh > 提示符。输入 help,能看到所有支持的命令。

验证方法: 在 msh 下输入 list_device,如果能看到 "uart1" 设备,说明注册成功。输入 ps,能看到 "tshell" 线程在运行,说明 FinSH 正常工作。

这里有个小细节,我提一下。FinSH 的输入输出,本质上是调用了 rt_device_read() 和 rt_device_write()。你写的驱动如果正确实现了 ops,FinSH 就能正常工作。不需要额外写任何适配代码。

我曾经在一个项目里,因为 DMA 发送完成中断没处理好,导致 FinSH 输入命令后没反应。查了半天,发现是 DMA 传输完成后没有调用 rt_hw_serial_isr() 通知框架。加上之后就好了。

最后,给你一个完整的驱动文件结构参考:

文件 内容
drv_usart.c ops 函数实现、中断处理、DMA 回调
drv_usart.h 设备结构体定义、函数声明
board.c 调用 rt_hw_usart_init()、配置控制台设备
rtconfig.h 使能 FinSH 和 Serial 框架宏定义

嗯,这一章的内容就到这。串口驱动是 RT-Thread 驱动移植的入门关,也是最重要的一关。搞定了它,后面的 SPI、I2C 驱动移植,思路都是一样的——理解框架、实现 ops、注册设备。你把这个流程走一遍,后面就轻车熟路了。