跳转至

指令译码

📅 1.26:24,25两天玩耍后对计算机运行过程的更深的理解(3h)

读讲义

一头雾水,什么都不懂,并对立即数产生了极大的疑惑与不解。 跟着讲义继续进行下去,花了估计两个小时之久实现了NEMU本体代码的跟踪调试: 存在一些抽象的地方一开始没有理解(本体程序在运行上竟然依赖客户程序的执行的调试,感到不可惊诧且难以理解),但后来醒悟:

心得:Host 与 Guest 的执行流差异———— 操作差异:终端的 si 是客户程序的指令级步进;VS Code 的 F10/F11 是模拟器的源码级步进。 映射关系:Guest 的 1 步 = Host 的 N 步。 在未打断点时,si 触发 NEMU 内部快速执行完一次 cpu_exec(1) 循环。 在打断点时,通过 IDE 逐步执行 C 代码,实际上是在解剖这一条指令的生命周期(Fetch -> Decode -> Execute)。 调试意义:客户程序的“不动”是因为我在底层拦截了它的时间流。只有底层 C 代码逻辑正确执行完毕,上层的客户程序才能正确“迈步”。如果底层指令未实现(Panic),客户程序就会永远停在世界的尽头。

总结与信心

虽然今天下午进展极其缓慢,也几乎没有进展,但是学会跟踪调试底层代码,并且明晰底层代码和客户代码的关系,后面再了解相关底层进程可以有很聪明的稳妥的办法了。 但也不免生出一点忧虑:下午对底层代码的跟踪调试,部分照着讲义能进行操作,其余文件配置几乎都是跟着gemini3pro搞的,虽然自己也能明白一点原理,但他妈的遇到这些纷繁复杂的问题还是没法自己独立地,三下五除二地解决,以后再遇到类似情况,又是极其麻烦的一通折腾。

📅 1.27:由立即数字节序之问生发出的联想与感悟(3h)

无ai辅助仅读讲义,找到指令补写的方法与入口

还行吧,就是在指定目录下面编译文件(几乎肯定是错的),然后看反汇编文件(或者报错信息直接看少什么指令)

模拟器的本质与字节序

最高潮的发言: 实际的逻辑是:因为我是模拟器,虚拟机,所以不得不从主机(物理机)的硬件来走我自己的数据, 而为了数据在流过物理硬件后就像没有流过一样(让他看起来好像只在我这个架构上运行),我需要保证主机和虚拟机的架构差异对我的数据流没有干扰,,因此考虑字节序,这也就是这两个问题的核心考察

ai总结 1.旧认知:NEMU 是一个独立的“虚拟机器”,程序在里面自主运行。

新认知:NEMU 本质上是 “ISA 模拟器 + 硬件差异弥合器”。 软件无自主性:模拟器只是代码(剧本),真正执行读写操作的永远是宿主机(Host)的物理 CPU。 API 视角:模拟器是面向宿主机硬件的一层 API 接口,它的职责是调用宿主机的能力来模拟目标架构的行为。

  1. 字节序问题的本质逻辑

    字节序冲突的根源在于“存储”与“解释”的分离

谁决定存储(摆放顺序)? > Guest(目标架构)。 二进制文件(bin)一旦编译生成,字节序就固化了(x86 为小端,68k 为大端)。 加载到内存(pmem)时,是原封不动的字节流搬运。 谁决定解释(读取视角)? > Host(宿主架构)。 物理 CPU 只能按照自己的硬件连线逻辑(端序)去理解内存中的数据。

  1. 判定 是否需要进行字节序翻转(Swap),仅取决于以下公式:

    Need Swap=(Guest架构端序≠Host架构端序) 只要两者不一致,模拟器(中间层)就必须介入进行“欺骗”。

  2. 哲学思考:透明性(Transparency) 虚拟化的终极目标是“透明”。 数据流经了宿主机的物理电路,受到了宿主机特性的干扰。

    模拟器的代码必须消除这种干扰,让数据“看起来”像是从未流过宿主机,而是在纯粹的目标架构中流动。 “弥合差异”就是为了维持这种“完美的幻象”。

📅 1.28:缓慢进展铺垫后理解与认知的井喷(4h)⚡🧠🧠🧠🧠🧠

一、 框架层如何兼容多架构 (ISA Agnostic)*

核心机制:通过 宏定义 (Macros) 和 构建系统 (Build System) 实现隔离与多态。 实现方式: Kconfig 生成配置宏(如 CONFIG_ISA_riscv32)。 Makefile 根据配置选择编译特定的架构目录(src/isa/riscv32/ vs x86/)。 上层统一的接口函数,如isa_exec_once()

