文章目录
- 总述
- 裸程序
- 存储布局
- DEMO
- 裸程序的缺点
- ELF程序
- 存储布局
- hello world分析
- elf可执行程序的适用场景
- 新的需求
- 共享库
- 进程地址空间
- 进程的内存管理
- 内核工作原理
- Linux进程空间管理实现原理
- 附:
- BIOS引导硬盘,软驱关键代码
总述
- linux上运行的程序从裸程序,到elf可执行程序再到共享库程序,层次由低到高,实现由简单到复杂,功能由弱变强,最终演变现在的二进制程序。本文试图通过分析这三类程序的存储布局,加载运行原理及各自可以实现的功能,来说明应用程序的运行原理
裸程序
存储布局
- 故名思义,裸程序是只包含代码和数据的二进制程序,它在编译成可执行程序时代码中的标号已经被编译器确定,并设置了入口地址(Entry point address),加载器只要按照约定将其加载到指定的地址并运行,就可以正确执行这段程序
DEMO
- 这段代码是NASM汇编代码,可以作为BIOS启动后加载的第一段程序。BIOS规范和中断向量表中规定int 18/19两个中断的作用的是引导光盘和硬盘,工作是将引导设备(floppy/harddisk)中的第0个磁盘的第1个扇区 - 引导扇区(扇区从1开始计数)的内容加载到0x7c00处,然后跳转到此处运行。
- 使用bochs模拟器,将这段程序先dd到软驱中,然后启动bochs从软驱引导,bochs实现了BIOS的功能,引导软驱时会加载其引导扇区内容到0x7c00处,然后转移到0x7c00,这段代码最终就会被正确执行
org 07c00h ; 告诉编译器程序加载到7c00处
jmp 07c0h:DispStrOff
code:
times 10 db 0
; never reach here
DispStrOff equ $ - $$
DispStr:
mov ax, BootMessage
mov bp, ax ; ES:BP = 串地址
mov cx, 16 ; CX = 串长度
mov ax, 01301h ; AH = 13, AL = 01h
mov bx, 000ch ; 页号为0(BH = 0) 黑底红字(BL = 0Ch,高亮)
mov dl, 0
int 10h ; 10h 号中断
jmp $
BootMessage: db "Hello, OS world!"
- 运行效果
- 编译该程序并反汇编查看其关键信息
00007C00 EA0F00C007 jmp word 0x7c0:0xf ; jmp 07c0h:DispStrOff
00007C05 0000 add [bx+si],al ; 10个字节的全0
00007C07 0000 add [bx+si],al
00007C09 0000 add [bx+si],al
00007C0B 0000 add [bx+si],al
00007C0D 0000 add [bx+si],al
00007C0F B8237C mov ax,0x7c23 ; mov ax, BootMessage
00007C12 89C5 mov bp,ax ; mov bp, ax
00007C14 B91000 mov cx,0x10 ; mov cx, 16
00007C17 B80113 mov ax,0x1301 ; mov ax, 01301h
00007C1A BB0C00 mov bx,0xc ; mov bx, 000ch
00007C1D B200 mov dl,0x0 ; mov dl, 0
00007C1F CD10 int 0x10 ; int 10h
00007C21 EBFE jmp short 0x7c21 ; jmp $
00007C23 48 dec ax ; db "Hello, OS world!"
00007C24 656C gs insb
00007C26 6C insb
00007C27 6F outsw
00007C28 2C20 sub al,0x20
00007C2A 4F dec di
00007C2B 53 push bx
00007C2C 20776F and [bx+0x6f],dh
00007C2F 726C jc 0x7c9d
00007C31 64 fs
00007C32 21 db 0x21
- 对比汇编源码,可以看到反汇编中将标号替换成了具体的地址,看来编译器完美地完成了我们布置的任务
- 还有一个问题值的思考,我们在反汇编的时候通过-o指定了程序加载地址,
ndisasm -o 0x7c00 real_mode > real_mode.dis
,如果不指定会是什么样,下面是不指定加载地址反汇编出来的内容 -
ljmp $0x07c0, $DispStr
指令中的0x7c0会被加载到cs寄存器中,0xf被加载到ip寄存器中,最后会跳转到0x7c0f地址,可以看到,如果BIOS把这段程序加载到地址0处,那么它将不会被正确执行,因为这条指令是位置相关
的,这条指令能否正确执行,取决于程序是否被正确的加载 -
mov ax, BootMessage
指令会将0x7c23写入ax,标号BootMessage
被汇编器替换成了0x7c23,等等,为什么会这样?因为我们用org 07c00h
伪指令设置了程序的加载地址
,因此汇编器会从0x7c00开始计算地址,代码中所有标号对应的地址,就等于相对起始处的偏移加上0x7c00,标号BootMessage
相对程序起始处偏移是0x23,再加上0x7c00,得到最终的0x7c23。这条指令能否正确执行,同样取决于程序是否被正确的加载,因此也是位置相关
的 -
jmp $
指令比较正常,无论程序被加载到0x0还是0x7c00,它都能正确的跳转,对比可以发现,这条指令和上面两条的不同时其二进制代码中没有与具体地址相关的数据,它记录的时相对地址,因此这条指令可以正常运行,它是位置无关
的
裸程序的缺点
- 上面对反汇编内容的分析可以看到,要想裸程序能够正常的运行,至少需要满足以下条件中的一个:
- 需要将裸程序加载到其特定的位置,这个位置称为
加载地址
- 裸程序的内容必须是位置无关的,这种代码称为
位置无关码
,PIC(position independent code)
- 裸程序的正确执行是需要条件的,反过来说,裸程序有以下的缺点:
- 不能加载到任意的地址的内存运行
- 编写裸程序必须要使用位置无关的指令
ELF程序
裸程序有这样那样的缺点,究其原因有两点:
- 程序中没有存放加载地址的信息,加载程序只能按照事先的约定地址来加载程序,如果程序中可以存放加载地址,那么它可以被任何一个加载程序正确运行
- 程序的位置无关性是用户来保证的,没有一种机制来保障程序的位置无关性,如果有这种机制,那么这个程序无论被加载到哪里,都能够正确地被运行
elf格式的程序很好的解决了这两个问题,并且也考虑了很多其它我们分析不到的情况,它相对裸程序来说,移植性更好,适用的范围更广
存储布局
- elf格式的文件的布局复杂,并且它不一定是可执行的程序,还可能是共享库或者对象文件等,这里我们关注elf可执行程序,并且只分析与加载地址相关的elf header。这里还有一些其它域的分析。
- elf header结构体
- elf header保存了文件的整体信息,组织结构信息等,包括魔数,文件类型,elf的版本,适用架构,加载地址(loader virtual address),segment table在文件内的偏移/条目大小/条目数量,section table在文件内的偏移/条目大小/条目数量,elf header头部大小等。elf header可以让加载程序找到元数据的所有地址,通过readelf -h可以查看elf header的所有信息,下面是一个简单的应用程序的elf header内容,对于加载器来说,elf格式的可执行程序elf heder中,重要的就是
Entry point address
,它是编译器和汇编器设置的加载地址,想要正确运行程序必须将其加载到这里设置的LVA处
hello world分析
- 反汇编hello程序,查看0x4004e0处的指令,它是.text section的开始,是
_start
标号的值,列出hello程序的section table,可以确认hello程序就是从_start
标号开始执行 - hello程序从
_start
标号处开始执行,那它之后的这段代码,是从哪儿来的?我们看到hello程序反汇编中并没有这段代码。 - 其实,这个工作是由gcc默认的链接脚本做的,它把运行hello程序必要的基本库同hello对象文件默认编译到一起,最终输出二进制的hello可执行程序。这个链接脚本还有一个工作是设置hello程序的加载地址。
ld --verbose
可以看到链接内置的链接脚本内容,我们分析两个地方
- 脚本的一开始就设置了程序的入口地址,也是LVM,也是加载地址,为
_start
- 查看程序的.text segment,它是由所有.o文件的.text.* section组成的,可以知道,
_start
标号后的代码,必定来自第一个提供.text.* section的.o文件 - 通过gcc -v -o hello hello.c可以看到gcc整个的编译,汇编,链接的过程,通过下面几点,可以解释
_start
标号后的代码来自哪里
a. 整个过程分为三个阶段,第一阶段编译,将c代码编译成汇编代码,使用的工具是cc1
b. 第二阶段是汇编,将汇编代码汇编成二进制程序,使用的工具是as
c. 第三阶段是链接,将hello.o对象文件同基本库对象文件链接,生成可执行的elf程序,使用的工具是collect2
d. 为了验证上面的步骤。我们分开执行每一步,最终生成的hello程序仍然可以运行正确
e. 在最后一步链接过程中,我们将hello.o与库对象链接起来了,hello.o中没有_start
标号,那么_start
标号及其后面的代码应该来自库文件,分析第hello.o后面的第一个.o文件:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crt1.o
- 查看对象文件的section段中,包含.text,并且由大小是0x2a
- 反汇编这个.o文件,查看其.text段的内容,对比hello二进制程序的反汇编,前面9行指令是一模一样的,后面几行操作命令是一样的,操作数不一样,应该是链接器在链接时做了调整,所以,可以猜测,hello中的.text段内容来自crt1.o,再分析其它几个.o文件,要么没有.text section,要么其内容和hello中的不一样,更加验证此结论。
- 最后再验证下hello程序是否真的被加载到0x4004e0并从此处运行
gdb反汇编0x4004e0,可以看到和hello可执行程序的反汇编一模一样,查看其内容和标号,也可以验证
从应用程序的内存映射图中也可以大致确定,hello程序的代码段是在00400000-00401000
区间
elf可执行程序的适用场景
- elf格式的可执行程序,可以满足一般使用场景,应用开发人员不用关注程序的链接和加载是否能正确执行,只要将其交给gcc和loader加载器,这两个基本工具:一个会生成具有加载地址的elf文件,另一个可以识别这个文件中的加载地址并正确加载。可以保证正确的链接和加载
- 如果所有程序都是上面这种使用场景,由于linux内核为进程分配的虚拟机地址空间是独立的,这意味着对于同一个应用程序,可以被许多个加载器同时加载到相同的地址空间。一个应用可以在同一时间同一个系统上多次运行,生成不同的进程。
新的需求
- 在共享库还没有出来之前,当我们要使用一个基本对象文件的既有的接口函数时,需要在链接时将这个对象文件同自己应用链接到一起,才能使用调用这个接口函数,每个想使用这个功能的应用程序都要链接这个对象。这大大增加了可执行程序中的数据量
- 为了减少数据量,有一个方法是在编译链接时不将包含基本功能接口的对象链接到应用中,只是在应用中为这个接口函数保留符号,等到程序运行时,再去加载对象文件,解析其接口对应的内存地址,这样就将接口函数的地址解析从链接阶段推迟到了运行阶段
- 满足上述需求的对象文件就是共享库,任何程序在运行时都可以加载它,并调用它提供的接口。
- 对比分析共享库和一般的可执行程序,共享库在加载时,加载它的程序已经运行了,因此这个进程的某些空间肯定已经被使用了,如果共享库和普通的可执行程序一样,设置了LVA,正好这个LVM在加载它的应用程序中已经被其它代码使用了,那么这个共享库将没法正确运行。进一步的,如果一个应用需要加载多个共享库,这两个共享库的LVA相同,那么肯定其中有一个无法正确的运行。
共享库
- 共享库有一个最基本的功能需要实现——支持任何程序将其加载到进程的任意地址空间。
编译可执行程序的时候,链接器会根据设置的LVM(如果不设置会使用默认的值)计算代码中所有的标号的地址,将标号与地址绑定。但共享库可以在任意地址运行,因此编译时不能确定共享库的加载地址(就算假定一个也不行)。解决这个问题有两个方向:
- 将绑定地址的工作交给加载共享库的加载器来做,加载完共享库,我们肯定知道这个共享库的地址了。将这个地址与共享库代码中的标号关联
- 使共享库的所有代码位置无关,无论共享库加载到哪儿,程序都可以正常运行。
进程地址空间
- 进程运行在内存上,其目标就是执行代码段提供的指令,所有进程数据都为代码执行服务,包括初始化数据段、BSS段(block started by symbol)、堆、栈等,不同数据类型应用于程序的不同执行场景。
一个hello程序
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int adder(int a, int b, int c, int d,
int e, int f, int g, int h);
int adder(int a, int b, int c, int d,
int e, int f, int g, int h)
{
return a+b+c+d+e+f+g+h;
}
void main(void)
{
int sum;
char *s = malloc(10);
memcpy(s, "xxxxxxxxx", 9);
s[9] = '\0';
sum = adder(1,2,3,4,5,6,7,8);
printf("s=%s,sum=%d\n",s,sum);
while(1)
{}
}
- 地址空间分布如下:
- maps输出的每一列信息解释如下
- 进程的地址空间由低到高布局如下:
- 正文段:可执行的正文程序,可共享,只读。进程其余所有数据都为此服务。
初始化数据段:正文程序中明确赋值的变量,运行时会用到,所有初始化的全局变量放在这里。初始化常量也放到这里。
BSS段:由符号开始的块,正文程序中声明但没有初始化的变量。这段被初始化为0。
堆:程序运行过程中可能通过调用malloc()这样的函数申请内存空间,Linux内核为这类行为预留了堆供其使用。如果堆中的数据持续增加,Linux内核可能会扩展堆的空间。
栈:数据先入后出,其特点决定程序中函数的递归调用,函数参数,局部变量的空间都从此分配。如果栈中的数据持续增加,Linux内核可能会扩展栈空间。
分析hello.c程序
数据分布情况:
char *s = malloc(10);
malloc从堆中分配数据,所以应指向堆:
55fc375b3000-55fc375d4000 rw-p 00000000 00:00 0 [heap]
memcpy(s, "xxxxxxxxx", 9);
字符常量"xxxxxxxxx"应放在初始化的数据段:
55fc37446000-55fc37447000 r--p 00002000 08:15 921133 /home/work/mm_struct/hello
55fc37447000-55fc37448000 r--p 00002000 08:15 921133 /home/work/mm_struct/hello
/*
TODO: 上面两段pgoff一样,是否都为初始化数据段?
*/
sum = adder(1,2,3,4,5,6,7,8);
x64架构函数调用使用“寄存器+栈”的方式传参,函数从左到右前6个参数用rdi,rsi,rdx,rcx,r8,r9传递,第7个及以上用栈。所以参数7和8在adder调用入栈:
7ffffb479000-7ffffb49a000 rw-p 00000000 00:00 0 [stack]
正文段:
除了hello程序本身编译产生的程序段,还包括bash加载时的init程序段,Libc共享库提供的可重定位程序段,都应放在正文段:
55fc37445000-55fc37446000 r-xp 00001000 08:15 921133 /home/work/mm_struct/hello
正文段属性r-xp,表示可读,可执行的私有段(private/share)
验证:
gdb attach `pidof hello`
(gdb) info registers rip
rip 0x55fc37445219 0x55fc37445219 <main+132>
(gdb) disas main+132
Dump of assembler code for function main:
0x000055fc37445195 <+0>: push %rbp
0x000055fc37445196 <+1>: mov %rsp,%rbp // rbp指向栈顶
0x000055fc37445199 <+4>: sub $0x10,%rsp // rsp栈针下移16byte
0x000055fc3744519d <+8>: mov $0xa,%edi // 调用malloc做准备,参数10传给edi
0x000055fc374451a2 <+13>: callq 0x55fc37445050 <malloc@plt> // 调用malloc,其地址在落在正文段中
0x000055fc374451a7 <+18>: mov %rax,-0x8(%rbp) // 返回参数s放入rax,保存到rbp-8的栈中,它指向的应该时heap中的地址
0x000055fc374451ab <+22>: mov -0x8(%rbp),%rax // 调用memcpy做准备,s->rax
0x000055fc374451af <+26>: mov $0x9,%edx // 9->edx
0x000055fc374451b4 <+31>: lea 0xe49(%rip),%rsi # 0x55fc37446004 // 字符串常量的地址落在数据段中 “xxxxxxxxx”->rsi
0x000055fc374451bb <+38>: mov %rax,%rdi // s->rdi
0x000055fc374451be <+41>: callq 0x55fc37445040 <memcpy@plt> // 同malloc
0x000055fc374451c3 <+46>: mov -0x8(%rbp),%rax
0x000055fc374451c7 <+50>: add $0x9,%rax
0x000055fc374451cb <+54>: movb $0x0,(%rax)
0x000055fc374451ce <+57>: pushq $0x8 // 调用adder做准备,参数入栈,rsp下移8byte
0x000055fc374451d0 <+59>: pushq $0x7 // rsp再下移8byte
0x000055fc374451d2 <+61>: mov $0x6,%r9d
0x000055fc374451d8 <+67>: mov $0x5,%r8d
0x000055fc374451de <+73>: mov $0x4,%ecx
0x000055fc374451e3 <+78>: mov $0x3,%edx
0x000055fc374451e8 <+83>: mov $0x2,%esi
0x000055fc374451ed <+88>: mov $0x1,%edi
0x000055fc374451f2 <+93>: callq 0x55fc37445155 <adder>
0x000055fc374451f7 <+98>: add $0x10,%rsp // 上移16byte
0x000055fc374451fb <+102>: mov %eax,-0xc(%rbp)
0x000055fc374451fe <+105>: mov -0xc(%rbp),%edx
0x000055fc37445201 <+108>: mov -0x8(%rbp),%rax
0x000055fc37445205 <+112>: mov %rax,%rsi
0x000055fc37445208 <+115>: lea 0xdff(%rip),%rdi # 0x55fc3744600e
0x000055fc3744520f <+122>: mov $0x0,%eax
0x000055fc37445214 <+127>: callq 0x55fc37445030 <printf@plt>
=> 0x000055fc37445219 <+132>: jmp 0x55fc37445219 <main+132>
cpu当前一直在程序末尾处循环,从头分析,rbp最开始指向栈顶,rsp下移了16byte,之后调用malloc申请内存,返回值保存到rax,rax将其压栈,所以(rbp-8)/(rsp+8)地址处存放的时s的值。之后rbp一直没变,rsp由于调用adder函数下移16byte,函数返回后仍然保持原位置。所以直到最后的循环,(rbp-8)/(rsp+8)仍然保存了局部变量s的值:
End of assembler dump.
(gdb) info registers rsp // 查看rsp
rsp 0x7ffffb497240 0x7ffffb497240
(gdb) x/10xg 0x7ffffb497248 // 查看 0x8(%rsp)处的内存信息,这个值是个指向heap的指针。
0x7ffffb497248: 0x000055fc375b3260 0x000055fc37445220
0x7ffffb497258: 0x00007efd40e4809b 0x0000000000000000
0x7ffffb497268: 0x00007ffffb497338 0x0000000100040000
0x7ffffb497278: 0x000055fc37445195 0x0000000000000000
0x7ffffb497288: 0x665a784ccc3224d0 0x000055fc37445070
(gdb) x/1sb 0x000055fc375b3260 // 查看s指向的内容,印证了我们的分析
0x55fc375b3260: "xxxxxxxxx"
(gdb) info registers rbp
rbp 0x7ffffb497250 0x7ffffb497250
进程的内存管理
- 内核中主要依靠两个数据结构
mm_struct,vm_area_struct
组织进程的线性空间。还是以一个程序为例 - 执行这个程序,查看它的用户态进程空间,验证是否和内核的数据结构打印信息一样
- 通过crash工具打印进程的两个数据结构
mm_struct,vm_area_struct
信息
- pidof 找到用户态程序
- 运行crash命令
- 打印进程的task_struct
crash> task 11569
- 在信息中找到mm成员
mm = 0xffff8ff31a1f2bc0
- 解析mm_struct结构,进程的线性空间被linux抽象成一个mm_struct结构,这个空间被分成若干内存区域管理,每段区域由vm_area_struct结构体表示,所有结构体组成一个链表,mmap成员指向该链表的入口,map_count记录当前由多少段内存区域。19,maps中显示了20段内存区域,其中有一个在内核空间,地址开头不一样,其余19个,就是这里的vm_area_struct
crash> mm_struct 0xffff8ff31a1f2bc0
- 解析首个vm_area_struct结构,关注vm_start和vm_end,这是10进制的输出,将其转化成16进制(00400000-00401000),对比maps中的第1,2列,完全相同,可以判断这个结构体就是链表中的第1个vm_area_struct成员。再看vm_flags标记,这是内存权限标记,这些标记指示用户和内核应该怎么使用这段内存区域。map中的第3列反映部分这个标记。134219893 = 0b1000000000000000100001110101,这里也可以查看到这段内存的权限,VM_READ和VM_EXEC bit位都为1,表示可读可执行,这是代码段,和maps中描述的一样。
- 再分析第4段内存区域,它对应进程的栈信息,hello函数里面在栈中分配了内存空间并使用mlock加了锁,mlock锁主要有以下功能:
a)被锁定的物理内存在被解锁或进程退出前,不会被页回收流程处理。
b)被锁定的物理内存,不会被交换到swap设备。
c)进程执行mlock操作时,内核会立刻分配物理内存(注意COW的情况)。
mlock的实现就是将vm_flags加上VM_LOCKED标记,并且和内核约定,加了这个标记后内核要怎么工作。135274611 = 0b1000000100000010000001110011,可以看到,这里VM_LOCKED被标记上了。
- 再分析下mm_struct,它是整个进程空间的管理者,除了维护虚拟机的地址空间,还要负责这个地址空间的映射,即VMA->PMA,因此它还要维护进程的页表和页表地址。当进程首次加载到cpu准备运行之前,内核会先分配一张空白的页表,将其初始化,然后取这张页表的地址放到mm_struct的pgd成员中,运行程序时,pgd的值会被加载到CR3寄存器中,这样cpu运行时就根据CR3的值找页表地址,随着缺页异常次数的增多慢慢填充这张页表。
- 总结一下,
mm_struct,vm_area_struct
分别对应进程的整个地址空间和一段地址空间。内核将地址空间分段管理,当进程申请时才给它分空间,mm_struct处理维护虚拟地址空间,还要记录页表地址。是CPU再运行该进程时能从CR3中找到页表。
内核工作原理
/*
TODO:
- 实验验证
*/
Linux进程空间管理实现原理
/*
TODO
Intel段机制
参考重新认识intel段机制寻址方式Intel页机制
*/附:
BIOS引导硬盘,软驱关键代码
/****************************************************************
* Boot code (int 18/19)
****************************************************************/
// Jump to a bootup entry point.
static void
call_boot_entry(struct segoff_s bootsegip, u8 bootdrv)
{
dprintf(1, "Booting from %04x:%04x\n", bootsegip.seg, bootsegip.offset);
struct bregs br;
memset(&br, 0, sizeof(br));
br.flags = F_IF;
br.code = bootsegip;
// Set the magic number in ax and the boot drive in dl.
br.dl = bootdrv;
br.ax = 0xaa55;
farcall16(&br);
}
// Boot from a disk (either floppy or harddrive)
static void
boot_disk(u8 bootdrv, int checksig)
{
u16 bootseg = 0x07c0;
// Read sector
struct bregs br;
memset(&br, 0, sizeof(br));
br.flags = F_IF;
br.dl = bootdrv;
br.es = bootseg;
br.ah = 2;
br.al = 1;
br.cl = 1;
call16_int(0x13, &br);
if (br.flags & F_CF) {
printf("Boot failed: could not read the boot disk\n\n");
return;
}
if (checksig) {
struct mbr_s *mbr = (void*)0;
if (GET_FARVAR(bootseg, mbr->signature) != MBR_SIGNATURE) {
printf("Boot failed: not a bootable disk\n\n");
return;
}
}
tpm_add_bcv(bootdrv, MAKE_FLATPTR(bootseg, 0), 512);
/* Canonicalize bootseg:bootip */
u16 bootip = (bootseg & 0x0fff) << 4;
bootseg &= 0xf000;
call_boot_entry(SEGOFF(bootseg, bootip), bootdrv);
}