☰
嵌入式Linux UART串口驱动开发:从寄存器到调试实战
2026/10/23 1:08:20 网站建设 项目流程

1. 为什么第 2 期先讲 UART:串口在驱动调试中的特殊地位

上一期聊完开发环境、交叉编译和 GPIO 点灯之后,很多朋友留言问接下来该学什么。我的答案是:先把 UART 串口驱动彻底打通。原因很简单,串口几乎是嵌入式驱动开发里第一个“既能当工具、又能当业务”的模块——调试内核要往串口打印日志,开机启动要看控制台输出,工业设备、传感器模块、蓝牙模组的通信也大量依赖串口。你说它难吧,寄存器就是那十几个;你说它简单吧,波特率误差、FIFO 丢数据、中断风暴、流控方向这些问题,足够让你折腾一整个晚上。

我记得有次帮同事排查一块新板卡,现象特别基础:上电后控制台完全没输出,内核似乎“死”在启动早期。排查半天发现不是没跑,而是串口波特率的分频配错了,实际波特率比期望值偏了将近 3%,电脑端自然收到一堆乱码。这种问题放在驱动开发的语境里,其实就属于“基本功不扎实”。所以这一篇我打算把 UART 驱动的完整链路从头到尾拆一遍,包括硬件特性怎么看、设备树怎么配、驱动框架怎么填、收发路径怎么设计,以及我实际调试中踩过的几个典型坑。

适合读这篇内容的人,我理解有三类:一是刚接触驱动开发的新人,需要一条完整的主线;二是已经在写内核驱动、但对串口这类“老朋友”没深究过细节的人;三是做板级 bringup、需要快速把串口调通再去调试其他模块的工程师。读完你至少能回答三个问题:UART 驱动该看芯片手册里的哪些信息?内核里串口框架分哪几层?遇到乱码、丢字符、死循环,第一步该查什么?

2. 硬件层先过一遍:电平、时钟与帧格式,驱动开发的“物理底账”

驱动工程师有一项很容易被忽视的能力,就是“读懂硬件手册里跟软件相关的部分”。串口驱动虽然最终操作的是寄存器,但寄存器背后连着真实的电平转换芯片、时钟树和外部收发电路。很多驱动问题表面上是代码 bug,根子却在硬件设计或者参数匹配上。

2.1 电平与接口形态:TTL、RS232、RS485 的驱动差异

串口这个名词其实是个笼统的说法。芯片引脚上直接输出的通常叫 TTL 电平,高电平约 3.3V 或 1.8V,低电平 0V,这种信号只能短距离板内传输;RS232 是 ±12V 左右的双极性信号,要经过电平转换芯片;RS485 则是差分信号,适合长距离、多节点组网,还多了一个方向控制脚。

从驱动的角度看,寄存器操作并不会因为你接的是 RS232 还是 RS485 而改变,CPU 看到的始终是“发送寄存器、接收寄存器、状态标志”。但代码里有两处会影响:一是 RS485 半双工模式下,发送前需要把收发器的方向控制引脚拉高,发完再拉低,否则收发会互相干扰;二是如果硬件设计用 GPIO 模拟方向切换,驱动必须在发送使能和发送完成两个时间点操作对应的 GPIO。

不少芯片自带了 RS485 模式寄存器,硬件会自动处理方向翻转。这时驱动要做的反而简单,我通常建议优先确认硬件设计是否支持自动方向切换,再决定软件里要不要手动补。如果你发现接上 RS485 设备后,对方收到一帧数据,但立刻“自收发”导致线路冲突,多半就是方向切换太慢,或者软件里发送完成后没及时把方向拉回接收态。

2.2 波特率、帧格式与时钟分频的计算逻辑

UART 是异步串行通信,收发双方没有共同时钟,靠的是“约定波特率 + 同步头”。一帧数据通常由起始位、数据位、校验位(可选)、停止位组成。起始位是一个拉低的跳变,接收端检测到这个跳变后,按预设波特率在数据位中间采样。如果双方波特率差异稍大,采样点就会逐渐偏移,最终出现乱码。

波特率寄存器的本质是一个分频器。很多串口控制器的分频公式是:

波特率 = 串口时钟频率 / (16 × 分频系数)

举个例子,假设串口时钟是 24MHz,想要 115200bps,那么分频系数算出来是 24MHz / (16 × 115200) ≈ 13.02。由于寄存器只能写整数,写入 13 后实际波特率会变成 24MHz / (16 × 13) ≈ 115385,误差只有 0.16%,完全没有问题。

