Node.js串口通信实战:serialport库从基础到避坑指南

📅 发布时间:2026/10/17 1:26:54
Node.js串口通信实战:serialport库从基础到避坑指南
做嵌入式、硬件或者物联网开发的人迟早会跟串口打交道。哪怕是现在USB、蓝牙、WiFi满天飞串口依然是调试设备、对接传感器、读取日志最可靠的一条路。这个月我在做一个设备联调的活儿又把serialport这个库从头到尾翻了一遍顺手把过程中踩过的坑、验证过的写法整理出来。如果你正准备在Node.js里通过串口跟下位机通信或者已经写了几个demo但总遇到乱码、丢包、打不开端口的问题这篇文章应该能帮你省下不少时间。我不会把serialport包装成什么黑科技它就是一堆封装好的API但真正用好的关键不在于API本身而在于你对串口协议的理解和代码层面的细节处理。下面从基础概念、选型思路、完整实操到问题排查一条线说清楚。1. 串口通信到底在解决什么问题1.1 串口是什么为什么到现在还没淘汰串口的全称叫串行接口一条线逐位发数据跟并口那种一次性发多位的方式相比看起来慢但胜在接线少、成本低、抗干扰能力强而且协议极其简单。很多单片机、GPS模块、扫码枪、电源管理芯片甚至一部分工业仪表都保留了串口作为最基础的通信接口。为什么到现在还没淘汰原因其实很朴素结构简单只要TX、RX、GND三根线就能完成最基本的双向通信协议透明没有复杂的握手和加密逻辑分析仪一抓就能看懂系统兼容性好几乎每个操作系统都有原生串口驱动调试刚需很多嵌入式设备第一版固件只支持串口打印日志等网络和USB稳定了才有上层应用。所以串口不会死只会慢慢变成“调试专用通道”或者“低成本控制通道”。对开发者来说掌握串口通信不是学一个过时的技术而是给自己留一扇通往硬件底层的大门。1.2 串口通信的核心参数波特率、数据位、校验位、停止位很多人用串口时第一件事就是问波特率填多少这个值必须和对方设备一致否则收到的全是乱码。串口通信的双方像是两个对表的人波特率就是表走得快慢只要一个快一个慢对话就对不上。具体参数拆开看波特率每秒传输的比特数常见的有9600、115200、460800等。高的快但易受干扰低的稳但慢。工业设备比较多用9600WiFi模块、GPS模块常用115200具体以设备手册为准。数据位一次传多少bit的有效数据通常为8位少数老设备用7位。校验位用于检测传输错误有none、even、odd三种。none就不加校验even要求数据中1的个数为偶数。现在设备普遍用none只有可靠性极低的场景才会开校验。停止位表示一帧结束的位常见1位或2位。1位是常态2位多见于老式设备。还有一个容易被忽略的点串口缓冲区的溢出问题。下位机如果不停往外发数据而上位机程序读取得不及时数据就会丢在内核缓冲区里。后面我会专门讲这个坑。2. 工具与库选型从系统自带到跨平台方案2.1 硬件准备与连接检查想用serialport做实验先得有一套能跑的硬件。最省钱的办法就是用USB转串口模块接一个MCU开发板或者直接拿两个USB转串口模块的TX和RX交叉对接电脑上就能看到数据回环。连接时记住一句话发送接接收地线要共地。也就是A模块的TX接B模块的RXA的RX接B的TXGND必须连在一起。我见过不少人只接TX和RX不接地线结果数据完全没法看因为两边电平参考点不一样信号是飘的。接完之后先用系统自带工具验证链路Windows下打开设备管理器看“端口(COM和LPT)”下面有没有新设备记住COM号Linux下用ls /dev/ttyUSB*或/dev/ttyACM*查看设备节点macOS下通常是/dev/tty.usbserial-xxx。如果看不到设备先不要怪代码大概率是驱动没装。常见的USB转串口芯片有三种CH340、CP210x、FT232每种都有对应驱动。CH340最便宜也最容易出问题首选去官网下载对应系统的驱动装完重启再看设备列表。2.2 serialport库的选型逻辑Node.js生态里做串口通信绕不开serialport这个库。它的前身是node-serialport现在已经迭代到npm包名serialport而且从v10开始采用了monorepo结构拆成多个子包比如serialport主包、serialport/stream、serialport/bindings-cpp等。为什么选它而不是自己用fs读写设备文件因为它统一了跨平台的API底层通过不同的binding实现Windows的Win32 API和Linux的termios你不用关心平台细节同一套代码可以跑在三端。它还有一个很实用的特性自动解析串口列表支持热插拔枚举。安装时要注意版本兼容性。serialport v12以上需要Node.js 20以上的某个range具体以npm官方文档为准。很多老项目还在用serialport v9API差异挺大比如new SerialPort(port, options, callback)的老写法在v10之后仍然支持但更推荐用事件监听的方式。另外如果你是需要频繁切换串口参数的项目可以考虑SerialPort类结合serialport/parser-readline或者serialport/parser-byte-length等parser包。这样数据解析逻辑跟串口逻辑解耦代码会干净很多。2.3 串口参数和解析器的组合策略串口通信里数据怎么读出来和怎么解析成一条条完整消息是两件事。serialport官方提供了一系列parser相当于数据流的加工管道。常见的场景设备发来的每行以\r\n结尾用ReadlineParser按行切分固定帧头帧尾比如0xAA 0x55开头用ByteLengthParser按长度截取不太够需要自定义parser只要固定字节数比如每个数据包固定8字节用ByteLengthParser最方便。在实际项目里数据长度不固定、需要按协议解析的情况占多数。后面我会专门写一个解析可变长度帧的例子。parser不是越高级越好有时候简单粗暴地在data事件里自己拼buffer反而容易控制。3. 实操用serialport收发数据的完整流程3.1 第一段代码打开串口并完成一次回环测试先跑一段最简单的代码验证环境可用。新建一个Node项目安装serialportnpm init -y npm install serialport然后写一个打开串口并发送数据的脚本这里以读取一个虚拟串口数据为例const { SerialPort } require(serialport); const port new SerialPort({ path: /dev/ttyUSB0, // Windows下是 COM3macOS是 /dev/tty.usbserial-xxx baudRate: 115200, dataBits: 8, parity: none, stopBits: 1, autoOpen: false, }); port.open((err) { if (err) { console.error(打开失败, err.message); return; } console.log(串口已打开); // 发送一段十六进制数据 const buffer Buffer.from([0xAA, 0x55, 0x01, 0x02]); port.write(buffer, (writeErr) { if (writeErr) { console.error(发送失败, writeErr.message); } else { console.log(发送成功); } }); }); port.on(data, (data) { console.log(收到数据, data.toString(hex)); }); port.on(error, (err) { console.error(串口错误, err.message); });这里有一个细节autoOpen设置成false然后手动调用open这样可以在打开前注册错误监听防止一些平台下未捕获的error事件导致进程崩溃。如果你直接用默认的autoOpen建议在new SerialPort后马上挂error监听否则设备被拔出时Node进程会直接报错退出。写完跑一下如果设备回发数据你就能在控制台看到hex格式的内容。如果什么都收不到先确认TX/RX有没有接反再接一个USB转串口模块用回环测试把TX和RX短接数据发出去能原样收到就说明链路没问题。3.2 解析可变长度帧自定义Parser很多下位机协议不是简单的一行文本而是二进制帧结构类似这样帧头固定两个字节比如0xAA 0x55长度字段一个字节代表后面有效数据的长度数据N个字节校验一个字节比如累加和低8位难点在于“长度字段”要等读完才知道这一帧到底有多长而串口数据是流的可能会半包到达也可能会粘包。所以解析的核心是维护一个接收缓冲区按状态机取帧。下面是一个完整的自定义解析器实现基于serialport的Transform类const { SerialPort } require(serialport); const { Transform } require(stream); class ProtocolParser extends Transform { constructor() { super(); this.buffer Buffer.alloc(0); } _transform(chunk, _encoding, callback) { // 把新数据追加进缓冲区 this.buffer Buffer.concat([this.buffer, chunk]); // 循环从缓冲区中提取完整帧 while (true) { if (this.buffer.length 4) { // 至少要有帧头2字节长度1字节校验1字节才能判断 break; } // 查找帧头 const frameHeadIndex this.buffer.indexOf(Buffer.from([0xAA, 0x55])); if (frameHeadIndex -1) { // 没找到帧头说明缓冲区都是垃圾数据清掉 this.buffer Buffer.alloc(0); break; } // 把帧头前面的数据丢掉 if (frameHeadIndex 0) { this.buffer this.buffer.subarray(frameHeadIndex); } // 此时缓冲区开头就是帧头读长度字节索引2 const dataLength this.buffer[2]; // 完整帧长 2字节帧头 1字节长度 dataLength 1字节校验 const frameLength 2 1 dataLength 1; if (this.buffer.length frameLength) { // 还没收完整等下一段数据 break; } // 取出整个帧 const frame this.buffer.subarray(0, frameLength); this.buffer this.buffer.subarray(frameLength); // 校验累加和 let sum 0; for (let i 0; i frame.length - 1; i) { sum (sum frame[i]) 0xFF; } if (sum ! frame[frame.length - 1]) { this.emit(protocolError, { frame, expected: frame[frame.length - 1], actual: sum }); continue; } // 校验通过发出完整帧 this.push(frame); } callback(); } } const port new SerialPort({ path: /dev/ttyUSB0, baudRate: 115200 }); const parser port.pipe(new ProtocolParser()); parser.on(data, (frame) { console.log(完整帧, frame.toString(hex)); }); parser.on(protocolError, (info) { console.error(校验失败, info); });写这个解析器时最核心的一点就是不要把状态变量放在外部全部封装在Transform内部的buffer里这样就不会因为并发data事件导致帧错乱。另一个要点是用了while(true)循环因为一个chunk里可能含有多个完整帧读出来一个之后缓冲区里可能还有第二个必须一直取到缓冲区不够一个完整帧为止。这种写法比起官方自带的parser更灵活但也要注意性能。如果你有高速数据流比如每秒几十KBBuffer.concat的拷贝开销会很明显建议使用固定大小的Buffer池和游标索引来优化。不过对于大多数开发板和传感器场景当前写法足够稳定。3.3 半包粘包问题的处理思路半包和粘包是串口和网络通信里最经典的两个问题。半包就是一个完整帧还没发完你先收到了其中一部分粘包就是连续发了两三个帧你一次性全收到了。如果不处理按固定位置读长度就永远对不上。上面自定义Parser已经解决了这两个问题先把所有数据吞进buffer再从buffer里找帧头、按长度截取。这是一个通用思路不管协议多复杂都是这套路子。但有一个容易忽略的边界情况如果帧头0xAA 0x55也出现在数据段里怎么办比如有效数据里有个字节正好是0xAA下一个是0x55按照上面“每找到一个帧头就尝试解析”的逻辑你会把数据中间误判成新帧的起点导致解析错乱。解决方法是设计协议时做字节填充/转义或者使用序号和长度来校验。举个例子有些协议把帧头设为0xAA 0x55后如果数据里出现0xAA就变成0xAA 0x00接收时看到0xAA 0x00就还原成0xAA。这种方式需要协议支持代码里也要有对应的逆转义逻辑。更稳妥的办法是除了累加和之外在帧尾加固定标志比如0x0D 0x0A解析时先找帧头再找帧尾通过长度和校验双重判断能大幅降低误判概率。我在实际项目里踩过这个坑当时用了一个便宜的红外测距模块它的数据里频繁出现帧头一样的字节解析出来的距离值经常跳变。排查了半天最后发现是误同步问题后来在固件端给协议加了转义问题才根除。所以如果你要设计自定义协议建议从一开始就考虑到帧内数据与帧头冲突的情况。3.4 写入操作与流控细节发送数据看起来就是port.write(buffer)但其中有两个细节值得注意。第一write之后不一定立刻写完底层会经过内核缓冲区排队。如果你紧接着调用port.close()操作系统可能还没来得及把数据发出去导致丢数据。正确做法是在write的callback里确认全部写入后再关闭而串口本身有一种drain机制可以用port.drain(callback)等待数据发送完成。port.write(buffer, (err) { if (err) return; port.drain(() { console.log(所有数据已发送到物理端口); port.close(); }); });第二如果你连续写入大量数据写得太快而设备处理不过来就会顶住内部缓冲。serialport的write内部其实是一个stream可以通过port.write()的返回值来判断是否应该等待drain事件const canContinue port.write(chunk); if (!canContinue) { port.once(drain, () { // 继续写下一段 }); }这个写法在发送大文件或者连续发指令时很有用。不过对大多数“每隔几百毫秒发一条指令”的控制场景简单write就够了不需要过度设计。4. 常见问题排查与避坑经验4.1 打不开端口权限不足Linux下经常遇到EACCES错误因为当前用户没有访问串口设备的权限。解决方法就是把用户加入dialout组然后重新登录sudo usermod -a -G dialout $USER注意加完组之后要注销再登录group权限才会生效。另外某些发行版还用的是lock组来管理串口锁文件如果看到/var/lock相关的错误也按同样的思路加组。Windows下如果报“拒绝访问”多半是串口被其他程序占用了比如串口调试助手还开着。关掉其他占用串口的程序再重跑就好。macOS下一般是连接权限弹窗没同意或者在System Settings里没有给终端授予“可移动卷”之类的权限检查一下隐私设置。4.2 收数据乱码先查波特率乱码十有八九是波特率不匹配。我记得有一次用某GPS模块手册写着默认38400但我用9600去读收到的hex都是0x00 0xFF这种半残数据。调成38400就正常了。所以如果乱码第一反应不是换数据位而是两端波特率是否完全一致。还有一种情况是TTL电平和RS232电平不匹配。市面上有些便宜USB转串口模块输出的是TTL电平而老的工控设备用的是RS232负逻辑电平直接接上去不仅乱码还可能烧芯片。需要用真正的RS232转接器或者带电平转换的模块。这个在选硬件时就要看清楚不是代码能解决的。除了波特率数据位和校验位不对也会表现为乱码。比如对端是7位偶校验你写成8位无校验虽然有时候看起来数据能出来几个但隔几个字节就错一个。把四项参数对齐基本能解决90%的乱码问题。4.3 丢数据或系统卡死检查缓冲区设备发数据很频繁但程序收着收着就断了或者界面卡住这种事我也处理过。根本原因往往是内核串口缓冲区溢出或者Node主线程处理不过来。先看Linux下的串口缓冲区大小可以用cat /proc/tty/driver/serial或者更通用的是查看设备文件所在系统的串口配置。如果缓冲区太小可以临时调大setserial /dev/ttyUSB0 rx_room 8192这个可能因系统而异不是所有环境都支持。更建议的做法是提高用户态消费速度在data回调里不要做耗时操作直接封装成Buffer丢进队列由另一个async循环处理。另外Node.js里如果data事件里同时做解析、写数据库、发网络请求主线程很容易被打满。正确姿势是让serialport的data事件只负责快速搬运数据用队列和单独的处理模块去消费const { SerialPort } require(serialport); const { EventEmitter } require(events); const port new SerialPort({ path: /dev/ttyUSB0, baudRate: 115200 }); const queue []; const emitter new EventEmitter(); port.on(data, (data) { queue.push(data); emitter.emit(newdata); }); emitter.on(newdata, () { if (emitter.processing) return; emitter.processing true; // 用setImmediate或异步函数处理队列 setImmediate(() { while (queue.length) { const chunk queue.shift(); // 这里做轻量级解析或者交给更慢的处理器 } emitter.processing false; }); });实际项目中甚至可以把这个队列交给worker_threads处理。不过如果你的设备每秒只发几条数据上面这些优化看看就行没必要提前架设复杂的多线程结构。4.4 不同系统下串口名的差异跨平台是serialport的优点但串口名每个系统都不一样写死了路径就会出错。Windows是COM3、COM4这种逻辑名Linux是/dev/ttyUSB0、/dev/ttyACM0、/dev/ttyS0macOS是/dev/tty.usbserial-xxx、/dev/cu.usbserial-xxx。需要注意/dev/ttyS0是主板原生串口而/dev/ttyUSB0是USB转出来的。前者权限和稳定性的表现不太一样枚举时也很容易搞混。serialport提供了SerialPort.list()来枚举所有串口可以打印 vendorId、productId 来区分硬件。比如有两个USB转串口设备你可能想固定使用CH340那个代码里就可以这样选const { SerialPort } require(serialport); async function findPort(vendorId, productId) { const ports await SerialPort.list(); const target ports.find( (p) p.vendorId vendorId p.productId productId ); return target ? target.path : null; }但要注意不同USB口插入同一个设备路径可能会变比如Linux下原来是ttyUSB0换一个口变成ttyUSB1。如果需要稳定标识最好通过udev规则创建符号链接。这个属于系统配置不在serialport范畴但联调时很有用。4.5 调试工具与常用命令开发串口应用光靠console.log很痛苦我一般会同时开两个工具。一个是系统级的串口调试助手像Windows下的SSCOM、Linux下的minicom、screen等主要是验证设备本身是否正常排除代码问题。用Linux自带的命令也可以快速测试# 监听串口输出CtrlA 然后按 X 退出 minicom -D /dev/ttyUSB0 -b 115200macOS 也可以用 screenscreen /dev/tty.usbserial-xxx 115200另一个是逻辑分析仪。如果数据收发不对用逻辑分析仪抓TX和RX的电平波形能一眼看出波特率有没有设错、有没有数据位错位。二十几块钱的USB逻辑分析仪配合免费软件就够用了。这比盲猜效率高得多特别是遇到硬件层面的信号问题时。另外要记住代码和调试工具不能同时占用同一个串口。很多莫名其妙的“打开失败”都是因为另一个调试软件没关。这一点听起来简单但我印象里至少有三次联调到半夜最后发现罪魁祸首就是后台挂着的调试助手。5. 一个完整的实用示例自动识别设备并连续采集传感器数据5.1 需求与协议定义基本的概念和坑都讲完了我再用一个完整的场景把零散知识串起来某传感器通过串口输出温湿度数据协议是0xAA 0x55开头长度字节固定为4后面跟温度、湿度、电量三个字节最后一字节是校验和。需要程序自动找到这个传感器设备号打开串口持续读取并打印数据。假设传感器设备的USB vendorId和productId不确定但我们知道数据帧具备固定特征所以可以用一个“扫描所有串口”的功能来定位。当然更简单的方案是手动通过配置文件指定串口路径。这里我做一个折中先列出所有串口然后对每个串口用9600和115200分别尝试打开发送一帧查询指令如果在一定时间内收到合法帧头就认定是该设备。这个例子在项目调试阶段很实用省得每次插拔USB口都要改路径。5.2 扫描串口的实现直接上代码注意做好超时控制和资源释放const { SerialPort } require(serialport); const QUERY_CMD Buffer.from([0xAA, 0x55, 0x00, 0x01, 0x00]); const BAUD_RATES [9600, 115200]; async function scanSystem() { const ports await SerialPort.list(); console.log(检测到串口, ports.map((p) p.path).join(, )); for (const portInfo of ports) { for (const baudRate of BAUD_RATES) { const result await tryIdentify(portInfo.path, baudRate); if (result) { console.log(设备位于 ${portInfo.path}波特率 ${baudRate}); return { path: portInfo.path, baudRate }; } } } return null; } function tryIdentify(path, baudRate) { return new Promise((resolve) { let settled false; const port new SerialPort({ path, baudRate, autoOpen: false }); const timer setTimeout(() { if (!settled) { settled true; try { port.close(); } catch {} resolve(false); } }, 1500); port.open((openErr) { if (openErr) { clearTimeout(timer); if (!settled) { settled true; resolve(false); } return; } const dataHandler (data) { const hex data.toString(hex); if (hex.startsWith(aa55)) { clearTimeout(timer); if (!settled) { settled true; resolve({ path, baudRate }); } try { port.removeListener(data, dataHandler); } catch {} try { port.close(); } catch {} } }; port.on(data, dataHandler); port.write(QUERY_CMD, (writeErr) { if (writeErr) { clearTimeout(timer); if (!settled) { settled true; resolve(false); } try { port.close(); } catch {} } }); }); port.on(error, () { clearTimeout(timer); if (!settled) { settled true; resolve(false); } }); }); } scanSystem().then((info) { if (!info) { console.log(未找到目标设备); } });这段代码有几个注意点首先是超时逻辑每个串口最多等1.5秒避免探测时卡在某个打不开但也不报错的端口上。其次是端口关闭时放在回调外面因为close本身可能因为端口被拔而抛错要包一层try/catch。再就是resolve只能执行一次所以用settled标记来避免重复resolve。实测下来扫描3个串口一般几秒内就能完成比手动改路径舒服多了。但这种方式也有代价如果设备在收到查询指令后不是立即回应而是等到有数据才主动上报查询指令就得做成“触发上报”语义具体要看设备协议。5.3 数据解析与持久化扫描确定端口后正式进入采集状态。这里用到了前面自定义Parser的思路但更简化因为数据里只有一帧有效不需要复杂的状态机function startReader(path, baudRate) { const port new SerialPort({ path, baudRate }); const parser port.pipe(new ProtocolParser()); parser.on(data, (frame) { // 帧格式AA 55 length temp humid battery checksum if (frame.length 7) return; const temp frame[3]; const humid frame[4]; const battery frame[5]; const checksum frame[6]; console.log(温度: ${temp}°C, 湿度: ${humid}%, 电量: ${battery}); }); parser.on(protocolError, (info) { console.log(校验失败期望 ${info.expected}实际 ${info.actual}); }); port.on(error, (err) { console.error(串口异常, err.message); }); }如果你要连续记录数据注意写入文件时别在data事件里同步写建议用appendFile的异步版本或者用一个批量写入的间隔任务。我经常用如下方式收集最近30秒的数据然后一次性写入一行避免文件句柄反复开关也减少阻塞。let cache []; setInterval(() { if (cache.length 0) return; const lines cache.join(\n); cache []; require(fs).appendFile(sensor.log, lines \n, (err) { if (err) console.error(写文件失败, err); }); }, 30000);5.4 优雅退出与释放资源串口程序有一个容易被忽略的细节进程退出时要关闭串口否则设备端会认为连接还在可能影响下次打开。处理退出至少包含两种场景普通结束和信号结束。async function shutdown() { if (port port.isOpen) { await port.close().catch(() {}); } process.exit(0); } process.on(SIGINT, shutdown); process.on(SIGTERM, shutdown);注意port.close()返回的是一个Promise但前提是串口没有处于打开失败的半状态。最好用try/catch包一下避免退出时又抛异常导致无法退出。另外如果你的程序里同时开了多个串口可以在一个管理对象里保存所有打开的端口退出时统一关闭。这样在热插拔场景下也方便重连。6. 几个容易被忽视的进阶细节6.1 蓝牙虚拟串口与TCP虚拟串口现实场景中串口不一定完全是物理串口。很多蓝牙模块在系统里会注册成虚拟串口比如HC-05、HC-06连接后就是COM口或tty设备代码里用serialport读取它跟读物理串口没什么区别但会有一些额外的延时和断连重连问题。重连时要注意先监听close事件然后做指数退避重连不能疯狂重试。还有一种情况是TCP转串口设备比如工业串口服务器。这时你面对的成了一个网络端口就不该用serialport了直接用Node.js的net模块建立TCP连接。有人会把串口服务器虚拟成本地串口再走serialport但这样多了一层转换稳定性反而更差。尽量根据实际传输层选择正确的库。6.2 超时重发机制串口通信不像TCP有可靠传输发出去的指令可能丢也可能设备没来得及回。所以需要自己实现超时重发发指令后启动计时器如果规定时间内没收到对应回复重发重发次数达到上限后就报错。具体实现可以用一个自增的seq字段每次发指令时把seq记录到Map里收到响应时根据seq找到对应的pending对象并清除计时器。这算是串口通信里一个轻量的应用层协议设计虽然代码量不大但对可靠性提升非常明显。我做过一个控制步进电机的项目上位机发给控制器移动指令控制器偶尔会漏处理。加上超时重发后整个系统基本不再出现“电机没动但上位机以为动了”的情况。6.3 日志记录hex和时间戳最后建议所有串口项目都保留原始日志。所谓原始日志就是把收发数据都按hex记录并带上精确时间戳。这不仅能帮你复现问题还能让同事或售后人员在出问题时快速定位是硬件还是软件。最简单的方案就是写一个中间层封装一下function logHex(prefix, data) { const time new Date().toISOString(); console.log([${time}] ${prefix} ${data.toString(hex)}); }在每次send和每次data回调时调用。数据量不大时随便打高并发时用日志库异步处理。这样过了几个月再遇到问题翻日志就能判断是不是协议对不上不需要依赖现场复现。我个人的习惯是任何一次联调结束都会保留当天的收发日志存档。因为设备端的固件版本、上位机代码版本都可能变如果你手边没有当时的报文记录后面追问题就只能靠猜。这一点在设备维护阶段尤其重要。串口通信看起来老派但它在底层世界里依然无处不在。很多看似诡异的现象比如乱码、丢帧、打不开端口最后都能回到几个基础原因上参数不匹配、电气故障、资源冲突、协议设计漏洞。我希望这篇从serialport出发的梳理能让你少踩几个我踩过的坑。如果你正打算用Node.js和串口打交道照着上面的例子一步步搭大多数场景都能顺利跑通。至于更复杂的高速数据采集、多设备管理无非也是在这个骨架上增加并发控制和缓冲策略。祝你的设备通信一调就通。