4. UART驱动实战:串口硬件原理、tty驱动框架、串口设备树配置

串口,UART,搞嵌入式的人手必备。说它简单吧,确实就两根线来回倒腾;说它复杂吧,tty 那一套框架能把人绕晕。今天咱们就把这三块掰开揉碎——硬件原理、驱动框架、设备树配置,一个一个来。

4.1 串口硬件原理:就两根线的事?

UART 全称是 Universal Asynchronous Receiver/Transmitter。异步,意味着没有时钟线。收发双方得事先约定好波特率,不然数据就乱套了。

硬件上就三根线:TX(发送)、RX(接收)、GND(地)。全双工,能同时发和收。我见过不少新手把 TX 和 RX 接反,然后在那调半天波特率——嗯,我也干过这事。

关键参数:

  • 波特率:每秒传输的符号数,常见 9600、115200、921600
  • 数据位:5~8 位,通常 8 位
  • 校验位:无校验、奇校验、偶校验
  • 停止位:1 位或 2 位

数据帧长这样:起始位(0)→ 数据位(LSB 先发)→ 校验位(可选)→ 停止位(1)。

举个例子,发送字符 'A'(0x41),波特率 115200,8N1:

起始位 | 1 0 0 0 0 0 1 0 | 停止位
  LSB ────────────────── MSB

硬件上,UART 外设内部有移位寄存器和 FIFO。FIFO 深度一般是 16 字节或 64 字节。数据来了先存 FIFO,CPU 有空了再读。这样能减少中断频率,提升效率。

我的习惯:调试阶段把 FIFO 关掉,用单字节中断模式。这样每个字节都能抓到,排查问题方便。量产再开 FIFO 提升吞吐。

4.2 tty 驱动框架:Linux 串口驱动的灵魂

Linux 里,串口驱动属于 tty 子系统。tty 是 Teletype 的缩写,历史遗留名字。说白了,tty 就是一套字符设备框架,专门处理终端类设备。

整个框架分三层:

层级 职责 关键结构体
tty 核心层 提供统一字符设备接口,管理线路规程 struct tty_driver, struct tty_port
线路规程层 处理数据格式、流控、回显等 struct tty_ldisc_ops
硬件驱动层 直接操作 UART 寄存器,收发数据 struct uart_ops, struct uart_port

用户空间看到的是 /dev/ttyS0、/dev/ttyAMA0 这类设备文件。应用层调用 open/read/write,最终会落到硬件驱动层的回调函数里。

我个人觉得,理解 tty 框架的关键是搞清楚 数据流:

  1. 应用层 write → tty 核心层 → 线路规程层(N_TTY) → 硬件驱动层 → UART 寄存器 → TX 引脚
  2. RX 引脚收到数据 → 硬件中断 → 驱动层读寄存器 → 推入 tty 缓冲区 → 线路规程层处理 → 应用层 read

我曾经踩过的坑:线路规程层默认是 N_TTY,它会把收到的数据做回显处理。如果你写一个自定义协议,不想让数据回显,得用 ioctl 把线路规程改成 N_RAW 或者自己注册一个。不然调试时发现发什么回什么,还以为硬件有问题。

硬件驱动层最核心的结构体是 struct uart_ops,里面全是函数指针:

struct uart_ops {
    unsigned int (*tx_empty)(struct uart_port *);
    void (*set_mctrl)(struct uart_port *, unsigned int);
    unsigned int (*get_mctrl)(struct uart_port *);
    void (*stop_tx)(struct uart_port *);
    void (*start_tx)(struct uart_port *);
    void (*stop_rx)(struct uart_port *);
    void (*enable_ms)(struct uart_port *);
    void (*break_ctl)(struct uart_port *, int);
    int (*startup)(struct uart_port *);
    void (*shutdown)(struct uart_port *);
    void (*set_termios)(struct uart_port *, struct ktermios *, struct ktermios *);
    void (*pm)(struct uart_port *, unsigned int, unsigned int);
    const char *(*type)(struct uart_port *);
    void (*release_port)(struct uart_port *);
    int (*request_port)(struct uart_port *);
    void (*config_port)(struct uart_port *, int);
    int (*verify_port)(struct uart_port *, struct serial_struct *);
    void (*poll_put_char)(struct uart_port *, unsigned char);
    int (*poll_get_char)(struct uart_port *);
};

