
简介面向FPGA与处理器设计学习者的完整工程包用Verilog搭建RISC-V架构五段流水线CPU覆盖取指-译码-执行-访存-回写全过程内置转发与阻塞探查常见数据冒险无需冲刷load-use仅插入一次气泡中断部分实现机器态、非向量、无嵌套的时钟中断并加入CSR寄存器及csrrc、csrrw等六条指令。软件侧以C语言编写简易操作系统内核提供标准I/O、字符串处理、软件乘除法等接口CPU核内通过内存映射将代码区、数据区、显存、键盘数据及中断临时栈统一调度汇编负责中断入口与上下文切换C内联汇编完成注册与处理结构清晰。包内共299个文件约7.46MB涵盖.v硬件源码、.mif初始化文件、.hex机器码、.dump反汇编、.c内核代码及.qsf工程配置另有PDF说明与脚本辅助理解。已有86人下载学习适合计算机体系结构课程设计、FPGA实验或RISC-V入门与进阶。1. 从DE10的LED到一串自己的printf把一个LED点亮只需要几十行Verilog但想在DE10 FPGA开发板上看到自己写的“Hello RISC-V”就需要把整套软硬件链路打通用Verilog实现RISC-V架构CPU用C语言编译出能在该CPU上运行的裸机程序再配一个足够简易的操作系统来管理任务切换。这个项目的价值不在某一块代码而在于它能同时验证指令集设计、数字电路、编译工具链和操作系统原理——四样东西只要有一环理解不透板子上的表现就会诚实地给你答案。我建议用RV32I单周期CPU起步先跑通UART输出再加Timer中断和任务调度最终在DE10上看到两个任务轮流打印字符串这就算真正闭环了。2. RISC-V cpu核心RV32I子集、数据通路与异常入口2.1 为什么选择RV32I而不是RV32IM指令集裁剪的边界RISC-V的指令集是模块化的RV32I是最基础的整数指令集固定32位指令长度包含算术逻辑、分支跳转、访存和系统指令总共47条左右。很多初学者会直接选RV32IM也就是加上M扩展的乘除法指令理由是C语言代码里乘除法太常见。这个想法没错但代价是CPU核心要额外实现mul、div单元数据通路面积变大时序收敛更难而DE10上的Cyclone V不是为高速运算准备的。实际项目里我一般这样裁剪如果目标只是跑通一个“能够响应定时器中断、切换两个任务”的简易操作系统RV32I完全够用。C编译器在-marchrv32i下遇到乘除法会调用libgcc里的软实现也就是说用加法移位模拟乘除代价是慢一点但对教学项目没有影响。这样CPU核心只需要实现五类指令格式R型、I型、S型、B型、U型和J型译码逻辑也能保持清晰。2.1.1 最小指令子集清单指令类型代表指令用途算术逻辑add, sub, addi, and, or, xor, slt运算与比较移位sll, srl, sra, slli, srli, srai移位与乘除法的软实现基础访存lw, sw, lb, sb, lhu, sh与RAM、外设交换数据分支beq, bne, blt, bge, bltu, bgeu条件分支跳转jal, jalr, auipc, lui函数调用与PC相对寻址系统ecall, mret陷入内核与返回用户态注意slt特别重要它是C语言里比较运算的基石。如果CPU没有slt编译器就只能用减法加分支来模拟代码体积和执行周期都会翻倍。另外fence指令在单核场景下可以当成nop处理但保留译码位会更稳妥。2.2 数据通路最小集指令译码表与寄存器堆写法单周期CPU的数据通路思路是取指后用组合逻辑译码根据opcode和funct3、funct7字段生成控制信号然后在一个时钟周期内完成读寄存器、运算、写回全部操作。DE10上的Cyclone V跑50MHz外部时钟绰绰有余我通常会把CPU时钟分频到10到20MHz给组合逻辑留足裕量。下面这段Verilog实现的是核心译码逻辑它把opcode字段映射到不同指令类型// 指令译码将32位指令分解为控制信号 always (*) begin case (opcode) 7b0110011: begin // R-type: add, sub, and, or, slt reg_write 1b1; alu_src 1b0; // 第二个操作数来自寄存器堆 mem_write 1b0; end 7b0010011: begin // I-type arith: addi, ori, slli reg_write 1b1; alu_src 1b1; // 第二个操作数来自立即数 mem_write 1b0; end 7b0000011: begin // load: lw, lb, lhu reg_write 1b1; mem_read 1b1; end 7b0100011: begin // store: sw, sb, sh reg_write 1b0; mem_write 1b1; end 7b1100011: begin // branch: beq, bne, blt branch 1b1; reg_write 1b0; end default: begin reg_write 1b0; mem_write 1b0; end endcase end这段代码的重点在于alu_src和reg_write的配合。R型和I型算术指令都要写回寄存器堆区别只在于第二个操作数来源——R型从寄存器堆读取I型从立即数扩展而来。store指令不需要写回但需要把寄存器数据送到数据总线branch指令则要同时输出两个读端口到比较器。寄存器堆我习惯用两个读端口、一个写端口的同步写异步读结构零号寄存器写使能必须强制拉低。否则C语言中未初始化的全局变量会被意外更改而且在早期调试时很难联想到是寄存器堆写坏导致的。2.3 冒险处理与时钟频率仿真能跑、板上卡住的原因单周期CPU没有数据冒险因为每条指令都在一个周期内完成下一条指令取指时上一条早已写回。但我在实际调试中发现很多人的设计在ModelSim仿真里完美运行下载到DE10上却出现“每隔几条指令跳飞一次”的现象。排查到最后往往是两类问题一类是异步复位信号毛刺另一类是分支跳转后PC更新逻辑出现组合环路。分支冒险是单周期CPU最容易被忽略的环节。跳转成立时PC在同一个时钟周期内既要从分支目标地址更新又要保证取指FIFO里的下一条指令作废。我的做法是在ID阶段计算分支条件把pc_next的mux选择信号放在组合逻辑中同时把valid信号打一拍让流水线寄存器在下一个时钟沿吸收掉错误的取指结果。// 取指PC更新普通顺序执行或跳转到目标地址 always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h0000_0000; else if (branch_taken) pc pc_branch_target; else pc pc 32d4; end参数说明branch_taken在ID阶段由比较器产生因此从IF取指到PC更新存在两个循环周期这个路径上的组合逻辑延迟是影响CPU最高时钟频率的关键。DE10上跑20MHz时我实测这一路径的Fmax有富余如果时钟拉高到50MHz需要检查Quartus时序报告里的Worst-Case Slack出现负数就要在代码里打拍或优化比较器结构。2.4 为操作系统预留的异常入口ecall与CSR寄存器读写简易操作系统和裸机C程序最大的区别在于操作系统需要“主动让出CPU”和“被硬件打断”两种机制这两者分别对应ecall指令和中断。RISC-V里它们统一通过异常入口处理核心CSR寄存器有三个mstatus全局中断使能、mepc异常返回地址、mcause异常原因编码。我在CPU里实现异常处理的寄存器组如下// 异常相关CSRmstatus、mepc、mcause reg [31:0] mstatus; reg [31:0] mepc; reg [31:0] mcause; always (posedge clk or negedge rst_n) begin if (!rst_n) begin mstatus 32h0000_1800; // MPP11, MPIE0 mepc 32h0; mcause 32h0; end else if (csr_we) begin case (csr_addr) 12h300: mstatus csr_wdata; 12h341: mepc csr_wdata; 12h342: mcause csr_wdata; endcase end end这组寄存器的关键行为是CPU执行ecall时硬件自动将当前PC存入mepc将异常原因写入mcause然后把PC跳转到mtvec指向的异常入口地址。操作系统只需要在入口处保存现场、调用C函数处理异常最后执行mret指令返回。mret会从mepc恢复PC并把mstatus.MPIE写回MIE完成一次完整的陷入与返回。有一个坑值得提醒在实现中断嵌套前必须保证ecall的mepc是pc本身还是pc4。RISC-V规范里ecall不自动加4所以操作系统处理完系统调用后要自己把mepc4再写回否则会死循环在同一个ecall里。这一步非常容易出错而且是仿真中不容易暴露出来的问题。3. SoC与外围内存映射、UART多字节收发和Timer中断3.1 内存映射表把UART、Timer、GPIO编到统一地址空间CPU核心一旦跑起来下一步就是让它能“看到”外设。最简单的方式是使用内存映射总线CPU发出地址地址译码器判断该地址属于哪个外设再选通对应的读回数据或写入数据。DE10项目里我常用的地址空间分配如下地址范围设备读行为写行为0x0000_0000 - 0x0000_3FFFROM程序存储返回指令不可写0x0000_4000 - 0x0000_7FFFRAM数据存储返回数据写入数据0x0000_8000 - 0x0000_8003UART数据寄存器读取接收FIFO写入发送FIFO0x0000_8004 - 0x0000_8007UART状态寄存器bit0TX_BUSY无0x0000_8010 - 0x0000_8013Timer控制寄存器当前计数值写入初值0x0000_8014 - 0x0000_8017Timer中断状态中断标志位写1清零0x0000_8020 - 0x0000_8023GPIO输出当前LED电平设置LED电平地址译码器不用做得太复杂一个大case语句就足够。关键是总线等待周期外设读写是组合逻辑完成的RAM读取也是组合逻辑但CPU是在时钟下降沿采集读回数据所以译码逻辑必须在半个时钟周期内稳定下来。如果发现读外设总是滞后一拍可以在总线上加一个ready信号CPU检测到ready后才锁存数据。3.2 用Verilog实现UART多字节收发与115200波特率UART是调试这个项目最趁手的工具没有之一。CPU是否正常跑起来、操作系统是否完成切换全部靠串口字符串输出判断。DE10板载USB转UART芯片引脚分配在原理图里有标注不需要额外硬件。波特率计算公式是分频系数 时钟频率 / (波特率 × 16)。DE10的50MHz时钟我通常先PLL分频到20MHz再设115200波特率分频系数约为10.85取整数11误差在0.1%以内完全够用。// UART发送模块8位数据1位起始位1位停止位 module uart_tx ( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output reg txd, output reg tx_busy ); localparam BAUD_DIV 11d11; // 20MHz / (115200 * 16) reg [10:0] clk_cnt; reg [3:0] bit_cnt; reg tx_start_d; always (posedge clk or negedge rst_n) begin if (!rst_n) begin txd 1b1; tx_busy 1b0; end else begin if (tx_busy) begin if (clk_cnt BAUD_DIV - 1) begin clk_cnt 0; if (bit_cnt 4d10) begin tx_busy 1b0; end else begin bit_cnt bit_cnt 1; case (bit_cnt) 4d0: txd 1b0; // 起始位 4d1: txd tx_data[0]; 4d2: txd tx_data[1]; // ... 逐位发送 4d9: txd 1b1; // 停止位 endcase end end else begin clk_cnt clk_cnt 1; end end else begin if (tx_start) begin tx_busy 1b1; bit_cnt 0; clk_cnt 0; end end end end endmodule这段代码里重要参数是BAUD_DIV和bit_cnt。bit_cnt从0到10正好对应一个起始位加8个数据位加1个停止位。多字节收发的场景中CPU往UART写数据前必须检查tx_busy否则新数据会把正在发送的字节冲掉。在实际C语言驱动里我一般封装一个uart_putc函数循环等待busy拉低再写入数据寄存器。接收方向的采样点设计有一个容易忽略的细节UART接收器的数据采样应当在每个bit的中点。20MHz时钟下起点检测到下降沿后等一个完整bit周期再采第一个数据位能最大程度避开信号边沿抖动。这里建议把clk_cnt的分频比调成波特率周期的一半再采样而不是边沿立即采。3.3 计数器产生系统tickTimer寄存器与中断信号简易操作系统的调度需要“心跳”这个心跳由硬件Timer产生。DE10项目里我实现的Timer本质上就是一个可重装载的down counter计数到0时产生一个脉冲中断同时自动从预设值重新开始计数。设计上也可以反过来用up counter加比较器计数器每个时钟周期加一当计数值等于比较寄存器时清零并产生中断。两种方式差别不大我习惯用down counter因为创建定时器中断时间较直观预设值就是中断间隔周期计数。// Timer32位down counter计数到0时产生中断 reg [31:0] timer_cnt; reg [31:0] timer_reload; reg timer_irq; always (posedge clk or negedge rst_n) begin if (!rst_n) begin timer_cnt 32hFFFF_FFFF; timer_irq 1b0; end else begin if (timer_cnt 32d0) begin timer_cnt timer_reload; timer_irq 1b1; end else begin timer_cnt timer_cnt - 1; timer_irq 1b0; end end end这里timer_reload的可设置范围决定OS的最小调度周期。如果CPU时钟是20MHztimer_reload设为200000就等价于10ms产生一次中断——这个频率对两个任务的轮流切换足够平滑也不会让CPU把大部分时间消耗在上下文切换上。一个容易踩的坑是Timer中断标志位用脉冲而不是电平。脉冲信号在CPU处理中断时需要立即清除否则会出现同一次中断被mstatus使能后再次触发。我在SoC里把timer_irq接到CPU的irq输入由CPU核心在进入异常处理时自动清掉该标志。这样省掉了软件清中断的步骤但也要求CPU中断逻辑对脉冲做出正确判断。3.4 板级验证前的三个检查点含仿真license问题第一个检查点是仿真环境。许多人在Windows下装完Quartus和ModelSim第一次跑仿真就报“17.1 error: failure to obtain a verilog simulation license.”这一类错误通常是环境变量里没有指向合法的license文件或者网卡号不匹配。我的建议是直接在Linux下用开源仿真器或者Quartus自带的ModelSim吸收出问题的可能精力留到功能调试上。第二个检查点是复位极性。DE10板上的KEY按键默认是低电平有效而很多现成代码模板用高电平复位。如果直接把别人的SoC顶层接到KEY上会发现一上电CPU就不断复位UART什么都打印不出来。所以顶层模块里要统一复位的极性或者加一个反相器再进CPU。第三个检查点是时钟约束。DE10上50MHz输入时钟经过PLL后分频给CPU必须在Quartus里正确分配引脚。很多人的CPU设计和代码都没问题但因为没有约束set_input_delay分析器得出的Fmax是不可信的。学习项目可以直接把时钟约束加上避免后续加外设导致时序混乱。4. C语言接入GCC工具链、链接脚本与ecall边界4.1 安装riscv工具链与DE10上的编译方式写RISC-V裸机程序的编译链和普通嵌入式开发相似用riscv64-unknown-elf-gcc交叉编译再用elf文件转换成Verilog可加载的hex格式。工具链的安装方式取决于主机环境Linux下直接用包管理器安装macOS或Windows下则需要下载预编译的二进制包。无论哪种方式关键是确认riscv64-unknown-elf-gcc -v能正常输出版本信息。编译参数中必须指定-marchrv32i -mabiilp32告诉编译器生成RV32I指令集且整数寄存器为32位。如果漏掉这两个参数GCC默认可能生成RV64或带乘除法的指令CPU执行时会触发非法指令异常。实际操作命令如下riscv64-unknown-elf-gcc -marchrv32i -mabiilp32 -nostdlib \ -Tlink.ld -O2 -ffreestanding -c crt0.S -o crt0.o riscv64-unknown-elf-gcc -marchrv32i -mabiilp32 -nostdlib \ -Tlink.ld -O2 -ffreestanding -c main.c -o main.o riscv64-unknown-elf-ld -T link.ld crt0.o main.o -o kernel.elf riscv64-unknown-elf-objcopy -O verilog kernel.elf kernel.hex参数说明-nostdlib告诉链接器不要链接标准库的启动文件裸机程序里没有操作系统帮你准备栈帧所以crt0.S负责完成初始化的脏活。-ffreestanding提醒编译器不要假设标准库存在避免生成对malloc等函数的隐式依赖。最后一步objcopy把elf文件转换为$readmemh可以直接加载的hex格式Quartus会在综合时把它写进ROM初始化文件。4.2 链接脚本与crt0.S每个C程序的第一行汇编C语言运行的前提是栈指针有效、.bss段清零、.data段从ROM拷贝到RAM。这段工作由crt0.S完成链接脚本则决定这些段在CPU地址空间里的位置。/* link.ld - 裸机程序的段布局 */ OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { ROM (rx) : ORIGIN 0x00000000, LENGTH 16K RAM (rw) : ORIGIN 0x00004000, LENGTH 16K } SECTIONS { .text : { *(.text._start) *(.text*) } ROM .data : { *(.data*) } RAM AT ROM .bss : { __bss_start .; *(.bss*) *(COMMON) __bss_end .; } RAM __stack_top ORIGIN(RAM) LENGTH(RAM); }链接触点是__stack_top符号。crt0.S通过la sp, __stack_top初始化栈指针然后清空.bss段调用main函数。需要注意RAM区末尾要预留足够空间给栈否则栈向下增长会覆盖.bss段里的全局变量表现为函数调用返回后全局变量神秘清零。# crt0.S - 启动代码 .section .text._start .globl _start _start: la sp, __stack_top # 设置栈顶 la t0, __bss_start la t1, __bss_end 1: bgeu t0, t1, 2f # bss清零完毕跳转 sw zero, 0(t0) addi t0, t0, 4 j 1b 2: call main 1: j 1b # main返回后死循环这段代码里有一个很多人忽略的细节la伪指令在RV32I下会被展开为auipc addi两条指令所以启动代码链接地址必须与ROM的实际地址一致。__bss_start和__bss_end由链接脚本自动计算GCC的-nostdlib并不同样取消全局变量清零这步必须自己做掉。4.3 用内嵌汇编触发ecall用户态到内核态的跳转裸机C程序与“简易操作系统”的分界线就是ecall指令。C语言里写一个sys_write函数实际上是把参数放进寄存器然后执行ecall。这里有一个关键约定a7寄存器存系统调用号a0、a1、a2依次存参数。// syscall.h - 系统调用用户态接口 static inline long sys_write(int fd, const char *buf, int len) { register long a7 __asm__(a7) 64; // 系统调用号 register long a0 __asm__(a0) fd; register long a1 __asm__(a1) (long)buf; register long a2 __asm__(a2) len; __asm__ volatile (ecall : r(a0) : r(a7), r(a0), r(a1), r(a2)); return a0; }这段内嵌汇编的表达式中r(a0)表示a0既是输入也是输出——内核在mret返回后把返回值放在a0寄存器里。初学者容易漏掉这个约束导致优化后的代码在函数返回时用旧的a0值。OS内核对sys_write的处理则是从a0取出文件描述符从a1取出缓冲区地址从a2读取长度然后调用UART发送函数逐字节输出。系统调用号和Linux保持一致有一个额外好处同一份main.c在PC上用gcc编译可以当普通程序调试在RISC-V上则走ecall陷入内核。这个惯例让裸机调试阶段可以先在PC上验证串口输出逻辑再下载到DE10上跑。4.4 BSS、堆栈与printfC语言内存管理的裸机版裸机环境下想要直接使用printf需要理解printf的输出路径printf内部调用_write系统调用而_write在裸机环境里需要你自己实现。我的做法是先实现基础的uart_putc再写一个极简的_write让printf底层输出发往UART。// printf重定向 int _write(int fd, char *buf, int size) { for (int i 0; i size; i) uart_putc(buf[i]); // 等待TX_BUSY后写数据寄存器 return size; }这个函数实现后main里可以直接用printf打印十六进制或十进制数排错效率高得多。但注意printf的可重入性如果在Timer中断处理函数里调用printf而主程序恰好也在printf执行中两个线程会同时操作UART发送状态造成字符交错。简易OS阶段我的建议是定义一个大缓冲区printf先写入ring buffer由主循环统一输出这样既避免重入问题也为后续系统调用实现异步读写打基础。另一个内存管理的关键是栈深度。DE10项目里RAM只有16KB每次任务切换大约保存32个寄存器共128字节两个任务就需要两个独立的栈空间。我在main函数之前用数组分配了task1_stack[1024]和task2_stack[1024]放在.bss段里。如果任务里的局部变量或函数调用层级过深栈会向下溢出到.bss导致数据被破坏。排查手段是给每个任务栈填充0xAA模式每次切换后检查栈尾是否被改写。5. 简易操作系统上下文切换与DE10板级验证5.1 任务切换的完整汇编保存现场、换栈、恢复现场简易操作系统只需要一个核心函数——task_switch。它的作用是把当前任务的寄存器现场保存到自己的栈上加载下一个任务的寄存器现场然后通过mret回到用户态继续执行。32个通用寄存器加mepc、mstatus总共要保存34个字的现场。# task_switch.S - 保存当前任务现场并切换到下一个任务 # a0 当前任务栈指针的地址current_task-sp # a1 下一个任务栈指针的值next_task-sp .globl task_switch task_switch: addi sp, sp, -32*4 sw x1, 0(sp) sw x2, 4(sp) sw x3, 8(sp) # ... 保存x3到x31 sw x31, 124(sp) csrr t0, mepc sw t0, 128(sp) csrr t0, mstatus sw t0, 132(sp) sw sp, 0(a0) # 更新当前任务的栈指针 mv sp, a1 # 切换到下一个任务的栈 lw t0, 132(sp) csrw mstatus, t0 lw t0, 128(sp) csrw mepc, t0 lw x1, 0(sp) # ... 恢复x3到x31 lw x31, 124(sp) addi sp, sp, 32*4 mret保存顺序有一个讲究x2就是sp自身任务切换汇编中不能用“保存x2后继续压栈”来操作因为这会破坏现场。所以一般把x2留到更新current_task-sp这一步直接保存。恢复现场时同理先恢复其他寄存器最后恢复sp再addi保证栈指针对齐到32字节边界。RISC-V规范要求栈指针在函数调用边界上保持16字节对齐。如果你的上下文切换代码里跳过了对齐处理某些编译优化后的浮点或向量指令会触发对齐异常。CPU指令里即使没有硬性报错调试起来也相当烦人所以在crt0.S里就要让__stack_top满足16字节对齐。5.2 round-robin调度与DE10板级验证调度器本身并不复杂用Timer中断作为心跳每个tick到来时把当前任务挂起从就绪队列里找下一个任务然后调用task_switch。关键点是中断处理函数返回值之后不能再回到被打断的上下文而是直接跳转到新任务。我采用的验证方案是让两个任务分别控制DE10上的LED闪烁频率任务A让LED0以1Hz闪烁任务B让LED1以2Hz闪烁。如果UART串口能持续打印“Task A”和“Task B”而互不穿插混乱就说明上下文切换真正生效了。板上调试时UART输出中如果出现两次相同任务连续执行先确认Timer中断是不是被过早关闭了。另一个高发问题在mstatus的MPP字段mret返回时如果MPP不是11CPU会回到user模式而用户态执行csrw指令会再次触发异常表现为系统不断重启。通过检查mcause的值能快速判断异常类型和来源。到了这一步DE10上跑通的已经不只是“一个用Verilog写的RISC-V CPU”而是一个能响应中断、主动切换任务的最小操作系统。接下来再想深入可以考虑给任务加入优先级调度、用mstatus实现用户态和机器态隔离或者在UART驱动里加入中断接收——每一项都能把项目的深度推上一个台阶但当前这个round-robin版本已经是验证软硬件协同的最佳起点。本文还有配套的精品资源点击获取