Linux串口驱动开发:从框架到实战的深度拆解
你有没有遇到过这样的场景?
在调试一块嵌入式板子时,minicom里数据乱码、丢包严重;或者刚写完的自定义UART驱动,一cat /dev/ttyS1就卡死系统。更离谱的是,明明硬件接线没问题,但波特率就是对不上——这些看似“玄学”的问题,背后其实都藏着Linux串口驱动机制的影子。
别急着换芯片或重做PCB,先搞清楚内核是怎么和你的UART控制器“对话”的。本文不讲泛泛而谈的API列表,而是带你钻进Linux串口子系统的内核深处,从uart_driver注册开始,一步步走到中断处理、tty流转、用户空间交互,最后用真实代码告诉你:为什么有些寄存器必须在特定时机设置,为什么ISR里不能打印日志,以及如何写出一个稳定可靠的串口驱动。
一个串口设备是如何被“看见”的?
我们常说“打开/dev/ttyS0”,但这背后发生的事远比 fopen 多得多。在Linux中,每个串口设备都不是孤立存在的,它属于一套分层管理的驱动架构——最上层是用户可见的字符设备节点,中间是统一的serial core子系统,底层才是具体的硬件操作逻辑。
这套设计的核心思想很简单:让平台无关的部分由内核统一处理,让开发者只关心自己的硬件差异。
举个例子,无论是TI的AM335x还是全志的D1,只要它们有UART控制器,就可以共用同一套 tty 层逻辑(比如回显、流控、termios配置)。你要做的,只是告诉内核:“我这儿有个串口,地址是0x48020000,中断号是72,时钟频率是48MHz。”剩下的创建设备节点、处理read/write调用、管理接收缓冲区等工作,全部交给 serial core 自动完成。
那么,你是怎么“告诉”内核这些信息的呢?答案就在两个关键结构体中:struct uart_driver和struct uart_port。
uart_driver:我不是端口,我是“端口工厂”
很多人初学时容易混淆uart_driver和uart_port。记住这个比喻:
uart_driver是工厂营业执照,uart_port是流水线上的具体机器。
也就是说,uart_driver不代表任何一个物理串口,而是描述一类串口设备的公共属性。比如你有一块板子上有3个UART控制器,那你就只需要定义一个uart_driver,然后为每个控制器实例化一个uart_port。
来看它的核心字段:
| 字段 | 作用说明 |
|---|---|
.owner | 必须设为THIS_MODULE,防止模块卸载时还在被引用 |
.driver_name | 驱动名,出现在/proc/tty/drivers列表中 |
.dev_name | 设备节点前缀,如 “ttyS” 或 “ttyAMA” |
.major/.minor | 主次设备号范围,通常主设备号固定为4(TTY_MAJOR) |
.nr | 最大支持的端口数量 |
典型的初始化如下:
static struct uart_driver my_uart_drv = { .owner = THIS_MODULE, .driver_name = "my_uart", .dev_name = "ttyMY", .major = TTY_MAJOR, .minor = 64, .nr = 2, // 支持两个串口 };注册时只需一行:
ret = uart_register_driver(&my_uart_drv); if (ret) { pr_err("无法注册串口驱动\n"); return ret; }这一步完成后,你会发现/proc/tty/drivers多了一行输出:
my_uart /dev/ttyMY 4 64-65 serial注意:此时还没有任何实际的硬件操作,也没有申请中断或映射寄存器。这只是向内核声明:“我可以提供最多2个名为 ttyMY0 和 ttyMY1 的串口设备”。
真正的硬件绑定,要等到uart_port被添加进来。
uart_port:你的硬件身份证
如果说uart_driver是工厂执照,那uart_port就是你每一台设备的“身份证”。它包含了所有与硬件直接相关的运行时信息。
关键字段详解:
| 字段 | 说明 |
|---|---|
.iotype | 访问方式:UPIO_MEM表示内存映射(常见于SoC),UPIO_PORT是x86的I/O端口 |
.mapbase | 寄存器物理地址 |
.membase | 映射后的虚拟地址(需 ioremap 后赋值) |
.irq | 使用的中断号 |
.uartclk | 串口时钟源频率(决定波特率精度) |
.fifosize | FIFO深度,影响收发策略 |
.ops | 指向struct uart_ops,定义了所有硬件操作函数 |
.line | 在当前驱动中的索引号(从0开始) |
典型定义:
static struct uart_port my_ports[] = { { .iotype = UPIO_MEM, .mapbase = 0x101f1000, .irq = IRQ_UART0, .uartclk = 14745600, .fifosize = 16, .ops = &my_uart_ops, .flags = UPF_BOOT_AUTOCONF, .line = 0, }, { .iotype = UPIO_MEM, .mapbase = 0x101f2000, .irq = IRQ_UART1, .uartclk = 14745600, .fifosize = 16, .ops = &my_uart_ops, .flags = UPF_BOOT_AUTOCONF, .line = 1, } };注册流程也很清晰:
for (i = 0; i < ARRAY_SIZE(my_ports); i++) { ret = uart_add_one_port(&my_uart_drv, &my_ports[i]); if (ret) pr_err("添加端口 %d 失败\n", i); }到这里,设备节点/dev/ttyMY0和/dev/ttyMY1才真正可用。但如果你现在就去读它,会发现什么也收不到——因为还没启用中断,也没配置波特率。
中断来了!谁来处理?uart_ops说了算
struct uart_ops是一组函数指针,相当于串口硬件的“操作说明书”。当内核需要打开端口、发送数据、修改波特率时,就会回调这里面的函数。
其中最重要的三个函数是:
.startup():端口首次打开时调用,用于申请中断、使能时钟.shutdown():关闭端口时释放资源.set_termios():处理 termios 结构,重新配置波特率、数据位等.handle_irq():可选,自定义中断处理入口
但我们通常不会自己实现.handle_irq(),而是通过 request_irq 注册一个标准中断服务程序(ISR),在里面解析状态寄存器并调用 serial core 提供的 API。
下面是一个典型的中断处理函数:
static irqreturn_t my_uart_isr(int irq, void *dev_id) { struct uart_port *port = dev_id; unsigned long flags; unsigned int status, ch, flag; spin_lock_irqsave(&port->lock, flags); status = readl(port->membase + UART_STATUS_REG); /* 是否有数据到达? */ if (status & RX_INT_PENDING) { while ((readl(port->membase + UART_LSR) & LSR_DR)) { ch = readl(port->membase + UART_RBR); // 读数据 flag = TTY_NORMAL; /* 处理错误标志 */ if (status & (LSR_OE | LSR_PE | LSR_FE)) { if (status & LSR_OE) flag = TTY_OVERRUN; if (status & LSR_PE) flag = TTY_PARITY; if (status & LSR_FE) flag = TTY_FRAME; status &= ~(LSR_OE | LSR_PE | LSR_FE); } /* 将数据送入tty层缓冲区 */ uart_insert_char(port, status, LSR_OE, ch, flag); } /* 告诉上层:有新数据到了! */ tty_flip_buffer_push(port->state->port.tty); } /* 发送完成中断:尝试发送下一个字节 */ if (status & TX_EMPTY_INT) { uart_write_wakeup(port->state->port.tty); } spin_unlock_irqrestore(&port->lock, flags); return IRQ_HANDLED; }重点来了:你在 ISR 中不能直接把数据拷贝给用户空间!
正确的做法是:
1. 用uart_insert_char()把接收到的数据放入tty flip buffer
2. 调用tty_flip_buffer_push()触发数据上抛
3. 内核会在下半部(softirq)将数据复制到 line discipline 缓冲区
4. 用户调用read()时才会真正拿到数据
这样做的好处是避免在中断上下文中做耗时操作,提升系统响应性。
数据是怎么从硬件流到用户空间的?
整个路径可以简化为一条链路:
硬件 UART → 中断触发 → ISR读取RBR → uart_insert_char() → tty_flip_buffer → tty_ldisc_buffer → 用户 read() 返回反过来,写数据的流程是:
用户 write() → tty layer 接收 → ldisc 处理(如XON/XOFF) → serial core 调用 port->ops->start_tx() → 启动中断或DMA发送这种分层模型带来了几个显著优势:
- 标准化接口:应用层无需关心底层是16550A还是S3C2410
- 灵活的线路规程:支持 N_TTY(默认)、PPP、HCI(蓝牙)等多种模式
- 内置流控机制:RTS/CTS 硬件流控、XON/XOFF 软件流控自动生效
- 异步I/O支持:poll/select/epoll 可监控串口状态变化
你可以用下面这条命令验证是否真的通了:
echo "hello" > /dev/ttyMY0如果对方串口终端能看到输出,恭喜你,驱动已经跑通!
开发中那些踩过的坑,我都替你试过了
🛑 痛点一:频繁丢包(Overrun)
现象:高波特率下(如 115200bps+)接收数据丢失严重。
原因分析:
- 中断延迟太高(被其他高优先级中断抢占)
- ISR执行时间太长(比如加了大量printk)
- FIFO溢出后未及时清空中断标志
解决方案:
-缩短临界区:只在必要时持锁,避免在ISR中调用复杂函数
-提高中断优先级:使用request_threaded_irq()分离顶半部和底半部
-启用DMA:对于大数据量传输,DMA比中断更高效
-合理设置FIFO触发级别:不要一味追求低延迟
🛑 痛点二:波特率不准
现象:两边都是9600bps,但通信失败。
根本原因:uartclk设置错误,导致分频后的实际波特率偏差过大。
解决方法:
1. 精确填写.uartclk字段(查手册确认时钟源)
2. 实现.set_termios()中的分频算法:
void set_baud_rate(struct uart_port *port, unsigned int baud) { unsigned int quot = (port->uartclk / (16 * baud)); writel(quot & 0xFF, port->membase + DLL); writel((quot >> 8) & 0xFF, port->membase + DLM); }建议:使用uart_get_baud_rate()和uart_update_timeout()辅助函数,它们已集成常见校验逻辑。
🛑 痛点三:并发访问冲突
现象:多线程同时读写串口时系统崩溃或数据错乱。
原因:多个上下文(进程、中断)同时访问寄存器。
正确做法:
- 所有硬件访问必须加锁:使用spin_lock_irqsave(&port->lock)
- 避免在中断中睡眠(不能调用 copy_to_user、kmalloc(GFP_KERNEL) 等)
高阶技巧:让你的驱动更健壮
✅ 支持设备树动态探测
现代Linux倾向于使用设备树(Device Tree)来描述硬件。你可以添加匹配表:
static const struct of_device_id my_uart_dt_ids[] = { { .compatible = "myvendor,my-uart-v1" }, { } }; MODULE_DEVICE_TABLE(of, my_uart_dt_ids);并在 probe 函数中解析 reg、interrupts 属性,实现自动配置。
✅ 实现电源管理
支持 suspend/resume 很简单:
static void my_uart_pm(struct uart_port *port, unsigned int state, unsigned int oldstate) { if (state == 0) { // resume: 重新使能时钟、恢复寄存器 } else { // suspend: 关闭时钟、保存关键寄存器 } } // 在 ops 中注册 .ops = { .pm = my_uart_pm, // ... };✅ 调试利器推荐
- 查看中断统计:
cat /proc/interrupts | grep uart - 启用控制台输出:配置
CONFIG_SERIAL_CORE_CONSOLE=y并绑定 earlycon - 添加调试日志:使用
dev_dbg()替代 printk,可通过 dynamic_debug 控制开关
写到最后:为什么你还得懂这套老古董机制?
也许你会问:现在都2025年了,谁还用串口?
答案是:几乎所有嵌入式设备都在用。
- 调试通道:uboot、kernel启动日志唯一可靠输出方式
- 工业现场:PLC、传感器、电表仍广泛使用 RS485/RS232
- 模组通信:GPS、LoRa、NB-IoT 模块大多通过串口对接
- 远程维护:即使以太网断开,串口也能连上去救砖
更重要的是,串口驱动是理解Linux字符设备模型的最佳入口。一旦你掌握了uart_port如何与 tty layer 协作、中断如何推动数据流动、用户空间如何通过标准接口访问硬件——再去学 I2C、SPI、甚至网络驱动,都会轻松很多。
所以,下次当你面对一个全新的SoC手册,看到那一排UART控制器寄存器时,不要再一头雾水。你应该想的是:
“好,我要先注册一个 driver,然后为每个 port 填好 mapbase 和 irq,再实现 ops 的 startup 和 set_termios……”
这才是一个合格的嵌入式开发者该有的思维路径。
如果你正在开发一款带有多串口扩展能力的边缘网关,或者需要对接某个奇葩通信协议的工控设备,欢迎在评论区分享你的挑战,我们一起拆解。