从零开始搞定ARM平台串口驱动移植:不只是“改个设备树”那么简单
你有没有遇到过这种情况?
系统跑起来了,板子也通电了,但串口就是没输出——黑屏、乱码、丢数据……明明代码一模一样,换个芯片就出问题。这时候你就知道,串口不是插上线就能用的“即插即用”外设,而是一个需要精心调教的硬件接口。
尤其是在ARM平台上,无论是Cortex-M的小型工控模块,还是Cortex-A上的Linux嵌入式网关,只要涉及底层通信,SerialPort(UART)驱动移植几乎是每个嵌入式工程师绕不开的一课。它看似简单,实则暗藏玄机:寄存器偏移错一位,整个通信链路瘫痪;波特率算差一点,高波特率下全盘崩溃。
本文不讲空话套话,也不堆砌术语,而是带你一步步拆解ARM平台上串口驱动移植的真实流程——从设备树怎么写、中断为何不触发,到DMA如何启用、休眠唤醒为何失效。我们会结合实际开发经验,把那些手册里不会明说、但你一定会踩的坑,全都摊开来讲。
为什么不能直接用标准驱动?
很多人以为:“Linux内核不是已经有8250和pl011这些通用串口驱动了吗?照着抄一遍设备树不就行了?”
听起来很美,现实却常常打脸。
原因很简单:你的SoC里的UART控制器,很可能压根不是标准IP核。
比如全志H3、瑞芯微RK3399、NXP i.MX系列,虽然都叫“UART”,但它们的寄存器布局、时钟门控方式、甚至中断触发机制,可能和经典的ARM PL011完全不同。有的是32位寄存器对齐,有的却是16位间隔还带偏移;有的支持DMA搬移,有的只能靠轮询……
所以一旦你发现串口初始化失败、读不到数据、或者中断进不去,别急着怀疑接线或波特率——先确认你的硬件是不是“非标选手”。
✅判断依据:查芯片手册!看UART章节的寄存器映射表。如果跟PL011不一致,那就得自己动手写驱动了。
驱动移植的核心任务:连接软件与硬件的“翻译官”
在Linux中,TTY/UART子系统是一套分层设计:
应用层 → line discipline → TTY core → UART driver → 硬件寄存器我们作为驱动开发者,要做的就是实现中间这一层“UART driver”,让它能正确地读写特定SoC的UART控制器。
这个过程本质上是在做三件事:
1.告诉内核:“这儿有个串口设备”——通过设备树描述资源。
2.告诉内核:“该怎么操作它”——实现uart_ops函数集。
3.告诉内核:“出了事找我”——注册中断处理程序。
下面我们逐个击破。
第一步:让内核“看见”你的串口 —— 设备树配置的艺术
设备树是现代ARM Linux的灵魂。没有正确的.dtsi节点,再好的驱动也白搭。
一个典型的UART节点长这样:
uart0: serial@9000000 { compatible = "myvendor,uart-v1"; reg = <0x9000000 0x1000>; interrupts = <GIC_SPI 24 IRQ_TYPE_LEVEL_HIGH>; clocks = <&clkc CLK_UART0>; clock-names = "uart_clk"; power-domains = <&pd_uart>; reg-shift = 2; /* 寄存器按4字节对齐 */ reg-io-width = 4; /* 使用32位访问 */ status = "okay"; };几个关键点必须注意:
compatible字段决定了匹配哪个驱动。如果你写了"myvendor,uart-v1",那你的驱动.of_match_table就得对应上,否则 probe() 根本不会被调用。reg-shift很容易被忽略。假设硬件寄存器每项占4字节,但索引是以单字节为单位递增的,就需要设置reg-shift=2(即左移2位,×4)。否则读出来的全是错位数据。clocks必须准确指向主控时钟源。很多初学者只填了个频率,却不申请时钟,在低功耗场景下会被PM框架关闭时钟导致通信中断。status = "okay"别忘了打开,不然设备直接被禁用。
💡小技巧:可以用of_property_read_u32(np, "clock-frequency", &freq)在驱动中动态获取时钟频率,避免硬编码。
第二步:搭建驱动骨架 —— uart_driver 与 uart_ops
1. 定义驱动实例
static struct uart_driver my_uart_driver = { .owner = THIS_MODULE, .driver_name = "my_serial", .dev_name = "ttyMY", .major = 0, .minor = 0, .nr = 2, // 支持两个端口 };.dev_name决定了设备文件名,比如/dev/ttyMY0和/dev/ttyMY1。.nr是最大端口数,别设太大浪费内存。
记得在模块加载时注册:
ret = uart_register_driver(&my_uart_driver); if (ret) return ret;2. 实现操作函数集(核心!)
这是真正控制硬件的地方。最关键的几个函数如下:
startup()—— 上电准备
static int my_uart_startup(struct uart_port *port) { int ret; // 使能时钟 clk_prepare_enable(port->clk); // 请求中断 ret = request_irq(port->irq, my_uart_interrupt, IRQF_SHARED, "my_uart", port); if (ret) { dev_err(port->dev, "failed to request irq\n"); goto err_clk; } // 初始化FIFO、清除状态 writel(UART_CR_RXE | UART_CR_TXE, port->membase + UART_CTRL); return 0; err_clk: clk_disable_unprepare(port->clk); return ret; }⚠️ 注意事项:
- 中断一定要用request_irq注册,不要漏掉IRQF_SHARED(多中断共享GIC时需支持)。
- 时钟要在使用前 enable,退出时 disable,否则耗电严重。
set_termios()—— 设置波特率、数据格式
这才是最容易出问题的部分!
static void my_uart_set_termios(struct uart_port *port, struct ktermios *termios, struct ktermios *old) { unsigned int baud, quot; unsigned long flags; baud = uart_get_baud_rate(port, termios, old, 300, 4000000); // 计算分频系数:quot = clk / (16 * baud) quot = DIV_ROUND_CLOSEST(port->uartclk, 16 * baud); spin_lock_irqsave(&port->lock, flags); // 写入DLL/DLM(假设支持DLAB模式) writel(quot & 0xff, port->membase + UART_DLL); writel((quot >> 8) & 0xff, port->membase + UART_DLM); // 设置数据位、停止位、校验 unsigned int lcr = 0; switch (termios->c_cflag & CSIZE) { case CS5: lcr |= UART_LCR_WLEN5; break; case CS6: lcr |= UART_LCR_WLEN6; break; case CS7: lcr |= UART_LCR_WLEN7; break; case CS8: lcr |= UART_LCR_WLEN8; break; } if (termios->c_cflag & CSTOPB) lcr |= UART_LCR_STOP; if (termios->c_cflag & PARENB) { lcr |= UART_LCR_PARITY; if (!(termios->c_cflag & PARODD)) lcr |= UART_LCR_EVENPAR; } writel(lcr, port->membase + UART_LCR); // 更新TTY层缓存 uart_update_timeout(port, termios->c_cflag & CBAUD, baud); spin_unlock_irqrestore(&port->lock, flags); // 清除接收FIFO writel(UART_FCR_CLEAR_XMIT | UART_FCR_CLEAR_RCVR, port->membase + UART_FCR); // 允许接收中断 writel(UART_IER_RDI, port->membase + UART_IER); }📌 波特率误差必须控制在 ±3% 以内。例如输入时钟48MHz,目标115200bps:
Divisor = 48000000 / (16 × 115200) ≈ 26.04 → 取整26 实际波特率 = 48000000 / (16 × 26) ≈ 115384.6 误差 = (115384.6 - 115200)/115200 ≈ +0.14%完全OK。但如果用了错误的时钟源(比如误当成24MHz),误差就会飙升至50%,通信必然失败。
第三步:中断服务程序 —— 数据来了怎么办?
中断是串口稳定运行的关键。如果不用中断,CPU就得不断轮询,效率极低。
static irqreturn_t my_uart_interrupt(int irq, void *dev_id) { struct uart_port *port = dev_id; unsigned int status, ier; status = readl(port->membase + UART_ISR); // 中断状态寄存器 ier = readl(port->membase + UART_IER); if (!(status & UART_ISR_INT_MASK) || !(ier & UART_IER_EN)) return IRQ_NONE; spin_lock(&port->lock); /* 接收数据就绪 */ while (status & UART_ISR_RX_READY) { unsigned char ch; unsigned int ls = readl(port->membase + UART_LSR); ch = readl(port->membase + UART_RBR); // 自动清除中断标志 uart_insert_char(port, ls, UART_LSR_OE, ch, TTY_NORMAL); } /* 发送缓冲空 */ if (status & UART_ISR_TX_EMPTY) { struct circ_buf *xmit = &port->info->xmit; if (!uart_circ_empty(xmit)) { writel(xmit->buf[xmit->tail], port->membase + UART_THR); xmit->tail = (xmit->tail + 1) & (UART_XMIT_SIZE - 1); uart_write_wakeup(port); // 唤醒等待发送的进程 } } spin_unlock(&port->lock); return IRQ_HANDLED; }🔍 关键细节:
-uart_insert_char()是TTY子系统的标准入口,不可跳过。
-uart_write_wakeup()会通知上层可以继续写入,否则write()可能会一直阻塞。
- 所有修改环形缓冲的操作都要加锁,防止并发访问。
常见坑点与调试秘籍
❌ 问题1:串口乱码 or 丢帧
现象:接收数据偶尔错一位,或者整包丢失
排查方向:
- 检查波特率是否精确(重点看时钟源)
- 是否启用了FIFO?若未清空可能导致旧数据残留
- 中断优先级太低,被其他ISR抢占导致响应延迟
🔧 解决方案:
- 启用DMA接收(减少中断频率)
- 提高中断优先级:irq_set_irq_type(irq, IRQ_TYPE_EDGE_RISING);
- 增大TTY缓冲区:修改CONFIG_UNIX98_PTYS或自定义 buffer size
❌ 问题2:休眠后无法恢复
现象:系统suspend/resume后串口无响应
根本原因:没有实现PM回调函数,硬件状态未重置
✅ 正确做法:
static int my_uart_suspend(struct device *dev) { struct uart_port *port = dev_get_drvdata(dev); uart_suspend_port(&my_uart_driver, port); return 0; } static int my_uart_resume(struct device *dev) { struct uart_port *port = dev_get_drvdata(dev); my_uart_hw_init(port); // 重新初始化寄存器 uart_resume_port(&my_uart_driver, port); return 0; } static const struct dev_pm_ops my_uart_pm_ops = { .suspend = my_uart_suspend, .resume = my_uart_resume, };记得在platform driver中绑定.pm = &my_uart_pm_ops。
性能优化建议:什么时候该上DMA?
| 场景 | 推荐模式 |
|---|---|
| 波特率 ≤ 115200,数据量小 | 中断模式即可 |
| 波特率 ≥ 921600,持续传输 | 必须启用DMA |
| 实时性要求高(如工业控制) | DMA + 双缓冲机制 |
DMA的好处显而易见:CPU不再频繁被打断,可以把时间留给更重要的任务。你可以配合scatter-gather模式批量搬运数据,进一步提升吞吐量。
当然,代价是复杂度上升——你需要管理DMA通道、处理完成回调、考虑内存一致性等问题。但对于高性能嵌入式系统来说,这笔投资值得。
最佳实践清单(收藏备用)
✅设备树命名规范:用serial@addr,compatible加厂商前缀
✅避免忙等待:永远不要在while循环里读LSR_DR!必须用中断或DMA
✅安全访问检查:每次访问port->membase前判断是否为NULL
✅并发保护:所有涉及缓冲区的操作都加spin_lock_irqsave()
✅日志分级输出:调试阶段用dev_dbg(),发布时关闭
✅兼容老内核:注意uart_add_one_port调用时机(应在probe()成功后)
写在最后:串口虽老,其命维新
尽管USB、以太网、Wi-Fi层出不穷,但在嵌入式世界里,串口依然是最可靠的“生命线”。
- 调试阶段,它是唯一能看到kernel log的方式;
- 工业现场,它是Modbus、CAN网关最常见的前端接口;
- 即便是AI边缘盒子,启动阶段照样依赖串口console排障。
掌握ARM平台下的SerialPort驱动移植,并非为了炫技,而是为了真正掌控系统的每一寸硬件资源。当你能在陌生的SoC上亲手点亮第一个printk("Hello UART!\n"),那种成就感,远胜于调通千行应用逻辑。
这条路没有捷径,只有反复阅读手册、调试寄存器、分析波形。但只要你走过一次全程,下次面对任何新平台,心里都会多一分底气。
如果你在移植过程中遇到了其他难题——比如多串口冲突、RS-485方向控制、IrDA模式切换——欢迎留言讨论,我们可以一起深入剖析。