没有 U 盘,没有屏幕:在 WSL2 里给 AGX Thor 线刷系统的八次尝试

一块 Jetson AGX Thor 开发套件,手边没有 U 盘也没有显示器,只有一根 USB-C 线和一台 Windows 笔记本。最后在 WSL2 的 Ubuntu 26.04 里把 JetPack 7.2 刷进了板载 NVMe,刷机本身试了八次。坑几乎全来自两处:宿主 Ubuntu 太新,以及 usbip 对大块传输的固有缺陷。

没有 U 盘,没有屏幕:在 WSL2 里给 AGX Thor 线刷系统的八次尝试
封面:笔记本用一根 USB-C 线连着一块带散热片的嵌入式模块
图 0:封面,AI 生成示意图(不是实拍)。

8 月 12 日下午,我手里有一块 Jetson AGX Thor 开发套件,一根 USB-C 线,一台 Windows 11 的笔记本。没有 U 盘,没有显示器,也没有键盘鼠标。官方的安装路线是把 ISO 写进 U 盘让板子自己装,这条路走不了。最后走的是老 Jetson 那种线刷:让板子进恢复模式,用 WSL2 里的 Ubuntu 26.04 跑 NVIDIA 的刷机脚本,把 JetPack 7.2(L4T r39.2.0)写进板载 NVMe。从下载 BSP 到 SSH 登进去,大约三个小时,刷机本身跑了八次才成。

整个过程是我和 Claude Code 一起做的:我负责按键、插线、盯着板子,它负责查文档、打补丁、看日志。八次尝试各自倒在哪一关,后面有一张图。先说结论:坑几乎全来自两处,一处是宿主 Ubuntu 26.04 太新(包名变了、sudo 换成了 sudo-rs、OpenSSH 10 删了 DSA),另一处是 usbip 对 adb 的大块传输扛不住,最后把 adb 这一段整个绕开了 usbip。

官方路线是 U 盘,我没有 U 盘

Thor 这一代的 Quick Start 很省事:下载一个 4.71 GB 的 ISO,用 balenaEtcher 写进 16 GB 以上的 U 盘,插到板子上,接屏幕键盘,开机就进安装器,装到 NVMe 大概十分钟。ISO 我是下好了的,Etcher 也装了,然后发现手边连一块移动硬盘都没有。

另一条路是线刷。NVIDIA 支持在 WSL2 里跑 SDK Manager,原理就是把处于恢复模式的板子用 usbipd 直通给 WSL 里的 Ubuntu,然后跑 BSP 包里的 l4t_initrd_flash.sh。我的 WSL 里刚好有一个 Ubuntu 26.04,于是就走这条。BSP 包 Jetson_Linux_R39.2.0_aarch64.tbz2 1.29 GB 加上 rootfs 包,走代理从 NVIDIA 的国内 CDN 下,几分钟。

最后跑通的拓扑是这样的,其中 adb 那一段的走法是第六次失败以后才改出来的:

线刷链路
图 1:恢复模式阶段走 usbip 进 WSL2,adb 写盘阶段改走 Windows 原生 USB。底部是放弃的 usbip 大传输路径。

接线和恢复模式

连电脑的 USB-C 线要插在紧挨 HDMI 的那个 USB-C 口,原装电源插另一个口,全程供电。Thor 侧面有三个键:左边电源,中间 Force Recovery,右边 Reset。开机状态下进恢复模式的顺序是:按住中键不放,点一下 Reset,再松开中键。关机状态下把"点 Reset"换成"点电源"。

这里有个小插曲。Claude Code 一开始给我的按键说明是从第三方 wiki 拼出来的,还加了"中键多按住 2 秒"之类的时间细节。我按了两次都没进去,设备直接从 usbipd list 里消失,说明板子正常重启了。我让它去官网核实(当时原话是"你仔细去官网看清楚按键顺序,尤其按键时间"),结果两份官方文档都只写顺序,没有任何"按几秒"的要求。关键只有一条:中键要一直按着,直到点完 Reset 之后再松。

恢复模式按键顺序
图 2:进恢复模式的按键顺序,以及怎么确认进去了。

进去的标志是 Windows 这边能看到 APX 设备:

powershell
usbipd list
# 1-7    0955:7026  APX    Not shared
usbipd attach --wsl --auto-attach --busid 1-7

