# UV-K5 V3 模拟器 在 PC 上运行泉盛 UV-K5 V3 / UV-K1 固件。这台电台用的是普冉 PY32F071(Cortex-M0+), QEMU 没有对应的机器模型,所以这里加了一个。 固件约五秒进入主循环,LCD 内容可读,键盘能驱动菜单。哪些建了模、哪些没有,见 [还原程度](#还原程度)。 *English: [README.md](README.md) · 本文档与英文版内容对应,改动请同步两份。* | 主界面 | 菜单 | 按键导航后 | | --- | --- | --- | | ![主 VFO 界面](docs/screenshots/main-vfo.png) | ![菜单停在 Step](docs/screenshots/menu-step.png) | ![菜单停在 RxDCS](docs/screenshots/menu-navigated.png) | 这是真实截图,不是效果图:`tools/screenshot.py` 从 guest 内存里读出固件的 `gFrameBuffer` 再渲染,所以这些就是 LCD 驱动实际写下的像素。从左到右依次是双守候主界面、 用 `key.py MENU` 打开的菜单(第 01/79 项 Step)、以及 `key.py DOWN DOWN` 之后的 03/79。 ## 它用来做什么 改一行固件就要重新烧进电台验证,太慢;而且有些 bug 从外面根本看不见。举个真实例子: CW 宏录制看起来毫无反应,原因在三层之下 —— keyer 被一个后续调用拆掉了,那个调用从错误的 VFO 重算了状态。在真机上你只看到"什么都没发生",在这里可以直接读那些变量。 它**不做**的事是复现电台行为。它复现固件**下达了什么命令** —— 频率、功率档位、什么时刻键控 —— 而不是模拟结果。键控包络、杂散发射、灵敏度都需要真机加频谱仪。这不是以后能补上的缺口: 收发芯片没有公开 datasheet,它的驱动是唯一可得的规格。 ## 还原程度 | 项目 | 状态 | | --- | --- | | 启动到主循环 | 可用,约 5 秒 | | LCD 内容 | 可用,经 `tools/screenshot.py` | | 显示对比度 / 反显 | 面板级设置,从控制器读取;反显还会改变渲染出的画面 | | SPI flash、设置、校准数据 | 可用,且断电保留 | | 频率输入 | 可用,按波段分别存储并保留 | | 键盘与菜单导航 | 可用,含从省电模式唤醒 | | 串口输出(固件日志) | 可用,出现在网页日志里 | | 串口输入、CPS 编程协议 | 可用,`-serial` 接任意字符设备 | | BK4819 寄存器接口 | 可用,RSSI 和状态可读 | | S 表 | 可用,经监听模式(SIDE1) | | 信号强度 | 取决于调谐位置:虚拟电台 vs 噪底 | | PTT 与发射 | 可用;TX 标识、计时器、话筒电平条 | | 喇叭 / 麦克风音频 | **不存在可建模的采样**,见[音频](#音频) | | `millis()` / TIM2 | 可用;按接近实际时间的速率递增 | | 时序精度 | **故意是错的**,见[时序](#时序) | | 模拟量射频行为 | **没建模,也永远不会有**,见 [AGENTS.md](AGENTS.md#the-bk4819-and-where-modelling-it-stops) | 短按 `tools/key.py MENU` 打开菜单,UP/DOWN 在其中移动,MENU 进入子菜单,直接输入菜单编号 可以跳到那一项。按键时长决定短按还是长按,而固件把这两者当成不同事件 —— 见[时序](#时序)。 **按键时长是最需要拿准的东西。** 按住 400 ms 以上算**长按**,处理函数的反应完全不同: `MAIN_Key_MENU` 在短按松手时打开菜单,走长按路径则什么都不做。所以如果某个键像是被忽略了, 应该**缩短**按压时间而不是延长。从省电模式唤醒不需要任何特殊操作 —— 一次 200 ms 的按压 既能唤醒电台也能打开菜单,空闲 45 秒后实测有效。 `tools/keypad_test.py` 会在一个临时 QEMU 实例上验证以上全部。它之所以存在,是因为键盘有一个 很不直观的陷阱:键盘模型的 `row_out` 数组**必须保持 `volatile`**,否则 GCC 在 -O2 下会证明 那些线路始终为 NULL,从而删掉所有对 `keypad_update_rows()` 的调用 —— 结果是没有任何行被驱动, 按键静默失效。改动那部分代码后请跑这个测试;`AGENTS.md` 里有目标代码层面的证据。 ## 目录结构 qemu/ 需要复制进 QEMU 源码树的文件 py32f071.c SoC 与机器定义(主要工作量在这里) armv7m_systick.*.patched 加了 poll-boost 属性的 SysTick assets/ flash.img,以及作为参考副本的 pristine/ calibration.bin 从真机导出的 512 字节校准数据 deploy/ HTTPS 前端用的 nginx vhost docs/reverse-proxy.md https://k6v3.mckero.dn42/ 是怎么提供服务的 *.zh-CN.md 中文翻译,与英文版同步维护 docs/screenshots/ 本 README 用到的 LCD 截图 tools/ 运行、截图、注入按键、探查状态 bin2elf.py 把发行版 .bin 包成 QEMU 能当内核加载的 ELF make_flash.py 生成 assets/flash.img;--blob 可把额外数据(中文版 需要的字体包)放到指定偏移 keypad_test.py 键盘回归测试,自己启动实例 test_flash_persist.py flash 写入能跨断电保留 test_freq_entry.py 输入的频率生效并保留 test_serial_rx.py 固件会响应编程命令 test_bk4819.py BK4819 寄存器接口,RSSI 不再恒为零 test_bk4819_readback.sh 寄存器读回的位对齐正确 test_smeter.py 监听时 S 表能读到信号 test_ptt.py PTT 能键控电台并干净释放 test_scan.py 繁忙频段不会卡死扫描 test_audio_path.py 固件想出声时功放会打开 test_battery.py 电量与低电告警跟随 ADC test_millis.py millis() 会递增,超时才可能到期 test_spectrum.py RSSI 取决于调谐位置,不是常数 check_docs.py 文档的断言是否仍与代码一致 run_tests.sh 跑上面全部,先检查构建 test_run_tests.sh 验证 runner 真的能发现失败 lib_kill_emulator.sh 只杀模拟器的清理逻辑 webui.py 网页远控:实时 LCD 加可点击键盘 dn42_firewall.sh 把网页端口限制到 DN42 来源 restore_flash.sh 把 flash 镜像回滚到初始状态 uvk5_qmp.py QMP 客户端 uvk5_lcd.py 帧缓冲解码、PNG 编码、抓帧 uvk5_keys.py 键盘模型接受的按键名 uvk5_logs.py 共享日志缓冲,含客户端 IP 归属 uvk5_stream.py /stream 背后的抓帧泵 uvk5_supervisor.py 启动、停止、以及故障后恢复模拟器进程 test_kill_emulator.sh 清理逻辑绝不会杀掉无关进程 uvk5_elf.sh 探针脚本从哪里找固件(环境变量,然后本仓库) (另有一批临时探针脚本 —— scan_trace.sh、gpio_watch.py 等 —— 留着是因为随手就能用,不是因为它们打磨过) harness/, stubs/, shim/, tests/ CW 时序链的宿主机构建(阶段 A) ### 在别的机器上跑:CI 与容器 `.github/workflows/unit.yml` 会安装 `requirements-dev.txt`(flask)并跑 `tools/run_tests.sh -q`:即 `test_uvk5_*.py` 单元测试加上 `check_docs.py`,它们不需要模拟器、不需要固件、也不需要 编译 QEMU。这条路才是让套件在**别人的机器**上保持诚实的东西——在它出现之前,runner 的默认 路径是作者本人的,新克隆不先改文件就什么也跑不了。 `Dockerfile` 构建同样的工具环境;若把 QEMU 源码树挂进去,还能跑模拟器测试(因为 `tools/setup_qemu.sh` 是给已有源码树打补丁,而不是下载一份): docker build -t uvk5 . && docker run --rm uvk5 # 单元测试 docker run --rm -v /path/to/qemu-7.2:/qemu-7.2 -e QEMU_SRC=/qemu-7.2 uvk5 \ bash -lc 'bash tools/setup_qemu.sh && bash tools/run_tests.sh' `check_docs.py` 里的 `file:line` 检查需要固件源码,而它不在本仓库里。没有它时检查器会**跳过** 那部分并明确说明;把 `UVK5_FW_DIR` 指向一份源码树即可让它生效。 ## 仓库里没有什么 有两类东西是刻意不放的,也都不应该提交: - **固件**:发行镜像、汉化/改版构建、引导 dump 都属于它们的作者,不属于这个项目。 需要时用 `tools/fetch_firmware.py` 从上游归档取一份到 `assets/firmware/`,该目录已被忽略。 - **任何从真电台读出来的数据**:`work/data.bin` 是一份 EEPROM dump——别人机器上的设置与校准。 它不是构建产物。它现在已被忽略,测试改用 `assets/pristine/` 自己拼 flash 镜像。 `assets/pristine/flash-pristine.img.gz` 与 `assets/calibration.bin` 是会随仓库分发的:它们是 一对 2 KB 的**合成**数据,由 `tools/make_flash.py` 拼成 flash 镜像,不是电台里的数据。 `work/` 里其余都是临时产物——镜像、日志、抓取结果。里面那四个脚本是**刻意跟踪**的,因为它们 记录了这台机器怎么驱动;其他内容一律忽略。 **如果这些东西已经在历史里了,现在删掉并不够。** 对象仍然可达,所以要把仓库公开就得先清理历史 (`git filter-repo`)或另起一个仓库。推送前先查一下: git log --stat -- work/data.bin assets/firmware ## 快速开始 从克隆到网页上跑起电台,大约五分钟。 # 1. 把机器模型打进 QEMU 7.2 源码树并编译。手工做法是拷三个文件、改 Kconfig 与 # meson.build、再 configure 和 ninja;这个脚本就是那几步(下面"构建"一节有说明)。 QEMU_SRC=~/src/qemu-7.2 bash tools/setup_qemu.sh # 2. 拿一份可运行的固件。仓库不再分发固件——这个工具会从上游项目的归档里取一份到 # assets/firmware/,并打印它的哈希。 python3 tools/fetch_firmware.py # 3. 固件保存设置所依赖的外部 flash 镜像。 python3 tools/make_flash.py # 4. 跑起来。 python3 tools/webui.py --qemu ~/src/qemu-7.2/build/qemu-system-arm \ --elf assets/firmware/f4hwn.fieldops.v6.0.0.bin # 然后打开 http://127.0.0.1:8080/ `--frame-addr` 与 `--status-addr` 都是可选的,通常不传。页面画的是**显示控制器自己的显存** —— 对任何固件那就是屏幕,不需要任何地址;这两个参数只服务 guest RAM 回落路径,而地址是由 `tools/uvk5_buffers.py` 从**正在运行的那份固件**里读出来的。自己在命令行给一个,就跳过这次搜索。 仓库里不保存任何一份构建的地址。 把任意 `.bin` 拖到页面上即可启动。页面上的 **Firmware slots** 表可以读写 flash 镜像里 多系统固件的四个槽位,**Multiboot** 会按住 MENU 重启以进入多系统菜单——前提是那份构建 确实带菜单:页面会标注不带菜单的构建,因为那时这个按钮做不了任何事。 再确认一下没坏: bash tools/run_tests.sh -q # 约 15 秒,不需要模拟器 bash tools/run_tests.sh # 全部;需要第 1 步那棵树 ## 构建 需要 QEMU 7.2 源码树,以及 `meson`、`ninja`、`libfdt-dev`、`libglib2.0-dev`、 `libpixman-1-dev`。 # 1. 把源文件放进 QEMU 源码树 cp qemu/py32f071.c $QEMU/hw/arm/ cp qemu/armv7m_systick.c.patched $QEMU/hw/timer/armv7m_systick.c cp qemu/armv7m_systick.h.patched $QEMU/include/hw/timer/armv7m_systick.h # 2. 注册这台机器。在 $QEMU/hw/arm/Kconfig 里: # config UVK5_V3 # bool # default y # depends on TCG && ARM # select PY32F071_SOC # config PY32F071_SOC # bool # select ARM_V7M # select UNIMP # 在 $QEMU/hw/arm/meson.build 里: # arm_ss.add(when: 'CONFIG_UVK5_V3', if_true: files('py32f071.c')) # 3. 只构建 ARM target cd $QEMU ./configure --target-list=arm-softmmu --disable-docs --disable-tools cd build && ninja qemu-system-arm 然后验证构建真的能用,大约一分钟: bash tools/run_tests.sh # 全部,几分钟 bash tools/run_tests.sh -q # 只跑单元测试,约 15 秒,不需要模拟器 **runner 会先检查构建,失败就拒绝继续。** 因为 `ninja` 失败时会把上一个二进制留在原地, 否则测试会拿一份从未编译过的代码跑出"通过"。单个测试仍然可以独立运行: python3 tools/keypad_test.py python3 tools/test_flash_persist.py python3 tools/test_freq_entry.py python3 tools/test_serial_rx.py python3 tools/test_slot_serial.py python3 tools/test_bk4819.py bash tools/test_bk4819_readback.sh python3 tools/test_smeter.py python3 tools/test_ptt.py python3 tools/test_scan.py python3 tools/test_audio_path.py python3 tools/test_battery.py python3 tools/test_millis.py python3 tools/test_spectrum.py 这件事比看起来重要。键盘可以在 -O2 下静默失效而**不产生任何编译警告** —— 见[还原程度](#还原程度)里关于 `volatile` 的说明 —— 所以"构建干净"不等于按键能用。 其中几个测试覆盖 flash 路径,那里有四个各自独立的故障,每一个都会把已存频率清零 而**不报任何错误**:细节见 [AGENTS.md](AGENTS.md#the-flash-bugs-four-faults-one-symptom)。 其余测试: cd tools && python3 -m unittest discover -p 'test_uvk5*.py' -v # 快,不需要模拟器 cd tools && python3 -m unittest test_webui -v # 快,不需要模拟器 python3 tools/test_webui_e2e.py # 自己启动模拟器 ## 运行 python3 tools/make_flash.py # 执行一次,生成 assets/flash.img 模拟器会写入这个镜像,所以一次会话可能留下改过的设置、甚至损坏的 EEPROM。 `assets/pristine/` 保存了镜像刚生成时的带校验和副本,`tools/restore_flash.sh` 负责还原: tools/restore_flash.sh --verify # 参考副本本身是否完好 tools/restore_flash.sh --diff # 当前镜像变了没有,变了多少 tools/restore_flash.sh # 还原,并先保存当前镜像 参考副本以 gzip 存储,占 2.3 KiB 而不是 2 MiB —— 因为镜像几乎全是 0xFF,小到可以放进 git。 运行时的镜像仍被忽略:它是会被写入的构建产物。 tools/run.sh # 启动机器 tools/where.sh # 固件当前执行到哪里 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 python3 tools/key.py MENU # 注入一次按键 tools/gpiob_dump.sh # GPIOB 寄存器 这台机器在 1234 端口暴露 GDB stub,QMP socket 在 `/tmp/uvk5-qmp.sock`。它是无头的: 屏幕是从 guest 内存里读出来而不是画出来的,所以不需要任何显示后端。 截图需要 `gFrameBuffer` 和 `gStatusLine` 的地址,而它们在不同固件构建之间会变。这样找: arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine' ## 网页远控 `tools/webui.py` 提供 LCD 画面和一个可点击的键盘,这样就能在浏览器里操作电台, 不用反复敲 `key.py` 加 `screenshot.py`。 tools/run.sh # 先起模拟器 python3 tools/webui.py # 不需要地址:它画的是面板显存 打开 。键盘按电台的实际布局排列,侧键在旁边。方向键、 回车(MENU)、Esc(EXIT)和数字键都绑定到了对应的物理按键。 按键时长取自你实际按住的时间,因为固件把超过 400 ms 的按压当作**长按**并派发成不同事件。 浏览器分别发送两个边沿,而不是让服务端按固定时长模拟一次按压。 想脚本化的话,接口如下: | 路由 | 用途 | | --- | --- | | `GET /` | 页面本身 | | `GET /stream` | multipart PNG 流,最高 15 fps | | `GET /frame.png` | 单帧 | | `POST /api/key` | `{"key": "MENU", "action": "down"}` — 也可以是 `up` 或 `tap` | | `POST /api/ptt` | `{"held": true}` — 按住 PTT,`false` 释放 | | `POST /api/release-all` | 释放所有按键,万一有键卡住 | | `GET /api/status` | QMP `query-status`,另含 `speaker`、`panel` 与 `firmware` | | `GET /api/firmware` | 当前镜像,以及它将按什么形态加载 | | `POST /api/firmware` | 请求体就是 `.bin` 或 `.elf`;启动它并重启模拟器 | | `GET /api/slots` | 当前 flash 镜像里的固件槽 | | `POST /api/slots/` | 请求体是一个 `.bin`;写进槽 `n` 并重启 | | `POST /api/slots//erase` | 擦除槽 `n` | | `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 | | `POST /api/apps/` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) | | `POST /api/apps//erase` | 清空应用槽 `n` | | `GET /api/apps/radio` | 问正在运行的固件它在每个应用槽里看到了什么(`0x0730`) | | `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 | 画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。 这样无论固件是谁写的、把缓冲区放在哪里,画面都是对的 —— 同一祖先改出来的各个版本 显示逻辑也各不相同,多系统那版干脆把图像放在完全不同的位置。 下面这段是**旧的取帧路径**(用 QMP `memsave` 直接读 guest RAM 里的 `gFrameBuffer` 与 `gStatusLine`,每帧约 1.35 ms),它保留为"没有面板模型的模拟器" 的回退路径;接下来的两条注意事项针对的正是它: - **必须用 `memsave`,不能用 `pmemsave`。** 帧缓冲符号是 CPU 虚拟地址。`pmemsave` 会把参数 当成物理地址,返回一整块零 —— 于是画面渲染成全空白,而且哪里都不报错。 - **不要走 gdb。** `screenshot.py` 通过 gdb 读帧,而 gdb 每次 attach 都会暂停 guest。 这对实时流完全不可用,而且会扰乱按键防抖的时序。 使用前值得知道的两个限制: - **QMP socket 只接受一个客户端。** 服务运行期间,`tools/key.py` 无法连到同一个模拟器。 - **没有任何认证。** 任何能访问到这个端口的人都能完全控制这台模拟电台。正因如此, 它默认只绑定 loopback。 ### 从页面上传固件 把 `.bin` 直接拖到页面上,或用 "Firmware" 选择文件,服务器就会存下它并启动它。 不需要包成 ELF,也不需要你去查地址。 镜像有两种形态,加载地址不同: | 形态 | 怎么认出来 | 加载地址 | | --- | --- | --- | | 应用镜像 | 复位向量在 `0x08002800` 之后 | `0x08002800` | | 整片镜像 | 复位向量落在引导区(`0x08000000`..`0x080027ff`) | `0x08000000` | `.elf` 自带程序头,两者都不需要。形态是**从镜像自己的头两个字读出来的** (主机侧 `tools/uvk5_image.py`,机器侧 `uvk5_sniff_app_offset()`),不是靠标志位或 文件名,因为判断错的症状是**静默**的:镜像整体偏 `0x2800` 字节,第一次取指读到的是 随便什么数据。不是可启动镜像的文件会以 400 拒绝,电台继续跑原来的固件。 上传文件放在 `work/firmware/`(`UVK5_UPLOAD_DIR` 可改)。模拟器正在运行时上传会 让它重启一次 —— 镜像是 QEMU 启动时选定的。 这里两种形态都端到端验证过:应用 `.bin`、`.elf`,以及一个"引导入口跳到 `0x08002800` 处应用"的整片镜像,最终都到达同一幅画面。 ### 固件槽,与多系统版固件 v6.0.0 版把开机菜单和四个固件槽放在外部 flash 里:开机按住 MENU 就会列出它们,选中一个 会用该槽的镜像重刷内部 flash 并复位。两部分都能从网页上操作。 - **固件槽**一栏每个槽一行,显示名字、版本、大小,以及头部 CRC-32 与镜像是否一致;可以 往某个槽写入一个 `.bin`,或擦除该槽。改动只写**工作副本** (`work/firmware/flash-current.img`),绝不改服务器启动时指定的那份文件,改完自动重启 模拟器来生效。 - **Multiboot**(或 Shift+M)会**从复位起按住 MENU** 重启模拟器。网页的按键事件做不到 这件事,因为固件在复位后的头几毫秒就采样键盘。机器侧对应 `-M uv-k5-v3,boot-key=MENU` 或 `UVK5_BOOT_KEY`,保持时间由 `UVK5_BOOT_KEY_MS` 决定(默认 8 秒:开机路径可能花 20 秒把当前固件"采纳"进槽 0,之后才会去采样键盘)。 - `tools/uvk5_slots.py` 做同样的事但离线:把槽写进 flash 镜像,并打印每个槽的内容。 ### 读回守卫,以及一个**还不算守卫**的草稿 `tools/test_bk4819_readback.sh` 守着「读回左移一位」那个 bug —— 读寄存器交出的值偏一位。它需要 ARM gdb,所以只在装了它的机器上跑。`tools/test_bk4819_readback.py` 是正在做的可移植替代,但 **它现在还不是守卫**:在正确的模型上它通过,可是**把修复拿掉它并不会失败**,说明它观测的位置 不是客人真正采样的那一刻。因此它**刻意没有注册进** `tools/run_tests.sh` —— 不能用一个未经证明的 检查换掉一个已经证明的。要等观测点移到驱动真正的采样边沿,它才能接手。 ### 哪份构建渲染正确,这件事怎么判定 网页画的是显示控制器**自己的显存**,不是固件的 framebuffer,所以它不需要知道某份固件把画面 放在哪 —— `tools/panel_dump.py` 在命令行里做同一件事,一个字符一个像素,方便把两份构建 diff: tools/panel_dump.py --qmp 127.0.0.1:4444 # ASCII tools/panel_dump.py --qmp 127.0.0.1:4444 --png shot.png # 放大后的 PNG 对这里量过的构建,这条路是忠实的:CN 5.9.0 的面板与它自己的 framebuffer **8192 个像素里吻合 8188 个**,而取回的 6.0.0 构建渲染出来**逐字节相同**。两者把面板寄存器配成**一样**的值 —— `0xA1` 段反序、`0xC0`、`0xA6`、列 0..127、起始行 0 —— 这正是关键:**映射不是寄存器决定的, 而是驱动决定的**,因为有的驱动在软件里补偿面板的段顺序,有的不补偿。所以映射无法从控制器设置里 推导,只能**量**出来(`--mapping` 就是干这个的)。 面板模型目前还有两处没落实,任何用到它们的构建都会像素错位:**显示起始行**(`0x40|n`,纵向 滚动),以及控制器的 **132 列** —— 模型把像素存在 `col - 4`,并把列计数器在 128 处回绕,所以 按 4..131 寻址的驱动会丢掉前 4 个像素、整行左移。两条都在待办上;都不影响上面量过的构建。 ### MOTO/DFU 刷机,以及这份构建没有打开的开关 出厂引导就在机器上,而且它**确实会说刷机协议**:真正的 `0x0518` / `0x0530` / `0x0519` 交互, 38400 波特,在应用区之前的 10 KB 里。它不肯做的,是**从外部**进入那个模式。下面是量出来的,不是猜的: | 尝试进入 DFU 的方式 | 实际发生了什么 | | --- | --- | | 复位起按住 PTT | 普通启动。固件自己的 `BOOT_GetMode()` 在没有第二个键时返回 `BOOT_MODE_NORMAL` | | PTT+SIDE1、PTT+SIDE2、MENU | 进了应用的几个特殊模式(F_LOCK、AIRCOPY、MULTIBOOT),从不是引导 | | 开机窗口内主机发字节(含 `0x0530`) | 被忽略;PC 从未离开应用区 | | 固件自带的 `0x05DD`(reset)命令 | 普通复位,直接回到应用 | 引导的判据只有一个字节:`ldrb r0,[r4]`,`r4 = 0x20000020`,与 1、2、3 比较,只有 **3** 才 会进入 DFU 处理器。这个字节在 SRAM 里,所以它只能挺过**软**复位:必须是**正在运行的程序**写它 然后复位。在固件里那就是 `overlay_FLASH_RebootToBootloader()`,而 `0x05DD` 只有在构建定义了 `ENABLE_OVERLAY` 时才走那条路: case 0x05DD: // reset #if defined(ENABLE_OVERLAY) overlay_FLASH_RebootToBootloader(); #else NVIC_SystemReset(); <-- 这份构建走的是这里 #endif **所以 MOTO 刷机不是卡在模拟器上。** 引导能跑、DFU 处理器存在、进入条件已知且可复现;这份固件 构建只是没有打开唯一能走到那里的那个开关。多系统版出于同样的原因有同样的性质——可以参考网页在 没有开机菜单的构建上打印的那句提示。 布局来自固件源码 `App/driver/mb_flash.h`:槽 0 在 `0x020000`,是内部镜像的备份;槽 1..4 从 `0x040000` 起、每 128 KiB 一个;镜像从槽内偏移 4 KiB 开始;64 字节头部含魔数 `FMB1`、镜像大小和 CRC-32。固件自带的 `0x0720`..`0x0727` 串口命令也按同样方式写槽, Windows 上的工具走的就是那条路。 有两种行为看起来像模拟器出错,其实不是:当**状态标记损坏**而槽 0 有效时,固件会停在 `STATE ERROR` 画面上以保护 Main;当状态标记**缺失**时,它会在菜单出现前把当前固件"采纳" 进槽 0(重写外部 flash)。写槽时会顺手擦掉那两个标记扇区,让它能重新判断。模型里的内部 flash 是可编程的(`0x40022000`:解锁、页擦除、编程、EOP、永不 BSY),所以恢复槽位是真的 替换了复位后 CPU 执行的镜像。 ### 从别处访问 这里的部署方式是服务只监听 loopback,前面放 nginx 做 TLS,对外是 `https://k6v3.mckero.dn42/`。vhost 配置见 [docs/reverse-proxy.md](docs/reverse-proxy.md),其中对这个应用特别重要的两项是: `proxy_buffering off`(否则画面流会一阵一阵地到)和 `X-Forwarded-For`(否则每条日志 都会被记成 127.0.0.1)。 用 `--host ::` 直接绑定也可以,但既然没有认证,那这个端口就必须按来源地址过滤。 `tools/dn42_firewall.sh` 把它限制到 DN42: tools/dn42_firewall.sh apply 8080 # 只允许 DN42 + loopback tools/dn42_firewall.sh show 8080 # 规则和包计数 tools/dn42_firewall.sh remove 8080 一个容易搞错的细节:这台主机的 `INPUT` 默认策略是 `ACCEPT`,所以只**放行** DN42 的规则 什么都改变不了 —— 一条规则都没有的时候,那个端口本来就是可达的。真正起作用的是**最后那条 `DROP`**。请通过看计数器来验证,而不是靠假设: tools/dn42_firewall.sh show 8080 # DROP 计数在涨,说明非 DN42 的流量真的被拒了 这些规则重启后不保留。重新执行 `apply`,或者用 `iptables-persistent` 持久化。 **PTT 不在键盘矩阵里**,因为固件是直接读它自己的引脚(PB10),而不是当成矩阵键去扫描。 它在界面上有独立按钮、也有独立接口,而且是"按住"而非"点一下": curl -X POST -H 'Content-Type: application/json' \ -d '{"held": true}' http://127.0.0.1:8080/api/ptt 任何结束会话的动作都会释放它 —— 把指针拖出按钮、关闭标签页、或者 `POST /api/release-all` —— 所以客户端消失不会让电台一直处于发射状态。`press` 属性仍然拒绝把 "PTT" 当作按键名; 未知按键返回 400 而不是转发出去。 ## 音频 **没有音频,而且没有什么可加的。** 在真机上,喇叭和麦克风都不经过 MCU:接收音频在 BK4819 内部解调,以模拟信号从它的 AF 输出出来;发射音频从麦克风直接进入芯片自己的 ADC。 固件全部能碰到的只有三样东西: | | | |---|---| | PA8 | 功放使能,开或关 | | `REG_47` | 芯片路由哪一路 AF 源 | | `REG_64` | 一个供固件显示的电平值 | **MCU 的地址空间里任何地方都不存在音频采样**,所以模拟器没有东西可以采集或播放 —— 网页也不需要麦克风或播放权限,因为根本没有内容需要它承载。在这里生成声音,等于 编造固件从未产生过的数据。 **真实可用的**是"固件此刻是否想出声",而 PA8 精确表达了这一点。界面把它显示成电源状态旁边的 一个喇叭图标,`/api/status` 以 `speaker` 字段上报。按 SIDE1 进入监听模式,它就会亮起。 ## 这台机器是怎么搭起来的 寄存器布局来自固件自带的厂商 CMSIS 头文件 (`Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h`),不是猜的。 FLASH 0x08000000 128 KB 应用在 +0x2800,bootloader 在它下面 SRAM 0x20000000 16 KB RCC 0x40021000 GPIO 0x50000000 端口 A、B、C、F,间隔 0x400 SPI1 0x40013000 显示屏 SPI2 0x40003800 flash ADC1 0x40012400 已建模:RCC、GPIO、ADC、两个 SPI 控制器、DMA1、TIM2、PY25Q16 flash,以及 ST7565 显示控制器自身的设置(对比度、反显、开屏/关屏)。 其余全部由一个带日志的兜底模块响应 —— **那份日志正是判断下一个值得建模的东西的依据。** 固件能启动之前,有七件事必须做对,每一件都是靠观察它停在哪里发现的: - **在应用偏移处做 flash 别名。** 内核从地址 0 取向量表,而镜像加载在 0x08002800, 所以 0 必须别名到那里,而不是 flash 基址。 - **时钟就绪位。** `BOARD_Init` 会轮询它们;每个使能位都要镜像到对应的就绪位。 - **ADC 校准。** `CR2.CAL` 是写 1 启动、硬件自清,所以绝不能把它存成置位状态, 否则等待循环永远出不来。 - **SPI 标志位。** 传输在寄存器写入内部就完成了,所以 TXE 保持置位,RXNE 由写入抬起。 - **DMA。** flash 驱动从不碰 SPI 数据寄存器 —— 它武装通道 4 和 5、使能传输完成中断, 然后自旋等待自己 ISR 设置的标志。 - **SysTick。** 见下文。 - **收发芯片数据线。** `RADIO_SetupRegisters` 等待 BK4819 REG_0C 的 bit 0 清零。 那条总线是 GPIO 位操作驱动的,所以在这条总线有真实模型之前,PB9 保持低电平, 让读取返回零。 ## 时序 `SYSTICK_DelayUs` 轮询 SysTick 计数器并累加差值。在真机上每次循环迭代会让计数器前进 几十个 tick;在模拟环境下,一次寄存器读取相对 guest 时间的开销要大得多,所以每次读取 计数器几乎不动。实测:一个 120 ms 的延时,在四秒里只前进了所需 5,760,000 个 tick 中的 832 个 —— 照这个速度要 **7.7 小时**才能完成。 **降低时钟频率没有用**,这一点值得在尝试之前知道:瓶颈是每秒的循环迭代次数,不是计数器速度。 把 48 MHz 降到 200 Hz 只快了 32 倍。 真正有效的办法是报告一个跑在真实值前面、且每次读取都在增长的计数值。SysTick 上的 `poll-boost` 属性就是干这个的。**之前有两次尝试是把那个值写回定时器**,结果每次读取都会 重新锚定计数 —— 上报的值不再变化、固件的 `if (cur != prev)` 判断永远不成立、 循环彻底卡死。 代价是任何延时期间 guest 时间都跑得飞快。对于验证菜单和控制流没问题;用来判断信号时序则是错的。 `poll-boost` 只加速计数器**读取**。SysTick **中断**仍然接近实时触发,而正是它们驱动 `SysTick_Handler` → `gNextTimeslice` → `APP_TimeSlice10ms` → `CheckKeys`。所以固件的 10 ms 时间片阈值在实际时钟下是成立的:一个按键必须按下 20 ms 才会被登记,400 ms 则成为长按。 **把这两者分清很重要。** `tools/key.py` 最初按住按键 2500 ms,前提是以为 guest 时间在这里 也跑得快 —— 结果把每次按压都变成了长按。那些在短按松手时才动作的处理函数(`MAIN_Key_MENU` 就是其中之一)全都无视了它,于是键盘看起来是坏的,其实并没有。 ## 阶段 A:宿主机上的 CW 时序链 `harness/`、`stubs/`、`shim/` 和 `tests/` 把 `app/cwkeyer.c` 与 `app/cwmacro.c` **原样不改**地对着桩驱动编译,配一个虚拟时钟和脚本化的电键输入。喂进一串触点闭合的时间线, 就能对解码出的字符和码元时长做断言。 **固件源码原样编译是刻意的。** 为了让它们能在宿主机上构建而去修改,会让测试与电台实际运行的 代码逐渐脱节。`CW_ReadKeys` 里的防抖是**照抄**而不是打桩的,因为它的不对称性 (要连续三次读取才登记按下,而释放是立即的)本身就是被测时序行为的一部分。 ## 显示控制器自己的设置 对比度(`SetCtr`)和反显(`SetInv`)是发给 ST7565 的命令,不是帧缓冲内容 —— `0x81 <值>` 与 `0xA6`/`0xA7` —— 所以 `gFrameBuffer` 一个字节都不变,任何渲染这份缓冲的 界面都看不出效果。这就是 SPI1 后面挂了一个小型 `TYPE_ST7565` 的原因(A0 接 PA6、CS 接 PB2, 即 `App/driver/st7565.c` 用的引脚)。它解析命令流并暴露三个**只读**属性: qom-get /machine/panel invert # 0xA6 / 0xA7 之后的值 qom-get /machine/panel contrast # 0x81 后面那个值 qom-get /machine/panel display-on # 0xAE / 0xAF 之后的值 `tools/uvk5_lcd.py` 在渲染时应用反显,因为这个效果是完全确定的,于是那个菜单项在网页里 看得见了。对比度是模拟量(玻璃有多黑),只报告不渲染。三者都在 `/api/status` 里, 页面上显示在喇叭图标旁边。`display-on` 只报告不动作:软复位(`0xE2`)是否清掉那个锁存位 无法确证,拿不确定的语义去把画面变黑,比不动它更糟。 ## Windows 上 模拟器、模型和工具本身是可移植的,不可移植的是外围包装。有四处不同,现在都在仓库内处理了: - **QMP 走 TCP。** Windows 版 QEMU 无法创建 unix socket,所以端点除了路径还可以是 `host:port` —— `tools/uvk5_qmp.py`、`tools/key.py`、`tools/uvk5_supervisor.py` 两者都收。 - **一处编译修正。** MSYS2 的 mingw-w64 能原样编译 QEMU 7.2,只有 `qemu/py32f071.c` 需要 `#include "qapi/visitor.h"`(`visit_type_uint64`),原版源码树不会间接带入。 - **发行版 `.bin` 不是内核镜像。** `armv7m_load_kernel()` 会把裸二进制加载到给它的地址上, 而这里那个地址是 flash 的**别名区**,于是 `.bin` 会整体高 0x2800 字节、永远起不来。 `tools/bin2elf.py` 给它套一个带正确程序头的 ELF32/ARM,这才是 `-kernel` 要的东西。 - **中文字体包在 SPI flash 里**,不在固件里:`tools/make_flash.py --blob 0:pack.uf2` 把每个 UF2 块放到它自己的目标地址。不做这一步,字体区读出来就是 0xFF。 `work/` 里留着一次 Windows 移植的记录:启动脚本、用固件源码验证过的取帧地址, 以及那些花掉时间的失败。 ## 许可 Apache 2.0,见 [LICENSE](LICENSE)。 一个例外:`qemu/py32f071.c` 按其文件头声明采用 GPL-2.0-or-later。它被编译进 QEMU, 并且派生自 QEMU 的设备模型(GPL-2.0),所以不可能是别的许可。工具、harness 和文档 是 Apache 2.0。 ## 致谢 基础固件:[armel/uv-k1-k5v3-firmware-custom](https://github.com/armel/uv-k1-k5v3-firmware-custom)。 寄存器定义来自厂商 CMSIS 头文件。