RK平台FIQ串口与普通串口冲突:从乱码定位到修复的完整指南
1. 串口打印乱成一锅粥,问题到底出在哪
嵌入式调试里最让人抓狂的场景之一,就是系统跑起来之后串口终端突然开始刷乱码、丢字符,甚至两个串口互相抢着输出,日志像被搅碎的纸片一样完全没法读。这次要聊的就是RK平台上FIQ串口和普通串口配置冲突导致打印混乱的问题,从现象定位到根因分析再到最终修复,完整走一遍。如果你正在用RK系列芯片做底层开发,尤其是涉及调试串口重映射、多串口共存、FIQ中断调试的场景,这篇内容应该能帮你少走不少弯路。
先说清楚这个问题的本质:RK平台的串口控制器支持FIQ(Fast Interrupt Request)模式,这个模式原本是为了让串口在中断上下文中也能快速响应、低延迟输出而设计的。但FIQ串口的寄存器配置和普通串口是共享同一套硬件资源的,一旦设备树里两个节点指向了同一个物理串口,或者FIQ模式的配置没有正确隔离,就会出现两个"逻辑串口"争抢同一个"物理外设"的局面。表现出来就是打印内容交错、波特率漂移、甚至系统直接卡死在串口中断里。
这个问题的受众其实很明确:做RK平台BSP开发的工程师、调试内核早期打印的底层开发者、以及需要在生产环境中同时使用调试串口和业务串口的嵌入式团队。哪怕你刚接触设备树配置,只要跟着排查思路走一遍,也能理解冲突的来龙去脉。下面我按实际排查的顺序,把整个分析和修复过程拆开讲。
2. 先搞清楚FIQ串口和普通串口的本质区别
2.1 两种模式在硬件层面的分工
RK平台的串口模块在硬件上是一个完整的UART控制器,但软件层面可以通过不同的驱动路径去访问它。普通串口走的是标准的8250/16550兼容驱动框架,注册到tty子系统,用户态通过/dev/ttyS*访问,中断走的是常规IRQ通道。而FIQ串口则是把UART的中断信号路由到FIQ通道上,FIQ在ARM架构里优先级高于普通IRQ,且FIQ模式下有独立的寄存器组(banked registers),不需要像IRQ那样先保存大量上下文,所以响应延迟极低。
这个设计初衷是好的:内核早期启动阶段,常规中断子系统还没初始化完,但你又需要看打印,这时候FIQ串口就能顶上。问题在于,FIQ通道在ARM上通常只有一个,而且FIQ处理程序一旦注册,它会独占这个通道。如果设备树里同时配置了FIQ串口节点和普通串口节点指向同一个UART控制器,两个驱动都会去操作同一个控制器的寄存器——一个在FIQ上下文里写THR寄存器发数据,另一个在普通中断里也在写,寄存器状态就彻底乱了。
我见过最典型的现象是:系统启动前期打印正常,等到普通串口驱动probe完成、tty注册之后,打印开始出现规律性的乱码,每隔几个字符就插入一段无关内容。这就是两个驱动在交替往同一个FIFO里塞数据,接收端自然解析不出来。
2.2 设备树里的"隐形重叠"
很多人排查时第一反应是看驱动代码,但其实根因往往藏在设备树里。RK平台的设备树中,串口节点通常长这样:
uart2: serial@ff1a0000 { compatible = "rockchip,rk3568-uart", "snps,dw-apb-uart"; reg = <0x0 0xff1a0000 0x0 0x100>; interrupts = <GIC_SPI 118 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru SCLK_UART2>, <&cru PCLK_UART2>; status = "okay"; };而FIQ串口的配置可能通过chosen节点或者单独的fiq-debugger节点来指定:
chosen { bootargs = "earlycon=uart8250,mmio32,0xff1a0000 fiq_debugger"; };注意这里的0xff1a0000——如果它和上面uart2的reg地址相同,那就是同一个物理串口被两套机制同时占用了。earlycon在早期启动时用这个地址输出,fiq_debugger也用它,等普通串口驱动起来后又注册了ttyS2,三套路径叠在一起,不乱才怪。
提示:排查第一步永远是核对设备树里所有涉及串口地址的节点,把reg属性、bootargs里的mmio地址、fiq-debugger配置的地址列成一张表,看有没有重复。
2.3 为什么FIQ模式更容易"抢赢"
从优先级上说,FIQ高于IRQ,所以当两个驱动同时想发数据时,FIQ路径的写入会打断普通中断的处理流程。更麻烦的是,FIQ处理程序通常不经过完整的中断框架,它直接操作寄存器,不会去拿普通串口驱动持有的锁。这就导致普通驱动以为自己在独占访问,实际上FIFO已经被FIQ那边改过了。这种无锁竞争在单核上表现为打印交错,在多核上可能直接触发数据 abort。
我在一次实际调试中遇到过这样的情况:普通串口驱动在serial8250_console_write里关中断写FIFO,但FIQ不受这个关中断影响(FIQ有独立的屏蔽位),结果FIFO的写指针被两边同时推进,接收端收到的字节流里混入了FIQ调试器的心跳字符。定位这个问题花了大半天,最后是靠抓UART控制器的FIFO状态寄存器才确认的。
3. 冲突排查的完整实操路径
3.1 从现象反推:先确认是不是"双写"
拿到一个打印混乱的板子,别急着改代码。先做三件事:第一,把串口线接到逻辑分析仪或者用另一台机器抓原始字节流,看乱码是规律性的还是随机的;第二,在内核启动参数里临时去掉fiq_debugger,只保留普通串口,看打印是否恢复正常;第三,如果去掉FIQ后正常,那基本可以锁定是双写冲突。
我习惯用一张表来记录不同配置组合下的现象,这样能快速缩小范围:
| 配置组合 | 早期打印 | 内核启动后打印 | 结论指向 |
|---|---|---|---|
| 仅earlycon | 正常 | 无输出 | 普通驱动未接管 |
| 仅fiq_debugger | 正常 | 正常但无法交互 | FIQ独占 |
| earlycon + 普通串口 | 正常 | 乱码 | 地址重叠 |
| fiq_debugger + 普通串口 | 正常 | 严重乱码/卡死 | FIQ与IRQ竞争 |
| 三者全开 | 正常 | 完全不可读 | 多重冲突 |
这张表是我踩过几次坑之后总结的,实际排查时按行去验证,基本两三轮就能定位到冲突层级。
3.2 核对设备树与bootargs的地址一致性
确认是双写之后,下一步是把所有"可能碰这个串口"的配置源找出来。RK平台上主要有四个地方需要检查:设备树的uart节点reg属性、chosen节点里的bootargs、fiq-debugger节点的配置、以及可能存在的stdout-path属性。
# 从编译出的dtb反查串口相关配置 fdtdump board.dtb | grep -A5 -i "uart\|fiq\|stdout\|earlycon"这一步的关键是确认物理地址。比如uart2的reg是0xff1a0000,那bootargs里的earlycon地址、fiq_debugger的地址都必须是这个值或者明确指向不同的串口。如果发现fiq_debugger配的是uart2的地址,而业务又需要uart2做普通打印,那就必须做取舍或者重映射。
注意:有些板子的fiq-debugger配置藏在
rockchip,fiq-debugger节点里,不在chosen中,用grep搜"fiq"关键字能快速找到。
3.3 用寄存器状态确认竞争现场
如果地址核对下来没有明显重叠,但现象依然存在,那就需要看运行时状态了。UART控制器的FIFO状态寄存器(偏移0x00附近的USR寄存器)能反映FIFO的填充情况。在冲突发生时,你会看到FIFO的读写指针异常跳动,或者FIFO里残留着不属于当前输出流的数据。
我通常会在普通串口驱动的serial8250_console_write入口和FIQ处理程序入口各加一个GPIO翻转,用示波器看两个信号的时序关系。如果发现FIQ的翻转出现在普通驱动"关中断-写FIFO-开中断"这个临界区内部,那就实锤了——FIQ绕过了普通驱动的临界区保护。
这个方法的成本略高,但比盲改代码高效得多。没有示波器的话,退而求其次可以在两个入口各打一个时间戳到内存buffer,事后dump出来对比,也能看出交错关系。
4. 修复方案的设计与落地
4.1 方案选型:隔离还是复用
定位到冲突之后,修复思路其实就两条路:要么把两个逻辑串口彻底隔离到不同的物理串口上,要么让它们共享同一个物理串口但做严格的互斥。前者简单粗暴但受限于硬件串口数量,后者灵活但实现复杂度高。
我的建议是优先做物理隔离。RK平台一般有多个UART控制器,把FIQ调试串口挪到一个独立的UART上(比如用uart0做FIQ,uart2做业务打印),设备树里改一下地址和引脚复用就行,风险最低。只有在串口资源实在紧张、必须复用时,才考虑软件互斥方案。
物理隔离的具体操作是:在设备树里确认fiq-debugger节点指向的地址与业务串口不同,同时检查pinctrl配置,确保两个串口的TX/RX引脚没有复用冲突。RK的引脚复用表里,同一个物理引脚可能对应多个UART功能,改地址之后一定要同步改pinctrl。
/* FIQ调试串口独占uart0 */ fiq_debugger: fiq-debugger { rockchip,serial-id = <0>; rockchip,wake-irq = <0>; rockchip,baudrate = <1500000>; pinctrl-names = "default"; pinctrl-0 = <&uart0_xfer>; status = "okay"; }; /* 业务打印走uart2,与FIQ隔离 */ &uart2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; };4.2 软件互斥方案的实现要点
如果确实需要复用同一个物理串口,那就得在驱动层做互斥。核心思路是让FIQ处理程序在写FIFO之前检查一个原子标志位,普通驱动在进入临界区时置位该标志,退出时清除。FIQ看到标志置位就丢弃本次输出或者缓存到环形buffer里延后发送。
这里有个坑:FIQ处理程序里不能调用任何可能睡眠的函数,也不能用普通的spinlock(因为FIQ不参与普通锁的竞争逻辑)。可行的做法是用一个atomic_t或者直接操作一个volatile变量,配合内存屏障保证可见性。
/* FIQ侧:写之前检查普通驱动是否在临界区 */ static volatile int uart_busy; static void fiq_uart_putc(char c) { if (uart_busy) return; /* 丢弃或缓存,避免竞争 */ while (!(readl(uart_base + UART_USR) & UART_USR_TX_FIFO_NOT_FULL)) ; writel(c, uart_base + UART_THR); } /* 普通驱动侧:临界区置位 */ static void serial8250_console_write(...) { uart_busy = 1; smp_wmb(); /* 原有写FIFO逻辑 */ ... smp_wmb(); uart_busy = 0; }这个方案能解决大部分交错问题,但代价是FIQ输出在冲突窗口内会被丢弃,调试信息可能不完整。所以它只适合"业务打印为主、FIQ仅做兜底"的场景,反过来就不合适了。
4.3 波特率与时钟配置的一致性校验
还有一个容易被忽略的冲突点:FIQ串口和普通串口如果对同一个UART设置了不同的波特率,分频寄存器会被反复改写,导致实际波特率在两个值之间跳变,接收端解析出来的就是乱码。这种情况在日志里表现为"前半段正常、后半段乱码"或者"每隔固定时间乱一次"。
修复方法是在设备树和bootargs里统一波特率。RK平台的fiq-debugger节点有rockchip,baudrate属性,bootargs里的earlycon可以带,115200n8这样的参数,普通串口的tty配置也要对齐。三处必须一致,否则分频寄存器就会被三方拉扯。
# bootargs中显式指定波特率,与fiq-debugger和tty配置对齐 bootargs = "earlycon=uart8250,mmio32,0xff1a0000,115200n8 console=ttyS2,115200n8";我实测下来,波特率不一致导致的乱码比双写冲突更隐蔽,因为它的现象是间歇性的,容易误判成硬件干扰。排查时用示波器量一下TX线上的位宽,如果位宽在两种值之间跳变,那就是分频寄存器被改了。
5. 实操心得与常见问题速查
5.1 几个反直觉的排查经验
第一个经验:先怀疑设备树,再怀疑驱动。我早期排查这类问题时总喜欢从驱动代码入手,结果绕了一大圈才发现是设备树里两个节点地址写重了。设备树是配置的源头,源头错了,驱动怎么改都是治标。
第二个经验:FIQ串口的日志不要和普通串口混在一个终端看。如果两个串口物理上接到同一个USB转串口模块的不同通道,有些模块的内部buffer会串数据,让你误以为是软件冲突。排查阶段尽量用两个独立的接收端。
第三个经验:注意status = "okay"的继承关系。RK的设备树里有些uart节点在soc级dtsi里默认是disabled,板级dts里再enable。如果板级dts里enable了uart2,但soc级dtsi里uart2的reg地址和fiq-debugger默认地址相同,冲突就悄悄发生了。改的时候要顺着包含关系往上查。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 修复方向 |
|---|---|---|---|
| 启动后打印规律性乱码 | 双驱动写同一FIFO | 去掉fiq_debugger对比 | 物理隔离或加互斥 |
| 打印间歇性乱码 | 波特率被反复改写 | 示波器量TX位宽 | 统一三处波特率配置 |
| 系统卡死在串口中断 | FIQ与IRQ死锁 | 看中断计数和调用栈 | 检查FIQ处理程序是否拿锁 |
| 早期打印正常、后期无输出 | 普通驱动接管后地址错 | 核对ttyS编号与reg | 修正设备树地址映射 |
| 多核下偶发数据abort | 无锁竞争导致总线异常 | 抓异常地址 | 加内存屏障和原子标志 |
5.3 修复后的验证方法
改完之后不能只看"打印正常了"就收工。我一般会做三轮验证:第一轮,连续跑24小时压力测试,让业务串口持续输出日志,同时FIQ调试器周期性打印心跳,看有没有偶发乱码;第二轮,人为在普通串口输出高峰期触发FIQ中断(比如通过调试命令),观察是否出现字符丢失;第三轮,用逻辑分析仪抓一段完整的字节流,逐字节比对发送端和接收端,确认没有插入或丢失。
只有三轮都过了,才算真正修复。我见过太多"改完看着好了、量产又出问题"的案例,根因就是验证不充分,冲突在低负载下不显现,高负载才暴露。
6. 从这次排查延伸出的配置管理建议
这类冲突的本质是"同一个硬件资源被多个配置源描述",而配置源分散在设备树、bootargs、驱动默认值三个地方,缺乏统一的校验机制。我在后续的项目里养成了一个习惯:在板级dts里加一段注释,把所有串口的物理地址、用途、占用者列清楚,改任何一处之前先看这张"地图"。
/* * UART资源分配表: * uart0 (0xff180000) -> FIQ调试串口,独占 * uart2 (0xff1a0000) -> 业务打印,console=ttyS2 * uart4 (0xff1c0000) -> 预留,未启用 */另外,编译流程里可以加一个脚本,自动提取dtb里所有uart节点的reg地址和bootargs里的mmio地址,做重复性检查,有重叠就报错。这个脚本不复杂,几十行Python就能搞定,但能在编译阶段就拦住大部分配置冲突,比运行时排查省事得多。
# 简化的地址冲突检查思路 import re, subprocess dtb_dump = subprocess.check_output(['fdtdump', 'board.dtb']).decode() uart_addrs = re.findall(r'serial@([0-9a-f]+)', dtb_dump) bootargs = re.search(r'bootargs\s*=\s*"([^"]+)"', dtb_dump) mmio_addrs = re.findall(r'mmio32,([0-9a-fx]+)', bootargs.group(1)) if bootargs else [] overlap = set(uart_addrs) & set(a.replace('0x', '') for a in mmio_addrs) if overlap: print(f"警告:以下地址存在串口配置重叠 -> {overlap}")这个检查逻辑很粗糙,但对付常见的地址写重已经够用了。实际项目里可以根据自己的设备树结构细化匹配规则。
最后分享一个我在实际调试中的体会:串口冲突这类问题,现象吓人但根因往往很简单,难的是在众多配置源里快速定位到那一个写错的地址。与其在驱动里加各种防御性代码,不如把配置源头管好。设备树是嵌入式系统的"宪法",宪法写矛盾了,下面怎么执行都是乱的。把资源分配表维护好、把编译期检查加上,这类问题基本就能绝迹。