mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
The Labs edition runs overlay apps (Tetris, Breakout, Plasma, Cube3D, Beam, Beacon, FoxHunt, BroadcastFM) that upstream UVStudio installs over WebSerial. This page owns the flash image, so the same bytes go to the same offsets with no serial protocol and no browser permission: APP_REGION_BASE 0x102000, APP_SLOT_STRIDE 0x2000, APP_CODE_OFFSET 0x1000, 16 slots, taken from the firmware's own App/apps/app_overlay.h rather than inferred. tools/uvk5_apps.py parses and validates the 64-byte FAP1 header (zlib CRC-32 over the code, vma 0x20000280, name, version, capabilities), lists, installs and erases slots, and refuses what the firmware would show as APP ERROR. test_uvk5_apps covers those refusals plus install/erase/list round trips, and parses a real upstream Beam.app when one has been downloaded. The header struct was 60 bytes at first -- a missing vma field -- which the real file's bytes showed at once.
1056 lines
70 KiB
Markdown
1056 lines
70 KiB
Markdown
# 在这个仓库里工作
|
||
|
||
给下一个接手的人的笔记。重点写代码里看不出来的东西,以及**已经在这里浪费过时间的错误**。
|
||
|
||
*English: [AGENTS.md](AGENTS.md) · 两份内容对应,改动请同步。*
|
||
|
||
## 这是什么
|
||
|
||
一个给普冉 PY32F071(Cortex-M0+)写的 QEMU 机器模型,让泉盛 UV-K5 V3 固件能在 PC 上跑。
|
||
约 5 秒进入主循环,LCD 可读。
|
||
|
||
机器定义和所有设备模型都在一个文件里:`qemu/py32f071.c`。这是刻意的:这些模型都很小,
|
||
而且彼此的接线紧密耦合,拆开只会把板级布局摊得更散,并不会让任何一部分更清楚。
|
||
|
||
## 它是怎么启动的
|
||
|
||
在排查任何看起来像启动问题的东西之前值得先读这一节。**这里没有 bootloader、没有内核、
|
||
没有分区表、没有文件系统** —— 固件是机器上唯一的代码,它完全独占 CPU。
|
||
|
||
**硬件只认两个数字。** Cortex-M0+ 复位后不运行任何引导逻辑。它从向量表第一个字取 SP、
|
||
第二个字取 PC,然后开始执行。整个交接就这么多。
|
||
|
||
.isr_vector 0x08002800 (readelf -SW, 大小 0xc0)
|
||
+0x00 0x20004000 初始 SP,也就是 16 KB SRAM 的顶端
|
||
+0x04 0x08002d49 Reset_Handler,同时是 ELF 入口点
|
||
|
||
有疑问就直接从镜像里读 —— 字节是小端,所以 `00400020 492d0008` 表示 SP 0x20004000
|
||
后面跟着 PC 0x08002d49:
|
||
|
||
objdump -s -j .isr_vector firmware.elf | head -5
|
||
|
||
那个奇数地址不是笔误:bit 0 标记 Thumb 状态,硬件取指时会把它屏蔽掉。
|
||
|
||
**`PY32_APP_OFFSET` 0x2800 是承重的。** Flash 从 `0x08000000` 开始,但前 10 KB 是出厂
|
||
bootloader 区域,所以应用在它之后。`armv7m_load_kernel()` 之所以要传这个偏移正是因为
|
||
这个 —— 改成在 `0x08000000` 加载,向量表就落在错误的位置,**第一次取指就会 fault**。
|
||
|
||
**启动代码是 31 行汇编**,在固件的 `Core/startup_py32f071xx.s` 里:
|
||
|
||
从 _estack 设置 SP
|
||
bl SystemInit
|
||
把 .data 从 flash (_sidata) 复制进 RAM (_sdata .. _edata)
|
||
清零 .bss (_sbss .. _ebss)
|
||
bl __libc_init_array
|
||
bl main
|
||
LoopForever: b LoopForever @ main 永不返回
|
||
|
||
复制和清零那两步才是有意思的地方。已初始化的全局变量存在 flash 里但必须可写,
|
||
所以要逐字复制进 RAM;未初始化的全局变量按 C 标准必须读作零,所以 `.bss` 要清掉。
|
||
在有操作系统的环境里,内核和加载器帮你做这些。这里没人做,所以**这两个循环只要有一个错了,
|
||
你就会得到静默变成垃圾的全局变量**。
|
||
|
||
**然后是应用:**
|
||
|
||
main() Core/Src/main.c —— 只配时钟,然后进 Main()
|
||
Main() App/main.c —— 真正的固件
|
||
SYSTICK_Init() 一切时序都以这个 10 ms tick 为基准
|
||
BOARD_Init() GPIO、SPI、LCD、键盘矩阵
|
||
UART_Init() 日志里那条 SERIAL 横幅就是从这来的
|
||
SETTINGS_InitEEPROM() 通过 SPI 从 flash 镜像读设置
|
||
while (1) { ... } 主循环,永不退出
|
||
|
||
**没有文件系统。** 最接近"挂载分区"的东西是 `SETTINGS_InitEEPROM()` 通过 SPI 读取固定的
|
||
字节偏移:`0xA008` 是省电字节,`0x0E70` 是 VFO 索引,等等。没有元数据、没有目录、
|
||
没有校验和 —— 只有一个代码和数据必须约定一致的地址。**所以某个设置读回来不对时,
|
||
先怀疑偏移,再怀疑传输层。**
|
||
|
||
启动耗时是模拟开销。本机实测:QEMU 启动后约 1.6 秒出现第一个像素、约 3.6 秒画出主界面,
|
||
也就是 README 里写的"约 5 秒"。真机大约一秒就起来了。
|
||
|
||
## 基本规则
|
||
|
||
**永远不要为了让模拟器能跑而改固件。** 固件是**基准**。如果某个东西跑不起来,那是模型错了。
|
||
一个修改了固件源码的"修复"会让之后所有测试失去意义,因为你测的已经不是电台实际运行的东西。
|
||
|
||
**寄存器布局来自厂商的 CMSIS 头文件**,不是搜 datasheet 搜来的,也不是推断出来的:
|
||
|
||
<firmware>/Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h
|
||
|
||
需要某个位的位置时,去那里读。有几处细节很反直觉 —— 比如 `LL_ADC_FLAG_EOS` 在这颗片子上
|
||
其实是 `ADC_SR_EOC` —— **靠猜会做出看着对、实际会挂的模型**。
|
||
|
||
**靠观察固件停在哪里来决定下一个要建模的东西**,而不是从头到尾读 datasheet。
|
||
这里每一个外设都是因为固件确实在等它才加进来的:
|
||
|
||
tools/where.sh 4 # 多采样几次调用栈
|
||
|
||
如果多次采样都停在同一个函数里,那就是个自旋循环。去看它读了什么。
|
||
|
||
## 怎么运行
|
||
|
||
python3 tools/make_flash.py # 一次;生成 assets/flash.img
|
||
tools/run.sh # GDB stub 在 :1234,QMP 在 /tmp/uvk5-qmp.sock
|
||
|
||
tools/where.sh # 执行到哪了
|
||
tools/gpiob_dump.sh # GPIOB 寄存器
|
||
python3 tools/key.py MENU # 注入一次按键
|
||
python3 tools/uvk5_buffers.py --qmp 127.0.0.1:4444 # 这份固件把它们放在哪
|
||
python3 tools/screenshot.py --frame-addr 0x... --status-addr 0x... \
|
||
--port 1234 --out screen.png
|
||
|
||
截图用的地址在不同固件构建之间会变,而 `screenshot.py` 读的是 guest RAM,所以需要它们。
|
||
`tools/uvk5_buffers.py` 会把控制器显存和 SRAM 做匹配、从正在运行的固件里找出来 —— 这里构建出来的
|
||
镜像是**只有程序头的最小 ELF、没有符号表**,所以 `nm` 对它们无字可读(完整链接的 ELF 才有)。
|
||
|
||
改完机器模型后重新构建:
|
||
|
||
cd $QEMU/build && ninja qemu-system-arm # 增量约 10 秒
|
||
|
||
任何靠近键盘或 GPIO 接线的改动之后,跑回归测试。它会在私有端口上启动自己的实例,
|
||
所以不会干扰正在运行的 `run.sh`:
|
||
|
||
python3 tools/keypad_test.py
|
||
|
||
还有一个浏览器界面,通常是手动折腾固件最快的方式:
|
||
|
||
python3 tools/webui.py # 不需要地址:页面画的是面板显存
|
||
|
||
关于它,有两点在这个仓库里干活时需要知道:
|
||
|
||
- **它会在整个生命周期里占住 QMP socket**,所以 `key.py` 不能同时运行。那个 socket
|
||
只接受一个客户端。
|
||
- **它刻意用 QMP `memsave` 读帧。** 不是 `pmemsave` —— 后者取**物理**地址,
|
||
对 `gFrameBuffer` 会静默返回全零,也就是一片空白屏幕而且哪里都不报错。
|
||
也不是 gdb —— gdb 每次 attach 都会暂停 guest,那会让画面流卡顿,还会扰乱按键防抖时序。
|
||
|
||
它的测试:`tools/test_uvk5_*.py` 和 `tools/test_webui.py` 不需要模拟器,
|
||
`tools/test_webui_e2e.py` 会自己启动一个。
|
||
|
||
固件也可以**从页面加载**,不必走命令行:`POST /api/firmware` 把请求体当作镜像,存进
|
||
`work/firmware/` 并启动它(模拟器正在运行就先重启)。镜像的**形态**是从镜像自己读出来的
|
||
(主机侧 `tools/uvk5_image.py`,机器侧 `uvk5_sniff_app_offset()`):*应用镜像*链接在
|
||
`0x08002800`,*整片镜像*从 `0x08000000` 开始,而地址 0 必须别名到对应的基址。判断错的
|
||
症状是**静默**的 —— 镜像整体偏 0x2800 字节,第一次取指读到的是随便什么数据 —— 所以这里
|
||
既不用标志位,也不按文件名约定。不是镜像的文件会被拒绝,且不打扰正在运行的电台。
|
||
|
||
这条路上有两件事是踩出来的:
|
||
|
||
- **flash 镜像走环境变量,不走 `-M`。** 经启动器启动时,QEMU 会用
|
||
"unsupported machine type" 拒绝 `-M uv-k5-v3,flash-image=...`:同一份 argv 我手跑就
|
||
正常,`-M help` 在**同一上下文**里能列出这台机器,argv 的 `repr` 干净,环境变量也
|
||
逐项比过、没有定论。属性本身仍然可用,所以两条路都保留;启动器现在只传机器名,另用
|
||
`UVK5_FLASH_IMAGE`,模型把它作为属性缺省的回退。根因未知 —— 没重新验证过"从页面开机"
|
||
之前,别把它当成冗余"清理"掉。
|
||
- **画面取自显示控制器,而不是 guest RAM。** 面板模型保存控制器自己的显示 RAM
|
||
(8 页 × 128 列),页面渲染的是它,所以**任何**固件都显示正确 —— 同一祖先改出的各个版本
|
||
显示逻辑不同,多系统那版更是把图像放在完全不同的位置。**不要**在数据之上再叠加驱动的
|
||
`0xA1` 段反转:同一时刻与 guest 自己的帧缓冲对比,不镜像时 8192 像素里对上 8153,
|
||
镜像后只剩 6557。读 `gFrameBuffer`(`memsave`)的旧路径保留为"没有面板模型的模拟器"
|
||
的回退。
|
||
|
||
## flash 的那些 bug:四个故障,一个症状
|
||
|
||
"频率改不了"和"关机后 flash 什么都不记得"看起来是两个抱怨。实际是**一个根因加上路上顺带
|
||
发现的三个真 bug**,全都在这个文件里。动 SPI、DMA 或 flash 模型之前值得读一遍,
|
||
因为每一个从上层都完全看不见。
|
||
|
||
1. **DMA 用了错误的地址空间** —— 这是真正的根因。它通过 `address_space_memory` 搬字节,
|
||
而那个地址空间**根本无法解码这个 SoC 的内存**:container region 只交给了 ARMv7M 内核,
|
||
从未注册进全局系统内存。读返回 `MEMTX_DECODE_ERROR` 和零;写则去了虚空。
|
||
现在 DMA 跑在一个基于 container 构建的 `AddressSpace` 上。
|
||
2. **页编程没有回卷。** 真实的 SPI NOR 只锁存低位地址,所以一次超过 256 字节页边界的
|
||
burst 会**从同一页的开头继续**。模型直接一路走了下去,于是固件确实会在单次 CS 事务里
|
||
发出的那个 0x008F00 处的 512 字节 burst 溢出到了 0x009000。
|
||
3. **DMA 启动得太早。** 传输在通道被使能时就跑了,但真实硬件上是**外设发出请求时**才开始。
|
||
驱动的顺序是先武装两个通道、再使能 SPI、最后置 TXDMAEN —— 所以在武装时就触发,
|
||
等于在读命令还没发出去之前就把总线时钟走完了。
|
||
4. **两个 DMA 通道一个接一个地跑。** SPI 是双向的,驱动用一个送 dummy 的 TX 通道
|
||
配一个收数据的 RX 通道来完成一次传输。让它们顺序执行,等于 TX 走完了整个传输,
|
||
RX 才开始看总线,那时上面什么都没有了。
|
||
|
||
其中**任何一个**都会把存放各波段 VFO 频率的那个扇区清零。而 `RADIO_ConfigureChannel`
|
||
**只在读到 `0xFFFFFFFF` 时**才用波段下限替代,所以一个存进去的零会被照字面采用,
|
||
然后被钳制到 `BX4819_band1_lower` —— 18 MHz。**输入的频率总是变回去,全部原因就在这里。**
|
||
|
||
`tools/test_freq_entry.py` 和 `tools/test_flash_persist.py` 里的 `MUST_NOT_CHANGE` 守卫
|
||
就是为了捕捉这四个中任何一个的回归。
|
||
|
||
### 是什么让这件事难查,以及应该怎么做
|
||
|
||
**给模型插桩,不要给 guest 插桩。** 频率输入框会在 `key_input_timeout_500ms / 3`
|
||
(约 2.5 秒)后超时,而一次 gdb attach 大约要 3 秒。所以**在输入数字之间探测会清空输入框**,
|
||
然后这次运行会报告一个由测量本身造成的失败。这至少产生过三个自信的错误结论,
|
||
包括"固件存到了波段 0"——而实际上只是输入框空了。改成往 `qemu/py32f071.c` 里加 `fprintf`
|
||
然后读 stderr —— guest 全程不停。
|
||
|
||
**在你知道数据形状之前,永远不要给诊断日志设上限。** 一个只记录前六个事务的探针
|
||
显示 payload 全是 `0xFF`,而这恰好支持了**完全错误的结论**。去掉上限之后,
|
||
真正重要的那些写入一目了然。
|
||
|
||
**在相信测试之前,先确认构建成功了。** `ninja` 失败会把上一个二进制留在原地而测试照样运行,
|
||
于是一个过期的构建**静默地**回答了你的问题。有两轮结果就是这样变得毫无意义的。
|
||
用 grep 在构建输出里找 `FAILED` 和 `error:`,出现任何一个就停下。
|
||
|
||
**每次运行之间重置 flash 镜像。** `assets/flash.img` 会被每次会话写入。一个从它出发的测试
|
||
可能发现工作已经做完了 —— 表现为"镜像逐字节相同",这和持久化坏了**完全无法区分**。
|
||
要从 `assets/pristine/` 出发,而且**恢复之前先把模拟器关掉**,因为关机会把旧的内存镜像
|
||
刷回文件覆盖掉。
|
||
|
||
**不要手算结构体偏移。** 这个 ELF 没有 DWARF,而结构体里含有大小不可假设的枚举。
|
||
手算出来的偏移产生过 `KEY_LOCK=4` 和 `TX_VFO=11`,**这两个值都不可能存在**。
|
||
要么用一个 `nm` 能报告、且类型无歧义的符号(`gInputBoxIndex` 就是个纯 `uint8_t`),
|
||
要么**靠行为定位字段** —— 用长按 `F` 切换键盘锁然后 diff 那块区域,一步就找到了
|
||
`KEY_LOCK` 在 `gEeprom+0x12`。
|
||
|
||
**仔细读你自己的探针输出。** 有个探针在 `phase` 递增**之前**就把它打印了出来,
|
||
这让一个正确的地址解码器看起来偏了一个字节。用 Python 重放那段逻辑才排除掉;
|
||
要是没那一步,一个本来能工作的实现就会被"修"坏。
|
||
|
||
## 已经出过的错
|
||
|
||
**GDB 断点会暂停 guest。** 一个跨越断点会话的按键**永远不会被处理**,因为主循环没在跑。
|
||
这产生过一整轮"按键没反应",而真相是"机器停着"。用 `tools/press_and_shot.sh` ——
|
||
它按下、让机器跑、然后读帧缓冲,全程没有任何断点。
|
||
|
||
**加速 SysTick 时不要把计数值写回去。** 有两次尝试是那样做的。结果每次读取都会重新锚定计数,
|
||
所以固件看到的值不再变化、它的 `if (cur != prev)` 判断永远不成立、延时循环彻底卡死 ——
|
||
**比原本要修的"慢"更糟**。有效的做法是报告一个跑在真实计数器前面的值,而不去动那个定时器。
|
||
|
||
**降低时钟频率不会加快延时循环。** 瓶颈是每秒的循环迭代次数,不是计数器速度。
|
||
48 MHz 降到 200 Hz 只换来 32 倍,远远不够。这是实测的,不是假设。
|
||
|
||
**未命名的 qdev in / out 线共用一个命名空间。** 一个同时有未命名 `qdev_init_gpio_in`
|
||
和 `qdev_init_gpio_out` 的设备会让 `qdev_get_gpio_in()` 产生歧义,然后板级接线会
|
||
**静默地接到错误的线上**。GPIO 模型用 `"pin-in"` 和 `"pin-out"` 正是因为这个。保持这样。
|
||
|
||
**按键时长必须**短**,不是越宽松越好。** 这一条以前写的是**相反**的东西 ——
|
||
说 guest 时间跑得快所以按压需要长时间按住,还说 `key.py` 应该按 2500 ms。
|
||
**那是错的,而且它把键盘工具链弄坏了很长时间。** 2500 ms 约等于 250 个固件 tick,
|
||
是长按阈值的六倍,所以每次按压都被派发成**长按**,而那些在短按松手时才动作的处理函数
|
||
什么都没做。见下面的键盘章节;`key.py` 现在按 200 ms。
|
||
|
||
**在相信一个工具的输出之前,先验证它自己的解析。** `gpio_watch.py` 有好几轮都报告
|
||
`IDR=0x0000`,因为它的正则**根本不匹配** gdb 的输出格式。寄存器是好的;读取器是坏的。
|
||
用走另一条路径的 `tools/gpiob_dump.sh` 交叉验证。
|
||
|
||
同一个陷阱再往外一层:**重定向会改变编码。** 三次 `qemu ... 2> probe.log` 的探针都报告
|
||
SPI 零传输、flash 零读取、片选零变化,而"固件根本不碰 SPI"被当成了结论写下来。
|
||
PowerShell 5.1 的 `2>` 按 UTF-16LE 写文件,于是探针打出的每一行 ASCII 里每个字符之间都夹着
|
||
NUL,`startswith("LCDW")` 永远匹配不上。把同一个文件按 UTF-16 解码,看到的是完整的 ST7565
|
||
初始化序列和 48 个不同的设置读取。**在相信一个"什么都没发生"的探针之前,先确认探针能被看见**:
|
||
读一下文件、数一下字节,或者用不会重新编码的 `cmd /c` 来写。
|
||
|
||
**QMP `pmemsave` 是物理地址,`memsave` 是虚拟地址。** 帧缓冲符号是 CPU 虚拟地址,
|
||
所以对 `gFrameBuffer` 用 `pmemsave` 会返回一整块零**并报告成功** —— 一片空白屏幕,
|
||
而且哪里都没有日志。网页界面最初就是建在 `pmemsave` 上的,因为一个计时基准说它更快;
|
||
**那个基准从来没有检查过内容**。要测量你真正在意的东西:这个 bug 是在渲染出的一帧
|
||
返回 0 个亮像素、而 gdb 路径报告 1693 时才浮出水面的。
|
||
|
||
### 页面是 f-string 生成的,所以要检查它真正吐出的脚本
|
||
|
||
网页 UI 是一整条 f-string。JavaScript 字符串字面量里多一个或少一个反斜杠,**整段**
|
||
`<script>` 就无法解析,而唯一的现象是状态栏永远停在 "connecting..."——同时所有接口用
|
||
`curl` 测又都正常。这真的发布过一次:`.split('\\')` 变成了 `.split('\')`,一个没闭合的
|
||
字符串,于是从浏览器看页面是死的,而每个测试都通过。
|
||
|
||
现在 `test_webui.TestPageScriptParses` 会把服务端真正输出的脚本抽出来交给 `node --check`。
|
||
**要测你交付出去的那个产物,而不是生成它的代码。**
|
||
|
||
### 探针必须真的能看到它在找的东西
|
||
|
||
有三轮"固件从不碰 flash"的结论,全是探针的错,而且每一轮看起来都像个发现:
|
||
|
||
- 探针按 `address >= 0x0C0000` 过滤,于是所有**没有地址**的帧——写使能、以及真正执行
|
||
擦除的扇区擦除——都被丢掉了。"0 次写"是过滤器造成的,不是固件。
|
||
- 握手只给了 1.5 秒,而固件需要约 4 秒才进入串口模式。"没有应答"是超时造成的。
|
||
- 探针为什么会完全看不见:PowerShell 5.1 的 `2>` 会写成 UTF-16LE,每行字符之间都夹
|
||
一个 NUL,任何过滤都匹配不上。
|
||
|
||
在相信一个空探针之前,先让它打印出一个你确定存在的东西。
|
||
|
||
### flash 模型每释放一次片选就把整份镜像写回
|
||
|
||
对一次设置保存来说 2 MB 不算什么;但对多系统的主机接口就是灾难——它是按 200 字节一块
|
||
编程槽位的(`App/app/uart.c` 的 `0x0724`,12 字节头 + 数据):一份 114 KB 固件会变成约
|
||
600 次全量重写,而且跑在 vCPU 线程上,于是**客人**和所有跟它对话的主机工具都要等。实测:
|
||
一次 64 字节的槽位写入花了六秒。
|
||
|
||
第一版修法是加 200 毫秒节流,那是错的:它用"强杀时静默丢掉最后一段写入"换来了测试变快。
|
||
现在模型记录**改动区间**并只写那一段(就地写),既快又更忠实——真机 NOR 的编程本来就不是
|
||
原子的。退出时的通知器仍然整份写回。
|
||
|
||
### 串口链路上跑着固件自己的屏幕流
|
||
|
||
`K5Viewer` 会把屏幕从 USART1 推出去。主机客户端如果只在等应答时才读,socket 就会积压,
|
||
**客人随后会阻塞**在写串口上:槽位传输会在中途开始丢应答,一次很小的写入也要好几秒。
|
||
正确做法是用一个读线程持续排空,等待逻辑只去看已经重组好的帧——在电台那一侧,任何跟它
|
||
对话的程序都适用同一条。
|
||
|
||
这条路上还有两点:固件的接收缓冲是 256 字节
|
||
(`App/driver/uart.c: UART_DMA_Buffer[256]`),240 字节数据加上帧头就超了,于是每一帧都被
|
||
静默丢弃,200 就合适;以及串口**会话**在约 6 秒没有 `0x0514` 后会超时
|
||
(`gSerialConfigCountDown_500ms = 12`),长传输一定会跨过它——用重新握手实测:写入立刻恢复。
|
||
|
||
这四点都修好后,`tools/uvk5_slots_serial.py` 能通过固件本身写入槽位,并由设备校验 CRC。
|
||
|
||
### 开机后才连上的串口客户端会错过一切
|
||
|
||
`-serial tcp:host:port,server=on,wait=off` 在**没有客户端连接时会丢弃**客人写出的字节。
|
||
固件的横幅是在最初几秒打印的,所以"等 QEMU 起来再连"(比如四秒后)会看到一个空端口——
|
||
和"客人根本没启动"长得一模一样。有四轮"引导什么也不发"其实是这个原因,不是引导。
|
||
|
||
先连接,再让客人跑。同一条陷阱也适用于引导等待主机时持续发出的 0x0518 洪水:它是持续的,所以
|
||
晚连的客户端**也能看到**——这正是这个错误能存活这么久的原因,它只对一次性启动输出显形。
|
||
|
||
另外两个在这里学到的习惯:
|
||
|
||
- **先确认探针能看到一个你确定存在的东西。** 一个 USART 寄存器探针报告零次访问,最自然的读法
|
||
是"引导从不配置串口"。而用同一个探针跑应用,得到 2239 次——这才说明探针是好的、引导确实是沉默的。
|
||
- **当一条记录下来的观察不再能复现时,把它当成未验证。** 引导的 Moto 洪水是从一次现在已无法用
|
||
当前构建与镜像复现的运行里写下来的;要依赖它之前先重新推导。
|
||
|
||
### 裸的 host:port 不是"scheme"
|
||
|
||
`uvk5_socket.connect` 会按 ":" 切分参数来找 scheme,于是监管进程、网页和 README 一直传的
|
||
`127.0.0.1:4444` 被读成:scheme 是 `127.0.0.1`、端口为空、主机为空。连接就一直等到超时。
|
||
从外面看到的现象是"网页开不了机",而用完全相同命令行手敲的 QEMU 半秒就应答 QMP,客人则在后台
|
||
好好跑着。
|
||
|
||
两件事让它难被发现:同一个助手也接受 `tcp:host:port`,所以用那种写法的测试都是通的;而失败
|
||
形式是**超时**而不是报错,看起来像"模拟器很慢或卡住"。现在 `test_uvk5_socket` 覆盖了所有会走到
|
||
`connect` 的写法。推广一句:**当一个助手接受多种写法时,每一种都要测**——没人测的那一种,往往
|
||
就是所有人都在用的那一种。
|
||
|
||
同一处的另一半是**另一个独立真问题**:QEMU 的 stderr 必须从启动那一刻就被排空。固件把屏幕流从
|
||
那根管道推出去,约一秒就能填满 64 KB,QEMU 写不进去就阻塞——主循环停了,QMP 自然也不应答。
|
||
两种做法都量过:排空时 QMP 0.5 秒应答;不排空时永远不应答。
|
||
|
||
### 你吞掉的寄存器,就是下一个程序死等的东西
|
||
|
||
出厂引导**完全起不来**:没有任何串口输出,PC 探针每一次采样都停在 `0x08000f38`。那地址在引导
|
||
区里,两条指令是:
|
||
|
||
0x0f38: ldr r2, [r1] ; r1 = 0x40022000,FLASH 控制器
|
||
0x0f3a: lsls r2, r2, #30
|
||
0x0f3c: lsrs r2, r2, #30 ; r2 = ACR & 3,就是 LATENCY 字段
|
||
0x0f3e: cmp r2, #1 ; 等 1 个等待周期
|
||
0x0f40: bne 0x0f38
|
||
|
||
早前加进来的 FLASH 控制器模型把 `ACR` 与 `OPTKEYR` 当成"可忽略的写"——直接提前 return,
|
||
于是通用路径从未保存它们,`ACR` 永远回读 0,引导在配置串口之前就死等。改成 `return false`
|
||
让数值被保存后,PC 立刻进入应用区(`0x08013ea0`),串口也开始有输出。
|
||
|
||
两条都通用:
|
||
|
||
- **只写寄存器也是寄存器。** 应用从不回读 `ACR`,所以只要只有应用在跑,这个缺陷就是隐形的;
|
||
下一个碰同一个外设的程序立刻撞上。
|
||
- **"以前是好的"就是一条二分指令。** 引导的 Moto 洪水在 FLASH 控制器建模**之前**被观察到,
|
||
之后就不再复现。正确的动作是问"这两次之间改了什么",而不是怀疑早先的记录。
|
||
|
||
### MOTO/DFU:入口是一个编译开关,不是一个按键
|
||
|
||
前 10 KB 里的出厂引导**确实**含有 38400 波特的 Moto DFU 处理器,模拟器也把引导跑对了。
|
||
但它从外部**进不去**,原因就在引导自己的代码里:
|
||
|
||
0x13f2 ldrb r0, [r4, #0] ; r4 = 0x20000020,SRAM 里的一个字节
|
||
0x13f4 cmp r0, #1
|
||
0x13f6 beq ...
|
||
0x13f8 cmp r0, #2
|
||
0x13fa beq ...
|
||
0x13fc cmp r0, #3
|
||
0x13fe bne ... ; 其它值继续等
|
||
0x140e bl 0x06f0 ; 只有 mode 3 走到这里:DFU 处理器
|
||
|
||
SRAM 能挺过软复位、挺不过断电,所以这个字节只能由「写完就复位」的程序设置。在应用里那是
|
||
`overlay_FLASH_RebootToBootloader()`,由串口命令 `0x05DD` 触发——**但只在构建定义了
|
||
`ENABLE_OVERLAY` 时**;没有它,同一条命令就是普通的 `NVIC_SystemReset()`。实测:向运行中的
|
||
电台发 `0x05DD`,之后**没有** `0x0518`,PC 也从未离开应用区。
|
||
|
||
四种入口都**用测量**而不是阅读排除了:单独按 PTT(固件自己的 `BOOT_GetMode()` 需要第二个键)、
|
||
PTT+SIDE1/SIDE2 与 MENU(那是应用的几个特殊模式)、开机窗口内主机发字节(含 `0x0530` 握手)、
|
||
以及 `0x05DD`。只跑引导、没有有效应用时它同样不进 DFU:它停在 `0x080000dc` 那六个自跳转之一,
|
||
那些是**挂起槽**,不是在等输入。
|
||
|
||
普适的教训:当一个固件的模式由 **RAM 里的一个字节**决定时,触发它的一般不是某个输入引脚,而是
|
||
复位前写这个字节的那个程序。去源码里找到写入者(这里是 `0x05DD`)和它外面的 `#ifdef`,整个条件
|
||
就到手了。
|
||
|
||
### 「换个固件就偏移」背后的两个像素 bug
|
||
|
||
两个都是靠让**页面自己说出画面来自哪里**找到的;两个能长期存活,都是因为错的那份输出看起来"挺合理"。
|
||
|
||
**那条悄悄画出了每一帧的回落路径。** `uvk5_stream.py` 用了 `STATUS_BYTES` 却没导入它,
|
||
于是面板分支每一帧都抛 `NameError`,被一句裸的 `except Exception: pass` 吞掉。网页画出的每一幅
|
||
画面都来自 guest RAM,用的是**某一份固件的地址**:装着那份固件时看着对,换别的就"貌似合理但整体
|
||
偏移"。让它现形的办法是报出**来源与原因**(`/api/panel` 回答:
|
||
`source: framebuffer, note: NameError: name 'STATUS_BYTES' is not defined`)。面板路径通了以后,
|
||
网页的 `/frame.png` 与控制器自己的显存**逐像素相同(8192/8192)**;修复前对同一份显存只有
|
||
**5594/8192**。`tools/test_uvk5_stream.py` 现在断言"面板可达时必须用面板"、"回落必须带原因被播报"。
|
||
|
||
**列计数器在 128 处回绕,而真实控制器是 132。** 控制器有 132 个列驱动,玻璃只显示其中 128 个、
|
||
且从第 4 列开始 —— 这就是模型把像素存到 `col - 4` 的原因。但计数器被 `& 0x7f` 掩过,于是地址
|
||
128..131 变成 0..3,落进 `col >= 4` 之外被丢弃:**每一行都丢掉了最右边 4 个像素**。而**电量图标
|
||
恰好就在那几列**,所以症状是"电量位置不对",以及在画到列 127 的固件上"画面看着偏了"——而那份汉化版
|
||
最右 4 列本来就是空的,看着正常。这就是它被读成"固件相关问题"的原因。前后都量了:把一页填满
|
||
`0xFF` 时,修复前列 124..127 全黑,修复后有内容(四列分别点亮 10/10/12/7),同一固件的画面与面板
|
||
显存 8192/8192。
|
||
|
||
两次的教训是这份文件反复在说的那一句:**一条悄悄换成别的来源的路径,会把一个硬错误变成貌似合理的
|
||
错答案**;而一个偏了四列的字节,在"重要的东西恰好住在那四列里"之前,是看不出来的。要报出来源,
|
||
并且要测"该走的那条优选路径确实被走了"。
|
||
|
||
### 屏幕缓冲是**找出来**的,不是写死的
|
||
|
||
原来是个默认值:`--frame-addr 0x200012BE --status-addr 0x2000163E` —— 那是**某一份构建**的地址,
|
||
被写进启动脚本当默认值。换成把缓冲放在别处的固件,画面就"貌似合理但错":实测用户真正刷进去的那份,
|
||
缓冲在 `0x2000129E` / `0x2000161E`,正好**早 32 字节**,于是**每一行都偏 32 字节**。这就是「换个
|
||
固件就偏移」的本来面目。
|
||
|
||
其实什么都不用假设。这里的固件镜像是**最小 ELF** —— 一个程序头、没有节头、没有符号表
|
||
(`tools/bin2elf.py` 就是这么写的)—— 所以**没有** `gFrameBuffer` 符号可读;但它有**行为**:
|
||
固件自己的缓冲里存着与控制器**相同的字节**,因为驱动就是从那拷贝过去的。
|
||
`tools/uvk5_buffers.py` 把控制器显存沿着 SRAM 滑动,取吻合度最高的那个偏移;对用户刷的那份文件,
|
||
它给出 1024/1024 字节吻合和**正确**的一对地址。
|
||
|
||
于是 `--frame-addr` / `--status-addr` 现在都是可选的 ✓,`work/run-webui.ps1` 不再传它们
|
||
(也不再有任何本机路径 ✓),页面会报出自己找到了什么:
|
||
|
||
buffers: {"frame": 0x2000129E, "status": 0x2000161E, "how": "sram search",
|
||
"score": 1024, "total": 1024}
|
||
|
||
两条习惯,这份文件里用别的话说过:**默认值里写进"某台机器/某份构建"的具体值,就是在等第二份构建来踩**;
|
||
以及 **没有符号可读时,就直接问它本人** —— 缓冲里的字节就是答案,靠**匹配**找出来,而不是猜。
|
||
|
||
面板路径完全不需要这些,而页面画的正是它:对任何固件,控制器的显存就是屏幕。这些地址只服务
|
||
guest RAM 回落路径 —— 所以搜索失败会被报出来,而不会挡住任何东西。
|
||
|
||
### 页面必须报告**设备自己说**它在跑什么
|
||
|
||
页面只知道它被交给的那个文件,而这是两个不同的问题。一个叫 `f4hwn.fusion.bin` 的构建,
|
||
报出的是 `EGZUMER+F4HWN v6.0.0.CN` —— 你那份就是 —— 所以「开机还是 CN 版」其实是页面在描述
|
||
**自己的输入**,不是电台。更麻烦的是:多系统版里,只要外部槽已提交且状态标记有效,出厂引导
|
||
**每次开机都会用那个槽重刷内部 flash**,于是你上传的镜像在被执行之前就被覆盖,而页面还在显示
|
||
一个从未运行过的文件名。
|
||
|
||
固件自己会回答这个问题:它在 USART1 上打印 `UV-K5 Firmware, ...`,模型给它打上 SERIAL 标记,
|
||
`tools/uvk5_banner.py` 把它读回来。`/api/firmware` 现在返回 `running: {banner,
|
||
matches_uploaded, note}`,页面显示 `device reports: ...`,**只在"设备报的版本根本不在你上传的
|
||
镜像里"时才提示** —— 因为文件名与横幅不同通常只是名字不同,而一个乱报警的提示最后会被无视。
|
||
|
||
把这条横幅读回来,也顺带暴露了我自己写的一个 bug:横幅**已经完全进不了日志**了 ——
|
||
`_start_stderr_pump` 被改成按 64 KB 读管道,以免 QEMU 阻塞。死锁确实因此没回来,但另一半被
|
||
悄悄丢掉:攒够 64 KB 之前什么都收不到,而横幅只有 40 字节。现在改回按行读,并且**仍然在等待
|
||
QEMU 之前就开始排空**。**一次既保住你要修的性质、又悄悄丢掉另一种性质的改写,是最贵的那种** ——
|
||
而这次它藏得住,是因为对那条二进制屏幕流来说,日志"看起来还是好的"。
|
||
|
||
### 叠加应用就是外部 flash 里的一块区域,页面可以直接写
|
||
|
||
Labs 版能跑小的叠加应用(Tetris、Breakout、Plasma、Cube3D、Beam、Beacon、FoxHunt、
|
||
BroadcastFM)。上游是用 UVStudio 通过 WebSerial 装进去的;在我们这里,它们就是**外部 flash 镜像
|
||
里的字节** —— 而镜像本来就在页面手里,所以既不需要串口协议,也不需要浏览器授权,只是**同样的字节
|
||
写在同样的偏移**。
|
||
|
||
布局是固件自己的,从它编译的头文件读出(`App/apps/app_overlay.h`),不是猜的:
|
||
`APP_REGION_BASE 0x00102000`(紧跟在两个状态标记之后)、`APP_SLOT_STRIDE 0x2000`、
|
||
`APP_CODE_OFFSET 0x1000`、16 个槽。每个槽放一个 64 字节的 `app_header_t`(magic `FAP1`、
|
||
对代码做 zlib CRC-32、名字、版本、vma 0x20000280、capabilities),代码在**下一个 4 KiB 扇区**。
|
||
`tools/uvk5_apps.py` 负责解析、校验、列出、安装、擦除;`tools/test_uvk5_apps.py` 管各种拒绝。
|
||
|
||
两点值得知道。**这 64 字节头和多系统槽位是共用的**:`FMB1` 是固件、`FAP1` 是应用 ——
|
||
这就是为什么"把应用装进槽 1"和"把固件放进槽 1"动的是同一块外部 flash,开机菜单也把两者列在一起。
|
||
另外,这块区域和屏幕缓冲一样,是**从固件自己的常量里读出来的**而非猜的:这里头结构的第一版是 60 字节,
|
||
因为漏了一个字段(`vma`),而真实的 `Beam.app` 字节当场就指出了这一点。
|
||
|
||
## 键盘:两个真 bug,都已修复
|
||
|
||
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
|
||
互相独立的原因**,按出现顺序:
|
||
|
||
1. **`tools/key.py` 把每个键都按 2500 ms** —— 工具链的 bug,紧接着下面讲。
|
||
2. **`row_out` 没有 `volatile`,所以 GCC 删掉了驱动行线的代码** —— 真正的模型 bug,
|
||
是后来在清理调试打印时引入的。见
|
||
[row_out 必须保持 volatile](#row_out-必须保持-volatile否则-gcc-会删掉键盘)。
|
||
|
||
两个都修了,`tools/keypad_test.py` 会防止任一个回归。
|
||
|
||
**SysTick 的两套机制是分开的**,把它们混为一谈就导致了上面的问题:
|
||
|
||
- SysTick **中断**接近实时触发。`SysTick_Handler` 设置 `gNextTimeslice`,
|
||
它门控 `APP_TimeSlice10ms` → `CheckKeys`。所以 `App/misc.c` 里的防抖阈值
|
||
**就按字面意思在实际时钟下生效**:`key_debounce_10ms = 2`(20 ms 才被登记)、
|
||
`key_repeat_delay_10ms = 40`(400 ms 算**长按**)。
|
||
- `poll-boost` 属性加速 SysTick 计数器**读取**,让 `SYSTICK_DelayUs` 能收敛。
|
||
它**不会**加快中断投递。
|
||
|
||
按住 2500 ms 约等于 250 个 tick,是长按阈值的六倍。每次按压都被派发成长按,
|
||
而处理函数是在短按松手时动作的:`MAIN_Key_MENU` 在 `if (bKeyHeld)` 分支就提前返回了,
|
||
永远不会打开菜单。这一点通过**在按住过程中**读 `gDebounceCounter` 得到确认 ——
|
||
按住 3 秒后它是 317,这既证明了时间片在运行,也说明按得远远太久了。
|
||
|
||
`key.py` 现在的值:`HOLD_MS = 200`、`LONG_HOLD_MS = 900`。端到端验证过 ——
|
||
`key.py MENU DOWN DOWN` 把菜单从 01/79 移到 03/79,`key.py UP` 又移回 02/79。
|
||
|
||
**如果某个按压像是被忽略了,不要延长按住时间。** 检查那个处理函数是不是想要短按,
|
||
再检查 `gEeprom.KEY_LOCK`(键盘锁上时 LCD 会画一个锁形图标,那时忽略按键是**正确行为**)。
|
||
|
||
### 驱动菜单:把一串按键作为一批发送
|
||
|
||
有三件事会让按键序列落到你没打算去的地方。这三件在这里都花过时间。
|
||
|
||
**在两次按压之间用 gdb 会暂停 guest。** 每次 `gdb-multiarch -batch` attach 都会在其执行
|
||
期间停住机器。每按一次就去看 `gMenuCursor`,会把一个六次按压的序列拖过 20 秒的菜单超时
|
||
(`App/misc.c` 里的 `menu_timeout_500ms`),于是界面**静默地**退回主界面,
|
||
剩下的按压就变成在调 VFO 而不是在导航。要在一个 Python 批次里通过 QMP 把整个序列发完,
|
||
最后再读一次状态。
|
||
|
||
**在子菜单内部 UP/DOWN 是反的。** 当 `gIsInSubMenu` 且 `!gEeprom.SET_NAV` 时,
|
||
`MENU_Key_UP_DOWN` 会翻转 `Direction`(`app/menu.c:2311`)。在列表里 DOWN 是往下移;
|
||
在编辑数值时,UP 是**减小**它。数值还会在 `MENU_GetLimits` 处**钳制而不是回绕**,
|
||
所以过冲会停在边界上。
|
||
|
||
**MENU 是切换,不只是进入。** 在主界面短按 MENU 打开菜单;在列表里它进入子菜单;
|
||
在子菜单里它**提交**(`gFlagAcceptSetting = true`)并退出一层。所以从列表连按两次 MENU
|
||
等于进去又立刻出来,看起来像什么都没发生。
|
||
|
||
**数字跳转**:在列表里输入菜单编号会直接跳过去,比数 DOWN 次数可靠。单个数字很可靠。
|
||
两位数需要两次按压落在同一个输入框窗口内,而 `MENU_Key_0_to_9` 一旦第一个数字是合法索引
|
||
就会跳转并返回(`app/menu.c:1826`),所以先 `3` 再 `0` 会停在 3 而不是 30。
|
||
要到达一个很远的条目,可靠的办法是打开菜单后**立刻用一次 gdb attach 预设 `gMenuCursor`**。
|
||
|
||
这样验证过的:菜单能打开、DOWN/UP 能移动列表、MENU 能进子菜单、数字键能选值。
|
||
截图确认了 Step 在 01/79、两次 DOWN 之后 RxDCS 在 03/79、BatSav 在 30/79 显示 OFF。
|
||
|
||
### row_out 必须保持 volatile,否则 GCC 会删掉键盘
|
||
|
||
`UVK5KeypadState::row_out` 声明为 `qemu_irq volatile`。**去掉 `volatile`,键盘就彻底不工作**:
|
||
无论醒着还是省电状态,没有任何按压能到达界面,而且**不会有任何警告**。
|
||
`tools/keypad_test.py` 覆盖这一点。
|
||
|
||
原因在目标代码里看得见。`qdev_init_gpio_out_named()` 是可内联的,而且只记录那个数组;
|
||
那些线路是后来由板级代码的 `qdev_connect_gpio_out_named()` 填进去的,**而 GCC 看不到那一步**。
|
||
不加 volatile 的话,GCC 在 -O2 下会证明每个元素都还是 NULL,看到 `qemu_set_irq()`
|
||
对 NULL irq 会立即返回,于是把 `keypad_update_rows()` 的函数体连同**所有五个调用点**一起删掉:
|
||
|
||
能到达 keypad_update_rows 的调用者
|
||
不加 volatile {} <- 一个都没有;调用全被删了
|
||
加 volatile {keypad_key_changed, keypad_col_changed, keypad_set_press,
|
||
keypad_reset, uvk5_machine_init}
|
||
|
||
`keypad_col_changed` 会被编译成一次存储加一个 `ret`,**完全没有调用**。加上 `volatile`
|
||
之后它以 `jmp keypad_update_rows` 结尾。所以没有任何行线被驱动,固件的扫描读到全高,
|
||
模型看起来是坏的。
|
||
|
||
走到这一步经历了**三个错误诊断**,都值得知道:
|
||
|
||
1. **"省电模式停掉了键盘扫描。"** 这条曾被当作模型缺陷写在这里。**不是** ——
|
||
醒着的时候一样是坏的。
|
||
2. **"它需要一点稳定时间。"** 有三个 `fprintf(stderr, "TRACE ...")` 探针在清理时被删掉了,
|
||
而把 `keypad_update_rows` 里那个恢复回去就修好了,加一个忙等循环也能修好。
|
||
这看起来像是时序依赖。**并不是** —— 那个 fprintf 和那个循环只是 GCC 无法丢弃的副作用,
|
||
它们让那个循环活了下来。
|
||
3. **"这是编译器的顺序问题。"** 一个零开销的
|
||
`__asm__ __volatile__("" ::: "memory")` 也能修好,8/8。同样的原因:
|
||
屏障是一个未知副作用,所以循环存活。
|
||
|
||
**最终定论靠的是对比两个目标文件,而不是对比行为。** 独立的 `keypad_update_rows` 符号
|
||
在两种情况下**指令完全相同**,这就是为什么早期只 diff 那个函数什么都没发现 ——
|
||
函数被内联进了它的调用者,差异在那里。
|
||
|
||
实测数据,每项 3 次以上,按压附近没有调试器:
|
||
|
||
| 变体 | 结果 |
|
||
| --- | --- |
|
||
| 不加 `row_out` | 0/12 |
|
||
| 加 `(void)r;` —— 惰性的,无副作用 | 0/6 |
|
||
| 完全相同的重新构建(稳定性对照) | 0/6 |
|
||
| 忙等循环,1 到 4000 次迭代 | 3/3 |
|
||
| `__asm__ ... "memory"` 屏障 | 12/12 |
|
||
| **`volatile row_out`**(真正的修复) | **10/10** |
|
||
|
||
**范围是查过的,不是假设的**:这个文件里另一个 out-GPIO 数组
|
||
`PY32GpioState::out` **不受影响**。给它也加 volatile 会产生**逐字节相同**的目标文件,
|
||
因为驱动那些线路的函数(`py32_gpio_write`)只能通过 `MemoryRegionOps` 函数指针表到达,
|
||
所以 GCC 做不了那种杀死键盘路径的全函数推理。保持它不加。
|
||
|
||
**要警惕的一般形态**:某个设备的 out-GPIO 线路只由板级代码连接,而驱动它们的函数
|
||
GCC 能看到全部调用者。如果一个模型的输出神秘地不起作用,**先去目标代码里查那个调用**,
|
||
不要先假设逻辑错了:
|
||
|
||
objdump -dr build/libqemu-arm-softmmu.fa.p/hw_arm_py32f071.c.o \
|
||
| grep -c qemu_set_irq
|
||
|
||
有两个测量错误让这件事比本来该有的难度大得多,都值得避免:
|
||
|
||
- **在松开按键之后才去读按键状态。** 一旦键抬起,`gKeyReading0` 永远是 `KEY_INVALID`,
|
||
于是它"证明"了这次按压从未被看到。要在**按住过程中**读。
|
||
- **相信在 `KEYBOARD_Poll` 上的 gdb 断点。** guest 停着的时候扫描的那些延时不消耗
|
||
guest 时间,所以在一个自由运行时返回 `KEY_INVALID` 的构建上,`Poll` 在断点下
|
||
会返回 `KEY_MENU`。**这单个观察把方向带错了很久。**
|
||
|
||
两个相关事实,都由实验确认,这样就没人再花时间了:
|
||
|
||
- **在 `assets/flash.img` 里打补丁改省电设置没有用。**
|
||
`SETTINGS_InitEEPROM` 会比较 flash `0x00A160` 处的版本字符串,在一个新镜像上发现不匹配,
|
||
于是写入设置扇区。而 `PY25Q16_WriteBuffer` 会在重新编程之前**擦除整个 4 KB 扇区**,
|
||
所以埋在 `0x00A00B` 的字节在 `settings.c:169` 的读取看到它之前就已经没了。
|
||
- **guest 侧的设置改动现在会持久化,所以测试方式要变。** PY25Q16 模型在 realize 时读入镜像、
|
||
留在 RAM 里,并在片选释放或进程退出时经临时文件写回,于是**每个会话都会改动
|
||
`assets/flash.img`**。实测跑完一次真实会话之后:本来全是 `0xFF` 的设置区 `0x00A000`
|
||
已经装着 guest 的设置,`0x8000..0x8800` 也变了,整个文件与开会话前的副本相差 2239 字节。
|
||
所以不要假设镜像是干净的 —— 要和 `assets/pristine/`(或你自己留的副本)对比,还原前先断电。
|
||
在 Windows 上这一步直到把 `rename()` 换成 `g_rename()` 之前都是静默失败的,见可移植性一节。
|
||
|
||
这里有用的工具:`tools/scan_trace.sh`(扫描读到了什么)、`tools/key_result.sh`
|
||
(Poll 返回了什么)、`tools/trace_run.sh`(那些 TRACE 点)。
|
||
|
||
原来在 `qemu/py32f071.c` 里的三个 `fprintf(stderr, "TRACE ...")` 探针已经删掉了 ——
|
||
它们在每次键盘轮询时都触发,把控制台埋掉了。它们分别在 `py32_gpio_set_input`、
|
||
`keypad_update_rows` 和 `keypad_col_changed` 里;`git log -p -- qemu/py32f071.c`
|
||
有确切的行,而且它们**仍然是查看一次按压是否到达模型的最快办法**
|
||
(对捕获的 stderr 执行 `grep -c 'keypad row0 -> 0'`)。
|
||
|
||
把那个 stderr 重定向到文件而不是管道,并且要知道 `keypad_update_rows` 里那个探针
|
||
**会把时序改到足以影响结果** —— 见上面关于稳定循环的说明。
|
||
|
||
注意 `uvk5-sat/build/CW/nr7y.cw.elf` 这个 ELF 不带 DWARF,所以 gdb 会报
|
||
`'gEeprom' has unknown type`。标量通过取地址再强转是可以读的
|
||
(`*(unsigned short*)&gDebounceCounter`);结构体字段需要手工偏移。
|
||
|
||
## BK4819,以及建模到哪里为止
|
||
|
||
寄存器接口已建模(`TYPE_UVK5_BK4819`):位操作驱动的三线总线被正确解码,寄存器读回固件写入的值,
|
||
固件只读不写的那些返回合理的值。接线是 CS 在 PF9、SCL 在 PB8、SDA 在 PB9 且双向都接了。
|
||
`tools/test_bk4819.py` 通过 QOM 检查寄存器组。
|
||
|
||
它修好的是这个:RSSI 以前在 18 个调用点上都读到**硬零** —— 也就是 -160 dBm ——
|
||
所以 S 表是空的,静噪和扫描逻辑面对的是一个死频段。主界面现在开机显示 400 MHz
|
||
而不是 18 MHz 下限,因为波段设置不再读到零了。
|
||
|
||
有两个约束**不可协商**,都源于固件里的无超时自旋循环:
|
||
|
||
- **REG_0C bit 0 必须保持清零。** `app/app.c:910` 和 `:1417` 是
|
||
`while (BK4819_ReadRegister(BK4819_REG_0C) & 1u)`,**完全没有超时**。
|
||
一个卡住的位会让 guest 挂死,而不是降级运行。
|
||
- **软复位必须重新播种测量寄存器。** `REG_00` bit 15(`BK4819_Init` 第一件事就发它)
|
||
否则会把它们留成零 —— 而真实硬件是**持续测量**的。这不是假设:第一次测试运行
|
||
正确解码了 48 个寄存器,却仍然报告 RSSI 为 0,原因恰恰就是这个。
|
||
|
||
### 跑测试
|
||
|
||
bash tools/run_tests.sh # 全部
|
||
bash tools/run_tests.sh -q # 只跑单元测试,不需要模拟器,约 15 秒
|
||
|
||
**用这个 runner,不要一条条粘命令。** 它先检查构建,失败就**停下**,这一点比听起来重要:
|
||
`ninja` 失败时会把上一个二进制留在原地,于是测试会心情愉快地对着一份从未编译过的代码跑。
|
||
在养成这个习惯之前,这产生过两轮**完全无意义**的结果。
|
||
|
||
它还只在 `qemu/py32f071.c` 与 QEMU 树里的副本不同时才重新构建,所以一次普通的测试运行
|
||
不会为不需要的重建付时间。
|
||
|
||
**runner 会先检查自己**,通过 `tools/test_run_tests.sh`。它的第一版写的是
|
||
|
||
if "$@" 2>&1 | sed 's/^/ /'; then
|
||
|
||
那检查的是 **sed 的**退出码,不是测试的 —— 所以无论什么坏了,每个测试都会被算成通过。
|
||
因此改用 `PIPESTATUS[0]`,并加了一个自检来断言一个失败的测试真的被计数并具名。
|
||
**一个不会失败的 runner 比没有 runner 更糟,因为它会被信赖。**
|
||
测试输出还会先过 `tr -cd`:gdb 驱动的测试会输出杂散字节,让日志在 grep 眼里成为
|
||
"binary file",从而**吞掉汇总行**。
|
||
|
||
模拟器测试会在私有端口上启动自己的 QEMU,每个耗时 20-30 秒,所以它们不会干扰
|
||
正在运行的 `run.sh` 或网页界面会话。
|
||
|
||
### 让文档保持诚实
|
||
|
||
python3 tools/check_docs.py # 也作为 run_tests.sh -q 的一部分运行
|
||
|
||
**文档会静默腐化,而通读是发现不了的。** 把全部文档翻译成中文的过程中,
|
||
翻出了四处**已经漂移**的断言:接口表缺三个路由、已建模外设列表漏了 TIM2、
|
||
审计表在 TIM2 已建模之后仍把 TIM 称作 stub、两个 README 都没列出几个库模块。
|
||
**这四处全是靠与源码比对发现的,没有一处是校对读出来的。**
|
||
|
||
所以现在这个比对是机械化的。它检查:每个 README 提到的工具是否存在、
|
||
`run_tests.sh` 里的每个测试在两种语言里是否都有记录、内部 `.md` 链接是否都能解析、
|
||
翻译对的标题结构是否匹配、内存映射地址是否与模型的 `#define` 一致、
|
||
文档传给某个工具的每个长参数是否真的存在于该工具中、
|
||
以及文档里的固件 `file:line` 引用是否仍指向正文声称的东西。
|
||
|
||
校验器自己也需要两处改动才能在作者的机器之外运行:所有读取都显式用 `encoding="utf-8"`
|
||
(默认是区域编码,Windows 上是 GBK,中文文档根本解不开),以及固件树路径来自 `UVK5_FW_DIR`
|
||
环境变量而不是写死,这样 `file:line` 那几项检查可以指向你手上任何一份固件树。
|
||
|
||
参数检查本身也留下了一个教训。它的第一版只匹配到行尾,所以对一条这样换行的命令
|
||
|
||
python3 tools/uvk5_buffers.py --qmp 127.0.0.1:4444 # 这份固件把它们放在哪
|
||
python3 tools/screenshot.py --frame-addr 0x... --status-addr 0x... \
|
||
--port 1234 --out screen.png
|
||
|
||
它只看到了 `--frame-addr`,别的都没看到 —— 9 个参数里查了 4 个,然后**报告一切干净**。
|
||
**一个静默地只覆盖了自己所声称范围四分之一的检查,比没有检查更糟**,
|
||
因为那个"干净"的结果会被相信。现在会先合并续行再做匹配。
|
||
|
||
写它的过程中有一个教训。早期版本用一个"取行内第一个数字"的正则来比对固件常量,
|
||
于是 `key_debounce_10ms = 20 / 10` 被读成 20,检查器于是宣布文档说 2 是错的。
|
||
**文档是对的,检查器是坏的。** 一个总在虚报的检查器会被忽略,
|
||
所以凡是它无法无歧义验证的东西,都宁可不查,而不是靠猜。
|
||
|
||
### 数"有多少帧不同"证明的东西比看起来少
|
||
|
||
写任何观察屏幕的测试之前值得知道这一点。
|
||
|
||
一旦接收机报告的 RSSI 会变化,S 表和它的 dBm 读数就会不停重绘。所以"相邻两帧是否不同"
|
||
在一台**完全停着不动的电台**上也会返回是。第一次尝试验证扫描是否还能工作时,
|
||
拿到了 8/8 帧全不同,**结果什么都没证明**。
|
||
|
||
改成比较能回答真正问题的那几行。帧缓冲是 128×64,存成 8 页各 128 字节,
|
||
第 *p* 页覆盖第 8p..8p+7 行:
|
||
|
||
page 0 状态行
|
||
pages 1-2 上方 VFO,大号频率数字
|
||
page 3 上方 VFO 的副行
|
||
pages 5-7 下方 VFO
|
||
|
||
`tools/test_scan.py` 比较 page 1-2,那里**只有在电台重新调谐时才会变**:
|
||
6 次采样得到 6 个不同的调谐位置。这很重要,因为"接收机永远繁忙"是一种很有可能
|
||
卡死扫描的情况,而 S 表那部分工作正好让接收机变成了永远繁忙。
|
||
|
||
顺带一提,**page 4 不是 S 表那一行** —— 在频率变化的同时,它在全部六次采样里逐字节相同。
|
||
|
||
### 什么算真正还原,什么只是"能响应读取"
|
||
|
||
这一节是在一次**合理的批评**之后写的:进度汇报一直在说什么**能跑**,
|
||
而不是什么**真正被还原了**。这两者不同,而且差距很容易被藏起来。
|
||
|
||
按固件自己的调用点数统计:
|
||
|
||
| 外设 | 调用点 | 状态 |
|
||
|---|---|---|
|
||
| GPIO | 55 | 已建模 |
|
||
| DMA | 59 | 已建模,跑在 CPU 的地址空间上 |
|
||
| SPI | 33 | 已建模,含 flash |
|
||
| TIM | 23 | TIM2 自 `fdcbe80` 起已建模;其余仍是 stub(背光 PWM) |
|
||
| ADC | 19 | 已建模;结果自 `e46cae2` 起可设置 |
|
||
| USART | 11 | 双向都已建模 |
|
||
| RTC、IWDG、WWDG、I2C、USB、CRC、EXTI、PWR | 0 | stub,而且固件从不使用它们 |
|
||
|
||
此外,SoC 之外还有:键盘、BK4819 寄存器接口、以及音频使能线。
|
||
|
||
**stub 只是接受写入并返回上次的值。** 那足以不挂死,仅此而已。这个区别之所以重要,
|
||
是因为**从上层完全看不见**:ADC 是**已建模**的,却依然永远返回硬编码的 2200,
|
||
于是 `gBatteryDisplayLevel`、`gLowBattery` 和低电弹窗**根本到不了**。
|
||
**能响应读取不等于被还原了。**
|
||
|
||
诚实的总结是:**固件依赖的数字侧已经被还原,而模拟侧没有、也不可能有**。
|
||
频率、flash、键盘、串口、寄存器编程、电池 —— 全都是真的。音频采样和射频行为 ——
|
||
无论在 MCU 的地址空间里还是在任何公开 datasheet 里,都不存在可建模的数据。
|
||
|
||
`millis()`/TIM2 和可设置的 ADC 补上了那两个真正要紧的缺口。剩下的,以及为什么:
|
||
|
||
**背光 PWM —— 刻意不建模。** `backlight.c` 用 TIM7 触发 DMA 通道 7,从一张 32 项的
|
||
占空比表重写 GPIOA 的 `BSRR` 来实现中间亮度,频率是
|
||
`PWM_FREQ * DUTY_CYCLE_LEVELS` = 128 kHz。建模它意味着每模拟秒 12.8 万次 GPIO 写入
|
||
和 DMA 传输,而且**没有任何可观测的变化**:背光是物理 LED 亮度,不碰帧缓冲,
|
||
所以 `frame.png` 两种情况下逐字节相同。而那两个**确实有可观测行为**的端点 ——
|
||
亮度 0 和全亮 —— 完全绕过定时器,直接调
|
||
`GPIO_TurnOffBacklight`/`TurnOnBacklight`,那两条已经能工作。代价高,收益为零。
|
||
|
||
**EXTI** —— 今天有零个调用点。任何改成中断驱动的重构都需要先有它。
|
||
|
||
### 音频:没有东西可以建模,而这本身就是结论
|
||
|
||
"加个喇叭和麦克风,然后在浏览器里授予音频权限"是很自然的要求,而它**做不到** ——
|
||
不是因为不够努力,而是因为**这两个器件都不在 MCU 上**。接收音频在 BK4819 内部解调,
|
||
以模拟信号从它的 AF 引脚出来;发射音频从麦克风进入芯片自己的 ADC。固件能碰的只有:
|
||
|
||
PA8 功放使能 (GPIO_EnableAudioPath, driver/gpio.h:34)
|
||
REG_47 芯片路由哪一路 AF 源
|
||
REG_64 一个它用来显示的电平值
|
||
|
||
**MCU 的地址空间里任何地方都不存在音频采样。** 没有东西可采集、没有东西可播放、
|
||
也没有内容需要浏览器权限去承载。在这里生成声音,等于**编造固件从未产生过的数据** ——
|
||
这和模拟量射频的界线是同一条。
|
||
|
||
**真实的是那个"意图"。** `TYPE_UVK5_AUDIO` 监视 PA8 并暴露只读的 `speaker-on`;
|
||
界面显示一个喇叭图标,`/api/status` 上报 `speaker`。**刻意做成只读**:
|
||
可写只会让测试骗自己。还有一条单元测试断言这个页面**永远不会**请求音频权限 ——
|
||
没有 `getUserMedia`、没有 `AudioContext`、没有 `<audio>` ——
|
||
因为让用户去批准一件不可能发生的事,比不提供它更糟。
|
||
|
||
### 一个比真实客户端更宽容的 stub 比没有 stub 更糟
|
||
|
||
`QmpClient.command` 返回的是**已解包**的值,出错时抛异常。而测试 stub 返回的是
|
||
`{"return": ...}`。于是 `webui.py` 被写成再解包一次,**88 个测试全部通过**,
|
||
而线上界面返回 500:
|
||
|
||
TypeError: argument of type 'bool' is not iterable
|
||
|
||
两个教训,都在这里花过时间。stub 现在被一条显式测试**钉在真实契约上**。
|
||
另外那个失败最初被一个裸的 `except: return None` 吞掉了,这让"调用坏了"和
|
||
"电台就是没在响"**无法区分** —— 还害我去追一个并不存在的僵尸进程。**要把原因记进日志。**
|
||
|
||
### PTT,以及发射电平条
|
||
|
||
**PTT 不是矩阵按键。** `GPIO_IsPttPressed` 直接读 PB10(`driver/gpio.h:31`,低电平有效),
|
||
所以模型给它一条独立的 GPIO 线而不是一个行列交点,在键盘设备上暴露为布尔属性 `ptt`。
|
||
|
||
正是这一点让发射电平条变得可达。`app/app.c:1700` **只在**
|
||
`gCurrentFunction == FUNCTION_TRANSMIT` 且 `gSetting_mic_bar` 置位时才绘制它 ——
|
||
后者是 flash `0xA0A8` 处 `Data[7]` 的 bit 4(`settings.c:423`),而空白 flash 读作
|
||
`0xFF`,所以它本来就是开的。电平本身来自 `REG_64`,经 `BK4819_GetVoiceAmplitudeOut`。
|
||
|
||
**把释放当成更重要的那一半。** 一个卡住的 PTT 会让模拟电台一直处于键控状态,
|
||
于是之后每一个测试都在对着一台正在发射的电台运行。网页界面在 `pointerleave`、
|
||
`pointercancel` 和 `pagehide` 时释放;`/api/release-all` 会**显式**清掉 PTT,
|
||
因为一个空的 `press` 碰不到它;而那个接口拒绝非布尔的请求体,
|
||
所以 `{"held": "false"}` 不能靠 truthiness 把发射机打开。
|
||
`tools/test_ptt.py` 断言的是释放,不只是按下。
|
||
|
||
如果你要再加一个非按键的按钮,有个陷阱值得知道:浏览器把处理函数装在了 `.key` 上,
|
||
而那也匹配到了 PTT 按钮,但它没有 `data-key` —— 于是它会发出按键 `"undefined"`。
|
||
用 `.key[data-key]`。
|
||
|
||
### 读取整体错位了一位,而这掩盖了其他一切
|
||
|
||
在 `ad88ee1` 修复,但值得一读,因为它藏了那么久。
|
||
|
||
寄存器读取到达时**整体左移了一位**:给 `REG_0C` 塞 `0x1248`,固件收到 `0x2490`。
|
||
固件每读一位是"读/拉高/拉低",所以命令字节的第八位之后、数据阶段之前还有一个下降沿 ——
|
||
而模型把那个沿当成了数据时钟,在 guest 采样之前就把 bit 15 移走了。
|
||
|
||
**为什么没人发现**:**写入一直是好的**,52 个寄存器精确保存着固件写入的值,
|
||
而固件轮询最勤的那个寄存器**本来就合法地是 `0`**。读到零、拿到零,看起来像成功。
|
||
**验证一条读取通路必须用一个已知非零的寄存器** —— `REG_3F` 是 `0x0C0C`,
|
||
`REG_78` 是 `0x2F5B`。
|
||
|
||
`tools/test_bk4819_readback.sh` 现在守住这一点:给 `REG_0C`(每 30 秒被读约 1700 次,
|
||
所以一定能采到)塞一个两个半字都带位的值,并在失败时**指出移位方向**。
|
||
bit 0 刻意留空 —— 一旦置位固件就会进入一个无超时的确认循环,而那个测试只关心对齐。
|
||
|
||
这也**推翻了之前四个诊断**。之前尝试静噪中断时,模型抬起的是 `REG_0C` bit 0,
|
||
而固件收到的是 bit 1,于是
|
||
|
||
while (BK4819_ReadRegister(BK4819_REG_0C) & 1u)
|
||
|
||
**永远不成立**,1719 次轮询看到了一个 guest 无法处理的标志。那几轮每一次都被归咎于
|
||
时序或门控。**当多个互相独立的尝试以同样的方式失败时,该怀疑共用的传输层,
|
||
而不是它上面的逻辑。**
|
||
|
||
### 静噪中断与 S 表:五次尝试,然后成了
|
||
|
||
**现在能工作了**(`e6cebed`)—— 想看结论可以直接跳到最后。那四次失败的尝试保留下来,
|
||
是因为每一次都产生了一个**自信的错误诊断**,而它们失败的**模式**才是有用的部分。
|
||
|
||
扫描很早就能工作:长按 `*`,频率确实会步进,7 秒内 6 帧各不相同。S 表不行,
|
||
因为 `ui/main.c:2370` **只在** `FUNCTION_IsRx()` 时才绘制它,
|
||
而那需要 `gCurrentFunction` 处于接收状态 —— 这要求芯片**报告静噪打开**,
|
||
而不只是有一个健康的 RSSI。
|
||
|
||
机制看起来很清楚:`REG_0C` bit 0 表示有中断待处理,固件写 `REG_02` 确认然后再读回它取标志位,
|
||
而 `sqlFound` 是 bit 3(位域定义在 `app/app.c:915`)。**结果位的选择和对机制的这个理解
|
||
两者都是错的。**
|
||
|
||
我实现了它 —— 在固件使能中断时抬起一次 `sqlFound` —— 然后**撤回了**。
|
||
guest 照样在跑,但事后 `REG_0C` bit 0 仍然是置位的:固件没有取走那个中断。
|
||
**那是一个潜伏的挂死**,因为 `app/app.c:910` 和 `:1417` 在那个位上无超时自旋,
|
||
所以任何在该位卡住时到达那里的路径都永远不返回。
|
||
**交付一个埋着挂死的模型,比交付一个没有 S 表的模型更糟。**
|
||
|
||
**第二次尝试,以及真正的原因。** 再试了一次,这次是在固件**轮询** `REG_0C` 时评估静噪,
|
||
而不是在它配置芯片时 —— 这修正了最初的错误,因为启动序列会把 `REG_3F` 写成 `0x0000`
|
||
再写成 `0x0C0C`,来回三次,所以在使能那次写入时抬起的标志**在任何人读到之前就被禁用了**。
|
||
同时也纠正了阈值字段:RSSI 开启电平在 `REG_78` 的 bit 15:8,单位 0.5 dB/step,
|
||
对应 `REG_67` 的 0.25 dB/step,**而不在 `REG_4E` 里**(那些低位是 **glitch** 阈值,
|
||
用它们意味着静噪根本不会打开)。
|
||
|
||
改对之后,芯片侧的一切都对得上 —— 实测 `en=0x0C0C`、`rssi=0x01E0`、阈值 94、
|
||
`REG_0C` 正确返回 1。**固件依然从不确认。** 而原因完全不在芯片侧:
|
||
|
||
gCurrentFunction=5 (FUNCTION_POWER_SAVE), gRxIdleMode=1
|
||
|
||
门控在 `app/app.c:1697`:
|
||
|
||
if (gCurrentFunction != FUNCTION_POWER_SAVE || !gRxIdleMode)
|
||
CheckRadioInterrupts();
|
||
|
||
在那个状态下两半都为假,这看起来就是答案了:没有 `CheckRadioInterrupts`,
|
||
所以没人去取那个标志。
|
||
|
||
**那个解释是错的,而推翻它的那个实验值得保留。** `app/app.c:1374` 在
|
||
`BATTERY_SAVE == 0` 时**直接拒绝**进入省电,而那个字节在 flash `0xA00B`
|
||
(空白 flash 读作 0xFF,`settings.c` 把它钳到 4 —— 最深的档位,这就是模拟器
|
||
一直停在那里的原因)。把那个字节改成 0,然后:
|
||
|
||
BATTERY_SAVE=4: fn=5 idle=1 polls=2161 acks=0
|
||
BATTERY_SAVE=0: fn=0 idle=0 polls=2161 acks=0
|
||
|
||
**门控通过了,而确认次数依然是零。** 一个在 `BK4819_ReadRegister` 上的 gdb 回溯确认
|
||
那个循环确实在运行 —— `CheckRadioInterrupts` 被内联进了 `APP_TimeSlice10ms`,
|
||
而那就是调用者:
|
||
|
||
#0 BK4819_ReadRegister
|
||
#1 APP_TimeSlice10ms
|
||
#2 Main
|
||
|
||
所以固件读 `REG_0C`、拿到 1、然后**不写** `REG_02`。压制它的东西在那个被内联的循环内部、
|
||
门控之后。把模型门控在 `REG_30`(`BK4819_Sleep` 会清零它)上也没用 ——
|
||
模型被询问时芯片是醒的,而固件仍然报告 `gRxIdleMode=1`。
|
||
|
||
**在 `e6cebed` 解决。** S 表读数是:`-53` dBm、S9 以上 `+40`、13 段里的 9 段、
|
||
`MONI`、以及一个在走的接收计时器。**这些数字自洽** —— UHF 上 S9 是 −93 dBm,
|
||
所以 −53 确实就是 S9+40。
|
||
|
||
有三件事必须同时正确,而**发现它们的顺序**才是困难所在。
|
||
|
||
*标志是 `SQUELCH_LOST`,bit 2。* 按 `app/app.c:1027`,"squelch lost" 才是那个
|
||
把 `g_SquelchLost` 设为 true 的东西,意思是**有信号**。而 `SQUELCH_FOUND`
|
||
读起来像"发现了信号",含义却相反。位定义在 `App/driver/bk4819-regs.h:290`。
|
||
|
||
*上报必须限速* —— 这里是每 64 次轮询一次。只报一次,固件会在启动期间就取走它,
|
||
那时这个标志还通向任何地方之前;每次轮询都报,请求位就会在**固件自己的收集循环内部**
|
||
被重新置位,而那个循环用 `REG_0C` 作条件且没有超时,于是永不退出。
|
||
**周期性上报同时满足两者**:循环总能排空,而这个消息会一直重复直到它开始有意义。
|
||
|
||
*真正的入口根本不是中断。* 电台空闲时停在省电模式,在那里它**不理静噪** ——
|
||
这就是为什么在 `BK4819_GetRSSI` 上的断点一次都没触发。`ACTION_Monitor`
|
||
**完全绕过静噪**:`app/app.c:482` 在 `gMonitor` 置位时选 `FUNCTION_MONITOR`
|
||
而不是 `FUNCTION_RECEIVE`,而 `settings.c:263` 把一个越界的存储动作默认成
|
||
`ACTION_OPT_MONITOR` —— 空白 flash(`0xFF`)正是越界。所以
|
||
**在一个 pristine 镜像上,短按 SIDE1 就会进入监听模式**:
|
||
|
||
之前: fn=5 idle=1 monitor=0 (FUNCTION_POWER_SAVE)
|
||
之后: fn=2 idle=0 monitor=1
|
||
|
||
要门控在 `RX_DSP`(`REG_30` bit 0)而不是整个寄存器是否为零:TX 和音调路径会在
|
||
`RX_DSP` 清零的情况下置上其他位,那样就会被误当成一台活着的接收机。
|
||
|
||
`tools/test_smeter.py` 端到端覆盖这条路径,并且比较**亮像素计数**而不是逐像素匹配,
|
||
这样一个无关的界面改动不会造成一个莫名其妙的失败。
|
||
|
||
有四个测量错误让这件事花的时间远超代码本身的难度。这四个**都产生了自信的错误结论**:
|
||
|
||
- **在 `REG_0C` 读取处采样 PC** 会落在 `BK4819_WriteU8`,也就是那个位操作辅助函数,
|
||
而不是调用者。采样 LR 也不行:`BK4819_ReadRegister` 会调用 `BK4819_ReadU16`,
|
||
所以 LR 指回读取函数内部。**用断点加回溯。**
|
||
- **一个在赋值之前打印 `shift_out` 的探针**,把一个即将作为 `0001` 发出的值报成了 `0000`。
|
||
差点变成"模型送出了错误的值"。
|
||
- **`BK4819_ReadRegister` 对 REG_0C 返回 0x0** 看起来像读取通路坏了,
|
||
我还据此改了位时序。但 REG_0C 在已提交的构建里**本来就合法地是 0** —— 没有东西去抬它。
|
||
**一个寄存器读取返回该寄存器真实的内容,什么都证明不了。**
|
||
要对着一个固件确实写过的寄存器检查(`REG_3F` 是 `0x0C0C`,`REG_78` 是 `0x2F5B`)。
|
||
- **断点之后用 `nexti`** 落到了一个无关的位置并报告 `r0 = 0`,喂给了同一个错误结论。
|
||
**`finish` 才给出真实的返回值。**
|
||
|
||
另外注意在这个目标上 **gdb 无法调用 guest 函数**(`print BK4819_ReadRegister(0x3f)` 会报错),
|
||
而且没有 `gCurrentRSSI` 这样的全局变量可读 —— RSSI 是读完即弃的。
|
||
**断点加 `finish` 是唯一能看到固件实际收到什么的办法。**
|
||
|
||
**到哪里为止。** 这里建模的是寄存器接口,不是电台。它复现固件**下达了什么命令** ——
|
||
频率、功率档位、什么时刻键控 —— 而**从不**复现模拟结果:键控包络、杂散发射、灵敏度。
|
||
|
||
**这不是以后能补上的缺口。** 这颗芯片没有公开 datasheet,所以它的驱动是唯一可得的规格,
|
||
而驱动只告诉你**哪些寄存器被写了**,永远不会告诉你**天线上出去了什么**。
|
||
那些问题需要真机加频谱仪。**不要让任何人从一个通过的模拟器测试里得出相反的结论,
|
||
包括这里加的那些测试。**
|
||
|
||
时序也是刻意错的 —— 见 README 里的 SysTick 章节。对菜单和控制流没问题;
|
||
对信号时序毫无用处。
|
||
|
||
## 串口,双向
|
||
|
||
可以工作,而且 `tools/test_serial_rx.py` 通过讲真实协议来证明这一点:
|
||
`0x0514` hello 会得到 `0x0515` ack,`0x051B` 会返回请求的 EEPROM 字节。
|
||
用 `-serial unix:/path/to.sock` 或任何其他字符设备接入;默认是 `serial0`。
|
||
|
||
有三件事必须同时对上,而每一件单独出错时都是**静默的**:
|
||
|
||
- **USART1 需要一个字符设备。** 否则它就是个寄存器 stub,进来的字节无处可来。
|
||
- **DMA 必须服务 USART,并递减 `CNDTR`。** `driver/uart.c` 从不读 DR。
|
||
它通过一个循环模式通道接收,并用
|
||
`sizeof(UART_DMA_Buffer) - LL_DMA_GetDataLength(...)` 定位新数据,
|
||
所以一个**永不移动的计数**意味着一个**永远看起来是空的缓冲区**,
|
||
不管实际到了多少字节。这个服务是在读取 `CNDTR` 时执行的,
|
||
而那恰好就是驱动查看的地方 —— 不需要定时器,而且在 guest 主动询问之前不会投递任何东西。
|
||
- **对 DR 的写入也必须到达字符设备。** 它们以前只送到 stderr。
|
||
于是宿主机上的工具发一条命令、固件确实回复了、而那个回复去了工具看不到的地方。
|
||
**这和"被忽略"完全无法区分**,还害我多debug了一轮:新测试第一次运行报告
|
||
"完全没有回复",同时启动输出是**零字节** —— 看起来像接收坏了,
|
||
而实际上发送一直好着,只是不可见。
|
||
|
||
通道还会记录它被编程的长度,因为 `CNDTR` 是倒着数的,写入偏移必须由差值算出来。
|
||
|
||
## 如果你要加一个外设
|
||
|
||
1. 从 CMSIS 头文件里读寄存器布局
|
||
2. 只建模固件真正碰到的部分;带日志的兜底模块(`py32-stub`)会告诉你那是哪些
|
||
3. 警惕自旋循环:任何固件会轮询的标志都必须**能够变化**,
|
||
而写 1 启动的位(比如 `ADC_CR2_CAL`)**绝不能被存成置位状态**
|
||
4. 重新构建、运行,并用 `tools/where.sh` 确认固件越过了它原来停住的地方
|
||
5. 往 `py32_stubs[]` 里加条目时**必须同时改 `PY32_NUM_STUB`**。忘了改以前是**静默**的:
|
||
那个设备根本不会被 realize,地址仍是未映射,唯一症状就是"什么都没变"。现在表后面有一条
|
||
`QEMU_BUILD_BUG_ON(ARRAY_SIZE(...) != PY32_NUM_STUB)`,会在编译期拦住。
|
||
这样找到过两个洞,都对多系统固件是致命的(见可移植性一节):`0x40007400` = `DAC1_BASE`、
|
||
`0x1FFF3000` = `UID_BASE`,两者都不在任何面向固件的清单里。所以现在整个 APB/AHB 外设空间
|
||
在具名设备之后还有一层**低优先级兜底**:没名字的寄存器会应答并记日志,而不是抛异常 ——
|
||
真机有应答、模拟器却数据异常,那是模型的 bug,不是新发现。
|
||
|
||
## 在新固件里找显示缓冲区的地址
|
||
|
||
`gFrameBuffer` 和 `gStatusLine` 在不同构建之间会移动,而且**两者都不是 128 字节对齐的**,
|
||
所以按对齐地址去猜,渲染出来的图会以"像字体问题"的方式出错:偏 0x3E 字节时,每一行都变成
|
||
"上一行的尾部 + 本行的头部",字形会被固定在某一列劈开,状态行也被帧内容盖住。别用眼睛判断
|
||
—— 固件源码会明确告诉你它们在哪。
|
||
|
||
1. 抓 SRAM(QMP `memsave`,或 `python work/qmp.py dump 0x20000000 0x4000 out.bin`)。
|
||
2. 挑"内容和落点都能从源码确定"的位图(`App/bitmaps.c` 配合 `App/ui/status.c`、
|
||
`App/driver/st7565.c`):`gFontPowerSave` 复制到状态行 +0、`gFontDWR` +18、
|
||
`gFontPttClassic` +54、`BITMAP_BatteryLevel1` +111(`LCD_WIDTH - 17`);
|
||
`BITMAP_VFO_Default` 由 `memcpy` 写到**帧行**偏移 0。
|
||
3. 在 SRAM 里搜这些字节串。只有唯一一个基址能让那四个状态行偏移同时成立,而 VFO 箭头的地址
|
||
**就是**帧缓冲基址。两者必须正好相差 `FRAME_LINES * LCD_WIDTH` = 896 —— 这就是"搜索收敛了"
|
||
的判据。5.9.0.CN 这份固件:帧缓冲 `0x200012BE`,状态行 `0x2000163E`。
|
||
|
||
拿到之后的自检:帧行 3 是界面 `memset` 掉的中缝,应当整行全 0;两个 VFO 同频时,
|
||
帧行 0/1 与 4/5 相同,而 2 与 6 不同(只有活动 VFO 的说明行有内容)。
|
||
|
||
## 可移植性:Windows 到底弄坏了什么
|
||
|
||
机器和工具本身是可移植的 C 与 Python;**包装**是 Linux 专用的。四个失败,每一个在有人依赖它
|
||
之前都是不可见的:
|
||
|
||
* **`rename()` 在 Windows 上不覆盖已存在的文件。** flash 回写是"写临时文件再改名覆盖",
|
||
于是每一次保存设置都失败(`cannot replace`),设置永远到不了磁盘,而 stderr 刷屏把主循环
|
||
挤住到 QMP 连问候语都发不出来 —— 最终只表现为 "power on failed: timed out"。
|
||
`g_rename()`(需要 `<glib/gstdio.h>`)在两个平台上都给出 POSIX 语义。
|
||
* **Windows 版 QEMU 无法创建 unix socket**,所以 QMP 必须以 `tcp:host:port` 传输;
|
||
`uvk5_qmp.py`、`key.py` 和 supervisor 的启动器现在两种形式都接受。
|
||
* **`qemu/py32f071.c` 对原版 QEMU 7.2 编译不过**,缺 `#include "qapi/visitor.h"`
|
||
(`visit_type_uint64`);`qom/object.h` 不会间接带入它。
|
||
* **`-kernel foo.bin` 会加载到错的地方。** `armv7m_load_kernel()` 把裸二进制加载到给它的基址,
|
||
而在本机那个基址是 flash 的**别名区**,于是镜像高 0x2800 字节,第一次取指就 fault。
|
||
`tools/bin2elf.py` 把发行版 `.bin` 套上带正确程序头的 ELF32/ARM。
|
||
|
||
**外置 flash 是有分区的,而且主固件确实在读它。** 有一次 26 秒的抓取里只看到 `0x00A0xx`,
|
||
于是"固件根本不用 flash"被当成结论写下来 —— 错了两次:第一次被 PowerShell 的 UTF-16 重定向
|
||
骗了,第二次是我把探针**截断在 80 次读**。把上限放到 4000、并打开菜单让固件画汉字之后,
|
||
一次启动就产生 3168 次读:设置区、`0x0A0000`/`0x0E0000` 用户字体包里的单个字模,以及
|
||
**`0x1E0000` 处一张 32 KB 字体表被按 32 字节一步连续走完 1024 次**。分区如下(由
|
||
`gitee.com/oldlicn/betula-multi-system-tool` 的配套数据反推,不是靠它那张分区图):
|
||
|
||
| 偏移 | 大小 | 内容 |
|
||
| --- | --- | --- |
|
||
| 0x000000 | 128 KB | 引导 + 设置(`0x00A0xx`)+ 校准(`0x010000`) |
|
||
| 0x020000 | 4 × 128 KB | 固件槽(工具里正好有"清空 0x20000-0x40000"到"0x80000-0xA0000") |
|
||
| 0x0A0000 | 256 KB | 用户字体包 16x16 |
|
||
| 0x0E0000 | 64 KB | 用户字体包 8x8 |
|
||
| 0x100000 | 1 MB | 出厂资源区,其中 `0x1E0000` 是那张 32 KB 字体表 |
|
||
|
||
官方十六份 128 KB 恢复文件可以拼回真机整片 2 MB 数据。把它的 `0x100000-0x200000` 区补进
|
||
`assets/flash.img` 之后,**固件画出来的内容会变** —— 说明那块数据是活的,不是装饰。
|
||
还没定论的是"哪块文字由哪个来源提供":屏幕上的 16 像素字模与 `0xA0000` 的字模包
|
||
(24 格里对 2 格)、与 `0x1E0000` 的表(8 格里 0 格)都不能逐字节对上。
|
||
|
||
面板设置是第五种、不一样的情况:对比度和反显根本不在帧缓冲里,所以任何渲染 `gFrameBuffer`
|
||
的东西都显示不出来。`TYPE_ST7565` 建模控制器自己的寄存器,`tools/uvk5_lcd.py` 把反显应用到
|
||
画面上;见 README.md。
|