Files
uv-k5-v3-emulator/AGENTS.zh-CN.md
T
mckero d099e2ecfc Round 20: the key-path conclusion is contradicted, and the probes' failure is unexplained
Round 15 measured get_key() repeated in an app loop at 27.1%, the same as the control, so calling it every pass is not what kills an overlay app; the previous section's use of the two key probes as evidence for that is wrong. Four probe builds (with and without an opening spin, with a short and with a 2M-iteration per-pass spin) all left the panel byte-identical at the launcher's own frame, so they did not run, and why is not established.

What that is not: not the shape other apps survived in, not get_key, not the framebuffer writes -- an app in the same slot that fills every framebuffer byte through api->fb and blits does paint (43 -> 596 non-zero bytes). The probes differ by zeroing the framebuffer and drawing with their own helper before blitting, which is the sort of difference that has to be isolated one change at a time rather than reasoned about.
2026-10-02 11:08:15 +08:00

1387 lines
99 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 在这个仓库里工作
给下一个接手的人的笔记。重点写代码里看不出来的东西,以及**已经在这里浪费过时间的错误**。
*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 搜来的,也不是推断出来的:
<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/uvk5_buffers.py --qmp 127.0.0.1:4444 # 这份固件把它们放在哪
python3 tools/screenshot.py --frame-addr 0x... --status-addr 0x... \
--port 1234 --out screen.png
截图用的地址在不同固件构建之间会变,而 `screenshot.py` 读的是 guest RAM,所以需要它们。
`tools/uvk5_buffers.py` 会把控制器显存和 SRAM 做匹配、从正在运行的固件里找出来 —— 这里构建出来的
镜像是**只有程序头的最小 ELF、没有符号表**,所以 `nm` 对它们无字可读(完整链接的 ELF 才有)。
改完机器模型后重新构建:
cd $QEMU/build && ninja qemu-system-arm # 增量约 10 秒
任何靠近键盘或 GPIO 接线的改动之后,跑回归测试。它会在私有端口上启动自己的实例,
所以不会干扰正在运行的 `run.sh`:
python3 tools/keypad_test.py
还有一个浏览器界面,通常是手动折腾固件最快的方式:
python3 tools/webui.py # 不需要地址:页面画的是面板显存
关于它,有两点在这个仓库里干活时需要知道:
- **它会在整个生命周期里占住 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 字符串字面量里多一个或少一个反斜杠,**整段**
`<script>` 就无法解析,而唯一的现象是状态栏永远停在 "connecting..."——同时所有接口用
`curl` 测又都正常。这真的发布过一次:`.split('\\')` 变成了 `.split('\')`,一个没闭合的
字符串,于是从浏览器看页面是死的,而每个测试都通过。
现在 `test_webui.TestPageScriptParses` 会把服务端真正输出的脚本抽出来交给 `node --check`。
**要测你交付出去的那个产物,而不是生成它的代码。**
### 探针必须真的能看到它在找的东西
有三轮"固件从不碰 flash"的结论,全是探针的错,而且每一轮看起来都像个发现:
- 探针按 `address >= 0x0C0000` 过滤,于是所有**没有地址**的帧——写使能、以及真正执行
擦除的扇区擦除——都被丢掉了。"0 次写"是过滤器造成的,不是固件。
- 握手只给了 1.5 秒,而固件需要约 4 秒才进入串口模式。"没有应答"是超时造成的。
- 探针为什么会完全看不见:PowerShell 5.1 的 `2>` 会写成 UTF-16LE,每行字符之间都夹
一个 NUL,任何过滤都匹配不上。
在相信一个空探针之前,先让它打印出一个你确定存在的东西。
### flash 模型每释放一次片选就把整份镜像写回
对一次设置保存来说 2 MB 不算什么;但对多系统的主机接口就是灾难——它是按 200 字节一块
编程槽位的(`App/app/uart.c` 的 `0x0724`,12 字节头 + 数据):一份 114 KB 固件会变成约
600 次全量重写,而且跑在 vCPU 线程上,于是**客人**和所有跟它对话的主机工具都要等。实测:
一次 64 字节的槽位写入花了六秒。
第一版修法是加 200 毫秒节流,那是错的:它用"强杀时静默丢掉最后一段写入"换来了测试变快。
现在模型记录**改动区间**并只写那一段(就地写),既快又更忠实——真机 NOR 的编程本来就不是
原子的。退出时的通知器仍然整份写回。
### 串口链路上跑着固件自己的屏幕流
`K5Viewer` 会把屏幕从 USART1 推出去。主机客户端如果只在等应答时才读,socket 就会积压,
**客人随后会阻塞**在写串口上:槽位传输会在中途开始丢应答,一次很小的写入也要好几秒。
正确做法是用一个读线程持续排空,等待逻辑只去看已经重组好的帧——在电台那一侧,任何跟它
对话的程序都适用同一条。
这条路上还有两点:固件的接收缓冲是 256 字节
(`App/driver/uart.c: UART_DMA_Buffer[256]`),240 字节数据加上帧头就超了,于是每一帧都被
静默丢弃,200 就合适;以及串口**会话**在约 6 秒没有 `0x0514` 后会超时
(`gSerialConfigCountDown_500ms = 12`),长传输一定会跨过它——用重新握手实测:写入立刻恢复。
这四点都修好后,`tools/uvk5_slots_serial.py` 能通过固件本身写入槽位,并由设备校验 CRC。
### 开机后才连上的串口客户端会错过一切
`-serial tcp:host:port,server=on,wait=off` 在**没有客户端连接时会丢弃**客人写出的字节。
固件的横幅是在最初几秒打印的,所以"等 QEMU 起来再连"(比如四秒后)会看到一个空端口——
和"客人根本没启动"长得一模一样。有四轮"引导什么也不发"其实是这个原因,不是引导。
先连接,再让客人跑。同一条陷阱也适用于引导等待主机时持续发出的 0x0518 洪水:它是持续的,所以
晚连的客户端**也能看到**——这正是这个错误能存活这么久的原因,它只对一次性启动输出显形。
另外两个在这里学到的习惯:
- **先确认探针能看到一个你确定存在的东西。** 一个 USART 寄存器探针报告零次访问,最自然的读法
是"引导从不配置串口"。而用同一个探针跑应用,得到 2239 次——这才说明探针是好的、引导确实是沉默的。
- **当一条记录下来的观察不再能复现时,把它当成未验证。** 引导的 Moto 洪水是从一次现在已无法用
当前构建与镜像复现的运行里写下来的;要依赖它之前先重新推导。
### 裸的 host:port 不是"scheme"
`uvk5_socket.connect` 会按 ":" 切分参数来找 scheme,于是监管进程、网页和 README 一直传的
`127.0.0.1:4444` 被读成:scheme 是 `127.0.0.1`、端口为空、主机为空。连接就一直等到超时。
从外面看到的现象是"网页开不了机",而用完全相同命令行手敲的 QEMU 半秒就应答 QMP,客人则在后台
好好跑着。
两件事让它难被发现:同一个助手也接受 `tcp:host:port`,所以用那种写法的测试都是通的;而失败
形式是**超时**而不是报错,看起来像"模拟器很慢或卡住"。现在 `test_uvk5_socket` 覆盖了所有会走到
`connect` 的写法。推广一句:**当一个助手接受多种写法时,每一种都要测**——没人测的那一种,往往
就是所有人都在用的那一种。
同一处的另一半是**另一个独立真问题**:QEMU 的 stderr 必须从启动那一刻就被排空。固件把屏幕流从
那根管道推出去,约一秒就能填满 64 KB,QEMU 写不进去就阻塞——主循环停了,QMP 自然也不应答。
两种做法都量过:排空时 QMP 0.5 秒应答;不排空时永远不应答。
### 你吞掉的寄存器,就是下一个程序死等的东西
出厂引导**完全起不来**:没有任何串口输出,PC 探针每一次采样都停在 `0x08000f38`。那地址在引导
区里,两条指令是:
0x0f38: ldr r2, [r1] ; r1 = 0x40022000,FLASH 控制器
0x0f3a: lsls r2, r2, #30
0x0f3c: lsrs r2, r2, #30 ; r2 = ACR & 3,就是 LATENCY 字段
0x0f3e: cmp r2, #1 ; 等 1 个等待周期
0x0f40: bne 0x0f38
早前加进来的 FLASH 控制器模型把 `ACR` 与 `OPTKEYR` 当成"可忽略的写"——直接提前 return,
于是通用路径从未保存它们,`ACR` 永远回读 0,引导在配置串口之前就死等。改成 `return false`
让数值被保存后,PC 立刻进入应用区(`0x08013ea0`),串口也开始有输出。
两条都通用:
- **只写寄存器也是寄存器。** 应用从不回读 `ACR`,所以只要只有应用在跑,这个缺陷就是隐形的;
下一个碰同一个外设的程序立刻撞上。
- **"以前是好的"就是一条二分指令。** 引导的 Moto 洪水在 FLASH 控制器建模**之前**被观察到,
之后就不再复现。正确的动作是问"这两次之间改了什么",而不是怀疑早先的记录。
### MOTO/DFU:入口是一个编译开关,不是一个按键
前 10 KB 里的出厂引导**确实**含有 38400 波特的 Moto DFU 处理器,模拟器也把引导跑对了。
但它从外部**进不去**,原因就在引导自己的代码里:
0x13f2 ldrb r0, [r4, #0] ; r4 = 0x20000020,SRAM 里的一个字节
0x13f4 cmp r0, #1
0x13f6 beq ...
0x13f8 cmp r0, #2
0x13fa beq ...
0x13fc cmp r0, #3
0x13fe bne ... ; 其它值继续等
0x140e bl 0x06f0 ; 只有 mode 3 走到这里:DFU 处理器
SRAM 能挺过软复位、挺不过断电,所以这个字节只能由「写完就复位」的程序设置。在应用里那是
`overlay_FLASH_RebootToBootloader()`,由串口命令 `0x05DD` 触发——**但只在构建定义了
`ENABLE_OVERLAY` 时**;没有它,同一条命令就是普通的 `NVIC_SystemReset()`。实测:向运行中的
电台发 `0x05DD`,之后**没有** `0x0518`,PC 也从未离开应用区。
四种入口都**用测量**而不是阅读排除了:单独按 PTT(固件自己的 `BOOT_GetMode()` 需要第二个键)、
PTT+SIDE1/SIDE2 与 MENU(那是应用的几个特殊模式)、开机窗口内主机发字节(含 `0x0530` 握手)、
以及 `0x05DD`。只跑引导、没有有效应用时它同样不进 DFU:它停在 `0x080000dc` 那六个自跳转之一,
那些是**挂起槽**,不是在等输入。
普适的教训:当一个固件的模式由 **RAM 里的一个字节**决定时,触发它的一般不是某个输入引脚,而是
复位前写这个字节的那个程序。去源码里找到写入者(这里是 `0x05DD`)和它外面的 `#ifdef`,整个条件
就到手了。
### 「换个固件就偏移」背后的两个像素 bug
两个都是靠让**页面自己说出画面来自哪里**找到的;两个能长期存活,都是因为错的那份输出看起来"挺合理"。
**那条悄悄画出了每一帧的回落路径。** `uvk5_stream.py` 用了 `STATUS_BYTES` 却没导入它,
于是面板分支每一帧都抛 `NameError`,被一句裸的 `except Exception: pass` 吞掉。网页画出的每一幅
画面都来自 guest RAM,用的是**某一份固件的地址**:装着那份固件时看着对,换别的就"貌似合理但整体
偏移"。让它现形的办法是报出**来源与原因**(`/api/panel` 回答:
`source: framebuffer, note: NameError: name 'STATUS_BYTES' is not defined`)。面板路径通了以后,
网页的 `/frame.png` 与控制器自己的显存**逐像素相同(8192/8192)**;修复前对同一份显存只有
**5594/8192**。`tools/test_uvk5_stream.py` 现在断言"面板可达时必须用面板"、"回落必须带原因被播报"。
**列计数器在 128 处回绕,而真实控制器是 132。** 控制器有 132 个列驱动,玻璃只显示其中 128 个、
且从第 4 列开始 —— 这就是模型把像素存到 `col - 4` 的原因。但计数器被 `& 0x7f` 掩过,于是地址
128..131 变成 0..3,落进 `col >= 4` 之外被丢弃:**每一行都丢掉了最右边 4 个像素**。而**电量图标
恰好就在那几列**,所以症状是"电量位置不对",以及在画到列 127 的固件上"画面看着偏了"——而那份汉化版
最右 4 列本来就是空的,看着正常。这就是它被读成"固件相关问题"的原因。前后都量了:把一页填满
`0xFF` 时,修复前列 124..127 全黑,修复后有内容(四列分别点亮 10/10/12/7),同一固件的画面与面板
显存 8192/8192。
两次的教训是这份文件反复在说的那一句:**一条悄悄换成别的来源的路径,会把一个硬错误变成貌似合理的
错答案**;而一个偏了四列的字节,在"重要的东西恰好住在那四列里"之前,是看不出来的。要报出来源,
并且要测"该走的那条优选路径确实被走了"。
### 屏幕缓冲是**找出来**的,不是写死的
原来是个默认值:`--frame-addr 0x200012BE --status-addr 0x2000163E` —— 那是**某一份构建**的地址,
被写进启动脚本当默认值。换成把缓冲放在别处的固件,画面就"貌似合理但错":实测用户真正刷进去的那份,
缓冲在 `0x2000129E` / `0x2000161E`,正好**早 32 字节**,于是**每一行都偏 32 字节**。这就是「换个
固件就偏移」的本来面目。
其实什么都不用假设。这里的固件镜像是**最小 ELF** —— 一个程序头、没有节头、没有符号表
(`tools/bin2elf.py` 就是这么写的)—— 所以**没有** `gFrameBuffer` 符号可读;但它有**行为**:
固件自己的缓冲里存着与控制器**相同的字节**,因为驱动就是从那拷贝过去的。
`tools/uvk5_buffers.py` 把控制器显存沿着 SRAM 滑动,取吻合度最高的那个偏移;对用户刷的那份文件,
它给出 1024/1024 字节吻合和**正确**的一对地址。
于是 `--frame-addr` / `--status-addr` 现在都是可选的 ✓,`work/run-webui.ps1` 不再传它们
(也不再有任何本机路径 ✓),页面会报出自己找到了什么:
buffers: {"frame": 0x2000129E, "status": 0x2000161E, "how": "sram search",
"score": 1024, "total": 1024}
两条习惯,这份文件里用别的话说过:**默认值里写进"某台机器/某份构建"的具体值,就是在等第二份构建来踩**;
以及 **没有符号可读时,就直接问它本人** —— 缓冲里的字节就是答案,靠**匹配**找出来,而不是猜。
面板路径完全不需要这些,而页面画的正是它:对任何固件,控制器的显存就是屏幕。这些地址只服务
guest RAM 回落路径 —— 所以搜索失败会被报出来,而不会挡住任何东西。
### 页面必须报告**设备自己说**它在跑什么
页面只知道它被交给的那个文件,而这是两个不同的问题。一个叫 `f4hwn.fusion.bin` 的构建,
报出的是 `EGZUMER+F4HWN v6.0.0.CN` —— 你那份就是 —— 所以「开机还是 CN 版」其实是页面在描述
**自己的输入**,不是电台。更麻烦的是:多系统版里,只要外部槽已提交且状态标记有效,出厂引导
**每次开机都会用那个槽重刷内部 flash**,于是你上传的镜像在被执行之前就被覆盖,而页面还在显示
一个从未运行过的文件名。
固件自己会回答这个问题:它在 USART1 上打印 `UV-K5 Firmware, ...`,模型给它打上 SERIAL 标记,
`tools/uvk5_banner.py` 把它读回来。`/api/firmware` 现在返回 `running: {banner,
matches_uploaded, note}`,页面显示 `device reports: ...`,**只在"设备报的版本根本不在你上传的
镜像里"时才提示** —— 因为文件名与横幅不同通常只是名字不同,而一个乱报警的提示最后会被无视。
把这条横幅读回来,也顺带暴露了我自己写的一个 bug:横幅**已经完全进不了日志**了 ——
`_start_stderr_pump` 被改成按 64 KB 读管道,以免 QEMU 阻塞。死锁确实因此没回来,但另一半被
悄悄丢掉:攒够 64 KB 之前什么都收不到,而横幅只有 40 字节。现在改回按行读,并且**仍然在等待
QEMU 之前就开始排空**。**一次既保住你要修的性质、又悄悄丢掉另一种性质的改写,是最贵的那种** ——
而这次它藏得住,是因为对那条二进制屏幕流来说,日志"看起来还是好的"。
### 叠加应用就是外部 flash 里的一块区域,页面可以直接写
Labs 版能跑小的叠加应用(Tetris、Breakout、Plasma、Cube3D、Beam、Beacon、FoxHunt、
BroadcastFM)。上游是用 UVStudio 通过 WebSerial 装进去的;在我们这里,它们就是**外部 flash 镜像
里的字节** —— 而镜像本来就在页面手里,所以既不需要串口协议,也不需要浏览器授权,只是**同样的字节
写在同样的偏移**。
布局是固件自己的,从它编译的头文件读出(`App/apps/app_overlay.h`),不是猜的:
`APP_REGION_BASE 0x00102000`(紧跟在两个状态标记之后)、`APP_SLOT_STRIDE 0x2000`、
`APP_CODE_OFFSET 0x1000`、16 个槽。每个槽放一个 64 字节的 `app_header_t`(magic `FAP1`、
对代码做 zlib CRC-32、名字、版本、vma 0x20000280、capabilities),代码在**下一个 4 KiB 扇区**。
`tools/uvk5_apps.py` 负责解析、校验、列出、安装、擦除;`tools/test_uvk5_apps.py` 管各种拒绝。
两点值得知道。**这 64 字节头和多系统槽位是共用的**:`FMB1` 是固件、`FAP1` 是应用 ——
这就是为什么"把应用装进槽 1"和"把固件放进槽 1"动的是同一块外部 flash,开机菜单也把两者列在一起。
另外,这块区域和屏幕缓冲一样,是**从固件自己的常量里读出来的**而非猜的:这里头结构的第一版是 60 字节,
因为漏了一个字段(`vma`),而真实的 `Beam.app` 字节当场就指出了这一点。
页面通过三个接口写入(`GET /api/apps`、`POST /api/apps/<n>`、`POST /api/apps/<n>/erase`),
它们改的都是模拟器启动用的那个 flash 镜像 —— 所以这一切在网页上就能做,**不需要 WebSerial、
不需要浏览器授权、也不需要串口协议**。页面在固件槽旁边列出这 16 个应用槽,每个槽都有选文件和擦除按钮。
接进去的过程中量到两件事,两件都改了代码:
* **那块区域可能本来就有东西。** 在真实镜像上,16 个槽读回来全是"既不是空、也不是应用"的数据 ——
汉化版构建的工厂资源块**压在 `0x102000` 上**。在那里安装会**无声地毁掉它**,所以 `install`
现在会拒绝装有非应用内容的槽,除非明确要求覆盖(工具用 `--force`,接口用 `?force=1`)。
拒绝时会说清那里是什么、槽在哪。
* **改动不会落在你传进去的那个文件上。** `_edit_flash` 会**复制**镜像并把槽指向副本 —— 这是故意的,
以免正在运行的模拟器脚下的文件被改写。第一版测试断言原文件变了,看到 0 字节差异,读起来像
"安装什么都没做" —— 其实字节在副本里。要检查 `FlashSlot.path`,不是你传进去的路径。
上游那一侧不用猜,直接读 UVStudio 自己的 `js/flash.js`:`MSG_APP_INFO 0x0730/0x0731`、
`MSG_APP_ERASE 0x0732/0x0733`、`MSG_APP_WRITE 0x0734/0x0735`、`MSG_APP_VALIDATE 0x0736/0x0737`
—— 与固件槽位同一套帧格式、再往后一族 —— 以及与本页相同的常量(`APP_SLOT_COUNT 16`、
`APP_IMG_OFFSET 0x1000`、`APP_HDR_SIZE 64`、`APP_MAGIC 0x31504146`)。从那里得到两个细节值得记住:
UVStudio **只用槽 0..7 并显示为 1..8**;而且它**最后才写头** —— 因为头里带着 committed 标志,
写一半绝不能通过校验。本页一次写整个槽,这个性质是白送的。
而整件事最后由**固件自己**确认了。Labs 版会在 USART 上回答 `0x0730`(应用信息),所以装好的
`Beam.app` 是**在电台上被问出来的**,不只是从文件里读回来的:
0x0730 slot 0 -> status 0
raw 0000 | 46415031 01000101 4c040000 5860970d 0000 0108 | 4265616d 0000
FAP1 hdr1 abi1 api1 1100 CRC 0x0d976058 flags 0x0801 "Beam"
这就是本页写进去的那个头,被运行中的固件原样念了回来 —— 所以区域、偏移、布局、字节都是对的。
槽 1 和槽 2 回答 `status 2` 加无关数据,那正是"安装守护"要防的那块重叠区。
页面还能**直接问电台**。`GET /api/apps/radio` 会连上固件的串口,对全部十六个槽发 `0x0730`,
于是答案来自**正在运行的固件**,而不是我们读文件的结果 —— 字节可能对,而固件仍然拒绝某个槽。
实测(经由页面,把 `Beam.app` 装进槽 0 之后):
slot 0 -> Beam 1.0 · 1100 B · crc 0xd976058 · shortcut beam · committed true
slots 1..3 -> 返回 status 2 与无关数据 (就是守护要拒绝的那块资源区重叠)
表格旁的按钮会去问它,并把答案显示在自己那一列。
这里有一处花了整整一轮:服务器会给它启动的模拟器一个串口(`--serial-port`,默认 4445),
而 QEMU 需要 mingw64 的 DLL 在 `PATH` 上。从 `work/run-webui.ps1` 启动会加上;手动启动不会 ——
缺 DLL 会让 QEMU **在打开 QMP 之前**就退出,而这只表现为"QMP socket never appeared",
没有别的线索。QEMU 的参数错误走的是 stdout,而启动器把它丢掉了。
**启动方式是先按 `F` 再按 `7`,然后用 MENU 运行。** 这是上游自己的措辞 ——
UVStudio 的 `locales/en.js` 原文是 "launch them from the F + 7 menu" —— 也正是
`App/apps/app_menu.c` 的做法:在已安装的那一行按 `KEY_MENU` 会调用 `APP_LaunchOverlay(sel)`,
它"一直运行到应用退出";EXIT 退出菜单。在模拟器里验证过,`Tetris.app` 是通过页面自己的接口装的:
F, 7 -> app 区读取从 0 次变成 8 次,出现带框的 "F4HWN APPS" 界面
MENU -> 槽 0 代码区出现 1 次读取,游戏自己的画面取代了电台界面
DOWN -> 方块移动;两秒后屏幕上仍然是游戏
在此之前我找了很久都进不去:二十个键的短按与长按、整整 79 项设置菜单、以及多系统菜单,都没让
app 区被读过 —— 而多系统菜单读的是**固件槽**,不是应用。**答案写在刷机工具自己的翻译文件里**,
不在我一直在读的固件源码里 —— 所以当一个功能的入口找不到时,**安装它的那个主机工具就是文档**。
**上传固件之后白屏:先查多系统状态,然后回滚镜像 —— 不要手工去改状态。** 实测:镜像里 `0x100000` 的
`FMP3` 标记记的是一个身份(size 120832、CRC `0x4D87CE48`),而 `-kernel` 加载的是另一份构建,于是固件
判定"正在运行的镜像不是我的状态所期望的那份",走恢复/收养路径,**什么都不画**:面板 1024 字节全为 0,
而串口横幅照常打印。把工作副本换回未改动的 dump,画面立刻回来(1024 字节里 485 字节点亮),三个已安装的
应用也可以重新装回。
对本节早先版本的两处更正。**这个标记不是"待执行标志"**:解出来看,`generation`、`image_size`、
`image_crc32`、`firmware_slot`/`slot_inv`、`config_bank`/`bank_inv` 与 `state_crc32` 全部自洽
(前 20 字节的 CRC 为 `0x661286F1`)—— 它只是一条普通的状态记录。**而清掉标记扇区不是修复手段**:试过两次
(先清整片 8 KiB,再只清 24 字节头),之后固件走的是 `MB_MARK_MISSING` = "新电台"那条路,会去收养当前
运行的固件,过程很慢、不画屏,而且**它的写回又把标记写回来了**。真正有效的是**回滚镜像**。
**而当"改了不生效"时,过期的写回依然值得怀疑**:模拟器退出时会把内存里的镜像写回文件,所以客人还活着
时做的改动,可能被它手里那份副本覆盖 —— 这也是标记被清掉后又重新出现的原因。
**没有 Arm 工具链也能编叠加应用:`pip install ziglang`。** 写它的机器上既没有 `arm-none-eabi-gcc`
也没有 Docker,但 Zig 自带 C 编译器、能交叉到 `thumb-freestanding-eabi`,够用了。Zig 会拒绝什么(都实测过):
`--defsym`、`-Ttext`、`--section-start`(被它的链接参数白名单挡掉);所以 VMA 用 `sed` 写进一份
`app.ld` 的副本;`-T` **是**会被转发的(给一个不存在的脚本它会报错);`--image-base` 虽然接受但没有用,
因为它把段按页对齐(0x20000280 变成 0x200103C8 与 0x20020C94 两个 LOAD)。
最花时间的是两个坑。**lld 会把 ELF 头与程序头表本身也映射成一个 LOAD** —— 52 + 4×32 = 180 字节、位于镜像
基址、里面没有任何节内容 —— 所以 `tools/elf2bin.py` **按已分配节提取,而不是按程序头**;跟着程序头走会让
镜像从 0x20000000 开始,加载器以 APP_ERR_VMA 拒绝。另一个是**一次除法就会牵出 `__aeabi_uidiv`**,而它在
`-nostdlib` 的块里并不存在 —— 正确的修法是**把除法去掉**(重复减法;用 `& 127` 加拒绝代替 `% 81`),
而不是往 4 KiB 的叠加区里链一个软除法。
入口必须在镜像最前面,因为加载器跳到块偏移 0,所以 `app_main` 带着上游自带应用用的同一个
`__attribute__((section(".text.entry"), used))`(`cube3d_app.c:183`)。
用页面装上去后的实测:固件把槽头读了两次,然后**正好从 槽+0x1000 读了 `code_size` 字节**
(Minesweeper 的 2408 字节代码就读了 2408 字节,而且这是整份启动日志里唯一一次这种大小的读取)。也就是说:
块的形状、头、CRC、VMA、偏移**全部被接受**。**但控制权是否真的交到叠加区仍未证实**:PC 探针每 100 ms 采样
一次、没采到叠加区地址,也没有出现 `APP ERROR` 屏,应用也没有画出来。现状就是如此 —— **管线验证到了
"加载"这一步,还没到"执行"**。
**这个模拟器里现在没有任何叠加应用真的跑起来:代码被拷进了叠加区,控制权却从未交出去。**
量法:通过 QMP **每 30 ms 轮询一次 PC**(比模型自带的 100 ms 探针更细),应用是用页面自己的接口装进去的:
* 启动 Tetris 之后,0x20000280 处的内存**与 Tetris 自己的代码逐字节相同**(f0b56b4c85b0036f...),
说明加载器的拷贝是正确且对齐的。本会话里我先前说的"错位一个字节"**是我自己的解析吃掉了第一个数值**,
不是模型的问题。**4.5 秒里 172 次采样、外加 2 秒里 64 次采样,全部落在固件 flash 内(0x08013260,一个等待
循环),没有一次落进那 4 KiB 叠加区。**
* 一个 **16 字节、什么都不调用**的应用(不碰显示、不读按键、不访问结构体成员,只是一个自增的 volatile 循环)
表现完全相同 —— 所以这**不是**"应用启动之后做了什么"的问题。
* 头也不是原因:Breakout 的头(flags 0x1、capabilities 0x0、entry_off 0、abi 1、api_min 1、hdr 1)与我们的
逐字段同形,而固件照样把它的代码拷了进去。
也就是说,故障在应用**之前**:固件完成校验与拷贝,然后**没有进入叠加区**。
**这使得本文件早先那条"Tetris 能跑、按 DOWN 棋子会动"的记录失效 —— 按本文件自己的规矩,在它能复现之前一律
当作未验证。** 用户按下 MENU 之后看到的画面(电台自己的顶行,例如 "F4 APRS",其余空白)就是主界面,
即应用菜单已经退出、而应用从未启动。
接下来该用的仪器,按顺序:固件在跳转前是否在等按键释放(按 MENU 前后 PC 都停在 0x08013260 这个等待循环里);
以及从**电台自己的菜单路径**启动上游应用时,拷贝进去的代码到底有没有被执行。
**同日更正:上面那些 PC 测量是在"从来没画出画面"的实例上做的;而且页面当前那份固件根本没有 F+7 应用菜单。**
方法上有两处错误。第一,这些运行里由我手工启动的每一个 QEMU 实例,面板都是空的(点亮像素 0),而页面自己的
实例是画的(803)—— 同一份镜像、**不同的固件文件**。所以"PC 从未进入叠加区"是在一块**从未跑到主循环**的
电台上的测量,属于**不成立**,不是结论。第二,直接驱动页面自己的 /api/key 并读它的 /api/panel,得到的就是
用户看到的现象:F、7、DOWN×3、MENU 全部返回 ok,而画面**一个像素都没变**(每次按键前后 ink 都是 232)。
页面的 firmware.bin 是 109.3 KiB;而能打开应用菜单的 Labs 版是 111.9 KiB,**不是同一份构建**。电台仍然能回答
0x0730 并列出四个已提交应用,说明它**支持应用区**,只是**没有 F+7 这个入口**(那是 Labs/UVStudio 的路径)。
所以用户看到的"进不去程序",实质是**这份构建上按键不生效**;要测叠加应用,只能**跑 Labs 版本身**。
测量要走**页面**,不要走手工启动的 QEMU:会画的是页面那个实例,而它的接口就是浏览器在用的同一套。
**在页面自己的实例上、用页面自己的接口复现:应用菜单是好用的,而在某一行按 MENU 会停在启动器里,应用始终不运行。**
这次测量时页面是健康的(恢复 pristine dump:485 个点亮字节,四个应用已装好并经 0x0730 确认 ✓)。驱动 /api/key、
读 /api/panel,得到:
* **F 再 7 确实打开了菜单** —— ink 1810 → 1976,带框标题覆盖在槽列表上。
* **DOWN×3 确实移动了选中项** —— ink 1976 → 1832,列表文字随之变化。
* 在应用那一行按 **MENU**,画面掉到只剩标题框(ink 1832 → 210),此后 MENU、DOWN、UP、F **全部不再生效**:
应用既不画、也不读按键。
* **EXIT 能把菜单叫回来**(ink 1832),说明固件确实进过启动器、也出来了。
所以墙在**启动器与应用之间**,而且与应用自己的代码无关(16 字节、什么都不调用的应用表现相同),也与头无关
(Breakout 的头逐字段同形)。"只剩标题框、按键失灵、EXIT 能回来",正是**启动器接管了屏幕、却始终没走到应用**的样子。
给接着做的人两条测量提醒。测试前先把 **pristine dump**(work/user-flash.img)恢复回去:带已收养 FMP3 标记
(image_crc32 是 0x9d27c3db 而不是 0x4d87ce48)的工作副本起来时面板是空的、或只显示启动画面,那时按键也不响应 ——
前面好几轮把这当成了"应用失败"。另外**永远不要手工启动 QEMU** 来测这个:手工起的实例没有画面,而页面自己的实例是画的。
**叠加应用能启动、能跑,而一旦调用 <<会读外部 flash 的固件服务>> 就当场死掉。**
在页面自己的模拟器上、打开模型探针测得(UVK5_PC_PROBE / UVK5_FLASH_PROBE 通过它自己的启动器继承,因此被测量的
正是那个会画画的实例)。三个应用、同一行、同一串按键:
* **Spin,16 字节,什么都不调用**:MENU 之后 383 个 PC 样本里有 **42 个**停在 `0x20000286` —— 它就在叠加区里
无限循环。**加载器完全正常。**
* **扫雷,2408 字节**:flash 探针里只有一次大读取 0x109000 len=2408,首字节就是块自己的 —— 代码确实被加载了;
而 PC 探针只捕到**一个**落在叠加区的样本,随后应用就没了。它活了**远不到 100 ms**。
* **Phases,372 字节**:每次调用之间直接往帧缓冲画一条横杠,于是**屏幕自己报告走到了哪一步** —— 只写帧缓冲与 led
两条横杠都在屏上,而 **delay_ms 之后那条从未出现**。(blit_full 与 led 能活过;一旦让出 CPU 去走固件自己的
服务,就死。)
叠加区**就是** PY25Q16 的扇区缓存 —— app_overlay.h 自己写着:它把代码拷进 4 KiB 叠加区(也就是 PY25Q16 扇区
缓存,与多系统 RAM stub 共用同一 VMA)。所以图景是:应用运行期间,某个服务去读外部 flash(字库表就在 0x1E0000),
整个扇区就落在应用头上,应用开始执行 flash 数据。真机上不可能这样 —— 上游自己的应用就调用 print_tiny —— 所以固件
必然在**应用已加载时对扇区缓存加了门**,而**模型缺的正是这道门**。
下一步:在固件的 flash 驱动里找到这道门(一个内存标志、一个寄存器、还是一根 GPIO),再看模型在被问到时答的是什么。
这是第一次,这个故障有了名字、也有了三个应用的可复现对照。
**更正:扇区缓存这条解释,与日志的时间顺序不符。**
flash 探针是按顺序记录每一笔事务的,而那一次启动的**最后一笔**正是应用自己的代码加载
(addr=103000 len=544,首字节 f0b583b0)。此后到应用消失为止**没有任何事务** —— 也就是说,在那个窗口里
**没有**字库或资源读取压在运行中的应用头上,**扇区缓存被覆盖在这里不可能是原因**。日志真正显示的是:整个会话里
有 7848 次字体区读取,它们都属于固件自己重绘画面的过程,而不属于应用的生命期。
于是搜索范围收敛到(只列实测过的):加载器会拷贝并跳转(有采样落在叠加区内,而且那个 16 字节、什么都不
调用的应用能在里面永远循环);应用随后在**远不到 100 ms** 内死亡(t+113 ms 读屏还是菜单,t+193 ms 只剩启动器
的标题框);先自旋再动作的应用能活过一条横杠 + blit + led,而在 delay_ms 附近死掉;一上来就调 blit 的应用
则连影子都还没留下就没了。**凶手在这最初的一百毫秒之内,而且不是 flash 读取。**
下一个仪器,只需要改模型一行而不是继续猜:PC 探针每 100 ms 采样一次,而应用整个生命期就是这个量级。把这个
间隔降到几毫秒,它就变成真正的 trace,能看出应用**停在叠加区的哪个位置**。
**PC 探针的间隔现在是可配置的(UVK5_PC_PROBE_MS);调到 2 ms 之后,叠加应用整个生命期只有一个采样点。**
100 ms 与被测对象同量级:启动器刚把应用带起来,下一个采样到来之前它就已经没了。uvk5_pc_probe_interval_ms()
读 UVK5_PC_PROBE_MS、缺省仍是 100,所以以往任何一次探针运行都保持原样。调到 2 ms 后,一次启动在约 0.94 秒里
得到 468 个样本,其中**恰好一个**落在那 4 KiB 叠加区内 —— 应用只活了约 **2 毫秒**,不是 100。
有了真正的 trace,两件事就此确定,另有一件是新发现。确定的:加载器会拷贝并跳转(那一个叠加区样本),而且
那个 16 字节、什么都不调用的应用能一直待在里面。反过来确定的:**扇区缓存覆盖说已经死了** —— 那次运行的 flash
日志里**最后一条**就是应用自己的代码加载(addr=103000 len=2408),**其后什么都没有**,包括字体读取。新发现:
应用死掉之后,固件**停在自身 flash 内的一个循环里**(PC 在 0x08005118/0x08005122/0x08005248 一带),**零次 flash
读取、按键完全无响应** —— 连 F 再 7 都打不开菜单了,显式地 MENU down/up 也毫无变化。也就是说**启动器(或它走的
故障路径)卡死了**,而不只是在等一次松开。
下一轮就从这里开始:应用在约 2 ms 内死亡,固件在加载它之后**再也没有读过 flash**,而剩下的是一个没有显示、
**应用体积的阶梯把矛头指向「拷贝尚未完成」:启动器在代码还没全到位时就跳了进去。**
体积与结果,全部是在页面自己的模拟器上、用 2 ms 的 PC 探针(UVK5_PC_PROBE_MS)测的:
| 应用 | 代码 | 结果 |
| --- | --- | --- |
| Spin | 16 B | 在叠加区里永远循环 |
| Delay | 248 B | **13 秒后仍在运行**(最近 60 个样本里 54 个在叠加区,地址 0x20000348) |
| Phases | 372 B | 活过 bar + blit + led |
| Ladder | 544 B | 还没画出任何东西就死了 |
| Minesweeper | 2408 B,加上开场自旋后 2428 B | 两种都在约 4 ms 内死掉 |
开场自旋救不了大应用,所以问题不在「第一次调用有多早」,而在**应用到底被拷进去了多少**:头几百字节可执行,
而它们之后的部分在应用跑起来时**还没到位**。这也解释了先前那个意外:事后手工检查时,Tetris 完整的 3300 字节
**确实**在叠加区里 —— 拷贝**最终会完成**,只是**在应用已经被跳进去之后**。这与本文件里已经记着的那四个 flash
bug 是同一族:**传输还在进行时就告诉加载器「已完成」**,于是它提前跳转。嫌疑点是 SPI/DMA 的忙/完成标志;
验证方式是**在一次大读取期间去观察这些标志**,而不是坐着推理。
**「体积阶梯」是假的线索:一个 3 KiB、什么都不调用的应用能一直跑;再依次加上 print_tiny、get_key、**
**display_clear,它照样跑得很好。扫雷是死在它自己的代码上。**
那个阶梯把两个变量混在了一起:每个小测试应用碰巧都先自旋,而每个大应用都一上来就调用固件。把两者分开来,
全部在页面自己的模拟器上用 2 ms 的 PC 探针测:
* 一个 **3028 字节**的应用(内容只是一个 volatile 数组加一个自旋)在叠加区里无限循环(1704 个样本,全部落在
它的前 512 字节内)—— 所以**块的大小根本不是问题**;
* 给它加一次 **print_tiny** 毫无影响(2039 个样本,而且 flash 日志里出现 1024 次字体区读取,第一条在 0x1e0000)
—— 所以**字体路径没问题,扇区缓存那个担心也一并作废**;
* 再加上 **get_key**:仍在运行(2029 个样本);
* 再加上 **display_clear**:仍在运行(13195 个样本里有 1978 个、15% 在应用内)。
至此,**「从未有应用成功调用过」的那三个函数全部无害**,而活下来的应用已经覆盖了扫雷用到的整个 API 面。它的失败
**在自己的代码里**。所以下一步就是一次普通的二分:把 draw() 里的东西一块块删掉,直到它活下来,每个变体都从页面装
**一次干净的单变量 A/B:扫雷那四个调用只做一遍能活;包进 for(;;) 就再也回不来。**
同一份文件、同样的调用顺序、唯一变量 —— 在页面自己的模拟器上用 2 ms 的 PC 探针测:
| 变体 | 主体 | 结果 |
| --- | --- | --- |
| A | display_clear、print_tiny、get_key、delay_ms(40),然后自旋 | **能跑**(应用内 2059 个样本,字体被读 1024 次) |
| B | 就这四个调用,包进 for(;;) | **再也没出现过**(应用内 0 个样本,而字体同样是 1024 次,说明它确实执行了第一轮) |
也就是说:这些调用**单独都没问题、按顺序做一遍也没问题**;**重复**它们才是应用的终点。
**下一轮之前必须先修判据,这一点值得写下来。**「应用内样本数」会**低估活着的应用**:一个把时间花在长固件函数里的
应用,会出现在 trace 的固件那一侧 —— 而那恰恰也是死掉的应用的样子。只含 display_clear() 的循环得了 6 分,只含
get_key() 和只含 delay_ms() 的循环得了 0 分 —— 这三个结果**不可比**:一个是有效测量,另两个读不出结论。修法是让
每个调用之间夹一段**自包含的长自旋**,这样活着的应用在大多数时间**确定**在叠加区里,trace 就能把两种情况分开。
**第 15 轮:用经过验证的判据,四个调用各自被重复都没问题 —— 而凶手就在 draw() 里面。**
先说判据,因为上一轮那个不可信:每个变体都是 for(;;) { 长自旋 ; 一个调用 },这样活着的应用大部分时间在叠加区里,
而死掉的从不出现。对照组(只有自旋)27.2%;display_clear 26.9%、get_key 27.1%、delay_ms(40) 26.8%、
print_tiny 27.0% —— **所以这四个调用被重复都没有问题**,而对照组证明这个判据能区分两种情况。
这也意味着上一轮的变体 B(四个调用放进循环、中间没有自旋)**几乎肯定没死**:它把时间几乎全花在固件函数里,
而那从 trace 上看与死掉的应用一模一样。教训与本文件反复讲的那条相同 —— **一个看不见你所找之物的测量,会心安理得
地返回零。**
把这些排除之后,二分已经收拢到一个函数上。把扫雷整个 draw() 掏空成 display_clear() + blit_full() 之后它**能跑** ——
应用内 97 个样本,与固件 PC 交替出现,固件仍在为它服务。而完整的 draw() 什么都不画:二十秒里面板始终停在启动器
的标题框,ink 一直是 210,说明**连 display_clear() 都没执行过**。所以结束应用的东西在 draw() 的躯干里 —— 那十一次
print_tiny、帧缓冲写入与光标反相 —— 这就是最后一公里。
下一步:用同一套变体装置分别测 draw() 的三块(标题文字、81 格循环、光标),每块都带上那些能活的应用所拥有的循环自旋。
**第 16 轮:draw() 的两半各自都没问题,而应用确实在跑 —— 下一个嫌疑是 blit 本身。**
用已验证的形状(for(;;) { 长自旋 ; 一块代码 })测:标题区(display_clear + 四次 print_tiny,含长字符串与 x=122)
得 26.1%;81 格循环加光标(只用局部变量写)也得 26.1% —— 都与对照组相同,即两者都活着。
完整版扫雷用同样方式测,**它在跑**:十五秒里有 116 个样本落在它里面,分布在八个不同地址(自旋在 0x200002e8/
0x200002ec,另有 0x2000035a、0x20000400、0x20000564、0x20000488 等),固件仍在为它服务 —— 启动期间出现 1024 次
字体区读取。然而九十秒里面板始终没有离开启动器的标题框,而那个框是在应用被跳进去**之前**画的,所以应用画的东西
从未到达玻璃。去掉开场自旋也没有任何变化,这同时否掉了先前那个「应用还卡在自旋里」的想法。
结论:应用在跑、也在调用固件,却从未完成一帧。剩下两个候选,而其中一个**从未被检查过**:静态变量(draw() 会读
g_cursor、g_state、g_mine_left、g_open、g_flag、g_mine —— 正是两个存活变体都没碰过的输入),以及
**从叠加应用发出的 blit_full 到底有没有送到面板模型** —— 掏空版当初只量了 PC 占比,从没看它的屏幕显示了什么。
第二个很便宜就能定论:一个只做 display_clear + blit_full 的应用,应当明显擦掉启动器的标题框。
**第 17 轮:游戏画出来了。缺的只是每帧那个延时,而显示通路从头到尾都是好的。**
三个测量把它定死了。一个只做清屏加 blit 的应用让面板逐字节未变;而通过 api->fb 把整个帧缓冲填满再 blit,面板
从 43 个非零字节变成 596 个 —— 也就是说**叠加应用的帧缓冲写入与 blit_full 确实能到达面板**,而此前"面板从不
变化"的读数说的是**内容**,不是**通路**。真正挡住那一帧的是扫雷自己在无效按键分支上的 delay_ms(40):在这个
模拟器里 40 ms 的客户机时间是若干秒的真实时间,而一帧本来就已经要在固件的字库读取里花掉大约二十秒,于是帧与帧
之间隔了几分钟。把那个延时去掉之后(这个循环本来每轮都重画),**24 秒内就出现了完整的一帧**:面板从 43 变到
390 个非零字节、ink 1275,标题、计数器和场地在转储里都清晰可读。
**剩下的是输入。** 按 MENU 没有任何变化(596 → 596),按 DOWN 则让应用**直接退出了循环**:面板回到启动器那个
43 字节的标题框,而那只会在"把某个键当成 EXIT"的那条路上发生。也就是说 get_key() 并没有返回这个应用在比较的
那些 APP_KEY_* 值,或者按键没有按发送的那样到达它。最便宜的查法是**让应用把它收到的原始键码画出来**、从面板上
读它,而不是猜映射。
**第 18-19 轮:原始键码探针从未跑起来,而对照把「输入」收窄到一个调用。**
做了两个不依赖字库的探针,用来画出它收到的原始键码(第 2 行一条标记横杠、第 20 行五个位),而在七次按键前后
面板都是**逐字节相同**的 —— 第 2 行 26 个亮点、第 20 行一个恒定图案,那正是启动器自己的画面。也就是说两个探针
**都没有画**。
有价值的是这组对照:形状相同、通过 api->fb 填满帧缓冲再 blit 的应用(FillFb,第 17 轮)**是能画的** —— 43 个
非零字节变成 596 —— 而它与这两个探针**唯一的实质差别**就是探针每轮都调用 api->get_key()。再结合第 17 轮的结果
(完整版扫雷画出了完整一帧,然后在**按下键的瞬间**消失,面板回到启动器的标题框 —— 而那只会发生在把某个键当成
EXIT 的路上),图景就是:**应用能跑、能画,是按键这条路结束了它。**
这就是输入目前的状况,而它只有**一个调用宽**:叠加应用里的 get_key()。扇区缓存那个问题也跟着回来了 —— 按键路径
是固件去读外部 flash 的一个可疑位置 —— 但 print_tiny 引发的那些字库读取并不会杀死应用,所以这是**按键路径特有**
的,而不是普遍的 flash 读取问题。
**第 20 轮:上一轮关于「按键路径」的结论被证伪,而探针自身的失败仍未查明。**
第 15 轮测过在应用循环里反复调用 get_key(),得 27.1% —— 与对照组相同 —— 所以「每轮都调用它」并不是杀死叠加
应用的原因,而上一节把两个键码探针当作这件事的证据是**错的**。四个探针构建(有/无开场自旋、每轮 3000 次/2M 次
自旋)都让面板**逐字节相同**:第 2 行 26 个亮点、第 20 行一个恒定图案,而那正是启动器的画面 —— 是在应用被跳进去
**之前**画的。也就是说探针没有运行,而**原因我并未查明**。
这不是什么:不是那些应用赖以存活的形状,不是 get_key,也不是帧缓冲写入 —— 同一个槽里、通过 api->fb 把每个
帧缓冲字节填满再 blit 的应用**是能画的**(43 → 596 个非零字节)。探针与它的差别在于:探针先把帧缓冲清零、再用
自己的辅助函数画、然后才 blit —— 而这类差别**必须一次只改一处去隔离**,不能靠推理,这正是本文件反复讲的纪律,
也是这四个探针所违背的。
目标当前状态:白屏已修复且已查明;Labs 固件正常显示;应用能构建、能从页面安装、能运行,并且**画出了完整一帧**
(标题、计数器与场地都清晰可读);文档双语文,每一步都 commit 并 push。**只剩「输入」这一项**,而它还没有被
收窄到某个调用 —— 已测得的部分是「按下键会结束正在运行的游戏」,而用来读原始键码的探针从未画出任何东西。
下一轮就从这里开始:同样的「自旋 + 单调用」形状,一次只放一个调用。
进去、读同一份 trace。比起一开始那个模拟器悬案,这是好得多的处境。
一条测量备注:flash 探针的逐条读取记录也正是发现字体读取的方式(1024 条,且没有一条落在应用身上),这个对照值得
一直开着。
临时办法(且标明只是临时办法):**小应用就是能用的应用**。给扫雷加的那个开场自旋不会进仓库 —— 它本来也没起作用。
没有按键活动的紧循环。
## 键盘:两个真 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 侧的设置改动现在会持久化,所以测试方式要变。** PY25Q16 模型在 realize 时读入镜像、
留在 RAM 里,并在片选释放或进程退出时经临时文件写回,于是**每个会话都会改动
`assets/flash.img`**。实测跑完一次真实会话之后:本来全是 `0xFF` 的设置区 `0x00A000`
已经装着 guest 的设置,`0x8000..0x8800` 也变了,整个文件与开会话前的副本相差 2239 字节。
所以不要假设镜像是干净的 —— 要和 `assets/pristine/`(或你自己留的副本)对比,还原前先断电。
在 Windows 上这一步直到把 `rename()` 换成 `g_rename()` 之前都是静默失败的,见可移植性一节。
这里有用的工具:`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` 或网页界面会话。
### 让文档保持诚实
python3 tools/check_docs.py # 也作为 run_tests.sh -q 的一部分运行
**文档会静默腐化,而通读是发现不了的。** 把全部文档翻译成中文的过程中,
翻出了四处**已经漂移**的断言:接口表缺三个路由、已建模外设列表漏了 TIM2、
审计表在 TIM2 已建模之后仍把 TIM 称作 stub、两个 README 都没列出几个库模块。
**这四处全是靠与源码比对发现的,没有一处是校对读出来的。**
所以现在这个比对是机械化的。它检查:每个 README 提到的工具是否存在、
`run_tests.sh` 里的每个测试在两种语言里是否都有记录、内部 `.md` 链接是否都能解析、
翻译对的标题结构是否匹配、内存映射地址是否与模型的 `#define` 一致、
文档传给某个工具的每个长参数是否真的存在于该工具中、
以及文档里的固件 `file:line` 引用是否仍指向正文声称的东西。
校验器自己也需要两处改动才能在作者的机器之外运行:所有读取都显式用 `encoding="utf-8"`
(默认是区域编码,Windows 上是 GBK,中文文档根本解不开),以及固件树路径来自 `UVK5_FW_DIR`
环境变量而不是写死,这样 `file:line` 那几项检查可以指向你手上任何一份固件树。
参数检查本身也留下了一个教训。它的第一版只匹配到行尾,所以对一条这样换行的命令
python3 tools/uvk5_buffers.py --qmp 127.0.0.1:4444 # 这份固件把它们放在哪
python3 tools/screenshot.py --frame-addr 0x... --status-addr 0x... \
--port 1234 --out screen.png
它只看到了 `--frame-addr`,别的都没看到 —— 9 个参数里查了 4 个,然后**报告一切干净**。
**一个静默地只覆盖了自己所声称范围四分之一的检查,比没有检查更糟**,
因为那个"干净"的结果会被相信。现在会先合并续行再做匹配。
写它的过程中有一个教训。早期版本用一个"取行内第一个数字"的正则来比对固件常量,
于是 `key_debounce_10ms = 20 / 10` 被读成 20,检查器于是宣布文档说 2 是错的。
**文档是对的,检查器是坏的。** 一个总在虚报的检查器会被忽略,
所以凡是它无法无歧义验证的东西,都宁可不查,而不是靠猜。
### 数"有多少帧不同"证明的东西比看起来少
写任何观察屏幕的测试之前值得知道这一点。
一旦接收机报告的 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` 确认固件越过了它原来停住的地方
5. 往 `py32_stubs[]` 里加条目时**必须同时改 `PY32_NUM_STUB`**。忘了改以前是**静默**的:
那个设备根本不会被 realize,地址仍是未映射,唯一症状就是"什么都没变"。现在表后面有一条
`QEMU_BUILD_BUG_ON(ARRAY_SIZE(...) != PY32_NUM_STUB)`,会在编译期拦住。
这样找到过两个洞,都对多系统固件是致命的(见可移植性一节):`0x40007400` = `DAC1_BASE`、
`0x1FFF3000` = `UID_BASE`,两者都不在任何面向固件的清单里。所以现在整个 APB/AHB 外设空间
在具名设备之后还有一层**低优先级兜底**:没名字的寄存器会应答并记日志,而不是抛异常 ——
真机有应答、模拟器却数据异常,那是模型的 bug,不是新发现。
## 在新固件里找显示缓冲区的地址
`gFrameBuffer` 和 `gStatusLine` 在不同构建之间会移动,而且**两者都不是 128 字节对齐的**,
所以按对齐地址去猜,渲染出来的图会以"像字体问题"的方式出错:偏 0x3E 字节时,每一行都变成
"上一行的尾部 + 本行的头部",字形会被固定在某一列劈开,状态行也被帧内容盖住。别用眼睛判断
—— 固件源码会明确告诉你它们在哪。
1. 抓 SRAM(QMP `memsave`,或 `python work/qmp.py dump 0x20000000 0x4000 out.bin`)。
2. 挑"内容和落点都能从源码确定"的位图(`App/bitmaps.c` 配合 `App/ui/status.c`、
`App/driver/st7565.c`):`gFontPowerSave` 复制到状态行 +0、`gFontDWR` +18、
`gFontPttClassic` +54、`BITMAP_BatteryLevel1` +111(`LCD_WIDTH - 17`);
`BITMAP_VFO_Default` 由 `memcpy` 写到**帧行**偏移 0。
3. 在 SRAM 里搜这些字节串。只有唯一一个基址能让那四个状态行偏移同时成立,而 VFO 箭头的地址
**就是**帧缓冲基址。两者必须正好相差 `FRAME_LINES * LCD_WIDTH` = 896 —— 这就是"搜索收敛了"
的判据。5.9.0.CN 这份固件:帧缓冲 `0x200012BE`,状态行 `0x2000163E`。
拿到之后的自检:帧行 3 是界面 `memset` 掉的中缝,应当整行全 0;两个 VFO 同频时,
帧行 0/1 与 4/5 相同,而 2 与 6 不同(只有活动 VFO 的说明行有内容)。
## 可移植性:Windows 到底弄坏了什么
机器和工具本身是可移植的 C 与 Python;**包装**是 Linux 专用的。四个失败,每一个在有人依赖它
之前都是不可见的:
* **`rename()` 在 Windows 上不覆盖已存在的文件。** flash 回写是"写临时文件再改名覆盖",
于是每一次保存设置都失败(`cannot replace`),设置永远到不了磁盘,而 stderr 刷屏把主循环
挤住到 QMP 连问候语都发不出来 —— 最终只表现为 "power on failed: timed out"。
`g_rename()`(需要 `<glib/gstdio.h>`)在两个平台上都给出 POSIX 语义。
* **Windows 版 QEMU 无法创建 unix socket**,所以 QMP 必须以 `tcp:host:port` 传输;
`uvk5_qmp.py`、`key.py` 和 supervisor 的启动器现在两种形式都接受。
* **`qemu/py32f071.c` 对原版 QEMU 7.2 编译不过**,缺 `#include "qapi/visitor.h"`
(`visit_type_uint64`);`qom/object.h` 不会间接带入它。
* **`-kernel foo.bin` 会加载到错的地方。** `armv7m_load_kernel()` 把裸二进制加载到给它的基址,
而在本机那个基址是 flash 的**别名区**,于是镜像高 0x2800 字节,第一次取指就 fault。
`tools/bin2elf.py` 把发行版 `.bin` 套上带正确程序头的 ELF32/ARM。
**外置 flash 是有分区的,而且主固件确实在读它。** 有一次 26 秒的抓取里只看到 `0x00A0xx`,
于是"固件根本不用 flash"被当成结论写下来 —— 错了两次:第一次被 PowerShell 的 UTF-16 重定向
骗了,第二次是我把探针**截断在 80 次读**。把上限放到 4000、并打开菜单让固件画汉字之后,
一次启动就产生 3168 次读:设置区、`0x0A0000`/`0x0E0000` 用户字体包里的单个字模,以及
**`0x1E0000` 处一张 32 KB 字体表被按 32 字节一步连续走完 1024 次**。分区如下(由
`gitee.com/oldlicn/betula-multi-system-tool` 的配套数据反推,不是靠它那张分区图):
| 偏移 | 大小 | 内容 |
| --- | --- | --- |
| 0x000000 | 128 KB | 引导 + 设置(`0x00A0xx`)+ 校准(`0x010000`) |
| 0x020000 | 4 × 128 KB | 固件槽(工具里正好有"清空 0x20000-0x40000"到"0x80000-0xA0000") |
| 0x0A0000 | 256 KB | 用户字体包 16x16 |
| 0x0E0000 | 64 KB | 用户字体包 8x8 |
| 0x100000 | 1 MB | 出厂资源区,其中 `0x1E0000` 是那张 32 KB 字体表 |
官方十六份 128 KB 恢复文件可以拼回真机整片 2 MB 数据。把它的 `0x100000-0x200000` 区补进
`assets/flash.img` 之后,**固件画出来的内容会变** —— 说明那块数据是活的,不是装饰。
还没定论的是"哪块文字由哪个来源提供":屏幕上的 16 像素字模与 `0xA0000` 的字模包
(24 格里对 2 格)、与 `0x1E0000` 的表(8 格里 0 格)都不能逐字节对上。
面板设置是第五种、不一样的情况:对比度和反显根本不在帧缓冲里,所以任何渲染 `gFrameBuffer`
的东西都显示不出来。`TYPE_ST7565` 建模控制器自己的寄存器,`tools/uvk5_lcd.py` 把反显应用到
画面上;见 README.md。