Files
uv-k5-v3-emulator/AGENTS.zh-CN.md
T
mckero 2667e046e8 Emulator: multiboot slots from the page, flash controller, portable tests
flash controller: store ACR/OPTKEYR instead of swallowing them, which is what stopped the factory bootloader from starting

slots over the firmware's own serial protocol (0x0720 family); uvk5_socket/uvk5_testenv so a fresh checkout skips instead of failing; web UI slot table and Multiboot button; quick start, CONTRIBUTING, and stop tracking firmware images and radio dumps
2026-10-01 14:54:34 +08:00

61 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/screenshot.py --frame-addr 0x200013DC \
    --status-addr 0x2000175C --port 1234 --out screen.png

截图用的地址在不同固件构建之间会变。这样拿到当前值:

arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine'

改完机器模型后重新构建:

cd $QEMU/build && ninja qemu-system-arm    # 增量约 10 秒

任何靠近键盘或 GPIO 接线的改动之后,跑回归测试。它会在私有端口上启动自己的实例, 所以不会干扰正在运行的 run.sh:

python3 tools/keypad_test.py

还有一个浏览器界面,通常是手动折腾固件最快的方式:

python3 tools/webui.py --frame-addr 0x200013DC \
    --status-addr 0x2000175C     # 然后打开 http://127.0.0.1:8080/

关于它,有两点在这个仓库里干活时需要知道:

  • 它会在整个生命周期里占住 QMP socket,所以 key.py 不能同时运行。那个 socket 只接受一个客户端。
  • 它刻意用 QMP memsave 读帧。 不是 pmemsave —— 后者取物理地址, 对 gFrameBuffer 会静默返回全零,也就是一片空白屏幕而且哪里都不报错。 也不是 gdb —— gdb 每次 attach 都会暂停 guest,那会让画面流卡顿,还会扰乱按键防抖时序。

它的测试:tools/test_uvk5_*.py 和 tools/test_webui.py 不需要模拟器, tools/test_webui_e2e.py 会自己启动一个。

固件也可以从页面加载,不必走命令行:POST /api/firmware 把请求体当作镜像,存进 work/firmware/ 并启动它(模拟器正在运行就先重启)。镜像的形态是从镜像自己读出来的 (主机侧 tools/uvk5_image.py,机器侧 uvk5_sniff_app_offset()):应用镜像链接在 0x08002800,整片镜像从 0x08000000 开始,而地址 0 必须别名到对应的基址。判断错的 症状是静默的 —— 镜像整体偏 0x2800 字节,第一次取指读到的是随便什么数据 —— 所以这里 既不用标志位,也不按文件名约定。不是镜像的文件会被拒绝,且不打扰正在运行的电台。

这条路上有两件事是踩出来的:

  • flash 镜像走环境变量,不走 -M。 经启动器启动时,QEMU 会用 "unsupported machine type" 拒绝 -M uv-k5-v3,flash-image=...:同一份 argv 我手跑就 正常,-M help 在同一上下文里能列出这台机器,argv 的 repr 干净,环境变量也 逐项比过、没有定论。属性本身仍然可用,所以两条路都保留;启动器现在只传机器名,另用 UVK5_FLASH_IMAGE,模型把它作为属性缺省的回退。根因未知 —— 没重新验证过"从页面开机" 之前,别把它当成冗余"清理"掉。
  • 画面取自显示控制器,而不是 guest RAM。 面板模型保存控制器自己的显示 RAM (8 页 × 128 列),页面渲染的是它,所以任何固件都显示正确 —— 同一祖先改出的各个版本 显示逻辑不同,多系统那版更是把图像放在完全不同的位置。不要在数据之上再叠加驱动的 0xA1 段反转:同一时刻与 guest 自己的帧缓冲对比,不镜像时 8192 像素里对上 8153, 镜像后只剩 6557。读 gFrameBuffer(memsave)的旧路径保留为"没有面板模型的模拟器" 的回退。

flash 的那些 bug:四个故障,一个症状

