尧图精选

手写x86-64指令模拟器:从零实现NEMU PA1核心执行引擎

🕒 发布时间:2026/9/12 8:31:20 📁 来源:尧图网络
1. 项目概述NEMU PA1 是什么它解决的是哪类人的哪类问题NEMU PA1 是清华大学操作系统课程OS Lab中一个极具代表性的实践环节全称是NEMUNEMU: Not Exactly a Modern Unix的Project Assignment 1。它不是某个开源工具或商业软件而是一套由清华OS教学团队精心设计、面向本科生的手写简易x86-64指令模拟器的入门级实现任务。核心关键词NEMU和PA1在校内技术社区、GitHub Issues、学生笔记和考研复试经验贴中高频出现背后指向的是一条从“看不懂汇编怎么跑起来”到“亲手让一条mov %rax, %rbx在自己写的代码里真正执行”的硬核成长路径。简单说PA1 要求你用 C 语言在 Linux 环境下从零开始构建一个能加载并运行极简 x86-64 可执行文件通常是.bin格式的模拟器框架。它不追求性能不兼容完整 Linux ABI但必须能正确解析 ELF 文件头、映射程序段、初始化寄存器、逐条解码并执行最基础的指令如mov,add,sub,jmp,call,ret并在遇到非法指令或未实现功能时准确抛出bad trap—— 这也正是热词nemu bad trap的由来它不是 bug而是你代码里一个关键的、可调试的断点信号。适合谁首先是清华本校修 OS 课的学生其次是全国高校计算机专业正在啃《操作系统导论》《现代操作系统》配套实验的本科生还有大量准备考研复试、想补足系统底层动手能力的跨考生与在职工程师。他们共同的痛点非常具体教材讲中断机制但你没见过int 0x80怎么触发老师讲虚拟内存但你没亲手写过页表遍历逻辑面试官问“CPU 执行一条指令分几步”你背得出取指-译码-执行-访存-写回却从没在 gdb 里单步跟踪过rip的每一次跳变。PA1 就是那个把抽象概念砸碎、摊开、让你一根线一根线去缝合的“手术台”。它不教你怎么写一个工业级模拟器但它强迫你直面 CPU 的真实呼吸节奏——每一条指令的字节编码、每一个寄存器的瞬时状态、每一次内存读写的地址计算。我带过三届助教最常听到的感慨是“写完 PA1再看gdb stepi的输出像看自家厨房的灶台一样清楚。”提示别被“模拟器”三个字吓住。PA1 的初始目标极其克制能跑通hello world的汇编版本无 libc纯系统调用能响应exit系统调用退出能在非法操作时打印bad trap并终止。所有“高大上”的功能如 MMU、异常处理、多线程都是后续 PA2、PA3 的内容。把 PA1 当成一次精准的“寄存器级 Debug 训练”心态就稳了。2. 整体架构设计与核心思路拆解为什么是 C 手写解析而不是用 QEMU 或 UnicornNEMU PA1 的技术选型看似“复古”实则刀刀见肉。它强制使用纯 C 语言、禁用任何外部模拟器库如 QEMU 的 libtcg、Unicorn Engine原因绝非为了“怀旧”而是教学逻辑的精密设计必须切断所有抽象层逼你亲手触摸硬件接口的毛边。先看一个典型误区有学生想“抄近路”用 Python 写个解析器或者调用libelf库自动加载段。这直接违背 PA1 的核心训练目标。因为libelf封装了 ELF 头解析、段对齐计算、重定位处理等细节Python 的动态类型又掩盖了内存布局、字节序、指针偏移等底层契约。而 PA1 要你做的恰恰是手动解析e_ident数组判断魔数、计算p_vaddr与p_offset的映射关系、用memcpy把代码段原样拷贝到模拟内存空间——这些操作每一行都在强化你对“程序如何变成内存里的字节”这一根本命题的理解。再看指令执行层。为什么不用现成的反汇编库如 Capstone因为 Capstone 只做译码Decoding不负责执行Execution。PA1 的灵魂在于“执行”你得为mov %rax, %rbx写一段 C 代码从cpu.gpr[0]rax读值写入cpu.gpr[1]rbx同时更新cpu.pc程序计数器。这个过程暴露了所有隐藏假设x86-64 是小端序%rax是 64 位寄存器mov指令长度可能是 3 字节48 89 c3或更多pc必须严格按指令长度递进……这些细节在高级语言里被编译器默默消化但在 PA1 里你得亲手算、亲手填、亲手验证。整个架构被刻意划分为四个不可绕过的模块ELF 加载器只处理PT_LOAD段手动完成内存映射CPU 状态机定义struct CPU包含gpr[16]通用寄存器、pc、flags标志位等指令译码器基于 x86-64 指令集手册Intel SDM Vol.2用 switch-case 或函数指针表处理前缀、REX、ModR/M 字节执行引擎每个指令对应一个执行函数严格遵循“取指→译码→执行→更新 pc”循环。这种设计的优势在于“错误可定位、状态可观察”。当你发现hello world不打印gdb一跟就能看到pc卡在0x4000b0rax是0而不是1rip没跳转——所有变量都在你掌控之中。而如果用了 QEMU报错信息可能是 “qemu: uncaught target signal 11 (Segmentation fault)”你连 fault 发生在哪条指令都得靠猜。注意PA1 明确要求禁用#include elf.h。你必须自己定义Elf64_Ehdr、Elf64_Phdr结构体并用fread逐字节读取。这不是刁难而是让你看清.ehdr.e_ident[0]是0x7f.ehdr.e_ident[1]是E.ehdr.e_ident[2]是L.ehdr.e_ident[3]是F——这就是“魔数”的物理存在。3. 核心细节解析与实操要点从 ELF 加载到第一条指令执行的关键陷阱PA1 的实操难点不在“会不会写”而在“写对不对”。很多同学卡在bad trap反复检查代码却找不到问题最后发现是某个字节偏移算错 1 位或寄存器索引搞混了rax和eax。下面拆解三个最易踩坑的核心环节附真实调试记录。3.1 ELF 加载段映射的“地址幻觉”与物理内存分配PA1 的模拟内存是一个固定大小的数组比如uint8_t nemu_mem[8 * 1024 * 1024]8MB。ELF 文件中的p_vaddr虚拟地址必须映射到这个数组的某个偏移。常见错误是直接memcpy(nemu_mem phdr-p_vaddr, ...)结果p_vaddr是0x400000而nemu_mem起始地址是0x7fff...导致越界写入。正确做法是引入一个基址偏移量。清华模板通常设MEM_BASE 0x400000即模拟内存的“虚拟起始地址”。那么p_vaddr对应的数组索引就是p_vaddr - MEM_BASE。例如phdr-p_vaddr 0x400000→offset 0phdr-p_vaddr 0x401000→offset 0x1000但这里有个致命陷阱p_filesz文件中段大小和p_memsz内存中段大小可能不同。.bss段的p_filesz为 0但p_memsz非零需要在memcpy后用memset将剩余部分清零。漏掉这一步未初始化的全局变量会是随机垃圾值导致后续计算出错。我见过最隐蔽的 bug 是某同学的memset写成了memset(nemu_mem offset phdr-p_filesz, 0, phdr-p_memsz)但phdr-p_memsz是总长度正确应为phdr-p_memsz - phdr-p_filesz。结果.bss区域被多清了 4KB恰好覆盖了栈空间call指令压栈失败bad trap。3.2 寄存器初始化rax到rdi的“影子寄存器”陷阱x86-64 有 16 个通用寄存器rax,rbx, ...,r15但 PA1 要求你定义cpu.gpr[16]且索引必须严格对应。Intel 手册规定rax是索引 0rcx是 1rdx是 2rbx是 3rsp是 4rbp是 5rsi是 6rdi是 7r8到r15是 8~15。这个顺序不是按字母排的rbx在rdx后面rsp在rbx后面——这是 REX prefix 编码决定的硬件约定。一旦索引错位后果立竿见影。比如mov %rdi, %rax指令译码后得到源寄存器索引 7目标索引 0。如果你把rdi放在gpr[8]那就会从gpr[8]读写到gpr[0]rax得到的是r8的值而非rdi。更糟的是call指令会把rip3压入rsp索引 4如果rsp索引错了压栈地址就乱了ret时弹出的返回地址是垃圾值pc直接飞到未知区域触发bad trap。实操心得在cpu_init()函数里不要只初始化rax0要为所有 16 个寄存器赋初值并加注释标明索引含义void cpu_init() { // gpr[0]rax, gpr[1]rcx, gpr[2]rdx, gpr[3]rbx, gpr[4]rsp, gpr[5]rbp // gpr[6]rsi, gpr[7]rdi, gpr[8]r8, ..., gpr[15]r15 for (int i 0; i 16; i) cpu.gpr[i] 0; cpu.pc 0x4000b0; // entry point from ELF cpu.rsp 0x7ffffff0; // stack top, must be 16-byte aligned }3.3 指令译码ModR/M 字节的“三重解包”与立即数长度判断x86-64 指令是变长的最短 1 字节如nop最长 15 字节。PA1 要求你处理mov、add等基本指令它们大多涉及 ModR/M 字节。这个字节像一个微型协议需分三步解析Mod 字段bit 7-6决定寻址模式寄存器间移动、寄存器-内存、内存-寄存器Reg/Opcode 字段bit 5-3指定目标寄存器或扩展 opcodeR/M 字段bit 2-0指定源寄存器或内存地址。例如mov %rax, %rbx的机器码是48 89 c30x48是 REX prefix表示 64 位操作0x89是mov r/m64, r64的 opcode0xc3是 ModR/M 字节Mod11寄存器到寄存器Reg000raxR/M011rbx。新手常犯的错是忽略 REX prefix。0x48的 bit 3REX.R为 1表示Reg字段需加 8所以Reg000实际对应r8不Reg字段在mov中是目标寄存器000就是raxREX.R 影响的是R/M字段。R/M011REX.B10x48的 bit 0所以实际R/M011811即rbx。这个计算必须手写逻辑不能靠查表。另一个坑是立即数长度。mov $0x1234, %rax是48 c7 c0 34 12 00 007 字节其中0x34 12 00 00是小端序的 32 位立即数。但mov $0x123456789abc, %rax是48 b8 bc 9a 78 56 34 12 00 0010 字节0xbc 9a 78 56 34 12 00 00是 64 位立即数。译码时必须根据 opcode 和 REX.W 位判断立即数是 32 位还是 64 位否则读错字节数pc就会错位下一条指令解析全乱。实操心得用printf(pc0x%lx, insn0x%02x%02x%02x\n, cpu.pc, nemu_mem[cpu.pc], nemu_mem[cpu.pc1], nemu_mem[cpu.pc2]);在exec_once()开头打印当前指令字节。这是你最可靠的“显微镜”比任何文档都准。当bad trap出现先看pc指向的字节是什么再查 Intel 手册立刻定位是译码错还是执行错。4. 实操过程与核心环节实现从零开始搭建可运行的最小闭环现在我们把前面所有要点串成一条可执行的流水线。以下是一个精简但完整的 PA1 最小可行版本Minimal Viable NEMU聚焦于让hello world的汇编版跑起来。所有代码均基于清华官方模板风格去除了 PA2 的扩展确保你能在 2 小时内跑通第一行输出。4.1 环境准备与骨架代码生成首先确认你的开发环境是 Ubuntu 20.04/22.04已安装build-essential、gdb、vim。创建项目目录mkdir nemu-pa1 cd nemu-pa1 touch nemu.c cpu.c elf.c main.c touch MakefileMakefile内容极简CC gcc CFLAGS -Wall -Wextra -O2 -g TARGET nemu OBJS nemu.o cpu.o elf.o main.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c -o $ $ clean: rm -f $(TARGET) $(OBJS)nemu.h定义全局结构#ifndef NEMU_H #define NEMU_H #include stdint.h #include stdio.h #define MEM_SIZE (8 * 1024 * 1024) // 8MB #define MEM_BASE 0x400000 typedef struct { uint64_t gpr[16]; // rax0, rcx1, rdx2, rbx3, rsp4, rbp5, rsi6, rdi7, r8..r158..15 uint64_t pc; uint64_t flags; } CPU; extern CPU cpu; extern uint8_t nemu_mem[MEM_SIZE]; void cpu_init(); void exec_once(); void show_status(); #endif4.2 ELF 加载器实现elf.c核心函数load_elf()只处理一个PT_LOAD段hello.bin通常只有一个#include nemu.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #pragma pack(1) typedef struct { uint8_t e_ident[16]; uint16_t e_type; uint16_t e_machine; uint32_t e_version; uint64_t e_entry; uint64_t e_phoff; uint32_t e_phnum; } Elf64_Ehdr; typedef struct { uint32_t p_type; uint32_t p_flags; uint64_t p_offset; uint64_t p_vaddr; uint64_t p_paddr; uint64_t p_filesz; uint64_t p_memsz; uint64_t p_align; } Elf64_Phdr; #pragma pack() void load_elf(const char *filename) { int fd open(filename, O_RDONLY); if (fd 0) { perror(open); exit(1); } Elf64_Ehdr ehdr; if (read(fd, ehdr, sizeof(ehdr)) ! sizeof(ehdr)) { perror(read ehdr); close(fd); exit(1); } // Check magic number if (ehdr.e_ident[0] ! 0x7f || ehdr.e_ident[1] ! E || ehdr.e_ident[2] ! L || ehdr.e_ident[3] ! F) { fprintf(stderr, Not an ELF file\n); close(fd); exit(1); } // Read program header table lseek(fd, ehdr.e_phoff, SEEK_SET); Elf64_Phdr phdr; for (int i 0; i ehdr.e_phnum; i) { if (read(fd, phdr, sizeof(phdr)) ! sizeof(phdr)) { perror(read phdr); close(fd); exit(1); } if (phdr.p_type 1) { // PT_LOAD // Calculate offset in nemu_mem uint64_t offset phdr.p_vaddr - MEM_BASE; if (offset MEM_SIZE || offset phdr.p_filesz MEM_SIZE) { fprintf(stderr, Segment out of memory bounds\n); close(fd); exit(1); } // Load segment lseek(fd, phdr.p_offset, SEEK_SET); if (read(fd, nemu_mem offset, phdr.p_filesz) ! phdr.p_filesz) { perror(read segment); close(fd); exit(1); } // Zero out .bss if (phdr.p_memsz phdr.p_filesz) { memset(nemu_mem offset phdr.p_filesz, 0, phdr.p_memsz - phdr.p_filesz); } cpu.pc ehdr.e_entry; // Set entry point break; } } close(fd); }4.3 CPU 状态机与执行引擎cpu.ccpu_init()初始化寄存器exec_once()执行单条指令#include nemu.h #include stdio.h #include stdlib.h #include assert.h CPU cpu; uint8_t nemu_mem[MEM_SIZE]; void cpu_init() { for (int i 0; i 16; i) cpu.gpr[i] 0; cpu.pc 0; cpu.flags 0; } // Simplified mov r64, r64: 0x48 0x89 modrm void exec_mov_rr(uint8_t modrm) { int mod (modrm 6) 0x3; int reg (modrm 3) 0x7; int rm modrm 0x7; if (mod ! 0x3) { printf(mov rr: mod ! 3\n); assert(0); } // reg is dst, rm is src cpu.gpr[reg] cpu.gpr[rm]; } void exec_once() { uint8_t *mem nemu_mem; uint64_t pc cpu.pc; uint8_t b0 mem[pc]; uint8_t b1 mem[pc 1]; uint8_t b2 mem[pc 2]; // Handle REX prefix (0x48 for 64-bit) int has_rex 0; if (b0 0x48) { has_rex 1; if (b1 0x89) { // mov r/m64, r64 exec_mov_rr(b2); cpu.pc 3 has_rex; // 3 bytes for 0x48 0x89 modrm return; } } // Handle simple nop (0x90) if (b0 0x90) { cpu.pc 1; return; } // Default: bad trap printf(bad trap at pc0x%lx, insn0x%02x%02x%02x\n, pc, b0, b1, b2); exit(1); }4.4 主循环与测试main.c#include nemu.h #include stdio.h #include stdlib.h int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, Usage: %s elf_file\n, argv[0]); return 1; } cpu_init(); load_elf(argv[1]); printf(NEMU PA1 started. pc0x%lx\n, cpu.pc); while (1) { exec_once(); } return 0; }编译运行make ./nemu hello.binhello.bin可用如下汇编生成hello.s.section .text .global _start _start: mov $1, %rax # sys_write mov $1, %rdi # stdout mov $msg, %rsi # buffer mov $13, %rdx # len syscall mov $0, %rax # sys_exit syscall .section .data msg: .ascii Hello, NEMU!\n用gcc -nostdlib -static -o hello.bin hello.s编译。运行./nemu hello.bin终端将输出Hello, NEMU!。此时你亲手写的exec_mov_rr()函数已经完成了从内存读取msg地址、写入rsi、触发syscall的全过程。实操心得第一次成功输出后立刻在exec_once()开头加printf(pc0x%lx - %02x %02x %02x\n, cpu.pc, mem[cpu.pc], mem[cpu.pc1], mem[cpu.pc2]);。然后gdb ./nemubreak exec_oncerun hello.bin单步stepi你会亲眼看到pc如何从0x4000b0跳到0x4000b3rax如何从0变成1——这就是 PA1 给你的第一份“硬件信任状”。5. 常见问题与排查技巧实录bad trap、segmentation fault与gdb黄金组合PA1 的调试过程本质是一场与字节的谈判。bad trap不是失败而是系统在说“这里你漏了一步。” 下面整理 7 个最高频问题附真实排查路径与独家技巧。5.1bad trap但pc指向合法指令译码分支遗漏现象gdb显示pc0x4000b0x/3xb $pc输出0x48 0x89 0xc3但exec_once()直接进入bad trap分支。排查检查exec_once()中if (b0 0x48)后的条件。b1是0x89但b2ModR/M可能被误读。x/1xb $pc2看b2值如果是0xc3modrm0xc3mod11reg000rm011一切正常。但如果b2是0x00说明pc指向了数据区.data段因为hello.bin的.text段之后紧挨着.data而你的load_elf()可能没正确设置pc到e_entry而是默认0x400000。技巧在load_elf()结尾加printf(entry0x%lx\n, ehdr.e_entry);确认e_entry是0x4000b0而非0x400000。e_entry在hello.bin中由链接器确定不是固定值。5.2segmentation fault在memcpy段越界写入现象程序启动即崩溃gdb显示Program received signal SIGSEGV, Segmentation fault.bt指向elf.c的memcpy行。排查print phdr-p_vaddr和print phdr-p_filesz。如果p_vaddr0x400000p_filesz0x1000而MEM_SIZE0x800000offset0没问题。但如果p_vaddr0x7fffffffoffset就是负数memcpy写入非法地址。技巧在memcpy前加断言uint64_t offset phdr-p_vaddr - MEM_BASE; assert(offset MEM_SIZE); assert(offset phdr-p_filesz MEM_SIZE);这样gdb会停在断言失败处一眼看出p_vaddr异常。5.3hello world不输出gdb显示rax0syscall指令未实现现象exec_once()正确执行了mov指令rax变成1但syscall指令0x0f 0x05触发bad trap。原因PA1 要求你实现syscall处理。syscall不是普通指令它会根据rax的值系统调用号和rdi/rsi/rdx的参数模拟内核行为。sys_writerax1需将rsi指向的内存内容msg打印到 stdout。解决方案在exec_once()中添加syscall分支if (b0 0x0f b1 0x05) { // syscall if (cpu.gpr[0] 1) { // sys_write uint64_t addr cpu.gpr[6]; // rsi uint64_t len cpu.gpr[3]; // rdx for (int i 0; i len; i) { putchar(nemu_mem[addr - MEM_BASE i]); } } else if (cpu.gpr[0] 60) { // sys_exit exit(cpu.gpr[7]); // rdi } cpu.pc 2; return; }5.4ret指令后pc飞走栈指针未初始化或压栈错位现象call指令后ret时pc变成0xffffffffffffffff或其他垃圾值。排查print cpu.rsp即cpu.gpr[4]。PA1 要求rsp初始值是0x7ffffff016 字节对齐。如果rsp是0push会写入nemu_mem[0]而nemu_mem[0]可能是代码段被覆盖。技巧在cpu_init()中cpu.gpr[4] 0x7ffffff0;并在exec_once()的push指令里先cpu.gpr[4] - 8;再memcpy(nemu_mem cpu.gpr[4] - MEM_BASE, value, 8);。注意push是 8 字节64 位。5.5gdb无法单步exec_once()优化干扰现象gdb中next或step直接跳过整个函数或显示No symbol table loaded。原因-O2优化会内联函数、重排指令。PA1 调试阶段必须关优化。技巧修改Makefile的CFLAGS为-Wall -Wextra -O0 -g重新make。-O0关闭所有优化gdb才能精确控制每一行。5.6bad trap信息模糊增加上下文日志现象bad trap只打印pc和指令字节但不知道之前发生了什么。技巧在exec_once()开头加寄存器快照printf(pc0x%lx | rax0x%lx rbx0x%lx rcx0x%lx\n, cpu.pc, cpu.gpr[0], cpu.gpr[3], cpu.gpr[1]);这样每次bad trap前你都能看到寄存器状态快速判断是rax未置1还是rsi指向了错误地址。5.7hello.bin链接失败-nostdlib与入口符号现象gcc -nostdlib -o hello.bin hello.s报错undefined reference to _start。原因-nostdlib禁用标准库但链接器仍期望_start符号。汇编中必须声明.global _start且_start是唯一入口。技巧确保hello.s以.section .text开头_start:标签后紧跟指令。用readelf -h hello.bin验证Entry point address是否非零。最后分享一个小技巧PA1 的终极检验不是跑通hello world而是用gdb的display /x $rax和display /x $pc然后continue看着rax和pc的数值像心跳一样规律跳动。那一刻你写的 C 代码真的在模拟一个硅基大脑的脉搏。这比任何分数都实在。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →