Full translations rather than summaries, section-for-section with the English:
README (13 sections), AGENTS.md (21), and docs/reverse-proxy.md. Each pair
cross-links to the other and says the two are kept in step, since documentation
that has silently diverged is worse than documentation that does not exist.
Verified rather than eyeballed: heading counts and order match in both pairs,
every internal .md link resolves, every tool named in either README exists, and
every test in run_tests.sh appears in both.
Translating turned up four things that were already stale in the English, which
is the honest argument for having done it this way -- a summary would not have
touched them:
- the endpoint table was missing /api/ptt, /api/power/<action> and
/api/logs, and did not mention the speaker field on /api/status
- the modelled-peripheral list omitted TIM2
- the audit table still called TIM a stub, unchanged since fdcbe80 modelled
TIM2
- neither README listed uvk5_logs.py, uvk5_stream.py, uvk5_supervisor.py or
test_kill_emulator.sh, which are part of the repo rather than scratch
The ad-hoc probe scripts are now acknowledged in one line instead of being
silently absent, and described as what they are: quick to reach for, not
polished.
Unit tests: 89 passed.
43 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 索引,等等。没有元数据、没有目录、
没有校验和 —— 只有一个代码和数据必须约定一致的地址。所以某个设置读回来不对时,
先怀疑偏移,再怀疑传输层。
进入主循环要约 15 秒,那是模拟开销。真机大约一秒就起来了。
基本规则
永远不要为了让模拟器能跑而改固件。 固件是基准。如果某个东西跑不起来,那是模型错了。 一个修改了固件源码的"修复"会让之后所有测试失去意义,因为你测的已经不是电台实际运行的东西。
寄存器布局来自厂商的 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 会自己启动一个。
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 交叉验证。
QMP pmemsave 是物理地址,memsave 是虚拟地址。 帧缓冲符号是 CPU 虚拟地址,
所以对 gFrameBuffer 用 pmemsave 会返回一整块零并报告成功 —— 一片空白屏幕,
而且哪里都没有日志。网页界面最初就是建在 pmemsave 上的,因为一个计时基准说它更快;
那个基准从来没有检查过内容。要测量你真正在意的东西:这个 bug 是在渲染出的一帧
返回 0 个亮像素、而 gdb 路径报告 1693 时才浮出水面的。
键盘:两个真 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 侧的设置改动不会持久化。(历史条目:现在 flash 会写回文件了, 见 flash 章节的第 1 条修复。)
这里有用的工具: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 或网页界面会话。
数"有多少帧不同"证明的东西比看起来少
写任何观察屏幕的测试之前值得知道这一点。
一旦接收机报告的 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确认固件越过了它原来停住的地方