mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
GET /api/apps/radio opens the firmware's serial port and sends 0x0730 for all sixteen slots, so the answer comes from the running firmware rather than from our reading of the file -- which is the check that matters, because the bytes can be right and the firmware still refuse a slot. Measured through the page after installing Beam.app into slot 0: slot 0 -> Beam 1.0, 1100 B, crc 0xd976058, shortcut beam, committed; slots 1..3 -> status 2 with unrelated data, the resource-block overlap the install guard refuses. A button beside the table asks it and shows the answer in its own column. The server gives its own emulator a serial port (--serial-port, default 4445) and uvk5_slots_serial.Radio gained a public app_info(slot), so nothing reaches into a private helper. uvk5_apps.parse_radio_reply decodes the answer and is tested without a radio. Also recorded: QEMU needs the mingw64 DLLs on PATH, and started by hand without them it exits before opening QMP, which surfaces only as 'QMP socket never appeared'.
592 lines
35 KiB
Markdown
592 lines
35 KiB
Markdown
# UV-K5 V3 模拟器
|
||
|
||
在 PC 上运行泉盛 UV-K5 V3 / UV-K1 固件。这台电台用的是普冉 PY32F071(Cortex-M0+),
|
||
QEMU 没有对应的机器模型,所以这里加了一个。
|
||
|
||
固件约五秒进入主循环,LCD 内容可读,键盘能驱动菜单。哪些建了模、哪些没有,见
|
||
[还原程度](#还原程度)。
|
||
|
||
*English: [README.md](README.md) · 本文档与英文版内容对应,改动请同步两份。*
|
||
|
||
| 主界面 | 菜单 | 按键导航后 |
|
||
| --- | --- | --- |
|
||
|  |  |  |
|
||
|
||
这是真实截图,不是效果图:`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 # 不需要地址:它画的是面板显存
|
||
|
||
打开 <http://127.0.0.1:8080/>。键盘按电台的实际布局排列,侧键在旁边。方向键、
|
||
回车(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/<n>` | 请求体是一个 `.bin`;写进槽 `n` 并重启 |
|
||
| `POST /api/slots/<n>/erase` | 擦除槽 `n` |
|
||
| `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 |
|
||
| `POST /api/apps/<n>` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) |
|
||
| `POST /api/apps/<n>/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 头文件。
|