"频率改不了"和"关机后 flash 什么都不记得"看起来是两个抱怨。实际是一个根因加上路上顺带 发现的三个真 bug,全都在这个文件里。动 SPI、DMA 或 flash 模型之前值得读一遍, 因为每一个从上层都完全看不见。

  1. DMA 用了错误的地址空间 —— 这是真正的根因。它通过 address_space_memory 搬字节, 而那个地址空间根本无法解码这个 SoC 的内存:container region 只交给了 ARMv7M 内核, 从未注册进全局系统内存。读返回 MEMTX_DECODE_ERROR 和零;写则去了虚空。 现在 DMA 跑在一个基于 container 构建的 AddressSpace 上。
  2. 页编程没有回卷。 真实的 SPI NOR 只锁存低位地址,所以一次超过 256 字节页边界的 burst 会从同一页的开头继续。模型直接一路走了下去,于是固件确实会在单次 CS 事务里 发出的那个 0x008F00 处的 512 字节 burst 溢出到了 0x009000。
  3. DMA 启动得太早。 传输在通道被使能时就跑了,但真实硬件上是外设发出请求时才开始。 驱动的顺序是先武装两个通道、再使能 SPI、最后置 TXDMAEN —— 所以在武装时就触发, 等于在读命令还没发出去之前就把总线时钟走完了。
  4. 两个 DMA 通道一个接一个地跑。 SPI 是双向的,驱动用一个送 dummy 的 TX 通道 配一个收数据的 RX 通道来完成一次传输。让它们顺序执行,等于 TX 走完了整个传输, RX 才开始看总线,那时上面什么都没有了。

其中任何一个都会把存放各波段 VFO 频率的那个扇区清零。而 RADIO_ConfigureChannel 只在读到 0xFFFFFFFF 时才用波段下限替代,所以一个存进去的零会被照字面采用, 然后被钳制到 BX4819_band1_lower —— 18 MHz。输入的频率总是变回去,全部原因就在这里。

tools/test_freq_entry.py 和 tools/test_flash_persist.py 里的 MUST_NOT_CHANGE 守卫 就是为了捕捉这四个中任何一个的回归。

是什么让这件事难查,以及应该怎么做

给模型插桩,不要给 guest 插桩。 频率输入框会在 key_input_timeout_500ms / 3 (约 2.5 秒)后超时,而一次 gdb attach 大约要 3 秒。所以在输入数字之间探测会清空输入框, 然后这次运行会报告一个由测量本身造成的失败。这至少产生过三个自信的错误结论, 包括"固件存到了波段 0"——而实际上只是输入框空了。改成往 qemu/py32f071.c 里加 fprintf 然后读 stderr —— guest 全程不停。

在你知道数据形状之前,永远不要给诊断日志设上限。 一个只记录前六个事务的探针 显示 payload 全是 0xFF,而这恰好支持了完全错误的结论。去掉上限之后, 真正重要的那些写入一目了然。

在相信测试之前,先确认构建成功了。 ninja 失败会把上一个二进制留在原地而测试照样运行, 于是一个过期的构建静默地回答了你的问题。有两轮结果就是这样变得毫无意义的。 用 grep 在构建输出里找 FAILED 和 error:,出现任何一个就停下。

每次运行之间重置 flash 镜像。 assets/flash.img 会被每次会话写入。一个从它出发的测试 可能发现工作已经做完了 —— 表现为"镜像逐字节相同",这和持久化坏了完全无法区分。 要从 assets/pristine/ 出发,而且恢复之前先把模拟器关掉,因为关机会把旧的内存镜像 刷回文件覆盖掉。

不要手算结构体偏移。 这个 ELF 没有 DWARF,而结构体里含有大小不可假设的枚举。 手算出来的偏移产生过 KEY_LOCK=4 和 TX_VFO=11,这两个值都不可能存在。 要么用一个 nm 能报告、且类型无歧义的符号(gInputBoxIndex 就是个纯 uint8_t), 要么靠行为定位字段 —— 用长按 F 切换键盘锁然后 diff 那块区域,一步就找到了 KEY_LOCK 在 gEeprom+0x12。

