4. Linux系统裁剪与定制

好,咱们进入第四章。这一章是网关开发的核心环节——你要让Linux内核认识你的硬件,并且只保留你需要的东西。说白了,就是给网关“瘦身”和“配型”。

我刚开始做网关项目时,直接拿了个通用内核就往上烧,结果启动要30秒,Flash空间还差一大截。后来才明白,嵌入式Linux不是桌面Linux,每一KB空间都得精打细算。

4.1 内核裁剪:只留你需要的

内核裁剪的目的很简单:去掉不需要的功能,减小镜像体积,加快启动速度。工业网关通常只需要网络、串口、GPIO、I2C/SPI等基础外设,什么蓝牙、Wi-Fi(除非网关自带)、显卡驱动,统统可以砍掉。

核心原则: 裁剪不是删代码,而是通过配置菜单关闭不需要的模块。内核源码树下的 make menuconfig 是你最常用的工具。

具体步骤我一般这样走:

  1. 确定目标硬件:CPU型号、外设接口、存储大小。比如我用的是TI的AM335x系列,那就要选对应的SoC支持。
  2. 执行 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig:进入图形化配置界面。
  3. 逐项检查:
    • 关闭所有非必要的驱动(如显卡、声卡、USB HID)。
    • 关闭调试选项(Kernel hacking 里很多调试功能可以关掉)。
    • 文件系统只保留需要的(比如只留 ext4 和 squashfs)。
  4. 保存为 .config,然后编译。

我的习惯: 每次裁剪前,先把默认配置备份一份。我曾经因为手快,把网络协议栈整个关掉了,编译出来的内核连ping都不通……嗯,从那以后我学会了 cp .config .config.bak。

4.2 内核编译:从源码到镜像

裁剪完配置,接下来就是编译。嵌入式内核编译和桌面版不太一样,你得指定架构和交叉编译器。

一个典型的编译命令序列如下:

export ARCH=arm
export CROSS_COMPILE=arm-linux-gnueabihf-
make mrproper
make am335x_evm_defconfig    # 使用厂商默认配置作为起点
make menuconfig              # 手动裁剪
make -j4 uImage LOADADDR=0x80008000
make -j4 dtbs                # 编译设备树
make -j4 modules             # 编译内核模块

这里有几个关键点:

  • mrproper:清理所有中间文件,确保从干净状态开始。
  • LOADADDR:指定内核加载地址,不同SoC不一样,查手册。
  • -j4:并行编译,加快速度。我一般设成CPU核心数+1。

注意: 编译时如果报错说缺少头文件或库,先检查交叉编译器是否安装完整。我遇到过最坑的一次是编译器版本太新,内核源码不兼容,最后换回Linaro 7.5才搞定。

4.3 设备树(Device Tree)语法与实战

设备树,说白了就是描述硬件信息的“配置文件”。它告诉内核:CPU是什么、内存多大、外设挂在哪里、中断号是多少。没有设备树,内核就不知道该怎么跟硬件打交道。

你想想看,以前ARM Linux用大量板级文件(board-*.c)来写硬件信息,每次换板子都要改内核代码。设备树把硬件描述和内核代码分开了,换板子只需要换一个.dts文件。

4.3.1 基本语法

设备树源文件(.dts)的语法很直观,我直接拿一个例子说明:

/dts-v1/;

/ {
    model = "My Industrial Gateway Board";
    compatible = "mycompany,gateway-v1", "ti,am335x";

    chosen {
        stdout-path = &uart0;
    };

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

    leds {
        compatible = "gpio-leds";
        led0: led@0 {
            label = "status:green";
            gpios = <&gpio1 16 GPIO_ACTIVE_HIGH>;
            linux,default-trigger = "heartbeat";
        };
    };

    &i2c0 {
        status = "okay";
        clock-frequency = <100000>;

        eeprom@50 {
            compatible = "atmel,24c02";
            reg = <0x50>;
        };
    };
};

关键概念:

元素 说明 示例
节点 用 / 开头,表示一个设备或总线 leds { ... }
属性 键值对,描述节点特征 compatible = "gpio-leds";
reg 地址和大小,格式为 <地址 大小> reg = <0x80000000 0x10000000>;
中断 用 interrupt-parent 和 interrupts 描述 interrupts = <0 67 4>;
引用 用 &label 引用其他节点 &i2c0 { ... };

4.3.2 实战:添加一个自定义外设

假设我们的网关板子上外挂了一个ADC芯片(比如MCP3208),通过SPI接口连接。我需要让内核识别它。

第一步,在设备树中添加SPI节点下的子节点:

