3. 串口驱动开发(一):Linux串口驱动框架、tty子系统、设备树配置
说到串口驱动,很多刚入行的朋友第一反应就是「打开设备、配置波特率、读写数据」。嗯,从应用层看确实是这样。但如果你要自己写一个串口驱动,或者调试一个串口设备死活不工作的情况,那就得深入理解 Linux 内核里那套 tty 子系统 了。
我个人习惯把串口驱动分成三层来看:tty 核心层、线路规程层、硬件驱动层。这三层各司其职,又互相配合。今天我们先从整体框架入手,把设备树配置也一并讲清楚。
核心观点:Linux 串口驱动本质上是 tty 驱动的一种。你写的串口驱动,最终要注册到 tty 核心,让用户空间通过 /dev/ttyXXX 来访问。
3.1 Linux 串口驱动整体框架
先看一张我画的框架图,把整个脉络理清楚:
这张图我画了好几次才满意。你想想看,数据从用户空间的 write() 到最终从串口发出去,中间要经过三层处理。每一层都有它存在的理由。
3.2 tty 子系统核心概念
tty 是 Teletype 的缩写,历史包袱很重。但 Linux 把它抽象成了一个非常优雅的框架。我简单梳理一下关键点:
| 组件 | 职责 | 对应文件/结构体 |
|---|---|---|
| tty 核心 | 设备号管理、打开/关闭、数据路由 | drivers/tty/tty_io.c |
| 线路规程 | 数据缓冲、行编辑、协议转换 | drivers/tty/n_tty.c |
| tty 驱动 | 硬件操作接口(收发、配置) | struct tty_driver |
| uart 驱动 | 串口专用封装,简化驱动开发 | drivers/tty/serial/serial_core.c |
我的经验:大多数嵌入式串口驱动都基于 serial_core 框架。你只需要实现 struct uart_ops 里的几个回调函数,剩下的 tty 注册、设备节点创建都由框架帮你搞定。
3.3 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 *);
};
这里我重点说几个你一定会碰到的回调:
- startup:打开串口时调用。你要在这里申请中断、初始化 FIFO、使能收发。我在项目中遇到过忘记清空 FIFO 导致收到乱码的情况,嗯,后来加了一行
uart_port->serial_in(port, UART_RX)把残留数据读掉就好了。 - shutdown:关闭串口时调用。释放中断、关闭 FIFO、复位硬件。注意要处理好并发,别在中断还在跑的时候就把寄存器清了。
- set_termios:配置波特率、数据位、停止位、校验位。这是最容易被忽略的回调。我曾经调试一个 115200 波特率的设备,死活不通,最后发现是
set_termios里波特率计算公式写错了,算出来实际是 9600。 - start_tx / stop_tx:控制发送启停。一般配合环形缓冲区使用,发送完一字节后判断是否还有数据要发。
3.4 设备树中的串口配置
现在大部分 ARM 平台都用设备树来描述硬件。串口节点长什么样?我拿一个实际项目中的例子:
&uart2 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&uart2_pins_a>;
clocks = <&clk_uart2>;
clock-names = "uart";
interrupts = <GIC_SPI 36 IRQ_TYPE_LEVEL_HIGH>;
interrupt-names = "uart";
dmas = <&dma 12 0 0x400>, <&dma 13 0 0x400>;
dma-names = "tx", "rx";
fifo-size = <64>;
};
这里有几个关键属性,我解释一下:
| 属性 | 含义 | 常见坑 |
|---|---|---|
status |
使能状态,okay 或 disabled |
很多人忘了写 okay,驱动加载了但设备不使能 |
pinctrl-0 |
引脚复用配置 | 引脚冲突是最常见的串口不工作原因 |
clocks |
时钟源,波特率发生器依赖它 | 时钟频率不对,波特率会成倍偏差 |
interrupts |
中断号与触发方式 | 中断号写错会导致 startup 失败 |
dmas |
DMA 通道,可选 | DMA 配置复杂,建议先轮询模式调通 |
fifo-size |
硬件 FIFO 深度 | 不匹配会导致 FIFO 溢出或利用率低 |
注意:设备树中的 compatible 属性必须与驱动中的 of_match_table 完全匹配,大小写和逗号都不能错。我曾经因为多了一个空格,驱动死活不 probe,查了整整半天。
3.5 驱动与设备树的绑定流程
驱动怎么和设备树节点对上号的?流程其实很清晰:
- 内核启动时解析设备树,为每个节点创建
struct platform_device。 - 驱动注册时,内核遍历设备树,匹配
compatible属性。 - 匹配成功,调用驱动的
probe函数。 - 在
probe中,通过of_property_read_*读取设备树属性。 - 调用
uart_register_driver和uart_add_one_port注册串口。
说白了,设备树就是驱动和硬件之间的「翻译官」。你驱动里要的时钟频率、中断号、DMA 通道,都从设备树里读。
3.6 一个最小串口驱动的骨架
我写一个最简的驱动骨架,帮你把上面的概念串起来:
static const struct of_device_id my_uart_of_match[] = {
{ .compatible = "mycompany,my-uart" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_uart_of_match);
static struct uart_ops my_uart_ops = {
.tx_empty = my_uart_tx_empty,
.startup = my_uart_startup,
.shutdown = my_uart_shutdown,
.set_termios = my_uart_set_termios,
.start_tx = my_uart_start_tx,
.stop_tx = my_uart_stop_tx,
.type = my_uart_type,
};
static int my_uart_probe(struct platform_device *pdev)
{
struct uart_port *port;
struct resource *res;
port = devm_kzalloc(&pdev->dev, sizeof(*port), GFP_KERNEL);
port->ops = &my_uart_ops;
port->type = PORT_MY_UART;
port->iotype = UPIO_MEM32;
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
port->membase = devm_ioremap_resource(&pdev->dev, res);
port->mapbase = res->start;
port->irq = platform_get_irq(pdev, 0);
if (port->irq < 0)
return port->irq;
uart_add_one_port(&my_uart_driver, port);
platform_set_drvdata(pdev, port);
return 0;
}
static struct platform_driver my_uart_driver = {
.probe = my_uart_probe,
.remove = my_uart_remove,
.driver = {
.name = "my_uart",
.of_match_table = my_uart_of_match,
},
};
module_platform_driver(my_uart_driver);
建议:刚开始写串口驱动时,先别急着加 DMA、流控这些高级功能。把 startup、set_termios、start_tx 这三个回调调通,能收发数据了,再逐步加功能。我每次都是这么干的,稳得很。
3.7 调试串口驱动的常用手段
驱动写好了,怎么验证?我分享几个实战中常用的方法:
- 查看 dmesg:驱动 probe 成功与否,中断有没有注册,都能看到。我习惯在
startup和shutdown里加dev_dbg()打印。 - 检查设备节点:
ls -l /dev/tty*看看你的串口设备有没有生成。主设备号 4 是 tty,204 是 ttyS。 - 用 stty 测试:
stty -F /dev/ttyS1 115200 cs8 -cstopb -parenb配置参数,然后用cat /dev/ttyS1 &和echo "hello" > /dev/ttyS1测试收发。 - 逻辑分析仪:这是终极大法。我曾经遇到一个 TX 引脚始终为高的问题,用逻辑分析仪一看,发现是 pinctrl 配置把 TX 配成了输入模式。
一句话总结:串口驱动开发,框架是骨架,设备树是血肉,uart_ops 是灵魂。把这三样搞明白,剩下的就是填代码了。