本文是系列第2篇。在上一篇中,我们从业务角度理解了SD-WAN终端的技术栈全貌。本文将深入OpenWrt系统内部,从设备上电开始,逐层剖析启动流程、Flash存储的分区结构,以及overlay文件系统的工作原理。

一、从按下电源键说起

当路由器接通电源后,硬件并不会直接运行操作系统,而是需要一个引导程序来完成最基本的硬件初始化,并将操作系统加载到内存中。

1.1 嵌入式设备的启动阶段

在普通的个人电脑上,BIOS(或UEFI)负责这个引导工作。但嵌入式设备通常没有BIOS这样的标准固件,引导任务完全由BootLoader来完成。

整个启动过程按时间顺序如下:

上电
 ↓
晶体振荡器发出固定频率的时钟信号
 ↓
CPU开始执行内部固化的少量启动代码(位于CPU芯片内部的一小块ROM中)
    ——这些代码会查找BootLoader存储在哪里
 ↓
加载并运行U-Boot(从Flash中)
 ↓
U-Boot完成硬件初始化(串口、内存、Flash控制器等)
 ↓
U-Boot将Linux内核从Flash复制到内存中
 ↓
跳转到内核入口地址,将控制权交给内核
 ↓
Linux内核启动,挂载根文件系统
 ↓
执行初始化进程(procd),启动各项系统服务
 ↓
系统就绪

1.2 U-Boot的角色

U-Boot(Universal Bootloader)是嵌入式领域最广泛使用的引导程序,由德国DENX小组开发,支持多种处理器架构和操作系统。

在SD-WAN终端的实际使用中,U-Boot最常涉及两个操作:

TFTP刷机:开发机上运行TFTP服务器并放置固件文件,路由器上电后进入U-Boot命令行,通过tftp命令从网络下载固件并写入Flash。

Web刷机:设备上电时按住Reset按钮进入恢复模式,U-Boot会启动一个简易的HTTP服务器,开发者通过浏览器上传固件。

在实际部署中,也常用第三方的Breed Web恢复控制台(如ZBT设备),它提供比原生U-Boot更友好的图形化刷机界面。

1.3 看门狗:系统的最后一道防线

工业级路由器对可靠性要求较高。如果系统正常运行过程中发生死机或异常挂起,就需要一种机制来强制恢复。

看门狗(Watchdog Timer)就是这种机制。从本质上说,它是一个硬件定时器:

  1. 系统正常运行时,应用程序定期向看门狗发送”喂狗”信号(清零计数器)
  2. 如果系统死机,应用程序不再发送喂狗信号
  3. 计数器溢出后,看门狗硬件会向CPU发送复位信号,强制重启

在OpenWrt中,看门狗通过设备节点/dev/watchdog暴露给应用程序。应用程序打开这个设备节点后,定期向其写入数据来喂狗;如果超过指定时间没有写入,系统就会被重启——俗称”被狗咬”。

OpenWrt的procd初始化系统内置了看门狗支持。在服务配置中使用respawn参数时,如果服务进程意外退出,procd会自动重启该进程。

二、Flash存储的分区结构

路由器使用Flash存储器(通常是SPI NOR Flash)来持久化存储固件和配置数据。Linux内核通过MTD(Memory Technology Device)子系统来管理这类存储设备。

2.1 一个典型的分区布局

以MT7628平台的8MB Flash为例:

┌──────────────────────────────────────┐ 0x000000
│           u-boot (192KB)             │
│        引导程序                      │
├──────────────────────────────────────┤ 0x030000
│        u-boot-env (64KB)            │
│     引导程序的环境变量配置            │
├──────────────────────────────────────┤ 0x040000
│        factory (64KB)               │
│  芯片出厂参数(MAC地址、天线校准等)   │
├──────────────────────────────────────┤ 0x050000
│                                     │
│        firmware (~7MB)              │
│  ┌───────────────────────────────┐   │
│  │        kernel (~4MB)         │   │
│  │     Linux内核                │   │
│  ├───────────────────────────────┤   │
│  │    rootfs (squashFS) (~4MB)  │   │
│  │  只读根文件系统                │   │
│  ├───────────────────────────────┤   │
│  │  rootfs_data (JFFS2) (~2MB)  │   │
│  │  可写数据分区(用户配置)      │   │
│  └───────────────────────────────┘   │
│                                     │
├──────────────────────────────────────┤
│        其他分区(可选)              │
│    backup / bdinfo / art 等         │
└──────────────────────────────────────┘ 0x800000 (8MB)

