3、驱动开发预备知识:Linux内核模块基础、字符设备驱动框架、设备树、内存管理、中断处理

寒武纪NPU(神经网络处理单元)作为AI加速协处理器,其驱动开发与普通Linux字符设备驱动有显著差异,但底层依赖的Linux内核基础设施完全一致。本章聚焦驱动开发前必须掌握的五大内核知识模块:内核模块的加载与卸载机制、字符设备驱动的完整框架、设备树对硬件拓扑的描述方法、内核内存管理的分配与映射策略、中断处理的下半部机制。这些知识是理解NPU驱动中命令队列提交、显存管理、多核中断分发等核心逻辑的基石。我们将从原理到实战,逐一拆解每个模块在NPU驱动场景下的具体应用形态。

NPU驱动开发五大内核预备知识模块 寒武纪NPU驱动 基于内核基础设施构建 内核模块基础 module_init/exit 许可证/参数/符号导出 字符设备框架 cdev/register_chrdev file_operations/ioctl 设备树 dts/dtsi/DTB compatible/reg/中断属性 内存管理 kmalloc/dma_alloc_coherent mmap/ioremap/CMA 中断处理 request_irq/threaded_irq tasklet/workqueue/softirq NPU驱动 = 字符设备框架(用户交互) + 设备树(硬件描述) + 内存管理(显存/命令缓冲) + 中断(任务完成通知) + 内核模块(生命周期) 五大模块相互配合,构成NPU驱动的完整运行底座

上图展示了五大内核预备知识模块与NPU驱动之间的支撑关系。内核模块是驱动的“外壳”,字符设备框架是“接口”,设备树是“硬件地图”,内存管理是“资源仓库”,中断处理是“事件通知”。下面逐一深入剖析。

3.1 内核模块基础:驱动的生命周期管理

核心原理

Linux内核模块(LKM)允许在运行时动态加载和卸载代码,无需重新编译内核。NPU驱动通常以独立模块(如cambricon_kmd.ko)形式发布。模块的入口点由module_init()和module_exit()宏指定,分别对应加载和卸载时的回调函数。模块还通过MODULE_LICENSE()声明许可证(GPL/Proprietary),通过module_param()暴露可调参数,通过EXPORT_SYMBOL()导出符号供其他模块使用。

操作流程

  1. 编写模块骨架:定义init/exit函数,使用module_init()/module_exit()注册。
  2. 编译:使用内核构建系统(Kbuild),编写Makefile,指定obj-m目标。
  3. 加载:insmod或modprobe(自动处理依赖)。
  4. 验证:lsmod查看模块列表,dmesg查看内核日志。
  5. 卸载:rmmod,确保无进程占用设备节点。

案例拆解:NPU驱动模块加载参数设计

寒武纪NPU驱动在加载时通常需要指定以下参数:

insmod cambricon_kmd.ko \
    max_devices=4 \          # 最大支持NPU设备数
    ddr_reserved_size=512M \ # 预留DDR空间用于设备内存映射
    irq_affinity=2 \         # 中断亲和性掩码,绑定到CPU2-3
    debug_level=3            # 调试日志级别

这些参数通过module_param()声明,在init函数中读取并影响后续的设备初始化逻辑。例如,ddr_reserved_size决定驱动从系统内存中预留多少连续物理内存用于NPU的显存映射,该值必须与设备固件配置匹配,否则会导致内存访问异常。

风险提示:模块卸载时若仍有进程持有设备文件描述符,或存在未释放的中断、内存映射,会导致内核崩溃(Oops)。NPU驱动必须在exit函数中严格按逆序释放资源:先注销中断,再释放内存映射,最后删除字符设备。

常见误区

  • 误用printk而不使用dev_info/dev_err,导致日志缺乏设备上下文信息。
  • 忽略MODULE_VERSION和MODULE_DESCRIPTION,不利于固件版本匹配和问题定位。
  • 在模块加载时执行耗时操作(如固件下载),应使用request_firmware()异步加载机制。

