第三章 Bootloader与内核启动:U-Boot移植与配置、Linux内核裁剪与编译、设备树(DTS)基础

各位同学,今天我们来聊聊嵌入式Linux启动过程中最关键的两个环节——Bootloader和内核启动。说实话,我在刚入行那会儿,觉得这部分特别神秘,好像系统一上电就自动跑起来了。后来自己动手做项目,才发现这里面的门道可不少。

嗯,咱们先从U-Boot说起。

3.1 U-Boot移植与配置

U-Boot,全称是Universal Bootloader。说白了,它就是嵌入式系统的"第一段程序"。CPU上电后,最先执行的就是它。它的任务很简单:初始化硬件,加载内核到内存,然后跳转过去执行。

我在项目中遇到过最头疼的事,就是U-Boot移植。不同板子的硬件配置千差万别,你得一个一个去适配。不过别怕,掌握了套路,其实也不难。

3.1.1 U-Boot的目录结构

先看看U-Boot的源码目录,我习惯先了解整体框架:

u-boot/
├── arch/          # 架构相关代码(ARM、x86、RISC-V等)
├── board/         # 板级支持包
├── common/        # 通用功能
├── drivers/       # 驱动代码
├── dts/           # 设备树文件
├── include/       # 头文件
├── net/           # 网络协议栈
├── cmd/           # 命令行命令
└── tools/         # 辅助工具

我个人建议,刚开始接触时重点关注 board/arch/ 这两个目录。因为移植工作主要就是改这两个地方。

3.1.2 移植U-Boot的基本步骤

移植U-Boot,说白了就是让它在你的板子上能跑起来。我总结了一下,核心就三步:

  1. 选择参考板:找一个和你硬件最接近的板子作为起点
  2. 修改板级配置:调整内存、时钟、外设等参数
  3. 编译验证:烧录到板子上看能不能跑

举个例子,假设我们要为一块基于i.MX6ULL的板子移植U-Boot:

# 1. 配置
make mx6ull_14x14_evk_defconfig

# 2. 修改板级文件
# 编辑 board/freescale/mx6ullevk/mx6ullevk.c
# 修改DDR配置、GPIO初始化等

# 3. 编译
make -j4

小技巧:我刚开始做移植时,经常忘记修改时钟配置。结果U-Boot跑起来后,串口输出全是乱码。后来我养成了一个习惯——先确认时钟树,再改其他配置。你想想看,时钟不对,啥都白搭。

3.1.3 U-Boot的配置系统

U-Boot使用Kconfig配置系统,和Linux内核类似。你可以通过 make menuconfig 来图形化配置:

make menuconfig

这里我建议重点关注几个配置项:

配置项 说明 我的建议
CONFIG_BOOTDELAY 启动延时(秒) 调试时设为3,量产时设为0
CONFIG_BOOTCOMMAND 默认启动命令 根据你的存储介质设置
CONFIG_SYS_TEXT_BASE U-Boot加载地址 要和硬件设计匹配

注意:我曾经因为CONFIG_SYS_TEXT_BASE设置错误,导致U-Boot加载到内存后和别的程序冲突,系统直接死机。这个地址一定要和你的内存布局对应好,别想当然。

3.2 Linux内核裁剪与编译

U-Boot跑起来后,下一步就是加载Linux内核。但内核本身很大,动辄几十兆。对于嵌入式设备来说,存储空间和内存都有限,所以我们需要裁剪内核。

说白了,内核裁剪就是去掉你不需要的功能。比如你的设备没有WiFi,那就把WiFi驱动去掉。没有USB,就把USB子系统去掉。这样内核体积能小很多。

3.2.1 获取内核源码

从kernel.org下载,或者用芯片厂商提供的BSP包。我个人习惯用厂商的BSP,因为已经针对硬件做了优化:

# 以Linux 5.10为例
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.tar.xz
tar -xf linux-5.10.tar.xz
cd linux-5.10

3.2.2 配置内核

内核配置有三种方式:

  • make defconfig:默认配置,适合快速上手
  • make menuconfig:图形化配置,我最常用
  • make savedefconfig:保存最小配置

我建议先用 make defconfig 生成基础配置,再用 make menuconfig 手动裁剪:

# 生成默认配置
make ARCH=arm defconfig

# 图形化裁剪
make ARCH=arm menuconfig

