☰
Linux多串口卡npreal2驱动加载与ttyr排障
2026/10/26 10:28:52 网站建设 项目流程

简介:面向需要在 Linux 4.x 与 5.x 内核上部署 Moxa 多串口卡 RealTTY 驱动的运维人员与嵌入式开发者,提供按内核版本分设分支的 npreal2 驱动源码,并汇入来自 AUR 仓库的内核适配补丁,用于解决新内核编译失败、proc 接口变更及设备节点权限等兼容问题。压缩包共 35 个文件、约 132KB,以 10 个 C 源文件与 6 个头文件构成驱动主体,配合 Makefile、安装与节点管理脚本、3 个 patch 补丁及说明文档,并保留原始文件与补丁冲突记录,便于对照修改。已有 311 人学习。读者可获得可编译的驱动代码、分版本适配思路和补丁排错线索,适合老设备在新系统上的迁移复用与驱动调试参考。

1. 一张 8 口卡只认出两个 ttyS:npreal2 补的是哪一层

机箱里插着一张 8 口 PCI 串口卡,开机后 lspci 能看见它,/dev下却只有 ttyS0 和 ttyS1,面板上那排 DB9 一个都点不亮——这是多串口现场最常见的一种“卡没坏、系统不认”。npreal2 就是为这种局面准备的:它是 Moxa RealTTY 在 Linux 上的内核模块名,加载成功后每个物理串口会变成/dev/ttyr00、/dev/ttyr01这样的一组字符设备,另外再配一套/dev/cur00呼出设备。它解决的不是“驱动装没装”这种表层问题,而是把一张多通道卡的中断聚合、深 FIFO、RS-485 方向翻转和 tty 语义,完整接进 Linux 的 tty 子层。适合三类人:手上有非标准 16550 布局的多口卡、单机要跑 8 个以上串口、需要硬件自动完成 485 收发的工控与自助设备开发者。

2. RealTTY 的驱动分层:8250 接不住的卡,npreal2 怎么接住

在动手编译之前,先把“为什么非得用厂商模块”这件事想清楚,否则后面每一次加载失败你都会怀疑是编译环境的问题。

2.1 一块卡八个口,通用 8250 驱动为什么接不上

内核里的 8250 驱动是按“标准寄存器布局 + 标准分频公式”枚举串口的,它维护一张 PCI ID 白名单,只有 ID 命中、并且通道寄存器偏移符合预期的卡,才会被展开成 ttyS*。多串口卡常见的形态是一颗桥片后面挂 4 到 8 个通道:通道寄存器按块偏移而不是连续排布、整卡共享一根中断线、FIFO 做到 64 或 128 字节、RS-485 的方向翻转由板载逻辑完成而不是靠 RTS 引脚。这几条里任意一条对不上,8250 就不会接管,结果就是 lspci 有设备、/dev没节点。

有人会用 setserial 手工指一个 I/O 口和 IRQ 试试,偶尔能“认出来”,但深 FIFO 用不上、共享中断处理不对、485 方向还得应用层自己掐时间翻 RTS,高波特率下丢字节是必然的。这就是选型的第一个判断点:卡是标准 16550A 兼容且内核白名单里有,直接用内核驱动最省事;只要涉及 8 口以上、非标波特率或硬件 485,就得走厂商模块这条路。

2.2 npreal2 在一张卡上做的四件事

第一件是探测与映射。模块注册一个 PCI driver,probe 阶段使能设备、申请 BAR 区域、把寄存器块 ioremap 进内核空间,然后按板卡型号算出通道数和每个通道的基址。这一步失败,dmesg 里通常只剩一行 probe 失败,/dev下什么都不会有。

第二件是中断处理。整卡共用一根 IRQ,ISR 进来后要遍历所有通道读中断标识寄存器,判断是收还是发还是线路状态变化,把收到的字节塞进每个端口自己的环形缓冲,再交给 tty 层往上刷。共享中断意味着这根线上还挂着别的设备,ISR 必须尽快返回,所以驱动里通常有“一次中断最多搬多少字节”的阈值——这个阈值直接决定了高波特率下的吞吐上限。

