Add Chinese translations of all three documents

Full translations rather than summaries, section-for-section with the English:
README (13 sections), AGENTS.md (21), and docs/reverse-proxy.md. Each pair
cross-links to the other and says the two are kept in step, since documentation
that has silently diverged is worse than documentation that does not exist.

Verified rather than eyeballed: heading counts and order match in both pairs,
every internal .md link resolves, every tool named in either README exists, and
every test in run_tests.sh appears in both.

Translating turned up four things that were already stale in the English, which
is the honest argument for having done it this way -- a summary would not have
touched them:

  - the endpoint table was missing /api/ptt, /api/power/<action> and
    /api/logs, and did not mention the speaker field on /api/status
  - the modelled-peripheral list omitted TIM2
  - the audit table still called TIM a stub, unchanged since fdcbe80 modelled
    TIM2
  - neither README listed uvk5_logs.py, uvk5_stream.py, uvk5_supervisor.py or
    test_kill_emulator.sh, which are part of the repo rather than scratch

The ad-hoc probe scripts are now acknowledged in one line instead of being
silently absent, and described as what they are: quick to reach for, not
polished.

Unit tests: 89 passed.
This commit is contained in:
mckero committed 2026-08-29 16:48:27 +01:00
1 parent 8b995aa610
commit 3df3c1b16d
6 files changed
+1217 -4

No files matched your search

