ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从 C 模拟器到 Verilog 处理器核:ysyx 入门笔记

从 C 模拟器到 Verilog 处理器核:ysyx 入门笔记

第一篇 ysyx 笔记,我不打算写成励志总结,就按实际动手的顺序来记:从开机装系统到第一个仿真波形跑出来,中间踩了哪些坑、为什么这么选、哪些参数是拍脑袋定的、哪些是踩了坑才改的。ysyx 这套路线本质上是一条处理器芯片设计的入门路径,从写 C 程序开始,一路走到用硬件描述语言拼出一个能跑指令的处理器核。它解决的问题很具体:很多人学计算机学了几年,知道 CPU 是“取指、译码、执行”,但从来没自己造过一个;也见过不少人 Verilog 语法背得滚瓜烂熟,却不知道一条add指令在数据通路上到底走哪几根线。这条路线就是把这层窗户纸捅破。适合谁来参考?零基础但愿意花时间啃的在校生、想从前端、后端转到底层硬件的开发者,以及单纯想知道“程序到底怎么跑起来的”那类人。下面这些内容都是我在第一阶段反复试错之后整理出来的,能直接参考着复现。

1. 拆解 ysyx 学习路线:先搞清楚这条路在训练什么

1.1 一句话定位:从一行 C 代码走到一块能跑程序的处理器

很多人的困惑在于:ysyx 到底是学硬件还是学软件?我的判断是,它训练的是“软硬交界处的那层能力”。一个 C 程序编译成机器码之后,就是一堆 32 位整数,处理器要做的事情无非是取出来、看懂它、执行它、写回结果。这条路线的全部内容,就是让你分别用软件的方式(写一个 C 模拟器)和硬件的方式(写 Verilog 模块)把这件事实现两遍。

为什么非得两遍?因为只写软件模拟器,你会把硬件当成“无限快、随便访问内存”的理想模型;只写 Verilog,你又很容易写出“语法正确但语义错误”的东西,因为你不清楚一条指令的行为规范到底该是什么样。两遍都写一遍,你对“指令集架构是一份契约”这句话才会有实感——软件和硬件各自照着这份契约实现,中间靠测试程序对齐。

我在第一周最大的收获不是学会了什么语法,而是脑子里建立起一张图:程序计数器指向哪、指令从哪取、操作数从哪来、结果写到哪。这张图一旦立住,后面看任何处理器框图都不再是天书。

1.2 为什么顺序是 Linux→C→数字逻辑→Verilog→SoC

这个顺序不是随便排的,每一层都是下一层的前置依赖。Linux 命令行是第一层,因为后面所有的编译、仿真、调试都在终端里完成,make、grep、git这些工具用不顺,后面每一步都会被卡住。第二层是 C 语言,尤其是指针、结构体、函数指针、宏这四样,写模拟器时几乎全靠它们。第三层是数字逻辑,组合逻辑、时序逻辑、状态机,这是硬件的思维方式,跟写软件完全不同——软件是顺序执行,硬件是同时存在。

第四层 Verilog 只是数字逻辑的“表达工具”,语法一周就能过一遍,真正难的是用它描述出正确的时序行为。最后一层才是 SoC 集成,把处理器核、总线、内存、外设拼到一起。这个顺序如果你打乱,比如直接跳到 Verilog 写 CPU,大概率会卡在“为什么我的波形里信号全是 X”这种问题上,因为你不清楚复位和时钟的关系。

我个人的建议是:不要跳阶段,但可以压缩阶段。Linux 和 C 如果本来就会,两天过一遍查漏补缺就行;数字逻辑那部分不要压缩,宁可多画几遍真值表和状态转移图。

1.3 第一篇笔记该记什么、不该记什么

第一篇笔记最常见的失败模式,是抄了一大堆命令和链接,三个月后自己都看不懂当初在干什么。我踩过这个坑,后来改成只记三类东西:第一类是结论,比如“工具链用哪个版本、为什么”;第二类是证据,比如某条命令的实际输出、某个报错的完整信息;第三类是复现步骤,从干净环境开始,按顺序敲哪些命令能回到当前状态。