但如果时钟是 40MHz,同样算 115200 波特率,分频系数 = 40MHz / (16 × 115200) ≈ 21.70,取整 21 后的实际波特率是 40MHz / (16 × 21) ≈ 119048,误差接近 3.3%,这就超过多数串口接收端能容忍的 2% 阈值,大概率会收到乱码。因此,真正开发的时候,我拿到一块新板子会先查串口时钟源是多少、是否支持小数分频,再动手配寄存器。能用小数分频尽量用,不支持的话就尝试换一个更合适的时钟源,或者改用 9600 这类对分频更“友好”的波特率。

2.3 从芯片手册提取驱动需要的寄存器信息

写串口驱动前,我用半小时专门读手册,重点抓下面几类信息,整理成表格放进项目笔记里:

信息类别需要确认的内容对驱动开发的影响
寄存器基地址与偏移控制器挂在哪段地址空间,收发寄存器、状态寄存器、控制寄存器的偏移决定 ioremap 范围以及宏定义
时钟信息串口时钟源、门控位、是否默认关闭不配置时钟,寄存器写进去也不生效
中断信息中断号、触发方式、是否支持共享决定 request_irq 参数和中断处理归属
复位控制是否需要解除复位、复位释放顺序有些芯片不复位,串口一直不工作
FIFO 与 DMA 能力FIFO 深度、触发阈值、是否支持 DMA 请求直接影响收发路径是轮询、中断还是 DMA
流控能力是否支持硬件流控、自动方向切换决定 uart_ops 里流控函数的实现方式

这一步不能省。我见过太多人直接照抄参考驱动,结果两家芯片虽然都叫“UART”,但 FIFO 深度不同、错误状态寄存器清除方式不同,跑起来就出各种怪问题。硬件手册就是驱动的“底账”,把账看明白了,后面代码只是按账本填表。

3. 内核串口驱动框架与设备树配置:从 open 到 read 的完整流程

如果只在裸机上写串口,代码可以很直白,直接读状态寄存器、写数据寄存器。但在嵌入式系统里,串口要面对的是用户空间程序、多线程并发、睡眠唤醒、电源管理等一系列复杂需求,不能想怎么写就怎么写,必须接入内核既有的串口抽象框架。

3.1 uart_driver / uart_port / uart_ops 三层结构

内核把串口驱动拆成了几个层次:最底层是字符设备层,用户用 open/read/write 操作/dev/ttySx;中间是 tty 核心与串口核心,负责缓冲区、行规程、流量控制等公共逻辑;最上面是具体的 uart 控制器驱动,由开发者实现。

控制器驱动里核心对象有三个:

uart_driver代表一种串口控制器,比如“这颗芯片自带的 5 个串口”,需要给它起名字、指定主次设备号、提供 ops。

uart_port代表一个实际串口端口,保存了 IO 内存、中断号、时钟频率、FIFO 大小等实例信息。

uart_ops是操作函数集合,内核在合适的时机调用我们实现的startup、shutdown、start_tx、stop_tx、set_termios、tx_empty等回调。

实际写驱动时,probe 函数主要负责从设备树读取硬件资源,填充uart_port各字段,然后调用注册函数把端口挂到串口核心上。收发逻辑不写在 read/write 里,而是通过中断或轮询把数据交给 tty 层,再由 tty 层往上送给用户态。

3.2 设备树节点的常见字段与配置示例

设备树是驱动与硬件资源的“对接清单”。一个典型 UART 节点长这样:

serial@10002000 { compatible = "vendor,xxx-uart"; reg = <0x0 0x10002000 0x0 0x1000>; interrupts = <0 40 4>; clocks = <&uart_clk 0>; pinctrl-0 = <&uart0_pins>; pinctrl-names = "default"; dmas = <&dma0 0>, <&dma0 1>; dma-names = "tx", "rx"; status = "okay"; };

这些字段各有讲究。reg是串口控制器的寄存器物理地址和长度;interrupts里的数字含义由芯片的中断控制器决定;clocks告诉驱动时钟来自哪个节点,驱动里通过clk_get_rate拿到实际频率;pinctrl用于管脚复用配置——很多板子串口不工作,就是因为管脚没有被复用成 UART 功能,而是被默认配成了 GPIO。

dmas字段要格外小心。我建议第一次调驱动时先不要配 DMA,等中断模式跑稳定了再叠加 DMA 功能。因为一旦设备树声明了 DMA,内核会尝试为串口申请 DMA 通道,如果驱动侧对 DMA 的缓存处理没有完全正确,反而会引入更难查的随机问题。

