将Arm飞牛从 eMMC/TF 卡无损迁移至外部存储(NVMe/USB/SATA/TF)的完整方案 —— 适用于瑞芯微 RK 系列平台(含小容量盘适配)

将 fnOS 从 eMMC 无损迁移至 NVMe SSD

日常用 ARM 设备,总习惯把固件刷进 eMMC 或者 TF 卡。eMMC 读写慢,寿命有限,用久了总觉得差口气。我手头有块 NanoPC-T4,给它刷了 Arm 飞牛固件简单体验了一下,就琢磨:要是能把系统挪到 NVMe 上,应该会爽很多。NanoPC-T4 正好有个 NVMe M.2 插槽,把系统装到外部存储上,就能绕过 eMMC 这个瓶颈。我去翻了友善官方的 eflasher-multiple-os 固件说明,它确实可以把根文件系统写到 NVMe 或 USB 设备里,但我不确定它能不能兼容飞牛的 rootfs。而且官方固件内核太老了(4.19),新特性和 Docker 完整支持都享受不到。好在 ARM 设备的引导思路是相通的,自己研究了一下,把飞牛系统迁移到了 NVMe 硬盘上。下面就是完整的折腾记录。

416b1551fe0532c502766cd8d65da302

先搞清楚 ARM 板子是怎么启动的

以 RK3399 为例,芯片内部固化了一段不可更改的启动 ROM(BootROM)。上电后,BootROM 会按顺序扫描可启动设备(一般是 SD 卡 → eMMC → SPI Flash),找到有效的 U-Boot 就加载执行。U-Boot 负责初始化内存、时钟等,然后从某个设备(通常是 eMMC 或 SD 卡的分区)加载内核(kernel)和设备树(dtb)。内核跑起来之后,再根据 root= 参数去挂载根文件系统。

关键点:RK3399 的 BootROM 不认识 NVMe。U‑Boot 和内核镜像必须放在 eMMC 或 SD 卡上,但根文件系统可以放在任何内核能驱动的地方——当然也包括 NVMe。顺着这个思路,整个方案可以拆成两步:

  1. 准备 rootfs:在 NVMe 上建好分区,把 eMMC 上的根文件系统完整复制过去,并处理好 UUID 冲突。
  2. 修改 PARTUUID:修改 eMMC 上的内核引导参数,让系统从 NVMe 的 PARTUUID 启动。

逆向分析:飞牛系统的引导逻辑

为了验证上述理论,并确保迁移方案万无一失,我们需要先拆解飞牛官方固件 fnos_Mainland-PE_arm_1.0.0_nanopc-t4_251.img,看看它的具体的引导逻辑

1. 查看镜像整体布局

在 Linux 主机上,我们首先使用 fdisk 查看镜像的分区表信息:

1
2
3
4
5
6
7
8
9
10
11
root@dev-vm:/home/dev# fdisk -l fnos_Mainland-PE_arm_1.0.0_nanopc-t4_251.img
Disk fnos_Mainland-PE_arm_1.0.0_nanopc-t4_251.img: 3.74 GiB, 4019191808 bytes, 7849984 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 9CBD79EE-7323-4A43-9CC3-45A0C2FB3636

Device Start End Sectors Size Type
fnos_Mainland-PE_arm_1.0.0_nanopc-t4_251.img1 65536 598015 532480 260M Linux filesystem
fnos_Mainland-PE_arm_1.0.0_nanopc-t4_251.img2 630784 7833599 7202816 3.4G Linux filesystem

我们发现:

**P1 (Boot)**:起始于第 65536 扇区(即 32MB 处),大小 260MB。这说明前 32MB 存放了 U-Boot、ATF 等底层引导代码,不属于 GPT 分区管理

**P2 (Rootfs)**:起始于第 630784 扇区,大小 3.4GB。

2. 挂载并检查文件系统类型

为了确认分区的具体文件系统类型,我们使用 losetup -P 自动扫描分区并挂载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
root@dev-vm:/home/dev# losetup -f --show -P fnos_Mainland-PE_arm_1.0.0_nanopc-t4_251.img
/dev/loop0

root@dev-vm:/home/dev# lsblk /dev/loop0
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
loop0 7:0 0 3.7G 0 loop
├─loop0p1 259:2 0 260M 0 part
└─loop0p2 259:3 0 3.4G 0 part

