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
+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()