ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

modbus-serial 实战:Node.js 串口采集与避坑指南

modbus-serial 实战:Node.js 串口采集与避坑指南

简介:Modbus-Serial 是面向 Node.js 与前端开发者的开源通信库,用于在 JavaScript 环境中实现 Modbus-RTU 串行与 TCP 协议通信,解决前端项目对接 PLC、电表、传感器等工业设备时的协议适配问题,适合具备一定 Node.js 基础、关注物联网与自动化应用的开发者。资源包共 57 个文件,以 46 个 js 源码为主,辅以 3 个 ts 类型声明、2 个 json 配置、2 个 sh 脚本及 license、md、yml 等说明文件,压缩包约 70KB,体积轻量、结构清晰。目前已有 1591 人学习下载。内容涵盖串行端口、TCP 端口、缓冲端口等多种连接实现,以及线圈与寄存器读写、CRC16 校验、日志调试、轮询与异步服务端等模块,并附示例代码,便于读者理解协议细节、快速集成到实际项目中并排查通信异常。

1. 串口设备接进浏览器之前,先搞清楚 modbus-serial 到底管哪一段

车间里一台老 PLC 还在跑,上位机却要从工控机换成一台带浏览器的触屏终端,现场工程师第一反应往往是“前端怎么读串口”。这个问题的答案不在浏览器里,而在一个常被误当成“前端库”的 Node.js 包上:modbus-serial。它做的事很具体——把 Modbus RTU / ASCII / TCP 的报文封装、CRC 校验、串口读写、超时重试这些脏活包起来,让你用几行 JavaScript 就能对着一台从站设备读保持寄存器。它不负责画界面,也不负责在浏览器里直接开 COM 口,真正跑起来的位置是 Node 进程或 Electron 主进程。适合谁?手上有一台 Modbus 从站、想用 JS 生态快速做采集脚本、网关服务或桌面端上位机的人。不适合谁?指望纯浏览器页面直连串口、或者需要完整 SCADA 组态能力的人。下面按“它是什么 → 怎么连上 → 参数怎么调 → 坑在哪”的顺序拆。

2. 从站地址、功能码与传输层:modbus-serial 的三层抽象

2.1 它封装的到底是什么协议层

Modbus 本身是一套应用层报文规范,PDU 里最关键的三样东西是功能码、起始地址、数量。读保持寄存器是功能码 0x03,读输入寄存器是 0x04,写单个寄存器 0x06,写多个寄存器 0x10。modbus-serial 把这些功能码映射成方法名,比如readHoldingRegisters、writeRegister,你不需要自己拼字节、算 CRC。它同时管传输层:RTU 走串口,ASCII 走串口但报文是可见字符,TCP 走网口并在 PDU 前加 MBAP 头。这三条路径在库里是不同 client 类,选错了连不上,报错还往往很含糊,这是新手第一个翻车点。

选型上要先确认从站设备的物理接口和协议变种。设备手册写“RS485,Modbus RTU,9600 8N1,从站地址 1”,那你就用ModbusRTU,串口参数照抄。手册写“Modbus TCP,端口 502”,用ModbusTCP,别去碰串口。有些设备同时支持 RTU over TCP(把 RTU 报文塞进 TCP 帧),这种要用ModbusRTU配 TCP 传输,库里有对应入口,但很多人直接拿ModbusTCP去连,报文对不上,现象是连上了却一直超时。

2.2 建立第一条连接:RTU 与 TCP 两套写法

先装依赖。串口场景需要原生串口模块,Node 版本较新时编译环节容易出问题,建议锁定 LTS 版本再装。

npm init -y npm install modbus-serial serialport

RTU 连接的写法如下,重点是串口路径、波特率、校验位、从站地址四项必须和设备手册完全一致。

const ModbusRTU = require("modbus-serial"); // 打开串口,参数逐项对应设备手册 const client = new ModbusRTU(); client.setID(1); // 从站地址,手册里通常写作 Slave ID / 站号 client.setTimeout(1000); // 单次请求超时,毫秒 async function main() { // COM3 是 Windows 写法,Linux 下一般是 /dev/ttyUSB0 await client.connectRTUBuffered("COM3", { baudRate: 9600, // 波特率 dataBits: 8, // 数据位 parity: "none", // 校验位:none / even / odd stopBits: 1 // 停止位 }); // 读保持寄存器:起始地址 0,数量 10 const res = await client.readHoldingRegisters(0, 10); console.log(res.data); // 返回的是 16 位无符号整数数组 client.close(); } main().catch((err) => { console.error("采集失败:", err.message); client.close(); });

