ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

modbus-serial:Node.js直连PLC与仪表的Modbus通信库

modbus-serial:Node.js直连PLC与仪表的Modbus通信库

简介:Modbus-Serial 是一个基于 Node.js 的开源 Modbus 协议库资源包,面向需要在前端或 Node.js 环境中与 PLC、电表、传感器等工业设备进行数据交互的开发者。资源以 zip 压缩包形式提供,共含 57 个文件,其中 46 个 JavaScript 文件构成通信核心,3 个 TypeScript 声明文件提供类型定义,另有 JSON 配置、Shell 脚本、license 和 readme 等配套,压缩包整体约 70 KB,轻量且便于阅读。包内覆盖 Modbus-RTU、ASCII 和 TCP 等通信方式,支持对线圈、离散输入、输入寄存器及保持寄存器进行读写,并提供服务端模拟、轮询模式、缓冲区操作、异步 Promise 封装等可直接运行的示例,同时内置错误处理、日志调试和 CRC/LRC 校验工具,有助于在真实项目中快速定位串口或网络通信问题。通过阅读源码与示例,读者可以掌握 Modbus 协议帧格式、地址与功能码的用法,理清串行与 TCP 通信的差异,并学会建立连接、收发数据和容错处理;资源目录采用模块化组织,区分核心源码、示例脚本、测试用例与工具函数,便于按需检索。目前已有 1591 人学习下载,适合物联网、能源管理和环境监测等场景的前端开发者,既可快速搭建设备监控面板,也可为网关或数据采集系统提供参考。

1. 前端开源库 modbus-serial:让 Node 直连 PLC、变频器与智能仪表

在生产现场调试设备的时候,我常被同一个问题卡住:PLC 和变频器的数据就挂在 Modbus 总线上,手边的上位机软件要么收费、要么重得跑不动。前端开源库 modbus-serial 解决的就是这件事,它是纯 JavaScript 实现的 Modbus 客户端,Node 里既能走串口(RS232/RS485),也能走 TCP 网口,浏览器里配 WebSerial 同样能直连设备。它适合两类开发者:一类做设备数据采集、生产看板、能耗监控,需要把电表、温控器的寄存器读进自己的系统;另一类是搞自动化集成的软件工程师,想快速验证协议报文,不想为一个小需求去装组态软件。我在一个模拟项目X中第一次用它替代串口调试助手做协议验证,后来直接沿用到产线数据采集中。

2. 架构设计:传输层抽象与协议编解码的分层思路

这个库最值得学的地方,是把「传输」和「协议」拆成了两层。Client 类只负责按 Modbus 规范组帧、发请求、解析响应,至于字节到底从串口线出来还是从网口出来,它一概不关心。这种分层让同一套业务代码可以无缝切换通信介质,也让我后来在接各种工业网关时省了大量重复代码。

2.1 依赖生态:serialport 与 net 双通道

串口侧依赖 serialport 这个原生模块,TCP 侧走 Node 自带的 net 模块。为了屏蔽两者的差异,ports 目录下每个端口类都实现同一组方法,典型的接口长这样:

// ports 目录下端口类的统一接口 class Port { async open() { /* 打开串口 / Socket */ } async close() { /* 关闭连接 */ } async write(data) { /* 发送原始字节 */ } async read() { /* 读取原始字节 */ } }

Client 在发起读写时,调用的是 port.write 和 port.read,而不是直接操作串口句柄。这样串口和 TCP 的差异被压缩到了 open 和初始化参数里。较新版本里支持的传输通道可以整理成下面这张表:

端口类连接方法典型使用场景
serialport.jsconnectSerialPort / connectRTUBufferedRS232/RS485 仪表、变频器
tcpport.jsconnectTCPPLC 网口、Modbus 网关
tcprtubuffered.jsconnectTcpRTUBufferedRTU 帧透传到 TCP 网关
udprtubuffered.jsconnectUDP老旧设备的 UDP 透传
websocketport.jsconnectWebSocket协议网关转 WebSocket
webserialport.jsconnectWebSerial浏览器直连串口设备

