这篇记录下使用仓颉语言开发界面GUI软件,
cj-link调试助手的完整来路:为什么要写一个调试助手、两条链路(串口与网络)各自怎么落、探针自检到底验了什么,以及我在 Windows 实机上是怎么被一处「读串口拿不回字节」逼到 C 桥里去的。
一、缘起:我想验证的是「能碰硬件的框架」
cj-tauri是我用仓颉语言做的 Tauri 2 对应物。写完力扣那个示例之后,我手上已经有了一块「能编译、能判题、能联远端」的靶子,但它有个共同点:全是纯软件链路——文件、进程、HTTP。框架真正没被压过的一类,是宿主能力:窗口、菜单、托盘、拖放,还有串口。
串口恰好是最难糊弄的一种。它不像网络那样能在同一台机器上开个 echo 服务器自娱自乐——有没有真的打开那个端口、有没有真的把字节写出去、驱动到底有没有把数据交回来,这三件事一旦有一件是假的,工具就毫无用处。拿它当靶子的好处是:验收标准天然是"线路上真的流过这些字节",而不是"命令返回了 ok"。
所以我想要一个东西:一个窗口里同时有串口和网络两条链路,共用一块收发日志。串口用于对板子、对模块;网络用于对远端服务、对本机做 TCP/UDP 联调。这类工具不新鲜(串口有 SSCOM / XCOM,网络有各种网络调试助手),正因为不新鲜,它才是一块好的试金石:所有功能都有明确的"正确"参照物。
二、先说清楚 cj-tauri 是什么
后面所有内容都建立在这一句话上:cj-tauri是用仓颉语言实现的 Tauri 2 对应物——把 Tauri 的三件套在仓颉里独立实现了一遍:WebView 宿主 + IPC 双向桥 + 能力安全模型。前端用任意 Web 技术(HTML/CSS/JS,另有 Vue 3 / React 18 模板),后端用仓颉静态编译,产物是不带 Chromium 的原生桌面应用:Windows 走 WebView2、Linux 走 WebKitGTK,鸿蒙 ArkWeb 是架构预留位。
cj-tauri 项目开源地址:https://atomgit.com/qq8864/cj-tauri
跟"换个语言重写一遍"相比,真正让我觉得有意思的是仓颉这门语言的形状把几件事推到了前台:
- 平台差异只允许出现在两处:仓颉侧的
@When条件编译,和 C 桥的native/bridge_*.c。IPC、能力校验、命令分发必须跨平台共用——所以这个示例的串口在 Windows 与 Linux 上是同名同签名的cj_bridge_serial_*,上层一行平台分支都没有。这不是洁癖,是因为我在下面第八节真的改了一次平台层,而改完之后两个平台的表现同时对了。 - 安全模型是"能力清单 + 三处联动":一个命令要能调,得同时做到「实现 handler」「
app.register(...)注册」「在capabilities/*.json里声明」。默认最小权限,漏一处分别得到command not registered或command not allowed。 - 命令是异步分发的:校验在调用线程上同步做,通过的命令
spawn到 worker 线程执行、结果回投前端。串口读写这类会阻塞的调用因此不会冻住窗口。 - 能力位如实上报:宿主支持什么由 C 桥按编译期开关声明,
system:host会如实报menu=1 tray=1这样的位。做不到的东西不会假装做到。
这个示例的装配长这样(src/main.cj,143 行全部在内):
TauriApp() .window(WindowConfig{ title: "cj-Link · 串口/网络调试助手", size: 1180x780 }) .register("link", LinkCommands()) # 一个处理器带 7 个 link:* 命令 .plugin(SerialPlugin()) # 官方 serial 插件:serial:list/open/read/write/close .plugin(StorePlugin()) # store 插件:面板配置落 store.json → app.run(内嵌页面优先、否则读 ui/index.html)能力清单capabilities/default.json一共放行了 10 个命令与 2 个事件:
commands: system:version link:serialList link:netConnect link:netSend link:netClose link:netStatus link:crcCalc link:ping report quit permissions: serial:default store:default ← 命名权限集,展开后与明文命令同权 events: link:tcpData link:tcpStatereport/quit是给探针自检用的辅助命令(第九节会讲它们凭什么算"证据"),permissions那两行是插件的命名权限集——插件声明"我提供serial:default这个集",清单引用它才生效;集声明 ≠ 放行,这条和插件的铁律一致。
怎么把它装起来用
两条路,看你想干什么。
A. 起一个新工程(有 Node 环境即可拿到 CLI):
npx cj-tauri create myapp# 脚手架;另有 --template vue|reactcdmyapp npx cj-tauri dev# 编译并起窗口B. 跑仓库里的示例(这篇讲的就在这里):
gitclone https://atomgit.com/qq8864/cj-tauri.git&&cdcj-tauricdnative&&./build_linux.sh# 先编 C 桥;Windows 用 native\build_win.bat# 产物必须落在 native/ 根,cjpm.toml 按那个路径找库cd../examples/cj-link&&cjpm build# 需要仓颉 SDK 1.2.0(含 stdx)进 PATH./run.sh# Linux;Windows 用 run.bat去哪儿找它
| 想找什么 | 地址 |
|---|---|
| 仓库(主托管,双推 AtomGit + GitHub) | atomgit.com/qq8864/cj-tauri |
官网(中英双语,源文件在website/) | yangyongzhen.github.io/cj-tauri |
| npm(CLI 与脚手架) | npmjs.com/package/cj-tauri |
| 上手文档 | README.md(概览与示例一览)、docs/使用文档.md(从零到窗口) |
| 设计取舍 | docs/设计决策记录.md(每个长期决策的「为什么」与真实代价) |
| 开发契约与坑清单 | AGENTS.md(这篇里的坑大半也在那儿有一份) |
三、它长什么样
窗口 1180×780,左边一列参数、右边一大块日志(标题栏写着「cj-Link · 串口/网络调试助手」):
┌────────────────────────────────────────────────────────────────────────┐ │ cj-Link v0.8.0 ● 未连接 │ │ 串口 / 网络二合一调试助手 · cj-tauri │ ├───────────────────┬────────────────────────────────────────────────────┤ │ [ 串口 ] [ 网络 ] │ 收发日志 [全部][接收][发送][系统] 自动滚动:开 视图:双显 清空│ ├───────────────────┼────────────────────────────────────────────────────┤ │ 串口 │ 00:02:00.062 · cj-link 就绪(等待连接) │ │ 端口 [COM1 ↻] │ 00:02:00.105 · 串口枚举完成: 2 个端口 │ │ 波特率 [115200 ▾] │ │ │ 数据位 [8 ▾] │ │ │ 校验位 [无 ▾] │ │ │ 停止位 [1 ▾] │ │ │ [ 打开串口 ] │ │ │ ───────────────── │ │ │ 校验计算 │ │ │ [01 03 00 00 …][算]│ │ │ ───────────────── │ │ │ 发送队列 │ │ │ 暂无可条目 │ │ └───────────────────┴────────────────────────────────────────────────────┘日志那一栏是这个工具的主体:HEX/ASCII 双显(双显 / 仅 HEX / 仅文本三档)、方向分色
(↑TX 黄 / ↓RX 绿 / ·系统灰)、毫秒时间戳、按方向过滤、TX/RX 字节与错误计数。中间那排按钮是给
"盯着看波形"用的——调试协议的时候,一眼分清"我发的"和"它回的"比什么都重要。
代码结构也一样平铺直叙,没有任何特殊之处:
src/main.cj 143 行 装配:link 处理器 + serial/store 插件 + 探针注入 src/commands.cj 879 行 7 个 link:* 命令(网络会话 / 校验 / 枚举 / ping) ui/index.html 845 行 整页前端(参数面板 + 收发日志 + 发送区) src/packed_*.cj 打包存根与 base64 资源装配(第九节) capabilities/default.json 10 个命令 / 2 个事件 / 2 个命名权限集前端页面为什么独立成ui/index.html,不内联进仓颉的三引号字符串?这个示例的 JS 里
“反斜杠"和”${}"到处都是(正则、\n拼接、模板字面量)。而仓颉的三引号字符串会处理反斜杠转义、${...}是活插值——把这份 JS 塞进"""里,等于让它先过一遍仓颉的词法分析:\n变成真换行、
把 JS 字符串或单行注释当场拆断,整个<script>语法错误,页面里一行都不执行而 stderr 毫无提示。
我在别的示例上被这个坑咬过一次,所以这个示例从一开始就把页面放成独立文件:源码树里改完刷新即生效,
打包的时候再由资源装配层把它读进二进制。
四、串口这条链路
枚举走link:serialList(统一出口):Windows 侧是 C 桥扫\Device\VSerial_*这类设备名,
Linux 侧扫/dev/tty*,出来的都是"人话"端口名。面板上那个 ↻ 就是重新枚举——插上 USB 转串口之后再点它。
打开是serial:open,带上波特率/数据位/校验位/停止位(默认 115200 8N1)。这里有个实际行为值得
先说清楚:打开是独占的,所以"打不开"通常不是代码问题,而是别的程序占着这个口——
这恰恰是调试助手这类工具的日常困境,页面上会如实把错误抛回来。
收走的是前端轮询serial:read:
// ui/index.html(节选)t().invoke('serial:read',{handle:S.serHandle,maxBytes:1024,timeoutMs:200})// 拿到数据 → 60ms 后再来一轮;空手回来 → 退到 500ms这段"拿到就快、空手就慢"的节奏不是一开始就这么写的——它其实是第八节那个 bug 逼出来的产物:
修复前那个timeoutMs: 200根本没人理,serial:read只会把"此刻恰好躺在缓冲里"的字节交回来,
所以页面只能用高频空轮询去碰运气。修好之后这个参数才真正生效,页面的轮询节奏也就可以松弛下来。
发是serial:write:HEX 或文本两种模式、可选追加 CRLF、也能挂定时发送(周期 50ms–60s)。
五、网络这条链路:一个命令三种模式
网络侧对外只有一个命令link:netConnect,用mode参数分三种模式——这不是为了省命令,
而是因为这三种模式对使用者是同一件事(“连出去/听进来”),差异在参数上:
| mode | 做什么 | 本示例的边界 |
|---|---|---|
tcp-client | 连一个对端,5s 建连超时 | 调试远端服务用 |
tcp-server | 本机监听,accept 多客户端(上限 16),每客户端一条读线程;发送缺省广播,可按通道#id定向 | 对端主动连上来时用(板子/设备回连) |
udp | connect 到对端后 send/receive(数据报语义) | 无连接场景;纯 bind 收报是二期 |
收统一走宿主事件推送,前端只需要接一次:
__CJ_TAURI__.listen('link:tcpData',function(ev){// 报文自带 mode / channel / from:TCP 客户端、TCP 服务器某个通道、UDP// 都从这一个出口进来,前端按这三个字段决定怎么显示})配套还有一条link:tcpState报连接状态变化。为什么串口是轮询、网络是推送?因为 TCP 这边有
可等待的 socket,仓颉侧的读线程能一直挂着等;而串口在 Windows 上没有等价的"数据到"事件源,
与其在 C 桥里再塞一根线程,不如让页面按节奏问——两种收法落在同一份命令契约之下,
前端只多接一个listen,没有第二种心智模型。
六、校验计算:把协议逻辑放在后端
link:crcCalc是这个示例里最"不界面"的一块:CRC16-Modbus(低前 / 高前两种字节序)、CRC16-CCITT、
累加和、异或和、LRC,外加一键把"整帧 + CRC"回填到发送框。
它在仓颉侧而不是在 JS 里,理由很直接:这是协议逻辑,算错了串口上发出去的就是废帧,
而废帧在日志里看起来和正常帧一模一样。所以我给它配了标准向量:
输入 01 03 00 00 00 02 输出 c40b (CRC16-Modbus,低字节在前)这条向量现在是探针自检里的一条断言——我不信"看起来对"的 CRC,界面上的计算按钮只是调用它。
七、探针自检:11 步,以及"线路上真收到过什么"
调试助手这类工具最容易糊弄自己的地方,是它自己说它成功了。所以这个示例的自检不是"点一遍看有没有报错",而是:
CJ_LINK_PROBE=1CJ_LINK_PROBE_SERIAL_PATH=COM1\CJ_LINK_PROBE_TCP_HOST=127.0.0.1CJ_LINK_PROBE_TCP_PORT=15021CJ_LINK_PROBE_UDP_PORT=15022\./target/release/bin/main2>app.log置上CJ_LINK_PROBE=1之后,宿主会在document-start阶段给页面注入一段预执行脚本(早于页面自己的
脚本执行),页面于是自动跑完11 步:
串口 open → write → read → close 校验 CRC16-Modbus 标准向量 01 03 00 00 00 02 → c40b TCP connect → send → recv → close UDP connect → send → recv → close每一步的结果经report命令回投到stderr——这里必须走 stderr:仓颉println的 stdout 有缓冲,
进程被强杀时那几行日志会丢,而桥的 stderr 是每行即时落盘的。run.sh(Linux)与run.bat selfcheck
(Windows)就是拿这份日志判绿的,口径都一样:数 PASS 明细、确认没有FAIL行。
但真正让我踏实的是对端那一侧。对端不是页面里某个 mock,而是独立跑在应用之外的进程:
- Linux:
cj-link-peer.py用pty扮演串口(回什么由它决定)+ TCP echo + UDP echo; - Windows:没有 pty——所以我把它拆成两个进程:
link-peer-win.py管 TCP/UDP echo,link-serial-echo.ps1用System.IO.Ports占住COM2「收到什么回什么」。
于是验收凭证落在应用进程之外:
对端日志(串口) rx 12 bytes: cj-link-ping 对端日志(TCP) rx 16 bytes 对端日志(UDP) rx 16 bytes 应用侧 11/11 PASS应用说"我发了"不算数,对端说"我收到了"才算。这条规矩在别的示例上已经救过我一次
(发后即忘的接口返回 0 却什么都没发出去),串口这种"没有回执就没有真相"的链路上更是必须。
Windows 这边本机正好有一对互通的虚拟串口:COM1 ↔ COM2(注册表里\Device\VSerial_0/_1),
枚举出来就是两个端口,对端拿一个、应用拿一个——所以"串口链路"在 CI 似的无人值守脚本里也能真跑通,
不需要真实硬件。
八、实机上折腾我的三件事
8.1 读串口拿不回字节:真凶在平台层,不在页面
第一轮探针只过了10/10。少的那一条很具体:串口那步的rx text=与close两条明细凭空消失,serial:read交回 0 字节。
按常理先怀疑上层:是不是前端轮询时序不对、是不是插件命令顺序有问题。查了一圈不成立——因为serial:read返回得很"干脆":它没超时、没报错,就是立刻说"没有数据"。
这时候最省事的做法是写一个几十行的 C 程序,绕开整个框架,直接对 Windows API 打同样的参数:
// 与桥里打开串口时同一套超时参数SetCommTimeouts(h,&ct);// ReadIntervalTimeout = MAXDWORD;两个 total 全 0ReadFile(h,buf,n,&got,&ov);打印出来的是:
phase1: ReadFile ok=1 got=0 err=0 elapsed=0 ms ← 预算写的是 1000ms0 毫秒就回来了。这套超时参数(ReadIntervalTimeout=MAXDWORD+ 两个 total 全 0)在 Windows 上是
"立刻交回已有字节"语义:缓冲里没数据时,驱动当场把请求完成成 0 字节、ReadFile返回 TRUE——
于是 C 桥里那条"等 OVERLAPPED 事件、最多等timeout_ms"的分支根本进不去。
前端传的timeoutMs: 200从第一天起就形同虚设:它只能拿到"此刻恰好已经躺在缓冲里"的字节,
其余时候靠高频空轮询碰运气。
修法是把预算按每次调用下发给驱动,而不是"打开时设一次":
ct.ReadIntervalTimeout=0;ct.ReadTotalTimeoutConstant=预算;// 有首字节立刻完成,没有就等满预算ct.ReadTotalTimeoutMultiplier=0;SetCommTimeouts(h,&ct);// 每次 read 之前这样语义就和 Linux 那边的poll对齐了(Linux 侧本来就是按预算等),只有预算为 0 时才回到
"立即返回"那套。改完重建 C 桥,探针10/10 → 11/11,串口那步拿回的正是对端回的字节:
serial:read hex=636a2d6c696e6b2d70696e67 (= "cj-link-ping")这里我想强调的不是这个具体的 API,而是两条通用教训:
- 凡"声称按预算等待"的宿主调用,都要用一个真的没数据的时间窗去打它。有数据的路径永远正常——
它是靠"有数据"蒙过去的。要证伪它,就必须在明确的空窗里测:预算 200ms 的调用必须在 200ms
左右回来,而不是 0ms。 - 平台语义差异要被解释成"这个平台为什么这么做",而不是"这个平台坏了"。
ReadIntervalTimeout=MAXDWORD
本身是合法且常见的写法(异步读的经典配置),它的错在于和"我要等满预算"这个意图不匹配。
把意图写进每次调用,比在打开时"设一次指望一直有效"稳。
8.2 打包第一次失败:看着像"框架缺依赖"
第一次跑单文件打包,报的是:
error: root package 'cjTauri' imports package 'stdx.encoding.json' in its source code, but the dependency info is missing指向的是框架包,字面意思像"框架忘了声明依赖"。但这个错只在打包场景出现、在示例目录cjpm build
时从来不出现——所以我先去看了打包脚本干了什么:它在dist-single/work/里自建一份工程副本再编译,
应用副本的 stdx 路径是写死的绝对路径,而框架包(path = ../..指回仓库根)仍然按框架自己的cjpm.toml展开${CANGJIE_STDX}。
结论:环境变量没导出。打包器不会替你设,因为那是"你本机的 stdx 装在哪"这件事。导出之后一次过:
exportCANGJIE_STDX=/d/cangjie-stdx/windows_x86_64_cjnative/dynamic/stdxbashscripts/pack-win-single.sh examples/cj-link这条也进AGENTS.md了——这类"报错指向的名字不是真原因"的坑最费时间,值得留档。
8.3 我第一张"界面正常"的截图,是别人的窗口
打包完之后我抓了张图想确认界面没问题。图看起来完全正常:布局对、配色对、数据也对。
但它不是这个应用的窗口——是我屏幕上压在那片坐标上的另一个程序。
原因是抓图脚本用的SetForegroundWindow被系统静默拒绝了(它连返回值都不看),
而CopyFromScreen抓的永远是"那一刻压在这片坐标最上面的窗口"。更隐蔽的是,窗口当时有一角在屏幕外,
那片坐标拍到的也是屏外的内容。
可靠做法是先ShowWindow(SW_RESTORE)+SetWindowPos(hwnd, HWND_TOPMOST, …)把目标顶到最前、
并且挪进屏内,抓完再HWND_NOTOPMOST还原。最后的判据写成了土话:
拍完先自己看一眼图里是不是这个窗口(标题栏、自己的 UI),别看命令的退出码——
退出码 0 在这件事上什么都不证明。
九、免 SDK 分发:一个 exe
示例本身能直接从源码跑(run.bat/run.sh),但想把它丢给"没装仓颉 SDK 的同事"时,它还能再往前一步:
exportCANGJIE_STDX=/d/cangjie-stdx/windows_x86_64_cjnative/dynamic/stdxbashscripts/pack-win-single.sh examples/cj-link# => examples/cj-link/dist-single/cj-link.exe产物是一个不带任何随行文件的 exe,本机实测:
| 复核项 | 实测值 |
|---|---|
| 体积 | 5,745,664 字节(约 5.5 MB) |
| 子系统 | Subsystem=00000002(GUI——双击不会弹控制台黑窗) |
| 导入表 | 8 个 DLL,全是 Windows 系统库:msvcrt / KERNEL32 / WS2_32 / SHELL32 / dbghelp / comdlg32 / USER32 / ole32 |
| WebView2Loader | 内嵌在 exe 里(162,392 字节),首次运行释放到%TEMP%\cj-tauri-loader\ |
页面、能力清单、vendor 全在二进制里,双击就开(仍然需要系统有 WebView2 Runtime,那是 Windows 自带的)。
和力扣那个包最大的区别是:这个包不需要 SDK 就能全功能使用——调试助手本身不编译任何代码,
它用的全是系统能力(串口 API、socket、WebView2)。所以这个 exe 是真正"拿走就能用"的。
包内我也完整验了一遍:在隔离目录里跑(独立的工作目录 + 独立的 WebView2 用户数据目录,
不碰你机器上那份store.json),串口枚举 2 个端口、探针11/11、
串口 / TCP / UDP 三条链路全活,stderr 里没有一条command not allowed或command not registered。
打包范式本身不值一提(就是 AGENTS 里那套「packed_assets.cj空存根 +packed_resources.cj资源装配」),
但有个设计选择值得点一下:页面读的是"内嵌优先、否则读盘"。
源码树里改ui/index.html立即生效(不用重新打包),打完包之后同一份代码又变成"读二进制里的那份"——
一套代码两种运行方式,而不是维护两份页面。
十、写在最后
回头看,这个示例做的其实是三件事。
一是证明"能碰硬件的宿主能力"是真的。串口这条链路是 枚举 → 打开 → 写 → 读 → 关闭,
Windows 与 Linux 走同名同签名的 C 桥导出,上层一行平台分支都没有。最直接的证明就是第八节那次修复:
我只改了 Windows 平台层的读实现,Linux 那侧一个字没动——平台差异收敛到一处这件事,
在被真实 bug 打一次的时候才显出价值。
二是把"证据"这件事做实。探针自检的 11 步回投到 stderr,而对端的日志是另外一个进程写的:
应用说"我发了"不算,对端说"我收到了 12 字节cj-link-ping"才算。同一套思路在 Linux 侧是run.sh,
判绿口径一样(数 PASS 明细、确认没有FAIL行)。
三是留下了可核对的数字:探针 11/11(串口 / CRC 标准向量 / TCP / UDP)、
单文件产物 5,745,664 字节、导入表 0 个非系统 DLL、GUI 子系统、本机 COM1↔COM2 真线往返。
最后是几句实话,免得期待错位:
- 串口与网络是单链路互斥的:同一时刻只用一条,页面上有提示。「串口↔网口透明互转」
(把串口收到的直接转到网上)是我有意不做的二期——那已经不是一个"调试助手"该有的语义了; - TCP 服务器面向调试场景:最多 16 个客户端、发送缺省广播,多客户端接进来之后的通道管理界面
也在二期(命令层已经支持link:netSend带channel定向,缺的是前端控件); - UDP 只做了 connect 型客户端(send/receive),纯 bind 收报是二期;
- 平台实测以Windows(WebView2)为主,Linux(WebKitGTK)侧本示例是通的
(run.sh那 33 条断言就是它的验收),鸿蒙 ArkWeb 目前仍是架构预留位——我不会把没跑过的说成跑过。
想拿它当工具:装好 SDK,cd examples/cj-link && cjpm build,然后run.bat(Windows)/run.sh(Linux);
不想装 SDK 就直接双击dist-single/cj-link.exe。想拿它当cj-tauri的样本,建议从两处读:src/commands.cj里的netConnect(一个命令三种模式怎么落)、native/platforms/windows/bridge_win.c里那个cj_plat_serial_read(第八节修的那处,
它把"宿主 API 的预算语义要按次下发"讲得比这段文字清楚)。
相关链接
- 仓库(主托管):atomgit.com/qq8864/cj-tauri
- 官网(中英双语):yangyongzhen.github.io/cj-tauri
- npm(CLI 与脚手架):npmjs.com/package/cj-tauri
- 这个示例:examples/cj-link
(含 README、探针脚本、Windows 侧对端脚本与打包存根) - 单文件 exe:
examples/cj-link/dist-single/cj-link.exe——随仓库分发,不装 SDK 也能全功能使用 - 同系列的坑与规矩:
AGENTS.md§4(这份坑清单里已经攒了不少同类条目,
这篇里的串口读、打包环境变量、抓图三件事都在其中) - 同类示例:
examples/modbus-poll(Modbus 调试工具,对标 Modbus Poll)、examples/leetcode-gui(判题 GUI,见《仓颉版力扣 GUI 开发记》)、examples/markdown-notes、examples/typing-poem、examples/music等,全部在examples/下