# 在这个仓库里工作 给下一个接手的人的笔记。重点写代码里看不出来的东西,以及**已经在这里浪费过时间的错误**。 *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 索引,等等。没有元数据、没有目录、 没有校验和 —— 只有一个代码和数据必须约定一致的地址。**所以某个设置读回来不对时, 先怀疑偏移,再怀疑传输层。** 启动耗时是模拟开销。本机实测:QEMU 启动后约 1.6 秒出现第一个像素、约 3.6 秒画出主界面, 也就是 README 里写的"约 5 秒"。真机大约一秒就起来了。 ## 基本规则 **永远不要为了让模拟器能跑而改固件。** 固件是**基准**。如果某个东西跑不起来,那是模型错了。 一个修改了固件源码的"修复"会让之后所有测试失去意义,因为你测的已经不是电台实际运行的东西。 **寄存器布局来自厂商的 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` 会自己启动一个。 固件也可以**从页面加载**,不必走命令行:`POST /api/firmware` 把请求体当作镜像,存进 `work/firmware/` 并启动它(模拟器正在运行就先重启)。镜像的**形态**是从镜像自己读出来的 (主机侧 `tools/uvk5_image.py`,机器侧 `uvk5_sniff_app_offset()`):*应用镜像*链接在 `0x08002800`,*整片镜像*从 `0x08000000` 开始,而地址 0 必须别名到对应的基址。判断错的 症状是**静默**的 —— 镜像整体偏 0x2800 字节,第一次取指读到的是随便什么数据 —— 所以这里 既不用标志位,也不按文件名约定。不是镜像的文件会被拒绝,且不打扰正在运行的电台。 这条路上有两件事是踩出来的: - **flash 镜像走环境变量,不走 `-M`。** 经启动器启动时,QEMU 会用 "unsupported machine type" 拒绝 `-M uv-k5-v3,flash-image=...`:同一份 argv 我手跑就 正常,`-M help` 在**同一上下文**里能列出这台机器,argv 的 `repr` 干净,环境变量也 逐项比过、没有定论。属性本身仍然可用,所以两条路都保留;启动器现在只传机器名,另用 `UVK5_FLASH_IMAGE`,模型把它作为属性缺省的回退。根因未知 —— 没重新验证过"从页面开机" 之前,别把它当成冗余"清理"掉。 - **画面取自显示控制器,而不是 guest RAM。** 面板模型保存控制器自己的显示 RAM (8 页 × 128 列),页面渲染的是它,所以**任何**固件都显示正确 —— 同一祖先改出的各个版本 显示逻辑不同,多系统那版更是把图像放在完全不同的位置。**不要**在数据之上再叠加驱动的 `0xA1` 段反转:同一时刻与 guest 自己的帧缓冲对比,不镜像时 8192 像素里对上 8153, 镜像后只剩 6557。读 `gFrameBuffer`(`memsave`)的旧路径保留为"没有面板模型的模拟器" 的回退。 ## 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` 交叉验证。 同一个陷阱再往外一层:**重定向会改变编码。** 三次 `qemu ... 2> probe.log` 的探针都报告 SPI 零传输、flash 零读取、片选零变化,而"固件根本不碰 SPI"被当成了结论写下来。 PowerShell 5.1 的 `2>` 按 UTF-16LE 写文件,于是探针打出的每一行 ASCII 里每个字符之间都夹着 NUL,`startswith("LCDW")` 永远匹配不上。把同一个文件按 UTF-16 解码,看到的是完整的 ST7565 初始化序列和 48 个不同的设置读取。**在相信一个"什么都没发生"的探针之前,先确认探针能被看见**: 读一下文件、数一下字节,或者用不会重新编码的 `cmd /c` 来写。 **QMP `pmemsave` 是物理地址,`memsave` 是虚拟地址。** 帧缓冲符号是 CPU 虚拟地址, 所以对 `gFrameBuffer` 用 `pmemsave` 会返回一整块零**并报告成功** —— 一片空白屏幕, 而且哪里都没有日志。网页界面最初就是建在 `pmemsave` 上的,因为一个计时基准说它更快; **那个基准从来没有检查过内容**。要测量你真正在意的东西:这个 bug 是在渲染出的一帧 返回 0 个亮像素、而 gdb 路径报告 1693 时才浮出水面的。 ### 页面是 f-string 生成的,所以要检查它真正吐出的脚本 网页 UI 是一整条 f-string。JavaScript 字符串字面量里多一个或少一个反斜杠,**整段** `