第三件是 tty 层注册。驱动申请一个 tty driver 结构,把 open/close/write/ioctl/set_termios 这些回调填进去,注册后由内核分配主设备号,次设备号就是端口序号,设备名是 ttyr 前缀。每口还额外注册一个 cur 前缀的呼出设备。ttyr 是“终端”语义:open 时会等载波信号、默认独占;cur 是“呼出”语义:不等载波、允许和 ttyr 同时打开。工业现场绝大多数程序用 ttyr 就够了,但调试时 cur 很好用——业务进程占着 ttyr 收数据,你可以用 cur 旁路进去看同一条线路上的字节。

第四件是 ioctl 扩展。标准 termios 只能表达常规波特率、数据位、流控,接口类型(RS-232/422/485)、非标波特率、板卡与端口信息查询这些,都要靠厂商私有命令字。这就是为什么很多程序在普通串口上跑得好好的,换到这张卡上就报“设置波特率失败”——它走的是标准路径,而卡需要私有 ioctl。

2.3 编译之前必须对齐的三件事

血泪经验是:npreal2 编译失败或加载失败的案例里,八成不是源码问题,而是“编译时用的是另一套内核”。模块的 vermagic 里带着内核版本和若干配置项,版本对不上,insmod 会直接拒绝。所以先别急着 make,先把环境核一遍。

# 1. 当前运行的内核版本 uname -r # 2. 有没有对应的内核构建目录(外部模块编译必须) ls -l /lib/modules/$(uname -r)/build # 3. 构建目录里的版本是不是真的等于 uname -r head -5 /lib/modules/$(uname -r)/build/Makefile # 4. 编译器版本,尽量与内核构建时接近 gcc --version | head -1 # 5. 内核是否允许加载模块 cat /proc/sys/kernel/modules_disabled # 6. 安全启动状态,开了会拦未签名模块 mokutil --sb-state 2>/dev/null || echo "no mokutil" # 7. 卡到底在不在总线上 lspci -nn | grep -i -E "serial|comm"

第一条和第三条要对照着看:/lib/modules/$(uname -r)/build常常是个软链接,指向的可能是升级前留下的旧头文件目录,head Makefile打出来的版本号跟uname -r不一致时,编出来的 .ko 一定加载不上。第二条如果目录不存在,说明只装了运行内核没装开发包,deb 系发行版装linux-headers-$(uname -r),rpm 系装kernel-devel-$(uname -r),注意版本号要写全,让它自己去匹配。

第五条返回 1 表示模块加载已被内核全局禁用,多见于加固过的系统,这种情况任何厂商模块都装不上,得先改内核启动参数。第六条返回 enabled 时,未签名模块会被内核拒绝,报错形如 “Key was rejected by service”,这不是驱动的问题,后面排查章节会专门讲怎么处理。

提示:如果这台机器升级过内核但没重启,uname -r是旧内核,而包管理器刚装的头文件是新内核的,这时候编译必然踩坑。先重启,再编译。

3. 把 npreal2 编译并加载起来:从 modinfo 看参数到一次回环自测

环境对齐之后,剩下的是标准的外部模块编译流程。厂商发布包通常带一个安装脚本把这些步骤包起来,但脚本一旦失败,你还是得知道它内部做了什么,所以这里按手工步骤走一遍。

3.1 从源码目录到 npreal2.ko:一条 kbuild 外部模块命令

# 解包到 /usr/src 下,编译产物和源码放一起便于回滚 tar xf npreal2-src.tar.gz -C /usr/src/ cd /usr/src/npreal2-src # 外部模块编译:-C 指向内核构建目录,M= 指向当前源码目录 make -C /lib/modules/$(uname -r)/build M=$PWD modules # 确认产物和它的 vermagic ls -l npreal2.ko modinfo ./npreal2.ko | grep -E "vermagic|filename|parm" # 安装到 extra 目录并刷新模块依赖 install -m 644 npreal2.ko /lib/modules/$(uname -r)/extra/ depmod -a