不该记的是:教科书式的概念定义(网上到处都有)、大段复制粘贴的教程原文、没有上下文的代码片段。这些东西记了也不会回头看。我的笔记文件里现在有一条很土但极其有用的规则——任何一个让我卡住超过 20 分钟的问题,必须写三行:现象、原因、解决办法。这三行在后面的阶段救过我很多次,因为它们全是别人文档里不会写的东西。

2. 环境搭建:把地基打牢再谈设计

2.1 系统选择与依赖清单:为什么我推荐干净的原生 Linux

环境这块我试过三种方案:虚拟机、容器、原生双系统。结论是原生安装的 Linux 最省心,虚拟机次之,容器最不推荐用来做硬件仿真——不是说容器不行,而是图形化的波形查看器在容器里配显示转发很折腾,而看波形是这个阶段的高频操作,折腾一次不划算。

系统版本上,我倾向选一个长期支持的稳定发行版,不要追最新。硬件工具链对系统库版本比较敏感,追新版本经常会遇到某个库改名或者降级的问题。装完系统后第一件事是更新软件源并安装基础构建工具:

sudo apt update sudo apt install -y build-essential git curl wget \ vim tmux tree htop gdb make cmake python3 python3-pip

这份清单里看着“多余”的几个其实都有用:tmux让你在编译等待时能开另一个窗口查资料;tree用来快速看清工程目录结构;htop在仿真卡死时能一眼看出是 CPU 跑满还是内存爆了。这些是小工具,但能明显降低每天的摩擦成本。

提示:不要用 root 用户日常操作,也不要sudo着跑编译。有些构建脚本会把生成文件的所有者改成 root,后面普通用户再改代码就会权限报错,排查起来很费时间。

2.2 交叉编译工具链:从下载到验证的三步走

我们要写的程序最终要跑在自己设计的处理器上,而自己设计的处理器指令集跟宿主机的 x86 不一样,所以需要交叉编译工具链——在 x86 上编译出目标架构能执行的机器码。以 RISC-V 为例,常见的工具链前缀是riscv64-unknown-elf-或riscv64-linux-gnu-,两者区别在于前者面向裸机程序(不带操作系统),后者面向带操作系统的程序。第一阶段写裸机测试程序,用前者更合适。

安装方式有两种:包管理器直接装,或者源码自己编译。包管理器装的好处是快,坏处是版本可能偏旧、组件不全;源码编译的好处是可控,坏处是要等半小时以上。我的建议是先用包管理器装一版,跑通验证流程,后面如果发现缺组件再考虑源码编译。

sudo apt install -y gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf

装完必须做一步验证,这一步很多人会跳过,结果后面报错时根本分不清是工具链问题还是代码问题:

riscv64-unknown-elf-gcc --version riscv64-unknown-elf-objdump --version printf 'int main(){return 0;}' > t.c riscv64-unknown-elf-gcc -march=rv32e -mabi=ilp32e -nostdlib -Ttext=0x0 t.c -o t.elf riscv64-unknown-elf-objdump -d t.elf | head -20

最后一条命令能看到反汇编出来的指令,说明工具链是通的。注意-march和-mabi这两个参数必须匹配,一个指定指令集(比如 rv32e 是最小的 32 位整数指令集),一个指定调用约定(比如 ilp32e 表示 int、long、指针都是 32 位)。这两个参数写错,编出来的机器码你后面在模拟器里根本跑不通。

2.3 仿真三件套:iverilog、verilator、gtkwave 的配置与版本坑

硬件这边需要三样东西:一个能编译 Verilog 的仿真器、一个能生成波形文件的运行时、一个能看波形的查看器。常用组合是iverilog + vvp + gtkwave,或者用verilator做高性能仿真。第一阶段我建议先上 iverilog,因为它对语法宽容、上手快、报错信息还算友好;等设计规模大了再切 verilator,因为 verilator 是把 Verilog 翻译成 C++ 再编译,速度快很多,但对代码风格要求严格,很多“能仿真但不可综合”的写法它直接报错。