二、 取指函数 inst_fetch(pc, len) 深度解析

len (指令长度) 的意义: 决定了 PC 指针在取完当前指令后,向后移动的字节步长。 定长指令集(RISC-V/MIPS):len 固定(如 4 )。 变长指令集(x86):len 动态变化,取决于当前指令的编码格式。 len 由 ISA 规范决定 vaddr (虚拟地址) 的意义: 代表内存地址,其数据类型宽度(uint32_t vs uint64_t)由机器字长决定。

由此衍生,为什么对主程序的调试必须要有一个客户程序在跑才能实现?因为主程序是模拟cpu,需要吃指令才能动起来,因此必须要编译运行一个客户程序,交叉编译产生指令给NEMU吃

交叉编译 (Cross Compilation): 在主机(x86)上生成目标架构(RISC-V)的可执行代码。 因为主机无法直接运行非本机架构的指令,必须通过模拟器解释执行。 加载机制 (Loading): NEMU 启动时,通过 fread 将客户程序的二进制文件(.bin)直接拷贝到模拟的物理内存数组(pmem[])中。 视角转换:主机眼中的“数组赋值”,就是客户机眼中的“程序装载”。

NEMU 指令流控制——dnpc 与 snpc 的辩证关系 核心发现:dnpc 与 snpc 的分离,本质上是为了处理程序控制流(Control Flow)的改变。

实现差异化的契机:特定汇编指令 由特定类型的指令人为制造的。(跳转)

触发者:控制流指令(Control Flow Instructions),如 跳转 (jal/jalr) 和 分支 (beq/bne)。 行为:这些指令在执行阶段(Pattern Matching 命中后),会显式覆写 dnpc 的值。(decode_exec中展开实现) 结果:dnpc 指向了新的跳转目标,而 snpc 依然指向内存中的下一行(并可能被保存到寄存器 ra 中作为回来的路)。

三、译码与执行详解——decode_exec展开后

准备阶段 (Macro Expansion)

预处理器把宏铺开

解析阶段 (Pattern Parsing)

输入:人类写的 "???? 0011" 字符串。 动作:pattern_decode 函数介入。 产出:生成三把钥匙 —— key (特征值), mask (过滤器), shift (对齐位移)。

匹配阶段 (Matching)

提取:提取inst。 变形:(uint64_t)inst >> shift (调整位置,统一位宽)。强转是为了“类型一致性”和“通用性,能处理各种长度的指令模式 过滤:& mask (只保留关键位,把 ? 的位置全部抹零)。 比对:== key (剩下的位是不是我们要找的?)。

执行与逃生 (Execute & Escape)

命中:如果比对成功 -> 提取操作数 -> 修改 PC/寄存器 -> goto 跳到终点。 未命中:if 失败 -> 退出当前代码块 -> 继续向下执行,尝试匹配下一个 INSTPAT。

tip:Opcode 与 Funct 的层级关系 * Opcode 究竟是干嘛的?为什么有了它还需要 Funct3/Funct7? Opcode :是“界/门”(大类)。 核心作用是告诉 CPU “怎么看待剩下的 25 个 bit”(即确定指令格式)。 NEMU 中的体现:INSTPAT 中的 key 和 mask,大部分就是在匹配 Opcode。 Funct3 (3 bits) & Funct7 (7 bits):这是“种/属”(具体动作)。 Opcode 决定了去哪个部门办事(去 ALU 算术单元,还是去 LSU 存储单元)。 Funct 决定了具体办什么业务。 * 为什么不把 Opcode 做大一点,省掉 Funct? 为了电路复用。

拆解 RISC-V 指令 * R1:一条 RISC-V 指令分几块? 以最复杂的 R 型指令(寄存器-寄存器运算)为例,32 位是这样切的: funct7(7) | rs2(5) | rs1(5) | funct3(3) | rd(5) | opcode(7) * R2:两个操作数在寄存器的位置? rs1 (Source Register 1):第 15-19 位。 rs2 (Source Register 2):第 20-24 位。 注意:这里存的是索引号(0~31)。CPU 拿到这个号,去寄存器堆(Register File)里查表,才能拿到真正的数值。 * R3:rd 是啥? rd (Destination Register):第 7-11 位。 含义:“目标寄存器”。算完的结果要写回给谁?就写给 rd 指向的那个寄存器。

