Files
uv-k5-v3-emulator/AGENTS.zh-CN.md
T
mckero 394f473df0 Correct the overlay cause: the log order rules the sector-cache overwrite out
The flash probe records every transaction in order and the last one for that launch is the app's own code load (addr=103000 len=544, first bytes f0b583b0); nothing follows before the app is gone, so no font or resource read lands on the running app in that window. The 7848 font-region reads in the log belong to the firmware's own repainting.

What stands measured: the loader copies and jumps (a PC sample lands in the overlay, and a 16-byte app that calls nothing loops there forever); the app dies well under 100 ms (panel reads at t+113 ms still show the menu, at t+193 ms only the launcher's title box); an app that spins first survives a bar, a blit and led and dies around delay_ms; one that calls blit immediately is gone before it can be seen. Next: drop the PC probe interval from 100 ms to a couple of milliseconds, which is a one-line model change and turns it into a real trace of the app's short life.
2026-10-02 10:08:40 +08:00

86 KiB
Raw Blame History

在这个仓库里工作

给下一个接手的人的笔记。重点写代码里看不出来的东西,以及已经在这里浪费过时间的错误。

English: 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,能看出应用停在叠加区的哪个位置。

键盘:两个真 bug,都已修复

这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有两个 互相独立的原因,按出现顺序:

  1. tools/key.py 把每个键都按 2500 ms —— 工具链的 bug,紧接着下面讲。
  2. row_out 没有 volatile,所以 GCC 删掉了驱动行线的代码 —— 真正的模型 bug, 是后来在清理调试打印时引入的。见 row_out 必须保持 volatile。

两个都修了,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。