板子上有两颗大芯片。一颗 Zynq UltraScale+ RFSoC 跑 Linux,统管全局;旁边一颗 Kintex UltraScale,中间是一条高速串行链路。上一篇里,第二颗芯片成了第一颗的扩展仓:一处放逻辑去跑的地方, 外加一块跨链路够得着的内存。

还有一根线挡在那里。FPGA 的逻辑存在易失性存储里,一断电就全忘光。要重新灌回去,每一次都得从工作站 接一根编程线过来。链路很快,算力也挂上了,可板子上还吊着一根线。

这一篇讲怎么把这根线拿掉:固件走数据用的那条链路发过去,写进目标芯片旁边的存储芯片,再让目标自己从 里面重启。动手的活由 AI 在 Python2Verilog 框架下完成,我在几个节点上给方向,外加亲手拨了一下开关。

更新闭环的三步:固件走链路进入目标旁边的配置闪存,一条带内寄存器写入让目标重启,芯片从自己的闪存重新加载,链路随后重锁。结果条显示 9.54 MB 走链路烧入、回读 0 字节不符,146 个扇区擦除写入并校验用时 295 秒。
让线缆消失的那个闭环。固件走链路进入目标旁边的存储器,一条命令让它重启,芯片带着刚收到的那份固件回来。

为什么显而易见的那条路走不通

往 FPGA 里灌逻辑,从来都是两条路。一条是把固件直接推进片内的配置存储器,立刻生效,但那是块易失性 存储,下次断电就没了。另一条是写进 FPGA 旁边的闪存芯片,芯片每次上电自己去读。调试阶段一直走第一条, 靠那根线。

第一条路搬不上链路,这个理由值得停一秒:写配置存储器会把整颗芯片重写一遍,而承载链路的逻辑就在这颗 芯片里。传到一半,负责传输的那个东西自己就不存在了。先烧闪存再重启,是远程更新的标准做法,我们做的 就是这一条。

藏在数据流里的一条烧写通道

第二颗 FPGA 旁边的闪存有 64 兆字节,接在专用的配置引脚上。这组引脚有个让整件事变便宜的性质:设计跑 起来之后,还能通过片内的一个接口继续访问它们,所以这颗闪存不占用任何普通引脚。

于是链路协议里多了一类帧。帧头带标记的,分流进烧写通道;其余照旧当数据通过,不受影响。标记后面是一个 控制器,用自己的自由运行时钟去驱动闪存。这一点比听上去要紧:擦除一个扇区花的时间远长于链路上的一次 抖动,而一个用链路时钟计时的控制器,会把擦到一半的操作丢在那里。批量操作一次往返搬 128 千字节。

一串帧里有一帧带着标记的帧头。分类器把不带标记的帧照旧当数据放行,把带标记的帧送进运行在独立自由时钟上的 SPI 控制器,由它驱动 64 兆字节的配置闪存。
一条流,两个去处。帧头上的标记决定去哪,控制器跑在自己的时钟上,链路抖动打不断一次正在进行的擦写。

闪存的命令集特意留在主机侧。FPGA 上的逻辑不认识任何厂商操作码,它只负责搬字节,这意味着换一块板子、 换一颗闪存,同一条通道照样能用,硬件一行都不用改。

真机上三步验收。走链路读回的器件识别码,与原理图上印的那颗型号完全对应。高地址上的单扇区试验擦除、 写入、回读、再擦除,逐字节精确。然后是整份:8.56 兆字节写到零地址,回读用了 165 秒,没有一个字节不符。

写路径通了。闪存里躺着一份完整固件。剩下的,只差让芯片上电时去读它。

启动测试说不,寄存器什么也没说

FPGA 上电去哪里找固件,由封装上三个模式引脚决定。原理图上印着默认值,而这个默认值就是从闪存启动。 于是我们发了一条按模式引脚重新配置的命令,芯片直接黑掉了,什么也没加载。

