4. UART裸机驱动:从控制器配置到中断收发

UART,说白了就是串口。在Zynq上做裸机开发,串口是你和硬件对话的第一扇门。我记得刚接触Zynq时,第一个外设驱动的就是UART——因为没它,你连printf都看不到,调试全靠猜。

这一节,我们不讲Linux下的ttyPS0怎么配,就讲裸机下怎么让UART老老实实收发数据。你会看到三种模式:轮询、中断、以及我踩过的坑。

4.1 UART控制器硬件结构

Zynq的UART控制器是Cadence IP核,挂在APB总线上。它的寄存器布局很规整,核心就这几个:

寄存器 偏移 作用
CR 0x00 控制寄存器(使能、复位、流控)
MR 0x04 模式寄存器(数据位、停止位、校验)
IER 0x08 中断使能寄存器
ISR 0x0C 中断状态寄存器
BRGR 0x10 波特率发生器(分频系数)
FIFO 0x30 收发FIFO数据寄存器

你可能会问:为什么FIFO地址是0x30而不是0x14?嗯,中间那几个寄存器是留作扩展的,不用管它。

核心要点:UART的收发都通过FIFO操作。读FIFO就是收数据,写FIFO就是发数据。硬件自动处理移位和帧格式。

UART裸机驱动知识结构 UART控制器 寄存器配置 轮询模式 中断模式 波特率 数据格式 while循环查状态位 ISR + 中断向量表 串口收发数据(读/写FIFO) printf / 数据收发 / 调试输出 三种模式:轮询适合简单调试,中断适合生产环境,DMA适合大数据量

4.2 寄存器配置:三步走

配置UART其实就三步。我在项目里从来都是按这个顺序来,一次都没出过问题:

  1. 复位并禁用:写CR寄存器的RST位,然后清掉TXEN/RXEN
  2. 设置波特率和格式:算分频系数,写BRGR;写MR选8N1
  3. 使能收发:CR里置位TXEN和RXEN

波特率怎么算?公式很简单:

BRGR = (时钟频率) / (波特率 × 16) - 1

举个例子,Zynq的UART时钟通常是50MHz,要配115200:

BRGR = 50000000 / (115200 × 16) - 1
     = 50000000 / 1843200 - 1
     ≈ 27.13 - 1
     = 26

取整后实际波特率是50000000/(27×16) ≈ 115740,误差0.47%,完全OK。

我的习惯:配完波特率后,先发一个0x55(二进制01010101),用示波器看波形。如果每个位宽度一致,说明分频系数算对了。这招帮我省了至少三次查寄存器的时间。

4.3 轮询模式:简单粗暴

轮询模式,说白了就是CPU死等。发数据时等FIFO空,收数据时等FIFO非空。

代码长这样:

// 发送一个字节
void uart_putc(char c) {
    // 等TX FIFO空
    while (!(readl(UART_BASE + ISR) & (1 << 2)));
    // 写FIFO
    writel(c, UART_BASE + FIFO);
}

// 接收一个字节
char uart_getc(void) {
    // 等RX FIFO有数据
    while (!(readl(UART_BASE + ISR) & (1 << 0)));
    // 读FIFO
    return readl(UART_BASE + FIFO) & 0xFF;
}

你看,就这么几行。但有个问题——如果对方不发数据,程序就卡死在while里了。我在调试一个传感器模块时就遇到过,对方上电慢,结果系统直接死等,连看门狗都来不及喂。

避坑指南:轮询收数据一定要加超时!我曾经在一个工业项目里没加超时,结果对方掉线后整条产线卡死。加个计数器,超时了就返回-1,至少系统还能继续跑。

4.4 中断模式:真正的生产力

轮询模式只适合调试。真正产品里,你得用中断。为什么?因为CPU不能老等着串口,它还有更重要的事要做。

中断模式的配置比轮询多两步:

  1. 使能UART的中断(IER寄存器)
  2. 在GIC里注册中断服务函数

中断服务函数(ISR)怎么写?我一般这样:

void uart_isr(void) {
    uint32_t status = readl(UART_BASE + ISR);

    // 收到数据
    if (status & (1 << 0)) {
        char c = readl(UART_BASE + FIFO) & 0xFF;
        // 把c放进环形缓冲区
        ringbuf_push(&rx_buf, c);
    }

    // 发送完成(如果需要)
    if (status & (1 << 2)) {
        // 可以发下一包了
    }
}

这里有个关键点:ISR里不要做复杂操作,把数据扔进缓冲区就赶紧返回。我见过有人直接在ISR里做协议解析,结果中断嵌套把自己搞死了。

经验之谈:中断里用环形缓冲区是标准做法。缓冲区大小根据波特率算——115200bps下,每字节约87μs,如果主频666MHz,缓冲区256字节够你处理2ms的数据。一般够用。

4.5 收发数据:两种模式的对比

对比项 轮询模式 中断模式
CPU占用 100%(死等) 仅收发瞬间
实时性 差(被其他任务阻塞) 好(硬件触发)
代码量 ~10行 ~50行(含缓冲区管理)
适用场景 调试、Bootloader 产品、多任务系统
风险 死锁(无超时) 中断嵌套、缓冲区溢出

你想想看,如果你的系统还要跑网络协议栈、控制电机、处理传感器数据,你敢用轮询吗?反正我不敢。

4.6 实战:把printf挂到UART上

裸机开发没有标准库的printf,但我们可以自己实现一个简易版。原理很简单:把printf的底层输出函数替换成我们的uart_putc。

// 简易printf实现
void my_printf(const char *fmt, ...) {
    char buf[256];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);

    // 逐字节通过UART发送
    char *p = buf;
    while (*p) {
        uart_putc(*p++);
    }
}

这样你就可以在裸机上用my_printf打印调试信息了。我在做Zynq的DMA驱动时,全靠这个打印寄存器值来定位问题。

小技巧:vsnprintf在ARM编译器里是有的,但如果你用GCC裸机,可能需要自己实现。我一般直接移植Newlib的printf,省事。

4.7 常见问题与排查

做UART驱动这么多年,我总结了几条排查思路:

  • 没输出?先查波特率对不对,再用示波器看TX脚有没有波形
  • 输出乱码?大概率是波特率或数据格式不匹配,检查BRGR和MR
  • 中断不触发?查IER有没有使能,GIC里有没有注册,优先级有没有配对
  • 丢数据?缓冲区太小或者ISR处理太慢,加大缓冲区或优化ISR

我记得有一次,客户说串口偶尔丢第一个字节。查了两天,最后发现是FIFO使能后第一个字节写入时硬件还没准备好。解决方案很简单:配置完UART后,先写一个哑字节,然后清掉FIFO。嗯,这种坑,没遇到过的人根本想不到。