3. 多核异构基础

好,咱们进入第三章。这一章我打算把Linux内核驱动框架的几个核心概念串起来讲。说实话,很多新人刚接触驱动开发时,容易被这些术语搞晕——字符设备、platform、设备树、绑定过程……感觉每个都是独立的知识点。其实它们是一套完整的体系,你搞懂了它们之间的关系,写驱动时心里就有底了。

3.1 字符设备驱动框架——最基础的驱动形态

字符设备驱动,说白了就是“按字节流读写”的设备驱动。比如串口、GPIO、LED灯,都属于这一类。我刚开始做驱动时,第一个练手的项目就是字符设备驱动——一个简单的虚拟设备,只做一件事:用户层写什么,内核层就打印什么。

字符设备驱动的核心结构,其实就三个要素:

  • 设备号:主设备号 + 次设备号,内核靠它找到对应的驱动
  • file_operations结构体:定义open、read、write、ioctl等操作函数
  • 设备节点:用户空间看到的/dev/xxx文件

来看一个最简的字符设备驱动框架代码:

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

#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME  "my_class"

static int major;
static struct class *my_class;
static struct cdev my_cdev;

static int my_open(struct inode *inode, struct file *file) {
    pr_info("Device opened\n");
    return 0;
}

static ssize_t my_read(struct file *file, char __user *buf, 
                        size_t count, loff_t *offset) {
    pr_info("Device read\n");
    return 0;
}

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