实际选择时,我一般遵循一个原则:如果现场设备是 RS485 总线,直接选 RTUBuffered 系列;如果是网口网关,优先选 TCP。RTUBuffered 的意思是串口数据会经过一个内部缓冲拼接,因为串口传输是分片的,一个完整的 Modbus 帧可能分好几次到达,buffered 模式负责攒够长度再交给协议层解析。

2.2 目录结构与模块职责

把源码包解开之后,目录结构大致是这样:

modbus-serial/ ├── index.js # 入口文件,导出 Client 类 ├── client.js # 核心客户端:组帧、收发、解析、超时 ├── crc16.js # CRC16 校验算法,独立成模块 ├── codes/ │ ├── codes.js # 功能码定义,功能码到内部操作名的映射 │ └── exceptions.js # Modbus 异常码定义 ├── ports/ # 各种传输层端口实现 │ ├── serialport.js │ ├── tcpport.js │ ├── tcprtubuffered.js │ ├── udprtubuffered.js │ ├── telnetport.js │ └── webserialport.js ├── utils/ │ └── buffer-to-array.js # Buffer 与普通数组互转 ├── test/ # 单元测试,包含一个模拟从站 ├── examples/ # 官方使用示例 └── package.json

client.js 是绝对的核心,里面包含了请求的构建、超时管理、响应解析。crc16.js 独立出来很有价值,因为 Modbus 的 CRC 算法在很多场景都要单独用,拆出来方便复用和单测。codes/codes.js 里维护了一张功能码映射表,它决定了调用 readHoldingRegisters 时底层组装的是 3 号功能码的帧:

// codes/codes.js — 功能码到内部操作名的映射 const codes = { 1: "readCoil", // 读线圈状态 2: "readDiscreteInput", // 读离散输入 3: "readHoldingRegister", // 读保持寄存器 4: "readInputRegister", // 读输入寄存器 5: "writeSingleCoil", // 写单个线圈 6: "writeSingleRegister", // 写单个寄存器 15: "writeMultipleCoil", // 写多个线圈 16: "writeMultipleRegister" // 写多个寄存器 };

也就是说,你在 client 上调用 readHoldingRegisters,底层实际上是根据功能码 3 这个数字去组帧的。这种以功能码为主键的设计让新增协议扩展变得非常直接,后面讲二次开发时我会展开。

2.3 协议帧格式与编解码流程

Modbus 有 RTU 和 TCP 两种最常见的帧格式,这个库对两者分别处理。RTU 帧结构是:从站地址 + 功能码 + 数据字段 + CRC16,其中 CRC 占两个字节,低字节在前。以读保持寄存器功能码 3 为例,请求帧长这样:

字段长度示例值说明
从站地址1 字节0x01目标从站 ID
功能码1 字节0x03读保持寄存器
起始地址2 字节0x0000从 0 号寄存器开始
寄存器数量2 字节0x000A读 10 个寄存器
CRC162 字节0x840A对前 5 字节计算

TCP 模式则套了一层 MBAP 报文头:事务标识 2 字节、协议标识 2 字节、长度 2 字节,再加上从站地址和功能码。TCP 帧不带 CRC,因为长度字段已经能界定帧边界,且 TCP 本身有链路层校验。所以同一个 readHoldingRegisters 方法,在 RTU 和 TCP 两种模式下走的是两套不同的组帧逻辑,这也就是为什么端口类要分 RTUBuffered 和普通 TCP。

编解码的整体流程可以概括为四个步骤:第一步,Client 根据功能码拼出请求字节;第二步,交给端口类把字节写出去;第三步,从端口读回响应字节,按帧格式切出从站地址、功能码和数据字段;第四步,把数据字段按 16 位大端序解析成 number 数组,交给回调或 Promise。这个流程贯穿了整个库的设计,看懂了它,也就看懂了 client.js 的绝大部分代码。

3. 核心模块拆解:一次读请求从组帧到解析的完整链路

这一章把 client.js 的关键路径拆开,从连接初始化一直讲到响应数据回到业务代码手里。理解了这条链路,你排查问题时就有方向了:出问题先判断是连接没建立、帧没组对,还是响应没解析对。

3.1 连接初始化:串口与 TCP 的差异点