3.2 字符设备驱动框架:用户态与内核态的桥梁

核心原理

NPU驱动本质上是字符设备驱动,通过struct file_operations向用户态提供open()、close()、ioctl()、mmap()、poll()等接口。设备号由主设备号(区分驱动类型)和次设备号(区分同类型的不同设备)组成。现代驱动推荐使用alloc_chrdev_region()动态分配设备号,并通过cdev_add()将设备添加到内核。

关键数据结构

数据结构作用NPU驱动中的典型应用
struct cdev字符设备内核对象每个NPU设备对应一个cdev实例
struct file_operations文件操作函数集定义open/ioctl/mmap等回调
struct file打开的文件实例保存每进程的上下文(如文件描述符私有数据)
struct inode文件元数据通过container_of获取设备私有结构体

操作流程

  1. 分配设备号:alloc_chrdev_region(&dev_num, 0, count, "cambricon")。
  2. 初始化cdev:cdev_init(&cdev, &fops),设置owner为THIS_MODULE。
  3. 添加设备:cdev_add(&cdev, dev_num, count)。
  4. 创建设备节点:使用class_create()和device_create()在/dev/下自动生成节点。
  5. 实现ioctl:定义命令码(如CNB_IOCTL_CMD_QUEUE),使用copy_from_user/copy_to_user传递数据。

案例拆解:NPU驱动的ioctl命令分发

寒武纪NPU驱动通过ioctl接收用户态指令,典型命令包括:

#define CNB_IOCTL_MAGIC 'C'
#define CNB_IOCTL_GET_VERSION   _IOR(CNB_IOCTL_MAGIC, 1, struct cnb_version)
#define CNB_IOCTL_QUEUE_TASK    _IOWR(CNB_IOCTL_MAGIC, 2, struct cnb_task)
#define CNB_IOCTL_ALLOC_MEM     _IOWR(CNB_IOCTL_MAGIC, 3, struct cnb_mem_info)
#define CNB_IOCTL_FREE_MEM      _IOW(CNB_IOCTL_MAGIC, 4, struct cnb_mem_info)

在ioctl实现中,使用switch分支处理不同命令。对于QUEUE_TASK,驱动需要将用户传入的任务描述符(包含模型ID、输入输出张量地址)拷贝到内核空间,然后写入NPU的命令队列寄存器,最后通过wait_event_interruptible()等待任务完成中断。

经验提醒:ioctl命令码必须使用_IO/_IOR/_IOW/_IOWR宏生成,确保方向、大小和魔数正确。否则在64位系统上可能出现数据截断或权限校验失败。NPU驱动中涉及指针传递时,务必使用copy_from_user,禁止直接解引用用户指针。

常见误区

  • 未实现llseek或poll,导致用户态无法使用select/epoll等待任务完成事件。
  • 在open时未检查设备是否已初始化完成(如固件未下载),应返回-ENODEV。
  • 忽略unlocked_ioctl与compat_ioctl的区别,32位应用在64位内核上调用会失败。

3.3 设备树:硬件拓扑的描述语言

核心原理

设备树(Device Tree)是一种描述硬件配置的数据结构,由.dts源文件编译为.dtb二进制,在系统启动时传递给内核。NPU作为PCIe或平台设备,其寄存器地址、中断号、复位GPIO、时钟频率等信息均通过设备树节点描述。驱动通过of_match_table匹配compatible属性,从而绑定到对应设备。

NPU设备树节点示例

cambricon_npu: npu@a0000000 {
    compatible = "cambricon,mlu270";
    reg = <0x0 0xa0000000 0x0 0x1000000>;  // 寄存器基址和大小
    interrupts = <0 42 4>;                 // SPI中断,中断号42,触发类型为高电平
    interrupt-parent = <&gic>;
    clocks = <&clk_npu>;
    clock-names = "npu_core";
    resets = <&rst_npu 0>;
    reset-names = "npu";
    dma-coherent;                          // 声明设备支持DMA一致性
    status = "okay";
};