root@dev-vm:/home/dev# mkdir -p /mnt/img_p1 /mnt/img_p2
root@dev-vm:/home/dev# mount /dev/loop0p1 /mnt/img_p1
root@dev-vm:/home/dev# mount /dev/loop0p2 /mnt/img_p2

root@dev-vm:/home/dev# df -T /mnt/img_p1 /mnt/img_p2
Filesystem Type 1K-blocks Used Available Use% Mounted on
/dev/loop0p1 ext4 247469 60368 169693 27% /mnt/img_p1
/dev/loop0p2 btrfs 3601408 2331256 932424 72% /mnt/img_p2

Boot 分区 (p1) 是 ext4 格式。这意味着我们在 NVMe 上格式化 Boot 分区时,必须使用 mkfs.ext4,而不能使用 FAT32。

Root 分区 (p2) 是 btrfs 格式。这验证了迁移时必须处理 Btrfs 的 UUID 冲突问题。

3. 查看 Boot 分区内容

挂载后,我们查看 /boot 分区里到底有什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@dev-vm:/home/dev# ls -l /mnt/img_p1
total 24560
-rwxr-xr-x 1 root root 1234 Mar 22 10:00 boot.cmd
-rwxr-xr-x 1 root root 1344 Mar 22 10:00 boot.scr
-rw-r--r-- 1 root root 234567 Mar 22 10:00 config-6.12.41-trim
drwxr-xr-x 2 root root 4096 Mar 22 10:00 dtb
drwxr-xr-x 2 root root 4096 Mar 22 10:00 efi
-rw-r--r-- 1 root root 85 Mar 22 10:00 fnEnv.txt
drwxr-xr-x 2 root root 4096 Mar 22 10:00 grub
-rw-r--r-- 1 root root 8901234 Mar 22 10:00 initrd.img-6.12.41-trim
drwx------ 2 root root 16384 Mar 22 10:00 lost+found
-rw-r--r-- 1 root root 4567890 Mar 22 10:00 System.map-6.12.41-trim
-rwxr-xr-x 1 root root 11234567 Mar 22 10:00 vmlinuz
-rwxr-xr-x 1 root root 11234567 Mar 22 10:00 vmlinuz-6.12.41-trim

这里包含了内核 (vmlinuz)、设备树 (dtb)、引导脚本 (boot.scr) 以及我们要修改的关键文件 fnEnv.txt

4. 深度解析:引导脚本 (boot.cmd) 的逻辑

通过查看 boot.cmd,我们发现了飞牛引导逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
root@dev-vm:/home/dev# cat /mnt/img_p1/boot.cmd
# DO NOT EDIT THIS FILE
#
# Please edit /boot/fnEnv.txt to set supported parameters
#

setenv load_addr "0x9000000"
setenv overlay_error "false"
# default values
setenv verbosity "1"
setenv console "both"
setenv bootlogo "false"
setenv rootfstype "btrfs,ext4"
setenv docker_optimizations "on"
setenv earlycon "off"

test -n "${distro_bootpart}" || distro_bootpart=1
test -n "${distro_rootpart}" || distro_rootpart=2

echo "Boot script loaded from ${devtype} ${devnum}:${distro_bootpart}"

if test -e ${devtype} ${devnum}:${distro_bootpart} ${prefix}fnEnv.txt; then
load ${devtype} ${devnum}:${distro_bootpart} ${load_addr} ${prefix}fnEnv.txt
env import -t ${load_addr} ${filesize}
fi

# ... (中间省略部分串口和控制台设置代码) ...

# get PARTUUID of first partition on SD/eMMC the boot script was loaded from
part uuid ${devtype} ${devnum}:${distro_bootpart} bootuuid
part uuid ${devtype} ${devnum}:${distro_rootpart} rootuuid

setenv bootargs "root=PARTUUID=${rootuuid} rootwait rw rootfstype=${rootfstype} ${consoleargs} consoleblank=0 loglevel=${verbosity} ubootpart=${bootuuid} usb-storage.quirks=${usbstoragequirks} ${extraargs} ${extraboardargs}"

if test "${docker_optimizations}" = "on"; then setenv bootargs "${bootargs} cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory"; fi

