简介:面向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编译器在-march=rv32i下遇到乘除法会调用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) 7'b0110011: begin // R-type: add, sub, and, or, slt reg_write = 1'b1; alu_src = 1'b0; // 第二个操作数来自寄存器堆 mem_write = 1'b0; end 7'b0010011: begin // I-type arith: addi, ori, slli reg_write = 1'b1; alu_src = 1'b1; // 第二个操作数来自立即数 mem_write = 1'b0; end 7'b0000011: begin // load: lw, lb, lhu reg_write = 1'b1; mem_read = 1'b1; end 7'b0100011: begin // store: sw, sb, sh reg_write = 1'b0; mem_write = 1'b1; end 7'b1100011: begin // branch: beq, bne, blt branch = 1'b1; reg_write = 1'b0; end default: begin reg_write = 1'b0; mem_write = 1'b0; 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 <= 32'h0000_0000; else if (branch_taken) pc <= pc_branch_target; else pc <= pc + 32'd4; 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里实现异常处理的寄存器组如下:
// 异常相关CSR:mstatus、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 <= 32'h0000_1800; // MPP=11, MPIE=0 mepc <= 32'h0; mcause <= 32'h0; end else if (csr_we) begin case (csr_addr) 12'h300: mstatus <= csr_wdata; 12'h341: mepc <= csr_wdata; 12'h342: mcause <= csr_wdata; endcase end end这组寄存器的关键行为是:CPU执行ecall时,硬件自动将当前PC存入mepc,将异常原因写入mcause,然后把PC跳转到mtvec指向的异常入口地址。操作系统只需要在入口处保存现场、调用C函数处理异常,最后执行mret指令返回。mret会从mepc恢复PC,并把mstatus.MPIE写回MIE,完成一次完整的陷入与返回。
有一个坑值得提醒:在实现中断嵌套前,必须保证ecall的mepc是pc本身还是pc+4。RISC-V规范里ecall不自动加4,所以操作系统处理完系统调用后要自己把mepc+4再写回,否则会死循环在同一个ecall里。这一步非常容易出错,而且是仿真中不容易暴露出来的问题。
3. SoC与外围:内存映射、UART多字节收发和Timer中断
3.1 内存映射表:把UART、Timer、GPIO编到统一地址空间
CPU核心一旦跑起来,下一步就是让它能“看到”外设。最简单的方式是使用内存映射总线:CPU发出地址,地址译码器判断该地址属于哪个外设,再选通对应的读回数据或写入数据。DE10项目里我常用的地址空间分配如下:
| 地址范围 | 设备 | 读行为 | 写行为 |
|---|---|---|---|
| 0x0000_0000 - 0x0000_3FFF | ROM(程序存储) | 返回指令 | 不可写 |
| 0x0000_4000 - 0x0000_7FFF | RAM(数据存储) | 返回数据 | 写入数据 |
| 0x0000_8000 - 0x0000_8003 | UART数据寄存器 | 读取接收FIFO | 写入发送FIFO |
| 0x0000_8004 - 0x0000_8007 | UART状态寄存器 | bit0=TX_BUSY | 无 |
| 0x0000_8010 - 0x0000_8013 | Timer控制寄存器 | 当前计数值 | 写入初值 |
| 0x0000_8014 - 0x0000_8017 | Timer中断状态 | 中断标志位 | 写1清零 |
| 0x0000_8020 - 0x0000_8023 | GPIO输出 | 当前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 = 11'd11; // 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 <= 1'b1; tx_busy <= 1'b0; end else begin if (tx_busy) begin if (clk_cnt == BAUD_DIV - 1) begin clk_cnt <= 0; if (bit_cnt == 4'd10) begin tx_busy <= 1'b0; end else begin bit_cnt <= bit_cnt + 1; case (bit_cnt) 4'd0: txd <= 1'b0; // 起始位 4'd1: txd <= tx_data[0]; 4'd2: txd <= tx_data[1]; // ... 逐位发送 4'd9: txd <= 1'b1; // 停止位 endcase end end else begin clk_cnt <= clk_cnt + 1; end end else begin if (tx_start) begin tx_busy <= 1'b1; 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 计数器产生系统tick:Timer寄存器与中断信号
简易操作系统的调度需要“心跳”,这个心跳由硬件Timer产生。DE10项目里我实现的Timer本质上就是一个可重装载的down counter,计数到0时产生一个脉冲中断,同时自动从预设值重新开始计数。
设计上也可以反过来用up counter加比较器:计数器每个时钟周期加一,当计数值等于比较寄存器时清零并产生中断。两种方式差别不大,我习惯用down counter,因为创建定时器中断时间较直观,预设值就是中断间隔周期计数。
// Timer:32位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 <= 32'hFFFF_FFFF; timer_irq <= 1'b0; end else begin if (timer_cnt == 32'd0) begin timer_cnt <= timer_reload; timer_irq <= 1'b1; end else begin timer_cnt <= timer_cnt - 1; timer_irq <= 1'b0; end end end这里timer_reload的可设置范围决定OS的最小调度周期。如果CPU时钟是20MHz,timer_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能正常输出版本信息。
编译参数中必须指定-march=rv32i -mabi=ilp32,告诉编译器生成RV32I指令集且整数寄存器为32位。如果漏掉这两个参数,GCC默认可能生成RV64或带乘除法的指令,CPU执行时会触发非法指令异常。实际操作命令如下:
riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib \ -Tlink.ld -O2 -ffreestanding -c crt0.S -o crt0.o riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -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、堆栈与printf:C语言内存管理的裸机版
裸机环境下想要直接使用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 = 当前任务栈指针的地址(¤t_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不是11,CPU会回到user模式,而用户态执行csrw指令会再次触发异常,表现为系统不断重启。通过检查mcause的值,能快速判断异常类型和来源。
到了这一步,DE10上跑通的已经不只是“一个用Verilog写的RISC-V CPU”,而是一个能响应中断、主动切换任务的最小操作系统。接下来再想深入,可以考虑给任务加入优先级调度、用mstatus实现用户态和机器态隔离,或者在UART驱动里加入中断接收——每一项都能把项目的深度推上一个台阶,但当前这个round-robin版本已经是验证软硬件协同的最佳起点。
本文还有配套的精品资源,点击获取