简介modbus-serial 开源库完全采用 JavaScript 编写运行于 Node.js 环境支持 Modbus-RTU 串口通信与 TCP 网络通信面向前端开发者和物联网从业者用于快速对接 PLC、智能电表、传感器等工业设备解决浏览器或服务端无法直接读写 Modbus 数据的难题。压缩包共含 57 个文件总体积约 70KB其中以 46 个 JS 源码文件为主体另有 3 个 TypeScript 类型定义文件、2 个 JSON 配置、2 个 Shell 脚本以及 README、LICENSE 等文档源码与配置分离便于按需阅读和二次开发。目前已有 1591 人学习浏览适合需要理解 Modbus 协议封装、串口/TCP 通信以及异步读写机制的开发者参考。资料内不仅包含核心库实现还整理有 CRC16/LRC 校验工具、RTU/TCP/C701 等多类端口封装、自动化测试文件以及二十余个可运行示例能够帮助读者快速掌握寄存器读写、线圈操作、错误处理和超时机制并直接复用到实际项目中。1. 为什么前端也要装 modbus-serial一次 PLC 联调踩出的经验去年接到一个某跨平台系统的活前端要实时读车间里 PLC 的寄存器数据。一开始我打算自己拼 Modbus RTU 报文CRC 校验、功能码、字节序全手写折腾了两天报文结构是拼出来了一接真实设备就翻车——不是超时就是校验失败最后查出来是字节序高低位搞反了。后来换用开源库 modbus-serial一条readHoldingRegisters就把报文组帧、CRC 校验、超时重试全包了半小时内联调跑通。这里把整个安装、配置、验证过程记录下来。这篇笔记适合两类人一是前端项目里要接串口或网口设备、但没写过 Modbus 协议的开发者二是运维或工控方向需要在 Node.js 环境里快速接 PLC、传感器、电表这类设备的工程师。modbus-serial 是目前 Node.js 生态里最常用的 Modbus 主站库支持 RTU、ASCII、TCP 三种模式一次安装能覆盖绝大多数现场。2. 装前准备Node.js 版本和串口权限一次理清2.1 Node.js 版本LTS 优先别在奇数版本上折腾modbus-serial 本质是一个纯 JavaScript 的 npm 包底层通过serialport库访问串口。它没有绑定某个具体 Node 大版本但在我装过的十几台机器上最稳的是 Node.js 14 LTS 和 16 LTS。Node 18 也能跑但如果你要同时用 node-serialport 的原生编译模块Node 18 有时候会在node-gyp这一步卡住需要装 Visual Studio Build Tools 或 Xcode Command Line Tools。装之前先确认当前 Node 版本终端执行node -v npm -v如果你本机还没有 Node我一般建议直接用 nvm 安装好处是版本切换方便某个项目需要旧版 Node 时不用卸载重装。常见做法是curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 16 nvm use 16nvm 装完会自动把当前 shell 切到对应 Node 版本执行node -v能打印出v16.20.2之类的结果就说明环境就绪。如果你是在某公司内网 Windows 机器上装没有 nvm 就用官方安装包装完记得重启终端让 PATH 生效。这里有个小教训不要图省事直接装最新版 Node尤其当项目里还有 serialport、modbus-serial 这类依赖时LTS 版本的兼容性明显更好省得后面处理一堆原生模块编译报错。2.2 Linux 串口权限先加 dialout 组省得后面到处碰壁如果你是在 Linux 上跑串口设备这一步几乎是必踩的坑。默认情况下普通用户没有访问/dev/ttyUSB0或/dev/ttyS0的权限node 进程打开串口时会报Error: Permission denied。我当时第一次装完库兴冲冲把代码写好一执行直接红线排查了半天才发现是权限问题。解决方式是把当前用户加入dialout组这是 Ubuntu/Debian 系常见的串口权限组sudo usermod -a -G dialout $USER执行完这条命令后必须注销重新登录或者重启一次组权限才会生效。有些精简系统用的组名是uucp比如 Arch 系不确定的话先看串口设备归属ls -l /dev/ttyUSB*输出会显示设备的所有者和所属组比如crw-rw---- 1 root dialout 188, 0...那就把用户加进对应的组。这一步做完后面打开串口就不会因权限翻车了。如果你用的是 Windows串口权限问题少一些但要注意设备管理器里查清楚串口号实际是 COM3 还是 COM5经常有 USB 转串口线每次插入分配的号不一样。2.3 安装 modbus-serial一条命令的事但先确认 npm 源环境就绪后进入项目目录初始化并安装npm init -y npm install modbus-serialnpm init -y会生成一个默认的 package.json不用管里面内容直接安装依赖即可。安装完成后可以在node_modules/modbus-serial下看到它的目录结构里面有README.md和examples文件夹这个 examples 目录值得先翻一遍里面有现成的 RTU 和 TCP 读写示例。国内网络环境下如果遇到 npm 下载卡住常见做法是把 registry 切到国内镜像npm config set registry https://registry.npmmirror.com装完可以执行npm list modbus-serial确认版本号。这里的版本选择原则尽量选最新稳定版但不要选带-beta或-next前缀的预发布版本。这类预发布版本通常改了内部接口网上搜到的示例代码不一定兼容。项目锁版本的话在 package.json 里写modbus-serial: ^8.0.0这样的范围即可下次npm install会自动装到这个大版本下的最新小版本。提示如果安装过程中报gyp ERR! stack Error: not found: make说明系统缺编译工具链。Ubuntu 下执行sudo apt install build-essentialWindows 下安装 Visual Studio Build Tools 并勾选 C 桌面开发组件然后重装一遍。3. 连接配置从串口 RTU 到 Modbus TCP 的三种接法3.1 串口 RTU 连接波特率、校验位、停止位怎么配modbus-serial 连接串口最常用的是connectRTUBaudRate方法它只需要两个参数设备路径和波特率。完整写法const ModbusRTU require(modbus-serial); const client new ModbusRTU(); client.connectRTUBaudRate(/dev/ttyUSB0, { baudRate: 9600, dataBits: 8, parity: none, stopBits: 1, }) .then(() { client.setID(1); client.setTimeout(1000); console.log(串口已连接); }) .catch((err) { console.error(连接失败:, err); });这段代码里的baudRate必须和从站设备一致绝大多数 PLC 和电表的出厂默认波特率是 9600但也有不少设备用 19200 或 115200。dataBits默认 8parity默认 nonestopBits默认 1这三项在遇到 Modbus ASCII 模式时会有变化但 RTU 模式下一般保持默认即可。setID(1)设置的是从站地址范围是 1 到 247要和设备侧拨码开关或配置软件里的站号一致。setTimeout(1000)是超时时间单位毫秒表示等待从站响应的最长时间。这里有一个我自己的习惯把串口参数写进一个配置文件而不是硬编码在业务代码里。因为现场设备的波特率、站号经常因为调试需要临时改放在一个config.json里改参数不用碰代码逻辑重新启动进程就生效。3.2 Modbus TCP 连接局域网设备不用管串口参数如果设备支持网口现在很多新 PLC 和网关都带以太网口推荐直接用 TCP省去串口线的物理连接也绕开权限问题。TCP 连接代码更简单const ModbusRTU require(modbus-serial); const tcpClient new ModbusRTU(); tcpClient.connectTCP(192.168.1.100, { port: 502, }) .then(() { tcpClient.setID(1); tcpClient.setTimeout(2000); console.log(TCP 已连接); }) .catch((err) { console.error(TCP 连接失败:, err); });connectTCP第一个参数是从站设备的 IP 地址第二个参数的port默认是 502这是 Modbus 标准端口。如果你的设备改过端口这里要跟着改。TCP 模式下没有波特率、校验位的概念因为数据链路层由以太网保证Modbus 的报文直接封装在 TCP 包里。但有一个坑需要注意有些设备用 502 端口需要先改成别的端口才能连上原因是有防火墙拦截或者设备的 Modbus TCP 服务端只监听特定端口。我遇到过一次设备默认端口是 1502OpenPLC 这类软件默认也是 1502。TCP 连接方式下同样要setID这个地址指的是数据包里的单元标识符Unit ID一般设备默认是 1不需要改动。3.3 同时挂多个从站一个 client 还是多个 client现场经常遇到一根总线挂多台设备的情况。比如一条流水线上有 3 个电表站号分别是 1、2、3。有两种做法一种是为每个从站单独创建 client 实例另一种是共用一个串口 client在每次读写前切换setID。常见做法是共用同一个串口连接因为串口是半双工总线同一时刻只能有一个请求在总线上如果开多个 client 去连同一个串口会触发资源竞争报Resource temporarily unavailable。正确写法是async function readMeter(slaveId) { client.setID(slaveId); const res await client.readHoldingRegisters(0, 10); return res.data; } await readMeter(1); await readMeter(2); await readMeter(3);每次读之前切换setID读完再切下一个。注意这里用的是await串行执行不要用Promise.all并发去读多个从站Modbus 总线不支持并发访问。这条经验是我在某次项目中踩出来的一开始并发去读 3 个电表结果只有第一个请求成功后面全部超时改成串行后一切正常。4. 读写寄存器主站代码怎么写才不至于读回一堆乱码4.1 读保持寄存器readHoldingRegisters 的返回结构Modbus 最常用的功能码是 03读保持寄存器和 04读输入寄存器。保持寄存器是可读可写的输入寄存器是只读的。modbus-serial 对应提供了readHoldingRegisters和readInputRegisters两个方法参数完全一致const res await client.readHoldingRegisters(0, 10); console.log(res.data);第一个参数是起始寄存器地址第二个参数是要读取的寄存器数量。返回的对象里有个data字段是寄存器数值数组。注意这里有个坑res.data是一个数组但数组元素是数字还是 Buffer取决于读取的寄存器数量。如果只读 1 个寄存器返回的是数字数组如果读多个底层可能返回 Buffer 类型的数据结构。稳妥的做法是统一做一次转换const values res.data; console.log(values); // [ 12, 34, 56, ... ]如果发现打印出来是类似Buffer 00 0c 00 22这样的二进制数据说明返回的 raw 数据没有解析成十进制需要用res.data的buffer属性或者手动调用parseBuffer逻辑。这个我在 5.4 节会详细讲。读寄存器时地址从 0 开始编号对应设备说明书里的寄存器地址需要减去 40001 或 30001 的偏移量。比如说明书写「保持寄存器 40001」那代码里的起始地址就是 0。4.2 写线圈和写寄存器写操作的参数格式容易搞混写操作有两个方向写单个线圈用writeCoil写单个寄存器用writeRegister。线圈是位类型只有 0 和 1 两种状态寄存器是 16 位整型。代码示例// 写线圈地址 0状态 true await client.writeCoil(0, true); // 写寄存器地址 1值 100 await client.writeRegister(1, 100);writeCoil的第二个参数是布尔值true 表示闭合false 表示断开。但有的设备说明书里要求写 0xFF00 表示闭合、0x0000 表示断开modbus-serial 内部会自动把 true 转成 0xFF00不用手动处理。writeRegister的第二个参数是 0 到 65535 之间的整数如果写负数会报错因为 Modbus 寄存器本身是无符号的负数需要转成补码形式例如 -1 转成 65535。如果遇到要连续写多个寄存器用writeRegisters(startAddr, array)await client.writeRegisters(0, [100, 200, 300]);这个方法的第二个参数是数组数量不要超过 123 个因为 Modbus RTU 报文最多携带 123 个寄存器250 字节有效载荷。大块写入时超过这个数会被拆包拆包逻辑有时候处理不好会丢数据这是 Modbus 协议本身的限制不是库的问题。4.3 轮询节奏read 和 interval 的搭配不当会掉链子PLC 数据采集通常是轮询模式每几百毫秒读一次。最容易犯的错误是把读取逻辑直接塞进setInterval回调里// 这种写法在高频轮询时可能堆积回调 setInterval(() { client.readHoldingRegisters(0, 10).then(console.log); }, 500);如果从站设备响应慢上一次请求还没完成下一次请求又发出去Modbus 总线上会乱套。正确做法是每次读完成后再启动下一次读用递归setTimeout或者setImmediate配合async function poll() { try { const res await client.readHoldingRegisters(0, 10); console.log(res.data); } catch (err) { console.error(读取失败:, err.message); } finally { setTimeout(poll, 500); } } poll();这里用finally保证无论读取成功还是失败都会在 500ms 后发起下一次轮询避免异常中断轮询循环。500ms 是相对保守的间隔如果你的设备响应快、总线上挂的从站少可以缩到 200ms但如果总线有多个从站间隔建议拉长到 1 秒以上否则会因为总线占用冲突持续报超时。注意轮询里加了超时 1000ms意味着每次最坏情况下单个从站要等 1 秒如果挂了 5 个从站一轮完整轮询最长要 5 秒。轮询间隔要大于「从站数量 × 超时时间」这个公式记住了能少踩很多坑。5. 避坑指南modbus-serial 最常见的五个翻车现场5.1 现象超时错误Error: MODBUS Timeout这个错误是最常见的我第一次联调就被它折磨了一个下午。现象是连接成功但一发读写请求就卡住直到超时时间到了报MODBUS Timeout。原因分几类第一从站地址不对setID设的跟设备实际站号不一致设备收到请求但发现不是自己的地址就不响应第二波特率、数据位、校验位和从站不一致报文在物理层就已经错乱了第三总线接线上有故障比如 RS485 的 A/B 接反了。排查顺序我一般按这个来先确认站号再看参数最后查线。站号最容易被忽略因为刚连上的时候往往没注意去设备面板确认拨码开关。参数检查就对比设备说明书把baudRate和parity抄到配置文件里。接线问题用万用表量一下 A/B 之间的电压正常应该在 2V 到 7V 之间如果测到接近 0V说明有短路或没供电。解决办法超时时间从 1000ms 加到 3000ms 再观察。如果 3 秒还是超时基本可以断定不是速度问题而是上面三类的某一种。不要通过无限加大超时来掩盖问题我曾经把一个项目的超时调到 10 秒结果设备一旦不响应整个轮询卡死 10 秒数据链路直接瘫了。5.2 现象Error: Permission denied, cannot open /dev/ttyUSB0这个报错出现在connectRTUBaudRate阶段进程连不上串口设备。原因就是 2.2 节说的当前用户不在串口设备所属组里。解决sudo usermod -a -G dialout $USER改完组后必须重新登录或者重启如果不想重启可以用newgrp dialout临时切换当前终端会话的组身份。还有一种隐藏情况如果用的是 Docker 容器跑应用容器里也要把串口设备映射进去并且以--privileged模式运行或者用--device /dev/ttyUSB0指定设备。这个坑在某次某公司的容器化部署里折腾了不少时间容器里ls /dev/ttyUSB0能看到设备但 node 就是打不开最后发现是容器缺少--privileged导致 ioctl 权限受限。5.3 现象读回来的寄存器数值明显不对比如温度显示 -273寄存器的值看起来对但数值大得离谱或成负数大概率是数据类型解析问题。Modbus 寄存器是 16 位无符号整数而实际业务数据可能是 32 位浮点数、32 位整数、或者带符号的 16 位整数。比如一个温度传感器用 2 个寄存器存一个 32 位浮点数你按单个寄存器读出来就是两个没意义的整数。解决方式是拿到原始 buffer 后自己按类型解析。modbus-serial 的readHoldingRegisters返回的res.data在多寄存器读取时可以是数组如果要还原成原始字节可以用const buf Buffer.from(res.data); // 如果 data 是数组 const temp buf.readFloatBE(0); // 按大端序读 4 字节浮点数这里要注意字节序。Modbus 协议默认大端序Big Endian但很多国产设备实际用的小端序导致读出来的浮点数完全错误。我的习惯是写一个字节序探测函数先读一段已知值比较大小端哪个对确定后在代码里固定写死不指望设备说明书因为说明书写大端但实际跑小端的设备我见过好几个。5.4 现象res.data的类型是 Buffer 还是数组分不清这个问题来自 modbus-serial 不同版本的返回结构差异。早期版本里读多个寄存器返回的是数组后来的版本在某些功能码下返回带buffer属性的对象。如果你用console.log(res.data)看到Buffer ...而不是[ ... ]说明拿到的是 Buffer 类型。稳妥的做法是统一转成数组再处理function toArray(data) { if (Buffer.isBuffer(data)) { return Array.from(data); } if (Array.isArray(data)) { return data.map((item) typeof item object item ! null ? item.value ?? item : item ); } return data; } const values toArray(res.data);这段工具函数在跨版本升级时能少改很多代码。我后来把 modbus-serial 从 7.x 升到 8.x 时就是靠这个兼容函数过滤掉了大部分差异不用逐个业务代码去调。5.5 现象串口报Resource temporarily unavailable这个错误出现在连接阶段或者运行一段时间后。如果出现在运行中几乎都是没有正常释放资源。比如进程崩溃或 CtrlC 退出时没有调用关闭方法串口被系统锁定下次启动就报这个错。解决方式是在进程退出时强制关闭连接process.on(SIGINT, async () { await client.close(); process.exit(0); });如果真的出现串口被锁死命令行层面可以用fuser -k /dev/ttyUSB0强杀占用进程。但这不是常规解决方案正确做法还是保证每次打开连接后有一个对应的关闭逻辑。另外检查是否两个不同 client 同时连同一个串口上面 3.3 节说的共用 client 就是为避免这个问题。6. 验证跑通没有 PLC 时用模拟从站自测装完库之后身边不一定有真实 PLC 可以联调。这时候不要干等设备用模拟从站先把主站代码验证一遍。我常用的组合是modbus-serial 的官方示例里带一个简易从站脚本可以直接在另一个 Node 进程里跑起来让你本机的真实串口或 TCP 端口中监听请求。先启动一个 TCP 模式的模拟从站监听 502 端口// server.js const ModbusRTU require(modbus-serial); const vector { getHoldingRegister: (addr) { // 返回一些测试数据 return { value: 100 addr }; }, }; const server new ModbusRTU.ServerTCP(vector, { port: 502, debug: false, }); server.on(socketError, (err) { console.error(socket error:, err); });然后启动模拟从站再写一个简短的客户端脚本去读// client.js const ModbusRTU require(modbus-serial); const client new ModbusRTU(); async function main() { await client.connectTCP(127.0.0.1, { port: 502 }); client.setID(1); client.setTimeout(1000); const res await client.readHoldingRegisters(0, 5); console.log(寄存器数据:, res.data); await client.close(); } main().catch((err) console.error(err));如果第二个脚本能打印出[100, 101, 102, 103, 104]这样的数据说明你的主站代码完全没问题。我这里用的测试数据是100 addr所以地址 0 返回 100地址 1 返回 101。这一步跑通了再去接真实设备时如果出现问题就可以明确锁定是设备侧配置问题而不是库的安装或使用问题。TCP 模式验证通过后建议再补一轮串口回环测试。如果手边没有 USB 转 485 工具可以简单把 USB 转串口的 TX 和 RX 接在一起做一个回环。不过串口回环测试要比 TCP 复杂因为 Modbus 从站设备不会主动回应需要模拟从站脚本监听真实的 USB 串口。这里不展开串口回环推荐大家直接用 TCP 模拟从站做验证最省事它覆盖了库的核心读写链路。整个验证跑通后我自己习惯留一个test.js在项目里里面就放着模拟从站的脚本和客户端测试脚本以后升级 modbus-serial 版本、换 Node 版本、或者迁移服务器环境时先跑一遍测试脚本确认读写正常再上生产。那次在升级 8.x 版本后就是靠这套测试脚本发现了返回结构差异提前处理掉了。从那以后每次部署新环境我都强制先走一遍这个验证流程确认主站能读到模拟从站的数据才开始写真正的业务逻辑。希望帮到你。本文还有配套的精品资源点击获取