ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebSerial Terminal:浏览器里跑串口终端的原理与实操

WebSerial Terminal:浏览器里跑串口终端的原理与实操

1. 浏览器里跑一个串口终端,到底图什么

第一次跟做嵌入式的朋友提“在浏览器里开串口终端”,对方第一反应基本都是:你这不是脱裤子放屁吗,本地装个 Tabby Terminal、Windows Terminal 不香吗?我一开始也这么想,直到有一次去客户现场调试一块工控板,对方只给了一台锁死权限的办公电脑,装不了任何驱动和软件,USB 转串口线插上去,设备管理器里能看到 COM 口,但没有任何工具能打开它。那一刻我才真正意识到 WebSerial Terminal 这类东西的价值:它把“打开串口”这件事,从“装驱动、装软件、配环境”压缩成了“打开一个网页、点一下连接”。

WebSerial 是浏览器提供的一套 JavaScript API,全称 Web Serial API,它允许网页在用户授权的前提下,直接访问本机的串口设备。注意这里的关键词是“用户授权”和“直接访问”,也就是说不需要任何本地中间件、不需要后台服务、不需要装客户端。Terminal 则是我们熟悉的终端交互形态:字符输入、字符回显、ANSI 转义序列、光标控制、历史命令。把这两者拼在一起,WebSerial Terminal 就是一个纯浏览器实现的串口终端工具,插上串口线,在网页里选端口、设波特率,就能像用 SecureCRT、PuTTY 那样收发数据。

它解决的问题非常具体:免安装、跨平台、即开即用。适合谁?第一类是嵌入式、硬件、物联网方向的工程师,尤其是经常跑现场、用别人电脑、被权限卡脖子的人;第二类是做教学和演示的人,学生机房里装一堆串口工具很麻烦,一个网页链接发下去就能用;第三类是前端开发者,想找一个真实硬件交互的项目练手,WebSerial Terminal 是个非常好的切入点,因为它同时涉及浏览器 API、流式数据处理、终端渲染这几个硬核方向。

我后面会把这套东西从设计思路、核心 API、终端渲染、踩坑排查到完整实操,一层层拆开讲。你看完不一定非要自己造一个,但至少下次再遇到“这台电脑装不了串口工具”的场景,你知道有条退路。

2. 整体设计思路:为什么是浏览器,为什么是流

2.1 方案选型:本地客户端、Electron、还是纯 Web

在动手之前,先把这个项目的技术路线想清楚,因为选型决定了后面所有的坑在哪里。市面上做串口终端,主流有三条路。

第一条是传统本地客户端,比如 PuTTY、SecureCRT、Tabby Terminal、Windows Terminal 配合串口插件。这条路最成熟,功能最全,日志、脚本、多会话、协议解析应有尽有。但它的死穴就是“安装”和“权限”。你在一台受管控的机器上,可能连安装包都运行不了;就算装上了,驱动签名、管理员权限、后台服务这些环节随时能把你卡住。热搜词里那句 “start the windows daemon from a non-elevated terminal; shared clients must not inherit administrator privileges” 说的就是这类问题——很多终端工具在 Windows 上依赖一个后台守护进程,普通权限的终端启动不了它,共享客户端又不能继承管理员权限,结果就是命令跑不起来,只能加--no-daemon参数绕开。这种报错本质上就是本地客户端架构带来的权限耦合。

第二条是 Electron 打包的桌面应用。它用 Web 技术写界面,但底层还是本地进程,能访问 Node.js 的串口库(比如serialport)。好处是界面开发快、跨平台,坏处是包体积大、还是要安装、还是可能触发权限问题。它没有真正解决“免安装”这个核心诉求。

第三条就是纯 Web 方案,也就是 WebSerial Terminal。它完全跑在浏览器沙箱里,串口访问走浏览器原生 API,不需要任何本地依赖。代价是:必须用支持 WebSerial 的浏览器(目前主要是 Chromium 内核的 Chrome、Edge、Opera 等),必须走 HTTPS 或 localhost(安全上下文要求),功能上受浏览器沙箱限制,比如不能后台常驻、不能访问任意系统资源。