先看两种最常用的连接方式放在一起的对比:

const ModbusRTU = require("modbus-serial"); // 串口 RTU 模式,RS485 仪表最常见 const client = new ModbusRTU(); client.connectRTUBuffered("/dev/ttyUSB0", { baudRate: 9600, dataBits: 8, stopBits: 1, parity: "none" }, (err) => { if (!err) console.log("串口连接成功"); }); // TCP 模式,PLC 网口或 Modbus TCP 网关 const tcpClient = new ModbusRTU(); tcpClient.connectTCP("192.168.1.20", { port: 502 }, (err) => { if (!err) console.log("TCP 连接成功"); });

connectRTUBuffered 第一个参数是串口设备路径,Linux 下通常是 /dev/ttyUSB0 或 /dev/ttyS0,Windows 下是 COM3 这样的写法。第二个参数是串口配置对象,baudRate 波特率必须和从站一致,最常见的是 9600,也有 19200 和 115200;parity 校验位可选 "none"、"even"、"odd"。这几项只要有一处和从站配置不一致,就会出现能连接但读不到数据的怪现象。

connectTCP 的参数则简单得多,host 填设备的 IP,port 默认 502。连接成功后还需要调用 setID 设置从站地址,这个地址对应帧里的从站地址字段,范围是 1 到 247。如果设备和它所在的网关已经固定了唯一地址,这一步很容易被忽略,忽略之后最常见的表现就是请求发出去但永远读不到响应,因为从站认为这个帧不是发给它的。

3.2 CRC 校验与字节序处理

RTU 模式没有 TCP 那种 MBAP 长度字段,从站靠 CRC 判断一个帧是否完整、是否有传输错误。crc16.js 实现的是标准 Modbus CRC16,核心代码不长:

// crc16.js — Modbus CRC16 计算 function crc16(buffer) { let crc = 0xFFFF; for (let i = 0; i < buffer.length; i++) { crc = crc ^ buffer[i]; // 与当前字节异或 for (let j = 0; j < 8; j++) { // 逐位处理 const lsb = crc & 0x0001; // 取出最低位 crc = crc >> 1; // 右移一位 if (lsb) { crc = crc ^ 0xA001; // 多项式异或 } } } // Modbus 规定 CRC 低字节在前 return Buffer.from([crc & 0xFF, (crc >> 8) & 0xFF]); }

算法的重点是 0xA001 这个多项式,它是标准 CRC16 多项式 0x8005 的反转形式,Modbus 协议族固定用它。最后的低字节在前是一个容易踩坑的细节,很多人在别的项目里做 CRC 校验时字节序写反了,导致自己算的和设备返回的对不上。这两个字节会被拼在请求帧末尾,从站校验通过才会执行命令。

字节序这个词在 Modbus 开发里反复出现。寄存器数据在报文里是 16 位大端序,也就是高位字节在前。库在解析响应时按大端序把两个字节拼成 number,你在业务侧看到的就是一个整数。但有些设备内部是按小端存数据的,比如某个寄存器存储 0x1234,协议报文中发的是 0x12 0x34,而设备文档里写的是按低字在前。这种情况不是库的 bug,你需要自行做一次字节交换,常见做法是(value >> 8) | ((value & 0xFF) << 8)。

3.3 响应解析与 Promise/callback 双模式

读请求发出去之后,库内部要做的事可以简化成下面这段逻辑:

// client.js 中读操作的高度简化版 readHoldingRegisters(address, length, callback) { const frame = buildRequest(this._slave, 3, address, length); this.write(frame, (err, response) => { if (err) { callback(err); return; } const values = parseResponse(response, 3); callback(null, { data: values, // number 数组 buffer: response, // 原始报文 Buffer,方便排查 slave: this._slave // 本次请求的从站地址 }); }); }

返回对象里三个字段各有用途:data 是解析后的寄存器数值数组;buffer 是完整响应报文,当 data 里的值和设备文档对不上时,拿 buffer 对照协议逐字节核对;slave 是请求时设置的从站地址。我调试时经常把 buffer 打印出来,用模组文档对照着看,基本能定位是设备响应异常还是库解析异常。

