📚 第12课:堆栈操作

📖 本课目标

深入实现堆栈操作指令:PUSH、POP、PHF、PLF,以及堆栈帧的管理。堆栈是函数调用、局部变量和中断处理的基础——没有堆栈,就没有结构化编程。

🧠 堆栈是什么?

堆栈(Stack)是一种后进先出(LIFO)的数据结构。在我们的8位CPU中,堆栈位于RAM的高地址区域,从$7FFF向下增长。R7作为堆栈指针(SP),始终指向栈顶。

堆栈内存布局(向下增长) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ $7FFF ┌─────────────┐ ← 栈底(初始SP+1) │ │ $7FFE │ 第1个PUSH │ ← SP初始位置 $7FFD │ 第2个PUSH │ $7FFC │ 第3个PUSH │ ← 当前SP $7FFB │ (空闲) │ │ │ │ ... │ │ │ $7800 └─────────────┘ ← VGA缓冲区开始 PUSH操作: SP--, [SP] ← data POP操作: data ← [SP], SP++ 注意:我们的堆栈向低地址增长(descending stack) SP指向最后一个被压入的数据(full stack)

🔧 Verilog实现——堆栈控制器

// ========================================
// stack_ctrl.v - 堆栈控制器
// Retro8 复古电脑 CPU 核心模块
// ✅Verilator验证通过
// ========================================

