☰
modbus-serial:前端直接读写Modbus RTU/TCP寄存器实战指南
2026/10/23 8:02:57 网站建设 项目流程

简介:面向需要在Node.js或前端环境中集成Modbus设备通信的开发者,这份开源资源提供了modbus-serial库的完整源码包。库采用纯JavaScript实现,同时支持Modbus-RTU串行与TCP通信,可应用于物联网控制、能源监测、环境数据采集等场景。压缩包共57个文件,以46个js源码文件为主,辅以TypeScript类型声明、npm配置、构建脚本及许可证和说明文档,整体仅70KB,结构紧凑。目前已有1591人浏览学习。解压后可直接查看库核心模块、端口实现、API封装、示例代码与单元测试用例,尤其适合希望深入理解Modbus协议实现或需要二次开发定制通信逻辑的开发者。免编译、安装便捷,能有效降低工业设备与前端系统之间的集成门槛。

1. 前端开源库 modbus-serial:谁说 Modbus 只是后端的活

做了几年前端,我一直没想过自己会跟 Modbus 协议打交道。直到某次接到一套车间设备监控系统的活儿,上位机界面要实时读取几十台仪表的数据,而后端同事排期排到下个月。我盯着设备文档里那串十六进制报文,脑子里只有一个念头:这活前端到底能不能干?

答案是能。modbus-serial 这个开源库就是为了解决这类场景而生的,它在 Node.js 环境和浏览器环境里都能跑,底层串口通信和 Modbus 协议栈都被封装好了,我只需要关心业务层面的寄存器地址和读写逻辑。它支持 RTU 和 TCP 两种主流模式,无论是连 USB 转串口的现场设备,还是走以太网的分布式 IO 模块,都能用同一套 API 接起来。

如果你是做工业数据可视化、设备运维平台、边缘计算网关的前端工程师,或者是想把硬件调试工具搬进浏览器里的全栈开发者,这个库值得花半小时看完这篇笔记。我不打算复述官方文档,只讲我拆过这个库之后沉淀下来的选型依据、可用参数和踩过的坑。

2. 选型与协议基础:RTU 与 TCP 的差异,以及 modbus-serial 的封装边界

2.1 Modbus 协议两种主流形态:RTU 与 TCP 的数据帧结构

Modbus 协议在物理层上分好几种,实际工程里跑得最多的是 RTU 和 TCP。RTU 走串口,常见载体是 RS-232、RS-485,数据帧按字节连续发送,每帧由地址码、功能码、数据区和 CRC 校验组成。TCP 走以太网,把同样的协议内容封装进 TCP 报文,在应用层头部直接携带单元标识符(Unit ID)和功能码,最后没有 CRC,因为传输层的 TCP 校验已经替我们兜底了。

拿一段实际报文说明,RTU 模式读保持寄存器,请求帧大概是这样的:从站地址 0x01,功能码 0x03,起始地址 0x0000,读取数量 0x0002,加上 2 字节 CRC。整个帧体量很小,所以 RS-485 链路上才能做到几十个设备轮询不卡顿。TCP 模式则省掉 CRC,转为在报文头里加一个 6 字节的事务处理标识和单元标识,这样可以在同一 TCP 连接上区分不同的 Modbus 事务。

提示:从帧结构就能看出一件事——选 RTU 还是 TCP,首先取决于现场物理链路,而不是代码层面想用哪个就用哪个。

modbus-serial 对这两种模式都做了适配。实例化之后通过设定端口类型来决定走串口还是走网络,API 层则基本统一,readHoldingRegisters、writeSingleRegister这些方法在两种模式下都能直接调。这就给了我们一个好处:先在本机用 TCP 模拟器把业务逻辑调试通,再切到现场串口设备,代码几乎不用改。我一般会在项目里预留一个配置项,让端口类型、串口波特率、TCP 地址都能通过环境变量切换。

2.2 modbus-serial 的 API 封装逻辑:它替你做了什么

这个库的核心思路是把 Modbus 协议栈拆成三层:传输层负责和串口或 TCP socket 通信,协议层负责组帧、解帧和 CRC 校验,业务层抛出读写寄存器、操纵线圈的友好 API。用户不需要手拼缓冲区,也不用关心请求重发的时机,modbus-serial 内部已经把超时管理和错误回调安排好了。

以读操作举例,它内部会把起始地址和数量拼进数据区,计算好 CRC(RTU 模式),然后通过串口发送。响应回来后,库会检查返回的地址和功能码是否与请求一致,再校验数据长度,最后把有效载荷提取成一个 ArrayBuffer 或数组返回给回调函数。这里面有大量边界判断:比如请求读取 10 个寄存器,设备只回了 8 个字节的数据,库会判定为错误而不是返回一个残缺结果。

