2. Linux 串口驱动框架:TTY 子系统、uart_ops 与设备注册
好,我们正式开始啃 Linux 串口驱动这块硬骨头。很多刚接触嵌入式的朋友,一看到 tty、uart_port、uart_ops 这些结构体就头大。别急,我带你一层层剥开它。
说白了,Linux 串口驱动就是一套“用户空间 -> TTY 核心 -> 串口核心 -> 具体硬件”的层层封装。你写应用层代码时调用的 open、read、write,最终会通过这套框架,落到你写的底层硬件操作函数上。
2.1 TTY 子系统:串口驱动的“交通枢纽”
TTY 子系统是整个串口通信的调度中心。它负责把用户空间的请求,分发给对应的底层驱动。我刚开始做驱动时,总以为直接操作寄存器就完事了。后来才发现,不搞懂 TTY 层,你连数据流怎么走的都理不清。
它的核心架构可以概括为三层:
| 层次 | 角色 | 关键文件/结构体 |
|---|---|---|
| TTY 核心 | 对上提供统一文件操作接口,对下调度线路规程 | tty_driver, tty_operations |
| 线路规程 (Line Discipline) | 数据加工厂,负责协议封装、流量控制、回显等 | n_tty (默认), tty_ldisc_ops |
| 串口核心 | 抽象 UART 硬件共性,管理端口、时钟、DMA | uart_driver, uart_port, uart_ops |
你想想看,用户发一个字节,流程是这样的:
- 用户调用
write(fd, buf, 1)。 - 进入 TTY 核心的
tty_write。 - 线路规程
n_tty_write处理特殊字符、缓冲区。 - 最终调用串口核心的
uart_write,触发你的硬件发送函数。
嗯,这里要注意:线路规程是容易被忽略的一环。我曾在项目里遇到一个诡异问题——发送特定数据时总是丢字节。查了两天,最后发现是线路规程的缓冲区溢出导致的。从那以后,我每次调试串口都会先检查 n_tty 的缓冲区大小。
核心要点: TTY 子系统不是直接操作硬件的。它通过线路规程做了一层“数据加工”。你写驱动时,大部分时间是在跟串口核心层打交道。
2.2 uart_ops 结构体:你的硬件操作“函数表”
这是你写驱动时最常接触的结构体。它定义了一组回调函数,串口核心在需要操作硬件时,就会调用你实现的这些函数。
我个人习惯把 uart_ops 看作“硬件抽象层接口”。你只要填好这张表,核心层就能自动管理你的串口了。
struct uart_ops {
unsigned int (*tx_empty)(struct uart_port *port);
void (*set_mctrl)(struct uart_port *port, unsigned int mctrl);
unsigned int (*get_mctrl)(struct uart_port *port);
void (*stop_tx)(struct uart_port *port);
void (*start_tx)(struct uart_port *port);
void (*stop_rx)(struct uart_port *port);
void (*enable_ms)(struct uart_port *port);
void (*break_ctl)(struct uart_port *port, int ctl);
int (*startup)(struct uart_port *port);
void (*shutdown)(struct uart_port *port);
void (*flush_buffer)(struct uart_port *port);
int (*ioctl)(struct uart_port *port, unsigned int cmd, unsigned long arg);
void (*throttle)(struct uart_port *port);
void (*unthrottle)(struct uart_port *port);
int (*request_port)(struct uart_port *port);
void (*release_port)(struct uart_port *port);
int (*config_port)(struct uart_port *port, int type);
int (*verify_port)(struct uart_port *port, struct serial_struct *ser);
void (*pm)(struct uart_port *port, unsigned int state, unsigned int oldstate);
};
这里我挑几个关键函数说说:
startup/shutdown:串口打开/关闭时调用。我在startup里通常会做三件事:使能 UART 时钟、申请中断、初始化 DMA 描述符。千万别忘了检查时钟是否已经使能,否则中断来了也白搭。start_tx:核心层要你开始发送数据。你在这里要做的就是把uart_port的circ_buf里的数据搬进硬件 FIFO 或 DMA 缓冲区。我曾经犯过一个错——在start_tx里直接死循环发送,结果把整个系统卡死了。记住,发送要基于中断或 DMA 完成回调来驱动,不能阻塞。tx_empty:查询发送 FIFO 是否为空。这个函数在关闭串口时会被调用,用来确保数据都发完了再关。我建议你在这里读取硬件状态寄存器,而不是依赖软件计数。set_mctrl/get_mctrl:控制 RTS/CTS 等硬件流控引脚。如果你的板子用了流控,这两个函数必须实现。我见过有人偷懒不实现,结果高速通信时数据错乱。
我的经验: 刚开始写 uart_ops 时,别急着把所有函数都填满。先实现 startup、shutdown、start_tx、tx_empty 这四个,让串口能收发数据。其他函数可以先用空实现或返回 0。等基本功能跑通了,再逐步完善。
2.3 串口设备注册流程:从模块加载到设备就绪
这部分是实操的关键。你写好的驱动,怎么让内核知道并管理起来?流程大致分三步:
- 分配并初始化
uart_driver:这是你的串口驱动“身份证”。 - 注册
uart_driver:告诉内核“我来了”。 - 添加
uart_port:告诉内核“我的硬件在这里”。
我们来看一个典型的注册代码片段:
static struct uart_driver my_uart_drv = {
.owner = THIS_MODULE,
.driver_name = "my_uart",
.dev_name = "ttyMYU",
.major = 0, // 动态分配主设备号
.minor = 0, // 起始次设备号
.nr = 4, // 支持4个串口
};
static int __init my_uart_init(void)
{
int ret;
// 1. 注册 uart_driver
ret = uart_register_driver(&my_uart_drv);
if (ret) {
pr_err("Failed to register uart driver\n");
return ret;
}
// 2. 为每个端口添加 uart_port
for (int i = 0; i < 4; i++) {
my_uart_ports[i].line = i;
ret = uart_add_one_port(&my_uart_drv, &my_uart_ports[i]);
if (ret) {
pr_err("Failed to add port %d\n", i);
uart_unregister_driver(&my_uart_drv);
return ret;
}
}
return 0;
}
module_init(my_uart_init);
这里有几个坑,我帮你指出来:
dev_name的命名:它会决定设备节点名。比如dev_name = "ttyMYU",那么第一个端口就是/dev/ttyMYU0。我建议你遵循tty+ 厂商缩写 + 序号 的命名方式,方便用户识别。nr字段:它告诉内核你最多支持几个串口。这个值不能随便填,要跟你实际硬件端口数一致。我曾经填了个 8,但板子上只有 2 个串口,结果内核为 8 个端口都分配了资源,浪费内存不说,还导致其他驱动申请资源失败。uart_add_one_port的调用时机:必须在uart_register_driver之后调用。而且,如果你的硬件支持热插拔(比如 USB 转串口),这个函数应该在设备插入时调用,移除时调用uart_remove_one_port。
注意: 在 uart_add_one_port 内部,核心层会调用你的 uart_ops->config_port 和 uart_ops->request_port。所以,在调用这个函数之前,你的 uart_port 结构体必须已经初始化好,特别是 ops、iotype、mapbase、membase 等字段。
2.4 知识结构总览
为了让你更直观地理解整个框架,我画了一张图。它展示了从用户空间到硬件寄存器的完整调用链。
从这张图你可以看到,你的驱动代码(uart_ops)处于整个调用链的底层。上层所有的请求,最终都会通过串口核心,调用到你实现的这些函数。所以,你写驱动时,重点就是填好 uart_ops 这张表。
总结一下:
- TTY 子系统是串口驱动的骨架,负责分层调度。
uart_ops是你与硬件交互的接口,必须实现核心函数。- 设备注册流程是
uart_register_driver->uart_add_one_port,顺序不能乱。
下一节,我们会深入 uart_port 结构体,看看怎么配置硬件参数、管理 DMA 缓冲区。到时候我会拿一个实际项目中的例子来拆解。