--auto-attach 很重要。刷机过程中板子会重枚举好几次,这个参数让 usbipd 一直盯着,掉了就重新挂回 WSL。

预处理就先失败了三次

刷机前要在 WSL 里解包 BSP、装依赖、把 NVIDIA 的驱动合进 ARM64 的根文件系统,再用 l4t_create_default_user.sh 预置一个账号和主机名(没有屏幕,跳过首次开机向导)。这一步在 Ubuntu 26.04 上连着失败了三回。

第一回,apt 直接说没有这个包:

text
E: Package 'qemu-user-static' has no installation candidate
Package qemu-user-static is a virtual package provided by:
  qemu-user-binfmt-hwe 1:10.2.1+ds-1ubuntu4.3
  qemu-user-binfmt 1:10.2.1+ds-1ubuntu3.2

为什么需要 qemu:线刷路线是 x86 电脑替板子准备整个系统,要 chroot 进 ARM64 的根文件系统跑里面的程序(装驱动 deb、建账号、算密码哈希),全靠 qemu-aarch64-static 代跑。26.04 把这个包改成了虚拟包,真正装进去的是动态链接的 qemu-user-binfmt。装上它还不够,NVIDIA 的 nv_qemu_setup_helper.sh 死认 /usr/bin/qemu-aarch64-static 这个文件名。最后是临时挂了一个 24.04(noble)的源,让 apt 把旧包下下来,只把那一个静态二进制抽出来放到位,用完就把源删了。

第二回,dpkg 的锁被系统自己的自动更新占着:

text
E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1464 (unattended-upgr)

systemctl stop unattended-upgrades 之后重跑就过了。第三回就是上面说的静态 qemu 文件还没补上。补上以后,合入驱动和建账号都走通了,账号用的是 NVIDIA 示例里的默认账号,刷完第一件事是改密码。

八次刷机

八次尝试各自倒在哪一关
图 3:八次刷机尝试各自倒在流水线的哪一关。前面三次预处理失败没画进去。

第一次:OpenSSH 10 没有 DSA

命令是 BSP 里的 ./l4t_initrd_flash.sh --erase-all jetson-agx-thor-devkit internal。第一次跑,进程几十秒就退出了。我那个包装脚本最后 echo 了一句"完成",这不能当证据,真正的标志得看工具自己打的 Flashing finished Successfully 还是 Unsuccessfully!!。这次是后者,而且还没碰到板子,死在生成刷写镜像的阶段。

根因在 tools/ota_tools/version_upgrade/ota_make_recovery_img_dtb.sh,它给临时刷写环境里的 sshd 生成主机密钥:

bash
ssh-keygen -t dsa -N "" -f "${_initrd_dir}/${ssh_config_dir}/ssh_host_dsa_key" >/dev/null 2>&1;check_error
ssh-keygen -t rsa -N "" -f "${_initrd_dir}/${ssh_config_dir}/ssh_host_rsa_key" >/dev/null 2>&1;check_error

OpenSSH 10 把 DSA 整个移除了,第一行必然失败,check_error 就把整个脚本掐了。那份 sshd 配置本来就不用 DSA,把这一行删掉即可。

第二次:设备重枚举后的 8 秒

第二次过了镜像生成,走到 RCM 通信就停了,日志里 Image Flashing took 0.49 秒。tegrarcm_v2 --new_session 把 bootloader 下载进板子以后,板子会断开再重新枚举一次 USB,usbipd 的自动重连大约要 8 秒才把它挂回 WSL,而 sendRcmCommand 这个函数一次都不重试,8 秒的空窗直接判死。给它加了重试(12 次、每次 5 秒)并在重试时刷新目标设备信息,这一关就过了。

每次失败以后,板子都停在半启动的临时状态,重跑之前必须再按一遍恢复模式。这一下午我按了大概十次。

第三次:sudo-rs 把环境清了

第三次 new_session 成功、读到芯片 ECID、fuse 校验完成、生成了 adb 序列号,走到进刷写环境的门口又倒了。这是今天最绕的一个坑,预处理阶段其实已经露过头:

text
sudo: preserving the entire environment is not supported, '-E' is ignored

