☰
modbus-serial实战:用Node.js读写Modbus串口设备
2026/10/14 16:24:01 网站建设 项目流程

简介:Modbus-Serial 是一份面向 Node.js 与前端开发者的开源库资源,采用纯 JavaScript 实现 Modbus-RTU 协议下的串行与 TCP 通信,免去额外编译步骤,可快速打通 PLC、传感器、工业设备与上层应用之间的数据交互,适用于物联网、能源管理、环境监测等场景。压缩包共 57 个文件,以 js 源码为核心,附有 ts 类型声明、json 配置、sh 辅助脚本、md 文档与 license 文件,整体仅 70KB,结构清晰、便于查阅。目前已有 1589 人学习浏览。资源内含完整的客户端/服务端封装、寄存器读写接口、错误处理与调试工具,覆盖串口 RS-232/RS-485 和 TCP/IP 两种连接方式;同时给出多个示例代码与测试用例,清晰演示线圈、保持寄存器、输入寄存器、离散输入的读写流程,帮助开发者理解 Modbus 主从架构、功能码调用及通信健壮性设计。

1. modbus-serial:让前端顺手接管工业串口数据的一个开源库

做设备数据面板时,最尴尬的不是图表画不出来,而是数据在仪表的串口上,前端页面却在浏览器里。过去只能让后端写个采集服务,再把数据推给前端,遇到调试现场改个寄存器地址,还要两头约时间。modbus-serial 解决的是这件事:它让 Node.js 直接通过串口和 Modbus RTU/ASCII 从站设备通信,前端开发者不需要了解 CRC 校验和帧结构的全部细节,就能读 PLC、仪表、电表和传感器的寄存器数据。它既是协议库也不是完整采集系统,适合做本地调试工具、边缘网关采集程序,以及快速搭一个可用的设备数据读写通道。

2. 为什么选它而不是裸写 serialport:协议封装的价值与边界

2.1 从裸串口到 Modbus 协议:modbus-serial 帮你省掉了哪一层

如果直接用 serialport 库打开串口,你会发现拿到手的只是一堆字节流,要自己拼请求帧、计算 CRC16、判断响应帧的地址和功能码、解析异常码,还要处理一帧数据被拆成两次到达的情况。这些逻辑本身不复杂,但每个设备都有一点自己的小脾气,写起来极其容易翻车。modbus-serial 做的事情就是把 Modbus 应用层的帧封装好:你只需要告诉它“从站地址是多少、要读哪个寄存器、读几个”,它负责组帧、发请求、校验响应、返回一个 buffer。

它的另一个好处是数据通道抽象。同一个客户端实例,既可以用new ModbusRTU()配合串口打开,也可以通过new ModbusRTU()后调用connectTcp()走以太网 Modbus TCP。这意味着现场调试时,串口设备和网络设备可以共用一个轮询逻辑,只是连接方式不同。对前端团队来说,这套抽象很直接:不需要关心串口帧和 TCP 帧的差异,统一用 readHoldingRegisters、writeSingleRegister 这类方法。

选型时还要算它的边界。modbus-serial 是请求-响应模型,同一时刻一个端口上只允许一个未完成的请求,自身也不是为高吞吐设计的。它适合每秒几次到几十次的轮询频率,不适合做高频数据采集。如果需要同时采集几十个设备、每个设备几十个寄存器,且频率超过 100ms 一轮,建议还是让它做边缘采集,不要试图在一个 Node 进程里扛超大并发。

2.2 与串口驱动的关系:serialport 依赖与打开串口的真实代价

modbus-serial 底层依赖 serialport 来做串口通信,所以安装时 serialport 会作为它的依赖被装进来。serialport 包含原生模块,在部分 Linux 环境下需要编译工具链;Windows 下通常有预编译二进制,装起来会顺利很多。真正让新手困惑的是“打开串口”这个动作本身:它不是简单的文件打开,而是要对串口设备做参数配置,包括波特率、数据位、停止位和校验位。这些参数必须和从站设备完全一致,否则会出现数据错乱或者完全没响应。

用 modbus-serial 打开串口时,常见做法是:

