首页 / 资讯中心 / 文章详情

APB总线协议详解:从AMBA定位、时序握手到Slave RTL与仿真验证

APB总线协议详解:从AMBA定位、时序握手到Slave RTL与仿真验证 ★ FEATURED ARTICLE
1. 先搞清楚 APB 在 AMBA 家族里到底站什么位置第一次接触 AMBA 的人十有八九会被一堆缩写绕晕AXI4、AHB、APB还有一堆带数字后缀的版本号。我当年翻 ARM 的规范文档时也是一头雾水直到把这三条总线画在一张图上看才明白它们其实是按性能—功耗—复杂度排了个梯队各管一摊事。AMBA 全称 Advanced Microcontroller Bus Architecture本质是 ARM 定义的一套片上互连规范解决的是一颗芯片里几十上百个模块怎么互相说话这个问题。APB 全称 Advanced Peripheral Bus翻译过来就是高级外设总线。名字里的外设两个字就是它的定位说明书它不负责跑大流量数据专门伺候那些低速、低带宽、对时序不敏感的外设比如 UART、I2C 控制器、GPIO、定时器、看门狗、SPI 主机、以及各种配置寄存器组。你打开任何一颗 SoC 的框图大概率能看到一条 APB 总线挂在 AXI 或 AHB 到外设的桥后面分出去十几个从设备。这篇笔记就围绕 APB 协议本身展开从它为什么这么设计、信号和时序怎么走、到怎么手写一个 Slave 模块、再到仿真联调时踩的坑尽量把这块讲透适合刚入门数字 IC 验证或 SoC 集成的朋友也适合做嵌入式底层开发、想搞明白寄存器读写背后发生了什么的工程师。1.1 三条总线各管一摊事把 AXI4、AHB、APB 放一起对比能最快建立直觉。AXI4 是面向高带宽场景的它有五条独立通道读地址、读数据、写地址、写数据、写响应支持乱序完成、支持突发传输主要用于 CPU 与 DDR 控制器、DMA、GPU 这类高吞吐模块之间的连接。AHB 是较早期的方案流水线式结构支持突发常用于中高速场景。APB 则完全是另一个极端非流水线、无突发、协议简单到用一张 A4 纸就能描述清楚。这种简单不是能力不足而是刻意为之。外设寄存器的访问频率通常很低——你配置一次 UART 波特率可能整个系统运行期间就写那么一两次读一个状态寄存器也就是偶尔为之。给这种访问配一套支持乱序和突发的复杂协议属于杀鸡用牛刀反而增加验证难度和硅面积。所以 APB 的取舍非常明确牺牲吞吐换取极低的逻辑开销和确定性的时序。特性AXI4AHBAPB通道结构5 条独立通道单通道流水线单通道非流水线突发传输支持支持不支持乱序完成支持不支持不支持典型时钟频率高中低典型用途DDR、DMA、GPU中速外设、片上存储低速外设配置寄存器逻辑复杂度高中极低1.2 APB 慢但慢得有道理很多人一开始会问APB 一次传输至少要两个时钟周期为什么不能一个周期搞定答案藏在它的状态机设计里。APB 的传输被明确划分成 Setup 和 Access 两个阶段Setup 阶段先把地址、写数据、控制信号全部摆好Access 阶段才真正完成传输。这种先摆好再执行的两拍节奏带来三个实打实的好处。第一是时序收敛容易。所有信号在 Setup 阶段就已经建立到 Access 阶段从设备有整整一个周期的时间去译码、访问内部寄存器综合时路径压力小容易跑到较高频率。第二是功耗可控。因为协议简单不需要复杂的握手和缓冲逻辑静态功耗和翻转功耗都低这对电池供电的物联网芯片很关键。第三是验证成本低。接口信号少、状态机只有 IDLE、SETUP、ACCESS 三个状态写激励和覆盖率模型都轻松。我实际做过一个对比同一个寄存器组用 AHB 接口实现和用 APB 接口实现APB 版本的逻辑门数大概只有 AHB 的三分之一验证用例数量也少了一半多。所以只要带宽需求允许能用 APB 就别上 AHB这是工程上的常规选择。1.3 什么时候该用 APB什么时候别硬上判断标准其实很简单看两点访问频率和数据量。如果某个模块每次传输的数据不超过总线位宽且两次访问之间隔了几十个甚至上百个时钟周期那 APB 完全够用。典型代表就是各类控制寄存器、状态寄存器、低速数据收发口。但如果遇到下面几种情况就该考虑换总线了。一是需要连续搬运大块数据比如图像传感器输出的像素流、音频采样流APB 一次只能搬一个数据宽度效率太低。二是需要高频率轮询比如某些实时控制环路里每个周期都要读状态这会直接占满 APB 的带宽。三是需要多主机竞争访问APB 本身是单主机结构多主机场景需要额外加仲裁逻辑不如直接用支持多主机的总线。提示很多 SoC 里 APB 并不是孤立存在的它通常通过一个 AXI-to-APB 或 AHB-to-APB 桥接模块挂到主干总线上。桥接模块负责把主干的突发访问拆成一次次单次 APB 传输这个拆包过程是理解系统带宽瓶颈的关键点后面第三节会细讲。2. APB 的信号与握手时序掰开揉碎讲搞清楚定位之后就要进入协议的肉搏环节了。APB 协议最核心的内容就是那几个信号和它们之间的时序关系这部分如果理解不透写 RTL 时一定会出问题。我当时学的顺序是先背信号表再对着波形图一个周期一个周期地看最后自己画时序图默写效果比单纯读文档好得多。2.1 必需信号清单APB 的信号按方向可以分成三类主机驱动给从机的、从机驱动给主机的、以及全局的。先把 APB3 的基础信号列清楚这是最常用的版本。信号名方向位宽说明PCLK全局1总线时钟所有信号同步于它的上升沿PRESETn全局1低电平有效的异步复位PADDR主机→从机通常 8~32地址总线PSEL主机→从机1从机选择每个从机一根PENABLE主机→从机1传输使能标识进入 Access 阶段PWRITE主机→从机11 表示写0 表示读PWDATA主机→从机8/16/32写数据PRDATA从机→主机8/16/32读数据PREADY从机→主机1从机就绪用于插入等待周期PSLVERR从机→主机1传输错误指示PSEL 的位宽设计有个细节值得说规范里每个从设备对应一根独立的 PSEL 信号但实际实现时很多设计会用 PADDR 的高位做地址译码生成一根选择信号。这样做的好处是省线坏处是译码逻辑分散在各个从机里。两种做法都常见我在项目里更倾向集中式译码把地址映射统一放在互连模块里从机只认自己那根 PSEL职责更清晰。2.2 写传输和读传输的波形节奏先把无等待周期的写传输过一遍这是最基本的节奏。假设时钟周期为 T一次写操作最少占两个周期。T1 周期Setup 阶段主机把 PADDR、PWRITE 拉高写操作、PWDATA 摆好同时把目标从机的 PSEL 拉高PENABLE 保持为低。这个阶段从机该做什么它只需要看到 PSEL 有效开始准备但不要动内部寄存器。T2 周期Access 阶段主机把 PENABLE 拉高。从机此时看到 PSEL 和 PENABLE 同时为高如果自己已经准备好接收数据就把 PREADY 拉高。主机采样到 PREADY 为高认为传输完成在 T3 周期撤销 PSEL、PENABLE。读传输的节奏类似区别在数据方向。T1 阶段主机把 PWRITE 拉低PADDR 摆好。T2 阶段从机把读出的数据放到 PRDATA 上同时拉高 PREADY。主机在 PREADY 为高的那个周期上升沿采样 PRDATA。这里有个容易忽略的点PRDATA 到底在哪个阶段有效规范上说是 Access 阶段但很多从机实现会在 PSEL 有效的整个期间都驱动 PRDATA靠组合逻辑完成读通路这样主机在 PREADY 拉高时直接采就行省一级寄存器。两种做法都能工作关键是要和主机的采样时序对齐。注意如果你的从机是组合读通路一定要保证 PADDR 在 Setup 到 Access 期间保持不变。主机如果在两个阶段之间改了地址从机会读到错误的数据。规范要求主机在这两个阶段保持地址和数据不变从机可以依赖这个约定。2.3 等待周期 PREADY 到底怎么插PREADY 是 APB 里最有嚼头的一个信号它让慢从机能够喘口气。典型场景是从机内部寄存器访问需要多个周期比如它后面挂了一个串行接口、一次读写要几十个周期才能完成或者是个需要跨时钟域同步的模块。插等待的做法是Access 阶段第一拍从机发现还没准备好把 PREADY 保持为低。主机看到 PREADY 为低不会撤销 PSEL 和 PENABLE而是继续等下一拍。从机在这段时间里完成内部操作等完成后把 PREADY 拉高传输才结束。这块状态机写法很关键从机不能因为 PREADY 为低就放松必须持续锁存地址和控制信号。我见过一个典型的错误实现从机在等待期间误以为传输已经结束提前把内部状态复位了结果 PREADY 拉高时数据已经乱套。正确做法是把从机的状态机分成 IDLE、SETUP、ACCESS 三个明确状态只有在 ACCESS 且 PREADY 为高时才真正完成一次传输并回到 IDLE。2.4 容易忽略的 APB4 和 APB5 新信号APB3 之后ARM 又发布了 APB4 和 APB5加了几个信号实际项目中用得到。APB4 增加了 PPROT 和 PSTRB。PPROT 是保护属性信号3 位分别标识特权级/用户级、安全/非安全、取指/数据三类属性。做安全芯片时这个信号很重要从机可以根据它决定是否拒绝某次访问。PSTRB 是字节选通信号位宽等于数据宽度除以 8用来支持部分字节写入。比如 32 位数据总线上只写低 16 位就把 PSTRB 设成 4b0011。这个信号在做寄存器按字节访问时特别有用没有它就只能整字写浪费功耗还可能误改相邻字节。APB5 进一步引入了 PWAKEUP 和一组用户自定义边带信号PUSER、PWUSER、PRUSER、PBUSER。PWAKEUP 用于低功耗管理让从机能够请求唤醒。边带信号则是给厂商预留的扩展通道可以传递调试信息或自定义属性。这些信号在通用外设里不一定用得上但在低功耗设计和安全设计中会经常碰到。3. 手写一个 APB Slave 寄存器模块理论讲够了直接上手写 RTL 才是检验理解的最好方式。我建议每个学 APB 的人都亲手写一个寄存器从机模块从最简单的零等待版本开始逐步加等待周期、加错误响应、加字节选通一步步把各个信号吃透。下面这套代码是我自己用的模板结构清晰可以直接改改用在项目里。3.1 接口定义与地址映射的约定先说地址映射的约定。假设从机内部有 8 个 32 位寄存器按字对齐地址偏移从 0x00 到 0x1C。PADDR 是 12 位低 2 位恒为 0因为按字对齐PADDR[11:2] 用来选寄存器高 4 位PADDR[11:8]可以作为从机的基址匹配位。这种高位匹配基址、低位选寄存器的划分方式是最常见的互连模块负责产生 PSEL从机内部只做低位译码。这种分层设计的好处是解耦。互连模块改动地址映射时从机代码不用动从机增加寄存器时互连模块也不用改。地址空间规划是在项目早期就要定下来的事我一般会在文档里画一张地址映射表明确每段地址归谁给每个模块预留足够的扩展空间避免后期寄存器加不下还得重排。3.2 零等待版本的 RTL 实现先看最简单的版本PREADY 恒为高PSLVERR 恒为低不含等待和错误响应。module apb_slave_regfile #( parameter ADDR_WIDTH 12, parameter DATA_WIDTH 32 )( input wire PCLK, input wire PRESETn, input wire [ADDR_WIDTH-1:0] PADDR, input wire PSEL, input wire PENABLE, input wire PWRITE, input wire [DATA_WIDTH-1:0] PWDATA, input wire [DATA_WIDTH/8-1:0] PSTRB, output reg [DATA_WIDTH-1:0] PRDATA, output wire PREADY, output wire PSLVERR ); reg [DATA_WIDTH-1:0] reg0; reg [DATA_WIDTH-1:0] reg1; reg [DATA_WIDTH-1:0] reg2; reg [DATA_WIDTH-1:0] reg3; // 零等待永远就绪无错误 assign PREADY 1b1; assign PSLVERR 1b0; wire write_en PSEL PENABLE PWRITE PREADY; wire [ADDR_WIDTH-3:0] word_addr PADDR[ADDR_WIDTH-1:2]; // 写逻辑按 PSTRB 做字节粒度写入 always (posedge PCLK or negedge PRESETn) begin if (!PRESETn) begin reg0 32h0000_0000; reg1 32h0000_0000; reg2 32h0000_0000; reg3 32h0000_0000; end else if (write_en) begin case (word_addr) 0: begin if (PSTRB[0]) reg0[7:0] PWDATA[7:0]; if (PSTRB[1]) reg0[15:8] PWDATA[15:8]; if (PSTRB[2]) reg0[23:16] PWDATA[23:16]; if (PSTRB[3]) reg0[31:24] PWDATA[31:24]; end 1: begin if (PSTRB[0]) reg1[7:0] PWDATA[7:0]; if (PSTRB[1]) reg1[15:8] PWDATA[15:8]; if (PSTRB[2]) reg1[23:16] PWDATA[23:16]; if (PSTRB[3]) reg1[31:24] PWDATA[31:24]; end 2: begin if (PSTRB[0]) reg2[7:0] PWDATA[7:0]; if (PSTRB[1]) reg2[15:8] PWDATA[15:8]; if (PSTRB[2]) reg2[23:16] PWDATA[23:16]; if (PSTRB[3]) reg2[31:24] PWDATA[31:24]; end 3: begin if (PSTRB[0]) reg3[7:0] PWDATA[7:0]; if (PSTRB[1]) reg3[15:8] PWDATA[15:8]; if (PSTRB[2]) reg3[23:16] PWDATA[23:16]; if (PSTRB[3]) reg3[31:24] PWDATA[31:24]; end default: ; endcase end end // 读逻辑组合输出PSEL 有效即驱动 always (*) begin case (word_addr) 0: PRDATA reg0; 1: PRDATA reg1; 2: PRDATA reg2; 3: PRDATA reg3; default: PRDATA 32h0000_0000; endcase end endmodule这段代码有几个细节值得说。写使能里的PREADY冗余吗不冗余。虽然零等待版本 PREADY 恒为 1但保留这个条件便于后面扩展等待逻辑写一次以后不用改。PSTRB 的字节粒度写入用了四个独立的 if而不是把 PWDATA 整体赋值这样单个字节写入不会影响相邻字节符合规范要求。读通路用组合逻辑地址一变数据就跟着变主机在 PREADY 拉高的那个周期采样时序上是够的。3.3 加上等待周期与错误响应真实项目里往往需要等待周期比如从机内部有跨时钟域同步或者外部低速接口。做法是把从机状态机显式写出来加一个等待计数器。// 带等待周期和错误响应的 APB slave 片段 localparam ST_IDLE 2b00; localparam ST_SETUP 2b01; localparam ST_ACC 2b10; reg [1:0] state; reg [2:0] wait_cnt; reg ready_r; reg slverr_r; reg [ADDR_WIDTH-1:0] addr_lat; reg write_lat; reg [DATA_WIDTH-1:0] wdata_lat; always (posedge PCLK or negedge PRESETn) begin if (!PRESETn) begin state ST_IDLE; wait_cnt 3d0; ready_r 1b0; slverr_r 1b0; end else begin case (state) ST_IDLE: begin ready_r 1b0; slverr_r 1b0; if (PSEL) state ST_SETUP; end ST_SETUP: begin // 锁存请求准备进入 Access addr_lat PADDR; write_lat PWRITE; if (PWRITE) wdata_lat PWDATA; wait_cnt 3d3; // 假设需要 3 个等待周期 if (PENABLE) state ST_ACC; end ST_ACC: begin if (wait_cnt ! 3d0) begin wait_cnt wait_cnt - 1b1; ready_r 1b0; end else begin ready_r 1b1; // 地址不在合法范围时报告错误 if (addr_lat[ADDR_WIDTH-1:4] ! 0) slverr_r 1b1; if (!PSEL) state ST_IDLE; else if (!PENABLE) state ST_SETUP; end end default: state ST_IDLE; endcase end end assign PREADY ready_r; assign PSLVERR slverr_r;这段代码里wait_cnt的初值决定插入几个等待周期改这个值就能适配不同从机的响应速度。slverr_r的置位条件示例是地址越界实际项目中还可以根据 PPROT 判断权限、根据访问类型判断合法性。等待计数器归零后 ready_r 拉高一个周期主机在这个周期完成采样下一周期从机根据 PSEL 和 PENABLE 的状态决定回 IDLE 还是准备下一次传输。注意状态机里的if (!PSEL) state ST_IDLE;和else if (!PENABLE) state ST_SETUP;这两句是处理背靠背传输的关键。APB 规范允许主机连续发起传输从机必须在一次传输结束后能立刻接下一次不能死锁在某一个状态。我调试时遇到过从机在 ACCESS 状态出不来原因就是漏了 PSEL 撤销的跳转条件。3.4 带宽与延迟的粗算写完代码得会算性能和延迟这是硬件工程师的基本功。假设 PCLK 频率 50 MHz周期 20 ns总线位宽 32 位。零等待传输一次占 2 个周期即 40 ns带宽 4 字节 / 40 ns 100 MB/s。如果插入 3 个等待周期一次传输占 5 个周期共 100 ns带宽降到 40 MB/s。这个数字直观说明为什么等待周期要尽量少一个看似无害的等待计数器能把有效带宽砍掉一半以上。对于配置类寄存器100 MB/s 看着不高但绝对够用因为访问稀疏。真正需要警惕的是那些看似配置类实则需要频繁访问的寄存器比如状态轮询这时候每一次读取都占一次完整传输累积起来很客观。我在一个项目里见过 CPU 每 1 us 轮询一个 APB 状态寄存器占掉的总线时间虽然只有百分之几但因为 APB 时钟本来就低叠加其他外设访问后还是造成了偶发拥堵后来改成中断驱动才解决。等待周期数单次传输周期数单次耗时50MHz理论带宽0240 ns100 MB/s1360 ns66.7 MB/s2480 ns50 MB/s35100 ns40 MB/s4. 仿真验证与联调排查实录写好的 RTL 必须过仿真才能上板验证环境搭得好不好直接决定调试效率。这一段我把自己常用的验证思路和踩过的坑整理出来都是实际项目中验证过的可以照着搭。4.1 一个够用的验证环境验证 APB 从机不需要太复杂的 UVM 环境一个 SystemVerilog 或 cocotb 的简单 testbench 就够了。核心思路是构造一个 APB 主机模型让它按协议产生激励然后对比读出数据和预期值。我习惯用 cocotb 搭这类小模块Python 写激励快调试直观。核心的读函数大概是这样async def apb_write(dut, addr, data, strb0xF): # Setup 阶段 dut.PSEL.value 1 dut.PENABLE.value 0 dut.PWRITE.value 1 dut.PADDR.value addr dut.PWDATA.value data dut.PSTRB.value strb await RisingEdge(dut.PCLK) # Access 阶段 dut.PENABLE.value 1 await RisingEdge(dut.PCLK) while dut.PREADY.value 0: await RisingEdge(dut.PCLK) # 撤销 dut.PSEL.value 0 dut.PENABLE.value 0 await RisingEdge(dut.PCLK) async def apb_read(dut, addr): dut.PSEL.value 1 dut.PENABLE.value 0 dut.PWRITE.value 0 dut.PADDR.value addr await RisingEdge(dut.PCLK) dut.PENABLE.value 1 await RisingEdge(dut.PCLK) while dut.PREADY.value 0: await RisingEdge(dut.PCLK) data dut.PRDATA.value.integer dut.PSEL.value 0 dut.PENABLE.value 0 await RisingEdge(dut.PCLK) return data这段代码体现了 APB 三阶段的完整节奏Setup 摆信号、Access 拉 PENABLE、等 PREADY、撤销。写测试时要注意await RisingEdge之后的采样点选择很讲究如果不小心采在信号变化的同一时刻会有竞争冒险。稳妥做法是采在时钟上升沿之后加一个小的延时或者用 cocotb 的ReadOnly阶段。验证用例建议从简单到复杂分层覆盖先测单个寄存器的写后读回再测多寄存器连续访问再过一遍字节选通的各种组合最后加上边界地址和非法地址的错误响应测试。覆盖率达到信号翻转和状态机路径全覆盖基本就扎实了。4.2 常见问题速查表下面这张表是我这些年调试 APB 从机时最常遇到的问题按现象、原因、解决思路整理遇到问题可以对号入座。现象可能原因排查与解决读数据一直是 0PRDATA 组合逻辑未覆盖该地址检查 case 分支是否穷举default 给合理值写数据丢失写使能条件不完整确认PSEL PENABLE PWRITE PREADY都在从机卡在等待状态PREADY 没有正确拉高检查等待计数器逻辑和状态跳转背靠背传输出错状态机未处理 PSEL 撤销补 IDLE/SETUP 跳转分支字节写入误改相邻字节未使用 PSTRB 做字节选通按字节逐位判断再写入PSLVERR 误报错误判断条件过严打印地址和控制信号逐笔核对时序违例组合读通路拉太长在 PRDATA 上插一级寄存器4.3 踩过的坑与实战心得说几个文档里不会写、但实际很坑的点。第一个是复位释放的时机。PRESETn 是异步复位但释放时如果不做同步容易造成状态机误跳转。稳妥做法是在从机内部加两级同步器处理 PRESETn 的释放沿虽然规范没强制要求但上板后能省很多偶发问题。第二个是地址位宽和寄存器数量的匹配。我最早写的版本 PADDR 用了 12 位寄存器却只有几个译码时直接拿高位去 case结果综合工具报了一大堆位宽不匹配的警告。正确做法是先用位拼接把 PADDR 压缩到实际需要的宽度再用于译码代码干净且综合友好。第三个是 PREADY 的驱动方式。有些设计为了省事把 PREADY 直接接高这样虽然简单但一旦从机响应变慢整个系统就没法插入等待属于把问题留给了未来。我现在的习惯是无论当前是否需要等待都保留完整的等待状态机以后从机速度降下来改个参数就行不用重修时序。第四个是中断和状态位的处理。APB 从机里经常有需要软件写 1 清 0 的中断状态位如果实现成普通可写寄存器软件就得先读后改再写容易引发竞态。正确做法是设计专门的 W1C 位读时返回状态写时按位清硬件层面就规避了竞态。这个设计模式在中断控制器里非常常见值得专门记住。第五个是调试时的波形观察技巧。用波形工具看 APB 时我一般把 PSEL、PENABLE、PREADY 三个信号放一起看就能很直观地判断传输处于哪个阶段、有没有插入等待。如果 PSEL 高而 PENABLE 低是 Setup两个都高而 PREADY 低是等待两个都高且 PREADY 高是完成。这个信号组合是排查所有时序问题的起点。我个人在实际操作中的体会是APB 协议本身不难难的是把它放到一个完整的系统里和别的模块协同工作。单独写一个从机可能两小时就搞定但联调时出现的各种边界情况往往要花好几倍的时间。所以从一开始就把状态机写规范、把边界条件想周全比后期打补丁省事得多。
阅读完成 · 觉得有帮助?
咨询建站