&spi0 {
    status = "okay";
    pinctrl-names = "default";
    pinctrl-0 = <&spi0_pins>;

    adc@0 {
        compatible = "microchip,mcp3208";
        reg = <0>;               // SPI片选0
        spi-max-frequency = <1000000>;
        vref-supply = <&vref_3v3>;
    };
};

第二步,在内核中确认驱动是否已启用。MCP3208的驱动在 drivers/iio/adc/ 下,通过 make menuconfig 找到并勾选:

Device Drivers --->
    Industrial I/O support --->
        Analog to digital converters --->
            <*> Microchip MCP3208

第三步,重新编译设备树和内核,烧录后启动。如果一切正常,你会在 /sys/bus/iio/devices/ 下看到新的ADC设备。

避坑指南: 我曾经在设备树里写错了SPI片选号,结果ADC和另一个设备冲突,系统启动时直接挂死。排查了半天才发现是 reg = <0> 写成了 reg = <1>。所以,每次改设备树后,建议先用 dtc -I dtb -O dts 反编译一下,确认生成的二进制文件和你写的一致。

4.4 知识体系总览

下面这张图把本章的核心逻辑串起来了。从硬件板子到最终运行的内核,设备树是中间的桥梁。

内核裁剪与设备树工作流程 内核裁剪与设备树工作流程 硬件板卡 CPU / 内存 / 外设 设备树 (.dts) 描述硬件拓扑与资源 内核配置 (.config) 裁剪功能 / 选择驱动 提取 匹配驱动 dtb 文件 编译后的设备树二进制 uImage / zImage 压缩后的内核镜像 内核模块 (.ko) 可动态加载的驱动 dtc 编译 make 编译 Bootloader 加载并启动内核 传递 dtb 地址给内核 核心思路 硬件描述(设备树)与内核逻辑分离 → 裁剪配置 → 编译 → 烧录 → 启动

4.5 添加自定义外设驱动:一个完整案例

如果内核里没有现成的驱动怎么办?那就自己写一个。我以最简单的GPIO控制为例,演示如何添加一个自定义驱动。

假设我们有一个LED灯,接在GPIO1_16上,想通过 /sys/class/leds/ 控制它。其实内核已经有 gpio-leds 驱动,但为了教学,我们写一个最小化的字符驱动。

4.5.1 驱动源码

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/gpio.h>
#include <linux/uaccess.h>

#define GPIO_LED 48  // GPIO1_16 对应的编号

static int led_open(struct inode *inode, struct file *file) {
    return 0;
}

static ssize_t led_write(struct file *file, const char __user *buf,
                         size_t len, loff_t *off) {
    char kbuf[4];
    if (copy_from_user(kbuf, buf, len))
        return -EFAULT;

    if (kbuf[0] == '1')
        gpio_set_value(GPIO_LED, 1);
    else
        gpio_set_value(GPIO_LED, 0);

    return len;
}

static struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .open = led_open,
    .write = led_write,
};

static int __init led_init(void) {
    int ret;

    ret = gpio_request(GPIO_LED, "my_led");
    if (ret) {
        pr_err("Failed to request GPIO\n");
        return ret;
    }

    gpio_direction_output(GPIO_LED, 0);
    register_chrdev(0, "my_led", &led_fops);
    pr_info("My LED driver loaded\n");
    return 0;
}

static void __exit led_exit(void) {
    gpio_free(GPIO_LED);
    unregister_chrdev(0, "my_led");
    pr_info("My LED driver unloaded\n");
}

module_init(led_init);
module_exit(led_exit);
MODULE_LICENSE("GPL");

4.5.2 编译与测试

把源码放到 drivers/char/ 下,修改 Kconfig 和 Makefile,然后重新编译内核或编译为模块。加载后:

# echo 1 > /dev/my_led   // 点亮
# echo 0 > /dev/my_led   // 熄灭

注意: 这只是演示用的最小驱动。实际产品中,你应该使用内核现有的子系统(如LED子系统、GPIO子系统),而不是直接操作 gpio_set_value。我见过不少新手直接操作GPIO寄存器,结果移植到新内核时全废了。

4.6 本章小结

内核裁剪让你去掉冗余,设备树让你描述硬件,自定义驱动让你扩展功能。这三者加起来,就是嵌入式Linux网关开发的“三板斧”。

我个人觉得,设备树是这里面最值得花时间啃的。你想想看,一个设备树写对了,硬件和内核的“沟通”就顺畅了;写错了,轻则外设不工作,重则系统起不来。所以,每次改完.dts,我都会用 dtc -I dtb -O dts 反编译检查一遍,确保没有低级错误。