我选第三条,理由很直接:这个项目的核心价值就是“零安装”和“零权限依赖”。如果为了功能全去走 Electron,那还不如直接用 Tabby Terminal。既然要做,就把“打开网页就能用”这个优势做到极致。功能上做减法没关系,串口调试最常用的就是收发、波特率、十六进制显示这几样,把这些做扎实,比堆一堆用不上的功能更有意义。

2.2 核心架构:三层拆分

整个 WebSerial Terminal 的架构可以拆成三层,这个分层思路很重要,因为它决定了代码怎么组织、问题怎么定位。

最底层是设备访问层,负责跟 WebSerial API 打交道:请求端口、打开端口、配置波特率/数据位/停止位/校验位、读写数据流。这一层是纯粹的浏览器 API 封装,跟界面无关。

中间层是数据转换层,负责在字节流和可读文本之间来回转换。串口传的是字节,终端显示的是字符,中间涉及编码(UTF-8、GBK、ASCII)、十六进制转换、行结束符处理(\n、\r\n、\r)。这一层最容易出 bug,因为编码问题往往表现为“乱码”,而乱码的原因可能有好几种。

最上层是终端渲染层,负责把数据显示成终端的样子,处理用户输入、命令历史、ANSI 转义序列、光标移动、清屏等。这一层可以自己写,也可以借助成熟的终端渲染库。

三层之间通过数据流连接:设备层读到的Uint8Array经过转换层变成字符串,交给渲染层显示;用户在渲染层输入字符,经过转换层变成字节,交给设备层写出。理解这个数据流,后面排查问题的时候就能快速定位是哪一层出了岔子。

2.3 为什么用流(Stream)而不是轮询

WebSerial 的读取接口返回的是一个ReadableStream,这是整个项目里最值得说道的设计。很多人第一反应是用setInterval定时去读,或者用事件回调。但串口数据是异步、不定长、可能突发的,轮询要么延迟高,要么空转浪费。

用流的方式,代码是这样的逻辑:拿到port.readable,用getReader()拿到读取器,然后await reader.read()一直循环。这个read()会挂起,直到真的有数据来了才返回。这样既没有轮询开销,又能第一时间拿到数据。写数据同理,port.writable.getWriter()拿到写入器,writer.write(uint8array)写出去。

这里有个关键细节:读写流是独占的。一旦你getReader()了,就不能再拿第二个 reader,否则会抛异常。所以在设计上,读循环和写操作要分开管理,读循环是一个长期运行的异步任务,写操作是随时可以插入的。我见过有人每次发数据都重新getWriter(),结果写几次就卡死,就是因为没释放前一个 writer。正确做法是打开端口后拿到 writer 并持有,或者每次写完releaseLock()。

3. 核心细节解析:WebSerial API 到底怎么用

3.1 请求端口与用户授权

WebSerial 的第一道门槛是用户授权。浏览器不允许网页静默访问串口,必须由用户主动触发(比如点击按钮),调用navigator.serial.requestPort()。这个调用会弹出一个浏览器原生的端口选择框,列出当前系统上可用的串口设备,用户选一个,返回一个SerialPort对象。

这里有几个实操要点。第一,requestPort()必须在用户手势(user gesture)的调用栈里执行,也就是说不能放在setTimeout或者异步回调里延迟调用,否则浏览器会拒绝。第二,返回的SerialPort对象可以被持久化,配合navigator.serial.getPorts()可以拿到之前授权过的端口列表,这样下次打开页面就不用重新选。第三,端口的getInfo()能拿到 USB 厂商 ID 和产品 ID,可以用来做设备识别,比如自动匹配某个特定型号的开发板。

// 请求端口,必须在用户点击事件里调用 const port = await navigator.serial.requestPort(); // 拿到端口信息,用于识别设备 const info = port.getInfo(); console.log('VID:', info.usbVendorId, 'PID:', info.usbProductId);

授权这个设计一开始会觉得麻烦,但站在安全角度是必须的。网页如果能随便开串口,那恶意网站就能通过串口搞事情。所以这个“用户点一下”的门槛,恰恰是 WebSerial 能存在的前提。

