RK3588平台RS485驱动优化:8250驱动中实现GPIO方向自动切换 刚调完一块RK3588板子上的RS485通信问题顺手把折腾过程整理出来。项目背景很典型硬件工程师把RS485收发器的DE/RE方向控制脚接在了一个普通GPIO上没有用UART的RTS。一开始应用层直接通过ioctl切方向结果一上压力测试就原形毕露丢字节、帧尾被切、总线冲突全来了。最后我把方向切换下沉到8250驱动里在start_tx/stop_tx回调中实现了RS485收发的自动切换这才算彻底解决。这篇文章就把完整的思路、驱动改动点和调试经验分享出来给正在RK3568/RK3588平台上折腾RS485的朋友一个参考。这个方案适合这几类人手里有RK3568/RK3588开发板或工控板RS485方向控制脚接的是普通GPIO不想在应用层每次读写前后手动切换方向对Modbus这类协议要求的帧间隔和误码率有严格要求有一定Linux内核驱动和设备树基础想自己动手改驱动的。如果你是新手文章里也会把相关概念尽量讲透跟着做问题不大。1. RS485方向切换的本质为什么应用层切换不靠谱1.1 半双工总线上的方向信号RS485物理层是半双工结构只有A/B两根差分线总线上任意时刻只能有一个节点在发送其余节点都在接收。收发器芯片内部同时集成了发送器和接收器两套电路共享A/B引脚所以必须有一个方向控制信号决定当前是发送还是接收。这个信号一般叫DE/RE很多芯片把DE和RE合并成一根线高电平使能发送、低电平使能接收也有分开成两根的但实际使用中通常用一根GPIO同时驱动。所以RS485驱动本质上就一件事在数据发送的前后窗口内精确控制方向线的电平。方向线拉早了收发器在数据还没准备好时就把发送器打开了AB线上的毛刺可能被其他节点误判为起始位。方向线关早了更致命最后一个字节还在移位寄存器里往外挪发送器已经进入高阻态帧尾被硬生生切掉。早期应用层切方向的各种问题都是因为这两个“窗口”控制不住。1.2 应用层切换的三种典型死法在项目初期我看到不少团队的第一反应是方向控制就是一条GPIO那我在写数据之前调个ioctl拉高写完再调个ioctl拉低不就行了短距离、低波特率、业务简单时确实能跑一旦丢进真实工况问题立刻暴露。第一种是切换原子性差。read/write和GPIO操作是两次独立的系统调用中间可以被进程抢占、被信号打断。如果应用层有两个线程同时操作同一个串口方向线状态会互相踩踏发着发着方向突然变成接收数据直接丢在路上。第二种是调度不确定性。Linux是分时系统从应用层调用到GPIO真正反转中间可能隔着几十微秒甚至几毫秒的调度延迟。9600波特率下一个字节约1.04ms这种延迟勉强还能忍跑到115200时一个字节只有86.8us调度延迟稍大一点帧尾就没了。第三种是帧间隔不可控。RS485总线从发送切到接收需要时间频繁切换方向会让帧与帧之间的间隔抖动非常大。Modbus这类协议对帧间隔有明确要求RTU模式要求1.5个字符间隔以上应用层切换很难保证这个约束从机解析时经常把一帧拆成两段。1.3 驱动层自动切换的价值把方向切换下沉到8250驱动的start_tx/stop_tx回调里相当于驱动替应用层把所有细节都处理好了。应用层只管write驱动在发送数据之前拉高方向线在最后一个字节真正从移位寄存器移出之后再拉低。省掉了两个ioctl同时保证了方向切换和数据发送在驱动内部是原子操作中间不会被调度器打断。这也正是“RS485收发自动切换”的意义所在。MOXA、研华这些专业串口卡厂商在Linux驱动里早就是这么做的。不过RK3568/RK3588平台用的是通用8250驱动框架默认不会替你操作普通GPIO做方向控制所以才需要自己动手改。2. 8250驱动里现成的RS485机制em485的边界在哪里2.1 内核为RS485预留的结构Linux串口子系统在include/uapi/linux/serial.h里定义了struct serial_rs485保存着RS485模式开关、RTS极性、延时参数等信息。应用层通过TIOCSRS485/TIOCGRS485这两个ioctl可以动态开启RS485模式也可以在设备树里通过linux,rs485-enabled-at-boot-time、rs485-rts-active-low、rs485-rx-during-tx等属性静态配置。8250驱动内部这些配置最终会落到uart_port.rs485结构里然后被8250的em485子模块消费。2.2 em485的设计前提方向脚必须是RTS8250驱动在drivers/tty/serial/8250/8250_em485.c里实现了一套RS485方向自动切换机制。逻辑大致是发送前在start_tx回调里把RTS电平翻转为发送态同时把RTS从硬件自动流控中剥离出来发送过程中保持这个状态发送完后在stop_tx回调里等THRE/TEMT条件满足经过一段可配置的延时再把RTS翻回接收态。关键点在于em485操作的方向信号是串口的RTS引脚。它的设计初衷是RS485方向控制线直接复用UART的RTS这也是很多RS485开发板的经典电路RTS低电平使能发送或者经过三极管反相后驱动DE。如果你的硬件刚好是这样接的那确实不用改驱动设备树配几个属性就能跑。2.3 RK3568/RK3588平台上em485不够用的真实原因Rockchip平台的UART控制器是DesignWare APB UART对应驱动drivers/tty/serial/8250/8250_dw.c。从机制上讲em485同样适用但项目里经常遇到两种情况让em485帮不上忙。第一方向控制脚是独立GPIO压根没接到RTS上。不少硬件工程师习惯把DE/RE接到一个普通GPIO觉得这样布局自由也方便和别的功能复用。可em485从头到尾只操作RTS普通GPIO不在它的视野里。第二RTS被硬件流控或其他功能占用。比如同一路串口接了带流控的外设RTS/CTS已经被占用了自然没法让给RS485方向切换。所以如果你的硬件已经把RS485方向脚焊死在普通GPIO上指望通过配置内核参数就实现自动切换是不现实的必须改8250平台驱动。这里也提醒一句如果硬件还没定型RS485方向脚接在RTS上是最省事的方案能不改驱动就不改驱动只有方向脚已经是普通GPIO时才需要走本文的路线。3. 动刀之前设备树与8250_dw的RS485支持点梳理3.1 先看清板级UART在设备树里的形态RK3568/RK3588的普通串口在设备树里类似这样以RK3588 SDK中uart3为例具体mux组以原理图为准uart3 { status okay; pinctrl-names default; pinctrl-0 uart3m0_xfer; };对应的驱动绑定是compatible snps,dw-apb-uart由8250_dw.c匹配。内核对这类串口的配置项是CONFIG_SERIAL_8250_DW底层依赖通用的CONFIG_SERIAL_8250。我们要做的就是在这个平台驱动上增加RS485方向GPIO的解析与控制而不是去改serial_core或者8250_port这些通用层。3.2 添加自定义设备树属性我加的设备树属性很朴素一套足够用uart3 { status okay; pinctrl-names default; pinctrl-0 uart3m0_xfer; rs485-gpios gpio1 RK_PB2 GPIO_ACTIVE_HIGH; linux,rs485-enabled-at-boot-time; };rs485-gpios是方向控制GPIO用标准GPIO描述符写法。属性名可以按自己习惯来如果想更接近内核风格也可以叫rs485-de-gpios之类解析流程完全一样。linux,rs485-enabled-at-boot-time让8250核心在串口启动时就进入RS485模式否则uart_port默认是标准UART模式start_tx/stop_tx不会进入RS485分支。有个容易踩的点rs485-gpios负责让驱动知道操作哪个GPIOlinux,rs485-enabled-at-boot-time负责让uart_port真正进入RS485模式两者缺一不可。少配了后者前面的GPIO解析代码即使读到描述符8250核心也不会按RS485流程去处理。3.3 解析GPIO时的注意事项在8250_dw的probe函数dw8250_probe里增加解析逻辑static int dw8250_probe(struct platform_device *pdev) { struct dw8250_data *data; // ... >static void dw8250_setup_port(struct uart_port *p) { // ... p-ops dw8250_ops; // ... }我们只需要在这个dw8250_ops基础上把start_tx/stop_tx替换成自己的实现在原逻辑前后加上方向GPIO控制即可。8250的收发中断、FIFO管理、波特率计算全部复用只改方向控制一条线。4. 核心改动把方向GPIO嵌进start_tx/stop_tx生命周期4.1 在platform data里保存方向GPIO修改drivers/tty/serial/8250/8250_dw.c在struct dw8250_data里增加两个字段struct dw8250_data { // ...原有字段... struct gpio_desc *rs485_gpio; bool rs485_tx_active; };rs485_tx_active用来记录当前方向状态。后面在发送停止、串口关闭、系统休眠恢复时都要参考这个标志避免重复翻转或漏翻。4.2 替换start_tx发数据前先拉高DE核心逻辑如下static void dw8250_rs485_start_tx(struct uart_port *p) { struct dw8250_data *d p-private_data; if (d-rs485_gpio !d-rs485_tx_active) { gpiod_set_value(d-rs485_gpio, 1); d-rs485_tx_active true; } serial8250_do_start_tx(p); }d-rs485_tx_active这个判断很关键。8250框架在发送过程中可能多次调用start_tx比如FIFO有空位、上层又塞了数据进来。如果每次都翻转GPIO方向线上会产生毛刺接收端会看到不完整的帧头。用状态位做防抖是必须的。serial8250_do_start_tx(p)负责把环形缓冲区里的数据灌进UART FIFO这才是真正的发送动作。方向GPIO必须在填入发送数据之前先就位顺序不能反。否则数据到了FIFO、移位寄存器开始移位方向线还没高前几个bit就丢了。4.3 停发时的处理不能一停了之stop_tx的基本框架如下static void dw8250_rs485_stop_tx(struct uart_port *p) { struct dw8250_data *d p-private_data; serial8250_do_stop_tx(p); if (!d-rs485_gpio || !d-rs485_tx_active) return; if (dw8250_rs485_tx_empty(p)) dw8250_rs485_deassert(d); else mod_timer(d-rs485_timer, jiffies usecs_to_jiffies(...)); }这里最关键的判断是dw8250_rs485_tx_empty是否所有数据都真正从引脚上出去了。8250的stop_tx在发送环形缓冲区已空时就会被调用但此时UART的发送移位寄存器里可能还有最后一个字节甚至FIFO里还有没移完的数据。如果立刻拉低DE最后那一两个字节会被硬生生截断。检测发送器是否真正空掉需要读UART的线路状态寄存器LSR。LSR的bit6是TEMTTransmitter Empty表示发送移位寄存器和保持寄存器都为空static bool dw8250_rs485_tx_empty(struct uart_port *p) { return (readl(p-membase UART_LSR) UART_LSR_TEMT) ! 0; }读LSR本身没有副作用不像很多外设寄存器读一次会清状态位可以放心在中断上下文里读。如果TEMT还没置位说明最后一个字节还在移位寄存器里往外挪。这时不能死等尤其低波特率下一个字符要几百微秒甚至几毫秒。我是用内核定时器延时后再去查一次TEMT等真正空了再拉低DE。延时时长按一个字符时间估算9600波特率下10个bit约1.04ms定时器设2ms115200波特率下一个字符约86.8us设150us后续再查一次。4.4 中断路径上的清场stop_rx、shutdown和suspend除了start_tx/stop_tx还有几个地方必须同步处理。stop_rx上层在接收过程中要求停止接收时也要把方向拉回接收态否则总线一直被当前节点占着。shutdown串口close时8250会调用shutdown关闭中断和FIFO。如果不把方向GPIO复位会出现串口已经关了、DE还停在高电平的尴尬局面整个RS485总线被这个节点一直霸占其他节点收发全部瘫痪。我在实际项目中就踩过这个坑下文会详细说。suspend/resume系统休眠唤醒后GPIO状态可能被硬件复位或电源管理改变需要用rs485_tx_active重新同步一次。这三个回调里统一调用一个deassert函数static void dw8250_rs485_deassert(struct dw8250_data *d) { if (!d-rs485_tx_active) return; gpiod_set_value(d-rs485_gpio, 0); d-rs485_tx_active false; }这样一个复位动作只在状态确实为发送中时才执行避免无意义的写GPIO操作。4.5 为什么不建议在8250通用层改有人可能会问start_tx/stop_tx是所有8250架构共用的回调直接在drivers/tty/serial/8250/8250_port.c里加逻辑行不行行是行但后果是所有使用8250驱动框架的串口包括PC上的16550A、各家SoC的串口都会因为这个新功能受到影响。方向GPIO是平台相关的就应该放在8250_dw这个平台驱动里。做嵌入式驱动开发平台差异下沉到平台驱动通用逻辑留在通用层这是最基础也最重要的分层意识。5. 时序细节TEMT检测、方向保持时间与关断时机5.1 THRE不等于TEMT这是最容易翻车的地方8250寄存器里有两个容易混淆的状态位THRETransmit Holding Register Empty和TEMTTransmitter Empty。THRE表示发送保持寄存器空了CPU可以继续往FIFO写新数据但此时移位寄存器里可能还有数据在往外移。TEMT表示保持寄存器和移位寄存器都空了也就是说最后一个bit已经从TXD引脚物理上出去了。最经典的翻车现场就是驱动在停止发送时判断了THRE或者压根没判断就直接去操作方向GPIO关DE。结果最后一个字符还在移位寄存器里没完全出去方向先没了。上位机收到的帧要么丢最后一个字节要么CRC校验错误。示波器上看波形能看到UART TX的最后一个脉冲被切掉或者幅度塌掉。所以方向关断判据必须用TEMT。8250_em485里有个细节做法很值得学在RS485模式打开且没有硬件流控RTS/CTS时它利用THRE中断之后的一小段定时器延时等TEMT而不是傻乎乎轮询。我们可以在自己的实现里用同样思路定时器到期后再读一次LSR确认TEMT再关DE。5.2 方向保持时间不要踩在收发器关断时间的临界点上方向线DE从高变低之后RS485收发器内部并非瞬间完成发送放大器关断。查常见RS485收发器手册DE到发送器输出高阻的延迟一般是几十纳秒到几百纳秒。看起来很短但如果你在TEMT刚置位的瞬间就拉低DETXD上最后一位的下降沿可能还没有完全驱动到AB线上接收端看到的帧尾会轻微变形。稳妥的做法是等TEMT置位后再额外保留一小段方向保持时间。这个时间可以按一个bit时间估算9600波特率约104us115200波特率约8.7us。我实际使用时习惯加50到200us的余量用hrtimer或普通内核定时器做延时都行。5.3 实时性够不够忙等与定时器的取舍等最后一个字节发送完实际有两条实现路径。一条是忙等在stop_tx里while循环读LSR直到TEMT为1。代码最简单判断最及时但低波特率下会霸占CPU几十微秒甚至几毫秒把内核线程和中断全部拖住多任务系统里不可接受。另一条是定时器加状态机。先关闭THRI中断然后设置一个hrtimer延时约一个字符时间后回调检查TEMT确认空了再关DE。这种方式避免了长时间占CPU代价是关断动作带了几十微秒的调度误差。但对于RS485通信来说几十微秒的误差完全在可接受范围内。我实际用的就是第二条路线。在定时器回调里也要再次检查rs485_tx_active标志因为回调被调度时串口可能已经被close或者方向已经被其他路径改过防止对已经不在发送态的GPIO做无意义操作。5.4 关断时机对协议层的影响以Modbus RTU为例一帧数据结束之后从机需要等待3.5个字符时间的静默期再开始应答。如果主机发完帧后立刻关DE从机能不能正确解析取决于AB线上的差分电平跟DE电平本身关系不大。只要帧尾完整DE稍微晚关一点不仅无碍反而能让总线电平更稳定。真正怕的是DE早关导致帧尾被截断。所以参数整定的原则是宁可让DE晚关几十微秒也不要早关一微秒。实测下来发完一帧后方向保持时间放在0.5ms到2ms之间都不会影响正常通信。在RS485组网场景里多节点共用总线时方向保持时间也能避免频繁的方向切换给总线引入额外噪声。6. 实测验证与踩坑记录6.1 验证方法回环、压力测试和逻辑分析仪驱动改完后不要急着接RS485总线测试按顺序来先用两根杜邦线把该UART的TX接到RX做本地回环测试确认发送和接收链路本身没问题。接上RS485收发器模块或者直接挂到RS485总线上用PC通过USB转RS485模块收发跑数据压力测试。抓波形。用逻辑分析仪同时抓UART TX引脚和方向控制GPIO确认DE拉高在数据之前DE拉低在最后一个bit离开TXD之后。下面这组命令是我常用的板端循环发送测试while true; do echo rs485_test_1234567890 /dev/ttyS3; sleep 0.1; done跑十几万包不丢数据才算基本合格。6.2 踩坑一发送FIFO半满时stop_tx被提前调用初版实现里我把拉低DE的动作直接放在stop_tx回调中结果出现一个诡异现象发送大包时帧中间偶尔会出现几十微秒的DE低电平毛刺PC接收端把一帧拆成两段甚至出现乱码。排查过程是加printk打印调用栈发现stop_tx并不是只在环形缓冲区空了才被调用。8250中断处理里如果FIFO暂时填不满比如DMA还在搬运数据或者上层调用uart_flush之类的接口都可能触发stop_tx。方向线不能跟着stop_tx的每次调用走必须有自己的状态机。最终方案就是前面讲的先判断rs485_tx_active只有原本处于发送态时才走关闭流程。关闭前必须看到TEMT否则挂定时器。如果下一次中断又触发start_tx方向线能无缝保持住不会出现毛刺。6.3 踩坑二串口close之后DE还霸占着总线压力测试后随手关掉测试程序发现整个RS485总线上所有节点都收不到数据了。用万用表一量DE引脚还稳在高电平。原因很简单shutdown回调被触发时8250核心不会主动去拉方向线。如果驱动只在start_tx/stop_tx里操作GPIOclose之后方向线就会停留在最近一次发送的状态。解决方式前面已经提过在shutdown和resume路径统一调用deassert函数把方向恢复到接收态。这个坑在真实项目里很容易漏掉因为只有在串口设备被close之后才会暴露而很多测试脚本从头到尾都占着串口不释放。6.4 踩坑三方向GPIO挂在I2C扩展器上驱动直接睡死这也是一个让我印象深刻的坑。方向GPIO最初设计时放在了一块PCA9535I2C GPIO扩展器上驱动在start_tx里调用gpiod_set_value后测试程序直接卡死内核打印调度器相关报错。原因在于gpiod_set_value对非MMIO型GPIO会走慢速路径在中断上下文或者持有锁的环境下会触发睡眠直接导致内核调度器异常甚至死锁。而start_tx是在UART发送中断上下文中被调用的绝不允许睡眠。这个问题的本质是硬件设计和驱动程序打架。最终让硬件工程师改版把方向控制脚接到了SoC的普通GPIO上。如果因为某些原因必须使用非MMIO GPIO只能通过工作队列异步操作GPIO但这样会让收发时序更不可控仍然建议优先改硬件。顺便提一句RS485接口的EMC电路设计包括TVS管、共模电感、终端匹配电阻在这种高速方向切换场景下别省否则波形毛刺和总线干扰会掩盖掉驱动层的正确性。6.5 实测数据与最终表现修正完上述问题后我整理了一组典型的实测结果。测试条件波特率1152008N1两块板子背靠背对发每包64字节循环30万包。场景发送总包数接收错误包数错误率应用层ioctl切换初版30000约120包0.4%驱动层切换无方向保持30000约35包0.11%驱动层切换带TEMT保持时间300000包0%后面两种实现的差距主要来自方向关断时机和DE毛刺这两处。最终版本在30万包压力测试中没有出现丢包或错包。如果把波特率降到9600帧间隔会更宽裕效果同样稳定。最后分享一个排查RS485问题非常好用的小技巧。抓波形时把逻辑分析仪的采样率设为UART波特率的8倍以上同时接三根线UART TX、收发器输出侧AB差分线中的一根、方向控制GPIO。三者对齐后一眼就能看出DE是否早关、帧尾是否被截断、AB线上有没有多余毛刺。很多所谓的偶发丢包用这个组合很快就能定位。如果没有逻辑分析仪也可以在驱动里加trace_printk每次翻转方向GPIO时打一条时间戳拿到的时序数据同样能辅助分析。