
简介modbus-serial是一个采用纯JavaScript编写的开源通信库专为Node.js环境设计用于实现Modbus-RTU串行与TCP协议解决前端/服务端与PLC、传感器等工业设备的数据交互问题适合前端工程师与物联网开发者学习使用。资源包共57个文件以JavaScript源码为核心包含完整库实现、示例代码、测试模块同时提供TypeScript类型声明、JSON配置、Shell辅助脚本及License、README等文档整体仅70KB结构精简易于分析。目前已有1591人学习/下载适合具备基础Node.js知识的初中级开发者深入实践。通过阅读源码与示例可以掌握Modbus主从通信中读取线圈、写入寄存器等基础操作理解串口与TCP连接的建立及数据包解析流程库内自带的错误处理和日志工具也有助于快速定位通信故障便于将相关能力迁移到智能家居、能源监控等真实项目中。1. 从串口读到第一个寄存器值我才敢说环境装对了做前端的人第一次碰 modbus-serial 这个库多半是接了工业上位机或者物联网网关的项目——浏览器里跑不了串口Node.js 倒是可以于是群里有人甩过来一句“用 modbus-serial 就行”然后你就开始踩坑。这个库本质上是一个纯 JavaScript 实现的 Modbus RTU / TCP 客户端专门跑在 Node.js 环境里用来和 PLC、电表、温控器、传感器这类 Modbus 从站设备通信。它能让你用十几行代码完成读保持寄存器、写线圈、读输入寄存器这些操作但它依赖 SerialPort 这个原生模块去操作串口原生模块就意味着编译、权限、驱动、版本匹配这一串连锁反应。我最初在某个模拟项目X里接一块 RS485 转 USB 的温湿度采集器折腾了两个晚上最后发现问题不在代码而是 Node 版本太高导致 node-gyp 编译失败串口根本没被打开。这篇文章就按我实际走过的路把环境怎么选、依赖怎么装、参数怎么配、坏了怎么救完整记录下来。适合两类人一是刚把 Node 装好、准备用 modbus-serial 做第一个 Modbus 客户端的开发者二是已经在生产环境里被串口通信的玄学问题折磨过的熟手想看看版本差异和参数取舍。2. 环境准备与版本选择Node、SerialPort、modbus-serial 三者怎么配对2.1 先确认 Node 版本范围和包管理器modbus-serial 是个纯 JavaScript 库本身对 Node 版本要求不苛刻但它依赖的 serialport 包是带原生 C 模块的Node 版本直接决定你能不能装上或者要不要本地编译。我之前在某个跨平台系统上做过一次测试Node 20 搭配 serialport 最新版不会触发 node-gyp 编译npm 会拉预编译二进制但如果锁定 serialport 10.x 又用 Node 21就很容易在 install 阶段看到 node-gyp 报错因为预编译二进制没有覆盖到那个组合。所以在动手之前先确认你本机的 Node 版本建议用 LTS 版本。常见做法是用 nvm 管理先执行nvm list看当前版本再和 modbus-serial 的依赖要求对比。实测下来 Node 16、18、20 这几个 LTS 版本都比较稳Node 14 虽然也能用但老版本对新版 serialport 的预编译支持已经开始缩水。安装命令方面npm 和 yarn 都能装关键是把 serialport 和 modbus-serial 一起装因为 modbus-serial 不会帮你自动装 serialport这是第一个容易被忽略的坑。node -v npm -v nvm list这三条命令分别确认 Node 版本、npm 版本和当前已安装的 Node 版本列表。如果 nvm list 显示多版本共存注意切到 LTS 那个再继续。Node 版本对后续的影响不在 JS 语法层面而在 serialport 的加载阶段。2.2 serialport 版本的选择逻辑serialport 从 9.x 到 12.xAPI 变化不算大但安装机制变化很大。9.x 和 10.x 时期很多环境还需要本地编译Windows 上要有 VS Build ToolsLinux 上要有 build-essential 和 python11.x 之后预编译二进制覆盖面明显变广。我的习惯是优先装最新稳定版装不上再降级到 10.x 配合 Node 16。在 Linux 服务器上还要额外考虑一件事如果下载预编译二进制失败npm 会尝试本地编译这时候系统里没有编译器就直接翻车。提前装上编译工具链能少走弯路但这不是 modbus-serial 的问题是任何带原生模块的 Node 包都逃不过的环节。Windows 用户则要注意 USB 转串口芯片的驱动CH340 和 CP210x 是两种最常见的芯片驱动没装好时serialport 能列出端口但打开会报错。我不建议一开始就在同一台机器上同时装多个大版本的 serialport很容易出现两个实例分别加载不同版本的原生二进制然后互相干扰。如果项目里已经有其他包依赖了 serialport先看它的版本范围尽量让 modbus-serial 复用同一个版本避免 node_modules 里出现两份 serialport。2.3 modbus-serial 版本与协议支持modbus-serial 的版本迭代主要在协议功能上比如 Modbus TCP、RTU、ASCII 的完整支持以及 TCP RTU 模式的加入。如果你只需要最常见的 Modbus RTU over serial 和 Modbus TCP那当前 npm 上的最新稳定版就能覆盖但如果你的设备用 ASCII 模式或者要通过 TCP 连接一个 RTU 网关务必确认版本说明里是否包含对应功能。npm view modbus-serial version npm view modbus-serial dependencies第一条命令查看最新版本号第二条命令查看它的依赖声明你能直接看到serialport被列在 optionalDependencies 还是 peerDependencies。如果是 optionalnpm 在某些网络环境下可能跳过安装这就解释了为什么装完 modbus-serial 后require(serialport)报模块找不到。2.4 Windows / Linux / macOS 的环境差异三套系统的坑不一样逐个说。Windows 上最大概率的坑是 USB 转串口驱动和串口号识别设备管理器里看到 COM3 不意味着 Node 能打开它有些工业设备的驱动会创建一个虚拟串口但实际通信需要厂家提供的专用 API。Linux 上最常见的坑是权限当前用户不在 dialout 或 uucp 组里fs.writeFile打开 /dev/ttyUSB0 会返回 EACCES。macOS 上则要注意系统安全策略对内核扩展的限制部分 USB 转串口芯片需要安装官方驱动并允许其加载。sudo usermod -aG dialout $USER这条命令把当前用户加入 dialout 组之后需要重新登录一次会话才会生效。这是 Linux 上解决“串口权限不足”的标准做法。如果不想重登临时方案是用sudo chmod 666 /dev/ttyUSB0但重启后失效只适合临时验证。3. 下载安装与初始化从 npm install 到第一个端口列表3.1 用 npm 安装依赖包在项目目录下执行安装我建议一次性把 modbus-serial 和 serialport 都装上避免后面因为缺依赖而排查半天。npm init -y npm install modbus-serial serialportnpm init -y快速生成 package.jsonnpm install会把两个包都写入 dependencies。装完之后在项目根目录跑一条验证命令node -e const ModbusRTU require(modbus-serial); console.log(ModbusRTU ? modbus-serial ok : fail)如果打印出modbus-serial ok说明这个纯 JS 模块加载正常。注意这条命令还不涉及串口只验证主库本身的核心文件是否完整。3.2 验证 serialport 能列出系统串口串口通信里端口能不能被枚举出来是第一道坎。写一小段临时脚本用 serialport 自带的list方法看看系统识别到了哪些串口。// list-ports.js const { SerialPort } require(serialport); SerialPort.list().then(ports { console.log(当前系统检测到的串口列表); ports.forEach(p console.log(路径: ${p.path} 制造商: ${p.manufacturer || 未知})); }).catch(err { console.error(枚举串口失败:, err.message); });运行node list-ports.js。如果这里就报错先不用往下看 modbus-serial 的代码因为串口层就挂了。常见现象是列表为空这时候要去检查驱动列表有设备但打开报错那就是权限或占用问题。这个脚本以后排查问题时可以反复用我一般会把它存成项目里的scripts/list-ports.js。3.3 串口打开失败时先看这三个参数如果SerialPort.list()能看到设备但后续openPort报错90% 是端口参数和系统属性对不上。串口通信最基础的三个参数是波特率、数据位、校验位modbus-serial 默认 RTU 模式是 8 数据位、无校验、1 停止位但你的设备可能不是这个配置。典型的翻车现场是设备是偶校验代码里用了无校验于是一直接收到乱码从站根本不给响应。const port new SerialPort({ path: /dev/ttyUSB0, baudRate: 9600, dataBits: 8, parity: none, stopBits: 1, autoOpen: false });这是 serialport 底层的参数结构。注意autoOpen: false的意思是先不自动打开等你自己调用port.open()——modbus-serial 内部也是这样管理端口的。这些参数必须和你从设备手册里查到的参数完全一致特别是 parity 和 stopBits很多老设备用偶校验加 1 停止位也有人用无校验加 2 停止位配错了不会报参数错误只会表现为“发出去的命令没有响应”。3.4 在 modbus-serial 中绑定串口与从站地址串口准备好之后回到 modbus-serial 的 API 层面。它的设计很直接先建一个 ModbusRTU 客户端对象然后绑定网络端口或串口端口最后设置从站地址和超时时间。const ModbusRTU require(modbus-serial); const client new ModbusRTU(); client.connectRTUBaudRate(/dev/ttyUSB0, { baudRate: 9600 }) .then(() { console.log(串口连接成功); client.setID(1); client.setTimeout(1000); }) .catch(err { console.error(连接失败:, err.message); });connectRTUBaudRate是 modbus-serial 专门为 RTU over serial 提供的方法第一个参数是串口路径第二个参数是配置对象。setID(1)设置要访问的从站地址如果设备地址是 2 或 3这里要跟着改。setTimeout(1000)是超时时间单位毫秒这个值直接影响通信速度——设置太短设备响应稍慢就会误报超时设置太长通信失败要等很久才能返回错误。对大多数工业设备1000 毫秒是一个比较稳妥的起点如果发现偶尔超时再往上加。注意connectRTUBaudRate这个方法是 15.x 之后的版本才稳定支持的方式它和早期写法的区别在于不需要手动new SerialPort再传入内部已经封装好了。如果用了老版本的 modbus-serial写法可能是client.connectRTU(/dev/ttyUSB0, { baudRate: 9600 })建议升级到当前 npm 最新版统一用法。4. 功能配置与常用操作读寄存器、写线圈、调超时与重试4.1 读保持寄存器readHoldingRegisters 的返回结构配好了串口接下来是真正从设备读数据。Modbus 协议里最常用的一个功能码是 0x03读保持寄存器对应 modbus-serial 里的readHoldingRegisters方法。保持寄存器通常是设备运行时数据比如当前温度、湿度、电压、电流一个寄存器占 16 位可能代表一个整数也可能是 IEEE 754 浮点数的一部分。async function readSensorData(client) { try { const address 0; const length 2; const result await client.readHoldingRegisters(address, length); console.log(寄存器原始数据:, result.data); console.log(buffer:, result.buffer); } catch (err) { console.error(读取失败:, err.message); } }readHoldingRegisters的第一个参数是起始寄存器地址第二个参数是读取的寄存器个数。返回值里有两个关键字段data是一个数值数组每个元素对应一个寄存器buffer是 Buffer 对象适合做位运算或者拼接。为什么两种格式都要提供因为一个 32 位浮点数通常跨两个寄存器存储比如温度和湿度各占 2 个寄存器读完 4 个寄存器后你需要自己按设备的字节序规则把两个 16 位拼成 32 位再解析。常见做法是直接从result.buffer里取字节做转换因为 Buffer 的字节操作比数组更直观。4.2 写操作writeSingleRegister 与 writeSingleCoil读是采集写是控制。modbus-serial 提供writeSingleRegister写单个保持寄存器、writeSingleCoil写单个线圈。写操作最常见的应用场景是给 PLC 下发一个设定值或者通过线圈启停设备。async function writeSetpoint(client, value) { try { const address 10; const response await client.writeSingleRegister(address, value); console.log(写入响应:, response); } catch (err) { console.error(写入失败:, err.message); } }writeSingleRegister的第一个参数是寄存器地址第二个参数是写入的数值最大是 65535一个寄存器的上限。注意有些设备把模拟量值做了缩放比如实际温度 25.5 度设备可能要求写入 255单位是 0.1 度这个缩放系数必须从设备手册里确认写错了设备不会报错只是控制结果不对。writeSingleCoil的第二个参数是布尔值true代表闭合/启动false代表断开/停止。如果遇到需要连续写多个寄存器的情况用writeRegisters配合一个数组比循环调用writeSingleRegister更高效因为它在同一条 Modbus 报文里完成多寄存器写入减少握手次数。测试时建议先用写单个寄存器的方法验证设备响应正常再改批量写因为批量写入一次失败会整包丢弃排查起来不如单写直观。4.3 超时、重试与数据校验的取舍modbus-serial 的setTimeout是全局超时不加重试机制。实际通信中RS485 总线经常出现偶发丢包特别是线缆长、节点多的时候。所以我一般会写一个带重试的封装函数而不是直接裸调 API。async function readWithRetry(client, address, length, retries 3) { let lastError null; for (let attempt 1; attempt retries; attempt) { try { return await client.readHoldingRegisters(address, length); } catch (err) { lastError err; console.warn(第 ${attempt} 次读取失败: ${err.message}); await new Promise(resolve setTimeout(resolve, 100)); } } throw lastError; }这个函数做了两层保护重试次数默认 3 次每次失败等待 100 毫秒再重试。为什么等待 100 毫秒因为 RS485 总线在半双工下需要切换收发状态设备刚回复完一帧错误码后立即再发新请求容易撞车。重试次数不是越多越好——如果设备本身离线重试只会拖长错误返回时间所以生产环境里我通常把重试次数设 2 到 3 次然后直接上报失败。另一个需要考虑的是 CRC 校验。modbus-serial 在 RTU 模式下会自动做 CRC16 校验当数据在传输过程中被干扰时校验失败会直接表现为超时或抛异常不会返回错误数据。这一点是协议栈自动处理的开发者不需要手动算 CRC但你要知道收到异常响应或超时不一定代表通信断开有可能只是这一帧 CRC 没过。遇到频繁 CRC 错误时优先排查接地和屏蔽层而不是改代码。4.4 Modbus TCP 模式从串口换到网络不是所有场景都走串口。当设备通过以太网口连接时modbus-serial 切换到 TCP 模式代码非常相似区别在连接方法从connectRTUBaudRate变成connectTCP。const client new ModbusRTU(); client.connectTCP(192.168.1.100, { port: 502, timeout: 2000 }) .then(() { console.log(TCP 连接建立); client.setID(1); }) .catch(err { console.error(TCP 连接失败:, err.message); });connectTCP的第一个参数是从站 IP第二个参数是端口配置。Modbus TCP 默认端口是 502这个端口在 Linux 上如果使用非 root 用户可能没有权限绑定监听——但这里是客户端连接不存在绑定的问题。真正要注意的是设备可能把端口改成 500 或非标准端口连接前用 telnet 或 nc 测一下端口通不通。TCP 模式的超时设置建议比串口模式短一些因为网络链路的延迟比串口稳定2000 毫秒是常用值。TCP 模式下还有一个选配参数connectTCP的第三参数或配置对象里可以设置rtuBaudRate当你要通过 TCP 连一个“串口服务器”并且它转发的是 RTU 帧时需要用setTimeout和setEncoding配合设备实际配置。我一般不推荐在 TCP 连接上启用 RTU 编码除非设备是网关型的串口服务器否则协议帧格式不对会通信失败。5. 常见问题排查串口装了为什么读不到数据5.1 现象一SerialPort.list() 能看到设备但 open 报错Linux 下/dev/ttyUSB0已经存在ls -l /dev/ttyUSB0能看到设备文件但脚本一打开就报Error: Permission denied, cannot open /dev/ttyUSB0。原因几乎都是当前用户不在 dialout 和 uucp 组中Ubuntu 系统默认只有 root 和 dialout 组内的用户能访问串口设备。解决方法是把用户加入 dialout 组并重新登录验证方式是用groups命令检查当前会话里有没有 dialout。如果着急验证也可以直接对设备文件 chmod 666但只适合临时调试不能写进部署脚本。sudo usermod -aG dialout $USER newgrp dialout ls -l /dev/ttyUSB0这里newgrp dialout的作用是在当前会话中立即切换附加组避免重新登录等待。ls -l确认设备在。5.2 现象二写代码后一直超时设备没有响应代码没问题、串口能打开、从站地址也正确但readHoldingRegisters一直抛Timeout异常。排查顺序很重要第一检查波特率是否和设备一致设备是 19200 而你配了 9600报文发出去设备听不懂第二检查校验位和数据位老设备常见“偶校验 1停止位”第三检查 RS485 的收发控制——很多 USB 转 RS485 适配器是自动切方向的但也有便宜模块需要手动拉高 DE 脚如果是后者在串口参数里加rts: true或dtr: true配置再试。const port new SerialPort({ path: /dev/ttyUSB0, baudRate: 9600, parity: even, stopBits: 1, dataBits: 8, autoOpen: false, rts: true });这条配置增加了rts: true用于让适配器在发送数据时自动拉高方向控制线。不是所有适配器都需要但这个参数是排查超时问题时的有效手段之一。我遇到过一款某公司的 RS485 模块必须把rts设为 true 才能发出完整帧否则对端只收到半包。5.3 现象三Windows 上 COM 端口不固定USB 转串口设备每次插拔后 COM 端口号会变昨天是 COM3 今天是 COM5脚本里写死的 COM3 就会打开失败。解决是用设备的硬件 ID 或友好名称来匹配端口而不是直接写 COM 号。在 Windows 设备管理器里查看端口属性的“详细信息”能找到 VID/PID。然后写一个小辅助函数根据 VID/PID 自动查找对应的 COM 端口。const { SerialPort } require(serialport); async function findPortByVidPid(vid, pid) { const ports await SerialPort.list(); const matched ports.find(p p.path.includes(COM) (p.vendorId vid p.productId pid)); if (!matched) { throw new Error(未找到 VID${vid} PID${pid} 对应的串口); } return matched.path; }这个函数的价值在于部署到别的机器时不需要手动改端口号。SerialPort.list()返回的每个端口对象包含vendorId和productId用设备芯片的 VID/PID 匹配是最可靠的方式。比如某款 CH340 芯片的设备 VID 是 1A86PID 是 7523把这两个参数传入函数就能动态定位串口。替换成你的实际设备参数即可。5.4 现象四modbus-serial 安装时 node-gyp 编译报错安装 serialport 依赖时看到跟node-gyp rebuild有关的红色报错后面跟着gyp ERR!或者python not found。原因是当前 Node 版本没有对应的预编译二进制npm 回退到源码编译而编译环境缺工具链。Windows 上先确认有没有安装 VS Build Tools 和 PythonLinux 上安装 build-essential 和 python3 即可。最快的后悔药方案是切换 Node 到 LTS 版本删除 node_modules 与 package-lock.json 重新安装。nvm install 20 nvm use 20 rm -rf node_modules package-lock.json npm installrm -rf node_modules package-lock.json是避免残留旧二进制与锁文件里的版本引用影响重新解析全删后重装是最干净的恢复方式。切换 Node 版本后npm install 会走新的预编译二进制下载路径大概率跳过本地编译。如果公司内网有 npm 代理还要确认npm config get registry指向的源是否同步了最新二进制。5.5 现象五日志说“slave exception response”设备返回异常码比如Function code: 0x83, Exception code: 0x02。这实际上是 Modbus 协议层在告诉你报文已收到但请求不合法。0x02 是非法数据地址说明你访问的寄存器地址超过了设备实际范围0x01 是非法功能码说明设备不支持这个命令0x03 是非法数据值。遇到这类错误不要再重试而是去核对设备寄存器映射表。比如你的设备保持寄存器起始地址是 40001但 modbus-serial 的地址参数是从 0 开始的需要映射成 0 或 1具体位移要看协议文档里说的“地址偏移”。这个坑非常隐蔽我曾在某个模拟项目X里因为把40001直接当参数传入导致一直报非法地址折腾半天。5.6 现象六数据读出来了但解析结果不对寄存器读到[1692, 17408]这种“中间值”看起来不是正常的温度数据。大概率是浮点数格式问题设备存储的是 IEEE 754 32 位浮点数4 个字节跨 2 个寄存器字节序和字序稍微不同就解析出天文数字。常用解析解法是先按大端序拼接 Buffer再通过readFloatBE转换如果结果不对则切换小端序或者交换两寄存器顺序。const buffer Buffer.from([0x06, 0x9C, 0x44, 0x00]); const floatValue buffer.readFloatBE(0); console.log(floatValue);这是把四个字节按大端序读成浮点数的示例。如果输出的数值异常大或异常小把readFloatBE替换成readFloatLE或者把前两个字节和后两个字节对调总有一种组合能命中设备的字节序。这类问题没有捷径只能靠测试不同的字节序组合来验证属于串口开发里最耗费时间的一类玄学问题。6. 验证跑通用 Modbus 模拟器做一次完整的读写闭环装了环境、配好参数、处理完坑最后一步是验证整个链路能跑通。最可靠的方式不是直接连真实 PLC而是用 Modbus 从站模拟器——它就像一个虚拟的 Modbus 设备你在电脑上起一个 TCP 端口或串口然后让你的代码去读写它。这样能完全避开硬件干扰把怀疑范围锁定在软件链路。Windows 上常用的模拟器是 Modbus SlaveLinux 上可以用 modbus-stack 的测试工具或者用 Python 的 pymodbus 快速起一个虚拟从站。我用 pymodbus 起从站的频率最高因为它跨平台、启动快而且能自定义寄存器初始值。# pip install pymodbus from pymodbus.server import StartTcpServer from pymodbus.datastore import ModbusSlaveContext, ModbusDataBlock from pymodbus.datastore.store import ModbusServerContext store ModbusSlaveContext( zero_modeTrue, hrModbusDataBlock(0, [25, 30, 0, 0, 0]) # 保持寄存器初始值 ) context ModbusServerContext(slaves{1: store}, singleFalse) StartTcpServer(context, address(127.0.0.1, 5020))这段 Python 代码模拟了一个地址为 1 的从站保持寄存器前两个值分别是 25 和 30监听 5020 端口。注意这里用的是 TCP 模式不需要真实串口对新手更友好。启动之后用前面写好的 modbus-serial TCP 客户端去连接127.0.0.1:5020读取寄存器地址 0 开始的 2 个值期望返回[25, 30]。const ModbusRTU require(modbus-serial); async function verifyConnection() { const client new ModbusRTU(); await client.connectTCP(127.0.0.1, { port: 5020 }); client.setID(1); client.setTimeout(2000); const readResult await client.readHoldingRegisters(0, 2); console.log(读到寄存器值:, readResult.data); await client.writeSingleRegister(0, 77); const readAfterWrite await client.readHoldingRegisters(0, 2); console.log(写入后寄存器值:, readAfterWrite.data); client.close(); } verifyConnection().catch(err console.error(验证失败:, err.message));这段验证脚本做的事很直接先读地址 0 和 1 的保持寄存器打印初始值然后向地址 0 写入 77再次读地址 0 和 1确认写入生效。如果输出是[25, 30]然后[77, 30]说明读写路径完全打通modbus-serial 正确组帧、TCP 正确传输、模拟器正确响应。如果第一次读取就超时先检查模拟器有没有真的跑起来可以用nc -zv 127.0.0.1 5020测端口如果端口通但超时检查你设置 setID 的地址是不是 1和模拟器里 slaves 字典的 key 保持一致。验证的时候有几个技巧。第一用 TCP 模式跑通之后再切换到串口模式因为网络链路的错误源比串口少很多如果一个 TCP 都通不了说明代码逻辑本身有问题第二写入验证一定要做因为很多 Modbus 设备对写操作有额外的权限控制或数值范围限制只读不写不能证明全部能力第三验证完要client.close()很多脚本跑完不关连接导致下次启动时端口被占用或者串口句柄泄漏。从那以后我每次在项目里引入 modbus-serial 之前都强制自己先起一个模拟从站把读写流程完整走一遍。这个习惯帮我避免了很多低级错误比如地址偏移算错、字节序弄反、超时设置太短。这套流程花不了十分钟但能让你对接真实设备时心里有底——至少你确定软件栈本身没有问题剩下要排查的只有硬件那边的故障了。希望这篇笔记能帮你少走几步弯路装好环境顺利读到第一个设备数据。本文还有配套的精品资源点击获取