Update FPGA and Vivado skills

This commit is contained in:
Myfro9
2026-07-14 18:18:17 +08:00
parent d20ec12fee
commit bceafcd58c
14 changed files with 1707 additions and 14 deletions
+84 -13
View File
@@ -4,7 +4,7 @@ description: >
Run the fpga-imx6-flow LangGraph workflow at E:\LangGraph_Design\FPGA-imx6-flow — 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: 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, 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 modifying RTL/imx6 code. It drives the graph in-process (no langgraph dev) and
hands the 5 interrupts (analyze / meets-requirement / propose / modify-FPGA / hands the 5 interrupts (analyze / meets-requirement / propose / modify-FPGA /
modify-imx6) back to this Claude session to execute. modify-imx6) back to this Claude session to execute.
@@ -43,8 +43,8 @@ USB-Blast 处于连接状态时给板卡上电或断电(保护 JTAG 适配器
标准时序(图拓扑已按此实现,手动操作或写 `tools_*` / 真机脚本时也必须遵守): 标准时序(图拓扑已按此实现,手动操作或写 `tools_*` / 真机脚本时也必须遵守):
``` ```
断电(COM8 power_off) → 确认已断电 → 断开USB-Blast(COM4 blast_off) → 确认已断开 断电(power_off) → 确认已断电 → 断开USB-Blast(blast_off) → 确认已断开
→ 上电(COM8 power_on) → 确认已上电 → 连接USB-Blast(COM4 blast_on) → 连接JTAG/烧写/抓ILA → 上电(power_on) → 确认已上电 → 连接USB-Blast(blast_on) → 连接JTAG/烧写/抓ILA
``` ```
**烧写 flash 的完整闭环**(固化后必须断电重启从 flash 加载再验证): **烧写 flash 的完整闭环**(固化后必须断电重启从 flash 加载再验证):
@@ -59,7 +59,12 @@ USB-Blast 处于连接状态时给板卡上电或断电(保护 JTAG 适配器
`power_off` 做整机断电重启,再次走完上述上电时序后才 `analyze`(抓ILA验证)。 `power_off` 做整机断电重启,再次走完上述上电时序后才 `analyze`(抓ILA验证)。
- **强化确认**:`power_on` 节点在上电前调用 `_ensure_blast_off_before_power()` 兜底确认 - **强化确认**:`power_on` 节点在上电前调用 `_ensure_blast_off_before_power()` 兜底确认
USB-Blast 已断开;若发现仍连接会强制断开并在 `history` 标记 `rule_violation=True`。 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 > **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 > 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 `langgraph` + `langgraph-checkpoint-sqlite` and the Vivado link). Do not proceed
until it succeeds. until it succeeds.
**For a real-hardware run** (`--real`), additionally confirm: `env_config.json`'s **For a real-hardware run** (`--real`), additionally confirm the machine's two config
`VIVADO_BIN_PATH` is valid; the vivado-mcp `power_on`/`USB_Blast_on` tools are files — **both live in `~`, neither is in the repo** (they hold COM numbers, VM/imx6
available; COM4/COM6/COM8 are present (`[System.IO.Ports.SerialPort]::GetPortNames()`). 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 **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. 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 | | `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}` | | `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"}` | | `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"}` | | `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 `<project>\runs\skill_checkpoint
- **MOCK (default)** — `FPGA_IMX6_MOCK=1`; hardware/check nodes return success - **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 immediately. Use this to dry-run the *design-loop logic* and the 5 interrupts with
no board attached. Great for demonstrating/validating the flow. no board attached. Great for demonstrating/validating the flow.
- **Real (`--real`)** — drives actual hardware. Requires the project's - **Real (`--real`)** — drives actual hardware. The `tools_hw` (power + USB-Blast relays,
`app/tools_hw.py` / `tools_vivado.py` / `tools_imx6.py` real paths to be implemented ports from `~/vivado_mcp_usb.conf`), `tools_imx6` (console restart/kill/log, port from
(they currently raise `NotImplementedError` as TODO — they will reuse the proven `~/fpga_imx6_flow.json`'s `ports.imx6`), and `tools_vivado` (JTAG program/flash) real paths are
vivado-mcp serial/Vivado logic). Until then, `--real` fails at the first hardware **now implemented and validated end-to-end (2026-07-05)** on the SMOD_ATSC board. Getting there
node; tell the user to wire the tools first (see `Notes/Task00.md` §7). 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 <dir> paramiko==2.12.0` then `PYTHONPATH=<dir> 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 <envsetup>; 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 ## Troubleshooting
| Symptom | Fix | | Symptom | Fix |
|---|---| |---|---|
| `UnicodeEncodeError: 'gbk' ...` | Don't use bare `conda run`; use the env `python.exe` or `conda run --no-capture-output`. | | `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`. | | `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_*`. | | 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. | | `resume` seems to start over | You passed a different `--thread-id` than `start`. Use the same one. |
+22 -1
View File
@@ -95,13 +95,32 @@ def main() -> None:
graph.invoke(init, cfg) graph.invoke(init, cfg)
elif args.command == "resume": elif args.command == "resume":
payload = json.loads(args.resume_json) if args.resume_json else {} 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 # status: no invoke, just snapshot
snap = graph.get_state(cfg) snap = graph.get_state(cfg)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
_emit({"status": "error", "error": str(exc)}, 1) _emit({"status": "error", "error": str(exc)}, 1)
if snap.next: if snap.next:
all_intr = [intr.value for task in snap.tasks for intr in task.interrupts]
_emit({ _emit({
"status": "interrupt", "status": "interrupt",
"thread_id": args.thread_id, "thread_id": args.thread_id,
@@ -109,6 +128,8 @@ def main() -> None:
"next": list(snap.next), "next": list(snap.next),
"iteration": snap.values.get("iteration"), "iteration": snap.values.get("iteration"),
"interrupt": _pending_interrupt(snap), "interrupt": _pending_interrupt(snap),
"pending_interrupts": all_intr,
"pending_count": len(all_intr),
}, 2) }, 2)
else: else:
_emit({ _emit({
+119
View File
@@ -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`、两条烧写路径的区别
+137
View File
@@ -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。
+77
View File
@@ -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/<tag>_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 重新实现**。
+307
View File
@@ -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@<host> '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@<host> \"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 <tag>_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()
+148
View File
@@ -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 <tag>_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()
+175
View File
@@ -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()
+151
View File
@@ -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\<tag>.{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\<tag>_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` 是已有笔记的索引。
+81
View File
@@ -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=<xpr>) # 打开工程
→ 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,不走网页。
+35
View File
@@ -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 寄存器不可靠。
+84
View File
@@ -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 <envsetup>; 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 <dir> paramiko==2.12.0`,然后 `PYTHONPATH=<dir>` 跑。
命令行 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 正常的已知好版本。
+24
View File
@@ -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 抢寄存器总线。
+263
View File
@@ -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()