编写,将代码输入
预处理,将hello.c,进行基本的字符串处理,并将外部库的源代码包含
编译,生成hello.i编译成为汇编文件hello.s
汇编,将hello.s汇编程可重定位文件
链接,将hello.s与可充定位目标文件和动态库连接成可执行文件
创建进程: terminal为其fork子进程
运行程序:terminal调用execve调用加载器,并映射虚拟内存,进入程序入口后开始在如物理内存。
信号:运行中途接受到ctrl+z ctrl+c以及kill命令调用terminal的进程处理函数
结束:shell父进程回收子进程,内核删除这个进程创建的所有数据结构
硬件环境:IntelCore i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD.
软件环境:Ubuntu18.04.1LTS
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit
hello.i
预处理之后文本文件
hello.s
编译之后的汇编文件
hello.o
汇编之后的可重定位目标文件
hello
链接之后的可执行目标文件
本章主要说明了hellop2p个过程,并列出了中间文件,并说明了这些文件的作用
预处理的概念是在编译前所进行的处理。
在预处理过程中,程序将#include后面所包含的库中全部函数的源码复制入新的文本文件,以保证后面的编译阶段不会出现函数缺失。
如果函数有#define a b,预处理过程会搜索字符串a并将其全部替换成b。
之后对于#if条件,如果满足if后面条件则将#endif,与其之间的代码保留在新生成的文本文件中,否则则不保留。
预处理将生成新的源代码提供给编译的下一步环节。
父进程回收子进程,内核删除这个进程创建的所有数据结构
编写,将代码输入
预处理,将hello.c,进行基本的字符串处理,并将外部库的源代码包含
编译,生成hello.i编译成为汇编文件hello.s
汇编,将hello.s汇编程可重定位文件
链接,将hello.s与可充定位目标文件和动态库连接成可执行文件
创建进程: terminal为其fork子进程
运行程序:terminal调用execve调用加载器,并映射虚拟内存,进入程序入口后开始在如物理内存。
信号:运行中途接受到ctrl+z ctrl+c以及kill命令调用terminal的进程处理函数
结束:shell父进程回收子进程,内核删除这个进程创建的所有数据结构
硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD.
软件环境:Ubuntu18.04.1 LTS
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit
hello.i
预处理之后文本文件
hello.s
编译之后的汇编文件
hello.o
汇编之后的可重定位目标文件
hello
链接之后的可执行目标文件
本章主要说明了hellop2p个过程,并列出了中间文件,并说明了这些文件的作用
预处理的概念是在编译前所进行的处理。
在预处理过程中,程序将#include后面所包含的库中全部函数的源码复制入新的文本文件,以保证后面的编译阶段不会出现函数缺失。
如果函数有#define a b,预处理过程会搜索字符串a并将其全部替换成b。
之后对于#if条件,如果满足if后面条件则将#endif,与其之间的代码保留在新生成的文本文件中,否则则不保留。
预处理将生成新的源代码提供给编译的下一步环节。
因为根据预处理需要进行的工作,在hello.c,中只需要将#include后面所包含的源码从文件中找到,并将其复制到新生成的文件。
根据上图文们可以看到预处理过程成功的找到了我们所包含的库函数
而从成功生成的hello.i的预处理进行过后的文件,我们也可以清楚地看见,函数的源码被复制进了新生成的文件
预处理是编译过程中的第一步主要进行源代码中字符串的替换,以及将程序所需要用到的外部函数的源码拷贝到源程序中,来进行下一步做。
(第2章0.5分)
编译时,编译器会首先检查源代码中是否包含语法错误,如果包含语法错误则直接终端编译过程。在确保程序并没有语法错误后。编译器将程序解释成一个顺序执行的指令结构,并将复杂的语言通过简单的指令表示出来。
并将这些操作所需的存储空间与对应寄存器,内存等结合起来。
同时在汇编语言上端生成用于指导连接器和汇编器工作的信息
main://主函数入口
.LFB5:
.cfi_startproc
pushq %rbp//将栈顶指针压栈
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp//将栈顶指针的值传给站 原栈底作为新栈顶
.cfi_def_cfa_register 6
subq $32, %rsp//新的栈底指针 当前栈大小0x32字节
movl %edi, -20(%rbp)//传入的argc参数作为int,占据4字节被放置在%edi寄存器中,最后存储在当前栈里
movq %rsi, -32(%rbp)//传入的字符串指针,在64为系统中指针大小8字节,因此被放在%rdizhong,最后存储在当前栈里
cmpl $3, -20(%rbp)
je .L2
movl $.LC0, %edi
call puts
movl $1, %edi //返回值int值因此被存在%edi而不是%rdi中
call exit
.L2:
movl $0, -4(%rbp)//i被放置在当前栈中
jmp .L3
.L4:
movq -32(%rbp), %rax
addq $16, %rax//其中%rax为&argv[16/8]=&argv[2]
movq (%rax), %rdx//其为第三个参数故被存储在%rdx中
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax//同理当前rax中存储argv[1]的地址
movq %rax, %rsi//作为第二个参数
movl $.LC1, %edi//常量字符串作为第一个参数
movl $0, %eax
call printf
movl sleepsecs(%rip), %eax//全局变量根据指针偏移进行访问
movl %eax, %edi//作为sleep函数的第一个参数
call sleep
addl $1, -4(%rbp)
.L3:
cmpl $9, -4(%rbp)
jle .L4
call getchar
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
编译过程是程序将高级语言转换成单一顺序的,多种简单指令组成的机器语言,在转化完成的机器代码中,包含着很多原本语言所未向程序员展示的内容,比如对寄存器的操作等。
把汇编语言翻译成机器语言的过程叫做汇编。汇编的作用是把编译阶段产生的汇编代码装换成二进制机器代码。
Elf头描述生成该文件的系统的字的大小和字节顺序,以及帮助连接器语法分析和解释目标文件的信息。
.rodata节存放常量
.strtab中存放c源文件各变量的名称
通过-r命令可以读取elf文件中的重定位信息其中info前四个字节表示当前符号在.systab中的index,后八位则代表重定位的类型。
通过与.symtab共同分析,可知puts的地址在编译时被放置在.text偏移为0x1b处
而exit则被放置在.text偏移量为0x25的地方
在linux,可重定位目标文件是一个ELF文件,它包含多个部分如下图所示
通过readelf可以将当前可执行文件的各部分单独读取出来
通过比较反汇编生成的汇编代码,与原本编译后生成的汇编代码,可以明显发现,栈的利用率提升较大,但是主流程却并没有什么变化。
其次,在汇编过程中,跳转不在通过编译生成的汇编代码中的.L1等标签进行跳转,而是将代码与地址结合起来,在地址之间进行跳转
同时,因为进行了重定位所有的call后面所进行跳转的函数都通过重定位实现,而不再汇编代码中体现。
而对于分支结构,由于和地址的结合,直接进行偏移量导向的跳转
汇编过程是将汇编代码汇编成可重定位目标文件的过程,在该过程中,源代码脱离了可以令人直接理解的文字载体,而转变成了可直接被计算机“认知”的二进制载体。通过阅读反汇编的代码和elf中的内容,并与汇编代码进行比较,了解了重定位的基本信息,和汇编过程中,汇编器进行了怎样的操作。=
链接是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
第一步:链接器首先将多个.o 文件相应的段进行合并,建立映射关系并且去合并符号表。进行符号解析,符号解析完成后就是给符号分配虚拟地址。
第二步:将分配好的虚拟地址与符号表中的定义的符号一一对应起来,使其成为正确的地址,是代码段的指令可以根据符号的地址执行相应的操作,最后由链接器生成可执行文件。
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro -o hello.out /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbegin.o -
L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o hello.o
elf头中存储的信息
从上一张表我们可以找到.text也就是程序代码的存储位置在0x400520通过edb的datadump找到该地址,进行分析比对,可以发现,其所存储数据与通过readelf直接读取.text所获得数据一致。
在未进行连接前所生成的hello.o文件,对其进行反汇编,得到的只有main函数的汇编代码,同时因为只生成了重定位信息,而没有进行重定位,因此在调用函数时,3其参数并没有直接给出,而全为0,并将重定位信息存储。
以调用sleep函数为例
在未进行重定位前,其指令二进制代码为e800000000,当重定位后
函数的地址和指令结合起来,可以看出二进制代码原本的0,被替换成sleep函数的地址。
加载程序
ld-2.23.so!_dl_start
ld-2.23.so!_dl_init
LinkAddress!_start
ld-2.23.so!_libc_start_main
ld-2.23.so!_cxa_atexit
LinkAddress!_libc_csu.init
ld-2.23.so!_setjmp
运行
LinkAddress!main
程序终止
ld-2.23.so!exit
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
通过readelf可以获得.got的地址通过edb的datadump查看该地址数据,发现其全为0
在运行完dl_init是发现其发生了改变
本章了解了连接的基本概念和作用,初步了解了编译器是怎样将内部函数和外部函数连接的,同时初步解释了延迟绑定的概念
概念:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:进程的存在令系统可以更简易的控制当前工作的集合,进程是系统并行的基石,通过进程上下文之间的切换,实现多个进程的同时运行
概念:shell-bash是一个为使用者提供操作界面的软件,它接受用户传入的字符串,帮助用户启动程序,进行进程管理。
处理流程:
1、获取用户输入的字符串,分离命令参数等。
2、如果用户输入的是内部命令,则直接执行。
3、如果用户输入的是外部命令,则通过根据用户要求创建外部程序的进程,并记录,更新其gid,记录其pid。
4、中断发生时,shell负责获取信号,并将信号发送给应该接受到的进程
通过size_t pid = fork();父进程创建子进程,该子进程得到与父进程用户级虚拟地址空间相同但是独立的一个副本。对于父进程来说,其pid的之位子进程的pid,而对于子进程,该值为0。
之后两个进程便相对独立的执行,由内核决定执行顺序。
进程通过调用execve以在当前进程的上下文作为新的上下文创建一个新的进程。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。
逻辑控制流:在正常的程序执行过程中,cpu并不为当前进程独占,而是多个进程交替地使用cpu
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
再调用sleep函数前,hello正常执行,如果出现其他进程抢断,则保存上下文,切换上下文,等待当前进程结束后,再切换为保存的上下文继续执行。
调用sleep函数时,进程切换用户模式到内核模式,主动请求使当前进程休眠,并将hello移除执行队列进入等待队列。定时器开始计时,逻辑控制流将当前cpu使用控制权移交给下一个进程。等计时器计时终止时,发送中断信号,保存当前执行进程上下文,再次进入内核模式,将hello再次添加到执行序列中。
当键盘输入ctrl+z的时候,shell对前台进程组发送SIGSTP信号,零前台进程组停止。
再次运行hello程序,生成新的进程,并继续停止
调用jobs命令显示当前在工作中的任务,可以看出两个进城都已经被停止
对第一个任务发送SIGINT
第一个任务的状态转为终止
尝试对已终止进程恢复,显示无法恢复
在运行时键入其他其随意字符,程序保持运行无影响
再次令程序停止
调用ps命令查看当前运行的进程
向进程发送SIGINT零进程终止
在本章中介绍了linux是如何通过shell来对进程进行启动和管理的
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,梅子姐都有一个唯一的物理地址。
逻辑地址:程序代码经过编译后出现在 汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。
地址空间是一个非负整数地址的有序集合:
如果地址空间的正数时连续的,那么我们就说它是一个线性地址空间,
虚拟内存被组织成为一个由存放再次盘上的N个连续的字节大小的单元组成的数组。每一字节都有唯一的虚拟地址,作为数组的索引。
在hello运行时,cpu为起分配了一定大小的虚拟空间,这些空间有一部分映射物理内存,一部分映射硬盘上的swapfile,一部分什么都没有映射。
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
36位VPN 被划分成四个9 位VPN,分别用于一个页表的偏移量
进程访问一个虚拟地址,有总线传输给地址翻译器,地址翻译器首先将虚拟地址中的给部分包括tlb索引,tlb标记等分离,若在tlb中直接命中,则将进程需求的ptr发送给L1cache。否则检测物理地址是否命中,如果命中则选择牺牲行进行驱逐,并返回PTE,反之则发生缺页错误,调用缺页处理函数。
shell通过fork为hello创建新进程。当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给hello进程唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和样表的原样副本。它将两个进程中的每个页面都标记为只读,并将每个进程中的每个区域结构都标记为写时复制。
1、删除系统分配给hello的虚拟地址中,已经存在的区域结构
2、为hello的代码,常量数据,未初始化的全局变量,以初始化的全局变量,创建新的区域和结构,这些新的区域都是私有的,写时复制的
3、映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
DRAM 缓存不命中称为缺页(page fault), 在不命中发生后,系统将模式转换为内存模式,调用缺页异常处理机制,通过算法选择出牺牲页,如果牺牲页包含数据则对其进行驱逐,之后将目标页加载进入贮存。系统将模式切换回用户模式,加载保存上下文,回到引发缺页异常的的指令,继续执行。
在程序运行时程序员使用动态内存分配器(如malloc)获得虚拟内存。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的。分配器的类型包括显式分配器和隐式分配器。前者要求应用显式地释放任何已分配的块,后者在检测到已分配块不再被程序所使用时,就释放这个块。
动态内存管理的策略包括首次适配、下一次适配和最佳适配。首次适配会从头开始搜索空闲链表,选择第一个合适的空闲块。搜索时间与总块数(包括已分配和空闲块)成线性关系。会在靠近链表起始处留下小空闲块的“碎片”。下一次适配和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快,避免重复扫描那些无用块。最佳适配会查询链表,选择一个最好的空闲块,满足适配,且剩余最少空闲空间。它可以保证碎片最小,提高内存利用率。
本章通过对hello实例的分析了解了在hello执行时,内存地址是如何分配的,并了解到了不同方法的内存管理,以及如何寻址。
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
文件操作:
打开文件:int open(char *filename, int flags, mode_t mode);
关闭文件:int close(int fd);
读文件:ssize_t read(int fd, void *buf, size_t n);
写文件:ssize_t write(int fd, const void *buf, size_t n);
其他操作:
ssize_t recvfrom(int sockfd,void *buf,size_t len,unsigned int flags, struct sockaddr *from,socket_t *fromlen); 经socket接收数据
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
可以看出在调用write的时候给他传递了一个由函数vsprint
我们接着查看vsprint的源代码
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++)
{
if (*fmt != '%')
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt)
{
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4; p += strlen(tmp); break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
可以得知vsprint程序按照格式fmt结合参数后生成已经被格式化之后的字串,并返回会字符串的长度
之后查看一下write函数的汇编代码
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
int INT_VECTOR_SYS_CALL是第一次遇到的奇妙指令,通过调用当前的指令可以调用sys_call这个函数,应该就是这个函数连结显示器
接着检查sys_call的汇编函数
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
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
Linux将I / O输入抽象为文件并提供Unix I / O接口。 通过这个接口,程序可以输入和输出,只需要到达描述符,底层硬件就可以实现操作系统。 Linux本身提供的一些系统功能已经实现了对底层的调用,例如write函数。 printf函数将此文件的内容间接输出到标准输出。 它调用syscall来触发中断以在内核模式下操作硬件。
通过抽象I / O接口和文件,应用程序可以轻松调用底层,以便在输入和输出设备上运行。
第二天清晨,这个hello坐在墙角里,两腮通红,嘴上带着微笑。她死了,在旧年的大年夜冻死了。新年的太阳升起来了,照在她小小的尸体上。hello死在那儿,成为一个僵死进程。
“她想给自己暖和一下。”人们说。谁也不知道她曾经看到过多么美丽的东西,她曾经多么幸福,跟着她的父进程一起走向新年的幸福中去。
1、编写,将代码输入
2、预处理,将hello.c,进行基本的字符串处理,并将外部库的源代码包含
3、编译,生成hello.i编译成为汇编文件hello.s
4、汇编,将hello.s汇编程可重定位文件
5、链接,将hello.s与可充定位目标文件和动态库连接成可执行文件
6、创建进程:terminal为其fork子进程
7、运行程序:terminal调用execve调用加载器,并映射虚拟内存,进入程序入口后开始在如物理内存。
8、信号:运行中途接受到ctrl+z ctrl+c以及kill命令调用terminal的进程处理函数
结束:shell父进程回收子进程,内核删除这个进程创建的所有数据结构