4. 串口驱动接入(UART)

UART,说白了就是嵌入式世界里最基础的“对话线”。Bootloader 阶段,屏幕没亮,网络没通,你靠什么跟芯片交流?绝大多数时候,靠的就是这根串口线。我最早做 Bootloader 时,第一件事就是把 UART 调通,因为后面所有的调试信息、升级指令、状态打印,全得从这条通道走。

这一章,我们就来聊聊 UART 协议的基础,以及在 Bootloader 这种裸机环境下,怎么用轮询模式把串口驱动跑起来。

4.1 UART 协议基础

UART 全称是 Universal Asynchronous Receiver/Transmitter,通用异步收发器。注意“异步”这两个字——它没有单独的时钟线,收发双方靠约定的波特率来同步。

一个标准的 UART 数据帧长这样:

起始位 数据位(5~8位) 校验位(可选) 停止位(1/1.5/2位)
1位低电平 LSB 先发 奇/偶/无 高电平

我个人习惯用 8N1 配置——8 位数据、无校验、1 位停止位。这是最通用的组合,兼容性最好。你在 Bootloader 里用这个配置,基本不会踩坑。

提示: 为什么起始位一定是低电平?因为空闲时总线是高电平,低电平跳变就是一个明显的“我要开始发数据了”的信号。这个设计很巧妙,你想想看。

4.2 Bootloader 中 UART 的轮询模式驱动实现

Bootloader 阶段,中断系统可能还没完全初始化,或者你根本不想引入中断的复杂性。这时候,轮询模式就是最靠谱的选择。

轮询模式的核心思路很简单:CPU 不断去检查 UART 的状态寄存器,看看有没有数据可读,或者能不能发数据。说白了,就是“主动问,不等待通知”。

4.2.1 初始化流程

初始化 UART,通常需要做这几步:

  1. 使能时钟——给 UART 外设和对应的 GPIO 口提供时钟。
  2. 配置 GPIO 复用——把 TX、RX 引脚切换到 UART 功能。
  3. 设置波特率——通过分频寄存器算出匹配的数值。
  4. 配置数据格式——数据位、停止位、校验位。
  5. 使能 UART——打开收发器。

我在项目中遇到过一个问题:初始化顺序搞反了。先使能 UART,再配 GPIO,结果 TX 引脚在使能瞬间输出了一段乱码。从那以后,我都是先配好 GPIO 和时钟,最后才打开 UART 使能位。

4.2.2 发送一个字节

发送的逻辑很简单:等发送保持寄存器空,然后把数据写进去。

void uart_putc(char c)
{
    // 等待发送缓冲区空
    while (!(UART->SR & UART_SR_TXE));
    // 写入数据
    UART->DR = c;
}

这里有个细节:有些芯片的 TXE 标志位表示“数据已转移到移位寄存器”,但可能还没发完。如果你紧接着要关串口或进低功耗,最好再等 TC(发送完成)标志位。嗯,这里要注意。

4.2.3 接收一个字节

接收也是类似的轮询:

char uart_getc(void)
{
    // 等待接收数据就绪
    while (!(UART->SR & UART_SR_RXNE));
    // 读取数据
    return (char)(UART->DR);
}

你可能会问:如果一直没数据,程序不就卡死了?没错,所以实际 Bootloader 里,我通常会加一个超时机制。比如用 SysTick 或者一个简单的循环计数,超过一定时间就返回错误。

警告: 读取 UART->DR 寄存器会自动清除 RXNE 标志位。如果你先读了别的寄存器,再读 DR,可能会丢失数据。这是我在调试一个 STM32 项目时踩过的坑。

4.3 波特率配置与数据收发

波特率是 UART 通信的“节奏”。收发双方必须用同一个节奏,否则数据就是一团乱码。

4.3.1 波特率计算

大多数 MCU 的 UART 波特率生成器,都是基于系统时钟分频的。公式大致是:

波特率 = 时钟频率 / (16 * USARTDIV)

USARTDIV 是一个分频系数,通常写在波特率寄存器里。比如系统时钟 72MHz,想要 115200 波特率:

USARTDIV = 72000000 / (16 * 115200) = 39.0625

取整后写入寄存器,实际波特率会有微小偏差。偏差在 ±2% 以内通常没问题。我见过一个项目,晶振精度不够,偏差到了 3.5%,结果 115200 下频繁丢字节,降到 9600 就正常了。

目标波特率 时钟 72MHz 时钟 48MHz 时钟 16MHz
115200 39.0625 26.0417 8.6806
57600 78.1250 52.0833 17.3611
9600 468.7500 312.5000 104.1667

4.3.2 数据收发流程

在 Bootloader 里,数据收发通常不是单个字节,而是一包一包地处理。我习惯的做法是:

  • 发送:把要打印的字符串或数据包,逐个字节调用 uart_putc。
  • 接收:用超时轮询收一个完整的数据帧,比如以 0x0A 换行符结尾,或者固定长度。

举个例子,一个简单的串口打印函数:

void uart_puts(const char *str)
{
    while (*str)
    {
        uart_putc(*str++);
    }
}

接收一个带超时的字节:

int uart_getc_timeout(char *c, uint32_t timeout)
{
    while (timeout--)
    {
        if (UART->SR & UART_SR_RXNE)
        {
            *c = (char)(UART->DR);
            return 0;  // 成功
        }
        // 简单的延时,具体延时量取决于你的系统时钟
        for (volatile uint32_t i = 0; i < 100; i++);
    }
    return -1;  // 超时
}
核心要点: Bootloader 中的 UART 驱动,不求花哨,但求稳定。轮询模式虽然“笨”,但它在任何情况下都能工作,不会因为中断没配好而出问题。

4.4 本章知识体系

下面这张图,把 UART 在 Bootloader 阶段的接入逻辑串起来了:

UART 驱动在 Bootloader 中的接入流程 UART 协议基础 初始化:时钟→GPIO→波特率→使能 发送:轮询 TXE → 写 DR 接收:轮询 RXNE → 读 DR 数据包处理:字符串/帧/超时 Bootloader 应用:打印/命令/升级 轮询模式:无中断依赖,代码简单,适合 Bootloader 早期阶段

这张图把整个流程串起来了。从协议基础开始,到初始化配置,再到收发实现,最后落到 Bootloader 的具体应用里。你写代码的时候,按这个顺序走,基本不会乱。

我记得有一次,一个同事在 Bootloader 里加了中断接收,结果中断向量表还没重定位,一收数据就跑飞。我帮他改成轮询模式,问题立刻解决。所以说,Bootloader 阶段,简单就是美。