3.3 console 与 tty 注册的区别

很多初学者会把“串口能打印日志”和“串口能被用户程序打开”混为一谈,其实这两条路径在内核里是分开的。

console是内核控制台,它的作用是在系统启动早期输出日志,甚至在系统崩溃时也能吐信息。普通串口驱动实现一个console结构体后,通过register_console注册进去,printk 的内容就会从该串口输出。console 的写函数通常不宜依赖中断,因为早期 interrupt 还没初始化完全,用轮询方式更稳妥。

tty设备是另一个通道,注册成功后对应/dev/ttySx,用户程序可以 open 它做业务通信。一个串口驱动既要注册 console,也可能要注册 tty,两者共享同一个物理口。区别在于 console 很简单、只有输出(有的也支持输入),而 tty 需要完整的输入缓冲区、行规程和唤醒机制。

实际开发中常见的问题是:控制台有输出,但/dev/ttySx打开后发送没反应。这往往不是寄存器配错,而是 tty 设备注册路径没走通,或者uart_ops里的start_tx没有正确启动发送流程。建议调试时先把 console 跑通、把启动日志打出来,再去验证用户态收发,分两步走,问题定位会快很多。

4. 核心驱动的编写与收发路径:轮询、中断与 DMA 三种模式的取舍

串口驱动的“灵魂”在收发路径。你不可能在用户态 read 时再去查寄存器等待数据,那样 CPU 就被白白占死了。实际项目中,收发路径有三种实现思路,各有适用场景。

4.1 轮询模式:Bootloader 阶段最可靠的收发方式

轮询模式就是 CPU 不断读取状态寄存器,查看是否可发送或是否有数据到来。比如发送一个字符:

static void uart_putc(struct uart_port *port, char c) { while (!(readl(port->membase + UART_LSR) & LSR_TX_EMPTY)) ; writel(c, port->membase + UART_THR); }

这段代码没有中断、没有复杂状态机,逻辑非常直接。代价是 CPU 一直被占着,性能低。但在系统启动早期、内核还没有初始化好中断系统时,轮询几乎是唯一的选择。很多 bootloader 里的串口打印就是这种实现。

我通常在驱动里保留一个轮询发送函数给早期 console 用,业务通信则用中断或 DMA。这样既能保证启动阶段可调试,又不会牺牲正常运行时的性能。

4.2 中断模式:业务串口最常见的选择

中断模式的核心思想是:数据到达时硬件产生中断,驱动在中断服务程序里读取 FIFO 并将数据交给 tty 层;发送时,当 tty 层有数据需要发送,驱动把第一批数据写入硬件,然后在“发送完成”或“发送 FIFO 低于阈值”的中断里继续填数据,直到全部发完。

写中断服务程序时,有几个点必须注意。第一是读中断状态寄存器判断具体中断类型,不同中断源要分开处理;第二是接收中断里要尽量一次性把 FIFO 中的数据全部读空,而不是读一个字符就退出来,否则高波特率下很容易溢出;第三是错误状态要主动清除。

一个简化但典型的接收中断流程:

static irqreturn_t uart_isr(int irq, void *data) { struct uart_port *port = data; unsigned int iir, lsr; while ((iir = readl(port->membase + UART_IIR)) & IIR_NO_INT) { if (iir & IIR_RX_DATA) { while ((lsr = readl(port->membase + UART_LSR)) & LSR_RX_READY) { unsigned char ch = readl(port->membase + UART_RBR); tty_insert_flip_char(&port->state->port, ch, 0); } } if (iir & IIR_TX_EMPTY) { uart_write_wakeup(port); } } tty_flip_buffer_push(&port->state->port); return IRQ_HANDLED; }

收发 FIFO 设计得越大,中断次数越少,CPU 负载越低。但 FIFO 深度通常只有几十字节,高波特率下仍然需要合理设置触发阈值,让驱动在“数据足够多”时才中断 CPU。这个阈值往往需要根据业务场景反复调,我后面会展开讲。

4.3 DMA 模式:高速串口的正确姿势与缓存一致性坑

当波特率走到 1Mbps 甚至更高,每个字节的中断会严重消耗 CPU。这时就该让 DMA 接管数据搬运:接收方向由 DMA 把串口 FIFO 里的数据直接搬到内存缓冲区,发送方向由 DMA 把内存里的数据搬到串口发送 FIFO。

