HIT-RISC-V | 我在浏览器里跑起一颗并不完整、但足够有趣的 CPU

我第一次打开这个仓库时,脑子里其实有一个很朴素的问题:如果不装开发板、不接串口,能不能只在浏览器里把一颗小小的 RISC-V CPU 跑起来?

答案是可以,但“跑起来”远比我想象得有意思。屏幕上先出现的是模拟器,不是一个漂亮的操作系统界面。寄存器、内存、指令、ELF 文件、启动代码,还有那块负责把数据画出来的 VDP,全都挤在一个网页里。每一步看起来都不大,可一旦其中一个地址算错,画面就会安静地黑掉,或者 CPU 像迷路一样不断执行错误的指令。

仓库地址:HIT-RISC-V-Experiments

HIT-RISC-V-Experiments 仓库预览图

一、我为什么会把 CPU 放进浏览器

以前做硬件或汇编相关实验时,我对“程序正在机器里运行”这句话的理解比较模糊。写下几行 C 代码,编译,执行,结果就出来了。中间发生了什么,往往被编译器和操作系统一起藏了起来。

这个项目刚好把那层遮罩撕开了一点。browser-emulator 目录里有浏览器端的模拟器,负责装载程序、模拟 RISC-V 指令、维护寄存器和内存,并把部分输出交给显示系统。firmware/mini 则更像另一半实验台:这里放的是面向 RV32 的固件、启动代码、链接脚本和一些方便写自定义指令的宏。

我不需要先把真实开发板接到电脑上。只要刷新页面,就能重新启动一颗“机器”。当然,它不是完整的商用处理器仿真器,指令集和外设都做了取舍;但对学习来说,这种不完整反而很诚实:我可以看见边界,也可以看见程序究竟卡在哪。

二、实验一:从 ELF 文件开始,先让 CPU 找到第一条指令

第一次真正追这个项目时,我没有急着看图形效果,而是从程序怎么进入模拟器开始。固件被编译成 ELF,浏览器端需要把它读进来,再把代码段、数据段放到相应的内存位置,最后把程序计数器放到启动入口。

这一步听起来有点像“把文件打开”,实际却很容易错。ELF 里的地址不只是一个普通数组下标,入口地址、段的加载位置、对齐方式都要彼此一致。只要链接脚本和加载器的理解不一致,CPU 可能从一个看似合法、实际上没有代码的地方开始执行。

我当时最喜欢做的调试动作,是盯着程序计数器和寄存器变化,一条一条对照指令。看到算术指令终于把结果写进目标寄存器时,感觉和在调试器里单步完全不一样——因为这里没有真正的硬件替我兜底,那个寄存器就是我在代码里亲手维护出来的状态。

reset
  -> load ELF
  -> initialize memory
  -> set program counter
  -> fetch instruction
  -> decode and execute
  -> update registers / memory

这一段流程是整个项目的地基。后面无论是串口输出还是画面刷新,本质上都要先经过这条链路。

三、实验二:启动代码和链接脚本,程序到底从哪里站起来

CPU 能执行指令之后,下一件让我头疼的是启动代码。平时写应用程序时,入口函数几乎是理所当然存在的;在这个项目里,启动过程要自己把栈、数据区和跳转关系交代清楚。链接脚本决定代码和数据放在什么位置,boot 代码则负责把机器带到一个可以运行固件的状态。

我一开始很容易把“源码里的地址”和“模拟器里的地址”混为一谈。页面看起来没有报错,程序也确实执行了几条指令,可一旦进入函数调用,返回地址或栈的位置就开始不对。最后留下的现象通常不是一个醒目的错误,而是画面停在半截,或者 UART 只吐出一小段字符。

后来我开始把启动过程拆成很小的检查点:先确认程序计数器,再确认栈指针,然后让 UART 打出一个极短的标记,最后才进入更复杂的初始化。这样做有些笨,却比一上来追完整程序有效得多。每多亮出一个字符,都说明 CPU、内存和固件之间又对上了一层。

仓库里的自定义指令宏也让我意识到,汇编并不是“把 C 换成另一种写法”。指令编码、寄存器约定和调用约定都需要自己保持一致。一个宏名字写得再像函数,也不会自动拥有函数的安全边界。

四、实验三:让模拟器不只是算数,而是真的画出东西

如果一直盯着寄存器,实验很快就会变得干巴巴。真正让我产生代入感的是 VDP、tile map、调色板和 sprite 这些部分。它们把“内存里的几个数字”变成了屏幕上的方块、颜色和移动轨迹。

