Update FPGA and Vivado skills
This commit is contained in:
@@ -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`、两条烧写路径的区别
|
||||
@@ -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。
|
||||
@@ -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 重新实现**。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user