Ubuntu 26.04 把 sudo 换成了 Rust 写的 sudo-rs,它不支持 -E。NVIDIA 这套 flashtools 从 Python 到 shell 到处用 sudo -E 把 PATH 和环境变量传给子进程,现在 -E 被忽略,子进程一启动 PATH 就被清掉,tegrarcm_v2、nvdd、wr_sh.sh 这些工具在运行到一半的时候集体"失踪"。

改法分三处:在 bootburn_lib.py 顶部把 flash 目录注入 PATH;把三处 "sudo -E " 字符串置空;l4t_initrd_flash_internal.sh 第 1335 行有一句 sudo python3 "${FLASH_EXEC}" ...,脚本本来就是 root 跑的,把 sudo 去掉。第三次倒下就是因为前两处改完以后,这个内层 sudo 又把环境清了一遍。

第四次:裸文件名

离线刷写流程(日志里自称 miniPDK -- offline binary flashing)不跑 SetBootBurnPaths,工具全是裸文件名,靠当前目录解析。把 flash/ 整个目录软链进 flash_workspace/flash-images/ 以后,flash_utilities.Copy 又因为源和目标是同一个文件而抛异常。给 Copy 加一句"是同一个文件就直接返回",第四次也翻过去了。

第五次和第六次:usbip 扛不住 adb

从这里开始,问题从工具链转到了 USB。板子进入临时刷写环境之后会换一个 USB 身份,从恢复模式的 0955:7026 APX(总线 1-7)变成 0955:7100 FunctionFS gadget (adb)(总线 1-14)。后面写盘全靠 adb:先推 wr_sh.sh 和 nvdd 两个工具进去,再按 FileToFlash.txt 的清单把镜像逐个分区推过去写。

第五次倒在等 adb 设备 70 秒超时,因为新身份 1-14 没人挂进 WSL。于是两个总线各起一个自动重连守护,WSL 里的 adb 服务端也接管了 5037 端口,第六次终于开始推文件了:699 字节的 wr_sh.sh 推成功,接着推 nvdd,死了。

text
adb: error: failed to get feature set: device offline
[ 6903.353383] vhci_hcd: urb->status -104
[ 6903.355254] vhci_hcd: urb->status -104
[ 6908.361916] vhci_hcd: urb->status -104

小文件能过,几百 KB 的文件必死,而且一死整条 usbip 链路都挂。这是 usbip 协议对大块传输的老毛病,后面要推的是几个 GB 的镜像,这条路走不通。

第七次:adb 服务端搬回 Windows

改的办法是让 adb 这一段完全不经过 usbip。adb 本来就是客户端和服务端分开的,服务端跑在 Windows 上,用 Windows 原生的 USB 栈直接和板子说话;WSL 里 NVIDIA 自带的 adb 客户端通过 tcp:127.0.0.1:5037 使唤它,WSL 的镜像网络模式让两边的 127.0.0.1 就是同一个。adb push 的文件是客户端这边读的,所以 WSL 里的路径不用做任何转换。恢复模式那一段的 tegrarcm 传输很小,继续走 usbip,它在那里一直很稳。

要点有三个。一是版本要一致:NVIDIA 的客户端是 1.0.40,对应 platform-tools r28,Windows 这边就装 r28,不要用最新版。二是 Windows 默认不把 0955:7100 这个 FunctionFS gadget 当 adb 设备,要给它的 Device Parameters 补一个 DeviceInterfaceGUIDs:

