第三章 Linux内核驱动基础

说实话,很多刚接触AI芯片驱动开发的朋友,上来就盯着硬件手册看寄存器,结果代码写到一半发现连加载都加载不进去。嗯,这很正常。内核驱动框架这东西,说白了就是操作系统和硬件之间的「翻译官」。你想想看,没有这个翻译官,你的AI加速器再强,Linux也不认识它。

我个人习惯把驱动框架比作「入住酒店」:设备树是房型图,platform驱动是前台接待,字符设备是客房服务。今天我们就把这套流程彻底讲透。

3.1 字符设备驱动框架

字符设备,就是按字节流读写的外设。比如串口、GPIO、还有我们AI芯片的控制接口。我刚开始做驱动时,以为字符设备就是简单的open/read/write,结果踩了个大坑——忘了注册设备号。

核心要点:字符设备驱动的三要素——设备号、file_operations结构体、cdev对象。

来看一个最简的字符设备框架:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>

#define DEVICE_NAME "ai_accel"
#define CLASS_NAME  "ai"

static int major_num;
static struct class *ai_class = NULL;
static struct device *ai_device = NULL;
static struct cdev ai_cdev;

static int ai_open(struct inode *inodep, struct file *filep) {
    pr_info("AI device opened\n");
    return 0;
}

static ssize_t ai_read(struct file *filep, char __user *buffer,
                       size_t len, loff_t *offset) {
    /* 这里实现从AI芯片读取数据 */
    return 0;
}

static struct file_operations ai_fops = {
    .owner = THIS_MODULE,
    .open = ai_open,
    .read = ai_read,
};

static int __init ai_init(void) {
    dev_t dev_num;
    
    /* 第一步:分配设备号 */
    alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    major_num = MAJOR(dev_num);
    
    /* 第二步:初始化cdev并注册 */
    cdev_init(&ai_cdev, &ai_fops);
    cdev_add(&ai_cdev, dev_num, 1);
    
    /* 第三步:创建设备类 */
    ai_class = class_create(THIS_MODULE, CLASS_NAME);
    ai_device = device_create(ai_class, NULL, dev_num, NULL, DEVICE_NAME);
    
    pr_info("AI driver loaded, major=%d\n", major_num);
    return 0;
}

static void __exit ai_exit(void) {
    device_destroy(ai_class, MKDEV(major_num, 0));
    class_destroy(ai_class);
    cdev_del(&ai_cdev);
    unregister_chrdev_region(MKDEV(major_num, 0), 1);
    pr_info("AI driver unloaded\n");
}

