本文共 24426 字,大约阅读时间需要 81 分钟。
摘 要
本文主要讲述了hello.c程序在编写完成后运行在linux中的生命历程,首先对源文件hello.c文件变为hello可执行文件的中的过程,以及产生的中间文件,来描述关于预处理,编译,汇编和链接的内容,分析了这些过程中产生的文件的相应信息和作用。并介绍了shell的内存管理、IO管理、进程管理等相关知识,了解了虚拟内存、异常信号处理等相关内容。
关键词:hello的一生;环境;预处理;编译;汇编;链接;进程;存储;I/O;计算机系统;
目 录
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
我是Hello,是每一个程序猿的初恋(羞羞……),我才是第一个玩 P2P和020的: From Program to Process
当hello一行一行的键入.c文件,它的一生就此开始,经过编译预处理器(cpp)的编译预处理变成.i文件,经过ccl的编译变成.s文件,as的汇编变成可重定位目标文件.o,链接器(ld)的链接产生可执行目标文件hello,在shell中键入启动命令,shell为hello进行fork子进程,hello便真正实现了From Program to Process
020:From Zero-0 to Zero -0:
之后shell进行execve,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,设置程序计数器,映射虚拟内存,然后加载物理内存,,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构,以上全部便是020的过程。
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
Visual Studio 2010 64位以上;GDB/OBJDUMP;DDD/EDB等
Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位,vim, gcc , as , ld , edb , readelf , HexEdit.
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 文件作用 |
hello.c | 源代码 |
hello.i | hello.c编译预处理生成的.i文件 |
hello.s | hello.i编译生成的.s文件 |
hello.o | hello.s汇编生成的.o文件(可重定位目标文件) |
hello | hello.o链接生成的可执行目标文件 |
hello.elf | hello.o的ELF格式文件 |
hello-o-asm.txt | hello.o反汇编生成的.txt可读文件 |
hello-out.txt | hello可执行文件反汇编生成的代码文件 |
hello-out.elf | hello的ELF格式文件 |
本章是大作业的开头,简述了Hello的P2P,020的整个过程,基本上概述了hello一生的经过几部分,以及hello的环境与工具,对整个hello一生列出一个框架结构。
预处理:编译之前处理,编译预处理器(cpp)根据以字符#开头命令,修改原始c程序。比如hello.c中第六行,#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并直接插入程序文本,就得到了另一个C程序,以.i为文件扩展名
作用:
1.将#include所包含的头文件stdio.h,stdlib.h,unistd.h直接加入到新的.i文件中。
2.对于一些宏定义,也在预处理阶段进行宏替换,用实际值替代宏定义字符串.
3.条件编译,当某些语句希望在条件满足之后才编译,例如格式:
(1)#ifdef 标识符,程序段1,#else,程序段2,#endif
当表达式1成立时,编译程序段1,当不成立时,编译程序段2。
使用条件编译可以使目标程序变小,运行时间变短。
4.添加行号信息文件名信息,便于调试
5.删除所有注释,如hello.c中的前四行
6.保留所有的#progma编译指令
预编译使问题或算法的解决方案增多,有助于我们选择合适的解决方案
预处理过程如下图:
图2-2Ubuntu下预处理的命令
打开hello.i文件,hello.i程序已经扩展成3060行,main函数在hello.i文件最下面,自3047行开始,在3047行之前为hello.c中stdio.h,stdlib.h,unistd.h等#为开头的头文件读取到的.i文件C程序,三个头文件依次展开,cpp打开/usr/include/stdio文件,进一步解析替换stdio.h文件中的#define定义的宏定义字符串,然后进一步解析条件判断语句,判断其中包含的逻辑。cpp递归展开使得所有宏定义,以及条件判断语句进行展开解析。
图2-3Hello的预处理结果main函数部分
本章主要描述了预处理的概念和具体的作用,在虚拟机中也对hello.c进行了实际的预处理,生成了hello.i文件做对比,对hello预处理结果进行解析,深入的了解了预编译过程的具体实现,以及特征。
编译:通俗来说是利用编译程序从预处理后的文件(.i)生成汇编语言程序(.s)。编译器(ccl)将文本hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,该程序包含main函数的定义,每一条定义都以文本格式描述的一条低级机器语言指令。
作用:
①语法检查:检查源程序是否合乎语法。
②调试措施:检查源程序是否合乎设计者的意图。
③修改手段:为用户提供简便的修改源程序的手段。
④覆盖处理:主要是为处理程序长、数据量大的大型问题程序而设置的。
⑤目标程序优化:提高目标程序的质量,即占用的存储空间少,程序的运行时间短。
⑥不同语言合用:其功能有助于用户利用多种程序设计语言编写应用程序或套用已有的不同语言书写的程序模块。
⑦人-机联系:确定编译程序实现方案时达到精心设计的功能。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译过程如下图:
图3-2Ubuntu下编译的命令
指令 | 解析 |
.file“hello.c” | //声明原文件hello.c |
.text | //代码段,程序入口 |
.section .rodata | //.rodata,只读数据 |
.align 8 | //声明数据和指令地址对齐的方式为8 |
.LC0: .string "\347\224\250\346\263\225: Hello 1190200203 \345\256\213\344\277\235\350\201\232 2.5 \357\274\201" | //声明一个string类型 |
.LC1: .string "Hello %s %s\n" | //声明一个string类型 |
.file | 声明源程序文件 |
.text | 代码段,程序入口 |
.globl | 全局变量声明 |
.data | 已初始化的全局变量和静态变量(数据段) |
.align | 声明数据和指令地址对齐的方式 |
.type | 声明函数类型,对象类型 |
.size | 声明大小 |
.long | 声明一个long类型 |
.section .rodata | .rodata,只读数据 |
.string | 声明一个string类型 |
.cfi def cfa offset .cfi def cfa register .cfi def cfa .cfi endpr oc等.cfi指令 | 呼叫帧信息,并且是一个GNUAS扩展来管理呼叫帧,划分了每个子帧中控制信令区域和数据区域的边界 |
Hello.c文件中数据类型有int型,数组argv[],输出的字符串
Hello.s文件中处理的数据类型有整数,数组,字符串。
1. 整数
(1)C程序中int i:编译器将局部变量储存在寄存器或者栈中
2.字符串:
Hello.s文件汇编程序中的字符串为
图3-3-2-2字符串声明
字符串声明在:.section .rodata中
3.数组,char*大小为8字节
C程序中int main(int argc,char *argv[]),argv作为char类型指针的数组,指针指向存放着字符指针的连续空间,起始地址为argv,函数在访问数组时分为argv[1],argv[2],在hello.s文件中使用两次%rax分别为两个数组首地址
图3-3-2-3数组声明
按照起始地址argv大小8字节计算数据地址取数据
int i; for(i=0;i<8;i++)
i=0; // movl $0, -4(%rbp)
mov指令完成,根据数据的大小不同使用不同后缀
整数操作数 b : 1字节、w :2 字节、l :4 字节、q :8字节
浮点型操作数 s : 单精度浮点数、 l :双精度浮点数
指令 | 结果 |
leaq Src,Dst(取地址指令) | Src地址模式表达式;将表达式对应的地址保存到Dst中 |
addq Src,Dest | #Dest=Dest+Src |
subq Src,Dest | #Dest=Dest-Src |
imulq Src,Dest | #Dest=Dest*Src |
salq Src,Dest | #Dest=Dest<<Src同shlq |
sarq Src,Dest | #Dest=Dest>>Src算数移位 |
shrq Src,Dest | #Dest=Dest>>Src逻辑移位 |
xorq Src,Dest | #Dest=Dest^Src |
andq Src,Dest | #Dest=Dest&Src |
orq Src,Dest | #Dest=Dest | Src |
i++运算操作
addl $1, -4(%rbp)
对计数器i自增,程序指令addl,后缀l代表操作数大小为4字节
subq $32, %rsp
addq $16, %rax
对栈针进行移动操作
leaq .LC1(%rip), %rdi
加载有效地址指令leaq;并计算LC1的段地址%rip+.LC1;并将地址传递到%rdi
3.3.4条件分支(关系跳转,循环)
条件控制指令 | 作用 |
jx(jmp je jle) | 根据条件跳转 |
setx | 根据条件组合,设置单个字节数值 |
cmp Src,Dest | 目的操作数-源操作数,格式于AND相同,修改OF、SF、ZF、CF、AF和PF |
C程序中判断argv不等于4,if(argc!=4)
汇编指令为
cmpl $4, -20(%rbp) //利用cmpl指令,设置argv-4为条件码
je .L2 //通过条件码判断条件码是否为零(相等),若argv= =4则跳转到L2,执行下一个循环,若不为零,则顺序执行if下面的语句
leaq .LC0(%rip), %rdi
call puts@PLT
movl $1, %edi
call exit@PLT
.L2:
movl $0, -4(%rbp)
jmp .L3 //无条件跳转,for循环开始
.L4: //循环体中操作,for循环中指令
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
movq -32(%rbp), %rax
addq $24, %rax
movq (%rax), %rax
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
addl $1, -4(%rbp)
.L3:
cmpl $7, -4(%rbp) //i<8;汇编指令通过,使用cmpl指令,比较i和7的大小关系,设置条件码i-7
jle .L4 //通过jle指令,判断如果i<8,则跳转到L4继续执行循环中操作,否则顺序执行下一条语句,跳出for循环
call getchar@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
函数调用前程序会使用寄存器存储参数,若寄存器大于6个则使用栈传参。传参寄存器的顺序为%rdi、%rsi、%rdx、%rcx、%r8、%r9
在执行call语句时先讲当前地址入栈,跳转到待执行函数指令处。函数返回,执行ret将地址出栈,返回值存储在%rax(%eax)中。管理局部数据
1.进入过程时申请空间1.生成代码,构建栈帧2.包括call指令产生的push操作
2.当返回时解除申请 ▪ 结束代码,清理栈帧 ▪ 包括ret指令产生的pop操作
Hello.s文件中函数过程操作:
main函数:
控制传递:main函数被系统启动函数__libc_start_main调用,call指令将下一条指令地址入栈,然后跳转到main函数执行
控制数据:调用过程向main函数传递参数argc和argv,分别使用%rdi和%rsi存储
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
函数出口
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret //函数正常出口为return 0,将%eax设置0返回
管理局部数据
分配栈空间,释放栈空间
pushq %rbp // %rbp记录栈底指针
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp //函数分配栈帧空间在%rbp之上
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
函数调用结束
leave //恢复栈空间为调用之前的状态,然后ret返回
ret
相当于pop,mov指令
printf函数:
leaq .LC0(%rip), %rdi
call puts@PLT
第一次printf将%rdi设置为“用法: Hello 1190200203 宋保聚 2.5 !\n”字符串的首地址
只有一个字符串参数,所以call puts@PLT
leaq .LC1(%rip), %rdi
movl $0, %eax
call printf@PLT
第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rsi为argv[1],%rdx为argv[2];第二次printf使用call printf@PLT
exit函数:
movl $1, %edi //传递数据:将%edi设置为1
call exit@PLT //控制传递
getchar函数:
call getchar@PLT //控制传递
本章编译器(ccl)将文本hello.i翻译成文本文件hello.s,其中为汇编语言,是对更加难以理解的机器语言的抽象化,覆盖了基本操作,但难以理解。通过对hello.s文件中汇编指令,我们深入了解了汇编器是如何高效的将C语言中各个数据类型以及各类操作转换为汇编语言指令,汇编器将经过编译预处理的.i文件编译成.s文件,C语言程序经过编译处理,理解了对不同类型的数据结构的存储与处理。
概念:把汇编语言书写的程序翻译成与之等价的机器语言程序。汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定向目标程序的格式,并将结果保存在目标文件hello.o中。
作用:汇编器处理的“高级语言”是汇编语言,根据汇编指令和特定的平台,把汇编指令翻译成机器代码;合并各个节,合并符号表,生成.o文件,输出的是机器语言二进制形式。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
汇编过程如下图:
图4-2Ubuntu下汇编的命令
图4-3获得可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF格式
ELF头 | 文件格式 |
.text | 已经编译程序的机器代码 |
.rodata | 只读数据 |
.data | 初始化的全局和静态变量 |
.bss | 未初始化的全局和静态变量 |
.symtab | 符号表 |
.rel.text | 一个.text节中位置的列表 |
.rel.data | 别模块引用或定义的所有全局变量重定位信息 |
.debug | 调试符号表 |
.line | 行号与.text的机器指令之间的映射 |
.strtab | 一个字符串表 |
符号表中有hello.o中定义和引用的函数和全局变量,信息,其中包含大小、类型、名字等信息
1.ELF头:
图4-3-1 ELF头
2.节头部表(Section header table)(节头表):
记录每个节的节名、偏移和大小
不同节的位置和大小由节头部表描述,其中目标文件每一个节都有一个固定大小的条目;
可重定位目标文件中,每个可装入节的起始地址总是0
图4-3-2节头部表
3. .symtab节存放函数和全局变量(符号表)信息,它不包括局部变量
图4-3-3 .symtab节
4.重定位节
.rel.text节的重定位信息,用于重新修改代码段的指令中的地址信息
.rel.data节的重定位信息,用于对被模块使用或定义的全局变量进行重定位的信息
图4-3-4重定位节
图4-4-1objdump获得hello.o反汇编代码
objdump获得hello.o的反汇编代码,对比hello.s文件中汇编代码
图4-4-2反汇编main代码
图4-4-3hello.s文件中的主函数
1.函数调用
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令,在反汇编代码中,分支转移表示为主函数+段内偏移量。反汇编代码中的跳转分为直接寻址与间接寻址。
2.条件分支变化:反汇编产生的跳转指令,不再是跳转到段名称.L2 .L3而是确定的相对偏移地址(链接器重定位), 段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在
3.数据访问:
全局变量访问,在反汇编中对.rodata中printf的格式串的访问需要通过链接时重定位的绝对引用确定地址,在汇编代码相应位置仍为占位符表示
在hello.s文件中,访问rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip.
机器语言的反汇编代码为每条语句都加上了具体的地址。
本章通过查看hello.o文件的ELF格式,对重定位项目分析和使用Objdump反汇编代码与hello.s文件汇编语言进行对比,分析了可重定位文件的结构和各个组成部分,深入了解了hello.s到hello.o的汇编过程,比较了机器语言和汇编代码的不同和关系。
概念:链接是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载(复制)到内存并执行,这个文件可被加载到内存并执行。链接可以执行于编译时,执行加载时,运行时,链接是由链接器的程序自动执行;
作用:链接器使分离编译成为可能,方便了模块化编程。合并各个.obj文件的节合并符号表,进行符号解析;进行符号地址的重定位;生成可执行文件。
使用ld的链接命令,应截图,展示汇编过程!如下图:
图5-2Ubuntu下链接的命令
注意不只连接hello.o文件
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
图5-3用readelf获得hello的ELF格式文件结构
ELF 文件包括三个索引表:
1.ELF header:在文件的开始,保存了路线图,描述了该文件的组织情况。
2.Program header table:告诉系统如何创建进程映像。
3.Section header able:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。
4.hello的ELF格式文件无两个.rel节(无需重定位)
1.ELF头部表
ELF头中字段e_entry给出执 行程序时第一条指令的地址, 而在可重定位文件中,此字段为0(与可重定位ELF文件不同的地方)
图5-3-1ELF头
2.节头部表:描述目标文件的节
Section Headers对hello中所有的节信息进行了声明,包括大小Size以及在程序中的偏移量Offset,大小、全体大小、旗标、链接、信息、对齐等信息,根据Section Headers中的信息可以定位各个节所占的区间。
图5-3-2节头
3.程序头表(段头表)(segment header table)
图5-3-3程序头
4. .init节:
用于定义_init函数,该函数用来进行可执行目标文件开始执行时的初始化工作,分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5-4edb0x400000到0x400ff0段
程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。
程序包含8个段:
PHDR:保存程序头表。
INTERP:指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
LOAD:表示一个需要从二进制文件映射到虚拟地址空间的段。
DYNAMIC:保存了动态链接器使用的信息。
NOTE:保存辅助信息。
GNU_STACK:权限标志,标志栈是否是可执行的。
GNU_RELRO:表示这段在重定位结束之后那些内存区域是需要设置只读
图5-5用Objdump指令获得hello反汇编文件
与hello.o反汇编文件相比,hello反汇编相比,hello反汇编地址为虚拟内存地址,并且多了许多节和子程序:
段节...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got
Disassembly of section .init://程序初始化需要执行的代码
0000000000401000 <_init>:
Disassembly of section .plt://动态链接-过程链接表
0000000000401020 <.plt>:调用的子函数如下
0000000000401090 <puts@plt>:
0000000000401090 <puts@plt>:
00000000004010b0 <getchar@plt>:
00000000004010c0 <atoi@plt>:
00000000004010d0 <exit@plt>:
00000000004010e0 <sleep@plt>:
Disassembly of section .text:
00000000004010f0 <_start>://hello主函数代码段
0000000000401120 <_dl_relocate_static_pie>:
0000000000401120 <_dl_relocate_static_pie>:
0000000000401160 <register_tm_clones>:
00000000004011a0 <__do_global_dtors_aux>:
00000000004011d0 <frame_dummy>:
00000000004011d6 <main>:
0000000000401270 <__libc_csu_init>:
00000000004012e0 <__libc_csu_fini>:
Disassembly of section .fini: //当程序正常终止时需要执行的代码
00000000004012e8 <_fini>:
使用ld链接命令时,动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main
.rodata引用:链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
程序流程 | 程序名称 | 程序地址 |
加载程序 | ld-2.31.so | 0x00007f5f63a77100 |
| hello!_start | 0x00000000004010f0 |
| libc_start_main | 0x00007f5f63894fc0 |
运行 | hello!main | 0x00000000004011d6 |
| hello!.plt | 0x0000000000401020 |
程序结束 | libc-2.31.so!exit | 0x00007ff136889128 |
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它;为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用got中地址跳转到目标函数
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT [0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT [0]和GOT [1]包含动态链接器在解析函数地址时会使用的信息。GOT [2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目
图5-7-1.got.plt起始位置
hello 的ELF格式文件中查找到,.got.plt起始位置为0x00404000
在调用dl_start之前0x00404008后的16个字节均为0
调用_start之后发生改变,0x00404008后的两个8个字节分别变为:0x00007f5ff1806191、0x00007f5ff17efbb0,其中GOT [0](对应0x400e28)和GOT [1](对应0x00007f5ff1806191)包含动态链接器在解析函数地址时会使用的信息。GOT [2](对应0x00007f5ff17efbb0)是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数;
0x00007f5ff1806191指向重定位表:
0x00007f5ff17efbb0指向重定位表:
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
在本章中,通过解释链接的概念及作用,分析hello的ELF格式,以及hello的虚拟地址空间,重定位过程,执行流程,和动态连接过程,深入学习了hello.o 可重定位文件到hello可执行文件的流程,和链接的各个过程.
概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。系统中每一个程序都运行在某个进程的上下文中,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
作用:
1.一个独立的逻辑控制流,它提供一种假象,好像我们运行的每个程序独占的使用处理器;
2.一个私有的地址空间,它提供一种假象,含香我们的程序独立的使用内存系统。
Shell的作用:为操作者提供操作界面,接受并解析外部命令,接受到用户的命令后,调运相应的应用程序。
1.可交互,和非交互的使用shell。在交互式模式,shell从键盘接收输入;在非交互式模式,shell从文件中获取输入。
2.shell提供了少量的内置命令,以便自身功能更加完备和高效。
3.shell除了执行命令,还提供了变量,流程控制,引用和函数等,类似高级语言一样,能编写功能丰富的程序。
4.shell强大的的交互性除了可编程,还体现在作业控制,命令行编辑,历史命令,和别名等方面。
处理流程:
fork进程:在终端中输入命令后,shell会处理该命令,判断出不是内置命令,则会调用fork函数创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID
1.调用一次,返回两次;在父进程中fork会返回子进程的PID(总为非零值),在子进程中fork会返回0 ;2.并行执行:父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令;3.相同但是独立地址空间;4.共享文件
图6-3Shell程序fork简单进程图
fork函数子进程之后,子进程调用execve函数,在execve加载了Hello之后,它调用启动代码。execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。
启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(int argc , char **argv , char *envp);
图6-4-1加载器虚拟内存映像
最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
图6-4-2Hello栈结构
1.逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
2.并发流:一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地执行。多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念被称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫时间分片。
3.私有地址空间:提供一种假象,含香我们的程序独立的使用内存系统;一个程序的空间中某个地址相关联的内存字节是不能被其他进程读或者是写的;
4.用户模式和内核模式:处理器通过某个控制寄存器中的一个模式位来提供限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能。该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行在内核模式中。没有设置模式位时,进程就运行在用户模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存;用户模式的进程不允许和执行特权指令、也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据
5.上下文切换:上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这个决策就叫做调度,是由内核中称为调度器的代码处理的。在内和调度了一个新的进程运行后,它就抢占当前进程,并使用上文所述的上下文切换的机制将控制转移到新的进程,上下文切换机制:1.保存当前上下文;2.回复某个先前被抢占的进程的上下文;3.将控制传递给新进程
开始Hello运行在用户模式,收到信号后进入内核模式,运行信号处理程序,之后再返回用户模式。运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
图6-5Hello上下文进程切换
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
hello执行过程中会出现的异常种类可有:中断、陷阱、故障、终止。
中断是来自I/O设备的信号,异步发生。硬件中断的异常处理程序被称为中断处理程序。
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。
信号允许进程和内核中断其他进程。每种信号都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程是不可见的。信号提供一种机制,通知用户进程发生了这些异常。
图6-6hello的异常与信号处理
运行过程中键入Ctrl+Z,停止程序;
键入Ctrl+C:终止hello进程;
键入回车;
键入ps查询当前进程数量和内容,被暂停的hello进程PID为4384,然后键入jobs,显示当前被暂停进程;
键入Ctrl+Z后,键入fg,恢复前台作业hello;
键入pstree;
键入kill和hello进程的PID:杀死hello进程;
键入乱码
在本章中,从进程的角度了解hello子进程fork和execve进程,并了解了Hello的进程执行过程中上下文切换以及逻辑控制流,以及Shell-bash处理流程和异常与信号处理的几种方法。
逻辑地址:程序代码经过编译后出现在汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成,格式为“段地址:偏移地址”。
线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入,地址空间中的整数是连续的。
虚拟地址:虚拟内存被组织为一个由存放在磁盘上的N 个连续的字节大小的单元组成的数组,N = 2n 个虚拟地址的集合 {0, 1, 2, 3, …, N-1}
Intel采用段页式存储管理(通过MMU)实现:
·段式管理:逻辑地址—>线性地址==虚拟地址;
·页式管理:虚拟地址—>物理地址。
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址,M = 2m 个物理地址的集合 {0, 1, 2, 3, …, M-1}
图7-1地址之间的关系
段式寄存器:用于存放段选择符。最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器
分段功能分两种情况:
实模式:逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存
保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段
逻辑地址向线性地址转换的过程中被选中的描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址,过程如下图:
将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
1.线性地址(虚拟地址VA):虚拟内存是一种对主存的抽象概念,是硬件异常、硬件地址翻译、主存、磁盘文件和内存文件的完美交互。为每个进程提供了一个大的、一致的和私有的地址空间。
虚拟内存被组织为一个由存放在磁盘上的N 个连续的字节大小的单元组成的数组
图7-3-1虚拟内存
2.页表:非常重要的数据结构;是一个页表条目的数组,将虚拟页地址映射到物理页地址
页命中:虚拟内存中的一个字存在于物理内存中
缺页::引用虚拟内存中的字,不在物理内存 中 (DRAM 缓存不命中)
图7-3-2虚拟页和物理页之间的关系
3.地址翻译:
图7-3-3-1基本参数
图7-3-3-2基于页表的翻译流程图
MMU地址翻译流程:
地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素的映射。CPU中存在一个控制寄存器为页表基址寄存器指向当前页表。n位的虚拟地址包含着p位的虚拟页面偏移和(n-p)位的虚拟页号。MMU利用VPN来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来就得到相对应的物理地址。
图7-3-3-3MMU地址翻译过程
图7-3-4缺页异常
1.TLB(翻译后备缓冲器):
MMU中一个小的具有高相联度的集合,用来实现虚拟页码向物理页码的映射 ;对于页码数很少的页表可以完全包含在TLB中,MMU 使用虚拟地址的 VPN 部分来访问TLB,如果TLB命中可以减少内存访问;如果TLB不命中,则引发了额外的内存访问。
图7-4-1TLB索引
2四级页表:
图7-4-2地址翻译流程
1—3级页表条目格式:
每个条目引用一个4KB子页表:
P: 子页表在物理内存中(1)不在(0)
R/W: 对于所有可访问页,只读或者读写访问权限
U/S: 对于所有可访问页,用户或超级用户(内核)模式访问权限
WT: 子页表的直写或写回缓存策略
A: 引用位(由MMU 在读或写时设置,由软件清除)
PS: 页大小为4 KB 或4 MB (只对第一层PTE定义)
Page table physical base address: 子页表的物理基地址的最高40位(强制页表 4KB 对齐)
XD:能/不能从这个PTE可访问的所有页中取指令
第4级页表条目格式:
D: 修改位(由MMU 在读和写时设置,由软件清除)
1.高速缓存(三级Cache):高速缓存存储器是小型的、快速的基于SRAM的存储器是在 硬件中自动管理的
缓存不命中的种类:
(1).冷不命中(或强制性不命中):当缓存为空时,对任何数据的请求都会不命中
(2).冲突不命中:大部分缓存将第k+1层的某个块限制在第k层块的一个子集里(有时只是一个块)
(3).容量不命中:当工作集的大小超过缓存的大小时,会发生容量不命中
图7-5-1高速缓存层次结构(三级Cache)
2.高速缓存读或写
读策略:1.定位组;2.检查组中的任何行是否有匹配的标记;3.是否行有效 :命中;4.定位从偏移开始的数据
写策略:
写命中:1.直写 (立即写入存储器);2.写回 (推迟到缓存行要替换时才写入内存),需要一个修改位 (标识缓存行与内存是否相同/有修改)
写不命中:1.写分配 (加载到缓存,更新这个缓存行),如后续有较多向该位置的写,优势明显;2.非写分配(直接写到主存中,不加载到缓存中)
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork在新进程中返回时,新进程虚拟内存和调用fork时虚拟内存相同。
共享对象及写时复制:两个进程都映射了私有的写时复制对象;区域结构被标记为私有的写时复制;私有区域的页表条目都被标记为只读;
写私有页的指令触发保护故障;故障处理程序创建 这个页面的一个新 副本;故障处理程序返回时重新执行写指令;尽可能地延迟拷贝(创建副本)
图7-6hello进程fork时的内存映射
execve函数在当前进程中加载并运行新程序a.out时
图7-7hello进程execve时的内存映射
Page fault缺页 :引用虚拟内存中的字,不在物理内存中,DRAM 缓存不命中
缺页中断处理,缺页中断处理函数为do_page_fault函数:
图7-8缺页故障与缺页中断处理
printf函数会调用malloc动态分配内存:动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的
分配器有两种基本风格,都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器:要求应用显式地释放任何已分配的块。一种是先进后出,另一种是地址顺序。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。而自动释放未使用的已分配的块的过程叫做垃圾收集
图7-9标记边界隐式空闲块
放置空闲块的策略有三种,分别是首次适配、下一次适配、最佳适配。
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一.次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块
分割空闲块:申请空间比空闲块小——可以把空闲块分割成两部分
合并空闲块:合并相邻的空闲块,边界标记检查前后是否有空闲块
本章通过介绍hello的储存器地址空间,段式管理,和页表管理,和在TLB与四级页表支持下的VA到PA的变换;三级Cache支持下的物理内存访问;hello进程fork时的内存映射;hello进程execve时的内存映射;缺页故障与缺页中断处理;动态存储分配管理;printf函数调用malloc动态分配内存几部分内容,深入了解了计算机系统存储的各种各样有序的管理。
设备的模型化:所有的IO设备都被模型化为文件,一个Linux文件就是一个 m字节的序列:B0 , B1 , .... , Bk , .... , Bm-1。所有的I/O设备都被模型化为文件:/dev/sda2(用户磁盘分区)/dev/tty2(终端);甚至内核也被映射为文件:/boot/vmlinuz-3.13.0-55-generic(内核映像);/proc(内核数据结构);而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O
设备分配使用方式 :独占设备不存在死锁问题;安全分配方式
设备管理:unix io接口
Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
UnixI/O接口及其函数:
打开和关闭文件
int open(char * filename, int flags, mode_t mode)
open函数将file那么转换为一个文件描述符并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符
int close(int fd)
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去
读写文件
ssize_t read(int fd, void * buf, size_t n);
read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值一1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量
ssize_t write(int fd, const void * buf, size_t n)
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置
改变当前的文件位置 (seek)
改变当前的文件位置:每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
指示文件要读写位置的偏移量lseek()
printf函数的实现分析:
/*sys_call的实现*/
/*sys_call的功能显示格式化了的字符串
ecx中是要打印出的元素个数
ebx中的是要打印的buf字符数组中的第一个元素
这个函数的功能就是不断的打印出字符,直到遇到:'\0'*/
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar函数实现分析:
/*异步异常-键盘中断的处理:
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCLL码,保存到系统的键盘缓冲区中*/
源代码:
int getchar(void)
{
static char buf[size];
static char *bb=buf;
static int n = 0;
if(n==0)
{
n=read(0,buf,size);
/*调用系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCLL码,直到读到回车符然后返回整个字符串,getchar进行封装*/
bb=buf;
}
return (--n>=0?(usigned char)*bb++:EOF;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章通过深入了解Linux I/O设备管理方法,Unix I/O接口及其接口函数,学习到了系统级I/O底层实现机制,再通过对printf的实现分析和getchar的实现分析,从代码级深入解析了Unix I/O的异常中断实现。
通过学习了hello的一生,感觉自己更加深入了解到了计算机底层的各种各样的运行机制,计算机系统中有着人类智慧的结晶,各个软硬件完美连接,协同工作,各种各样完美的机制,无论是P2P还是020,都是人们智慧的结晶。我们要先编写代码:用高级语言写.c文件,然后用P2P和020
P2P的: From Program to Process
1.编译预处理:将hello.c调用的函数库加入到hello.i中,并条件编译,宏展开;
2.编译:由.i生成.s汇编文件
3.汇编:将hello.s翻译为机器语言指令,会变成为可重定位目标文件hello.o
4.链接:将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
O2O: From Zero-0 to Zero-0
5.运行:在shell中输入命令
6.创建子进程:shell进程调用fork为其创建子进程
7.运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
8.执行指令:CPU为其分配时间片,加载器将计数器预置在程序入口点,在一个时间片中,hello享有CPU资源,顺序执行自己的逻辑控制流
9.访问物理内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址,CPU通过其来访问
10.动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存
11.信号:shell的信号处理函数可以接受程序的异常和用户的请求
12.终止:执行完成后shell父进程回收子进程,内核删除为这个进程创建的所有数据结构,就这样程序结束了。
大作业有点复杂,但是设计到的知识非常多,再写的过程中,其实也相当于一次期末前的大致复习。
列出所有的中间产物的文件名,并予以说明起作用。
文件 | 文件作用 |
hello | hello.o链接生成的可执行文件 |
hello.c | 源代码 |
hello.elf | hello.o的ELF格式文件 |
hello.i | hello.c编译预处理生成.i文件 |
hello.o | hello.s汇编生成的.o文件(可重定位目标文件) |
hello.s | hello.i编译生成的.s文件 |
hello-o-asm.txt | hello.o反汇编生成的.txt可读文件 |
hello-out.txt | hello可执行文件反汇编生成的代码文件 |
hello_out.elf | hello的ELF格式文件 |
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔E.布莱恩特 大卫R.奥哈拉伦. 深入理解计算机系统(第3版).机械工业出版社. 2018.4
[2] 博客园:printf函数实现深度剖析
[3] hello的一生:
[4] ELF文件-段和程序头:
[5] Linux内核:IO设备的抽象管理方式:
[6] ELF可重定位目标文件的格式
[7] getchar()函数详解:
[8] 内存地址转换与分段
[9] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[10] CMU教学视频:
[11] C语言——预处理:https://blog.csdn.net/z1162565234/article/details/80466842
[12] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[13] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[14] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[15] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[16] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.