From 3df3c1b16d8e7daa5ccc3feaf7788a74691d5b42 Mon Sep 17 00:00:00 2001 From: MCKero Date: Sat, 29 Aug 2026 16:48:27 +0100 Subject: [PATCH] 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/ 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. --- AGENTS.md | 4 +- AGENTS.zh-CN.md | 705 ++++++++++++++++++++++++++++++++++++ README.md | 18 +- README.zh-CN.md | 365 +++++++++++++++++++ docs/reverse-proxy.md | 2 + docs/reverse-proxy.zh-CN.md | 127 +++++++ 6 files changed, 1217 insertions(+), 4 deletions(-) create mode 100644 AGENTS.zh-CN.md create mode 100644 README.zh-CN.md create mode 100644 docs/reverse-proxy.zh-CN.md diff --git a/AGENTS.md b/AGENTS.md index 578678d..bfb68bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/AGENTS.zh-CN.md b/AGENTS.zh-CN.md new file mode 100644 index 0000000..dba709d --- /dev/null +++ b/AGENTS.zh-CN.md @@ -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 搜来的,也不是推断出来的: + + /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`、没有 `