4、UART驱动开发:TTY子系统介绍、串口设备树配置、串口收发数据实战

好,咱们今天聊串口。UART,嵌入式开发里最基础、也最常用的通信接口。你想想看,从调试打印到跟传感器、GPS模块、蓝牙模块通信,哪样离得开它?我做了这么多年HMI开发,串口这块踩过的坑,说多了都是泪。今天咱们就把串口驱动开发的几个核心点掰开揉碎讲清楚。

4.1 TTY子系统:串口背后的“大管家”

很多初学者会问:我明明操作的是串口硬件,为什么Linux里要搞个TTY子系统?说白了,TTY就是Linux对终端设备的一种抽象。它把键盘、显示器、串口这些乱七八糟的输入输出设备,统一包装成一个“终端”接口。

我个人习惯把TTY子系统理解成一个“中间人”。上层应用(比如你的Qt程序、shell)通过标准的文件操作——open、read、write——来跟TTY打交道。TTY子系统负责处理行规约(line discipline)、信号处理、回显这些脏活累活。下层才是真正的串口驱动,负责操作UART硬件寄存器。

嗯,这里要注意:TTY子系统分三层:

  • TTY核心层:提供统一的设备号管理、文件操作接口。说白了就是给应用层看的“门面”。
  • 线路规程(Line Discipline):这是TTY的灵魂。它负责数据缓冲、编辑(比如退格、删除)、特殊字符处理(Ctrl+C中断)。默认的N_TTY规程处理标准终端行为。
  • 串口驱动层:直接跟硬件寄存器打交道。我们写串口驱动,主要就是实现这一层。

关键点:应用层通过/dev/ttyS0/dev/ttyAMA0这些设备节点访问串口。这些节点背后,就是TTY子系统在调度。

我在项目中遇到过一个问题:某款HMI设备,串口打印偶尔会丢字符。查了半天,发现是线路规程的缓冲区太小,数据量一大就溢出。后来调整了N_TTY的缓冲区大小,问题解决。所以,别小看TTY子系统,它往往是性能瓶颈的隐藏点。

4.2 串口设备树配置:让内核认识你的串口

设备树,说白了就是给内核递的一张“硬件配置清单”。串口驱动要正常工作,设备树节点必须配对。我见过太多人因为设备树里一个属性写错,串口死活不工作。

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

uart0: serial@fe201000 {
    compatible = "arm,pl011", "arm,primecell";
    reg = <0x0 0xfe201000 0x0 0x1000>;
    interrupts = <0 29 4>;
    clocks = <&uart0_clk>;
    clock-names = "uartclk";
    pinctrl-names = "default";
    pinctrl-0 = <&uart0_pins>;
    status = "okay";
};

咱们逐条拆解:

  • compatible:驱动匹配的关键。内核通过这个字符串找到对应的串口驱动。比如"arm,pl011"对应的是ARM PrimeCell UART驱动。
  • reg:UART控制器的基地址和地址范围。我建议你对照芯片手册确认这个地址,写错了内核直接挂。
  • interrupts:中断号、触发类型。串口收发数据靠中断驱动,这个配不对,数据收不到。
  • clocks:串口的工作时钟。波特率计算全靠它。时钟频率不对,波特率就偏了。
  • pinctrl:引脚复用配置。串口的TX、RX引脚必须配置成UART功能,否则信号出不去。

避坑指南:我曾经在某个项目里,设备树里忘了配pinctrl,结果串口TX引脚默认被配置成GPIO,输出电平死活不对。查了两天才发现是引脚复用没配。所以,pinctrl一定要检查。

另外,有些SoC的串口支持硬件流控(RTS/CTS)。如果需要,设备树里要加上uart-has-rtscts;属性。否则流控信号不会使能。

4.3 串口收发数据实战:从驱动到应用

好了,理论讲完,咱们上手实战。我以一个典型的ARM Linux平台为例,演示串口收发数据的完整流程。

4.3.1 驱动层:注册与初始化