它还能自动处理多个并发请求。虽然我强烈不建议对同一个串口设备发出并发读写,但库确实为每个请求分配了事务 ID,并维护了一张待响应映射表。TCP 模式下这张表尤其有用,因为 TCP 链路允许多个 Modbus 事务交错传输,响应顺序不一定和请求一致,库根据事务 ID 把响应路由回正确的回调。

2.3 三种业界常见的选型误判

第一种是以为这个库只能用在 Node.js 后端。实际上它的浏览器构建版本通过 Web Serial API 来访问串口,所以在 Chrome 系浏览器里能直接读取 USB 转串口设备的数据。这个能力对开发调试工具类前端特别有用,比如某设备厂商的量产测试台,浏览器里点一个按钮,串口直接下发校准指令。

第二种是拿它当万能协议解析器。modbus-serial 只是 Modbus 主站侧的客户端实现,它不能解析自定义协议的私有报文。如果现场的仪表不是标准 Modbus 协议,而是某厂商自己的协议,需要先自己实现协议解析;或者用另一个中间层把私有协议翻译成 Modbus 寄存器映射。

第三种是忽略主从模式限制。在这个协议体系里,modbus-serial 永远扮演主站(Master)角色,它主动发起请求,从站(Slave)被动响应。如果业务场景需要设备主动上报数据,Modbus 协议本身做不到,就需要在上层加一个定时轮询机制,或者搭配其他通信协议做补充。认清这个边界能省下不少调试时间。

3. 环境搭建与实例化:从 npm 安装到第一个串口轮询

3.1 安装与依赖注意点

安装过程比较直接,npm 安装那个包即可。需要留意的是,如果运行环境是 Electron 或一些精简的 Node.js 运行时,串口模块可能无法自动编译原生依赖,安装时会触发 node-gyp 编译。环境里需要准备好 Python 和 C++ 编译工具链,否则安装会直接失败。

浏览器端使用时不装串口驱动包,而是通过 Web Serial API 与底层通信,所以包体可以走浏览器构建版本,体积会小很多。国内网络环境下安装原生依赖偶尔会踩源码下载超时的坑,我的习惯是配置镜像源后重试,或者临时切换依赖镜像完成安装。

代码导入方面,Node.js 环境用 CommonJS 或 ES Module 都行。浏览器环境引入方式稍有区别,如果打包工具是 Vite 或 Webpack,注意不要走原生串口模块的分支,否则构建时会报一串 Node.js 核心模块找不到的错误。

3.2 实例化串口客户端并完成首次连接

基础用法是先创建一个 ModbusRTU 实例,再调用 connectRTU 方法绑定串口参数。以下代码用于连接一个常见的 USB 转 RS-485 设备:

const ModbusRTU = require('modbus-serial'); const client = new ModbusRTU(); client.connectRTU('/dev/ttyUSB0', { baudRate: 9600, dataBits: 8, parity: 'none', stopBits: 1 }, (err) => { if (err) { console.error('串口连接失败:', err.message); return; } console.log('串口已连接,准备轮询'); });

连接参数里最关键的是波特率、校验位和数据位,这些必须和从站设备保持一致。比如设备手册写 9600 8 N 1,那就是波特率 9600、数据位 8、无校验、停止位 1,任何一项对不上都收不到正确响应。设备地址和超时时间在连接之后设置,我一般是连接成功后才配置这些参数。

注意:Linux 下串口路径可能是 /dev/ttyUSB0 或 /dev/ttyS0,Windows 下则是 COM3 这种格式。路径写错了连接回调会直接报打开失败。

对于 TCP 设备,连接方式换成connectTCP,参数改成 IP 和端口:

client.connectTCP('192.168.1.100', 502, (err) => { if (err) { console.error('TCP 连接失败:', err.message); return; } client.setID(1); client.setTimeout(1000); });

这里地址和端口要和从站设备保持同一网段,502 是 Modbus TCP 的默认端口,但也有些厂商设备用 5020 或自定义端口,以设备文档为准。

3.3 最简单的轮询流程代码

连接建立之后就要设计轮询周期了。我的做法是先封装一个 readDevice 函数来读取特定地址的寄存器,再用 setInterval 做循环读取:

function readDeviceData(addr, callback) { client.readHoldingRegisters(addr, 2, (err, data) => { if (err) { callback(err, null); return; } callback(null, data.data); }); } setInterval(() => { readDeviceData(0, (err, values) => { if (err) { console.error('轮询出错:', err.message); return; } console.log('寄存器值:', values); }); }, 3000);