我先从 tile map 入手。地图数据本身并不神秘,难的是弄清楚每个位置到底表示什么:是 tile 编号,还是颜色索引?坐标的原点在哪里?一行结束后地址怎么跳?如果把宽度算错,画面不会立刻崩溃,只是所有图块会慢慢错位,像一张被拉坏的棋盘。

调色板则是另一个很直观的坑。数据都写进去了,图形也在移动,可颜色完全不对。那一刻我才发现,图像“看起来不对”背后可能不是绘图算法的问题,只是索引表和实际颜色的对应关系没有对上。

等到 sprite 能够在画面里移动时,体验突然从“读代码”变成了“我在控制一台机器”。键盘输入、坐标更新、碰撞边界和下一帧刷新彼此连起来,哪怕只是一个很小的移动方块,也足以让我反复刷新页面确认它是不是按预期走了一格。

这部分没有大型引擎帮我处理细节。每一个 tile、每一次坐标变化,最后都要落回内存和模拟外设。正因为画面简单,我反而更容易看懂一帧是怎样被拼出来的。

五、实验四:定时器、UART 和那些不肯安静的中断

当程序开始和外设交互,时间就变成了一个新问题。轮询当然容易理解:不断读状态、不断判断、不断等待。但如果所有事情都靠主循环盯着,程序很快会变得臃肿。仓库里保留了定时器、中断和 UART 调试相关的内容,我也正是在这里第一次认真面对“什么时候发生”这个问题。

定时器中断不像普通函数调用那样由我明确写出一条跳转语句。它更像有人在程序执行到某个时刻敲门,CPU 必须保存现场、跳到中断入口,处理完以后再回来。现场保存少一项,返回时的状态就可能已经不是原来的状态。

UART 调试在这里特别重要。图形坏掉时,屏幕只能告诉我“结果不对”;串口标记则能告诉我程序走到了哪个阶段。于是我会给初始化、定时器触发和异常路径分别放很短的输出。输出太多会干扰判断,太少又不够定位,这个分寸也需要自己慢慢摸出来。

有一次我以为是中断逻辑坏了,追了很久才发现其实是一个状态位没有及时清掉。中断不断重入,主循环自然什么也做不好。这个错误并不高级,却让我第一次切身体会到,硬件系统里的“一个标志位”真的可能改变整台机器的节奏。

六、这个仓库给我的感觉:像一块藏在网页里的实验板

RISC-V 这个项目最特别的地方,不是它实现了多少条指令,而是它把几个通常分散学习的层次压缩到了一个可以反复刷新、反复观察的页面里:

层次我在仓库里看到的东西它解决的问题
指令级 取指、译码、寄存器和内存 CPU 怎样执行一条指令
程序级 ELF、入口地址、启动代码 程序怎样被装进机器
固件级 链接脚本、UART、定时器、中断 程序怎样和机器协作
显示级 VDP、tile map、palette、sprite 状态怎样变成可见画面

我以前容易把这些内容看成几门不同的课:体系结构讲指令,嵌入式讲外设,图形学讲显示,软件工程讲项目结构。真正把它们放在同一个仓库里以后,边界没有消失,但关系变得明显了。一条错误的地址可能同时表现为错误的指令、错误的输出和黑掉的屏幕。

七、它还不完美,但我不想把这些痕迹抹掉

这个项目并不是一个完整的 RISC-V 生态,也不是可以拿来替代真实开发板的通用模拟器。部分依赖和浏览器环境比较旧,外设模型也只覆盖了实验需要的范围。文档里有些地方更像给当时的自己留的提示,而不是一份从零开始的教程。

但这正是它适合做学习记录的地方。很多问题没有被包装成“只要运行就能成功”的演示:地址错了会黑屏,启动顺序不对会停住,寄存器和中断没有维护好,程序就会用一种很沉默的方式告诉我——它不再相信我写的状态。

如果重新整理一次,我会补更清晰的指令覆盖说明、自动化测试和更完整的调试日志,也会把模拟器与固件之间的接口单独画出来。不过我大概仍然会保留那个最朴素的入口:打开浏览器,看一颗不算完整的 CPU 从第一条指令开始动起来。

仓库地址:HIT-RISC-V-Experiments

说明:本文根据个人历史实验材料和公开仓库整理,部分文字经过 AI 辅助润色;代码结构和技术细节以仓库当时的版本为准。课程资料仅用于学习回顾和个人作品整理,不用于当前课程作业或考试。

posted @ 2026-09-11 00:11  何以牵尘  阅读(5)  评论(0)    收藏  举报