飞步云宫

胎胚有始,尘劫无终

树莓派以标准姿态行于 NixOS 世界:以搭建一个 Router 为例

相较于 x86 平台高度统一的 UEFI + ACPI 启动接口,ARM 这一侧就割裂多了。

长久以来,ROM Code -> SPL -> U-Boot -> Device Tree (DTB) 这样的启动链是我们在 ARM 上最熟知的。然而,这种引导方式是高度定制化的,比如说它的硬件描述必须预先写在静态的设备树里,内核要直接读取到主板的 .dtb 文件才能正确驱动硬件。这导致操作系统和硬件结合得十分紧密。我们在制造一个系统镜像时,不仅要考虑操作系统层面的问题,还必须关心不同 SoC 的实现细节、不同板子的硬件分布,要编辑各种寄存器地址和中断号,甚至还要处理引导分区的烧录——这些其实已经超出了操作系统应有的关注范围。并且,这样打包出的镜像也没什么通用性可言。即使看似两个板子的规格一致,各项参数也差不多,仅仅是一些引脚连接上的差异,也可能会导致为一个打包的镜像无法工作于另一个。

为了解决这种碎片性的问题,近年来 ARM 在面向服务器和数据中心的芯片中推行 SBSA 和 SBBR 规范,前者声明了 SoC 之间的内核通用性,而后者通过要求提供 UEFI 接口和 ACPI 表,达成了与 x86 平台近似的操作系统与平台固件之间的解耦。已经七八年过去了,普及得应该还不错。可惜这对我们单板机用户来说并没有什么直接意义,注意到了吗?这是针对服务器的规范,芯片厂商既无意也无动力在嵌入式市场做完整的标准化固件与彻底的 ACPI 支持。

这就意味着,我们原本仍然要经常忍受有些脏乱的工作流程:写出一个个只能用于单一型号的配方,再打包出同样单一的镜像,然后在每次内核升级时重复劳动且提心吊胆。

幸运的是,为了同样减少嵌入式领域的痛苦,ARM 后来确立了 EBBR (Embedded Base Boot Requirements) 规范,它做出了一个很务实的妥协:统一提供 UEFI 启动接口,但允许硬件描述继续沿用设备树(DTB)。(按语: 按最新进展,EBBR 应该已经更名为 BBR 了,这个更改指明了该规范是基石性的,而 SBBR 是在其上的进阶标准。)这与开源社区的思路不谋而合,这个词可能不贴切,也许它正是社区和企业人员之间交流碰撞之下产生的,是谋而后合。

社区中从不缺乏寻找解决方案的热情。比如说 Tow-Boot 就致力于提供类似于 x86 上常规“BIOS”的体验。它本质上是一个带有强烈标准化主张的 U-Boot 发行版。原版 U-Boot 虽然早已支持了 UEFI 子系统,兼任了平台固件和启动加载器的活儿,但配置极其繁杂;Tow-Boot 则通过屏蔽复杂的底层环境变量配置、预设标准化的启动顺序,在体验上完成了操作系统与底层硬件的解耦,让运行其上的系统可以按自己的喜好使用符合 UEFI 规范的 Boot Loader,像 GRUB、systemd-boot 之类的。

扯远了,虽然介绍了这么多 Tow-Boot,但鉴于我们手头恰好有块树莓派 4B,我们其实可以使用 pftf/RPi4 仓库中提供的标准 EDK2 固件。社区付出了巨大的努力,将纯正的 EDK2 源码移植到了树莓派上,为其构建出了一套原生的、标准的 UEFI 环境,在这个环境下,我们没有遵循 EBBR 的方式,即通过 UEFI 接口向 Linux 主线内核传递 DTB 来驱动硬件,而是使用了默认开启的 ACPI 表。这为我们在其上安装标准的 Linux 发行版并运行主线内核提供了极大的便利。

闲言叙得已经够多了,下面我将示范如何在树莓派 4B 上构建我的 NixOS Router。(以下内容假定读者对 Nix 生态中的工具和术语有一定了解)

一、 搭建跳板环境#