轮询间隔根据设备特性和通信链路综合考虑。串口模式下一般不建议低于 500ms,因为一帧请求加上从站响应至少需要几十毫秒,间隔太小会导致请求排队,响应超时概率明显上升。TCP 模式下可以稍微快一点,但要考虑从站 CPU 的承受能力,有些 PLC 扫描周期本身就要几十毫秒,轮询太频繁等于制造冲突。

这里readHoldingRegisters的第二参数是读取寄存器个数,返回的 data.data 是一个数组,数组元素是 16 位无符号整数。如果实际数据是浮点数,需要自己用位操作把两个相邻寄存器的值拼成 IEEE 754 格式。这属于参数层面的关键细节,我单独放在下一章细讲。

4. 读写寄存器实战:线圈、保持寄存器与输入寄存器的参数踩坑

4.1 功能码对应的 API 映射表

Modbus 协议定义了几类数据对象,每一类对应一个功能码。modbus-serial 把这几个功能码直接映射成了方法。下面是工程里最常用的映射关系:

数据对象类型功能码对应 API读写属性
线圈(Coil)0x01 / 0x05 / 0x0FreadCoils / writeCoil / writeCoils可读可写
离散输入(Discrete Input)0x02readDiscreteInputs只读
保持寄存器(Holding Register)0x03 / 0x06 / 0x10readHoldingRegisters / writeSingleRegister / writeRegisters可读可写
输入寄存器(Input Register)0x04readInputRegisters只读

理解这个表是调试现场的基础。很多设备手册会把数据表按线圈和寄存器分开列,比如某温控器:线圈地址 0 是启动开关,保持寄存器地址 0 是当前温度,输入寄存器地址 0 是温度设定值。地址和功能码混在一起的时候,要先分清读取类型再调用对应 API。

提示:混淆线圈和保持寄存器的 API 是本类库最常见的误用。地址相同不代表是同一个数据,0x01 和 0x03 读出来是两套完全独立的数据空间。

实际使用中有个容易忽略的点:数据对象地址在报文里是 0 起的,但很多设备手册会以 1 起标注地址。遇到地址对不上的情况,把手册地址减 1 再传给 API。比如手册写保持寄存器地址 40001,实际传入的是 0。

4.2 读保持寄存器:地址偏移与返回缓冲区的对应关系

保持寄存器是 16 位一个单位,一次读取多个寄存器时返回的是数组,顺序与起始地址严格对应。下面的代码读取从地址 10 开始的 4 个寄存器,并把原始值与解析后的值都打出来:

