半双工的 RS-485 只有一对差分线,发送和接收不能在时间上重叠,收发器必须在两者之间来回切换。这个切换动作看似简单,落到 Linux 上却经常出问题:首字节丢失、末字节被截断、收到自己发出的报文、单帧长报文偶发 CRC 错误,绝大多数都来自"方向切换的时机没有和 UART 的发送时序对齐"。把这件事从应用层挪到内核驱动层,是消除这类时序抖动最直接的办法。
本文给出 Linux 下用内核 RS-485 接口配置半双工方向控制的完整操作过程:先确认驱动是否支持,再用 TIOCSRS485 写入配置,然后回读核对驱动实际接受的参数,最后在总线和协议层验证。文中命令与代码可直接复现,代码里的结构体布局与 ioctl 返回值语义均按内核 uapi 头文件编写。
一、适用场景:三种方向控制方案,只有一种用得上本文
RS-485 收发器有发送使能(DE)和接收使能(/RE)两个控制脚,谁负责切换这两个脚,决定了软件要不要介入:
- 方案 A:收发器方向脚接到 UART 的 RTS。发送前把 RTS 置为有效、发送完再释放。这是本文要解决的情形,也是工控主板、多串口卡上最常见的做法。
- 方案 B:板上自带自动方向电路。用发送数据本身(或专用自动方向收发器)检测后自动切换,软件按普通串口收发即可。此时不应再开启内核 RS-485 模式,否则两套机制会互相干扰。
- 方案 C:用户态程序手动翻 RTS。通过
TIOCMBIS/TIOCMBIC操作TIOCM_RTS。时序取决于进程调度,延迟通常在毫秒量级,波特率一高就不可靠,只适合低速场合。
判断依据很具体:查板卡原理图或手册,确认 UART 的 RTS 引脚是否连到收发器的 DE/RE;如果连了,且所用串口驱动实现了 RS-485 配置,就可以用本文流程。反过来,如果板上已经是自动方向电路,或者 RTS 根本没接出去,本文的配置不会生效。
二、前置条件
| 项目 | 要求与检查方法 |
|---|---|
| 硬件接线 | RTS 连到 DE/RE,且清楚是否经过反相器;同时确认该 RTS 没有被设计成硬件流控脚 |
| 驱动支持 | 对目标 tty 执行 TIOCGRS485 能成功返回(第三步的探测代码)。返回 ENOTTY 说明这条 tty 上根本没有实现该 ioctl |
| 访问权限 | /dev/ttyS* 通常是 root:dialout、权限 crw-rw----;非 root 账号需加入 dialout 组,否则 open() 直接返回 Permission denied |
| 总线侧 | 两端 120 Ω 终端电阻、A/B 接线正确、共地或屏蔽层处理到位。总线电气问题不要和方向切换问题混在一起排查 |
| 工具 | gcc 或 python3;强烈建议备一台示波器或逻辑分析仪,方向切换最终要看波形确认 |
三、第一步:确认端口与驱动支持情况
先确定设备名,并看内核是怎么枚举这个串口的:
ls -l /dev/ttyS* /dev/ttyUSB*
dmesg | grep -iE "ttyS|8250|uart" | tail -20
设备名会随插槽、枚举顺序变化,量产设备建议用 /dev/serial/by-path/ 下的固定路径,避免重启后 ttyS 编号漂移。
接着用 TIOCGRS485 做一次探测。这一步的目的不是读取配置,而是确认"这条 tty 上有没有这个 ioctl",第三步的完整代码可以直接拿来跑:能成功返回就说明驱动实现了 RS-485 配置;返回 ENOTTY(errno 25,"Inappropriate ioctl for device")说明不支持。需要提醒的是,pty(/dev/pts/N)以及部分 USB 转串口适配器通常不支持该 ioctl,这类设备需要改用方案 B/C,或换成实现了该接口的驱动/适配器。
有 root 权限时,还可以用 cat /proc/tty/driver/serial 看 UART 类型、端口地址与中断,确认目标端口确实是真实 UART 而不是虚拟端口。
四、第二步:固定帧格式,并关掉所有会改写字节流的选项
RS-485 传的是二进制帧,行规程任何"贴心的转换"都是灾难。先把端口设成 raw 模式:
stty -F /dev/ttyS0 raw -echo -echoe -echok -crtscts 115200 cs8 -cstopb -parenb
stty -F /dev/ttyS0 -a
raw:关闭回车换行转换、特殊字符解释、信号生成等行规程处理。不设这一项,0x0D、0x11、0x13之类的字节会被改写或吞掉。-crtscts:必须关闭硬件流控。RTS 已被用作方向控制脚,再交给流控逻辑抢占,方向切换时序就无从保证。- 帧格式:命令里的
115200 cs8 -cstopb -parenb就是 8N1(8 数据位、无校验、1 停止位)。Modbus RTU 现场常用 8E1(偶校验),具体以从站的实际配置为准,改帧格式时只动parenb/parodd即可。
注意 stty 没有 RS-485 相关选项,标准内核 RS-485 模式的方向控制只能通过 ioctl 完成(板上自带自动方向电路的方案不在此列),这也是很多人卡住的地方。
五、第三步:用 TIOCSRS485 写入方向控制配置
C 语言写法最直接,编译成一个小工具即可:
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/serial.h>
int main(int argc, char **argv)
{
int fd = open(argv[1], O_RDWR | O_NOCTTY);
if (fd < 0) { perror("open"); return 1; }
struct serial_rs485 conf;
memset(&conf, 0, sizeof(conf));
if (ioctl(fd, TIOCGRS485, &conf) < 0) { /* 探测驱动是否支持 */
perror("TIOCGRS485");
return 2;
}
printf("before: flags=0x%08x before=%ums after=%ums\n", conf.flags,
conf.delay_rts_before_send, conf.delay_rts_after_send);
memset(&conf, 0, sizeof(conf));
conf.flags = SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND;
conf.delay_rts_before_send = 1; /* 单位:毫秒 */
conf.delay_rts_after_send = 1; /* 单位:毫秒 */
if (ioctl(fd, TIOCSRS485, &conf) < 0) {
perror("TIOCSRS485");
return 3;
}
/* 成功时 conf 里已被填入驱动实际生效的配置 */
printf("after : flags=0x%08x before=%ums after=%ums\n", conf.flags,
conf.delay_rts_before_send, conf.delay_rts_after_send);
close(fd);
return 0;
}
gcc -O2 -o rs485cfg rs485cfg.c
./rs485cfg /dev/ttyS0
习惯用 Python 的话,用 fcntl.ioctl 加 struct 打包同样可行。结构体总长 32 字节,字段顺序为 flags、delay_rts_before_send、delay_rts_after_send 再加 20 字节的填充/地址联合体:
import fcntl, struct
TIOCGRS485 = 0x542E
TIOCSRS485 = 0x542F
FMT = '<III5I' # flags, delay_before, delay_after, padding[5]
SIZE = struct.calcsize(FMT) # 32
def get_rs485(fd):
buf = bytearray(SIZE)
fcntl.ioctl(fd, TIOCGRS485, buf, True)
return struct.unpack_from('<III', buf, 0)
def set_rs485(fd, flags, before_ms, after_ms):
buf = bytearray(struct.pack(FMT, flags, before_ms, after_ms, 0, 0, 0, 0, 0))
fcntl.ioctl(fd, TIOCSRS485, buf, True) # True 表示允许内核回写生效值
return struct.unpack_from('<III', buf, 0)
flags = 0x1 | 0x2 # ENABLED | RTS_ON_SEND
print(set_rs485(fd, flags, 1, 1))
各字段的具体含义
| 字段 / 标志位 | 含义 | 使用要点 |
|---|---|---|
SER_RS485_ENABLED(bit 0) | 启用 RS-485 模式 | 不清零其他位的基座,所有配置都建立在它之上 |
SER_RS485_RTS_ON_SEND(bit 1) | 发送期间 RTS 的逻辑电平为 1 | 收发器 DE 高有效且 RTS 直连 DE 时置位;若板上加了反相器则要清掉 |
SER_RS485_RTS_AFTER_SEND(bit 2) | 发送结束后 RTS 的逻辑电平为 1 | 半双工通常清掉,让总线回到接收状态 |
SER_RS485_RX_DURING_TX(bit 4) | 发送期间仍然接收 | 用于四线全双工或需要本地回显的场合;半双工两线一般不开,否则会读到自己的数据 |
SER_RS485_TERMINATE_BUS(bit 5) | 使能总线终端电阻 | 仅在收发器/板卡带可切换终端时才有效 |
SER_RS485_ADDRB、ADDR_RECV、ADDR_DEST(bit 6/7/8) | 多点寻址模式与收发地址 | 部分驱动支持,用于硬件地址过滤,不是所有平台都实现 |
SER_RS485_MODE_RS422(bit 9) | 切换为 RS-422 模式 | 仅在需要四线制时使用 |
delay_rts_before_send | 拉高 RTS 到开始发送之间的延时,单位毫秒 | 给收发器留出使能时间;设 0 时高波特率下容易丢首字节 |
delay_rts_after_send | 发送结束后到释放 RTS 的延时,单位毫秒 | 等最后一个字节从移位寄存器移出;设 0 时末字节可能被截断 |
两个 delay 的单位是毫秒,这一点在内核 uapi 头文件的注释里写得很明确。这个粒度相对字符时间相当粗:115200 bit/s、8N1 时一个字符约 86.8 μs,1 ms 已经相当于约 11.5 个字符时间。所以 delay 只能"宁大勿小"地微调,不要指望用它做精细的时序补偿。另外串口核心层对两个 delay 设有上限(100 ms),超过的值会被截断;如果所用驱动完全不支持延时,写入的值会被清零并打印一条警告——这两种情况都不会让 ioctl 失败,只能靠回读和内核日志发现。
结构体总长固定为 32 字节,末尾 20 字节是保留空间,其中较新内核用于放置多点寻址的地址字段。不同内核版本的字段细节有差异,所以只用"启用 + RTS 电平 + 两个 delay"这几项时按上面的布局打包即可;确实要用多点寻址时,应以目标内核的 uapi 头文件为准逐字段填写。
六、第四步:回读确认驱动实际接受了什么
这一步最容易被跳过,也是最容易踩坑的地方:TIOCSRS485 对"能被识别但驱动不支持"的项并不报错,而是修正后回填并返回。ioctl 返回 0 只说明这次配置被接受了,不代表每一项都按你的意思生效。
下面几类情况属于"静默修正",只能靠回读发现:
- 请求了驱动没有实现的标志位(例如可切换终端
SER_RS485_TERMINATE_BUS、发送期间接收SER_RS485_RX_DURING_TX),对应位会被清掉; - 请求的 delay 超过串口核心层允许的上限,会被截断到上限;驱动完全不支持延时功能时,delay 直接被清零;
- 两个 RTS 电平标志被设成同一个取值(都置位或都不置位,等于"发送前后电平相同",对半双工没有意义),内核会自动改成其中一种(优先
RTS_ON_SEND)并打一条警告。
而下面这些属于"直接拒绝",ioctl 返回 -1、errno 为 EINVAL:
- 置了
SER_RS485_ADDR_RECV/ADDR_DEST却没有置SER_RS485_ADDRB; - 填了
addr_recv/addr_dest的值,但对应的使能位没有置; - 置了超出当前驱动支持范围的新增标志位(多点寻址、RS-422 等)。
所以配置完之后应该做三件事:
- 把
TIOCSRS485回填的conf(或紧接着再TIOCGRS485一次的结果)打印出来,逐位核对flags与两个 delay 是否符合预期; - 立刻看一眼内核日志:
dmesg | grep -i rs485。上面那些静默修正都会在日志里留下痕迹(延时不被支持、延时被截断、RTS 电平被自动改写),这是最快的确诊线索; - 把回读输出和日志检查一起写进设备初始化脚本,让"配置是否真的生效"可回溯。现场排障时,一行回读输出往往比反复改应用代码更快定位问题。
七、第五步:在总线与协议层验证
1. 波形:最直接的证据
示波器或逻辑分析仪接三路信号:RTS(或收发器 DE)、TXD、A/B 差分线。观察两件事:
- DE 的上升沿应出现在起始位之前——这对应
delay_rts_before_send; - DE 的下降沿应出现在最后一个停止位之后、FIFO 完全排空之后——这对应
delay_rts_after_send。
如果下降沿落在停止位中间,总线上就会出现被截断的最后一个字节,对端表现为 CRC 错或帧错。
2. 双节点对测
用一块 USB 转 RS-485 适配器(或第二台设备的 485 口)与板载口对拖:A 接 A、B 接 B,两端各接 120 Ω 终端(或一端终端、另一端偏置),双方互发随机数据并逐字节比对。这一步能快速区分"方向切换问题"和"总线电气问题"。
3. 协议层:Modbus RTU 短报文往返
用从站设备或模拟从站做短报文往返,统计超时与 CRC 错误次数。要注意帧间静默的要求:RTU 规定帧与帧之间至少 3.5 个字符时间的静默,且波特率高于 19200 时该间隔固定取 1.75 ms(字符间超时 t1.5 固定取 0.75 ms)。如果 delay_rts_after_send 设得过大(比如几毫秒),主站侧的接收窗口会被拉长,轻则响应变慢,重则把一帧拆成两段处理。
4. 长稳测试
连续跑几万帧并统计错误率,比单次通断更能暴露方向切换的边界问题。产线设备上尤其要覆盖高低温与长线缆工况。
八、常见坑与处置
| 现象 | 常见原因 | 处置 |
|---|---|---|
| 发送正常,但从站一条也收不到 | RTS 极性反了,发送时收发器实际处于接收态 | 翻转 SER_RS485_RTS_ON_SEND,再用波形确认 DE 是否随发送拉高 |
| 收到的数据里混着自己发的报文 | 发送期间接收器仍使能(DE/RE 未并接,或开了 RX_DURING_TX) | 检查 DE/RE 接法;半双工两线清掉 RX_DURING_TX |
| 高波特率下首字节丢失 | delay_rts_before_send 为 0 或太小,收发器还没来得及使能 | 从 1 ms 起调,按波形收紧 |
| 末字节被截断、对端报帧错 | RTS 释放过早,最后一个字节还在移位寄存器里 | delay_rts_after_send 至少给 1 ms,配合 FIFO 深度确认 |
| ioctl 返回成功,行为却没变 | 驱动回退了不支持的标志位,或 delay 被截断/清零 | 回读 flags 与 delay,并查 dmesg | grep -i rs485 的警告 |
ioctl 返回 -1、errno 为 EINVAL | 置了地址接收/发送位却没有 ADDRB,或给了地址值却没有对应使能位;也可能是请求了驱动不支持的新增标志位 | 按"地址位—使能位"成对设置,或去掉多点寻址/RS-422 请求 |
| 两个 RTS 电平标志设成了同一个值 | 对半双工无意义,内核会自动改写其中一个并打警告 | 明确只置 RTS_ON_SEND(或只置 RTS_AFTER_SEND),用回读确认 |
设了 delay_rts_after_send,回读却是 0 | 驱动不支持该延时,值被清零(内核日志有 not supported) | 改用支持延时的驱动,或从应用层保证帧间隔 |
返回 ENOTTY(errno 25) | 该 tty 上没有实现 RS-485 ioctl(pty、部分 USB 转串口) | 换用支持的内核驱动/板卡,或退化为方案 B/C |
| 用户态翻 RTS 在高速下不稳 | 进程调度与系统调用延迟,量级可达毫秒 | 只在低速(社区经验 9600 bit/s 以下)使用;高速必须下移到驱动层 |
| 配置莫名"失效" | RS-485 配置属于端口级别,其他进程重新打开或初始化该 tty 会覆盖 | 统一由一处初始化并在打开后回读确认,避免多处重复配置 |
| 方向时序错乱、偶发冲突 | 同一端口上同时打开了硬件流控(CRTSCTS) | 关闭硬件流控,RTS 只能专职做方向控制 |
open() 报 Permission denied | 当前账号不在 dialout 组 | 加入 dialout 组,或用 udev 规则固定属主与权限 |
| 板上已有自动方向电路,却仍开内核 RS-485 模式 | 两套方向控制互相干扰 | 只保留一套:自动方向电路场合按普通串口使用 |
九、边界说明
本文覆盖的是"RTS 驱动收发器方向"这一类半双工配置。多点寻址(ADDRB)与 RS-422 四线模式只有在驱动明确实现时才可用,不能仅凭 flags 位存在就假定可用。内核 RS-485 接口能否使用、能用到什么程度,最终取决于所用 UART 驱动与 BSP 的实现,不同平台的行为可能差异明显——判断依据始终是"ioctl 是否成功 + 回读到的实际配置",而不是内核版本号或芯片手册上的一句"支持 RS-485"。涉及产线停机的改动,建议先在备机上完整走一遍本文的探测、配置、回读与波形验证流程,再推到现场。