引言
一个能跑的 CPU 内核只是 SoC 的一半。真正让芯片「有用」的是外设:串口打印调试信息、定时器产生周期中断、GPIO 控制引脚、DMA 搬运数据。这些外设通过总线挂到 CPU 上,通过内存映射寄存器被软件访问,通过中断线向 CPU 报告事件。
外设设计有两个容易被低估的难点。第一是总线协议:AXI4 有 5 个独立通道、复杂的握手规则和突发传输语义,写错一个信号就会导致偶发的死锁。第二是中断系统:多个外设同时请求中断时,谁先响应?中断服务程序执行期间来了更高优先级的中断怎么办?嵌套中断的上下文如何保存?这些问题在 PLIC(Platform-Level Interrupt Controller)里有一套标准答案。
本文按「总线 → 外设 → 中断 → DMA」的顺序展开。前两节讲地址空间与总线协议(这是所有外设的共同基础),中间四节讲具体外设与 PLIC,然后讲中断处理流程与上下文保存,最后给出 UART 的实现与调试方法。前置阅读是 RISC-V 指令集架构详解 中的 CSR 与 trap 机制。
目录
- 内存映射与地址译码
- AXI4 与 APB 总线协议
- UART 外设设计
- GPIO、SPI 与 I2C
- 定时器与 CLINT
- PLIC 架构与寄存器布局
- 中断优先级与阈值机制
- 中断处理流程与上下文保存
- CSR 与中断使能链
- DMA 控制器设计
- UART 外设的 Verilog 实现
- 调试与验证方法
1. 内存映射与地址译码
SoC 里所有外设都挂在统一地址空间上,CPU 用普通的加载/存储指令访问它们——这就是内存映射 I/O(MMIO)。
典型的 RISC-V SoC 地址映射:
0x0000_0000 BootROM(复位入口,含启动代码)
0x0200_0000 CLINT(定时器与软件中断)
0x0C00_0000 PLIC(中断控制器)
0x1000_0000 UART0 0x1000_1000 SPI0 0x1001_0000 GPIO
0x8000_0000 DDR 主存(到 0xFFFF_FFFF)
地址译码器根据地址高位选择目标从设备:
// 地址译码:高位选区域,低位选具体设备
always_comb begin
sel_uart = 1'b0; sel_gpio = 1'b0; sel_ddr = 1'b0;
case (addr[31:28])
4'h1: begin
if (addr[27:12] == 16'h0000) sel_uart = 1'b1;
else if (addr[27:12] == 16'h0001) sel_spi = 1'b1;
end
4'h8, 4'h9, 4'hA, 4'hB, 4'hC, 4'hD, 4'hE, 4'hF: sel_ddr = 1'b1;
default: ;
endcase
end
两个实践要点:
- 地址译码要检查完整地址,不能只看高位。否则一个外设会占用过大的地址范围,掩盖配置错误。
- 未映射地址要返回总线错误,而不是静默丢弃——这样软件访问错误地址时能立即发现。
2. AXI4 与 APB 总线协议
AXI4 是高性能主从总线,用于 CPU、DMA 与内存之间。它的特点是 5 个独立通道、支持突发传输、读写分离:
| 通道 | 方向 | 作用 |
|---|---|---|
| AW | 主→从 | 写地址 |
| W | 主→从 | 写数据 |
| B | 从→主 | 写响应 |
| AR | 主→从 | 读地址 |
| R | 从→主 | 读数据 + 响应 |
每个通道都用 valid/ready 握手:发送方拉高 valid,接收方拉高 ready,两者同时为高的时钟边沿完成传输。
// AXI 写地址通道握手:awvalid 与 awready 同时为高的边沿完成一次传输
always @(posedge clk or negedge rst_n)
if (!rst_n) awready <= 1'b0;
else awready <= !awready && can_accept && !(awvalid && awready);
三条握手纪律(违反会导致偶发死锁):
- valid 一旦拉高,在握手完成前不能撤销,数据也不能变。
- ready 可以依赖 valid,但 valid 不能依赖 ready(否则死锁)。
- 各通道独立握手,写地址和写数据可以不同时到达。
APB 是低带宽的简单总线,用于连接 UART、GPIO 这类慢速外设。它只有 3 个阶段(Setup、Access、Complete),不需要突发,实现极简。实际 SoC 常用 AXI → AXI-Lite → APB 的桥接结构:高速设备挂 AXI,慢速外设通过 APB 桥接。
3. UART 外设设计
UART 是最常用的调试外设,也是学习外设设计的理想起点。它的寄存器组:
| 偏移 | 名称 | 读/写 | 作用 |
|---|---|---|---|
| 0x00 | RBR/THR | R/W | 接收缓冲/发送保持寄存器 |
| 0x04 | IER | R/W | 中断使能 |
| 0x08 | IIR/FCR | R/W | 中断标识/FIFO 控制 |
| 0x0C | LCR | R/W | 线路控制(数据位、停止位、校验) |
| 0x10 | LSR | R | 线路状态(数据就绪、发送空) |
| 0x14 | DLL/DLM | R/W | 波特率分频低/高字节 |
发送逻辑的核心是「分频计数器 + 状态机」:把系统时钟分频到波特率,然后按时序移出起始位、数据位、停止位。接收逻辑要做中点采样——在每个位周期的中间时刻采样,容忍收发两端的波特率误差(通常 ±2%~3%)。
波特率计算:divisor = f_clk / (16 × baud)。例:50MHz / (16 × 115200) = 27.1,取 27,实际波特率 50e6/(16×27) = 115740,误差 0.47%。误差要求是累计不超过 10 位时间的 ±5%,即单端不超过 ±2.5%。
关键设计点:
- 过采样(通常 16×):用 16 倍时钟采样,检测起始位下降沿后延迟 8 个采样周期采第一个数据位的中点,之后每 16 个采样周期采一位。这比「每个位周期采一次」鲁棒得多。
- FIFO 缓冲:发送和接收各一个 FIFO,避免 CPU 每次都等一个字节的时间。典型深度 16 或 64。
- LSR 状态位:
DR(数据就绪)、THRE(发送保持寄存器空)、TEMT(发送器空)。轮询和中断模式都依赖这些位。
4. GPIO、SPI 与 I2C
GPIO 最简单:一组可配置方向(输入/输出)的引脚,通过寄存器读写。进阶特性包括中断(引脚电平变化触发)、上拉/下拉、开漏输出。
// GPIO 双向引脚:三态控制
assign gpio_pin = (dir == 1'b1) ? out_val : 1'bz; // 1=输出
assign in_val = gpio_pin;
SPI 是四线同步串行总线(SCLK、MOSI、MISO、CS),全双工、速率高(几十 MHz)。它的配置维度有四个:时钟极性(CPOL)、时钟相位(CPHA)、位序(MSB/LSB 优先)、片选数量。
SPI 有四种模式(CPOL 空闲电平 × CPHA 采样边沿):Mode 0/2 在空闲低/高时于第一个边沿采样,Mode 1/3 在空闲低/高时于第二个边沿采样。配置错模式会导致采样到错位的数据。
I2C 是两线总线(SCL、SDA),支持多主多从,靠设备地址寻址。它的特殊之处是开漏输出 + 上拉电阻,因此需要处理时钟同步(多主时的仲裁)和时钟拉伸(从设备拉低 SCL 要求主机等待)。
5. 定时器与 CLINT
RISC-V 的定时器与软件中断由 CLINT(Core-Local Interruptor) 管理,每个 hart 一组寄存器:
| 偏移 | 寄存器 | 作用 |
|---|---|---|
| 0x0000 | msip | 软件中断挂起(写 1 触发) |
| 0x4000 | mtimecmp | 定时器比较值 |
| 0xBFF8 | mtime | 全局定时器计数(只读) |
工作方式:mtime 是一个自由运行的 64 位计数器(通常以固定频率递增,如 10MHz),当 mtime >= mtimecmp 时,定时器中断被触发。软件通过写 mtimecmp = mtime + 周期 来产生周期性中断。
// 设置周期性定时器中断(mtime 频率 10MHz)
void timer_set_periodic(uint64_t period_us) {
uint64_t now = *(volatile uint64_t *)CLINT_MTIME;
*(volatile uint64_t *)CLINT_MTIMECMP = now + period_us * 10;
}
void timer_isr(void) { timer_set_periodic(1000); tick_count++; } // 1ms 后再中断
64 位访问的原子性问题:mtime 是 64 位,RV32 上一次只能读 32 位,两次读之间可能发生进位导致数据不一致。标准解法是「读高 → 读低 → 再读高,若高字变化则重试」。
6. PLIC 架构与寄存器布局
PLIC(Platform-Level Interrupt Controller) 是 RISC-V 标准的外部中断控制器,负责汇聚所有外设的中断线,仲裁后送给各个 hart。
外设中断源(最多 1024 个)
↓
[ 网关 Gateway ] ← 每个源一个:把电平/边沿转成「挂起」状态
↓
[ 仲裁器 Arbiter ] ← 按优先级选出最高的挂起中断
↓
[ 目标 hart 的 M/S 上下文 ]
PLIC 的寄存器空间分三段:
| 区域 | 偏移 | 作用 |
|---|---|---|
| 优先级 | 0x000000 + 4×id | 每个中断源一个 32 位优先级(0 = 禁用) |
| 挂起 | 0x001000 + 4×word | 只读,查看哪些中断挂起 |
| 使能 | 0x002000 + 0x80×ctx + 4×word | 每个上下文一份使能位图 |
| 阈值/认领 | 0x200000 + 0x1000×ctx | 阈值寄存器 + 认领/完成寄存器 |
// PLIC 使用流程
#define PLIC_PRIO(id) (PLIC_BASE + 4 * (id))
#define PLIC_ENABLE(ctx) (PLIC_BASE + 0x2000 + 0x80 * (ctx))
#define PLIC_THRESH(ctx) (PLIC_BASE + 0x200000 + 0x1000 * (ctx))
#define PLIC_CLAIM(ctx) (PLIC_BASE + 0x200004 + 0x1000 * (ctx))
void plic_init(int ctx, int irq_id) {
*(uint32_t *)PLIC_PRIO(irq_id) = 1; // 优先级 1(非零即启用)
*(uint32_t *)PLIC_ENABLE(ctx) |= (1 << irq_id); // 使能该中断
*(uint32_t *)PLIC_THRESH(ctx) = 0; // 阈值 0:接受所有优先级
}
// 中断处理:认领 → 处理 → 写回完成
void external_isr(void) {
uint32_t id = *(uint32_t *)PLIC_CLAIM(0);
if (id == UART0_IRQ) uart_handle();
*(uint32_t *)PLIC_CLAIM(0) = id;
}
认领(claim)机制是 PLIC 的关键设计:读取 claim 寄存器会返回当前最高优先级的挂起中断 ID,并自动清除其挂起位;处理完成后写回该 ID 表示完成,网关才会允许该源再次触发。
7. 中断优先级与阈值机制
PLIC 的仲裁规则:
- 只有优先级 > 阈值的挂起中断才能被转发。阈值是一个「最低可接受优先级」的门槛。
- 优先级相同时按 ID 小的优先。
- 同一中断源在 claim 后、complete 前不会再触发(防止中断风暴)。
仲裁示例(阈值 = 2):源 3 优先级 5 → 转发(5 > 2);源 7 优先级 1 → 不转发(1 < 2);源 2 优先级 5 → 转发,且因 ID 更小优先于源 3。
阈值机制的实际用途是实现中断屏蔽的层级:在临界区里临时提高阈值,就能屏蔽低优先级中断而不影响高优先级中断。这比全局关中断(mstatus.MIE = 0)更精细。
嵌套中断的实现在 RISC-V 上需要软件配合:默认情况下,进入 trap 后 mstatus.MIE 被清零,所有中断被屏蔽。要支持嵌套,需要在 ISR 里重新打开 MIE,并保存足够的上下文。多数裸机系统不做嵌套,以换取简单性。
8. 中断处理流程与上下文保存
一次完整的中断处理:
# trap 入口(mtvec 指向这里)
trap_entry:
# 1. 保存上下文:只保存会被 ISR 破坏的调用者保存寄存器
addi sp, sp, -64
sw ra, 0(sp)
sw t0, 4(sp)
sw t1, 8(sp)
sw a0, 16(sp)
sw a1, 20(sp)
sw a2, 24(sp)
sw t2, 12(sp)
sw t3, 40(sp)
sw t4, 44(sp)
sw t5, 48(sp)
sw t6, 52(sp)
# 2. 读 mcause 判断 trap 类型:最高位为 1 表示中断
csrr t0, mcause
blt t0, zero, irq_handler
j trap_return # 异常处理(非法指令、页错误等)
irq_handler:
# 3. 按 mcause 低位分发:11 = 外部中断(PLIC),7 = 定时器
andi t0, t0, 0x3ff
li t1, 11
beq t0, t1, external_isr
li t1, 7
beq t0, t1, timer_isr
j trap_return
trap_return:
# 4. 恢复上下文后 mret:跳回 mepc,恢复 mstatus.MPP
lw ra, 0(sp)
lw t0, 4(sp)
# ... 恢复其余寄存器 ...
addi sp, sp, 64
mret
两个关键点:
- 只保存被破坏的寄存器:RISC-V 的 ABI 规定了哪些寄存器是「调用者保存」,ISR 作为被调用者只需保存自己用到的。全保存(32 个寄存器)简单但慢。
mret的语义:跳到mepc,把mstatus.MPP恢复到特权级,并按需恢复mstatus.MIE。这是唯一的中断返回指令。
9. CSR 与中断使能链
中断要真正被响应,必须整条链都使能:
外设内部中断使能 → PLIC 使能位 → PLIC 优先级 > 阈值
→ CSR mie.MEIE → CSR mstatus.MIE → 触发 trap(mcause = 0x8000000B)
任何一环没开,中断都不会到达。这是调试「中断不响应」问题的排查清单——从外设往 CPU 方向逐级检查。
// 开启外部中断的完整配置
void enable_external_irq(void) {
plic_init(0, UART0_IRQ); // PLIC 侧
*(volatile uint32_t *)(UART0_BASE + IER) |= 0x01; // 外设侧
uint32_t mie; asm volatile ("csrr %0, mie" : "=r"(mie));
mie |= (1 << 11); // MEIE
asm volatile ("csrw mie, %0" :: "r"(mie));
asm volatile ("csrsi mstatus, 8"); // MIE = 1
}
10. DMA 控制器设计
DMA 让外设直接与内存交换数据,无需 CPU 逐字节搬运。一个简单的 DMA 控制器有这些寄存器:
DMA 控制器的寄存器组通常包含:SRC(源地址)、DST(目标地址)、LEN(传输长度)、CTRL(启动/方向/中断使能)、STATUS(忙/完成/错误标志)。实现核心是一个状态机 + 地址计数器,通过 AXI 主接口发起突发读写:
// DMA 传输状态机(简化)
localparam S_IDLE=2'd0, S_READ=2'd1, S_WRITE=2'd2, S_DONE=2'd3;
always @(posedge clk) begin
case (state)
S_IDLE: if (start) begin addr <= src; remain <= len; state <= S_READ; end
S_READ: if (rd_valid) begin buf <= rd_data; state <= S_WRITE; end
S_WRITE: if (wr_done) begin
addr <= addr + 4; remain <= remain - 4;
state <= (remain <= 4) ? S_DONE : S_READ;
end
S_DONE: begin done <= 1'b1; state <= S_IDLE; end
endcase
end
关键设计考量:
- 突发长度:AXI 支持最大 256 拍的突发,用突发能显著提升带宽(减少地址握手开销)。
- 缓存一致性:DMA 绕过 CPU 缓存,需要软件显式 flush/invalidate,否则 CPU 可能读到旧数据。这是 DMA 使用中最常见的 bug。
- 描述符链:高级 DMA 支持「描述符链表」,一次配置就能完成多个不连续的传输。
11. UART 外设的 Verilog 实现
发送模块的核心(接收模块是镜像,但要加中点采样):
module uart_tx #(parameter DIV = 27) ( // DIV = f_clk/(16*baud)
input wire clk, rst_n,
input wire tx_start,
input wire [7:0] tx_data,
output reg tx,
output reg tx_busy
);
localparam S_IDLE=2'd0, S_START=2'd1, S_DATA=2'd2, S_STOP=2'd3;
reg [1:0] state; reg [3:0] tick_cnt; reg [2:0] bit_idx; reg [7:0] shreg;
wire tick = (tick_cnt == 4'd15); // 16 倍过采样
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= S_IDLE; tx <= 1'b1; tx_busy <= 1'b0; tick_cnt <= 0;
end else if (state == S_IDLE) begin
tx <= 1'b1; tx_busy <= 1'b0;
if (tx_start) begin
shreg <= tx_data; tx_busy <= 1'b1;
tick_cnt <= 0; state <= S_START;
end
end else if (tick) begin
tick_cnt <= 0;
case (state)
S_START: begin tx <= 1'b0; state <= S_DATA; end
S_DATA: begin
tx <= shreg[0]; shreg <= {1'b0, shreg[7:1]};
if (bit_idx == 3'd7) state <= S_STOP;
else bit_idx <= bit_idx + 1'b1;
end
S_STOP: begin tx <= 1'b1; state <= S_IDLE; end
default: state <= S_IDLE;
endcase
end else tick_cnt <= tick_cnt + 1'b1;
end
endmodule
接收模块的要点是起始位检测 + 中点采样:空闲时监视 rx,检测到下降沿后等待 8 个采样周期(半个位周期)确认起始位有效(排除毛刺),之后每 16 个采样周期采一位,采完 8 位后检查停止位。
12. 调试与验证方法
外设调试的常见问题与定位方法:
| 现象 | 可能原因 | 定位方法 |
|---|---|---|
| 串口无输出 | 波特率不对、引脚约束错、TX/RX 接反 | 逻辑分析仪测实际波特率 |
| 输出乱码 | 波特率误差大、数据位/停止位配置不符 | 核对 LCR 配置与分频值 |
| 中断不触发 | 使能链某一环没开 | 逐级检查(外设→PLIC→mie→mstatus) |
| 中断只触发一次 | 忘记 claim/complete,或未清外设中断标志 | 检查 ISR 是否写回 claim |
| DMA 数据错误 | 缓存未 flush、地址未对齐 | 检查 cache 操作与地址 |
| AXI 偶发死锁 | 握手协议违反 | 用 AXI protocol checker |
验证手段:
- 协议检查器:Vivado 的 AXI Protocol Checker IP 能自动检测握手违规,是 AXI 调试的必备工具。
- 总线监视器:把 AXI 事务打印成日志,便于对照软件行为。
- 软件自测:写一个「回环测试」——UART 发送的数据通过外部跳线回到接收端,比对内容。
- 形式验证:对 FIFO、握手协议这类模块做形式验证,能发现仿真难以覆盖的边界情况。
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| 总线 | AXI4:高带宽、复杂 | APB:简单、带宽低 |
| 中断汇聚 | PLIC:标准、可扩展 | 自定义控制器:简单、不通用 |
| UART 采样 | 16× 过采样:鲁棒、耗资源 | 单次采样:省资源、易误码 |
| 中断处理 | 全保存上下文:安全、慢 | 只保存用到的:快、易错 |
| 嵌套中断 | 支持:响应快、复杂 | 不支持:简单、高优先级要等 |
| DMA | 用:解放 CPU、缓存一致性复杂 | 不用:简单、CPU 占用高 |
常见坑清单
- AXI valid 撤销:握手未完成就撤销 valid 或改数据,导致从设备状态错乱,必须保持到握手完成。
- valid 依赖 ready:形成组合环路导致死锁,valid 的产生逻辑绝不能看 ready。
- 地址译码不完整:只看地址高位,外设占用过大空间,掩盖映射错误。
- 未映射地址静默丢弃:软件访问错误地址无任何反馈,应返回总线错误。
- PLIC 忘记 claim/complete:中断只触发一次,或同一源反复触发造成中断风暴。
- 中断使能链漏开一环:外设、PLIC、mie、mstatus 任缺一个中断都不响应,需逐级排查。
- 64 位 mtime 读取不一致:RV32 上两次读之间发生进位,需「读高-读低-重读高」重试。
- DMA 与缓存不一致:DMA 写内存后 CPU 读到缓存里的旧数据,必须 flush/invalidate。
- UART 波特率误差过大:累计误差超过半个位周期就误码,分频值要算准;未做起始位中点确认则毛刺会被当作起始位。
- GPIO 双向引脚缺少三态控制:输入模式下仍驱动引脚,与外部信号冲突甚至损坏器件。
小结
RISC-V SoC 的外设与中断体系可以概括为三层:总线层负责地址译码与协议握手(AXI/APB),设备层负责具体功能(UART/GPIO/定时器/DMA),中断层负责事件汇聚与优先级仲裁(PLIC)。三层之间的接口是内存映射寄存器和中断线,边界清晰。
调试外设问题的通用方法是从两端向中间排查:软件侧确认配置寄存器写对了,硬件侧用逻辑分析仪看信号,中间用协议检查器抓总线违规。中断问题的排查尤其适合「使能链逐级检查」这个清单化方法。
下一步建议阅读 RISC-V 工具链与裸机开发 ,学习如何为这些外设编写驱动、配置链接脚本、用 OpenOCD 调试;或者进入 芯片设计流程与 EDA 工具链 ,看这些模块如何一步步走向流片。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。