写驱动时,你主要实现 startup(初始化硬件)、shutdown(关闭硬件)、start_tx(启动发送)、set_termios(设置波特率等参数)。

我的建议:先实现 poll_put_char 和 poll_get_char,这两个函数用于早期调试。内核还没完全启动时,串口控制台就靠它们输出信息。你想想看,如果这两个函数没写好,连内核启动 log 都看不到,后面怎么调?

4.3 串口设备树配置:让内核认识你的硬件

设备树是描述硬件信息的。串口设备树节点告诉内核:UART 外设在哪个地址、用哪个中断、时钟频率多少、引脚怎么配置。

一个典型的串口设备树节点:

&uart1 {
    status = "okay";
    pinctrl-names = "default";
    pinctrl-0 = &uart1_pins;
    clocks = <&clk_uart1>;
    clock-names = "uartclk";
    current-speed = <115200>;
    dmas = <&dma 0 0x40000000>, <&dma 1 0x40000000>;
    dma-names = "tx", "rx";
};

关键属性说明:

属性 含义 说明
status 设备状态 "okay" 启用,"disabled" 禁用
pinctrl-0 引脚复用配置 指向 pinctrl 子系统的节点
clocks 时钟源 UART 模块的工作时钟
current-speed 默认波特率 驱动初始化时设置
dmas DMA 通道 可选,用于 DMA 模式收发

引脚配置通常在 pinctrl 节点里定义:

uart1_pins: uart1-pins {
    mux {
        function = "uart1";
        groups = "uart1_tx", "uart1_rx";
    };
    conf {
        pins = "uart1_tx", "uart1_rx";
        drive-strength = <2>;
        bias-pull-up;
    };
};

驱动里通过 of_property_read_u32 等 API 读取设备树属性。比如读 current-speed:

struct device_node *np = pdev->dev.of_node;
u32 baud;
if (of_property_read_u32(np, "current-speed", &baud))
    baud = 115200;  // 默认值

我曾经踩过的坑:设备树里配了 DMA,但 DMA 驱动没加载。结果串口驱动 probe 时申请 DMA 通道失败,直接返回 -EPROBE_DEFER。内核会反复重试 probe,但 DMA 驱动一直没加载,系统就卡在这了。解决办法:要么确保 DMA 驱动先加载,要么在设备树里先去掉 dmas 属性,等 DMA 调通了再加。

还有一个容易忽略的点:时钟频率。UART 的波特率发生器需要知道输入时钟频率,才能算出分频系数。如果设备树里 clocks 配错了,波特率就会不准。我遇到过客户反馈串口丢数据,最后发现是时钟频率差了 3%,累积到长数据帧就出错了。

4.4 整体框架图

下面这张图把串口驱动从硬件到应用层的完整路径画出来了,你一看就明白:

UART 驱动整体框架 应用层:open / read / write tty 核心层:struct tty_driver 线路规程层:N_TTY / N_RAW 硬件驱动层:struct uart_ops / struct uart_port 硬件层:UART 外设寄存器 设备树配置 引脚复用 (pinctrl) 时钟 / DMA 配置 用户空间 /dev/ttyS0 /dev/ttyAMA0 /dev/ttyUSB0

从这张图能清楚看到:应用层通过设备文件访问,经过 tty 核心层和线路规程层,最终落到硬件驱动层操作寄存器。设备树和引脚配置在底层提供硬件描述信息。

4.5 实战要点总结

  • 硬件层面:确认 TX/RX 没接反,波特率一致,GND 共地。这是最基础的,也是最容易出问题的。
  • 驱动层面:先实现 poll 模式保证能输出,再实现中断模式,最后考虑 DMA。一步一步来,别想一口吃成胖子。
  • 设备树层面:时钟频率必须准确,引脚复用配置要跟原理图一致。我习惯在设备树里加 current-speed 属性,这样驱动初始化时就能设好波特率,不用等应用层来配置。

调试小技巧:用逻辑分析仪抓 TX/RX 引脚波形,比用示波器方便。设置好波特率,看起始位和停止位对不对,数据位是不是反了(LSB/MSB 顺序)。一眼就能看出问题。

串口驱动看起来简单,但 tty 框架的复杂度不低。把硬件原理搞明白,把框架层次理清楚,把设备树配准确,剩下的就是水到渠成的事。