把 OpenWrt 刷进 ZTE ZXHN E2631:升级包格式、A/B 槽回退与 Wi-Fi 的全过程

8643 字
43 分钟
把 OpenWrt 刷进 ZTE ZXHN E2631:升级包格式、A/B 槽回退与 Wi-Fi 的全过程

本文面向想在这台 ZTE 光猫/路由一体机上跑第三方系统的玩家,记录的是全过程:怎么把原厂固件格式读透、怎么打包、怎么刷、踩了哪些坑、哪些假设被真机推翻、现在能用到什么程度。全文已重写至 v1.3(2026-10-24)时点——很多早期”卡着”的问题现在都有答案了,历史推演保留、现状以最新为准。 适用设备: ZTE ZXHN E2631(Sanechips ZX279128S,双核 Cortex-A9) 适用系统: 自建 OpenWrt SNAPSHOT(内核 Linux 6.18.55),v1.3

适配说明

项目状态:活跃,v1.3 已于 2026-10-24 发布,机器在当主路由日常用。

有线转发走 PPE 硬件流卸载:395 Mbps 持续实测 CPU 仅占 7%;Wi-Fi 开机自启(首次关联死机在 v1.3 长期使用中未再复现)、指示灯状态机、LuCI 在线升级 + A/B 双系统一键切换全部就绪(见第七、八、九章)。

剩一条硬边界:Wi-Fi 吞吐走 CPU 软路径,约 200M 封顶——mt76 开源栈没有原厂 IDM 直通,原厂千兆 Wi-Fi 依赖其私有 4.1 内核引擎,无法搬到 6.18(详见第七章)。

一、要解决的两件事#

拿到这台机器时只提了两个硬需求:

需求内容结果
①产物能直接从原厂系统的 supgrade.html 刷进去✅ 真机验收通过
②利用原厂 A/B 分区机制,在 OpenWrt 里改启动选择标记切回原系统✅ 真机验收通过

剩下的都是工程量:这台机器的 SoC 没有任何上游支持,Wi-Fi 是一颗挂在 PCIe 上的 MT7916,闪存是 128 MiB SPI-NAND。

Important

全过程有一条铁律:永远只写非活动槽,绝不碰槽 0(原厂)。一旦写坏原厂槽,这台机器就只能拆机用编程器救。


二、先把硬件弄清楚#