make -C ... M=$PWD modules是内核 kbuild 的标准外部模块入口,-C把工作目录切到内核构建树,M=告诉它源码在别处,编译产物会落在源码目录里。这一步报错通常是缺头文件或者内核构建树没被 prepare 过,前者补开发包,后者在构建目录执行一次make modules_prepare(部分发行版的开发包里已经准备好,不需要手动做)。

modinfo里的 vermagic 字段要和uname -r完全一致,多一个后缀、少一个+,加载都会失败。安装到extra/是约定俗成的位置,好处是depmod -a之后modprobe能按名字找到它,不用担心和发行版自带的同名模块打架。

3.2 modinfo -p 的输出怎么读,哪些参数值得改

不同发行包里的参数命名有差异,所以不要背参数名,直接问模块自己:

# -p 只列出参数,-0 适合脚本解析,这里看人读的版本 modinfo -p npreal2

输出里的参数大致分三类,处理策略完全不同:

参数类别在输出里的形态建议
资源上限类形如 max_boards、max_ports,值是整数只在实际端口数超过默认值时改大,避免无谓占用
命名与节点类形如 tty_prefix、create_callout,值是字符串或 0/1前缀保持默认 ttyr,除非和现有设备名冲突;不需要 cur 节点时关掉能少一半节点
调试类形如 debug、verbose,值是 0/1/2 及以上排障时临时开到 2,稳定后必须调回 0,否则日志量会拖慢 ISR

改参数不要直接写在 insmod 命令里,那样重启就丢了,而且业务脚本里散落着命令行参数很难维护。写进 modprobe 配置文件:

# /etc/modprobe.d/npreal2.conf # 参数名以 modinfo -p npreal2 的实际输出为准,这里用占位符 options npreal2 <调试参数>=0 <节点参数>=1

注意:调试级别开高之后,每收一批字节都会打日志,在 921600 波特率下这是灾难级的开销,现象是平时正常、一跑大数据量就开始丢字节,很多人会误判成硬件问题。

3.3 开机自动加载与第一次回环自测

手工加载先验证一次,再谈开机自启:

modprobe npreal2 lsmod | grep npreal2 dmesg | tail -40 # 重点看 probe、IRQ 号、注册了几个端口 ls -l /dev/ttyr* /dev/cur* 2>/dev/null

dmesg 里应该能看到每个通道被注册的信息,以及申请到的中断号。如果只有模块加载成功、没有端口注册,那问题在探测阶段而不是模块加载阶段,下一步应该回去看卡的 BAR 是否被别的驱动占着。

自测用硬件回环最省事:把某个口的 DB9 第 2、3 脚短接,写什么读什么。

import serial PORT = "/dev/ttyr00" # 用 udev 固定后的名字更稳,见下一章 payload = bytes(range(256)) * 4 # 1 KB,覆盖 0x00~0xFF 全部字节值 # 回环测试:该口 TX 与 RX 短接,所以必须关掉硬件流控,否则会等 CTS 卡死 with serial.Serial(PORT, 115200, timeout=1, bytesize=8, parity="N", stopbits=1, rtscts=False, xonxoff=False) as ser: ser.reset_input_buffer() ser.write(payload) ser.flush() # 等发送 FIFO 真正排空,再开始读 got = ser.read(len(payload)) print("sent", len(payload), "got", len(got), "match", got == payload)

参数上有三个点容易错。rtscts=False是回环的前提,开了硬件流控而 CTS 没被短接,程序会一直阻塞在 write 上,看起来像“卡死”。flush()在部分实现里只是刷用户态缓冲,真正等到线路发完还需要驱动配合,所以读的时候给timeout=1兜底,别用阻塞读。reset_input_buffer()必须在写之前调,否则上一轮残留字节会让match变成 False,白排查半天。

