简介:这份资源是 NodeJS 环境下 MODBUS-RTU 与 MODBUS-TCP 协议的纯 JavaScript 实现,基于 node-serialport 构建,面向需要与 RS485、RS232 等串行设备或工业电子设备通信的开发者,也适合用 Arduino 控制机器人、灌溉控制器等场景的入门与进阶用户。压缩包共 65 个文件,以 52 个 js 源码为主,辅以 3 个 ts 类型声明、2 个 yml 工作流配置、2 个 md 说明文档及 json、sh 等辅助文件,整体约 84KB,体积轻量却覆盖完整。目录中 ports 模块提供 rtubufferedport、tcpport、udpport、c701port 等多种端口实现,apis 封装连接与 promise 调用,utils 内含 crc16、lrc 校验工具,examples 则给出读写、轮询、服务端等示例脚本,便于快速理解调用方式。目前已有 2620 人学习下载,读者可借此掌握 Modbus 主从通信的 JavaScript 落地方法,直接复用端口抽象、校验算法与示例代码,减少从零搭建通信层的时间成本。
1. 为什么我最终把 Modbus 采集服务压进了 Node.js 进程
三年前接手一个产线数据采集项目时,我的第一反应是用 Python 写 Modbus 轮询脚本,毕竟 pymodbus 生态成熟、示例多。但那个项目的前端是 Electron 做的看板,后端已经有一套 Node.js 的 MQTT 网关在跑,如果再塞一个 Python 进程进去,部署、守护、日志、打包全要维护两套。后来我换了个思路:既然 Node.js 本身就能做串口和 TCP 通信,为什么不用纯 JavaScript 直接实现 Modbus 协议栈?node-modbus-serial就是在这个背景下进入视野的——它用纯 JavaScript 实现了 MODBUS-RTU 和 MODBUS-TCP 的客户端(以及部分服务端)能力,不依赖任何原生编译模块,npm install完就能跑,这在跨平台交付时省掉了大量交叉编译的麻烦。
这篇文章面向的是需要把 Modbus 设备数据接进 Node.js 服务的工程师:你可能在做工业看板、边缘网关、设备管理后台,或者只是想让一个 Node 脚本定时读几个寄存器。我会从协议栈选型讲到 RTU 串口和 TCP 两种链路的实际代码,再讲参数怎么调、坑在哪,最后给一个能直接抄的轮询采集模板。读完你应该能判断这个方案值不值得用,以及怎么在自己的环境里跑通。
2. 纯 JS 实现 Modbus 协议栈:选型理由与核心对象拆解
2.1 为什么不用原生模块,纯 JS 的代价和收益
Modbus 协议本身并不复杂,RTU 就是在串口上按帧发字节,TCP 就是去掉 CRC 校验、加个 MBAP 头。真正麻烦的是串口访问——传统方案要么用serialport这种带原生绑定的库,要么用 C++ 扩展。node-modbus-serial的做法是把协议层和传输层解耦:协议层是纯 JS,传输层通过「端口」抽象来适配,RTU 走串口、TCP 走 socket。它内部依赖serialport做串口读写,但协议编解码、CRC 计算、超时重试全是 JS 实现的。
收益很直接:协议逻辑可调试、可单测,出问题时你能在 JS 层打断点看每一帧字节。代价是串口那层仍然依赖原生模块,所以「纯 JS」指的是协议栈,不是整个链路零原生依赖。这一点很多介绍里含糊带过,实际部署时如果目标平台没有预编译的serialport二进制,还是要处理编译问题。我的经验是:x86 Linux 和 Windows 基本无痛,ARM 板子上偶尔要手动指定serialport版本。
2.2 核心对象:ModbusRTU、ModbusTCP 与 Port 抽象
库暴露的主要入口是ModbusRTU和ModbusTCP两个类,它们都继承自同一个基类,共享读写方法。RTU 需要你传入一个串口路径和参数,TCP 需要 host 和 port。核心方法就那几个:
| 方法 | 作用 | 常用参数 |
|---|---|---|
readHoldingRegisters(addr, len) | 读保持寄存器(功能码 03) | 起始地址、寄存器数量 |
readInputRegisters(addr, len) | 读输入寄存器(功能码 04) | 同上 |
readCoils(addr, len) | 读线圈(功能码 01) | 起始地址、位数量 |
writeRegister(addr, val) | 写单个保持寄存器(功能码 06) | 地址、值 |
writeRegisters(addr, values) | 写多个保持寄存器(功能码 16) | 地址、值数组 |
setID(id) | 设置从站地址 | 1~247 |
这些方法都返回 Promise,超时或异常会 reject。理解 Port 抽象很关键:RTU 模式下库内部会打开串口并管理帧间隔,TCP 模式下它维护一个 socket 连接池。你不需要手动拼帧,但需要理解「一次请求对应一次 Promise」这个模型,后面讲并发和轮询节奏时都围绕它。
2.3 安装与最小可运行环境
先建一个干净目录,初始化并安装:
mkdir modbus-demo && cd modbus-demo npm init -y npm install node-modbus-serial如果你要用 RTU,还需要确认serialport被正确安装。安装完可以跑一个探测脚本,列出本机串口:
// list-ports.js const { SerialPort } = require('serialport'); // 列出当前系统识别到的串口设备 SerialPort.list().then(ports => { ports.forEach(p => { console.log(p.path, p.manufacturer, p.serialNumber); }); });逻辑说明:SerialPort.list()返回系统串口列表,path是设备路径(Windows 下形如COM3,Linux 下形如/dev/ttyUSB0),manufacturer和serialNumber用来区分多个同型号 USB 转串口设备。参数上没什么可调的,但要注意 Linux 下普通用户可能没有串口读写权限,需要把用户加入dialout组,否则打开串口会报Permission denied。这一步不做,后面所有 RTU 代码都会卡在打开端口失败。
3. 用 node-modbus-serial 跑通 RTU 与 TCP 两条链路
3.1 RTU 串口参数:波特率、数据位、校验位、停止位怎么对
RTU 最容易翻车的地方不是代码,是串口参数和设备手册对不上。先看一段标准连接代码:
// rtu-read.js const ModbusRTU = require('modbus-serial'); const client = new ModbusRTU(); // 串口参数必须与从站设备手册完全一致 const portConfig = { path: '/dev/ttyUSB0', // Windows 下改为 'COM3' baudRate: 9600, // 常见 9600 / 19200 / 38400 / 115200 dataBits: 8, // 几乎固定为 8 stopBits: 1, // 常见 1,少数设备用 2 parity: 'none', // 'none' | 'even' | 'odd' }; async function main() { await client.connectRTUBuffered(portConfig.path, portConfig); client.setID(1); // 从站地址,必须与设备一致 client.setTimeout(1000); // 单次请求超时,毫秒 try { // 读保持寄存器:起始地址 0,读 10 个 const data = await client.readHoldingRegisters(0, 10); console.log('寄存器原始值:', data.data); console.log('缓冲区:', data.buffer); } catch (err) { console.error('读取失败:', err.message); } finally { client.close(); } } main();逻辑说明:connectRTUBuffered是推荐用法,它内部用缓冲区处理串口数据流,比早期的connectRTU更稳。setID设置从站地址,setTimeout设置单次请求超时。readHoldingRegisters(0, 10)里的 0 是协议地址,注意很多设备手册写的是「40001」这种 Modbus 数据模型地址,对应协议地址要减 1,也就是 40001 对应地址 0。这个偏移是新手最常踩的坑,读出来全是错位数据往往就是这里差了 1。
参数上,baudRate、parity、stopBits必须和从站设备一致,不一致的表现是超时或 CRC 错误。dataBits基本都是 8。如果设备手册写「8N1」,就是 dataBits 8、parity none、stopBits 1。
3.2 TCP 链路:MBAP 头、单元标识与连接复用
TCP 模式简单很多,没有波特率这些物理参数,但要注意单元标识(Unit ID)和 MBAP 头:
// tcp-read.js const ModbusRTU = require('modbus-serial'); const client = new ModbusRTU(); async function main() { // TCP 连接:host、port,options 里可设超时 await client.connectTCP('192.168.1.100', { port: 502 }); client.setID(1); // 单元标识,TCP 网关下常用来区分后端从站 client.setTimeout(2000); try { const data = await client.readInputRegisters(0, 6); console.log('输入寄存器:', data.data); } catch (err) { console.error('TCP 读取失败:', err.message); } finally { client.close(); } } main();逻辑说明:connectTCP建立 socket 连接,默认端口 502。setID在 TCP 下对应 MBAP 头里的单元标识,如果你直连的是一个 Modbus TCP 设备,这个值通常是 1 或 255;如果连的是串口服务器/网关,这个值对应网关后面挂的从站地址。readInputRegisters用功能码 04,和保持寄存器的 03 区别在于输入寄存器通常只读。
连接复用方面,node-modbus-serial的 TCP 客户端会保持 socket,你可以连续发多个请求,不用每次重连。但要注意:如果中间网络断开,socket 可能进入半开状态,后续请求会一直超时。我的做法是加一个健康检查,连续失败 N 次就close()再重连。
3.3 数据类型转换:16 位寄存器怎么变成浮点数和整数
Modbus 寄存器是 16 位的,但实际设备里可能是 32 位整数、浮点数,甚至 64 位。库返回的是原始 16 位数组,转换要自己做:
// decode.js // 把两个 16 位寄存器按大端拼成 32 位浮点数 function registersToFloat(regs, offset = 0) { const buf = Buffer.alloc(4); buf.writeUInt16BE(regs[offset], 0); buf.writeUInt16BE(regs[offset + 1], 2); return buf.readFloatBE(0); } // 把两个 16 位寄存器拼成 32 位有符号整数 function registersToInt32(regs, offset = 0) { const buf = Buffer.alloc(4); buf.writeUInt16BE(regs[offset], 0); buf.writeUInt16BE(regs[offset + 1], 2); return buf.readInt32BE(0); } // 示例:假设读到 [0x41A0, 0x0000] const regs = [0x41A0, 0x0000]; console.log('浮点数:', registersToFloat(regs)); // 20 console.log('整数:', registersToInt32(regs));逻辑说明:关键是字节序。Modbus 本身规定寄存器内部是大端,但两个寄存器谁在前谁在后(字序)由设备厂商决定,常见有「高字在前」和「低字在前」两种。上面代码假设高字在前。如果读出来数值离谱,先把两个字交换再试。浮点数用readFloatBE,整数用readInt32BE,无符号用readUInt32BE。这个转换逻辑建议封装成工具函数,因为一个项目里往往有十几种点位,每种的数据类型和字序都可能不同。
4. 轮询采集服务的工程化:并发、重试与数据落库
4.1 轮询节奏:setInterval 的陷阱与串行队列
很多人第一版轮询是这么写的:
// 错误示范:setInterval 并发请求 setInterval(async () => { const data = await client.readHoldingRegisters(0, 10); save(data); }, 1000);这段代码的问题在于:如果一次请求耗时超过 1 秒(网络抖动、设备响应慢),下一次定时又触发了,两个请求会在同一个 client 上并发。Modbus 是请求-响应模型,同一个连接上并发会导致响应错配,读出来的数据张冠李戴。正确做法是串行队列,上一次跑完再排下一次:
// poll-serial.js const ModbusRTU = require('modbus-serial'); const client = new ModbusRTU(); const POINTS = [ { name: 'temperature', addr: 0, len: 2, type: 'float' }, { name: 'pressure', addr: 2, len: 1, type: 'uint16' }, { name: 'status', addr: 3, len: 1, type: 'uint16' }, ]; async function pollOnce() { const result = {}; for (const p of POINTS) { const res = await client.readHoldingRegisters(p.addr, p.len); result[p.name] = res.data; } return result; } async function loop(intervalMs) { while (true) { const start = Date.now(); try { const data = await pollOnce(); console.log(new Date().toISOString(), data); } catch (err) { console.error('轮询异常:', err.message); } const elapsed = Date.now() - start; const wait = Math.max(0, intervalMs - elapsed); await new Promise(r => setTimeout(r, wait)); } } async function main() { await client.connectRTUBuffered('/dev/ttyUSB0', { baudRate: 9600 }); client.setID(1); client.setTimeout(800); await loop(2000); } main();逻辑说明:loop用while(true)加await实现串行轮询,每次跑完计算耗时,用intervalMs - elapsed补齐间隔,避免请求堆积。POINTS数组把点位配置化,新增点位只改配置不改逻辑。setTimeout(800)要小于轮询间隔,否则一次超时会拖慢整个周期。这个模板我用了很多项目,稳定性和可维护性都够。
4.2 断线重连与错误分类处理
串口和网络都会断,关键是区分「可重试错误」和「致命错误」。常见错误分类:
| 错误现象 | 可能原因 | 处理策略 |
|---|---|---|
Port Not Open | 串口被拔或未打开 | 重新 connect |
Timed out | 设备无响应、参数错 | 重试 2 次后跳过 |
CRC error | 干扰、波特率不匹配 | 重试,持续则检查线路 |
ECONNREFUSED | TCP 目标未监听 | 退避重连 |
Data length error | 响应帧长度异常 | 丢弃本次,重试 |
重连逻辑建议包一层:
// reconnect.js async function ensureConnected(client, connectFn, retries = 3) { for (let i = 0; i < retries; i++) { try { await connectFn(); return true; } catch (err) { console.warn(`连接失败第 ${i + 1} 次:`, err.message); await new Promise(r => setTimeout(r, 1000 * (i + 1))); } } return false; }逻辑说明:connectFn是一个返回 Promise 的连接函数,比如() => client.connectRTUBuffered(path, config)。退避时间用1000 * (i + 1)递增,避免频繁重连打爆设备。重试次数别设太大,3 次够了,再失败就上报告警,让人去查线路。
4.3 数据落库:批量写入与时间戳对齐
采集到的数据最终要落库。高频轮询下逐条 insert 会拖垮数据库,建议批量攒一批再写:
// batch-insert.js const pool = []; // 内存缓冲 function pushPoint(deviceId, point, value) { pool.push({ device_id: deviceId, point: point, value: value, ts: Date.now(), }); } async function flush(db, batchSize = 100) { if (pool.length === 0) return; const batch = pool.splice(0, batchSize); // 以 PostgreSQL 为例,用多值 insert const values = batch.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` ).join(','); const params = batch.flatMap(r => [r.device_id, r.point, r.value, r.ts]); await db.query( `INSERT INTO metrics (device_id, point, value, ts) VALUES ${values}`, params ); }逻辑说明:pool是内存缓冲,pushPoint只入内存不写库,flush按batchSize切批,用参数化多值 insert 减少往返。时间戳在采集时打,不要等落库时打,否则批量写入的时间会全部挤在一起,失去时序意义。batchSize根据数据库承受能力调,100 到 500 都常见。注意内存缓冲要有上限,防止数据库长时间不可用时内存爆掉。
5. 避坑与排查:那些让我加班到凌晨的 Modbus 问题
5.1 读出来全是 0 或 65535
现象:连接成功,请求不报错,但数据全是 0 或 65535。原因通常是地址偏移错了,或者读错了寄存器区。Modbus 有四个区:线圈(0x)、离散输入(1x)、输入寄存器(3x)、保持寄存器(4x),功能码不同。手册写 40001 是保持寄存器,对应功能码 03、协议地址 0;写 30001 是输入寄存器,对应功能码 04。解决:先确认功能码和地址偏移,用单个寄存器读测试,别一上来读一片。
5.2 CRC 错误反复出现
现象:偶发或持续 CRC error。原因可能是波特率不匹配、线路干扰、接地不良,或者多个从站地址冲突。解决:先降波特率到 9600 试,再检查 A/B 线是否接反(RS485 的 A/B 反接很常见),最后查终端电阻。如果只有长距离时出错,多半是干扰,加屏蔽和终端电阻。
5.3 TCP 连接正常但请求超时
现象:connectTCP成功,但readHoldingRegisters一直超时。原因常见是单元标识设错,或者目标设备只支持特定功能码。解决:把setID改成 1 或 255 分别试,用 Modbus 调试工具先确认设备能响应,再回来查代码。另外注意有些网关需要先发一次请求「激活」后端串口,第一次超时第二次才通,这种可以在连接后先发一个探测请求。
5.4 并发请求导致数据错位
现象:数据偶尔串位,温度值跑到压力点位上。原因就是前面说的并发请求。解决:确保所有请求串行,用队列或await链。如果确实需要高吞吐,给每个从站开独立 client,不要在一个 client 上并发。
5.5 串口打开失败 Permission denied
现象:Linux 下connectRTUBuffered报权限错误。原因是当前用户不在dialout组。解决:sudo usermod -aG dialout $USER然后重新登录。Windows 下一般是串口被其他程序占用,关掉占用程序即可。
6. 进阶:把采集服务做成可观测、可配置的常驻进程
走到这一步,你已经能读能写能落库了。但一个真正要上产线的采集服务,还得解决三个问题:配置热更新、指标可观测、异常可追溯。我一般会把点位配置抽成 JSON 文件,用fs.watch监听变化,改了配置不用重启进程:
// config-watch.js const fs = require('fs'); let points = JSON.parse(fs.readFileSync('./points.json', 'utf8')); fs.watch('./points.json', (eventType) => { if (eventType === 'change') { try { points = JSON.parse(fs.readFileSync('./points.json', 'utf8')); console.log('点位配置已热更新,共', points.length, '个点位'); } catch (err) { console.error('配置解析失败,保留旧配置:', err.message); } } });逻辑说明:fs.watch监听文件变化,解析成功才替换,解析失败保留旧配置,避免改错配置导致服务崩溃。points用let声明,轮询循环里每次读取最新值。
可观测性方面,至少暴露三个指标:采集成功率、单次轮询耗时、连续失败次数。可以用prom-client暴露 Prometheus 格式,也可以简单打日志。我的习惯是每轮打一行结构化日志,包含时间戳、耗时、成功点位数、失败点位数,方便事后 grep。
验证方法上,我强烈建议先用 Modbus 模拟器(比如 Modbus Slave 或开源的模拟工具)造一个从站,把地址、数据类型、异常响应都模拟出来,再对接真实设备。真实设备往往有各种不按手册来的行为,模拟器能帮你先把代码逻辑跑通,减少现场调试时间。
最后说个我踩过的坑:早期我把轮询间隔设成 200ms,觉得越快越好,结果运行一周后串口服务器死机了。后来才明白,很多低端设备根本扛不住高频轮询,寄存器读写有内部处理延迟。现在我的默认间隔是 1 秒起步,根据设备手册的响应时间往上调,宁可慢一点也要稳。采集服务的第一目标是稳定,不是快。希望帮到你。
本文还有配套的精品资源,点击获取