3.2 打开端口与参数配置

拿到端口后,用port.open(options)打开,options 里配置串口参数。这些参数必须跟对端设备一致,否则收到的就是乱码或者根本收不到。

参数含义常用值说明
baudRate波特率9600 / 115200必须与设备一致,最常见的是 115200
dataBits数据位8绝大多数场景都是 8
stopBits停止位1少数设备用 2
parity校验位none调试场景基本不用校验
flowControl流控none除非设备要求,否则不开
bufferSize缓冲区大小4096读写缓冲区,按需调整
await port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none', bufferSize: 4096 });

波特率这块我要多说一句。很多人调试时收不到数据,第一反应是代码有问题,其实十有八九是波特率不对。115200 和 9600 是最常见的两个值,如果设备手册写的是 115200,你配成 9600,收到的就是一堆乱码。反过来也一样。所以打开端口前,先确认设备手册上的串口参数,这是基本功。

3.3 读取数据:流式循环的正确写法

读取是核心中的核心。前面说了用getReader()加循环,但具体怎么写有讲究。

const reader = port.readable.getReader(); const decoder = new TextDecoder('utf-8'); while (true) { const { value, done } = await reader.read(); if (done) { // 端口关闭或流被取消 reader.releaseLock(); break; } if (value) { // value 是 Uint8Array const text = decoder.decode(value, { stream: true }); terminal.write(text); } }

这里有几个细节值得展开。第一,TextDecoder的stream: true参数非常重要。串口数据是按块到达的,一个多字节的 UTF-8 字符可能被拆到两个数据块里。如果不用stream: true,每个块单独解码,遇到被拆开的字符就会解出乱码。加上这个参数,decoder 会缓存不完整的字节序列,等下一个块来了再一起解。第二,reader.read()返回的done为 true 时,说明流结束了,这时候必须releaseLock(),否则端口无法正常关闭。第三,读循环要放在一个独立的异步函数里,用try/catch包住,因为端口断开、设备拔出都会让read()抛异常。

还有一个容易被忽略的点:读循环和关闭端口的顺序。如果你想关闭端口,不能直接port.close(),因为读循环还持有 reader。正确顺序是先取消读循环(reader.cancel()),等循环退出并releaseLock()之后,再port.close()。顺序错了就会卡住,这是新手最常踩的坑之一。

3.4 写入数据:编码与换行符

写数据相对简单,但编码和换行符是两个坑。

const writer = port.writable.getWriter(); const encoder = new TextEncoder(); await writer.write(encoder.encode('AT\r\n')); writer.releaseLock();

编码方面,TextEncoder默认输出 UTF-8。如果设备要求 ASCII,UTF-8 对 ASCII 字符是兼容的,没问题。但如果要发中文或者特殊字符,就要确认设备端的编码。换行符方面,这是重灾区。有的设备认\n,有的认\r\n,有的认\r。你发AT\n设备没反应,换成AT\r\n就好了,这种情况太常见了。我的建议是在界面上做一个换行符选择器,让用户自己选,而不是写死。

写入还有一个并发问题。如果用户快速连点发送按钮,多个write()可能交叉执行。虽然 WebSerial 的 writer 内部有队列,但为了逻辑清晰,最好在应用层加一个发送队列或者禁用按钮,避免数据交错。

4. 终端渲染:从字节流到像样的终端界面

4.1 自己写还是用现成库

终端渲染这块,摆在面前的选择是:自己用 DOM 或者 Canvas 写,还是用 xterm.js 这类成熟库。

自己写的好处是可控、体积小、没有依赖。但终端渲染远没有看起来那么简单。ANSI 转义序列有几十种,光标移动、颜色、清屏、滚动区域、制表符对齐,每一项都要处理。你随便连一个 Linux 设备,ls一下,输出里就带着颜色转义序列,自己写的渲染器很可能显示成一堆[0m[32m这样的乱码。

用 xterm.js 的好处是这些它全帮你处理了,ANSI 支持完整,性能好(用 Canvas 渲染),还有光标、选区、滚动这些细节。坏处是引入了一个不小的依赖,而且它的输入输出接口需要跟 WebSerial 对接。

我的选择是用 xterm.js 做渲染层,把精力集中在串口通信和业务逻辑上。这不是偷懒,而是合理的分工。终端渲染是一个已经被解决得很好的问题,没必要重复造轮子。当然,如果你就是想练手,自己写一个简易版也完全可以,只是要接受功能上的妥协。

4.2 xterm.js 与 WebSerial 的对接

对接的核心就两件事:把串口读到的数据写进终端,把终端里的输入写到串口。

import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Consolas, monospace', scrollback: 5000, convertEol: false }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById('terminal')); fitAddon.fit(); // 串口数据 -> 终端 term.write(text); // 终端输入 -> 串口 term.onData(data => { const writer = port.writable.getWriter(); writer.write(new TextEncoder().encode(data)); writer.releaseLock(); });

这里有个细节:convertEol参数。如果设为 true,xterm 会把\n自动转成\r\n,这在某些场景下能让显示更整齐,但也可能跟设备实际发送的内容不符。调试场景我建议设为 false,保持原始数据,避免误导。

onData回调里每次输入都重新拿 writer,这在低频输入下没问题,但如果是粘贴一大段文本,会触发很多次onData,每次都拿 writer 效率不高。优化做法是维护一个 writer 实例,或者做一个输入缓冲,攒一小段时间再发。不过对于串口调试这种低频场景,简单写法就够了,不必过度优化。

4.3 十六进制显示与混合模式

串口调试经常需要看十六进制,尤其是调试二进制协议的时候。纯文本模式会把不可打印字符显示成乱码或者空白,根本没法分析。

实现十六进制显示,思路是在数据转换层加一个开关。读到的Uint8Array不直接解码成文本,而是转成十六进制字符串,比如48 65 6C 6C 6F对应 “Hello”。显示的时候可以按行分组,每 16 字节一行,左边是十六进制,右边是对应的 ASCII 可打印字符,这就是经典的 hex dump 格式。

function toHexDump(bytes) { let result = ''; for (let i = 0; i < bytes.length; i += 16) { const chunk = bytes.slice(i, i + 16); const hex = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' '); const ascii = Array.from(chunk).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join(''); result += `${i.toString(16).padStart(8, '0')} ${hex.padEnd(47)} ${ascii}\r\n`; } return result; }

混合模式则是文本和十六进制同时显示,或者提供一个切换按钮。我的做法是默认文本模式,提供一个“HEX”开关,打开后所有收发数据都以十六进制显示。发送的时候也支持十六进制输入,比如用户输入41 54 0D 0A,解析成字节发出去。这个功能对调试 Modbus、自定义二进制协议特别有用。

5. 完整实操:从零搭一个可用的 WebSerial Terminal

5.1 环境准备与项目初始化

先说环境要求。浏览器必须是 Chromium 内核且版本足够新,Chrome 89+ 开始支持 WebSerial。页面必须运行在安全上下文下,也就是 HTTPS 或者localhost。如果你直接双击打开一个本地 HTML 文件(file://协议),WebSerial 是不工作的,这点一定要注意。

项目初始化很简单,不需要构建工具也能跑,但为了代码组织方便,我用 Vite 起一个最小项目。

npm create vite@latest webserial-terminal -- --template vanilla cd webserial-terminal npm install xterm xterm-addon-fit npm run dev

npm run dev起来之后是http://localhost:5173,localhost 属于安全上下文,WebSerial 可以正常工作。如果你要部署到线上给同事用,必须配 HTTPS,否则浏览器会直接屏蔽navigator.serial。

5.2 页面结构与关键代码

页面结构就三块:顶部工具栏(端口选择、波特率、连接/断开按钮、HEX 开关),中间终端区域,底部发送区(输入框、发送按钮、换行符选择)。

核心的连接逻辑我整理成一个模块,关键步骤如下。

第一步,检测浏览器支持。if (!('serial' in navigator))就提示用户换浏览器,别让用户点了半天没反应还不知道为什么。

第二步,请求并打开端口。把requestPort()和open()串起来,注意requestPort()必须在点击事件里同步调用。

第三步,启动读循环。用一个标志位控制循环的退出,端口关闭时把标志位置 false,循环自然结束。

第四步,绑定终端输入。term.onData里把数据写到串口。

第五步,断开连接。先停读循环,再releaseLock(),最后port.close()。

let port = null; let reader = null; let keepReading = true; async function connect() { port = await navigator.serial.requestPort(); await port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none' }); keepReading = true; readLoop(); } async function readLoop() { reader = port.readable.getReader(); const decoder = new TextDecoder('utf-8'); try { while (keepReading) { const { value, done } = await reader.read(); if (done) break; if (value) term.write(decoder.decode(value, { stream: true })); } } catch (e) { console.error('读取异常', e); } finally { reader.releaseLock(); } } async function disconnect() { keepReading = false; if (reader) await reader.cancel(); if (port) await port.close(); port = null; reader = null; }

这段代码是整个项目的骨架,看着不长,但每一行都有讲究。keepReading标志位配合reader.cancel()是优雅退出读循环的标准做法。finally里releaseLock()保证异常情况下也能释放锁。disconnect里的顺序不能乱,先 cancel 再 close。

5.3 参数计算与选择过程

波特率的选择不是随便填的。它决定了每秒传输的位数,收发双方必须严格一致。常见值有 9600、19200、38400、57600、115200、230400、460800、921600。115200 是目前最主流的调试波特率,因为它在速度和稳定性之间平衡得最好。

如果你要传大量数据,比如固件升级或者日志抓取,可以考虑更高的波特率。但要注意,波特率越高,对线材质量和电磁环境越敏感。我用 921600 在一根劣质 USB 转串口线上试过,丢包严重,降到 115200 就稳了。所以不是越高越好,要看实际链路质量。

缓冲区大小bufferSize默认 4096 字节,一般够用。如果你发现高速传输时数据有丢失,可以适当调大,比如 16384。但这个值也不是越大越好,太大会增加内存占用和延迟。

5.4 实操现场记录:一次完整的调试过程

我拿一块 ESP32 开发板做实测。接线很简单,USB 线插上,系统识别出一个 COM 口(Windows)或者/dev/ttyUSB0(Linux)。打开页面,点“选择端口”,浏览器弹出端口列表,选中那个 COM 口,波特率选 115200,点连接。

连接成功后,终端区域是空白的。我在发送框输入AT,换行符选\r\n,点发送。终端上立刻回显了AT,然后设备返回OK。这说明收发链路是通的。

接着我按了一下开发板上的复位键,终端里刷出一大段启动日志,里面有乱码。这就是典型的编码问题。ESP32 默认输出 UTF-8,但启动日志里可能混着一些非 UTF-8 的字节。我打开 HEX 模式一看,乱码位置对应的字节是0xFF 0xFE之类的,属于正常现象,不影响功能。如果乱码出现在正常文本里,那就要检查波特率是不是配错了。

整个流程走下来,从打开页面到看到设备日志,不超过一分钟。这就是 WebSerial Terminal 的价值所在:没有安装,没有配置,插上线就能干活。

6. 常见问题与排查技巧实录

6.1 端口打不开、读不到数据的排查顺序

串口调试的问题,排查要有顺序,不能瞎试。我总结了一个从外到内的检查流程。

先看硬件层:线插好了吗?设备供电了吗?驱动装了吗?Windows 上设备管理器里能看到 COM 口吗?如果看不到,那是驱动或者硬件问题,跟 WebSerial 无关。

再看权限层:Linux 上串口设备通常需要dialout组权限,当前用户不在这个组里就打不开。Windows 上一般没这个问题,但如果设备被其他程序占用了,也会打不开。这里要特别注意,串口是独占资源,如果 PuTTY 或者别的工具已经打开了这个口,浏览器这边就会报错。热搜词里提到的 “the connection to the terminal's pty host process is unresponsive” 就是这类占用或进程无响应的问题,本质上是资源被占住了。

然后看参数层:波特率、数据位、停止位、校验位,逐项跟设备手册核对。波特率错是最常见的。

最后看代码层:读循环启动了吗?reader.read()有没有正常返回?decoder的stream参数加了吗?

6.2 常见问题速查表

现象可能原因排查方法解决
点连接没反应浏览器不支持检查'serial' in navigator换 Chrome/Edge 新版本
弹不出端口选择框非用户手势调用确认在点击事件里调用把requestPort移到点击回调
打开端口报错端口被占用关闭其他串口工具释放占用后重试
收到乱码波特率不匹配核对设备手册改成正确波特率
收到乱码编码问题切换 HEX 模式看原始字节调整解码方式
发数据没反应换行符不对试\n、\r\n、\r用换行符选择器
关闭端口卡住读循环未退出检查releaseLock顺序先 cancel 再 close
数据丢块缓冲区太小观察高速传输场景调大bufferSize
多字节字符乱码decoder 未用 stream检查TextDecoder参数加{ stream: true }

6.3 几个我踩过的坑

第一个坑是忘记处理设备拔出。调试过程中拔掉 USB 线,reader.read()会抛异常,如果没 catch,整个读循环就崩了,页面看起来像死了一样。正确做法是在 catch 里提示用户设备已断开,并清理状态。

第二个坑是在onData里做异步操作。term.onData的回调如果写成 async,里面的await会让输入顺序错乱。比如用户快速输入abc,三个字符的回调并发执行,写出去的顺序可能是acb。解决办法是在回调里同步拿 writer 写,或者用一个队列串行化。

第三个坑是HEX 模式和文本模式切换时的状态残留。切换模式时,如果 decoder 里还缓存着半个字符,切换后就会出问题。我的做法是切换时重置 decoder,并清空终端显示,避免混淆。

第四个坑是页面失焦后数据积压。浏览器对后台标签页会降频,读循环可能被暂停,导致数据在缓冲区里堆积。等切回前台时,一大波数据涌进来,终端可能卡顿。解决办法是限制单次写入终端的字符数,或者用requestAnimationFrame分批渲染。

7. 还能怎么扩展:几个实用的方向

基础版本跑通之后,这个项目还有不少可以深挖的地方。

日志录制与导出。串口调试经常需要把日志存下来分析。可以在数据转换层加一个录制开关,把收到的原始字节和时间戳存到内存或者 IndexedDB,支持导出成文本或者二进制文件。这个功能在现场调试时特别有用,因为设备可能只出一次故障,错过了就没了。

命令快捷发送。调试时经常要反复发同样的命令,比如AT+RST、help。可以做一个命令面板,把常用命令存起来,点一下就发。再进一步,支持命令序列,比如上电后依次发几条初始化命令,中间加延时。

协议解析插件。如果经常调试某一种协议,比如 Modbus RTU,可以写一个解析层,把原始字节解析成有意义的字段显示。这比看十六进制直观多了。这个方向可以做成插件机制,不同协议一个插件,按需加载。

多端口同时连接。有些场景需要同时监控两个串口,比如一个发指令一个收响应。WebSerial 支持同时打开多个端口,只要每个端口有独立的 reader 和 writer。界面上可以用标签页或者分屏来管理。

数据可视化。如果串口传的是传感器数据,可以实时画成曲线图。这个用 Canvas 或者轻量图表库就能做,把解析出来的数值喂给图表,比看数字滚动直观得多。

我个人最看好日志录制和命令快捷发送这两个方向,因为它们直接解决现场调试的痛点,实现成本也不高。协议解析插件则适合有特定领域需求的团队,通用性差一些,但价值密度高。

最后分享一个我在实际使用中的体会:WebSerial Terminal 这类工具,最大的优势不是功能多强,而是它把“开始调试”这件事的门槛降到了最低。以前去现场,光是装驱动、装工具、配环境就要折腾半小时,现在一个网页链接发过去,对方自己就能打开。这个体验上的差异,用过一次就回不去了。当然它也有边界,比如不能替代需要复杂脚本和自动化的场景,但对于日常的串口调试,它已经足够好用了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进