module stack_ctrl (
    input  wire          clk,
    input  wire          rst_n,
    input  wire          push,       // 压栈请求
    input  wire          pop,        // 出栈请求
    input  wire [7:0]   push_data,  // 压栈数据
    input  wire [7:0]   mem_data,   // 从内存读回的数据
    output wire [7:0]   pop_data,   // 出栈数据
    output reg  [15:0]  sp,         // 堆栈指针
    output wire [15:0]  addr_out,   // 堆栈访问地址
    output wire          stack_full, // 堆栈满标志
    output wire          stack_empty // 堆栈空标志
);

    // 堆栈边界
    localparam [15:0] SP_INIT = 16'h7FFF;  // 初始SP(栈底+1)
    localparam [15:0] SP_MIN  = 16'h7800;  // 堆栈最低地址

    // 初始化和更新SP
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            sp <= SP_INIT;
        else if (push)
            sp <= sp - 16'd1;
        else if (pop)
            sp <= sp + 16'd1;
    end

    // 堆栈访问地址
    assign addr_out = push ? (sp - 16'd1) : sp;

    // 出栈数据
    assign pop_data = mem_data;

    // 堆栈满/空检测
    assign stack_full  = (sp <= SP_MIN);
    assign stack_empty = (sp >= SP_INIT);

endmodule

🔍 堆栈帧与局部变量

函数调用的堆栈帧

每次函数调用,堆栈上会创建一个"帧"(frame),包含返回地址、参数和局部变量:

函数调用堆栈帧布局 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 高地址 ┌──────────────┐ │ 参数2 │ 调用者压栈 │ 参数1 │ 调用者压栈 │ 返回地址高位 │ CALL自动压栈 │ 返回地址低位 │ CALL自动压栈 ├──────────────┤ ← 当前SP │ 局部变量1 │ 被调用者PUSH │ 局部变量2 │ 被调用者PUSH │ 保存的R4 │ 被调用者PUSH │ 保存的R5 │ 被调用者PUSH ├──────────────┤ ← 函数执行时的SP │ │ 低地址 访问帧中的数据: 参数: [SP+偏移] (通过变址寻址) 局部变量: [SP+小偏移]

完整的函数调用约定

// 调用 add(a, b) → result
// a在R1, b在R2

// 调用者 (main)
PUSH R2          // 压入参数b
PUSH R1          // 压入参数a
CALL add_func    // 调用函数
// 返回后,R1 = 结果
// 清理参数
POP R3           // 弹出参数a(丢弃)
POP R3           // 弹出参数b(丢弃)

// 被调用者 (add_func)
add_func:
    // 读取参数:[SP+2]=a, [SP+3]=b
    // 返回地址占2字节,参数在返回地址之上
    LDA R1, [SP+2]  // a
    LDA R2, [SP+3]  // b
    ADD R1, R2      // R1 = a + b
    RET             // 返回,结果在R1

📝 练习

练习1:递归阶乘

用递归实现阶乘函数 factorial(n):如果n<=1返回1,否则返回n*factorial(n-1)。写出完整的汇编代码,包括参数传递和堆栈帧管理。

练习2:堆栈溢出检测

修改stack_ctrl模块,当堆栈满时设置一个标志位而不是静默覆盖数据。设计CPU如何响应堆栈溢出。

练习3:可重入函数

什么是可重入函数?为什么在中断处理中必须使用可重入函数?用汇编举例说明不可重入函数的问题和修复方法。

🏆 成就解锁

📚 堆栈守护者

达成条件:

奖励:你掌握了结构化编程的基础!函数、递归、局部变量——所有高级语言特性的底层实现你都理解了。指令集阶段完成!🏆

🔬 中断与堆栈

中断处理是堆栈最关键的应用之一。当CPU响应中断时,必须保存当前状态,处理完后再恢复:

中断响应流程

中断响应时的堆栈变化 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 正常执行: 中断响应: PC = $8005 中断信号到来 FLAGS = $04 (I=0,开中断) ↓ 1. 自动关中断(I=1) 2. PUSH PC低位 3. PUSH PC高位 4. PUSH FLAGS 5. PC ← 中断向量 堆栈: 堆栈: $7D: ?? $7D: $08 (PC高) $7E: ?? $7E: $05 (PC低) $7F: ?? $7F: $04 (FLAGS) 中断处理完成后: RTI指令: 1. POP → FLAGS (恢复标志) 2. POP → PC高位 3. POP → PC低位 4. 恢复I位(开中断)

中断安全的子程序

中断可能随时打断程序执行。如果子程序使用了共享资源(如寄存器),必须确保中断处理不会破坏这些资源:

// 中断安全的函数
safe_func:
    // 保存所有将被修改的寄存器
    PUSH R4         // 保存callee-save寄存器
    PUSH R5
    // 现在可以安全地使用R4、R5
    // 即使中断来了,中断处理会保存/恢复自己的寄存器
    
    // ... 函数体 ...
    
    // 恢复寄存器(注意顺序:后进先出)
    POP R5
    POP R4
    RET

📐 堆栈与内存管理

alloca——运行时栈分配

有时函数需要动态大小的局部变量空间。可以通过调整SP来实现:

// 分配N字节的栈空间(N在R1)
// 方法:SP -= N
MOV R7, R1        // 保存N到临时位置
// R7(SP)不能直接做减法,需要间接操作
// 实际实现:用循环PUSH 0
LDI R2, #0
alloc_loop:
    PUSH R2          // 压入0,扩展栈空间
    DEC R1
    JNZ alloc_loop
// 现在[SP+0]到[SP+N-1]是可用的局部空间

// 释放:用循环POP
// 或者直接恢复之前保存的SP值

📊 堆栈使用模式汇总

模式指令典型用途
保存/恢复寄存器PUSH/POP函数入口/出口
参数传递PUSH(调用者)函数调用
局部变量调整SP函数内部
返回地址CALL/RET子程序调用
中断上下文PHF/PLF+PUSH/POP中断处理
表达式求值PUSH/POP编译器生成
递归每层递归一个帧递归算法

📐 多种调用约定对比

调用者清理 vs 被调用者清理

函数调用后谁来清理栈上的参数?两种方案各有优劣:

方案A: 调用者清理(C语言风格)

// 调用 add(10, 20)
LDI  R1, #20
PUSH R1          // 参数2
LDI  R1, #10
PUSH R1          // 参数1
CALL add_func
// 调用者清理参数
POP  R3          // 弹出参数1
POP  R3          // 弹出参数2
// 优点: 支持可变参数(如printf)

方案B: 被调用者清理(Pascal风格)

// 调用 add(10, 20)
LDI  R1, #20
PUSH R1
LDI  R1, #10
PUSH R1
CALL add_func
// 不需要清理!被调用者已经清理了
// 优点: 代码更短,调用处更干净
// 缺点: 不支持可变参数

寄存器传参 vs 栈传参

方式优点缺点
寄存器传参最快,无需内存访问参数数量受限(只有8个寄存器)
栈传参参数数量无限制慢,每次需要内存访问
混合方式前几个用寄存器,多余的用栈编译器复杂度高

Retro8采用混合方式:R1-R3传递前3个参数,多余的用栈。返回值通过R1传递。

🔬 堆栈溢出防护

Canary(金丝雀)保护

防止堆栈溢出攻击的一种方法是Canary值——在返回地址前放一个随机值,函数返回前检查它是否被修改:

// 函数入口
safe_func:
    PUSH R4         // 保存寄存器
    LDA  R4, canary // 读取Canary值(随机)
    PUSH R4         // 将Canary压栈
    
    // ... 函数体 ...
    
    // 函数出口:检查Canary
    POP  R4         // 弹出Canary
    LDA  R5, canary // 重新读取
    CMP  R4, R5     // 比较是否一致
    JNZ  stack_corrupted  // 不一致!堆栈被破坏
    POP  R4         // 恢复寄存器
    RET

stack_corrupted:
    HLT             // 停机(安全失败)