资讯动态

Node.js工业数据采集:用modbus-serial实现Modbus RTU/TCP寄存器读写

发布时间:2026/10/23 8:02:59 来源:尧图企业网站定制
简介面向前端开发者的 Modbus-Serial 开源库资源包适用于需要在 Node.js 或前端环境中实现 Modbus-RTU/TCP 通信的开发者。该库采用纯 JavaScript 编写无需额外编译支持串行RS-232/RS-485与 TCP/IP 连接提供读线圈、写寄存器、读输入寄存器等丰富 API并内置错误检查与日志调试机制可大幅降低工业设备互联的开发门槛。压缩包共 57 个文件以 46 个 JS 源码文件为主体另含 TypeScript 类型声明3 个、配置文件、Shell 脚本、License 及 README 文档整体仅 70KB结构紧凑便于快速查阅与集成。已有 1591 人浏览学习口碑可见。资源内含完整源码、示例脚本如简单读写、轮询、服务器模拟等与测试用例阅读 README 和示例即可快速上手适合物联网、能源管理、环境监测等场景的二次开发。1. 设备取数为什么我用前端开源库 modbus-serial 读现场仪表做前端开源项目的人到了工业现场往往会有一次心态转换浏览器里都是交互和状态但车间里那台温控表、变频器或流量计只认Modbus协议寄存器号、功能码、CRC校验一个都不能少。某次我接手一台老式温控表的数据采集刚开始自己拿字节流拼帧半天耗在 CRC 运算和粘包处理上换成基于 Node.js 的 modbus-serial 之后串口打开、寄存器读取、数据解析不到六十行就通了。这篇笔记直接按“选型 → RTU 实操 → TCP 实操 → 避坑 → 调试”往下走适合正在选型、或者已经遇到“读不到数据”问题的开发者。2. 先摸清两条链路RTU 与 TCP 的选型判断和最小工程初始化2.1 RTU 与 TCP同一个协议两种物理通道Modbus 的报文核心是功能码加寄存器地址加数据但 RTU 和 TCP 的传输与封包方式差异很大。RTU 走串口典型场景是一根 RS485 总线线上可以挂多台从站主站发请求从站靠地址匹配响应帧尾带 16 位 CRC协议本身没有显式长度字段靠字节停顿来区分一帧结束。TCP 走以太网报文前面多了一段 6 字节的 MBAP 头包含事务标识、协议长度等字段粘包问题天然比串口少。可以理解为RTU 更依赖链路层的时序稳定性TCP 则把一部分时序问题交给了网络栈处理。选型时我一般先判断物理通道。桌面调试、设备就在主机旁边USB 转 RS485 是成本最低的路径直接调 open 传串口路径设备已经挂在现场主机和设备之间隔着几十米甚至跨车间沿用 RTU 就要拉很长的屏蔽双绞线后级还得考虑地电位干扰。这种场景大多数做法是挂一个 RS485 转以太网网关主机侧由 open 换成 connectTCP链路变了功能码调用保持不变。同一个库统一两套链路是它在 Node 侧取数方案里比较省心的原因。维度Modbus RTUModbus TCP物理层RS232 / RS485 串口以太网帧结构地址功能码数据CRCMBAP头功能码数据帧结束判定字节间隙3.5 字符时间报文长度字段典型调试串口助手网络抓包 / 端口探测很多人在选型时只看“协议支持”忽略了现场布线条件最后卡在一根根本不存在的串口线上。用哪个版本不是看哪个更“先进”而是看从站物理接口是什么、主机到从站之间有没有现成网线。2.2 为什么是 modbus-serial选型判断而不是库堆砌Node.js 生态里能处理 Modbus 的库不止一个modbus-serial 的设计更贴近“直接对一台从站操作”的心智模型。它没有重新造串口轮子底层用 serialport 做串口收发用 net 模块做 TCP 连接上层统一封装成 ModbusRTU 实例功能码方法名很直观readHoldingRegisters、readInputRegisters、readCoils、readDiscreteInputs、writeCoil、writeRegister。从代码可读性和排错成本来看这种命名比在一个底层 parser 里手动组织缓冲区容易得多。另一个容易被忽略的选型理由是错误对象分类。它返回的错误可以区分 CRC 失败、超时、从站异常响应而不是把所有问题都吞成一个笼统的“读取失败”。我在某现场调试时同一根 RS485 总线上并联两台仪表一台正常一台偶发 CRC 错如果库把所有异常都归为“读不到数据”就只能看到失败日志无法定位是哪台从站回了坏帧。正因为错误有区分度配合原始帧打印几分钟就锁定了问题出在从站上电抖动。选型还有一个判断标准是否值得自己维护协议状态机。只读一两个寄存器手写十几行也能实现一旦涉及多从站轮询、断线重连、超时重试、异常响应码解析自己维护一套串口状态机的时间成本会成倍上升。modbus-serial 把这层管好业务代码只需要关心寄存器地址和数据类型。代价是它内部用了 FastBuffer 做数据缓冲初学者在解析原始字节时容易踩类型转换的坑这点后面会专门展开。2.3 最小工程初始化依赖安装与打开串口的前置检查动手写业务代码前我一般先确认 Node.js 版本和串口权限。modbus-serial 依赖 serialport 的原生模块Linux 下当前用户不在 dialout 组里open 会直接报 permission denied这个报错经常被误判为库有问题。先执行下面的命令安装依赖mkdir modbus-demo cd modbus-demo npm init -y npm install modbus-serial先用一个最小脚本验证依赖是否安装成功node -e console.log(require(modbus-serial))能打印出对象说明依赖正常。接着验证串口能不能打开这一步非常关键const ModbusRTU require(modbus-serial); const client new ModbusRTU(); client.open(/dev/ttyUSB0, { baudRate: 9600, dataBits: 8, parity: none, stopBits: 1 }, (err) { if (err) { console.error(open failed:, err.message); return; } console.log(serial port opened); client.close(); });这段代码先实例化 ModbusRTU再调用 open 并传入串口路径和串口参数。open 的回调里必须先判断 err否则打开失败时程序会静默挂住后面所有请求都像石沉大海。参数里的 baudRate、dataBits、parity、stopBits 必须与从站设备铭牌或配置工具一致不能按主机侧习惯随意写。如果 open 成功说明进程已经拿到串口设备如果失败先排查路径是否存在、串口是否被其他程序占用再回头看代码。提示Linux 下 open 报 permission denied 时先确认当前用户是否在 dialout 组或检查 /dev/ttyUSB0 的权限再排查代码逻辑。3. 从 open 到 readHoldingRegistersRTU 模式读取与写入的完整路径3.1 串口参数和从站“对齐”不是玄学串口读取最常见的翻车点就是参数不一致。Modbus RTU 从站出厂默认一般是 9600 波特率、8 数据位、1 停止位、无校验也就是常说的 8N1。但设备经过现场配置后可能被改成 19200 或偶校验主机侧任何一项不一致都会导致收乱码或 CRC 校验失败。看到乱码时先别怀疑代码第一步永远是对参数。串口参数常见值注意事项baudRate9600 / 19200两侧必须一致改一处没用dataBits8Modbus RTU 基本都用 8paritynone / even偶校验要两侧同时开stopBits1个别设备用 2需看手册有些设备宣传说支持波特率自适应但那只对从站侧有效主机侧依然要固定一个波特率去握手。我一般会把 open 的 options 显式写全不依赖库默认值。实际排查时先用串口工具发一个固定请求帧比如读保持寄存器的标准帧01 03 00 00 00 01 84 0A看从站有没有回包回包正常但改到 modbus-serial 里读不到再回头查 parity 是否写成了字符串 none。这类问题通常不是协议层面的而是配置层面的低级失误。3.2 读保持寄存器完整代码与 buffer 解析串口打通后读保持寄存器是最常用操作。很多仪表把温度、压力、频率这类运行参数放在保持寄存器里通过功能码 0x03 读取。下面这段代码在 open 成功后读取两个寄存器并做 16 位与 32 位解析const ModbusRTU require(modbus-serial); const client new ModbusRTU(); client.setID(1); // 从站地址必须与设备拨码/配置一致 client.open(/dev/ttyUSB0, { baudRate: 9600, dataBits: 8, parity: none, stopBits: 1 }, err { if (err) throw err; client.setTimeout(1000); client.readHoldingRegisters(0, 2, (err, data) { if (err) { console.error(read failed:, err.message); client.close(); return; } const raw Buffer.from(data.buffer); // 转成标准 Buffer 便于解析 console.log(registers:, data.data); console.log(u16[0]:, raw.readUInt16BE(0)); console.log(u32be[0..1]:, raw.readUInt32BE(0)); // 高字在前时成立 client.close(); }); });代码里有三个关键点。第一setID(1) 设置的是从站单元号串口总线上每个从站必须有唯一地址。第二readHoldingRegisters(0, 2) 的第二个参数是寄存器个数而不是字节数设备手册写“读取 4 个寄存器”意味着读 4 个 16 位寄存器对应 8 字节而不是 4 字节。第三data.data 是现成的寄存器数值数组直接按索引取数需要自己拼原始字节时用 Buffer.from(data.buffer) 转成标准 Buffer再调用 readUInt16BE、readUInt32BE 这类方法。这里有一个经常绕晕人的地方readHoldingRegisters(0, 2) 在协议层发出的起始地址是 0x0000但有些仪表手册把寄存器编号写成 40001 这种 PLC 风格地址。PLC 编址体系里 40001 对应协议地址 0所以读 40005 实际要传 4而不是 5。这个“差一位”不是 Bug是两套编号体系换算的结果后面避坑部分会再展开。3.3 写操作writeCoil 与 writeRegister 的参数边界读之外另一个常碰到的动作是写单个线圈和写单个寄存器。启动信号这类开关量一般对应线圈用 writeCoil设定温度、给定速度这类 16 位数值一般对应保持寄存器用 writeRegister。示例client.writeCoil(0, true, (err, res) { if (!err res.value true) { console.log(coil 0 set to ON); } }); client.writeRegister(1, 50, (err, res) { if (!err) { console.log(register 1 write ok:, res.value); } });writeCoil 的回调里可以检查 res.value 是否与期望一致这是确认写入生效的简单办法writeRegister 同样会回读写入值。需要注意的是部分从站不支持写保持寄存器或者将写操作视为非法功能码收到 Illegal function 错误时去设备手册“寄存器类型定义”里确认当前地址是否真的可写。另一个容易踩的坑是数值范围writeRegister 超过 65535 会截断或抛错如果设备手册要求 32 位写入需要拆成高 16 位和低 16 位两个寄存器来写。我在某项目里把速度设定值 50000 直接写进 16 位寄存器结果被截断成 14464查了很久才发现是数值范围没对齐。4. 走网络链路Modbus TCP 的连接参数、单元号与轮询重连4.1 connectTCP 与监听端口先确认网络能 ping 通设备挂在以太网侧时用 connectTCP 替代 open。TCP 模式默认端口是 502但有些设备允许自定义端口网关也可能做端口映射。连接前我一般先执行 ping 和端口探测确认不是网络不可达再动代码。一个完整读取示例const ModbusRTU require(modbus-serial); const client new ModbusRTU(); client.setID(1); client.connectTCP(192.168.1.100, { port: 502 }) .then(() { console.log(tcp connected); client.setTimeout(2000); return client.readHoldingRegisters(0, 2); }) .then(data { console.log(tcp read:, data.data); client.close(); }) .catch(err { console.error(error:, err.message); });connectTCP 返回 Promise和 open 的回调风格不同用起来更顺手。这里有一个容易忽略的细节IP 相同但 setID 不同socket 照样能建立因为单元号只影响报文里的单元标识不影响连接本身。但 TCP 网关往往会把单元号映射到后级串口总线的从站地址所以 setID 仍然必须写对。Promise 写法适合短时验证脚本程序要长期运行建议监听 connected 事件做初始化读取不要把所有逻辑都堆在 then 链里。4.2 单元号一个常被忽略的设备身份位单台串口设备调试时setID(1) 随手一写反正总线上只有一台。但现场如果有多台从站并联或者 TCP 网关背后挂了多台串口表计单元号就是定位设备的唯一方式。常见错误有两种一是所有从站默认都设了 1导致总线冲突二是 TCP 网关配置的映射单元号与主机侧 setID 不一致表现为能连上但读到的是另一台设备的数据。排查单元号问题我会先把总线上只留一台从站用 setID(1) 收数据做基准再逐台加回总线确认每台的响应是否唯一。不要指望用软件去“扫描发现”设备地址多数从站地址由拨码开关或配置工具设定软件侧只能匹配不能猜测。遇到每台设备地址都不清楚时挨台断电上电是最快的定位方式。4.3 轮询与重连setInterval 的回退坑生产环境读设备一般不是读一次而是每隔几百毫秒或几秒轮询一次。用 setInterval 包一个 async 函数看似简单实际会碰到重入问题一次读取还没结束下一次定时器又触发了。Modbus 串口是半双工两个请求叠加会让从站无法区分帧边界。常见做法是加标志位跳过上一次未完成的操作或者直接用 setTimeout 在每次请求完成后重新计时。async function poll() { try { await client.readHoldingRegisters(0, 2); } catch (err) { console.error(poll error:, err.message); } finally { setTimeout(poll, 500); } } poll();这种 setTimeout 递归式轮询保证两轮读取之间至少相隔 500 毫秒不会在串口还在处理上一帧时发出新请求。TCP 模式下重连还要单独处理网关重启或网线被拔出后已有 socket 可能变成半开状态下一次请求直接报超时。我一般会在错误捕获里检查 client.isOpen为 false 就重新 connectTCP同时加最大连续失败次数防止断网后无限快速重连把日志刷爆。这些边界条件在简单脚本里可以忽略放到长期运行的服务里就必须逐条处理。5. 避坑排查从乱码到误读的六条实踩记录5.1 现象读回来的寄存器数据全是乱码或 NaN现象readHoldingRegisters 返回的 data.data 是 NaN或者打印 data.buffer 是一串无规律字节。原因主机与从站波特率、校验位不一致或 RS485 线 A/B 接反。RTU 对字节时序敏感波特率不匹配会直接破坏整个帧库无法解析出有效寄存器值。解决先用串口助手按设备手册参数发固定请求帧01 03 00 00 00 01 84 0A确认设备能回包再检查 USB 转 485 的 A/B 线序必要时物理调换线缆复测。软件侧则逐项核对 open options 与从站实际配置。注意有些 USB 转 485 模块的 A/B 标识和说明书相反这种问题用眼睛看是看不出来的只有换线序实测。现象open 成功但读取回调里稳定出现 CRC 错误。原因CRCERR 不一定是协议码写错更多时候是从站响应帧被总线干扰、RS485 地电位差导致字节位翻转或者从站上电过程中响应帧本身就不完整。现场多台设备共用电源时这类问题尤其明显。解决先抓原始响应帧连续读几十次看错误比例。偶发 CRC 错误优先考虑加地线、换带隔离的 USB-485 转换器每帧都 CRC 错检查线序和从站侧配置。曾经遇到一台仪表只在设备刚上电的前 5 秒回坏帧加了一个启动延时就好了。5.3 现象请求后等待很久才报 Timeout现象读取操作延迟几百毫秒甚至几秒最终抛 Timeout 错误。原因串口被其他程序占用、从站地址不对导致从站根本不回帧或 setTimeout 设得太小。还有一种情况是 RS485 总线上同时有多台从站地址冲突主机请求发出后多台设备同时抢发响应总线乱掉。解决先检查占用。Linux 下用 lsof 查看串口被哪个进程持有Windows 下看设备管理器里串口是否被串口助手占用。再 setID 是否正确最后把 setTimeout 放大到 2000ms 观察。地址冲突问题可以逐台断电找到重复地址的从站重新拨码。5.4 现象数值在合理范围但明显偏大或偏小现象温度表显示 25 度读出来是 50 或 2500数据本身不是乱码但量级不对。原因寄存器起始地址差一位手册从 1 开始计数而代码从 0 开始计数或者读错了寄存器类型把输入寄存器当成保持寄存器读。部分设备还会在寄存器里带小数点缩放系数比如实际值 25 度寄存器里存的是 2500。解决先确认手册编号体系40001 对应协议地址 040005 对应协议地址 4再分别读保持寄存器和输入寄存器做对比如果读出来是 2500检查手册里有没有“分辨率 0.01”这类说明换算系数通常写在量程表格里。5.5 现象TCP 连接正常但运行一段时间后全部超时现象程序刚启动时读写正常跑几个小时后所有请求全部超时重启进程又恢复。原因网关半开连接或链路长时间无通信被中间防火墙断开。setInterval 轮询如果没有重连逻辑socket 处于半开状态时不会自动恢复请求一直发在已经断掉的连接上。解决在 catch 中检查 client.isOpen为 false 就重新 connectTCP并带指数退避。连续失败 5 次后把重连间隔从 1 秒拉到 10 秒、30 秒避免不断快速重连把网关打挂。另外定期给连接做心跳读取比等失败再重连更稳。5.6 现象读出来的 32 位浮点数完全不对现象两个寄存器拼出来的浮点和设备显示值差得离谱有时是负数有时是天文数字。原因字节序和字序不匹配。多数设备按高字在前存储也就是寄存器顺序是 [高 16 位, 低 16 位]直接用 readUInt32BE 即可但也有设备低字在前。寄存器内部的字节序也可能被配置成大端或小端。解决把 buffer 分别用 readUInt32BE 和 readUInt32LE 解析打印十六进制对比。比如 0x41200000 转成 float 是 10.0如果顺序反了就会得到完全不同的值。确定字序后再做一次 buf.swap32() 或者交换寄存器顺序把这个处理固化在解析函数里不要每次读现场再猜。6. 快速定位根因原始帧打印与 Promise 化超时包装6.1 裸链路调试先看原始帧再做业务封装读不到数或数值不对的时候只看 err.message 能知道失败类型但看不到从站到底回了什么。modbus-serial 底层串口收到每个字节都会触发 data 事件调试时监听一下把原始十六进制帧打出来比对着手册猜状态更直接client._port.on(data, chunk { console.log(raw frame:, chunk.toString(hex)); });一个完整的 RTU 响应帧通常是“地址功能码字节数数据CRC”比如01 03 04 41 20 00 00 7B 66。看到这个结构就能立刻确认从站确实回了 4 字节数据剩下的只是字节序和浮点转换问题。注意_port是内部属性不建议写进生产代码只适合脚本调试阶段。经常遇到想在回调里自己控制超时的情况。modbus-serial 的 setTimeout 是库层面的为了统一业务侧的错误处理我会再包一层 Promise放进工具目录function readRegs(client, addr, len, timeout 1500) { return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(timeout)), timeout); client.readHoldingRegisters(addr, len, (err, data) { clearTimeout(timer); err ? reject(err) : resolve(data.data); }); }); }clearTimeout 必须放在回调开头避免超时后回调又触发 resolve造成重复 settletimeout 参数根据设备手册的响应时间调整。包装后业务代码直接await readRegs(client, 0, 2)出错时统一进 catch代码结构和排错路径都清晰很多。从那以后我每次接新设备都会先跑一遍裸链路测试用 open 打通串口、监听原始帧、逐条核对寄存器映射然后再加入业务封装和轮询逻辑。这个顺序帮我避开了大部分“为什么读不到”的夜间排查。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