load ${devtype} ${devnum}:${distro_bootpart} ${kernel_addr_r} ${prefix}vmlinuz

load ${devtype} ${devnum}:${distro_bootpart} ${fdt_addr_r} ${prefix}dtb/${fdtfile}
fdt addr ${fdt_addr_r}
fdt resize 65536
# ... (后续加载 DTB 和启动内核的代码) ...

booti ${kernel_addr_r} - ${fdt_addr_r}

逻辑链条分析:

  1. 默认行为:脚本通过 part uuid 获取当前启动设备(eMMC)第 2 分区的 PARTUUID,并设为默认的 root=PARTUUID=${rootuuid}
  2. 环境变量注入:脚本检测并加载 /boot/fnEnv.txt,将其中的变量(如 extraargs)导入环境。
  3. 参数优先级${extraargs} 被显式拼接在 bootargs 字符串的末尾。Linux 内核在解析启动参数时,遵循“后出现者覆盖先出现者”的规则。

再看 fnEnv.txt 的初始内容:

1
2
3
4
5
6
root@dev-vm:/home/dev# cat /mnt/img_p1/fnEnv.txt 
verbosity=1
bootlogo=false
console=both
extraargs=cma=256M
fdtfile=rockchip/rk3399-nanopc-t4.dtb

结论:
当我们在 fnEnv.txtextraargs 中追加 root=PARTUUID=<NVMe_ID> 时,最终的启动命令行变成了:
... root=PARTUUID=<eMMC_ID> ... root=PARTUUID=<NVMe_ID>

内核读取到第二个 root 参数,从而忽略第一个,成功从 NVMe 启动。这种设计非常巧妙,它允许用户在不重新编译 boot.scr 二进制文件的情况下,通过简单的文本编辑即可灵活切换启动盘。

基于上述分析,我们决定采用 “eMMC 引导 + NVMe 系统” 的分离架构:

  • eMMC:只放引导文件(U‑Boot、内核、DTB),将挂载NVME硬盘的根文件系统
  • NVMe:放完整的根文件系统,所有读写都在这儿,享受高速低延迟。

这个方案既能绕过 RK3399 的引导限制,又能把 eMMC 的寿命省下来,同时把 NVMe 的性能用满。而且,这种“引导介质与 rootfs 分离”的思路并不局限于 NVMe——如果 eMMC 损坏了,或者板子根本没有 eMMC,甚至你想把系统挪到 USB 3.0 硬盘、SATA 盘、另一张 TF 卡上,本质都是一样的:只要 BootROM 能从某个介质(比如 TF 卡)把 U‑Boot 和内核拉起来,内核就能从任何它认得到的设备上挂载 rootfs。后面你会看到,我们只改了一个 root=PARTUUID= 参数,这个参数指向哪里,系统就从哪里启动。

