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_initmodule_exit 是入口和出口,就像C程序的main函数
  • __init__exit 是宏,告诉内核:初始化函数用一次就扔掉,省内存
  • printk 是内核版的printf,输出到内核日志,用 dmesg 命令查看
我的小习惯: 调试时我喜欢在printk里加上模块名,比如 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
我曾经踩过的坑: 有一次我加载模块后,系统直接卡死了。后来发现是模块里有个死循环,把CPU占满了。所以写模块时,千万别在init函数里写死循环,否则内核会挂掉。建议用 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_usercopy_from_user 这两个函数。内核空间不能直接访问用户空间的内存,必须用这两个函数做安全拷贝。我见过新手直接 memcpy,结果内核崩溃了。

加载模块后,用 mknod /dev/my_virt_dev c 主设备号 0 创建设备节点,然后就可以用 echo "hello" > /dev/my_virt_devcat /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):节点的特征,比如 compatiblereginterrupts
  • 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里长什么样。准备好了吗?