在确立了以 UEFI 为基础的启动流程后,我们需要解决如何将系统安装到设备上的问题。传统的单板机装机流程往往需要手动分区、挂载文件系统以及复制引导文件。在 NixOS 的生态中,我们可以借由 diskonixos-anywhere 将这一过程完全声明式化。为了实现这一目标,我们首先需要一个能够自动联网且开启 SSH 的跳板(Live)环境。

1. 基于 Nixpkgs 和 Flakes 编写跳板镜像配方#

为了实现无头(Headless)安装,目标设备开机后必须能自动接入局域网并允许远程控制。在这里,我没有直接使用官方提供的标准 Live 镜像,而是编写了一个 Flake,引入 Nixpkgs 内置的 sd-image-aarch64.nix 模块,从源码层面定制了一个 Live 环境。在这个配置中,预先声明了 Wi-Fi 凭证和部署机(日常使用的电脑)的 SSH 公钥:

{
  description = "Raspberry Pi 4B Headless LiveCD";

  inputs = {
    # 锁定 nixpkgs 的版本,如 nixos-24.05 或 nixos-unstable
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs =
    { self, nixpkgs }:
    {
      nixosConfigurations.rpiLive = nixpkgs.lib.nixosSystem {
        # 指定目标架构为 ARM64
        system = "aarch64-linux";

        modules = [
          # 1. 引入官方的 aarch64 SD 卡镜像基础配方
          "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix"

          (
            { pkgs, ... }:
            {
              # 2. 注入 SSH 公钥 (配置 root 与默认的 nixos 用户),公钥记得换成你自己的
              users.users = {
                # 注意,这里为什么要给 root 用户配置登录公钥呢?因为树莓派上运行 kexec 有问题,我们无法使用 nixos-anywhere 的默认流程完成安装
                # 而在特定的 nixos-anywhere 流程下,我们必须以一个有 root 权限的用户作为 ssh 目标,这在后文会有具体介绍
                root.openssh.authorizedKeys.keys = [
                  "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILvTmb1zsdEywosctFd+5dlXM3fgKIeK5xzCZp0WtR1b"
                ];
                # nixos用户其实用不到了,这里只是失败尝试的痕迹
                nixos = {
                  isNormalUser = true; # 明确声明为普通用户
                  extraGroups = [ "wheel" ]; # 加入 wheel 组以允许执行 sudo
                  openssh.authorizedKeys.keys = [
                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILvTmb1zsdEywosctFd+5dlXM3fgKIeK5xzCZp0WtR1b"
                  ];
                };
              };

              security.sudo.wheelNeedsPassword = false;

              # 3. 开启 SSH 服务
              services.openssh.enable = true;

              # 4. 配置 Wi-Fi(如果树莓派插了网线,这块 networking 都可以省略)
              networking.wireless = {
                enable = true;
                networks."MY_WIFI_SSID".psk = "MY_PASSWORD";
              };

              # 5. 关闭 zstd 极限压缩以大幅加快本地构建速度
              sdImage.compressImage = false;

              system.stateVersion = "26.05";
            }
          )
        ];
      };
    };
}

2. 构建并写入镜像#

在本地运行构建命令(例如 nix build .#nixosConfigurations.rpiLive.config.system.build.sdImage)后,将生成的 .img 镜像写入到 U 盘或 MicroSD 卡中(dd 之类的写入方法我想大家都应该熟悉,这里就不赘述了)。将其插入树莓派并通电,系统启动后便会自动连接无线网络,等待后续的 SSH 连接。

二、 准备固件资源和目标系统配方#

1. 准备 UEFI 固件#

按照常规逻辑,我们可能需要手动将 pftf/RPi4 固件写入到目标存储设备的 FAT32 引导分区中。但在接下来的自动化部署流程中,这并非必要。我们可以直接在部署机上下载该固件的 Release 压缩包,并解压至本地项目的一个特定目录中(例如 extra-files/boot)。这些固件文件将在系统安装阶段被统一同步至目标设备。

2. 编写目标系统配方#

在 NixOS 中,系统的全貌由声明式的代码定义。对于这台软路由,我们需要在配置中解决三个核心问题:底层存储如何自动化划分、在这个特殊的 UEFI 环境下如何引导,以及如何配置内核的路由转发能力并避开树莓派内置网卡的底层陷阱。

首先是声明式存储划分 (disko) 为了让 nixos-anywhere 能够在目标设备上自动完成抹盘和分区,我们引入了 disko 模块。在这个配方中,我们通过设备的 WWID 来精准锁定主磁盘(避免了 sda 这样可变的引用方式引起问题),并为其规划了标准的 GPT 分区表和 Btrfs 文件系统:

{ inputs, primaryDiskWwid, swapSize, ... }:
{
  imports = [ inputs.disko.nixosModules.disko ];
  disko.devices.disk.primary = {
    device = "/dev/disk/by-id/${primaryDiskWwid}";
    type = "disk";
    content = {
      type = "gpt";
      partitions = {
        ESP = {
          type = "EF00";
          # 关键细节:保留前 1M 的空间,以规避 nixos-anywhere 写入 EFI 分区时的潜在失败问题
          start = "1M";
          end = "1024M";
          priority = 1;
          content = {
            type = "filesystem";
            format = "vfat";
            mountpoint = "/boot";
            mountOptions = [ "defaults" ];
          };
        };
        main = {
          size = "100%";
          priority = 2;
          content = {
            type = "btrfs";
            extraArgs = [ "-f" ];
            subvolumes = {
              "@" = { mountOptions = [ "compress=zstd" "noatime" ]; mountpoint = "/"; };
              "@nix" = { mountOptions = [ "compress=zstd" "noatime" ]; mountpoint = "/nix"; };
              "@persistent" = { mountOptions = [ "compress=zstd" "noatime" ]; mountpoint = "/persistent"; };
              "@swap" = { mountOptions = [ "noatime" ]; mountpoint = "/.swapvol"; swap.swapfile.size = swapSize; };
            };
            mountpoint = "/.btrfs/main";
          };
        };
      };
    };
  };

  fileSystems = {
    "/".neededForBoot = true;
    "/nix".neededForBoot = true;
    "/boot".neededForBoot = true;
    "/persistent".neededForBoot = true;
  };
}

这套配置不仅自动划分了 1GB 的 ESP 引导分区,还将系统根目录 /、Nix 存储 /nix、持久化数据 /persistent 以及 Swap 交换文件分别放置在不同的 Btrfs 子卷中,并开启了 zstd 透明压缩。最后,务必为这些核心挂载点打上 neededForBoot = true 的标签,确保系统在启动初期(initrd 阶段)就能正确挂载它们。这里面的持久化分区并非必要,是为“无状态”系统准备的配置,与本文内容无关,不需要的读者可以忽略。

其次是引导配置 (boot.nix) 既然我们已经用上了标准的 UEFI 固件,那么引导器的选择就变得非常现代,直接使用 systemd-boot 即可。但这里有一个针对单板机 EDK2 环境的极其关键的细节:必须禁止操作系统尝试去修改 EFI 变量。

{ ... }:
{
  boot = {
    loader = {
      systemd-boot.enable = true;
      systemd-boot.consoleMode = "auto";
      # 关键:树莓派的 EDK2 固件环境不支持操作系统直接写入 EFI 变量(这项工作需要得到硬件提供方支持,然而)
      efi.canTouchEfiVariables = false;
    };
  };
}

再次是基础网络与内核转发 (networking.nix) 作为一台路由器,其核心职责是数据包的跨网段转发。我们需要在内核级别显式开启 IPv4 和 IPv6 的转发功能。在物理接口的规划上,我们指定连接光猫的接口为 WAN 口 (enabcm6e4ei0),并创建一个虚拟网桥 (br-lan) 来统合后端的有线 LAN 口和无线 AP 接口。

boot.kernel.sysctl = {
  "net.ipv4.ip_forward" = 1;
  "net.ipv6.conf.all.forwarding" = 1;
  "net.ipv6.conf.enabcm6e4ei0.accept_ra" = 2;
};

有了明确的内外网接口划分后,开启 NixOS 原生的 NAT 功能就非常简单了。只需指定外部出口和内部接口,系统会自动为我们配置好 Nftables 的伪装(Masquerade)规则。至于局域网内的 IP 分配,我们抛弃了老旧的方案,选择使用现代的 Kea DHCP 服务来提供 IPv4 和 IPv6 (ULA) 的租约,同时配合 radvd 进行无状态地址自动配置 (SLAAC) 和路由通告。

最后是作为便捷维护入口的无线 AP 配置 (services.hostapd) 利用树莓派的内置无线网卡发射 Wi-Fi 信号是一个理所当然的需求,NixOS 也提供了完善的 hostapd 模块。但在主线 Linux 内核与现代加密协议的碰撞下,这里暗藏玄机。

在配置认证模式时,前沿(cutting-edge)的极客可能会倾向于配置 wpa3-sae-transition(WPA2/WPA3 过渡模式)以获得更好的安全性。然而,WPA3 协议在标准上强制要求开启 PMF(受保护的管理帧)。树莓派 4B 内置的 Broadcom 开源驱动 (brcmfmac) 在处理带有 PMF 的 SAE 握手时存在底层的处理缺陷,这会导致较新的设备在密码验证阶段卡死或频繁提示无法连接。

因此,为了保证局域网接入的绝对稳定性,我们只能老老实实地退回到经典的 wpa2-sha1 模式。另外,请务必设定国家码,这似乎是驱动限制,不解锁合规的发射频段和功率则 hostapd 无法启动。

services.hostapd = {
  enable = true;
  radios.wlan0 = {
    band = "2g";
    channel = 6;
    countryCode = "CN"; # 必须配置国家码以解锁硬件限制
    networks.wlan0 = {
      ssid = "Router-RaspberryPi-4B-1";
      authentication = {
        # 避开 Broadcom 驱动的 WPA3 PMF 缺陷,稳妥使用 WPA2
        mode = "wpa2-sha1";
        wpaPasswordFile = "/path/to/your/secret/wifi-password";
      };
      settings.bridge = "br-lan";
    };
  };
};

至此,我们已经通过 NixOS 配置声明了这台路由器从文件系统划分到底层引导再到上层网络服务的全部运行规则。篇幅所限,本文只列出了与这台机器承载的功能相关的特定配置,完整的代码请参考我的公开配置仓库,您会在其中发现,除了这些功能特化的部分,其余的配置都是和我的其它各种类型的 x86 设备共享的。这也就是本文标题想表达的意思,在当前的 NixOS 世界里,很多 ARM SBC 可以不那么特殊,而是以(几乎)标准的形态运行,至于这个几乎的取值,则要依赖硬件厂的开放程度了。

好了,回到正文,这些配置将被统一收集在机器的入口文件(如 default.nix)中等待实例化。

麦苗已经全部种下了,接下来,我们将要进入收割阶段了:利用工具,将这些目标系统的配方以及底层的 UEFI 固件,全自动、无接触地“注入”到树莓派的存储介质中。

三、 执行 nixos-anywhere 发起一键安装#

到了这一步,我们已经万事俱备:树莓派正运行着带有预设公钥的跳板系统,而部署机上则准备好了目标系统的代码配方以及存放在 extra-files/boot 目录下的 UEFI 固件。

1. 为什么跳板镜像要用 NixOS 和 root 用户?(注意到了吗,配方注释中的伏笔)#

在常规的 nixos-anywhere 流程中,我们甚至可以直接连入一台运行着普通 Ubuntu 的机器,工具会通过 kexec 机制在内存中“凭空”拉起一个 NixOS 安装环境,随后接管系统,获得对启动磁盘的操作能力。

然而,树莓派底层的内核配置天然对 kexec 支持极差(例如缺失 /proc/kcore 等),强制执行几乎必然导致内核崩溃或死机。具体讨论参考这里,其中提到可以尝试用 NixOS 的基础镜像,但我测试下来,Nixpkgs 中打包出的默认 ARM 基础镜像,在树莓派上也跑不了 kexec。

为了解决这个问题,我们必须让跳板镜像和目标磁盘分属两个设备,并且跳板镜像必须也是合格的 NixOS 环境,这样即使我们通过指定 nixos-anywhere 的 phases 跳过了 kexec 阶段也可以正常完成安装。但这里要注意 nixos-anywhere 的内置行为,在标准流程里,kexec 创建的环境中包含一个临时的 root 用户,nixos-anywhere 会使用这个用户来执行后续操作,即使我们跳过了这个阶段,跑到安装阶段时它还是会以 root 用户去执行。这导致即使我们命令中指定了 ssh 的用户,到这里它还是会跟我们要 root 用户的密码,然而 root 用户在基础镜像中没有密码。于是我们干脆在跳板镜像的配方里给 root 配置好自己的 ssh 公钥,然后在部署的时候直接指定 root 用户登录,一劳永逸。

2. 发起自动化部署#

通过局域网路由器后台或串口确认到树莓派跳板系统的局域网 IP 后,我们只需在本地部署机的终端里敲下这行命令:

nix run github:nix-community/nixos-anywhere -- \
  --flake .#Router-RaspberryPi-4B-1 \
  --build-on local \
  --extra-files ./extra-files \
  --no-substitute-on-destination \
  --phases disko,install \
  root@<树莓派的IP地>

解释一下这行命令中包含的黑魔法:

  • 强制跳过 kexec (--phases disko,install):这是我们解决部署过程挂起的关键手段。通过明确指定执行阶段,我们要求 nixos-anywhere 放弃尝试拉起新的临时环境,直接利用当前跳板镜像的底层能力,从 disko 格式化阶段直接开始干活。
  • 算力卸载与闭包直推 (--build-on local--no-substitute-on-destination):树莓派 4B 的 CPU 性能孱弱。这两个参数指示整个 NixOS 目标系统(闭包)的求值和构建完全在更强的本地部署机上完成。随后,部署机将构建好的系统闭包通过 SSH 以二进制流的形式直接“塞”进树莓派的目标磁盘里,不仅免去了树莓派自行拉取缓存的带宽开销,也避免了因算力不足导致的超长等待。
  • disko 磁盘接管:工具会读取我们在前一步编写好的 efi-btrfs.nix 存储配方。它通过 WWID 找到目标 U 盘(或硬盘),抹除旧数据,依次划出预留的 1M 空隙、ESP 引导分区以及 Btrfs 主分区,并透明挂载好所有的子卷和 Swap 交换文件。
  • 无缝固件注入 (--extra-files):NixOS 的闭包可以通过 SSH 复制到目标磁盘,那么额外的文件也可以,这个参数给了我们自主部署一些起始文件的能力。它会将本地 extra-files/ 目录下的文件原封不动地映射到目标系统的文件层级中。由于我们提前将 EDK2 固件放在了本地的 extra-files/boot/ 目录下,部署过程中,固件的全部文件都会被工具放置到前置工序中格式化好的 FAT32 ESP 分区中,彻底免去了手动干预的烦恼。

3. 拔除跳板,迎接新生#

等待几分钟,当部署机终端里的构建与文件推送日志停止滚动,并打出完成的提示时,我们的装机工作就彻底结束了。

此时,拔掉树莓派的电源,拔下作为跳板的 U 盘(确保只将写入了新系统的目标 U 盘留在设备上),再次接通电源。

这一次,树莓派的底层硬件会从目标 U 盘的 ESP 分区读取到刚才的 EDK2 固件(GPU 引导),顺畅地拉起原生 UEFI 环境(ARM CPU 启动);随后,UEFI 按照标准流程将控制权交接给 systemd-boot,最终唤醒我们的 NixOS Router。

至此,这台树莓派进入了 NixOS 的声明式世界。


附录:确保可以从 U 盘启动的前置措施

如果读者手里的树莓派 EEPROM 版本比较老,那么可能是没法从 USB 设备启动的。为了避免这种情况,我们可以在开始一切之前先更新一下 EEPROM。不过,我们基于 UEFI 启动的系统,由于开启了 ACPI 的原因,主线 Linux 内核无法读取到树莓派底层的专有邮箱接口(vcmailbox),这会导致在系统中直接运行官方的 rpi-eeprom-update 工具直接报错。因此,建议读者使用官方推荐方式或镜像来执行升级操作。


上一篇: