2. Linux 内核与驱动基础:内核编译与模块加载、设备树解析、字符设备驱动框架、GPIO 与 PWM 驱动实战
好,咱们进入第二章。这一章是硬桥硬马的底层功夫。说白了,你要在嵌入式 Linux 上部署 AI 模型,内核和驱动这关绕不过去。模型跑在用户态,但最终要操作硬件——摄像头、GPIO、PWM 调速风扇、SPI 闪存……这些全得靠驱动。
我刚开始做嵌入式时,总觉得驱动是内核工程师的事。后来发现,模型推理框架移植时,经常要自己调设备树、改 GPIO 中断、甚至写个简单的字符设备驱动来验证硬件通路。嗯,这一章就是帮你把这些基本功打扎实。
2.1 内核编译与模块加载
先聊内核编译。你拿到一块开发板,厂家给了内核源码,第一件事就是把它编译出来。我个人习惯用 make menuconfig 来配置,图形界面直观,不容易漏选项。
编译命令其实就几条,但参数别搞错:
# 配置内核(基于厂家默认配置)
make ARCH=arm64 defconfig
make ARCH=arm64 menuconfig
# 编译内核和模块
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j4
# 安装模块到目标目录
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules_install INSTALL_MOD_PATH=./modules
这里有个坑——交叉编译工具链版本。我曾经在一个项目里用了太新的 GCC 编译内核,结果启动时内核 panic,折腾了两天才发现是工具链 ABI 不兼容。建议用厂家 SDK 配套的工具链,或者 Linaro 的稳定版。
模块加载这块,insmod 和 modprobe 的区别你要清楚:
| 命令 | 特点 | 适用场景 |
|---|---|---|
insmod |
直接加载 .ko 文件,不处理依赖 | 调试单个模块,路径明确 |
modprobe |
自动解析依赖,加载所有需要的模块 | 生产环境,模块间有依赖关系 |
rmmod |
卸载模块 | 调试或热插拔场景 |
insmod 配合 dmesg 看打印信息,能快速定位模块初始化失败的原因。我习惯在模块入口函数里加 printk 打印模块名和参数,方便确认加载顺序。
2.2 设备树(Device Tree)解析
设备树是嵌入式 Linux 的硬件描述语言。你想想看,PC 上有 ACPI 自动枚举硬件,但嵌入式平台千奇百怪,SoC 厂家得告诉内核「我这颗芯片有哪些外设、地址是多少、中断号是多少」。设备树就是干这个的。
一个典型的设备树节点长这样:
/ {
model = "TI AM335x BeagleBone Black";
compatible = "ti,am335x-bone-black", "ti,am335x-bone", "ti,am33xx";
leds {
pinctrl-names = "default";
pinctrl-0 = <&user_led_pins>;
led2 {
label = "beaglebone:green:heartbeat";
gpios = <&gpio1 21 GPIO_ACTIVE_HIGH>;
linux,default-trigger = "heartbeat";
};
};
};
这里有几个关键概念:
- compatible:驱动匹配的关键字符串,驱动里通过
of_match_table来匹配 - reg:寄存器地址和大小,描述硬件资源
- interrupts:中断号,注意 SPI 和 GPIO 中断的编码方式不同
- pinctrl:引脚复用配置,这个最容易出错
&gpio1 21 这个 21 是 bank 内的偏移,不是全局编号。不同厂家的 GPIO 编号规则不一样,一定要看 datasheet。
设备树编译命令:
# 编译 .dts 为 .dtb
dtc -I dts -O dtb -o am335x-boneblack.dtb am335x-boneblack.dts
# 反编译 .dtb 为 .dts(调试用)
dtc -I dtb -O dts -o am335x-boneblack.dts am335x-boneblack.dtb
调试设备树时,我常用 ls /proc/device-tree/ 来查看内核实际解析到的设备树内容,确认节点有没有被正确识别。
2.3 字符设备驱动框架
字符设备驱动是嵌入式 Linux 里最基础的驱动模型。GPIO、PWM、I2C、SPI 这些设备,本质上都是字符设备。框架其实很固定,核心就是 file_operations 结构体:
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static int my_open(struct inode *inode, struct file *filp) {
pr_info("my_device opened\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) {
// 从硬件读取数据,拷贝到用户空间
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) {
// 从用户空间接收数据,写入硬件
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
};
注册字符设备的步骤我总结为四步:
- 分配设备号:
alloc_chrdev_region动态分配,或者register_chrdev_region静态指定 - 初始化 cdev:
cdev_init绑定 file_operations - 添加设备:
cdev_add告诉内核这个设备存在 - 创建设备节点:
class_create+device_create自动在/dev下生成节点
open/read/write/ioctl 操作设备文件,驱动里对应的函数会被调用。这就是「一切皆文件」的思想。
调试字符设备驱动时,我习惯在 open 和 release 里加打印,确认用户态程序是否正确打开了设备。另外,copy_to_user 和 copy_from_user 这两个函数一定要用,不能直接解引用用户空间指针——内核态和用户态的地址空间是隔离的。
2.4 GPIO 与 PWM 驱动实战
好,到了动手环节。GPIO 和 PWM 是嵌入式里最常用的外设接口。模型部署时,你可能需要用 GPIO 控制复位引脚、用 PWM 调节散热风扇转速。
GPIO 驱动
Linux 内核提供了 gpiolib 子系统,操作 GPIO 变得很简单:
#include <linux/gpio.h>
#include <linux/of_gpio.h>
// 从设备树获取 GPIO 编号
int gpio = of_get_named_gpio(np, "enable-gpios", 0);
if (gpio < 0) {
pr_err("Failed to get GPIO\n");
return gpio;
}
// 申请并配置 GPIO
gpio_request(gpio, "my_device_enable");
gpio_direction_output(gpio, 0); // 初始输出低电平
// 控制电平
gpio_set_value(gpio, 1); // 拉高
gpio_set_value(gpio, 0); // 拉低
这里要注意:gpio_request 一定要检查返回值,我曾经漏了检查,结果两个驱动抢同一个 GPIO,系统直接死锁。
PWM 驱动
PWM 驱动在 Linux 中通过 pwm 子系统管理。设备树里配置好 PWM 控制器和通道,驱动里调用 API 设置频率和占空比:
#include <linux/pwm.h>
struct pwm_device *pwm = pwm_get(&pdev->dev, NULL);
if (IS_ERR(pwm)) {
pr_err("Failed to get PWM\n");
return PTR_ERR(pwm);
}
// 配置 PWM:频率 1kHz,占空比 50%
pwm_config(pwm, 500000, 1000000); // duty_ns=500us, period_ns=1000us
pwm_enable(pwm);
下面这张图总结了本章的知识体系,帮你理清内核、设备树、驱动和应用层的关系: