最小系统介绍
什么是最小系统?怎么实现?
使用printf实现打印hello world
有没有更简单的实现?
(1) 不使用外部任何库,头文件
(2) 单个文件
(3) 最简单的代码
打印:hello world
使用汇编语言实现
x86-64位机器实现打印hello world程序如下:
c
char* str = "Hello world!\n";
//static const char* str = "Hello world!\n";
//arch/x86/entry/syscalls/syscall_64.tbl
static void exit() {
//void exit(int status);
//param1: status
asm("movq $42,%rdi \n\t"
//syscall number exit = 60
"movq $60,%rax \n\t"
//enter system call
"syscall \n\t");
}
static void printf() {
//ssize_t write(int fd, const void *buf, size_t count);
¦ //param3: count
asm("movq $13, %%rdx \n\t"
//param2: buf
"movq %0, %%rsi \n\t"
//param1: fd
"movq $1, %%rdi \n\t"
//syscall number write = 1
"movq $1, %%rax \n\t"
//enter system call
//"syscall \n\t" ::"r"(str));
"syscall \n\t" ::"r"("Hello world!\n"));
}
void nomain() {
printf();
exit();
}
aarch64位机器实现打印hello world程序如下:
c
char* str = "Hello world!\n";
void exit() {
asm("ldr X0,=45 \n\t"
¦ "ldr X8,=93 \n\t"
¦ "svc 0x0 \n\t");
#if 0
asm("mov X0,#45 \n\t"
¦ "mov X8,#93 \n\t"
¦ "svc 0x0 \n\t");
#endif
⚠ }
⚠ void printf() {
#if 1
asm("ldr X2,=13 \n\t"
¦ "mov X1,%0 \n\t"
¦ "ldr X0,=1 \n\t"
¦ "ldr X8,=64 \n\t"
¦ "svc 0x0 \n\t" ::"r"(str));
#else
asm("mov X2,#13 \n\t"
¦ "mov X1,%0 \n\t"
¦ "mov X0,#1 \n\t"
¦ "mov X8,#64 \n\t"
¦ "svc 0x0 \n\t" ::"r"(str));
#endif
}
void nomain() {
printf();
>> exit();
}
最小系统实现
正常C语言的包含glibc库的实现(x86架构(32bit,64bit))
不使用glibc的汇编语言实现(x86架构(32bit,64bit))
如果有机会的话把RK3308上面的ARM64架构的glibc实现和汇编语言实现也进行代码编写和反汇编介绍
X0-X7存放参数 X8存放系统调用编号
ARM64系统调用:
Sys_write ------ 64
Sys_exit ------ 93
SVC 进入系统调用
最小系统牵扯到的细节部分
同样是能打印hello world的程序,为什么一个程序这么大,另一个这么小?程序运行流程一样吗?程序怎么运行的?程序里面到底是什么?机器怎么执行我们写的程序?
系统调用过程介绍(x86架构(32bit,64bit))
(1) X86架构中老版本使用int 0x80软中断方式实现系统调用(用于程序跨越到内核程序中),后来使用fast system calls(sysenter进入系统调用,sysexit退出)
(2) X86-64bit系统中使用fast system calls(syscall进入系统调用,sysret返回用户程序)
整体调用流程(结合程序反汇编内容介绍):
(1) 从入口函数(初始化输入参数,读取系统环境,初始化堆,初始化IO,文件等)
(2) main(调用系统调用相关内容介绍)
(3) 退出函数exit等进行介绍
C语言程序编译流程
示例(打印hello world字符串):
c
#include<stdio.h>
int main(int argc, char **argv)
{
printf("Hello World!\n");
return 0;
}
Linux下,当我们使用gcc来编译Hello World程序时,只需使用最简单的命令:
bash
gcc hello.c
./a.out
Hello World!
事实上,上述过程可以分解为4个步骤:分别为:预处理、编译、汇编、链接,如下图所示:
预处理
首先源代码hello.c和相关头文件,如stdio.h等被预编译器cpp预编译成成一个.i文件。
如下命令:
bash
Gcc --E hello.c --o hello.i
或者
cpp hello.c
预处理过程主要处理源代码中以#开头的预编译指令,如:"include","define"等,处理规则如下:
(1) 将所有#define删除,展开所有宏定义
(2) 处理所有预编译指令,如:#if、#ifdef、#else、#elif、#endif
(3) 处理#include,将头文件内容插入到该预编译指令位置(递归插入)。
(4) 删除所有注释"//"和"/**/"
(5) 添加行号和文件名标识,如:#2 "hello.c",以便于编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号
(6) 保留所有#pragma编译器指令(编译器使用)
经过预编译后的.i文件不包含任何宏定义,因为所有宏均已展开,头文件也被展开。
编译
编译的过程是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成的相应汇编代码文件,这个过程是整个程序构建的核心部分,也是最复杂的部分(涉及到编译器原理)。
编译命令:
bash
Gcc --S hello.i --o hello.s
预处理编译两步合成一步:
bash
Gcc --S hello.c --o hello.s
或者使用cc1:
bash
Cc1 hello.c
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎对应一个机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,只需要根据汇编指令和机器指令对照表一一翻译就可以了。
汇编器使用as:
bash
as helloc.s --o hello.o
或者:
bash
gcc --c hello.s -o hello.o
或者:
bash
gcc --c hello.c --o hello.o
链接
链接器命令:
bash
ld -static /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/11/crtbeginT.o -L /usr/lib/gcc/x86_64-linux-gnu/11/ -L /usr/lib/x86_64-linux-gnu/ -
L /usr/lib -L /lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
如下图所示:
编译器工作大致流程
编译器最繁重,最复杂的任务就是将源码翻译成汇编语言。
编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。如下图所示:
介绍ELF文件中的各个段
使用实际的例子来进行介绍
ELF文件类型
一个程序包括:代码段,数据段,bss,堆栈等,如下一个简单的例子:
bash
.text 代码段
.data 数据段
.bss 未初始化全局变量和局部静态变量
.rodata1 只读数据段,如:字符串常量,全局const变量,跟.rodata一样
.comment 存放的是编译器版本信息,如:GCC 11.4.0
.debug 调试信息
.dynamic 动态链接部分
.hash 符号哈希表
.line 调试时的行号表,即编译源代码行号与编译后指令对照表
.note 额外的编译器信息,如:公司名,发布版本号等
.strtab string table字符串表,用于存储ELF中用到的各类字符串
.symtab symbol table符号表
.shstrtab 段名字符串表
.plt
.got 动态链接跳转表和全局入口表
.init
.fini 程序初始化和终结代码段
ELF文件头
举例分析ELF文件头(/mnt/share/最小系统/ simpleSection.c)
ELF头定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、程序入口地址、程序头入口和长度、段表位置和长度及段的数量等。
对应/usr/include/elf.h中如下结构:
举例如下:
bash
root@sc-VirtualBox:最小系统# readelf -h simpleSection.o
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 984 (bytes into file)
标志: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 14
Section header string table index: 13
各个节属性如下:
Magic:ELF魔数对应e_ident,16字节用来标识ELF文件平台属性,如:ELF字长(32位/64位)、字节序、ELF文件版本
最开始四个字节是所有ELF文件都必须相同的标识码,分别为0x7F,0x45,0x4c,0x46,第一个字节对应ASCII字符里面的DEL控制符,后面三个字节刚好就是ELF这3个字节的ASCII码。这四个字节为ELF文件魔术,用来确定文件类型。
接下来一个字节为ELF文件类型,第6个字节为字节序,第七个字节为ELF版本(ELF1.2以后没有更新)。后面9个字节ELF标准没有定义。
段表保存ELF文件中各个段的相关属性(段名,段长度,在文件中偏移,读写权限及段的其他属性)。编译器,、链接器和装载器都是依靠段表来定位和访问段的属性。段表偏移由e_shoff决定。
ELF段表结构
ELF段表描述符结构(数组形式存储每个段信息),段表结构如下:
各个成员含义如下:
例:
bash
root@sc-VirtualBox:最小系统# readelf -S simpleSection.o
There are 14 section headers, starting at offset 0x3d8:
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000045 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 000002e8
0000000000000048 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 00000088
000000000000000c 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 00000094
0000000000000008 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 00000094
0000000000000004 0000000000000000 A 0 0 4
[ 6] .comment PROGBITS 0000000000000000 00000098
000000000000002c 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000c4
0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.pr[...] NOTE 0000000000000000 000000c8
0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 000000e8
0000000000000058 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 00000330
0000000000000030 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 00000140
0000000000000138 0000000000000018 12 7 8
[12] .strtab STRTAB 0000000000000000 00000278
000000000000006e 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 00000360
0000000000000074 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
段类型:
段的标志位:
系统保留段:
段链接信息:
重定位表:
/rel.text段类型为重定位表。链接器在处理目标文件时,需要对目标文件中某些部分进行重定位,即代码段数据段中对绝对地址引用的位置。这些信息都记录在ELF文件的重定位表中,每个需要重定位的代码段或者数据段,都有一个对应的重定位表。
字符串表:
字符串表包含了各类字符串,如段名(.strtab或者.shstrtab),变量名等
由上可知,ELF文件通过文件头就可以获知整个文件结构中各个段内容。
符号
符号包括:程序文件中包含的所有使用到的函数和变量。函数名和变量名就是符号名。每个目标文件都会有对应的符号表,记录了用到的所有符号,每个符号对应的数值就是符号值。
符号表中所有符号分类如下:
(1) 目标文件中全局符号(举例/mnt/share/最小系统/simpleSection.c),func1,main,global_init_var
(2) 引用的外部全局符号(未定义在本文件中),如:printf
(3) 段名,由编译器产生,值就是该段的起始地址,如:.text,.data等
(4) 局部符号,这类符号只在编译器内部可见,可用来分析程序崩溃
(5) 行号信息,即目标文件指令和源代码中的行号
c
ELF符号表(段名为.symtab)结构如下:
typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
每个ELF符号都对应一个ELF符号表数组元素。
各成员对应如下:
st_info:
st_shndx(符号所在段):
如果符号在本目标文件中,那么这个成员表示符号所在的段在段表中的下标,但是如果符号不是定义在本目标文件中,或者对于某些特殊符号,st_shndx有些特殊,如下:
符号值(st_value):每个符号对应值。大致分为如下几类:
(1) 符号不是"COMMON"块,则st_value表示符号在段中的偏移。即符号所对应的函数或者变量位于st_shndx制定的段,偏移st_value位置。
(2) 目标文件中,如果符号是"COMMON块"类型,则st_value表示该符号的对齐属性。
(3) 在可执行文件中,st_value表示符号的虚拟地址。这个虚拟地址对于动态链接器来说十分有用。
例(/mnt/share/最小系统/ simpleSection.o):
特殊符号
__executable_start -- 程序开始地址
Etext - 代码段结束地址
Edata - 数据段结束地址
End - 程序结束地址
例:(/mnt/share/最小系统/specialSymbol.c)
强符号弱符号
attribute((weak))来指定全局变量为弱符号。
链接
链接过程分为两步:空间地址分配,符号解析与重定位
第一步 空间与地址分配
扫描所有输入文件,获取各个段长度,位置,属性,将所有输入目标文件中符号表定义和符号引用收集起来,同一放到全局符号表中。链接器ld获取所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,建立映射关系
第二步 符号解析与重定位(核心)
使用第一步搜集到的信息,读取输入文件中段的数据,重定位信息,并且进行符号解析与重定位,调整代码中的地址等。
bash
例(/mnt/share/最小系统/link/a.c b.c):
gcc -c a.c b.c
ld a.o b.o -o ab -e main
空间与地址分配
链接前后,目标文件各个段的分配,程序虚拟地址如下:
链接之后,各个段地址确定,各个符号地址由段地址加上偏移即可得到。
符号解析与重定位
反汇编a.o如下:
符号shared可直接确定地址404000(readelf -S ab可知),符号swap需要根据偏移量来进行计算。Call指令是一条近址相对位置调用指令,后面跟的是调用指令下一条指令的偏移量,call指令下一条指令是add,它的地址为0x40102e,相对于add指令偏移量为0x40102e+7=0x401035
重定位表
链接器如何知道哪些指令需要被调整,怎么调整?有一个重定位表结构专门用来保存重定位相关信息。
c
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
} Elf64_Rel;
例:
bash
root@sc-VirtualBox:link# objdump -r a.o
a.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000001a R_X86_64_PC32 shared-0x0000000000000004
000000000000002a R_X86_64_PLT32 swap-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
符号解析
由于程序由多个文件组成,必然涉及到要使用到外部符号,当链接器需要对某个符号进行重定位的时候,首先就要从全局符号表中查找相关符号,然后进行重定位操作。如果找不到相关引用到的符号,则直接报符号未找到(也就是编译器编译源代码的时候打印出来的undefined xxx错误)。
介绍ELF文件程序启动调用流程
好了,上一节结束,我们已经知道了程序链接的流程,程序已经正常生成,下面就开始运行程序。
使用实际的例子来进行介绍(结合printf程序来看)
一个C/C++程序一般都从main开始,随着main结束而结束。实际中,main函数调用之前,为了使线程能够顺利执行,要先初始化执行环境,比如堆分配初始化(malloc,free)、线程子系统等(C++中构造函数在main调用之前被调用,析构函数在main调用之后被调用)。
Linux系统下一般程序入口为_start,这个函数是glibc的一部分。当我们的程序与Glibc库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分入口,程序初始化部分完成一系列初始化过程,调用main函数执行程序主体。Main函数执行完成之后,返回到初始化部分,进行一些清理工作,然后结束进程(典型的例子:C++构造和析构函数)。ELF文件定义了两种特殊的段,如下:
(1) .init 包含了进程初始化代码。Main函数被调用之前,glibc初始化部分安排执行这个段中的代码。
(2) .fini 包含了进程终止代码。Main函数正常退出,glibc会安排执行这个段中的代码。
进程在内存中的整体布局如下所示:
使用具体例子来看进程在内存空间的实际内存分布图(cat /proc/self/maps)
举例说明,程序入口点是main函数吗?
1./mnt/share/最小系统/entry-c/1.c
当程序刚刚执行到main的时候,全局变量的初始化过程已经结束了(a值已经确定),main函数的两个参数(argc,argv)也都已经被传了过来。此外,在你不知道的时候,堆和栈的初始化已经悄悄完成,一些系统I/O也都已经初始化了,因此可以正常使用printf和malloc。
- /mnt/share/最小系统/entry-c/2.c
atexit注册main结束或者exit函数调用结束程序之后要执行的函数。
3.c++里面的构造函数和析构函数
bash
一个典型程序的运行步骤如下:
(1) 操作系统创建进程之后,把控制权交给程序入口(这个入口往往是运行库的某个入口函数)。
(2) 入口函数完成运行库和程序运行环境的初始化工作,包括:堆,I/O,线程,全局变量构造等。
(3) 入口函数初始化完成,调用main函数,正式开始执行程序主体部分。
(4) Main函数执行完毕之后,返回到入口函数,进行清理工作,包括:全局变量析构,堆销毁,关闭I/O等,然后进行系统调用结束进程。
入口函数如何实现
GLIBC入口函数
bash
示例(/mnt/share/最小系统/c/tinyHelloWorld.c):
Objdump --d tinyHelloWorld
Glibc入口函数为_start(入口点为ld链接器指定(可通过ld --verbose来查看默认链接器脚本),可以通过参数自行修改入口点)。_start由汇编实现,并且和平台相关,下面可以单独看x86_64的_start实现:
c
ENTRY (_start)
/* Clearing frame pointer is insufficient, use CFI. */
cfi_undefined (rip)
/* Clear the frame pointer. The ABI suggests this be done, to mark
¦ the outermost frame obviously. */
xorl %ebp, %ebp
/* Extract the arguments as encoded on the stack and set up
¦ the arguments for __libc_start_main (int (*main) (int, char **, char **),
¦ int argc, char *argv,
¦ void (*init) (void), void (*fini) (void),
¦ void (*rtld_fini) (void), void *stack_end).
¦ The arguments are passed via registers and on the stack:
main: %rdi
argc: %rsi
argv: %rdx
init: %rcx /* 调用main前的初始化工作 */
fini: %r8 /* 调用main后的收尾工作 */
rtld_fini: %r9 /* 动态库加载收尾工作 */
stack_end: stack. */ /* 栈底地址 */
mov %RDX_LP, %R9_LP /* Address of the shared library termination
¦ function. */
#ifdef __ILP32__
mov (%rsp), %esi /* Simulate popping 4-byte argument count. */
add $4, %esp
#else
popq %rsi /* Pop the argument count. */
#endif
/* argv starts just at the current stack top. */
mov %RSP_LP, %RDX_LP
/* Align the stack to a 16 byte boundary to follow the ABI. */
and $~15, %RSP_LP
/* Push garbage because we push 8 more bytes. */
pushq %rax
/* Provide the highest stack address to the user code (for stacks
¦ which grow downwards). */
pushq %rsp
#ifdef SHARED
/* Pass address of our own entry points to .fini and .init. */
mov __libc_csu_fini@GOTPCREL(%rip), %R8_LP
mov __libc_csu_init@GOTPCREL(%rip), %RCX_LP
mov main@GOTPCREL(%rip), %RDI_LP
#else
/* Pass address of our own entry points to .fini and .init. */
mov $__libc_csu_fini, %R8_LP
mov $__libc_csu_init, %RCX_LP
mov $main, %RDI_LP
#endif
/* Call the user's main function, and exit with its value.
¦ But let the libc call main. Since __libc_start_main in
¦ libc.so is called very early, lazy binding isn't relevant
¦ here. Use indirect branch via GOT to avoid extra branch
¦ to PLT slot. In case of static executable, ld in binutils
¦ 2.26 or above can convert indirect branch into direct
¦ branch. */
call *__libc_start_main@GOTPCREL(%rip)
hlt /* Crash if somehow `exit' does return. */
END (_start)
bash
_start -> __libc_start_main -> generic_start_main
generic_start_main:
__pthread_initialize_minimal 线程库初始化
__cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL) 注册动态链接器初始化
__libc_init_first (argc, argv, __environ); libc初始化
__cxa_atexit ((void (*) (void *)) fini, NULL, NULL) 注册程序收尾代码__libc_csu_fini
(*init) (argc, argv, __environ MAIN_AUXVEC_PARAM) --__libc_csu_init
result = main (argc, argv, __environ MAIN_AUXVEC_PARAM) 运行main函数
exit (result) 程序退出
调用注册的atexit函数
根据平台架构调用对应的流程
Glibc组成
(1) 头文件/usr/include
(2) 库文件/usr/lib/libc.a libc.so
(3) 运行库/usr/lib/x86_64-linux-gnu/crt1.o crti.o crtn.o
Crt1.o包含程序入口函数_start,由他负责调用__libc_start_main初始化libc并调用main。由于C++的出现和对ELF文件的改进,出现了必须要在main函数之前执行的全局/静态对象构造和必须在main函数之后执行的全局/静态对象析构。为了满足类似需求,运行库在每个目标文件中引入两个与初始化相关的段".init"和".fini"。运行库保证这两个段里面的代码先于/后于main函数执行。链接器进行链接的时候,需要一些辅助代码,所以引入.crti.o和crtn.o。
Crti.o和crtn.o包含的代码实际上是_init和_finit()函数的开始和结尾部分,可通过反汇编objdump --dr crti.o和crtn.o查看。二进制文件中的init段和fini段都是通过合并Crti.o和crtn.o中的init和fini段代码而来。
举例:
bash
objdump -dr crti.o
objdump -dr crtn.o
objdump --d /mnt/share/最小系统/c/tinyHelloWorld
剩余:crtbeginT.o, libgcc.a, libgcc_eh.a, crtend.o
crtbeginT.o, crtend.o实现C++全局构造和析构函数
__do_global_dtors_aux析构函数.fini中会调用
Libgcc.a处理平台差异性的东西(如,32位不支持64位long long类型的运算,libgcc.a中包含整数运算,浮点数运算等)
Libgcc_eh.a包含支持c++异常处理相关函数
gmon_start 用于生成程序执行的状态profile文件(包含程序运行时各部分运行时间),gcc加上编译选项-pg程序运行结束即可生成gmon.out文件,通过gprof工具可以分析。
程序加载过程(程序如何加载进内存中):
覆盖装入和页映射是两种典型的动态装载的方法。
(1) 覆盖装入
(2) 页映射(目前主流操作系统使用)
进程创建运行流程
三步:
(1) 创建一个独立的虚拟地址空间
I386的Linux下,创建虚拟空间实际上只是分配一个页目录,不设置映射关系(映射关系等到后面程序发生也错误的时候再进行设置)
(2) 读取可执行文件头,并且建立虚拟地址空间与可执行文件的映射关系
进程数据结构中保存如下信息:
代码在可执行文件中的位置,大小,对齐关系
虚拟空间的位置,大小
(3) 将CPU的指令存储器设置成可执行文件的入口地址,启动运行
包括:内核空间切换到用户空间,堆栈信息保存,CPU运行权限切换等
缺页:
CPU开始执行第一条指令时,发现虚拟内存对应页面没有实际对应的物理内存页面,此时触发缺页异常,操作系统会进行专门处理操作,找到空页面所在的VMA,计算出相应页面在可执行文件中的偏移,在物理内存中分配一个物理页面,将虚拟内存页与物理内存页之间进行映射,再返回到用户空间继续执行指令。
Linux内核装载ELF过程简介
首先,bash进程(每当开启一个虚拟终端的时候都会开一个bash进程等待解释用户输入命令,此处可举例演示)会调用fork()系统调用创建一个新的进程,然后新进程调用execve()系统调用执行制定的ELF文件,原先bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令,如下示例:
c
int execve(const char *filename, char *const argv[],
char *const envp[]);
char buf[4096] = {0};
pid_t pid;
while(1) {
printf("minibash$ ");
scanf("%s", buf);
pid = fork();
if (pid == 0) {
execve(buf, NULL, NULL);
} else if (pid > 0) {
Waitpid(pid, &status, 0);
} else {
Printf("fork error!\n");
}
}
execve调用系统调用入口为sys_execve(),sys_execve进行一些参数的检查复制之后,调用do_execve。Do_execve()首先查找可执行文件,读取文件前128字节(a.out,Java,脚本程序等),判断文件格式,ELF可执行文件前四个字节为0x7F,'e','l','f'。
读取头部之后,调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中每种可执行文件都有相应的装载处理过程,search_binary_handle通过判断文件头魔数确定文件格式,进而调用装载处理函数。比如ELF文件装载处理过程为load_elf_binary();a.out装载处理过程为load_aout_binary();可执行脚本处理过程为load_script()。
bash
load_elf_binary函数流程如下:
(1) 检查ELF可执行文件格式有效性,包括:魔数,程序表中段数量
(2) 寻找动态链接.interp段,设置动态链接器路径
(3) 根据ELF文件头表描述,对ELF文件进行映射,比如:代码,数据,只读数据
(4) 初始化ELF进程环境
(5) 系统调用返回地址设置为ELF入口文件点(有解释器,则设置为解释器entry)
加载完毕之后,返回至do_execve,返回至sys_execve,返回到用户空间,执行新程序。
此处可介绍一个实际例子,并且伴随着内核源代码进行分析即可。(ELF装载过程-strace-ls.txt,内核代码加载ELF分析)。
bash
chongsun2@ubuntu:~$ strace pwd
**execve("/bin/pwd", ["pwd"], [/* 20 vars */]) = 0** 此处将校验ELF文件,并将ELF文件加载到内存中并设置PC知道到ELFentry入口处(具体可查看kernel/fs/exec.c中SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp))
brk(0) = 0x77c000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f52aea71000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=63239, ...}) = 0
mmap(NULL, 63239, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f52aea61000
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P \2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=1840928, ...}) = 0
mmap(NULL, 3949248, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f52ae48c000
mprotect(0x7f52ae646000, 2097152, PROT_NONE) = 0
mmap(0x7f52ae846000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1ba000) = 0x7f52ae846000
mmap(0x7f52ae84c000, 17088, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f52ae84c000
close(3) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f52aea60000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f52aea5e000
arch_prctl(ARCH_SET_FS, 0x7f52aea5e740) = 0
mprotect(0x7f52ae846000, 16384, PROT_READ) = 0
mprotect(0x606000, 4096, PROT_READ) = 0
mprotect(0x7f52aea73000, 4096, PROT_READ) = 0
munmap(0x7f52aea61000, 63239) = 0
brk(0) = 0x77c000
brk(0x79d000) = 0x79d000
open("/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=3571056, ...}) = 0
mmap(NULL, 3571056, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f52ae124000
close(3) = 0
getcwd("/home/chongsun2", 4096) = 16
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 10), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f52aea70000
write(1, "/home/chongsun2\n", 16/home/chongsun2
) = 16
close(1) = 0
munmap(0x7f52aea70000, 4096) = 0
close(2) = 0
exit_group(0) = ?
+++ exited with 0 +++