DMA 模式最关键的问题是缓存一致性。CPU 和 DMA 都访问内存,但 CPU 有缓存,DMA 看到的是物理内存。如果 DMA 往内存写入数据,CPU 缓存里还残留着旧数据,CPU 读到的就是错乱的字符,而且问题非常随机,跟缓存命中情况相关。

解决办法是用内核提供的dma_alloc_coherent分配一致性内存,或者用dma_map_single配合dma_sync_single_for_cpu/dma_sync_single_for_device手动同步。我建议接收缓冲尽量用dma_alloc_coherent,省心;发送缓冲如果来自 tty 层的动态数据,则要用 map/unmap 或专门维护的环形缓冲。

还有一个绕不开的问题:DMA 接收在缓冲区没满时不会主动产生完成中断。比如你发了一条 10 字节的短消息,缓冲区设了 4KB,DMA 收完 10 字节后可能一直等,CPU 这边拿不到数据。常规做法是配合串口的“空闲中断”或超时定时器:检测到接收线空闲超过几个字节时间,就认为一帧数据结束,把当前 DMA 缓冲区里的数据交上去。很多串口驱动里的接收超时逻辑,本质就是给 DMA 接收“补一个结束信号”。

5. 真实开发中踩过的坑:波特率误差、丢字符、中断风暴与流控

驱动代码写完后,调试才是真正的重头戏。我把自己在 UART 驱动调试中遇到最多、也最有代表性的几类问题整理在这里。这些问题看起来五花八门,但根因往往都很集中。

5.1 波特率误差积累:一串看似随机的乱码

有次在某块高性能 SoC 上调试,串口控制台一旦开启,终端里全是乱码,偶尔还能蹦出几个能辨认的字母。我第一反应是电平有问题,拿示波器一看,波形高低电平均正常。然后用逻辑分析仪抓一个字节,数一下位宽,发现每个位的时间比 115200 应有的 8.68 微秒明显偏宽,实际波特率大概只有 8 万出头。

查代码才发现,驱动里波特率分频直接用了某个外部时钟,而这个频率对 115200 并不友好,取整后的误差超过了 2%。接收端的采样窗口本来就窄,累计误差一大,后面几位采样点就全部错位。修这类问题,优先看芯片支持不支持小数分频,不支持就换用内部某个更合适的 PLL 分频作为串口时钟源。改完之后用逻辑分析仪重新量位宽,误差降到 0.1% 以内,一串串乱码立刻变成完全正常的日志输出。

5.2 FIFO 阈值设太高导致的收包延迟与丢字符

另一类问题是数据不会乱,但会出现延迟或者尾字节丢失。我印象很深的一个场景:设备周期性上报几百字节的传感器数据,波特率 460800,驱动配置了 FIFO 触发阈值“满一半才中断”。结果每次收到数据,前半段都正常,后半段经常缺一段。后来查清是阈值太高,数据填满 FIFO 的速度比 CPU 响应中断快,出现了硬件溢出。

处理这种问题,阈值要结合波特率和中断响应延迟一起算。高波特率下我倾向把触发阈值调低,宁可稍微多点中断次数,也不让数据溢出。另外,接收侧最好维护一个环形缓冲区,中断里只管把数据从硬件 FIFO 搬到内存 ring buffer,具体的解析和上报放到下半部或等待空暇时再做,避免上层处理慢了堵住中断。

这里还要补充一个“帧结束判断”的常识:串口本身没有帧尾,怎么知道一次上报的数据完了?最稳妥的实现是空闲超时,也就是收完最后一个字节后,如果串口空闲时间超过约定阈值,就认为一帧数据结束。这比“固定长度判断”和“缓冲区满判断”都抗干扰。

5.3 中断服务程序里没清标志位造成的“死循环”

有次调试一个外设中断驱动的串口,现象是系统运行几秒后就“死机”,但板子上的心跳灯还在闪,证明 CPU 没完全挂,只是主循环卡在某处。我打开内核的 irq 统计,发现某个中断号的处理次数每秒高达几百万次。

这就是典型的中断风暴。根因是我在中断服务程序里只处理了接收数据生,却没有做错误状态的清除。串口上偶尔来了个噪声,触发帧错误标志,中断控制器反复上报,而我的 ISR 每次返回时都没把错误中断清掉,于是硬件一直挂起同一个中断,形成风暴。

排查方法并不复杂:在 ISR 入口做一个计数器并周期性打印,看次数是否异常飙升;之后逐位检查中断状态寄存器,确认所有可能的中断源都有对应的“读取即清除”动作。现在我把这条当作串口驱动必查项,尤其是错误类中断,一定要读状态寄存器来清除,不能只屏蔽它,否则错误状态悬在那里,后面正常收数据也会受影响。

