最近在做一个 SoC 里的小 IP,属于那种“不起眼但关键时刻要命”的模块:外设访问控制 IP。简单说就是给总线上的外设访问加一道硬性的闸门,所有主设备(CPU、DMA、AI加速器)访问外设寄存器和内存区域,都必须先过这一关。我这次整个设计流程都拉了 AI 工具进来一起干,从请求拦截策略的定义,再到权限表的原子更新机制,AI 承担了相当一部分骨架代码和验证环境生成的工作。这篇文章就把整个过程拆开讲,重点是请求拦截的判定逻辑、权限配置怎么做到可靠更新,以及 AI 辅助设计时哪些坑我已经替你踩过了。
这个方向的读者应该都是做数字 IC 设计或者验证的同行。如果你正在设计总线防火墙、安全隔离模块、外设代理一类的组件,或者只是好奇“AI 写 RTL 到底靠不靠谱”,这篇东西都能给你一些参考。
1. IP到底解决了什么问题:外设访问失控的底层风险
1.1 没有访问控制的SoC有多被动
先举个最常见也最危险的场景。SoC 里挂了一堆外设:UART、SPI、GPIO、定时器、DMA 控制器,还有片内 SRAM 的某些保留区域。传统做法是,所有 master 都能看到完整地址空间,谁想访问谁就发起事务,总线 network 一概转发。你说这是“设计简单”,但代价是没有一点安全边界。比如说,某个被攻破的 AI 加速器固件,理论上可以直接往 DMA 控制器的描述符地址写数据,然后 DMA 就去改写内存任意位置。这类攻击路径在真实产品里被爆过很多次,不是危言耸听。
外设访问控制 IP 要干的活就是把这层风险用硬件掐死。它的位置在总线主设备(master)和从设备(slave)之间,每个访问外设的事务都必须经过它的仲裁和检查。检查通过,事务放行到真正的目标外设;检查不通过,直接返回错误响应,外设寄存器根本看不到这个请求。
这里有个关键点:这个 IP 不是简单的地址译码器。地址译码只是决定“路由到哪”,而外设访问控制决定了“允许不允许”。一个放在 AXI Address Decode 后面的访问过滤器,它在事务还没到达外设之前,就要基于地址、设备 ID、安全属性、事务类型做一整套策略判断。
1.2 需求拆解:不只是“加一个MUX”那么简单
我一开始也以为这就是给 AXI slave 端口套一层地址过滤逻辑,后来做完 feature list 才发现里面全是细节。一个完整的外设访问控制 IP 至少要涵盖这些需求:
- 支持多 master 对单外设的差异化访问策略,A master 能读不能写,B master 只能访问特定子区域
- 地址区间必须是可配置的,用寄存器定义 region 的基址、上限、访问类型,而不是写死
- 支持 secure 和 non-secure 事务的属性区分,通常要和系统级 TrustZone 或者 SoC 安全策略联动
- 有中断输出,一旦发生违规访问,可以通知安全处理器做后续审计和处理
- 权限表动态可更新,且更新过程必须“原子”完成,不能留下一个中间状态让非法请求钻空子
最后一条是这个 IP 的灵魂。如果是固定权限,用一堆组合逻辑比较器就干完了;但只要权限需要软件动态配置,就必须解决“配置过程中访问请求怎么办”的问题。权限更新不是写字寄存器,它是一项并发控制工程设计。
2. 顶层架构与关键技术选型:过滤器该挂在总线哪里
2.1 挂载位置的选择:AXI中间层和从设备侧
外设访问控制 IP 在 SoC 里的挂载方式,直接决定了后面所有逻辑的设计复杂度。我这次的目标外设挂在 AXI 总线上,选择了在 AXI Interconnect 和目标 slave 之间插入一个 AXI-to-AXI 过滤器,而不是去改 interconnect 内部。
这么选的道理很直接:改 interconnect 需要动整个总线矩阵,验证工作量很大,而且会影响其他无关外设的事务延迟。做成一个独立的、穿在路径上的过滤器,它只关心“从上游 master 过来的请求要不要放行”,对自己身后的真正外设完全透明,这个隔离性对模块复用非常友好。
但代价是,这个 IP 自己必须完整实现 AXI 协议的四通道握手,AXI 的 out-standing 特性、burst 传输、narrow transfer 这些都要处理。如果只是做地址过滤,很多实现可以简化,但因为挂在真实总线上,饿死、死锁、写响应乱序这些问题都得在架构里考虑进去,不然验证环境里边跑边崩。
2.2 请求拦截的判定流程:地址、ID、类型三把锁
请求进来以后,判定逻辑按以下顺序走:
- 地址空间初步过滤,把不属于任何配置 region 的请求直接定义为无效访问
- 地址精确匹配到某个 region,取出该 region 的权限配置
- 检查 master 身份,验证请求的 AXI ID 或者系统分配的主设备标签是否在允许访问的清单里
- 检查事务属性,读写类型是否被允许, secure 事务是否被降级, burst 长度是否超出区域余量
- 全部通过才真正放行到目标外设,否则返回错误响应
这套流程如果每个请求都从头到尾完整执行,就会带来不小的流水线深度和时序压力。我为每个 region 设计了一个小的匹配状态,再用一个查询状态机串行检查,基础时延控制在两拍以内。两拍对于 AXI 协议来说是完全可以接受的。
2.3 为什么用“状态机 + 规则表”而不是纯组合逻辑
很多第一次看这类 IP 的人都问:直接做组合逻辑,地址同时和所有 region 比较,谁匹配就按谁的策略走,不是更快吗?
纯组合的“并行比较器”方案,只有在规则数量很少(2~4条)且不需要动态配置的前提下才合理。规则一多,比较器阵列的扇出马上就爆,布线延迟上去了,时序收敛困难。更重要的是,动态配置需要寄存器存储规则内容,你需要一组可写的配置寄存器,这本身就是时序路径上的大负担。
我最后采用的是规则表 + 查询状态机。规则表存放在一组寄存器阵列中,每个 region 的配置(基址、上限、读使能、写使能、主设备掩码)各自独立,CPU 通过 APB 从接口配置。请求到达时,状态机从 region 0 开始逐条匹配,匹配成功后根据命中规则给出响应。
它的优势很明显:规则条数可以做到很大(比如 32 条),不影响时序;配置可以动态改,改完立即生效。缺点是,如果规则数太多,串行查询会拖慢请求响应。我这次 8 个 region 跑在 400MHz 下,实测没问题。如果未来要支持上百条规则,那就得上 TCAM 或者 hash 方案了,那是另一个课题。
3. 权限原子更新的设计细节:从影子寄存器到并发窗口
3.1 “原子更新”到底在防什么
安全性上最微妙的点在于:权限表是多个寄存器组成的。比如你要给某个 master 开放一个新外设的访问权限,同时关掉它对另一个外设的权限,这至少涉及两个 region 配置的改写。
如果没有原子更新机制,CPU 先写寄存器 A,再写寄存器 B。在 A 写完、B 没写的窗口里,系统要么处于“两个外设都能访问”的过宽状态,要么处于“两个外设都不能访问”的过窄状态。前者可能让一个本来不该有权限的请求通过,后者可能让正在进行的实时业务中断。
这种“中间状态”在软件并发场景下是可以容忍的,但在硬件安全场景里不行。因为总线上的请求是持续不断、完全随机的,只要中间状态存在,非法请求就有机可乘。权限原子更新的最终效果,就是要让整个策略表“要么全部新值生效,要么全部旧值保持”,不存在第三种状态。
3.2 影子寄存器 + Commit 的双缓冲机制
标准做法是双缓冲(Double Buffering),我拆成两个平面:“影子平面”和“活动平面”。软件更改权限时,先写影子平面,所有修改都发生在影子区域,对当前硬件判定逻辑完全不可见。
当软件确认所有新配置都已写入影子平面后,再对一个专用寄存器发出 commit 操作。commit 信号在内部触发一个状态机,把影子平面所有内容一次性拷贝到活动平面。拷贝过程只需要几个时钟周期,期间新请求进来,看到的是完整的旧策略(在拷贝开始前被接收)或者完整的新策略(在拷贝完成后),绝不会看到新旧混合的法规。
这里你可能会问:commit 拷贝的这 2-3 拍,请求被堵住了吗?我的处理是,让请求在那几拍里保持等待,用 AXI 的 ready/valid 握手机制做 pause。下游外设不感知这个等待,最坏情况就是一次事务多等三拍,对于外设访问的场景完全无所谓。
3.3 并发请求与更新窗口的竞态处理
真正容易翻车的是边界情况:commit 发起的同一拍,正好有一个请求完成了地址匹配,拿到了活动平面里某一条规则,而这条规则正在被影子平面覆盖。虽然拷贝过程极短,但这个微妙的重叠窗口必须处理。
我的方案是在 commit 周期插入一个“请求冻结窗口”。commit 一开始,状态机拉高内部 busy 信号,所有新到达的请求先在这个信号前排队,不进入匹配流程。拷贝结束后,busy 撤销,排队的请求按到达顺序逐个处理。这个机制实现成本很低,只需要在入口加一个两级 FIFO,但安全性提升很大。
这之后还有一个问题:如果软件在影子平面写了一半,突然想放弃这次更新怎么办?我的做法是提供一个软复位位,能够把影子平面的内容重新载入当前活动平面的值,丢弃所有未提交的修改。这个功能刚开始觉得是多余的,后来在调试系统固件时救了我两次,很值得做进去。
4. AI 辅助设计的实际体验:从 RTL 到 UVM 都让它干了
4.1 用 AI 写 RTL:能跑,但绝不能直接信
我这次用 AI 出 RTL 骨架,大致流程是:先把接口时序图、寄存器列表和状态机描述整理成需求文档,然后让模型按照文档生成 SystemVerilog 代码。
值得肯定的是,AI 对典型结构(寄存器读写译码、APB 从接口、基础状态机)的生成质量是相当稳定的,能节省至少一天的工作量。但问题也很典型:AI 生成的状态机常常缺少异常路径处理,比如 APB 的写 strobe 和地址错位、burst 传输中跨 region 边界、master 身份未定义时的默认动作等等。这些都是很容易被 AI 忽视的真边界,一旦仿真跑起来,覆盖率工具分分钟教你做人。
所以我把 AI 生成的 RTL 当“初稿”而不是“成品”,每一行代码都要过自己的脑子。我用了一个技巧:让 AI 同时生成一个设计注释文档,说明每个状态转移的条件和原因。这个注释文档带来的额外价值超出了我的预期,review 代码时完全可以对着它逐条核对。
4.2 用 AI 搭 UVM 验证环境:大幅度降低重复劳动
UVM 环境里最枯燥的部分是寄存器模型、sequence、配置类这些模板化组件。我这次也全部交给 AI 生成基础版本,然后自己补充约束条件和功能覆盖点。
AI 生成 register model 的准确率相当高,因为这类代码的规律性很强,输入输出很明确。sequence 部分也不难,但要注意 AI 生成的 transaction 约束往往太宽松,导致验证没跑到真正的边界条件。比如随机地址生成时,它默认就是在整个地址空间均匀撒点,对于访问控制 IP 来说,最有价值的测试是精确覆盖 region 的基址、边界、越界一步,这些约束我全部手动加上。
断言(assertion)部分是我最克制使用 AI 的地方。AI 能写简单的 immediate assertion,但复杂的并发断言(比如 atomic update 期间不允许非法请求漏过)它写得一塌糊涂,我自己写的 SVA 反而很快。这里我强烈建议:AI 可以帮你做断言框架,核心安全断言一定要自己人工确认。
4.3 AI 生成代码的坑:两个真实翻车现场
第一个翻车现场出现在 APB 接口的写时序上。AI 默认认为 APB 的 set-up、access、tear-down 三个阶段里,写数据在 PWRITE 有效后立刻就可以被采样,但它忽略了寄存器总线中“先译码再写入”的内部延迟,导致某些配置寄存器的写入有概率失败。这个问题最后靠我添加写完成握手信号才解决,AI 生成的代码整体节奏和真实 APB 信号模型是有出入的。
第二个翻车现场在 burst 请求的地址更新上。AXI burst 的地址在每个节拍递增,AI 写的地址判断逻辑只检查了首地址是否落在 region 内,没有检查整条 burst 的地址范围是否完全覆盖在 region 内。遇到跨 region 的 burst,就直接放行了半个区域外的地址,这在安全上完全不能接受。修这个 bug 不复杂,但要意识到:AI 对“地址连续性”这件事的直觉是不够的,它擅长的是单点判断,不擅长范围连续判断。
5. 仿真验证与问题排查实录
5.1 UVM 环境搭建的三个教训
这套 IP 的 UVM 环境我前后搭了大概三天,踩过的坑值得说几个。
第一个教训是环境层次不能太深。第一次我按公司标准分了五层结构:test -> test_base -> env -> agent -> model。结果调试起来每个层级都在打印信息,噪声巨大。后来简化成 test 直连 env,env 内含一个 agent 和一个 scoreboard,层次的减少让 debug 效率提高非常明显。
第二个教训是寄存器模型要单独做。UVM 自带的uvm_reg机制如果直接从 RTL 顶层例化,会把 APB 从口的所有细节暴露给 model,非常不灵活。我最后把寄存器模型单独放一个模块,通过 set 和 get 接口和 scoreboard 通信,IP 内部的信息对验证环境做到最小暴露。
第三个教训和并发有关:commit 窗口期间,如果要发一个随机写权限表的 sequence,必须先在环境里全局同步。最简单的做法是发一个 barrier event,让所有 sequence 等待。如果忽略这个同步,你会在波形里看到一些完全无法解释的临时权限配置。
5.2 常见问题速查表
| 现象 | 根因 | 解决思路 |
|---|---|---|
| 配置影子寄存器后功能立即变化 | 影子平面和活动平面地址映射写错 | 检查寄存器地址 offset,尤其是 commit 控制寄存器的位置 |
| burst 请求只检查首地址导致越界 | 地址连续性判断缺失 | 在匹配逻辑前增加 burst 地址完整区间计算 |
| commit 期间非法请求漏过 | 未添加请求冻结窗口 | 入口加 FIFO,commit 时暂停新事务,拷贝结束再恢复 |
| 随机验证时权限配置被意外破坏 | 缺少 sequence 同步 | 加 barrier event 或统一 sequence 优先级 |
| APB 写入偶尔不稳定 | 写完成握手缺失 | 确认 APB 时序,增加内部写完成信号 |
| AI 生成规则表的默认值不安全 | 默认全通 | 上电复位默认值设为全拒绝,后续由安全软件按需配置 |
这张表是我实际调试过程的提炼,如果你也做类似的安全过滤模块,基本可以照着排查。
5.3 覆盖率驱动的收敛策略
覆盖率是另一个重头戏。外设访问控制 IP 的功能覆盖点主要集中在:请求类型×权限组合、非法访问断言触发、commit 冻结窗口等待、burst 边界穿越。
我这次用 SystemVerilog covergroup 收集覆盖点,配合随机约束,跑到仿真时间大约 2 小时,行覆盖率 91%,功能覆盖率 96.8%。剩下 3.2% 的功能覆盖点,基本都是非常极端的组合,比如 32 位 master 同时在 commit 冻结窗口里发起 burst 请求又同时撤销。这些最后用定向测试用例补齐。
覆盖率收敛这件事 AI 帮不了太多,因为它的随机种子策略和真实设计意图之间缺乏针对性。我花了两天手动补定向用例,最后功能覆盖率到 100%,行覆盖率到 97.2%,对可综合 IP 来说已经算不错的交付标准。
6. 从规格到交付的完整实操流程清单
6.1 步步为营的设计步骤
我自己这次用的流程,按节点拆成八步,每一步都有明确输出:
- 需求规格定义:列清楚支持的 master 数量、外设 region 数量、事务类型、安全属性,这是后面所有工作的锚点
- 微架构文档:画出数据通路、状态机转移图、寄存器列表,这一步占到整个研发周期的 30%,别省
- AI 生成 RTL 初稿:把微架构文档打包给 AI,生成基础代码,记得同步让它产出注释文档
- 人工 review 和补边界逻辑:重点看 burst 地址连续性、并发窗口、默认值安全性
- 独立验证环境搭建:UVM 环境、寄存器模型、断言、功能覆盖点
- 随机仿真与定向用例补全:这个环节覆盖率工具说了算
- FPGA 原型验证或硬件加速验证:我这次用 FPGA 原型验证平台跑通,验证时间比纯仿真快两个量级
- 交付与集成文档:RTL、验证报告、覆盖率报告、集成手册,全部归档
6.2 关键模块的参考实现片段
有些代码结构几乎每次都能复用,贴两个核心部分。
第一个是 APB 从接口的写握手部分,这也是 AI 第一次翻车的位置:
// 写数据在 PADDR 有效后,要等 write_done 拉高才算真正完成 logic [7:0] mem [REG_COUNT]; logic [31:0] write_data_q; logic write_pending; logic write_done; always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) begin write_pending <= 1'b0; write_done <= 1'b0; end else begin if (PENABLE && PWRITE && PSEL) begin write_data_q <= PWDATA; write_pending <= 1'b1; write_done <= 1'b0; end else if (write_pending) begin // 这里是核心:写数据是组合逻辑的反馈,需要一个稳定周期 mem[PADDR[$clog2(REG_COUNT)-1:0]] <= write_data_q; write_pending <= 1'b0; write_done <= 1'b1; end end end第二个是请求冻结窗口的简化表示:
logic commit_busy; logic req_freeze; assign req_freeze = commit_busy && request_valid; // 当 commit 时,捕获当前请求,等 busy 释放后继续 always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) saved_request <= 0; else if (req_freeze) saved_request <= request_payload; else if (!req_freeze && freeze_ack) saved_request <= 0; end这段代码的逻辑很清晰:commit_busy 拉高时,新请求进不了匹配状态机,冻结窗口内的请求被保存,等 busy 释放后按保存的数据继续处理。实际项目中 freeze 信号可能需要多包括一个周期,但这个思路是通用的。
6.3 一点关于交付的额外建议
最后说一点我在集成阶段学到的体会。外设访问控制 IP 交付时,光给 RTL 和验证报告是不够的,你还需要提供一套安全配置示例和软件驱动代码。因为最终用户怎么使用这套权限配置,直接影响系统的安全性。我这次附带了一套 Linux 下的设备树配置说明和一个安全配置 API 参考,集成方照着填 region 参数就能跑,少了无数来回沟通成本。
AI 辅助设计这个事,我的态度从“试试看”变成了“必须用”,但用得很克制。RTL 骨架、寄存器模型、模板 sequence 这些结构化强、变化少的活儿,AI 做又快又好;反观需要深层安全判断、并发窗口分析和边界条件推导的部分,AI 只能给点启发,最后的责任还是得设计者自己扛。这个外设访问控制 IP 从需求明确到交付,完整走完大概六周,对比纯手工估计能省 30% 的验证代码编写时间,但主要体现在环境和模板搭建上,真正的重要逻辑还是靠人。
如果你也要做类似的 IP,我的建议是:把“原子更新”和“并发窗口”相关的问题想透,再动手写代码。这块逻辑一旦有个小疏漏,功能验证阶段看起来都正常,一旦集成到复杂 SoC 里,出现的是那种最难定位的偶发安全违例问题。