这个库同时支持回调和 Promise 两种用法。不传 callback 时,方法会返回一个 Promise,配合 async/await 写起来很舒服:

const res = await client.readHoldingRegisters(0, 10); console.log(res.data); // 例如 [0, 0, 100, 120, 0, ...]

超时处理也统一在 Client 里做,setTimeout 传入毫秒数,超时后 Promise 会 reject,回调版本则在 err 里携带超时错误。需要注意超时是整个请求周期的超时,不包括连接建立的时间,所以在网络状况差的环境下,超时时间不要设得太短,否则设备只是慢了一点,你这边已经报错了。

4. 项目落地:在 Node 环境跑通第一个 Modbus 从站

理论看完,直接上实操。这一章从零开始建立一个小项目,先读通、再写入,最后用虚拟从站做本地联调。整个过程依赖一份可复现的脚本,不需要真实硬件也能完成。

4.1 环境准备与最小安装

项目初始化最简单的方式是用 npm:

mkdir modbus-demo && cd modbus-demo npm init -y npm install modbus-serial

安装完成后检查一下 node_modules 里有没有编译产物。因为串口依赖是原生模块,在某些精简环境里安装时会触发编译,如果报缺少编译器,先在系统里装好 Python 和 C++ 构建工具再重装。如果你的场景只走 TCP 不碰串口,npm 安装时仍会把这个原生依赖一起拉下来,这是这个库依赖设计的现状,不算 bug,但可以在文档里提前知晓,免得部署到瘦身环境时被编译步骤吓到。

4.2 读写保持寄存器:一份可抄作业的脚本

下面是连接 Modbus TCP 从站、读保持寄存器、写单个和多个寄存器的完整脚本:

// demo.js — Modbus TCP 读写保持寄存器 const ModbusRTU = require("modbus-serial"); const client = new ModbusRTU(); const SLAVE_ID = 1; async function main() { // 1. 建立 TCP 连接 await client.connectTCP("192.168.1.20", { port: 502 }); client.setID(SLAVE_ID); client.setTimeout(2000); // 2. 从 0 号寄存器开始连续读 10 个 const res = await client.readHoldingRegisters(0, 10); console.log("寄存器值:", res.data); // 3. 写单个寄存器(地址 10,值 1000) const writeRes = await client.writeSingleRegister(10, 1000); console.log("写入结果:", writeRes); // 4. 从 20 号地址连续写 3 个寄存器 await client.writeRegisters(20, [1, 2, 3]); client.close(); } main().catch((err) => { console.error("运行失败:", err.message); client.close(); process.exit(1); });

connectTCP 建立连接后,setID 指定从站地址,setTimeout 设置单次请求超时为 2 秒。readHoldingRegisters 的两个参数分别是起始地址和数量;writeSingleRegister 的参数是寄存器地址和写入值;writeRegisters 的第二参数是数值数组,一次写多个连续寄存器。所有方法在不传回调时都返回 Promise,所以可以用 await 串成同步风格,可读性比回调嵌套高不少。

每个方法对应的协议行为可以整理成一张参数速查表:

方法参数对应功能码说明
readHoldingRegistersstart, count3读保持寄存器
readInputRegistersstart, count4读输入寄存器
readCoilsstart, count1读线圈状态
readDiscreteInputsstart, count2读离散输入
writeSingleRegisteraddr, value6写单个寄存器
writeRegistersaddr, values16写多个寄存器
writeSingleCoiladdr, value5写单个线圈
writeCoilsaddr, values15写多个线圈

业务上最常见的是读保持寄存器和输入寄存器,前者是 PLC 里可读写的变量区,后者通常是设备采集量,只能读不能写。写操作要格外谨慎,因为 Modbus 从站一般没有写保护,写错地址可能把设备参数改坏,我在真实项目里习惯先把写目标寄存器地址和值的上限在代码里用常量约束住。

提示:真实设备上的写操作没有默认撤销机制,动手之前先确认寄存器地址范围和写入值不超过设备文档允许的上限。

4.3 虚拟从站联调:没有硬件也能开发