操作流程

  1. 编写dtsi:在SoC级dtsi中定义NPU控制器节点,包含寄存器、中断、时钟等基础属性。
  2. 板级dts覆盖:在具体板卡dts中通过&cambricon_npu引用节点,修改status或添加板级特有属性(如电源控制GPIO)。
  3. 驱动解析:在probe函数中使用of_iomap()映射寄存器,irq_of_parse_and_map()获取中断号,of_clk_get()获取时钟。
  4. 匹配绑定:定义of_device_id数组,包含compatible字符串,通过module_platform_driver()注册。

案例拆解:多NPU设备树配置

在AI服务器中,通常集成2-4颗NPU。设备树中需要为每颗NPU分配独立的寄存器地址和中断号:

&cambricon_npu0 {
    reg = <0x0 0xa0000000 0x0 0x1000000>;
    interrupts = <0 42 4>;
    status = "okay";
};
&cambricon_npu1 {
    reg = <0x0 0xb0000000 0x0 0x1000000>;
    interrupts = <0 43 4>;
    status = "okay";
};

驱动在probe时通过platform_get_resource()获取各自的寄存器地址,通过platform_get_irq()获取中断号。这样同一份驱动代码可以处理多个设备实例,通过container_of区分不同设备的私有数据。

风险提示:设备树中的reg地址必须与硬件实际解码地址一致,否则驱动访问寄存器时会导致总线错误(External Abort)。中断号必须与中断控制器(GIC)的分配一致,否则中断无法触发。修改设备树后必须重新编译并烧写dtb,仅修改dts不会生效。

常见误区

  • 忘记添加dma-coherent属性,导致DMA分配使用非一致性内存,性能下降。
  • 在设备树中使用status = "disabled"后未在板级dts中重新启用,驱动无法probe。
  • 依赖设备树节点的name属性进行匹配,而应使用compatible属性。

3.4 内存管理:显存与命令缓冲的基石

核心原理

NPU驱动涉及多种内存类型:寄存器映射内存(通过ioremap)、DMA缓冲区(用于设备访问)、用户态映射内存(通过mmap)。内核提供kmalloc(小内存、物理连续)、dma_alloc_coherent(DMA安全、一致性)、vmalloc(大内存、虚拟连续)等分配接口。NPU显存通常通过预留物理内存(CMA或memmap)实现。

内存分配接口对比

接口特点适用场景释放方式
kmalloc物理连续,大小有限(通常<128KB)命令描述符、小数据结构kfree
dma_alloc_coherent物理连续,DMA安全,无缓存一致性问题设备可访问的缓冲区(如命令队列)dma_free_coherent
ioremap映射设备寄存器物理地址到内核虚拟地址访问NPU控制寄存器iounmap
mmap将内核缓冲区映射到用户态虚拟地址用户态直接访问显存或结果缓冲区munmap(用户态调用)

操作流程:DMA缓冲区分配与映射

  1. 分配:dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL),返回内核虚拟地址,同时获得DMA总线地址。
  2. 使用:将dma_handle写入NPU的地址寄存器,设备通过该地址访问缓冲区。
  3. 用户态映射:在mmap回调中调用dma_mmap_coherent(),将同一缓冲区映射到用户空间。
  4. 释放:dma_free_coherent(dev, size, cpu_addr, dma_handle)。

案例拆解:NPU命令队列的内存管理

寒武纪NPU驱动为每个设备维护一个命令队列(Command Queue),队列深度通常为1024项,每项16字节。分配流程如下:

#define CMD_QUEUE_DEPTH 1024
#define CMD_QUEUE_SIZE (CMD_QUEUE_DEPTH * 16)

