news 2026/10/15 15:08:56

Node.js纯JS实现Modbus协议栈:RTU与TCP采集服务实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Node.js纯JS实现Modbus协议栈:RTU与TCP采集服务实战

简介:这份资源是 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干扰、波特率不匹配重试,持续则检查线路
ECONNREFUSEDTCP 目标未监听退避重连
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 秒起步,根据设备手册的响应时间往上调,宁可慢一点也要稳。采集服务的第一目标是稳定,不是快。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:08:47

AI 动手之前先让人拍板:Laravel AI 工具审批机制完整指南

【免费下载链接】ai The Laravel AI SDK provides a unified, expressive API for interacting with AI providers such as OpenAI, Anthropic, Gemini, and more. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ai89/ai 点击查看 免费下载 在 Laravel AI 中&#xff…

作者头像 李华
网站建设 2026/10/15 15:08:36

Windows NTP时间同步实战:从SNTP到NTP客户端模式升级

简介&#xff1a;这是一款面向Windows系统管理员与C开发者的轻量级NTP时间同步客户端工具&#xff0c;解决局域网或公网环境下系统时钟漂移、手动校时低效等问题&#xff0c;适用于IT运维、工业控制终端时间统一、嵌入式测试环境等对时间精度有基础要求的场景。资源包为114KB的…

作者头像 李华
网站建设 2026/10/15 15:08:26

ANSYS APDL编辑调试器:断点调试与变量监视实战指南

简介&#xff1a;这份资源是ANSYS官方出品的APDL编辑调试器&#xff0c;面向使用ANSYS进行参数化建模与仿真脚本开发的工程师、科研人员及高校学生&#xff0c;尤其适合需要编写、调试APDL命令流的中高级用户。它提供一个与实时上下文帮助集成的编辑环境&#xff0c;支持命令参…

作者头像 李华
网站建设 2026/10/15 15:07:46

CSDN 内容榜文章记录

说明 仅个人存档台账&#xff0c;用来记录登上 CSDN 内容榜的原创博文。非技术干货。 序号博文标题/链接上榜时间排名截图1QtFFmpegOpenCV‑DNN 实现本地或 RTMP/RTSP 实时 YOLOv5 目标检测播放器-CSDN博客2026-09-301825.【具身智能】ROS2 本地 LLM 构建家庭管家机器人集群…

作者头像 李华
网站建设 2026/10/15 15:07:20

SpringBoot+Vue+MySQL新闻资讯系统毕业设计全流程实战指南

又到一年毕业设计季&#xff0c;每年这时候都能在答疑群里看到类似的问题——“老师&#xff0c;新闻资讯系统用SpringBoot还是SSH&#xff1f;”“Vue和JSP到底选哪个&#xff1f;”“数据库表到底怎么设计才能不被答辩老师怼&#xff1f;”作为一个带过不少毕业生做课题、也帮…

作者头像 李华
网站建设 2026/10/15 15:07:16

DeepSeek V3 API 从零到精通:ChatBox 配置、temperature 调优与批量处理实战

简介&#xff1a;这份《DeepseekV3从零基础到精通学习手册》面向希望系统掌握DeepSeek V3平台的初学者与进阶用户&#xff0c;帮助读者从注册登录、API调用到实际案例完整走通使用流程。资源包内含1个PDF文件&#xff0c;大小约1.11MB&#xff0c;以图文并茂的文档形式呈现&…

作者头像 李华