如果回环通过,说明驱动、tty 层、中断链路都是通的,接下来才是业务侧的波特率、流控、485 这些真正难的部分。

4. ttyr00 与 cur00 的落地细节:udev 固定命名、权限与开机加载顺序

在实验室里/dev/ttyr00能用,不代表现场能用。多张卡、多个槽位、一次内核升级,都可能让编号漂移,而业务代码里写死的端口号不会跟着变。

4.1 ttyr 与 cur 两套节点的差别,什么时候必须用 cur

ttyr 走的是终端语义:open 默认等到 DCD 有效才返回,默认独占,一个进程打开后第二个进程会拿到 EBUSY。cur 走呼出语义:不等载波,允许和被占用的 ttyr 同时打开同一条线路。

这个差别在两种场景里很关键。一是设备平时不接、按需连接的场合,用 ttyr 打开会一直阻塞在等载波上,换 cur 就不会。二是现场排障,业务进程已经占着/dev/ttyr03在收数据,你想看看线路上到底有没有字节,用cat /dev/cur03可以旁路观察而不打断业务——这是我认为 cur 节点最大的实用价值,比它在教科书里的呼出用途更常用。

反过来也要注意:某些应用库会把 cur 当成普通串口打开,然后发现没有独占保护,两个进程同时读写导致数据错乱。判断标准很简单——单进程独占就用 ttyr,需要旁路或按需呼出才用 cur,别为了“能打开”而随手换成 cur。

4.2 用 udev 把 ttyr 编号钉成业务名

固定名字不能靠猜,要从设备自身的稳定属性里取。先看一个已存在的节点有哪些可用属性:

udevadm info -a -n /dev/ttyr00 | head -60

输出里按层级列出父设备属性,越往下越靠近硬件。稳定的通常是 PCI 槽位(形如KERNELS=="0000:03:00.0")和端口序号,不稳定的是KERNEL=="ttyr*"里的数字本身。据此写规则:

# /etc/udev/rules.d/99-npreal2.rules # 按 PCI 槽位 + 端口序号绑定,槽位号和端口属性以 udevadm info -a 的实际输出为准 SUBSYSTEM=="tty", KERNELS=="0000:03:00.0", ATTRS{<端口属性>=="0", \ SYMLINK+="tty_plc1", MODE="0660", GROUP="dialout" SUBSYSTEM=="tty", KERNELS=="0000:03:00.0", ATTRS{<端口属性>=="1", \ SYMLINK+="tty_scale1", MODE="0660", GROUP="dialout"

SUBSYSTEM=="tty"限定只处理 tty 设备,KERNELS匹配的是设备在 sysfs 树里自身的名字,ATTRS匹配父设备属性,两者别混用——用错层级会导致规则完全不生效,而且不报错。SYMLINK+=创建的是软链接,原节点/dev/ttyr00仍然存在,所以业务代码改成/dev/tty_plc1之后,用 msdiag 之类的工具仍然能按 ttyr 编号去查,两边不冲突。

改完规则要让它立刻生效:

udevadm control --reload-rules udevadm trigger --subsystem-match=tty udevadm info -q symlink -n /dev/ttyr00 # 确认软链接已经挂上

4.3 权限、组和加载顺序:别让业务进程抢在模块前面

设备节点的属主默认是 root,业务进程通常不是 root,所以规则里必须带GROUP="dialout"并把运行账号加进这个组。少见但会踩的坑是:加组之后没有重新登录,进程的附加组列表还是旧的,表现为ls -l看着权限没问题、程序打开仍然报 Permission denied。用id确认当前会话的组列表,必要时重登。

开机顺序是另一个高频问题。模块没加载,/dev/ttyr00就不存在,业务服务先起来就会直接退出。两种情况分别处理:模块本身用 modules-load 保证早期加载,业务服务用 systemd 的依赖关系等设备出现。