5.4 流控与电平转换导致的“能发不能收”

还有一种非常恼人的情况:板子向 PC 发数据完全正常,PC 向板子发却毫无反应;或者反过来,板子收得到但发不出去。这种“单向通”的问题,优先级最高的排查点其实是硬件链路。

常见原因有几种。第一,硬件流控线没接全。芯片默认开了 CTS/RTS,但板上 CTS 引脚悬空,硬件认为对端不允许发送,于是把发送通道堵死。驱动里要么初始化时明确关闭硬件流控,要么保证接线真正连上。第二,RS232 电平转换芯片的方向引脚被固定死,或者 RS485 的 DE 脚一直处于接收方向,导致发送时信号根本没上总线。第三,管脚复用配置错误,TX 对应的引脚复用了,RX 对应的引脚却还是 GPIO 模式。

排除这类问题的标准动作是回环测试:先直接在芯片引脚处把 TX 和 RX 短接,如果这样能自发自收,说明控制器本身是好的,问题出在后面的外部链路;再逐级接入转换芯片,看哪一级开始丢数据。这个逐级定位的过程,比盯着代码猜要高效得多。

6. 调试手段与工具选型建议

写到这里,我最后把自己平时调试串口驱动的一套“工具箱”做个总结。工具不一定要多高级,但用法要对,能让问题快速收敛。

6.1 三层调试方式:printk、逻辑分析仪、串口分析工具

驱动开发里最先用到的肯定是打印。内核对串口驱动的日志有几档控制,调试阶段我常用dev_dbg加动态调试开关,避免把调试信息全堆到正常日志里。启动早期则靠earlycon,把参数传给内核,在驱动完全初始化之前就能看到打印。

遇到波形层面的问题,逻辑分析仪比示波器更实用。只要把采样率设到波特率的 16 倍以上,配合 UART 解码协议,就能直接看出发送的是不是预期数据、位宽是否精准、起始位是否干净。我个人抓波特率误差、确认发送时序、判断外部电路影响,都靠这台仪器。

上位机端的串口分析工具就更日常了。很多终端工具支持自定义波特率、数据位、校验位,还能显示十六进制和 ASCII 混合视图。调试时用个工具把自己的收发模式写好,对比驱动侧寄存器日志,往往很快看出问题出在“发出去的字节不对”还是“收到的字节不对”。

6.2 回环测试的三种接法

回环测试是串口调试的“金标准”。我从简单到复杂常用三种接法:

第一种,芯片引脚本地短接,TX 和 RX 用飞线直接连起来。这样把控制器和外部电路彻底隔离,如果单片机自发自收正常,说明寄存器配置、中断路径、波特率都没问题。

第二种,通过板上的 RS232 或者 USB 转串口芯片回路。这能验证外部电平转换链路、保护电路是否影响数据。如果本地短接正常、过转换芯片后异常,问题就在外部链路。

第三种,双板对打。让两块板子互相跑压力测试脚本,出现错误时可以对比双方日志,快速定位是发送侧乱码还是接收侧丢数据。这个方法最贴近真实使用场景,也是出货前必做的验证。

6.3 自动测试脚本与长期稳定性验证

人工拿串口助手点几下只能证明基本功能通,不能证明长期稳定。我习惯在开发板上跑几类自动化测试:

  • 小包高频测试:每秒发几十条短消息,验证频繁中断下没有丢包;
  • 大包连续流测试:持续发送几十 KB 的数据,验证 FIFO、DMA 和上层缓冲在高吞吐下的表现;
  • 空采与睡眠唤醒测试:让串口长时间空闲再突然收数据,验证时钟门控、电源管理会不会把串口模块搞“睡死”;
  • 干扰测试:在串口线附近人为制造电磁噪声,验证错误状态处理和重同步能力。

前两类测试最容易暴露 FIFO 阈值和中断处理问题,第三类容易暴露流控和休眠唤醒问题,第四类则考验错误中断清除逻辑。把这四关都过了,串口驱动才算真正接地气。

我自己的体会是,UART 虽然协议简单,但正因为简单,它在每一块板子上都像一个“地基”,地基不稳,上层 WiFi、蓝牙、传感器都一样不稳。把串口驱动的寄存器、框架、收发路径和调试手段都走通一遍,后面再写 SPI、I2C、CAN 这类复杂外设驱动时,很多思路都是相通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询