设计并实现16位程序计数器(Program Counter, PC)及其控制逻辑。PC决定了CPU执行指令的顺序,是分支、跳转、调用和返回的基础。我们将实现PC的递增、条件分支、绝对跳转和子程序调用所需的PC管理。
程序计数器是一个16位寄存器,保存着下一条要执行的指令在内存中的地址。CPU每执行一条指令,PC就会更新——通常是递增到下一条指令,但分支和跳转指令会改变PC的值,从而改变执行流。
// ========================================
// pc_ctrl.v - 程序计数器控制
// Retro8 复古电脑 CPU 核心模块
// ✅Verilator验证通过
// ========================================
module pc_ctrl (
input wire clk,
input wire rst_n,
input wire [15:0] pc_next, // 来自外部的新PC值(跳转/中断)
input wire [7:0] offset, // 条件分支偏移(有符号)
input wire [1:0] pc_src, // PC更新源选择
input wire [1:0] insn_len, // 当前指令长度
input wire branch_take, // 分支条件满足
input wire pc_enable, // PC更新使能
output reg [15:0] pc, // 当前PC值
output wire [15:0] pc_plus, // PC + insn_len
output wire [15:0] pc_branch // PC + offset
);
// 顺序递增:PC + 指令长度
assign pc_plus = pc + {{14'd0, insn_len}};
// 条件分支:PC + 有符号偏移
// 将8位有符号偏移符号扩展到16位
wire [15:0] sign_ext_offset = {{8{offset[7]}}, offset};
assign pc_branch = pc_plus + sign_ext_offset;
// PC更新逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pc <= 16'h8000; // 复位后从ROM起始地址开始
end
else if (pc_enable) begin
case (pc_src)
2'b00: pc <= pc_plus; // 顺序执行
2'b01: pc <= branch_take ? pc_branch : pc_plus; // 条件分支
2'b10: pc <= pc_next; // 绝对跳转
2'b11: pc <= pc_next; // 中断向量
default: pc <= pc_plus;
endcase
end
end
endmodule
条件分支使用8位有符号偏移,范围是-128到+127。我们通过符号扩展将8位偏移转为16位:
// 符号扩展:将8位有符号数扩展到16位
wire [15:0] sign_ext_offset = {{8{offset[7]}}, offset};
// 例如:offset=0xFE (-2) → 0xFFFE
// offset=0x05 (+5) → 0x0005
注意偏移是相对于 PC + 指令长度 计算的,不是相对于当前PC。这是因为分支指令执行时,PC已经指向了下一条指令。
我们的内存映射中,ROM从0x8000开始。CPU复位后应该从ROM中取指执行,所以PC初始值设为0x8000。这与真实硬件一致——6502的复位向量在$FFFC/$FFFD,但我们简化为直接从ROM起始地址开始。
// tb_pc_ctrl.cpp - PC控制测试台
#include "Vpc_ctrl.h"
#include "verilated.h"
#include <cstdio>
int main(int argc, char** argv) {
Verilated::commandArgs(argc, argv);
Vpc_ctrl* pc = new Vpc_ctrl;
int errors = 0;
// 复位
pc->rst_n = 0; pc->clk = 0; pc->pc_enable = 0;
for (int i = 0; i < 10; i++) { pc->clk = !pc->clk; pc->eval(); }
pc->rst_n = 1;
if (pc->pc != 0x8000) { printf("FAIL: PC after reset = %04X
", pc->pc); errors++; }
else printf("PASS: PC = 0x8000 after reset
");
// 顺序递增:2字节指令
pc->pc_src = 0; pc->insn_len = 2; pc->pc_enable = 1;
pc->clk = 1; pc->eval(); pc->clk = 0; pc->eval();
if (pc->pc != 0x8002) { printf("FAIL: PC+2 = %04X
", pc->pc); errors++; }
else printf("PASS: PC = 0x8002 after +2
");
// 条件分支(taken):offset = -4 (0xFC)
pc->pc_src = 1; pc->offset = 0xFC; pc->branch_take = 1;
pc->clk = 1; pc->eval(); pc->clk = 0; pc->eval();
// PC = 0x8002 + 2 + (-4) = 0x8000
if (pc->pc != 0x8000) { printf("FAIL: branch = %04X
", pc->pc); errors++; }
else printf("PASS: branch to 0x8000
");
// 绝对跳转
pc->pc_src = 2; pc->pc_next = 0xABCD;
pc->clk = 1; pc->eval(); pc->clk = 0; pc->eval();
if (pc->pc != 0xABCD) { printf("FAIL: jump = %04X
", pc->pc); errors++; }
else printf("PASS: jump to 0xABCD
");
printf("
Errors: %d
", errors);
if (errors == 0) printf("ALL TESTS PASSED! ✅
");
pc->final(); delete pc;
return errors ? 1 : 0;
}
如果当前PC = 0x8100,执行一条2字节的条件分支指令:
CALL指令需要保存返回地址到堆栈,RET指令从堆栈恢复PC。设计PC保存/恢复的接口。提示:需要额外的输出信号 pc_save 和 pc_saved_val。
在简单的3级流水线中,PC需要在取指阶段就计算下一个值。修改PC模块,使其在每个时钟周期自动递增,而不需要等指令执行完。
达成条件:
奖励:你控制了CPU的"命运"——PC决定了程序走向哪里,而你现在完全理解了它。
虽然我们的8位CPU使用简单的条件分支,但现代CPU使用分支预测来提升性能。理解PC控制是理解分支预测的基础。
在流水线CPU中,分支指令在执行前就需要知道下一条指令的地址。如果等分支条件判断完再取指,流水线就会"冒泡"(bubble),浪费时钟周期。分支预测器通过猜测分支走向来避免这种浪费。
常见的预测策略:
| 策略 | 准确率 | 硬件复杂度 |
|---|---|---|
| 总是不跳 | ~60% | 最低 |
| 总是跳 | ~65% | 最低 |
| BTFN(反向跳/前向不跳) | ~85% | 低 |
| 1位预测器 | ~90% | 低 |
| 2位饱和计数器 | ~93% | 中 |
| 全局历史预测器 | ~97% | 高 |
理解PC和分支偏移最好的方式是看一个实际循环的地址布局:
循环的分支偏移是负数(向后跳),这是最常见的情况。BTFN策略"反向跳"就是基于这个观察——循环通常向后跳转,且大多数迭代都会跳转。