串口驱动的入口是uart_register_driveruart_add_one_port。内核里,串口驱动通常实现一个struct uart_ops结构体,里面包含:

  • .startup:打开串口时调用,初始化硬件、申请中断。
  • .shutdown:关闭串口时调用,释放资源。
  • .set_termios:设置波特率、数据位、停止位、校验位。
  • .start_tx:启动发送。
  • .stop_tx:停止发送。

我习惯在startup函数里做两件事:一是使能UART的接收中断,二是清空FIFO。这样能保证串口一打开就能收数据。

static int my_uart_startup(struct uart_port *port)
{
    // 使能接收中断
    writel(UART_RX_INT_EN, port->membase + UART_IMSC);
    // 清空FIFO
    writel(UART_FIFO_CLEAR, port->membase + UART_ICR);
    return 0;
}

4.3.2 中断处理:数据收发的核心

串口中断处理函数是性能的关键。我见过一些新手在中断里做太多事,导致系统响应变慢。记住:中断里只做最必要的事——读数据、写数据、清中断标志。

static irqreturn_t my_uart_irq(int irq, void *dev_id)
{
    struct uart_port *port = dev_id;
    unsigned int status;

    status = readl(port->membase + UART_MIS);
    if (status & UART_RX_INT) {
        // 读数据,放入TTY缓冲区
        while (!(readl(port->membase + UART_FR) & UART_FR_RXFE)) {
            unsigned char ch = readl(port->membase + UART_DR);
            uart_insert_char(port, status, 0, ch, TTY_NORMAL);
        }
        // 清中断
        writel(UART_RX_INT, port->membase + UART_ICR);
    }
    if (status & UART_TX_INT) {
        // 发送数据
        // ...
    }
    return IRQ_HANDLED;
}

个人经验:接收中断里,我建议用uart_insert_char而不是直接操作TTY缓冲区。这个函数会帮你处理线路规程的转换,比如回车换行、特殊字符过滤。省心不少。

4.3.3 应用层:打开、配置、读写

驱动写好了,应用层怎么用?标准做法是:

  1. 打开设备fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY);
  2. 配置串口:用tcsetattr设置波特率、数据位等。我建议用cfsetispeedcfsetospeed分别设置输入输出波特率,虽然大多数情况两者一致。
  3. 读写数据read(fd, buf, len)write(fd, buf, len)。注意:read是阻塞的,如果没有数据会一直等。可以用fcntl设置非阻塞模式。
int fd;
struct termios options;

fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY);
tcgetattr(fd, &options);
cfsetispeed(&options, B115200);
cfsetospeed(&options, B115200);
options.c_cflag |= (CLOCAL | CREAD);
options.c_cflag &= ~PARENB;  // 无校验
options.c_cflag &= ~CSTOPB;  // 1位停止位
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8;      // 8位数据位
tcsetattr(fd, TCSANOW, &options);

// 发送数据
write(fd, "Hello UART", 10);

// 接收数据
char buf[256];
int n = read(fd, buf, sizeof(buf));

注意:应用层配置串口时,c_cflag里的CLOCALCREAD一定要置位。否则串口可能无法正常工作。我曾经因为忘了设CLOCAL,导致串口打开后modem信号检测不过,数据发不出去。

4.4 总结与避坑

串口驱动开发,说难不难,说简单也不简单。我总结几个关键点:

  • 设备树:compatible、reg、interrupts、clocks、pinctrl,一个都不能少。
  • 中断处理:轻量、高效,别在中断里做复杂操作。
  • TTY子系统:理解它的分层结构,遇到问题知道从哪层查起。
  • 应用层配置:termios结构体里的每个字段都有意义,别偷懒。

嗯,最后说一句:串口调试时,示波器是你的好朋友。我曾经靠示波器抓波形,发现某款芯片的UART TX引脚默认电平不对,原来是芯片内部上拉电阻没使能。这种问题,光看代码是看不出来的。

好了,这一章就到这儿。下一章咱们聊SPI驱动开发,那个比串口稍微复杂点,但套路是一样的。