Web Serial API 串口通信实战:浏览器直连硬件设备 浏览器里直接跟串口设备对话这件事放在几年前还只能靠桌面应用或者本地服务来中转。Web Serial API 的出现把这个环节彻底抹平了——插上设备打开网页点一下授权数据就能双向流动。我做嵌入式工具链和上位机替代方案有些年头了第一次跑通这个 API 的时候确实有点意外原来浏览器已经能直接操作硬件到这个程度了。这篇文章面向的是需要做设备调试、数据采集、固件烧录辅助工具的前端和嵌入式工程师也适合想把传统上位机搬到 Web 上的团队参考。核心关键词就一个Web Serial API 串口通信。我会从方案选型、核心原理、完整实操到踩坑排查把这条链路讲透代码可以直接抄。1. 方案选型与整体设计思路1.1 为什么是 Web Serial 而不是传统上位机传统串口通信方案无非几种Electron 打包桌面应用、Python 配 PySerial 写脚本、C# 写 WinForm 上位机、或者本地起一个 WebSocket 服务做中转让网页连。这几种我都用过各有各的麻烦。Electron 体积大一个简单工具打包出来上百兆Python 脚本对非技术用户不友好环境依赖经常出问题C# 上位机跨平台是硬伤WebSocket 中转方案最接近 Web 体验但多了一层服务要部署和维护用户还得先装个东西。Web Serial API 的定位很明确让网页直接访问串口。它是 Chromium 内核浏览器提供的一组 JavaScript 接口属于 Web 平台能力的一部分。你不需要装任何插件、不需要本地服务、不需要打包一个 HTTPS 页面就能跟单片机、传感器、PLC、串口屏这些设备通信。对于内部工具、教学演示、设备配置页面这类场景它的部署成本几乎为零——把页面往服务器一放用户打开就能用。当然它也有边界。目前主要是 Chromium 系浏览器支持Chrome、Edge、Opera 等Firefox 和 Safari 还没跟进。另外它要求页面运行在安全上下文里也就是 HTTPS 或者 localhost。这两点在做方案选型时必须先确认否则后面白忙活。1.2 整体架构拆解整个通信链路可以拆成四层来理解这样排查问题时能快速定位是哪一层出了状况。层级职责关键对象/接口用户交互层触发授权、选择设备、展示数据按钮事件、UI 渲染API 调用层请求端口、打开关闭、读写数据navigator.serial、SerialPort流处理层字节流的读写与转换ReadableStream、WritableStream设备物理层实际的串口硬件通信USB 转串口芯片、MCU用户点按钮触发navigator.serial.requestPort()浏览器弹出设备选择框用户选中后拿到SerialPort对象。接着调用port.open()并传入波特率等参数端口打开后通过port.readable和port.writable拿到读写流。数据以Uint8Array的形式在流里流动你需要自己处理编码解码。这个分层理解很关键因为实际开发中大部分 bug 都出在流处理层——比如读循环没正确退出、写入时流被锁住等等。1.3 关键约束与前置条件有几个硬性条件必须先满足不然代码写得再对也跑不起来。浏览器支持必须是 Chromium 内核且版本足够新。可以通过serial in navigator做特性检测不支持就直接给用户提示别让按钮点了没反应。安全上下文页面必须是 HTTPS 或 localhost。本地开发用localhost没问题部署到服务器必须配证书。用户手势触发requestPort()必须在用户交互比如点击的回调里调用不能在页面加载时自动弹窗浏览器会拦截。端口独占一个串口同一时间只能被一个页面或应用占用。如果设备被其他程序打开了浏览器这边会报错。提示特性检测建议写成if (!(serial in navigator))然后引导用户换浏览器或给出降级方案而不是直接抛异常让页面白屏。2. 核心 API 细节与实操要点2.1 请求端口与授权机制navigator.serial.requestPort()是整个流程的入口。它返回一个 Promiseresolve 出用户选择的SerialPort对象。如果用户取消选择Promise 会 reject这个 reject 必须捕获否则控制台会报未处理的 Promise 错误。async function selectPort() { try { const port await navigator.serial.requestPort(); return port; } catch (err) { // 用户取消选择会走到这里 console.warn(用户未选择端口:, err.message); return null; } }这里有个容易被忽略的点授权是有记忆的。用户第一次选择某个设备后浏览器会记住这个授权。下次调用navigator.serial.getPorts()就能直接拿到之前授权过的端口列表不需要重新弹窗。这个机制对于需要频繁连接同一设备的工具非常有用可以做一个快速重连功能。const ports await navigator.serial.getPorts(); if (ports.length 0) { // 有已授权的端口可以直接用 const port ports[0]; }但要注意getPorts()返回的是已授权端口不代表设备当前一定插着。如果设备拔了打开端口时会失败所以打开操作依然要包在 try/catch 里。2.2 打开端口与参数配置打开端口时最关键的参数是波特率此外还有数据位、停止位、校验位、流控。这些参数必须和设备端完全一致否则收到的就是乱码或者根本收不到数据。await port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: none, bufferSize: 4096, flowControl: none });参数怎么选取决于你的设备。常见的组合是115200 8N1也就是波特率 115200、8 数据位、无校验、1 停止位。但有些老设备用 9600有些工业设备用 38400 配偶校验。这个必须查设备手册不能猜。我见过有人拿 115200 去连一个 9600 的设备折腾半天以为是代码问题其实就是波特率不匹配。bufferSize是读写缓冲区大小默认值通常够用但如果你要高速传输大量数据可以适当调大。flowControl一般设none除非设备明确要求硬件流控。注意port.open()如果端口已经被打开会抛InvalidStateError。所以打开前最好检查一下port.readable和port.writable是否为 null或者直接用 try/catch 兜住。2.3 读取数据读循环的正确写法读取是整块逻辑里最容易写错的地方。核心是拿到port.readable的 reader然后在一个循环里不断read()。const reader port.readable.getReader(); try { while (true) { const { value, done } await reader.read(); if (done) { // 流被关闭了 break; } // value 是 Uint8Array handleData(value); } } catch (err) { console.error(读取错误:, err); } finally { reader.releaseLock(); }这段代码有几个关键点必须理解。第一reader.read()在没有数据时会挂起等待不会空转消耗 CPU这是流式读取的优势。第二done为 true 表示流已经关闭通常发生在端口关闭或者设备断开时必须 break 出循环否则会死循环。第三releaseLock()必须调用否则端口无法正常关闭会一直卡住。为什么读循环要放在独立的异步函数里因为它是一个长期运行的任务会一直阻塞在await reader.read()上。如果你把它和 UI 逻辑写在一起整个页面就卡住了。正确做法是把它作为一个后台任务启动通过回调或者事件把数据推给 UI。2.4 写入数据编码与流锁写入相对简单但编码转换和流锁是两个坑。const writer port.writable.getWriter(); const encoder new TextEncoder(); const data encoder.encode(ATRST\r\n); await writer.write(data); writer.releaseLock();TextEncoder把字符串转成Uint8Array这是 Web Serial 要求的格式。如果你要发十六进制字节就得手动构造数组const hexData new Uint8Array([0xAA, 0x55, 0x01, 0x02]); await writer.write(hexData);流锁的问题port.writable同一时间只能被一个 writer 锁定。如果你在写入过程中又调了一次getWriter()会抛TypeError。所以要么每次写完就releaseLock()要么维护一个全局 writer 并做好串行化。我个人的习惯是每次写入都获取和释放虽然稍微啰嗦但不容易出并发问题。2.5 关闭端口与资源释放关闭端口前必须先释放所有 reader 和 writer 的锁否则port.close()会一直挂起。async function closePort(port, reader, writer) { if (reader) { await reader.cancel(); reader.releaseLock(); } if (writer) { writer.releaseLock(); } await port.close(); }reader.cancel()会中断正在挂起的read()让读循环能正常退出。这一步很关键如果只是releaseLock()而不 cancel读循环可能还卡在read()上导致后续操作异常。3. 完整实操流程与代码实现3.1 最小可用 Demo 搭建我先把一个能跑通的最小闭环写出来包含连接、发送、接收、断开四个动作。你可以直接复制到一个 HTML 文件里用 localhost 打开测试。!DOCTYPE html html head meta charsetutf-8 title串口调试/title /head body button idconnect连接/button button iddisconnect disabled断开/button input idinput placeholder输入要发送的内容 button idsend disabled发送/button pre idlog/pre script srcserial.js/script /body /html对应的serial.jslet port null; let reader null; let keepReading true; const logEl document.getElementById(log); function log(msg) { logEl.textContent msg \n; logEl.scrollTop logEl.scrollHeight; } document.getElementById(connect).addEventListener(click, async () { if (!(serial in navigator)) { log(当前浏览器不支持 Web Serial API); return; } try { port await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); log(端口已打开); document.getElementById(connect).disabled true; document.getElementById(disconnect).disabled false; document.getElementById(send).disabled false; readLoop(); } catch (err) { log(打开失败: err.message); } }); async function readLoop() { const decoder new TextDecoder(); while (port.readable keepReading) { reader port.readable.getReader(); try { while (true) { const { value, done } await reader.read(); if (done) break; if (value) { log(收到: decoder.decode(value)); } } } catch (err) { log(读取异常: err.message); } finally { reader.releaseLock(); } } } document.getElementById(send).addEventListener(click, async () { const text document.getElementById(input).value; if (!text || !port) return; const writer port.writable.getWriter(); const encoder new TextEncoder(); await writer.write(encoder.encode(text \r\n)); writer.releaseLock(); log(发送: text); }); document.getElementById(disconnect).addEventListener(click, async () { keepReading false; if (reader) { await reader.cancel(); } await port.close(); port null; log(端口已关闭); document.getElementById(connect).disabled false; document.getElementById(disconnect).disabled true; document.getElementById(send).disabled true; });这段代码跑起来后点连接选设备就能收发数据了。注意readLoop里外层while判断了port.readable这是为了处理流被意外中断后重新获取 reader 的情况。3.2 数据解析从字节流到可用信息串口收到的是字节流实际项目里你需要把它解析成有意义的数据。常见的有三种模式。文本模式设备发的是 ASCII 字符串直接用TextDecoder解码。但要注意如果一条消息被拆成多个 chunk 到达直接解码会得到半截内容。稳妥做法是维护一个缓冲区按分隔符比如换行符切分。let buffer ; function handleTextChunk(value) { buffer new TextDecoder().decode(value); const lines buffer.split(\n); buffer lines.pop(); // 最后一段可能不完整留到下次 lines.forEach(line { if (line.trim()) log(完整消息: line.trim()); }); }定长帧模式设备按固定长度发数据比如每帧 8 字节。那就攒够 8 字节处理一次。let byteBuffer new Uint8Array(0); function handleFixedFrame(value) { const merged new Uint8Array(byteBuffer.length value.length); merged.set(byteBuffer); merged.set(value, byteBuffer.length); byteBuffer merged; while (byteBuffer.length 8) { const frame byteBuffer.slice(0, 8); byteBuffer byteBuffer.slice(8); processFrame(frame); } }协议帧模式带帧头帧尾和校验的协议比如AA 55 ... CRC。这种需要状态机来解析先找帧头再读长度再校验。这块逻辑量比较大建议单独封装成一个解析器类。3.3 十六进制收发与格式化调试硬件时经常需要按十六进制收发。发送端把用户输入的AA 55 01这种字符串转成字节数组function hexStringToBytes(str) { const clean str.replace(/\s/g, ); if (clean.length % 2 ! 0) throw new Error(十六进制长度必须是偶数); const bytes new Uint8Array(clean.length / 2); for (let i 0; i bytes.length; i) { bytes[i] parseInt(clean.substr(i * 2, 2), 16); } return bytes; }接收端把字节数组格式化成十六进制显示function bytesToHexString(bytes) { return Array.from(bytes) .map(b b.toString(16).padStart(2, 0).toUpperCase()) .join( ); }这两个函数几乎是串口工具的标配建议直接放进工具库。padStart(2, 0)保证单字节也显示两位不然0x05会显示成5看起来不整齐。3.4 自动重连与设备热插拔处理实际使用中设备可能被拔掉再插上页面需要能感知并恢复。Web Serial 提供了connect和disconnect事件。navigator.serial.addEventListener(connect, (e) { log(设备已连接: e.target); }); navigator.serial.addEventListener(disconnect, (e) { log(设备已断开); // 清理状态提示用户重新连接 keepReading false; port null; });disconnect事件触发时读循环会因为流关闭而退出但你需要把 UI 状态重置让用户能重新点连接。这里有个细节断开事件里的e.target是那个SerialPort对象可以用来判断是不是当前正在使用的端口。实操心得热插拔处理不要只依赖事件最好在读写异常里也做兜底。因为有些情况下事件触发和流报错有先后两边都处理才能保证状态一致。4. 常见问题排查与避坑指南4.1 连接类问题速查现象可能原因排查方向点连接没反应浏览器不支持检查serial in navigator弹窗里看不到设备驱动未装/线缆问题系统设备管理器确认串口存在打开报 Failed to open端口被占用关闭其他串口工具打开报 Access denied权限不足Linux 下检查用户组权限收到乱码波特率不匹配核对设备手册参数乱码这个问题特别常见。除了波特率数据位和校验位不匹配也会导致乱码。我遇到过设备用 7 数据位偶校验的默认 8N1 连上去全是乱码改成dataBits: 7, parity: even就正常了。4.2 读写异常与流锁问题最常见的报错是TypeError: Failed to execute getWriter on WritableStream: WritableStream is locked。这说明上一个 writer 没释放。解决办法是确保每次写入后都releaseLock()或者用 try/finally 保证释放。const writer port.writable.getWriter(); try { await writer.write(data); } finally { writer.releaseLock(); }读取端类似reader.releaseLock()也要放在 finally 里。另外如果读循环里抛了异常没被捕获reader 的锁不会自动释放端口就再也关不掉了。所以读循环的 try/catch/finally 结构必须完整。4.3 数据粘包与半包处理串口是字节流没有消息边界的概念。设备连续发两条消息可能一次read()全收到粘包也可能一条消息分两次收到半包。这是串口通信的固有特性不是 bug。处理思路就是前面说的缓冲区加分隔符或者定长切分。关键原则是永远不要假设一次 read 就是一条完整消息。我见过不少新手直接对每次 read 的 value 做解析结果数据一快就出错。正确的做法是把解析逻辑和读取逻辑解耦读取只管往缓冲区塞解析器从缓冲区里按规则提取完整帧。4.4 性能与缓冲区调优高速传输时如果发现丢数据可以从几个方向调。一是调大bufferSize给系统更多缓冲空间。二是优化读取循环避免在循环里做耗时操作收到数据先塞队列另起任务处理。三是检查波特率是否够用115200 大约每秒 11KB如果数据量大可能需要上 921600 甚至更高。注意波特率不是越高越好线材质量、线长、设备能力都会影响稳定性。长距离传输时高波特率反而容易出错这时候要降速保稳定。4.5 安全上下文与部署坑本地开发用localhost没问题但部署到服务器必须 HTTPS。如果用了自签名证书浏览器会警告用户需要手动信任。正式环境建议用正规证书。另外如果页面嵌在 iframe 里需要给 iframe 加allowserial属性否则 API 不可用。这个坑我在做嵌入式设备管理平台时踩过页面单独打开正常嵌进主框架就调不通查了半天才发现是 iframe 权限问题。5. 进阶扩展与工程化建议5.1 封装成可复用的串口管理类把前面这些逻辑揉在一起封装成一个类用起来会清爽很多。核心是管理好端口生命周期、读循环、数据回调这几件事。class SerialManager { constructor() { this.port null; this.reader null; this.keepReading false; this.onData null; this.onError null; } async connect(options { baudRate: 115200 }) { this.port await navigator.serial.requestPort(); await this.port.open(options); this.keepReading true; this._readLoop(); } async _readLoop() { while (this.port.readable this.keepReading) { this.reader this.port.readable.getReader(); try { while (true) { const { value, done } await this.reader.read(); if (done) break; if (value this.onData) this.onData(value); } } catch (err) { if (this.onError) this.onError(err); } finally { this.reader.releaseLock(); } } } async send(data) { const writer this.port.writable.getWriter(); try { await writer.write(data); } finally { writer.releaseLock(); } } async disconnect() { this.keepReading false; if (this.reader) await this.reader.cancel(); if (this.port) await this.port.close(); this.port null; } }这样上层只需要manager.onData handleData和manager.connect()关注点分离得比较干净。5.2 与前端框架集成在 Vue 或 React 里用的时候注意把串口实例放在组件外部或者用 ref 持有不要放在响应式数据里。因为SerialPort对象被 Proxy 包裹后内部方法调用可能出问题。我试过把 port 放进 Vue 的reactive里结果port.open()报奇怪的错拿出来就好了。组件卸载时记得在onUnmounted或useEffect的清理函数里断开连接否则读循环还在跑会造成内存泄漏。5.3 日志记录与数据导出调试工具离不开日志。建议把收发数据带上时间戳记录下来支持导出成文本或 CSV。时间戳用performance.now()比Date.now()精度高适合分析时序问题。导出用 Blob 加URL.createObjectURL就能实现纯前端下载不需要后端参与。function exportLog(records) { const text records.map(r [${r.time}] ${r.dir} ${r.data} ).join(\n); const blob new Blob([text], { type: text/plain }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download serial-log.txt; a.click(); URL.revokeObjectURL(url); }这套东西组合起来一个纯 Web 的串口调试工具就成型了功能上能覆盖大部分日常调试需求部署成本却比传统上位机低得多。我在几个内部项目里用这个方案替代了原来的桌面工具团队反馈都还不错尤其是免安装这一点对非技术同事很友好。唯一需要提前沟通的就是浏览器限制得确保大家用的是 Chromium 内核的浏览器。