struct npu_device {
    void __iomem *reg_base;
    void *cmd_queue_cpu;      // 内核虚拟地址
    dma_addr_t cmd_queue_dma; // DMA总线地址
    // ...
};

// 在probe中分配
dev->cmd_queue_cpu = dma_alloc_coherent(dev, CMD_QUEUE_SIZE,
                                        &dev->cmd_queue_dma, GFP_KERNEL);
if (!dev->cmd_queue_cpu) {
    dev_err(dev, "Failed to allocate command queue\n");
    return -ENOMEM;
}
// 将DMA地址写入NPU寄存器
writel(lower_32_bits(dev->cmd_queue_dma), dev->reg_base + QUEUE_BASE_LOW);
writel(upper_32_bits(dev->cmd_queue_dma), dev->reg_base + QUEUE_BASE_HIGH);

用户态通过mmap映射该队列后,可以直接写入命令描述符,然后通过ioctl通知驱动“门铃”(Doorbell)寄存器,触发NPU执行。

经验提醒:使用dma_alloc_coherent分配的内存默认是cache一致的,无需手动刷新缓存。但如果使用kmalloc + dma_map_single,则必须在CPU写入后调用dma_sync_single_for_device(),在设备写入后调用dma_sync_single_for_cpu(),否则会出现数据不一致。

常见误区

  • 使用kmalloc分配大块内存(>128KB),导致分配失败。应使用dma_alloc_coherent或alloc_pages。
  • 忘记检查dma_alloc_coherent的返回值,空指针解引用导致内核崩溃。
  • 在mmap中直接使用remap_pfn_range映射kmalloc内存,但未考虑缓存一致性,导致用户态看到脏数据。

3.5 中断处理:任务完成的通知机制

核心原理

NPU执行任务完成后,通过中断通知CPU。中断处理分为上半部(硬中断)和下半部(软中断)。上半部要求快速执行,通常只做最小处理(如读取中断状态寄存器、清除中断标志),耗时操作(如唤醒等待队列、处理完成回调)放入下半部。Linux提供tasklet、workqueue、threaded_irq等下半部机制。

中断处理机制对比

机制上下文特点适用场景
tasklet软中断上下文不可睡眠,执行快,绑定到CPU轻量级处理,如清除中断标志
workqueue进程上下文可睡眠,可阻塞,调度灵活重量级处理,如唤醒用户进程
threaded_irq内核线程将中断处理完全线程化,可睡眠需要与用户态交互的复杂处理

操作流程:注册中断与处理

  1. 获取中断号:从设备树解析或platform_get_irq()。
  2. 注册中断:request_irq(irq, handler, flags, name, dev_id),其中dev_id用于共享中断识别。
  3. 实现handler:读取中断状态寄存器,判断中断源,清除中断标志,调度下半部。
  4. 下半部处理:在workqueue中唤醒等待队列,更新任务完成状态。
  5. 释放中断:free_irq(irq, dev_id),必须在设备移除时调用。

案例拆解:NPU任务完成中断处理

寒武纪NPU驱动的中断处理流程如下:

static irqreturn_t npu_irq_handler(int irq, void *data)
{
    struct npu_device *dev = data;
    uint32_t status = readl(dev->reg_base + INT_STATUS);
    
    // 清除中断标志(写1清0)
    writel(status, dev->reg_base + INT_CLEAR);
    
    if (status & TASK_DONE_MASK) {
        // 调度workqueue处理任务完成
        schedule_work(&dev->task_done_work);
    }
    if (status & ERROR_MASK) {
        dev_err(dev, "NPU error interrupt, status=0x%x\n", status);
        schedule_work(&dev->error_work);
    }
    return IRQ_HANDLED;
}

static void task_done_work_handler(struct work_struct *work)
{
    struct npu_device *dev = container_of(work, struct npu_device, task_done_work);
    // 更新任务状态,唤醒等待的用户进程
    dev->task_completed = true;
    wake_up_interruptible(&dev->wait_queue);
}

