3、驱动开发预备知识:Linux内核模块基础、字符设备驱动框架、设备树、内存管理、中断处理
寒武纪NPU(神经网络处理单元)作为AI加速协处理器,其驱动开发与普通Linux字符设备驱动有显著差异,但底层依赖的Linux内核基础设施完全一致。本章聚焦驱动开发前必须掌握的五大内核知识模块:内核模块的加载与卸载机制、字符设备驱动的完整框架、设备树对硬件拓扑的描述方法、内核内存管理的分配与映射策略、中断处理的下半部机制。这些知识是理解NPU驱动中命令队列提交、显存管理、多核中断分发等核心逻辑的基石。我们将从原理到实战,逐一拆解每个模块在NPU驱动场景下的具体应用形态。
上图展示了五大内核预备知识模块与NPU驱动之间的支撑关系。内核模块是驱动的“外壳”,字符设备框架是“接口”,设备树是“硬件地图”,内存管理是“资源仓库”,中断处理是“事件通知”。下面逐一深入剖析。
3.1 内核模块基础:驱动的生命周期管理
核心原理
Linux内核模块(LKM)允许在运行时动态加载和卸载代码,无需重新编译内核。NPU驱动通常以独立模块(如cambricon_kmd.ko)形式发布。模块的入口点由module_init()和module_exit()宏指定,分别对应加载和卸载时的回调函数。模块还通过MODULE_LICENSE()声明许可证(GPL/Proprietary),通过module_param()暴露可调参数,通过EXPORT_SYMBOL()导出符号供其他模块使用。
操作流程
- 编写模块骨架:定义init/exit函数,使用
module_init()/module_exit()注册。 - 编译:使用内核构建系统(Kbuild),编写Makefile,指定
obj-m目标。 - 加载:
insmod或modprobe(自动处理依赖)。 - 验证:
lsmod查看模块列表,dmesg查看内核日志。 - 卸载:
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的显存映射,该值必须与设备固件配置匹配,否则会导致内存访问异常。
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获取设备私有结构体 |
操作流程
- 分配设备号:
alloc_chrdev_region(&dev_num, 0, count, "cambricon")。 - 初始化cdev:
cdev_init(&cdev, &fops),设置owner为THIS_MODULE。 - 添加设备:
cdev_add(&cdev, dev_num, count)。 - 创建设备节点:使用
class_create()和device_create()在/dev/下自动生成节点。 - 实现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()等待任务完成中断。
_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";
};
操作流程
- 编写dtsi:在SoC级dtsi中定义NPU控制器节点,包含寄存器、中断、时钟等基础属性。
- 板级dts覆盖:在具体板卡dts中通过
&cambricon_npu引用节点,修改status或添加板级特有属性(如电源控制GPIO)。 - 驱动解析:在
probe函数中使用of_iomap()映射寄存器,irq_of_parse_and_map()获取中断号,of_clk_get()获取时钟。 - 匹配绑定:定义
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缓冲区分配与映射
- 分配:
dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL),返回内核虚拟地址,同时获得DMA总线地址。 - 使用:将
dma_handle写入NPU的地址寄存器,设备通过该地址访问缓冲区。 - 用户态映射:在
mmap回调中调用dma_mmap_coherent(),将同一缓冲区映射到用户空间。 - 释放:
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 | 内核线程 | 将中断处理完全线程化,可睡眠 | 需要与用户态交互的复杂处理 |
操作流程:注册中断与处理
- 获取中断号:从设备树解析或
platform_get_irq()。 - 注册中断:
request_irq(irq, handler, flags, name, dev_id),其中dev_id用于共享中断识别。 - 实现handler:读取中断状态寄存器,判断中断源,清除中断标志,调度下半部。
- 下半部处理:在workqueue中唤醒等待队列,更新任务完成状态。
- 释放中断:
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)的完整协作。任何一步失败都必须正确回滚已分配的资源,避免内存泄漏或设备节点残留。
Documentation/driver-api/下的相关章节,并参考drivers/misc/中已有的协处理器驱动实现。