首页 / 资讯中心 / 文章详情

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

WebSerial Terminal:浏览器里跑串口终端的原理与实操 ★ FEATURED ARTICLE
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必须与设备一致最常见的是 115200dataBits数据位8绝大多数场景都是 8stopBits停止位1少数设备用 2parity校验位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。如果设备要求 ASCIIUTF-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参数。如果设为 truexterm 会把\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 Terminal5.1 环境准备与项目初始化先说环境要求。浏览器必须是 Chromium 内核且版本足够新Chrome 89 开始支持 WebSerial。页面必须运行在安全上下文下也就是 HTTPS 或者localhost。如果你直接双击打开一个本地 HTML 文件file://协议WebSerial 是不工作的这点一定要注意。项目初始化很简单不需要构建工具也能跑但为了代码组织方便我用 Vite 起一个最小项目。npm create vitelatest webserial-terminal -- --template vanilla cd webserial-terminal npm install xterm xterm-addon-fit npm run devnpm run dev起来之后是http://localhost:5173localhost 属于安全上下文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/ttyUSB0Linux。打开页面点“选择端口”浏览器弹出端口列表选中那个 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 terminals 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支持导出成文本或者二进制文件。这个功能在现场调试时特别有用因为设备可能只出一次故障错过了就没了。命令快捷发送。调试时经常要反复发同样的命令比如ATRST、help。可以做一个命令面板把常用命令存起来点一下就发。再进一步支持命令序列比如上电后依次发几条初始化命令中间加延时。协议解析插件。如果经常调试某一种协议比如 Modbus RTU可以写一个解析层把原始字节解析成有意义的字段显示。这比看十六进制直观多了。这个方向可以做成插件机制不同协议一个插件按需加载。多端口同时连接。有些场景需要同时监控两个串口比如一个发指令一个收响应。WebSerial 支持同时打开多个端口只要每个端口有独立的 reader 和 writer。界面上可以用标签页或者分屏来管理。数据可视化。如果串口传的是传感器数据可以实时画成曲线图。这个用 Canvas 或者轻量图表库就能做把解析出来的数值喂给图表比看数字滚动直观得多。我个人最看好日志录制和命令快捷发送这两个方向因为它们直接解决现场调试的痛点实现成本也不高。协议解析插件则适合有特定领域需求的团队通用性差一些但价值密度高。最后分享一个我在实际使用中的体会WebSerial Terminal 这类工具最大的优势不是功能多强而是它把“开始调试”这件事的门槛降到了最低。以前去现场光是装驱动、装工具、配环境就要折腾半小时现在一个网页链接发过去对方自己就能打开。这个体验上的差异用过一次就回不去了。当然它也有边界比如不能替代需要复杂脚本和自动化的场景但对于日常的串口调试它已经足够好用了。
阅读完成 · 觉得有帮助?
咨询建站