可以通过cat /proc/mtd命令在运行中的设备上查看实际的分区信息:

dev:    size   erasesize  name
mtd0: 00030000 00010000 "u-boot"
mtd1: 00010000 00010000 "u-boot-env"
mtd2: 00010000 00010000 "factory"
mtd3: 01fb0000 00010000 "firmware"
mtd4: 00118da1 00010000 "kernel"
mtd5: 01e9725f 00010000 "rootfs"
mtd6: 019e0000 00010000 "rootfs_data"

2.2 各分区的作用

u-boot分区:存储引导程序。这是设备能够启动的基础,损坏后设备将无法启动(变成”砖头”)。

u-boot-env分区:存储引导程序的环境变量,如启动参数、bootdelay(等待按键的超时时间)等。

factory分区:存储芯片出厂时写入的硬件参数。对SD-WAN终端开发来说,最重要的是其中保存的MAC地址。每台设备的MAC地址是唯一的,系统启动时需要从这个分区读取MAC地址并配置到网络接口上。此外,该分区还保存WiFi天线校准参数等信息。

firmware分区:这是升级操作的主要目标。它内部又分为kernel(Linux内核)和rootfs(根文件系统)两部分,系统运行时由内核的MTD分区驱动进行二次划分。

2.3 固件文件的类型

OpenWrt编译输出多种格式的固件文件,用途各不相同:

  • factory.bin:完整的固件镜像,包含所有分区。用于首次刷机或完全恢复出厂设置
  • sysupgrade.bin:仅包含kernel和rootfs,用于在线升级。升级时会保留用户的配置数据
  • initramfs-kernel.bin:将内核和根文件系统打包在一起,完全在内存中运行。用于开发和测试,不会写入Flash

三者的关系:sysupgrade.bin + 用户配置区 = factory.bin 的大小

2.4 分区定义的位置

分区布局在源码中的DTS(Device Tree Source)设备树文件中定义。以8MB Flash的MT7628为例:

partition@0 {
    label = "u-boot";
    reg = <0x0 0x30000>;      // 起始地址0x0,大小192KB
};
partition@30000 {
    label = "u-boot-env";
    reg = <0x30000 0x10000>;   // 起始地址0x30000,大小64KB
};
factory: partition@40000 {
    label = "factory";
    reg = <0x40000 0x10000>;   // 起始地址0x40000,大小64KB
};
partition@50000 {
    label = "firmware";
    reg = <0x50000 0x790000>;  // 起始地址0x50000,大小约7MB
};

在某些设备上,Flash的实际可用空间可能大于固件分区的大小。例如32MB Flash的设备,firmware分区可能只定义了16MB,剩余空间未被使用。如果需要更大的固件空间,可以修改DTS中的reg值,但需要确保不超过Flash的实际物理容量,否则系统会报错:

mtd: partition "firmware" extends beyond the end of device "spi0.0" -- size truncated to 0xfb0000

三、Overlay文件系统:读与写的分离

理解了分区结构后,一个自然的问题是:rootfs标记为只读,但用户安装软件、修改配置后,这些变更保存在哪里?

3.1 传统方案的问题

如果将整个根文件系统设为可读写(如ext4格式),有一个显著的风险:用户错误操作或异常断电可能导致文件系统损坏,设备无法启动。对于路由器这种”可能永远没人去现场维护”的设备来说,这是不可接受的。

3.2 OpenWrt的解决方案:Overlay

OpenWrt采用了一种巧妙的方案——将文件系统分为只读层可写层,通过overlay机制叠加在一起:

系统启动流程:

