文章目录:
OS Lab1 内核、启动和PRINTF
OS,启动!
QEMU是一个计算机模拟器,可以模拟CPU等硬件环境,用来运行实验中生成的操作系统可执行文件。
启动流程:加载ELF格式内核到内存,跳转到内核的入口。
内核代码结构
.
├── include # 系统头文件
│ └── mmu.h # 里面有内存布局图
├── init # 内核初始化目录
│ └── start.S # _start 初始化CPU和栈指针
│ └── init.c # 各模块的初始化函数
├── kern # 内核主体代码目录,存放内核核心功能模块实现
├── lib # 通用库函数目录,提供字符串处理、内存操作、格式化输出等基础函数
├── mk
├── out # 编译输出目录
├── target # 目标平台相关目录,存放特定平台/目标系统的文件或产物
│ └── mos # 当前目标系统相关内容
├── tests # 测试用例目录,存放本地测试和功能验证代码
├── tools # 工具目录,不属于MOS,在linux上运行
├── .gitignore # Git 忽略规则文件,指定无需纳入版本控制的文件
├── .mos-this-lab # 实验标识文件,用于辅助识别当前实验内容
├── include.mk # 供 Makefile 引用
├── kernel.lds # 内核链接脚本,用于指定内核各段的链接布局和加载地址
└── Makefile # Makefule(1) Makefile
include include.mk # 部分变量定义在此
# 文件输出定义
target_dir := target # MOS 构建目标所在目录
mos_elf := $(target_dir)/mos # 最终需要生成的 ELF 可执行文件
user_disk := $(target_dir)/fs.img # MOS 文件系统使用的磁盘镜像文件
link_script := kernel.lds
# 子模块定义
modules := lib init kern # 需要生成的子模块
objects := $(addsuffix /*.o, $(modules)) # 遍历需要生成的目标文件
QEMU_FLAGS := -cpu 4Kc -m 64 -nographic -M malta \
$(shell [ -f '$(user_disk)' ] && \
echo '-drive id=ide,file=$(user_disk),if=ide,format=raw') \
-no-reboot # QEMU 运行参数
.PHONY: all $(modules) clean
all: $(mos_elf) # 我们的“最终目标”
$(mos_elf): $(modules) # 调用链接器 $(LD) 链接所有目标文件
$(LD) $(LDFLAGS) -o $(mos_elf) -N -T $(link_script) $(objects)
$(modules): # 进入各个子目录进行 make
$(MAKE) --directory=$@
clean:
for d in $(modules); do \
$(MAKE) --directory=$$d clean; \
done; \
rm -rf *.o *~ $(mos_elf)
run:
$(QEMU) $(QEMU_FLAGS) -kernel $(mos_elf)先要make子模块(lib init kern…),然后使用链接器链接子模块,大概展开后是这样:
mips-linux-gnu-ld $(LDFLAGS) -o target/mos -N -T kernel.lds lib/*.o init/*.o kern/*.o可以看到把上一步子模块里面生成的.o文件链接到了一起,使用的链接脚本为上面定义的kernel.lds文件(后面也单独讲了这个文件),最后生成了target/mos这个最终的可执行文件。使用all目标作为make的最终执行结果。
清理:make clean,运行:make run,均为.PHONY目标(即不依赖文件生成时间,每次都会执行)。
这里使用mips-linux-gnu-前缀为交叉编译器,意思是在本地环境(可能x86下)编译,要拿到MIPS模拟器里面运行,即目标代码运行的平台和编译器运行的平台不同。
(2) ELF
「1」编译处理过程
对于含有多个.c文件的工程来说,编译器会首先将所有的.c文件以文件为单位,编译成.o 文件。最后再将所有的.o 文件以及函数库链接在一起,形成最终的可执行文件。
预处理: 仍是C语言文本,把#include头文件复制粘贴,#define定义的宏替换为代码,以及条件编译指令#if等,去掉注释,生成.i文件。
$ gcc -E hello.c -o hello.i编译: C语言翻译为汇编代码,生成.s文件。
$ gcc -S hello.i -o hello.s汇编: 将汇编代码转换为机器代码,生成.o目标文件。
$ gcc -c hello.s -o hello.o链接: 和系统库等文件编译到一起,得到可执行文件。
「2」ELF是什么
一般有三种类型:
- 可重定位文件,例如:
*.o,通常叫“目标文件”,还不能直接运行,需要链接。 - 可执行文件,例如:链接完成后的程序(最终mos文件),可以被操作系统加载运行。
- **共享对象文件,例如:
*.so,是动态链接库。
「3」ELF结构
分为这几个部分:
分为几个部分,看图很好理解:
- ELF头,包括程序的基本信息,比如体系结构和操作系统,同时也包含了节头表和段头表相对文件的偏移量(offset)。
- 段头表(或程序头表),主要包含程序中各个段(segment)的信息, 段的信息需要在运行时刻使用。
- 节头表,主要包含程序中各个节(section)的信息,节的信息需要在程序编译和链接的时候使用。
- 段头表中的每一个表项,记录了该段数据载入内存时的目标位置等,记录了用于指导应用程序加载的各类信息。
- 节头表中的每一个表项,记录了该节程序的代码段、数据段等各个段的内容,主要是链接器在链接的过程中需要使用。
即通过ELF头,我们可以得知段头表、节头表位置,通过这两个表可以查出其中的表项在ELF文件里面的位置,如.text等。
其中节头表主要用于链接阶段,段头表主要用于运行。
Exercise 1.1 (只保留补全部分)
/* Exercise 1.1: Your code here. (1/2) */ sh_table = (const char *)binary + ehdr->e_shoff; sh_entry_count = ehdr->e_shnum; sh_entry_size = ehdr->e_shentsize; /* Exercise 1.1: Your code here. (2/2) */ shdr = (const Elf32_Shdr *)((const char *)sh_table + i * sh_entry_size); addr = shdr->sh_addr;解析:看elf.h里面各个结构体的定义即可(中文注释见附录)。同时所有的偏移地址按照字节寻址,最终的节头表需要保存为对应结构体指针格式。
「4」 ELF内容解析
使用readelf -l查看一个可执行文件的ELF内容:
各列含义如下:
- Type:段类型
- Offset:这段内容在 ELF 文件中的偏移
- VirtAddr:加载到进程虚拟地址空间后的地址
- PhysAddr:物理地址,现代 OS 下一般不太关心
- FileSiz:文件中这段占多少字节
- MemSiz:装入内存后这段应占多少字节(MemSiz >= FileSiz,因为ELF里面有的在.bss段)
- Flags:权限
这块内容为了说明什么?>> 模拟器加载内核的时候,也需要将内核代码、数据加载到对应的位置上,才能保证内核正常运行 >> 所以我们需要知道内核的正确加载位置是哪里。
(3)MIPS内存布局
程序中一般使用的是虚拟地址,通过MMU转为物理地址发往总线访存。
本次实验使用的MIPS虚拟地址空间为4GB,划分为四个大区域:
| 区域 | 地址范围 | 大小 | 访问权限 | 是否经 TLB | 是否缓存 |
|---|---|---|---|---|---|
| kuseg | 0x00000000 - 0x7FFFFFFF | 2GB | 用户态 / 内核态都可用 | 是 | 取决于页属性,通常可缓存 |
| kseg0 | 0x80000000 - 0x9FFFFFFF | 512MB | 仅内核态 | 否,直接映射 | 是 |
| kseg1 | 0xA0000000 - 0xBFFFFFFF | 512MB | 仅内核态 | 否,直接映射 | 否 |
| kseg2/3 | 0xC0000000 - 0xFFFFFFFF | 1GB | 仅内核态 | 是 | 取决于页属性,通常可缓存 |
因为TLB依赖于操作系统,所以OS装在只能使用Kseg0/1,其中Kseg0因为缓存了,速度快一些,所以一般使用kseg0。
实验中需要使用部分地址(见附录mmu.h),.h头文件已经定义好对应的变量了:
#define PAGE_SIZE 4096 // 4KB
#define PTMAP PAGE_SIZE // 4KB
#define PDMAP (4 * 1024 * 1024) // 4MB
#define KERNBASE 0x80020000 // 内核代码起始地址
#define ULIM 0x80000000 // 用户空间上限 / 内核空间起点
#define KSTACKTOP (ULIM + PDMAP) // 0x80400000,内核栈顶
#define UVPT (ULIM - PDMAP) // 0x7FC00000,用户页表映射区
#define UPAGES (UVPT - PDMAP) // 0x7F800000,物理页信息区
#define UENVS (UPAGES - PDMAP)// 0x7F400000,进程/环境信息区
#define UTOP UENVS // 0x7F400000,用户空间顶部
#define UXSTACKTOP UTOP // 0x7F400000,用户异常栈顶
#define USTACKTOP (UTOP - 2 * PTMAP) // 0x7F3FE000,普通用户栈顶
#define UTEXT PDMAP // 0x00400000,用户程序入口
#define UCOW (UTEXT - PTMAP) // 0x003FF000,写时复制保留页
#define UTEMP (UCOW - PTMAP) // 0x003FE000,临时映射页(4)Linker Script
在不同的设备上使用链接器ld时,由于不同设备加载地址不同,所以需要Linker Script记录各个节应该如何映射到段,以及各个段应该被加载到的位置。
其基本格式:
SECTIONS
{
. = 0x10000;
.text : { *(.text) }
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
}其中,.代表当前位置,即接下来的内容从什么位置开始放起。
而第二行:定义一个输出段.text,把所有输入文件里的 .text 段都放进来。
.text :
定义输出段名字叫.text{ *(.text) }*表示所有目标文件(.text)表示取它们的.text段
后面的一样。如果没写.,则满足对齐要求后,接着往后排。
应该只用会这两个语法。
Exercise 1.2
/* Step 1: Set the loading address of the text section to the location counter ".". */ /* Exercise 1.2: Your code here. (1/4) */ . = 0x80020000; /* Step 2: Define the text section. */ /* Exercise 1.2: Your code here. (2/4) */ .text : { *(.text) } /* Step 3: Define the data section. */ /* Exercise 1.2: Your code here. (3/4) */ .data : { *(.data) } bss_start = .; /* Step 4: Define the bss section. */ /* Exercise 1.2: Your code here. (4/4) */ .bss : { *(.bss) } bss_end = .;第一个去找内核启动地址(mmu.h),应该可以替换为
KERNBASE。后面的那不是和前面例子一模一样吗()另外,
bss_start:.bss段起始地址bss_end:.bss段结束地址gpt说是用于方便给
.bss段清零。
_start函数
Exercise 1.3
#include <asm/asm.h>
#include <mmu.h>
.text
EXPORT(_start)
.set at
.set reorder
/* Lab 1 Key Code "enter-kernel" */
/* clear .bss segment */
la v0, bss_start
la v1, bss_end
clear_bss_loop:
beq v0, v1, clear_bss_done
sb zero, 0(v0)
addiu v0, v0, 1
j clear_bss_loop
/* End of Key Code "enter-kernel" */
clear_bss_done:
/* disable interrupts */
mtc0 zero, CP0_STATUS
/* hint: you can refer to the memory layout in include/mmu.h */
/* set up the kernel stack */
/* Exercise 1.3: Your code here. (1/2) */
li sp, 0x80400000
/* jump to mips_init */
/* Exercise 1.3: Your code here. (2/2) */
jal mips_init这个函数内主要做了这么几件事:首先是_start函数的声明,随后2、3行的.set允许汇编器使用at寄存器,也允许对接下来的代码进行重排序。接下来直到clear_bss_done标签的代码对.bss段进行了清零操作。 而最后一行代码则禁用了外部中断。
要首先将sp寄存器设置到内核栈空间的位置上,随后跳转到mips_init函数。
栈指针的位置在mmu.h中可以看到,直接赋值即可。然后jal跳转到mips_init地址即可。
printk函数
代码结构
- kern/machine.c:这个文件负责往 QEMU 的控制台输出字符,其原理为读写某一个特殊 的内存地址。
- kern/printk.c:此文件中,实现了 printk,但其所做的,实际上是把输出字符的函数, 接受的输出参数给传递给了vprintfmt 这个函数。
- lib/print.c:此文件中,实现了 vprintfmt 函数,其实现了格式化输出的主体逻辑。
附录
tools/readelf/elf.h
/* 本文件定义了标准 ELF 类型、结构体和宏。 */
#ifndef _ELF_H
#define _ELF_H
#include <stdint.h>
/* 来自 GNU C Library 的 ELF 定义文件。我们为了实验对该文件做了简化,
* 移除了 ELF64、以及我们不关心的结构体和枚举定义。
*/
/* 16 位数据类型。 */
typedef uint16_t Elf32_Half;
/* 有符号和无符号 32 位数据类型。 */
typedef uint32_t Elf32_Word;
typedef int32_t Elf32_Sword;
/* 有符号和无符号 64 位数据类型。 */
typedef uint64_t Elf32_Xword;
typedef int64_t Elf32_Sxword;
/* 地址类型。 */
typedef uint32_t Elf32_Addr;
/* 文件偏移类型。 */
typedef uint32_t Elf32_Off;
/* 节索引类型,为 16 位数据。 */
typedef uint16_t Elf32_Section;
/* 符号索引类型。 */
typedef uint32_t Elf32_Symndx;
/* 实验 1 关键代码 "readelf-struct-def" */
/* ELF 文件头。它出现在每个 ELF 文件的开头。 */
#define EI_NIDENT (16)
typedef struct {
unsigned char e_ident[EI_NIDENT]; /* 魔数及其他信息 */
Elf32_Half e_type; /* 目标文件类型 */
Elf32_Half e_machine; /* 体系结构 */
Elf32_Word e_version; /* 目标文件版本 */
Elf32_Addr e_entry; /* 入口点虚拟地址 */
Elf32_Off e_phoff; /* 程序头表在文件中的偏移 */
Elf32_Off e_shoff; /* 节头表在文件中的偏移 */
Elf32_Word e_flags; /* 处理器特定标志 */
Elf32_Half e_ehsize; /* ELF 头大小(字节) */
Elf32_Half e_phentsize; /* 程序头表项大小 */
Elf32_Half e_phnum; /* 程序头表项数量 */
Elf32_Half e_shentsize; /* 节头表项大小 */
Elf32_Half e_shnum; /* 节头表项数量 */
Elf32_Half e_shstrndx; /* 节头字符串表索引 */
} Elf32_Ehdr;
/* e_ident 数组中的各字段。EI_* 宏是该数组中的索引。
每个 EI_* 宏下面定义的宏,是该字节可能取的值。 */
#define EI_MAG0 0 /* 文件标识第 0 字节索引 */
#define ELFMAG0 0x7f /* 魔数字节 0 */
#define EI_MAG1 1 /* 文件标识第 1 字节索引 */
#define ELFMAG1 'E' /* 魔数字节 1 */
#define EI_MAG2 2 /* 文件标识第 2 字节索引 */
#define ELFMAG2 'L' /* 魔数字节 2 */
#define EI_MAG3 3 /* 文件标识第 3 字节索引 */
#define ELFMAG3 'F' /* 魔数字节 3 */
/* 节头。 */
typedef struct {
Elf32_Word sh_name; /* 节名称 */
Elf32_Word sh_type; /* 节类型 */
Elf32_Word sh_flags; /* 节标志 */
Elf32_Addr sh_addr; /* 节地址 */
Elf32_Off sh_offset; /* 节偏移 */
Elf32_Word sh_size; /* 节大小 */
Elf32_Word sh_link; /* 节链接 */
Elf32_Word sh_info; /* 节额外信息 */
Elf32_Word sh_addralign; /* 节对齐 */
Elf32_Word sh_entsize; /* 节表项大小 */
} Elf32_Shdr;
/* 程序段头。 */
typedef struct {
Elf32_Word p_type; /* 段类型 */
Elf32_Off p_offset; /* 段在文件中的偏移 */
Elf32_Addr p_vaddr; /* 段虚拟地址 */
Elf32_Addr p_paddr; /* 段物理地址 */
Elf32_Word p_filesz; /* 段在文件中的大小 */
Elf32_Word p_memsz; /* 段在内存中的大小 */
Elf32_Word p_flags; /* 段标志 */
Elf32_Word p_align; /* 段对齐 */
} Elf32_Phdr;
/* 关键代码 "readelf-struct-def" 结束 */
/* p_type(段类型)的合法取值。 */
#define PT_NULL 0 /* 未使用的程序头表项 */
#define PT_LOAD 1 /* 可装载程序段 */
#define PT_DYNAMIC 2 /* 动态链接信息 */
#define PT_INTERP 3 /* 程序解释器 */
#define PT_NOTE 4 /* 辅助信息 */
#define PT_SHLIB 5 /* 保留 */
#define PT_PHDR 6 /* 头表自身的表项 */
#define PT_NUM 7 /* 已定义类型的数量。 */
#define PT_LOOS 0x60000000 /* 操作系统特定范围起始 */
#define PT_HIOS 0x6fffffff /* 操作系统特定范围结束 */
#define PT_LOPROC 0x70000000 /* 处理器特定范围起始 */
#define PT_HIPROC 0x7fffffff /* 处理器特定范围结束 */
/* p_flags(段标志)的合法取值。 */
#define PF_X (1 << 0) /* 段可执行 */
#define PF_W (1 << 1) /* 段可写 */
#define PF_R (1 << 2) /* 段可读 */
#define PF_MASKPROC 0xf0000000 /* 处理器特定 */
#endif /* _ELF_H */mmu.h部分
o 4G -----------> +----------------------------+------------0x100000000
o | ... | kseg2
o KSEG2 -----> +----------------------------+------------0xc000 0000
o | Devices | kseg1
o KSEG1 -----> +----------------------------+------------0xa000 0000
o | Invalid Memory | /|\
o +----------------------------+----|-------Physical Memory Max
o | ... | kseg0
o KSTACKTOP-----> +----------------------------+----|-------0x8040 0000-------end
o | Kernel Stack | | KSTKSIZE /|\
o +----------------------------+----|------ |
o | Kernel Text | | PDMAP
o KERNBASE -----> +----------------------------+----|-------0x8002 0000 |
o | Exception Entry | \|/ \|/
o ULIM -----> +----------------------------+------------0x8000 0000-------
o | User VPT | PDMAP /|\
o UVPT -----> +----------------------------+------------0x7fc0 0000 |
o | pages | PDMAP |
o UPAGES -----> +----------------------------+------------0x7f80 0000 |
o | envs | PDMAP |
o UTOP,UENVS -----> +----------------------------+------------0x7f40 0000 |
o UXSTACKTOP -/ | user exception stack | PTMAP |
o +----------------------------+------------0x7f3f f000 |
o | | PTMAP |
o USTACKTOP ----> +----------------------------+------------0x7f3f e000 |
o | normal user stack | PTMAP |
o +----------------------------+------------0x7f3f d000 |
a | | |
a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
a . . |
a . . kuseg
a . . |
a |~~~~~~~~~~~~~~~~~~~~~~~~~~~~| |
a | | |
o UTEXT -----> +----------------------------+------------0x0040 0000 |
o | reserved for COW | PTMAP |
o UCOW -----> +----------------------------+------------0x003f f000 |
o | reversed for temporary | PTMAP |
o UTEMP -----> +----------------------------+------------0x003f e000 |
o | invalid memory | \|/
a 0 ------------> +----------------------------+ ----------------------------MIPS指令
| 类别 | 指令 | 格式 | 作用 | 备注 |
|---|---|---|---|---|
| 算术 | add | add rd, rs, rt | rd = rs + rt | 有符号加法,检查溢出 |
| 算术 | addu | addu rd, rs, rt | rd = rs + rt | 不检查溢出 |
| 算术 | addi | addi rt, rs, imm | rt = rs + imm | 立即数加法 |
| 算术 | addiu | addiu rt, rs, imm | rt = rs + imm | 不检查溢出 |
| 算术 | sub | sub rd, rs, rt | rd = rs - rt | 有符号减法 |
| 算术 | subu | subu rd, rs, rt | rd = rs - rt | 不检查溢出 |
| 算术 | mul | mul rd, rs, rt | 乘法结果低位放 rd | 有些环境支持 |
| 算术 | mult | mult rs, rt | 乘积放入 HI/LO | 高位在 HI,低位在 LO |
| 算术 | multu | multu rs, rt | 无符号乘法 | 结果放 HI/LO |
| 算术 | div | div rs, rt | 商到 LO,余数到 HI | 有符号除法 |
| 算术 | divu | divu rs, rt | 商到 LO,余数到 HI | 无符号除法 |
| 特殊寄存器 | mflo | mflo rd | rd = LO | 取乘除法结果的商/低位 |
| 特殊寄存器 | mfhi | mfhi rd | rd = HI | 取乘除法余数/高位 |
| 特殊寄存器 | mtlo | mtlo rs | LO = rs | 写 LO |
| 特殊寄存器 | mthi | mthi rs | HI = rs | 写 HI |
| 逻辑 | and | and rd, rs, rt | 按位与 | |
| 逻辑 | andi | andi rt, rs, imm | 按位与立即数 | 常用于掩码 |
| 逻辑 | or | or rd, rs, rt | 按位或 | |
| 逻辑 | ori | ori rt, rs, imm | 按位或立即数 | 常配合 lui |
| 逻辑 | xor | xor rd, rs, rt | 按位异或 | |
| 逻辑 | xori | xori rt, rs, imm | 按位异或立即数 | |
| 逻辑 | nor | nor rd, rs, rt | 按位或后取反 | 常用于实现 not |
| 移位 | sll | sll rd, rt, shamt | 逻辑左移 | 低位补 0 |
| 移位 | srl | srl rd, rt, shamt | 逻辑右移 | 高位补 0 |
| 移位 | sra | sra rd, rt, shamt | 算术右移 | 高位补符号位 |
| 移位 | sllv | sllv rd, rt, rs | 可变左移 | 位数在 rs 中 |
| 移位 | srlv | srlv rd, rt, rs | 可变逻辑右移 | |
| 移位 | srav | srav rd, rt, rs | 可变算术右移 | |
| 比较 | slt | slt rd, rs, rt | 若 rs < rt,则 rd=1 | 有符号比较 |
| 比较 | sltu | sltu rd, rs, rt | 若 rs < rt,则 rd=1 | 无符号比较 |
| 比较 | slti | slti rt, rs, imm | rs < imm 则 rt=1 | 有符号比较 |
| 比较 | sltiu | sltiu rt, rs, imm | rs < imm 则 rt=1 | 无符号比较 |
| 访存 | lw | lw rt, offset(base) | 读一个字到寄存器 | 4 字节 |
| 访存 | sw | sw rt, offset(base) | 把一个字写入内存 | 4 字节 |
| 访存 | lb | lb rt, offset(base) | 读一个字节 | 符号扩展 |
| 访存 | lbu | lbu rt, offset(base) | 读一个字节 | 零扩展 |
| 访存 | sb | sb rt, offset(base) | 写一个字节 | |
| 访存 | lh | lh rt, offset(base) | 读半字 | 符号扩展,2 字节 |
| 访存 | lhu | lhu rt, offset(base) | 读半字 | 零扩展 |
| 访存 | sh | sh rt, offset(base) | 写半字 | 2 字节 |
| 访存 | ll | ll rt, offset(base) | 原子读 | 配合 sc |
| 访存 | sc | sc rt, offset(base) | 原子写 | 配合 ll |
| 跳转/分支 | beq | beq rs, rt, label | 若相等则跳转 | 常用于 if |
| 跳转/分支 | bne | bne rs, rt, label | 若不等则跳转 | |
| 跳转/分支 | bgez | bgez rs, label | 若 rs >= 0 则跳转 | 和 0 比较 |
| 跳转/分支 | bgtz | bgtz rs, label | 若 rs > 0 则跳转 | |
| 跳转/分支 | blez | blez rs, label | 若 rs <= 0 则跳转 | |
| 跳转/分支 | bltz | bltz rs, label | 若 rs < 0 则跳转 | |
| 跳转/分支 | j | j label | 无条件跳转 | |
| 跳转/分支 | jal | jal label | 跳转并保存返回地址 | 返回地址进 $ra |
| 跳转/分支 | jr | jr rs | 跳到寄存器中的地址 | 常写 jr $ra |
| 跳转/分支 | jalr | jalr rd, rs | 间接调用 | 常用于函数指针 |
| 数据装入 | lui | lui rt, imm | 立即数放高 16 位 | 低 16 位补 0 |
| 伪指令 | li | li rt, imm | 装立即数 | 伪指令 |
| 伪指令 | la | la rt, label | 装地址 | 伪指令 |
| 伪指令 | move | move rd, rs | rd = rs | 常等价于 addu rd, rs, $zero |
| 伪指令 | neg | neg rd, rs | rd = -rs | 伪指令 |
| 伪指令 | not | not rd, rs | 按位取反 | 常等价于 nor rd, rs, $zero |
| 伪指令 | nop | nop | 空操作 | 常等价于 sll $zero, $zero, 0 |
| 系统调用 | syscall | syscall | 执行系统调用 | 服务号放 $v0 |



