第二章 硬件接口与寄存器:NPU硬件架构概览、关键寄存器描述、MMIO映射与访问方法

好,咱们直接进入正题。这一章要聊的是NPU的硬件接口和寄存器,说白了就是驱动和硬件对话的“语言”。你想想看,驱动要指挥NPU干活,总得有个沟通渠道吧?这个渠道就是寄存器,而MMIO就是咱们访问这些寄存器的钥匙。

2.1 NPU硬件架构概览

先说说NPU的整体架构。我见过的NPU,不管哪家厂商的,基本都逃不出这几个核心模块:计算单元、存储单元、控制单元和总线接口。

  • 计算单元:这是NPU的心脏,负责矩阵乘法和卷积运算。通常由多个MAC(乘累加)阵列组成。
  • 存储单元:包括片上SRAM和外部DDR接口。SRAM用来放中间结果,DDR放权重和输入数据。
  • 控制单元:解析指令、调度任务、管理数据流。说白了就是NPU的“大脑”。
  • 总线接口:连接SoC内部总线,比如AXI或AHB。驱动就是通过这个接口访问NPU的。

我个人习惯:拿到一块新NPU,第一件事就是看它的地址空间分布图。哪个区域是寄存器,哪个区域是SRAM,哪个区域是DDR映射,心里要有数。否则后面调试起来,你会怀疑人生的。

下面这张图是我手绘的NPU内部结构,你可以对照着看:

NPU硬件架构概览 总线接口 (AXI/AHB) 控制单元 (指令解析/调度) 计算单元 (MAC阵列) 卷积/矩阵乘法 存储单元 (SRAM/DDR) 权重/输入/中间结果 图例: 总线接口 控制单元 计算单元 存储单元

2.2 关键寄存器描述

寄存器是NPU的“控制面板”。每个寄存器都有特定的功能,驱动通过读写它们来让NPU干活。我挑几个最关键的说说:

寄存器名称 偏移地址 位宽 功能描述
NPU_CTRL 0x0000 32位 控制寄存器:启动/停止NPU、复位、使能中断
NPU_STATUS 0x0004 32位 状态寄存器:忙/空闲、错误码、完成标志
NPU_CMD_ADDR 0x0008 32位 命令队列基地址:指向DDR中的指令列表
NPU_CMD_SIZE 0x000C 32位 命令队列长度:以字节为单位
NPU_INT_EN 0x0010 32位 中断使能寄存器:控制哪些事件触发中断
NPU_INT_STATUS 0x0014 32位 中断状态寄存器:读取后自动清除

避坑指南:我曾经在一个项目里,因为没仔细看NPU_STATUS寄存器的位定义,把忙标志和错误标志搞反了。结果驱动一直以为NPU在忙,实际上它早就报错了。嗯,从那以后我养成了习惯——每个寄存器的每个位都要对照手册确认一遍。

2.3 MMIO映射与访问方法

MMIO(Memory-Mapped I/O)是驱动访问NPU寄存器的标准方式。说白了,就是把NPU的寄存器映射到CPU的地址空间里,然后像读写内存一样操作它们。

在Linux下,我们通常用ioremap()来做这件事。看代码:

#include <linux/io.h>
#include <linux/ioport.h>

#define NPU_BASE_ADDR  0x1A000000  // NPU寄存器基地址
#define NPU_REG_SIZE   0x1000      // 寄存器空间大小

static void __iomem *npu_reg_base;

int npu_init(void)
{
    // 请求I/O内存区域
    if (!request_mem_region(NPU_BASE_ADDR, NPU_REG_SIZE, "npu")) {
        pr_err("Failed to request memory region\n");
        return -EBUSY;
    }

    // 映射到内核虚拟地址空间
    npu_reg_base = ioremap(NPU_BASE_ADDR, NPU_REG_SIZE);
    if (!npu_reg_base) {
        pr_err("Failed to ioremap\n");
        release_mem_region(NPU_BASE_ADDR, NPU_REG_SIZE);
        return -ENOMEM;
    }

    pr_info("NPU registers mapped at %p\n", npu_reg_base);
    return 0;
}

void npu_exit(void)
{
    if (npu_reg_base) {
        iounmap(npu_reg_base);
        release_mem_region(NPU_BASE_ADDR, NPU_REG_SIZE);
    }
}

// 读写寄存器的封装函数
static inline u32 npu_read_reg(u32 offset)
{
    return readl(npu_reg_base + offset);
}

static inline void npu_write_reg(u32 offset, u32 value)
{
    writel(value, npu_reg_base + offset);
}

映射完成后,读写寄存器就简单了。比如启动NPU:

void npu_start(void)
{
    u32 ctrl_val;

    // 读取当前控制寄存器值
    ctrl_val = npu_read_reg(NPU_CTRL);

    // 设置启动位(假设bit0是启动位)
    ctrl_val |= 0x1;

    // 写回控制寄存器
    npu_write_reg(NPU_CTRL, ctrl_val);

    // 等待NPU确认启动
    while (!(npu_read_reg(NPU_STATUS) & 0x1)) {
        cpu_relax();  // 忙等待,实际项目中建议用中断
    }

    pr_info("NPU started successfully\n");
}

注意readl()writel()是带内存屏障的访问函数。为什么需要这个?因为CPU和NPU可能对指令执行顺序有不同理解。不加屏障的话,你写了一个寄存器,CPU可能还没真正写到硬件上,NPU就读到了旧值。我踩过这个坑,调试了整整两天才发现是少了wmb()

你可能会问:为什么不直接用指针访问?嗯,理论上可以,但那样你就失去了内存屏障的保护。而且ioremap()返回的地址是虚拟地址,直接解引用可能会触发异常。所以,老老实实用readl/writel系列函数吧。

2.4 实际项目中的经验总结

最后,分享几个我在实际项目中积累的经验:

  • 寄存器访问一定要加锁:多核CPU同时访问NPU寄存器,不加锁的话,数据竞争会让你崩溃。我习惯用自旋锁,因为寄存器访问通常很快。
  • 调试时多用printk打印寄存器值:我曾经靠打印NPU_STATUS寄存器值,定位到一个硬件bug——某个位在特定条件下不会自动清零。
  • 注意字节序:NPU寄存器通常是小端模式,但有些SoC是大端。如果搞反了,写进去的值和读出来的值会不一样。我建议在初始化时先写一个已知值再读回来验证。
  • 中断处理要快:NPU完成一个任务后通常会触发中断。中断处理函数里不要做复杂操作,只读取状态、清除中断标志、唤醒等待队列就好。复杂操作放到工作队列或tasklet里做。

我个人习惯:在驱动初始化时,我会把NPU所有寄存器的默认值都读出来打印到内核日志里。这样后面出问题时,可以对比是哪个寄存器被意外修改了。这个习惯帮我省了不少调试时间。

好了,这一章的内容就到这里。MMIO映射和寄存器访问是NPU驱动开发的基础,后面所有的高级功能都建立在这之上。你把这些搞懂了,后面写命令队列、处理中断、管理DMA都会顺手很多。

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