🎯 第05课:程序计数器

📖 本课目标

设计并实现16位程序计数器(Program Counter, PC)及其控制逻辑。PC决定了CPU执行指令的顺序,是分支、跳转、调用和返回的基础。我们将实现PC的递增、条件分支、绝对跳转和子程序调用所需的PC管理。

🧠 程序计数器是什么?

程序计数器是一个16位寄存器,保存着下一条要执行的指令在内存中的地址。CPU每执行一条指令,PC就会更新——通常是递增到下一条指令,但分支和跳转指令会改变PC的值,从而改变执行流。

程序计数器更新逻辑 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ┌─────────────┐ │ PC[15:0] │ │ 当前值 │ └──────┬──────┘ │ ┌─────────────────┼─────────────────┐ │ │ │ ┌────┴────┐ ┌─────┴─────┐ ┌─────┴─────┐ │ PC + N │ │ PC+offset │ │ 新地址 │ │ 顺序递增│ │ 条件分支 │ │ 绝对跳转 │ └────┬────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ └────────┬────────┘────────┬────────┘ │ │ ┌─────┴─────┐ ┌─────┴─────┐ │ MUX选择 │────▶│ PC更新 │ │ pc_src │ │ 时钟沿 │ └───────────┘ └───────────┘ pc_src选择: 00 → PC + insn_len (顺序) 01 → PC + offset (条件分支,有符号偏移) 10 → 新地址 (JMP/CALL,来自指令操作数) 11 → 中断向量 (中断响应)

🔧 Verilog实现

// ========================================
// 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已经指向了下一条指令。

为什么复位地址是0x8000?

我们的内存映射中,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;
}

📝 练习

练习1:分支范围计算

如果当前PC = 0x8100,执行一条2字节的条件分支指令:

  1. 能跳转到最大前向地址是多少?
  2. 能跳转到最大后向地址是多少?
  3. 如果要跳转到0x8000,偏移量应该是多少?

练习2:PC保存与恢复

CALL指令需要保存返回地址到堆栈,RET指令从堆栈恢复PC。设计PC保存/恢复的接口。提示:需要额外的输出信号 pc_savepc_saved_val

练习3:流水线PC

在简单的3级流水线中,PC需要在取指阶段就计算下一个值。修改PC模块,使其在每个时钟周期自动递增,而不需要等指令执行完。

🏆 成就解锁

🎯 流程控制者

达成条件:

奖励:你控制了CPU的"命运"——PC决定了程序走向哪里,而你现在完全理解了它。

🔬 分支预测初探

虽然我们的8位CPU使用简单的条件分支,但现代CPU使用分支预测来提升性能。理解PC控制是理解分支预测的基础。

分支预测为什么重要?

在流水线CPU中,分支指令在执行前就需要知道下一条指令的地址。如果等分支条件判断完再取指,流水线就会"冒泡"(bubble),浪费时钟周期。分支预测器通过猜测分支走向来避免这种浪费。

常见的预测策略:

策略准确率硬件复杂度
总是不跳~60%最低
总是跳~65%最低
BTFN(反向跳/前向不跳)~85%
1位预测器~90%
2位饱和计数器~93%
全局历史预测器~97%
💡 我们的CPU不需要分支预测,因为它是多周期设计——每条指令执行完才开始下一条。但如果你以后设计流水线版本,分支预测将成为必须面对的挑战。

📐 循环的地址布局

理解PC和分支偏移最好的方式是看一个实际循环的地址布局:

循环示例:1到10求和 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 地址 指令 说明 $8000: LDI R1, #0 ; sum = 0 $8002: LDI R2, #1 ; i = 1 $8004: ADD R1, R2 ; sum += i $8006: INC R2 ; i++ $8007: LDI R3, #11 ; 比较11 $8009: CMP R2, R3 ; i == 11? $800B: JNZ $-7 ; 不等则跳回$8004 偏移 = $8004 - ($800B + 2) = -9 -9的补码 = 0xF7 $800D: STA R1, $100 ; 存储结果 $8010: HLT ; 结束 偏移计算: JNZ在$800B,2字节指令 下一条指令地址 = $800B + 2 = $800D 目标地址 = $8004 偏移 = $8004 - $800D = -9 (0xF7)

循环的分支偏移是负数(向后跳),这是最常见的情况。BTFN策略"反向跳"就是基于这个观察——循环通常向后跳转,且大多数迭代都会跳转。