# /etc/modules-load.d/npreal2.conf —— 系统启动早期加载模块 npreal2
# /etc/systemd/system/serial-gateway.service [Unit] Description=Serial gateway After=systemd-modules-load.service # 等设备节点真的出现,而不是等模块加载完就假定节点已就绪 ConditionPathExists=/dev/tty_plc1 [Service] Type=simple ExecStart=/opt/gateway/bin/gateway --port /dev/tty_plc1 Restart=on-failure RestartSec=3 [Install] WantedBy=multi-user.target

After=systemd-modules-load.service只保证模块加载动作排在前面,不保证设备节点已经创建完成,所以再加ConditionPathExists做兜底;如果节点还没出现,服务这次启动会被跳过而不是崩溃,配合Restart=on-failure与运维侧重启策略,比在启动脚本里sleep 5靠谱得多。

还有一类问题出在休眠唤醒之后:机器挂起到内存再唤醒,卡的中断可能没恢复,现象是节点还在、打开也正常,就是收不到数据。稳妥做法是写一个唤醒后重载模块的 unit:

# /etc/systemd/system/npreal2-resume.service [Unit] Description=Reload npreal2 after resume After=suspend.target hibernate.target [Service] Type=oneshot ExecStart=/sbin/modprobe -r npreal2 ExecStart=/sbin/modprobe npreal2 [Install] WantedBy=suspend.target hibernate.target

重载会让已经打开的 fd 失效,所以业务进程必须能容忍设备重新打开,这一点要在设计阶段就和业务方讲清楚,别等到现场才发现。

5. 加载失败与收发异常的 5 条排查记录:从 dmesg 第一行开始读

排障顺序永远是:先看模块加载有没有成功,再看端口有没有注册,再看节点有没有创建,最后才看数据对不对。跳步会让你在错误的方向上耗掉半天。

5.1 insmod 报 Invalid module format

现象:insmod npreal2.ko直接失败,dmesg 里跟一句 “disagrees about version of symbol” 或 “Invalid module format”。

原因:编译用的内核构建树和当前运行内核不是同一套,vermagic 对不上;或者开了 CONFIG_MODVERSIONS 但符号 CRC 不匹配。

解决:modinfo npreal2.ko | grep vermagic和uname -r逐字符对照,注意有没有+、-smp之类的后缀差异。确认/lib/modules/$(uname -r)/build指向的目录里 Makefile 的版本号一致,必要时先重启到目标内核再编译,别在升级完还没重启的机器上折腾。

5.2 模块加载成功但 /dev 下没有 ttyr 节点

现象:lsmod里有 npreal2,/dev/ttyr*一个都没有,dmesg 里也没有端口注册记录。

原因:探测阶段没找到可用的卡或通道。可能是卡被别的驱动先占了 BAR 区域,也可能是这张卡的 ID 不在当前驱动版本的支持列表里,还可能是 devtmpfs 没挂载导致节点没自动创建(这种情况少见,但容器里跑的时候会遇到)。

解决:先lspci -nn -k看卡后面跟的 “Kernel driver in use” 是哪个,如果是内核自带驱动占着,需要先把它加入黑名单再加载 npreal2;如果显示 “Kernel modules:” 为空但驱动仍不认,多半是版本不支持,得换驱动包而不是改参数。dmesg 里搜模块名加 probe,能看到具体卡在哪一步。容器场景下确认/dev是不是 devtmpfs,不是的话节点要在宿主机建好再映射进去。

5.3 Secure Boot 开着,加载被拒

现象:insmod: ERROR: could not insert module: Key was rejected by service。

原因:内核处于安全启动状态,只接受签名过的模块。

解决:三条路——在固件里关掉安全启动(工控机通常允许,但要和甲方确认合规要求);给模块签名并在 MOK 里注册公钥;或者直接用发行版提供的签名机制打包。第一条最省事但需要现场权限,第二条最规范但流程长。这里没有后悔药,装机前就该确认这一项,别等部署完才发现。

5.4 高波特率下丢字节,低波特率正常

现象:115200 跑得好好的,提到 921600 之后偶发丢字节,错误计数上涨,业务侧表现为协议帧校验失败率升高。

