diff --git a/fpga-imx6-flow/SKILL.md b/fpga-imx6-flow/SKILL.md index 94053db..4dfb148 100644 --- a/fpga-imx6-flow/SKILL.md +++ b/fpga-imx6-flow/SKILL.md @@ -4,7 +4,7 @@ description: > Run the fpga-imx6-flow LangGraph workflow at E:\LangGraph_Design\FPGA-imx6-flow — the closed-loop SMOD (FPGA + i.MX6) hardware-in-the-loop design/debug pipeline: power on the board, connect JTAG, program bitstream/flash, restart the imx6 app, - analyze (ILA + COM6 serial), judge against design requirements, and iterate by + analyze (ILA + imx6 serial console), judge against design requirements, and iterate by modifying RTL/imx6 code. It drives the graph in-process (no langgraph dev) and hands the 5 interrupts (analyze / meets-requirement / propose / modify-FPGA / modify-imx6) back to this Claude session to execute. @@ -43,8 +43,8 @@ USB-Blast 处于连接状态时给板卡上电或断电(保护 JTAG 适配器 标准时序(图拓扑已按此实现,手动操作或写 `tools_*` / 真机脚本时也必须遵守): ``` -断电(COM8 power_off) → 确认已断电 → 断开USB-Blast(COM4 blast_off) → 确认已断开 - → 上电(COM8 power_on) → 确认已上电 → 连接USB-Blast(COM4 blast_on) → 连接JTAG/烧写/抓ILA +断电(power_off) → 确认已断电 → 断开USB-Blast(blast_off) → 确认已断开 + → 上电(power_on) → 确认已上电 → 连接USB-Blast(blast_on) → 连接JTAG/烧写/抓ILA ``` **烧写 flash 的完整闭环**(固化后必须断电重启从 flash 加载再验证): @@ -59,7 +59,12 @@ USB-Blast 处于连接状态时给板卡上电或断电(保护 JTAG 适配器 `power_off` 做整机断电重启,再次走完上述上电时序后才 `analyze`(抓ILA验证)。 - **强化确认**:`power_on` 节点在上电前调用 `_ensure_blast_off_before_power()` 兜底确认 USB-Blast 已断开;若发现仍连接会强制断开并在 `history` 标记 `rule_violation=True`。 -- vivado-mcp 工具:`power_on`/`power_off`(COM8 继电器)、`USB_Blast_on`/`USB_Blast_off`(COM4)。 +- 两个继电器的**串口号不在本工作流里绑定**:唯一来源是 `~/vivado_mcp_usb.conf`(逐机而异, + 不入库)。`app/tools_hw.py` 通过 `vivado_mcp.serial_config.load_serial_ports()` 读取它, + 串口收发/组帧/CRC 也复用 `vivado_mcp.serial_io`——协议只有一份实现。 + 文件不存在时调 vivado-mcp 的 **`detect_serial_relays`** 工具自动探测生成。 + (对应的 MCP 工具是 `power_on`/`power_off`/`USB_Blast_on`/`USB_Blast_off`,和这里的 + `tools_hw` 走同一套底层。) > **Run via the env python directly** (not bare `conda run`): on this Windows GBK > console, `conda run` re-prints the child's UTF-8 stdout through a GBK codec and @@ -90,9 +95,17 @@ if (-not (Test-Path $py)) { Write-Host "[MISSING] langgraph env" } else { `langgraph` + `langgraph-checkpoint-sqlite` and the Vivado link). Do not proceed until it succeeds. -**For a real-hardware run** (`--real`), additionally confirm: `env_config.json`'s -`VIVADO_BIN_PATH` is valid; the vivado-mcp `power_on`/`USB_Blast_on` tools are -available; COM4/COM6/COM8 are present (`[System.IO.Ports.SerialPort]::GetPortNames()`). +**For a real-hardware run** (`--real`), additionally confirm the machine's two config +files — **both live in `~`, neither is in the repo** (they hold COM numbers, VM/imx6 +passwords, and the Vivado path, all of which differ per machine): + +| 文件 | 装什么 | 缺了怎么办 | +|---|---|---| +| `~/vivado_mcp_usb.conf` | 电源继电器 + USB-Blast 继电器的 COM 号 | 调 vivado-mcp 的 **`detect_serial_relays`** 工具自动探测生成 | +| `~/fpga_imx6_flow.json` | imx6 控制台 COM 号 (`ports.imx6`)、`VIVADO_BIN_PATH`、虚拟机/imx6 口令 | 照工程根的 `fpga_imx6_flow.example.json` 手工创建 | + +List the machine's ports with `[System.IO.Ports.SerialPort]::GetPortNames()`. +工程根目录下**不该**再有 `env_config.json`——它已废弃,`app/config.py` 见到就报错。 **Default runs are MOCK** (dry-run of the design loop with no hardware), because the workflow's `tools_*` real-hardware paths are still TODO — see "Mock vs real" below. @@ -122,7 +135,7 @@ it, then resume the **same `--thread-id`** with a JSON payload. Repeat until exi | `action` | excalidraw node | What you (the session) do | Resume JSON | |---|---|---|---| -| `analyze` | 通过ila/串口分析 | Grab COM6 serial log + pull ILA waveform (vivado-mcp); summarize | `{"analysis_log":"<摘要/路径>"}` | +| `analyze` | 通过ila/串口分析 | Grab imx6 serial log + pull ILA waveform (vivado-mcp); summarize | `{"analysis_log":"<摘要/路径>"}` | | `meets_requirement` | 符合设计要求? | Judge the analysis against the design requirement | `{"meets": true}` or `{"meets": false}` | | `propose` | 分析原因,提出修改方案 | Diagnose root cause; decide what to change + next program mode | `{"reason":"...","need_fpga": true/false,"need_imx6": true/false,"program_mode":"bitstream"或"flash"}` | | `modify_fpga` | 改 RTL→综合→出 bit/flash | Edit RTL, run Vivado synth/impl, produce bit + flash files | `{"bit_path":"...","flash_path":"...","program_mode":"flash"}` | @@ -160,17 +173,75 @@ To abandon a paused run and start clean, delete `\runs\skill_checkpoint - **MOCK (default)** — `FPGA_IMX6_MOCK=1`; hardware/check nodes return success immediately. Use this to dry-run the *design-loop logic* and the 5 interrupts with no board attached. Great for demonstrating/validating the flow. -- **Real (`--real`)** — drives actual hardware. Requires the project's - `app/tools_hw.py` / `tools_vivado.py` / `tools_imx6.py` real paths to be implemented - (they currently raise `NotImplementedError` as TODO — they will reuse the proven - vivado-mcp serial/Vivado logic). Until then, `--real` fails at the first hardware - node; tell the user to wire the tools first (see `Notes/Task00.md` §7). +- **Real (`--real`)** — drives actual hardware. The `tools_hw` (power + USB-Blast relays, + ports from `~/vivado_mcp_usb.conf`), `tools_imx6` (console restart/kill/log, port from + `~/fpga_imx6_flow.json`'s `ports.imx6`), and `tools_vivado` (JTAG program/flash) real paths are + **now implemented and validated end-to-end (2026-07-05)** on the SMOD_ATSC board. Getting there + took **7 fixes** — see memory `fpga-imx6-flow-tx-safe`: power_on waits `sys init over` before + JTAG (else TX dies); `_PROJ`→SMOD_ATSC; `deploy_app`→no-op (deploy out-of-band, see above); + driver multi-interrupt resume (map by `action`); `power_off` blast-off-first + check retry guard + (else infinite loop); serial open-retry on transient err5 (now in `vivado_mcp.serial_io`); + `program_flash`→proven + `/fpga_flash` flasher + Mfg-ID verify (old tcl was a false-positive). Verify a flash landed with + JTAG `get_hw_ilas` (iter0 = **1 ILA / 0 VIO**; golden = **0 ILA / 2 VIO**). + +## imx6 access + file transfer (modify_imx6 / deploy step) — PROVEN 2026-07-05 + +The `deploy_app` graph node is a no-op (deploy is done **out-of-band by the session**). +Here is the working recipe for the `modify_imx6` interrupt (cross-compile HEAD's app and +push it to the imx6). All of this is **network-based and fast** — no slow serial file push. + +**Hosts / creds** +- **imx6 mgmt: `192.168.1.143`** (Windows host is on `192.168.1.125`, same subnet — `ping` works). + The old memory "imx6 not reachable over the network" is **outdated**; the mgmt port is reachable. + Login: **`root` / `anywavecom`**. (The serial console still works too but its login auto-logs-out and + is flaky; prefer network SSH for imx6 shell/log reads.) +- **Cross-compile VM: `bowei@192.168.223.128`** (Ubuntu, password **`lr7402`** — ask the user, don't + guess/brute-force). Toolchain env-setup: + `/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi`. + If VM SSH gives `kex_exchange_identification: Connection closed`, the VM's sshd is down/hung — + ask the user to `sudo systemctl restart sshd`. + +**SSH library quirk (critical)** +- The imx6's SSH server is **old (legacy ciphers + `ssh-rsa` host key)**. paramiko **5.0 fails** + (`unknown cipher` / `no acceptable host key`). Install **paramiko 2.12** to a temp dir and use it + for the imx6 (it has TripleDES/aes-cbc/ssh-rsa): + `python -m pip install --target paramiko==2.12.0` then `PYTHONPATH= python ... -W ignore`. + The **VM** SSH is modern, so the env's paramiko 5.0 works for the VM (or use 2.12 for both). +- The imx6 SSH has **no SFTP subsystem** (`open_sftp()` → `EOF during negotiation`). Transfer files + via **`cat > file` over an exec channel**: + ```python + ch = imx6_client.get_transport().open_session() + ch.exec_command("cat > /aw/anywave/app/imx_atsc_app_head") + with open(local_bin, "rb") as f: ch.sendall(f.read()) + ch.shutdown_write(); ch.recv_exit_status() # 0 = ok + ``` + (The VM has SFTP; `open_sftp().get(...)` works there.) + +**Full modify_imx6 flow (validated)** +1. Windows: `git archive --format=tar -o head_atsc.tar HEAD:imx6/src/atsc_app` (13 MB, self-contained). +2. paramiko→VM (pw `lr7402`): SFTP the tar up; extract to a **FRESH dir** (`~/atsc_head_build`, never + touch `atsc_tsoip_build/` etc.); build: + `bash -lc 'source ; cd ~/atsc_head_build && make'` → output `imx_atsc_app` (~2.92 MB ELF). +3. paramiko→VM SFTP the built `imx_atsc_app` down to Windows. +4. paramiko-2.12→imx6 (pw `anywavecom`): `cat >`-push it to `/aw/anywave/app/imx_atsc_app_head`, + `chmod 755`, `cp -a imx_atsc_app_head imx_atsc_app` (swap active; golden stays as + `imx_atsc_app.bak_pre_rx1`), md5-verify. +5. Cold-boot (tools_hw blast_off→power_off→power_on, TX-safe) so rc.local runs the new app. +6. Verify: network SSH grep `/var/module_log/imx_atsc_app.log` for `TALISE_radioOn` / `fb init state ok` + / `fpga[0xBB]=0x03` (sync=1/tvalid=1); spectrum for TX; (ILA needs an `.ltx` → skip unless re-impl). + +> The `git HEAD` app (b07d73b) is the known-good **RX1-on-framerA (M=4/lane0x03/RX1RX2) + correct TX** +> build. On-device `imx_atsc_app.rx1` and any `M=2/lane0x04` variant **break TX** — do not use them. ## Troubleshooting | Symptom | Fix | |---|---| | `UnicodeEncodeError: 'gbk' ...` | Don't use bare `conda run`; use the env `python.exe` or `conda run --no-capture-output`. | +| imx6 SSH `unknown cipher` / `no acceptable host key` (paramiko 5) | Use **paramiko 2.12** (temp `--target` dir) for the imx6 legacy SSH; see "imx6 access" above. | +| imx6 `open_sftp` → `EOF during negotiation` | imx6 has no SFTP subsystem; push files via `cat > file` over an exec channel. | +| VM SSH `kex_exchange_identification: Connection closed` | VM sshd down/hung — user `sudo systemctl restart sshd`. | | `import failed ... Run /langgraph-vivado-install` | The `langgraph` env is missing/incomplete → run `/langgraph-vivado-install`. | | Exit 1 `NotImplementedError ... tools_*` | A `--real` run hit an unwired hardware tool. Run without `--real` (mock), or implement `tools_*`. | | `resume` seems to start over | You passed a different `--thread-id` than `start`. Use the same one. | diff --git a/fpga-imx6-flow/scripts/run_imx6_flow.py b/fpga-imx6-flow/scripts/run_imx6_flow.py index ef3da1e..7d9ab29 100644 --- a/fpga-imx6-flow/scripts/run_imx6_flow.py +++ b/fpga-imx6-flow/scripts/run_imx6_flow.py @@ -95,13 +95,32 @@ def main() -> None: graph.invoke(init, cfg) elif args.command == "resume": payload = json.loads(args.resume_json) if args.resume_json else {} - graph.invoke(Command(resume=payload), cfg) + snap0 = graph.get_state(cfg) + pend = [intr for task in snap0.tasks for intr in task.interrupts] + if len(pend) <= 1: + graph.invoke(Command(resume=payload), cfg) + else: + # Multiple interrupts pending (parallel fan-out, e.g. modify_fpga + + # modify_imx6). payload must be a map keyed by each interrupt's "action" + # (or interrupt id); resume them all at once with an id->value map. + resume_map = {} + for intr in pend: + val = intr.value if isinstance(intr.value, dict) else {} + action = val.get("action") + if action in payload: + resume_map[intr.id] = payload[action] + elif intr.id in payload: + resume_map[intr.id] = payload[intr.id] + else: + resume_map[intr.id] = {} + graph.invoke(Command(resume=resume_map), cfg) # status: no invoke, just snapshot snap = graph.get_state(cfg) except Exception as exc: # noqa: BLE001 _emit({"status": "error", "error": str(exc)}, 1) if snap.next: + all_intr = [intr.value for task in snap.tasks for intr in task.interrupts] _emit({ "status": "interrupt", "thread_id": args.thread_id, @@ -109,6 +128,8 @@ def main() -> None: "next": list(snap.next), "iteration": snap.values.get("iteration"), "interrupt": _pending_interrupt(snap), + "pending_interrupts": all_intr, + "pending_count": len(all_intr), }, 2) else: _emit({ diff --git a/fpga_update/SKILL.md b/fpga_update/SKILL.md new file mode 100644 index 0000000..15fbcd8 --- /dev/null +++ b/fpga_update/SKILL.md @@ -0,0 +1,119 @@ +--- +name: fpga_update +description: 通过网络远程升级 SMOD 板(192.168.1.143)上 ATSC 激励器工程的 FPGA 镜像——先用 Vivado + perl 生成 quickboot 的 _update.mcs,再用 TCP 1234 的私有协议把它流式烧进 SPI flash 的 update 槽。只要用户提到"远程升级"、"升级 mcs"、"把新 bit 升上去"、"update mcs"、"远程更新 FPGA"、"Xfpga_ru"、"生成 quickboot mcs"、"quickboot"、"RU_golden 之后怎么升级"、"192.168.1.143 升级",或者在 my_exciter 工程里跑完综合实现后想把结果固化到板子上,就必须用这个 skill——哪怕他们没说"skill"、没提 mcs,只说了"升上去"、"更新一下 FPGA"、"重新出个 mcs"。注意:这条路和网页上的 Remote Update 完全无关(那个只认 imx6 的 tar.gz 固件包)。JTAG 直烧打底镜像请改用 fpga_flash skill。 +--- + +# SMOD FPGA 远程升级 + +把 `my_exciter` 工程实现出的 `.bit` 变成 quickboot 更新镜像,再通过网络烧进板子的 SPI flash。 + +板子必须**事先用 JTAG 烧过 `RU_golden`** 才支持远程升级 —— golden 提供上电时的兜底镜像和 +IPROG 跳转头。没烧过 golden 就先走 `/fpga_flash`。 + +## 先搞清楚你在走哪条路 + +这是最容易出错的地方,两条路互不相干: + +| | 这个 skill | 网页 Remote Update | +|---|---|---| +| 升什么 | **FPGA 镜像**(`.mcs`) | **imx6 固件包**(`.tar.gz`) | +| 走哪 | TCP **1234**,私有帧协议 | HTTP `upload.cgi` → `check_package.sh` | + +把 `.mcs` 传到网页上不会报错,但 `check_package.sh` 找不到 `*.tar.gz` 就会 +`rm -rf /aw/upgrade/*` 把它删掉,什么都不会发生。原厂的 GUI 工具是 `tools/Xfpga_ru.exe`, +没有命令行接口;`scripts/fpga_ru.py` 是它的等价替代,而且会告诉你失败在第几帧。 + +## 流程 + +### 1. 生成 `_update.mcs` + +```bash +python scripts/gen_update_mcs.py # → bitstream/atsc_exciter_update.mcs +python scripts/gen_update_mcs.py --tag smod_dpd # → bitstream/smod_dpd_update.mcs +``` + +脚本按顺序跑项目里一直在用的那三个脚本(`post_bitstream.tcl` → `runScript_GenRu.tcl` → +`MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64`),然后校验产物的起始地址是 +`0x800000`(这等价于"确实是用 `-imagesize 64` 生成的")。 + +它还替你挡了一个坑:`runScript_GenRu.tcl` 里是 `glob *.bit`,而 `bitstream/` 目录里躺着二十几个 +历史 `.bit`。脚本会把旧的临时挪走、跑完再放回去。**别绕过它去手动跑 tcl**,除非你已经清空了目录。 + +`-imagesize 64` 不要改。它和 `SpiFlashProgrammer.vhd` 里的 `cAddrUpdateStart/End` 是一对, +理由见 `references/quickboot.md`。 + +### 2. 确认 ADPC 为 OFF(保险起见) + +DPD 的 sample&load 和升级数据流共用同一条 SPI1,而 FPGA 在升级期间会把 SPI1 上任何字都当成镜像数据。 +这条路径没被验证过(三次连续升级成功那次 `adpc=0`),所以稳妥起见先关掉: + +```bash +# 登录后读 sys_cfg,第 3 个字段就是 adpc(0=OFF) +curl -s -b "session=$SESS" "http://192.168.1.143/atsc_status_cfg.cgi?sys_cfg=&" +``` + +网页账号是 **`anywavecom` / `anywavecom`**(不是 `root`;`root/anywavecom` 是 SSH 的)。 + +### 3. 烧进去 + +```bash +python scripts/fpga_ru.py --mcs bitstream/atsc_exciter_update.mcs +python scripts/fpga_ru.py --mcs ... --dry-run # 只校验文件,不碰硬件 +``` + +先跑 `--dry-run`。它会检查行长、整行切分、EOF 记录、起始地址 —— 这些错误在真机上要等几分钟 +才会以一个没有上下文的 `ABORT_ERROR` 暴露出来。 + +烧写要几分钟(8 MB,约 47600 帧)。**中途不要断电、不要重试。** 真断了也不会变砖:升级一开始就把 +切换字擦掉了,冷启动会退回 golden。 + +成功后 app 会自动重启 imx6、复位 FPGA。然后**冷启动板子**(遵守 SMOD 上电时序)让 FPGA 从 flash +重新加载新镜像。 + +### 4. 验证 + +抓 ILA、看 app 日志(`/var/module_log/imx_atsc_app.log`)、或者看频谱。 +闭环调试交给 `fpga-imx6-flow` skill。 + +## 排错 + +`fpga_ru.py` 会打印失败在哪一步、哪一帧、收到什么 ack。对着这张表看: + +- **擦除永不结束**(最容易误判的一个)→ 十有八九 programmer 已经报错退出了。 + imx6 的 `POLL_ERASER` **不看 ERROR 位**,把"已失败"当成"还在擦",所以客户端只会一直等。 + 别信进度条,直接读设备日志: + + ```bash + ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.1.143 \ + "grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3" + ``` + + `rstate=0x7`(ERROR=1, DONE=1, EraseOK=0)= programmer 中止;`rstate=0x0` 才是真的在擦, + 擦完会看到 `rstate=0xc` + `UPSTATE_EARSE_END`。 + 历史上这里的元凶是 `smod_top.v` 没把 flash 的 SPI 引脚接出去,见 `references/protocol.md`。 +- **`ABORT_ERROR` 出现在第一帧** → mcs 格式不对,先跑 `--dry-run`。 +- **`ABORT_ERROR` 出现在数据流中后段** → 怀疑 SPI1 上有别人(DPD)在写,把 ADPC 关掉重试。 +- **没有应答 / 超时** → app 的 `sys_ready` 还是 0,或者 app 没在跑。 + `ssh root@192.168.1.143 'ps | grep atsc'`(SSH 密码 `anywavecom`,dropbear 老得很, + 命令行 ssh 要加 `-o HostKeyAlgorithms=+ssh-rsa`)。 + +> 别用 `python ssh.py "tail -f ..."` 之类一次性读到 EOF 的封装去跟日志 —— 它永远不返回, +> 什么也抓不到。升级完再 grep 日志文件即可,`log_printf` 已经落盘。 + +## 不碰硬件地改协议客户端 + +`scripts/mock_device.py` 是照着 `tcp_task.c` 的状态机实现的假设备,包括那三个硬限制和 +`':'` 重切分行为。改完 `fpga_ru.py` 先拿它跑一遍: + +```bash +python scripts/mock_device.py --port 12340 & +python scripts/fpga_ru.py --mcs <某个>_update.mcs --host 127.0.0.1 --port 12340 --yes +``` + +正确的一次会让 mock 收到**恰好 8388608 字节(8.000 MiB)**—— 正是 `cAddrUpdateEnd - cAddrUpdateStart`。 +对不上就是客户端的分帧错了。`--not-ready-every N` / `--abort-at-frame N` 可以逼出重传和中止路径。 + +## 参考 + +- `references/protocol.md` —— TCP 1234 帧格式、时序、三个硬限制、DPD 污染缺陷的完整证据链 +- `references/quickboot.md` —— flash 布局、切换字兜底、为什么是 `-imagesize 64`、两条烧写路径的区别 diff --git a/fpga_update/references/protocol.md b/fpga_update/references/protocol.md new file mode 100644 index 0000000..9fcd6ba --- /dev/null +++ b/fpga_update/references/protocol.md @@ -0,0 +1,137 @@ +# FPGA 远程升级协议(TCP 1234) + +## 首先:这条路和网页升级毫无关系 + +设备网页 `sys_set.html` 的 **Remote Update** 框是给 **imx6 固件包**用的,不是给 FPGA 用的。 +把 `.mcs` 传上去等于扔进垃圾桶: + +``` +upload.cgi → 存到 /aw/upgrade/ → 通知 app(WEB_UPGRADE) +app: start_upgrade() → set_upgrade_flg(1) → imx_up_thr → check_package.sh +check_package.sh: find /aw/upgrade -name '*.tar.gz' ← 只认 tar.gz + 找不到 → ret=3 → rm -rf /aw/upgrade/* ← 你的 mcs 被删了 +``` + +(`start_upgrade()` 还会在 `ctrl==CTRL_LOCAL` 时直接 return 0 —— 本地模式下网页升级本就不生效。) + +FPGA 走的是**另一条独立的路**:app 里的 `fpga_upgrade_task()` 在 **TCP 1234** 上起了个 server +(`inc/config.h: UPGRADE_LISTEN_PORT 1234`),跑私有帧协议。原厂用 `tools/Xfpga_ru.exe` 驱动它 +(一个 16 KB 的 .NET GUI,没有命令行)。`scripts/fpga_ru.py` 就是它的替代品。 + +服务端权威实现:`my_exciter/imx6/src/atsc_app/task/tcp_task.c` :: `fpga_upgrade_process()`。 + +## 帧格式 + +``` +请求 AA | cmd | datalen:u16 小端 | payload | sum | BB +应答 AA | ack | 01 | 00 | ack | sum | BB (固定 7 字节) +sum 从帧头到 sum 之前所有字节的 8-bit 累加和(get_check_sum,见 bsp/general_api.c) +``` + +服务端校验 `rxdata[0]==0xAA && rxdata[datalen+5]==0xBB`,再比对 `get_check_sum(rxdata, datalen+4)`。 +应答里 ack 同时出现在 `[1]` 和 `[4]`;校验和错时 `[1]` 被改写,所以**读 `[4]`**。 + +| cmd | 值 | ack | 值 | +|---|---|---|---| +| `UPGRADE_START` | 0x01 | `START_OK` | 0x01 | +| `POLL_ERASER` | 0x02 | `ERASER_ING` / `ERASER_END` | 0x02 / 0x03 | +| `W_DATA_BLOCK` | 0x03 | `READY` / `NOT_READY` | 0x04 / 0x05 | +| `W_DATA_BLOCK_LAST` | 0x04 | `ABORT_SUCCESS` / `ABORT_ERROR` | 0x06 / 0x07 | + +## 时序 + +1. `UPGRADE_START`(空负载)→ `START_OK`。 + 服务端往 FPGA 寄存器 `0xE0` 写 `RESET_EN(bit0)=1` 再写 0,放开 `SpiFlashProgrammer`。 +2. 轮询 `POLL_ERASER` 直到 `ERASER_END`。擦 8 MB 要几十秒。 +3. 逐帧 `W_DATA_BLOCK`,负载是 **mcs 的原始 ASCII 文本行**。 + - `READY` → 发下一帧 + - `NOT_READY` → 等 1 ms **重发同一帧** +4. 负载里出现 EOF 记录(`:00000001FF`)后,改发 `W_DATA_BLOCK_LAST`(空负载)轮询, + 直到 `ABORT_SUCCESS` 或 `ABORT_ERROR`。服务端最多让你轮 100 次。 +5. `ABORT_SUCCESS` 后 app 调 `reset_fpga_system()` 并置 `fpga_up_sys_reboot=1` —— **imx6 会重启, + 连接会断**。这是正常的,不是错误。 + +## 三个会把你坑死的硬限制 + +都在 `recv_upgrade_data_parse()` / `inc/tcp_task.h` 里,超了就 `ABORT_ERROR`: + +1. **负载 ≤ 538 字节**。`rxdata[MAX_DATA_LEN=544]` 装的是**整帧**,帧头尾占 6 字节。 +2. **每行长度必须在 `[11, 50]` 字节之间**(`UPDATA_RAW_FRAME_MIN/MAX_LEN`),**含行尾 CRLF**。 + 实际 mcs 只有三种行长:17(ELA)、45(数据)、13(EOF),都合法。 +3. **一帧里必须是整行**。服务端不看换行符,它用 `strchr(payload, ':')` 重新切分, + 最后一行按 `strlen` 量到负载末尾。**一行跨两帧 = 两行畸形行 = 直接中止。** + +另外服务端一次 `lwip_recv()` 读一帧,所以客户端要一次 `send()` 发完整帧,并关掉 Nagle。 + +数据行 `:10 AAAA 00 <32 hex> CC` 里只有那 16 个数据字节被取走;地址和 ELA 记录全被忽略 +(详见 `references/quickboot.md`)。8 MB 镜像 = 524288 行 × 16 B,按 11 行/帧 → 约 47663 帧。 + +## 曾经的"第二次远程升级必失败" —— 已定位并修复 + +**症状**:JTAG 烧完 RU_golden 后第一次远程升级成功;冷启动后再升级第二次,卡在擦除阶段永不结束。 + +**根因**:`smod_top.v` 里 flash 的三根 SPI 线**根本没接出去**。 + +```verilog +// 修复前 —— 只是内部 wire,MISO 还被钉死成常量 +wire outSpiCsB, outSpiMosi; +wire inSpiMiso = 1'b1; + +// RU_golden_top.v 一直是对的 +output outSpiCsB ; +output outSpiMosi ; +input inSpiMiso ; +``` + +`smod_ioplanning.xdc` 和 `timing.xdc` 早就把 `outSpiCsB`(P18) / `outSpiMosi`(R14) / `inSpiMiso`(R15) +约束好了,但顶层没有这几个端口,于是 Vivado 只是丢了三条 CRITICAL WARNING 就继续: + +``` +CRITICAL WARNING: [Vivado 12-4739] set_output_delay:No valid object(s) found for '[get_ports outSpiCsB]' +``` + +后果:my_exciter 里的 `SpiFlashProgrammer` 片选和 MOSI 悬空、MISO 恒为 1,读 flash ID 得到全 `0xFF`, +**在擦除之前就报错退出**。golden 的引脚是接好的,所以由 golden 执行的那一次升级永远成功 —— +两次升级执行擦写的是**不同 bitstream 里的 programmer**,这正是"第一次成、第二次败"的由来。 + +**修复**:把三个信号提升为 `smod_top` 的顶层端口(已改)。实测 `rstate` 从 +`0x7`(ERROR=1, DONE=1, EraseOK=0)变为 `0x0` → `0xc`(Ready=1, EraseOK=1),擦除正常结束。 + +## ⚠ imx6 缺陷:`POLL_ERASER` 不看 ERROR 位 + +上面那个 FPGA bug 之所以表现为"卡死"而不是"报错",是因为 `tcp_task.c` 的擦除轮询只判断 `EARSE_OK`: + +```c +rstate = fpga_byte_read(0x01, FPGA_UPGRADE_CTRL_ADDR); +if (rstate & FPGA_UP_STATUS_EARSE_OK_MASK) ret_data = ERASER_END; +else ret_data = ERASER_ING; // ERROR 位被无视 +``` + +于是"已经失败"和"还在擦"返回同一个 `ERASER_ING`,客户端只能一直等到超时。原厂那个 +`Xfpga_ru.exe` 就是这样毫无提示地转圈。 + +**这个 bug 还在。** 排查时不要信客户端的"erasing...",直接读设备日志: + +```bash +ssh -o HostKeyAlgorithms=+ssh-rsa root@192.168.1.143 \ + "grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3" +``` + +`rstate=0x7` = programmer 已中止;`rstate=0x0` 才是真的在擦。 + +**根治**:在 `POLL_ERASER` 分支里加 `if (rstate & FPGA_UPGRADE_STATUS_ERROR_MASK) ret_data = ABORT_ERROR;`。 +改完要重新交叉编译并部署 app。 + +## 尚未证实的风险:DPD 与升级数据流共用 SPI1 + +这一条**不是**上面那个 bug 的原因,但结构上确实存在,写在这里以免以后踩: + +- FPGA 侧升级期间 `inReset_EnableB==0`,`spi_slave32` 常开,SPI1 上任何 32-bit 字都会被当成镜像数据。 + `smod_top.v` 自己的注释:`// when doing dpd S&L, MISO is spi read pin`。 +- imx6 侧 DPD 的 `ispi_tx_big_block()`(`bsp/dpd_proc.c`)和升级的 `ispi_fpga_send_buf_4Byte()` + **用同一个 `fpga_spi_fd`**(`bsp/isys_config.c`)。 +- 互斥量 `g_now_only_upgrade_fpga` 只在 `lcd_task.c` 一处被读,且在 `ERASER_END` 才置位。 + 另一个标志 `get_upgrade_flg()` 只有网页升级会置位。 + +实测三次连续升级成功时 `adpc=0`(ADPC_OFF),所以 DPD 的 S&L 没有在跑 —— **这条路径从未被真正验证过**。 +稳妥起见,升级前把 ADPC 设为 OFF。 diff --git a/fpga_update/references/quickboot.md b/fpga_update/references/quickboot.md new file mode 100644 index 0000000..e62c7e5 --- /dev/null +++ b/fpga_update/references/quickboot.md @@ -0,0 +1,77 @@ +# Flash 布局与 QuickBoot 兜底 + +板上 flash:Winbond **w25q128bv**,128 Mbit = 16 MB,SPIx1,**24-bit 地址**,64 KB sector / 4 KB subsector。 + +## 三个地址,一个都不能记错 + +来自 `my_exciter.srcs/sources_1/quickboot/SpiFlashProgrammer.vhd`(**RU_golden 和 my_exciter 引用的是同一份文件**, +见 `RU_golden.xpr` 里的 `$PPRDIR/../my_exciter/.../SpiFlashProgrammer.vhd`,所以两个工程的常数天然一致): + +```vhdl +cAddrUpdateStart = X"00400000" -- update 镜像起点 +cAddrUpdateEnd = X"00C00000" -- update 镜像终点+1 → 槽宽 8 MB +cAddrSwitchWordN25Q = X"00000FFC" -- 切换字 +cAddrWidth = 24 -- 和 w25q128bv 匹配 +``` + +flash 实际布局(从 `golden_design_initial.mcs` 解码得到,不是推测): + +``` +0x000000..0x000FFB FF FF ... 空 +0x000FFC 00 99 55 66 切换字(AA 被写成 00 = 无效) +0x001000 20000000 NOOP + 30020001 00400000 写 WBSTAR = 0x400000 + 30008001 0000000F 写 CMD = IPROG → 跳到 update 镜像 +0x001050 AA995566 golden bitstream 自己的同步字 + ..0x3FFFFF golden bitstream(RU_golden_top.bit,3.4 MB) +0x400000..0xBFFFFF update 镜像(8 MB,日常远程升级写这里) +0xC00000..0xFFFFFF 未用 +``` + +**兜底机制**:上电时 FPGA 从地址 0 扫描同步字。 +- 切换字 = `AA995566` → 在 0xFFC 同步 → 执行 0x1000 处的 IPROG → 加载 update 镜像。 +- 切换字 ≠ `AA995566`(擦除后是 `FFFFFFFF`)→ 0xFFC 不同步 → 继续扫 → 在 0x1050 撞上 golden 的同步字 → **加载 golden**。 + +远程升级一开始就把切换字所在的 subsector 擦掉(`sProgrammerEraseSwitchWord`,SSE 命令, +擦 `0x000000..0x000FFF`),写完校验通过后再把切换字写回去。所以**升级中途断电只会退回 golden,不会变砖**。 + +> subsector 是 4 KB,擦 0xFFC 只波及 `0x000000..0x000FFF`。IPROG 头在 `0x001000`,属于下一个 +> subsector,**不会被擦到**。golden 是安全的 —— 如果怀疑"升级把 golden 弄坏了",可以排除这条。 + +## 为什么 `-imagesize 64` + +perl 脚本的模型是"golden 和 update 一样大、update 紧跟 golden": +`imageSizeBytes = imagesize(Mbit) × 131072`,`updateStartAddr = imageSizeBytes`。 + +- **golden**(`MakeSpiFlashProgrammerOnlyInitMcs.pl -imagesize 32`)→ 4 MB → IPROG 里的 + WBSTAR = `0x400000`,和 `cAddrUpdateStart` 对上。日志见 `RU_golden/bitstream/gen_initial.log`。 +- **update**(`MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64`)→ 8 MB → 负载 + `pad(bitstream, 0x7FFFFC) + crc32` 正好 8 MB,和槽宽 `0xC00000-0x400000` 对上。 + +两个 imagesize 不一样是**故意的**(VHDL 注释里的 "Plan B: golden enlarged to 4MB"),别去"统一"它们。 + +`_update.mcs` 里的 Intel-HEX 地址写的是 `0x800000`,看起来和 `cAddrUpdateStart=0x400000` 矛盾 —— +**它不矛盾,因为地址被忽略**。imx6 的 `recv_upgrade_data_parse()` 只从 `:10xxxx00<32 hex>` 记录里抠出 +16 个数据字节,扩展线性地址记录 `:02000004` 直接当"中间控制字段"丢掉。FPGA 从 `cAddrUpdateStart` +顺序写。所以对远程升级而言,`_update.mcs` 只有**负载长度**和**CRC**是有意义的。 + +校验 `_update.mcs` 的起始地址是 `0x800000`,本质上是在校验"这个文件是用 `-imagesize 64` 生成的"。 + +## 两条烧写路径,别搞混 + +| | JTAG 直烧 | 网络远程升级 | +|---|---|---| +| 文件 | `RU_golden/bitstream/golden_design_initial.mcs` | `bitstream/_update.mcs` | +| 工具 | `/fpga_flash` skill(flash 型号 `w25q128bv`) | `scripts/fpga_ru.py` | +| 写到 | `0x000000..0x3FFFFF` | `0x400000..0xBFFFFF` | +| 何时用 | 首次装机、板子变砖、换打底镜像 | 日常迭代 | + +`_update.mcs` **不能**拿去 JTAG 烧写:它只是 8 MB 裸数据 + CRC,没有 bitstream 头。 +反过来 `_initial.mcs` 也不能拿去远程升级。 + +严守上电时序:断电 → 断 USB-Blast → 上电 → 连 USB-Blast → 烧写 → 断电 → 断 USB-Blast → 上电 → 连 USB-Blast。 + +## 调试期的第三条路 + +`program_device` 直接把 `.bit` 下到 FPGA,掉电即失,不碰 flash,配 `.ltx` 还能抓 ILA。 +迭代验证用它最快,功能确认后再走远程升级固化。固化用的 bit 要**去掉 ILA 重新实现**。 diff --git a/fpga_update/scripts/fpga_ru.py b/fpga_update/scripts/fpga_ru.py new file mode 100644 index 0000000..4f0eec3 --- /dev/null +++ b/fpga_update/scripts/fpga_ru.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Remote-update the SMOD FPGA by streaming a quickboot _update.mcs to the imx6. + +This is a Python reimplementation of what ``tools/Xfpga_ru.exe`` (a .NET GUI, no CLI) +does. The device side is ``my_exciter/imx6/src/atsc_app/task/tcp_task.c`` :: +``fpga_upgrade_task``, listening on TCP 1234. It parses the MCS text we send and +pokes the bytes into the FPGA's SpiFlashProgrammer, which erases and writes the +update half of the SPI flash by itself. + +This has nothing to do with the web page's "Remote Update" box -- that one runs +check_package.sh, which only accepts *.tar.gz imx6 firmware bundles and deletes +anything else it finds. See references/protocol.md. + +Wire format (all values from tcp_task.c / inc/tcp_task.h):: + + request AA | cmd | datalen:u16le | payload | sum | BB + response AA | ack | 01 | 00 | ack | sum | BB (7 bytes) + sum 8-bit sum of every byte from the head up to (not incl.) sum + +Usage: + python fpga_ru.py --mcs .../bitstream/atsc_exciter_update.mcs + python fpga_ru.py --mcs ... --host 192.168.1.143 --yes + python fpga_ru.py --mcs ... --dry-run # parse + frame, touch no hardware +""" + +import argparse +import os +import re +import socket +import sys +import time + +# ------------------------------------------------------------------ protocol constants + +HEAD, TAIL = 0xAA, 0xBB + +UPGRADE_START, POLL_ERASER, W_DATA_BLOCK, W_DATA_BLOCK_LAST = 0x01, 0x02, 0x03, 0x04 + +ACK = { + 0x01: "START_OK", + 0x02: "ERASER_ING", + 0x03: "ERASER_END", + 0x04: "READY", + 0x05: "NOT_READY", + 0x06: "ABORT_SUCCESS", + 0x07: "ABORT_ERROR", +} +START_OK, ERASER_ING, ERASER_END = 0x01, 0x02, 0x03 +READY, NOT_READY, ABORT_SUCCESS, ABORT_ERROR = 0x04, 0x05, 0x06, 0x07 + +# rxdata[] on the device is MAX_DATA_LEN = 544 bytes and holds the *whole* frame, +# so the payload can never exceed 544 - 6 bytes of framing. Going over this silently +# truncates the frame and the tail check fails. +MAX_FRAME = 544 +FRAME_OVERHEAD = 6 +MAX_PAYLOAD = MAX_FRAME - FRAME_OVERHEAD # 538 + +# recv_upgrade_data_parse() rejects any line outside this range with -2 -> ABORT_ERROR. +LINE_MIN, LINE_MAX = 11, 50 + +UPDATE_START_ADDR = 0x800000 +ELA_RE = re.compile(rb"^:02000004([0-9A-Fa-f]{4})") +EOF_RECORD = b":00000001FF" + + +# ---------------------------------------------------------------------------- framing + +def build(cmd, payload=b""): + body = bytes([HEAD, cmd]) + len(payload).to_bytes(2, "little") + payload + return body + bytes([sum(body) & 0xFF, TAIL]) + + +def parse_ack(resp): + if len(resp) != 7 or resp[0] != HEAD or resp[6] != TAIL: + raise ProtocolError(f"malformed response: {resp.hex(' ')}") + return resp[4] + + +class ProtocolError(RuntimeError): + pass + + +class Device: + def __init__(self, host, port, timeout): + self.sock = socket.create_connection((host, port), timeout=timeout) + # Every frame is a request/response round trip; Nagle would just add latency. + self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + def xfer(self, cmd, payload=b"", timeout=None): + if timeout is not None: + self.sock.settimeout(timeout) + self.sock.sendall(build(cmd, payload)) + try: + resp = self.sock.recv(16) + except socket.timeout: + raise ProtocolError( + f"no response to cmd 0x{cmd:02X} within {self.sock.gettimeout()}s.\n" + " The app returns an empty reply (and we block) when sys_ready==0 or the\n" + " frame was malformed. Check the app is up: ssh root@ 'ps | grep atsc'" + ) + if not resp: + raise ProtocolError("device closed the connection") + return parse_ack(resp) + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +# ------------------------------------------------------------------------ mcs handling + +def load_chunks(path, max_payload, allow_any_image): + """Split the MCS into whole-line payloads. + + Lines must stay whole: the device re-splits our payload on ':' and measures each + line's length itself, so a line straddling two frames is parsed as two malformed + lines and aborts the upgrade. + """ + with open(path, "rb") as fh: + lines = fh.readlines() + if not lines: + raise SystemExit(f"[FAIL] {path} is empty") + + first_ela = None + for line in lines: + m = ELA_RE.match(line) + if m: + first_ela = int(m.group(1), 16) << 16 + break + if first_ela != UPDATE_START_ADDR and not allow_any_image: + got = "none" if first_ela is None else f"0x{first_ela:X}" + raise SystemExit( + f"[FAIL] {os.path.basename(path)} starts at {got}, expected 0x{UPDATE_START_ADDR:X}.\n" + " That is not a quickboot _update.mcs. Uploading a base or _initial mcs\n" + " writes garbage into the update slot; the board then falls back to golden\n" + " on the next cold boot. Regenerate with scripts/gen_update_mcs.py.\n" + " (Override with --allow-any-image if you really mean it.)" + ) + + for n, line in enumerate(lines, 1): + if not line.startswith(b":"): + raise SystemExit(f"[FAIL] line {n} does not start with ':' -- not an Intel-HEX mcs") + if not (LINE_MIN <= len(line) <= LINE_MAX): + raise SystemExit( + f"[FAIL] line {n} is {len(line)} bytes (incl EOL); the device only accepts " + f"{LINE_MIN}..{LINE_MAX} and aborts otherwise" + ) + if not lines[-1].startswith(EOF_RECORD): + raise SystemExit(f"[FAIL] last line is {lines[-1]!r}, expected the EOF record {EOF_RECORD.decode()}") + + chunks, cur = [], [] + size = 0 + for line in lines: + if size + len(line) > max_payload and cur: + chunks.append(b"".join(cur)) + cur, size = [], 0 + cur.append(line) + size += len(line) + if cur: + chunks.append(b"".join(cur)) + return chunks, len(lines) + + +# ------------------------------------------------------------------------------ stages + +def do_erase(dev, timeout): + print("[2/4] erasing the update image region (this is the slow part)") + ack = dev.xfer(UPGRADE_START, timeout=30) + if ack != START_OK: + raise ProtocolError(f"UPGRADE_START -> {ACK.get(ack, hex(ack))}, expected START_OK") + + deadline = time.monotonic() + timeout + dots = 0 + while time.monotonic() < deadline: + ack = dev.xfer(POLL_ERASER, timeout=30) + if ack == ERASER_END: + print("\n erase complete") + return + if ack != ERASER_ING: + raise ProtocolError(f"POLL_ERASER -> {ACK.get(ack, hex(ack))}") + dots += 1 + print(f"\r erasing... {dots * 0.5:.0f}s", end="", flush=True) + time.sleep(0.5) + raise ProtocolError( + f"flash still erasing after {timeout}s.\n" + " POLL_ERASER only ever reports ERASER_ING/ERASER_END -- the app never checks the\n" + " ERROR bit, so a programmer that aborted looks identical to one that is still busy.\n" + " Read the real status on the device:\n" + " ssh root@ \"grep 'ing|end' /var/module_log/imx_atsc_app.log | tail -3\"\n" + " rstate=0x7 (ERROR|DONE, EraseOK=0) means SpiFlashProgrammer aborted immediately --\n" + " the running design cannot reach the flash. See references/protocol.md.") + + +def do_write(dev, chunks, total_lines, retries): + print(f"[3/4] streaming {total_lines} mcs lines in {len(chunks)} frames") + sent_lines = 0 + t0 = time.monotonic() + for i, chunk in enumerate(chunks): + for attempt in range(retries + 1): + ack = dev.xfer(W_DATA_BLOCK, chunk, timeout=60) + if ack == READY: + break + if ack == NOT_READY: + # Device asks us to re-send this exact block after a short pause. + time.sleep(0.001) + continue + if ack in (ABORT_SUCCESS, ABORT_ERROR): + return ack + raise ProtocolError(f"W_DATA_BLOCK -> {ACK.get(ack, hex(ack))}") + else: + raise ProtocolError(f"frame {i} still NOT_READY after {retries} retries") + + sent_lines += chunk.count(b"\n") + if i % 2000 == 0 or i == len(chunks) - 1: + pct = 100.0 * sent_lines / total_lines + rate = sent_lines / max(time.monotonic() - t0, 1e-6) + eta = (total_lines - sent_lines) / max(rate, 1e-6) + print(f"\r {pct:5.1f}% {sent_lines}/{total_lines} lines ETA {eta/60:.1f} min", + end="", flush=True) + print() + return None + + +def do_finish(dev, timeout): + print("[4/4] waiting for the FPGA to report the write is done") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + ack = dev.xfer(W_DATA_BLOCK_LAST, timeout=30) + except (ProtocolError, ConnectionResetError, OSError) as exc: + # On success the app calls reset_fpga_system() and reboots the imx6, so the + # socket dying right here is expected -- but we can no longer read the ack. + raise ProtocolError(f"link dropped while waiting for the final ack: {exc}") + if ack in (ABORT_SUCCESS, ABORT_ERROR): + return ack + if ack != READY: + raise ProtocolError(f"W_DATA_BLOCK_LAST -> {ACK.get(ack, hex(ack))}") + time.sleep(0.2) + raise ProtocolError(f"no final verdict after {timeout}s") + + +# -------------------------------------------------------------------------------- main + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--mcs", required=True, help="path to the quickboot _update.mcs") + ap.add_argument("--host", default="192.168.1.143") + ap.add_argument("--port", type=int, default=1234) + ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt") + ap.add_argument("--dry-run", action="store_true", help="validate and frame the mcs, contact nothing") + ap.add_argument("--allow-any-image", action="store_true", + help="skip the 0x800000 start-address check (you almost never want this)") + ap.add_argument("--max-payload", type=int, default=MAX_PAYLOAD) + ap.add_argument("--erase-timeout", type=float, default=600) + ap.add_argument("--finish-timeout", type=float, default=120) + ap.add_argument("--retries", type=int, default=200) + args = ap.parse_args() + + if not 0 < args.max_payload <= MAX_PAYLOAD: + raise SystemExit(f"[FAIL] --max-payload must be 1..{MAX_PAYLOAD} (device rxdata is {MAX_FRAME} bytes)") + + print(f"[1/4] validating {args.mcs}") + chunks, total_lines = load_chunks(args.mcs, args.max_payload, args.allow_any_image) + mb = os.path.getsize(args.mcs) / 1e6 + print(f" {mb:.1f} MB, {total_lines} lines, {len(chunks)} frames, starts at 0x{UPDATE_START_ADDR:X}") + + if args.dry_run: + print("[dry-run] mcs is well-formed and would stream cleanly. Nothing was sent.") + return + + if not args.yes: + print(f"\nAbout to erase and rewrite the update image on {args.host}.") + print("The golden image is untouched, so a failure here is recoverable by cold boot.") + print("Do NOT power the board off while this runs.") + print("\nMake sure DPD is OFF. The exciter's DPD sample&load writes to the same SPI1 bus\n" + "we stream the image over, and the FPGA's spi_slave32 will happily fold those words\n" + "into the flash image. See references/protocol.md.") + if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"): + raise SystemExit("aborted") + + dev = Device(args.host, args.port, timeout=30) + try: + do_erase(dev, args.erase_timeout) + verdict = do_write(dev, chunks, total_lines, args.retries) + if verdict is None: + verdict = do_finish(dev, args.finish_timeout) + finally: + dev.close() + + if verdict == ABORT_SUCCESS: + print("\n[OK] FPGA reports the update image was written and verified.") + print("The app has reset the FPGA and is rebooting the imx6.") + print("Now cold-boot the board (observe the SMOD power-up sequence) to load the new image.") + return + raise SystemExit( + f"\n[FAIL] device returned {ACK.get(verdict, hex(verdict))}.\n" + "The update slot is now half-written, but the switch word was erased at the start,\n" + "so the board still cold-boots golden. Nothing is bricked.\n\n" + "If this was ABORT_ERROR late in the stream, suspect SPI1 contention: the exciter's\n" + "DPD sample&load shares fpga_spi_fd with this upload, and the FPGA folds any SPI1 word\n" + "into the image. Turn DPD off and retry. references/protocol.md has the full story.") + + +if __name__ == "__main__": + main() diff --git a/fpga_update/scripts/gen_update_mcs.py b/fpga_update/scripts/gen_update_mcs.py new file mode 100644 index 0000000..7ac6609 --- /dev/null +++ b/fpga_update/scripts/gen_update_mcs.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Build the quickboot _update.mcs for the SMOD ATSC exciter. + +Runs exactly the three scripts the project has always used, in order: + + 1. tcl_1/post_bitstream.tcl impl_1/*.bit,.bin,.ltx -> bitstream/ + 2. tcl_1/runScript_GenRu.tcl write_cfgmem -size 64 -interface SPIx1 + -> bitstream/atsc_exciter.mcs + 3. tcl_1/MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64 atsc_exciter.mcs + -> bitstream/atsc_exciter_update.mcs + +Step 2 does ``glob *.bit`` inside bitstream/, so every stale .bit sitting there gets +concatenated into one giant image. bitstream/ accumulates iteration bits, so this +script moves the old ones aside for the duration and puts them back afterwards. + +The resulting _update.mcs is ONLY for the network remote update (scripts/fpga_ru.py). +It cannot be JTAG-flashed -- see references/quickboot.md. +""" + +import argparse +import glob +import os +import re +import shutil +import subprocess +import sys +import tempfile + +REPO = r"E:\Xilinx_design\smod\SMOD_ATSC\my_exciter" +TCL_DIR = os.path.join(REPO, "my_exciter.srcs", "tcl_1") +BITSTREAM = os.path.join(REPO, "bitstream") +IMPL = os.path.join(REPO, "my_exciter.runs", "impl_1") + +POST_BITSTREAM = os.path.join(TCL_DIR, "post_bitstream.tcl") +GEN_RU = os.path.join(TCL_DIR, "runScript_GenRu.tcl") +PERL_SCRIPT = os.path.join(TCL_DIR, "MakeSpiFlashProgrammerMcsFiles_backup.pl") +DEFAULT_VIVADO = r"D:\Xilinx\Vivado\2018.1\bin\vivado.bat" + +# runScript_GenRu.tcl hardcodes this name. +BASE_MCS = "atsc_exciter.mcs" +UPDATE_MCS = "atsc_exciter_update.mcs" + +# The perl script emits these alongside the one we want. +BYPRODUCTS = ("atsc_exciter_initial.mcs", "atsc_exciter_golden.mcs", "atsc_exciter_total.mcs", + "atsc_exciter_update2.mcs", "atsc_exciter_simple.mcs") + +# ":02000004HHHH" is the Intel-HEX extended-linear-address record. +ELA_RE = re.compile(rb"^:02000004([0-9A-Fa-f]{4})") +EXPECT_ELA = 0x800000 + + +def sh(cmd, cwd=None): + print(f" $ {' '.join(cmd)}", flush=True) + if subprocess.run(cmd, cwd=cwd).returncode != 0: + sys.exit("[FAIL] command failed; see output above") + + +def first_ela(path): + with open(path, "rb") as fh: + for line in fh: + m = ELA_RE.match(line) + if m: + return int(m.group(1), 16) << 16 + return None + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--vivado", default=DEFAULT_VIVADO) + ap.add_argument("--tag", help="rename the result to _update.mcs so it survives the next run") + ap.add_argument("--keep-byproducts", action="store_true") + args = ap.parse_args() + + for path, what in ((args.vivado, "vivado"), (POST_BITSTREAM, "post_bitstream.tcl"), + (GEN_RU, "runScript_GenRu.tcl"), (PERL_SCRIPT, "perl script")): + if not os.path.exists(path): + sys.exit(f"[FAIL] {what} not found: {path}") + + impl_bits = glob.glob(os.path.join(IMPL, "*.bit")) + if len(impl_bits) != 1: + sys.exit(f"[FAIL] expected exactly one .bit in {IMPL}, found {len(impl_bits)}: " + f"{[os.path.basename(b) for b in impl_bits]}") + print(f"[1/4] implementation bit: {os.path.basename(impl_bits[0])}") + + # Park every stale .bit so runScript_GenRu.tcl's `glob *.bit` can only see the fresh one. + stash = tempfile.mkdtemp(prefix="smod_bit_stash_") + parked = glob.glob(os.path.join(BITSTREAM, "*.bit")) + for b in parked: + shutil.move(b, os.path.join(stash, os.path.basename(b))) + if parked: + print(f"[2/4] parked {len(parked)} stale .bit files from bitstream/ (restored at the end)") + + try: + # One vivado launch, both tcl scripts: each re-derives its own path from [info script]. + wrapper = os.path.join(stash, "run_both.tcl") + with open(wrapper, "w") as fh: + fh.write(f'source {{{POST_BITSTREAM}}}\n') + fh.write(f'source {{{GEN_RU}}}\n') + print(f"[3/4] vivado: post_bitstream.tcl + runScript_GenRu.tcl -> {BASE_MCS}") + sh([args.vivado, "-mode", "batch", "-nojournal", "-notrace", "-source", wrapper], cwd=stash) + + base = os.path.join(BITSTREAM, BASE_MCS) + if not os.path.exists(base): + sys.exit(f"[FAIL] {base} was not produced") + + print(f"[4/4] perl MakeSpiFlashProgrammerMcsFiles_backup.pl -imagesize 64 {BASE_MCS}") + sh(["perl", PERL_SCRIPT, "-imagesize", "64", BASE_MCS], cwd=BITSTREAM) + finally: + for name in os.listdir(stash): + if not name.endswith(".bit"): + continue + dest = os.path.join(BITSTREAM, name) + # post_bitstream.tcl just copied the fresh impl_1 bit here under this same + # name; putting the parked (stale) one back would silently undo that. + if not os.path.exists(dest): + shutil.move(os.path.join(stash, name), dest) + shutil.rmtree(stash, ignore_errors=True) + + update = os.path.join(BITSTREAM, UPDATE_MCS) + if not os.path.exists(update): + sys.exit(f"[FAIL] {UPDATE_MCS} was not produced") + + # -imagesize 64 must place the image at 0x800000. If it doesn't, the payload is not + # 8 MB and the FPGA's CRC check over 0x400000..0xC00000 will fail after a long upload. + ela = first_ela(update) + if ela != EXPECT_ELA: + got = "none" if ela is None else f"0x{ela:X}" + sys.exit(f"[FAIL] {UPDATE_MCS} starts at {got}, expected 0x{EXPECT_ELA:X}. Do not upload it.") + + if not args.keep_byproducts: + for name in BYPRODUCTS: + p = os.path.join(BITSTREAM, name) + if os.path.exists(p): + os.remove(p) + + if args.tag: + tagged = os.path.join(BITSTREAM, f"{args.tag}_update.mcs") + shutil.move(update, tagged) + update = tagged + + print(f"\n[OK] {update} ({os.path.getsize(update)/1e6:.1f} MB)") + print(f" base mcs kept at {os.path.join(BITSTREAM, BASE_MCS)}") + print("\nNext: python scripts/fpga_ru.py --mcs \"%s\"" % update) + print(" Turn DPD off first -- see references/protocol.md.") + + +if __name__ == "__main__": + main() diff --git a/fpga_update/scripts/mock_device.py b/fpga_update/scripts/mock_device.py new file mode 100644 index 0000000..ef194f4 --- /dev/null +++ b/fpga_update/scripts/mock_device.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""A fake SMOD imx6 that speaks the FPGA remote-update protocol on TCP 1234. + +Mirrors ``fpga_upgrade_process()`` in tcp_task.c as literally as possible, including +the parts that bite: the ':'-based re-splitting of our payload, the 11..50 byte line +limit, and the READY-then-ABORT_SUCCESS handshake at the end. + +The point is to be able to exercise fpga_ru.py end-to-end without erasing a real +flash. If a change to fpga_ru.py passes against this and against the real board, +the two agree; if it passes here but not there, this file is the thing that's wrong. + + python mock_device.py --port 12340 + python fpga_ru.py --mcs some_update.mcs --host 127.0.0.1 --port 12340 --yes + +Fault injection, to prove the client's retry/abort paths actually work: + --not-ready-every N answer NOT_READY to every Nth data frame + --erase-polls N answer ERASER_ING to the first N POLL_ERASER frames + --abort-at-frame N answer ABORT_ERROR on the Nth data frame +""" + +import argparse +import socket +import sys + +HEAD, TAIL = 0xAA, 0xBB +UPGRADE_START, POLL_ERASER, W_DATA_BLOCK, W_DATA_BLOCK_LAST = 0x01, 0x02, 0x03, 0x04 +START_OK, ERASER_ING, ERASER_END = 0x01, 0x02, 0x03 +READY, NOT_READY, ABORT_SUCCESS, ABORT_ERROR = 0x04, 0x05, 0x06, 0x07 + +LINE_MIN, LINE_MAX = 11, 50 +MAX_DATA_LEN = 544 + + +def reply(ack): + tx = bytes([HEAD, ack, 1, 0, ack]) + return tx + bytes([sum(tx) & 0xFF, TAIL]) + + +def split_lines_like_c(payload): + """Reproduce the strchr(':') walk in W_DATA_BLOCK. + + The device never looks at newlines: it finds the first ':', then each following + ':' ends the previous line. The final line runs to the NUL terminator, which is + why a payload that ends mid-line silently mis-measures that line. + """ + out = [] + start = payload.find(b":") + if start < 0: + return None + while True: + nxt = payload.find(b":", start + 1) + out.append(payload[start:] if nxt < 0 else payload[start:nxt]) + if nxt < 0: + return out + start = nxt + + +def parse_line(line): + """recv_upgrade_data_parse(): -2 bad length, -1 EOF record, 16 data bytes, 0 control.""" + if not (LINE_MIN <= len(line) <= LINE_MAX): + return -2 + length = int(line[1:3], 16) + rectype = int(line[7:9], 16) + if length == 0x10 and rectype == 0x00: + return 16 + if length == 0x00 and rectype == 0x01: + return -1 + return 0 + + +class Mock: + def __init__(self, args): + self.args = args + self.bytes_written = 0 + self.data_frames = 0 + self.erase_polls = 0 + self.file_end = False + self.done_polls = 0 + + def on_frame(self, cmd, payload): + if cmd == UPGRADE_START: + self.bytes_written = 0 + self.erase_polls = 0 + self.file_end = False + return START_OK + + if cmd == POLL_ERASER: + self.erase_polls += 1 + return ERASER_ING if self.erase_polls <= self.args.erase_polls else ERASER_END + + if cmd == W_DATA_BLOCK: + self.data_frames += 1 + if self.args.abort_at_frame and self.data_frames == self.args.abort_at_frame: + return ABORT_ERROR + if self.args.not_ready_every and self.data_frames % self.args.not_ready_every == 0: + return NOT_READY # client must re-send this exact frame + + lines = split_lines_like_c(payload) + if lines is None: + return ABORT_ERROR + ack = READY + for line in lines: + n = parse_line(line) + if n == -2: + print(f" [mock] ABORT: bad line ({len(line)} bytes) {line[:20]!r}", file=sys.stderr) + return ABORT_ERROR + if n == -1: + self.file_end = True + ack = READY # check_fpga_upgrade_done() is not DONE yet + elif n > 0: + self.bytes_written += n + return ack + + if cmd == W_DATA_BLOCK_LAST: + self.done_polls += 1 + if self.done_polls < self.args.done_polls: + return READY + return ABORT_SUCCESS if self.file_end else ABORT_ERROR + + return ABORT_ERROR + + def serve_one(self, conn): + buf = b"" + while True: + data = conn.recv(MAX_DATA_LEN) + if not data: + return + buf += data + # The real device does a single recv() per frame; we tolerate splits so a + # client bug shows up as a protocol error rather than a hang. + while len(buf) >= 6: + datalen = int.from_bytes(buf[2:4], "little") + total = datalen + 6 + if len(buf) < total: + break + frame, buf = buf[:total], buf[total:] + if frame[0] != HEAD or frame[-1] != TAIL: + print(f" [mock] bad framing {frame[:4].hex(' ')}", file=sys.stderr) + return + if sum(frame[:datalen + 4]) & 0xFF != frame[datalen + 4]: + conn.sendall(reply(NOT_READY)) + continue + conn.sendall(reply(self.on_frame(frame[1], frame[4:4 + datalen]))) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--port", type=int, default=12340) + ap.add_argument("--erase-polls", type=int, default=2) + ap.add_argument("--done-polls", type=int, default=1) + ap.add_argument("--not-ready-every", type=int, default=0) + ap.add_argument("--abort-at-frame", type=int, default=0) + args = ap.parse_args() + + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", args.port)) + srv.listen(1) + print(f"[mock] listening on 127.0.0.1:{args.port}", flush=True) + + conn, _ = srv.accept() + mock = Mock(args) + try: + mock.serve_one(conn) + finally: + conn.close() + mib = mock.bytes_written / (1 << 20) + print(f"[mock] {mock.data_frames} data frames, {mock.bytes_written} bytes ({mib:.3f} MiB), " + f"file_end={mock.file_end}") + # A correct run streams exactly the 8 MiB update image. + sys.exit(0 if mock.file_end else 1) + + +if __name__ == "__main__": + main() diff --git a/smod_atsc_dev/SKILL.md b/smod_atsc_dev/SKILL.md new file mode 100644 index 0000000..0813494 --- /dev/null +++ b/smod_atsc_dev/SKILL.md @@ -0,0 +1,151 @@ +--- +name: smod_atsc_dev +description: > + 在 SMOD 硬件平台(Artix-7 xc7a200t + i.MX6 + ADRV9009)上开发、调试、固化 ATSC 激励器: + 改 RTL 并用 vivado-mcp 综合实现、在 Ubuntu VM 上交叉编译 imx6 的 atsc_app 并部署、 + 用 ILA/串口/频谱做闭环验证。(生成 quickboot .mcs 和远程升级 FPGA 转交 fpga_update skill。) + + 只要用户提到 SMOD、my_exciter、RU_golden、smod_top、atsc_app、ATSC 激励器、8VSB 调制、 + ADRV9009/ORx/DPD/TxAGC、tuner(Sony CXD2878)、TSoIP、quickboot、远程升级 mcs、192.168.1.143、 + 或在 E:\Xilinx_design\smod\SMOD_ATSC 下做任何 FPGA / imx6 改动,就必须使用这个 skill —— 即便 + 他们没点名 "skill",也没说 "SMOD",只是说 "把新 bit 升上去"、"改一下激励器的 RTL"、 + "imx6 app 编译部署一下"、"抓个 ILA 看看"、"重新出个 mcs"。这个 skill 给出平台事实、 + 两条烧写路径的选择、上电时序铁律,并把闭环调试转交给 fpga-imx6-flow。 +--- + +# SMOD ATSC 激励器开发 + +SMOD 是一块 **Artix-7 (xc7a200tfbg676-2) + i.MX6 + ADRV9009** 的 ATSC 激励器板。 +FPGA 做 8VSB 调制 / DPD / AGC / TS 前处理,imx6 跑 `atsc_app`(配置 ADRV9009、tuner、 +网页、SNMP),两者通过一个 I2C/寄存器桥通信。 + +这个 skill 是**总入口**:它告诉你平台的硬事实、该走哪条烧写路径、以及什么时候把活 +交给别的 skill。具体细节在 `references/`,可直接运行的脚本在 `scripts/`。 + +## 平台事实速查 + +| 项 | 值 | +|---|---| +| 激励器 Vivado 工程 | `E:\Xilinx_design\smod\SMOD_ATSC\my_exciter\my_exciter.xpr`(top = `smod_top`,也是 **git 仓库根**) | +| 打底 golden 工程 | `E:\Xilinx_design\smod\SMOD_ATSC\RU_golden\RU_golden.xpr`(top = `RU_golden_top`) | +| FPGA / Flash | `xc7a200tfbg676-2` / Winbond **w25q128bv**(128 Mbit = 16 MB,SPIx1) | +| Vivado | `D:\Xilinx\Vivado\2018.1`(`bin\vivado.bat`) | +| imx6 源码 | `my_exciter\imx6\src\atsc_app`(Makefile 在该目录根) | +| imx6 设备 | `192.168.1.143`,`root` / `anywavecom`(网页 + SSH;Windows 主机 `192.168.1.125` 同网段) | +| 交叉编译 VM | `bowei@192.168.223.128`,密码 `lr7402`,构建目录 `~/atsc_head_build` | +| 工具链 | `/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi` | +| app 部署路径 | `/aw/anywave/app/imx_atsc_app`(先写 `imx_atsc_app_head`,再 `cp -a` 覆盖 active) | +| app 日志 | `/var/module_log/imx_atsc_app.log` | +| 串口 | COM 号**逐机而异,两处配置都在 `~` 下、都不入库**:两个继电器(电源 / USB-Blast)看 `~/vivado_mcp_usb.conf`(缺了就跑 vivado-mcp 的 `detect_serial_relays` 自动探测生成);imx6 控制台看 `~/fpga_imx6_flow.json` 的 `ports.imx6`。**任何地方都不要写死 COM 号。** | +| 原理图 | `my_exciter\Notes\SMOD_V1.0_SCH251010.PDF` | +| 构建产物约定 | `my_exciter\bitstream\.{bit,ltx,mcs,prm}`,一次迭代一个 tag(`smod_p1`、`smod_dpd`…) | + +## ⚠️ 硬件铁律:上电时序 + +**给 SMOD 上电/断电之前,USB-Blast 必须先断开。** 绝不在 USB-Blast 连接状态下切换板卡电源 +——这会打坏 JTAG 适配器和板卡。 + +``` +断电(power_off) → 断开 USB-Blast(USB_Blast_off) → 上电(power_on) → 连接 USB-Blast(USB_Blast_on) → JTAG/ILA +``` + +用 vivado-mcp 的 `power_on` / `power_off` / `USB_Blast_on` / `USB_Blast_off` 工具执行。 +上电后要**等 imx6 串口控制台打印 `sys init over` 再连 JTAG**,否则 TX 链路会挂掉。 + +## 两条烧写路径 —— 先选对,再动手 + +这是本平台最容易搞错的地方。板上 flash 是 **quickboot 双镜像**布局:golden 镜像在 +`0x000000..0x3FFFFF`,update 镜像在 `0x400000..0xBFFFFF`,切换字 `0xAA995566` 在 `0x000FFC`。 + +| | **A. JTAG 直烧** | **B. 网络远程升级**(PR07 主线) | +|---|---|---| +| 何时用 | 板子是砖 / 首次装机 / 要换 golden | 日常迭代,板子已有 golden 且网络能通 | +| 烧什么 | `RU_golden\bitstream\golden_design_initial.mcs` | `my_exciter\bitstream\_update.mcs` | +| 怎么做 | `/fpga_flash` skill(flash 型号 `w25q128bv`) | **`/fpga_update` skill** | +| 需要 | USB-Blast + 上电时序 | 只要 `192.168.1.143` 能访问 | +| 风险 | 高(要动电源和 JTAG) | 低(写坏了 golden 会兜底) | + +**调试期**还有第三种:`program_device` 直接把 `.bit` 下到 FPGA(掉电即失),配合 `.ltx` 抓 ILA。 +迭代阶段用它最快,验证通过后再走 A 或 B 固化。 + +### B 走 `/fpga_update` + +**远程升级不走网页。** 网页的 Remote Update 只认 imx6 的 `*.tar.gz` 固件包,`.mcs` 传上去会被 +`check_package.sh` 删掉。FPGA 走的是 TCP **1234** 上的私有协议。生成 mcs、烧写、排错的完整流程 +都在 `fpga_update` skill 里,包括两个必须知道的事实: + +- **擦除卡住 ≠ 还在擦。** imx6 的 `POLL_ERASER` 不看 ERROR 位,programmer 报错和正常忙碌 + 返回同一个 `ERASER_ING`。读设备日志的 `rstate` 才知道真相(`0x7`=已中止,`0x0`=真在擦)。 +- `-imagesize 64` 和 `SpiFlashProgrammer.vhd` 里的 `cAddrUpdateStart/End` 是一对,别改。 + +升级完成后**断电冷启动**,让 FPGA 从 flash 重新加载,再验证。 + +## 三条典型工作流 + +**只改 RTL** → 编辑 `my_exciter.srcs/sources_1/**` → vivado-mcp `run_synthesis` + +`run_implementation` + `generate_bitstream` → `program_device` 下 bit → 抓 ILA 验证 → +满意后走 `/fpga_update` 固化。 + +**只改 imx6** → 编辑 `imx6/src/atsc_app/**` → 提交到 git(脚本默认打包 `HEAD`)→ + +```bash +python .../scripts/imx6_app.py build-deploy --tag mychange +``` + +→ 冷启动让 `rc.local` 拉起新 app → `imx6_app.py log` 看日志。 + +**FPGA + imx6 联调** → 交给 **`/fpga-imx6-flow`**。那个 skill 驱动一张 LangGraph 图, +把上电/JTAG/烧写/重启做成确定性节点,把「分析 → 判断是否达标 → 提方案 → 改 FPGA / 改 imx6」 +这四步作为中断交回当前会话执行,一轮一轮迭代。PR07 明确要求走这条路做联合调试。 + +## 什么时候调别的 skill + +| 场景 | 用 | +|---|---| +| 上电 / JTAG / 综合实现 / 抓 ILA / 下 bit | **vivado-mcp** 工具(`power_on`、`program_device`、`run_tcl`…) | +| 闭环设计-调试迭代(本 skill 的主推荐) | **`/fpga-imx6-flow`** | +| JTAG 烧 flash(golden 或砖机恢复) | **`/fpga_flash`**(flash 型号选 `w25q128bv`) | +| 单模块 RTL 仿真 / 出 testbench | **`/vivado-sim`** | +| ILA 抓出来的 IQ CSV 做频谱 / OBW / 带肩 | **`/fft-analyse`** | +| 网页操作(登录、升级、看频谱页) | **claude-in-chrome** | +| 实测频谱(N9320B 频谱仪) | `mcp__mcp_n9320b__*` 工具 | + +## scripts/ + +| 脚本 | 用途 | +|---|---| +| `imx6_app.py` | `build` / `deploy` / `build-deploy` / `log` —— VM 交叉编译 + 推到 imx6 + 读日志 | + +出 `.mcs` 和远程升级的脚本在 `/fpga_update` skill 里(`gen_update_mcs.py`、`fpga_ru.py`)。 + +两个脚本都 `--help` 可查。`imx6_app.py deploy` 会自动装 paramiko 2.12 到缓存目录并 re-exec +(imx6 的 dropbear 太老,paramiko 5 连不上,见下)。 + +## references/ + +按需读,别一次全读进来: + +- `references/fpga.md` —— RTL 目录结构、XDC、vivado-mcp 用法、ILA/ltx 约定、已知时序告警的性质 +- `references/imx6.md` —— app 源码结构、交叉编译细节、部署/日志/寄存器访问、网页 +- `references/upgrade.md` —— quickboot 双镜像布局、mcs 生成的确切参数、golden 恢复 +- `references/history.md` —— 历史 PR/Task 笔记索引(tuner、TSoIP、ORx、DPD、AGC 各自踩过什么坑) + +## 常见坑 + +| 症状 | 原因 / 处理 | +|---|---| +| 远程升级后重新上电 FPGA 不跑 | 传错了文件 —— 必须是 `_update.mcs`(起始 `0x800000`),不是 `write_cfgmem` 出的基础 mcs | +| 远程升级卡在 "erasing" 不动 | programmer 已报错退出(`rstate=0x7`),imx6 的轮询不看 ERROR 位。见 `/fpga_update` | +| imx6 SSH 报 `unknown cipher` / `no acceptable host key` | dropbear 太老。用 paramiko **2.12**(`imx6_app.py` 已处理);命令行 ssh 加 `-o HostKeyAlgorithms=+ssh-rsa` | +| imx6 `open_sftp()` → `EOF during negotiation` | 设备没有 SFTP 子系统。用 `cat > file` 走 exec channel 传文件 | +| VM SSH `kex_exchange_identification: Connection closed` | VM 的 sshd 挂了,请用户 `sudo systemctl restart sshd` | +| 上电后 TX 链路死掉 | 上电后没等 `sys init over` 就连了 JTAG | +| `power_off` 卡在循环里 | 先 `USB_Blast_off` 再 `power_off`,顺序反了 | +| WNS ≈ -9 ~ -13 ns | JESD204 `init_seq_data` 的既有 false path,与功能无关,不要为它改设计 | +| `i2cget` 读 FPGA 寄存器时好时坏 | 带调试 ILA 的 bit 时序更差;且会和 app 抢总线。以生产 bit 为准,或从 app 日志读 | + +## 写笔记 + +这个工程的约定是 `my_exciter\Notes\PRxx.md` 记**需求**,`Taskxx.md` 记**方案与实现总结** +(含验证数据和踩过的坑)。做完一轮有结论的工作,补一份 `Taskxx.md` —— 后来的人(和后来的你) +靠它避免重复踩坑。`references/history.md` 是已有笔记的索引。 diff --git a/smod_atsc_dev/references/fpga.md b/smod_atsc_dev/references/fpga.md new file mode 100644 index 0000000..4a091bd --- /dev/null +++ b/smod_atsc_dev/references/fpga.md @@ -0,0 +1,81 @@ +# FPGA 侧(my_exciter / RU_golden) + +## 工程 + +| | my_exciter | RU_golden | +|---|---|---| +| xpr | `E:\Xilinx_design\smod\SMOD_ATSC\my_exciter\my_exciter.xpr` | `...\RU_golden\RU_golden.xpr` | +| top | `smod_top`(`my_exciter.srcs/sources_1/top/smod_top.v`,~2300 行) | `RU_golden_top` | +| 作用 | ATSC 激励器全功能 | 打底镜像:网口 + quickboot 自烧,用来兜底和支撑远程升级 | +| 器件 | xc7a200tfbg676-2 | 同左 | + +`my_exciter` 目录本身是 **git 仓库根**(`imx6/` 也在里面)。RU_golden 的 xpr 引用了 +my_exciter 里的少数源文件(`ts_lock_top.vhd`、`sdram_tsoip_top.vhd`),改动时留意。 + +## RTL 目录(`my_exciter.srcs/sources_1/`) + +| 目录 | 内容 | +|---|---| +| `top/` | `smod_top.v` 顶层 + 时钟/AGC/DA 前处理/ORx 抽取等杂项 | +| `atsc_mod_xip/` | 8VSB 调制核(加密 IP) | +| `dpd/` | 数字预失真 | +| `txagc/` | 发射 AGC | +| `asi_pre/`, `dvb_asi/`, `ts/` | ASI / TS 输入与包同步(`src_preproc`、`spi_lock_det`) | +| `tsoip/`, `tsoip_atsc/`, `udp/`, `sdram/` | TS over IP 收发与缓存 | +| `tuner/` | Sony CXD2878 tuner 的 TS 接口(`TUN_D[7:0]/TUN_VLD/TUN_CLK`) | +| `quickboot/` | `SpiFlashProgrammer.vhd` + `SpiSerDes.vhd` + `spi_slave32.v` —— 远程升级的 FPGA 侧 | +| `i2c/` | imx6↔FPGA 的寄存器桥 | +| `gps/`, `uart/`, `misc/`, `bd_cfg/`, `coef/`, `ip/` | 其余 | + +约束在 `constrs_1/new/`:`smod_ioplanning.xdc`(SMOD 板的引脚,**这块板用这个**)、 +`project_ioplanning.xdc`(旧板,别混用)、`timing.xdc`、`timing_cleanup_120m.xdc`、 +`bitstream.xdc`、`debug.xdc`(当前为空,ILA 用 IP 例化而非 XDC 标记)。 + +## 用 vivado-mcp 干活 + +典型顺序(工具名即 `mcp__vivado-mcp__*`): + +``` +start_session(project=) # 打开工程 + → verilog_compile_check # 改完 RTL 先做语法检查,比跑综合快得多 + → run_synthesis + → get_critical_warnings # 综合后立刻看,别等到实现完 + → run_implementation + → generate_bitstream + → get_timing_report / get_utilization_report + → power_off → USB_Blast_off → power_on → (等 sys init over) → USB_Blast_on + → program_device # 下 bit(掉电即失) +``` + +其它常用:`run_tcl` / `safe_tcl`(任意 Tcl)、`get_run_progress`(长任务轮询)、 +`get_ip_status` / `inspect_ip_params`、`xdc_lint` / `xdc_auto_fix`、`get_io_report`。 + +综合+实现在这个工程上要 **20~40 分钟**。改一行就重跑之前,先用 `verilog_compile_check`。 + +## ILA + +`smod_top.v` 里 ILA 是**手工例化的 IP**(不是 `mark_debug`)。历史上用过 `ila_eth2`、 +`ila_2`、`ila_3`、`ila_adrv9009_fb`;当前活跃的是 `u_ila_adrv9009_rx`(复用 +`ila_adrv9009_fb` IP,探针宽度 16,16,1,8,1,1,1,8,8,8,8,5)。 + +要换探针就直接改那个例化的 probe 连接。**探针宽度必须和 IP 里配的一致**,否则综合报错。 +需要新宽度就改 IP 或换一个 ILA IP。 + +抓波形要 `.ltx`(`impl_1/smod_top.ltx`)。归档时 `.bit` / `.ltx` / `.mcs` 三件套用**同一个 tag** +放进 `bitstream/`,否则下次想复现就对不上号了。ILA 导出的 CSV 交给 `/fft-analyse` 做频谱。 + +**固化前把调试 ILA 去掉**,重新实现出干净的生产 bit:ILA 吃资源、恶化时序,还会和 imx6 +抢寄存器总线。 + +## 时序 + +`get_timing_report` 上 WNS 常年在 **-9 ~ -13 ns**,路径落在 JESD204 TX 的 +`init_seq_data`(`jesd204_phy_1lane_38j82_refclk` group)。这是基础设计里**既有的 +初始化序列 false path**,与 ATSC 数据通路无关 —— JESD 链路实测能起来,RF/调制/tuner 都正常。 + +不要为了这个数字去改设计。真正要盯的是你改动那部分的时钟域和 CDC。 + +## 出 mcs + +走 `/fpga_update` skill。一句话:远程升级要的是 `_update.mcs`,不是 `write_cfgmem` 的直接产物; +而且它走 TCP 1234,不走网页。 diff --git a/smod_atsc_dev/references/history.md b/smod_atsc_dev/references/history.md new file mode 100644 index 0000000..9fc2b17 --- /dev/null +++ b/smod_atsc_dev/references/history.md @@ -0,0 +1,35 @@ +# 历史笔记索引(`my_exciter\Notes\`) + +约定:**`PRxx.md` = 需求**(用户提出的要做什么),**`Taskxx.md` = 方案 / 实现总结** +(怎么做的、踩了什么坑、验证结论)。`discussNN-*.md` 是过程讨论。 +遇到相关主题**先读对应的 Task 文件**——里面的结论往往能省掉一整轮硬件迭代。 + +| 主题 | 需求 | 方案 / 总结 | 结论要点 | +|---|---|---|---| +| 从 EMV7.1 移植到 SMOD | `PR00.md`、`PR01.md` | `Task00.md`、`Task01.md` | 引脚重排到 `smod_ioplanning.xdc`;IO bank / 自由引脚勘查脚本也在 Notes 里(`task01_*.tcl`) | +| ADRV9009 JESD + SPI 寄存器核对 | `PR02.md` | `Task02.md` | JESD204B TX/RX lock status 寄存器位置 | +| TSoIP(网口 2 收 IP 流取 TS) | `PR03.md` | — | 根因见 memory `tsoip-root-cause-0x50` | +| Tuner(473 MHz RF → TS → 调制核) | `PR04.md` | `Task04.md` | demod 是 **Sony CXD2878 + FREIA**,不是 MN88436;移植 EMV9.3 的 `sonytuner/`;ILA 实测 19.41 Mbps、`ts_lock_a=1`、0x47 间隔 188 | +| ATSC 信号质量分析(RX1 路) | `PR06.md` | `Task06.md` | 用 RX1 接收自发信号,算带肩 + 8VSB 星座 + SNR,网页 `sys_spectrum.html` 显示 | +| 改用 ORx→DPD 40 MHz 反馈 | `PR06-1.md` | `Task06-1.md` | 反馈通道换成 ORx | +| ORx/RX1 双通道可切换 + DPD 内部 AGC | `PR06-2.md` | `Task06-2.md` | 共用滤波下采样;大量频谱截图见 `PR06-2_*.png`,索引在 `PR06-2_频谱截图索引.md` | +| 本 skill 的由来 | `PR07.md` | (本 skill) | 要求:SMOD ATSC 开发总入口,联调走 `/fpga-imx6-flow` | + +## 其它散落资源 + +- `Notes/*.png` —— 历次频谱 / 星座 / 波形截图,文件名带 iter 号,配合 Task 文件看 +- `Notes/create_ila_adrv9009_fb.tcl` —— 建 ILA IP 的 Tcl +- `Notes/task01_*.tcl` —— IO / DSP / 未约束端口的勘查脚本 +- `Notes/workflow_state.db`、`workflow_threads.json` —— fpga-imx6-flow 的 LangGraph 状态 +- `RU_golden/skills/*.py` —— 早期 RMII/GMII 抓包分析脚本(网口调试用) +- `bitstream/ila_*.csv`、`orx_*.csv` —— 历次 ILA 导出,交给 `/fft-analyse` 复现频谱 + +## 已固化的经验(别重新踩) + +- 上电时序铁律;上电后等 `sys init over` 再连 JTAG,否则 TX 死。 +- WNS -9~-13 ns 是 JESD204 `init_seq_data` 的既有 false path,与功能无关。 +- imx6 dropbear 老:paramiko 2.12 + `cat >` 传文件(无 SFTP)。 +- 设备上 `imx_atsc_app.rx1` 和 `M=2/lane 0x04` 变体会打断 TX;git `HEAD` 的版本是好的。 +- 远程升级走 TCP 1234(不是网页!),只能传 `_update.mcs`,传错文件会导致冷启动后 FPGA 不跑。 +- 远程升级"卡在擦除"通常是 programmer 已报错(rstate=0x7),imx6 轮询不看 ERROR 位。见 `/fpga_update`。 +- 带调试 ILA 的 bit 下 `i2cget` 读 FPGA 寄存器不可靠。 diff --git a/smod_atsc_dev/references/imx6.md b/smod_atsc_dev/references/imx6.md new file mode 100644 index 0000000..0e37b6f --- /dev/null +++ b/smod_atsc_dev/references/imx6.md @@ -0,0 +1,84 @@ +# imx6 侧(atsc_app) + +## 源码 + +`E:\Xilinx_design\smod\SMOD_ATSC\my_exciter\imx6\src\atsc_app`(Makefile 在这一层): + +| 目录 | 内容 | +|---|---| +| `main.c` | 起线程:`fpga_upgrade_task`、`tcp_task`、`web`、`lcd`、`gps`… | +| `bsp/` | 板级:`i2c_drv.c`、`fpga.c`(FPGA 寄存器读写)、`eeprom.c`(配置)、`dac38j82.c`、`dpd_proc.c`、`isys_config.c` | +| `talise/`, `adrv9009/`, `adi_hal/` | ADI ADRV9009 驱动 | +| `sonytuner/` | Sony CXD2878 + FREIA(CXD6821) 解调驱动(从 EMV9.3 移植,见 PR04) | +| `task/` | `upgrade.c`(软件包升级)、`tcp_task.c`、`iweb_task.c`、`tsoip.c`、`iq_rx.c` | +| `inc/` | 头文件;`fpga.h` 里 `FPGA_UPGRADE_I2C_REG_ADDRES = 0xE0` | + +同级还有 `imx6/src/web/`(cgi + www 页面,含 `upload.cgi` 远程升级入口)。 + +## 交叉编译 + +工具链在 VM `bowei@192.168.223.128`(密码 `lr7402`): + +``` +/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi +``` + +流程(`scripts/imx6_app.py build` 已封装): + +1. Windows 侧从 git 打包源码 —— **用 `HEAD` 而不是工作区**,这样构建的东西可追溯: + `git -C my_exciter archive --format=tar -o app.tar HEAD:imx6/src/atsc_app`(约 13 MB) +2. SFTP 上传到 VM,解到 **`~/atsc_head_build`**(一个干净目录;不要碰 `~/atsc_tsoip_build/` 之类的旧目录) +3. `bash -lc 'source ; cd ~/atsc_head_build && make'` → 产出 `imx_atsc_app`(~2.9 MB ELF) +4. SFTP 取回 Windows + +VM 的 sshd 是现代版本,paramiko 5 / OpenSSH 都能连。若报 +`kex_exchange_identification: Connection closed`,是 VM 的 sshd 挂了,请用户 `sudo systemctl restart sshd`。 + +## 部署到设备 + +设备 `192.168.1.143`,`root` / `anywavecom`。**它的 SSH 是老 dropbear**,有两个坑: + +1. **算法太老**:paramiko 5 会报 `unknown cipher` / `no acceptable host key`。 + 要用 **paramiko 2.12**(有 TripleDES / aes-cbc / ssh-rsa): + `python -m pip install --target paramiko==2.12.0`,然后 `PYTHONPATH=` 跑。 + 命令行 ssh 则加 `-o HostKeyAlgorithms=+ssh-rsa`。 +2. **没有 SFTP 子系统**:`open_sftp()` 直接 `EOF during negotiation`。传文件走 exec channel: + + ```python + ch = client.get_transport().open_session() + ch.exec_command("cat > /aw/anywave/app/imx_atsc_app_head") + ch.sendall(open(local_bin, "rb").read()) + ch.shutdown_write(); assert ch.recv_exit_status() == 0 + ``` + + (另一条老路是 SSH 反向端口转发 + 设备侧 `wget`,见 Task04;现在没必要了。) + +部署顺序 —— **先落一个新名字,验证 md5,再覆盖 active**,这样传输失败不会毁掉能跑的 app: + +``` +cat > /aw/anywave/app/imx_atsc_app_head +chmod 755 imx_atsc_app_head +md5sum 对比 +cp -a imx_atsc_app_head imx_atsc_app # 覆盖 active +``` + +`scripts/imx6_app.py deploy` 就是这个顺序。已知好用的备份是 `imx_atsc_app.bak_pre_rx1`。 + +改完 app 要**冷启动**(断电重启,遵守上电时序)让 `rc.local` 拉起新进程。 + +## 观察设备 + +- 日志:`/var/module_log/imx_atsc_app.log`。健康标志:`TALISE_radioOn`、`fb init state ok`、 + `fpga[0xBB]=0x03`(sync=1 / tvalid=1)。`imx6_app.py log` 直接拉。 +- 配置:`/aw/anywave/cfg/exc_cfg.conf`,`[BLK_SYS_PARA] reg50` 选输入源 + —— `0`=Tuner,`1`=TS,`2`=EAS,`3`=TSoIP。 +- 网页:`http://192.168.1.143`(`sys_rf.html`、`sys_ts.html`、`sys_set.html`、 + `status_info.html`、`log_output.html`、以及远程升级的 `upload.cgi`)。 +- 串口控制台(COM 号见 `~/fpga_imx6_flow.json` 的 `ports.imx6`,逐机而异、不入库)能用,但会自动登出、比较飘。**优先走网络 SSH**读日志和跑命令。 +- 直接读 FPGA 寄存器可以 `i2cget`,但它会和 app 抢总线;带调试 ILA 的 bit 下更不可靠。 + 以 app 日志为准,或者临时停掉 app。 + +## 已知不能用的构建 + +设备上的 `imx_atsc_app.rx1`、以及任何 `M=2 / lane 0x04` 的变体**会打断 TX**。 +git `HEAD` 的版本是 RX1-on-framerA(`M=4` / lane `0x03` / RX1RX2)+ TX 正常的已知好版本。 diff --git a/smod_atsc_dev/references/upgrade.md b/smod_atsc_dev/references/upgrade.md new file mode 100644 index 0000000..91ce7df --- /dev/null +++ b/smod_atsc_dev/references/upgrade.md @@ -0,0 +1,24 @@ +# 远程升级 / Flash 布局 + +**这块内容的唯一真源是 `fpga_update` skill。** 要生成 `_update.mcs` 或者把它烧进板子, +直接用那个 skill,别在这里另起炉灶。 + +- `~/.claude/skills/fpga_update/SKILL.md` —— 流程 +- `~/.claude/skills/fpga_update/references/quickboot.md` —— flash 布局、切换字兜底、`-imagesize 64` 的由来 +- `~/.claude/skills/fpga_update/references/protocol.md` —— TCP 1234 协议、以及 DPD 污染升级数据流的缺陷 + +三件容易记反的事: + +1. **FPGA 的远程升级不走网页。** 网页的 Remote Update 只认 imx6 的 `*.tar.gz` 固件包; + `.mcs` 传上去会被 `check_package.sh` 直接删掉。FPGA 走 TCP **1234** 的私有协议 + (原厂工具 `tools/Xfpga_ru.exe`,替代品 `fpga_update/scripts/fpga_ru.py`)。 +2. **升级前必须关掉 DPD**,否则 DPD 的 SPI1 流量会混进镜像,CRC 校验失败。 +3. **`_update.mcs` 不能 JTAG 烧写**,`_initial.mcs` 也不能远程升级。首次装机 / 救砖走 + `/fpga_flash`(flash 型号 `w25q128bv`,烧 `RU_golden/bitstream/golden_design_initial.mcs`)。 + +## 调试期的第三条路 + +`program_device` 直接把 `.bit` 下到 FPGA,掉电即失。迭代验证用它最快 —— 不碰 flash, +配 `.ltx` 就能抓 ILA。功能确认后再走远程升级固化。 + +固化用的 bit 要**去掉调试 ILA 重新实现**:ILA 吃资源、恶化时序,还会和 imx6 抢寄存器总线。 diff --git a/smod_atsc_dev/scripts/imx6_app.py b/smod_atsc_dev/scripts/imx6_app.py new file mode 100644 index 0000000..81ba84a --- /dev/null +++ b/smod_atsc_dev/scripts/imx6_app.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Cross-compile the SMOD imx6 atsc_app on the Ubuntu VM and deploy it to the board. + + build package app source -> VM (~/atsc_head_build) -> make -> pull imx_atsc_app back + deploy push a local imx_atsc_app to 192.168.1.143, md5-verify, then swap it in + build-deploy both + log tail /var/module_log/imx_atsc_app.log from the device + +Two device quirks drive the design here: + + * The imx6 runs an old dropbear. Modern paramiko can't negotiate with it + ("unknown cipher" / "no acceptable host key"), so this script vendors + paramiko 2.12 into ~/.cache/smod_atsc_dev and re-execs itself under it. + * That dropbear has no SFTP subsystem, so files go over an exec channel with + ``cat > file`` rather than sftp.put(). + +Deploy writes imx_atsc_app_head first and only overwrites the active imx_atsc_app once +the md5 matches -- a half-transferred binary should never become the thing rc.local runs. + +After deploying you still need a cold boot (power_off -> USB_Blast_off -> power_on -> +USB_Blast_on) for rc.local to pick up the new app. This script deliberately does not +touch the relays; that sequencing lives in the vivado-mcp tools. +""" + +import argparse +import hashlib +import os +import subprocess +import sys +import tarfile +import tempfile + +REPO = r"E:\Xilinx_design\smod\SMOD_ATSC\my_exciter" +APP_SUBDIR = "imx6/src/atsc_app" + +VM_HOST, VM_USER, VM_PASS = "192.168.223.128", "bowei", "lr7402" +VM_BUILD_DIR = "atsc_head_build" # relative to the VM home dir +TOOLCHAIN = "/opt/fsl-imx-x11/4.1.15-2.0.0/environment-setup-cortexa7hf-neon-poky-linux-gnueabi" + +IMX_HOST, IMX_USER, IMX_PASS = "192.168.1.143", "root", "anywavecom" +IMX_APP_DIR = "/aw/anywave/app" +IMX_STAGING = f"{IMX_APP_DIR}/imx_atsc_app_head" +IMX_ACTIVE = f"{IMX_APP_DIR}/imx_atsc_app" +IMX_LOG = "/var/module_log/imx_atsc_app.log" + +BINARY = "imx_atsc_app" +VENDOR_MARKER = "SMOD_PARAMIKO_VENDORED" +# paramiko 2.12 reaches for algorithms.TripleDES, which cryptography >= 43 removed. +VENDOR_PKGS = ["paramiko==2.12.0", "cryptography<43"] + + +# --------------------------------------------------------------------------- vendoring + +def ensure_legacy_paramiko(): + """Re-exec under a vendored paramiko 2.12 unless we're already running under it.""" + if os.environ.get(VENDOR_MARKER): + return + cache = os.path.join(os.path.expanduser("~"), ".cache", "smod_atsc_dev", "paramiko212") + if not os.path.isdir(os.path.join(cache, "paramiko")): + print(f"[setup] installing legacy paramiko into {cache} (one time)", flush=True) + os.makedirs(cache, exist_ok=True) + # --no-warn-conflicts: pip diffs --target installs against the ambient env and + # reports conflicts that don't apply here, which reads like a failure. + subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "--no-warn-conflicts", + "--target", cache] + VENDOR_PKGS, check=True) + env = dict(os.environ, **{VENDOR_MARKER: "1"}) + env["PYTHONPATH"] = cache + os.pathsep + env.get("PYTHONPATH", "") + env["PYTHONWARNINGS"] = "ignore" + sys.exit(subprocess.run([sys.executable, os.path.abspath(__file__)] + sys.argv[1:], env=env).returncode) + + +# ------------------------------------------------------------------------------- ssh + +def connect(host, user, password): + import paramiko + cli = paramiko.SSHClient() + cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + cli.connect(host, username=user, password=password, timeout=20, look_for_keys=False, allow_agent=False) + return cli + + +def run(cli, cmd, check=True, quiet=False): + if not quiet: + print(f" [ssh] {cmd}") + _, out, err = cli.exec_command(cmd, timeout=1800) + rc = out.channel.recv_exit_status() + stdout, stderr = out.read().decode(errors="replace"), err.read().decode(errors="replace") + if check and rc != 0: + sys.exit(f"[FAIL] rc={rc}\n{stdout}\n{stderr}") + return rc, stdout, stderr + + +def md5_of(path): + h = hashlib.md5() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +# ----------------------------------------------------------------------------- build + +def package_source(repo, source_mode): + tar_path = os.path.join(tempfile.mkdtemp(prefix="smod_app_"), "app.tar") + if source_mode == "git": + subprocess.run(["git", "-C", repo, "archive", "--format=tar", "-o", tar_path, f"HEAD:{APP_SUBDIR}"], + check=True) + rev = subprocess.run(["git", "-C", repo, "rev-parse", "--short", "HEAD"], + capture_output=True, text=True, check=True).stdout.strip() + print(f"[build] packaged {APP_SUBDIR} from git HEAD ({rev})") + else: + src = os.path.join(repo, *APP_SUBDIR.split("/")) + skip = {".o", ".bak", ".zip", ".tar"} + with tarfile.open(tar_path, "w") as tf: + for root, _, files in os.walk(src): + for name in files: + if os.path.splitext(name)[1] in skip or name == BINARY: + continue + full = os.path.join(root, name) + tf.add(full, arcname=os.path.relpath(full, src)) + print(f"[build] packaged {APP_SUBDIR} from the working tree (stale .o excluded)") + return tar_path + + +def cmd_build(args): + tar_path = package_source(args.repo, args.source) + out_path = os.path.abspath(args.out) + + vm = connect(VM_HOST, VM_USER, VM_PASS) + try: + sftp = vm.open_sftp() + print(f"[build] uploading {os.path.getsize(tar_path)/1e6:.1f} MB to VM") + sftp.put(tar_path, f"/home/{VM_USER}/app_src.tar") + + run(vm, f"rm -rf ~/{VM_BUILD_DIR} && mkdir -p ~/{VM_BUILD_DIR} && " + f"tar xf ~/app_src.tar -C ~/{VM_BUILD_DIR}") + + print("[build] cross-compiling (this takes a few minutes)") + rc, out, err = run(vm, f"bash -lc 'source {TOOLCHAIN} && cd ~/{VM_BUILD_DIR} && make {args.make_args}'", + check=False) + tail = "\n".join((out + err).splitlines()[-40:]) + if rc != 0: + sys.exit(f"[FAIL] make exited {rc}. Last output:\n{tail}") + print(tail) + + remote_bin = f"/home/{VM_USER}/{VM_BUILD_DIR}/{BINARY}" + rc, _, _ = run(vm, f"test -f {remote_bin}", check=False) + if rc != 0: + sys.exit(f"[FAIL] make succeeded but {remote_bin} is missing") + + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + sftp.get(remote_bin, out_path) + finally: + vm.close() + + print(f"\n[OK] {out_path} ({os.path.getsize(out_path)/1e6:.2f} MB, md5 {md5_of(out_path)})") + return out_path + + +# ---------------------------------------------------------------------------- deploy + +def push_file(cli, local, remote): + """Send a file over an exec channel: the device's dropbear has no SFTP subsystem.""" + stdin, stdout, _ = cli.exec_command(f"cat > {remote}", timeout=600) + with open(local, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 16), b""): + stdin.write(chunk) + stdin.channel.shutdown_write() + rc = stdout.channel.recv_exit_status() + if rc != 0: + sys.exit(f"[FAIL] `cat > {remote}` exited {rc}") + + +def cmd_deploy(args): + local = os.path.abspath(args.binary) + if not os.path.exists(local): + sys.exit(f"[FAIL] not found: {local}") + want = md5_of(local) + print(f"[deploy] {local} (md5 {want})") + + imx = connect(IMX_HOST, IMX_USER, IMX_PASS) + try: + print(f"[deploy] staging -> {IMX_STAGING}") + push_file(imx, local, IMX_STAGING) + run(imx, f"chmod 755 {IMX_STAGING}") + + _, out, _ = run(imx, f"md5sum {IMX_STAGING}") + got = out.split()[0] + if got != want: + sys.exit(f"[FAIL] md5 mismatch after transfer: device {got} != local {want}. " + f"Active app untouched.") + print("[deploy] md5 verified") + + run(imx, f"cp -a {IMX_STAGING} {IMX_ACTIVE}") + _, out, _ = run(imx, f"md5sum {IMX_ACTIVE}") + if out.split()[0] != want: + sys.exit("[FAIL] active binary md5 mismatch after swap") + finally: + imx.close() + + print(f"\n[OK] deployed to {IMX_ACTIVE}") + print("Now cold-boot the board so rc.local starts the new app:") + print(" vivado-mcp: power_off -> USB_Blast_off -> power_on -> (wait 'sys init over') -> USB_Blast_on") + print(f"Then check the log: python {os.path.basename(__file__)} log") + + +# ------------------------------------------------------------------------------- log + +def cmd_log(args): + imx = connect(IMX_HOST, IMX_USER, IMX_PASS) + try: + cmd = f"tail -n {args.lines} {IMX_LOG}" + if args.grep: + cmd += f" | grep -E '{args.grep}'" + _, out, _ = run(imx, cmd, check=False, quiet=True) + print(out) + finally: + imx.close() + print("\nHealthy markers: TALISE_radioOn / 'fb init state ok' / fpga[0xBB]=0x03") + + +# ------------------------------------------------------------------------------ main + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + def add_build_args(p): + p.add_argument("--repo", default=REPO) + p.add_argument("--source", choices=["git", "worktree"], default="git", + help="git (default, reproducible) packages HEAD; worktree packages uncommitted edits") + p.add_argument("--make-args", default="", help="extra args passed to make on the VM") + p.add_argument("--out", default=os.path.join(tempfile.gettempdir(), BINARY)) + + b = sub.add_parser("build", help="cross-compile on the VM and pull the binary back") + add_build_args(b) + + d = sub.add_parser("deploy", help="push a local binary to the imx6 and swap it in") + d.add_argument("binary", nargs="?", default=os.path.join(tempfile.gettempdir(), BINARY)) + + bd = sub.add_parser("build-deploy", help="build then deploy") + add_build_args(bd) + + lg = sub.add_parser("log", help="tail the app log on the device") + lg.add_argument("-n", "--lines", type=int, default=80) + lg.add_argument("-g", "--grep", help="extended regex to filter with") + + args = ap.parse_args() + ensure_legacy_paramiko() # after parsing, so --help never triggers a pip install + + if args.cmd == "build": + cmd_build(args) + elif args.cmd == "deploy": + cmd_deploy(args) + elif args.cmd == "build-deploy": + args.binary = cmd_build(args) + cmd_deploy(args) + elif args.cmd == "log": + cmd_log(args) + + +if __name__ == "__main__": + main()