没有真实设备时,可以用 Node 的 net 模块写一个极简的 Modbus TCP 从站做联调。下面这个脚本监听 502 端口,只处理功能码 3 的读请求,返回的寄存器值等于起始地址加上偏移量:

// mock-slave.js — 用 net 模拟一个 Modbus TCP 从站 const net = require("net"); const server = net.createServer((socket) => { socket.on("data", (buffer) => { const funcCode = buffer[7]; // MBAP 头 7 字节后是功能码 if (funcCode === 0x03) { const start = buffer.readUInt16BE(8); // 起始地址 const count = buffer.readUInt16BE(10); // 寄存器数量 const data = Buffer.alloc(count * 2); for (let i = 0; i < count; i++) { data.writeUInt16BE(start + i, i * 2); } // 响应:事务ID + 协议ID + 长度 + 从站 + 功能码 + 字节数 + 数据 const header = Buffer.from([ buffer[0], buffer[1], 0x00, 0x00, 0x00, 0x03 + count * 2, buffer[6], 0x03, count * 2 ]); socket.write(Buffer.concat([header, data])); } }); }); server.listen(502, () => { console.log("模拟从站已监听 502 端口"); });

MBAP 报文头共 7 个字节:2 字节事务标识、2 字节协议标识、2 字节长度、1 字节从站地址,所以功能码在索引 7 的位置,起始地址在索引 8 到 9,寄存器数量在索引 10 到 11。响应里的长度字段计算是 3 加数据字节数,3 是功能码和字节计数字段本身占的长度。一个事务标识在 Modbus TCP 里用来匹配请求和响应,联调脚本里直接原样返回即可。

起两个终端,分别运行node mock-slave.js和node demo.js,demo 那边的输出应该是寄存器值[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],这样就能在完全没有硬件的情况下跑通整个读写链路,后面接真实设备时只要把 IP 和参数改成设备的实际值。

5. 避坑排查:串口开发最容易翻车的 5 个问题

这一章是我在多个现场项目里踩出来的经验汇总。每一条都按现象、原因、解决三个层次写,方便你在遇到同样问题时直接对号入座。

5.1 串口打不开或报 permission denied

现象:Linux 下 connectRTUBuffered 打开 /dev/ttyUSB0 时直接报错,错误信息类似 EACCES permission denied,但在 root 用户下却能正常连接。

原因:当前用户没有串口设备的访问权限。绝大多数发行版把串口设备归到 dialout 用户组,普通用户默认不在该组里。

解决:把当前用户加入 dialout 组后重新登录,命令是sudo usermod -a -G dialout 用户名。Windows 下同类问题通常是驱动没装好或串口号被占用,先用设备管理器确认真实 COM 号,再检查是不是有串口调试工具占着口没有释放。这个坑几乎每个刚开始做串口开发的人都会遇到,属于环境问题而不是代码问题。

5.2 CRC 校验失败与字节序玄学

现象:读请求发出去后,从站不响应,或者在调试工具里看到响应报文能收到但库报校验错误;另一类现象是寄存器读出来的值明显不对,比如文档写 100,读出来是 25600。

原因:CRC 是对组帧前的字节序列计算的,如果请求帧里地址或数量字段填错,计算出的 CRC 对不上;字节序问题是设备按小端序存放数据,而 Modbus 报文按大端序传输,导致高低字节互换。

解决:先拿协议文档人工组一帧,用支持 CRC 计算的串口工具验证自己的计算和工具一致,确认没问题再排查代码。数据值不对时,先打印 res.buffer 对照文档确认报文字节,再做一次高低字节交换。这两个问题排在一起出现尤其迷惑,我第一次遇到时也以为是库算错了 CRC,最后发现是设备文档里寄存器地址标的是十进制,我没换算就直接用了。

5.3 "Port is not open" 的异步时序问题

现象:代码里 connect 之后立刻调用 readHoldingRegisters,偶尔抛出 Port is not open 错误,多跑几次就出现一次。

原因:connect 虽然是回调风格 API,但在某些版本下回调触发时序和底层串口完全打开之间存在竞态。你前一行刚发起连接,后一行已经发请求,底层端口还没准备好。