1. 内核将 rootfs_rom(squashFS只读分区)挂载为根目录 /
   同时挂载为 /rom(方便用户访问原始文件)
       ↓
2. 将 rootfs_data(JFFS2可写分区)挂载到 /overlay
       ↓
3. 将 /overlay 透明挂载到 根目录 /
   (在已有的rootfs_rom之上再叠加一层)
       ↓
4. 将一部分内存(tmpfs)挂载为 /tmp

最终的效果是:从用户视角看,只有一个统一的根目录/。但对文件的读写操作会遵循以下规则:

  • 读取文件:先在rootfs_data(可写层)中查找,找不到再到rootfs_rom(只读层)中查找
  • 修改文件:将原始文件从只读层复制到可写层,然后在可写层上修改。只读层的原始文件保持不变
  • 删除文件:在可写层创建一个”whiteout”标记,遮蔽只读层中的对应文件
  • 新增文件:直接写入可写层

这种机制的好处是:无论用户怎么修改,原始系统文件始终完好无损。执行”恢复出厂设置”只需清空rootfs_data分区,系统就会回到编译固件时的初始状态。

3.3 文件系统格式对比

特性 squashFS JFFS2
读写 只读 可读写
压缩 支持(节省Flash空间) 支持
擦写寿命 不涉及 针对Flash的磨损均衡
适合场景 系统文件(很少变化) 用户数据(频繁变化)

可以在设备上通过df命令查看各挂载点的使用情况,确认overlay机制是否正常工作。

3.4 /tmp的特殊作用

OpenWrt将一部分内存挂载为/tmp目录。这意味着写入/tmp的文件不会占用Flash空间,但设备重启后会丢失。这对于以下场景很有用:

  • 将运行时环境安装到内存中(opkg install runtime-base -d ram),不占用宝贵的Flash空间
  • 存放临时文件和进程间通信的数据

四、系统初始化:从内核到服务

内核启动并挂载文件系统后,需要启动各种系统服务。OpenWrt使用procd作为初始化系统。

4.1 启动脚本的执行顺序

procd会按编号顺序执行/etc/rc.d/目录下的启动脚本(以S开头的文件),编号越小越先执行:

编号 脚本 作用
S10 boot 执行UCI默认配置初始化、生成网络配置
S10 system 根据UCI配置设置系统参数
S11 sysctl 加载内核参数配置
S19 firewall 启动防火墙(fw3)
S20 network 启动网络服务(netifd)

值得注意的是,S10boot脚本中有一个特殊的机制:它会执行/etc/uci-defaults/目录下的所有脚本。这些脚本只在第一次启动时执行一次,执行成功后会被自动删除。这种机制用于完成一些一次性的初始化工作,比如设置默认管理密码、生成SSL证书等。

4.2 网络配置的自动生成

S10boot中,系统会调用/bin/config_generate程序来自动生成初始的网络配置。这个程序的输入来源是/etc/board.json文件。

board.json描述了当前硬件的板级信息(接口布局、MAC地址、LED配置等),它由/bin/board_detect程序生成。board_detect通过遍历执行/etc/board.d/目录下的脚本来收集信息,这些脚本通过调用uci-defaults.sh库函数将硬件信息写入board.json

这个机制在”添加新设备型号”时非常重要——开发者需要为新型号编写相应的DTS文件和board.d脚本,系统才能正确识别硬件并生成网络配置。

五、小结

理解OpenWrt的启动流程和存储结构,是进行任何定制开发的基础。总结几个关键要点:

  1. 启动链:U-Boot → 内核 → procd → 系统服务,每一层都有明确的职责
  2. 分区设计:引导、环境变量、出厂参数、固件四类分区各有用途,升级只动固件分区
  3. Overlay文件系统:通过读写分离实现系统文件的保护和用户配置的持久化,恢复出厂设置只需清空可写层
  4. 初始化流程:board_detect → board.json → config_generate,自动生成与硬件匹配的网络配置

在下一篇文章中,我们将进入编译体系——如何从OpenWrt源码出发,构建出包含自定义功能的固件。