pip install ziglang cross-compiles to thumb-freestanding-eabi, which is enough to build a .app with no arm-none-eabi-gcc and no Docker. Measured refusals: --defsym, -Ttext and --section-start come back as unsupported linker args, so the VMA is resolved into a copy of app.ld; -T is forwarded (a missing script errors); --image-base is accepted but page-aligns the segments into 0x200103C8 and 0x20020C94. tools/elf2bin.py extracts allocated sections rather than program headers, because lld maps the ELF header and phdr table as a 180-byte LOAD of its own -- following the headers starts the image at 0x20000000 and APP_ERR_VMA. One division pulled in __aeabi_uidiv, which a -nostdlib blob cannot have: Minesweeper now avoids division entirely. app_main carries the .text.entry attribute upstream's apps use, so the entry is first for the loader's jump to offset 0. Minesweeper builds to 2408 bytes of code against a 4096-byte budget. Installed through the page, the firmware reads the slot header twice and then exactly code_size bytes from slot+0x1000 -- the only read of that size in the boot log -- so the blob shape, header, CRC, VMA and offset are all accepted. Whether control reaches the overlay is unproven: the 100 ms PC probe saw no overlay address, no APP ERROR screen appears, and the app does not draw. test_elf2bin pins the phantom-header-segment lesson; both AGENTS files record the rest.
78 KiB
在这个仓库里工作
给下一个接手的人的笔记。重点写代码里看不出来的东西,以及已经在这里浪费过时间的错误。
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 模型之前值得读一遍, 因为每一个从上层都完全看不见。
- DMA 用了错误的地址空间 —— 这是真正的根因。它通过
address_space_memory搬字节, 而那个地址空间根本无法解码这个 SoC 的内存:container region 只交给了 ARMv7M 内核, 从未注册进全局系统内存。读返回MEMTX_DECODE_ERROR和零;写则去了虚空。 现在 DMA 跑在一个基于 container 构建的AddressSpace上。 - 页编程没有回卷。 真实的 SPI NOR 只锁存低位地址,所以一次超过 256 字节页边界的 burst 会从同一页的开头继续。模型直接一路走了下去,于是固件确实会在单次 CS 事务里 发出的那个 0x008F00 处的 512 字节 burst 溢出到了 0x009000。
- DMA 启动得太早。 传输在通道被使能时就跑了,但真实硬件上是外设发出请求时才开始。 驱动的顺序是先武装两个通道、再使能 SPI、最后置 TXDMAEN —— 所以在武装时就触发, 等于在读命令还没发出去之前就把总线时钟走完了。
- 两个 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 屏,应用也没有画出来。现状就是如此 —— 管线验证到了
"加载"这一步,还没到"执行"。
键盘:两个真 bug,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有两个 互相独立的原因,按出现顺序:
tools/key.py把每个键都按 2500 ms —— 工具链的 bug,紧接着下面讲。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 结尾。所以没有任何行线被驱动,固件的扫描读到全高,
模型看起来是坏的。
走到这一步经历了三个错误诊断,都值得知道:
- "省电模式停掉了键盘扫描。" 这条曾被当作模型缺陷写在这里。不是 —— 醒着的时候一样是坏的。
- "它需要一点稳定时间。" 有三个
fprintf(stderr, "TRACE ...")探针在清理时被删掉了, 而把keypad_update_rows里那个恢复回去就修好了,加一个忙等循环也能修好。 这看起来像是时序依赖。并不是 —— 那个 fprintf 和那个循环只是 GCC 无法丢弃的副作用, 它们让那个循环活了下来。 - "这是编译器的顺序问题。" 一个零开销的
__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会比较 flash0x00A160处的版本字符串,在一个新镜像上发现不匹配, 于是写入设置扇区。而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_00bit 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 是倒着数的,写入偏移必须由差值算出来。
如果你要加一个外设
- 从 CMSIS 头文件里读寄存器布局
- 只建模固件真正碰到的部分;带日志的兜底模块(
py32-stub)会告诉你那是哪些 - 警惕自旋循环:任何固件会轮询的标志都必须能够变化,
而写 1 启动的位(比如
ADC_CR2_CAL)绝不能被存成置位状态 - 重新构建、运行,并用
tools/where.sh确认固件越过了它原来停住的地方 - 往
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 字节时,每一行都变成
"上一行的尾部 + 本行的头部",字形会被固定在某一列劈开,状态行也被帧内容盖住。别用眼睛判断
—— 固件源码会明确告诉你它们在哪。
- 抓 SRAM(QMP
memsave,或python work/qmp.py dump 0x20000000 0x4000 out.bin)。 - 挑"内容和落点都能从源码确定"的位图(
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。 - 在 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。