我手里的东西

  • 开发板:NanoPC-T4(RK3399)
  • 引导盘:板载 eMMC,14.6 GB
  • 系统盘:Intel Optane M10 16GB NVMe,实际可用只有 13.4 GB
  • 系统:fnOS 1.1.24
  • 内核:Linux 6.12.41
  • 文件系统:Btrfs
  • 引导方式:U-Boot 脚本(boot.scr + fnEnv.txt

注意:本文所有操作都假设你的硬盘设备名为 nvme0n1。你的可能是 nvme1n1 或别的,请用 lsblk 确认后替换。

一上来就碰了个硬钉子

量了一下,傲腾 M10 虽然标称 16GB,实际可用只有 13.4 GB,而 eMMC 上的根分区占了 14.1 GB。用 dd 直接克隆肯定不行,目标盘比源盘还小,得换个思路——不能用块设备克隆,只能用文件级复制,挑着有用的文件搬过去。

一步步开干

前提:你的设备已经刷好飞牛系统并从 eMMC/TF 正常启动,本文所有操作均在该系统下执行。

1. 先看看设备

1
lsblk

我的输出:

1
2
3
4
5
6
7
8
9
10
11
12
root@NanoPC-T4:/# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
mmcblk2 179:0 0 14.6G 0 disk
├─mmcblk2p1 179:1 0 285M 0 part /boot
└─mmcblk2p2 179:2 0 14.1G 0 part /
mmcblk2boot0 179:32 0 4M 1 disk
mmcblk2boot1 179:64 0 4M 1 disk
zram0 252:0 0 1.9G 0 disk [SWAP]
nvme0n1 259:0 0 13.4G 0 disk
└─nvme0n1p1 259:1 0 13.4G 0 part
└─md0 9:0 0 13.4G 0 raid1
└─trim_08a0334e_a652_40cb_a25e_151bd2290a7e-0 253:0 0 13.4G 0 lvm

注意,我的傲腾 M10 之前在飞牛上挂载过,残留了 LVM 和 RAID 的配置,得先清理干净。

2. 清理 NVMe 上的旧配置

如果不清理干净,后面分区格式化会出各种奇怪问题——比如明明已经分区了,但 lsblk 还是能看到旧的分区结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 强制卸载所有可能相关的挂载点(根据你的实际情况改)
umount -l /vol1 2>/dev/null || true
umount -l /dev/md0 2>/dev/null || true
umount -l /dev/nvme0n1p1 2>/dev/null || true

# 停止 LVM 和 RAID 服务(替换成你实际的 LVM 名称)
lvchange -an -ff trim_08a0334e_a652_40cb_a25e_151bd2290a7e-0 2>/dev/null || true
mdadm --stop /dev/md0 2>/dev/null || true
dmsetup remove trim_08a0334e_a652_40cb_a25e_151bd2290a7e-0 2>/dev/null || true

# 擦除分区表头扇区,让内核认为这盘是空的
dd if=/dev/zero of=/dev/nvme0n1 bs=512 count=100 conv=fsync

# 通知内核重新读取分区表
partprobe /dev/nvme0n1

执行完后,lsblk 应该只看到一个光秃秃的 nvme0n1

1
2
3
4
5
6
7
8
9
root@NanoPC-T4:/# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
mmcblk2 179:0 0 14.6G 0 disk
├─mmcblk2p1 179:1 0 285M 0 part /boot
└─mmcblk2p2 179:2 0 14.1G 0 part /
mmcblk2boot0 179:32 0 4M 1 disk
mmcblk2boot1 179:64 0 4M 1 disk
zram0 252:0 0 1.9G 0 disk [SWAP]
nvme0n1 259:0 0 13.4G 0 disk

3. 确认 eMMC 上的文件系统类型

1
blkid /dev/mmcblk2p2

我得到:

1
2
root@NanoPC-T4:/# blkid /dev/mmcblk2p2
/dev/mmcblk2p2: LABEL="rootfs" UUID="48b0a7cb-68bc-4337-b9ad-fc605dbb31bf" UUID_SUB="f29b6a03-37ca-4e05-987e-a2b5d5e05154" BLOCK_SIZE="4096" TYPE="btrfs" PARTUUID="afe747ae-13c5-4540-8cd0-94fb975662e5"

是 Btrfs,记下这个 UUID,后面要用。

4. 给 NVMe 分区(第一步:准备 rootfs 的开始)

在给 NVMe 分区时,我特意创建了两个分区:p1 (Boot) 和 p2 (Root),这与 eMMC 上的原始布局完全一致。这样做主要基于两点考虑:

  1. 保持拓扑一致性
    保留相同的分区结构(Boot + Root),使得 NVMe 硬盘在逻辑上成为 eMMC 的完整副本。如果未来需要更换引导介质,或者使用工具对整块 NVMe 进行镜像备份/恢复时,这种一致性可以避免很多路径映射和挂载点的麻烦。

  2. 隐性备份 /boot 分区
    虽然 RK3399 的 BootROM 无法直接从 NVMe 启动,导致 NVMe 上的 p1 分区在正常启动流程中不会被加载,但它完整地保留了内核、设备树和引导脚本。

    • 灾难恢复价值:如果 eMMC 上的 /boot 分区因误操作或文件系统损坏而丢失,我们可以轻松地从 NVMe 的 p1 分区中恢复这些关键文件。
    • 版本对照:当我们需要升级内核或调试引导问题时,NVMe 上的 /boot 可以作为上一个稳定版本的参考基准。

因此,尽管 p1 不参与启动,但将其复制过去是一种低成本、高收益的保险策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 彻底清一下残留元数据
wipefs -a /dev/nvme0n1
partprobe /dev/nvme0n1

# 创建 GPT 分区表
parted /dev/nvme0n1 mklabel gpt
# 输入 yes 确认

# 创建 Boot 分区,285MB,和 eMMC 保持一致
parted /dev/nvme0n1 mkpart primary ext4 1MiB 286MiB

# 创建 Root 分区,占满剩余空间(此时建立了一个ext4分区标签,后面会格式化成btrfs)
parted /dev/nvme0n1 mkpart primary ext4 286MiB 100%

# 刷新分区表
partprobe /dev/nvme0n1

# 检查分区结果
lsblk /dev/nvme0n1

5. 格式化

1
2
3
4
5
# Boot 分区用 ext4,兼容性最好
mkfs.ext4 /dev/nvme0n1p1

# Root 分区用 btrfs,和源系统一致
mkfs.btrfs -f -L rootfs_nvme /dev/nvme0n1p2

格式化完,顺手记一下新分区的 UUID 和 PARTUUID:

1
blkid /dev/nvme0n1p2

6. 挂载并复制数据(继续准备 rootfs)

1
2
3
4
5
6
7
8
9
10
11
# 创建临时挂载点
mkdir -p /mnt/dst_boot /mnt/dst_root

# 挂载目标分区
mount /dev/nvme0n1p1 /mnt/dst_boot
mount /dev/nvme0n1p2 /mnt/dst_root

# 检查空间够不够
df -h / # 源已用空间
df -h /mnt/dst_root # 目标可用空间
# 必须确保 源已用 < 目标可用

确认空间足够后,开始复制。因为目标盘比源盘小,不能用 dd,只能用 rsync 做文件级复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 复制 Boot 分区
rsync -avHAX /boot/ /mnt/dst_boot/

# 复制根文件系统(排除虚拟文件系统和外部挂载点)
rsync -avHAX \
--exclude='/proc/*' \
--exclude='/sys/*' \
--exclude='/dev/*' \
--exclude='/run/*' \
--exclude='/mnt/*' \
--exclude='/tmp/*' \
--exclude='/lost+found/*' \
--exclude='/vol*/' \
--exclude='/media/*' \
/ /mnt/dst_root/

等几分钟,看到类似这样的输出就成功了:

1
2
sent 5,244,244,907 bytes  received 1,572,046 bytes  62,080,674.00 bytes/sec
total size is 5,241,423,994 speedup is 1.00

7. 修改 NVMe 分区的 UUID(关键步骤,属于准备 rootfs 的收尾)

现在遇到一个新问题:我们用 rsync 把文件原样复制过去了,但 Btrfs 文件系统的 UUID 也被原样复制了。也就是说,NVMe 上的新 rootfs 和 eMMC 上的旧 rootfs 有完全相同的 UUID。内核在挂载时看到两个相同 UUID 的 Btrfs 卷,就会混乱——它不知道该用哪一个。所以必须给 NVMe 上的 rootfs 生成一个新的 UUID。

先卸载 root 分区(btrfstune 要求分区不能处于挂载状态):

1
umount /mnt/dst_root

然后生成新 UUID:

1
btrfstune -u /dev/nvme0n1p2

工具会问你是否确认,输入 y

1
2
3
4
5
6
New fsid: ff743428-987f-4649-b87e-3a94a65c94c6
Set superblock flag CHANGING_FSID
Change fsid in extent tree
Change fsid in chunk tree
Clear superblock flag CHANGING_FSID
Fsid change finished

记录下新旧 UUID 和 PARTUUID:

1
2
3
4
5
6
7
OLD_UUID=$(blkid -s UUID -o value /dev/mmcblk2p2)
NEW_UUID=$(blkid -s UUID -o value /dev/nvme0n1p2)
NEW_PARTUUID=$(blkid -s PARTUUID -o value /dev/nvme0n1p2)

echo "旧 UUID (eMMC): $OLD_UUID"
echo "新 UUID (NVMe): $NEW_UUID"
echo "新 PARTUUID (NVMe): $NEW_PARTUUID"

我的输出:

1
2
3
旧 UUID (EMMC): 48b0a7cb-68bc-4337-b9ad-fc605dbb31bf
新 UUID (NVMe): ff743428-987f-4649-b87e-3a94a65c94c6
新 PARTUUID (NVMe): 871c986b-5385-45ae-a241-2e875a3ecc43

8. 修改 NVMe 里的 /etc/fstab(让新 rootfs 引用自己的 UUID)

NVMe 上的 rootfs 里还有一个 /etc/fstab 文件,它里面写的是旧的 UUID(指向 eMMC)。如果不改,即使内核从 NVMe 启动了,后续挂载 / 时又会因为 UUID 不匹配而出错。所以得把它也换成新的 UUID。

重新挂载 NVMe 的 root 分区:

1
mount /dev/nvme0n1p2 /mnt/dst_root

备份并替换 UUID:

1
2
cp /mnt/dst_root/etc/fstab /mnt/dst_root/etc/fstab.bak
sed -i "s/$OLD_UUID/$NEW_UUID/g" /mnt/dst_root/etc/fstab

验证一下:

1
cat /mnt/dst_root/etc/fstab

应该看到 / 分区已经指向新的 UUID:

1
2
3
4
5
root@NanoPC-T4:/# cat /mnt/dst_root/etc/fstab
.......
UUID=ff743428-987f-4649-b87e-3a94a65c94c6 / btrfs defaults,noatime,errors=remount-ro 0 1
UUID=cf2ecdac-946c-4790-a894-19c10b526a1a /boot ext4 defaults,noatime,errors=remount-ro 0 2
tmpfs /tmp tmpfs defaults,nosuid 0 0

确认无误后,卸载:

1
2
umount /mnt/dst_root
umount /mnt/dst_boot

至此,第一步“准备 rootfs” 完成:NVMe 上已经有了一个独立、可用的根文件系统,UUID 也改好了。

9. 修改 eMMC 上的引导配置(第二步:修改 PARTUUID)

现在,NVMe 上的系统已经准备好了,但内核还不知道要去 NVMe 上找 root。因为 RK3399 的 BootROM 只认识 eMMC/SD 卡,所以我们还是从 eMMC 启动内核,但要让内核把根文件系统挂载到 NVMe 上。这就需要修改内核启动参数。

另外,如果直接使用文件系统的 UUID 来指定 root,可能会遇到问题:在早期引导阶段,内核不一定能正确解析 Btrfs 的 UUID(尤其当有多个相同类型的文件系统时)。更可靠的办法是用 PARTUUID——这是分区表的属性,在分区创建时就固定了,不会因为文件系统格式化而改变,而且内核在很早期的阶段就能识别它。

编辑 eMMC 上的 /boot/fnEnv.txt

1
vim /boot/fnEnv.txt

原来的内容大概是这样:

1
2
3
4
5
6
verbosity=1
bootlogo=false
console=both
extraargs=cma=256M
fdtfile=rockchip/rk3399-nanopc-t4.dtb
kernelfile=vmlinuz-6.12.41-trim

我们需要在 extraargs=cma=256M 后面追加 root=PARTUUID=xxxx-xxxx-xxxx-xxxx。注意 PARTUUID= 后面不要加双引号cma=256Mroot=... 之间必须有一个空格

修改后:

1
2
3
4
5
6
verbosity=1
bootlogo=false
console=both
extraargs=cma=256M root=PARTUUID=871c986b-5385-45ae-a241-2e875a3ecc43
fdtfile=rockchip/rk3399-nanopc-t4.dtb
kernelfile=vmlinuz-6.12.41-trim

为什么用 PARTUUID 而不是 UUID?
PARTUUID 是分区表的属性,不会因为文件系统重新格式化而改变,在早期引导阶段更可靠。而且,这个 root=PARTUUID= 参数是通用的——如果你的 eMMC 坏了,你可以把同样的参数指向另一张 TF 卡上的根分区;如果你想用 USB 3.0 硬盘启动,只要内核能识别该硬盘,改一下 PARTUUID 就行。整个方案的精髓就在这一行。

10. 重启验证

建议再检查一遍 /boot/fnEnv.txt 里的 PARTUUID 是否和 blkid /dev/nvme0n1p2 输出一致。

1
2
sync
reboot

验证一下是否真的跑在 NVMe 上

重启后,登录系统,执行:

1
2
3
df -hT /
lsblk
mount | grep " / "

d3e505c095da7d8fcc8bdcc818ecb575

可以看到 / 已经挂载在 /dev/nvme0n1p2 上了,eMMC 的 mmcblk2p2 没有被使用。


性能测试:傲腾到底有多快

Intel Optane M10 是一块很有意思的小硬盘。它的容量不大(16GB),但用的是 3D XPoint 介质,延迟极低,读写磨损均衡做得特别好。普通 TLC 固态的 4K 随机读写通常只有几万 IOPS,而傲腾可以轻松跑到六位数。在 NanoPC-T4 上,虽然 PCIe 2.0 x4 的带宽限制了顺序读写(理论上限约 1.6 GB/s,实际更少),但傲腾真正的杀手锏——4K 随机读写超低延迟——几乎不受影响。跑数据库、Docker 容器、频繁读写小文件的场景,这块小傲腾能带来脱胎换骨的体验。

先装 fio

1
sudo apt update && sudo apt install fio -y

然后跑几个测试:

1
2
3
4
5
6
7
8
9
10
11
# 4K 随机读
fio --name=randread --ioengine=libaio --iodepth=1 --rw=randread --bs=4k --direct=1 --size=256M --numjobs=1 --runtime=60 --group_reporting --filename=/home/fio_test_read

# 4K 随机写
fio --name=randwrite --ioengine=libaio --iodepth=1 --rw=randwrite --bs=4k --direct=1 --size=256M --numjobs=1 --runtime=60 --group_reporting --filename=/home/fio_test_write

# 混合读写(70% 读 30% 写,典型服务器负载)
fio --name=mixed --ioengine=libaio --iodepth=4 --rw=randrw --rwmixread=70 --bs=4k --direct=1 --size=256M --numjobs=2 --runtime=60 --group_reporting --filename=/home/fio_mixed

# 清理
rm /home/fio_test_* /home/fio_mixed_* 2>/dev/null

我的测试结果:

测试项目 速度/性能 延迟 评价
顺序写入 611 MB/s - 吃满 PCIe 2.0 带宽
顺序读取 800 MB/s - 含内存缓存加速
4K 随机读 137,000 IOPS 6.16 μs 企业级性能
4K 随机写 135,000 IOPS 6.13 μs 读写几乎一致
混合读写 读 181k / 写 77.8k IOPS 29 μs 低负载依然极低延迟

傲腾在 RK3399 上跑出了 13 万 IOPS,延迟只有 6 微秒——系统响应飞快,Docker 容器启动也明显流畅了。

这个方案能走多远

回过头看,这次迁移的核心其实就两步:先在目标盘上准备好一个独立的根文件系统,再改一下 eMMC 上 fnEnv.txt 里的 root=PARTUUID= 参数。这个思路并不绑定 NVMe,也不依赖 eMMC 必须完好。

引导介质可以是 eMMC,也可以是 TF 卡。万一哪天 eMMC 彻底写坏了,只要把同样的引导文件(U‑Boot、内核、dtb)和 fnEnv.txt 拷到一张 TF 卡上,插上去就能照样启动。而 root=PARTUUID= 后面那个参数,你想指向哪里就指向哪里——NVMe 可以,USB 3.0 硬盘可以,SATA 盘可以,甚至另一张 TF 卡也可以。只要内核能驱动那个设备,系统就能从那儿跑起来。

所以这套方法不仅救活了我这块小容量傲腾,也适用于任何想把瑞芯微 ARM 设备的系统从慢速存储迁到高速外部介质的场景。如果你的 eMMC 还没坏,它可以作为提速方案;如果已经坏了,它更是一条复活路径。

如果你使用的是大容量 SSD ,社区中常见的 dd 克隆法可能更快。但如果你像我一样使用小容量傲腾硬盘,亦或是想深入理解 ARM 引导机制,那么本文的 rsync + PARTUUID 方案是唯一且最安全的选择。

LECREATE

2026 年 3 月 22 日


将Arm飞牛从 eMMC/TF 卡无损迁移至外部存储(NVMe/USB/SATA/TF)的完整方案 —— 适用于瑞芯微 RK 系列平台(含小容量盘适配)
https://blog.lecreate.asia/2026/04/30/2026032201/
作者
LECREATE
发布于
2026年4月30日
许可协议