最近在调一个RISC-V SoC的整合项目,片上有一块512KB的SRAM,挂了好几个主设备:CPU核、带描述符的DMA控制器,还有一个千兆网口的DMA。一开始大家图省事,所有主设备都能直接访问这块内存,谁想读就读、想写就写。结果调试过程中DMA配错地址,直接把启动代码所在区域冲掉了,整颗芯片直接“半砖”。从那之后我就在琢磨,得给片上内存加一道权限检查,于是就有了这个AXI MPU的研发实践。整个过程用AI大模型辅助了不少工作,包括代码框架、验证用例和排查思路,写这篇文章把从方案选型到落地验证的完整过程捋一遍。
1. 为什么要给片上内存单独加一道权限检查
片上内存不像DDR那样需要经过复杂的控制器和地址映射,它通常是SoC里最高速、最敏感的一块存储,经常存放中断向量表、实时性要求高的任务堆栈、加密密钥、或者硬件描述符队列。如果你把它当成普通的SRAM裸奔,等于把一个房间的钥匙挂在大门口,谁进来都能翻一遍。权限检查的核心思路不是去改每个挂在AXI总线上的master,而是在通往受保护内存的必经之路上加一道闸门,逐个核对“谁在访问”“访问哪个地址范围”“要做什么操作”。
1.1 多主设备共享总线上的“透明人”问题
多主设备的系统里,仲裁器只负责决定同一时刻谁占用总线,它不负责判断这个访问是否合理。AXI互连网络里的各种主设备对副设备来说几乎都是“透明人”——从端只能看到一个地址和一组控制信号,根本不知道背后是CPU在执行正规的内存拷贝,还是一个DMA在错误地搬运垃圾数据。如果不做权限隔离,任何一个具备总线master能力的模块都有可能成为攻击面。
我曾经遇到过DMA描述符被异常数据覆盖,导致DMA被“绑架”去反复读写整个地址空间的情况。你去看总线波形会发现,CPU和DMA在互相踩踏,仲裁器按照固定优先级把总线让给DMA,CPU连中断都响应不了。后来我们把DMA能访问的片上内存范围限定到几个固定缓冲区,再把关键配置区域设置为“只读”,问题才算从根上解决。这就是MPU存在的第一层意义:保护的不是内存本身,而是整个系统的稳定性和可信边界。
1.2 MPU、MMU与总线防火墙的边界划分
很多搞软件的人一听到内存保护,第一反应是MMU。但MMU是放在CPU侧的,它负责虚拟地址到物理地址的转换,以及基于页表的访问权限控制。你让一个DMA去走CPU的MMU根本不现实,DMA看到的物理地址和它自己的行为规则,需要在总线层面用另一套机制去约束。
MPU(Memory Protection Unit)跟MMU的区别在于,它不做地址翻译,只做区域匹配和权限裁决。它的好处是面积小、延迟低、逻辑直白,适合做硬件层面的快速拦截。而总线防火墙通常挂在互连总线内部,比如Arm NIC-400里的TrustZone filter,它能把地址区间的访问规则下发到互连节点上。我们自己做的这个AXI MPU,更接近一个位于受保护内存入口处的独立过滤模块,不依赖特定厂家的互连IP,整个逻辑可综合、可移植、可验证,也方便后续增加自定义的主设备ID规则。
选这个方案还有一个现实原因:项目里的片上内存控制器和AXI interconnect相对固定,我不想为了加权限策略去改动别人已经验证好的IP内部逻辑。做一个挂在内存控制器前面的MPU模块,风险边界最小,出问题也好回退。
2. 方案确定:挂在AXI入口的MPU该怎么设计
这里最关键的决策,是MPU到底放在哪里、以什么方式介入原有的AXI通道。所有AXI主设备的读请求走AR通道,写请求走AW通道和W通道,响应走R和B通道。要在不影响整条链路的前提下插入检查逻辑,必须想清楚它在时序和协议上对原有握手的影响。
2.1 三种拦截位置的选择逻辑
第一种做法是在每个主设备发起访问的地方独立接一个MPU,比如CPU侧放一个、DMA侧放一个。这样隔离度最强,但多个MPU配置逻辑重复、面积浪费,而且软件要分别初始化不同的寄存器,容易漏配。
第二种是放在内存控制器前面,即所有主设备的访问统一汇聚到MPU,MPU后端才是真正的SRAM控制器。这种方式集中管理、配置简单,一个MPU管所有master,正是我们最终采用的结构。
第三种是干脆把权限检查逻辑合并进AXI interconnect的slave端口里。这样做性能最好,但意味着要深入修改互联IP,或者自己维护一个带过滤功能的AXI仲裁器,研发量和回归风险都不可控。用我们最终方案的话,MPU处于interconnect和SRAM controller之间,它只拦截和转发地址/数据/响应,不需要关心谁在仲裁,也不需要改动其他IP。
这个位置选择还有个好处:可以拿到AXI通道上的全部信息。比如AWID、AWADDR、AWPROT、AWSIZE、AWLEN,这些信号在仲裁之后的入口处都齐全。只要按照同样的ID和握手规则把请求往下传,后面的事务管理完全可以沿用标准AXI协议,不需要特殊的桥接逻辑。
2.2 区域表与权限模型
上一套结构之后,接下来就是定义区域寄存器。我的设计里放了8个region,每个region由一组64位的控制寄存器描述:
- base:区域基地址,要求对齐到区域大小
- size:区域大小,编码为2的幂,取值从4KB到512MB
- perm:读写执行权限
- secure_ns:安全属性,非安全访问是否允许
- enable:该区域是否生效
地址比较逻辑不用傻傻地拿全32位地址去跟某个上限做“大于等于/小于等于”。那样综合出来比较面积大,而且跨时钟域和时序收敛都很痛苦。标准做法是把“基地址+大小”换算成“掩码+比较值”,只需要一个按位异或加一个归约或门。
区域重叠是一个必须预先规定好语义的问题。软件配置寄存器时可能不小心把两个区域配成重叠关系,如果MPU内部对重叠区域出现“同时命中但权限不一致”,那就是歧义。我的做法是编号小的区域优先级最高。这样规则唯一、行为可预测,也方便写验证用例去覆盖。
权限模型采用“一个结果,三个拒绝条件”:未命中任何region默认拒绝;命中但访问方向不允许;命中但安全/非安全属性不匹配。这样的建模方式非常朴素,但能让后续的RTL编码和断言都清晰很多。
2.3 存储器映射与配置界面
MPU本身也要占一块地址空间,否则软件没法配置规则。我把控制寄存器映射到系统总线上一个独立的4KB窗口,片选信号直接接给MPU从端口。这个窗口只能用具有“MPU配置权限”的主设备访问,否则MPU就形同虚设——比如普通DMA也能改写权限规则,那一切保护都白搭。
配置寄存器我加了一位写锁定寄存器。软件初始化完成后可以把MPU配置区锁死,再写配置寄存器的行为会被忽略并产生错误响应。这样即使后续某段代码被攻击,也无法轻易修改内存保护规则。
3. 核心逻辑实现的具体过程
3.1 AW/AR通道拦截:地址比较器的落地
AXI协议最核心的一点就是valid/ready握手。MPU插入后,需要在一拍之内完成地址比较并给出“放行还是拦截”。实际上比较逻辑可以做到很浅,我的RTL里地址比较器的基本结构是这样的:
logic [31:0] region_mask; logic [31:0] addr_masked; logic region_hit; assign region_mask = ~({32{1'b1}} << region_size_code); // 4KB code->12'b0 mask lower assign addr_masked = awaddr & region_mask; assign region_hit = (addr_masked == region_base_align);这个写法把“大小”翻译成掩码,地址与掩码之后等于对齐后的基地址,就说明命中了。因为大小是2的幂,基地址按大小对齐,所以不会出现边界判断的偏斜问题。
AW通道的有效性判断放在组合逻辑里,但最终给后端的“假请求”要做一个寄存器化的决策。原因有两个:一是组合逻辑直接驱动握手信号,时序上容易成为整个SoC的critical path;二是如果MPU一拍以内没法稳定完成所有比较和权限判断,那么时序约束和布局布线阶段会很痛苦。
实际实现时我加了一个流水级:AWVALID和AWREADY握手成功之后,下一拍才把地址判断结果透传给后端的SRAM控制器。也就是说MPU在AW通路上多花一个时钟周期。这在AXI互联场景下通常可接受,因为总线本身的outstanding能力和乱序处理可以在一定程度上掩盖这一拍延迟。真正要注意的是必须在握手成功的同一个周期锁存决策结果,不能等到下一拍再根据当时抓到的AWADDR去判断,否则outstanding事务来了会串号。
3.2 跨边界Burst的检查策略
APB也好、单次AXI也好,检查单个地址很简单。但AXI允许Burst传输,一次写16拍的问题在于,start_addr到end_addr可能跨过两个region。如果只是判断起始地址命中,就会放过一片危险区。
计算突发终点的公式:
bytes_per_beat = 2^AxSIZE total_bytes = (AxLEN + 1) * bytes_per_beat end_addr = AxADDR + total_bytes - 1 last_addr_unit = end_addr >> (AxSIZE + AxBURST_FIXED? ...)更稳妥的办法是每次拿AxADDR和AxLEN、AxSIZE算出突发覆盖的结束地址,然后用“起始地址和结束地址必须落在同一个允许访问的region里”作为条件。如果不能保证同一个region,宁可把这个访问判为非法,也不要放行后靠从端自己去截断。
跨边界还有一种情况是AXI的固定式突发(FIFO/BURST_FIXED),它实际上是在同一个地址上重复多次访问。这种访问如果落在region边界上,理论上只有首拍地址命中,但地址不变。为了逻辑简化,我默认所有Burst都按“起止地址区间覆盖”来检查,固定式突发也能正确覆盖。
3.3 非法访问的错误响应与异常路径
当MPU判定一个访问非法之后,不能只是把请求丢掉。对主设备来说,“没有响应”是最坏的故障,它会挂起整个总线。所以我们必须按AXI的方式返回错误。
写请求的处理相对简单。AR通道不用管,AW通道被判断为非法的请求,MPU不会转发给SRAM控制器,而是直接生成一笔带有SLVERR的B响应:
always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) begin bvalid <= 1'b0; bresp <= 2'b00; end else begin bvalid <= aw_valid_ff && aw_deny_ff; bresp <= (aw_valid_ff && aw_deny_ff) ? 2'b10 : 2'b00; end end这个写法里注意B通道非常依赖AWID,如果允许outstanding和乱序返回,必须按照原请求的AWID生成B响应的ID。在只拦截AW通道的情况下,主设备期望在发出写地址之后一段时间内看到对应的B响应,如果MPU不转发AW、也不发B响应,从上层看就是卡死。所以我的经验是:宁可晚一拍发错误响应,也不能不发。
读请求返回错误时,R通道需要带上RRESP和RDATA。对读非法访问,我给出的RDATA是恒定的安全数据,比如全0,这样可以避免把片上内存的敏感数据泄露给无权限的读取者。同时我拉了一个中断信号给中断控制器,通知特权软件有非法访问发生,便于做日志记录和后续安全审计。
3.4 用AI辅助生成RTL与人工评审的配合
写MPU这种中规中矩的控制通路,非常适合用AI大模型来打底稿。我自己的流程是先给AI一个明确的接口描述,包括AXI信号列表、region寄存器的定义、以及权限规则的说明。然后让它生成一份SystemVerilog主框架。一份比较完整的初版代码通常30分钟能出来,如果自己手写,至少半天。
我给AI的提示词通常会包含这几类约束:
- “生成可综合的SystemVerilog代码,不要带initial begin,不要用fork/join,不要用仿真专用系统函数”
- “AXI握手逻辑严格使用valid/ready机制,决策结果必须寄存器化”
- “地址比较使用掩码方式,不要用范围比较”
- “非法访问写通道返回SLVERR,同时置位中断线”
但这些代码我从来不会直接用。AI最擅长的是生成“典型”的结构,而不是“特定时序约束下的最优点”。它容易在复位结构、AXI响应ID匹配、outstanding事务的处理上做得过于理想化。
我的落地流程是这样:AI生成初稿,我先做一遍结构评审,再把关键模块的接口改成符合项目里的命名规范和位宽参数,然后补上流水级和异常处理逻辑。真正进入验证环境后,大量边界用例会把AI代码里隐藏的问题暴露出来。AI在这里更像一个效率倍增器,而不是替代工程判断的“自动写码机”。
4. 验证环境与排查实录
MPU这类权限控制模块,最怕的不是正常功能有问题,而是“该拦的没拦、不该拦的瞎拦”。因此验证的完备性比代码本身的精美程度更重要。我搭了一套UVM环境,把MPU作为DUT,前面挂标准的AXI master agent,后面接一个模拟的SRAM controller slave model。
4.1 UVM环境与断言监控
UVM环境的核心组件包括:
- AXI master agent:可配置多个sequence,支持随机地址、随机Burst长度、随机ID
- 寄存器模型:用于配置region表、锁定寄存器
- 分频的Scoreboard:维护一个“理想MPU”的参考模型,每次事务进入DUT之前先按C模型算一次命中结果,然后对比DUT输出的响应
- SVA断言:直接在RTL里嵌入协议断言,监控非法访问被正确拦截
SVA断言是精简验证回归最重要的手段之一。举个例子,当AW请求命中了允许区域时,相应的B响应就不允许出现SLVERR,这条断言写出来非常直观:
property p_write_success_not_slverr; @(posedge clk) disable iff (!rst_n) (awvalid && awready && aw_hit && aw_write_ok) |=> !(bvalid && bresp == 2'b10); endproperty用断言的好处是,一旦后续某些优化改动了MPU的内部时序,回归测试能立刻定位到是哪个响应通道出了偏差。我在项目里坚持“每条权限规则必须有至少一条对应的断言”,这个过程AI也能帮上忙,它可以基于规则描述帮你生成初步断言,但断言里的时序语义需要人工严格确认。
4.2 边界测试用例设计
我整理了一张覆盖矩阵,基本覆盖了MPU的所有边界行为:
| 测试类别 | 具体用例 | 期望结果 |
|---|---|---|
| region命中 | 地址等于region基地址 | 按perm字段放行或拒绝 |
| region边界 | 地址等于size边界前1字节 | 必须放行 |
| region边界外 | 地址等于size边界 | 必须拒绝 |
| Burst跨region | 起始命中、结束跨到禁止区 | 整体拒绝 |
| 安全属性 | 非安全访问安全region | 拒绝并报错 |
| 权限属性 | 读请求访问只写region | 拒绝并报错 |
| region重叠 | 地址同时命中region0和region3 | 按region0规则执行 |
| lock寄存器 | 锁定后再写region配置 | 写响应返回错误 |
| outstanding | 多个不同ID写请求同时乱序 | 响应ID与请求ID一一对应 |
这组用例跑完之后,大概能发现一半以上的RTL问题。比如Burst跨region是所有人最容易漏掉的地方,因为它同时依赖AWADDR、AWLEN和AWSIZE三个信号,不是看一眼AWADDR就能判断的。
4.3 常见问题与排查建议
下面是我在这个MPU从验证到集成过程中实际踩过的几个坑,写在这里供参考:
未命中默认策略一开始设成了“默认放行”,这样最方便被测功能跑通。后来安全评审时被指出这是致命伤——权限模块必须默认拒绝,宁可误杀不可漏判。所以最后我把“默认策略”设计成一个可配置位,默认值是拒绝,仿真用例里显式测试这个位。
还有一个原因是B响应生成逻辑里的ID匹配。当MPU同时拦截多个AWN通道时,如果用简单FIFO保存拒绝标志,没处理乱序,就会出现明明拒绝的是ID=3的写请求,回来的Bresp却挂到ID=5的响应上。这会导致主设备把成功或失败归错事务,极难排查。我的经验是:拒绝标志必须带AWID字段,或者直接用关联数组按ID暂存。
地址对齐不正确也是个高频问题。region大小如果是1MB,而基地址写了0x10002000,那么硬件会认为基地址的低20位不合法,要么配置写不进去,要么比较一直无法命中。软件初始化的时候一定要检查对齐关系。
时序问题主要出在比较器太长。32位地址按位比较,再叠加上region优先级仲裁,组合逻辑很容易超过时钟周期约束。如果后仿真或综合报告中显示路径违例,优先考虑把“region命中判断”拆成两级流水,第一级算掩码后的地址相等性,第二级做优先仲裁,能有效缓解组合逻辑压力。
5. 从这次实践里沉淀下来的几点体会
回过头来看,给片上内存加权限检查这件事,原理并不复杂,真正复杂的是如何在不破坏AXI协议时序的前提下,把权限语义准确地落到总线层面。用AI辅助研发确实能缩短前期“搭架子”的时间,尤其在生成寄存器配置代码、基础断言和验证场景代码这三块效率特别明显。但AI给不了你“这个模块必须默认拒绝”的安全底线意识,也给不了你对AXI协议细节的敏感度。这些只能靠实际踩坑和阅读协议规范获得。
最后分享两个小经验:第一,任何涉及权限控制和安全的模块,RTL里都要尽量避免“黑盒默认放行”,所有default分支都应该朝拒绝的方向设计;第二,验证环境里一定要有至少一个“反向用例”,专门制造非法访问来验证拦截逻辑,很多问题只有在这种用例下才会暴露。希望这篇实践记录能帮到正在做类似AXI内存保护模块的朋友。