仔细读你自己的探针输出。 有个探针在 phase 递增之前就把它打印了出来, 这让一个正确的地址解码器看起来偏了一个字节。用 Python 重放那段逻辑才排除掉; 要是没那一步,一个本来能工作的实现就会被"修"坏。

已经出过的错

GDB 断点会暂停 guest。 一个跨越断点会话的按键永远不会被处理,因为主循环没在跑。 这产生过一整轮"按键没反应",而真相是"机器停着"。用 tools/press_and_shot.sh —— 它按下、让机器跑、然后读帧缓冲,全程没有任何断点。

加速 SysTick 时不要把计数值写回去。 有两次尝试是那样做的。结果每次读取都会重新锚定计数, 所以固件看到的值不再变化、它的 if (cur != prev) 判断永远不成立、延时循环彻底卡死 —— 比原本要修的"慢"更糟。有效的做法是报告一个跑在真实计数器前面的值,而不去动那个定时器。

降低时钟频率不会加快延时循环。 瓶颈是每秒的循环迭代次数,不是计数器速度。 48 MHz 降到 200 Hz 只换来 32 倍,远远不够。这是实测的,不是假设。

未命名的 qdev in / out 线共用一个命名空间。 一个同时有未命名 qdev_init_gpio_in 和 qdev_init_gpio_out 的设备会让 qdev_get_gpio_in() 产生歧义,然后板级接线会 静默地接到错误的线上。GPIO 模型用 "pin-in" 和 "pin-out" 正是因为这个。保持这样。

按键时长必须短**,不是越宽松越好。** 这一条以前写的是相反的东西 —— 说 guest 时间跑得快所以按压需要长时间按住,还说 key.py 应该按 2500 ms。 那是错的,而且它把键盘工具链弄坏了很长时间。 2500 ms 约等于 250 个固件 tick, 是长按阈值的六倍,所以每次按压都被派发成长按,而那些在短按松手时才动作的处理函数 什么都没做。见下面的键盘章节;key.py 现在按 200 ms。

在相信一个工具的输出之前,先验证它自己的解析。 gpio_watch.py 有好几轮都报告 IDR=0x0000,因为它的正则根本不匹配 gdb 的输出格式。寄存器是好的;读取器是坏的。 用走另一条路径的 tools/gpiob_dump.sh 交叉验证。

同一个陷阱再往外一层:重定向会改变编码。 三次 qemu ... 2> probe.log 的探针都报告 SPI 零传输、flash 零读取、片选零变化,而"固件根本不碰 SPI"被当成了结论写下来。 PowerShell 5.1 的 2> 按 UTF-16LE 写文件,于是探针打出的每一行 ASCII 里每个字符之间都夹着 NUL,startswith("LCDW") 永远匹配不上。把同一个文件按 UTF-16 解码,看到的是完整的 ST7565 初始化序列和 48 个不同的设置读取。在相信一个"什么都没发生"的探针之前,先确认探针能被看见: 读一下文件、数一下字节,或者用不会重新编码的 cmd /c 来写。

QMP pmemsave 是物理地址,memsave 是虚拟地址。 帧缓冲符号是 CPU 虚拟地址, 所以对 gFrameBuffer 用 pmemsave 会返回一整块零并报告成功 —— 一片空白屏幕, 而且哪里都没有日志。网页界面最初就是建在 pmemsave 上的,因为一个计时基准说它更快; 那个基准从来没有检查过内容。要测量你真正在意的东西:这个 bug 是在渲染出的一帧 返回 0 个亮像素、而 gdb 路径报告 1693 时才浮出水面的。

页面是 f-string 生成的,所以要检查它真正吐出的脚本

网页 UI 是一整条 f-string。JavaScript 字符串字面量里多一个或少一个反斜杠,整段 <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 控制器建模之前被观察到, 之后就不再复现。正确的动作是问"这两次之间改了什么",而不是怀疑早先的记录。

键盘:两个真 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/screenshot.py --frame-addr 0x200013DC \
    --status-addr 0x2000175C --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。