所有结论都来自原厂系统里的只读取证(/proc/*、/sys/*、设备树、配置空间 dump),不是照着型号猜的。

部件实测规格对移植的影响
SoCSanechips ZX279128S,2× Cortex-A9 rev 1上游无此平台,需自带全套时钟/复位/平台驱动
FPU无 VFP(Features: half fastmult edsp tls)用户态必须软浮点 EABI5,CONFIG_VFP 必须关
内存256 MiB(原厂设备树 reg = <0x40000000 0x10000000>,bootargs 带 mem=256M)早期我按 128 MiB 保守配置,能启动只是因为它是子集
闪存128 MiB SPI-NAND(HYF1GQ4UTACAE),擦除块 128 KiB,片上 ECCJFFS2 的 clean marker 位置是致命细节,见第六节
有线1 个 WAN + 3 个 LAN,全部走 SoC 内置 GPHY不是 H3600 的 4+1,抄板文件前必须改
Wi-FiMT7916(PCIe ID 14c3:7906 + 14c3:790a,跨两个 root port)双 HIF 结构,5G 依赖第二个功能
USB原厂内核 # CONFIG USB is not set探测不存在的控制器会挂死并被看门狗重启,必须完全不带
Warning

网上关于这颗 SoC 的资料不一定对。本文里每个数字都能落到一个偏移或一条命令上,凡是没验证的我都明说”未测”。


三、原厂升级包格式:逐字节读出来的规则#

原厂升级页 supgrade.html 和命令行 fw_flashing 用的是同一个刷写器,包合法就收。先看整片 flash 的落点:内核内建分区表共 15 条(vmlinux 偏移 0x432358),槽 A / 槽 B 的内建表名是 kernel1 / kernel2,两槽 rootfs 是逐字节镜像(运行地址同为 0x40008000),下图偏移全部从 0x0 起算、逐 128 KiB 块比对过命中:

E2631 flash 落点图:内核内建分区表 + 字节比对命中(ZX279128S / 128 MiB SPI-NAND / 15 条内建分区表)
E2631 flash 落点图:内核内建分区表 + 字节比对命中(ZX279128S / 128 MiB SPI-NAND / 15 条内建分区表)

至于单个槽内部,一个升级包的结构是这样:

槽基址 + 0x000000 内核区(我们声明 4 MiB)
槽基址 + 0x400000 rootfs 区(含 squashfs 之后的 rootfs_data 尾部)
槽基址 + 0x1400000 版本头块(512 字节 verhdr) ← 我们的槽 1
槽基址 + 0x1560000 版本头块 ← 原厂槽 0 在这一代的位置

头块的位置是从头自己的 +0x30 字段读出来的,不是固定常数:7B5 那代原厂头落在 槽0 + 0x1560000,而我们槽 1 的头在 槽1 + 0x1400000。e2631-fwslot info 会把两边各自的 header@ 与 serial 一起打出来,这就是判”当前跑的是哪一槽”最可靠的读数。

verhdr 的字段表(全部按真机 live 头 + 官方包三方互验出来):

Important

下面所有偏移都相对于 512 字节槽头的起点。在包文件里槽头起点是 0x14, 所以”文件里的偏移 = 表中值 + 0x14”。这个双重基准坑过我一次,写在这里免得别人再踩。

槽内偏移字段实测值(原厂 7B5 / 槽 A)
+0x08/+0x0cupgradeKey0x1351 / 0x5211
+0x10版本串V1.0.0.7B5.8000
+0x30本头块在槽内的落点偏移0x1560000
+0x34内核长度0x33fdcc
+0x38内核在包内的偏移0x234
+0x3c内核 CRC320x9ef7c246(重算匹配)
+0x40rootfs 长度0x1220000
+0x44rootfs 在包内的偏移0x340214
+0x48rootfs CRC320x3df85ee2(对密文算,重算匹配)
+0x6c产品名ZXHN E2631 V1.0.0
+0xa4CRC1 = CRC32(hdr[0x00<0xa4>])0x6e61dfdb
+0xa8构建时间串20250925221124
+0xc4/+0xc8flash 容量 / 块对齐0x08000000 / 0x10000
+0xf4内层魔数33333333 66666666 99999999 cccccccc
+0x1e0..0x1ec槽表{0x700000, 0x2300000, 0x2300000, 0x3f00000}
+0x1f0本头块所在槽基址0x700000
+0x1f4serial(选槽依据)33(我们槽 1 是 34)
+0x1f8新版本标志刷写器写 1
+0x1fcCRC2 = CRC32(hdr[0x00<0x1fc>])0xe5183678

全是标准 zlib CRC-32。包文件的整体结构是:0x00 四个魔数 ⇒ 0x14 512 字节槽头 ⇒ 0x214 32 字节槽标记 ⇒ 0x234 内核 ⇒ rootfs 从”声明的内核区长度 + 0x214”开始。

Important

cspboot 只用 CRC1 判断这个头是否有效(不看 CRC2),并且按 serial 比大小选槽: slot0.serial < slot1.serial 或 slot0.serial==0 就起 slot1,相等起 slot0。 所以”换启动槽”= 擦掉含头块的那个 128 KiB 块,再写回改了 serial 与 CRC2 的 512 字节, 载荷一个字节都不用动 —— 需求②正是建立在这条上。 原厂 fw_flashing -v switch 的 bug 就是不擦除就地写:NAND 只能把 1 变 0,serial 不会变大。 serial 是 32 位单调计数,只涨不重置,离回绕还有几十亿次刷写,不用担心。

还有两条落点规则,比字段自洽更重要:

  1. rootfs 的真实落点不是头里写的那个偏移:cspboot 用 rootfs = 槽起点 + ceil(声明的内核长度 / 128 KiB) × 128 KiB,再从头里取 rSize 当长度去算 CRC。 官方四包的 rFileOff 恰好恒等于 0x214 + ceil(kSize/0x20000),两者从不分叉,所以那个字段 “看起来被用了”,其实只是巧合。我们因此把内核区声明成 4 MiB,让推导结果正好落在 槽 + 0x400000, 与设备树一致。
  2. rSize 必须停在 squashfs 末尾:内核会把 squashfs 之后的整块尾部再注册成一个叫 rootfs_data 的分区给 overlay 用,若头里的 rSize 把这段算进校验窗口, 第一次保存配置就毁掉 rootfs CRC ⇒ 下次上电该槽被判无效 ⇒ 表现成”存不住配置、重启就回原厂”。
失败类型有没有自动补救
头部 CRC1 不合格该槽被忽略
内核段 / rootfs 段 CRC 不合格自动回落另一槽(串口实录 Romfs CRC check failed … Total found 2 firmwares(valid:1), start from ver 0)
内核跑起来之后挂死没有,serial 比大小、没有尝试计数,反复上电只会回到同一个挂死点
Warning

上面这张表决定了试错成本:几何错、内容错、写坏块都不会变砖;但”起来之后挂死”必须有串口或 failsafe 退路才敢试。


四、打包与门禁:别把 40 分钟的构建当测试#

打包在工作区根用 Node 完成,官方模板包不进仓库:

Terminal window
node build/tools/make_fixture.mjs out/fixture
node build/host/release.mjs --dir out/gen1 --out out/e2631-openwrt-xxx.bin

release.mjs 会做两层独立验证,而不是”打完就算好”:

  1. 格式层:重算两道头部 CRC、内核 CRC、rootfs CRC、body 长度、槽预算,并检查推导出的 rootfs 偏移;
  2. 启动层(verify_img.mjs):从设备树反推分区顺序,核对内核内建的 root=、驱动是否内建、命令行是否强制、USB/看门狗是否真的没启用、软浮点、rootfs 里的包清单。

槽工具也在本地做门禁(WSL 里有 gcc、没有 node,所以分两步):

Terminal window
wsl -u builder bash build/wsl/check-kit.sh
PASS: e2631-fwslot compiles clean with -Wall -Wextra -Werror
PASS: accepts the synthesized package
PASS: rejects the same package with one kernel byte changed
Important

重打包时钉出两个事实:包内载荷不是从 0 和 0x400000 开始:0x00 是四个魔数、0x14 是槽头、0x214 是 32 字节槽标记、内核从 0x234 开始,rootfs 从 声明的内核区长度 + 0x214 开始(我们把内核区声明成 4 MiB,所以 squashfs 的 hsqs 落在 0x400214,全文件只出现一次);另外打包本身是确定性的(同一目录打两次字节完全一致),但构建目录重打包出的镜像和板上那份不是同一次构建(内核逐字节相同、rootfs 从超级块 +0x4 的 inode 数开始不同)。所以”板上那一版”必须留好文件本身,不能靠重打包复现。


五、需求①验收:从原厂网页刷进去#

刷完自动重启,起来的正是我们的 OpenWrt —— LuCI 状态页上型号、目标平台、固件版本、内核版本全部对得上,uptime 只有 9 分钟,说明它是刚被写进槽 1 的那一份:

刷完后第一次进 LuCI:型号 ZTE ZXHN E2631,目标平台 sanechips/generic,SNAPSHOT r0-db8b8d4,内核 6.18.55,uptime 9 分钟
刷完后第一次进 LuCI:型号 ZTE ZXHN E2631,目标平台 sanechips/generic,SNAPSHOT r0-db8b8d4,内核 6.18.55,uptime 9 分钟

刷的动作完全在原厂页面上完成:在 supgrade.html 里选中我们的包提交,页面自己刷完并重启——没有串口、没有 tftp、没有拆机。事后从设备侧核对写槽语义:

running slot: 1
slot 0: base 0x700000 span 0x1c00000 mtd5 header@0x1560000 serial=33 version="V1.0.0.7B5.8000"
slot 1: base 0x2300000 span 0x1c00000 mtd6 header@0x1400000 serial=34 version="V1.0.0.8B8.8000" [running] [next boot]

结论:

  • 原厂网页直接接受我们打的包;serial 由刷写侧自己抬到 34(不吃包里的字段);
  • 槽 0(原厂)body 一个字节没动 ⇒ 需求②的回退保险完好;
  • 开机日志出现 mount_root: no usable overlay filesystem found, using tmpfs overlay ⇒ 整槽重写会连 overlay 一起清掉(所以 v1.3 的升级流程里,配置备份走用户自行下载/上传,不做自动迁移)。
Note

早先”刷完必须在 600 秒内 e2631-commit”这一步已经不需要了。那道保险(e2631-trial:到点把原厂槽的 serial 抬高并重启)本意是”刷完起不来也能自己回原厂”,但它让一台没人碰的机器在刷完十分钟后自己跑回原厂 —— 在”有线可用 + 手动开射频”这个功能范围下,意外大于保护,所以 v1.1 起默认不生效。 想把它装回去:touch /etc/e2631-trial-arm。


六、第一次真正启动:五个坑#

坑现象修法
U-Boot 传的命令行原厂 bootargs 是 root=/dev/mtdblock8 rootfstype=jffs2 init=/bin/sh,会把 PID 1 换成 shell内核内建命令行 + CONFIG_CMDLINE_FORCE=y
ofpart 的分区命名内核会把 squashfs 之后的空间自动切成 rootfs_data 给 overlay头里的 rSize 改成 ceil(squashfs/128K),让校验窗口和可写区不重叠;DTS 里删掉自己声明的同名分区
JFFS2 clean markerSPI-NAND 片上 ECC 每次 program 都会重算页 ECC,同一页写两次就变成不可纠补丁 029/030:marker 放数据区而非 OOB。没有它,overlay 挂不上、机器卡在只读根
reboot 卡死发完 reboot 22/23 端口双双失联 160 秒syscon-reboot 节点没被建成设备(topcrm 走 CLK_OF_DECLARE,子节点被跳过),DTS 里补上
四个口随机 MAC没人引用 macaddr_tag_1eDTS 给 4 个口绑 nvmem cell(tag 偏移 +0x1e,本机基址 f8:73:1a:d3:...)

灯的硬件事实(实测,全是 ACTIVE_LOW):40=绿、60=红、8=蓝,LED 通道名是”颜色<功能>”格式(如 red:status、green:wan-online)。v1.0~v1.2 时代的灯语服务其实因为一个匹配 bug 从来没真正干过活,v1.3 把整套灯控重写了(见第九章)。

MDIO 上 4 个内置 GPHY 在 0x0a..0x0d 全部应答,端口名来自 DTS 的 label(驱动里 strscpy(netdev->name, label))。


七、性能:从”300M 软件路径”到”PPE 硬件卸载”#

这一章的时间跨度最长,也是被证伪结论最多的一章——先按时间线把当时的测量原样保留,再说清每个数字后来怎么样了。

7.1 拓扑#

PC 192.168.1.177,默认网关 192.168.1.1 —— 而 192.168.1.1 的 ARP 是 f8-73-1a-d3-ef-a8,就是盒子自己的 LAN MAC(铁证:它 br-lan 的链路本地地址 fe80::fa73:1aff:fed3:efa8 正是这个 MAC 的 EUI-64),家里路由器在 WAN 侧:

br-lan 192.168.1.1/24(成员 lan1 lan2 lan3)
wan 192.168.5.18/24 default via 192.168.5.1 dns 192.168.5.1
wan inet6 2409:8a4c:...:fa73:1aff:fed3:efa9/128(动态,全球地址)

LAN 侧只有 ULA fdf7:c5eb:3647::/60 —— 上游没委派 PD,所以 LAN 客户端拿不到全球 IPv6。

NAT 是通的(ping 223.5.5.5 3/3、RTT 35 ms、nf_conntrack_count=159)。

Note

我一度怀疑 dnsmasq 坏了,因为 dl.openssl.org 回 NXDOMAIN。拿上游 192.168.5.1 直接查同样是 NXDOMAIN ⇒ 不是我们的问题。这条纠正很重要:差点把一条正常行为写进”已知缺陷”。

7.2 软件路径的 ~300 Mbit/s 封顶(v1.2 时点,诊断仍成立)#

每流 209,715,200 B,从 PC 的 HTTP 服务拉,HZ=100:

情况用时合计CPU0 忙碌率
1 流(中断在 CPU0)6 s279 Mbit/s(562−51)/600 = 85%(softirq 57%、sys 21%)
4 流,rps_cpus=1(出厂状态)22 s305 Mbit/s(2200−295)/2200 = 87%(softirq 65%)
4 流,rps_cpus=3(两核)22 s305 Mbit/s81%
4 流,rps_cpus=2(RX 交给 CPU1)21 s319 Mbit/s(+4.6%)80%
4 流,rps_cpus=3 + RFS22 s305 Mbit/s—

原因全在驱动的卸载位上(ethtool -k lan1):

rx-checksumming: off [fixed]
tx-checksum-ip-generic: off [fixed]
tx-scatter-gather: off [fixed]
tx-tcp-segmentation: off [fixed]
receive-hashing: off [fixed]

ethtool -l/-C 也不支持 ⇒ 单 RX 队列、无 RSS、无校验和/TSO/SG 卸载,每个包都要在一个核上走完 IP/TCP 处理。双核是活的,但 NET_RX 软中断计数是 14150 : 282 这种量级差。这段诊断到今天仍然成立——它描述的是CPU 软件转发路径的真实成本。

当时还有一条”改 uci 就能试”的候选:fw4 里没有 flowtable 对象、defaults 里没有 flow_offloading,而 /proc/modules 里 nft_flow_offload 等模块都已加载。这条在 v1.3 兑现了,见下节。

7.3 PPE 硬件流卸载打通(v1.3)#

真相是:平台驱动 zx279128s-ppe.c 一直带着完整的 PPE 硬件流卸载实现(分类器规则、流表、trap 全是真编程),缺的只是 nft flowtable 的用户态语句包和防火墙开关。v1.3 补上 kmod-nft-offload + flow_offloading=1(含 hw),硬件路径就通了。

验证用的是三重铁证(OPPO 商城 CDN、32 线程、真机实测):

395 Mbps 持续下载
CPU 占用 7%
WAN 软件计数器纹丝不动

第三条最关键:PPE 直转的包不过 CPU 网络栈,/proc/net/dev 的 WAN 计数不动恰好证明流量真的在硬件里走。395M 是 CDN 给单 IP 的上限,不是路由器的——LAN↔WAN 千兆满载数字仍待一台不限速的服务端来测。

Warning

“上限 200M/300M”这个结论我错了很多轮:腾讯系 CDN 单 IP 限速,换三家 CDN 混合、48 并发、CPU 始终 10% 也压不上去,一度写成”PPE 上限 200M”;换 OPPO 商城 CDN 立刻 395M。测转发速率先确认服务端不限速,CDN 的假象会把路由器瓶颈顶替掉。

还有一条架构性边界:Wi-Fi 客户端的流被 PPE 拒绝(ppe_add_skip:入口必须是交换引擎自己的口),所以无线侧永远走 CPU 软路径、~200M 封顶——这是 mt76 开源栈与原厂私有引擎的架构差距,不是调参能解决的。原厂千兆 Wi-Fi 靠的是 IDM/NPP 直通,那些二进制模块绑死它私有的 4.1 内核,搬到 6.18 没有可能。


八、Wi-Fi:从”开机不加载”到”默认自启”#

这台机器的无线是 MT7916,两个 PCIe 功能分别挂在两个不同的 root port 上:0000:01:00.0 = 14c3:7906(主)和 0001:01:00.0 = 14c3:790a(ext-HIF)。

Important

实测推翻了”5G 依赖 ext-HIF”的旧说法:只 bind 7906,两条 phy 都会出来—— /sys/class/ieee80211/phy0|phy1/device 都指向 0000:01:00.0,iw phy 里 phy1 = Band 2(5G)、 phy0 = Band 1(2.4G)。也就是说 5G 用主功能就有,790a 不需要绑(绑上反而回到”关联即死”那个形态)。 netifd 用 path 的 +N 后缀区分同一器件上的两条 phy,接口名是 phy1-ap0(不是 wlan0)。

8.1 曾经卡住的两件事,现状#

① 冷启动后第一次固件下载,约 1/3 抽签 —— 这条仍然是 open。同一个镜像三个不同 boot 的手动 bind:up=251 挂、up=336 成、up=307 挂,与”绑得早晚”无关;成功那次从打完 WA 版本号到注册 led 只隔约 0.1 秒。v1.0~v1.2 的交付形态因此是射频默认不上(kmod-mt7915e 的 AutoProbe 被删掉):把不确定性和”进不去系统”解耦,基线开机是确定性的。v1.3 换了策略——自启默认开,但带自保护:上次启动没完成就落一个 pending 标记,下次跳过自启,任何一次挂死只损失一次断电重上,不会把 ssh 关在门外。

② 客户端关联整机硬死 —— 这条在 v1.3 里未再复现。当时的形态很凶:心跳服务在 up=159.49 之后整条消失,aer= 一直是 0,没有 oops、没有 RCU stall,死的时候 sta=0(死在关联握手中)。v1.3 长时间日常使用(多客户端、双频)再没出现过。诚实地说没有做对照实验,不知道该归功给 country=CN、延迟绑定、PL310 I/O 读取保护(v1.3 合入)中的哪一个,还是它们的组合。

8.2 v1.3 的无线交付形态#

  • 开机自启:START=95 + 延迟 25 s(时序方案来自社区贡献者 tigaotg 的交接 PR,在此致谢),真机验证双频自动广播、改 SSID/密码后重启保留配置。不想自启:touch /etc/e2631-wifi-noautoload。
  • 首次开机的 SSID 从原厂 tag 分区读这台机器自己的出厂名(本机为 ChinaNet-ygckk9 / ChinaNet-ygckk9-5G,成对识别、自校验、不钉死字节偏移)。首刷版曾因 busybox tr 对字符类补集静默无效回退成 E2631——改成八进制区间 \40-\176 后真机复测通过。
  • 首启开放网络、无密码,管理员密码同样留空由用户自己设——不再搞 MAC 派生密码那套。
  • 5G 发射功率 20 → 23 dBm:mt76 默认 country 00 全频封 20dBm,两条配置路径都写 country=CN 后 5G 解锁。
  • 校准数据一直从原厂 wifi 分区运行时读取(11-mt76-caldata 挂载 mtd JFFS2 抽 caldata 文件),无烘焙副本。

8.3 排障:手动拉起射频(两道闸门)#

镜像里有两道独立的闸门:驱动不自动加载(AutoProbe 被删)、以及 squashfs 里默认没有 /etc/config/wireless。所以”起驱动”和”发射”是两件事,排障时照这个顺序敲(已实测出 SSID):

Terminal window
/sbin/e2631-wifi-on 0 # 门 1:stage caldata -> 关 autoprobe -> modprobe mt7915e -> 只 bind 14c3:7906
iw phy # 见到 Wiphy phy0/phy1 才算这一步成功
wifi config # 门 2:让 netifd 生成 /etc/config/wireless
for d in $(uci show wireless | sed -n 's/^\(wireless\.[^=]*\)=wifi-device$/\1/p'); do uci set $d.disabled=0; done
for s in $(uci show wireless | sed -n 's/^\(wireless\.[^=]*\)=wifi-iface$/\1/p'); do
uci set $s.disabled=0; uci set $s.mode=ap; uci set $s.network=lan
uci set $s.ssid=E2631; uci set $s.encryption=none
done
uci commit wireless
wifi down; sleep 1; wifi up; sleep 3
iw dev # 期望 phy1-ap0 type AP ssid E2631 channel 36 (5180 MHz)

起来之后盒子侧和手机侧各看一眼:

LuCI 无线页:radio1 = MediaTek MT7916AN,信道 36(5.180 GHz),SSID E2631,BSSID 82:0C:43:26:60,20.00 dBm
LuCI 无线页:radio1 = MediaTek MT7916AN,信道 36(5.180 GHz),SSID E2631,BSSID 82:0C:43:26:60,20.00 dBm

手机侧同一时刻扫到的 E2631 5GHz —— 空口真的有 beacon,不是只在驱动里"看起来有"
手机侧同一时刻扫到的 E2631 5GHz —— 空口真的有 beacon,不是只在驱动里"看起来有"

三个只在真机上才暴露的细节,都写进脚本了:生成器写的是命名段 radio1/default_radio1 (不是匿名 @wifi-iface[n]),而且因为本机 board.json 没有 wlan.defaults,生成的接口带 disabled '1'; 启用必须 wifi down; wifi up,裸 wifi reload 会把接口拆掉不重建(症状是 iw dev 空,但 phy 和 固件都好着,极像”卡掉线”);同一 phy 上再建第二个 band 的 radio 时,path 要用不带 +N 后缀的那条, 复制 5G 的 +1 会让两个 radio 抢同一条 phy(LuCI 显示”类型 unknown / 信道 0”,都不发射)。

另外,镜像里的 /sbin/e2631-fwstate(读 MT7916 的 FW_STATE 位)在这个内核下用不了: 它要 /dev/mem,而本内核没开 CONFIG_DEVMEM(报 fwstate: no /dev/mem)。看驱动状态请用 iw phy / dmesg / cat /proc/interrupts;免刷机的替代是下面那个探测模块。

8.4 被证伪的假设(都是拿真机烧出来的)#

假设判据结论
链路被 quirk 顶到 Gen2 是病因补丁 031 生效后 EP LnkSta=0x1011、根端口 0x3011,与原厂一致,冷 bind 仍挂证伪
linux-firmware 的 20240823 blob 太旧原厂 20250103 blob:同 boot 里第二次下载能过,冷启动第一次仍挂证伪
FW_STATE 1 秒超时太短换成 8 秒的模块,第一次 bind 照样静默硬挂证伪
在 rc.d 里加载太早,延后就好延到 up=128.65 手动加载,死点一样证伪(延后买到的是”开机确定性”)
dual-HIF 是凶手只绑 7906(790a=none)时”关联即死”仍复现排除为唯一原因
MPS/MRRS/ExtTag 与原厂不同真机 dump:我们 DevCtl=0x291f、原厂 0x2910,MPS=128 / MRRS=512 / RO=1 / ExtTag=1 全同,只差错误上报使能位证伪
原厂也走私有 blob 组合device/backup/mt7916.ko 里 mt7915_*/mt76_* 符号全为 0,字符串是 DBDC_card%d.dat ⇒ MTK 私有全 MAC 驱动“原厂 blob + mt76”不是原厂组合

8.5 不刷机就能读芯片寄存器的办法#

/dev/mem 这条路走不通:出厂内核 .config 里就是 # CONFIG_DEVMEM is not set,而 generic 与 target 两个片段都写着 CONFIG_DEVMEM=y,全树没有任何片段要求它是 n(drivers/char/Kconfig:311 只是 bool / default y)—— 请求是在 kconfig 解析阶段被丢掉的,原因未查清。busybox 也没编 devmem applet。

于是写了个探测模块(out/regprobe/,构建脚本 build/wsl/build-regprobe.sh)。两个关键细节:

Caution

冷卡的 Command=0x0140(Memory Space Enable 还没置),不先写 PCI_COMMAND |= PCI_COMMAND_MEMORY 的话每次 MMIO 读都返回 0xffffffff,看起来和链路死掉一模一样。 另外配置空间偏移 0x12 在头里是 BAR0 的高半(读到 0x1030 正好是 0x10300000 的上 16 位),不是 LnkSta;LnkSta 在 PCIe capability + 0x12(根端口 cap 在 0x70,主功能 cap 在 0x80)。

读到的基线:上电后从未绑过驱动时 MMIO 读得动,MT_TOP_MISC=0x00000000、FW_STATE=0(INITIAL),第二个功能也是 0 ⇒ 卡死之前总线是活的,死的是”第一次下载”这个动作本身。

Warning

这台板子上”改链路状态”的动作都可能是致命的:运行时手写 LnkCtl2.TLS + Retrain Link 会让 DLLLA 从 1 掉到 0 并当场挂死,而且这一步当时被放进 START=05 ⇒ 之后每次开机都进不去系统。任何这类实验之前,先想好怎么进去(failsafe 里 e2631-fwslot switch + 从原厂侧重刷是可用退路)。


九、v1.3:把”能刷”变成”能用”#

v1.2 之前这台机器是”能刷进去的验证平台”,v1.3 把它推成了可以当主路由日用的系统。这一章是除性能与无线之外的全部增量。

9.1 LED 灯控重写#

原守护脚本的 find_led 按”颜色结尾”匹配,而 LED 通道名是”颜色<功能>”格式(red:status、green:wan-online)——从第一天起就是空操作,灯语从来没真正执行过。修复成双向匹配后重写整套状态机:

  • 开机 < 45 s:红灯慢闪(600 ms);
  • 有默认路由(联网):绿灯常亮;
  • 无默认路由:红灯常亮;
  • 联网状态变化时红→绿自动切换(2 s 轮询默认路由)。

/etc/diag.sh 用 overlay 覆盖:启动段慢闪、done 态不再无条件点绿灯(把”是否在线”的语义留给状态机)。

9.2 时区与 NTP#

时区钉死 Asia/Shanghai(CST-8),NTP 源换国内(ntp.aliyun.com / cn.pool.ntp.org)。之前系统跑在 UTC 上,时间相关的一切(cron、日志、LuCI 显示)都要手动掰 8 小时。

9.3 主题与登录页白屏(两个 CRLF 坑)#

默认主题恢复为 Argon(上游 ucode 模板 + 草地背景 bg1.webp)。这一路踩了两个同根不同形的坑,都记进了失误清单:

  1. vendor 化主题时对全目录 sed -i 's/\r$//' 清 CRLF,没排除二进制——PNG 签名 89504e47 0d0a 被砍成 0a、WebP 的 RIFF 头短 2 字节,所有图片损坏,登录页白屏;
  2. 修上条时改成”按扩展名白名单清 CRLF”,又漏掉无扩展名的 uci-defaults 脚本(30_luci-theme-argon 带 CR 执行失败 → 主题不生效,登录页再次白屏回落 Bootstrap)。

教训一句话:清 CRLF 只能按二进制扩展名黑名单排除,不能按文本扩展名白名单包含。

9.4 LuCI:备份/升级页四标签 + 在线升级#

系统 → 备份/升级页改造成 4 个标签:本地刷写 / 在线升级 / 槽位管理 / 配置备份。槽位管理页一键切回原厂,不用再敲命令行。

  • 在线升级(e2631-upgrade check / run):GitHub Release 检查 → 下载 → sha256 + fwslot check 双校验 → sysupgrade → platform.sh → fwslot flash --slot 1。除槽 1 外不写 flash 任何区域,原厂槽永远安全;ghproxy 镜像加速可配(uci set e2631.upd.mirror=...)。
  • 配置备份改为用户自行下载/上传(整槽重写会连 overlay 一起清掉,自动迁移反而是坑)。
  • 两个实现层面的坑:rpcd 后端脚本必须实现 $1=list(方法表)和 $1=call $2=方法 的 shell 协议,写错则 ubus list 有对象但调用 Not Found;LuCI 自定义 section 必须走 NamedSection.prototype.render.apply 再 appendChild,直接覆盖 render() 会丢标签容器、内容泄漏到第一个标签。

9.5 47 MiB userdata 分区(可选 extroot)#

分区表新增 userdata(0x3f00000..0x6e00000),这个窗口在四个原厂版本(4B4/6B5/7B5/8B8)下都验证过空闲(升级包头槽表一致、原厂内核分区止于 0x2fc0000、编程器全片 dump 中该区间擦空)。挂载采用三重门控:

  1. preinit 只挂 UBI,不格式化;
  2. /etc/init.d/e2631-userdata 只有读到 UBIFS 超级块魔数(设备上没有 od/hexdump,用 md5 常量 2c877f78… 判)才把 fstab 挂载项置为 enabled——开机永远不会格式化这个分区;
  3. /sbin/e2631-userdata-init 初始化需要显式 E2631_CONFIRM。

心跳诊断保持 opt-in(touch /etc/e2631-hb-enable)。槽位工具在 /usr/sbin(不是 /sbin,这个路径坑过 sysupgrade,见失误清单)。

9.6 网络微调与兜底#

fw4 每次 ifup 重载 flowtable 时,驱动会把 rps_cpus 重置回 1——解法是 iface hotplug 快路径重新应用 + cron 定时跑 /sbin/e2631-netune-apply 兜底(最后写者赢)。同时:PAUSE 帧通告关闭、RPS/XPS 指到 CPU1、gmac IRQ 亲和中。


十、仓库与版本#

仓库角色
Zlion-Y/zte-e2631-openwrt唯一活跃的仓库。overlay/(板级 DTS、base-files、内核/mt76 补丁)、wsl/(本地构建路径与取证脚本,wsl/INDEX.md 分组)、board/(板端脚本与原厂侧刷写 plan)、host/(Windows 侧打包/投递/串口工具)、docs/(两份中文工作文档 + refs-local 分析笔记)
Zlion-Y/openwrt-sanechips-mirror上游 PR#25469 的 fork,构建基线钉在 db8b8d41…
Zlion-Y/e2631-mainline-zx279128s只留这块板子的主线内核改动:相对上游 v6.18.38 改 6 个文件、加 4 个文件。“U-Boot 里 tftp 载入固件” 通路退役在 tag v0.1.1
Zlion-Y/e2631-openwrtstage-1 脚手架(已 archive、只读),历史已并入上面活跃仓库的 stage1-scaffold 分支,仓库里只剩一个指针

主线仓库清得彻底:那颗不相干的 SoC(ZX279133 / SR1010,arm64)的 110 个文件全删,它漏在公共文件里的部分退回上游(SPI-NAND “fail-closed”、XPCS 胶水、arm64 dts、xhci CCI 钩子、DSA RTL8372N tag)。清完拿 blob 哈希跟 gregkh/linux 的 v6.18.38 逐文件比过:改 6 个、加 4 个、上游一个不缺。

顺手逮到的两个坑值得单独记:

  1. make dtbs 从来没进过板级目录 —— arch/arm/boot/dts/Makefile 里根本没有 subdir-y += zte,以前能出 dtb 纯粹是因为把目标点了名。
  2. Windows 大小写不敏感的 checkout 装不下内核里那批只差大小写的同名文件(xt_DSCP.c 与 xt_dscp.c 等 13 组),第一次提交时它们就互相覆盖了内容。这种损坏在磁盘上永远看不见,修它只能用 git update-index --cacheinfo 按上游 blob 写回索引。

还有 CRLF:这份树在 Windows 工作副本里是 CRLF,而 kconfig 直接解析失败(Documentation/Kconfig:13: unknown statement "If")。所以验证构建一律把 git archive HEAD 物化成 LF 树再编——zx279128s_e2631_defconfig 零警告,zImage 9,318,912 B。

CI 彻底删掉了,构建只在本地 WSL 跑;原本 CI 检查的东西变成两条本地命令(见第四节)。产物由本人本地构建后发到 Release(host/publish.mjs:建 Release、传包 + SHA256SUMS,回读 GitHub asset digest 跟本地 sha256 比对)。

发出去的四版:

版本内容sha256 前 8 位
v1.0第一次能刷能启动;没有 e2631-wifi-ap;那道十分钟保险还开着86012481
v1.1加 e2631-wifi-ap(有 bug:只认匿名段);保险改成默认不生效a6295d4a
v1.2修好那个脚本(命名段、+N path、wifi down/up)02b35106
v1.3PPE 硬件加速、Wi-Fi 自启 + 出厂 SSID、LED 状态机、LuCI 四标签 + 在线升级、Argon 主题、时区/NTP——当前版本edfefb95

前三版内核段逐字节相同、差异都在 rootfs;v1.3 内核本身也有更新(PL310 I/O 读取保护等),rootfs 新增 nft-offload 内核模块与全套脚本/界面。v1.0→v1.2 期间有人怀疑”v1.1 不挂、v1.2 挂”,把三个包各自解出 rootfs 逐文件比哈希——mt7915e.ko、固件 blob、e2631-wifi-on 三版完全相同,会挂的那条路径字节级一致,所以那是冷启动抽签,不是版本回归。


十一、已知边界与下一步#

仍未解决的(诚实清单):

边界说明
Wi-Fi 吞吐 ~200M架构性:Wi-Fi 流被 PPE 拒绝(ppe_add_skip),走 CPU 软路径。原厂千兆靠私有 IDM/NPP 引擎,无法移植
冷启动 1/3 抽签MT7916 第一次固件下载约 1/3 概率挂;v1.3 用 pending 标记自保护(挂一次→断电重上→下次跳过自启保住有线)
PPPoE 上行可能不走硬件路径(同族端口平台踩过这个坑),PPPoE 环境待验证
PPE 流表 384 条占满自动回落软件路径,正常家用远远用不满
LAN↔WAN 千兆满速PPE 已证 ≥395M(受 CDN 限制),真实上限需要不限速服务端
镜像绑死槽 1内核命令行硬编码 root 分区在槽 1 窗口,镜像刷进槽 0 会 panic——所有历史版本都这样

下一步(按性价比):

  1. slotfix v2 槽无关引导:cspboot 的 bootpara 被 zImage 解压器页表踩掉(0x40007000 dump 实证),ATAG 列表也因无 memreserve 被内核复用——需要 U-Boot bdinfo 的 bi_boot_params 地址 + DT memreserve + ATAG 解析,让镜像刷哪个槽都能起。
  2. 冷启动抽签定位:把 FW_STATE 采样塞进驱动下载流程每一步(一次开机分清”等待太短 / WACPU 没起来 / MMIO 已不应答”三种死法);绑驱动前显式复位芯片(≥100 ms,“可能致命”类,先想好 failsafe 退路);反汇编原厂驱动的固件 bring-up(mt7916.ko、kernel1.bin 是明文 uImage,不需要真机,安全)。
  3. LAN↔WAN 千兆转发数字:找不限速的服务端把 PPE 真实上限测出来。
  4. PPE 深度调优(可选):BMU 缓冲池、hash 表容量、队列深度寄存器——可能再抬一截。

十二、我自己的失误清单(值得后来的人警惕)#

失误代价
把配置空间偏移 0x12 当成 LnkSta 读,宣布”Gen1/Gen2 没差别”一条错误结论写进了文档,后来按 capability 结构重读才纠正
两次用 ssh 断开判定”死机”,其实网页还能进差点白拆一次机;此后判死一律用端口探测
把改链路状态的命令放进开机脚本之后每次开机都卡死,只能走 failsafe 翻回原厂重刷
一次实验同时动两个变量(rmmod + 起 AP)白死一次电,什么都没证明
手写补丁没按真实源码生成malformed patch at line 37,此后一律用生成器 + APPLIED == GENERATED 见证
把带明文 telnet root 口令的 board/ 目录整体提交并推到了公开仓库145 个文件里的口令进了历史。当下能做的都做了:全仓脱敏成 ${ZTE_TELNET_PW}、工具改从环境取值、加端到端见证;历史用 git filter-branch 全量改写后强推(74 个提交、3278 个 blob 复查 0 命中)
文档里写”心跳日志已轮转""两个 radio 出厂是关闭的""/sbin/e2631-fwslot”核对时全部不成立:脚本没有轮转、radio0 实际 disabled='0'(安全靠的是驱动不加载)、工具在 /usr/sbin
把”无线配置生成器在本机直接退出”当成结论拿构建期的空 /etc/board.json 当运行期状态。真因是那个”找接口”的 sed 只认匿名段,而生成器写的是命名段
让用户跑 /sbin/e2631-fwstate 看芯片状态它要 /dev/mem,这个内核没开 CONFIG_DEVMEM,在镜像里根本跑不起来。工具是死的,我却当成能用的递出去了
给 2.4G 复用了 5G 那条 +1 path同一 phy 被两个 radio 抢,LuCI 里”类型 unknown / 信道 0”,两个都不发射
发布工具的 tag 写死成 v1.0一次正文更新把 v1.2 的内容打到了 v1.0 的 Release 上(已改参数化,并逐个 Release 回读核对)
inspect-rootfs.sh 从不解压就报内容读的是上一次留在 /tmp 的目录,永远报旧 rootfs;差点据此白重编一次
只 make package/base-files/clean 不跑 apply.sh 就重打overlay 新文件根本没进构建树,产物 md5 与上一版逐字节相同 = 白编
(v1.3 一轮新增,以下 7 条)
vendor 主题时 find . -type f -exec sed -i 's/\r$//' 不排除二进制所有 PNG/WebP 损坏→登录页白屏,查了三轮才定位到 RIFF 短 2 字节
修上图时按扩展名白名单清 CRLF漏掉无扩展名的 uci-defaults 脚本→再次白屏(回落 Bootstrap)
把 fwslot 写死 /sbin(实际 /usr/sbin)sysupgrade ramfs 阶段找不到二进制,两次升级静默没写成
自造 01.png 渐变背景塞进 background/覆盖了上游默认选择逻辑,草地被顶掉(bg1.webp 才是默认,background/ 应留空)
set -- $(latest) 判失败无网时显示空 tag 而不是报错
用 /proc/net/dev WAN 计数器测 PPE 转发速率PPE 直转的包不过 CPU 网络栈,计数器纹丝不动→误判”流量没过路由器”
腾讯 CDN 48 并发测出 200M 就下结论”PPE 上限 200M”换 OPPO CDN 立刻 395M——CDN 单 IP 限速的假象当了路由器瓶颈

十三、命令速查#

从 OpenWrt 切回原厂(需求②):

Terminal window
e2631-fwslot info
e2631-fwslot switch
reboot

串口上这三条照敲的实录(switch 之后原厂槽 0 的 serial 被抬到 43,reboot 就回原厂了):

串口实录:e2631-fwslot info 显示 running slot 1,switch 后 slot 0 header 的 serial 抬到 43,reboot 回原厂
串口实录:e2631-fwslot info 显示 running slot 1,switch 后 slot 0 header 的 serial 抬到 43,reboot 回原厂

无线:v1.3 默认开机自启(约 25 s 后双频起来)。不想自启 touch /etc/e2631-wifi-noautoload;排障手动拉起见第 8.3 节。

在线升级(不动原厂槽):

Terminal window
e2631-upgrade check # 查 GitHub Release 最新版
e2631-upgrade run # 下载 + 双校验 + 刷槽 1 + 重启
uci set e2631.upd.mirror=https://mirror.example/https://github.com # 可选:镜像加速

看驱动状态(/sbin/e2631-fwstate 在无 CONFIG_DEVMEM 的内核里用不了):

Terminal window
iw phy
dmesg | tail -40
cat /proc/interrupts

看槽与几何:

Terminal window
cat /proc/mtd
e2631-fwslot info

判死活(别只看 ssh):

Terminal window
node out/probe.mjs 192.168.1.1:22 192.168.1.1:80 192.168.1.1:23

文档版本: 2026-10-24(v1.3,全文重写至 v1.3 时点) 适用设备: ZTE ZXHN E2631(Sanechips ZX279128S,256 MiB RAM,128 MiB SPI-NAND) 适用系统: 自建 OpenWrt SNAPSHOT(Linux 6.18.55),v1.3 = 当前已发布版本

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
把 OpenWrt 刷进 ZTE ZXHN E2631:升级包格式、A/B 槽回退与 Wi-Fi 的全过程
https://blog.zlion.top/posts/e2631-openwrt-port-log/
作者
Shiyan
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Firefly 博客接入 Pages CMS:浏览器里写文章,不用本地环境
博客搭建给 Firefly(Astro)博客仓库放一份 .pages.yml,接入 Pages CMS 后在浏览器里就能写文章、传图片、改独立页面,保存即提交 GitHub 并自动部署;附完整配置与踩坑记录
2
个人主页重写:Vue 3 毛玻璃 Bento + 洛雪音源
博客搭建用 Vue 3 + Vite 重写个人导航主页:唯一运行时依赖是 vue,构建产物 gzip 约 60KB。毛玻璃 Bento 布局 + 全配置驱动的「探索更多」面板(新闻/热榜/音乐/Epic 限免/历史上的今天/站点监控);音乐播放器接上洛雪音源,自定义音源脚本跑在 serverless 函数里。零环境变量部署到 Vercel,fork 改一个 config.js 就能变成你自己的主页
3
fileserver:单文件自建文件服务器(GO)
运维用一个 Go 编译的二进制文件在服务器 / NAS / 路由器上自建文件服务:分片断点续传上传、限时限次分享链接、文件夹分享与静态网页发布、WebDAV、媒体相册预览、一键自更新,零运行时依赖。另有一个介绍页站点,先把功能和部署方式讲清楚再让你下载。
4
中兴巡天AX3000 (E2631) 主线 Linux 内核移植全记录
路由器从固件解密、SoC 逆向到 6.18.38 主线内核上板验证的完整记录,覆盖 mihomo 全功能适配、Windows 交叉编译与开源复现流程
5
Z500/Z503/Z507升级小方糖公版系统
路由器记录 Z503 通过 Telnet 扩容分区,升级为公版的过程.
随机文章随机推荐

评论区

Profile Image of the Author
Shiyan
Hello, I'm Shiyan.
公告
欢迎来到我的博客!
分类
标签
最新动态
站点统计
文章
13
分类
6
标签
43
总字数
39,065
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0