client.readHoldingRegisters(10, 4, (err, data) => { if (err) { console.error('读取失败:', err.message); return; } const rawValues = data.data; // 寄存器顺序:addr10, addr11, addr12, addr13 const voltage = rawValues[0]; // 电压原始值 const current = rawValues[1]; // 电流原始值 const rawCombined = (rawValues[2] << 16) | rawValues[3]; // 32位数据拼包 console.log('电压:', voltage, '电流:', current, '组合值:', rawCombined); });

这里data.data是 modbus-serial 返回的一个数组,数组元素的值范围是 0 到 65535,对应寄存器里的 16 位无符号整数。如果寄存器里存的是负温度,设备可能用二进制补码表示,那么就需要手动把 16 位无符号转成有符号。

function toSigned16(value) { return value > 32767 ? value - 65536 : value; }

还有一个高频需求是把两个相邻寄存器拼成 32 位浮点数,比如设备用 IEEE 754 格式在连续两个寄存器里存放一个浮点温度值:

function regsToFloat(regArray, offset) { const high = regArray[offset]; const low = regArray[offset + 1]; const buf = Buffer.alloc(4); buf.writeUInt16BE(high, 0); buf.writeUInt16BE(low, 2); return buf.readFloatBE(0); }

字节序存在两种可能:有些设备是先高字节后低字节,有些反过来。一旦解析出来的数值明显离谱,比如温度变成十万级,多半是高低位反了,把读写顺序调换一下重新解析即可。

4.3 写单寄存器与写多寄存器的参数差异

写操作分成写单个寄存器和批量写多个寄存器,API 不一样,参数格式也不同。写单个寄存器用writeSingleRegister,第一个参数是寄存器地址,第二个是数值:

client.writeSingleRegister(0, 38, (err, data) => { if (err) { console.error('写寄存器失败:', err.message); return; } // 设备返回的响应帧里会带地址和写入值 console.log('写入成功,返回数据:', data); });

批量写用writeRegisters,第二个参数改为数组,数组长度就是要写入的寄存器个数。设备会自动按顺序写入指定数量的寄存器,比如要写入一组设备参数,通常是三个寄存器保存一个参数组:

const configArray = [100, 200, 300]; client.writeRegisters(20, configArray, (err, data) => { if (err) { console.error('批量写入失败:', err.message); return; } console.log('批量写入成功'); });

批量写的数组长度如果超过 125,Modbus 协议限制单帧最多传输 125 个寄存器,需要拆成多次写请求。虽然这个库没有强制校验这个上限,但设备端往往会拒绝超出限制的请求,返回异常功能码。工程上遇到大批量初始化设备参数时,我一般会写个分片函数,每段不超过 120 个寄存器,留出余量。

写线圈的方法writeCoil第二参数是布尔值,需要注意 modbus-serial 会自动把 true 转成 0xFF00,false 转成 0x0000。设备端只认这两个值,转成 FF01 之类的写法反而不标准。

5. 避坑排查:串口占用、超时与字节序的 5 个典型问题

5.1 问题一:串口被占用导致连接失败

现象:连接串口时报Error: Resource temporarily unavailable,或者调用close后再重新连接时一直失败。

原因:在 Windows 或 Linux 下,串口同一时刻只允许一个进程独占。如果之前运行的调试脚本没有正常释放串口,或者某个终端还挂着串口监视工具,新的进程就拿不到这个串口。

解决:先用系统命令确认占用情况,把旧的连接对象释放掉。Linux 下可以用lsof /dev/ttyUSB0查看占用进程并杀掉;Windows 下关闭所有可能占用 COM 口的软件。关键点是 modbus-serial 的close操作是异步的,关闭后要等回调真正完成再重新连接。代码里比较好的做法是重连前强制走一次 close 流程。

function safeReconnect(client, portConfig) { if (client.isOpen) { client.close(() => { client.connectRTU(portConfig.path, portConfig.options, handleOpen); }); } else { client.connectRTU(portConfig.path, portConfig.options, handleOpen); } }

5.2 问题二:连接正常但请求超时

现象:连接串口没有报错,但每次readHoldingRegisters的回调都是Timeout错误。设备明明在线,手动用串口调试工具发报文能收到响应。

原因:modbus-serial 的超时计时是从请求发出到收到完整响应帧之间的时长。如果从站响应比较慢,或者串口波特率太低导致一帧数据传了好几百毫秒,默认超时时间就不够用。还有一种情况是设置的单位 ID 和设备实际从站地址不一致,设备直接丢弃了请求帧,主站等不到响应自然超时。

解决:先确认从站地址,再把超时时间调大。地址检查不需要改代码,直接在实例上设置:

client.setID(1); client.setTimeout(3000); // 原来的1000ms不够,改成3000ms

注意:setID设置的是请求帧里的从站地址字段,不是 TCP 端口号。TCP 模式下如果设备不支持 Modbus TCP 的单元标识,地址可能固定填 0 或 255,具体看设备协议栈实现。

排查超时问题时先用串口助手抓包,如果看到主站发出请求但设备没有响应,基本可以断定是从站地址或功能码不匹配。如果设备响应了但主站仍然报超时,通常是响应帧里 CRC 错误被库内部丢弃了。

5.3 问题三:数值解析出来的结果明显不对

现象:读取温度值 0x0128 本来应该是 29.6 摄氏度,代码解析出来却变成 4736,或者一个负数被读成 65535 附近的大正数。

原因:一是 16 位整数可能是有符号数,没有做无符号转有符号处理;二是两个寄存器拼浮点数时高低位顺序不一致;三是可能设备用的是 32 位整数而不是浮点数,没有按文档解析。

解决:先看设备手册确认数据格式。如果是 16 位有符号,用前面提过的toSigned16函数转换;如果是 32 位数据,确认寄存器顺序和字节序后按对应逻辑拼包。我见过的最常见翻车现场是设备手册写「低字节在前」,实际护线程序却按高字节在前解析,导致温度值变成完全不可信的乱数。准备一个已知数值的静态寄存器作为基准,用几组测试数据把字节序摸透再写正式解析逻辑。

// 测试已知串:设备手册标明寄存器0x03=0x41C8, 0x04=0x0000 => 25.0 const rawHigh = 0x41C8; const rawLow = 0x0000; const buf = Buffer.alloc(4); buf.writeUInt16BE(rawHigh, 0); buf.writeUInt16BE(rawLow, 2); console.log(buf.readFloatBE(0)); // 如果输出 25.0,说明你解析方向是对的

5.4 问题四:RTU 模式下 CRC 校验一直报错

现象:读请求发出后,callback 返回的错误信息是CRC error。用串口助手发相同报文却能得到正确响应。

原因:modbus-serial 自带 CRC 校验逻辑,它会用收到的整帧数据计算一次 CRC,如果算出来的结果与帧尾携带的 CRC 不符,就判定为帧损坏。常见原因是波特率不一致导致一位数据位错乱,或者 RS-485 链路上有干扰信号混入帧体。

解决:检查波特率和数据位是否与设备一致,特别是 USB 转 RS-485 适配器有时会存在波特率漂移。再看 RS-485 的 A/B 线是否接反,接地是否可靠,终端电阻是否匹配。CRC 错误在行业现场几乎都指向物理层信号质量问题,和代码关系不大。如果只在一条长线缆上频繁出现 CRC 错误,先把波特率降下来,比如从 19200 降到 9600,往往能显著改善。

5.5 问题五:浏览器端 Web Serial 权限导致无法连接

现象:Chrome 浏览器里调用库的串口连接方法,报错信息提示Failed to open serial port,或者连端口列表都获取不到。

原因:浏览器访问串口必须满足两个条件:一是页面必须在 HTTPS 或 localhost 环境下运行,二是用户需要在用户手势中主动授权(比如点击按钮后弹出选择设备对话框)。脚本自动连接或者从非 HTTPS 页面调用都会被浏览器拦截。

解决:把开发环境启动成 localhost 服务,并在页面里设计一个点击按钮触发授权流程。连接串口不能在页面加载时自动执行,必须在点击事件回调里发起。同时建议在连接前检查navigator.serial是否存在,不存在就提示用户切换到 Chrome 内核浏览器。

async function requestSerialPort() { if (!navigator.serial) { alert('当前浏览器不支持 Web Serial API,请使用 Chrome 89+'); return; } const port = await navigator.serial.requestPort(); // 拿到 port 之后再传给 modbus-serial 的浏览器实例绑定串口 }

这个限制是浏览器安全模型决定的,绕不过去。如果是给车间产线开发工具,直接用 Electron 打包桌面应用是更省心的选择,因为 Electron 里可以走 Node.js 原生串口通道,不受页面权限限制。

6. 进阶:把 modbus-serial 封装成可复用的设备服务层

项目做了一半,我发现最核心的问题不是单条读写指令怎么写,而是代码组织。把client.readHoldingRegisters散落在事件回调里,几十个设备点位会把代码堆成一锅粥。所以后来我养成了一个习惯:所有用 modbus-serial 的项目,第一步永远是先做一层薄薄的设备服务层。

这个服务层的核心接口只有一个:readAddressMap。

class ModbusDevice { constructor(client, addressMap) { this.client = client; this.map = addressMap; } async readSnapshot() { const snapshot = {}; for (const key of Object.keys(this.map)) { const { addr, type, length } = this.map[key]; try { if (type === 'holding') { const res = await this.readHolding(addr, length); snapshot[key] = this.formatValue(res, key); } } catch (e) { snapshot[key] = null; console.warn(`读取点位 ${key} 失败:`, e.message); } } return snapshot; } formatValue(regs, key) { const config = this.map[key]; if (config.scale !== undefined) { return regs[0] * config.scale; } return regs[0]; } }

地址映射表是一个纯配置对象,把业务字段名和寄存器的地址、类型、缩放因子放在一起:

const deviceMap = { temperature: { addr: 0, type: 'holding', length: 1, scale: 0.1 }, pressure: { addr: 1, type: 'holding', length: 1, scale: 0.01 }, alarmCode: { addr: 10, type: 'holding', length: 1 } };

这样上层业务只需要写readSnapshot()拿数据,不需要关心协议细节。现场新增设备点位时只改配置对象,业务代码完全不动。遇到个别设备地址不连续或需要分批读取的,我在配置对象里再加一个分组字段,按组构造请求,既减少串口通信次数,又避免单帧请求过长。

从那以后我每次接手类似项目,都会先花 30 分钟把「连接层 + 配置层 + 服务层」这套骨架搭起来,再谈具体业务。坚持下来,现场调试的翻车率低了很多,设备数据采集前端也稳定扛过了多次长时间运行。modbus-serial 帮我把协议栈的黑匣子打开了,但我更看重的是它 API 的简洁度,让我可以把精力放在设备语义层而不是字节流上。希望这篇记录能帮你在下一个硬件联调的项目里少走几步弯路。

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

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

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

立即咨询