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 Notes for whoever picks this up next. Focused on what is not obvious from the
code, and on mistakes that already cost time here. 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 ## What this is
A QEMU machine for the Puya PY32F071 (Cortex-M0+), so Quansheng UV-K5 V3 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 | | GPIO | 55 | modelled |
| DMA | 59 | modelled, over the CPU's address space | | DMA | 59 | modelled, over the CPU's address space |
| SPI | 33 | modelled, with the flash | | 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` | | ADC | 19 | modelled; result settable since `e46cae2` |
| USART | 11 | modelled both directions | | USART | 11 | modelled both directions |
| RTC, IWDG, WWDG, I2C, USB, CRC, EXTI, PWR | 0 | stub, and the firmware never uses them | | 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 readable, and the keypad drives the menus. See [Status](#status) for what is and
is not modelled. 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 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) | | ![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 calibration.bin 512-byte dump from a real radio
deploy/ nginx vhost for the HTTPS front end deploy/ nginx vhost for the HTTPS front end
docs/reverse-proxy.md how https://k6v3.mckero.dn42/ is served 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 docs/screenshots/ LCD captures used in this README
tools/ run, screenshot, inject keys, probe state tools/ run, screenshot, inject keys, probe state
keypad_test.py keypad regression test, boots its own instance 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_qmp.py QMP client
uvk5_lcd.py framebuffer decode, PNG encode, frame grabber uvk5_lcd.py framebuffer decode, PNG encode, frame grabber
uvk5_keys.py key names the keypad model accepts 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) harness/, stubs/, shim/, tests/ host build of the CW timing chain (stage A)
## Building ## Building
@@ -227,8 +236,11 @@ Endpoints, if you want to script it:
| `GET /stream` | multipart PNG stream, up to 15 fps | | `GET /stream` | multipart PNG stream, up to 15 fps |
| `GET /frame.png` | one frame | | `GET /frame.png` | one frame |
| `POST /api/key` | `{"key": "MENU", "action": "down"}` — also `up` or `tap` | | `POST /api/key` | `{"key": "MENU", "action": "down"}` — also `up` or `tap` |
| `POST /api/release-all` | release every key, if one ever sticks | | `POST /api/ptt` | `{"held": true}` — hold PTT, `false` to release |
| `GET /api/status` | QMP `query-status` | | `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 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: 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 SPI2 0x40003800 flash
ADC1 0x40012400 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 Everything else answers through a logging catch-all — the log is how the next
thing worth modelling gets identified. 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 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. 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 ## Why a proxy at all
`tools/webui.py` has no TLS and no authentication. Running it on loopback and `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 的日志行正确显示没有客户端。