The bootloader's DFU handler is reachable only when SRAM[0x20000020] is 3, which only a program that then resets can write. The application's 0x05DD takes that path only with ENABLE_OVERLAY; this build resets straight back into the application instead. Ruled out by measurement: PTT alone, PTT+SIDE1/SIDE2, MENU, a host byte in the boot window including 0x0530, and 0x05DD. boot-key now reads a + separated list, because the firmware's own BOOT_GetMode() needs PTT and a matrix key together for every special boot mode. Tested against all four documented combinations.
62 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/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 模型之前值得读一遍, 因为每一个从上层都完全看不见。
- 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,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有两个 互相独立的原因,按出现顺序:
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/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 是倒着数的,写入偏移必须由差值算出来。
如果你要加一个外设
- 从 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。