powershell
$key = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USB\VID_0955&PID_7100\<设备实例ID>\Device Parameters'
New-ItemProperty -Path $key -Name 'DeviceInterfaceGUIDs' -PropertyType MultiString `
  -Value '{F72FE0D4-CBCB-407d-8814-9ED673D0DD6B}' -Force | Out-Null

三是把 1-14 从 usbipd 解绑还给 Windows(会弹一次 UAC)。改完实测 adb push 有 51 MB/s。第七次一路推到写根分区,分区表、BCT 这些小分区挨个写完并通过 MD5 校验,9.25 GB 的根分区写到 62% 的时候,又断了。

第八次:shim

这次的死法是 localhost 上的一次瞬断。刷机工具每写一块(10 MB)就调一次 adb,上千条短 TCP 连接里有一次没连上,整个流程就退出了。另外工具里还会调 adb kill-server 和 adb start-server,照字面执行会把 Windows 上的服务端杀掉。

解决办法是把 flash/adb 换成一个 shell shim,真身改名 adb.nvidia:

bash
#!/bin/sh
for a in "$@"; do
  case "$a" in
    kill-server|start-server) exit 0;;      # 服务端在 Windows 上,不许动
  esac
done
REAL="$(dirname "$(readlink -f "$0")")/adb.nvidia"
export ADB_SERVER_SOCKET=tcp:127.0.0.1:5037    # 钉死外部服务端
n=0
while :; do
  out="$("$REAL" "$@" 2>&1)"; rc=$?
  if [ $rc -ne 0 ]; then
    case "$out" in
      *"cannot connect to daemon"*|*"failed to start daemon"*|*"device offline"*)
        n=$((n+1)); [ $n -lt 20 ] && sleep 2 && continue;;
    esac
  fi
  printf '%s\n' "$out"; exit $rc
done

第八次从 19:11 开始,十几分钟写完。日志里每个分区都有 Image written correctly, md5 sum matches,最后是 resizeFilesystem: done 和 Flashing finished Successfully。

刷完以后的几件小事

写完以后板子停在临时刷写环境里,不会自己重启。那个环境里的 adbd 不支持 adb reboot,要用 adb shell reboot -f。结果它重启以后又回到了恢复模式,usbipd list 里 APX 又出现了,1-7 的守护还把它抓回了 WSL。原因是刷写环境留了一个"重启进 RCM"的标志,正常收尾会清掉,强制重启跳过了那一步。只按一下 Reset(不碰中键),它就从 NVMe 正常启动了。

USB 身份的四次变化
图 4:板子的 USB 身份在整个过程里换了四次,下排是主机端每一步看到的东西。

正常启动以后那根 USB-C 线就变成了网线。Windows 11 对 L4T 这个 0955:7020 复合设备的 RNDIS 是原生支持的,电脑自动拿到 192.168.55.100,板子固定是 192.168.55.1(这是 L4T 写死的,和路由器无关);旁边还会枚举出一个 NCM 网卡显示无连接,忽略就好。刚开机头一分钟 ping 可能不通,等一下。SSH 进去:

text
Linux thor 6.8.12-1021-tegra #1 SMP PREEMPT Mon Jun  1 13:25:46 PDT 2026 aarch64
/dev/nvme0n1p1  936G  9.2G  880G   2% /
# R39 (release), REVISION: 2.0

根文件系统已经自动扩到了整块盘。主机名是 thor,mDNS 也在广播,所以插网线进局域网以后用 thor.local 就行。板子还会枚举一个串口(我这里是 COM10,115200),网络全挂的时候是保底。

有人会问为什么不能像手机那样 adb shell。因为 adbd 只活在中间那个临时刷写环境里,正式系统是普通的 Ubuntu,等价物就是 SSH。

还有两个是几天后断电再上电才碰到的:Thor 的 RTC 掉电会回到 3 月,任何对时间敏感的东西(TLS 证书、基于时间的认证)都会莫名失败,先校时再排查;另一个是 curl 的 --noproxy '*' 会让 -x 指定的代理失效,用它测代理连通性会得到一个"假直连"的结果,我为这个白查了三轮。

几条操作上的经验

  • 每次失败重跑之前,先按键回到恢复模式,再清理 WSL 里残留的刷机进程。清理的时候别用 pkill -f 'adb.*server',这条命令自己的命令行里也含这个字样,会把自己杀掉。
  • 从 PowerShell 往 WSL 里发多语句命令,$ 的转义反复出错。写成 .sh 文件再执行,省心。
  • 恢复模式阶段(usbip)和 adb 阶段(Windows 原生)是两条不同的 USB 路径,1-7 交给 usbipd,1-14 必须留在 Windows 侧,不要绑 usbipd。
  • 再刷一次很快:BSP 还在 WSL 的 /root/thor/Linux_for_Tegra,补丁都在,进恢复模式后跑 ./l4t_initrd_flash.sh --erase-all --flash-only jetson-agx-thor-devkit internal(要 root),镜像不用重新生成。

这块板子后来接到了另一个项目里(Polarfire 接口板加相机那一套),那是另一篇的事。

← 上一篇 让 Claude Code 自己去 1688 采数据,再给产品打分:两天实验记录 下一篇 → 让 Claude Code 替运营同事更新竞品监控表:46 个 Amazon / Walmart 链接的一个下午