在menuconfig界面里,我通常会重点检查这几个地方:

  1. General setup:关闭调试选项
  2. Device Drivers:只保留需要的驱动
  3. File systems:只保留需要的文件系统
  4. Networking support:按需裁剪网络协议栈

经验之谈:我做过一个项目,客户要求内核体积控制在2MB以内。一开始觉得不可能,后来把不用的驱动、文件系统、网络协议全去掉,最后内核只有1.8MB。你想想看,裁剪的空间其实很大。

3.2.3 编译内核

配置完成后,就可以编译了:

# 编译内核镜像
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4 zImage

# 编译设备树
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4 dtbs

# 编译内核模块
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4 modules

编译完成后,你会得到几个关键文件:

  • arch/arm/boot/zImage:压缩后的内核镜像
  • arch/arm/boot/dts/xxx.dtb:设备树二进制文件
  • 模块文件:.ko文件,需要安装到根文件系统

3.3 设备树(DTS)基础

设备树,英文叫Device Tree,简称DTS。它是什么?说白了,就是描述硬件信息的配置文件。

在早期Linux版本里,硬件信息都硬编码在内核代码里。换一块板子就得改内核代码,非常麻烦。设备树出现后,硬件描述和内核代码分开了。你换板子,只需要改设备树文件就行。

3.3.1 设备树的基本结构

设备树文件以 .dts 为后缀,编译后生成 .dtb 文件。它的结构是树状的:

/dts-v1/;

/ {
    model = "My Board";
    compatible = "vendor,myboard";

    #address-cells = <1>;
    #size-cells = <1>;

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x10000000>;  // 256MB内存
    };

    chosen {
        bootargs = "console=ttymxc0,115200";
    };

    soc {
        #address-cells = <1>;
        #size-cells = <1>;
        ranges;

        uart1: serial@02020000 {
            compatible = "fsl,imx6ul-uart";
            reg = <0x02020000 0x4000>;
            interrupts = <0 26 4>;
            status = "okay";
        };

        gpio1: gpio@0209c000 {
            compatible = "fsl,imx6ul-gpio";
            reg = <0x0209c000 0x4000>;
            interrupts = <0 66 4>;
            gpio-controller;
            #gpio-cells = <2>;
        };
    };
};

嗯,这里有几个关键概念:

  • 节点(node):用 {} 包裹的硬件设备描述
  • 属性(property):节点的具体参数,如 compatiblereg
  • 标签(label):如 uart1:,方便在其他地方引用

3.3.2 常用属性说明

属性 说明 示例
compatible 设备兼容性字符串 "fsl,imx6ul-uart"
reg 寄存器地址和大小 <0x02020000 0x4000>
interrupts 中断号、类型 <0 26 4>
status 设备状态(okay/disabled) "okay"

避坑指南:我曾经在写设备树时,把 reg 属性的地址写错了。结果内核启动时,驱动去访问错误的地址,系统直接panic。后来我养成了一个习惯——写设备树时,一定对照芯片手册确认寄存器地址。

3.3.3 设备树的编译与使用

设备树源文件需要编译成二进制文件才能被内核使用:

# 编译设备树
dtc -I dts -O dtb -o myboard.dtb myboard.dts

# 反编译(调试用)
dtc -I dtb -O dts -o myboard.dts myboard.dtb

在U-Boot中,加载设备树的命令是这样的:

# 从SD卡加载设备树
fatload mmc 0:1 0x83000000 myboard.dtb

# 启动内核时传递设备树
bootz 0x80800000 - 0x83000000

这里要注意,设备树的加载地址不能和内核镜像重叠。我一般习惯把内核放在 0x80800000,设备树放在 0x83000000,中间留出足够空间。

3.4 启动流程总结

好了,我们把整个启动流程串起来看看:

  1. 上电:CPU从ROM中执行固化代码
  2. U-Boot启动:初始化DDR、时钟、串口等
  3. 加载内核:从存储介质读取zImage到内存
  4. 加载设备树:读取dtb文件到内存
  5. 跳转到内核:U-Boot传递启动参数,跳转到内核入口
  6. 内核启动:解压内核,解析设备树,初始化驱动
  7. 挂载根文件系统:启动init进程,进入用户空间

嗯,这一章的内容就到这里。说实话,Bootloader和内核启动这部分,刚开始会觉得有点复杂。但只要你动手做一遍,把U-Boot烧到板子上,看着串口输出一行行信息,那种成就感还是很棒的。

下一章我们会讲根文件系统的构建,到时候再聊。