nexus_tui Plugin 開發指南
本頁內容與 PDK 內的
docs\NEXUS_TUI_PLUGIN_GUIDE.md相同(依章節分頁,連結已改成站內連結)。
給第三方開發者:怎麼寫一顆 IC Plugin、它會在什麼時候被呼叫、以及三種掛載型態 (headless/widget/canvas)分別能做什麼、不能做什麼。
PDK 裡另外有
sdk/README.md(標頭檔案本身的索引)與四顆完整範例:plugins/template_plugin/(Widget 模式的最小起點)、plugins/widget_demo/(Widget 模式的完整示範,本指南談 widget 型別與事件時就是指它)、plugins/canvas_demo/(Canvas 模式)、plugins/port_demo/(既有韌體移植)。 本指南把它們串成一份可以從頭讀到尾的說明;每一個指令、路徑、錯誤碼都已對照 原始碼逐字核對。本指南講怎麼做與為什麼;逐支 API 的精確契約(參數、回傳值、錯誤碼、範例)查 NEXUS_TUI_PLUGIN_API_REFERENCE.md。
Plugin 的交付形式一律是 DLL,本指南從頭到尾談的都是 DLL。把 Plugin 靜態 連結進主程式是主程式端的建置選項,需要主程式原始碼,不在 PDK 範圍內。
1. 十分鐘上手
手上已有韌體走 1a,從零開始走 1b。
1a 移植既有韌體
手上已經有一份能跑在 8051/Keil 風格 MCU 上的 I2C 韌體時,從這裡開始,不必 從 1b 的空白範本寫起。
- 複製
plugins/port_demo/並改名。改名處、把韌體換入、抽出迴圈體與進入點 的逐步流程本指南不重寫——plugins/port_demo/AGENT.md裡每一步都附驗收 方式與禁止清單,照它做。 - include 規則與 1b 相同:只允許
./src與../../sdk,見 6.0 include 規則。 init()必須原樣回傳nx_port_bind()的值(port_glue.c的pd_init()就是return (NxLong)nx_port_bind(ctx, host);一行), 不要自己改回return ERR_OK;。一顆移植 DLL 同時只能服務一個掛載: 第二個掛載時它回ERR_ALREADY_IN_USE,照原樣回傳才能讓 Core 拒絕 第二個掛載、保住第一個,見 nx_port_bind。
驗證
建置出來的 DLL 要先進到 Core 實際掃描的 out\<triple>\plugins\ 才叫得到——
port_demo 是獨立子專案,跟 1b 步驟 3 的「獨立建置」是同一種情況,把產物
放進去的做法見 1b 步驟 3~4(根建置的 plugins 目標,或建完手動複製)。
建置完成後,不需要接實體 dongle,用 hal_mock 後端就能驗證整條路是通的:
out\<triple>\nexus_tui.exe
dongle mock
p my_fw
fwrun(dongle mock 綁定 hal_mock 後端;p <name> 是 plugin switch <name>
的簡碼,見 1b 步驟 5;fwrun 是 port_demo 的自訂指令。)指令跑完後應在 Log
面板看到一行 fw: run ok。
自訂指令不得與內建指令同名(例:
run是內建的run <script.lua>)—— 同名者永遠叫不到,Plugin 載入時 Log 會出現一則... is shadowed by the built-in ... command and can never be invoked的 WARN。
自訂指令的
help會列在help輸出末尾的「Plugin commands」區塊,指令名稱 可用 Tab 補齊;handler 回傳非ERR_OK時,Host 會把該指令的usage當提示記一則 Log (usage: <字串>)。兩個欄位都可以是NULL(視為空字串)。
⚠
fwrun(與isp)只在hal_mock後端上有意義——它們會直接寫0x50/0x58這兩個固定位址,那是範例示範用的位址,不得對實體板子 執行。對自己移植完成的 Plugin 做真硬體驗收,見plugins/port_demo/AGENT.md「真硬體驗收」一節。
1b 從零寫 Plugin
以下步驟只用到 PDK 裡就有的東西:sdk/ 加一個 plugins/<name>/ 目錄。
-
複製範本。 把
plugins/template_plugin/整個目錄複製一份,改名字,例如:cp -r plugins/template_plugin plugins/myic保留裡面的
build.bat/Makefile/src/(含template_plugin.rc)/template_panel.h/template_panel.nxlay全套——這是一顆完全獨立的子專案, 只依賴./src與../../sdk;版面檔.nxlay與資源腳本.rc都在目錄內, 不需要 PDK 以外的任何檔案。
include 邊界。 只允許 include 兩處:./src(自己的原始碼)與
../../sdk(nexus_plugin_api.h/nexus_errors.h/ABI_VERSION.h)。
絕對不允許 include src/core/、src/tui/、src/gui/、
src/host_common/ 下的任何檔案——這是實體目錄邊界,由該目錄 Makefile
的 include path 強制,寫錯會在編譯期立刻失敗,不會等到第三方拿到 PDK
才發現。驗收方式:把 sdk/ 與範本目錄複製到一台乾淨機器直接跑
build.bat,不得出現 file not found。
-
改名字。 有五處要一起改,改漏一處不會編譯失敗,只會出現「兩顆 Plugin 同名」或「DLL 檔名對不上內容」這種容易誤判的症狀:
plugins/myic/build.bat裡的NX_NAME/NX_SRCplugins/myic/Makefile裡的NAME/SRCplugins/myic/src/template_plugin.c開頭的TEMPLATE_PLUGIN_NAME(對外指令列上p <name>要打的字)與TEMPLATE_DEFAULT_ADDRNexusPluginGetInterface()裡s_plugin.name/s_plugin.description- 版面資源名稱:
src/template_plugin.rc的TEMPLATE_PANEL RCDATA ...與template_plugin.c的TEMPLATE_LAYOUT_NAME必須是同一個字串(改漏的症狀是LoadLayout()回ERR_NOT_FOUND,Plugin 載入失敗)
-
建置這一顆 Plugin。 在
plugins/myic/目錄內直接跑它自己的build.bat(不需要整個主程式的原始碼樹):cd plugins/myic build.bat clangmingw x64 release支援的工具鏈。
plugins/template_plugin/build.bat接受vc6/gcc/vs2022/clangcl/clangmingw共五種,不帶參數時預設clangmingw x64 debug;你從範本複製出來的plugins/myic/build.bat因此也是 五工具鏈。其餘三顆範例(widget_demo/canvas_demo/port_demo)的build.bat只接受clangmingw,指定別的工具鏈會以錯誤訊息中止(其餘四種 只由範本保留);它們的原始碼仍維持 C89。clangmingw是唯一受 CI 守門的工具鏈——但 CI 只實際建置並驗證範本 (plugins/template_plugin/)的clangmingw車道(-std=c89 -pedantic); 範本的其餘四種工具鏈(vc6/gcc/vs2022/clangcl)是盡力支援 (best-effort),不受 CI 守門:指令本身可用、依然能編出可載入的 DLL, 但沒有自動化驗證每次合併都還編得過——手上已有那套開發環境(例如既有 韌體團隊慣用 VC6)時仍可直接用,例:build.bat vc6 x86 debug。若某天 真的壞了,只能靠外部回報才會被發現。 其餘範例另外被主程式測試套件直接編譯(widget_demo.c、port_demo的原始碼), 且全部四顆範例的每一支.c都過一遍-std=c89 -pedantic-errors -Werror語法檢查,這兩條走的是主程式的旗標,不是範例自己的build.bat。成功時輸出
plugins/myic.dll(也就是plugins/myic/的上一層,NX_OUT=..\%NX_NAME%.dll)——這正是根目錄plugins/底下、與其他 Plugin 子目錄並列的位置。⚠ 這是一個已知陷阱。 從 repo 根目錄跑
build.bat clangmingw x64 release不會遞迴建置plugins/*——那個指令只建置Core.dll與兩個 Host。要讓根建置遞迴走訪plugins/*並把產物扁平複製進out\<triple>\plugins\,必須明確加上plugins目標:build.bat clangmingw x64 release plugins(對應到
build.bat的:classify分派與Makefile的make plugins目標,兩者最終都呼叫build.bat <tc> <arch> <cfg>對每個plugins/*/build.bat逐一呼叫,單一子專案失敗只跳過該子專案。) -
把產物放進
plugins\。 如果走的是步驟 3 的獨立建置,myic.dll目前 停在 repo 根目錄的plugins\,還需要手動複製進 Core 實際掃描的位置——<Core.dll 所在目錄>\plugins\*.dll,也就是out\<triple>\plugins\:cp plugins/myic.dll out/clangmingw-x64-release/plugins/如果改走步驟 3 的
build.bat clangmingw x64 release plugins,這一步已經 由根建置自動做完。 -
執行並載入。
out\clangmingw-x64-release\nexus_tui.exe進到指令列後:
p myic @p0(
p <name>是plugin switch <name>的簡碼,見src/host_common/cmdline/cmd_table.c的NX_CMD_ID_PLUGIN_SWITCH_SHORT那一列。)template_plugin 宣告了 widget 面板,所以互動模式下不帶@p<N>會掛到目前有焦點的面板(並把那一格改型),沒有焦點時回報pass @p<N>;因此建議一律寫@p<N>。headless 只發生在宣告沒有面板的 Plugin,或 Batch 下不帶@p<N>——見第 3 章。
第一個真正的動作
範本的 probe() 什麼都不讀。把它換成「讀 IC 的第一個暫存器並印到 Log」,就能確認整條路(Plugin → Core → dongle → IC)是通的:
/* 取代範本的 tpl_probe();s_host 是 init() 存下的 const NxHostApi *。
* 需要 <stdio.h> 與 <string.h>。 */
static NxLong NX_CALL tpl_probe(NxPluginCtx ctx)
{
NxJobRequest req;
NxByte reg0;
NxLong rc;
char line[64];
memset(&req, 0, sizeof(req)); /* 全 0 = 沿用本掛載的位址、bank、後端 */
req.size_of_struct = (NxDword)sizeof(req);
req.op = NX_JOB_READ;
req.offset = 0x00;
req.length = 1;
rc = s_host->ExecSync(ctx, &req, ®0, (NxWord)sizeof(reg0));
if (rc != ERR_OK)
{
sprintf(line, "myic: read 0x00 failed, rc=%ld", (long)rc);
s_host->LogWrite(ctx, NX_LOG_WARN, line);
return rc;
}
sprintf(line, "myic: reg 0x00 = 0x%02X", (unsigned)reg0);
s_host->LogWrite(ctx, NX_LOG_INFO, line);
return ERR_OK;
}看到 X 就是 Y:
| 看到 | 原因與處置 |
|---|---|
rc=-85(ERR_WRONG_THREAD) | 這段放錯 callback:寫進了 init() 或其他 UI Thread callback。硬體存取只能在 probe() 這類 I2C Worker Thread callback 裡,見第 4 章 |
rc=-40(ERR_HW_OFFLINE) | 沒接 dongle 或後端開不起來。先用 hal_mock 後端驗證 Plugin 本身 |
| Log 什麼都沒出現 | Plugin 根本沒掛上(plugin list 看不到它);cleanup() 裡的 Log 會照常出現,不是這個原因 |
2. 生命週期
一次掛載(p <name> 或 p <name> @p<N>)從 DLL 被載入到被卸載,依序經過:
[UI Thread:Plugin 載入期]
NexusPluginGetInterface(host_abi_major, host_abi_minor)
|
v abi_major 相符,回傳 &s_plugin
[UI Thread]
init(ctx, host) -- 宣告 widget(Widget 模式)、寫一筆 LogWrite
|
v
[I2C Worker Thread:本次掛載綁定的後端]
probe(ctx) -- 回 ERR_OK 才視為掛載成功;失敗(且沒帶 force)則整組進入失敗狀態保留,等使用者卸載(cleanup 屆時才呼叫)
|
v
.-------------------------------------------------.
| 這顆 Plugin 存活期間,反覆發生: |
| |
| poll_fn(ctx) 周期輪詢(背景) |
| 自訂指令 handler 使用者打了自訂指令 |
| on_widget_event(...) Widget 模式:使用者操作 |
| ui_render_fn(...) Canvas 模式:render 執行緒 |
| 依請求呼叫(見第 7 章) |
| on_key_press(...) Widget/Canvas:方向鍵等 |
'-------------------------------------------------'
|
v Job Queue 中屬於本 Plugin 的工作清空
[I2C Worker Thread]
cleanup(ctx) -- 強制實作;呼叫當下 ctx 已經無法反查掛載cleanup 在 I2C Worker Thread 上呼叫,不是 UI Thread——這是最常見的
「複製別人程式碼」誤區:偵測到本 Plugin 的 Job 佇列已清空的是 Worker,且
Batch 模式沒有 UI 迴圈可倚賴。完整的執行緒歸屬表見第 4 章。
失敗時的生命週期:
init()回傳失敗或崩潰:整顆 Plugin(含它宣告的全部掛載)進入失敗狀態並保留在畫面上——標題顯示失敗標記、已宣告的 Widget 變灰、不再收到任何 callback、Core 管理的 GPIO/UART 立即收回。不會送probe(),也不會呼叫cleanup()(init()從未成功過)。因此init()在回傳失敗之前,請自己釋放已經配置的資源(記憶體、執行緒、已開的檔案)。probe()回傳失敗(且使用者沒帶force):同樣進入失敗狀態保留,不再自動卸載。cleanup()不會在失敗當下呼叫,而是延到使用者卸載這顆 Plugin 時才呼叫一次(init()成功過)。手動掛載時,probe()失敗的 Plugin 不會被記進設定檔(原本就寫在設定檔裡、啟動時自動載入的名稱則保留)——只有init()成功、probe()通過(或沒有實作probe()、或使用者帶了force)之後,這個掛載才會成為「下次啟動要自動載入」的設定。- 失敗狀態有兩種結束方式:
plugin restart(卸載後把同一顆 Plugin 重新掛回同一個槽位與面板,等於重新走一次init()、probe();失敗的 Log 末尾會直接告訴使用者該打什麼),或plugin unload卸載。plugin restart對正常運作中的 Plugin 也能用。probe()失敗的 Plugin,在 Core 偵測到它所用的 dongle 離線、之後又重新連線時,會被自動重跑一次(等同自動執行plugin restart;輪詢關閉時不會)。Core 只在傳輸失敗後才察覺離線,若沒有任何東西在讀寫該 dongle,拔插不一定被察覺,仍需手動plugin restart。DLL 本身不會被卸掉(要整個程式重啟才會釋放),所以 DLL 的全域變數在重跑之間會保留——init()請不要假設自己是第一次執行。 - 失敗中的 Plugin 若用靜態指令表(
commands[])宣告了自訂指令,使用者打那個指令時會看到「這個指令屬於失敗中的 Plugin、原因是什麼、用plugin restart重試」,而不是unknown command。Core 只讀這張靜態表,不會呼叫失敗 Plugin 的任何程式碼。
3. 三態表:headless/widget/canvas
這是本指南的核心。 同一顆 DLL,依使用者用什麼指令掛載它,會落在三種 掛載型態之一——三者收得到的 callback、能不能被使用者操作,完全不同。
| headless | widget | canvas | |
|---|---|---|---|
| 怎麼進入 | 由 Plugin 決定:宣告 panel_mode = NX_PANEL_MODE_NONE 的 Plugin,p <name> 一律 headless(帶 @p<N> 也被忽略);Batch 下不帶 @p<N> 也是 headless | p <name> @p3(該象限會被自動改成 plugin_widget;也可先 layout br plugin_widget,或用 plugin show <name>) | p <name> @p3(自動改成 plugin_canvas;也可先 layout br plugin_canvas) |
init/cleanup/probe | 是 | 是 | 是 |
poll_fn/on_tick/自訂指令 | 是 | 是 | 是 |
on_widget_event | 狀態進 Core,但無人操作 | 是 | 否 |
ui_render_fn | 否 | 否 | 是 |
on_key_press | 否(沒有象限) | 是(GUI 焦點 widget 吃掉的鍵不派發,見 7.4) | 是 |
Draw* | ERR_INVALID_CONTEXT | ERR_INVALID_CONTEXT | 是,僅限 ui_render_fn 內(在 render 執行緒上) |
LoadLayout/SetWidgetProp 等 widget 服務 | 回 ERR_OK,狀態進 Core | 是 | 可呼叫但不顯示 |
headless(Plugin 宣告沒有面板,或 Batch 下不帶 @p<N>)沒有象限可對應,故一切 UI 來源事件
(按鍵、widget 操作、渲染)都到不了它——這是設計,不是缺陷:headless
掛載存在的理由是讓自訂指令與背景輪詢可以在完全不佔用任何面板的情況下運作
(Batch 模式正是靠這一點才能跑 Plugin 自訂指令,見第 9 章)。
表中「怎麼進入」欄的指令已逐字核對
src/host_common/cmdline/cmd_table.c:
p <name> [@p<N>] [force](第 176~178 行,plugin switch <name>的簡碼)plugin [switch <name> [@p<N>] [force]|unload|list|show <name>|version <name>](第 172~174 行;plugin show <name>是這一列的其中一種呼叫形式;plugin version <name>查註冊表,不要求該名稱掛載於本 Tab)layout [@p<N> <panel>|<h_pct> <v_pct>](第 219221 行)——107 行。layout br plugin_widget是它的隱藏別名形式(layout <quadrant> <panel>,br命中parse_bare_quad_alias(),cmd_parse.c第 3092 行),正式詞彙 是layout @p<N> plugin_widget;plugin_widget/plugin_canvas兩個面板類型名稱定義於src/host_common/config/host_config.c第 106
本表與 sdk/nexus_plugin_api.h 內「Callback 派發狀態總表」(IcPlugin
定義之前)互為表裡:那張表記錄的是「Core 目前是否真的呼叫這個
callback」(目前除了永久停用的 on_uart_rx 之外,所有 callback 都已派發,包括
on_mouse_event/
on_layout_changed/
on_watch_triggered/
on_log_generated/
on_config_load/
on_config_save),本表記錄的是「已派發的 callback 在哪種掛載型態下會不會到達
Plugin」。兩者不衝突:一個 callback 要嘛先通過「有沒有派發」,再談「這個
掛載型態下到不到得了」。
4. 執行緒歸屬(單一真相表)
其他章節只連結本表,不重複列。 依「事件來源」決定,寫錯就是踩
兩條禁令:UI Thread 不得做 I2C 讀寫,Worker Thread 不得直接呼叫渲染函式
(要重畫就呼叫 NotifyUiRefresh())。
| Callback | 執行緒 |
|---|---|
init | UI Thread(載入期,同步回報是否成功) |
on_key_press | UI Thread(TUI 與 GUI 皆派發;GUI 焦點 widget 吃掉的鍵不派發,見第 7.4 節) |
on_mouse_event | UI Thread(僅 Canvas 面板;TUI 與 GUI 皆派發;按下後捕獲,拖曳出面板仍送 DRAG/UP) |
on_layout_changed | UI Thread(Widget 與 Canvas 面板;只在內容區尺寸有變時送;單位為抽象格) |
on_command_pre_execute | UI Thread(TUI 與 GUI 兩個 Host 皆派發:使用者在 cmd> 打的指令,以及對話框/選單/chip 組出的指令。不派發:監看觸發的 do 命令、空字串(僅 NULL/"";只含空白的字串照常派發)、GUI Plugin Manager 的非降版 install(降版 install 與 remove 經確認框,照常派發)) |
on_tab_activate/on_tab_deactivate | UI Thread |
ui_render_fn(僅 Canvas 模式) | Core 的 render 執行緒(每個 Canvas 掛載一條;進入前 Core 已釋放所有共享鎖;與上面的 UI Thread callback 並行,共用狀態須自行加鎖,見第 7 章) |
cleanup | I2C Worker Thread(本次掛載綁定的後端) |
probe/poll_fn/interrupt_handler/自訂指令 handler/on_tick/on_widget_event | I2C Worker Thread(本次掛載綁定的後端) |
on_int_event(ABI 4.1) | I2C Worker Thread(本次掛載綁定的後端;廣播給該後端上所有實作者,與 poll_fn 不並行) |
on_uart_rx_ex | 該埠自己的 UART Worker Thread |
on_watch_triggered | I2C Worker Thread(本次掛載綁定的後端;只送 Watch 所屬掛載) |
on_log_generated | Core 的 Log 廣播執行緒(非同步、非產生該筆 Log 的執行緒;仍須假設多執行緒進入) |
on_config_load/on_config_save | UI Thread(送出:init() 後、probe() 前;收集:卸載前、Host 結束存檔前,見第 8 章) |
on_uart_rx(不帶
_ex)已永久停用,全庫零呼叫點。
Widget 模式不需要擔心 ui_render_fn 的執行緒問題與並行加鎖——它根本不會被呼叫;
狀態的唯一真相在 Core,Host 只讀快照渲染,Plugin 完全不出現在 render path 上。
5. 讀寫硬體的三種方式:ExecSync/SubmitJob/GetRegSnapshot
| 方式 | 阻塞? | 允許的執行緒 | 用途 |
|---|---|---|---|
ExecSync() | 是,直到硬體回應 | 僅 I2C Worker Thread 上的 callback(probe/poll_fn/interrupt_handler/自訂指令 handler/on_widget_event/on_tick;on_watch_triggered 也在這條執行緒) | probe()——需要在返回前就知道 IC 在不在 |
SubmitJob() + on_complete | 否,非同步 | UI Thread 與 I2C Worker Thread 皆可 | 背景輪詢、任何「送出去、之後再處理結果」的存取 |
GetRegSnapshot() | 否,不觸碰硬體 | 任一執行緒 | render 前取暫存器快照到自己的緩衝區 |
5.1 ExecSync——同步、僅 Worker Thread
ExecSync() 是 Plugin 同步存取硬體的唯一入口,取代 ABI 2 的兩支固定
簽章同步讀寫函式(已刪除,不留別名)。它吃一個
NxJobRequest,
所以日後的新需求走「結構結尾追加 + size_of_struct 降級」,函式表不再
長大。
在 UI Thread 上呼叫一律回 ERR_WRONG_THREAD(-85)且不做任何硬體存取,
這是刻意的:UI Thread 不得做 I2C 讀寫,那會讓畫面卡住整個匯流排交易的時間。存在的
唯一理由是 probe() ——SubmitJob() 的結果經 on_complete 事後才到,
GetRegSnapshot() 完全不碰硬體,兩者都無法讓 probe() 在返回前判定 IC
是否存在。
memset() 的那些零就是全部的位址/bank/後端設定:
slave_addr == 0
=「本掛載當前綁定的位址」、
bus_name[0] == '\0'
=「本掛載綁定的後端」。
bank == 0 的意思
不是「沒有 bank 偏好」——見下段,這是 ABI 3.0 落地後唯一修正過的一條。
三者仍是同一條規則的三個實例,合起來逐位元組重現 ABI 2 那條同步讀取
路徑的行為——ABI 2 那支函式根本沒有位址/bank/後端參數,一律用掛載
當下的值。
⚠ 不要改成填你自己
slaves[]裡宣告的位址。 宣告位址永遠不會被寫進 掛載設定(寫入掛載位址的入口是mount指令,或plugin switch建掛載 當下的初始化,之後使用者的addr/mount覆寫仍具權威);使用者一下addr/mount改過位址,填宣告值就會讓每一次ExecSync()打到錯的位址。
static NxLong myic_read_sync(NxPluginCtx ctx, NxWord offset,
NxByte *buf, NxWord len)
{
NxJobRequest req;
memset(&req, 0, sizeof(req));
req.size_of_struct = (NxDword)sizeof(req);
req.op = NX_JOB_READ;
req.offset = offset;
req.length = len;
return s_host->ExecSync(ctx, &req, buf, len);
}
static NxLong NX_CALL myic_probe(NxPluginCtx ctx)
{
NxByte id_reg;
NxLong rc;
rc = myic_read_sync(ctx, /* offset */ 0x00, &id_reg, /* len */ 1);
if (rc != ERR_OK)
{
return rc; /* ERR_HW_OFFLINE / ERR_I2C_NACK / ... */
}
return (id_reg == MYIC_EXPECTED_ID) ? ERR_OK : ERR_NOT_SUPPORTED;
}寫入同形:req.op = NX_JOB_WRITE; req.data = buf;,out/out_cap 傳
(NxByte *)0, 0。完整範例見 plugins/widget_demo/src/widget_demo.c 的
wd_read_sync()/wd_write_sync()。
ExecSync 忽略 priority/on_complete/user_data(同步呼叫沒有
事後回呼可言)。
5.1a bank 的完整語意
req->bank == 0 不是「不帶 bank」,而是「帶 bank,用我記得的那一頁」。
ExecSync()/SubmitJob() 兩個入口逐字相同:
掛載 bank_mode | req->bank | 結果 |
|---|---|---|
NX_BANK_MODE_REG | 0 | 帶 bank,bank = plugin_bank(Core 記得你上次切到哪一頁) |
NX_BANK_MODE_REG | n != 0 | 帶 bank,bank = n,單次覆寫,不改 plugin_bank |
NX_BANK_MODE_NONE | 0 | 不帶 bank(flat) |
NX_BANK_MODE_NONE | n != 0 | ERR_INVALID_PARAM |
表外位址(slave_addr 不等於任何掛載) | 0 | flat 8-bit,不進 RegModel |
| 表外位址 | n != 0 | ERR_INVALID_PARAM |
「零=不帶」在此不成立的理由:plugin_bank 本身可以合法地是 0(IC 開機後
預設就在 bank 0)。若把 0 當成不帶 bank,一顆停在 bank 0 的掛載每一筆
memset() 過的存取都會被送成 flat;只要任何其他管道(使用者 bank 2、
橋接)把硬體切去別頁,Core 再也不會為這顆 Plugin 切回來——plugin_bank
是「韌體相信自己在哪一頁」的正確性前提,不是效能優化。
寫 bank_reg 一律用寫入 API,Core 在送出端自動辨識並轉成 bank 切換
(下段),你不需要、也不應該自己判斷「這次寫的是不是 bank 暫存器」。
5.1b bank 暫存器攔截——直接寫,Core 幫你轉
你的韌體序列如果長得像:
myic_write_sync(ctx, 0x0F, &bank_val, 1); /* 0x0F 是 bank 選擇暫存器 */
myic_write_sync(ctx, 0x10, &data, 1); /* 到了新頁再寫別的暫存器 */第一筆寫入會被 Core 在送出端辨識為「這是本掛載宣告的 bank_select_reg」
並轉成一次 NX_JOB_BANK_SWITCH(成功後更新 plugin_bank),而不是一次
普通的 WRITE。你不需要改寫程式碼——這正是 shim/韌體移植可以照抄原始
dprxset(bank_reg, mask, val) 序列的原因。
被攔截的條件是「offset 恰好等於 bank_reg 且 length == 1」,其餘一律
ERR_INVALID_PARAM,包含這兩種都會被拒的形狀:
①起點不等於 bank_reg 但範圍涵蓋它(例如一次 burst 寫 4 個位元組、其中第 2 個
恰好落在 bank_reg 上);②起點正好等於 bank_reg 但 length > 1(例如
write(bank_reg, 2 bytes))。②同樣被拒、不會被轉成 bank 切換——Core 不會替你
把一次多位元組 burst 拆成「切頁 + 其餘位元組」,那種寫法本身就是不小心的 bug,
Core 不猜你的意圖。讀 bank_reg 一律照讀硬體,不從 plugin_bank 快取回。
5.1c 表外位址
slave_addr 若不等於任何已建立的掛載(自己的或別人的),Core 仍會嘗試
在本後端上直接存取那顆位址(NO_RECORD:只記 Log,不進任何
RegModel),但只支援 flat 8-bit(offset > 0xFF 一律 ERR_INVALID_PARAM
——未載入 Plugin 時的預設定址寬度)。這是給「探測別顆晶片是否存在」或
「讀一次不打算長期持有掛載的裝置」用的。
5.1d bus_name
空字串=本掛載綁定的後端(既有行為)。非空字串時,ExecSync() 先用
HAL_FindBackend() 解析這個名字:
- 名字不存在 →
ERR_HW_OFFLINE(打錯字的後端名稱)。 - 名字存在但目前執行緒不是它的 Worker →
ERR_WRONG_THREAD(跨後端 同步存取違反每後端單一擁有者 Worker Thread 的模型)。
先解析、後判執行緒的順序是刻意的:打錯字的人應該被告知「這個名字不
存在」,不該被導向去查一個完全不相干的執行緒問題。
SubmitJob() 走 bus_name 時把該名稱當成 dongle_override 交給
Core_Job_SubmitFromPlugin(),排入該後端自己的 Worker 佇列,不受目前執行緒限制。
(刻意不走 CoreJob_SubmitOnBackend():那支函式收不下 on_complete、
user_data 與 owner mount_id,用它會讓具名後端的 Job 掉失完成回呼,也掉失
卸載期的擁有權計數。)
5.1e NX_JOB_XFER——write-then-read 任意前綴
op = NX_JOB_XFER 用於「Core 無法用 offset 語意描述的交易」:先寫
data[0..length),再以 repeated-start 讀回 rx_len 個位元組;
length == 0 是合法的 address-only 探測(rx_len 不看;僅確認位址有無
應答,常見於執行期位址掃描)。不進 RegModel(Core 無法解讀前綴語意),
但仍受 bank 約束——若本掛載是 NX_BANK_MODE_REG,Core 仍會先切到
plugin_bank 再發出傳輸,因為「該在哪一頁」與「這筆傳輸的前綴是什麼」
是兩個獨立問題。後端不支援 xfer(無 NX_HAL_CAP_RAW_XFER)回
ERR_NOT_SUPPORTED。rx_len/length 上限各 NX_JOB_MAX_PAYLOAD(512),
與 rd/wr 的 256 上限是兩件不同的事,不要混用。
5.1f Dongle 專屬服務(ABI 4.1):INT 事件、匯流排恢復、腳位脈衝、vendor op
ABI 4.1 在 IcPlugin 與 NxHostApi 結尾追加了一組只在支援的 Dongle 上有意義的服務,舊 Host 沒有,
使用前一律用 NX_ABI_HAS_FIELD 確認(不要比 abi_minor,樣板見第 10 章):
| 服務 | 用途 | 參考 |
|---|---|---|
IntConfigure/IntGetStatus/IntClear | 設定 INT 腳邊緣與上下拉、讀狀態、清計數;邊緣事件由 on_int_event 交付 | IntConfigure、on_int_event |
BusRecover | 匯流排被卡住時補時脈救回,回報 SCL/SDA 電位 | BusRecover |
GpioPulse | 在自己持有的輸出腳送一個定寬脈衝(最長 1 秒,期間佔住匯流排 Worker) | GpioPulse |
NX_JOB_VENDOR(ExecSync 專用) | 以名稱呼叫 Dongle 自述目錄裡的 vendor op;req.vendor_op 填名稱 | NxJobRequest::vendor_op |
這些服務與 on_int_event 都在本掛載綁定後端的 I2C Worker Thread 上;UI Thread 呼叫回 ERR_WRONG_THREAD。
有 INT 時 poll_fn 不會自動停止。 Core 不替你判斷「有 INT 就不必輪詢」,兩者互不相干。建議二選一:
在 poll_fn 內自行節流(INT 生效時直接回 ERR_OK,或每 N 次才做一次保險讀),或由使用者把該掛載的輪詢週期設成 0。
不要靠 SetPollingInterval() 停輪詢:它目前未實作、一律回 ERR_NOT_IMPLEMENTED。
INT 涵蓋不全的 IC,以及多顆 IC 共線(wired-OR)時漏掉的邊緣,都需要保險輪詢。另外 INT 事件的取回本身就是 Core 的背景輪詢,同樣受 poll off 管制
(poll off 時不讀,poll on 後一次交出累積,容量有限,超出的計入 on_int_event 的 dropped),
所以關掉 poll_fn 不等於沒有 USB 流量。IntConfigure 有擁有權:Core 合併同後端各請求者的設定(邊緣取聯集),Plugin 卸載時自動釋放自己的請求,最後一位釋放才關閉 INT;IntClear 是資料重置、不檢查擁有權,會影響同後端其他 Plugin 尚未收到的事件。dropped 非零代表事件有缺漏,重新讀一次 IC 狀態暫存器即可補救。
5.1.1 ShouldAbort——長效操作的取消訊號
NxHostApi::ShouldAbort 是長效操作的取消訊號:
while (!done)
{
if (s_host->ShouldAbort(ctx))
{
return ERR_CANCELLED; /* 自己收尾後返回 */
}
/* ... ISP 迴圈的一輪 ... */
}Core 不會替你中斷任何東西。 分鐘級的迴圈(flash 燒錄、chip erase、 長輪詢)必須自己在每一輪查它。非零代表:本 Plugin 已被標記待移除、 掛載綁定的後端離線、或程式正在關閉。它可以在任一執行緒呼叫,成本相對於 一筆匯流排交易低三個數量級,不需要節流。
5.2 SubmitJob + on_complete——非同步,背景輪詢用
static NxLong NX_CALL myic_poll(NxPluginCtx ctx)
{
NxJobRequest req;
memset(&req, 0, sizeof(req));
req.size_of_struct = (NxDword)sizeof(NxJobRequest);
req.op = NX_JOB_READ;
req.priority = NX_JOB_PRI_LOW; /* 背景輪詢一律低優先級,服從 poll off */
/* slave_addr 留在 memset() 的 0 —— 「本掛載當前綁定的位址」哨兵。
§5.1 的 ⚠ 對 SubmitJob 同樣成立:填你自己宣告的位址不會報錯,
只會讓使用者下過 addr / mount 之後,背景輪詢繼續讀舊位址。 */
req.offset = 0x00;
req.length = (NxWord)MYIC_POLL_LEN;
req.on_complete = myic_on_complete;
return s_host->SubmitJob(ctx, &req);
}
static void NX_CALL myic_on_complete(NxPluginCtx ctx, NxLong rc,
const NxByte *data, NxWord len,
void *user_data)
{
NX_UNUSED_ARG(user_data);
if (rc != ERR_OK || data == (const NxByte *)0)
{
return;
}
memcpy(s_snap, data, (size_t)len); /* data 只在本次呼叫期間有效 */
s_host->NotifyUiRefresh(ctx); /* Worker Thread 通知 UI 的唯一手段 */
}priority 用 NX_JOB_PRI_LOW 是背景輪詢的正確選擇:它讓這筆存取服從
poll off(Gating 在 SubmitJob 入口),也讓使用者的手動指令永遠排在
前面。on_complete 在該 mount 所綁定後端的 I2C Worker Thread 上
呼叫,禁止在裡面呼叫任何渲染函式,要重繪一律經
NotifyUiRefresh()。
5.3 GetRegSnapshot——不碰硬體,render 前取快照
static void hm_fill_from_snapshot(NxPluginCtx ctx)
{
NxByte buf[256];
NxByte valid[32]; /* 256-bit 有效遮罩;不得傳 NULL */
NxLong rc;
rc = s_host->GetRegSnapshot(ctx, /* slave_index */ 0, /* bank */ 0,
buf, (NxDword)sizeof(buf),
valid, (NxDword)sizeof(valid));
if (rc != ERR_OK)
{
return; /* Copy-on-read 失敗時不覆寫既有快照 */
}
memcpy(s_snap, buf, sizeof(s_snap));
memcpy(s_valid, valid, sizeof(s_valid));
}兩個參數是必要的,不是裝飾:
slave_index是IcPlugin::slaves[]的索引,0~slave_count - 1皆合法,每個索引各自對應自己的掛載與快照;slave_index >= slave_count回ERR_INVALID_PARAM——這是 Plugin 自己的 程式錯誤,不會靜默退回slaves[0]的快照,後者會讓你拿到別顆 IC 的資料而完全看不出來。valid是 256-bit 的逐 byte 有效遮罩,不接受NULL。少了它,從來 沒讀過的位置會被畫成00——那是最常見的合法值,錯了看不出來。
這是 Canvas 模式在 ui_render_fn 之前(例如在 on_complete 或
poll_fn 裡)準備繪圖資料的方式——ui_render_fn 內不要直接呼叫 I2C 函式,
請讀事先準備好的緩衝(需要即時讀硬體時可用 ExecSync(),見 7.2)。這份緩衝
是 Worker 與 render 執行緒共用的狀態,要加鎖(見 7.2)。
6. Widget 模式
優先用這個模式。 它是唯一跨任意 Host(TUI/GUI/第三方 Host)完整可攜
的路徑,而且 Plugin 完全不出現在 render path 上——天生免疫 render 崩潰、
逾時與 deadlock。只有需要暫存器熱圖、位元時序圖這類自由繪製時才改用
Canvas,範例在 plugins/canvas_demo/(見第 7 章)。
完整範例:plugins/template_plugin/src/template_plugin.c。它示範的關鍵設計:
-
版面是
.nxlay檔,不是 C 陣列。 面板要顯示什麼寫在plugins/myic/myic_panel.nxlay(INI 風格:每個 widget 一段[w.<id>], 鍵有type=/y=/x=/min_w=/weight=/h=/label=…,完整格式見docs/NEXUS_LAYOUT_TOOL_SDD.md)。它是唯一真相;要在 C 裡用 widget 編號時, 由工具產生一份只含編號的標頭:nxlayout --emit-header plugins/myic/myic_panel.nxlay plugins/myic/myic_panel.h產生的
.h(例如template_panel.h)只有WIDGET_ID_*與<PANEL>_WIDGET_COUNT常數,被#include進.c;widget 本身不在裡面。.nxlay↔.h的一致性由test/tools/test_layout_codegen.c以雜湊比對 守門(.h檔頭的NXLAY_SOURCE_HASH註解);.h是產物,不要手改, 改完.nxlay就重跑--emit-header重新產生。 -
版面嵌進 DLL,
init()載入一次。 把.nxlay當成RCDATA資源編進 DLL (src/template_plugin.rc;路徑相對於src目錄,建置腳本把.\src放在 資源編譯器的 include path 上):TEMPLATE_PANEL RCDATA "../template_panel.nxlay"然後在
init()呼叫一次host->LoadLayout(ctx, 0, "TEMPLATE_PANEL", 0)(template_plugin.c就是這樣做的)。資源名稱是 ASCII[A-Za-z0-9_]、1 ~ 63 字元、不分大小寫。三個變化形:- 開發迭代:
flags帶NX_LAYOUT_F_FILE,name改為相對於 Plugin DLL 所在目錄的檔案路徑,改了.nxlay不必重編 DLL。 - 沒有 DLL 資源可用(靜態登錄的 Plugin,或版面在執行期才組出來):
LoadLayoutData(ctx, 0, data, len, 0)直接吃記憶體裡的.nxlay文字(UTF-8,len最多 131072 位元組, 不要求 NUL 結尾)。 - 清空面板:
LoadLayout(ctx, 0, NULL, 0)。
版面載入後就不再重載:內容逐位元組相同的重新載入是 no-op(不打斷使用者 正在輸入的字);內容真的不同則整棵 widget 樹重建,已存在的 widget 的值 不保留(要再
SetWidgetProp())、正在編輯的輸入框先提交、在途事件作廢。 版面驗證失敗時回ERR_INVALID_PARAM(原因寫進 Plugin 的 Log),版面含 Host 不認得或尚未實作的type=時整份回ERR_NOT_SUPPORTED,第二個參數是目標panel_id,目前每個掛載只支援一個邏輯面板(索引0)。 - 開發迭代:
-
之後只用屬性與事件。
SetWidgetProp(ctx, panel_id, widget_id, prop, &v)寫值、標籤、選項、隱藏/啟用等一切狀態,GetWidgetProp讀回;版面無法事先決定時用CreateWidget/DestroyWidget動態增刪。屬性值是NxPropVal(恆為 24 位元組;先填SizeOfStruct與kind),字串與 blob 在呼叫返回時已複製,緩衝區可立刻重用。文字欄位每段 最多 31 位元組(LABEL/AUX,NX_WLABEL_MAX_BYTES;ITEMS的每項文字與ROWS的名稱、值最多 255 位元組):LABEL/AUX超過時SetWidgetProp()回ERR_FULL(舊值保留),ITEMS/ROWS單項超過 255 回ERR_INVALID_PARAM,.nxlay的label=/aux=超過時LoadLayout()回ERR_INVALID_PARAM。影子表另有文字配額(每面板 512 KiB、全體 4 MiB), 用盡同樣是ERR_FULL。使用者操作經
on_widget_event(ctx, panel_id, widget_id, evt)送回,evt->kind是NX_WEVT_CHANGED(evt->val是新值)或NX_WEVT_ACTIVATED(evt->val是點擊位置封裝,鍵盤觸發為NX_WPOS_KEYBOARD)。事件是非同步交付的,由該掛載的 I2C Worker 依序呼叫; 同一 widget 連續的NX_WEVT_CHANGED只在佇列尾端合併;Plugin 自己呼叫SetWidgetProp()不會產生事件。 -
IcPlugin用具名賦值填寫(見NexusPluginGetInterface()),不是 位置初始化式——IcPlugin有 37 個欄位,C89 沒有指定初始化器,位置錯位 在兩個函式指標簽章剛好相同時(例如on_layout_changed與ui_render_fn)編譯器完全不會警告,而 ABI 重整這種「刪掉幾個欄位、 加回幾個」的欄位改動正是位置初始化式會靜默錯位的那種變更。 -
位址寫在
slaves[]裡:static const NxSlaveDecl s_slaves[] = { { 0x58, NX_ADDR_MODE_8BIT, NX_BANK_MODE_REG, 0x0F, 0x07, 0, {0, 0}, "MAIN" } }; /* IcPlugin 端(形式一律照 plugins/template_plugin/src/template_plugin.c): * s_plugin.slaves = s_slaves; * s_plugin.slave_count = (NxByte)(sizeof(s_slaves) / sizeof(s_slaves[0])); * s_plugin.slave_decl_size = (NxDword)sizeof(NxSlaveDecl); */三個都要填,
slave_decl_size漏了就載不進來:slaves是個裸指標、slave_count只是元素數,中間沒有任何 欄位告訴 Core 單一元素有多大(IcPlugin::size_of_struct描述的是IcPlugin自己,不是它指向的陣列)。沒有它,Core 走訪slaves[i]只能用它自己編譯期的sizeof(NxSlaveDecl),你的元素一旦跟 Host 不同大小,slaves[1]起每一個都落在錯的位移上,而讀出來的位址 與 bank 欄位看起來完全合法。因此 Core 在註冊時逐位元組比對它, 不符即拒絕載入並記一則 ERROR。一律寫(NxDword)sizeof(NxSlaveDecl), 不要寫死數字。一顆 IC 對外露出多個位址時全部宣告出來(上限 16),
slaves[0]是面板 預設顯示的那一顆。執行期才算得出來的位址(整組偏移、建置變體)一律 以「宣告全部候選 + 把不一定在線上的標NX_SLAVE_F_OPTIONAL」表達—— 沒有執行期宣告 API,因為那會讓plugin list在init()跑之前說不出 這支 Plugin 有哪些位址。
7. Canvas 模式
只有需要暫存器熱圖、位元時序圖這類自由繪製時才選這個模式。完整範例:
plugins/canvas_demo/src/canvas_demo.c(一個依 8x8 位元組熱圖顯示暫存器
內容的 Plugin,也是第一個使用 NX_PKEY_*/NX_PMOUSE_* 的範例——方向鍵與
滑鼠滾輪捲動顯示的起始位移、點一格選取、按住左鍵拖曳塗格子,並用
on_layout_changed 快取面板尺寸顯示在標題列)。
7.1 Context Guard
DrawText()/
DrawBox()/
DrawRegion()
只在 ui_render_fn 呼叫堆疊內有效(該堆疊在 Core 的 render 執行緒上);
在外面呼叫一律回 ERR_INVALID_CONTEXT(-84)。Widget 模式不需要 Context
Guard——這正是 Widget 模式優先被建議的原因之一。
7.2 render 執行緒、並行加鎖與耗時保護
Core 為每個 Canvas 掛載開一條專屬的 render 執行緒,ui_render_fn 在那條
執行緒上呼叫。UI Thread 每次要畫面時只做兩件事:送出一筆 render
請求(只保留最新的寬高),並把上一次 render 錄下的繪圖指令重播給 Host。
因此畫面晚一幀,但 Plugin 再慢也不會凍結 TUI/GUI。繪圖指令(DrawText()/
DrawBox()/DrawRegion())被深拷貝進 Core 的緩衝,每個面板上限 64 KB,
超過的指令被丟棄並記一次 WARN;內容與上一份相同時 Core 不會通知重繪。
並行是你的責任。 ui_render_fn 與你的 UI Thread callback
(on_key_press、on_mouse_event、on_layout_changed)以及 I2C Worker 上的
on_complete/poll_fn 同時執行。兩邊共用的變數(canvas_demo 的
s_base、s_sel、s_snap、s_paint、s_lay_w/s_lay_h)必須用同一把鎖
保護。canvas_demo 的寫法:
init建立CRITICAL_SECTION,cleanup刪除(cleanup在 I2C Worker 上執行,不是 UI Thread);ui_render_fn在鎖內把共用狀態複製到區域變數、填好s_cells[],放鎖後才呼叫DrawText()/DrawRegion();- 每個 callback 只在鎖內改共用狀態,持鎖期間不呼叫任何 HostAPI(
NotifyUiRefresh()、SubmitJob()、Draw*都放鎖後),避免鎖序死結。
耗時保護。 超過 10 ms 只記一次 WARN,Plugin 不會下線;超過 2 秒,Core 記
ERROR、把這個面板標成「無回應」並在面板內容區底列顯示 render 無回應,同時
暫停派發新請求,直到 ui_render_fn 返回後恢復。卡死的執行緒不會被終止,Plugin
DLL 也不會被卸載(錯誤碼 ERR_RENDER_TIMEOUT -83 保留數值,但
Core_Plugin_Render() 不再回傳它)。ui_render_fn 崩潰仍使該掛載下線。
canvas_demo 仍用 DrawRegion() 一次提交整塊字元格(s_cells[]),因為逐格
DrawText() 的跨 DLL 呼叫會把每次 render 拖得更慢。
ui_render_fn 內禁止 Sleep() 與 malloc()(render 路徑禁止動態配置)。
允許呼叫 ExecSync()(Core 轉交 I2C Worker 以高優先級執行,最多等 5 秒),
canvas_demo 的 s_cells[] 是靜態配置的固定大小陣列,理由正是不配置。
7.3 NotifyUiRefresh 的節奏
背景讀取完成(on_complete,I2C Worker Thread)把結果 memcpy 進自己的
快照緩衝後,呼叫 s_host->NotifyUiRefresh(ctx) 請 Host 重繪——這是 Worker
Thread 通知 UI 的唯一合法手段,同一畫面更新週期內的多次呼叫會被 Core
摺疊為一次重繪請求。不要在 on_complete 裡直接呼叫任何渲染函式。
7.4 GUI 上的 on_key_press
on_key_press 在 TUI 與 GUI 兩個 Host 都會派發。canvas_demo 的方向鍵
捲動熱圖因此兩個 Host 都生效。唯一的差異:GUI 的 Widget 面板上,焦點 widget 吃掉的鍵
不會送達 Plugin——焦點在 TEXTBOX/FILEPICK/SPIN 時所有鍵都歸它,焦點在
BUTTON/CHECKBOX/RADIO/DROPDOWN 時只有 Space、Enter 與方向鍵歸它,其餘鍵(含 Tab)照常送達;
DROPDOWN 的清單展開期間,除 Tab(先收起清單、再照常送達)外所有鍵都歸清單(舊版焦點在 DROPDOWN 上會吞掉所有鍵,現在閉合時字母鍵與 F 鍵等會送到 Plugin)。
判定看的是焦點 widget 的型別(視窗版的 widget 全部自繪,主視窗沒有任何子視窗,焦點由 Host 自己記錄)。
Canvas 面板沒有 widget 焦點,不受影響;Widget 面板的攔截鍵要避開 widget 會吃的鍵
(方向鍵、Space、Enter,以及 TEXTBOX 等輸入型 widget 有焦點時的所有鍵)。
對話框、選單、版面配置器與 REG_TABLE 就地編輯開啟期間兩個 Host 都不派發。
7.5 滑鼠與面板尺寸
Canvas 面板可以實作 on_mouse_event
與 on_layout_changed,
兩個 Host 都派發:
- 座標是面板內容區內的相對抽象格(左上角
0,0),不是像素;常數在nexus_plugin_keys.h(NX_PMOUSE_BTN_*/NX_PMOUSE_ACT_*)。 - 只送給 Canvas 面板;Widget 面板用
on_widget_event。沒有 hover、雙擊與修飾鍵。 - 按下即捕獲:按下之後到放開之前,
DRAG與UP一定送給同一個面板,即使游標 已移出面板(座標可為負或超出寬高,要自行檢查範圍)。DRAG只在所在格改變時送。UP最遲在下一筆輸入、或 Host 失去滑鼠捕獲時補送;面板已不是 Canvas 或已換了另一顆 Plugin 時不送。 - 左/右/中鍵與滾輪兩個 Host 都送;回
NX_EVT_PASS時 Host 的既有行為(例如右鍵貼上)照舊。 - 滾輪只在游標下的 Canvas 面板同時是焦點面板時送出。
on_layout_changed只在內容區尺寸有變時送,Plugin 快取後供繪圖使用;ui_render_fn的width/height是同一份幾何。- 用新標頭編出來的 Plugin 在較舊的 Host 上只是收不到這些事件,不影響載入。
8. 設定持久化(on_config_load/on_config_save)
Plugin 若有需要跨執行記住的設定(板級接線、閾值、開關……),不必自己碰
config.ini——Core 幫你收送,你只管兩支 callback。
8.1 段名與格式規則
段名固定是 [Plugin.<name>],<name> 為 IcPlugin::name;同名 Plugin
的全部掛載實例共用同一段(多掛載不代表多份設定)。段內是一般的 key=value
行,鍵不得以 [/;/# 開頭,鍵與值皆不得含控制字元;每行內容(去除
\r/\n 後)不得超過 287 位元組(NX_CFG_TEXT_LINE_MAX - 1,
src/common/plugin_cfg_text.c 的 raw_len 上限)。
這條「略過註解/空白行」的寬容規則只適用於使用者在 config.ini 裡手寫的
內容(Host 用 IniDoc_SectionText() 序列化該段時):使用者可以手寫、可以
縮排、可以插自己的 ; 註解行,這些都會被正確跳過,不會讓整段被拒收。
你自己在 on_config_save 裡寫進 buf 的輸出沒有這層寬容——空白行
(全空白)會被自動跳過,但任何一行沒有 =、或鍵以 [/;/# 開頭、
或含控制字元,都會讓整份輸出被 PluginCfgText_Validate() 拒收(見
8.5:視同回傳非 ERR_OK)。輸出裡不要夾雜註解行——那是給使用者手寫
config.ini 用的寬容,不是給你的輸出用的。
手動編輯過的段在啟動當下就被拒收時,退出存檔不會覆寫它:若使用者手改
config.ini 讓 [Plugin.<name>] 這一段在啟動時就沒通過上述格式規則(例如
夾了控制字元,或整段超過 4094 位元組),Core 只記一筆 WARN、讓你的 Plugin
跑在內建預設值上;即使本次執行期間你正常呼叫了 on_config_save 並回
ERR_OK,Host 結束時仍然不會用這次收集到的(預設值)內容整段取代磁碟
上那個壞掉的段——使用者原始寫下的內容會原封不動留著(只是仍然載入失敗、
仍然記 WARN),直到他自己把內容修正到能被重新載入為止(違反本規則等同
本專案的鐵則「絕不自動刪除看似無效的設定,保留並記 WARN」)。
8.2 長度上限
on_config_save 的 buf_size 恆為 NX_PLUGIN_CONFIG_MAX(4096,含結尾
NUL);有效內容長度必須 <= NX_PLUGIN_CONFIG_MAX - 2(4094 位元組)——
Core 在你的 callback 返回後一律把 buf[buf_size - 1] 強制寫成 NUL,所以你
自己的結尾 NUL 必須落在前 4095 個位元組內,不然這次輸出視同「找不到 NUL」
而被拒收。
8.3 送出時機
在 probe() 之前:init() 成功之後,Core 依上次收集(或使用者手改
config.ini)留下的內容,依行序逐鍵呼叫 on_config_load(ctx, key, value)
——key/value 都已去除前後空白。同名鍵只送第一次出現者。只送給主
掛載(slaves[0]);若你的 Plugin 宣告了多個 slave 位址,衍生掛載不會
重複收到。headless/widget/canvas 三種掛載型態待遇相同。
8.4 收集時機
Core 在兩個時間點呼叫 on_config_save(ctx, buf, buf_size):使用者卸載
這顆 Plugin 之前,以及 Host 結束程式、存檔之前。你回傳 ERR_OK 時
buf 必須是完整的設定(不是差量)——沒有輸出任何內容(空字串)代表
「我沒有任何要記住的設定」,config.ini 該段的鍵會被全部移除。
Batch 模式(--batch)只會呼叫 on_config_load,永遠不會呼叫
on_config_save(Batch 讀但絕不寫回 config.ini)。
整段取代會刪掉你沒輸出的任何鍵:Host 把你這次 on_config_save 的輸出
當成該段的完整內容整段取代(見模組層級文件的「整段取代的精確語意」),
不是差量合併。這對「在意版本相容性」的 Plugin 有直接後果——如果使用者從
新版降級回你這顆 Plugin 的舊版,或使用者手動/被別的工具加了一個你這個
版本不認得的鍵,你若只回傳自己認得的那幾個鍵,on_config_save 一存檔就會
把那個不認得的鍵永久刪掉。在意這件事的作者,請在 on_config_load 裡把
收到的每一個鍵(含你不認得的)都暫存起來,on_config_save 時原樣把它們
連同你自己更新過的鍵一起回傳(echo back),而不是只回傳你目前版本認識的
子集。
on_config_save 在 UI Thread 上執行,且必須快、不可阻塞:兩個收集時機
(使用者卸載這顆 Plugin 之前、Host 結束程式存檔之前)都在 UI Thread,後者
甚至落在 Ctrl+C/關閉視窗觸發的收尾寬限期之內(系統寬限期約 5 秒,期間
不等待任何 Job)——這支 callback 擋在整個關閉流程正中央,跑得慢就
是讓使用者的視窗多晾著幾秒。ExecSync(見 §5.1)在這裡不可用:UI
Thread 上呼叫一律回 ERR_WRONG_THREAD,不做任何硬體存取,所以不要想著在
on_config_save 裡「存檔前最後同步讀一次硬體」;要記住的設定應該是你早就
持有在記憶體裡的狀態(多半就是上一次 on_config_load 收到、或使用者透過
widget/自訂指令改過的值),不是現在才去問硬體要來的值。
8.5 回傳負值(或任何非 ERR_OK)的意義
on_config_save 回傳任何不是 ERR_OK 的值(不限負值)=「這次先別存,
維持我上次成功交出去的內容(或磁碟上原有的內容,若這是本次執行第一次被
問)」——Core 記一筆 WARN,不動既有暫存,你不會因此丟失任何已經存好的
設定。什麼時候該回非 ERR_OK:你的設定此刻正處於一個不完整、不該被記下來
的中間狀態(例如正在等一個非同步操作完成)。
8.6 崩潰行為
兩支 callback 崩潰時都由 SEH 邊界攔下,不會波及主程式,但下場不同:
on_config_load崩潰:該次switch(含背後的init())仍視為 成功,但主掛載立即被標記FAULTED(Tab 標題顯示IC:<name>(faulted))、記一筆 ERROR,且不送probe()——尚未送出的 其餘鍵也不再送。Core_Plugin_Switch()仍回ERR_OK(不是ERR_CALLBACK_CRASHED,那個錯誤碼保留給「init()崩潰、從未掛載」)。on_config_save崩潰:這次收集作廢(暫存不動,等於這次什麼都沒存 到),該掛載同樣被標記FAULTED(與其他 callback 崩潰一致)。若崩潰 發生在使用者卸載這顆 Plugin 的收集點,卸載完全不受影響——照常進行並 回ERR_OK。
兩者皆與其他 callback 崩潰的既有隔離機制(單一 Plugin 下線、不影響主程式)
同形,唯一特別之處是 on_config_load 崩潰不會讓 switch 本身失敗。
8.7 同名實例共用
假設使用者同時在 Tab 1 與 Tab 2 各掛了一份你的 Plugin:兩份掛載共用同一個
[Plugin.<name>] 段。on_config_load 在兩次 switch 各自收到一次
(各自的主掛載各收一次);on_config_save 在收集時只找其中一個代表
實例(以 (tab, slot) 較小者為準)詢問一次。不要假設兩個實例之間會即時
互相同步設定——B 實例讀到 A 剛存的新值,要等 B 自己下一次被 switch(重新
init)才會發生。
8.8 最小範例(C89,兩支 callback)
static char s_saved_cfg[64] = ""; /* 上次收到的設定,這裡只示範存一個字串 */
static void NX_CALL my_config_load(NxPluginCtx ctx, const char *key,
const char *value)
{
NX_UNUSED_ARG(ctx);
if (strcmp(key, "note") == 0)
{
strncpy(s_saved_cfg, value, sizeof(s_saved_cfg) - 1);
s_saved_cfg[sizeof(s_saved_cfg) - 1] = '\0';
}
}
static NxLong NX_CALL my_config_save(NxPluginCtx ctx, char *buf, int buf_size)
{
char line[80];
size_t len;
NX_UNUSED_ARG(ctx);
sprintf(line, "note=%s\n", s_saved_cfg); /* s_saved_cfg 定長 64,line 80 夠裝 */
len = strlen(line);
if ((int)(len + 1) > buf_size)
{
return ERR_BUFFER_TOO_SMALL; /* 寧可回錯,不要輸出半截內容 */
}
memcpy(buf, line, len + 1); /* 連同結尾 NUL 一起複製 */
return ERR_OK;
}8.9 版本相容性(新增鍵)
[Plugin.<name>] 的設定格式只准向後相容:新版本新增的鍵,讀取端(你的
on_config_load)必須有自己的預設值——不能因為舊 config.ini 沒有這個鍵
就出錯、崩潰、或整顆 Plugin 進不了 probe();舊版本 Plugin 讀到一個自己不
認得的鍵,必須忽略它(不得報錯,也不得因此拒收整段)。這是因為使用者
可能會把你的 Plugin 降版——降版後的舊版本仍要能正常開機,即使
config.ini 裡躺著新版本才認得的鍵。搭配 8.4 提到的「echo back 你不認得的
鍵」寫法:新版寫進去的鍵,即使你現在的版本不認得,也該原樣留在磁碟上,不
要因為你不認得就把它連同其他設定一起洗掉。
9. Batch 模式
--batch 與互動 Host 共用同一個執行檔,也會掃描 Plugin DLL 並能呼叫 Plugin
自訂指令:
nexus_tui.exe --batch --cmd "p myic" --cmd "mycmd 0x10 0x20"(--batch / --cmd "<command>"(可重複)/ --dongle <name> /
--addr <hex> / --log-level <mask> / --run <script.lua>。)
⚠ Batch 會等你的自訂指令 handler 跑完,並用它的回傳值決定 Exit Code (已對照
src/host_batch/batch_main.c並實測):handler 回ERR_OK就繼續 下一條;回任何其他值,Batch 停止後續指令、往 stderr 印cmd N ('...') failed: plugin command failed (see log above),Exit Code 是 該回傳值的絕對值(例:ERR_HW_OFFLINE-40→40)。指令名稱根本不存在時 印unknown command,Exit Code 是7。因此 handler 失敗時要把錯誤碼回傳出去: 只寫一行 Log 卻回ERR_OK,Exit Code 就是0。handler 產生的 Log 會原樣印到 stdout。Batch 最多等 10 秒;超過的 handler 會被誤判成unknown command, 所以要在批次裡跑的指令必須在 10 秒內做完。
Batch 掃描 Plugin 只走 Core_Plugin_ScanDefault(NULL)——只掃 DLL,不註冊
靜態 Plugin。
10. ABI 降級樣板
IcPlugin/NxHostApi 兩個 ABI 邊界結構的新欄位一律加在結尾並 bump
NX_PLUGIN_ABI_MINOR(不得在結構中段插欄位)。唯一合法的「這個欄位存不存在」判定
手段是:
ExecSync本身是 ABI 3.0 起的既有基線,每一個 4.x Host 都有它,本身不需要 做這個檢查;以下用它示範寫法只是取一個讀者熟悉的欄位——真正需要這個 檢查的,是你的 Plugin 用到了比自己編譯期基線更晚的 minor 才新增的欄位。
if (NX_ABI_HAS_FIELD(host->size_of_struct, NxHostApi, ExecSync) &&
host->ExecSync != (NxLong (NX_CALL *)(NxPluginCtx, const NxJobRequest *,
NxByte *, NxWord))0)
{
rc = host->ExecSync(ctx, &req, buf, len);
}
else
{
/* 較舊的 Host:退回 SubmitJob() 的非同步路徑,或直接回 ERR_NOT_SUPPORTED */
}雙重檢查缺一不可:NX_ABI_HAS_FIELD 確認該欄位完整落在對方宣告的
size_of_struct 範圍內(用 offsetof(field) + sizeof(field),不是只比對
起始位移),再檢查函式指標本身非 NULL——同一個欄位範圍內,Host 仍可能
合法地把它填成 NULL(表示不提供該服務)。
⚠ 絕不可比較
abi_minor的大小來判斷「這個欄位存不存在」。abi_minor只說明契約版本,size_of_struct才說明對方實際填了 多少——用 minor 判定欄位存在性,會在 Host 與 Plugin 各自獨立升版、 minor 數字碰巧對不齊的情況下讀到未初始化的記憶體。
這條禁令與 on_key_press 的鍵碼改版並不衝突:NX_PLUGIN_ABI_MINOR
由 14 bump 到 15 那次,沒有任何欄位增減,on_key_press() 的 key
參數值域從「host_common 內部的 NX_KEY_*」改成「sdk/nexus_plugin_keys.h
的 NX_PKEY_*」——這是同一個欄位的語意變更,NX_ABI_HAS_FIELD 判定的
是結構大小,在這裡完全用不上。Plugin 要分辨 Host 用哪一套鍵碼值域,看的是
NexusPluginGetInterface(host_abi_major, host_abi_minor) 傳進來的
host_abi_minor 本身,這正是 abi_minor 存在的目的——判斷「對方的行為
契約是哪一版」是它的正當用途,判斷「對方有沒有這個欄位」才是被禁止的那件
事,兩者不是同一件事。
11. 常見錯誤對照表
| 症狀 | 原因 |
|---|---|
LoadLibrary 回 ERROR_BAD_EXE_FORMAT(193) | 架構不符(x86 plugin 配 x64 主程式或反之)。訊息不會提到架構 |
Plugin 沒出現在 plugin list | abi_major 不符、找不到 NexusPluginGetInterface、或架構不符 |
以 abi 3.x 編的舊 DLL 載不進來(Log:ERR_ABI_MISMATCH) | 4.0 把 widget 介面整批換掉,major 不符一律拒載。Log 訊息說明方向:DLL 比 Host 舊要「用 abi 4.x 的 PDK 重編」,DLL 比 Host 新要「更新 Host」 |
LoadLayout 回 ERR_NOT_FOUND | .rc 的 RCDATA 資源名稱與 LoadLayout() 的 name 不一致、.rc 沒編進 DLL,或(NX_LAYOUT_F_FILE)檔案不存在;靜態登錄的 Plugin 沒有 DLL 資源,要改用 LoadLayoutData() |
LoadLayout 回 ERR_INVALID_PARAM | .nxlay 語法或驗證失敗(原因在 Plugin 的 Log)、label=/aux= 超過 31 位元組、保留位非 0、版面格式版本比 Host 新 |
LoadLayout 回 ERR_NOT_SUPPORTED | 版面含此 Host 不認得或尚未實作的 type=;整份載入失敗,不會載一半 |
SetWidgetProp 回 ERR_FULL,值沒變 | LABEL/AUX 超過 31 位元組、blob 超過容量,或影子表文字配額(每面板 512 KiB、全體 4 MiB)用盡;舊值保留 |
ERR_WRONG_THREAD | 在 UI Thread 呼叫 ExecSync |
ExecSync 打到錯的位址 | 填了自己 slaves[] 裡宣告的位址而非 0。填 0(=「本掛載當前綁定的位址」)才會跟著使用者的 addr/mount 走,見 5.1 |
ExecSync 對 NX_BANK_MODE_NONE 掛載回 ERR_INVALID_PARAM | bank 傳了非 0 值——NONE 掛載沒有 bank 語意,任何非 0 bank 一律拒絕,見 5.1a |
ExecSync 對 REG 掛載每次都把 IC 拉回某一頁 | 誤以為 bank == 0 是「不帶 bank」而每次都傳 0 想跳過 bank 切換——0 其實是「切到 plugin_bank」,見 5.1a:想不換頁就不要碰任何會改變頁面的呼叫,Core 自己會記得上一頁 |
bus_name 打錯字卻回 ERR_WRONG_THREAD 而非「名字不存在」 | 不會發生:Core 先解析名字再判執行緒,名字不存在必為 ERR_HW_OFFLINE;若你觀察到相反行為,回報缺陷 |
ERR_INVALID_CONTEXT | 在 ui_render_fn 之外呼叫 Draw* |
ERR_POLL_DISABLED | poll off 期間送出低優先級 Job(正確行為) |
我的 on_layout_changed 沒有每次縮放都被呼叫 | 只在面板內容區的尺寸與上次送出的不同時才送;綁定後首次必送,面板不再是 Plugin 面板時送一次 0,0 |
我的 on_mouse_event 在 Widget 面板收不到 | 只送 Canvas 面板;Widget 面板的互動走 on_widget_event |
我的 on_mouse_event 收到負座標或超出寬高的座標 | 正常:按下後捕獲,拖曳出面板時 DRAG/UP 仍會送達,座標可為負或超出面板;請先檢查範圍再當索引 |
我的 on_config_save 寫的值沒進 config.ini | 依序檢查:①回傳值不是 ERR_OK(任何非 ERR_OK 都會讓這次收集被丟棄,見 8.5);②輸出內容含不合法的行(沒有 =、控制字元含 TAB、鍵以 [/;/# 開頭——見 8.1,這些在你的輸出裡不會被寬容跳過);③輸出超過 4094 位元組(NX_PLUGIN_CONFIG_MAX - 2,見 8.2);④Plugin 在收集期間崩潰;⑤這是 Batch 模式(--batch 永遠不呼叫 on_config_save,見 8.4);⑥這顆 Plugin 本 session 從未被掛載過/未被收集過(沒有 dirty 段可寫回) |
| 我的中文在 Host 上變成 U+FFFD,或中文路徑開不到檔 | 字串不是 UTF-8(原始檔碼頁、MSVC 沒加 /utf-8、VC6 字面值),或路徑還在用 fopen/*A API。見第 14 章 |
| 我的 Plugin 崩潰把整個程式帶走了 | 該建置沒有 SEH 隔離。執行 ver 看 SEH isolation |
12. 移植對照表
把 8051/MCU 韌體搬進 Plugin 時,每個韌體概念在 Plugin 裡對應什麼:
| 韌體裡的東西 | Plugin 裡改成 | 契約 |
|---|---|---|
main() 開頭的初始化(會碰硬體的部分) | probe() | IcPlugin::probe |
main() 開頭的初始化(不碰硬體的宣告) | init() | IcPlugin::init |
| 無窮迴圈的一輪 | 背景週期:poll_fn;使用者觸發:自訂指令 | IcPlugin::poll_fn、IcPlugin::commands |
| ISR | interrupt_handler | IcPlugin::interrupt_handler |
delay | delay_ms/delay_us | delay_ms、delay_us |
printf | nx_port_printf(Port Shim 自動重導向)或 LogWrite | nx_port_printf、NxHostApi::LogWrite |
malloc/free | nx_port_malloc/nx_port_free | nx_port_malloc |
| 寫 bank-select 暫存器換頁 | 照寫;在 slaves[] 宣告 bank 模式,Core 攔截並記住目前的頁 | NxSlaveDecl::bank_mode、NxJobRequest::bank |
| 多條 I2C bus | SubmitJob + NxJobRequest::bus_name 存取其他匯流排;nx_i2c_set_bus 只選本掛載 Worker 自己擁有的具名後端 | NxJobRequest::bus_name、NxHostApi::SubmitJob、nx_i2c_set_bus |
| 可被中止的長迴圈 | 每輪檢查取消訊號 | NxHostApi::ShouldAbort、nx_port_should_abort |
| EEPROM/flash 裡的設定值 | config.ini 的 [Plugin.<name>] 段 | IcPlugin::on_config_load、IcPlugin::on_config_save |
已知限制與不在範圍內的項目
以下幾類韌體行為不在 Plugin 模型的涵蓋範圍內,或必須先在目標硬體上量測:
- 同一顆移植 DLL 同時掛到兩個位置:移植層的綁定狀態與韌體自己的全域
變數在一顆 DLL 裡都只有一份,第二個掛載會在
init()被拒絕 (ERR_ALREADY_IN_USE),第一個照常運作。要換位置先卸載原本那一個。 - µs 級 GPIO bit-bang 波形(例如進入 ISP 模式前的特殊翻轉序列):USB dongle 每次 GPIO 翻轉的開銷是毫秒級,µs 等級的時序不保證做得到, 能不能用來進 ISP 要實際在目標 dongle 上量測,不能假設一定可行。
- 1ms 時槽紀律:Windows 一般排程精度在十幾毫秒量級,Plugin 無法保證 每 1ms 準時執行一次動作;需要這種即時性的韌體邏輯要留在 MCU 上,不要 搬進 Plugin。
- 跨行程的韌體 image 所有權:這是橋接路線特有的問題,與 Plugin ABI
無關。需要讓不能改的廠商程式與 nexus 共用 dongle 時,走橋接路線,見
docs/NEXUS_TUI_BRIDGE_PORTING.md。 - 執行期才決定的 slave 宣告:韌體常見「執行期算出這次要用哪個位址」
的寫法,在 Plugin 裡改成先把全部候選位址都宣告出來、標記
NX_SLAVE_F_OPTIONAL,讓 Core 在init()跑之前就能回答「這顆 Plugin 有哪些位址」——沒有讓 Plugin 在執行期才新增宣告的 API。
13. Plugin 版本號
IcPlugin 結尾(ABI 3.1 加入,4.0 起為基線)有四個 NxWord 欄位:version_major/
version_minor/version_patch/version_rsv——這是 Plugin 作者自己的
版本號(例如 1.0.0),與 abi_major/abi_minor(介面記憶體佈局是哪一版)
無關,兩者不要混為一談。version_rsv 保留給未來擴充,一律填 0。
13.1 單一來源:plugin_version.h
版本號只能改一個地方。每顆 Plugin 自己在 ./src 下放一支
plugin_version.h(只含 include guard 與四個 #define,不含任何 C
宣告——因為 Windows 的資源編譯器(rc.exe/windres)也會直接
#include 它,看不懂 C 宣告;且它必須是純 ASCII,資源編譯器用系統
ANSI codepage 解析 .rc,與 .bat/.mk 那條規則同一類風險):
/* plugin_version.h */
#define PLUGIN_VER_MAJOR 1
#define PLUGIN_VER_MINOR 0
#define PLUGIN_VER_PATCH 0
#define PLUGIN_VER_STR "1.0.0" /* 必須與上面三個數字逐字對得上 */這四個巨集同時餵給兩個消費者,兩者不會走樣:
.c:NexusPluginGetInterface()裡把IcPlugin::version_major/version_minor/version_patch設成這三個巨集,version_rsv填0。.rc:VERSIONINFO資源用同一份plugin_version.h的巨集組FileVersion/ProductVersion,讓使用者在檔案總管對 DLL 按右鍵 →「內容」→「詳細資料」就能看到版本,不必啟動主程式。
改版只改 plugin_version.h 這一份檔案——.c 與 .rc 都是引用巨集,
不必逐一去改。範例見 plugins/template_plugin/src/plugin_version.h
(連同 template_plugin.c、template_plugin.rc 三份怎麼串起來)。
13.2 plugin list 會顯示版本
Host 端 plugin list 指令會在每顆 Plugin 名字後面直接接版本號,例如
template(1.0.0);讀不到版本(更舊 ABI 的 Plugin,size_of_struct 沒有
涵蓋到這四個新欄位)時顯示 template(-),不會讓整條 plugin list 失敗。
你不需要為了讓版本顯示出來做任何事,只要按 13.1 填好 IcPlugin 的版本
欄位即可。
plugin list 的輸出上限 192 bytes,Plugin 一多會被截斷——若只想查一顆
已註冊 Plugin 的版本(例如發版腳本要驗證剛建出的 DLL),改用
plugin version <name>:已知版本輸出 <name> x.y.z;未知輸出 <name> -
(兩者皆 ERR_OK,版本未知不是錯誤);名稱根本沒註冊過才回
ERR_NOT_FOUND。查的是註冊表,不要求該 Plugin 目前掛載在任何 Tab 上。
0.0.0 保留代表「未宣告」:即使你的 size_of_struct 涵蓋了這三個
欄位,若三者全為 0(例如複製了範例卻忘記改 plugin_version.h),
plugin list 一樣顯示 (-),不會顯示成一個看起來合法的 (0.0.0)。
理由:如果全零被當成「已知版本 0.0.0」,任何未來的版本(哪怕只是
0.0.1)在 Plugin Manager 眼中都會被判定為「有更新」,一顆忘記填版本號
的 Plugin 就會永遠顯示「可更新」。從 1.0.0(或至少 0.0.1)開始編號,
不要用全零當起點。
14. 字串編碼:重編不會自動支援 UTF-8
14.1 約定
Host 與 Plugin 之間流動的每一條字串都是 UTF-8,兩個方向都一樣:
- Host 給你的:指令的
argv、on_widget_event的文字值、Log 文字、路徑與名稱。 - 你給 Host 的:
name/description、widget 的標題與文字值、LogWrite()的訊息、指令的說明與用法。
這是文件約定,ABI 版本與所有結構都沒有變:不需要改 abi_major/abi_minor,
既有的 Plugin 照常載入。唯一例外是 LogWriteStyled() 的 msg,它的契約不變,
仍必須是純 ASCII(spans[] 的位移是位元組)。
舊 Plugin 若送出非 ASCII 的系統碼頁位元組(例如繁體中文系統上的 CP950、日文系統上的 Shift-JIS),Host 不會猜碼頁:非法序列一律顯示成 U+FFFD(Unicode 的替代字元),不會當機、也不會拒載。 這是刻意的有損政策。
14.2 重編不會自動支援 UTF-8
把舊 Plugin 用新的 PDK 重編,只會換掉標頭;你的原始碼怎麼產生字串,沒有任何東西替你改。 依你的 Plugin 做的事,分成四種情況:
| 你的 Plugin 做的事 | 要不要改 | 怎麼改 |
|---|---|---|
| 只用 ASCII 字串(英文 Log、英文 widget 標籤、名稱與說明) | 不用改 | ASCII 是 UTF-8 的子集。 |
只把 Host 給的字串原樣轉送(例如把 argv 存起來再放回 widget、寫進 Log) | 不用改 | 沒有解讀內容就不會出錯。 |
拿 Host 給的路徑去開檔、載入 DLL、列目錄(fopen、CreateFileA、LoadLibraryA、FindFirstFileA…) | 要改 | 路徑是 UTF-8,*A 與 fopen 依系統碼頁解讀,路徑含中文或日文(例如中文使用者名稱的 C:\Users\...)時會靜默開錯檔或開不到。改用 NxFopenUtf8(),或先 NxUtf8ToW() 再呼叫 *W API。 |
| 自己送出中文(原始碼裡有中文字面值,或從檔案、使用者輸入組字串) | 要改 | 讓字串是 UTF-8,見下方編譯器表。 |
用 strlen() 當畫面欄數做排版(置中、對齊、截斷、Canvas 的 DrawText 座標) | 要改 | UTF-8 的一個中文字是 3 位元組、2 欄。改用 NxUtf8DisplayWidth() 量欄數、NxUtf8Truncate() 截斷。 |
14.3 中文字面值怎麼寫
字面值是不是 UTF-8,取決於編譯器怎麼解讀你的原始檔:
| 工具鏈 | 作法 |
|---|---|
clang(clangmingw、clangcl)、MinGW gcc | 預設把原始檔當 UTF-8;原始檔存成 UTF-8(無 BOM)即可。 |
MSVC(vs2022) | 加編譯選項 /utf-8(把原始檔與執行字元集都設成 UTF-8);沒加時依系統碼頁解讀,字面值會變成 CP950 之類。 |
| VC6 | 沒有辦法產生 UTF-8 字面值。只能自己寫位元組:"\xE7\xAF\x84\xE6\x9C\xAC"(範本),或寬字串 L"\x7BC4\x672C" 加 NxWToUtf8()。 |
最不依賴編譯器的寫法,就是寬字串加 \x 跳脫再轉 UTF-8(plugins/template_plugin 的 ping 指令就是這樣寫的):
char utf8[64];
if (NxWToUtf8(L"\x7BC4\x672C", utf8, (int)sizeof(utf8)) > 0)
{
host->LogWrite(ctx, NX_LOG_INFO, utf8);
}兩個陷阱:
\x會一路吞掉後面所有十六進位數字。"\xE7\xAF\x84abc"會把84abc當成同一個跳脫序列。需要時用字串相接切開:"\xE7\xAF\x84" "abc"。- 原始檔裡的非 ASCII 位元組不得緊貼在
"或*/前面:VC6 以 CP950 掃描原始碼,會把結尾吞掉,整個字串不關閉、行號也不可信。把\x跳脫寫在字面值裡,或在中文與結尾符號之間放一個 ASCII 字元(... 。 */)。
14.4 sdk/nexus_utf8.h
只有標頭、全部 static、純 C89/C++98、不依賴 Host,VC6、MinGW、MSVC、clang 皆可編。
#include "nexus_utf8.h" 即可,沒有要連結的東西。
| 函式 | 用途 |
|---|---|
NxUtf8ToW(src, dst, dst_cch) | UTF-8 轉 UTF-16;dst 傳 NULL 只查所需 WCHAR 數。非法位元組變 U+FFFD。 |
NxWToUtf8(src, dst, dst_cb) | UTF-16 轉 UTF-8;dst 傳 NULL 只查所需位元組數。 |
NxFopenUtf8(path, mode) | 以 UTF-8 路徑開檔(_wfopen);plugins/template_plugin 的 note 指令示範。 |
NxUtf8DisplayWidth(s) | 畫面欄數(CJK、全形、emoji 為 2,組合字元為 0);plugins/canvas_demo 示範。 |
NxUtf8Truncate(src, dst, dst_cb) | 複製進固定緩衝,放不下時在字元邊界截斷,保證 NUL 結尾;plugins/widget_demo 的 Echo 示範。 |
注意事項:
- 本檔不 include
windows.h(只在它尚未載入時自行宣告兩個 kernel32 轉換函式),所以windows.h放在它前面或後面都能編,也不會把s_host、DrawText之類的常見識別字被winsock.h/winuser.h的巨集弄壞。 - 轉換函式失敗回
0(不是負值),並把dst[0]設成 NUL,所以忘記檢查回傳值時至少不會讀到垃圾。 NxUtf8DisplayWidth()的寬度表與 Host 的 Log 面板一致(歧義寬度字元算 2 欄),表刻意只收 CJK、全形、常見符號與 emoji。- 固定長度緩衝(例如
NX_WLABEL_MAX_BYTES)是位元組上限;超長的字串SetWidgetProp()回ERR_FULL、舊值保留(Core 不截斷),所以要放進去的字串自己先用NxUtf8Truncate()截在字元邊界。
15. 延伸閱讀
docs/NEXUS_TUI_PLUGIN_API_REFERENCE.md——逐支 API 的精確契約(本指南的 連結都指向它)。sdk/README.md——十一個標頭檔案本身的索引與凍結規則。sdk/nexus_plugin_api.h的「Callback 派發狀態總表」(IcPlugin定義之前) ——第 3 章(派發狀態、可達掛載型態)的權威來源;第 4 章(執行緒歸屬) 的唯一權威來源是本檔第 4 章自己那張表,該總表僅為導讀,不逐一覆述執行緒。docs/NEXUS_LAYOUT_TOOL_SDD.md——.nxlay格式與nxlayout工具鏈完整規格。docs/NEXUS_TUI_BRIDGE_PORTING.md——如果你要接的不是「寫一顆 Plugin」, 而是「讓一個不能改的既有廠商程式跟 nexus 共用同一顆 dongle」,看這份。