解决:把连接和读写都放进 async 流程里,用 await 保证连接完成再发请求;也可以用 Promise 封装连接回调。还有一个习惯:不要在业务里反复创建新 Client,一个 Client 对应一条总线路由,复用同一个实例,连接建立一次即可。这类时序问题在串口和 TcpRTUBuffered 模式下都比较常见,属于典型的「代码看逻辑没问题,跑起来偶尔犯病」。

5.4 多从站轮询丢包与报文交叉

现象:脚本里循环轮询 1 到 5 号从站,起初正常,跑几分钟后某个从站开始随机读不到,报超时;用真实串口工具抓包发现总线上有交叉的报文。

原因:RS485 是半双工总线,同一时刻只允许一个节点发言。请求返回到下一次请求之间如果间隔太短,设备可能还在释放总线,或者主站发出的下一帧还没有完全发完,接收缓冲区里残留了上一帧的尾部数据,造成字节错位。

解决:在两个请求之间加一个固定延时,常见做法是 50 到 100 毫秒,视设备型号而定。更稳健的做法是读响应完成后再延时,而不是固定循环节拍。另外把超时错误和总线上其他错误分开捕获,单个从站超时不要中断整条轮询链路,我在代码里习惯给每个从站各自 try/catch,一台设备掉线不影响其他设备继续采集。

5.5 浏览器端 WebSerial 的兼容性差异

现象:同一个库,在 Node 里跑得好好的,搬到浏览器里用 WebSerial 连接,要么找不到串口授权弹窗,要么连接行为不同。

原因:浏览器端走的是 WebSerial API,它必须先经过一个请求授权的用户交互过程,没有这个授权就无法打开串口。而且 WebSerial 的串口体验和 Node 下 serialport 并不完全一致,比如某些字节流处理方式受限。

解决:不要把 Node 的启动流程照搬到浏览器。浏览器端代码先绑定一个按钮触发navigator.serial.requestPort()拿到端口实例,再传入库的 WebSerial 端口类完成连接,同时处理好协议需要时重新授权的逻辑。我的习惯是浏览器端只在低并发、单设备的场景使用,多从站轮询和密集采集还是交给 Node 端服务,浏览器负责展示和写入。

6. 二次开发指南:扩展功能码与优化轮询策略

6.1 新增自定义功能码的扩展方式

当设备用到标准功能码之外的自定义码时,直接改库源码比在业务层硬拼 Buffer 更干净。常见做法是把包 fork 下来,在 codes.js 里追加映射,再在 client.js 的原型上挂对应方法。比如一些电表支持 0x2B 读设备标识:

// fork 后的扩展:codes.js 中增加映射 codes[0x2B] = "readDeviceIdentification"; // client.js 中新增方法 Client.prototype.readDeviceIdentification = function () { const frame = Buffer.from([this._slave, 0x2B, 0x0E, 0x03, 0x00]); return this.write(frame).then((response) => { return { data: response, buffer: response }; }); };

这种做法的好处是组帧、超时、响应读取这些机制全部复用库本身的,你只需要关心新功能码的报文结构。国内很多设备厂商的私有协议都是在标准 Modbus 帧上扩展功能码,看懂这个扩展点,就能接进任何基于 Modbus 的设备。

6.2 轮询策略与并发控制

另一个常见的改造点是轮询策略。默认的读法是一个请求等一个响应,网关项目里从站一多,效率就上不来。我常用的方式是固定时序轮询加每站独立异常捕获:

// 多从站轮询,带固定延时和独立异常处理 const stations = [1, 2, 3]; async function pollAll() { for (const id of stations) { client.setID(id); try { const res = await client.readHoldingRegisters(0, 20); console.log(`从站 ${id} 首寄存器:`, res.data[0]); } catch (err) { console.error(`从站 ${id} 读取失败:`, err.message); } await sleep(100); // 留给总线的释放时间 } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }

这套模式我在多个采集项目里一直沿用。从那以后我每次接新设备,都强制先写一个最小通信用例把寄存器表跑通,再进业务代码,避免把协议问题和业务问题混在一起排查。希望帮到你。

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

RELATED READING

延伸阅读

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