逻辑说明:connectRTUBuffered用的是带缓冲的串口读取方式,比逐字节读取更稳,现场长线、干扰大的时候差别明显。setID必须在连接前或连接后立刻设置,漏设会默认按地址 1 发,多从站总线上就会读错设备。readHoldingRegisters(0, 10)里的 0 是协议地址,不是手册上常写的 40001 这种展示地址,两者差 1,这个偏移是血泪经验里出现频率最高的一条。

TCP 连接换一套入口,其余方法名不变。

const ModbusRTU = require("modbus-serial"); const client = new ModbusRTU(); async function main() { // 参数:从站 IP、端口,第三个参数是 unitId(部分网关需要) await client.connectTCP("192.168.1.50", { port: 502 }); client.setID(1); client.setTimeout(2000); const res = await client.readInputRegisters(0, 4); console.log(res.data); client.close(); }

参数说明:connectTCP的第二个参数对象里port默认 502,但现场网关经常改成 1502 或别的端口,连不上先核对端口。setID在纯 TCP 直连设备上有时被忽略,但经过 Modbus 网关转 RTU 时必须设对,否则网关不知道转发给哪台从站。超时在 TCP 场景建议给到 2000 毫秒以上,跨网段时 1000 毫秒偏紧。

2.3 地址映射与数据类型:读上来的是原始 16 位字

库返回的res.data是一组 16 位无符号整数,它不知道你设备里这个寄存器是温度、是状态字、还是两个寄存器拼成的 32 位浮点。这一步必须自己按设备手册做解释。常见做法是:单个寄存器按有符号数处理时用(v << 16) >> 16转成 int16;32 位量要读两个连续寄存器,再按字序拼装。

// 把两个 16 位寄存器拼成 32 位浮点,字序按设备手册选择 function toFloat32(high, low, wordSwap = false) { const buf = Buffer.alloc(4); if (wordSwap) { buf.writeUInt16BE(low, 0); buf.writeUInt16BE(high, 2); } else { buf.writeUInt16BE(high, 0); buf.writeUInt16BE(low, 2); } return buf.readFloatBE(0); } // 有符号 16 位 function toInt16(v) { return (v << 16) >> 16; }

逻辑说明:字序(word order)是 Modbus 采集里最玄学的一环,同一款设备不同固件版本都可能不一样,读出来是1.2e-38这种离谱值,八成是高低字反了。参数wordSwap就是给这个用的,先按手册试,手册没写就拿一个已知量(比如量程中点)反推。这一步没有捷径,只能对着设备试。

3. 批量采集与并发控制:别让一条总线被请求打爆

3.1 串行化请求是 RTU 的硬约束

Modbus RTU 是半双工总线,同一时刻只能有一个主站请求在跑。如果你在代码里Promise.all并发发十个读请求,报文会在总线上互相踩,现象是随机超时、偶发 CRC 错误,排查起来非常折磨。正确做法是给 client 套一个串行队列,一个请求回来再发下一个。库本身不强制串行,这个责任在使用方。

// 极简串行队列:保证同一时刻只有一个 Modbus 请求在途 class SerialQueue { constructor() { this.tail = Promise.resolve(); } push(task) { const run = this.tail.then(task, task); // 吞掉错误,避免一个失败卡死整条队列 this.tail = run.catch(() => {}); return run; } } const queue = new SerialQueue(); async function readSafe(start, count) { return queue.push(() => client.readHoldingRegisters(start, count)); }

逻辑说明:this.tail始终指向最后一个任务的 Promise,新任务挂在它后面,天然形成先进先出。then(task, task)让前一个任务失败时也继续执行下一个,否则一次超时会把后续全部堵死。这个队列很粗糙但够用,生产环境可以换成成熟的限流库,核心思想不变:RTU 总线上并发等于自找麻烦。

3.2 分块读取与寄存器数量上限

一次读多少个寄存器有讲究。协议层面单次最多 125 个保持寄存器(功能码 0x03),但现场设备往往更保守,超过 20 到 30 个就开始返回异常码。稳妥做法是按 10 到 20 个一块分批读,块与块之间留一点间隔。

// 分块读取,每块 16 个寄存器,块间留 50ms 间隔 async function readRange(start, total) { const CHUNK = 16; const result = []; for (let offset = 0; offset < total; offset += CHUNK) { const count = Math.min(CHUNK, total - offset); const res = await queue.push(() => client.readHoldingRegisters(start + offset, count) ); result.push(...res.data); await new Promise((r) => setTimeout(r, 50)); } return result; }

参数说明:CHUNK取 16 是个折中值,太小则请求次数多、总线占用高,太大则部分设备直接抛异常码 0x03(非法数据值)。块间 50 毫秒是给从站留的处理时间,老设备响应慢,这个值可以加到 100。如果设备手册明确写了单次最大寄存器数,以手册为准。

3.3 异常码怎么读:库抛出的错误里藏着从站的话

从站返回异常时,库会抛错,错误对象里通常带modbusCode或类似字段。常见异常码:0x01 非法功能码(设备不支持这个功能)、0x02 非法数据地址(地址超范围)、0x03 非法数据值(数量不对)、0x04 从站设备故障。看到 0x02 先查地址偏移,看到 0x03 先减数量,看到超时而不是异常码,则往物理层和从站地址上找。

try { await client.readHoldingRegisters(0, 200); } catch (err) { // 不同版本字段名略有差异,打印整个错误对象最稳 console.error("错误详情:", err); if (err.modbusCode === 3) { console.error("数量超限,减小单次读取寄存器数"); } }

逻辑说明:不要只打印err.message,很多版本里 message 只有一句 “Modbus exception”,真正的异常码在附加字段上。把整个错误对象打出来,第一次对接新设备时能省掉大量猜测。

4. 避坑与排查:五个现场高频翻车点

4.1 现象:连上了但每次读都超时

原因通常是三选一:从站地址设错、串口参数(波特率/校验位)和设备不一致、A/B 线接反。RS485 的 A/B 标注各家不统一,接反了物理层能通但数据全乱。解决顺序:先用设备厂商的调试工具确认能读通,再换到代码里;核对setID与手册站号;用示波器或串口监听工具看发出的报文有没有回应。别一上来就怀疑库。

4.2 现象:读出来的数值明显不对,比如温度是 65000 多

原因:寄存器是无符号 16 位,设备实际存的是有符号数或定点数。解决:按手册做符号转换或除以 10、100 的缩放系数。65000 多对应 -500 多,除以 10 就是 -50 摄氏度,这类定点表示在温控设备里极常见。

4.3 现象:Linux 下报权限错误,打不开 /dev/ttyUSB0

原因:当前用户不在串口设备所属用户组里。解决:把用户加入对应组(常见是 dialout),重新登录生效;临时验证可以用sudo跑一次确认是权限问题。Windows 下对应的是 COM 口被别的软件占用,关掉厂商调试工具再试。

4.4 现象:跑一段时间后突然全部超时,重启进程又好了

原因:串口句柄泄漏或异常后没有正确关闭,也可能是从站设备看门狗复位。解决:在采集循环外层加健康检查,连续 N 次失败就client.close()后重建连接;同时确认每次异常分支都调用了 close。长期运行的采集服务一定要有重连逻辑,这是后悔药。

4.5 现象:TCP 能连上,读数据却报非法数据地址

原因:经过网关时,unitId 没设对,网关把请求转给了错误的从站,或者地址偏移在网关侧又被加了一次。解决:确认setID与网关配置的从站映射一致;用网关自带的管理页面看请求日志,确认转发出去的地址是不是你期望的。

5. 把采集脚本做成能长期跑的服务:重连、心跳与验证

单次读通只是开始,真正上线要解决的是“连续跑一周不出事”。我的习惯是把连接管理抽出来,做成带状态的重连器:连接成功置位,任何一次请求抛错就累计失败计数,连续三次失败触发关闭并延时重连,重连间隔做退避,避免从站还没恢复就被请求淹没。

// 带退避重连的采集循环骨架 let failures = 0; let backoff = 1000; async function ensureConnected() { if (client.isOpen) return; await client.connectRTUBuffered("COM3", { baudRate: 9600, dataBits: 8, parity: "none", stopBits: 1 }); client.setID(1); client.setTimeout(1000); } async function loop() { while (true) { try { await ensureConnected(); const data = await readRange(0, 32); failures = 0; backoff = 1000; console.log("采集正常:", data.slice(0, 4)); } catch (err) { failures += 1; console.error(`第 ${failures} 次失败:`, err.message); if (failures >= 3) { client.close(); await new Promise((r) => setTimeout(r, backoff)); backoff = Math.min(backoff * 2, 30000); // 退避上限 30 秒 } } await new Promise((r) => setTimeout(r, 1000)); // 采集周期 } }

参数说明:failures >= 3是触发重连的阈值,现场干扰大可以放宽到 5;backoff从 1 秒翻倍到 30 秒封顶,防止从站断电期间疯狂重试;采集周期 1 秒是多数监控场景的够用值,需要更快就压缩,但要同步评估总线负载。验证方法很直接:拔掉串口线,看日志是否按预期累计失败并进入退避;插回去,看是否在退避结束后自动恢复采集。这一步我每次交付前都强制走一遍,因为“能读一次”和“能一直读”之间隔着的就是这些状态处理。

还有一个容易被忽略的验证点:把读到的原始值和设备面板显示值对一遍,尤其是负数和带小数的量。曾经有一次现场温度一直显示正常,直到冬天出现零下才暴露出符号位没处理,这种问题在常温测试里永远发现不了。从那以后我每次对接新设备,都会专门构造一个跨越零点的读数去验证符号和缩放,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进