下一步顺理成章:读配置状态寄存器。它说模式引脚是 000,那不是从闪存启动。它还说配置从未完成,而这个 设计当时明明在运行,还在通过链路回我们的消息。

后面这条读数才是有意思的地方。一个自相矛盾的寄存器不是弱证据,而是根本不算证据。既然产生这份回读的 通路本身就是坏的,它对模式引脚的说法同样信不得。上一篇的教训在这里再次生效:记录会骗人,行为不会。 重新配置的那次尝试,早就给出了诚实的答案。

那就回图纸,还是上次那套办法:把原理图按几何关系解析出来,抠出电路的那个角落。三个模式引脚全接在一个 四位拨码开关上,而图纸角落里印的那个默认值,装配时根本没拨到位。我们想要的设置和现状恰好只差一位, 所以只需要拨动一个位置。哪个方向算“开”都无所谓,反正拨一下就是取反。

怎么证明它跑的是哪一份

开关拨完,芯片自己完成了配置。可是“配置完成”是很弱的说法:它区分不了芯片加载的是闪存里那份,还是 之前线缆留下的旧固件。

设计里有个计数器,记录闪存接口发过多少字节。读出来是 64:恰好是一次全新加载上电预热发出的字节数, 一个都不多。这个计数器过不了一次重新加载,所以能读出 64,只可能是从零开始跑起来的。指明启动来源的 是它,任何一个状态标志都做不到。

并排两块。左边是状态寄存器,在一个运行中的设计上报告模式引脚 000 且配置未完成,标注为自相矛盾。右边是字节计数器,读数恰好是一次全新加载的 64 个预热字节,标注为全新加载、来自闪存。
寄存器自己跟自己打架,什么也定不了。计数器必须从零开始,它读出的预热字节数指明了启动来源。

然后线彻底摘掉。一条寄存器写入走链路发过去,回执先离开链路,芯片再重启,这样一次沉默的失败永远不会 被当成一次沉默的成功。芯片通过片内重新配置接口从闪存重新加载自己,几秒后链路重锁,两端都在新固件上 应答。

从这以后,给第二颗芯片换固件就是一次远程更新。主机把新固件写进闪存,发一条命令,芯片带着它回来。 断电重来,它还是带着它回来。

同一套流程,跑在更宽的链路上

后来这套流程又在更宽的通路上跑了一遍:两条通道捆绑起来,芯片内部的数据通路也加宽,让捆绑出来的带宽 真正用得上。同一批回环测试从每秒 3.0 千兆涨到 5.24 千兆,链路自己的上限也挪得足够远,于是瓶颈从线上 搬进了芯片里。

在捆绑通路上,整套更新从头跑到尾:9.54 兆字节,146 个扇区擦除、写入并校验,没有一个字节不符,用时 295 秒,随后远程重启,每一项检查都在第一次读取时就应答。烧写和重启这两件事,现在跑在它们自己烧出来的 系统之上。

留下的三条准则

这三条都写回了框架,成了每次构建都会跑的检查。

信行为,不信记录。从一个运行中的设计读出的状态寄存器,同时报告了两件不可能都成立的事。真发一次 命令,让硬件自己回答。

回执先走,动作后发。以重启收尾的命令会丢掉自己的回复,于是在主机看来,成功和失败长得一模一样。 回执先离开,芯片才重启。

启动来源的证据要有零点。想知道芯片跑的是哪一份固件,就看一个在全新加载时必然清零的计数器。配置 有没有报告成功,跟固件从哪里来没有关系。

上一篇的结尾说,接线关系从此是测出来的,不是假设出来的。这一篇把同样的话送给启动方式。图纸说是一个 设置,寄存器说是另一个,都不算数。算数的,是芯片真的从自己的闪存跑起来的那一刻。

这根线上只剩在线调试一件事,而那件事同样有把调试流量搬到网络上的标准做法。等它也挪到这条链路上, 这根线就彻底不用了。