深入实现堆栈操作指令:PUSH、POP、PHF、PLF,以及堆栈帧的管理。堆栈是函数调用、局部变量和中断处理的基础——没有堆栈,就没有结构化编程。
堆栈(Stack)是一种后进先出(LIFO)的数据结构。在我们的8位CPU中,堆栈位于RAM的高地址区域,从$7FFF向下增长。R7作为堆栈指针(SP),始终指向栈顶。
// ========================================
// 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),包含返回地址、参数和局部变量:
// 调用 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
用递归实现阶乘函数 factorial(n):如果n<=1返回1,否则返回n*factorial(n-1)。写出完整的汇编代码,包括参数传递和堆栈帧管理。
修改stack_ctrl模块,当堆栈满时设置一个标志位而不是静默覆盖数据。设计CPU如何响应堆栈溢出。
什么是可重入函数?为什么在中断处理中必须使用可重入函数?用汇编举例说明不可重入函数的问题和修复方法。
达成条件:
奖励:你掌握了结构化编程的基础!函数、递归、局部变量——所有高级语言特性的底层实现你都理解了。指令集阶段完成!🏆
中断处理是堆栈最关键的应用之一。当CPU响应中断时,必须保存当前状态,处理完后再恢复:
中断可能随时打断程序执行。如果子程序使用了共享资源(如寄存器),必须确保中断处理不会破坏这些资源:
// 中断安全的函数
safe_func:
// 保存所有将被修改的寄存器
PUSH R4 // 保存callee-save寄存器
PUSH R5
// 现在可以安全地使用R4、R5
// 即使中断来了,中断处理会保存/恢复自己的寄存器
// ... 函数体 ...
// 恢复寄存器(注意顺序:后进先出)
POP R5
POP R4
RET
有时函数需要动态大小的局部变量空间。可以通过调整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 | 编译器生成 |
| 递归 | 每层递归一个帧 | 递归算法 |
函数调用后谁来清理栈上的参数?两种方案各有优劣:
方案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
// 不需要清理!被调用者已经清理了
// 优点: 代码更短,调用处更干净
// 缺点: 不支持可变参数
| 方式 | 优点 | 缺点 |
|---|---|---|
| 寄存器传参 | 最快,无需内存访问 | 参数数量受限(只有8个寄存器) |
| 栈传参 | 参数数量无限制 | 慢,每次需要内存访问 |
| 混合方式 | 前几个用寄存器,多余的用栈 | 编译器复杂度高 |
Retro8采用混合方式:R1-R3传递前3个参数,多余的用栈。返回值通过R1传递。
防止堆栈溢出攻击的一种方法是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 // 停机(安全失败)