+3 -1
View File
@@ -3,6 +3,8 @@
Notes for whoever picks this up next. Focused on what is not obvious from the
code, and on mistakes that already cost time here.
*中文:[AGENTS.zh-CN.md](AGENTS.zh-CN.md) · the two are kept in step; change both.*
## What this is
A QEMU machine for the Puya PY32F071 (Cortex-M0+), so Quansheng UV-K5 V3
@@ -522,7 +524,7 @@ Counted from the firmware's own call sites:
| GPIO | 55 | modelled |
| DMA | 59 | modelled, over the CPU's address space |
| SPI | 33 | modelled, with the flash |
| TIM | 23 | **stub** — backlight PWM and `millis()` |
| TIM | 23 | TIM2 modelled since `fdcbe80`; the rest stubbed (backlight PWM) |
| ADC | 19 | modelled; result settable since `e46cae2` |
| USART | 11 | modelled both directions |
| RTC, IWDG, WWDG, I2C, USB, CRC, EXTI, PWR | 0 | stub, and the firmware never uses them |
+705
View File
@@ -0,0 +1,705 @@
# 在这个仓库里工作
给下一个接手的人的笔记。重点写代码里看不出来的东西,以及**已经在这里浪费过时间的错误**。
*English: [AGENTS.md](AGENTS.md) · 两份内容对应,改动请同步。*
## 这是什么
一个给普冉 PY32F071(Cortex-M0+)写的 QEMU 机器模型,让泉盛 UV-K5 V3 固件能在 PC 上跑。
约 5 秒进入主循环,LCD 可读。
机器定义和所有设备模型都在一个文件里:`qemu/py32f071.c`。这是刻意的:这些模型都很小,
而且彼此的接线紧密耦合,拆开只会把板级布局摊得更散,并不会让任何一部分更清楚。
## 它是怎么启动的
在排查任何看起来像启动问题的东西之前值得先读这一节。**这里没有 bootloader、没有内核、
没有分区表、没有文件系统** —— 固件是机器上唯一的代码,它完全独占 CPU。
**硬件只认两个数字。** Cortex-M0+ 复位后不运行任何引导逻辑。它从向量表第一个字取 SP、
第二个字取 PC,然后开始执行。整个交接就这么多。
.isr_vector 0x08002800 (readelf -SW, 大小 0xc0)
+0x00 0x20004000 初始 SP,也就是 16 KB SRAM 的顶端
+0x04 0x08002d49 Reset_Handler,同时是 ELF 入口点
有疑问就直接从镜像里读 —— 字节是小端,所以 `00400020 492d0008` 表示 SP 0x20004000
后面跟着 PC 0x08002d49:
objdump -s -j .isr_vector firmware.elf | head -5
那个奇数地址不是笔误:bit 0 标记 Thumb 状态,硬件取指时会把它屏蔽掉。
**`PY32_APP_OFFSET` 0x2800 是承重的。** Flash 从 `0x08000000` 开始,但前 10 KB 是出厂
bootloader 区域,所以应用在它之后。`armv7m_load_kernel()` 之所以要传这个偏移正是因为
这个 —— 改成在 `0x08000000` 加载,向量表就落在错误的位置,**第一次取指就会 fault**。
**启动代码是 31 行汇编**,在固件的 `Core/startup_py32f071xx.s` 里:
从 _estack 设置 SP
bl SystemInit
把 .data 从 flash (_sidata) 复制进 RAM (_sdata .. _edata)
清零 .bss (_sbss .. _ebss)
bl __libc_init_array
bl main
LoopForever: b LoopForever @ main 永不返回
复制和清零那两步才是有意思的地方。已初始化的全局变量存在 flash 里但必须可写,
所以要逐字复制进 RAM;未初始化的全局变量按 C 标准必须读作零,所以 `.bss` 要清掉。
在有操作系统的环境里,内核和加载器帮你做这些。这里没人做,所以**这两个循环只要有一个错了,
你就会得到静默变成垃圾的全局变量**。
**然后是应用:**
main() Core/Src/main.c —— 只配时钟,然后进 Main()
Main() App/main.c —— 真正的固件
SYSTICK_Init() 一切时序都以这个 10 ms tick 为基准
BOARD_Init() GPIO、SPI、LCD、键盘矩阵
UART_Init() 日志里那条 SERIAL 横幅就是从这来的
SETTINGS_InitEEPROM() 通过 SPI 从 flash 镜像读设置
while (1) { ... } 主循环,永不退出
**没有文件系统。** 最接近"挂载分区"的东西是 `SETTINGS_InitEEPROM()` 通过 SPI 读取固定的
字节偏移:`0xA008` 是省电字节,`0x0E70` 是 VFO 索引,等等。没有元数据、没有目录、
没有校验和 —— 只有一个代码和数据必须约定一致的地址。**所以某个设置读回来不对时,
先怀疑偏移,再怀疑传输层。**
进入主循环要约 15 秒,那是模拟开销。真机大约一秒就起来了。
## 基本规则
**永远不要为了让模拟器能跑而改固件。** 固件是**基准**。如果某个东西跑不起来,那是模型错了。
一个修改了固件源码的"修复"会让之后所有测试失去意义,因为你测的已经不是电台实际运行的东西。
**寄存器布局来自厂商的 CMSIS 头文件**,不是搜 datasheet 搜来的,也不是推断出来的:
<firmware>/Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h
需要某个位的位置时,去那里读。有几处细节很反直觉 —— 比如 `LL_ADC_FLAG_EOS` 在这颗片子上
其实是 `ADC_SR_EOC` —— **靠猜会做出看着对、实际会挂的模型**。
**靠观察固件停在哪里来决定下一个要建模的东西**,而不是从头到尾读 datasheet。
这里每一个外设都是因为固件确实在等它才加进来的:
tools/where.sh 4 # 多采样几次调用栈
如果多次采样都停在同一个函数里,那就是个自旋循环。去看它读了什么。
## 怎么运行
python3 tools/make_flash.py # 一次;生成 assets/flash.img
tools/run.sh # GDB stub 在 :1234,QMP 在 /tmp/uvk5-qmp.sock
tools/where.sh # 执行到哪了
tools/gpiob_dump.sh # GPIOB 寄存器
python3 tools/key.py MENU # 注入一次按键
python3 tools/screenshot.py --frame-addr 0x200013DC \
--status-addr 0x2000175C --port 1234 --out screen.png
截图用的地址在不同固件构建之间会变。这样拿到当前值:
arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine'
改完机器模型后重新构建:
cd $QEMU/build && ninja qemu-system-arm # 增量约 10 秒
任何靠近键盘或 GPIO 接线的改动之后,跑回归测试。它会在私有端口上启动自己的实例,
所以不会干扰正在运行的 `run.sh`:
python3 tools/keypad_test.py
还有一个浏览器界面,通常是手动折腾固件最快的方式:
python3 tools/webui.py --frame-addr 0x200013DC \
--status-addr 0x2000175C # 然后打开 http://127.0.0.1:8080/
关于它,有两点在这个仓库里干活时需要知道:
- **它会在整个生命周期里占住 QMP socket**,所以 `key.py` 不能同时运行。那个 socket
只接受一个客户端。
- **它刻意用 QMP `memsave` 读帧。** 不是 `pmemsave` —— 后者取**物理**地址,
对 `gFrameBuffer` 会静默返回全零,也就是一片空白屏幕而且哪里都不报错。
也不是 gdb —— gdb 每次 attach 都会暂停 guest,那会让画面流卡顿,还会扰乱按键防抖时序。
它的测试:`tools/test_uvk5_*.py` 和 `tools/test_webui.py` 不需要模拟器,
`tools/test_webui_e2e.py` 会自己启动一个。
## flash 的那些 bug:四个故障,一个症状
"频率改不了"和"关机后 flash 什么都不记得"看起来是两个抱怨。实际是**一个根因加上路上顺带
发现的三个真 bug**,全都在这个文件里。动 SPI、DMA 或 flash 模型之前值得读一遍,
因为每一个从上层都完全看不见。
1. **DMA 用了错误的地址空间** —— 这是真正的根因。它通过 `address_space_memory` 搬字节,
而那个地址空间**根本无法解码这个 SoC 的内存**:container region 只交给了 ARMv7M 内核,
从未注册进全局系统内存。读返回 `MEMTX_DECODE_ERROR` 和零;写则去了虚空。
现在 DMA 跑在一个基于 container 构建的 `AddressSpace` 上。
2. **页编程没有回卷。** 真实的 SPI NOR 只锁存低位地址,所以一次超过 256 字节页边界的
burst 会**从同一页的开头继续**。模型直接一路走了下去,于是固件确实会在单次 CS 事务里
发出的那个 0x008F00 处的 512 字节 burst 溢出到了 0x009000。
3. **DMA 启动得太早。** 传输在通道被使能时就跑了,但真实硬件上是**外设发出请求时**才开始。
驱动的顺序是先武装两个通道、再使能 SPI、最后置 TXDMAEN —— 所以在武装时就触发,
等于在读命令还没发出去之前就把总线时钟走完了。
4. **两个 DMA 通道一个接一个地跑。** SPI 是双向的,驱动用一个送 dummy 的 TX 通道
配一个收数据的 RX 通道来完成一次传输。让它们顺序执行,等于 TX 走完了整个传输,
RX 才开始看总线,那时上面什么都没有了。
其中**任何一个**都会把存放各波段 VFO 频率的那个扇区清零。而 `RADIO_ConfigureChannel`
**只在读到 `0xFFFFFFFF` 时**才用波段下限替代,所以一个存进去的零会被照字面采用,
然后被钳制到 `BX4819_band1_lower` —— 18 MHz。**输入的频率总是变回去,全部原因就在这里。**
`tools/test_freq_entry.py` 和 `tools/test_flash_persist.py` 里的 `MUST_NOT_CHANGE` 守卫
就是为了捕捉这四个中任何一个的回归。
### 是什么让这件事难查,以及应该怎么做
**给模型插桩,不要给 guest 插桩。** 频率输入框会在 `key_input_timeout_500ms / 3`
(约 2.5 秒)后超时,而一次 gdb attach 大约要 3 秒。所以**在输入数字之间探测会清空输入框**,
然后这次运行会报告一个由测量本身造成的失败。这至少产生过三个自信的错误结论,
包括"固件存到了波段 0"——而实际上只是输入框空了。改成往 `qemu/py32f071.c` 里加 `fprintf`
然后读 stderr —— guest 全程不停。
**在你知道数据形状之前,永远不要给诊断日志设上限。** 一个只记录前六个事务的探针
显示 payload 全是 `0xFF`,而这恰好支持了**完全错误的结论**。去掉上限之后,
真正重要的那些写入一目了然。
**在相信测试之前,先确认构建成功了。** `ninja` 失败会把上一个二进制留在原地而测试照样运行,
于是一个过期的构建**静默地**回答了你的问题。有两轮结果就是这样变得毫无意义的。
用 grep 在构建输出里找 `FAILED` 和 `error:`,出现任何一个就停下。
**每次运行之间重置 flash 镜像。** `assets/flash.img` 会被每次会话写入。一个从它出发的测试
可能发现工作已经做完了 —— 表现为"镜像逐字节相同",这和持久化坏了**完全无法区分**。
要从 `assets/pristine/` 出发,而且**恢复之前先把模拟器关掉**,因为关机会把旧的内存镜像
刷回文件覆盖掉。
**不要手算结构体偏移。** 这个 ELF 没有 DWARF,而结构体里含有大小不可假设的枚举。
手算出来的偏移产生过 `KEY_LOCK=4` 和 `TX_VFO=11`,**这两个值都不可能存在**。
要么用一个 `nm` 能报告、且类型无歧义的符号(`gInputBoxIndex` 就是个纯 `uint8_t`),
要么**靠行为定位字段** —— 用长按 `F` 切换键盘锁然后 diff 那块区域,一步就找到了
`KEY_LOCK` 在 `gEeprom+0x12`。
**仔细读你自己的探针输出。** 有个探针在 `phase` 递增**之前**就把它打印了出来,
这让一个正确的地址解码器看起来偏了一个字节。用 Python 重放那段逻辑才排除掉;
要是没那一步,一个本来能工作的实现就会被"修"坏。
## 已经出过的错
**GDB 断点会暂停 guest。** 一个跨越断点会话的按键**永远不会被处理**,因为主循环没在跑。
这产生过一整轮"按键没反应",而真相是"机器停着"。用 `tools/press_and_shot.sh` ——
它按下、让机器跑、然后读帧缓冲,全程没有任何断点。
**加速 SysTick 时不要把计数值写回去。** 有两次尝试是那样做的。结果每次读取都会重新锚定计数,
所以固件看到的值不再变化、它的 `if (cur != prev)` 判断永远不成立、延时循环彻底卡死 ——
**比原本要修的"慢"更糟**。有效的做法是报告一个跑在真实计数器前面的值,而不去动那个定时器。
**降低时钟频率不会加快延时循环。** 瓶颈是每秒的循环迭代次数,不是计数器速度。
48 MHz 降到 200 Hz 只换来 32 倍,远远不够。这是实测的,不是假设。
**未命名的 qdev in / out 线共用一个命名空间。** 一个同时有未命名 `qdev_init_gpio_in`
和 `qdev_init_gpio_out` 的设备会让 `qdev_get_gpio_in()` 产生歧义,然后板级接线会
**静默地接到错误的线上**。GPIO 模型用 `"pin-in"` 和 `"pin-out"` 正是因为这个。保持这样。
**按键时长必须**短**,不是越宽松越好。** 这一条以前写的是**相反**的东西 ——
说 guest 时间跑得快所以按压需要长时间按住,还说 `key.py` 应该按 2500 ms。
**那是错的,而且它把键盘工具链弄坏了很长时间。** 2500 ms 约等于 250 个固件 tick,
是长按阈值的六倍,所以每次按压都被派发成**长按**,而那些在短按松手时才动作的处理函数
什么都没做。见下面的键盘章节;`key.py` 现在按 200 ms。
**在相信一个工具的输出之前,先验证它自己的解析。** `gpio_watch.py` 有好几轮都报告
`IDR=0x0000`,因为它的正则**根本不匹配** gdb 的输出格式。寄存器是好的;读取器是坏的。
用走另一条路径的 `tools/gpiob_dump.sh` 交叉验证。
**QMP `pmemsave` 是物理地址,`memsave` 是虚拟地址。** 帧缓冲符号是 CPU 虚拟地址,
所以对 `gFrameBuffer` 用 `pmemsave` 会返回一整块零**并报告成功** —— 一片空白屏幕,
而且哪里都没有日志。网页界面最初就是建在 `pmemsave` 上的,因为一个计时基准说它更快;
**那个基准从来没有检查过内容**。要测量你真正在意的东西:这个 bug 是在渲染出的一帧
返回 0 个亮像素、而 gdb 路径报告 1693 时才浮出水面的。
## 键盘:两个真 bug,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
互相独立的原因**,按出现顺序:
1. **`tools/key.py` 把每个键都按 2500 ms** —— 工具链的 bug,紧接着下面讲。
2. **`row_out` 没有 `volatile`,所以 GCC 删掉了驱动行线的代码** —— 真正的模型 bug,
是后来在清理调试打印时引入的。见
[row_out 必须保持 volatile](#row_out-必须保持-volatile否则-gcc-会删掉键盘)。
两个都修了,`tools/keypad_test.py` 会防止任一个回归。
**SysTick 的两套机制是分开的**,把它们混为一谈就导致了上面的问题:
- SysTick **中断**接近实时触发。`SysTick_Handler` 设置 `gNextTimeslice`,
它门控 `APP_TimeSlice10ms` → `CheckKeys`。所以 `App/misc.c` 里的防抖阈值
**就按字面意思在实际时钟下生效**:`key_debounce_10ms = 2`(20 ms 才被登记)、
`key_repeat_delay_10ms = 40`(400 ms 算**长按**)。
- `poll-boost` 属性加速 SysTick 计数器**读取**,让 `SYSTICK_DelayUs` 能收敛。
它**不会**加快中断投递。
按住 2500 ms 约等于 250 个 tick,是长按阈值的六倍。每次按压都被派发成长按,
而处理函数是在短按松手时动作的:`MAIN_Key_MENU` 在 `if (bKeyHeld)` 分支就提前返回了,
永远不会打开菜单。这一点通过**在按住过程中**读 `gDebounceCounter` 得到确认 ——
按住 3 秒后它是 317,这既证明了时间片在运行,也说明按得远远太久了。
`key.py` 现在的值:`HOLD_MS = 200`、`LONG_HOLD_MS = 900`。端到端验证过 ——
`key.py MENU DOWN DOWN` 把菜单从 01/79 移到 03/79,`key.py UP` 又移回 02/79。
**如果某个按压像是被忽略了,不要延长按住时间。** 检查那个处理函数是不是想要短按,
再检查 `gEeprom.KEY_LOCK`(键盘锁上时 LCD 会画一个锁形图标,那时忽略按键是**正确行为**)。
### 驱动菜单:把一串按键作为一批发送
有三件事会让按键序列落到你没打算去的地方。这三件在这里都花过时间。
**在两次按压之间用 gdb 会暂停 guest。** 每次 `gdb-multiarch -batch` attach 都会在其执行
期间停住机器。每按一次就去看 `gMenuCursor`,会把一个六次按压的序列拖过 20 秒的菜单超时
(`App/misc.c` 里的 `menu_timeout_500ms`),于是界面**静默地**退回主界面,
剩下的按压就变成在调 VFO 而不是在导航。要在一个 Python 批次里通过 QMP 把整个序列发完,
最后再读一次状态。
**在子菜单内部 UP/DOWN 是反的。** 当 `gIsInSubMenu` 且 `!gEeprom.SET_NAV` 时,
`MENU_Key_UP_DOWN` 会翻转 `Direction`(`app/menu.c:2311`)。在列表里 DOWN 是往下移;
在编辑数值时,UP 是**减小**它。数值还会在 `MENU_GetLimits` 处**钳制而不是回绕**,
所以过冲会停在边界上。
**MENU 是切换,不只是进入。** 在主界面短按 MENU 打开菜单;在列表里它进入子菜单;
在子菜单里它**提交**(`gFlagAcceptSetting = true`)并退出一层。所以从列表连按两次 MENU
等于进去又立刻出来,看起来像什么都没发生。
**数字跳转**:在列表里输入菜单编号会直接跳过去,比数 DOWN 次数可靠。单个数字很可靠。
两位数需要两次按压落在同一个输入框窗口内,而 `MENU_Key_0_to_9` 一旦第一个数字是合法索引
就会跳转并返回(`app/menu.c:1826`),所以先 `3` 再 `0` 会停在 3 而不是 30。
要到达一个很远的条目,可靠的办法是打开菜单后**立刻用一次 gdb attach 预设 `gMenuCursor`**。
这样验证过的:菜单能打开、DOWN/UP 能移动列表、MENU 能进子菜单、数字键能选值。
截图确认了 Step 在 01/79、两次 DOWN 之后 RxDCS 在 03/79、BatSav 在 30/79 显示 OFF。
### row_out 必须保持 volatile,否则 GCC 会删掉键盘
`UVK5KeypadState::row_out` 声明为 `qemu_irq volatile`。**去掉 `volatile`,键盘就彻底不工作**:
无论醒着还是省电状态,没有任何按压能到达界面,而且**不会有任何警告**。
`tools/keypad_test.py` 覆盖这一点。
原因在目标代码里看得见。`qdev_init_gpio_out_named()` 是可内联的,而且只记录那个数组;
那些线路是后来由板级代码的 `qdev_connect_gpio_out_named()` 填进去的,**而 GCC 看不到那一步**。
不加 volatile 的话,GCC 在 -O2 下会证明每个元素都还是 NULL,看到 `qemu_set_irq()`
对 NULL irq 会立即返回,于是把 `keypad_update_rows()` 的函数体连同**所有五个调用点**一起删掉:
能到达 keypad_update_rows 的调用者
不加 volatile {} <- 一个都没有;调用全被删了
加 volatile {keypad_key_changed, keypad_col_changed, keypad_set_press,
keypad_reset, uvk5_machine_init}
`keypad_col_changed` 会被编译成一次存储加一个 `ret`,**完全没有调用**。加上 `volatile`
之后它以 `jmp keypad_update_rows` 结尾。所以没有任何行线被驱动,固件的扫描读到全高,
模型看起来是坏的。
走到这一步经历了**三个错误诊断**,都值得知道:
1. **"省电模式停掉了键盘扫描。"** 这条曾被当作模型缺陷写在这里。**不是** ——
醒着的时候一样是坏的。
2. **"它需要一点稳定时间。"** 有三个 `fprintf(stderr, "TRACE ...")` 探针在清理时被删掉了,
而把 `keypad_update_rows` 里那个恢复回去就修好了,加一个忙等循环也能修好。
这看起来像是时序依赖。**并不是** —— 那个 fprintf 和那个循环只是 GCC 无法丢弃的副作用,
它们让那个循环活了下来。
3. **"这是编译器的顺序问题。"** 一个零开销的
`__asm__ __volatile__("" ::: "memory")` 也能修好,8/8。同样的原因:
屏障是一个未知副作用,所以循环存活。
**最终定论靠的是对比两个目标文件,而不是对比行为。** 独立的 `keypad_update_rows` 符号
在两种情况下**指令完全相同**,这就是为什么早期只 diff 那个函数什么都没发现 ——
函数被内联进了它的调用者,差异在那里。
实测数据,每项 3 次以上,按压附近没有调试器:
| 变体 | 结果 |
| --- | --- |
| 不加 `row_out` | 0/12 |
| 加 `(void)r;` —— 惰性的,无副作用 | 0/6 |
| 完全相同的重新构建(稳定性对照) | 0/6 |
| 忙等循环,1 到 4000 次迭代 | 3/3 |
| `__asm__ ... "memory"` 屏障 | 12/12 |
| **`volatile row_out`**(真正的修复) | **10/10** |
**范围是查过的,不是假设的**:这个文件里另一个 out-GPIO 数组
`PY32GpioState::out` **不受影响**。给它也加 volatile 会产生**逐字节相同**的目标文件,
因为驱动那些线路的函数(`py32_gpio_write`)只能通过 `MemoryRegionOps` 函数指针表到达,
所以 GCC 做不了那种杀死键盘路径的全函数推理。保持它不加。
**要警惕的一般形态**:某个设备的 out-GPIO 线路只由板级代码连接,而驱动它们的函数
GCC 能看到全部调用者。如果一个模型的输出神秘地不起作用,**先去目标代码里查那个调用**,
不要先假设逻辑错了:
objdump -dr build/libqemu-arm-softmmu.fa.p/hw_arm_py32f071.c.o \
| grep -c qemu_set_irq
有两个测量错误让这件事比本来该有的难度大得多,都值得避免:
- **在松开按键之后才去读按键状态。** 一旦键抬起,`gKeyReading0` 永远是 `KEY_INVALID`,
于是它"证明"了这次按压从未被看到。要在**按住过程中**读。
- **相信在 `KEYBOARD_Poll` 上的 gdb 断点。** guest 停着的时候扫描的那些延时不消耗
guest 时间,所以在一个自由运行时返回 `KEY_INVALID` 的构建上,`Poll` 在断点下
会返回 `KEY_MENU`。**这单个观察把方向带错了很久。**
两个相关事实,都由实验确认,这样就没人再花时间了:
- **在 `assets/flash.img` 里打补丁改省电设置没有用。**
`SETTINGS_InitEEPROM` 会比较 flash `0x00A160` 处的版本字符串,在一个新镜像上发现不匹配,
于是写入设置扇区。而 `PY25Q16_WriteBuffer` 会在重新编程之前**擦除整个 4 KB 扇区**,
所以埋在 `0x00A00B` 的字节在 `settings.c:169` 的读取看到它之前就已经没了。
- **guest 侧的设置改动不会持久化。**(历史条目:现在 flash 会写回文件了,
见 flash 章节的第 1 条修复。)
这里有用的工具:`tools/scan_trace.sh`(扫描读到了什么)、`tools/key_result.sh`
(Poll 返回了什么)、`tools/trace_run.sh`(那些 TRACE 点)。
原来在 `qemu/py32f071.c` 里的三个 `fprintf(stderr, "TRACE ...")` 探针已经删掉了 ——
它们在每次键盘轮询时都触发,把控制台埋掉了。它们分别在 `py32_gpio_set_input`、
`keypad_update_rows` 和 `keypad_col_changed` 里;`git log -p -- qemu/py32f071.c`
有确切的行,而且它们**仍然是查看一次按压是否到达模型的最快办法**
(对捕获的 stderr 执行 `grep -c 'keypad row0 -> 0'`)。
把那个 stderr 重定向到文件而不是管道,并且要知道 `keypad_update_rows` 里那个探针
**会把时序改到足以影响结果** —— 见上面关于稳定循环的说明。
注意 `uvk5-sat/build/CW/nr7y.cw.elf` 这个 ELF 不带 DWARF,所以 gdb 会报
`'gEeprom' has unknown type`。标量通过取地址再强转是可以读的
(`*(unsigned short*)&gDebounceCounter`);结构体字段需要手工偏移。
## BK4819,以及建模到哪里为止
寄存器接口已建模(`TYPE_UVK5_BK4819`):位操作驱动的三线总线被正确解码,寄存器读回固件写入的值,
固件只读不写的那些返回合理的值。接线是 CS 在 PF9、SCL 在 PB8、SDA 在 PB9 且双向都接了。
`tools/test_bk4819.py` 通过 QOM 检查寄存器组。
它修好的是这个:RSSI 以前在 18 个调用点上都读到**硬零** —— 也就是 -160 dBm ——
所以 S 表是空的,静噪和扫描逻辑面对的是一个死频段。主界面现在开机显示 400 MHz
而不是 18 MHz 下限,因为波段设置不再读到零了。
有两个约束**不可协商**,都源于固件里的无超时自旋循环:
- **REG_0C bit 0 必须保持清零。** `app/app.c:910` 和 `:1417` 是
`while (BK4819_ReadRegister(BK4819_REG_0C) & 1u)`,**完全没有超时**。
一个卡住的位会让 guest 挂死,而不是降级运行。
- **软复位必须重新播种测量寄存器。** `REG_00` bit 15(`BK4819_Init` 第一件事就发它)
否则会把它们留成零 —— 而真实硬件是**持续测量**的。这不是假设:第一次测试运行
正确解码了 48 个寄存器,却仍然报告 RSSI 为 0,原因恰恰就是这个。
### 跑测试
bash tools/run_tests.sh # 全部
bash tools/run_tests.sh -q # 只跑单元测试,不需要模拟器,约 15 秒
**用这个 runner,不要一条条粘命令。** 它先检查构建,失败就**停下**,这一点比听起来重要:
`ninja` 失败时会把上一个二进制留在原地,于是测试会心情愉快地对着一份从未编译过的代码跑。
在养成这个习惯之前,这产生过两轮**完全无意义**的结果。
它还只在 `qemu/py32f071.c` 与 QEMU 树里的副本不同时才重新构建,所以一次普通的测试运行
不会为不需要的重建付时间。
**runner 会先检查自己**,通过 `tools/test_run_tests.sh`。它的第一版写的是
if "$@" 2>&1 | sed 's/^/ /'; then
那检查的是 **sed 的**退出码,不是测试的 —— 所以无论什么坏了,每个测试都会被算成通过。
因此改用 `PIPESTATUS[0]`,并加了一个自检来断言一个失败的测试真的被计数并具名。
**一个不会失败的 runner 比没有 runner 更糟,因为它会被信赖。**
测试输出还会先过 `tr -cd`:gdb 驱动的测试会输出杂散字节,让日志在 grep 眼里成为
"binary file",从而**吞掉汇总行**。
模拟器测试会在私有端口上启动自己的 QEMU,每个耗时 20-30 秒,所以它们不会干扰
正在运行的 `run.sh` 或网页界面会话。
### 数"有多少帧不同"证明的东西比看起来少
写任何观察屏幕的测试之前值得知道这一点。
一旦接收机报告的 RSSI 会变化,S 表和它的 dBm 读数就会不停重绘。所以"相邻两帧是否不同"
在一台**完全停着不动的电台**上也会返回是。第一次尝试验证扫描是否还能工作时,
拿到了 8/8 帧全不同,**结果什么都没证明**。
改成比较能回答真正问题的那几行。帧缓冲是 128×64,存成 8 页各 128 字节,
第 *p* 页覆盖第 8p..8p+7 行:
page 0 状态行
pages 1-2 上方 VFO,大号频率数字
page 3 上方 VFO 的副行
pages 5-7 下方 VFO
`tools/test_scan.py` 比较 page 1-2,那里**只有在电台重新调谐时才会变**:
6 次采样得到 6 个不同的调谐位置。这很重要,因为"接收机永远繁忙"是一种很有可能
卡死扫描的情况,而 S 表那部分工作正好让接收机变成了永远繁忙。
顺带一提,**page 4 不是 S 表那一行** —— 在频率变化的同时,它在全部六次采样里逐字节相同。
### 什么算真正还原,什么只是"能响应读取"
这一节是在一次**合理的批评**之后写的:进度汇报一直在说什么**能跑**,
而不是什么**真正被还原了**。这两者不同,而且差距很容易被藏起来。
按固件自己的调用点数统计:
| 外设 | 调用点 | 状态 |
|---|---|---|
| GPIO | 55 | 已建模 |
| DMA | 59 | 已建模,跑在 CPU 的地址空间上 |
| SPI | 33 | 已建模,含 flash |
| TIM | 23 | TIM2 自 `fdcbe80` 起已建模;其余仍是 stub(背光 PWM) |
| ADC | 19 | 已建模;结果自 `e46cae2` 起可设置 |
| USART | 11 | 双向都已建模 |
| RTC、IWDG、WWDG、I2C、USB、CRC、EXTI、PWR | 0 | stub,而且固件从不使用它们 |
此外,SoC 之外还有:键盘、BK4819 寄存器接口、以及音频使能线。
**stub 只是接受写入并返回上次的值。** 那足以不挂死,仅此而已。这个区别之所以重要,
是因为**从上层完全看不见**:ADC 是**已建模**的,却依然永远返回硬编码的 2200,
于是 `gBatteryDisplayLevel`、`gLowBattery` 和低电弹窗**根本到不了**。
**能响应读取不等于被还原了。**
诚实的总结是:**固件依赖的数字侧已经被还原,而模拟侧没有、也不可能有**。
频率、flash、键盘、串口、寄存器编程、电池 —— 全都是真的。音频采样和射频行为 ——
无论在 MCU 的地址空间里还是在任何公开 datasheet 里,都不存在可建模的数据。
`millis()`/TIM2 和可设置的 ADC 补上了那两个真正要紧的缺口。剩下的,以及为什么:
**背光 PWM —— 刻意不建模。** `backlight.c` 用 TIM7 触发 DMA 通道 7,从一张 32 项的
占空比表重写 GPIOA 的 `BSRR` 来实现中间亮度,频率是
`PWM_FREQ * DUTY_CYCLE_LEVELS` = 128 kHz。建模它意味着每模拟秒 12.8 万次 GPIO 写入
和 DMA 传输,而且**没有任何可观测的变化**:背光是物理 LED 亮度,不碰帧缓冲,
所以 `frame.png` 两种情况下逐字节相同。而那两个**确实有可观测行为**的端点 ——
亮度 0 和全亮 —— 完全绕过定时器,直接调
`GPIO_TurnOffBacklight`/`TurnOnBacklight`,那两条已经能工作。代价高,收益为零。
**EXTI** —— 今天有零个调用点。任何改成中断驱动的重构都需要先有它。
### 音频:没有东西可以建模,而这本身就是结论
"加个喇叭和麦克风,然后在浏览器里授予音频权限"是很自然的要求,而它**做不到** ——
不是因为不够努力,而是因为**这两个器件都不在 MCU 上**。接收音频在 BK4819 内部解调,
以模拟信号从它的 AF 引脚出来;发射音频从麦克风进入芯片自己的 ADC。固件能碰的只有:
PA8 功放使能 (GPIO_EnableAudioPath, driver/gpio.h:34)
REG_47 芯片路由哪一路 AF 源
REG_64 一个它用来显示的电平值
**MCU 的地址空间里任何地方都不存在音频采样。** 没有东西可采集、没有东西可播放、
也没有内容需要浏览器权限去承载。在这里生成声音,等于**编造固件从未产生过的数据** ——
这和模拟量射频的界线是同一条。
**真实的是那个"意图"。** `TYPE_UVK5_AUDIO` 监视 PA8 并暴露只读的 `speaker-on`;
界面显示一个喇叭图标,`/api/status` 上报 `speaker`。**刻意做成只读**:
可写只会让测试骗自己。还有一条单元测试断言这个页面**永远不会**请求音频权限 ——
没有 `getUserMedia`、没有 `AudioContext`、没有 `<audio>` ——
因为让用户去批准一件不可能发生的事,比不提供它更糟。
### 一个比真实客户端更宽容的 stub 比没有 stub 更糟
`QmpClient.command` 返回的是**已解包**的值,出错时抛异常。而测试 stub 返回的是
`{"return": ...}`。于是 `webui.py` 被写成再解包一次,**88 个测试全部通过**,
而线上界面返回 500:
TypeError: argument of type 'bool' is not iterable
两个教训,都在这里花过时间。stub 现在被一条显式测试**钉在真实契约上**。
另外那个失败最初被一个裸的 `except: return None` 吞掉了,这让"调用坏了"和
"电台就是没在响"**无法区分** —— 还害我去追一个并不存在的僵尸进程。**要把原因记进日志。**
### PTT,以及发射电平条
**PTT 不是矩阵按键。** `GPIO_IsPttPressed` 直接读 PB10(`driver/gpio.h:31`,低电平有效),
所以模型给它一条独立的 GPIO 线而不是一个行列交点,在键盘设备上暴露为布尔属性 `ptt`。
正是这一点让发射电平条变得可达。`app/app.c:1700` **只在**
`gCurrentFunction == FUNCTION_TRANSMIT` 且 `gSetting_mic_bar` 置位时才绘制它 ——
后者是 flash `0xA0A8` 处 `Data[7]` 的 bit 4(`settings.c:423`),而空白 flash 读作
`0xFF`,所以它本来就是开的。电平本身来自 `REG_64`,经 `BK4819_GetVoiceAmplitudeOut`。
**把释放当成更重要的那一半。** 一个卡住的 PTT 会让模拟电台一直处于键控状态,
于是之后每一个测试都在对着一台正在发射的电台运行。网页界面在 `pointerleave`、
`pointercancel` 和 `pagehide` 时释放;`/api/release-all` 会**显式**清掉 PTT,
因为一个空的 `press` 碰不到它;而那个接口拒绝非布尔的请求体,
所以 `{"held": "false"}` 不能靠 truthiness 把发射机打开。
`tools/test_ptt.py` 断言的是释放,不只是按下。
如果你要再加一个非按键的按钮,有个陷阱值得知道:浏览器把处理函数装在了 `.key` 上,
而那也匹配到了 PTT 按钮,但它没有 `data-key` —— 于是它会发出按键 `"undefined"`。
用 `.key[data-key]`。
### 读取整体错位了一位,而这掩盖了其他一切
在 `ad88ee1` 修复,但值得一读,因为它藏了那么久。
寄存器读取到达时**整体左移了一位**:给 `REG_0C` 塞 `0x1248`,固件收到 `0x2490`。
固件每读一位是"读/拉高/拉低",所以命令字节的第八位之后、数据阶段之前还有一个下降沿 ——
而模型把那个沿当成了数据时钟,在 guest 采样之前就把 bit 15 移走了。
**为什么没人发现**:**写入一直是好的**,52 个寄存器精确保存着固件写入的值,
而固件轮询最勤的那个寄存器**本来就合法地是 `0`**。读到零、拿到零,看起来像成功。
**验证一条读取通路必须用一个已知非零的寄存器** —— `REG_3F` 是 `0x0C0C`,
`REG_78` 是 `0x2F5B`。
`tools/test_bk4819_readback.sh` 现在守住这一点:给 `REG_0C`(每 30 秒被读约 1700 次,
所以一定能采到)塞一个两个半字都带位的值,并在失败时**指出移位方向**。
bit 0 刻意留空 —— 一旦置位固件就会进入一个无超时的确认循环,而那个测试只关心对齐。
这也**推翻了之前四个诊断**。之前尝试静噪中断时,模型抬起的是 `REG_0C` bit 0,
而固件收到的是 bit 1,于是
while (BK4819_ReadRegister(BK4819_REG_0C) & 1u)
**永远不成立**,1719 次轮询看到了一个 guest 无法处理的标志。那几轮每一次都被归咎于
时序或门控。**当多个互相独立的尝试以同样的方式失败时,该怀疑共用的传输层,
而不是它上面的逻辑。**
### 静噪中断与 S 表:五次尝试,然后成了
**现在能工作了**(`e6cebed`)—— 想看结论可以直接跳到最后。那四次失败的尝试保留下来,
是因为每一次都产生了一个**自信的错误诊断**,而它们失败的**模式**才是有用的部分。
扫描很早就能工作:长按 `*`,频率确实会步进,7 秒内 6 帧各不相同。S 表不行,
因为 `ui/main.c:2370` **只在** `FUNCTION_IsRx()` 时才绘制它,
而那需要 `gCurrentFunction` 处于接收状态 —— 这要求芯片**报告静噪打开**,
而不只是有一个健康的 RSSI。
机制看起来很清楚:`REG_0C` bit 0 表示有中断待处理,固件写 `REG_02` 确认然后再读回它取标志位,
而 `sqlFound` 是 bit 3(位域定义在 `app/app.c:915`)。**结果位的选择和对机制的这个理解
两者都是错的。**
我实现了它 —— 在固件使能中断时抬起一次 `sqlFound` —— 然后**撤回了**。
guest 照样在跑,但事后 `REG_0C` bit 0 仍然是置位的:固件没有取走那个中断。
**那是一个潜伏的挂死**,因为 `app/app.c:910` 和 `:1417` 在那个位上无超时自旋,
所以任何在该位卡住时到达那里的路径都永远不返回。
**交付一个埋着挂死的模型,比交付一个没有 S 表的模型更糟。**
**第二次尝试,以及真正的原因。** 再试了一次,这次是在固件**轮询** `REG_0C` 时评估静噪,
而不是在它配置芯片时 —— 这修正了最初的错误,因为启动序列会把 `REG_3F` 写成 `0x0000`
再写成 `0x0C0C`,来回三次,所以在使能那次写入时抬起的标志**在任何人读到之前就被禁用了**。
同时也纠正了阈值字段:RSSI 开启电平在 `REG_78` 的 bit 15:8,单位 0.5 dB/step,
对应 `REG_67` 的 0.25 dB/step,**而不在 `REG_4E` 里**(那些低位是 **glitch** 阈值,
用它们意味着静噪根本不会打开)。
改对之后,芯片侧的一切都对得上 —— 实测 `en=0x0C0C`、`rssi=0x01E0`、阈值 94、
`REG_0C` 正确返回 1。**固件依然从不确认。** 而原因完全不在芯片侧:
gCurrentFunction=5 (FUNCTION_POWER_SAVE), gRxIdleMode=1
门控在 `app/app.c:1697`:
if (gCurrentFunction != FUNCTION_POWER_SAVE || !gRxIdleMode)
CheckRadioInterrupts();
在那个状态下两半都为假,这看起来就是答案了:没有 `CheckRadioInterrupts`,
所以没人去取那个标志。
**那个解释是错的,而推翻它的那个实验值得保留。** `app/app.c:1374` 在
`BATTERY_SAVE == 0` 时**直接拒绝**进入省电,而那个字节在 flash `0xA00B`
(空白 flash 读作 0xFF,`settings.c` 把它钳到 4 —— 最深的档位,这就是模拟器
一直停在那里的原因)。把那个字节改成 0,然后:
BATTERY_SAVE=4: fn=5 idle=1 polls=2161 acks=0
BATTERY_SAVE=0: fn=0 idle=0 polls=2161 acks=0
**门控通过了,而确认次数依然是零。** 一个在 `BK4819_ReadRegister` 上的 gdb 回溯确认
那个循环确实在运行 —— `CheckRadioInterrupts` 被内联进了 `APP_TimeSlice10ms`,
而那就是调用者:
#0 BK4819_ReadRegister
#1 APP_TimeSlice10ms
#2 Main
所以固件读 `REG_0C`、拿到 1、然后**不写** `REG_02`。压制它的东西在那个被内联的循环内部、
门控之后。把模型门控在 `REG_30`(`BK4819_Sleep` 会清零它)上也没用 ——
模型被询问时芯片是醒的,而固件仍然报告 `gRxIdleMode=1`。
**在 `e6cebed` 解决。** S 表读数是:`-53` dBm、S9 以上 `+40`、13 段里的 9 段、
`MONI`、以及一个在走的接收计时器。**这些数字自洽** —— UHF 上 S9 是 −93 dBm,
所以 −53 确实就是 S9+40。
有三件事必须同时正确,而**发现它们的顺序**才是困难所在。
*标志是 `SQUELCH_LOST`,bit 2。* 按 `app/app.c:1027`,"squelch lost" 才是那个
把 `g_SquelchLost` 设为 true 的东西,意思是**有信号**。而 `SQUELCH_FOUND`
读起来像"发现了信号",含义却相反。位定义在 `App/driver/bk4819-regs.h:290`。
*上报必须限速* —— 这里是每 64 次轮询一次。只报一次,固件会在启动期间就取走它,
那时这个标志还通向任何地方之前;每次轮询都报,请求位就会在**固件自己的收集循环内部**
被重新置位,而那个循环用 `REG_0C` 作条件且没有超时,于是永不退出。
**周期性上报同时满足两者**:循环总能排空,而这个消息会一直重复直到它开始有意义。
*真正的入口根本不是中断。* 电台空闲时停在省电模式,在那里它**不理静噪** ——
这就是为什么在 `BK4819_GetRSSI` 上的断点一次都没触发。`ACTION_Monitor`
**完全绕过静噪**:`app/app.c:482` 在 `gMonitor` 置位时选 `FUNCTION_MONITOR`
而不是 `FUNCTION_RECEIVE`,而 `settings.c:263` 把一个越界的存储动作默认成
`ACTION_OPT_MONITOR` —— 空白 flash(`0xFF`)正是越界。所以
**在一个 pristine 镜像上,短按 SIDE1 就会进入监听模式**:
之前: fn=5 idle=1 monitor=0 (FUNCTION_POWER_SAVE)
之后: fn=2 idle=0 monitor=1
要门控在 `RX_DSP`(`REG_30` bit 0)而不是整个寄存器是否为零:TX 和音调路径会在
`RX_DSP` 清零的情况下置上其他位,那样就会被误当成一台活着的接收机。
`tools/test_smeter.py` 端到端覆盖这条路径,并且比较**亮像素计数**而不是逐像素匹配,
这样一个无关的界面改动不会造成一个莫名其妙的失败。
有四个测量错误让这件事花的时间远超代码本身的难度。这四个**都产生了自信的错误结论**:
- **在 `REG_0C` 读取处采样 PC** 会落在 `BK4819_WriteU8`,也就是那个位操作辅助函数,
而不是调用者。采样 LR 也不行:`BK4819_ReadRegister` 会调用 `BK4819_ReadU16`,
所以 LR 指回读取函数内部。**用断点加回溯。**
- **一个在赋值之前打印 `shift_out` 的探针**,把一个即将作为 `0001` 发出的值报成了 `0000`。
差点变成"模型送出了错误的值"。
- **`BK4819_ReadRegister` 对 REG_0C 返回 0x0** 看起来像读取通路坏了,
我还据此改了位时序。但 REG_0C 在已提交的构建里**本来就合法地是 0** —— 没有东西去抬它。
**一个寄存器读取返回该寄存器真实的内容,什么都证明不了。**
要对着一个固件确实写过的寄存器检查(`REG_3F` 是 `0x0C0C`,`REG_78` 是 `0x2F5B`)。
- **断点之后用 `nexti`** 落到了一个无关的位置并报告 `r0 = 0`,喂给了同一个错误结论。
**`finish` 才给出真实的返回值。**
另外注意在这个目标上 **gdb 无法调用 guest 函数**(`print BK4819_ReadRegister(0x3f)` 会报错),
而且没有 `gCurrentRSSI` 这样的全局变量可读 —— RSSI 是读完即弃的。
**断点加 `finish` 是唯一能看到固件实际收到什么的办法。**
**到哪里为止。** 这里建模的是寄存器接口,不是电台。它复现固件**下达了什么命令** ——
频率、功率档位、什么时刻键控 —— 而**从不**复现模拟结果:键控包络、杂散发射、灵敏度。
**这不是以后能补上的缺口。** 这颗芯片没有公开 datasheet,所以它的驱动是唯一可得的规格,
而驱动只告诉你**哪些寄存器被写了**,永远不会告诉你**天线上出去了什么**。
那些问题需要真机加频谱仪。**不要让任何人从一个通过的模拟器测试里得出相反的结论,
包括这里加的那些测试。**
时序也是刻意错的 —— 见 README 里的 SysTick 章节。对菜单和控制流没问题;
对信号时序毫无用处。
## 串口,双向
可以工作,而且 `tools/test_serial_rx.py` 通过讲真实协议来证明这一点:
`0x0514` hello 会得到 `0x0515` ack,`0x051B` 会返回请求的 EEPROM 字节。
用 `-serial unix:/path/to.sock` 或任何其他字符设备接入;默认是 `serial0`。
有三件事必须同时对上,而每一件单独出错时都是**静默的**:
- **USART1 需要一个字符设备。** 否则它就是个寄存器 stub,进来的字节无处可来。
- **DMA 必须服务 USART,并递减 `CNDTR`。** `driver/uart.c` 从不读 DR。
它通过一个循环模式通道接收,并用
`sizeof(UART_DMA_Buffer) - LL_DMA_GetDataLength(...)` 定位新数据,
所以一个**永不移动的计数**意味着一个**永远看起来是空的缓冲区**,
不管实际到了多少字节。这个服务是在读取 `CNDTR` 时执行的,
而那恰好就是驱动查看的地方 —— 不需要定时器,而且在 guest 主动询问之前不会投递任何东西。
- **对 DR 的写入也必须到达字符设备。** 它们以前只送到 stderr。
于是宿主机上的工具发一条命令、固件确实回复了、而那个回复去了工具看不到的地方。
**这和"被忽略"完全无法区分**,还害我多debug了一轮:新测试第一次运行报告
"完全没有回复",同时启动输出是**零字节** —— 看起来像接收坏了,
而实际上发送一直好着,只是不可见。
通道还会记录它被编程的长度,因为 `CNDTR` 是倒着数的,写入偏移必须由差值算出来。
## 如果你要加一个外设
1. 从 CMSIS 头文件里读寄存器布局
2. 只建模固件真正碰到的部分;带日志的兜底模块(`py32-stub`)会告诉你那是哪些
3. 警惕自旋循环:任何固件会轮询的标志都必须**能够变化**,
而写 1 启动的位(比如 `ADC_CR2_CAL`)**绝不能被存成置位状态**
4. 重新构建、运行,并用 `tools/where.sh` 确认固件越过了它原来停住的地方
+15 -3
View File
@@ -7,6 +7,8 @@ The firmware boots to its main loop in about five seconds, the LCD contents are
readable, and the keypad drives the menus. See [Status](#status) for what is and
is not modelled.
*中文:[README.zh-CN.md](README.zh-CN.md) · the two are kept in step; change both.*
| Main screen | Menu | Navigated with keys |
| --- | --- | --- |
| ![main VFO screen](docs/screenshots/main-vfo.png) | ![menu at Step](docs/screenshots/menu-step.png) | ![menu at RxDCS](docs/screenshots/menu-navigated.png) |
@@ -78,6 +80,7 @@ keypresses silently stop working. Run the test after touching that code;
calibration.bin 512-byte dump from a real radio
deploy/ nginx vhost for the HTTPS front end
docs/reverse-proxy.md how https://k6v3.mckero.dn42/ is served
*.zh-CN.md Chinese translations, kept in step
docs/screenshots/ LCD captures used in this README
tools/ run, screenshot, inject keys, probe state
keypad_test.py keypad regression test, boots its own instance
@@ -102,6 +105,12 @@ keypresses silently stop working. Run the test after touching that code;
uvk5_qmp.py QMP client
uvk5_lcd.py framebuffer decode, PNG encode, frame grabber
uvk5_keys.py key names the keypad model accepts
uvk5_logs.py shared log buffer, with client-IP attribution
uvk5_stream.py the MJPEG-style frame pump behind /stream
uvk5_supervisor.py starts, stops and recovers the emulator process
test_kill_emulator.sh cleanup never kills an unrelated process
(plus ad-hoc probe scripts -- scan_trace.sh, gpio_watch.py and friends --
kept because they are quick to reach for, not because they are polished)
harness/, stubs/, shim/, tests/ host build of the CW timing chain (stage A)
## Building
@@ -227,8 +236,11 @@ Endpoints, if you want to script it:
| `GET /stream` | multipart PNG stream, up to 15 fps |
| `GET /frame.png` | one frame |
| `POST /api/key` | `{"key": "MENU", "action": "down"}` — also `up` or `tap` |
| `POST /api/release-all` | release every key, if one ever sticks |
| `GET /api/status` | QMP `query-status` |
| `POST /api/ptt` | `{"held": true}` — hold PTT, `false` to release |
| `POST /api/release-all` | release every key and PTT, if one ever sticks |
| `POST /api/power/<action>` | `on`, `off`, `reset`, `pause`, `resume` |
| `GET /api/logs?since=N` | log entries after cursor N, with client IPs |
| `GET /api/status` | QMP `query-status`, plus a `speaker` field |
Frames are read with QMP `memsave`, about 1.35 ms each, and the guest keeps
running throughout. Two details there are easy to get wrong:
@@ -323,7 +335,7 @@ Register layouts come from the vendor CMSIS header shipped with the firmware
SPI2 0x40003800 flash
ADC1 0x40012400
Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1, and the PY25Q16 flash.
Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1, TIM2, and the PY25Q16 flash.
Everything else answers through a logging catch-all — the log is how the next
thing worth modelling gets identified.
+365
View File
@@ -0,0 +1,365 @@
# UV-K5 V3 模拟器
在 PC 上运行泉盛 UV-K5 V3 / UV-K1 固件。这台电台用的是普冉 PY32F071(Cortex-M0+),
QEMU 没有对应的机器模型,所以这里加了一个。
固件约五秒进入主循环,LCD 内容可读,键盘能驱动菜单。哪些建了模、哪些没有,见
[还原程度](#还原程度)。
*English: [README.md](README.md) · 本文档与英文版内容对应,改动请同步两份。*
| 主界面 | 菜单 | 按键导航后 |
| --- | --- | --- |
| ![主 VFO 界面](docs/screenshots/main-vfo.png) | ![菜单停在 Step](docs/screenshots/menu-step.png) | ![菜单停在 RxDCS](docs/screenshots/menu-navigated.png) |
这是真实截图,不是效果图:`tools/screenshot.py` 从 guest 内存里读出固件的
`gFrameBuffer` 再渲染,所以这些就是 LCD 驱动实际写下的像素。从左到右依次是双守候主界面、
用 `key.py MENU` 打开的菜单(第 01/79 项 Step)、以及 `key.py DOWN DOWN` 之后的 03/79。
## 它用来做什么
改一行固件就要重新烧进电台验证,太慢;而且有些 bug 从外面根本看不见。举个真实例子:
CW 宏录制看起来毫无反应,原因在三层之下 —— keyer 被一个后续调用拆掉了,那个调用从错误的
VFO 重算了状态。在真机上你只看到"什么都没发生",在这里可以直接读那些变量。
它**不做**的事是复现电台行为。它复现固件**下达了什么命令** —— 频率、功率档位、什么时刻键控 ——
而不是模拟结果。键控包络、杂散发射、灵敏度都需要真机加频谱仪。这不是以后能补上的缺口:
收发芯片没有公开 datasheet,它的驱动是唯一可得的规格。
## 还原程度
| 项目 | 状态 |
| --- | --- |
| 启动到主循环 | 可用,约 5 秒 |
| LCD 内容 | 可用,经 `tools/screenshot.py` |
| SPI flash、设置、校准数据 | 可用,且断电保留 |
| 频率输入 | 可用,按波段分别存储并保留 |
| 键盘与菜单导航 | 可用,含从省电模式唤醒 |
| 串口输出(固件日志) | 可用,出现在网页日志里 |
| 串口输入、CPS 编程协议 | 可用,`-serial` 接任意字符设备 |
| BK4819 寄存器接口 | 可用,RSSI 和状态可读 |
| S 表 | 可用,经监听模式(SIDE1) |
| 信号强度 | 取决于调谐位置:虚拟电台 vs 噪底 |
| PTT 与发射 | 可用;TX 标识、计时器、话筒电平条 |
| 喇叭 / 麦克风音频 | **不存在可建模的采样**,见[音频](#音频) |
| `millis()` / TIM2 | 可用;按接近实际时间的速率递增 |
| 时序精度 | **故意是错的**,见[时序](#时序) |
| 模拟量射频行为 | **没建模,也永远不会有**,见 [AGENTS.md](AGENTS.md#the-bk4819-and-where-modelling-it-stops) |
短按 `tools/key.py MENU` 打开菜单,UP/DOWN 在其中移动,MENU 进入子菜单,直接输入菜单编号
可以跳到那一项。按键时长决定短按还是长按,而固件把这两者当成不同事件 —— 见[时序](#时序)。
**按键时长是最需要拿准的东西。** 按住 400 ms 以上算**长按**,处理函数的反应完全不同:
`MAIN_Key_MENU` 在短按松手时打开菜单,走长按路径则什么都不做。所以如果某个键像是被忽略了,
应该**缩短**按压时间而不是延长。从省电模式唤醒不需要任何特殊操作 —— 一次 200 ms 的按压
既能唤醒电台也能打开菜单,空闲 45 秒后实测有效。
`tools/keypad_test.py` 会在一个临时 QEMU 实例上验证以上全部。它之所以存在,是因为键盘有一个
很不直观的陷阱:键盘模型的 `row_out` 数组**必须保持 `volatile`**,否则 GCC 在 -O2 下会证明
那些线路始终为 NULL,从而删掉所有对 `keypad_update_rows()` 的调用 —— 结果是没有任何行被驱动,
按键静默失效。改动那部分代码后请跑这个测试;`AGENTS.md` 里有目标代码层面的证据。
## 目录结构
qemu/ 需要复制进 QEMU 源码树的文件
py32f071.c SoC 与机器定义(主要工作量在这里)
armv7m_systick.*.patched 加了 poll-boost 属性的 SysTick
assets/ flash.img,以及作为参考副本的 pristine/
calibration.bin 从真机导出的 512 字节校准数据
deploy/ HTTPS 前端用的 nginx vhost
docs/reverse-proxy.md https://k6v3.mckero.dn42/ 是怎么提供服务的
*.zh-CN.md 中文翻译,与英文版同步维护
docs/screenshots/ 本 README 用到的 LCD 截图
tools/ 运行、截图、注入按键、探查状态
keypad_test.py 键盘回归测试,自己启动实例
test_flash_persist.py flash 写入能跨断电保留
test_freq_entry.py 输入的频率生效并保留
test_serial_rx.py 固件会响应编程命令
test_bk4819.py BK4819 寄存器接口,RSSI 不再恒为零
test_bk4819_readback.sh 寄存器读回的位对齐正确
test_smeter.py 监听时 S 表能读到信号
test_ptt.py PTT 能键控电台并干净释放
test_scan.py 繁忙频段不会卡死扫描
test_audio_path.py 固件想出声时功放会打开
test_battery.py 电量与低电告警跟随 ADC
test_millis.py millis() 会递增,超时才可能到期
test_spectrum.py RSSI 取决于调谐位置,不是常数
run_tests.sh 跑上面全部,先检查构建
test_run_tests.sh 验证 runner 真的能发现失败
lib_kill_emulator.sh 只杀模拟器的清理逻辑
webui.py 网页远控:实时 LCD 加可点击键盘
dn42_firewall.sh 把网页端口限制到 DN42 来源
restore_flash.sh 把 flash 镜像回滚到初始状态
uvk5_qmp.py QMP 客户端
uvk5_lcd.py 帧缓冲解码、PNG 编码、抓帧
uvk5_keys.py 键盘模型接受的按键名
uvk5_logs.py 共享日志缓冲,含客户端 IP 归属
uvk5_stream.py /stream 背后的抓帧泵
uvk5_supervisor.py 启动、停止、以及故障后恢复模拟器进程
test_kill_emulator.sh 清理逻辑绝不会杀掉无关进程
(另有一批临时探针脚本 —— scan_trace.sh、gpio_watch.py 等 ——
留着是因为随手就能用,不是因为它们打磨过)
harness/, stubs/, shim/, tests/ CW 时序链的宿主机构建(阶段 A)
## 构建
需要 QEMU 7.2 源码树,以及 `meson`、`ninja`、`libfdt-dev`、`libglib2.0-dev`、
`libpixman-1-dev`。
# 1. 把源文件放进 QEMU 源码树
cp qemu/py32f071.c $QEMU/hw/arm/
cp qemu/armv7m_systick.c.patched $QEMU/hw/timer/armv7m_systick.c
cp qemu/armv7m_systick.h.patched $QEMU/include/hw/timer/armv7m_systick.h
# 2. 注册这台机器。在 $QEMU/hw/arm/Kconfig 里:
# config UVK5_V3
# bool
# default y
# depends on TCG && ARM
# select PY32F071_SOC
# config PY32F071_SOC
# bool
# select ARM_V7M
# select UNIMP
# 在 $QEMU/hw/arm/meson.build 里:
# arm_ss.add(when: 'CONFIG_UVK5_V3', if_true: files('py32f071.c'))
# 3. 只构建 ARM target
cd $QEMU
./configure --target-list=arm-softmmu --disable-docs --disable-tools
cd build && ninja qemu-system-arm
然后验证构建真的能用,大约一分钟:
bash tools/run_tests.sh # 全部,几分钟
bash tools/run_tests.sh -q # 只跑单元测试,约 15 秒,不需要模拟器
**runner 会先检查构建,失败就拒绝继续。** 因为 `ninja` 失败时会把上一个二进制留在原地,
否则测试会拿一份从未编译过的代码跑出"通过"。单个测试仍然可以独立运行:
python3 tools/keypad_test.py
python3 tools/test_flash_persist.py
python3 tools/test_freq_entry.py
python3 tools/test_serial_rx.py
python3 tools/test_bk4819.py
bash tools/test_bk4819_readback.sh
python3 tools/test_smeter.py
python3 tools/test_ptt.py
python3 tools/test_scan.py
python3 tools/test_audio_path.py
python3 tools/test_battery.py
python3 tools/test_millis.py
python3 tools/test_spectrum.py
这件事比看起来重要。键盘可以在 -O2 下静默失效而**不产生任何编译警告** ——
见[还原程度](#还原程度)里关于 `volatile` 的说明 —— 所以"构建干净"不等于按键能用。
其中几个测试覆盖 flash 路径,那里有四个各自独立的故障,每一个都会把已存频率清零
而**不报任何错误**:细节见
[AGENTS.md](AGENTS.md#the-flash-bugs-four-faults-one-symptom)。
其余测试:
cd tools && python3 -m unittest discover -p 'test_uvk5*.py' -v # 快,不需要模拟器
cd tools && python3 -m unittest test_webui -v # 快,不需要模拟器
python3 tools/test_webui_e2e.py # 自己启动模拟器
## 运行
python3 tools/make_flash.py # 执行一次,生成 assets/flash.img
模拟器会写入这个镜像,所以一次会话可能留下改过的设置、甚至损坏的 EEPROM。
`assets/pristine/` 保存了镜像刚生成时的带校验和副本,`tools/restore_flash.sh` 负责还原:
tools/restore_flash.sh --verify # 参考副本本身是否完好
tools/restore_flash.sh --diff # 当前镜像变了没有,变了多少
tools/restore_flash.sh # 还原,并先保存当前镜像
参考副本以 gzip 存储,占 2.3 KiB 而不是 2 MiB —— 因为镜像几乎全是 0xFF,小到可以放进 git。
运行时的镜像仍被忽略:它是会被写入的构建产物。
tools/run.sh # 启动机器
tools/where.sh # 固件当前执行到哪里
python3 tools/screenshot.py --frame-addr 0x200013DC \
--status-addr 0x2000175C --port 1234 --out screen.png
python3 tools/key.py MENU # 注入一次按键
tools/gpiob_dump.sh # GPIOB 寄存器
这台机器在 1234 端口暴露 GDB stub,QMP socket 在 `/tmp/uvk5-qmp.sock`。它是无头的:
屏幕是从 guest 内存里读出来而不是画出来的,所以不需要任何显示后端。
截图需要 `gFrameBuffer` 和 `gStatusLine` 的地址,而它们在不同固件构建之间会变。这样找:
arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine'
## 网页远控
`tools/webui.py` 提供 LCD 画面和一个可点击的键盘,这样就能在浏览器里操作电台,
不用反复敲 `key.py` 加 `screenshot.py`。
tools/run.sh # 先起模拟器
python3 tools/webui.py --frame-addr 0x200013DC \
--status-addr 0x2000175C # 再起服务
打开 <http://127.0.0.1:8080/>。键盘按电台的实际布局排列,侧键在旁边。方向键、
回车(MENU)、Esc(EXIT)和数字键都绑定到了对应的物理按键。
按键时长取自你实际按住的时间,因为固件把超过 400 ms 的按压当作**长按**并派发成不同事件。
浏览器分别发送两个边沿,而不是让服务端按固定时长模拟一次按压。
想脚本化的话,接口如下:
| 路由 | 用途 |
| --- | --- |
| `GET /` | 页面本身 |
| `GET /stream` | multipart PNG 流,最高 15 fps |
| `GET /frame.png` | 单帧 |
| `POST /api/key` | `{"key": "MENU", "action": "down"}` — 也可以是 `up` 或 `tap` |
| `POST /api/ptt` | `{"held": true}` — 按住 PTT,`false` 释放 |
| `POST /api/release-all` | 释放所有按键,万一有键卡住 |
| `GET /api/status` | QMP `query-status`,另含 `speaker` 字段 |
画面用 QMP `memsave` 读取,每帧约 1.35 ms,且 guest 全程继续运行。这里有两个细节很容易搞错:
- **必须用 `memsave`,不能用 `pmemsave`。** 帧缓冲符号是 CPU 虚拟地址。`pmemsave` 会把参数
当成物理地址,返回一整块零 —— 于是画面渲染成全空白,而且哪里都不报错。
- **不要走 gdb。** `screenshot.py` 通过 gdb 读帧,而 gdb 每次 attach 都会暂停 guest。
这对实时流完全不可用,而且会扰乱按键防抖的时序。
使用前值得知道的两个限制:
- **QMP socket 只接受一个客户端。** 服务运行期间,`tools/key.py` 无法连到同一个模拟器。
- **没有任何认证。** 任何能访问到这个端口的人都能完全控制这台模拟电台。正因如此,
它默认只绑定 loopback。
### 从别处访问
这里的部署方式是服务只监听 loopback,前面放 nginx 做 TLS,对外是
`https://k6v3.mckero.dn42/`。vhost 配置见
[docs/reverse-proxy.md](docs/reverse-proxy.md),其中对这个应用特别重要的两项是:
`proxy_buffering off`(否则画面流会一阵一阵地到)和 `X-Forwarded-For`(否则每条日志
都会被记成 127.0.0.1)。
用 `--host ::` 直接绑定也可以,但既然没有认证,那这个端口就必须按来源地址过滤。
`tools/dn42_firewall.sh` 把它限制到 DN42:
tools/dn42_firewall.sh apply 8080 # 只允许 DN42 + loopback
tools/dn42_firewall.sh show 8080 # 规则和包计数
tools/dn42_firewall.sh remove 8080
一个容易搞错的细节:这台主机的 `INPUT` 默认策略是 `ACCEPT`,所以只**放行** DN42 的规则
什么都改变不了 —— 一条规则都没有的时候,那个端口本来就是可达的。真正起作用的是**最后那条
`DROP`**。请通过看计数器来验证,而不是靠假设:
tools/dn42_firewall.sh show 8080
# DROP 计数在涨,说明非 DN42 的流量真的被拒了
这些规则重启后不保留。重新执行 `apply`,或者用 `iptables-persistent` 持久化。
**PTT 不在键盘矩阵里**,因为固件是直接读它自己的引脚(PB10),而不是当成矩阵键去扫描。
它在界面上有独立按钮、也有独立接口,而且是"按住"而非"点一下":
curl -X POST -H 'Content-Type: application/json' \
-d '{"held": true}' http://127.0.0.1:8080/api/ptt
任何结束会话的动作都会释放它 —— 把指针拖出按钮、关闭标签页、或者 `POST /api/release-all` ——
所以客户端消失不会让电台一直处于发射状态。`press` 属性仍然拒绝把 "PTT" 当作按键名;
未知按键返回 400 而不是转发出去。
## 音频
**没有音频,而且没有什么可加的。** 在真机上,喇叭和麦克风都不经过 MCU:接收音频在 BK4819
内部解调,以模拟信号从它的 AF 输出出来;发射音频从麦克风直接进入芯片自己的 ADC。
固件全部能碰到的只有三样东西:
| | |
|---|---|
| PA8 | 功放使能,开或关 |
| `REG_47` | 芯片路由哪一路 AF 源 |
| `REG_64` | 一个供固件显示的电平值 |
**MCU 的地址空间里任何地方都不存在音频采样**,所以模拟器没有东西可以采集或播放 ——
网页也不需要麦克风或播放权限,因为根本没有内容需要它承载。在这里生成声音,等于
编造固件从未产生过的数据。
**真实可用的**是"固件此刻是否想出声",而 PA8 精确表达了这一点。界面把它显示成电源状态旁边的
一个喇叭图标,`/api/status` 以 `speaker` 字段上报。按 SIDE1 进入监听模式,它就会亮起。
## 这台机器是怎么搭起来的
寄存器布局来自固件自带的厂商 CMSIS 头文件
(`Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h`),不是猜的。
FLASH 0x08000000 128 KB 应用在 +0x2800,bootloader 在它下面
SRAM 0x20000000 16 KB
RCC 0x40021000
GPIO 0x50000000 端口 A、B、C、F,间隔 0x400
SPI1 0x40013000 显示屏
SPI2 0x40003800 flash
ADC1 0x40012400
已建模:RCC、GPIO、ADC、两个 SPI 控制器、DMA1、TIM2,以及 PY25Q16 flash。
其余全部由一个带日志的兜底模块响应 —— **那份日志正是判断下一个值得建模的东西的依据。**
固件能启动之前,有七件事必须做对,每一件都是靠观察它停在哪里发现的:
- **在应用偏移处做 flash 别名。** 内核从地址 0 取向量表,而镜像加载在 0x08002800,
所以 0 必须别名到那里,而不是 flash 基址。
- **时钟就绪位。** `BOARD_Init` 会轮询它们;每个使能位都要镜像到对应的就绪位。
- **ADC 校准。** `CR2.CAL` 是写 1 启动、硬件自清,所以绝不能把它存成置位状态,
否则等待循环永远出不来。
- **SPI 标志位。** 传输在寄存器写入内部就完成了,所以 TXE 保持置位,RXNE 由写入抬起。
- **DMA。** flash 驱动从不碰 SPI 数据寄存器 —— 它武装通道 4 和 5、使能传输完成中断,
然后自旋等待自己 ISR 设置的标志。
- **SysTick。** 见下文。
- **收发芯片数据线。** `RADIO_SetupRegisters` 等待 BK4819 REG_0C 的 bit 0 清零。
那条总线是 GPIO 位操作驱动的,所以在这条总线有真实模型之前,PB9 保持低电平,
让读取返回零。
## 时序
`SYSTICK_DelayUs` 轮询 SysTick 计数器并累加差值。在真机上每次循环迭代会让计数器前进
几十个 tick;在模拟环境下,一次寄存器读取相对 guest 时间的开销要大得多,所以每次读取
计数器几乎不动。实测:一个 120 ms 的延时,在四秒里只前进了所需 5,760,000 个 tick 中的
832 个 —— 照这个速度要 **7.7 小时**才能完成。
**降低时钟频率没有用**,这一点值得在尝试之前知道:瓶颈是每秒的循环迭代次数,不是计数器速度。
把 48 MHz 降到 200 Hz 只快了 32 倍。
真正有效的办法是报告一个跑在真实值前面、且每次读取都在增长的计数值。SysTick 上的
`poll-boost` 属性就是干这个的。**之前有两次尝试是把那个值写回定时器**,结果每次读取都会
重新锚定计数 —— 上报的值不再变化、固件的 `if (cur != prev)` 判断永远不成立、
循环彻底卡死。
代价是任何延时期间 guest 时间都跑得飞快。对于验证菜单和控制流没问题;用来判断信号时序则是错的。
`poll-boost` 只加速计数器**读取**。SysTick **中断**仍然接近实时触发,而正是它们驱动
`SysTick_Handler` → `gNextTimeslice` → `APP_TimeSlice10ms` → `CheckKeys`。所以固件的
10 ms 时间片阈值在实际时钟下是成立的:一个按键必须按下 20 ms 才会被登记,400 ms 则成为长按。
**把这两者分清很重要。** `tools/key.py` 最初按住按键 2500 ms,前提是以为 guest 时间在这里
也跑得快 —— 结果把每次按压都变成了长按。那些在短按松手时才动作的处理函数(`MAIN_Key_MENU`
就是其中之一)全都无视了它,于是键盘看起来是坏的,其实并没有。
## 阶段 A:宿主机上的 CW 时序链
`harness/`、`stubs/`、`shim/` 和 `tests/` 把 `app/cwkeyer.c` 与 `app/cwmacro.c`
**原样不改**地对着桩驱动编译,配一个虚拟时钟和脚本化的电键输入。喂进一串触点闭合的时间线,
就能对解码出的字符和码元时长做断言。
**固件源码原样编译是刻意的。** 为了让它们能在宿主机上构建而去修改,会让测试与电台实际运行的
代码逐渐脱节。`CW_ReadKeys` 里的防抖是**照抄**而不是打桩的,因为它的不对称性
(要连续三次读取才登记按下,而释放是立即的)本身就是被测时序行为的一部分。
## 许可
Apache 2.0,见 [LICENSE](LICENSE)。
一个例外:`qemu/py32f071.c` 按其文件头声明采用 GPL-2.0-or-later。它被编译进 QEMU,
并且派生自 QEMU 的设备模型(GPL-2.0),所以不可能是别的许可。工具、harness 和文档
是 Apache 2.0。
## 致谢
基础固件:[armel/uv-k1-k5v3-firmware-custom](https://github.com/armel/uv-k1-k5v3-firmware-custom)。
寄存器定义来自厂商 CMSIS 头文件。
+2
View File
@@ -3,6 +3,8 @@
How `https://k6v3.mckero.dn42/` is set up on this host. The emulator UI itself
speaks plain HTTP on loopback; nginx terminates TLS and proxies to it.
*中文:[reverse-proxy.zh-CN.md](reverse-proxy.zh-CN.md) · the two are kept in step.*
## Why a proxy at all
`tools/webui.py` has no TLS and no authentication. Running it on loopback and
+127
View File
@@ -0,0 +1,127 @@
# 用 HTTPS 提供网页界面
`https://k6v3.mckero.dn42/` 在这台主机上是怎么配起来的。模拟器界面本身只在 loopback 上
讲普通 HTTP;由 nginx 终结 TLS 并反代到它。
*English: [reverse-proxy.md](reverse-proxy.md) · 两份内容对应,改动请同步。*
## 为什么要用反向代理
`tools/webui.py` 既没有 TLS 也没有任何认证。让它跑在 loopback 上、由 nginx 面向网络,
意味着可以复用已有的证书和已有的 443 监听,而且**这个界面根本不能被直接访问到**。
## vhost 配置
放在 `/etc/nginx/sites-available/k6v3`,软链接进 `sites-enabled/`。仓库里在
[`deploy/nginx-k6v3.conf`](../deploy/nginx-k6v3.conf) 存了一份副本,
因为这里没有别的东西给 `/etc` 做版本控制。
server {
listen 172.21.91.140:80;
listen [fd3c:3f9b:6424:2::5]:80;
server_name k6v3.mckero.dn42;
return 301 https://$host$request_uri;
}
server {
listen 172.21.91.140:443 ssl;
listen [fd3c:3f9b:6424:2::5]:443 ssl;
server_name k6v3.mckero.dn42;
ssl_certificate /etc/letsencrypt/live/mckero-wildcard/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mckero-wildcard/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
tcp_nodelay on;
}
}
启动服务时绑定到 loopback,因为只有 nginx 需要访问它:
python3 tools/webui.py --frame-addr 0x200013DC --status-addr 0x2000175C \
--host 127.0.0.1
## 那些不能省的配置项
**`proxy_buffering off`。** `/stream` 是一个无限长的
`multipart/x-mixed-replace` 响应。开着缓冲的话,nginx 会把帧攒起来,
于是画面一阵一阵地到、或者看起来卡死了。**这是别人重写 vhost 时最可能漏掉的一项。**
**`proxy_read_timeout` 要远大于空闲帧间隔。** 这个流每 `IDLE_FRAME_INTERVAL_S`(2 秒)
发一个保活帧,所以默认的 60 秒本来还能活 —— 但**一个被暂停的 guest 什么都不产生**,
那时默认值就会掉连接。
**`X-Forwarded-For`。** 日志面板会把每一行归属到一个客户端 IP。在代理后面
`REMOTE_ADDR` 恒为 127.0.0.1,所以**没有这个头的话每一条都会显示成来自服务器自己**。
`webui.py` 只信任第一跳。
**`tcp_nodelay on`。** 按键响应又小又频繁;Nagle 算法会恰好给那些**以延迟为关键**的
请求增加延迟。
## 不需要新地址,也不需要新证书
两者都是刻意的:
- 443 与这些地址上的其他 vhost 共用,靠 SNI 区分,所以不占用额外的 IP。
- 已有的 `*.mckero.dn42` 通配证书本来就覆盖这个名字,所以不需要签发任何东西。这样检查:
openssl x509 -in /etc/letsencrypt/live/mckero-wildcard/fullchain.pem \
-noout -text | grep -A1 'Subject Alternative Name'
**只绑定了 DN42 地址。** 这台主机上的公网地址同样在 443 上监听,而它们**完全没被碰过** ——
**暴露范围是由 `listen` 地址决定的**,所以这个界面从互联网上访问不到。
## DNS
指向它的记录:
k6v3.mckero.dn42. A 172.21.91.140
k6v3.mckero.dn42. AAAA fd3c:3f9b:6424:2::5
## 配置过程中踩到的坑
**`http2 on;` 需要 nginx 1.25.1+。** 这台主机跑的是 1.22.1,那条指令不存在。
更糟的是 `nginx -t` 是在**创建软链接之前**跑的,所以它通过了,
而随后的 `systemctl reload` 失败并让 nginx **停止运行** ——
在那一行被删掉之前,其他站点全都断了。
**先建软链接,再 `nginx -t`,然后 reload。** 在 1.22 上启用 HTTP/2 的语法是
`listen ... ssl http2;`。
**新加的 `listen` 地址需要 reload 才会生效。** 在上面那次失败的 reload 之后,
`systemctl start` 把 nginx 拉回来了,但它**没有绑定** `[fd3c:3f9b:6424:2::5]:443`;
v6 请求失败,而**日志里没有任何错误**。第二次 `systemctl reload nginx` 才创建了那个 socket。
如果一个新加的地址拒绝连接,**先看 `ss -ltnp | grep 443`**,再去别处找。
## 验证
# 两个协议族都测,而且要校验证书,不要用 -k 跳过
curl -s -o /dev/null -w '%{http_code}\n' \
--resolve 'k6v3.mckero.dn42:443:172.21.91.140' \
https://k6v3.mckero.dn42/
curl -s -g -o /dev/null -w '%{http_code}\n' \
--resolve 'k6v3.mckero.dn42:443:[fd3c:3f9b:6424:2::5]' \
https://k6v3.mckero.dn42/
# 这个流必须持续投递帧,而不是最后一次性全来
curl -sk --resolve 'k6v3.mckero.dn42:443:172.21.91.140' \
https://k6v3.mckero.dn42/stream | head -c 20000 | grep -c PNG
# 日志归属:条目里应该带真实客户端地址,而不是 127.0.0.1
curl -sk --resolve 'k6v3.mckero.dn42:443:172.21.91.140' \
https://k6v3.mckero.dn42/api/logs
配置完成后的实测:两个协议族都是 HTTP 200 且证书校验通过,第一帧在 0.01 秒到,
空闲画面下 12 秒 7 帧,日志条目正确归属到 `172.21.91.140` 和
`fd3c:3f9b:6424:2::5`,而固件串口和 QEMU 的日志行正确显示没有客户端。