const ModbusRTU = require('modbus-serial'); const client = new ModbusRTU(); client.open(portPath, { baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', timeout: 3000 }, (err) => { if (err) { console.error('打开串口失败', err.message); return; } console.log('串口已打开'); });

这段代码里,portPath在 Windows 上是COM3这样的名字,在 Linux 上是/dev/ttyUSB0或/dev/ttyS0。baudRate要和设备铭牌或手册里的值一致,绝大多数仪表默认 9600,但也有很多设备默认 19200 或 115200,不看手册硬试的话只能挨个猜。parity常用 'none'、'even'、'odd' 三种,需要注意:串口参数错误时,open 不会报错,往往是后续请求超时或数据错位,这一点排查起来最费时间。

一个经常被忽略的细节是串口设备的互斥访问。同一时刻只能有一个进程打开同一个串口,如果你先用串口调试助手占用了 COM3,Node 进程再 open 就会报Error: Opening COM3: Access denied。这不是 modbus-serial 的 bug,而是操作系统层面的串口独占限制。调试时记住:先把调试助手关掉,再启动采集程序;或者反过来,程序跑着的时候,别去用助手连同一个串口。

2.3 它替你做了哪些协议细节:功能码、CRC 与异常码

Modbus RTU 请求帧的格式是:从站地址 + 功能码 + 数据 + CRC16。裸写串口时,CRC 计算是容易出错的一步,尤其是高低字节顺序反了会导致设备完全不理会请求。modbus-serial 内部封装了 CRC 计算和校验,你传字符串、数组或 buffer 作为请求数据,库会负责编码。响应帧同样会经过校验,如果校验不对,客户端会把这次响应当作无效数据处理,保证你不会拿着坏数据去画图表。

它支持的常用功能码包括:读线圈(0x01)、读离散输入(0x02)、读保持寄存器(0x03)、读输入寄存器(0x04)、写单线圈(0x05)、写单寄存器(0x06)、写多寄存器(0x10)。这些功能码在上层 API 里对应readCoils、readDiscreteInputs、readHoldingRegisters、readInputRegisters、writeCoil、writeRegister、writeRegisters。如果你的设备手册里提到“功能码 03”,那对应的就是readHoldingRegisters,记住这个对应关系可以少走很多弯路。

如果从站返回异常响应,比如访问了不存在的寄存器地址,设备会回一个异常码。modbus-serial 会把异常码暴露出来,通常是响应 buffer 里的第二个字节加 0x80。我在实际调试中见过最多的两个异常码是 0x02(非法数据地址)和 0x03(非法数据值)。前者通常是地址超出了设备的寄存器范围,后者多半是写入值超过了量程,比如寄存器只能写 0-100,你却写了 200。

3. 最小可运行:从打开串口到读一个保持寄存器的完整命令模板

3.1 安装与客户端初始化:先跑通一个最小请求

安装 modbus-serial 只需要在项目目录里执行一条命令:

npm install modbus-serial

如果你的项目里还不存在package.json,先执行npm init -y生成一个。安装完成后,写一个最简单的读取脚本。以读取从站地址 1、保持寄存器从地址 0 开始、读 2 个寄存器为例:

const ModbusRTU = require('modbus-serial'); const client = new ModbusRTU(); const portPath = process.argv[2] || '/dev/ttyUSB0'; const slaveId = 1; client.open(portPath, { baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none' }, (err) => { if (err) { console.error('open error:', err.message); process.exit(1); } client.setID(slaveId); read(); }); function read() { client.readHoldingRegisters(0, 2, (err, data) => { if (err) { console.error('read error:', err.message); client.close(); return; } console.log('寄存器原始值:', data.data); client.close(); }); }

参数说明:readHoldingRegisters(addr, len, callback)的第一个参数是从站寄存器起始地址(协议地址,从 0 开始),第二个参数是要读的寄存器个数。data.data是一个数组,每个元素是 16 位无符号整数,范围 0-65535。setID(slaveId)用来设置从站地址,Modbus 单主机模式下,同一个串口上可以挂多个从站,每次请求前需要把 ID 切到目标从站。

3.2 读保持寄存器与输入寄存器:数据的两种来源别搞混

很多设备手册会区分“保持寄存器”(Holding Register,功能码 03)和“输入寄存器”(Input Register,功能码 04)。保持寄存器通常是可读可写的参数区,比如设定温度、运行模式;输入寄存器是只读的测量值区,比如当前温度、电压。如果你的目的是采集测量数据,一般读输入寄存器;如果你要改设备参数,读保持寄存器,并且后续用写寄存器命令去改。

读输入寄存器的代码和读保持寄存器几乎一样,只是把方法名换掉:

client.readInputRegisters(0, 4, (err, data) => { if (err) { console.error('read error:', err.message); return; } console.log('输入寄存器值:', data.data); });

这里的地址同样是协议地址。比如设备手册上写“电压测量值在输入寄存器地址 30001”,多数情况下你要在代码里填 0,因为 30001 是 PLC 习惯的编址方式,对应的协议地址是 0。如果你填 30001,设备会回异常码 02。这个地址偏移问题非常容易踩,后面避坑章节我还会专门讲。

3.3 配置从站地址与串口的对应关系:多从站轮询前的基础认知

一个串口上挂多个设备时,每个设备需要有唯一的从站地址。假设串口上有两块仪表,地址分别是 1 和 2,轮询时就要先setID(1)读一遍,再setID(2)读一遍。modbus-serial 的setID()是同步方法,调用后会作用于后续的所有请求。需要注意:请求与请求之间要有间隔,不能连续发出两个请求而不等待响应,否则第二个请求大概率会超时。

常见做法是写一个轮询表,把每个从站的待读寄存器列清楚:

const devices = [ { id: 1, reads: [{ addr: 0, len: 2, name: '温度' }] }, { id: 2, reads: [{ addr: 0, len: 4, name: '电能参数' }] } ]; async function pollDevice(dev) { client.setID(dev.id); for (const item of dev.reads) { const data = await client.readHoldingRegisters(item.addr, item.len); console.log(dev.id, item.name, data.data); } } (async () => { for (const dev of devices) { await pollDevice(dev); } })();

这里使用了 Promise 风格的调用,modbus-serial 的方法都支持回调风格与 Promise 风格双重用法。在 async 函数里用await会让代码更线性、更好排查。需要注意的是:Promise 风格下,如果底层串口还没 open 完成就调用读方法,会抛错,所以要把 poll 逻辑放在 open 的回调之后启动。

4. 把数据读对:寄存器数量解析、浮点拼装与大小端那点事

4.1 16 位整数直接拿,32 位浮点要自己拼:小心端序

Modbus 寄存器长度是 16 位,所以设备手册里写的“32 位浮点数”在协议层占 2 个寄存器。modbus-serial 返回的数组按 16 位整数的粒度给你,你需要自己把两个 16 位拼成一个 32 位 float。拼接时最核心的问题是字节序:Modbus 规范本身没有规定多寄存器数据的字节序,所以不同厂商的实现千奇百怪,我甚至见过同一个厂商不同型号的设备字序相反的。

先看最常见的情况,大端模式拼接:

const ModbusRTU = require('modbus-serial'); function twoRegistersToFloat32(high, low) { const buf = Buffer.alloc(4); buf.writeUInt16BE(high, 0); buf.writeUInt16BE(low, 2); return buf.readFloatBE(0); } const raw = [0x4120, 0x0000]; // 模拟读到的两个寄存器 const value = twoRegistersToFloat32(raw[0], raw[1]); console.log(value); // 10

这段代码把两个寄存器按大端顺序写入 4 字节 buffer,再按 IEEE 754 浮点格式解析。0x41200000对应的十进制浮点正好是 10.0。但如果你把 raw 数组顺序换一下,得到的就是一个完全不同的数。常见的基准测试值有两个:输入1.0,如果寄存器返回0x3F80 0x0000,说明是大端在前;如果返回0x0000 0x3F80,说明低字在前。记录这个差异,后续所有浮点解析都按这个规律处理。

4.2 批量读线圈和离散输入:位打包的解析方式

线圈和离散输入是按位存储的,一个字节里装了 8 个布尔量。比如你读 10 个线圈,设备返回的数据可能只有 2 个字节,每个 bit 对应一个线圈的状态。modbus-serial 的readCoils返回的数据结构和读寄存器不一样,data.data是一个数组,里面是按字节打包后的值,你需要手动拆位。

拆位的代码通常这样写:

client.readCoils(0, 10, (err, data) => { if (err) return; const bytes = data.data; const coilStates = []; for (let i = 0; i < 10; i++) { const byteIndex = Math.floor(i / 8); const bitIndex = i % 8; coilStates.push((bytes[byteIndex] >> bitIndex) & 1); } console.log(coilStates); });

这里的约定是:第一个线圈对应第一个字节的最低位(bit 0)。如果你的设备厂商从高位开始排,那解析出来的状态就是反的。遇到这种情况,把bitIndex换成7 - bitIndex即可。如果读上来的数据全是 0 或全是不合理的 1,先别怀疑设备,先检查 bit 顺序。

读取离散输入用readDiscreteInputs,解析方式和线圈完全一致。线圈可写,离散输入只读,用的时候别把两者搞混。

4.3 写单寄存器与写多寄存器:写入参数时的数据类型注意

写单个保持寄存器用writeRegister(addr, value),value 范围是 0-65535。如果你要写负数或浮点,需要先把数值转换成对应的无符号整数表示。比如写 -1,对应的是 0xFFFF 即 65535,你直接传 -1 会怎样?看下面代码:

client.writeRegister(0, -1, (err, data) => { if (err) console.error(err.message); });

实际上 modbus-serial 内部会做转换,很多情况下把 -1 传给 writeRegister 会变成 0xFFFF,和协议层预期一致。但为了保险,建议写入前自己统一做转换,不要依赖库的行为,因为不同版本的库对负数处理不完全一致。写多寄存器用writeRegisters(addr, array),数组里的每个元素是一个 16 位值。

写入完成后,设备返回的响应里会带写入的地址和值,你可以打印出来核对:

client.writeRegisters(100, [0x3F80, 0x0000], (err, data) => { if (err) return; console.log('写回显:', data.data); });

这里data.data是设备回显的寄存器值数组,正常情况应该和你写入的一致。如果不一致,说明设备做了特殊处理或写入失败,需要进一步看异常码。

5. 避坑排查:串口打不开、超时、数据错位的 6 个真实案例

5.1 串口被占用与权限不足:open 报错的两种常见原因

现象:调open()时报错Opening COM3: Access denied,或者 Linux 上报错Error: Permission denied。第一种情况最常见于同时开着串口调试助手和采集脚本;第二种情况出现在 Linux 系统,当前用户不属于 dialout 或 uucp 用户组,没有权限访问串口设备。

原因:串口是独占设备,一个进程打开后,另一个进程无法再打开。Linux 上串口设备的默认权限属于 dialout 组,普通用户不在组里就没法读写。

解决:关掉所有占用串口的软件。Linux 下执行sudo usermod -a -G dialout $USER,然后重新登录会话生效;不想改用户组的话,也可以临时用sudo chmod 666 /dev/ttyUSB0解除权限限制,但重启后失效。排查时先运行ls -l /dev/ttyUSB*看设备权限,再运行lsof /dev/ttyUSB0看哪个进程占用了串口。

5.2 从站不响应:超时参数与重试设计

现象:请求发出后长时间没有回调,直到超时报Timed out。这是 Modbus 串口调试中占比最高的问题。原因可能是从站地址不对、串口参数不匹配、从站设备根本没上电,也有可能是线路问题,比如 A/B 线接反了。

解决:先核对从站地址,用setID设置的地址必须和设备拨码开关或配置软件里的地址一致。然后确认串口参数,尤其是波特率。最后在代码里设计重试机制,不要一次超时就报警停摆。一个可靠的重试写法如下:

const TIMEOUT = 2000; const RETRY_TIMES = 3; async function readWithRetry(client, addr, len, retry = RETRY_TIMES) { try { const data = await client.readHoldingRegisters(addr, len); return data; } catch (err) { if (retry <= 0) throw err; await new Promise(resolve => setTimeout(resolve, 500)); return readWithRetry(client, addr, len, retry - 1); } }

这个函数里,每次超时后等 500ms 再重试,最多重试 3 次。注意:不要在重试之间重新 open 串口,串口一旦开好可以持续复用,反复开关反而容易出问题。如果重试 3 次仍然超时,再去检查物理层。

5.3 串口参数与设备不符:波特率、校验位带来的玄学问题

现象:open 成功,偶尔读到一个值,但数据明显不对;或者完全无响应。这是串口参数不匹配的典型特征。你设 9600,设备设 19200,两边波特率不一致,收到的数据基本是乱码;校验位设错,响应帧校验大概率失败。

原因:数据的每位传输时序和采样点由波特率决定,校验位由参数决定。不一致时,字节流能通,但帧层面全是错误。

解决:去设备配置软件或面板里确认实际波特率、数据位、停止位、校验位,然后把这些参数和 open 配置一一对应。如果设备面板显示 “9600, 8, 1, none”,open 配置里就写{ baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none' },少一个参数都可能出问题。多数仪表出厂是 9600,但变频器或较新的电能表经常默认 115200,别想当然。

5.4 地址偏移:手册地址与协议地址转换

现象:读寄存器时设备返回异常码 02,或者读出来的数值和面板完全对不上。

原因:设备手册里写“温度寄存器地址 40001”,这是 PLC 厂商的编址习惯,40001 对应协议地址 0,40100 对应协议地址 99。协议层的地址永远从 0 开始。如果你把 40001 直接传给readHoldingRegisters(40001, 1),设备收到的是从地址 40001 处读,自然非法。同理,30001 是输入寄存器起始,对应协议地址 0(功能码 04);10001 是离散输入寄存器起始,00001 是线圈起始。

解决:按表转换。

手册地址寄存器类型功能码协议地址起始
00001-09999线圈010
10001-19999离散输入020
30001-39999输入寄存器040
40001-49999保持寄存器030

用代码做一次转换,别手算:

function deviceAddrToProtocol(addr) { if (addr >= 40001) return addr - 40001; if (addr >= 30001) return addr - 30001; if (addr >= 10001) return addr - 10001; return addr - 1; }

这段函数把所有类型的设备寻址都转成协议地址,写入readHoldingRegisters(deviceAddrToProtocol(40001), 1)之后,请求的是协议地址 0,也就是手册里的 40001。不同厂商对编址的解释不太一致,有的手册直接写协议地址,要先确认手册里那一列的表头是“地址”还是“PLC 地址”。

5.5 响应 buffer 的 stale data:重复读返回旧值

现象:请求已经成功返回过一次,第二次读到相同寄存器时,返回的是上一次的数据,看起来像是在“缓存”。

原因:这是没有打开client.setTimeout或没有正确处理响应间隔造成的。实际上更常见的原因是:你在上一次请求完成后又没有等设备准备好,立即发起了第二次请求,设备回的是上一条残余数据,或者总线上的信号没有完全安定。

解决:保证每个请求之间留 50ms 以上的间隙。在 Promise 模式下可以用await new Promise(r => setTimeout(r, 50))做间隔,或者在轮询里直接用setInterval并控制周期。另一个方法是每次读取前检查client.isOpen和client.isBusy状态,busy 时跳过本次请求:

if (client.isBusy) { console.log('busy,跳过本轮读取'); return; }

isBusy在请求未完成时为 true,读取结束后恢复 false。如果发现大量请求被跳过,说明间隔太短,把轮询周期加长。

5.6 设备掉线后的连接状态判断:close 与重连策略

现象:设备断电后,后续 read 一直超时,重新接回设备后,轮询恢复正常,但偶尔会报错Port is not open。

原因:设备掉线一般不影响串口本身的打开状态,但长时间无响应可能触发了你的重试逻辑,重试次数耗尽抛出了异常;如果你在异常分支里调用了client.close(),串口就被关掉了,设备恢复后也没有挂接回来,程序处于“串口关闭但轮询还在跑”的状态。

解决:不要在错误分支里随意 close。把 close 只保留在进程退出或主动重建连接时使用。推荐的做法是重试耗尽后标记设备离线,设备侧加一个心跳检测:每次成功读取时更新 lastActive 时间,超过阈值判定掉线,等到下一次成功读取时再清除标记:

let lastActive = Date.now(); async function poll() { try { const data = await readWithRetry(client, 0, 2); lastActive = Date.now(); console.log('数据正常:', data.data); } catch (err) { console.log('设备离线已经', ((Date.now() - lastActive) / 1000).toFixed(1), '秒'); } } setInterval(poll, 2000);

这样即使设备掉线,程序也只是记录状态,不会把串口关闭,设备恢复后下一次 poll 就自动回到正常。把 close 留给进程退出信号处理,比如监听process.on('SIGINT')时调用client.close()。

6. 让它更稳一点:轮询架构、日志与一主多从的进阶写法

6.1 定时轮询的架构:setInterval 与 async 循环的选择

现场采集程序最忌讳的就是用setInterval发请求而不考虑上一次请求是否完成。如果一次读取耗时超过轮询周期,下一次请求会和上一次重叠,modbus-serial 内部虽然会排队,但排队过多会让响应时间变得不可控。更稳妥的方式是自循环:每次轮询完成后,再等固定间隔发起下一次。

async function loop() { while (true) { await poll(); await sleep(500); } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

loop()里的await poll()保证了读取完成前不会进入下一次轮询,后面的sleep(500)是设备稳定间隔。这样不管 poll 内部耗时多少,真实轮询周期始终是“耗时+间隔”,不会出现多个请求叠在一起的问题。用这种方式,即使某个设备响应特别慢,也只是这轮周期变长,不会让整个流程崩溃。

6.2 一主多从:轮询表驱动与从站号切换的坑

一个串口上挂多个从站时,最常见的问题是切换setID和请求之间没有间隔。某些设备对地址切换比较敏感,刚切完 ID 立即发请求会超时。我一般会在每次 ID 切换后加 100-200ms 的去抖时间,代价是整体轮询变慢,但换来的是稳定。轮询表结构建议把从站 ID 和读写列表放在一起,方便以后增删设备:

const pollTable = [ { id: 1, reads: [{ name: '电压', func: 'readHoldingRegisters', addr: 0, len: 2 }] }, { id: 2, reads: [{ name: '电流', func: 'readInputRegisters', addr: 0, len: 4 }] } ]; async function runPollTable() { for (const dev of pollTable) { client.setID(dev.id); await sleep(100); for (const item of dev.reads) { const data = await client[item.func](item.addr, item.len); console.log(dev.id, item.name, data.data); } } }

这个循环每次只轮询一轮,外层再套一个while(true)就形成了完整采集。注意client[item.func]这种动态方法调用的写法,前提是func名字和 modbus-serial 的 API 完全一致,写错了会在运行时抛 TypeError,建议加一层校验或映射表兜底。

6.3 日志与验收:怎么确认你读到的值是对的

把数据读出来只是第一步,怎么证明读对了才是真正花时间的地方。我的习惯是加一个校验日志,每次读取时把面板值或已知基准值对比。比如拿一个已知输出 50.0Hz 的信号发生器接上设备,程序读出来要稳定在 49.9-50.1 范围内,误差过大的优先怀疑寄存器解析错了,而不是设备有问题。日志里记录原始寄存器数组、拼接方式和最终物理值,这样现场对比时不用猜中间过程。也可以加一个简单的浮动日志缓存,记录最近 10 次读数的变化趋势,如果数值出现连续跳变,多半是寄存器选错或数据类型解析错。等到现场跑通之后,再考虑把日志输出级别调低,但原始寄存器数据建议长期保留,因为设备厂商排查问题时最常问的就是“你读到的原始寄存器值是多少”。

最后说一个我自己的教训:早期做采集程序时,我因为贪快把轮询间隔压到 100ms,串口上还挂了三台从站,结果数据偶尔错位,查了半天发现是某台设备响应偶尔超过 200ms,请求重叠了。后来改成自循环加去抖,问题再没出现过。做串口采集,稳定永远比快重要,这个方向值得投入,但要把间隔、重试、状态判断这些基础打扎实。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询