原因:常见有三个。一是调试参数没关,每批数据都打日志;二是应用层 read 的缓冲小于一次中断搬运的量,驱动侧缓冲被覆盖;三是共享中断被同一条线上的其他设备拖慢,ISR 进不来。

解决:先把调试级别调回 0;再把应用侧读缓冲调到 4096 以上并循环读,不要一次读几十字节;然后看/proc/interrupts里这根中断的计数增长是否跟得上数据量,跟不上就是中断被抢,考虑换插槽让卡独占一根线。开了硬件流控的场合还要确认对端真的在拉 RTS/CTS,很多丢字节最后查出来是流控线没接。

5.5 重启之后端口号变了,业务连错设备

现象:昨天/dev/ttyr03接的是称重仪,今天重启后变成接 PLC 了,数据全乱。

原因:ttyr 的次设备号来自驱动的探测顺序,多张卡或者槽位变动时会重排。

解决:按 4.2 节用 udev 按 PCI 槽位绑定业务名,业务代码只认软链接。已经上线改不动的系统,至少要在启动脚本里做一次端口自检——向每个口发一条已知命令,看回应是否符合预期,不匹配就告警而不是硬跑。

6. 进阶:RS-485 方向翻转、非标波特率与 24 小时压测怎么设计

真正让项目延期的是 RS-485。硬件上先确认两件事:板上跳线或 DIP 开关设的是 2 线还是 4 线,总线两端有没有 120Ω 终端电阻。软件侧,如果驱动支持内核通用 485 接口,用标准 ioctl 最省心:

#include <sys/ioctl.h> #include <linux/serial.h> /* 使能驱动自动翻转 RTS 作为 485 方向控制 */ struct serial_rs485 rs485 = {0}; rs485.flags = SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND; rs485.delay_rts_before_send = 1; /* 拉高到发首字节的间隔,单位 ms */ rs485.delay_rts_after_send = 1; /* 发完到拉低的间隔,单位 ms */ if (ioctl(fd, TIOCSRS485, &rs485) < 0) { /* 返回 ENOTTY 说明驱动没实现通用接口,改用厂商私有 ioctl */ }

两个 delay 是玄学重灾区。设太小,最后一个字节还没出移位寄存器方向就切了,对方收到的是截断帧;设太大,总线切换慢,高波特率下吞吐掉一半。经验值是从 1ms 起调,用示波器看 DE 引脚和最后一个停止位的关系,收到完整帧且总线释放及时就停。返回 ENOTTY 时不要硬试,直接翻驱动包里的头文件找厂商命令字,这是两套不同的实现路径。

非标波特率(比如 250k、500k 这类)标准 termios 表达不了,要么用厂商 ioctl 设置分频,要么走 termios2 的 BOTHER。判断依据很简单:stty -F /dev/ttyr00 250000报 invalid argument,就说明这条路不通。

压测别用dd猛灌,那样只能证明“能写”,证明不了“不丢”。我一般用两个口对接,一端按固定长度帧循环发带序号的数据,另一端逐帧校验序号连续性,跑 24 小时,统计丢帧率和最大连续丢帧数。

观察项正常表现异常时的第一怀疑对象
序号连续性24 小时零丢帧应用读缓冲太小或调试日志没关
中断计数增长与收字节数同步共享中断被其他设备抢占
帧校验错误稳定为 0485 方向切换时序或终端电阻缺失
进程 CPU 占用平稳且低于 20%读循环没有阻塞,在空转

最后说个我自己的习惯:任何一张多串口卡上线前,我一定先把调试级别开到最高跑十分钟,把 dmesg 完整存一份当基线,然后再关掉。出问题时拿这份基线和现场日志对比,比从零猜快得多。这套流程在十几台设备上救过我,也让我明白一件事——npreal2 这类厂商模块的难点从来不在编译,而在“编号、权限、时序”这三件小事上,把这三件钉死,剩下的都是业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询