做SoC集成的朋友大概都有过这种体验:外设寄存器手册写得明明白白,代码一跑读出来不是 0 就是 0xFFFFFFFF,抓波形看上半天,最后发现是自己没吃透 APB 那两拍半的时序。AMBA 总线协议这个体系里,AXI4 总线协议和 AHB 总线协议经常被拿来做性能分析,APB 协议却总被当成"简单到没什么可讲"的那一个。但恰恰是 APB 总线,几乎每一个芯片里都会出现几十上百个实例——UART、I2C、GPIO、看门狗、时钟控制器、复位控制器,统统挂在它上面。它的简单是一种刻意的设计取舍,理解了这个取舍,你才知道什么时候该用它、什么时候必须换成 AHB 或 AXI4。
这篇总结适合三类人看:刚接触数字前端、第一次手写 APB 从机的同学;做 SoC 集成、需要把第三方 IP 挂到总线上却总在时序上栽跟头的工程师;还有做验证、要写 APB 时序断言和覆盖率的人。我会从协议定位讲到信号语义,从逐拍时序讲到一份可以直接抄的 Verilog 从机实现,最后把这几年踩过的坑整理成一张速查表。APB 时序这东西,看十遍手册不如自己对着波形数一遍拍子,下面我们就按这个思路来。
1. AMBA总线家族里APB的定位与方案取舍
1.1 为什么一条"慢总线"值得单独存在
AMBA 从诞生到现在,一直是分层设计的思路:AXI4 总线协议负责高带宽、乱序、多 outstanding 的主干数据搬运,AHB 总线协议负责中高带宽、流水线化的片上互联,而 APB 协议负责把那些跑不快、也用不着跑快的寄存器型外设挂上去。这个分层不是历史包袱,而是非常实在的成本取舍。你可以把整个片上总线想象成城市交通:AXI 是高速公路,AHB 是城市主干道,APB 就是小区门口那条单车道。
单车道为什么不能被主干道取代?因为每条车道都要配红绿灯、监控、路牌,这些都是面积和功耗。APB 的信号数量少得可怜,一个最简的从机只需要 PCLK、PRESETn 加七八根线就能工作;它没有流水线、没有 burst、没有 outstanding 的概念,任何一笔传输都是独立的、不可打断的。这意味着从机的状态机可以极其简单,往往几十行 RTL 就能搞定,综合出来的面积小、时序余量大、验证工作量和出错概率都低。
反过来,如果你把一个 GPIO 的寄存器组接到 AXI4 上,会发生什么?你需要处理 ID 通道、处理 burst 边界、处理写响应通道的乱序返回、处理 4KB 边界规则。这些机制对一块只有四个寄存器的外设来说完全是浪费。所以 APB 的价值不在于"能跑多快",而在于"用最小的代价把简单外设接进系统",这是一个典型的工程性价比选择。
1.2 APB2、APB3、APB4 三代协议的信号差异
很多人写文档时会笼统地说"APB 协议",但实际项目里你用的是哪一代,直接决定了从机能不能插入等待周期、能不能报错。下面这张表是我自己整理的三代对比,按信号维度列出来更直观。
| 信号 | APB2 | APB3 | APB4 及之后 | 说明 |
|---|---|---|---|---|
| PCLK / PRESETn | 有 | 有 | 有 | 时钟与低有效复位 |
| PSELx | 有 | 有 | 有 | 从机片选,每从机独立 |
| PENABLE | 有 | 有 | 有 | 访问阶段使能 |
| PADDR | 有 | 有 | 有 | 字节地址 |
| PWRITE | 有 | 有 | 有 | 1 写 0 读 |
| PWDATA | 有 | 有 | 有 | 写数据 |
| PRDATA | 有 | 有 | 有 | 读数据 |
| PREADY | 无 | 有 | 有 | APB3 引入,支持等待周期 |
| PSLVERR | 无 | 有 | 有 | APB3 引入,支持错误响应 |
| PSTRB | 无 | 无 | 有 | 字节选通 |
| PPROT | 无 | 无 | 有 | 保护属性,含特权与安全信息 |
| PWAKEUP | 无 | 无 | 后续版本补充 | 低功耗唤醒握手 |
这张表里最关键的判断点就是 PREADY。APB2 时代没有 PREADY,意味着任何一笔传输都是固定的两拍:SETUP 一拍、ACCESS 一拍,从机必须在这一拍里把数据准备好。这在早期工艺下还能接受,但当外设内部有跨时钟域、有 FIFO、有慢速模拟接口时,固定两拍就成了灾难——你被迫在从机里加缓冲、加握手,反而把简单问题复杂化了。
APB3 引入 PREADY 之后,从机才真正获得了"拉长 ACCESS 阶段"的能力,想等多久等多久,只要 PREADY 保持低电平就行。APB4 又补上了 PSTRB 和 PPROT,前者让字节写操作不用整字读改写,后者让安全和非安全访问能在总线层面区分开。所以你在选 IP 的时候,一定要先确认系统互联用的是什么版本,再决定从机怎么实现——给一个 APB4 互联写从机,你却按 APB2 固定两拍的方式处理,遇到需要等待的场景必挂。
1.3 吞吐率上限的量化推算
经常有人问我 APB 到底能跑多快,这个问题不能凭感觉答,得算。APB 没有流水线,一笔传输最少两拍——SETUP 一拍加 ACCESS 一拍,所以理论峰值吞吐率的公式是:
峰值吞吐率 = PCLK 频率 × 数据位宽 / 8 / 2
如果 PCLK 跑 50MHz、数据位宽 32 位,那就是 50M × 4 ÷ 2 = 100MB/s。把 PCLK 提到 100MHz,同样 32 位,峰值也就 200MB/s。这个数字放到今天看其实很低,AXI4 在同样频率下随便就能跑出几个 GB/s。
但这个算法只对零等待传输成立。一旦从机在 ACCESS 阶段插入一个等待周期,一笔传输变成三拍,吞吐率立刻掉到原来的三分之二;插入 N 个等待周期,吞吐率就是理论峰值除以 (N+2)。这也是为什么在 APB 外设里要尽量避免长等待——如果你需要频繁搬运大块数据,那说明这块外设本来就不该挂在 APB 上,应该换成带 burst 的 AHB 或 AXI4。
反过来说,APB 的这个"两拍下限"也给了设计者一个明确的约束:如果一块外设的寄存器访问频率很低(比如配置寄存器一辈子写一次),插入几个等待周期完全无所谓;但如果它是数据通路的一部分(比如 DMA 的配置寄存器、或者一条慢速 SPI 的收发寄存器),就得算清楚等待周期带来的带宽损失。我在做音频采样率转换器的时候就吃过这个亏,早期版本把数据寄存器放在 APB 上,每个采样点都要读一次带等待周期的寄存器,最后算下来带宽刚好卡在临界点,稍微加一点处理延迟就爆音了。
2. APB信号语义与三态时序的逐拍拆解
2.1 信号清单与每个信号的语义边界
理解 APB 的关键是理解每根线的"有效窗口"——大部分信号不是全程有效的,只在特定拍子里才有意义。这是我见过最多的翻车点:有人把 PWDATA 当成全程有效,结果在 SETUP 阶段就把它接进了组合逻辑;也有人以为 PRDATA 在 SETUP 阶段就该准备好,然后困惑为什么读出来是旧值。
PSELx 是片选,一旦拉高就代表"这笔画到我了",它会从 SETUP 一直保持到 ACCESS 结束。注意每根 PSEL 是独立的,一个互联里通常有一根 AHB-to-APB 桥挂多个从机,每个从机有自己的一根 PSEL。PENABLE 是访问阶段使能,它在 SETUP 阶段必然是低的,到 ACCESS 阶段才拉高。PADDR、PWRITE、PWDATA、PPROT 这几根属于"控制与数据",它们在 SETUP 阶段就必须已经有效,并且在 ACCESS 阶段保持稳定,直到传输完成。
PRDATA 是唯一的例外,它只在 ACCESS 阶段的最后一拍(也就是 PREADY 拉高那一拍)才被采样,所以从机只要在 PREADY 拉高的同时把数据放上去就行。PREADY 由从机驱动,低电平时表示"我还没准备好",高电平时表示"这笔完成了"。PSLVERR 也是从机驱动的,并且只在传输的最后一拍有效,表示这笔传输失败了。PSTRB 在写传输时选通哪些字节有效,四位对应 32 位数据的四个字节。
2.2 IDLE、SETUP、ACCESS 三态下的逐拍动作
APB 的状态机只有三个状态,简单到可以用一张时序表说清楚,但每一拍的动作必须记牢。
IDLE 阶段:PSEL 为低,PENABLE 为低,总线空闲。此时从机不应该对任何输入做出响应,PRDATA 输出建议保持默认值(通常是 0),PREADY 可以是任意值但推荐拉高。
SETUP 阶段:一笔传输的开始。PSEL 拉高,PENABLE 保持低,同时 PADDR、PWRITE、PWDATA、PPROT 全部有效。这个阶段固定只有一拍,无论从机是否有等待,都必须走完这一拍才能进 ACCESS。我在第一次写从机时曾经试图"跳过"SETUP 阶段,在 PSEL 拉高的同一拍就直接响应,结果桥那边采样不到正确的地址,整条总线挂死。
ACCESS 阶段:PENABLE 拉高,PSEL 继续保持高。如果从机不需要等待,PREADY 在这个阶段的第一拍就是高,那么这笔传输两拍结束。如果从机需要等待,PREADY 保持低,ACCESS 阶段就被拉长,同时 PSEL、PENABLE、PADDR、PWRITE、PWDATA 都必须保持不变。等到从机把 PREADY 拉高的那一拍,传输完成——这一拍也是采样 PRDATA(读操作)或真正执行写操作(写操作)的时间点。
这里有个容易被忽略的细节:PREADY 拉高的那一拍,PSEL 和 PENABLE 仍然是高的,紧接着的下一拍才会进入下一个传输的 SETUP,或者回到 IDLE。也就是说判据永远是"PSEL 高、PENABLE 高、PREADY 高"三者同时成立,缺一不可。我在做验证的时候见过从机把判据写成 "PENABLE && PREADY",漏掉了 PSEL,结果在总线空闲但 PENABLE 残留的情况下误触发了一次写。
2.3 PREADY 握手的两种等待模型
从机什么时候该插入等待周期,看起来是个自由选择,其实有很强的工程约束。我把实际项目里常见的做法归成两类。
第一类是零等待模型,PREADY 直接恒接高电平。适合那些寄存器纯粹是触发器、读写操作组合逻辑就能完成的从机,比如 GPIO、简单的定时器配置寄存器。这种从机面积最小,时序最好收敛,验证也最省事。我个人的习惯是:只要没有跨时钟域、没有 FIFO、没有需要多周期计算的寄存器,一律用零等待。
第二类是可变等待模型,PREADY 根据内部状态动态产生。典型场景有三种:一是寄存器读取需要经过慢速组合路径(比如读一个模拟模块的采样值),需要多打几拍;二是从机内部有跨时钟域逻辑,需要同步器稳定后再返回;三是访问 FIFO 或存储阵列,需要等读写指针更新。这时候 PREADY 的产生逻辑要特别注意——它必须是"组合可控"的,不能因为等待期间内部状态抖动而出现毛刺。
我在一个 SPI 从机项目里踩过一个典型的坑:从机的 PREADY 是用一个计数器产生的,计数器在 PREADY 拉高后又继续跑了一拍才复位,结果下一笔传输刚进 SETUP 阶段就发现 PREADY 还是低的,桥误以为从机又插入了一个等待周期,导致后续时序整体错位。后来把计数器改成"PREADY 拉高的同时清零"就解决了。这类问题的核心是:PREADY 的高低必须严格对应"这笔传输是否完成",任何残留状态都会污染下一笔。
2.4 PSLVERR 的正确定义与三个高频误用
PSLVERR 是 APB3 引入的错误响应信号,语义是"这笔传输失败了"。但它的用法有三个非常典型的误用,我觉得值得单独拉出来讲。
第一个误用是把 PSLVERR 打成寄存器输出。很多人写 RTL 时习惯把所有输出都寄存一拍,觉得这样时序好。但 PSLVERR 只在传输的最后一拍有效,如果你把它打了一拍,桥会在下一拍才看到错误信号,而此时 PSEL 和 PENABLE 可能已经撤了,错误就被丢掉。正确做法是让 PSLVERR 组合产生,判据是 PSEL && PENABLE && 地址未命中(或者任何错误条件)。
第二个误用是把 PSLVERR 当成重试请求。有些设计者希望从机报错后主机能自动重发,于是在从机里保持 PSLVERR 高电平等待重发。这是错的——APB 里 PSLVERR 只表示这笔传输失败,协议本身没有重试机制,重发是软件层面的事。从机唯一要做的是在错误发生的那一拍拉高 PSLVERR,然后立刻释放。
第三个误用是错误和等待不分。有些从机发现地址没译中,习惯性地把 PREADY 拉低进入等待,指望主机超时放弃。这会导致总线挂死,因为主机没有任何超时机制,它会永远等下去。正确做法是:地址没译中就立刻把 PREADY 拉高、同时把 PSLVERR 拉高,一拍结束这笔传输并报错。这是 APB 里非常关键的一条经验,我在做互联集成时见过不止一次因为这个问题导致整个系统上电后卡死。
注意:PSLVERR 和 PREADY 必须是在同一拍同时给出的组合信号,任何一方的延迟都会破坏协议的原子性。
3. 手写一个APB从机外设:从寄存器规划到RTL落地
3.1 寄存器映射与地址译码规划
在动手写 RTL 之前,先把寄存器映射表定清楚,这一步偷懒后面会加倍还回来。拿一个最简单的例子:一个带控制寄存器、分频寄存器和状态寄存器的外设,地址规划大概是这样。
| 偏移地址 | 名称 | 位宽 | 读写属性 | 说明 |
|---|---|---|---|---|
| 0x000 | CTRL | 32 | RW | 使能位与模式选择 |
| 0x004 | DIV | 32 | RW | 分频系数,低 16 位有效 |
| 0x008 | STS | 32 | RO | 状态与 FIFO 计数 |
地址规划有两个原则要遵守。第一,偏移量必须按数据位宽对齐。32 位数据宽度下,寄存器偏移应该是 4 的倍数,否则 PADDR 的低两位永远是 0,你在译码时会发现地址比较对不上。第二,地址译码要覆盖未命中情况。桥传过来的地址不一定每次都落在你这几个寄存器上,未命中的地址必须能被识别出来并报 PSLVERR,而不是被默默忽略。
在实际的 SoC 里,地址译码通常分两层:桥那一层做粗译码,根据 PADDR 高位选出某一根 PSEL;从机这一层做细译码,用 PADDR 低位区分具体寄存器。我一般会把从机的地址宽度参数化,比如定义一个 ADDR_W = 12,只用低 12 位参与译码,高位在桥里已经消化掉了,这样从机可以复用,换个基地址就能挂到别的位置。
3.2 APB从机的Verilog实现与逐行拆解
下面这份代码是我自己项目里一直在用的模板,按 APB4 写的,向下兼容 APB3(把 PSTRB 相关逻辑去掉即可),也能退化到 APB2(把 PREADY 恒接 1)。
module apb_regs #( parameter ADDR_W = 12, parameter DATA_W = 32 )( input wire PCLK, input wire PRESETn, input wire [ADDR_W-1:0] PADDR, input wire PSEL, input wire PENABLE, input wire PWRITE, input wire [DATA_W-1:0] PWDATA, input wire [DATA_W/8-1:0] PSTRB, input wire [2:0] PPROT, output reg [DATA_W-1:0] PRDATA, output wire PREADY, output wire PSLVERR, output reg [31:0] cfg_ctrl, output reg [31:0] cfg_div, input wire [31:0] sts_fifo ); localparam [ADDR_W-1:0] ADDR_CTRL = 12'h000; localparam [ADDR_W-1:0] ADDR_DIV = 12'h004; localparam [ADDR_W-1:0] ADDR_STS = 12'h008; wire addr_hit = (PADDR == ADDR_CTRL) || (PADDR == ADDR_DIV ) || (PADDR == ADDR_STS ); assign PREADY = 1'b1; assign PSLVERR = PSEL & PENABLE & ~addr_hit; wire wr_en = PSEL & PENABLE & PWRITE & PREADY & addr_hit; integer i; always @(posedge PCLK or negedge PRESETn) begin if (!PRESETn) begin cfg_ctrl <= 32'h0; cfg_div <= 32'h0; end else if (wr_en) begin if (PADDR == ADDR_CTRL) begin for (i = 0; i < DATA_W/8; i = i + 1) if (PSTRB[i]) cfg_ctrl[i*8 +: 8] <= PWDATA[i*8 +: 8]; end if (PADDR == ADDR_DIV) begin for (i = 0; i < DATA_W/8; i = i + 1) if (PSTRB[i]) cfg_div[i*8 +: 8] <= PWDATA[i*8 +: 8]; end end end always @(*) begin PRDATA = 32'h0; case (PADDR) ADDR_CTRL: PRDATA = cfg_ctrl; ADDR_DIV : PRDATA = cfg_div; ADDR_STS : PRDATA = sts_fifo; default : PRDATA = 32'h0; endcase end endmodule这份代码里有几个设计决定值得展开讲。PREADY 直接恒接 1,是因为这个外设的所有寄存器都是触发器,读写都不需要等待。PSLVERR 用组合逻辑产生,判据是"PSEL 高、PENABLE 高、地址没命中",正好对应传输的最后一拍,符合协议要求。
写使能 wr_en 的表达式里,我特意保留了 PREADY。虽然这里 PREADY 恒为 1,写不写这个条件结果一样,但如果后续要给某个寄存器插入等待周期,wr_en 里带上 PREADY 能保证写操作只在传输真正完成的那一拍执行。这是我从一次翻车里学到的:早期版本的 wr_en 只有 PSEL & PENABLE & PWRITE,后来给状态寄存器加了等待逻辑,忘记改 wr_en,结果写操作在 ACCESS 的第一拍就执行了,而那时从机其实还没准备好,写进去的值直接被后续逻辑覆盖。
读数据的 always 块用的是组合逻辑,而且第一句就是给 PRDATA 赋默认值 0。这条看似多余的语句非常关键——没有它,case 里未覆盖的分支会让综合工具推断出锁存器(latch),在数字设计里锁存器是能避则避的东西,会带来时序分析上的麻烦。default 分支也建议显式写出,即使只是赋 0,也能让综合和 SDK 报出"未覆盖地址"的问题。
PSTRB 的处理用了一个 for 循环逐字节更新。这里要注意,如果从机不支持字节写(很多老 IP 不支持),就直接把整字写进去,同时把 PSTRB 当无关信号忽略掉。但如果互联是按 APB4 传的,从机却忽略了 PSTRB,会有一个隐蔽的后果:主机做字节写时,你会把整个 32 位都覆盖掉,相邻字节被意外改写。这种 bug 在小数据量测试里往往测不出来,等软件跑到某个特定场景才炸,排查成本极高。
3.3 接入总线:AHB-to-APB桥与互联结构
从机写完了,怎么接进系统?绝大多数情况下你不会直接和主机打交道,中间隔着一座 AHB-to-APB 桥或者 AXI4-to-APB 桥。桥内部做的事情大致有这几件。
第一是地址译码与 PSEL 生成。桥接收主机侧的地址,按高几位判断落在哪个 APB 从机区间,然后把对应的 PSEL 拉高,同时把地址低位转成 PADDR 传下去。第二是时序转换。主机侧可能是流水线化的地址和数据通道,桥需要把它拍平成 APB 的 SETUP-ACCESS 两拍结构,通常桥内部会有一个小状态机来完成这个转换。第三是时钟域处理。APB 的 PCLK 未必等于主机时钟,桥里可能有一个分频器,把 HCLK 二分频或四分频后作为 PCLK,也可能需要真正的异步握手。
这里有一个非常实用的经验:在系统集成阶段,一定要确认 PCLK 的实际频率。很多桥默认做二分频,如果你按满频算外设的时序参数,上板就会发现实际访问速度只有预期的一半。我在调试一块音频 SoC 时,软件侧按 100MHz 配置了定时器的分频系数,实测周期却总是差两倍,查了半天才发现桥把 PCLK 分成了 50MHz,改配置表就好了。
还有一点关于多从机的互联。桥通常会输出一组 PSEL,每个从机接一根。规范要求同一时刻只有一根 PSEL 为高,如果你发现波形里有两根 PSEL 同时拉高,那基本可以断定桥的地址译码有重叠,或者你的从机地址范围配错了。这种情况下的典型现象是:主机读某个外设,两个从机同时驱动 PRDATA,发生总线冲突,读回来的数据时对时错。
4. APB验证要点:断言、覆盖率与调试手段
4.1 必须落地的几条SVA断言
验证 APB 从机,最省力的办法是把协议规则写成 SystemVerilog 断言,让仿真工具自己盯着。下面这几条是我每做一个 APB 从机都会加的,覆盖了协议里最容易被破坏的规则。
// 1. SETUP 之后必须进入 ACCESS 阶段 property p_setup2access; @(posedge PCLK) disable iff (!PRESETn) (PSEL && !PENABLE) |=> (PSEL && PENABLE); endproperty // 2. ACCESS 阶段在 PREADY 拉高之前必须保持 PSEL/PENABLE property p_access_hold; @(posedge PCLK) disable iff (!PRESETn) (PSEL && PENABLE && !PREADY) |=> (PSEL && PENABLE); endproperty // 3. 等待期间控制信号不得变化 property p_ctrl_stable; @(posedge PCLK) disable iff (!PRESETn) (PSEL && PENABLE && !PREADY) |=> $stable(PADDR) && $stable(PWRITE) && $stable(PWDATA) && $stable(PSTRB); endproperty // 4. PSLVERR 只在传输最后一拍有效 property p_slverr_last_cycle; @(posedge PCLK) disable iff (!PRESETn) PSLVERR |-> (PSEL && PENABLE && PREADY); endproperty // 5. 空闲状态下不应出现访问阶段 property p_idle_no_enable; @(posedge PCLK) disable iff (!PRESETn) !PSEL |-> !PENABLE; endproperty assert property (p_setup2access) else $error("SETUP did not enter ACCESS"); assert property (p_access_hold) else $error("ACCESS phase dropped early"); assert property (p_ctrl_stable) else $error("Control signals changed during wait"); assert property (p_slverr_last_cycle) else $error("PSLVERR asserted at wrong cycle"); assert property (p_idle_no_enable) else $error("PENABLE high while PSEL low");第五条断言我特别想强调。它的作用是保证 PENABLE 不会在 PSEL 为低的时候冒出来,这对应前面提到的那个 bug:如果从机的 PENABLE 是从桥里直接引出来的,而桥的复位或者中断逻辑有毛刺,就可能出现 PENABLE 单独拉高的情况。我见过一次因为这条规则没加,仿真跑了三天才在某个特定复位序列下复现出误写,加上断言之后不到十分钟就定位了。
断言的写法上有个技巧:disable iff (!PRESETn)一定要加,否则复位期间信号全乱,断言会大量误报,把真正的问题淹没掉。另外$stable比逐位比较更简洁,但要注意它只比较值,不比较 X 态,如果你的信号在等待期间变成 X,$stable可能判不出来,需要配合 X 检查断言一起用。
4.2 功能覆盖率怎么收才算干净
APB 从机的覆盖率收集,重点不是行覆盖率,而是传输场景的交叉覆盖。我的习惯是定义这么几个覆盖点。
传输类型上,要覆盖读、写两大类,并且各自再分零等待和带等待。带等待又细分单周期等待和多周期等待,因为我们前面算过,等待周期数直接影响吞吐率,也是从机逻辑最容易出问题的地方。地址维度上,每个寄存器都要被读到和写到,同时要有未命中地址的采样,用来验证 PSLVERR 路径。
还有两个容易被漏掉的覆盖点。一个是 PSTRB 的全部组合,如果从机支持字节写,至少要把 0x1、0x2、0x4、0x8 和 0xF 这几种采到。另一个是连续背靠背传输——前一笔刚结束,下一笔的 SETUP 紧接着就来,中间没有 IDLE 间隔。这种场景最容易暴露从机状态残留的问题,我前面提到的 PREADY 计数器不复位,就是靠背靠背传输才测出来的。
4.3 波形调试的几个抓手
真到了要对着波形找问题的时候,我的习惯是按固定顺序看四样东西,基本上能覆盖九成的问题。
先看 PSEL 和 PENABLE 的相位关系。正确的波形永远是 PSEL 先起,隔一拍 PENABLE 才起,且 PENABLE 起的时候 PSEL 还在。如果看到两根线同时起,或者 PENABLE 比 PSEL 早,那基本可以断定桥或者从机的状态机写错了。
再看 PREADY 的形态。如果它一直是高,但传输却比预期慢,那问题不在从机,在桥的分频或者主机的发起频率上。如果它一直低,那总线肯定是挂了,回头查从机的 PREADY 产生逻辑。
第三看 PRDATA 有效的时刻。它应该在 PREADY 拉高的那一拍有效,如果提前或者滞后,说明从机的读数据路径多打了或者少打了一拍。这个错位在零等待场景下尤其常见,因为很容易顺手写成时序逻辑。
最后看 PADDR 的对齐。PADDR 的最低位如果出现非零值(32 位数据宽度下应该是 2'b00),说明地址译码或者桥的地址转换有问题,这时候即使数据看起来对,也只是巧合。
5. 常见问题与排查技巧实录
5.1 一张速查表搞定八成故障
下面这张表是我这些年攒下来的,遇到问题先按现象查一遍,能省掉大量抓波形的时间。
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 读出来全是 0 | PRDATA 组合块缺默认赋值,或地址未命中 | 检查 case 是否覆盖、PRDATA 是否默认赋 0 |
| 读回来是 0xFFFFFFFF | 从机没有驱动 PRDATA,总线被上拉 | 确认 PRDATA 是 output 而非 inout,确认使能逻辑 |
| 写进去再读出来是旧值 | 写使能用了 PSEL 而不是 PSEL&PENABLE | 检查 wr_en 表达式是否含 PENABLE |
| 寄存器偶尔被写两次 | 同上的另一种表现,PREADY 等待期间重复写 | 在 wr_en 里加上 PREADY 条件 |
| 总线整体挂死无响应 | PREADY 一直为低,或地址未命中却没报错 | 查 PREADY 产生逻辑,确认未命中路径 |
| 综合报 latch 推断 | always @(*) 里没有默认赋值 | 所有组合输出先赋默认值 |
| 字节写把相邻字节改了 | 忽略 PSTRB 做了整字写 | 按 PSTRB 逐字节更新,或明确不支持字节写 |
| 访问速度比预期慢一半 | 桥对 PCLK 做了分频 | 确认 PCLK 实际频率,重新核算定时参数 |
| PSLVERR 抓不到 | 从机把 PSLVERR 打了拍 | 改为组合输出,且只在最后一拍有效 |
| 两根 PSEL 同时为高 | 桥的地址译码重叠,或从机地址范围配错 | 核对地址映射表,检查译码比较逻辑 |
5.2 三个文档里不会写的实操经验
第一个经验关于背靠背传输下的状态清理。很多从机的设计思路是"一笔传输结束就把所有中间状态复位",这在单笔传输时没问题,但在背靠背场景下,前一笔的清理逻辑可能和下一笔的 SETUP 阶段重叠,导致下一笔的地址还没稳定就被采样。我在一个外设里遇到过这个问题,最后解决方式是把清理逻辑做成"在 PREADY 拉高的同一拍完成",而不是"PREADY 拉高的下一拍"。这个时序细节在手册里是查不到的,只能靠对着波形数拍子。
第二个经验关于复位的处理。APB 的 PRESETn 是低有效异步复位,但很多从机在复位释放的那一刻直接开始响应 PSEL,结果复位期间桥可能还在发传输,导致第一次访问莫名其妙失败。稳妥的做法是:从机内部对复位做一个同步释放,并且在复位释放后的前几个周期忽略一切请求。这个做法在系统上电阶段尤其重要,因为那时候各个模块的时钟稳定时间不一致,桥可能已经能发请求了,从机还没准备好。
第三个经验关于地址译码的边界。我做集成时见过从机的译码写成PADDR[11:2] == 10'h0,然后用 PADDR 的其他位来做寄存器区分——这种写法在单个从机测试时完全没问题,但一旦挂到有多从机的互联上,高位地址是桥选片用的,从机不该再关心。正确做法是把高位在端口上就裁掉,比如从机只接 PADDR[11:0],高位由桥消化。这样从机的地址范围永远是 4KB 对齐的,也不会出现两个从机同时被判中的情况。
提示:给你的每一个 APB 从机都加上一条"未命中即报错"的组合路径,哪怕这个从机只有一两个寄存器。它的代价极小,却能在系统集成阶段帮你快速定位地址映射错误。
5.3 APB与AHB、AXI4的选型判断
最后说说选型。判断一块外设该挂哪条总线,我一般就看三个问题。
第一,有没有数据吞吐需求。如果外设需要持续搬运数据(比如 DMA、以太网 MAC、显示控制器),一律走 AXI4,AHB 作为次选。如果只是配置寄存器加上偶尔的一两次数据读写(UART、I2C、GPIO),APB 完全够用。第二,需不需要乱序和 outstanding。APB 是严格顺序的,一笔不完成下一笔不能开始;如果外设内部有多个独立的队列需要并行访问,APB 就会成为瓶颈。第三,面积和功耗预算紧不紧。APB 从机的面积可能是同等功能 AHB 从机的三分之一到五分之一,对于一颗芯片里几十上百个外设来说,这个差距相当可观。
我个人的实践是:把所有"人机配置类"外设全部放在 APB 上,把"数据搬运类"外设放在 AXI4 上,中间用 AHB 做桥接和轻量级互联。这个分法在大多数中低端 SoC 上都够用,也不会因为某条总线过于复杂而拖慢整个项目的收敛节奏。
APB 协议看起来简单,但它简单得很有讲究——每一个"省略"背后都是明确的工程取舍。把它吃透之后,你会发现剩下那些复杂协议(AHB 的流水线、AXI4 的乱序)也不过是在 APB 这个基础上逐层叠加机制而已。