3、Linux内核与设备驱动基础:内核模块编程、字符设备驱动框架、platform总线与设备树、GPIO与中断驱动开发

说实话,很多做嵌入式Linux的朋友,一开始都被驱动开发给吓住了。我当年也是,看着那堆内核代码,心里直打鼓。但后来我发现,只要你把几个核心概念吃透了,驱动开发其实就是个搭积木的活儿。

这一章,咱们就聊聊驱动开发里最基础、也最常用的几个东西。内核模块怎么编?字符设备怎么搞?设备树和platform总线是干嘛的?还有GPIO和中断,这些在气象数据采集里天天都要打交道。

3.1 内核模块编程——驱动的最小单元

Linux内核是个庞然大物,但好在它支持模块化。说白了,就是你可以把一段代码编译成一个.ko文件,然后动态地加载到内核里,或者卸载掉。这比每次改个驱动都要重新编译整个内核,可方便太多了。

我习惯把内核模块比作「乐高积木」。你写一个模块,就是造一块积木,然后插到内核这个「大底板」上。模块之间可以互相调用,但要注意符号的导出。

一个最简单的内核模块,长这样:

#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("一个简单的气象数据采集驱动模块");

你看,结构很清晰。init函数在模块加载时执行,exit函数在卸载时执行。这里有个坑,我刚开始写的时候经常犯——printk的日志级别。如果你在终端看不到输出,多半是级别设低了,试试KERN_ALERT

我的小习惯: 调试模块时,我总爱在init函数里加个printk,确认模块确实加载成功了。这招虽然土,但管用。

3.2 字符设备驱动框架——让应用层和硬件对话

气象数据采集,说白了就是读传感器数据。传感器在Linux里,通常被抽象成一个字符设备。你想想看,应用层程序通过openreadwrite这些标准接口,就能操作硬件,多优雅。

字符设备驱动的核心,是file_operations结构体。它就像一张函数表,告诉内核:当用户程序调用read时,该执行驱动里的哪个函数。

static struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .write = my_write,
    .release = my_release,
};

我记得有一次做温湿度传感器驱动,用户态程序读到的数据总是乱码。查了半天,发现是my_read函数里,没有把数据从内核空间拷贝到用户空间。嗯,这里要注意,copy_to_user这个函数不能少。

注册字符设备的流程,我总结了三步:

  1. 分配设备号——用alloc_chrdev_region或者register_chrdev_region
  2. 初始化cdev——用cdev_initfile_operations挂上去
  3. 添加设备——用cdev_add告诉内核
我曾经踩过的坑: 设备号是稀缺资源,千万别用静态分配的死数字。用alloc_chrdev_region让内核动态分配,省心又安全。

3.3 platform总线与设备树——驱动和设备怎么配对

你想想看,以前写驱动,硬件地址、中断号都硬编码在代码里。换个板子,就得改代码重新编译。这多麻烦。

设备树(Device Tree)的出现,就是为了解决这个问题。它把硬件信息从驱动代码里剥离出来,放到一个.dts文件里。驱动只需要说「我要用哪个设备」,剩下的由内核去匹配。

platform总线,就是设备和驱动之间的「红娘」。它负责把设备树里描述的硬件,和对应的驱动绑在一起。

一个典型的设备树节点,比如我们的气压传感器:

bmp280@76 {
    compatible = "bosch,bmp280";
    reg = <0x76>;
};

驱动这边,只需要声明自己支持哪个compatible

static const struct of_device_id bmp280_of_match[] = {
    { .compatible = "bosch,bmp280" },
    { /* 结束标记 */ }
};
MODULE_DEVICE_TABLE(of, bmp280_of_match);

static struct platform_driver bmp280_driver = {
    .probe = bmp280_probe,
    .remove = bmp280_remove,
    .driver = {
        .name = "bmp280",
        .of_match_table = bmp280_of_match,
    },
};
module_platform_driver(bmp280_driver);

当内核启动时,它会遍历设备树,找到compatible"bosch,bmp280"的节点,然后调用bmp280_probe函数。在probe里,你可以拿到设备的寄存器地址、中断号等信息。

说白了: 设备树就是硬件的「身份证」,platform总线就是「户籍警」,驱动就是「服务窗口」。三者配合,才能让硬件正常工作。

3.4 GPIO与中断驱动开发——响应外部事件

气象数据采集里,很多传感器都有数据就绪引脚。比如,当气压传感器采集到新数据时,它会拉高一个GPIO引脚。这时候,驱动就需要通过中断来响应,而不是傻傻地轮询。

GPIO的操作,在Linux里已经封装得很好了。你可以用gpio_request申请一个引脚,用gpio_direction_input设置方向,用gpio_get_value读取电平。

中断的注册,稍微复杂一点。你需要先拿到中断号,然后注册中断处理函数:

static irqreturn_t bmp280_irq_handler(int irq, void *dev_id)
{
    struct bmp280_dev *bmp280 = (struct bmp280_dev *)dev_id;
    
    // 读取数据
    bmp280_read_data(bmp280);
    
    // 唤醒等待队列,通知应用层
    wake_up_interruptible(&bmp280->wq);
    
    return IRQ_HANDLED;
}

static int bmp280_probe(struct platform_device *pdev)
{
    // ... 其他初始化代码 ...
    
    int irq = platform_get_irq(pdev, 0);
    if (irq < 0) {
        dev_err(&pdev->dev, "获取中断号失败\n");
        return irq;
    }
    
    ret = request_irq(irq, bmp280_irq_handler, 
                      IRQF_TRIGGER_RISING, 
                      "bmp280", bmp280);
    if (ret) {
        dev_err(&pdev->dev, "注册中断失败\n");
        return ret;
    }
    
    return 0;
}

这里有个细节,我特别想强调——中断上下文不能睡眠。你在中断处理函数里,不能用kmallocGFP_KERNEL标志,也不能用mutex_lock。因为中断处理时,调度器是停掉的。

我的经验: 中断处理函数要短小精悍。如果要做复杂操作,就用tasklet或者workqueue把活儿推到下半部去干。

说到GPIO,还有一个常用的功能——输出控制。比如控制加热器、风扇,或者LED指示灯。用gpio_direction_output设置方向,然后用gpio_set_value设置电平,就这么简单。

好了,这一章的内容就这些。内核模块、字符设备、platform总线、设备树、GPIO和中断,这几个概念你吃透了,嵌入式Linux驱动开发的大门就算打开了。下一章,咱们就真刀真枪地写一个完整的气象数据采集驱动。