简易RISC-V架构CPU的模拟器,主要涉及ELF文件的加载、指令的解码、执行
1.创建docker容器,相关脚本放在/docker
目录下
2.进入容器,安装必要依赖
2.1 换源
sed -i s@/archive.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
sed -i s@/security.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
apt-get clean
apt-get update
2.2 安装依赖
apt-get install -y clang make libmpc3
2.3 安装交叉编译工具链
-
先去git下载编译好的交叉编译工具链
-
解压到本地
tar -zxvf riscv64-elf-ubuntu-20.04-nightly-2023.04.21-nightly.tar.gz
# 解压后生成的文件夹为riscv,我们需要的交叉编译器为riscv64-unknown-elf-gcc
- 配置环境变量
vim ~/.bashrc
export PATH=$PATH:/riscv/bin
- 模拟器:通过软件模拟目标硬件和软件环境,使得在一台设备上可以运行另一种完全不同架构的系统或应用程序。原理是模拟目标系统的底层硬件指令集和功能,通过翻译指令或直接模拟硬件逻辑,使软件以为自己运行在真实硬件上
- 虚拟机:通过在现有硬件上虚拟化一个独立的操作系统环境,通常运行与宿主机相同架构的操作系统。重点是用虚拟化技术将宿主机的物理硬件抽象为虚拟硬件,供客体操作系统使用
交叉编译工具链通常按以下格式进行命名:
[arch-][vendor-][os-][abi-]name
- [ ]表示可省略
- arch:指体系结构,例如arm、x86_64、aarch64
- vendor:指工具链提供商,例如apple、unknown或者直接省略
- os:指目标平台运行的操作系统,例如linux,none(裸机开发,不会链接任何与OS相关的库),elf(这个其实并非指操作系统,而是指文件以elf格式保存,可用于裸机和Unix操作系统)
- abi:应用程序二进制接口(Application Binary Interface),交叉编译链所选择的C库函数和目标映像的规范,该字段常见的值有abi 、eabi(embedded abi)、gun(glibc+oabi)、gnueabi(glibc+eabi)、gnueabihf (hf 指默认编译参数支持硬件浮点功能)等。
示例
1. arm-linux-gnueabi-gcc:用于ARM体系结构的交叉编译工具链。"arm"表示ARM体系结构,"linux"表示目标操作系统为Linux,"gnueabi"表示使用GNU的嵌入式ABI。
2. arm-linux-gnueabihf-gcc:与上面的工具链类似,但是添加了"hf"表示使用硬浮点(hard-float)的ABI。这意味着该工具链支持使用硬件浮点指令进行浮点运算。
3. x86_64-linux-gnu-gcc:用于x86-64体系结构(也称为AMD64或Intel 64)的交叉编译工具链。"x86_64"表示目标体系结构为x86-64,"linux"表示目标操作系统为Linux,"gnu"表示使用GNU的ABI。
4.riscv64-unknown-elf-gcc:用于riscv64体系结构,unknown表示开发商未知,elf表示是针对裸机程序进行编译,默认使用eabi
工具链通常不只是编译器,还包括binutils(二进制处理工具)和C库比如glibc
|---aarch64-linux-gnu/ <----特定于 ARM 架构的交叉编译工具链的二进制文件、库和头文件
|---bin/ <----Binutils:一组用于编译、汇编、链接等操作的工具集合
|---aarch64-linux-gnu-gcc
|---aarch64-linux-gnu-g++
|---...
|---include/ <----和 C++ 标准库的头文件,以及 GCC 内部使用的头文件
|---lib/ <----这三个lib通常包含库文件和辅助程序
|---lib64/
|---libexec/
|---share/ <----包含一些额外的数据文件,如语言文件、man pages、GCC 的插件和配置脚本等
参考链接:
ELF(Executable and Linkable Format)是一种可执行文件和可链接库的标准格式,通常在Linux和其他类Unix操作系统中使用。ELF文件包含了程序的代码、数据、符号表、调试信息等。
- 如果用了Linux,则可执行文件或库必须用ELF格式保存
- ELF不是任何特定架构的必需格式,裸机程序就可以不用ELF
- 如果要使用ELF文件,则需要实现个加载器来对ELF文件进行解析,Linux操作系统本身通常充当加载器,如果裸机程序要用ELF格式的,则必须手动实现个加载器,比如QEMU其实就内置了个加载器,所以才支持ELF格式的裸机程序
采用ELF格式的文件主要有以下4类:
ELF 文件的作用有两个,一是用于程序链接(为了生成程序);二是用于程序执行(为了运行程序)。针对这两种情况,可以从不同的视角来看待同一个目标文件。当它分别被用于链接和用于执行的时候,其特性必然是不一样的,我们所关注的内容也不一样。从链接和运行的角度,可以将 ELF 文件的组成部分划分为链接视图和运行视图这两种格式。
- ELF Header:位于文件开始处,包含整个ELF文件的信息
- Program Header Table:用于描述加载程序(如内核或动态链接器)如何将文件的各部分加载到内存
- Section:用于程序的链接,在每个节中包含有指令数据、符号数据、重定位数据等等,例如.text、.data、.bass节...
- Segment:在内存中加载的区域,包含多个Section
- Section Header Table:描述文件中各段的信息(如代码段、数据段等),通常用于链接阶段。
对于可执行程序,Program Header
是必须的,描述了不同的段即Segment
,Section Header
是可选的
对于链接程序,Program Header
是可选的,Section Header
是必须的,描述了不同的section
-
file
:快速查看 ELF 文件类型 -
readelf
:查看 ELF 文件的头信息、节头、程序头等详细信息-h
:查看ELF Header
ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: RISC-V Version: 0x1 Entry point address: 0x100000000 Start of program headers: 64 (bytes into file) Start of section headers: 9720 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 1 Size of section headers: 64 (bytes) Number of section headers: 27 Section header string table index: 25
-l
:查看Program Header Table
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x00000000 0x00000000 0x004000 0x004000 R E 0x1000 LOAD 0x004000 0x00004000 0x00004000 0x000000 0x000000 RW 0x1000
-S
:查看Section Header Table
Section Headers: [Nr] Name Type Address Off Size ES Flg Lk Inf Al [ 0] .text PROGBITS 0000000000001000 000004 0003c0 00 AX 0 0 16 [ 1] .data PROGBITS 0000000000004000 0003c4 000100 00 WA 0 0 16 [ 2] .bss NOBITS 0000000000005000 0004c4 000100 00 WA 0 0 16 [ 3] .symtab SYMTAB 0000000000006000 0005c4 000f00 10 0 10 4
-a
:显示所有信息-D
:显示 ELF 文件的动态符号表,通常用于动态链接的 ELF 文件
Dynamic section at offset 0x6b0 contains 20 entries: Tag Type Name/Value 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 0x000000000000000c (INIT) 0x0000000000010000 0x000000000000000d (FINI) 0x0000000000011000 0x0000000000000019 (SONAME) Library soname: [libfoo.so.1]
-s
:显示 ELF 文件的符号表,列出文件中的所有符号,包括函数、变量、对象等
Symbol table '.symtab' contains 5 entries: Num: Value Size Type Bind Vis Ndx Name 1: 0000000000001000 56 FUNC GLOBAL DEFAULT 1 _start 2: 0000000000001040 56 FUNC GLOBAL DEFAULT 2 main 3: 0000000000001080 56 FUNC GLOBAL DEFAULT 3 foo 4: 00000000000010c0 56 FUNC GLOBAL DEFAULT 4 bar 5: 0000000000001100 56 FUNC GLOBAL DEFAULT 5 baz
-
objdump
:反汇编 ELF 文件,查看汇编代码 -
objcopy
:用于在不同的对象文件格式之间进行转换- 将ELF格式的文件转为二进制
objcopy -O binary -S your_elf_file.elf your_binary_file.bin
1.结构和组织:
- ELF文件:包含多个段,如
.text
、.data
、.bss
等,以及程序头表等,用于描述这些段在内存中的布局。ELF文件还包含符号表、重定位信息等,用于链接和调试 - 普通二进制文件:通常是简单的连续二进制流,不包含段的概念。它们通常只包含可执行代码和数据,没有额外的元数据,数据之间也没有分段
2.加载和执行:
-
ELF文件:需要操作系统的配合来加载和执行。操作系统会解析ELF文件的程序头表,将各个段加载到内存中的适当位置,并执行文件头指定的入口点。举个例子:假设有一个简单的 ELF 文件,其中
e_entry
指向地址0x1000
,表示程序的入口点。- 操作系统加载 ELF 文件时,会根据程序头表将
.text
段(代码段)加载到内存中,例如加载到0x1000
地址。 - 加载完成后,操作系统将 CPU 的程序计数器(PC)设置为
0x1000
,开始执行位于该地址的指令。
- 操作系统加载 ELF 文件时,会根据程序头表将
-
普通二进制文件:直接被烧录到内存中,不需要操作系统的参与。芯片复位时PC指针会被设定为固定地址,并从该地址开始执行,所以普通二进制文件一般直接被加载到该地址(像之前写的STM32裸机程序、U-Boot之类的都是这样开始运行的)
3.重定位和链接:
- ELF文件:支持动态重定位和链接,可以在运行时动态加载和链接共享库(==只有ELF格式的文件才支持动态库==)
- 普通二进制文件:不支持动态重定位和链接,它们通常是静态编译的,所有必要的代码和数据都包含在单个文件中
4.文件大小和效率:
- ELF文件:由于包含额外的元数据,文件大小可能比普通二进制文件大
- 普通二进制文件:通常更小,因为它们只包含必要的代码和数据
mmap()
是Linux中一个内存映射的系统调用,用于将一个文件映射到当前进程的虚拟内存空间(针对不同类型的文件它的作用不同)。映射后的文件或内存区域可以像普通内存一样访问,从而避免了通过传统I/O操作(如read()
和write()
)进行数据拷贝。通过这种方式,mmap()
可以提供更高效的内存管理,减少了内核空间和用户空间之间的拷贝开销。
使用场景:
1.减少内核空间和用户空间之间的拷贝开销
- 普通的文件:当我们通过
read()
读取文件的内容时,流程大概是这样的:磁盘-->内核的内存空间-->用户进程的内存空间,文件内容首先被读取到内核的内存空间中,然后再拷贝给用户进程。这个从内核->用户的拷贝是比较耗费时间的,使用mmap
可以让用户空间的一段内存映射到被打开文件在内核内存中的位置,这样就可以直接在用户空间通过对映射区的读写来间接地读写文件了 - 设备驱动文件:设备驱动有的时候需要申请内核空间的内存(比如FrameBuffer设备需要申请一块内存来保存要显示的东西),如果应用层要对这片内存进行读写,驱动层需要调用
copy_from_user
或copy_to_user
这样的接口,这同样是内核和用户空间的拷贝,为了减少不必要的拷贝,可以让用户空间的一块内存直接映射到驱动在内核空间分配的内存,这样的话就可以在用户空间直接修改驱动文件的内存了
2.共享内存
mmap()
也常用于实现进程间通信,例如映射一个共享内存区域,允许多个进程同时访问同一块内存
3.创建执行内存区域,将二进制可执行文件直接映射到进程的地址空间,从而实现更快速的代码加载和执行
之所以要在用户空间和内核空间拷贝数据,是因为用户空间无法直接访问内核空间的内存,
mmap()
的作用就是建立一个用户空间到内核空间的映射,从而可以直接访问内核空间的一块内存
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
其中各参数的含义如下:
-
addr
:映射在用户内存空间的起始地址,如果为NULL,则由系统自动分配一个地址 -
length
:映射的长度,以字节为单位 -
port
:映射区域的保护方式。可以为下列几种方式的组合:PROT_EXEC
:可执行PROT_READ
:可读PROT_WRITE
:可写PROT_NONE
:不可访问
-
flags:标志位,可以是下列标志的组合:
MAP_FIXED
:使用指定的映射起始地址,如果起始地址不可用,则会报错MAP_SHARED
:共享映射,允许多个进程访问同一个内存区域,对共享内存区的修改会立即反映到所有的映射中MAP_PRIVATE
:私有映射,内存区域只能被当前进程访问,对私有内存区的修改不会反映到其他进程中MAP_ANONYMOUS
:创建一个匿名映射,不需要和文件关联
-
fd
:映射对象的文件描述符,如果使用MAP_ANONYMOUS
标志,则该参数应该设置为-1 -
offset
:被映射对象的偏移量。如果映射的是文件,那么offset
应该为fd
指定的文件中的偏移量;如果是匿名映射,那么offset
应该为0 -
返回值:映射区的在用户内存空间的起始地址,如果映射失败则返回
MAP_FAILED
参考链接:
这个项目里的interp.c
用到了函数指针,对于函数指针的用法,之前一直容易忘记,现在做个笔记
- 函数指针的定义:指向一个函数的指针
函数指针的定义及使用方法如下:
return_type (*pointer_name)(parameter_list);
int add(int a, int b) {
return a + b;
}
// func_ptr是个指向函数的指针
int (*func_ptr)(int, int) = add;
// 通过函数指针调用函数
int result = func_ptr(3, 5);
可以看到用这种方法还是比较麻烦的每次创建个函数指针都得写一大串,所以一般用下面这个方法:
typedef return_type (*alias_name)(parameter_list)
int add(int a, int b) {
return a + b;
}
// func_ptr是一类函数指针的别名
typedef int (*func_ptr)(int, int);
// 相当于std::function<int(int, int)>;
func_ptr fun = add;
// 通过函数指针调用函数
int result = fun(3, 5);