2、Linux内核驱动基础:内核模块编程、字符设备驱动框架、设备树基础
好,咱们进入第二章。说实话,很多做嵌入式Linux开发的朋友,一开始都被内核驱动这块给唬住了。我当年刚接触时也一样,感觉这东西太底层了,摸不着头脑。但后来做车载通信模组驱动移植,天天跟内核打交道,慢慢就摸清了门道。
这一章,我带你把地基打牢。咱们不讲虚的,就讲三个最核心的东西:内核模块怎么编、字符设备驱动怎么写、设备树怎么配。这三板斧学会了,后面移植4G/5G模组驱动就顺了。
2.1 内核模块编程——说白了就是“插件”
Linux内核本身是个大胖子,啥功能都编译进去的话,又大又笨。所以内核设计者想了个办法——模块化。需要啥功能,就动态加载啥模块,不用了就卸载。这不就跟手机装App一样嘛。
我习惯把内核模块比作“乐高积木”。内核是底板,模块就是一块块积木,需要时拼上去,不需要时拆下来。车载通信模组的驱动,绝大多数情况下都是以模块形式存在的。
2.1.1 最简单的内核模块长啥样?
来看一个最简模块代码。别怕,就几行:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "Hello, 车载模组驱动加载成功!\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "驱动卸载,再见!\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("你的名字");
MODULE_DESCRIPTION("一个简单的车载模组驱动示例");
嗯,这里要注意几个点:
module_init和module_exit是入口和出口,就像C程序的main函数__init和__exit是宏,告诉内核:初始化函数用一次就扔掉,省内存printk是内核版的printf,输出到内核日志,用dmesg命令查看
printk(KERN_INFO "[my_mod] 初始化成功\n");,这样看日志时一眼就能找到自己的打印。
2.1.2 编译和加载模块
写好了代码,怎么编译?你需要一个Makefile。我直接给你一个模板:
obj-m := my_driver.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
然后执行 make,会生成 my_driver.ko 文件。加载和卸载命令:
# 加载模块
insmod my_driver.ko
# 查看模块是否加载
lsmod | grep my_driver
# 卸载模块
rmmod my_driver
# 查看内核日志
dmesg | tail -20
schedule() 或工作队列来处理耗时任务。
2.2 字符设备驱动框架——用户和内核的“桥梁”
模块加载了,但用户空间的应用程序怎么跟硬件通信?这就需要字符设备驱动了。说白了,字符设备驱动就是给硬件在 /dev 目录下创建一个文件,应用程序通过读写这个文件来操作硬件。
4G/5G模组通常通过USB或PCIe接口连接,在Linux里被识别为多个字符设备(比如 /dev/ttyUSB0、/dev/cdc-wdm0)。这些设备背后,就是字符设备驱动在干活。
2.2.1 字符设备的核心结构体
写字符设备驱动,你绕不开这几个东西:
| 结构体/函数 | 作用 |
|---|---|
struct cdev |
字符设备的内核表示 |
struct file_operations |
定义设备支持的操作(open, read, write, ioctl等) |
alloc_chrdev_region() |
动态分配设备号 |
cdev_init() |
初始化cdev结构体 |
cdev_add() |
将设备注册到内核 |
我画个简单的流程图,你感受一下:
用户空间: open("/dev/mydev") → read() → write() → close()
↓ ↓ ↓ ↓
内核空间: my_open() → my_read() → my_write() → my_release()
↓ ↓ ↓ ↓
硬件层: 初始化硬件 读取数据 发送命令 关闭硬件
2.2.2 实战:一个虚拟字符设备
咱们写一个简单的虚拟字符设备,它不操作真实硬件,只在内核里维护一个缓冲区。这在调试驱动框架时非常有用。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_virt_dev"
#define BUFFER_SIZE 1024
static dev_t dev_num;
static struct cdev my_cdev;
static char device_buffer[BUFFER_SIZE];
static int my_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "设备已打开\n");
return 0;
}
static ssize_t my_read(struct file *file, char __user *buf,
size_t len, loff_t *offset)
{
int ret;
if (*offset >= BUFFER_SIZE)
return 0;
if (*offset + len > BUFFER_SIZE)
len = BUFFER_SIZE - *offset;
ret = copy_to_user(buf, device_buffer + *offset, len);
*offset += len;
return len;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
int ret;
if (*offset >= BUFFER_SIZE)
return -ENOSPC;
if (*offset + len > BUFFER_SIZE)
len = BUFFER_SIZE - *offset;
ret = copy_from_user(device_buffer + *offset, buf, len);
*offset += len;
return len;
}
static int my_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "设备已关闭\n");
return 0;
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
static int __init my_init(void)
{
// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
// 2. 初始化cdev
cdev_init(&my_cdev, &fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册设备
cdev_add(&my_cdev, dev_num, 1);
printk(KERN_INFO "字符设备注册成功,主设备号: %d\n", MAJOR(dev_num));
return 0;
}
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "字符设备已卸载\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
copy_to_user 和 copy_from_user 这两个函数。内核空间不能直接访问用户空间的内存,必须用这两个函数做安全拷贝。我见过新手直接 memcpy,结果内核崩溃了。
加载模块后,用 mknod /dev/my_virt_dev c 主设备号 0 创建设备节点,然后就可以用 echo "hello" > /dev/my_virt_dev 和 cat /dev/my_virt_dev 测试了。
2.3 设备树基础——硬件的“户口本”
设备树(Device Tree)是啥?说白了,就是描述硬件信息的配置文件。以前ARM Linux用大量的板级文件(board-xxx.c)来描述硬件,每次换硬件都要改内核代码,太麻烦了。设备树把硬件描述从内核代码里剥离出来,变成独立的 .dts 文件。
你想想看,车载通信模组要适配不同车型、不同平台,每个平台的GPIO、中断、电源管理都不一样。如果没有设备树,你得为每个平台写一套驱动代码。有了设备树,驱动代码只需要一套,硬件差异全在 .dts 文件里配。
2.3.1 设备树的基本语法
设备树文件后缀是 .dts,编译后变成 .dtb(二进制)。来看一个简单的例子:
/dts-v1/;
/ {
model = "车载通信模组评估板";
compatible = "mycompany,car-modem-eval";
// 这是CPU节点
cpu@0 {
compatible = "arm,cortex-a7";
reg = <0>;
};
// 串口节点,用于AT指令通信
uart0: serial@10000000 {
compatible = "ns16550";
reg = <0x10000000 0x1000>;
interrupts = <0 33 4>;
clock-frequency = <24000000>;
status = "okay";
};
// GPIO按键,用于模组复位
gpio-keys {
compatible = "gpio-keys";
reset-button {
label = "Modem Reset";
gpios = <&gpio1 3 GPIO_ACTIVE_LOW>;
linux,code = <KEY_RESTART>;
};
};
};
这里有几个概念要搞清楚:
- 节点(node):每个硬件单元是一个节点,用
{}包裹 - 属性(property):节点的特征,比如
compatible、reg、interrupts - compatible:最重要的属性,驱动通过它来匹配设备。格式是
"厂商,设备型号" - reg:描述地址和大小,格式是
<地址 大小>
compatible 属性时,一定要加厂商前缀。比如 "mycompany,mydevice",不要只写 "mydevice"。因为内核里可能有重名的设备,加上前缀能避免冲突。我在项目中就遇到过因为 compatible 写得太简单,导致驱动匹配到了错误的设备。
2.3.2 驱动如何读取设备树信息
驱动代码里怎么拿到设备树里配的参数?看这个例子:
#include <linux/of.h>
#include <linux/of_gpio.h>
static int my_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
struct device_node *np = dev->of_node;
const char *str;
u32 val;
int gpio;
// 读取字符串属性
if (of_property_read_string(np, "label", &str) == 0) {
dev_info(dev, "标签: %s\n", str);
}
// 读取整数属性
if (of_property_read_u32(np, "clock-frequency", &val) == 0) {
dev_info(dev, "时钟频率: %d Hz\n", val);
}
// 读取GPIO
gpio = of_get_named_gpio(np, "reset-gpios", 0);
if (gpio_is_valid(gpio)) {
devm_gpio_request_one(dev, gpio, GPIOF_OUT_INIT_HIGH, "reset");
}
return 0;
}
static const struct of_device_id my_of_match[] = {
{ .compatible = "mycompany,car-modem-eval" },
{ /* 结束标记 */ }
};
MODULE_DEVICE_TABLE(of, my_of_match);
static struct platform_driver my_driver = {
.probe = my_probe,
.driver = {
.name = "my_driver",
.of_match_table = my_of_match,
},
};
module_platform_driver(my_driver);
看到没?驱动通过 of_match_table 来声明自己支持哪些设备,内核在启动时会根据设备树里的 compatible 属性来匹配驱动。匹配上了,就调用 probe 函数。
2.3.3 设备树编译与调试
写好了 .dts 文件,需要编译成 .dtb:
# 编译设备树
dtc -I dts -O dtb -o my_board.dtb my_board.dts
# 反编译(查看二进制dtb的内容)
dtc -I dtb -O dts -o my_board_decompiled.dts my_board.dtb
# 查看当前系统的设备树
cat /proc/device-tree/模型名称
ls /proc/device-tree/
.dts 文件,但忘了重新编译 .dtb,结果折腾了半天发现硬件没反应。后来才想起来,内核加载的是 .dtb,不是 .dts。所以每次改完 .dts,一定要记得 make dtbs 或者手动编译。
2.4 本章小结
好,咱们捋一捋这一章的核心:
- 内核模块:动态加载的代码片段,用
insmod加载,rmmod卸载。注意别在init里写死循环 - 字符设备驱动:通过
/dev下的文件与用户空间交互。核心是file_operations结构体,实现 open、read、write 等函数 - 设备树:硬件的描述文件,把硬件信息从内核代码里解耦出来。驱动通过
compatible属性匹配设备,用of_系列API读取参数
这三样东西,是嵌入式Linux驱动的基石。后面咱们移植4G/5G模组驱动时,会反复用到这些知识。比如模组的USB驱动是字符设备,电源管理依赖设备树里的GPIO配置,模组固件加载可能要用内核模块的机制。
下一章,咱们开始真正接触4G/5G模组,看看它在Linux里长什么样。准备好了吗?