简介:面向计算机科学与技术专业学生与Verilog硬件设计初学者,这份华中科技大学计算机组成原理课程设计资料以流水线CPU上板实现为核心,覆盖取指、译码、执行、访存、写回五级流水,以及插入气泡、数据重定向、多级嵌套中断等关键机制,可用于课程设计复现、仿真验证与FPGA上板参考。压缩包共345个文件,约31.39MB,包含Verilog源码、XML与RPT报告、DCP与BIT综合实现产物、TCL脚本及仿真/工程配置等,便于按工程、源码、报告和上板结果分层查阅。已有1949人学习下载,读者可据此梳理流水线冲突处理与中断嵌套的实现思路,结合测试平台观察波形、核对综合报告,并借鉴工程目录组织和调试记录完成自己的CPU流水线验证。
1. 从一条卡在 EX 段的 addi 说起:这套 Verilog 流水线源码能补哪块拼图
仿真跑到第 37 个周期,一条本该写回$t1的addi卡在 EX 段不动,波形上 PC 纹丝不动、IF/ID 的指令却还在往后爬——第一次做流水线的人基本都会撞上这一幕。这套某高校计算机组成原理课程设计的 Verilog 源码,把这类画面背后的东西摊开得更彻底:五级流水、load-use 与分支的气泡插入、EX/MEM 与 MEM/WB 两条数据重定向通路,以及中断服务程序还能被更高优先级再打断一次的多级嵌套中断。课设要交可综合代码的能直接拿来对照,工作几年后想补一遍底层、把“转发”和“冲刷”真正搞明白的开发者也能拿它当参照。工程是纯 Verilog 可综合风格,没有塞外部 IP,仿真器和综合工具都能直接吃进去。
2. 五级划分与流水寄存器:一条指令从取指走到写回要过几道寄存器
流水线 CPU 的麻烦从来不在单个模块里,而在模块之间那排寄存器上。少打一拍,后面所有通路全部错位;多打一拍,延迟又白搭。所以看这套源码,我建议先花二十分钟把cpu_top.v里的连线理顺,再去翻具体的 ALU 和控制单元——顺序反过来,很容易陷进某个模块出不来,回头还是不知道数据从哪儿来。
2.1 顶层与五级划分:cpu_top 里的那根主干线
源码的五级是教科书式的 MIPS:IF 取指 → ID 译码 → EX 执行 → MEM 访存 → WB 写回,级与级之间卡着if_id、id_ex、ex_mem、mem_wb四个流水寄存器,每个模块一个.v文件。顶层的职责其实很单一:把下一拍 PC 的选择、各级流水寄存器的使能和冲刷、以及组合逻辑的例化接起来,不在顶层做任何运算。
// cpu_top.v(节选)—— 顶层只做连线和下一拍 PC 的选择 module cpu_top ( input wire clk, input wire rst_n, input wire [3:0] ext_int, // 4 路外部中断源,bit0 优先级最高 output wire [31:0] pc_debug ); // ---- 流水控制信号 ---- wire stall_if; // 停顿:PC 与 IF/ID 保持 wire flush_if_id; // 冲刷:分支成立或中断进入时清空 wire flush_id_ex; // 插气泡:清空 ID/EX 的控制字 wire branch_taken; // 分支在 EX 级判定成立 wire int_enter, eret; // 中断进入 / 中断返回 // ---- 各级之间的数据 ---- wire [31:0] pc, pc_next, pc_branch, epc; assign pc_next = int_enter ? epc : branch_taken ? pc_branch : stall_if ? pc : pc + 32'd4; pc u_pc (.clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc)); imem u_imem (.addr(pc), .instr(instr)); if_id u_if_id (.clk(clk), .rst_n(rst_n), .stall(stall_if), .flush(flush_if_id), .pc_in(pc), .instr_in(instr), ...); id u_id (...); id_ex u_id_ex (.clk(clk), .rst_n(rst_n), .flush(flush_id_ex), ...); ex u_ex (...); ex_mem u_ex_mem (...); mem u_mem (...); mem_wb u_mem_wb (...); wb u_wb (...); assign pc_debug = pc; endmodulepc_next这一串三目嵌套就是整条流水线的红绿灯:中断进入优先级最高,直接跳epc;分支成立跳目标地址;停顿保持当前 PC;其余情况顺序 +4。这里的优先级顺序不是随便排的,中断请求可能在分支的同一条指令上到达,谁先谁后决定了返回地址保存的是哪一条,改代码时动这一行要格外小心。
还有一个由“分支放在 EX 级判定”带来的连带成本:分支成立的那一拍,PC 已经往前多取了指令,所以flush_if_id要清掉 IF/ID 里的错指令,flush_id_ex要把 ID/EX 的控制字清成 nop,一口气废掉两条。这套源码没做分支预测,就是老老实实“判到了再冲刷”,逻辑干净、好验证,缺点也明摆着——每次跳转固定浪费两个周期。后面第 6 章要扩展的话,这是第一个可以下手的地方。
2.2 控制字的流水化:为什么控制器要一路跟到 WB
单周期 CPU 里控制器只活在一个时钟周期内,流水线不行。ID 级译出来的regwrite、memtoreg、memwrite,要到 EX、MEM、WB 才真正派上用场,中间隔着两三拍,只能跟着流水寄存器一起打拍传下去。这套源码把控制信号打包成一个 9 位控制字,塞进id_ex,然后一级一级往下搬。
// id_ex.v(节选)—— 控制字随数据一起进流水寄存器 module id_ex ( input wire clk, rst_n, flush, input wire [31:0] pc_in, rs_val_in, rt_val_in, imm_in, input wire [4:0] rs_in, rt_in, rd_in, input wire [8:0] ctrl_in, // 来自 control.v 的 9 位控制字 output reg [31:0] pc_out, rs_val_out, rt_val_out, imm_out, output reg [4:0] rs_out, rt_out, rd_out, output reg [8:0] ctrl_out ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin {pc_out, rs_val_out, rt_val_out, imm_out} <= 0; {rs_out, rt_out, rd_out, ctrl_out} <= 0; end else if (flush) begin ctrl_out <= 9'b0; // 全零 = 一条 nop:不写寄存器、不写存储器 end else begin pc_out <= pc_in; rs_val_out <= rs_val_in; rt_val_out <= rt_val_in; imm_out <= imm_in; rs_out <= rs_in; rt_out <= rt_in; rd_out <= rd_in; // 目的寄存器此时已由 id 级选好 rt 或 rd ctrl_out <= ctrl_in; end end endmoduleflush置位时只清ctrl_out,不清数据通路上的rs_val_out、imm_out,这是省逻辑的写法——气泡指令不会用到这些值,清了纯属多一路扇出。这个取舍本身不算错,但后面避坑那章会提到它埋的一个雷。
控制字的位定义在源码里是固定的,读其余模块之前最好先把它抄在纸上:
| 位 | 名称 | 作用 |
|---|---|---|
| [8] | regwrite | 允许写寄存器堆 |
| [7] | memtoreg | WB 级写回数据取自存储器而非 ALU |
| [6] | memwrite | 允许写数据存储器 |
| [5] | memread | 读数据存储器 |
| [4] | branch | 分支类指令标志,送 EX 级比较 |
| [3] | alusrc | ALU 第二操作数选立即数 |
| [2:0] | aluop | ALU 运算类型:加、减、与、或、小于置位 |
2.3 目录结构与文件职责对照
工程文件是典型的“一个模块一个文件”,没有按级合并成大文件,翻起来对新手比较友好。我第一次打开时按目录扫了一遍,大致长这样:
| 文件 | 职责 | 阅读时该盯的点 |
|---|---|---|
cpu_top.v | 顶层连线、下一拍 PC 选择 | 三个控制信号的优先级 |
pc.v | 程序计数器 | 复位值、中断入口的载入路径 |
imem.v | 指令存储器 | $readmemh预装程序的初始化 |
if_id.v/id_ex.v/ex_mem.v/mem_wb.v | 四级流水寄存器 | stall与flush行为的差异 |
id.v | 译码、读寄存器值、立即数符号扩展 | 目的寄存器选rt还是rd |
control.v | 主控制器 | 控制字位定义、非法指令的默认值 |
regfile.v | 32×32 寄存器堆 | 写优先、$0恒为零 |
ex.v | ALU、分支比较、重定向前插 | 前插的优先级 |
alu.v | 算术逻辑单元 | 减法与slt的实现方式 |
mem.v | 数据存储器 | 读写时序、字对齐 |
wb.v | 写回数据选择 | memtoreg的二选一 |
hazard_unit.v | load-use 冒险检测 | 和分支、中断信号的冲突 |
forwarding_unit.v | 旁路控制 | 优先级与$0保护 |
int_ctrl.v | 中断优先级与现场栈 | 嵌套层数上限 |
2.4 命名与位宽约定:读代码前先记住的几条规矩
源码里跨模块的信号统一带级名前缀:id_ex_rt、ex_mem_rd、mem_wb_regwrite,看到名字基本能判断它是哪一拍的值。寄存器堆的两路输出叫rs_val、rt_val,而编号叫rs、rt——这个区分看着啰嗦,但非常必要。血泪经验:把rs编号当数值接进 ALU 的事故,我见过不止一次,波形上表现为 ALU 结果永远等于 0 到 31 之间的某个小数字,一眼就能认出来。位宽方面,PC 和所有数据通路统一 32 位,寄存器编号 5 位,立即数扩展后 32 位,写存储器时按字(32 位)寻址,地址的低两位在mem.v里被直接截掉。
3. 从零跑通:iverilog + GTKWave 下的一次完整仿真
拿到源码包最怕的是“打开就懵”,所以第一步永远是把仿真单独跑起来,别碰综合。这套工程没有绑定任何厂商工具,命令行下用开源工具链就能完整验证。
3.1 工具链与最小可跑环境
需要的东西不多:一个 Icarus Verilog(iverilog加vvp)负责编译仿真,一个 GTKWave 看波形。Linux 上直接包管理器装,Windows 上装好之后确认iverilog -V能出版本号就行。源码里没有用到 SystemVerilog 的独有语法,-g2012只是为了兼容个别always_comb写法,用-g2005一般也能过,如果报错就加-g2012。
# 一次性把所有源文件编进来,注意别漏 hazard_unit / forwarding_unit / int_ctrl iverilog -g2012 -o sim_cpu \ tb_cpu.v cpu_top.v pc.v imem.v if_id.v id.v id_ex.v \ ex.v ex_mem.v mem.v mem_wb.v wb.v regfile.v alu.v \ control.v hazard_unit.v forwarding_unit.v int_ctrl.v vvp sim_cpu # 跑仿真,生成 wave.vcd gtkwave wave.vcd & # 看波形如果你用的综合工具是 Vivado 那一类,做法是把所有.v加进工程、把tb_cpu.v设成 simulation-only,然后跑行为仿真。要注意imem.v里用initial块调$readmemh装程序,这段代码只对仿真有效,综合时会被忽略,上板后会读出一堆零——这是很多人第一次上板时 PC 一路 0 的直接原因。
3.2 testbench:程序怎么装进指令存储器
imem.v里会给存储器留一个数组,仿真开始时用$readmemh从十六进制文件里读指令。文件里每行一条 32 位指令,用 8 个十六进制字符表示,行尾可以带//注释。想验证“load-use 必须插气泡”,最省事的办法就是手写一小段程序,让lw后面立刻跟上一条用到结果的指令。
`timescale 1ns/1ps module tb_cpu; reg clk = 0, rst_n = 0; reg [3:0] ext_int = 4'b0000; wire [31:0] pc_dbg; cpu_top u_cpu (.clk(clk), .rst_n(rst_n), .ext_int(ext_int), .pc_debug(pc_dbg)); always #5 clk = ~clk; initial begin $readmemh("prog.hex", u_cpu.u_imem.mem); // 预装程序,路径按实际改 #20 rst_n = 1; // 复位拉高两个周期后再放 #3000 $finish; end // 固定周期触发中断:先来一路,再在服务程序执行中加一路更高优先级 initial begin #900 ext_int = 4'b0001; #100 ext_int = 4'b0011; #300 ext_int = 4'b0000; end initial begin $dumpfile("wave.vcd"); $dumpvars(0, tb_cpu); end endmodule配套的prog.hex可以极小,四条指令足够看出问题:
20080008 // addi $t0, $zero, 8 -> $t0 = 8 ac080000 // sw $t0, 0($zero) -> 存储器[0] = 8 8c090000 // lw $t1, 0($zero) -> $t1 = 8(要等一拍) 01295020 // add $t2, $t1, $t1 -> $t2 = 16,紧跟 lw,必须被 stall参数说明:u_cpu.u_imem.mem是层次化路径,直接访问子模块内部数组,写 testbench 时最容易在这里拼错名字;#20 rst_n = 1里的 20 对应两个时钟周期,复位太短会让寄存器堆的初值捕捉不稳定。运行vvp之后,如果终端没有任何$display输出,说明程序跑完了但没有打印,这时可以自己往wb.v里加一句$display("cycle=%0d pc=%h rd=%0d data=%h", ...),比对着波形找一整天高效得多。
3.3 波形上的六个观察点
程序跑起来不代表流水线是对的。我一般在 GTKWave 里固定加六组信号,逐一确认:
| 观察点 | 该看到什么 | 看不到说明什么 |
|---|---|---|
pc | 每拍稳定 +4,只有分支或中断时跳变 | PC 逻辑被 stall 信号误触发 |
if_id_instr | stall 那拍保持不变 | if_id的使能接反了 |
id_ex_ctrl_out | 气泡那拍全 0 | flush_id_ex没接到 ID/EX |
forward_a/forward_b | 相关指令间隔 1~2 条时出现10或01 | forwarding_unit的比较条件漏了$0 |
branch_taken与flush_if_id | 同拍拉高 | 分支在 ID 级判定,接错了级 |
epc与nest_level | 中断后nest_level加一,返回后减一 | 现场栈的出入栈顺序搞反了 |
3.4 综合与上板前的两项检查
仿真过了再谈综合。第一项检查是组合环:ex.v里的前插逻辑如果写成“ALU 输出又反馈回来参与下一拍选择”,综合工具会报出 combinational loop 警告,这种环在仿真里可能只是延迟变大,上板后行为直接跑飞。第二项是锁存器推断:always @(*)块里只要有分支没赋到某个输出,就会推出一堆 latch,时序彻底不可控,综合报告里搜latch能一眼看到。
提示:综合前把
tb_cpu.v从工程里排除,否则$dumpfile、$finish这类系统任务会直接报错;实在要用,得用`ifdef SIM包起来。
4. 核心机制拆解:气泡插入、数据重定向与多级嵌套中断
三个模块撑起了这套源码的含金量,也正好对应三个最容易翻车的机制。分开讲,但心里要清楚它们的输出都汇到同一根pc_next和同一组流水使能上。
4.1 冒险检测单元:气泡到底插在哪一拍
load-use 冒险的本质是:lw的数据要等到 MEM 级结束才拿得到,而紧跟其后的指令在 EX 级就要用,中间只隔了一拍,前插也救不回来。唯一的办法是让后面那条指令在 ID 级多停一拍,等lw走到 WB,再靠前插把值补进去。
// hazard_unit.v —— 只在 load-use 时停一拍 module hazard_unit ( input wire [4:0] if_id_rs, input wire [4:0] if_id_rt, input wire [4:0] id_ex_rt, // lw 的目的寄存器是 rt input wire id_ex_memread, // ID/EX 级是否为 lw output reg stall_if, // 保持 PC 与 IF/ID output reg flush_id_ex // 清空 ID/EX,插入气泡 ); always @(*) begin if (id_ex_memread && ((id_ex_rt == if_id_rs) || (id_ex_rt == if_id_rt))) begin stall_if = 1'b1; flush_id_ex = 1'b1; end else begin stall_if = 1'b0; flush_id_ex = 1'b0; end end endmodule逻辑说明:检测对象是“ID/EX 级正在执行的指令”和“IF/ID 级刚取到的指令”之间的一对相邻关系,所以比较的是id_ex_rt和if_id_rs/rt。stall_if拉高时 PC 不递增、IF/ID 不更新,两条指令原地等;flush_id_ex拉高时 ID/EX 的控制字清零,等于往 EX 级送进一个什么都不做的气泡。
参数说明:id_ex_rt之所以是rt而不是rd,是因为 MIPS 的lw把目的寄存器编在rt字段;如果后来扩展到 R 型指令也要检测,就得改成rd(前提是你的译码器把目的寄存器统一切换到了rd)。另外这里没有排除$0,理论上lw $zero, ...会白白停顿一拍,功能不错但浪费周期,加个id_ex_rt != 5'd0就能省掉。
注意:如果同时把
stall_if和flush_if_id接到if_id模块,一定要在模块内部定好优先级。分支成立那一拍如果恰好也在停顿,谁先谁后直接决定取到的是目标地址还是垃圾指令。
4.2 重定向网络:三条旁路的来源与去向
前插解决的是“值已经有了,只是还没写回寄存器堆”这个时间差。这套源码做了两条主通路:EX/MEM 的结果直接喂回 EX 级输入端(最近的一条),MEM/WB 的结果作为备选。第三条是给sw准备的——要写进存储器的数据也可能来自刚从 MEM/WB 出来的前一条指令,不能从寄存器堆里读旧值。
// forwarding_unit.v —— 旁路选择,编号越大越优先 module forwarding_unit ( input wire [4:0] id_ex_rs, id_ex_rt, input wire [4:0] ex_mem_rd, input wire ex_mem_regwrite, input wire [4:0] mem_wb_rd, input wire mem_wb_regwrite, output reg [1:0] forward_a, // ALU 第一操作数:00 寄存器堆 / 01 MEM/WB / 10 EX/MEM output reg [1:0] forward_b // ALU 第二操作数,编码同上 ); always @(*) begin forward_a = 2'b00; forward_b = 2'b00; if (ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs)) forward_a = 2'b10; // EX/MEM 最新,优先 else if (mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rs)) forward_a = 2'b01; if (ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rt)) forward_b = 2'b10; else if (mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rt)) forward_b = 2'b01; end endmodule逻辑说明:if / else if的写法本身就是优先级——EX/MEM 离得近,值更新,必须压过 MEM/WB。如果两条通路同时命中同一个源寄存器,选远的那个就是典型错误,波形上表现为add的结果差了整整一代数据,间隔足够远就对,间隔近就错。
参数说明:ex_mem_rd和mem_wb_rd是各级挑起写回任务的目的寄存器编号,regwrite用来排除sw、beq这类不写寄存器的指令。!= 5'd0这一句不能省,$0恒为零,但前插会把它替换成某条指令的运算结果,于是$0被“写”进一个非零值——这种 bug 在测试程序里只在你恰好用$0作源操作数时才发作,非常隐蔽。
到了ex.v里,这两组选择信号会被展开成三选一:
// ex.v(节选)—— 前插的落地写法 always @(*) begin case (forward_a) 2'b10: alu_a = ex_mem_alu_result; // 上一条指令的结果 2'b01: alu_b_src = mem_wb_data; // 上上条的结果 default: alu_a = id_ex_rs_val; // 正常从寄存器堆读 endcase end // sw 的写数据同样要走一遍前插,否则写进存储器的是旧值 assign store_data = (forward_b == 2'b10) ? ex_mem_alu_result : (forward_b == 2'b01) ? mem_wb_data : id_ex_rt_val;4.3 多级嵌套中断:进入、压栈、再次进入与返回
“多级嵌套”是这个工程里最有意思的部分:中断服务程序执行到一半,来了一个优先级更高的中断请求,CPU 要能挂起当前服务程序、保存现场、跳进新的服务程序,返回时再一层一层退回去。硬件上靠一个深度为 4 的现场栈实现,栈里存返回地址和被屏蔽的优先级。
// int_ctrl.v(节选)—— 优先级编码 + 现场栈 module int_ctrl ( input wire clk, rst_n, input wire [3:0] ext_int, // 4 路中断源,bit0 最高 input wire instr_done, // 当前指令执行完,可安全响应 input wire eret, // 中断返回指令译码结果 input wire [31:0] pc_next, output reg int_enter, output reg [31:0] epc_out, output reg [1:0] nest_level ); reg [31:0] epc_stack [0:3]; reg [3:0] mask_stack [0:3]; reg int_req; reg [1:0] int_vec; // 优先级编码:bit0 最高,低编号优先 always @(*) begin int_req = 1'b0; int_vec = 2'd0; if (ext_int[0]) begin int_req = 1'b1; int_vec = 2'd0; end else if (ext_int[1]) begin int_req = 1'b1; int_vec = 2'd1; end else if (ext_int[2]) begin int_req = 1'b1; int_vec = 2'd2; end else if (ext_int[3]) begin int_req = 1'b1; int_vec = 2'd3; end if (nest_level >= 2'd3) int_req = 1'b0; // 栈满,不再响应新中断 end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin nest_level <= 2'd0; int_enter <= 1'b0; epc_out <= 32'd0; end else if (int_req && instr_done) begin epc_stack[nest_level] <= pc_next; // 保存返回地址 mask_stack[nest_level] <= ext_int; // 保存当前屏蔽状态 epc_out <= pc_next; nest_level <= nest_level + 2'd1; int_enter <= 1'b1; end else if (eret && nest_level != 2'd0) begin nest_level <= nest_level - 2'd1; epc_out <= epc_stack[nest_level - 2'd1]; int_enter <= 1'b0; end else begin int_enter <= 1'b0; end end endmodule逻辑说明:中断请求是组合逻辑判出来的,但只在instr_done有效时才真正受理——这一点很关键,若在指令执行中途跳走,EX/MEM 里的状态就丢了。进入时把pc_next存进栈,那是下一条还没取到的指令地址,返回时正好从那里继续。返回路径把nest_level减一后取上一层栈顶,cpu_top看到int_enter就把pc_next切到epc。
参数说明:mask_stack目前只是存下来备查,真正实现“服务程序执行期间响应更高优先级”靠的是比较int_vec与当前层的屏蔽字——源码里这段判断写在中断服务程序的软件部分,硬件只负责压栈和跳转。nest_level >= 2'd3是栈满保护,去掉这一句,第五次嵌套会把前四层现场全覆盖掉,现象是返回时 PC 跳到某个完全无关的地址。这套机制和常见 RTOS 的“中断嵌套计数器”是一个思路,只是这里用硬件栈替代了软件变量。
4.4 分支冲刷与中断冲刷共用一条通路
flush_if_id和flush_id_ex这两个信号,分支和中断都要用。源码的做法是在顶层把两个来源或起来:
assign flush_if_id = branch_taken | int_enter; assign flush_id_ex = hazard_stall | branch_taken | int_enter;或逻辑本身没问题,问题在于误解“冲刷等于停顿”。分支成立时是冲刷、PC 跳目标;load-use 时是停顿、PC 原地。两者都让 IF/ID 不更新,但pc_next的取值完全不同。把这两个概念混在一起写,波形上看起来都能“停住”,功能却差了两个周期,调试时会怀疑人生。我自己的习惯是在顶层给这两个信号起完全不同的前缀名(stall_*和flush_*),并且严禁在别的模块里用同一个变量名。
5. 避坑与排查:五类反复出现的翻车现场
这些不是我凭空想出来的,是照着波形一条一条对出来的。每一条都按“现象 → 原因 → 解决”写,遇到时直接对照。
5.1 现象:仿真一开始,所有信号全是 X
原因:rst_n复位时间不够,或者寄存器堆、流水寄存器没有复位分支。Verilog 里的reg初值是 X,要靠initial或复位赋初值;而mem.v里的存储器数组默认也是 X,读一个未初始化的地址,数据通路立刻污染。
解决:在 testbench 里至少给复位留两个完整时钟周期(#20起步);检查每个always @(posedge clk or negedge rst_n)是否都有if (!rst_n)分支;存储器数组在initial里用for循环全部清零,而不是只清前几个。还有一点容易忽略:if_id、id_ex里的控制字复位值必须是 0,否则复位期间会冒出一条“幽灵指令”去写寄存器。
5.2 现象:分支指令后面那条指令被执行了
原因:分支判定放在 EX 级,成立时 PC 已经往前取了至少一条错指令,而flush_if_id没有接到,或者接到了但优先级低于stall_if。
解决:确认flush_if_id真的连到了if_id的flush端口;在顶层明确优先级——冲刷应当压过停顿。另外,分支的目标地址要在
本文还有配套的精品资源,点击获取