# 在这个仓库里工作 给下一个接手的人的笔记。重点写代码里看不出来的东西,以及**已经在这里浪费过时间的错误**。 *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`、没有 `