3. Linux 串口驱动框架:TTY子系统、串口核心层、uart_ops结构体
说实话,很多刚接触Linux串口驱动的朋友,一上来就被TTY、线路规程、串口核心层这些概念搞晕了。我当年也一样,对着内核代码看了三天,愣是没理清数据到底是怎么从用户态跑到串口硬件上的。
这一章,我们就来拆解这个框架。你把它想成一个三层快递系统:TTY子系统是前台客服,串口核心层是分拣中心,uart_ops就是最后一公里的快递员。搞清楚这三层,串口驱动对你来说就没秘密了。
3.1 TTY子系统:用户态与内核态的桥梁
TTY,全称Teletypewriter,这名字听着古老,但它的设计思想一直沿用至今。说白了,它就是一个字符设备的抽象层。
用户程序通过标准的文件操作——open、read、write——来跟串口打交道。你想想看,如果每个串口芯片都暴露不同的接口给用户,那应用层工程师得疯掉。TTY子系统就是来解决这个问题的,它提供了一套统一的接口。
这里有个关键概念叫线路规程(Line Discipline)。它夹在TTY驱动和字符设备层之间,负责数据的加工。比如你按下一个回车键,线路规程可能会把它转换成换行符;或者你输入退格,它负责处理回显。默认的线路规程是 N_TTY,处理标准的终端协议。
我在项目中遇到过一个问题:用串口接一个GPS模块,数据总是丢包。查了半天,发现是线路规程默认开启了回显和行编辑功能,把GPS的二进制数据流给污染了。解决办法就是切换到 N_RAW 线路规程,让数据原样通过。
tcsetattr() 关闭所有行处理选项,或者直接设置线路规程为原始模式。我曾经因为没关回显,导致升级包校验一直失败,折腾了两天。
3.2 串口核心层(Serial Core):承上启下的调度中心
串口核心层,在内核里对应 drivers/tty/serial/serial_core.c。它不直接操作硬件,而是提供了一套标准的注册、注销、中断处理机制。
它的主要工作有三件:
- 管理UART端口:维护一个全局的端口列表,每个端口对应一个串口设备。
- 处理中断:接收硬件中断,判断是接收、发送还是错误事件,然后调用对应的回调函数。
- 提供操作接口:定义了一套标准的操作函数集,也就是我们马上要讲的
uart_ops。
你可以把串口核心层想象成一个项目经理。它不亲自写代码,但它知道什么时候该叫谁干活。它负责把TTY子系统传来的请求,翻译成具体的硬件操作指令。
下面这张图,能帮你理清这三层的关系:
3.3 uart_ops 结构体:驱动工程师的战场
好了,终于到了我们真正要写代码的地方。uart_ops 是串口核心层定义的一个结构体,里面全是函数指针。你的硬件驱动,就是要把这些函数指针实现出来。
这个结构体定义在 include/linux/serial_core.h 里。我挑几个最重要的成员给你看:
| 函数指针 | 作用 | 我的经验 |
|---|---|---|
startup |
启动串口端口,申请中断、初始化硬件 | 记得在这里检查中断号是否有效,我吃过一次共享中断没注册好的亏 |
shutdown |
关闭串口端口,释放中断、关闭硬件 | 一定要确保所有DMA传输已经停止,否则会panic |
set_termios |
设置串口参数:波特率、数据位、停止位、校验位 | 这是最容易被调用的函数,每次用户用 tcsetattr 都会进来 |
start_tx |
启动发送传输 | 通常在这里把数据写入发送FIFO或启动DMA |
stop_tx |
停止发送传输 | 流控时会被调用,别傻傻地把FIFO清空了 |
tx_empty |
检查发送FIFO是否为空 | 关闭端口前必须调用,确保数据都送出去了 |
get_mctrl |
获取Modem控制信号状态(CTS/DSR等) | 硬件流控全靠它,读寄存器时要加锁 |
set_mctrl |
设置Modem控制信号(RTS/DTR等) | 同样要注意并发访问 |
来看一个简化的 start_tx 实现示例,感受一下:
static void my_uart_start_tx(struct uart_port *port)
{
struct my_uart_data *priv = port->private_data;
unsigned long flags;
spin_lock_irqsave(&port->lock, flags);
// 检查发送FIFO是否已满
if (readl(priv->membase + UART_FSR) & FSR_TX_FULL) {
// FIFO满了,使能发送中断,等硬件通知我们
writel(readl(priv->membase + UART_IMR) | IMR_TX_EMPTY,
priv->membase + UART_IMR);
} else {
// FIFO有空位,直接写入
uart_xmit_advance(port, 1);
writel(port->x_char ? port->x_char :
port->state->xmit.buf[port->state->xmit.tail],
priv->membase + UART_TX);
port->icount.tx++;
}
spin_unlock_irqrestore(&port->lock, flags);
}
uart_ops 中的函数,除了 startup 和 shutdown,都是在持有 port->lock 自旋锁的情况下被调用的。所以你在这些函数里绝对不能睡眠,也不能调用 kmalloc 等可能引起阻塞的函数。我见过有人在这里调了 msleep,结果整个系统都卡死了。
3.4 中断处理:数据流动的引擎
串口驱动里,中断处理是性能的关键。串口核心层提供了一个标准的处理函数 uart_handle_irq,但大多数时候,我们还是要自己写中断处理函数。
一个典型的中断处理流程是这样的:
- 硬件触发中断,CPU进入中断服务程序(ISR)。
- ISR读取中断状态寄存器,判断中断类型。
- 如果是接收中断:从FIFO读取数据,调用
uart_insert_char放入TTY缓冲区。 - 如果是发送中断:从发送缓冲区取数据,写入FIFO。
- 如果是错误中断:记录错误统计,可能需要复位硬件。
- 调用
tty_flip_buffer_push通知TTY层有数据可读。
这里有个细节:uart_insert_char 和 tty_flip_buffer_push 的配合。前者把数据放进一个叫 flip buffer 的临时缓冲区,后者把这个缓冲区的内容提交给线路规程。为什么要分两步?因为中断上下文不能直接操作TTY缓冲区,需要推迟到软中断或工作队列中处理。
3.5 注册与注销:让内核认识你的驱动
驱动写好了,怎么让内核用起来?通过 uart_register_driver 和 uart_add_one_port 这两个函数。
流程很简单:
- 先定义一个
uart_driver结构体,填上驱动名称、设备号、端口数量等信息。 - 调用
uart_register_driver注册到串口核心层。 - 对于每个物理串口,分配一个
uart_port结构体,设置好uart_ops、iotype、mapbase等字段。 - 调用
uart_add_one_port添加端口。
注销时反过来:先 uart_remove_one_port,再 uart_unregister_driver。
嗯,这里要注意:uart_port 结构体里的 private_data 指针是你的私有数据,用来存放硬件相关的上下文。别把它浪费了,我一般用它指向一个包含锁、内存基地址、DMA描述符的结构体。