module_init(ai_init);
module_exit(ai_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("AI Chip Driver Team");

我的经验:alloc_chrdev_region会自动分配一个空闲的设备号,比手动指定major_num要安全。我曾经在项目里硬编码了major=240,结果和系统里另一个驱动冲突,调试了一整天才发现。

3.2 platform驱动模型

为什么需要platform驱动?因为有些设备不挂在PCIe、USB这些总线上,而是直接挂在CPU的内存总线上。比如我们AI芯片的配置寄存器,就是通过内存映射访问的。

platform驱动模型的核心是「匹配」——驱动和设备通过名字或设备树节点进行匹配。我个人习惯用设备树方式,因为更灵活。

static struct platform_driver ai_platform_driver = {
    .probe = ai_probe,
    .remove = ai_remove,
    .driver = {
        .name = "ai_accel",
        .of_match_table = ai_of_match,
    },
};

module_platform_driver(ai_platform_driver);

probe函数是驱动的入口点,当内核发现匹配的设备时就会调用它。我在probe里通常会做三件事:

  1. 解析设备树获取资源(地址、中断号)
  2. 映射物理地址到虚拟地址
  3. 注册字符设备或其他子系统

注意:probe函数执行时不能睡眠太久,否则会影响系统启动速度。我见过有人直接在probe里做硬件初始化循环等待,结果内核报「scheduling while atomic」错误。

3.3 设备树解析与实战

设备树(Device Tree)是描述硬件信息的结构化数据。说白了,就是告诉内核:「我这里有个AI芯片,它的寄存器在0x40000000,中断号是42」。

来看一个典型的AI芯片设备树节点:

ai_accel: ai-accelerator@40000000 {
    compatible = "vendor,ai-accel-v1";
    reg = <0x40000000 0x10000>;
    interrupts = <0 42 4>;
    clocks = <&clkc 15>;
    status = "okay";
};

在驱动中解析设备树的代码:

static int ai_probe(struct platform_device *pdev) {
    struct resource *res;
    void __iomem *base;
    int irq_num;
    
    /* 获取寄存器地址 */
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(&pdev->dev, res);
    if (IS_ERR(base))
        return PTR_ERR(base);
    
    /* 获取中断号 */
    irq_num = platform_get_irq(pdev, 0);
    if (irq_num < 0)
        return irq_num;
    
    /* 解析时钟信息 */
    struct clk *clk = devm_clk_get(&pdev->dev, NULL);
    if (!IS_ERR(clk))
        clk_prepare_enable(clk);
    
    dev_info(&pdev->dev, "AI accelerator at 0x%llx, irq=%d\n",
             (unsigned long long)res->start, irq_num);
    return 0;
}

避坑指南:我曾经在解析reg属性时用了platform_get_resource(pdev, IORESOURCE_MEM, 1),结果一直返回NULL。后来才发现设备树里只定义了一个reg区域,索引应该从0开始。嗯,这种低级错误,犯过一次就记住了。

3.4 module_init/exit机制

module_init和module_exit是驱动的入口和出口。内核通过这两个宏来管理驱动的生命周期。

加载流程是这样的:

  • insmod时,内核调用module_init指定的函数
  • rmmod时,内核调用module_exit指定的函数
  • 如果驱动编译进内核,module_init会在系统启动时被调用

这里有个细节很多人不知道:module_init其实是一个宏,它把函数指针放到一个特定的section里。内核在启动或加载模块时,会遍历这个section来调用所有初始化函数。

/* 实际上module_init展开后类似这样 */
#define module_init(x)  __initcall(x);

/* 对于编译为模块的情况 */
static int __init ai_init(void) { ... }
module_init(ai_init);

/* 对于编译进内核的情况,会变成 */
device_initcall(ai_init);

关键点:__init标记告诉内核,这个函数在初始化完成后可以释放掉。我见过有人把probe函数也标记为__init,结果第二次绑定设备时直接崩溃。__init只适用于只会执行一次的函数。

3.5 知识体系总览

为了让你更直观地理解这三者的关系,我画了一张图:

Linux内核驱动框架知识体系 字符设备驱动 Platform驱动模型 设备树解析 设备号 file_operations cdev注册 probe/remove 匹配机制 资源管理 compatible reg/irq 解析API 三者协作关系 设备树描述硬件 → platform驱动解析设备树 → 字符设备提供用户空间接口 module_init/exit 管理整个生命周期 module_init / module_exit 贯穿始终

3.6 实战要点总结

组件 核心函数 常见错误 我的建议
字符设备 cdev_init, cdev_add 设备号冲突 用alloc_chrdev_region动态分配
platform驱动 platform_driver_register probe不匹配 检查compatible字符串是否一致
设备树 of_match_table 解析不到资源 用devicetree.org的语法检查工具
模块机制 module_init/exit __init误用 只标记一次性初始化函数

最后提醒:调试驱动时,别忘了打开内核的dynamic debug。我习惯在probe函数里加一句dev_dbg,这样可以通过/sys/kernel/debug/dynamic_debug/control动态控制日志输出,比printk好用多了。

嗯,这一章的内容就到这里。驱动开发这东西,光看代码是不够的,一定要动手编译、加载、调试。下次遇到insmod后dmesg里啥也没有的情况,别慌——先检查module_init有没有写对。


公众号:蓝海资料掘金营,微信deep3321