3. UART 串口通信原理与轮询驱动

好,咱们今天来聊聊串口。说实话,在嵌入式开发里,UART 是我用得最多的外设,没有之一。调试信息、日志输出、与传感器通信,甚至有时候做简单的 bootloader,都离不开它。D1s 这颗芯片的 UART0,默认就接在了调试串口上,所以咱们先从它入手。

3.1 UART 协议基础

UART,全称是 Universal Asynchronous Receiver/Transmitter。说白了,就是通用异步收发器。它不需要时钟线,只需要两根数据线:TX(发送)和 RX(接收)。

它的通信格式是这样的:

  • 起始位:拉低电平,表示开始传输
  • 数据位:5~8 位,通常用 8 位
  • 校验位:可选,奇校验或偶校验
  • 停止位:拉高电平,表示结束

我刚开始学的时候,总觉得这玩意儿挺玄乎。后来自己用逻辑分析仪抓了一次波形,一下子就明白了。你想想看,没有时钟线,双方怎么同步?靠的就是这个起始位。接收端检测到电平从高变低,就知道要开始收数据了。

波特率:这是 UART 通信的灵魂。双方必须约定好相同的波特率,比如 115200。波特率决定了每个 bit 的时长。D1s 的 UART0 最高能跑到 4Mbps,不过咱们调试用 115200 就够了。

3.2 D1s UART0 寄存器配置

D1s 的 UART0 挂在 APB2 总线上。要让它工作,得先打开时钟,再配置引脚复用,最后设置波特率和数据格式。

我个人习惯,先把寄存器地址定义好。这样写代码时一目了然:

// UART0 基地址
#define UART0_BASE      0x02500000

// 寄存器偏移
#define UART0_RBR       (UART0_BASE + 0x00)  // 接收缓冲寄存器
#define UART0_THR       (UART0_BASE + 0x00)  // 发送保持寄存器
#define UART0_DLL       (UART0_BASE + 0x00)  // 除数锁存器低字节
#define UART0_DLH       (UART0_BASE + 0x04)  // 除数锁存器高字节
#define UART0_IER       (UART0_BASE + 0x04)  // 中断使能寄存器
#define UART0_LCR       (UART0_BASE + 0x0C)  // 线路控制寄存器
#define UART0_LSR       (UART0_BASE + 0x14)  // 线路状态寄存器
#define UART0_USR       (UART0_BASE + 0x7C)  // UART 状态寄存器

嗯,这里要注意。RBR 和 THR 是同一个地址,读就是收,写就是发。DLL 和 DLH 也是同一个地址,但需要先设置 LCR 的 DLAB 位才能访问。

配置步骤其实就三步:

  1. 使能时钟:操作 CCU 模块,打开 UART0 的时钟门控
  2. 配置引脚:将 PB8/PB9 复用为 UART0 的 TX/RX
  3. 设置参数:波特率、数据位、停止位、校验位

我曾经在某个项目里,因为忘了开时钟,折腾了整整一个下午。后来学乖了,每次初始化外设,第一件事就是检查时钟有没有打开。

波特率的计算公式很简单:

// 时钟频率 24MHz,目标波特率 115200
// 除数 = 时钟频率 / (16 * 波特率)
// 除数 = 24000000 / (16 * 115200) = 13.02

// 取整后,实际波特率 = 24000000 / (16 * 13) = 115384
// 误差约 0.16%,完全可用

D1s 的 UART 时钟源是 24MHz,这个值在数据手册里有。我建议你直接用 13 作为除数,误差很小。

小技巧:如果你需要精确的波特率,可以用 1.8432MHz 的时钟源。但 D1s 默认是 24MHz,115200 的误差已经足够小,不用纠结。

3.3 轮询方式发送与接收字符

轮询,说白了就是死等。发送时,等发送缓冲区空;接收时,等接收缓冲区有数据。虽然效率不高,但简单可靠,调试阶段最实用。

先看发送:

void uart_putchar(char c) {
    // 等待发送缓冲区空
    while (!(readl(UART0_LSR) & (1 << 5)));
    // 写入数据
    writeb(c, UART0_THR);
}

接收也类似:

char uart_getchar(void) {
    // 等待接收数据就绪
    while (!(readl(UART0_LSR) & (1 << 0)));
    // 读取数据
    return readb(UART0_RBR);
}

这里有个坑,我必须要说一下。LSR 寄存器的 bit5 是发送保持寄存器空标志,bit0 是接收数据就绪标志。但 D1s 的数据手册里,还提供了一个 USR 寄存器,它的 bit0 是 UART 忙标志。我建议你只用 LSR,因为 USR 在某些版本里行为不一致。

注意:轮询接收时,如果没有数据,程序会卡死在 while 循环里。所以实际项目中,要么加超时,要么用中断。咱们现在只是演示原理,先这么写。

3.4 实现 printf 函数

有了 putchar,实现 printf 就水到渠成了。嵌入式里最常用的做法,就是重写 _write 函数,把输出重定向到串口。

我习惯用这样的方式:

// 重定向 _write 到串口
int _write(int file, char *ptr, int len) {
    int i;
    for (i = 0; i < len; i++) {
        uart_putchar(ptr[i]);
    }
    return len;
}

这样,你只要在代码里包含 <stdio.h>,然后直接调用 printf 就行了。不过要注意,D1s 的裸机环境没有标准库支持,你得自己实现 _write_read

如果你不想依赖标准库,也可以自己写一个简易的 printf:

void my_printf(const char *fmt, ...) {
    va_list args;
    char buf[256];
    
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);
    
    char *p = buf;
    while (*p) {
        uart_putchar(*p++);
    }
}

我个人更推荐第二种方式。为什么呢?因为标准库的 printf 太庞大了,动不动就几十 KB。在裸机环境里,能省则省。而且自己写的,出了问题也好排查。

实战建议:调试阶段,先用轮询方式把 printf 跑通。等后面学了中断,再改成中断方式。这样一步步来,不容易出问题。

好了,UART 的轮询驱动就讲到这里。下一节咱们会深入中断,到时候串口就能真正解放 CPU 了。你先把今天的代码跑一遍,看看串口能不能打印出 "Hello, D1s!"。