四、更新pc

  • 为什么把cpu.pc更新为dnpc,,,因为dnpc才是指令真正的走向

自动化:Makefile 充当了连接主机工具链与 NEMU 运行时的桥梁,实现了从源码到模拟器运行的全链路自动化。

就是说,当你深入思考,发现了一个你并不能知道的,一般处在模拟器和主机交接黑盒子时,往往是makefile中的自动化操作

技术债清单(Knowledge Inventory)

🏷️ Tag 1: GCC 扩展语法 - 标签地址 (Labels as Values) 代码体现:&&__instpat_end_ 和 goto *ptr; 知识点:标准 C 语言的 goto 只能接标签名。但 GCC 允许你用 && 获取标签的内存地址,存到指针里,然后用 goto * 进行间接跳转。 用途:常用于实现高效的解释器(Threaded Code),比 switch-case 更快。 🏷️ Tag 2: 宏的局部作用域 (Do-While-Zero Idiom) 代码体现:do { ... } while(0) 知识点:在宏定义中包裹代码块的标准写法。 用途: 允许在宏里定义局部变量(如 key, mask)而不污染外部。 让宏在 if-else 语句中表现得像一个真正的函数调用,不会因为分号问题导致语法错误。 🏷️ Tag 4: 字符串字面量拼接 代码体现:"???????" "?????..." 知识点:C 语言中,两个相邻的字符串字面量会自动合并。 用途:在写很长的二进制模式串时,可以分段写,增加可读性。

📅 1.29:汇编指令补充过程中的收获(指令本身的二进制表示内涵)🕊️(3h)

对立即数分布规则(很不规则的但确实规则的规则)

  • 钉子户 RISC-V 指令集里有绝对不能动的,Rs1 (源寄存器,占 15-19 位),Opcode 和 Rd。因为硬件电路必须第一时间找到它们,它们不能动。

  • 小弟的困境 I-Type (12位立即数,小弟) 必须要和“钉子户” Rs1 合作(做运算),所以它绝对不能覆盖 15-19 位。

  • 绝对空旷 放眼望去,指令里只有 高位 (20-31位) 是没人抢的。于是,只能被迫搬家,把自己的核心数据 (1-10位) 搬到了 21-30位,把符号位插在 31位。

  • 大哥照顾小弟 J-Type (20位立即数,大哥) 不需要 Rs1,原本可以独占低位。但为了电路复用,大哥决定迁就小弟:把 1-10 位放在了指令的 21-30 位,这样硬件连线不用变。

  • 大哥被迫解体 代价:大哥的 头 (符号位) 被锁死在 31 位,身子 (1-10位) 为了照顾小弟被锁死在 21-30 位,剩下的手脚 (11-19位) 只能塞回 12-19 位那些剩下的缝隙里。 结果: J-Type 在我们眼里变成了“乱序拼图”,但在电路眼里,这是最完美的一家亲

  • 算术右移 (srai) 和 逻辑右移 (srl) 的本质区别。 C 语言的 >> 行为取决于左操作数的类型。 如果是 unsigned,它补 0(逻辑右移)。 如果是 signed,它补符号位(算术右移)。 应用:所以你在写 srai 时,必须加上(sword_t)强转,把这个数当有符号数处理,补符号位

📅 1.30:汇编指令补充过程中的收获(指令所实现操作中的)🕊️(3h)

警惕“惯性思维”、符号扩展(SEXT)的隐形陷阱

问题现象+思维误区 在实现 lh (Load Halfword) 指令时,测试用例报错。

预期行为:直接调用Mr函数,对结果不做处理——有其他类似指令+imm的普遍的不处理行为,使以为所有的SEXT都在decode_oprand或相关函数中被实现 实际行为:需要进行针对性的格式转换,从小往大慢慢变 后果:导致后续的分支判断或算术运算逻辑完全错误。 深度技术复盘 核心矛盾: Mr的倔强---------Mr (内存读取函数) 返回的是 word_t (无符号数) C语言的倔强-------而 C 语言在将“短无符号数”赋值给“长有符号数”时,默认执行的是零扩展 (Zero Extension) 性转-----(int16_t) 用来将数据有符号化,对于Mr的2字节返回结果,int16_t先将结果看作有符号结果

中间这个玩意就是用来决定前面的扩展行为的,因为前面的扩展(大位)看人下菜😶‍🌫️ 基因扩容----sword_t,用来把数据进行针对性扩展,根据逻辑

逻辑

对无符号进行补0,对有符号进行补符号位(最高位的那个)