浏览器里直接跟串口设备对话,这件事放在几年前还只能靠桌面应用或者本地服务来中转。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()是整个流程的入口。它返回一个 Promise,resolve 出用户选择的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('AT+RST\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 charset="utf-8"> <title>串口调试</title> </head> <body> <button id="connect">连接</button> <button id="disconnect" disabled>断开</button> <input id="input" placeholder="输入要发送的内容"> <button id="send" disabled>发送</button> <pre id="log"></pre> <script src="serial.js"></script> </body> </html>对应的serial.js:
let 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 加allow="serial"属性,否则 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 内核的浏览器。