第二章 硬件接口与寄存器: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内部结构,你可以对照着看:
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