用户态通过poll()或read()阻塞等待任务完成,驱动在workqueue中调用wake_up_interruptible()唤醒。

风险提示:中断处理函数中禁止调用可能导致睡眠的函数(如kmalloc(GFP_KERNEL)、mutex_lock)。如果必须使用,应通过workqueue或threaded_irq延迟处理。此外,共享中断必须使用dev_id参数区分设备,否则中断风暴会导致系统崩溃。

常见误区

  • 在中断上半部中执行耗时操作(如打印大量日志),导致中断延迟增大。
  • 未正确清除中断标志,导致中断反复触发,CPU占用率飙升。
  • 使用request_irq时未设置IRQF_SHARED标志,但硬件实际共享中断,导致注册失败。

3.6 综合实战:NPU驱动初始化流程串联

将上述五大模块串联起来,NPU驱动的probe函数典型流程如下:

static int npu_probe(struct platform_device *pdev)
{
    struct npu_device *dev;
    int ret;
    
    // 1. 分配设备私有结构体
    dev = kzalloc(sizeof(*dev), GFP_KERNEL);
    if (!dev) return -ENOMEM;
    platform_set_drvdata(pdev, dev);
    
    // 2. 映射寄存器(设备树)
    dev->reg_base = devm_platform_ioremap_resource(pdev, 0);
    if (IS_ERR(dev->reg_base)) return PTR_ERR(dev->reg_base);
    
    // 3. 获取中断并注册(中断处理)
    dev->irq = platform_get_irq(pdev, 0);
    ret = devm_request_irq(&pdev->dev, dev->irq, npu_irq_handler,
                           0, "cambricon-npu", dev);
    if (ret) return ret;
    
    // 4. 分配DMA命令队列(内存管理)
    dev->cmd_queue = dma_alloc_coherent(&pdev->dev, CMD_QUEUE_SIZE,
                                        &dev->cmd_queue_dma, GFP_KERNEL);
    if (!dev->cmd_queue) return -ENOMEM;
    
    // 5. 注册字符设备(字符设备框架)
    ret = alloc_chrdev_region(&dev->dev_num, 0, 1, "cambricon");
    if (ret) goto err_free_dma;
    cdev_init(&dev->cdev, &npu_fops);
    dev->cdev.owner = THIS_MODULE;
    ret = cdev_add(&dev->cdev, dev->dev_num, 1);
    if (ret) goto err_unregister_region;
    
    // 6. 创建设备节点
    dev->cls = class_create("cambricon");
    device_create(dev->cls, NULL, dev->dev_num, NULL, "npu%d", dev->id);
    
    // 7. 初始化等待队列和workqueue
    init_waitqueue_head(&dev->wait_queue);
    INIT_WORK(&dev->task_done_work, task_done_work_handler);
    
    dev_info(&pdev->dev, "NPU driver initialized successfully\n");
    return 0;
    
err_unregister_region:
    unregister_chrdev_region(dev->dev_num, 1);
err_free_dma:
    dma_free_coherent(&pdev->dev, CMD_QUEUE_SIZE, dev->cmd_queue, dev->cmd_queue_dma);
    return ret;
}

该流程展示了设备树解析(步骤2-3)、内存管理(步骤4)、字符设备注册(步骤5-6)、中断与工作队列初始化(步骤7)的完整协作。任何一步失败都必须正确回滚已分配的资源,避免内存泄漏或设备节点残留。

经验总结:NPU驱动开发的核心在于理解内核基础设施的“约定优于配置”原则。设备树描述“有什么硬件”,驱动负责“如何驱动”,字符设备提供“用户接口”,内存管理保证“数据可达”,中断机制实现“事件通知”。掌握这五大模块的协同方式,是编写稳定、高效NPU驱动的关键。建议在开发前先阅读内核文档Documentation/driver-api/下的相关章节,并参考drivers/misc/中已有的协处理器驱动实现。