简介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 类选错了连不上报错还往往很含糊这是新手第一个翻车点。选型上要先确认从站设备的物理接口和协议变种。设备手册写“RS485Modbus RTU9600 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 serialportRTU 连接的写法如下重点是串口路径、波特率、校验位、从站地址四项必须和设备手册完全一致。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转成 int1632 位量要读两个连续寄存器再按字序拼装。// 把两个 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是触发重连的阈值现场干扰大可以放宽到 5backoff从 1 秒翻倍到 30 秒封顶防止从站断电期间疯狂重试采集周期 1 秒是多数监控场景的够用值需要更快就压缩但要同步评估总线负载。验证方法很直接拔掉串口线看日志是否按预期累计失败并进入退避插回去看是否在退避结束后自动恢复采集。这一步我每次交付前都强制走一遍因为“能读一次”和“能一直读”之间隔着的就是这些状态处理。还有一个容易被忽略的验证点把读到的原始值和设备面板显示值对一遍尤其是负数和带小数的量。曾经有一次现场温度一直显示正常直到冬天出现零下才暴露出符号位没处理这种问题在常温测试里永远发现不了。从那以后我每次对接新设备都会专门构造一个跨越零点的读数去验证符号和缩放希望帮到你。本文还有配套的精品资源点击获取