十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Electron+SerialPort串口开发:ABI兼容与打包交付避坑

Electron+SerialPort串口开发:ABI兼容与打包交付避坑 接手这个需求的时候我的判断是两三天的小活——Electron 里嵌一个串口收发面板Electron 做壳SerialPort 做底层界面上几个按钮加一个日志框收工。结果真正动手才发现代码本身从来不是难点难的是让这个 Electron SerialPort 组合在开发机、测试机、用户机三台完全不同的环境上都稳定跑起来。本地 dev 模式下跑得好好的electron-builder 一打包用户双击打开就弹Cannot find module .../bindings.node换到一台 Ubuntu 机器上又变成/dev/ttyUSB0权限不足再换一台插着 USB 转串口线的电脑端口列表里干脆什么都没有。这些问题没有一个出在业务逻辑里但它们能让你在原地卡整整两天。这篇东西就是把这整个过程摊开讲。适合谁看如果你正准备用 Electron 做一个跟硬件打交道的上位机工具比如扫码枪配置器、单片机调试面板、PLC 参数读写工具、串口烧写助手那你大概率会把我踩过的坑再踩一遍。我会从依赖选型、ABI 兼容、进程架构、IPC 设计、端口枚举、驱动权限、数据分帧、故障排查一路讲到打包交付和长时间稳定运行代码都是能直接抄的。不讲什么是串口这种基础课默认你知道波特率是干什么的。1. 串口能力搬进 Electron这道题的难点到底在哪1.1 一次 require 背后穿过了多少层很多人对串口的心理模型是调个库读写字节但在 Electron 里从你写下import到数据真正出现在设备引脚上中间隔着一条很长的调用链你的业务代码new SerialPort(...)serialport这个包的 JS 封装层serialport/bindings-cpp这个绑定层一层用 C 写的原生桥接代码编译产物.node动态链接库文件操作系统的设备驱动Windows 上是COMx类 Unix 系统上是/dev/ttyUSB0或/dev/tty.usbserial-xxxUSB 转串口芯片的驱动CH340、CP2102、FTDI、PL2303 这几家最常见最后才是外设的 UART 引脚这条链上任何一环断了现象都是串口用不了但原因天差地别可能是 ABI 不匹配可能是驱动没装可能是权限不够也可能是别的软件把端口占着。这也是我一直建议团队里做上位机的同学要建立的第一认知——先定位断在哪一层再谈修不要一看到报错就去重装依赖那是浪费时间。另一个必须提前建立的认知是Electron 里的 Node 环境和标准 Node.js 不是同一个东西。Electron 打包了自己的 Node 运行时它的原生模块 ABI 版本NODE_MODULE_VERSION跟同号 Node.js 是对不上的。一个用node-gyp对着 Node 18 编出来的.node文件拿到 Electron 里加载轻则报was compiled against a different Node.js version重则直接段错误闪退。这个问题在纯 Node 项目里几乎不存在在 Electron 里是日常。1.2 谁持有串口句柄三种架构的取舍在动键盘之前先决定串口对象放哪儿。这决定了后面所有代码的组织方式我见过太多人一开始随手写在渲染进程写到一半发现要读设备信息、要处理异常、要重连只能推倒重来。架构方案做法优点代价渲染进程直连打开nodeIntegration页面里直接require(serialport)写起来最快不用想 IPC安全模型被破坏任何注入脚本都能操作硬件原生模块加载路径在打包后极易出错不推荐用于交付产品主进程独占 IPC串口实例只在主进程创建渲染进程通过 IPC 请求边界清晰安全端口状态只有一个权威来源需要设计一套 IPC 协议数据回传要做批处理独立子进程 / 本地服务串口能力单独跑一个进程用 socket 或标准输入输出通信串口崩了不会拖死 UI可以跨语言复用复杂度最高进程守护、启动顺序、退出清理都要自己管我自己的选择是第二种而且几乎没有犹豫过。理由是串口的生命周期天然应该跟应用生命周期绑定主进程是最合适的管理者渲染进程只负责我想打开哪个口、我要发什么不关心句柄怎么来的。至于第三种除非你的应用同时要管十几路串口并且对崩溃隔离有硬要求否则属于过度设计。顺便说一个踩过的坑不要图省事在渲染进程里用window.require去加载串口模块。Vite、Webpack 这类打包工具会尝试静态分析并处理这个 require最后给你打出一个空模块或者直接报解析失败。这类问题在开发阶段可能被掩盖一旦构建到生产版本就暴露。2. 依赖装不对后面全白费版本选择与 ABI 打通2.1 版本差异比你想的大serialport这个包从 v9 到 v10 有过一次相当重要的实现调整底层绑定改成了基于 Node-APIN-API的方案。这件事对 Electron 开发者的意义是实质性的N-API 是 ABI 稳定的接口用它编出来的原生模块可以在不同 Node 版本、不同 Electron 版本之间直接复用不需要针对每个 Electron 版本重新编译。所以我的建议很直接新项目一律用 v10 以上的版本能上 v12 就上 v12。别再用serialport8、serialport/bindings9这类老组合那是 ABI 地狱的重灾区每次升级 Electron 都要重新折腾一轮。装完之后确认一下node_modules/serialport/bindings-cpp/prebuilds/目录里有没有对应平台的预编译文件。有预编译文件意味着你大概率不用本地编译也就意味着不用装 Visual Studio Build Tools 或者build-essential这在 CI 上能省掉十几分钟的等待。2.2 electron-rebuild 到底在修什么即使新版本更省心你仍然应该知道electron-rebuild现在包名是electron/rebuild在干什么因为总有项目会退回到源码编译路径。它的工作逻辑很简单读到你当前 Electron 的版本号换算出对应的 ABI然后带着这套 ABI 参数重新跑一次原生模块的构建流程产出跟 Electron 匹配的.node文件。什么时候会真的需要它预编译产物没有覆盖你的平台比如某些 ARM 架构的 Linux 板子你的项目里有其他原生模块而它们的预编译产物不全你做过依赖降级装到了非 N-API 的老版本命令本身没什么花头# 只重建串口相关模块速度最快 npx electron/rebuild -f -w serialport # 如果串口依赖是通过 bindings-cpp 间接引入的直接指定它更稳 npx electron/rebuild -f -w serialport/bindings-cpp-f是强制重建-w是只处理指定模块这两个参数组合能把一次重建从几分钟压缩到十几秒。把它挂到postinstall上是个常见做法{ scripts: { postinstall: electron-rebuild -f -w serialport } }注意postinstall里跑重建会让每次npm install都变慢团队协作时容易被人抱怨。我的折中方案是把它写成独立脚本npm run rebuild:native只在真正报 ABI 错误的时候手动执行同时把这条写进 README避免新人一头雾水。2.3 包管理器配置pnpm 用户必看的两条如果你用 pnpm有两个配置不加上后面打包环节一定会出问题。第一个是构建脚本白名单。pnpm 从 v10 开始默认阻止依赖执行安装脚本而原生模块恰恰依赖安装脚本去下载或编译二进制。表现就是node_modules装完看着正常一运行就说找不到绑定文件。解决办法是在package.json里显式放行{ pnpm: { onlyBuiltDependencies: [ electron, serialport, serialport/bindings-cpp, esbuild ] } }第二个是node_modules的链接方式。pnpm 默认用符号链接组织依赖非常节省磁盘但 electron-builder 在收集文件时对符号链接的处理一直很别扭经常出现本地能跑、装完就崩。我的做法是在项目根目录的.npmrc里改成扁平结构node-linkerhoisted shamefully-hoisttrue代价是失去了 pnpm 引以为傲的磁盘优势换来的是打包结果可预测。做上位机工具这种东西我从来优先选可预测。3. 主进程管端口渲染进程管界面IPC 通道怎么设计3.1 preload 里只暴露刚好够用的那几个方法安全配置上没什么可商量的nodeIntegration: false、contextIsolation: true所有能力通过 preload 脚本用contextBridge暴露。关键是暴露面要收窄不要写一个通用的invoke(channel, ...args)转发函数——那等于把整个 IPC 面全开了前面那些安全配置就白设了。我通常的接口形状是这样// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(serialApi, { list: () ipcRenderer.invoke(serial:list), open: (options) ipcRenderer.invoke(serial:open, options), close: (path) ipcRenderer.invoke(serial:close, path), write: (path, data) ipcRenderer.invoke(serial:write, { path, data }), onData: (handler) { const listener (_event, payload) handler(payload); ipcRenderer.on(serial:data, listener); return () ipcRenderer.removeListener(serial:data, listener); }, onState: (handler) { const listener (_event, payload) handler(payload); ipcRenderer.on(serial:state, listener); return () ipcRenderer.removeListener(serial:state, listener); } });注意onData返回了一个取消订阅的函数。这个细节非常重要前端框架里组件挂载时注册监听、卸载时不取消反复切换页面之后同一个数据会被处理 N 次日志面板刷刷刷地重复输出你会以为是设备在重复发数据查半天。3.2 请求类操作统一做成 Promise主进程侧用ipcMain.handle处理请求用ipcMain.on或者webContents.send推送事件这个分工要分清有返回值、可能失败的操作走 handle单向通知走 send。// main.js const { ipcMain, BrowserWindow } require(electron); const { SerialPort } require(serialport); const ports new Map(); // path - SerialPort 实例 ipcMain.handle(serial:list, async () { const list await SerialPort.list(); return list.map((p) ({ path: p.path, manufacturer: p.manufacturer || , vendorId: p.vendorId || , productId: p.productId || , serialNumber: p.serialNumber || })); }); ipcMain.handle(serial:open, async (event, options) { const { path, baudRate 115200 } options; if (ports.has(path)) { return { ok: false, error: PORT_ALREADY_OPEN }; } return new Promise((resolve) { const port new SerialPort( { path, baudRate, dataBits: 8, stopBits: 1, parity: none, autoOpen: false }, (err) { if (err) resolve({ ok: false, error: err.message }); } ); port.on(open, () { ports.set(path, port); resolve({ ok: true }); }); port.on(error, (err) { resolve({ ok: false, error: err.message }); }); port.open((err) { if (err) resolve({ ok: false, error: err.message }); }); }); });这里有个细节值得多说一句我把autoOpen设成false然后手动port.open()。原因是autoOpen: true的情况下如果端口被占用错误会通过 error 事件异步抛出而不是从构造函数里抛出。在一个 Promise 化的接口里异步事件和构造异常混在一起会让错误处理写得很难看。统一手动开、统一用回调收第一手错误代码清爽很多。3.3 数据回传必须做批处理这是我认为整个 IPC 设计里最容易翻车的地方。串口在 115200 波特率下理论上每秒能来一万多个字节。如果你在port.on(data)里直接webContents.send一秒就是成百上千次 IPC 调用渲染进程会被事件洪水冲垮界面直接卡死日志框滚动都跟不上。我的做法是在主进程里加一个小的聚合缓冲按时间窗口打包发送const FLUSH_INTERVAL 30; // 毫秒 const pending new Map(); // path - Buffer[] let timer null; function scheduleFlush() { if (timer) return; timer setTimeout(() { timer null; for (const [path, chunks] of pending) { if (!chunks.length) continue; const merged Buffer.concat(chunks); chunks.length 0; const win BrowserWindow.getAllWindows()[0]; if (win) { win.webContents.send(serial:data, { path, hex: merged.toString(hex), time: Date.now(), length: merged.length }); } } }, FLUSH_INTERVAL); } port.on(data, (buf) { if (!pending.has(path)) pending.set(path, []); pending.get(path).push(buf); scheduleFlush(); });30 毫秒是我实测下来比较舒服的值界面上看不出延迟IPC 调用量降了两个数量级。这个值可以根据你的业务调如果只是收发几字节的控制指令16 毫秒甚至 50 毫秒都无所谓如果是采波形数据那要考虑的就不是 IPC 频率而是要不要直接在主进程做降采样再传。4. 打开之前先枚举端口识别、驱动与权限4.1SerialPort.list()里哪几个字段真正有用枚举端口看起来是最简单的一步实际上决定了你的应用好不好用。如果不做任何过滤用户会在下拉框里看到一堆COM1到COM20的幽灵端口还有蓝牙虚拟出来的串口选起来一脸懵。list()返回的每个对象里有这么几个字段值得关注字段典型值用途pathCOM5//dev/ttyUSB0打开端口时必须传的值唯一标识manufacturerwch.cn/Silicon Labs判断这是不是 CH340 或 CP2102 芯片vendorId/productId1a86/7523最可靠的芯片识别方式配合一张已知表做白名单serialNumber0001或空同一型号多个设备时用来区分但很多廉价芯片不提供pnpIdUSB\VID_1A86PID_7523...调试时可以参考业务里基本不用我一般的做法是维护一张小表把常见 USB 转串口芯片的 VID/PID 列进去界面上把匹配到的端口排在前面并标注芯片类型其余的收进其他端口分组。CH340 是1a86:7523CP2102 是10c4:ea60FTDI 系列是0403:6001PL2303 是067b:2303。这几个覆盖了绝大多数 DIY 场景。注意SerialPort.list()在 Windows 上偶尔会漏掉刚插入的设备因为系统枚举有延迟。用户的操作习惯是插上线立刻点刷新这时候列表里没有他就会以为软件坏了。我的处理方式是在刷新按钮上加一个 500 毫秒的延迟重试或者提供一个插入后自动刷新的开关用轮询兜底。4.2 Linux 上的权限问题与设备被抢占这是我在 Ubuntu 上浪费最多时间的一块两个坑几乎必踩。第一个是权限。普通用户默认没有/dev/ttyUSB0的读写权限打开时会报Error: Permission denied, cannot open /dev/ttyUSB0。标准解法是把用户加进dialout组sudo usermod -aG dialout $USER # 需要重新登录会话才生效或者用下面的方式立刻拿到权限验证 newgrp dialout也可以临时用sudo chmod 666 /dev/ttyUSB0验证一下是不是权限问题但这只能验证不能作为方案写进文档——设备每次重新插拔权限都会重置。想要一劳永逸可以写一条 udev 规则让特定 VID/PID 的设备插入后自动带上权限# /etc/udev/rules.d/99-serial.rules SUBSYSTEMtty, ATTRS{idVendor}1a86, ATTRS{idProduct}7523, MODE0666, GROUPdialout写完执行sudo udevadm control --reload-rules sudo udevadm trigger然后重新插拔设备即可。第二个坑更隐蔽较新的 Ubuntu 桌面版自带一个叫 brltty 的盲文显示服务它会主动去抓 CH340 芯片的设备抓走之后你的程序打开端口就报忙或者设备节点一闪就消失。判断方法是用dmesg | tail看看是不是有ch341-uart被brltty抢走的日志。处理方式是把 brltty 对这类设备的自动接管关掉# 屏蔽 brltty 的 udev 规则并停掉服务仅在没有实际盲文设备时这么干 sudo systemctl stop brltty-udev.service sudo systemctl mask brltty-udev.service这个坑的恶心之处在于它跟你写的代码毫无关系你在代码里怎么改都没用必须从系统层面解决。4.3 Windows 和 macOS 的差异点Windows 上最大的问题是驱动。CH340 这类芯片在 Win10 之后有时能自动识别有时不行装不上驱动的表现是设备管理器里出现带感叹号的未知设备list()里自然什么都看不到。这时候要引导用户去芯片厂商官网下载对应驱动装上装完重新插拔。给用户交付的文档里一定要写清楚这一点不然一半的支持工单都是软件看不到串口。macOS 上稍微省心一些CP2102 和 FTDI 基本免驱CH340 在新系统上建议装厂商提供的驱动。设备节点名字是/dev/tty.usbserial-xxxx或/dev/tty.wchusbserialxxxx这种形式跟 Linux 的命名风格完全不同如果你的代码里有硬编码路径的判断逻辑记得写成配置或者用list()的结果。5. 收发这一层Buffer、分帧、粘包与十六进制5.1 写完为什么还要 drain新手写串口发送最常见的代码是port.write(buffer)然后就去干别的了。在低频率发送时这没毛病但连续快速发送多条指令时就会出问题write只是把数据交给操作系统内核缓冲区真正的物理传输是异步进行的底层驱动会根据自己的节奏往外吐字节。如果你连续 write 了五条指令然后立刻关闭端口很可能是最后一条还没发完端口就关了。正确的做法是写完等排空function writeAndDrain(port, buffer) { return new Promise((resolve, reject) { port.write(buffer, (err) { if (err) return reject(err); port.drain((err2) { if (err2) return reject(err2); resolve(); }); }); }); }port.drain()的回调触发时表示内核缓冲区里的数据已经全部交给了硬件。对于需要严格时序的协议比如某些芯片的上电时序、某些设备的握手流程这个等待是必须的。5.2 三种分帧策略选错了就是灾难串口是字节流没有消息边界的概念。设备一次发 20 个字节你的data事件可能触发一次收到 20 字节也可能触发两次收到 8 和 12 字节甚至可能跟下一条消息粘在一起。所以分帧策略必须根据设备的协议来定选错了就是无尽的乱码和错位。策略适用场景实现方式主要风险定长分帧设备每次固定发 N 字节比如某些传感器周期上报ByteLengthParser一旦有一次丢字节后面全部错位需要超时重置分隔符分帧文本协议以换行、回车或自定义字符结尾DelimiterParser或ReadlineParser数据内容里不能出现分隔符否则被截断长度字段分帧二进制协议包头里有长度字节手写状态机实现复杂但最健壮能扛住任意切包如果是文本类协议用现成的解析器最省事const { ReadlineParser } require(serialport/parser-readline); const parser port.pipe(new ReadlineParser({ delimiter: \r\n })); parser.on(data, (line) { console.log(收到一行:, line); });但真实项目里尤其是跟单片机、工业设备打交道绝大多数是二进制协议必须自己写状态机。5.3 手写一个带校验的协议状态机假设设备协议是这样的帧头两个字节0xAA 0x55接着一个字节表示数据长度然后是数据体最后一个字节是前面所有字节的异或校验。这是非常典型的自定义协议结构很多国产模块都是这个套路。class FrameParser { constructor(onFrame) { this.onFrame onFrame; this.buf Buffer.alloc(0); this.MAX_LEN 512; // 防止异常数据把内存吃满 } push(chunk) { this.buf Buffer.concat([this.buf, chunk]); while (true) { // 找到帧头 const head this.buf.indexOf(Buffer.from([0xaa, 0x55])); if (head 0) { // 没有帧头保留最后一个字节可能是被切开的 0xAA this.buf this.buf.slice(Math.max(0, this.buf.length - 1)); return; } if (head 0) { this.buf this.buf.slice(head); } if (this.buf.length 3) return; // 还不够读长度 const len this.buf[2]; const total 3 len 1; // 头2 长度1 数据 校验1 if (len this.MAX_LEN) { // 长度明显异常丢掉这个帧头继续找 this.buf this.buf.slice(2); continue; } if (this.buf.length total) return; // 等下一批数据 const frame this.buf.slice(0, total); let xor 0; for (let i 0; i total - 1; i) xor ^ frame[i]; if (xor frame[total - 1]) { this.onFrame(frame.slice(3, 3 len)); } else { // 校验失败只丢弃帧头继续找避免把后面的有效帧一起扔掉 this.buf this.buf.slice(2); continue; } this.buf this.buf.slice(total); } } }这段代码里有三个经验性的设计我认为比代码本身更重要。第一个是校验失败时只丢弃两个字节的帧头而不是丢掉整帧。这一点很多人写反了。如果校验失败就把整个total长度丢掉那么当你的缓冲区里出现一段垃圾数据恰好凑出了合法的长度字段时你会顺手把后面一个真正的有效帧也扔掉表现为偶尔丢一帧数据极难排查。只丢帧头重新找代价是多扫描几次但不会误伤。第二个是保留最后一部分没有帧头的字节。当你在一批数据的尾部看到孤零零一个0xAA它很可能是下一帧被切开的开头。直接全丢会导致下一批数据来的时候找不到帧头整帧报废。第三个是长度字段的合法性检查。设备出故障、线路干扰、波特率不匹配时你可能会读到一堆随机字节其中偶然出现的0xAA 0x55搭配一个 200 多的长度值会让解析器一直等待一个永远不会到来的大帧。加上长度上限超过就认为不是真帧头跳过继续找。5.4 十六进制与文本的双向转换界面上给工程师看的日志通常希望是空格分隔的十六进制比如AA 55 03 01 00 02 F9这样最直观。而发指令的时候用户也习惯直接敲十六进制字符串。这两个方向的转换各有一个小坑。Buffer 转十六进制展示直接toString(hex)出来是一整串没有分隔的字符串看着很累function toHexView(buf) { return buf.toString(hex).replace(/(..)/g, $1 ).trim().toUpperCase(); }反过来用户输入的十六进制字符串转 Buffer必须先把空格、换行、逗号这些分隔符全部清掉并且校验长度是偶数function fromHexInput(text) { const clean text.replace(/[\s,]/g, ); if (clean.length % 2 ! 0) { throw new Error(十六进制长度必须是偶数); } if (!/^[0-9a-fA-F]*$/.test(clean)) { throw new Error(包含非十六进制字符); } return Buffer.from(clean, hex); }提示用户从别的串口工具里复制指令过来时经常会带上中文全角空格或者看不见的零宽字符导致校验失败但肉眼看不出问题。我一般会在解析前先把所有非 ASCII 字符过滤掉同时在界面上把无效输入高亮出来这比反复提示格式错误有用得多。6. 我踩过的坑都在这里从打不开到数据乱的排查链路6.1 端口打不开四种报错分别指向不同方向打开失败是最高频的问题但每种错误码背后的原因完全不同。养成先看错误码再动手的习惯能省掉大量无谓的尝试。报错内容根本原因排查动作Error: Port is not open/ 打开就报错端口路径不存在或已被拔出重新调list()确认路径还在注意 Windows 上 COM 号会变Error: Access denied/Permission denied权限不足或端口被别的程序占用Linux 查dialout组所有平台都检查有没有其他串口工具在跑Error: Device or resource busy端口被占用关掉串口调试助手、烧写工具、另一个实例的自己Error: Cannot find module .../bindings.node原生模块没加载上ABI 不匹配或打包时文件没被正确释放见下一节端口被占用这一条我要单独强调一次。调试的时候你很可能同时开着 SSCOM、XCOM 或者芯片厂商的烧写工具它们会独占端口你的程序打开就失败。更隐蔽的是你自己程序的多实例用户双击图标开了两个窗口第二个实例打开同一个端口当然失败。我的做法是在主进程里用app.requestSingleInstanceLock()做单实例限制第二个实例直接聚焦已有窗口从根上避免这种情况。还有一种情况是打开成功了但立刻触发 close 事件。这通常意味着物理层有问题USB 线接触不良、外设供电不足导致反复重启、或者 USB 转串口模块本身质量不行。换个模块或者换根线问题往往就消失了。6.2 打得开但收不到数据这类问题最磨人因为没有任何报错程序安安静静就是没数据。我的排查顺序是这样的第一步确认参数是否完全一致。波特率不对是最常见的原因。115200 和 9600 混用的时候收到的不是没数据而是一堆乱码字节但如果设备的协议解析器把这些乱码判断为无效帧全部丢弃你在界面上看到的就是什么都没有。所以先在原始字节层面看有没有数据进来别急着看解析后的结果。数据位、停止位、校验位这三个参数同样要对上尤其是老设备很多是 8 数据位、1 停止位、偶校验的组合默认的none就不对。第二步确认物理接线。这是硬件层面最经典的错误收发线接反了。设备的 TX 要接转换模块的 RX设备的 RX 要接转换模块的 TX交叉连接。接反的表现就是完全收不到或者只能收不能发。另外地线必须共地尤其是两块板子各自独立供电的时候不共地经常出现通信不稳定或完全不通。第三步确认电平匹配。这里有个容易出事的点9 针 RS232 接口的电平是正负十几伏单片机 UART 是 3.3V 或 5V 的 TTL 电平两者绝对不能直连中间必须有电平转换芯片。如果你拿一个 USB 转 TTL 模块去接真正的 RS232 设备轻则通信不上重则烧掉引脚。反过来也一样。另外 RS485 是差分信号需要专门的转换器而且有收发方向控制的问题有些便宜的转换器不支持自动换向需要程序手动控制 RTS 引脚来切换收发状态。第四步确认设备在等你发指令。很多设备是问答式的不上电主动上报你必须先发一条查询指令它才回。这种时候对着一个安静的串口怀疑人生没有意义先找设备的手册看有没有握手流程或者唤醒指令。6.3 收得到但数据乱、丢、粘数据能进来但内容不对问题基本集中在三个方向。粘包和切包。前面讲过分帧这里补充一个现场经验判断是不是分帧问题最直接的办法是看时间戳和字节数。如果两个逻辑上独立的响应被合并在一个事件里长度会是两个响应的总和反过来如果一个响应被拆成两次两次的长度加起来刚好是完整长度。在日志面板里把每次收到的字节数打出来规律一眼就能看出来。丢数据。在 Linux 上有一个比较典型的问题高波特率比如 921600下长时间接收大量数据时会出现规律性丢字节。这通常跟 USB 转串口芯片的缓冲区、驱动实现以及系统调度有关系。可以尝试的方向包括在打开端口前先设置较大的内核缓冲区setserial或者某些驱动支持的参数、降低波特率、换用 FTDI 芯片的模块它的驱动在高波特率下表现通常更稳、以及在程序里用事件驱动尽快读走数据不要在数据处理里做耗时操作阻塞事件循环。数据内容对不上。如果你的程序要发中文或者多字节字符注意编码问题。串口本身传的是字节如果你的设备期望 GBK 而代码里用了 UTF-8 编码字符串再转 Buffer中文必然乱码。文本类协议统一用 ASCII非 ASCII 字符提前约定编码方式。还有一个很隐蔽的问题如果解析器里用了正则去匹配文本而数据流里恰好包含了会触发贪婪匹配的内容可能一次吃掉好几帧。二进制协议别用正则老老实实写状态机。7. 打包交付把带原生模块的应用送到别人电脑上7.1 asar 归档里不能放 .node 文件electron-builder 默认会把应用代码打进一个 asar 归档文件。asar 是个只读的虚拟文件系统Electron 自己能从里面读 JS但操作系统的动态链接器不认识 asar所以.node原生模块文件没法从归档内部被加载。这就是为什么开发环境一切正常、打包之后立刻报Cannot find module的原因。解决办法是告诉打包器把原生模块从归档里排除出来放在外面当普通文件{ build: { asar: true, asarUnpack: [ **/node_modules/serialport/**, **/*.node ] } }我一般两条都写按目录排除覆盖串口的整个依赖树按扩展名兜底覆盖其他可能的原生模块。第一遍打包之后一定要亲自去resources/app.asar.unpacked目录里翻一下确认.node文件真的在里面。这个动作只要花三十秒能省掉一次完整的发布—用户报错—回滚循环。7.2 electron-builder 和 pnpm 的组合配置除了前面说的.npmrc里的node-linkerhoisted打包配置上还有两点需要注意。第一如果你已经确认用的是带 N-API 预编译产物的新版串口依赖可以在打包时关掉自动重建避免因为构建机缺编译工具链而失败{ build: { npmRebuild: false, buildDependenciesFromSource: false } }这个设置的前提是你本地已经验证过.node文件能正常加载。如果你不确定宁可开着让它重建只是构建时间会长一些。第二区分平台的产物。Windows 上一般出 NSIS 安装包或者免安装的 portable 版本Linux 上出 AppImage 或 deb。用命令行参数控制# 打 Windows 包 npx electron-builder --win # 打 Linux 包 npx electron-builder --linux # 只打当前平台快速验证配置有没有问题 npx electron-builder --dir--dir这个参数我强烈建议加进日常流程它只产出未打包的目录结构速度极快用来反复验证 asar 配置、文件是否齐全非常合适。等目录版本确认没问题了再去出正式安装包。7.3 没有硬件怎么开发搭一对虚拟串口做串口应用的一个尴尬是你不可能时时刻刻插着设备。解决办法是造一对虚拟串口让两个端点互相连通一个端点给你的程序另一个端点给串口调试助手或者你自己写的模拟脚本。Linux 和 macOS 上socat是最省事的工具socat -d -d pty,raw,echo0 pty,raw,echo0执行之后终端会打印出两条设备路径类似/dev/pts/3和/dev/pts/4。你的应用打开其中一个另一个用脚本或调试助手打开互相发数据就能验证整个链路。要注意的是socat创建的是伪终端SerialPort.list()通常枚举不到它们所以你的应用要支持手动输入设备路径提供一个手动输入端口的入口否则测试的时候选都没法选。这一点在真实使用中也有价值——有些特殊设备就是不在标准枚举结果里。Windows 上可以用 com0com 这类虚拟串口工具创建一对互连的 COM 口效果类似。有了这套东西你就能在没有任何硬件的情况下把协议解析、数据展示、异常处理这些逻辑全部测一遍把硬件相关的问题压缩到最后一小部分。7.4 交付前我会走一遍的检查清单在一台没有开发环境的干净电脑上安装并运行确认不依赖任何全局安装的东西插拔设备若干次确认端口列表刷新正常拔掉后已打开端口能正确感知断开打开端口的同时用另一个串口工具尝试占用确认错误提示是给人看的中文而不是原始报错故意用错误的波特率连接确认界面不会崩溃只显示乱码连续运行两小时以上确认内存没有持续增长日志面板不会无限膨胀检查安装包体积如果异常大多半是把不该带的依赖打进去了8. 让它长时间跑得住重连、队列与日志8.1 断线重连要写成状态机不要写成回调套回调实际部署中USB 线被碰掉、设备断电重启、USB 集线器抽风都是常态。程序必须能自己缓过来而不是让用户重启应用。我的做法是给每个端口维护一个状态closed、opening、open、error。监听close和error事件一旦从open掉出去就进入带退避的重连循环。function startReconnect(path, options, ports) { let attempt 0; const tryOpen async () { attempt 1; const delay Math.min(1000 * Math.pow(1.5, attempt), 10000); const result await openPort(path, options, ports); if (result.ok) return; if (attempt 30) return; // 放弃通知界面让用户手动处理 setTimeout(tryOpen, delay); }; setTimeout(tryOpen, 1000); }指数退避的上下限要设。下限太低会疯狂重试日志刷屏CPU 也浪费上限太高用户等得不耐烦。1 秒起、最多 10 秒是我用下来比较平衡的区间。重试次数也要封顶不能无限尝试否则设备真的坏了的时候你会一直占着 CPU 和一个错误状态界面上永远显示正在连接用户不知道该怎么办。还有一个细节重连之前要确保旧实例彻底清理掉把data、error、close这几个监听器全部移除并置空引用。我见过一次内存泄漏就是这么来的——每次重连都新建一个实例但旧实例没被回收跑一天之后内存涨到几百兆。8.2 写队列别让用户点两下就撞车界面上如果有发送按钮用户手快连点两下两次 write 交错执行如果协议要求发完指令等响应再发下一条第二次写入要么被设备忽略要么产生一个莫名其妙的错误响应。保险的做法是在主进程里为每个端口维护一个写队列串行化所有写操作class WriteQueue { constructor(port) { this.port port; this.chain Promise.resolve(); } push(buffer) { this.chain this.chain.then( () writeAndDrain(this.port, buffer), () writeAndDrain(this.port, buffer) // 上一次失败不影响下一次 ); return this.chain; } }注意 then 的两个分支写了同样的逻辑这是故意的前一次写入失败不应该让整条队列永久卡死。另外队列长度要有上限超过就拒绝新的写入并提示用户否则一个死循环发送请求会把队列撑爆。如果协议是严格的请求—应答模式还可以在队列之上再加一层超时控制发出指令后启动一个定时器超时未收到对应响应就标记为失败释放队列继续下一条。没有这层超时一次丢响应会让整个队列永远等下去。8.3 日志面板是现场排障的唯一救命绳现场用户跟你说它就是不好使你没法去现场只能靠日志。所以我在这类工具里一定会做三件事。第一原始收发数据全量记录不做截断同时标注方向和精确到毫秒的时间戳。格式化之后的漂亮日志适合人看但排查的时候你需要的是原始字节。界面上可以只显示最近几百条但落盘的日志文件要完整。第二关键状态变更单独记一条包括打开、关闭、错误、重连开始、重连成功。这样从日志时间轴上一眼就能看出是设备掉了还是程序崩了不用去翻数据流。第三日志要能一键导出。做成一个按钮把当前会话的文件打包导出让用户发给你。这个小功能能把沟通成本降到最低否则你可能要花半小时在电话里描述你打开那个目录找到……。日志本身也要管理大小。我的做法是按天或按大小切分保留最近若干个文件超出自动删除最旧的。写日志用追加模式不要每次全量重写文件否则高频率数据下磁盘会被写爆。最后分享一个我自己吃过亏的地方别把日志往console.log里塞然后指望开发工具能看。生产环境里没有控制台而且高频 console 输出本身就会拖慢主进程。串口的日志一定要走独立的文件写入通道并且做批量写入攒够一定条数或者间隔一定时间落盘一次而不是每来一帧就写一次磁盘。
返回列表