这篇记录下使用仓颉语言开发界面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:tcpState
report / quit 是给探针自检用的辅助命令(第九节会讲它们凭什么算"证据"),permissions 那两行是插件的命名权限集 ------插件声明"我提供 serial:default 这个集",清单引用它才生效;集声明 ≠ 放行,这条和插件的铁律一致。
怎么把它装起来用
两条路,看你想干什么。
A. 起一个新工程(有 Node 环境即可拿到 CLI):
bash
npx cj-tauri create myapp # 脚手架;另有 --template vue|react
cd myapp
npx cj-tauri dev # 编译并起窗口
B. 跑仓库里的示例(这篇讲的就在这里):
bash
git clone https://atomgit.com/qq8864/cj-tauri.git && cd cj-tauri
cd native && ./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:
js
// 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 收报是二期 |
收统一走宿主事件推送,前端只需要接一次:
js
__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 步,以及"线路上真收到过什么"
调试助手这类工具最容易糊弄自己的地方,是它自己说它成功了。所以这个示例的自检不是"点一遍看有没有报错",而是:
bash
CJ_LINK_PROBE=1 CJ_LINK_PROBE_SERIAL_PATH=COM1 \
CJ_LINK_PROBE_TCP_HOST=127.0.0.1 CJ_LINK_PROBE_TCP_PORT=15021 CJ_LINK_PROBE_UDP_PORT=15022 \
./target/release/bin/main 2> 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 打同样的参数:
c
// 与桥里打开串口时同一套超时参数
SetCommTimeouts(h, &ct); // ReadIntervalTimeout = MAXDWORD;两个 total 全 0
ReadFile(h, buf, n, &got, &ov);
打印出来的是:
phase1: ReadFile ok=1 got=0 err=0 elapsed=0 ms ← 预算写的是 1000ms
0 毫秒就回来了。 这套超时参数(ReadIntervalTimeout=MAXDWORD + 两个 total 全 0)在 Windows 上是
"立刻交回已有字节"语义:缓冲里没数据时,驱动当场把请求完成成 0 字节、ReadFile 返回 TRUE------
于是 C 桥里那条"等 OVERLAPPED 事件、最多等 timeout_ms"的分支根本进不去 。
前端传的 timeoutMs: 200 从第一天起就形同虚设:它只能拿到"此刻恰好已经躺在缓冲里"的字节,
其余时候靠高频空轮询碰运气。
修法是把预算按每次调用下发给驱动,而不是"打开时设一次":
c
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 装在哪"这件事。导出之后一次过:
bash
export CANGJIE_STDX=/d/cangjie-stdx/windows_x86_64_cjnative/dynamic/stdx
bash scripts/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 的同事"时,它还能再往前一步:
bash
export CANGJIE_STDX=/d/cangjie-stdx/windows_x86_64_cjnative/dynamic/stdx
bash scripts/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/下