sudo apt install -y iverilog gtkwave iverilog -V gtkwave --version

这里有个很典型的坑:iverilog 和 gtkwave 的版本不匹配时,波形窗口打开可能一片空白。原因是新版 gtkwave 对波形文件格式的支持有变化,旧版 iverilog 生成的格式它读不全。解决办法不是去改代码,而是把两者都升到较新的版本,或者统一用发行版仓库里的版本。我当时折腾了一个晚上,最后发现只是版本问题,非常不值。

另外一个容易忽略的点是波形文件的体积。一次仿真如果记录所有信号,几百个周期就能生成上百 MB 的文件,打开会卡死。正确做法是在测试平台里只记录你当前关心的那几个信号,或者只记录特定时间窗口。

2.4 目录结构与版本管理:让三个月后回来的自己看得懂

工程目录我建议一开始就定好,别等文件堆成山再整理。我的做法是分成四层:

目录放什么说明
tool/脚本、工具链配置环境相关的都收在这,换机器时直接复制
src/主体代码软件模拟器和硬件模块各建子目录
test/测试程序与测试平台测试用例按指令类型分组
note/笔记与日志每篇笔记按日期或阶段命名

版本管理这块,我要强调一个反直觉的点:第一次提交要尽早,而且提交信息要具体。很多人习惯写完一大坨再提交,结果一出问题就回不去了。我现在的习惯是每完成一个“能跑通的最小单元”就提交一次,提交信息写清楚“做了什么、验证到什么程度”,比如“完成取指模块,能正确读出前 8 条指令”。

另外,.gitignore要一开始就配好,把编译产物、波形文件、日志文件都排除掉。波形文件动辄几十 MB,提交进去会让仓库变得极难管理,而且完全没有版本比对的意义。

3. 核心实现:从 C 解释器到 Verilog 数据通路

3.1 第一步:写一个能跑十几条指令的取指-译码-执行循环

第一版模拟器不要贪多,能跑十几条最基础的指令就够了。核心结构就是一个循环,循环体里做四件事:取指、译码、执行、更新程序计数器。下面是我第一版的骨架,用 C 写的,去掉了很多错误检查,重点看结构:

#include <stdint.h> #include <stdio.h> #define MEM_SIZE (128 * 1024) static uint8_t mem[MEM_SIZE]; static uint32_t reg[32]; static uint32_t pc = 0; static uint32_t fetch() { uint32_t inst = 0; for (int i = 0; i < 4; i++) inst |= (uint32_t)mem[pc + i] << (8 * i); return inst; } static void execute(uint32_t inst) { uint32_t opcode = inst & 0x7f; uint32_t rd = (inst >> 7) & 0x1f; uint32_t rs1 = (inst >> 15) & 0x1f; uint32_t rs2 = (inst >> 20) & 0x1f; uint32_t imm = (int32_t)inst >> 20; /* 简化版立即数,仅够跑通 */ switch (opcode) { case 0x13: reg[rd] = reg[rs1] + imm; pc += 4; break; /* addi */ case 0x33: reg[rd] = reg[rs1] + reg[rs2]; pc += 4; break; /* add */ case 0x6f: pc += imm; break; /* jal */ case 0x67: { uint32_t t = pc + 4; pc = (reg[rs1] + imm) & ~1u; reg[rd] = t; break; } default: printf("unknown inst @0x%x: 0x%08x\n", pc, inst); pc += 4; } } int main() { reg[0] = 0; /* 这里省略加载镜像的代码,实际用 fread 把 bin 读进 mem */ while (pc < MEM_SIZE) { uint32_t inst = fetch(); if (inst == 0) break; execute(inst); reg[0] = 0; /* x0 恒为 0,这行不能忘 */ } return 0; }