static int __init my_init(void) {
    // 1. 分配设备号
    major = register_chrdev(0, DEVICE_NAME, &fops);
    if (major < 0) return major;

    // 2. 创建设备类
    my_class = class_create(THIS_MODULE, CLASS_NAME);

    // 3. 创建设备节点
    device_create(my_class, NULL, MKDEV(major, 0), 
                  NULL, DEVICE_NAME);

    pr_info("Module loaded with major %d\n", major);
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, MKDEV(major, 0));
    class_destroy(my_class);
    unregister_chrdev(major, DEVICE_NAME);
    pr_info("Module unloaded\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
我的习惯:写字符设备驱动时,我一般先用动态分配设备号(传0给register_chrdev),而不是硬编码。这样能避免设备号冲突,尤其是在多核异构平台上,多个驱动模块同时加载时特别有用。

这段代码虽然简单,但包含了字符设备驱动的完整生命周期:加载→注册→创建设备节点→用户空间访问→卸载。你想想看,如果只是做纯软件模拟的设备,这套框架完全够用。

3.2 platform驱动模型——让驱动和设备解耦

字符设备驱动有个问题:它把设备和驱动写死了。比如上面那个例子,设备号、设备名都是硬编码在代码里的。如果硬件变了,你得改代码重新编译。这在嵌入式多核异构场景下,简直就是灾难。

platform驱动模型就是为了解决这个问题。它把“设备”和“驱动”分开管理:

  • platform_device:描述硬件资源(地址、中断、DMA通道等)
  • platform_driver:描述驱动逻辑(probe、remove、suspend、resume等)
  • 匹配机制:内核通过name字段或设备树,把设备和驱动绑在一起

我曾在项目中遇到过一个问题:同一个IP核,在不同芯片上有不同的基地址和中断号。如果用传统字符设备驱动,得维护N个版本的代码。改用platform模型后,只需要写一个驱动,然后为每个芯片配一个platform_device结构体就行。

platform驱动的核心结构:

static int my_probe(struct platform_device *pdev) {
    struct resource *res;
    void __iomem *base;
    int irq;

    // 获取内存资源
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(&pdev->dev, res);

    // 获取中断号
    irq = platform_get_irq(pdev, 0);

    pr_info("Device probed: base=%p, irq=%d\n", base, irq);
    return 0;
}

static int my_remove(struct platform_device *pdev) {
    pr_info("Device removed\n");
    return 0;
}

static struct platform_driver my_driver = {
    .probe  = my_probe,
    .remove = my_remove,
    .driver = {
        .name = "my_platform_dev",
        .of_match_table = my_of_match,  // 设备树匹配表
    },
};

module_platform_driver(my_driver);
关键点:platform_driver的probe函数,是驱动和设备绑定的“入口”。只有设备和驱动匹配成功,probe才会被调用。你在probe里做硬件初始化、注册字符设备、申请中断等操作。

3.3 设备树匹配机制——硬件描述的语言

设备树(Device Tree)是什么?说白了,它就是一份描述硬件拓扑结构的“配置文件”。以前我们用板级文件(board-xxx.c)硬编码硬件信息,每次换硬件都得改内核源码。设备树把硬件描述从内核源码中剥离出来,变成.dts文件,编译成.dtb后由bootloader传给内核。

设备树匹配机制的核心是compatible属性。驱动通过of_match_table声明自己支持哪些设备,内核根据设备树节点的compatible值进行匹配。

举个例子,设备树中的节点:

/ {
    soc {
        my_device: my-device@40000000 {
            compatible = "vendor,my-device-v1", "vendor,my-device";
            reg = <0x40000000 0x1000>;
            interrupts = <0 31 4>;
            clocks = <&clkc 15>;
        };
    };
};

对应的驱动匹配表:

static const struct of_device_id my_of_match[] = {
    { .compatible = "vendor,my-device-v1", .data = &v1_config },
    { .compatible = "vendor,my-device",    .data = &v2_config },
    { /* end */ }
};
MODULE_DEVICE_TABLE(of, my_of_match);
我曾经踩过的坑:compatible字符串的格式一定要是"厂商,设备名",中间是逗号不是点。而且.dts和驱动里的字符串必须完全一致,包括大小写。有一次我因为把"vendor,my-device"写成了"vendor.my-device",排查了整整两天才找到原因。

设备树匹配的优先级是这样的:先匹配compatible列表中的第一个,如果不成功再匹配第二个,以此类推。所以你可以把最精确的版本号放在前面,通用的版本放在后面。

3.4 驱动与设备的绑定过程——从设备树到probe

好,现在我们把前面三个概念串起来。驱动和设备到底是怎么绑定的?我画了一张流程图,你看完就明白了。

驱动与设备绑定过程流程图 系统启动,bootloader加载dtb 内核解析dtb,创建device_node链表 为每个节点注册platform_device 驱动模块加载 注册platform_driver 内核遍历bus的match函数 匹配成功 → 调用probe()

这张图展示了完整的绑定流程。我解释几个关键步骤:

  1. 设备树解析:内核在启动时解析dtb,为每个节点创建device_node结构体
  2. platform_device注册:内核遍历device_node,为符合条件的节点创建platform_device
  3. 驱动加载:驱动模块加载时,注册platform_driver到内核
  4. 匹配过程:内核遍历platform_bus_type上的match函数,比较驱动的of_match_table和设备节点的compatible属性
  5. probe调用:匹配成功,内核调用驱动的probe函数,传入匹配到的platform_device指针
我建议:在多核异构平台上,不同核可能运行不同的操作系统(比如一个核跑Linux,另一个核跑RTOS)。设备树中可以通过status属性控制哪些设备对Linux可见,哪些对RTOS可见。这样同一个硬件平台,不同核看到的设备视图是不同的。

3.5 三种机制的协同关系

最后,我用一个表格总结一下字符设备驱动、platform驱动模型和设备树匹配机制的关系:

机制 核心作用 适用场景 与上下层关系
字符设备驱动 提供文件操作接口(open/read/write/ioctl) 所有按字节流访问的设备 位于最上层,直接面向用户空间
platform驱动模型 解耦设备和驱动,提供probe/remove生命周期 片上设备(非热插拔) 位于中间层,管理设备和驱动的匹配
设备树匹配 描述硬件拓扑和资源,提供compatible匹配 所有基于设备树的平台 位于底层,提供硬件描述数据

在实际项目中,这三者是层层嵌套的:设备树描述硬件→platform驱动模型负责匹配→probe函数中注册字符设备。你写驱动时,通常是在probe里调用cdev_init、cdev_add来注册字符设备,同时通过device_create在/dev下创建设备节点。

核心思路:设备树提供“是什么”,platform模型负责“怎么配”,字符设备框架提供“怎么用”。三者缺一不可。

嗯,这一章的内容就到这里。你理解了这三个机制的关系,后面讲具体的外设驱动(I2C、SPI、DMA等)时,你会发现它们都是在这个框架上搭建的。多核异构场景下,这套框架的优势更加明显——不同核可以共享同一份设备树,各自加载不同的驱动,互不干扰。