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

+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` 确认固件越过了它原来停住的地方