这段代码有大量简化,比如立即数的提取只处理了 I 型和 J 型的部分情况,分支指令也没写。但它足以让你理解“一条指令就是一个 32 位整数,不同位段承担不同含义”这件事。位段提取是这一步的核心技能,你必须能对着指令编码表,把任意一条指令拆成 opcode、rd、rs1、rs2、funct3、funct7 这些字段。这个能力在写 Verilog 时是直接复用的。

注意:reg[0] = 0` 这一行看起来多余,但漏掉它会引发非常隐蔽的 bug——某条指令把结果写进了 x0,后续所有依赖 x0 的指令全错,而你在软件层面很难定位。硬件里 x0 是靠接地的寄存器实现的,软件里就得靠强制清零来模拟。

3.2 关键设计点:内存模型、寄存器文件与指令编码的一致性

三个设计点值得展开说,因为它们是后面软硬件对照验证的基础。

第一是内存模型。最简单的做法就是一个大字节数组,按小端序(低位字节在低地址)读写。小端序这个选择必须跟你的处理器设计保持一致,否则同一个 32 位整数在软件和硬件里的字节排列不一样,测试程序会莫名其妙算错。我在这一步犯过错:模拟器用大端序拼装,硬件按小端序解读,结果同一段程序两边跑出不同结果,排查了大半天。

第二是寄存器文件。软件里就是一个uint32_t[32]数组,硬件里是带两个读端口、一个写端口的存储单元。要注意的是硬件里读是组合逻辑(地址一变输出就变),写是时序逻辑(时钟沿到来才生效)。这个“读写不对等”的特性,是后面写 Verilog 时最容易搞错的地方。

第三是指令编码的一致性。字段位置、立即数符号扩展方式、分支偏移的计算方式,这三样必须严格按规范来。我的做法是把指令编码表打印出来贴在屏幕边上,每实现一条就对着核对一遍位段。慢是慢了点,但比后面调试快得多。尤其是符号扩展,一个 12 位立即数要扩展成 32 位,高位补 0 还是补符号位,写错了在某些输入下正确、某些输入下错误,极难发现。

3.3 第二步:把同一套逻辑翻译成 Verilog 模块

有了 C 版本的语义基础,翻译成 Verilog 就顺理成章了。硬件版本的关键差别在于:没有“循环”,只有“每个时钟周期做一件事”。所以软件里的 while 循环,在硬件里就变成了状态机的状态转移。

取指模块的一个简化写法:

module fetch( input wire clk, input wire rst, input wire stall, input wire [31:0] pc_next, output reg [31:0] pc, output wire [31:0] inst ); always @(posedge clk) begin if (rst) pc <= 32'h8000_0000; else if (!stall) pc <= pc_next; end assign inst = mem[pc[18:2]]; /* 简化:按字寻址的指令存储器 */ endmodule

这段代码里有两个细节值得说。第一个是复位的处理:PC 复位到一个固定的起始地址,具体地址由你的存储映射决定,我这里随便写了一个常见值。第二个是stall信号:这是性能优化的入口,当流水线需要暂停时,PC 要保持在原地不动。第一版可以先不实现暂停逻辑,但接口先留出来,后面加流水线的时候不用大改。

再看 ALU,它最能体现软硬对照的价值:

module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] op, output reg [31:0] y ); always @(*) begin case (op) 4'd0: y = a + b; 4'd1: y = a - b; 4'd2: y = a & b; 4'd3: y = a | b; 4'd4: y = a ^ b; 4'd5: y = (a < b) ? 32'd1 : 32'd0; default: y = 32'd0; endcase end endmodule

always @(*)表示这是组合逻辑,输出随输入立即变化。这个块里必须用阻塞赋值=,如果写成<=,仿真可能看似正常但综合出来的电路行为不对。这条规则我刚写 Verilog 时被坑过一次:一个组合逻辑块里混用了两种赋值,仿真波形上偶尔出现延迟一拍的结果,找了两天才定位到。

3.4 软硬对照验证:用同一段测试程序对比两边结果

这是我认为整条路线里最有价值的一步。做法很简单:写一段汇编测试程序,同时跑在 C 模拟器和 Verilog 仿真里,对比最终寄存器状态和内存内容。如果两边一致,说明你的语义实现是对齐的;如果不一致,就缩小范围,一段一段测试。

测试程序的粒度建议从粗到细:先跑一条指令,再跑两条互相依赖的指令,再跑一个小循环,最后跑一段完整的计算程序(比如求斐波那契数列、冒泡排序)。每一层都要能通过再往下走。

对比的方式有两种:一种是让 Verilog 仿真在结束时把寄存器堆写成文件,然后用脚本跟 C 模拟器的输出比对;另一种更直观,在测试平台里用$display打印关键信号,跟 C 版本的printf对照。前一种适合自动化、适合回归测试,后一种适合调试。我自己的做法是:调试阶段用打印,验证阶段用文件比对。

提示:写测试程序时,最后加一条“写特定值到特定内存地址”的指令作为结束标志。这样仿真器知道什么时候该停,不用靠“跑到多少周期就结束”这种不可靠的方式。

4. 常见问题与排查技巧实录

4.1 环境与工具链问题速查表

环境问题占了我第一阶段总时间的四成以上,整理成表格方便对号入座:

现象大概率原因处理方式
command not found工具链没装或在非登录 shell 下找不到检查安装路径,确认已写入 PATH
编译报找不到头文件目标平台的库没装齐装对应平台的 lib 开发包
生成的可执行文件跑不起来编成了宿主平台架构用file命令确认目标架构
仿真器版本不支持某个语法版本过旧升级仿真器,或改用兼容写法
波形窗口打开是空白波形文件格式与查看器不匹配统一两者版本,或换输出格式
编译超时或卡死磁盘空间不足或内存耗尽用df -h、free -h排查

这张表里的每一条我都实际遇到过。最有迷惑性的是第二条和第三条:报错信息看起来像是代码问题,实际是环境和架构问题。我现在的习惯是先确认环境,再怀疑代码,顺序反过来会浪费大量时间。

4.2 编译链接与运行时报错

链接阶段的错误往往比编译阶段更难懂。几个高频场景:

第一个是链接地址不对。裸机程序的链接地址必须跟你的处理器存储映射对齐,如果你的处理器从某个固定地址开始取指,而链接脚本把代码放在了另一个地址,程序就会跑飞。表现是模拟器刚启动就执行到非法指令。解决办法是写一个最简单的链接脚本,明确指定代码段起始地址。

第二个是入口点不对。链接器默认的入口可能不是你写的那个函数,需要显式指定。表现是程序能加载但第一条指令就不是你期望的那条。

第三个是启动代码缺失。裸机程序没有运行时环境,栈指针没人初始化,全局变量没人清零。需要自己写一小段汇编启动代码,设置栈指针、清零 BSS 段、跳到主函数。这段代码不长,但绝对不能省,因为 C 代码里任何一个函数调用都要用栈。

riscv64-unknown-elf-gcc -march=rv32e -mabi=ilp32e -nostdlib \ -Ttext=0x80000000 -e _start start.S main.c -o prog.elf riscv64-unknown-elf-objcopy -O binary prog.elf prog.bin

-e _start指定入口符号,objcopy把 ELF 转成纯二进制镜像,这个二进制文件才是模拟器和硬件真正读进去的东西。这一串命令我建议写进 Makefile,手工敲容易漏参数。

4.3 仿真与波形排查

仿真阶段最典型的现象是信号全是 X。X 在 Verilog 里表示“未知”,通常意味着某个寄存器没有被初始化。常见原因有三个:复位信号没接好、复位时间不够长、某个寄存器在复位分支里漏写了。

排查方法很土但有效:从输出往回一层层看波形,找到第一个变成 X 的信号,然后看它的驱动源。如果驱动源本身正常,那就是赋值时机的问题;如果驱动源也是 X,就继续往回找。这个过程不要跳步,一层层退回去,绝大多数情况下三四个层级内就能定位。

第二个高频问题是时序不对:波形看起来信号都对,但结果就是差一拍。这种情况我一般先检查三件事:时钟沿用的是上升沿还是下降沿、非阻塞赋值有没有写成阻塞赋值、组合逻辑里有没有引入意外的锁存器(if 语句没写全所有分支就会这样)。第三条特别隐蔽,因为仿真能过,但综合工具会报 latch 警告,而很多人不看警告。

第三个问题是仿真跑不完。如果设计里有组合逻辑环路,仿真会卡在同一个时间步里无法推进。表现是仿真时间停在某个值不动、CPU 跑满。定位方法是看仿真器的输出,或者在可疑模块里加打印。

4.4 那些文档里不会写的经验

几条纯经验的东西,都是踩坑换来的。

第一,日志比调试器好用。图形化调试器看着高级,但在硬件仿真里,把关键信号打印到文件再事后分析,效率往往更高。我习惯在测试平台里加一个“每 N 个周期打印一次 PC 和关键信号”的机制,出问题时直接翻日志,比单步跟踪快得多。

第二,最小复现大于一切。遇到 bug 别在原工程里改,新建一个最小工程,只保留能复现问题的最少代码。这个过程本身经常就能让你发现问题——我至少有三次在剥离代码的过程中发现是自己某个参数写错了。

第三,别在深夜调时序问题。时序 bug 的本质是“你的直觉和实际行为不符”,疲劳状态下直觉更不可靠,越调越乱。我的做法是遇到这类问题先记录现象、写清楚已经排除了哪些可能,第二天再看,往往十分钟就解决了。

第四,定期从零重建环境。每隔一段时间,按自己的笔记从干净环境重装一遍,看看能不能在半小时内跑通。这个过程能帮你发现笔记里漏记的隐含依赖——很多东西你以为“当然是这样”,换台机器就不成立了。

5. 节奏与笔记方法:让第一篇笔记不至于烂尾

5.1 时间分配:给每个阶段设一个硬性出口

这条路线最大的风险是中途放弃,而放弃的原因通常不是难,是没有进度感。我的做法是给每个阶段设一个“出口条件”,条件达成就算过关,不追求完美。

Linux 和 C 的出口是:能在命令行里完成文件操作、能写一个带指针和结构体的小程序并编译通过。数字逻辑的出口是:能手画一个四状态状态机并说明每个状态的转移条件。Verilog 的出口是:能写一个计数器模块并仿真出正确波形。模拟器的出口是:能跑通十条指令并与预期结果一致。每个出口我都控制在几天内,完成就进入下一阶段,遇到问题记进笔记,但不允许卡住超过一个设定时长。

这个方法的逻辑是:入门阶段的深度优先要适可而止。很多细节在后面的阶段会自然理解,一开始死抠反而会磨掉热情。当然,出口条件不能随便降低,尤其是“能跑通”这三个字,必须是真跑通,不是“看起来差不多”。

5.2 笔记的写法:把“结论+证据+复现步骤”三件套固定下来

最后说说笔记本身。我给每篇笔记定了一个固定结构,写起来不用想格式,直接往里填:

  • 本阶段目标:一句话,最好可验证
  • 结论清单:这阶段确认下来的事实,比如工具版本、参数取值、命令写法
  • 踩坑记录:现象、原因、解决办法,三行一条
  • 复现步骤:从干净环境到当前状态,按顺序的命令序列
  • 待验证的疑问:暂时没搞明白的,留到后面回头补

这个结构的好处是,三个月后回来看,你能快速判断“当时的结论现在还有效吗”。“待验证的疑问”这一栏特别重要,它相当于给自己留了一堆钩子,很多当时没想通的问题,在后续阶段会突然有答案,这时候回去补一句,知识就闭环了。

我个人在实际操作中的体会是:笔记的价值不在于写得多漂亮,而在于能不能让你在半年后不看教程就把环境重建出来。我现在衡量一篇笔记好坏的标准就一条——照着它做,能不能在一个小时内从空系统跑到第一个波形出来。如果不行,说明笔记里还缺东西,补上。

返回列表