调试RK3588串口RS485的时候,最让人抓狂的往往不是“收不到数据”,而是数据“偶尔被吃掉最后几个字节”。我们有一块边缘计算测试板,跑Modbus RTU从站协议,主机发请求过来,从站正常应答,但主机那边时不时报超时重试。一开始怀疑是应用层代码问题,后来用示波器抓差分总线,发现响应帧的停止位被切掉了将近半个位宽——罪魁祸首是总线方向切换得太早,最后几个bit还没真正离开485收发器,DE/RE信号就已经被拉回接收方向了。
这篇文章记录的是我从应用层一路追到内核8250驱动,最后通过修改驱动层RS485停止发送逻辑,把“自动收发控制”做到中断级精度的完整过程。内容涉及RK3588平台串口控制器的工作原理、THRE/TEMT两个状态位的本质区别、8250驱动里RS485钩子的改造方法,以及实测波形和排错思路。适合正在做RK3588工控板、边缘计算网关,或者被RS485丢尾巴、首字节畸变折磨的嵌入式Linux工程师参考。
1. 停发命令后丢了尾巴:这不是应用层sleep能解决的问题
1.1 RS485的半双工物理约束:方向切换是收发器的“开关”
RS485和RS232最大的不同在于物理层。RS232是单端信号,发送和接收各自独立引脚,可以同时收发;RS485则是一对差分线,同一时刻只能有一个设备驱动总线,所以总线上的每个节点都必须在“发送模式”和“接收模式”之间切换。这个切换动作由收发器芯片(比如常见的SP3485、MAX485)上的DE和RE引脚控制:DE拉高进入发送模式,RE拉低进入接收模式。很多板子上干脆把DE和RE短接在一起,用一根GPIO控制,高电平发送、低电平接收。
问题就出在这个“切换时机”上。CPU侧的UART控制器把数据送到485收发器,收发器再把差分信号驱动到AB线上。你从应用层调用write(),数据经过Linux tty层、串口驱动、FIFO、移位寄存器,最后从TXD引脚出来,再经过收发器上总线,这一段路径有明确的物理时序。如果DE在“数据最后一个bit还没完全走完”的时候就拉低,等于把正在说话的人的话筒突然关掉——总线上最后一位停止位直接消失,对端收完一帧后发现停止位异常,于是报告帧错误或CRC错误,重试机制就会被反复触发。
1.2 用户态GPIO切换方案的三个硬伤
很多项目的第一版方案很简单:应用层在write()之后手动把方向GPIO拉低,或者用ioctl操作串口RTS引脚。实测下来,这种方式在低速、单主机、低负载的demo里勉强能用,一上压测就暴露问题。
第一个硬伤是“不知道线路真正空闲的时刻”。应用层write()返回只代表数据进了内核缓冲区,不代表已经发到线上。有人用tcdrain()等tty层缓冲排空,但tcdrain等待的颗粒度也和实际硬件状态有偏差,而且它是否等待移位寄存器完全清空,在不同内核版本、不同DMA配置下行为并不一致。用sleep基本靠猜,加少了丢字节,加多了总线响应慢得离谱。
第二个硬伤是多线程/多进程场景下状态机混乱。RS485是半双工,如果有两个进程同时读写,一个在发送时另一个也在发送,谁去切DE?谁负责切回来接收?用户态加锁能解决一部分,但应用层根本不知道内核什么时候真正完成了发送,锁加在哪里都别扭。
第三个硬伤是DMA模式下更难拿捏。RK3588的串口经常配合DMA使用,write()返回的时机和DMA搬运完成、FIFO清空、移位寄存器发送完毕完全是三个时间点,用户态sleep根本对齐不了。这个问题的根源不在应用层,而在驱动层对“发送完成”的定义。要根治,必须让方向切换跟着硬件的真实状态走。
2. RK3588的8250串口“发完”的真正判据:THRE、TEMT和DMA完成
2.1 FIFO、移位寄存器与LSR状态位
RK3588的串口控制器兼容16550A,发送路径上其实有两级结构:CPU或DMA先把数据写入发送FIFO,UART硬件再把FIFO里最老的一个字节装载到发送移位寄存器,然后按照波特率一位一位地把这10个bit(起始位+8数据位+停止位)移出到TXD引脚。
这里有两个容易混淆的状态位,都挂在线路状态寄存器LSR里。THRE(Bit5)表示“发送保持寄存器空”,在FIFO模式下可以理解为“FIFO还有空间,你可以继续写数据”;TEMT(Bit6)表示“发送保持寄存器和移位寄存器都空”,这才是“整条发送链路真的空了”。
打个比方:FIFO是候车厅,移位寄存器是正在驶出站台的列车。THRE等于候车厅里没有滞留旅客了,但列车还没完全离站;TEMT等于最后一班列车彻底开出站台。你要在列车没完全离站时就关站台门,就有乘客被夹住——这就是停止位被截断的机理。
2.2 8250驱动默认的stop_tx时机,以及它为什么不总等于“线上发完”
8250驱动的中断处理路径大致是:UART触发中断,驱动通过IIR寄存器判断中断类型,如果是发送空中断(THRE中断),就调用serial8250_tx_chars(),从发送环形缓冲区继续往FIFO灌数据。当驱动发现发送缓冲区已空,会调用__stop_tx()去停止发送流程。
问题在于,不同内核版本对“发送缓冲区已空”的判断颗粒度并不完全相同。有的版本只检查了FIFO能不能继续接数据,并没有等待TEMT置位就调用了stop_tx相关回调。也就是说,驱动认为“发完”的时刻,可能线上还有数据正在移位输出。
在RS485场景下,8250驱动其实预留了钩子:uart_port结构里维护rs485_start_tx和rs485_stop_tx两个函数指针,8250_em485.c这个文件就是专门处理RS485方向切换的。默认实现下,它会在开始发送时拉高RTS,停止发送时拉低RTS。如果板子的DE/RE接在串口RTS引脚上,理论上这套机制是能工作的。但问题的关键是:默认实现里“停止发送”的触发点是否严格等到TEMT置位?我们SDK自带的8250驱动在实测中并没有做到这一点,结果是DE提前释放,线上停止位被切掉。这也是为什么很多人直接用标准RS485驱动,仍然会出现偶发丢字节。
2.3 DMA回调里的第二个坑:搬运完成不等于线路完成
RK3588的SDK里,串口经常配置成DMA模式来降低CPU负载。DMA的发送完成回调在DMA控制器把数据从内存搬到FIFO之后就会触发,这个时刻FIFO里可能还有一堆数据没被移位寄存器取走,更别说最后一位还没出TXD。
有人会想:那我干脆把RS485方向切换放在DMA发送完成回调里,是不是就准了?实测结果同样不稳。DMA完成回调只是“搬运完成”,不是“线路发送完成”。在DMA模式下做RS485方向控制,正确思路仍然要回到LSR的TEMT位,或者用足够精确的定时器补足FIFO剩余数据全部移出的时间,然后才能安全切换DE。这段认识是我在这个项目里踩得最深的一个坑,也直接决定了后面改造的方向。
3. 改造8250驱动:让DE/RE跟着真实发送完成时刻走
3.1 先看清楚DE/RE接在哪:标准RTS路径和独立GPIO路径
动手改驱动之前,必须先确认板子原理图上DE/RE是怎么连的。如果DE/RE直接连到UART的RTS引脚,那标准8250_em485.c的框架就能用,你只是需要给它配置正确的RS485属性和延时参数。但我们这块板子的硬件设计偏偏把DE/RE接到了普通GPIO,没有复用RTS引脚。引脚已经由pinctrl配置成了GPIO功能,软件上没办法通过标准RTS信号去控制方向。这种情况下,必须让8250驱动“认识”这个GPIO,在RS485的启动/停止发送钩子里主动翻转它。
如果你的板子也是这种接法,第一步是在驱动里申请这个GPIO,拿到gpio_desc,并且把它保存到串口驱动的私有数据里。申请GPIO不要用老的gpio_request+gpio_set_value接口,直接用 gpiod_get() 这套,设备树里配 gpio-controller 属性,更干净也更容易在驱动里做方向管理。
3.2 补丁落点一:解析RS485控制GPIO
改造不需要大动干戈,重点是在RS485配置入口追加一段解析逻辑。我们是在8250的probe路径里,找到解析RS485属性的位置,在后面增加对自定义属性 rs485-dir-gpios 的解析。得到gpio_desc后设置默认输出为接收电平,保证上电时不会占用总线。这段逻辑用到的设备树属性不是Linux标准属性,所以不同SDK命名可能不一样。建议参考你们SDK内核里8250_of.c实际支持的属性列表,再决定字段名。
/* 在驱动私有数据结构中维护的字段 */ struct rs485_dir_priv { struct gpio_desc *dir_gpio; /* DE/RE控制脚 */ bool dir_invert; /* 极性是否反转 */ u32 stop_delay_us; /* 发送结束后的保护延时 */ u32 start_delay_us; /* 发送开始前的建立延时 */ };解析完GPIO之后,把它通过port->private_data挂在uart_port上,这样start_tx/stop_tx回调里都能访问到。
3.3 补丁落点二:stop_tx里等待TEMT并补“尾巴延时”
这是整个改造里最关键的一步。原来RS485的停止发送回调里,只是简单地把方向引脚拉回接收电平;我的做法是把这个回调改成两段逻辑:
- 先检查LSR寄存器的TEMT位。如果TEMT还没置位,说明移位寄存器里还有数据在移出,此时绝对不能切方向。用轮询方式等待TEMT置位,但必须加超时保护,防止硬件异常导致死循环。
- TEMT置位后再追加一个保护延时。为什么要延?因为TEMT置位只是说移位寄存器空了,但TXD引脚上最后一个停止位可能还在电平保持阶段,尤其是长线传输时,485收发器的关断还有延迟。不补这段尾巴延时,停止位依然有被削掉的风险。
延时值怎么定,后面会专门讲。
static void rs485_stop_tx_custom(struct uart_port *port) { struct uart_8250_port *up = container_of(port, struct uart_8250_port, port); struct rs485_dir_priv *priv = port->private_data; unsigned long flags; int timeout = 10000; spin_lock_irqsave(&port->lock, flags); /* 1. 等待移位寄存器彻底空,必要时poll LSR的TEMT */ while (!(serial_in(up, UART_LSR) & UART_LSR_TEMT) && timeout--) { cpu_relax(); } /* 2. 再补一段尾巴延时,保护停止位完整走完 */ if (priv->stop_delay_us) udelay(priv->stop_delay_us); /* 3. 方向拉回接收 */ if (priv->dir_gpio) { gpiod_set_value(priv->dir_gpio, priv->dir_invert ? 1 : 0); } else { serial8250_rts_down(port); } spin_unlock_irqrestore(&port->lock, flags); }注意上面这段只是示意,直接放在硬中断上下文里用udelay并不可取,尤其是低波特率下延时可能到毫秒级,会严重劣化中断实时性。更稳妥的做法是把等待TEMT和延时放到hrtimer回调里执行,中断里只负责启动定时器。文章后面会专门对比这两种做法的取舍。
3.4 补丁落点三:start_tx里的“发送提前量”
解决了发送结束侧的停止位问题之后,压测时又暴露出一个反向问题:从站偶发收到“请求但完全不回应”,抓波形发现第一字节的起始位有畸变。原因是DE拉高之后,485收发器的驱动器使能到总线真正进入稳定驱动状态,有一段建立时间。如果UART在DE刚拉高的一瞬间就把第一个起始位丢上总线,收发器还没完全进入发送态,起始位的前沿就被削弱了,对端采样失败。
解决方式和停止侧对称:start_tx回调里先拉高DE,然后延迟一小段建立时间,再让数据真正开始发送。这个“发送提前量”不同收发器芯片差异很大,要看数据手册,通常在1us到10us之间。我们的板子实测下来取5us足够,再大会浪费总线时间。
3.5 忙等还是hrtimer:两种延时的取舍
这里必须做一个选择:延时到底是在中断上下文里忙等,还是用定时器异步完成?
| 实现方式 | 精度 | 中断占用 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| udelay/ndelay忙等 | 与CPU负载相关,但微秒级够用 | 高,低波特率下可能阻塞几十到几百us | 低 | 波特率较高、系统任务少、快速原型验证 |
| hrtimer异步 | 微秒级,不受中断上下文限制 | 低,不长时间阻塞 | 中 | 量产设备、低波特率、对实时性有要求 |
| delayed_work | 调度延迟不稳定 | 低 | 低 | 不建议用于精确方向切换 |
我的最终选择是hrtimer。8250_em485.c本身就用了hrtimer来处理RS485延时,所以这个路径在8250驱动里是现成的。改造时把“等待TEMT”和“切换GPIO”放进hrtimer回调,start_tx/stop_tx只是按需启动定时器,定时器到期后执行真正的方向切换。这样做既不阻塞串口中断,时序也稳定。
延时值的计算其实很简单。位时间 = 1秒 / 波特率。115200波特率下,一位约8.68us,一个完整字符帧约86.8us;9600波特率下,一位约104.2us,一帧约1.04ms。停止侧保护延时我通常从半个位时间开始,用示波器盯着停止位和DE下降沿的相对位置,不够就加,有余量就减。实测115200下最终定格在50us;9600下用到150us左右。起始侧的建立延时按收发器手册来,我们板子的收发器建立时间约3us,实际配置5us留了余量。
4. 设备树、编译烧录和运行时验证
4.1 设备树属性:标准RS485属性与自定义GPIO属性
RK3588适配Linux的8250驱动时,RS485相关设备树属性在不同SDK里有所差异,下面只列出我们验证过的写法。如果你的SDK版本不同,务必先翻drivers/tty/serial/8250/8250_of.c,看驱动实际解析哪些属性,否则配了也没效果。
&uart0 { pinctrl-names = "default"; pinctrl-0 = <&uart0_xfer>; status = "okay"; linux,rs485-enabled-at-boot-time; rs485-rts-active-low; rs485-dir-gpios = <&gpio3 RK_PB0 GPIO_ACTIVE_HIGH>; };linux,rs485-enabled-at-boot-time 让驱动在启动阶段就使能RS485模式,rs485-rts-active-low 控制极性,rs485-dir-gpios 是我们自己加的字段,用来把独立GPIO传给驱动。如果你的板子DE/RE接的是RTS引脚,就只需要前两个属性,不用自定义GPIO字段。驱动代码里还需要修改RS485配置回调,把这个GPIO的解析结果存好,并确保默认处于接收方向。
RK3588的UART节点别名也要确认,默认情况下uart0对应/dev/ttyS0,但具体编号由板级dts里的aliases决定。调试时用 stty -F /dev/ttyS0 115200 raw 确认端口可用。
4.2 编译内核/DTB的通用流程与烧录注意
RK3588的SDK一般用vendor内核,编译入口通常是SDK脚本,但原理都一样:先配好交叉编译环境,再编内核镜像和设备树。通用命令如下:
export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu- make rockchip_linux_defconfig make -j$(nproc) Image dtbs编译完成后需要把新的boot.img打包进设备。RK3588的DTB在部分SDK里是和内核镜像一起打包到boot分区,修改dts后编译产生的dtb要和Image一起打包。烧录前先备份原分区,烧错boot分区不会破坏用户数据,但会导致系统起不来,所以务必确认打包脚本里指定的分区名。
如果只想快速验证驱动改动,可以把8250相关源码编成模块来替换,但8250_em485.c在很多SDK里是直接编译进内核的,临时改成模块还涉及内核配置的修改,反而容易引入其他问题。我在实际调试中直接用完整编内核的方式,多花点编译时间,但验证路径更干净。
4.3 验证用例清单与示波器测量方法
驱动改完后,光是跑通收发还不够,要建立一套可复现的验证用例。我的验证清单大致是这样:
- 短接线回环测试:TX接RX,确认基本收发无丢字节。
- 双机对测:两台RK3588板子通过RS485互连,分别跑主站和从站,循环收发大包数据,观察是否丢字节。
- 协议层压测:跑Modbus RTU主从,反复读写保持寄存器,统计超时重试次数。
- 波形验证:示波器CH1接TXD引脚,CH2接DE/RE引脚,使用显著的触发沿,分别抓发送起始阶段和结束阶段的时序。
波形验证有两个关键观察点。第一个是DE下降沿和最后一个停止位结束沿之间的相对位置,正常情况下DE下降沿必须落在停止位“结束之后”,最好留有几十ns以上余量;第二个是起始阶段DE上升沿到第一个起始位下降沿之间的间隔,必须大于收发器的建立时间。差分线上的波形也要测,用差分探头看AB之间,因为485收发器输出相对TXD输入有延迟,只看TXD会给你一个过于乐观的假象。
5. 压测中真实遇到的两个诡异问题:排查链路记录
5.1 停止位被切掉半个位,对端报帧错误
这是最初的本体问题。排查链路值得完整记录一遍,因为每走一步都在排除一个错误的猜测。
先从应用层查。以为是sleep时间不够,把方向切换前的等待从5ms一路加到50ms,确实有效果,超时率从很高降到偶尔,但总线响应吞吐惨不忍睹,而且不同负载下表现不稳定。这一步得出一个结论:这是时序问题,不是逻辑问题,不能靠用户态无脑加sleep压,必须精确量化。
拿起示波器抓DE和TXD。发现每次发送结束,DE下降沿都比TXD最后一个字符的停止位结束沿“早到”大约半个位宽,但时间上不固定。这说明驱动发送完成判据本身有误——它在系统认为“该停止发送”的时候,线上确实还有最后半个字节没走完。然后去读8250驱动里stop_tx相关代码,发现SDK内核版本并没有在RS485停止发送路径里等TEMT,而是FIFO空了就直接切方向。这就是根因。
把TEMT等待加进去之后,波形上DE下降沿移到停止位结束之后,帧错误消失。但随着温度、电压变化,又出现偶发的超时,进一步检查发现是长线传输时485收发器关断需要一点额外时间,于是在TEMT后面再补尾巴延时,最终用示波器标定出50us这个稳妥值。整个过程核心就一句话:不要相信“系统说发完了”,要相信“示波器上线路上真的完了”。
5.2 首字节偶发畸变,主机侧报CRC错误
停止位问题解决后,另一个诡异现象浮现了:数据帧完整无缺,但主机那边偶尔报CRC错误。抓波形看到,帧结构都对,就是第一字节的起始位前沿“塌”了一截,导致采样偏移,最终影响整个数据采样窗口。
这个问题的本质是DE的建立时序不对。开始时我们的start_tx逻辑是拉高DE的同时立刻灌数据,等于站台门刚开,列车就冲出去,收发器驱动器建立过程还没完成。把DE拉高和“开始发送”之间加5us建立延时后,波形起始位前沿变得完整锐利,CRC错误也随之消失。这一步的经验是:RS485方向切换不是只关心“结束侧”的尾巴时间,“开始侧”的建立时间同样关键,两个方向都要在示波器上看清楚。
换收发器道之后还注意过一点:DE/RE如果分得很散,有的收发器在DE拉低后RE才真正使能,中间可能短暂处于“既不发也不收”的高阻窗口。如果这个窗口发生在数据帧结束的瞬间,可能引入总线电平抖动。所以条件允许的情况下,尽量选DE和RE内部联动、短接控制的收发器,或者驱动里在切换前后加必要的死区。
5.3 排查过程中的两个关键认知
第一个认知是:凡是“偶尔丢字节”“偶尔CRC错误”这类问题时,不要先怀疑协议和电路,先用示波器把时序钉死。第二个认知是:驱动层方向切换和协议层帧间隔要放在一起考虑。Modbus RTU要求帧间隔至少3.5个字符时间,如果驱动切换引入的静默时间太长,会影响从站对帧的切割判断;太短又会吞掉停止位。这个平衡只能在实际总线上压测出来,不是光靠文档计算能搞定的。
6. 硬件自动收发、标准框架和驱动改造的取舍
6.1 硬件自动方向收发器:省心,但不等于万能
市面上有些485收发器芯片宣称“自动方向控制”,典型的是利用TXD空闲电平来推断发送结束,自动切换方向,软件完全不用管。这类芯片在简单的点对点应用里非常省心,我们的第二块验证板上试过,确实不用改驱动就能跑通Modbus。
但它的短板也很明显。首先,它对“TXD长时间维持低电平”这种异常状态比较敏感,一旦出现字节内低电平持续过长的情况,可能误判为接收方向切换,导致数据撕裂。其次,在多主总线或强干扰环境下,它缺乏对总线冲突状态的感知,方向切换时机可能不够精准。第三,这类芯片通常不支持特别低的波特率或者超长帧,适用场景有边界。做量产选型时,不要因为“硬件自动”就完全放弃驱动方案,尤其是总线负载重、帧间隔要求严格的场景。
6.2 标准serial_rs485的delay字段为什么不够用
Linux的serial_rs485结构里确实预留了delay_rts_before_send和delay_rts_after_send这两个字段,8250_em485.c通过hrtimer来实现延时切RTS。理论上你可以通过TIOCSRS485 ioctl直接设置发送前/发送后延时,不需要改驱动。
实测发现,在我们内核版本里,这两个字段的接口粒度不够理想,按毫秒为最小步进来算,115200波特率下一位只有8.68us,却只能以1ms步进,这等于把几微秒的事情放到毫秒级去做,总线利用率被浪费得厉害。而且这个机制只操作RTS信号,如果你的DE/RE接在普通GPIO上,它完全不知道要动哪根线。所以“标准RTS路径”和“独立GPIO路径”要分开看:前者优先用标准字段配置,后者才需要驱动改造。
6.3 我们的最终方案和适用边界
最终量产方案保留了内核驱动里的RS485钩子改造,由驱动自动管理DE/RE切换,应用层完全无感。同时通过设备树配置了方向极性和延时参数,换板卡时不需要重新编译驱动,只改dts即可。这套方案在我们的测试环境下已经稳定运行数月,波形上看停止位完整,CRC错误清零。
适用范围上要清楚:这套改造解决的是“8250驱动在RS485模式下方向切换时机不准”的问题,它不会帮你解决硬件上收发器选型错误、总线端接电阻匹配不良、地电位差过大这类物理层问题。如果你的板子连波形都没抓过,先别急着改驱动——先量出问题在哪,再动手。驱动层能改的是时序精度,物理层的坑还得靠示波器和电路设计来填。
最后分享一点个人体会:调这类RS485问题,最忌讳的是看到问题就加sleep、加延时、加重试,然后祈祷它消失。正确做法永远是先量化,再定位,最后改代码。我这次如果不先用示波器确认是停止位被杀,很可能会在应用层无脑调参调一星期还找不着北。RK3588平台串口驱动本身比较标准,8250驱动的RS485框架也足够成熟,只要你理解了TEMT、DE建立时间、尾巴延时这三个关键点,方向切换做到亚微秒级精度一点都不难。另外提一句,这套改造思路换到其他用8250驱动的平台同样适用,核心判据不变,变的只是设备树字段名和GPIO编号而已。