目录
摘 要
本文通过追踪hello程序在Linux系统的一生,展现了hello程序从源程序hello.c经过预处理、编译、汇编、链接生成hello可执行文件,并由操作系统进行进程管理、存储管理以及I/O管理的全过程。结合《深入理解计算机系统》这本书,在linux下展示了hello在linux系统下的整个生命周期,加深了对于计算机系统和程序执行过程的理解。
关键词:Hello程序;Ubuntu;linux;预处理;编译;汇编,链接;进程;shell;存储;虚拟内存;I/O
第1章 概述
1.1 Hello简介
Hello程序是一个具有延时的打印hello信息的程序
1.p2p:From Program to Process,意思是程序员编写出程序的源文件,然后经过预处理、编译、汇编、链接,最后得到一个可执行程序。在shell里运行这个可执行文件,shell会为他创建一个进程并加载这个可执行文件,这就创好了一个新进程,这个过程是从编程到进程,是程序从代码到执行的一个过程,也就是P2P。
2.020:From Zero to Zero,shell为可执行程序创建好新进程后,将控制权交到它的手里,它具有自己独立的虚拟地址空间、进程上下文等等资源,这是hello从无到有的过程。在该进程运行结束后,父进程会将其回收,并删除它的相关数据和进程信息,由此是从有到无的过程。整个过程就是从无到有,接着从有到无,即020。
1.2 环境与工具
硬件环境: CPU:Intel Core i7-10875,16GB内存。
系统环境: 虚拟机:Ubuntu 20.04.4 LTS,Oracle VM VirtualBox。
工具:文本编辑器gedit,反汇编工具edb 1.3,反汇编工具objdump,编译环境gcc等。
1.3 中间结果
文件名 | 内容 |
hello.i | hello.c经过预处理得到的文本文件 |
hello.s | hello.i经过编译器编译得到的文本文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
elf.txt | hello.o通过readelf生成的elf文件 |
hello_objdump.txt | hello.o反汇编代码文件 |
hello | 链接器链接得到的可执行文件 |
hello.elf | hello可执行程序的elf文件 |
hello_asm.txt | hello的反汇编代码文件 |
1.4 本章小结
本章主要介绍了hello程序的基本内容,然后解释了P2P和020的含义是什么,列举了实验环境的相关信息,以及实验过程中生成的文件和它们的作用。
第2章 预处理
2.1 预处理的概念与作用
C语言编译器的预处理是将原始的代码按照带有“#”号的预处理语句进行扩展,例如在#include处插入文件,把#define的宏进行替换,根据条件选择#if内的代码等。
①头文件(Header files):头文件一般有两种目的:1.系统头文件提供了部分操作系统结构。2.我们自定义的头文件,它包含着我们代码的接口。
②宏(Macros):宏可以理解为代码片段的别名。使用宏时,我们会使用实际的代码代替宏。
③条件编译(Conditionals):条件指令能够指示cpp是否向编译器包含相关的代码段。常见的条件指令有:#if,#elif,#endif等
此外,还有Diagnostics,Line Control,Pragmas等会被cpp处理
预处理的作用:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如程序第一行的#include<stdio.h>代码告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。这步操作的结果就是得到了另一个C程序,通常以.i作为文件扩展名。经过预处理后,代码中的注释内容也会被删去。
2.2在Ubuntu下预处理的命令
预处理命令:
linux>cpp hello.c > hello.i

首先利用预处理命令cpp hello.c > hello.i生成预处理后的程序hello.i,然后用cat指令查看hello.i的内容。
2.3 Hello的预处理结果解析

cat查看hello.i发现预处理文件的内容非常多,比如这里是各种库的地址。

还有各类运用typedef定义的数据类型别名,方便后续的调用 。
图2.3-3
然后是一些被预处理器展开的库函数的函数声明,这些就是include头文件里包含的内容。


跳过hello.i前面的内容,查看最后hello.i文件最后的部分,发现了main函数,这里main函数的代码和hello.c里是一模一样的。只是在hello.c里写的注释在hello.i中均被删掉了。
2.4 本章小结
hello.c经过预处理后得到了hello.i文件,通过查看hello.i的内容可以发现,预处理器对hello.c代码做了大量的展开和替换(头文件展开,宏替换,条件替换,删除注释等等),使hello.i的内容非常多。这让我认识到了,虽然在.c文件中只有短短一行include,但是经过预处理后程序文件会变得如此之大,一段简单的程序,背后隐含着很复杂的处理内容。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
- 概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它是一个汇编语言格式的文本文件。
- 作用:作用分为以下六方面:
①扫描(词义分析):将源代码程序输入扫描器,将源代码中的字符序列分割为一系列c语言中的符合语法要求的字符单元,这一部分可以分为自上而下的分析和自下而上的分析两种方式。
②语法分析:基于词法分析得到的字符单元生成语法分析树。
③语义分析:在语法分析完成之后由语义分析妻进行语义分析,主要就是为了判断指令是否是合法的C语言指令,也可以叫做静态语义分析,只判断语法是否正确,并不判断一些在链接或执行时可能出现的错误。
④中间代码:中间代码的作用是可使使得编译程序的逻辑更加明确,主要是为了下一步代码优化的时候优化的效果更好。
⑤代码优化:根据用户指定的不同优化等级对代码进行安全的、等价的优化,这一行为的目的主要是为了提升代码在执行时的性能。
⑥生成代码:生成代码是编译的最后一个阶段。在经过上面的所有过程后,在这一过程中将会生成一个汇编语言代码文件,也就是最后得到的hello.s文件。
3.2 在Ubuntu下编译的命令
编译命令:
linux>gcc -S hello.i -o hello.s

由此看到,命令产生了hello.s文件。
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
hello.s文件的主要内容
名称 | 内容 |
.file | 声明源文件 |
.text | 代码段 |
.section .rodata | 只读代码段 |
.globl | 声明全局变量 |
.size | 声明大小 |
.string | 声明一个字符串类型 |
.type | 声明符号是数据类型或函数类型 |
.align | 指令或者数据的存放地址进行对齐的方式 |
3.3.1 数据
1. 字符串

程序中有两个字符串,"用法: Hello 120L020829 袁野 秒数!\n",是第一个printf传入的输出格式化参数。另一个是终端输入的储存在argc[]为地址的数组中的”Hello %s %s\n”是第二个printf传入的输出格式化参数。这两个字符串都在只读数据段中。
2.全局符号
在图3.3.1-1的.LC1段中可以看到.globl main,这是声明了main这个全局符号,并且紧接着在下面的.type 中声明main的类型为@function,即main是一个函数。
3.局部变量

变量在栈内的存储位置如上图所示。
①int argc:
main函数的第一个参数是局部变量argc,存储的是在程序运行的时候输入的参数个数。

由图3.3.1-3的代码可以发现argc储存在栈中地址为-20(%rbp)的位置(argc为main函数的第一个参数,所以通过%edi进行参数传递)。
②argv[](数组):
main函数的第二个参数是局部变量argv数组,它对应于上图第23行,将它存储在栈中地址为-32(%rsp)的位置。

argv是存放char指针的数组。argv数组中一个元素大小为8个字节。由上图可以更清楚地看到在hello.s中的两条指令指令:
36: movq (%rax), %rdx
39: movq (%rax), %rax
这就是取以argv中元素为地址的数据,这步操作为了解析终端输入的命令参数。
③int i:
main函数内第一行声明了局部变量int i。局部变量声明后会保存在寄存器或程序运行栈中。

如图可以看到,声明了一个变量i,并把i存储在-4(%rbp)的位置。
4.立即数:


由上图可以看到,hello.s文件中有许多以$符开头的立即数,他们的主要功能是赋值运算或者是比较。
3.3.2赋值操作:
hello.c里的赋值操作主要有i=0和i++:

首先在声明变量i的时候,编译器将0赋给它,这就对应于for循环的第一步i=0,将0赋给了i。并且i是int型,长度为四字节,所以用movl指令。

这里可以很明显的看到,第51行指令给i做加1的操作,紧接着在53行就比较了i和立即数$7的大小关系,如果小于等于7则需要跳回.L4执行循环。这就对应了hello.c中的:
for(i=0;i<8;i++){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
}
3.3.3 算术操作与逻辑操作:
指令 | 效果 | 描述 |
1eaq S,D | D←&S | 加载有效地址 |
INC D DEC D NEG D NOT D | D←D十1 D←D-1 D← -D D←~D | 加1 减l 取负 取补 |
ADD s,D | D←D+s | 加 |
SUB S,D | D←D-S | 减 |
IMUL S,D | D←D*S | 乘 |
XOR S,D | D←D^S | 异或 |
OR S,D | D←D|S | 或 |
AND S,D | D←D&S | 与 |
SAL k,D | D←D<<k | 左移 |
SHL k,D | D←D<<k | 左移 |
SAR k,D | D←D>>k | 算术右移 |
SHR k,D | D←D<<k | 逻辑右移 |
1、i++:
在3.3.2中描述的i++既是赋值操作同时也是算术运算操作。
2、加载有效地址:


将.LC(%rip)的值传到%rdi中,作为第一参数,调用puts或printf函数。
3、开辟栈空间:

在main函数的一开头,就先进行了subq $32,%rsp,将栈顶指针值减去32,开辟了32字节的栈空间。
3.3.4关系操作和控制转移:
1、argc!=4:

在局部变量的那部分已经说明argc存储在-20(%rbp)的位置,这里看到第24行指令:cmpl $4,-20(%rbp),这就是比较argc与4的大小,如果等于则跳转到.L2。对应于hello.c中的:
if(argc!=4){
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
2、i<8:
在for循环中,循环判断条件是i<8。

这里可以看到编译器将判断i<8优化为判断i<=7,每一次循环都通过i-7来设置条件码,然后执行jle比较,进行循环跳转。
3.3.5函数操作:
汇编指令中通过call指令来进行函数调用的操作。
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外, 过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。
在hello.c中有多个函数调用,首先明确在X86系统中函数参数的存储规则,第1~6个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,若有更多的参数则保存在栈中的某些位置。函数的返回值保存在%rax寄存器中。
1、main函数:

首先声明了main是一个全局符号且是一个函数。

main函数的参数是argc和argv,main函数被调用即call才能执行(被系统启动函数__libc_start_main调用)。call指令将下一条指令的地址压栈,然后跳转到main 函数,完成对main函数的调用。程序结束时,调用leave指令恢复栈空间为调用之前的状态,然后 ret 返回。
2、printf函数/puts函数
参数:第一次调用的时候只传入了字符串参数首地址;for循环中调用的时候传入了 argv[1]和argc[2]的地址。
调用:第一次是满足if条件的时候调用,第二次是在for循环条件满足的时候调用。
①第一次调用:
if(argc!=4){
printf("用法: Hello 学号 姓名 秒数!\n");
exit(1);
}
满足if条件后,将会执行第一个printf函数。

首先比较argc与4的大小,if的条件是不相等,所以将会执行第26行指令。
这里发现第26行并不是call printf而是call puts,这是由于在这个条件分支内部调用的printf输出内容是一个确定的字符串,并不需要格式控制,所以编译器自行将它优化为了puts函数输出字符串。
②第二次调用:
for(i=0;i<8;i++){
printf("Hello %s %s\n",argv[1],argv[2]);
sleep(atoi(argv[3]));
}
在for循环内部,每一次循环执行一个printf语句。


.L3段是循环控制,判断循环何时终止,.L4段是循环体。每一次循环都执行一次第43行的call printf指令,调用一次printf函数,这条指令之前的操作是printf函数的参数传递。
3、exit函数

第28行指令,将1传给%edi,然后调用exit函数退出。对应的是hello.c中的exit(0);
4、sleep函数/atoi函数

执行完printf语句后,将会执行sleep(atoi(argv[3]));
这一句涉及两个函数调用,一个是sleep函数,另一个是atoi函数。
sleep函数的原型是:
unsigned int Sleep (unsigned int seconds);
atoi函数原型是:
Int atoi (const char * str);
首先是用atoi函数将argv字符串数组中的第四个元素,也就是命令行的第四个命令转成int类型。然后将它作为参数调用sleep函数。
5、getchar函数:

当循环执行结束后,main函数内调用getchar函数,然后将0传入%eax作为返回值返回。
3.3.6 类型转换:

sleep函数的原型是:
unsigned int Sleep (unsigned int seconds);
atoi函数原型是:
int atoi(const char* str);
这里可以看到,atoi函数的返回值是int型,而sleep的参数类型为unsigned int,所以将atoi返回值直接作为sleep参数时发生了隐式的类型转换,将int转为unsigned int。
3.4 本章小结
本章主要讲述了编译的概念与作用,编译器如何处理各种数据和操作,以及c语言中各种类型和操作所对应的的汇编代码。重点分析了hello.s中的各部分汇编代码的主要功能已经执行流程。尤其是还注意到了一些编译器的自行优化功能,比如将i<8优化为i<=7,将printf优化为puts等。
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
1、概念:汇编器as将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中。(hello.o是一个二进制文件)。
2、作用:将汇编代码根据特定的转换规则转换为二进制代码,也就是机器代码,机器代码也是计算机真正能够理解的代码格式。
4.2 在Ubuntu下汇编的命令
linux>gcc -c hello.s -o hello.o 或 linux>as hello.s -o hello.o

4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF头 | 节 |
.text | |
.rodata | |
.data | |
.bss | |
.symtab | |
.rel.text | |
.rel.data | |
.debug | |
.line | |
.strtab | |
节头部表 | 描述目标文件的节 |
4.3.1 生成elf文件:
linux>readelf -a hello.o > ./elf.txt
使用这一命令导出我们需要的elf的文件。

如图4.3.1-1所示,已经生成了elf.txt文件。
4.3.2 ELF头:

ELF头以16字节的序列 Magic开始,Magic描述系统字的大小和字节顺序以及一些其他的信息。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
一个典型的ELF可重定位目标文件包含下面几个节:
名称 | 内容 |
.txt | 已编译程序的机器代码。 |
.rodata | 只读数据。 |
.data | 已初始化的全局和静态局部变量。 |
.bss | 未初始化的全局和静态变量,以及所有被初始化为0的全局或静态变量。 |
.symtab | 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。 |
.rel.text | 一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息。 |
.debug | 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量以及原始的C源文件。 |
4.3.3读取节头表:

节头部表(Section Headers)包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,代码可执行,但是不可写;数据段和只读数据段都不可执行,且只读数据段也不可写。
4.3.4 重定位信息:

重定位节主要包含的信息内容如下:
偏移量(offset) | 需要被修改的引用的节的偏移 |
信息(info) | 包括symbol和type两部分, 其中symbol占前 4个字节,type占后4个字节,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型。 |
类型(type) | 告知链接器如何修改新的引用 |
符号名称(name) | 重定位目标的名称 |
加数(addend) | 一个有符号常数,一些类型的重定位要使用它对被修改引用的值做便宜调整 |
4.3.5 符号表:

符号表(.symtab)存放程序中定义和引用的函数和全局变量的信息。name是符号名称。对于可冲定位目标模块,value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

首先利用命令
linux>objdump -d -r hello.o > hello_objdump.txt
生成了反汇编文件,并将它存储在 hello_objdump.txt中。

分析:
hello.o的反汇编代码与hello.s文件总体上基本相同,只有小部分差别。hello.s文件中只显示了汇编代码,而在反汇编代码所显示的不仅仅是汇编代码,还有机器指令码。
1、全局变量:hello.s文件中,全局变量是通过语句:段地址+%rip完成的;对于hello.o的反汇编来说,则是:0+%rip,因为.rodata节中的数据是在运行时确定的,需要重定位,现在填0占位,并为其在.rela.text节中添加重定位条目。
2、分支转移:hello.s文件中分支转移是使用段名称进行跳转,例如指令:je .L2,意为跳转到.L2段。而hello.o文件中分支转移是通过地址进行跳转的,根据地址之间的偏移量计算目标地址并执行跳转操作。
3、函数调用:hello.s文件中,函数调用call后跟的是函数名称,例如call printf。而在hello.o文件中,call后跟的是下一条指令。因为这些函数都是共享库函数,地址是不确定的,因此call指令将相对地址设置为全0,然后在.rela.text节中为其添加重定位条目,等待链接的进一步确定。
4.5 本章小结
汇编这一步骤使得hello程序真正开始从文本状态转化为二进制状态,但这一步并不是简单地将程序翻译为机器码,而是生成“可重定位的”机器码。在这一章里对比hello.s与hello.o文件,介绍了汇编的概念与作用,对可重定位目标文件ELF进行了详细的分析。对比并分析了hello.s和hello.o反汇编代码的异同。本章对汇编的过程进行了详细的分析。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
- 概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
- 作用:链接可以使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以将它分解成更小、更好管理的模块,可以独立地修改和编译这些模块,当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
链接生成hello可执行文件:
linux>ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

链接生成hello可执行文件。
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
首先使用readelf -a hello > hello.elf命令生成elf文件

5.3.1 ELF头


hello与hello.o的ELF头主要内容大致相同。
不同之处在上图中做了标注。hello.o的文件类型是REL(可重定位文件),而hello的文件类型是EXEC(可执行文件)。此外,由于hello.o中还没有完成链接,所以入口点地址均为0x0,而hello中完成了链接已经成为了一个可执行文件,入口点地址确定为0x4010f0。
5.3.2 节头

描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
5.3.3 重定位节

5.3.4 符号表

5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。


观察edb的Data Dump窗口,窗口显示虚拟地址由0x401000开始,到0x402000结束
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

在hello.elf文件中,节头表里可以看到.interp段的地址是0x4002e0,然后在edb中查找0x4002e0处的内容:/lib64/ld-linux-x86-64.so.2。这就是动态链接器。

其他字段也可以跟.interp一样在edb中找到
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
1、地址区别:

hello完成了重定位,所以汇编代码里的地址是虚拟地址。

而hello.o中的是为重定位的,所以地址是相对偏移地址。
所以不难发现,链接器首先会将所有模块的节都组织起来,为可执行文件的虚拟地址空间定型,再根据这个虚拟地址空间将那些存在hello.o里的.rel.text和.rel.data节的重定位条目指向的位置的操作数都设置为正确的地址。
2、hello的反汇编代码中多了许多文件节,如.init节与.plt节,而hello.o的反汇编代码中只有.text节


3、hello中链接了许多共享库函数,比如下面的puts和printf等等

这里链接的函数就是hello.c中使用的puts(第一个printf优化而来的),printf,getchar,atoi,exit,sleep。
链接主要分为两个过程:符号解析和重定位。
1、符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来。
2、重定位:编译器和汇编器生成从0开始的代码和数据节。重定位节和符号定义链接器将相同类型的节合并,生成ELF节。链接器将运行时的内存地址分配给生成的节,此时程序中每条指令和全局变量都有唯一的运行时地址。要合并相同的节,确定新节中所有定义符号在虚拟地址空间中的地址,还要对引用符号进行重定位,修改.text节和.data节中对每个符号的引用,需要用到在.rel_data和.rel_text节中保存的重定位信息。
5.6 hello的执行流程

首先用rbreak指令在每个函数入口处打上断点,然后用c(continue)继续执行来查看整个程序的执行过程。
1、_init(): 地址是0x401000
2、_start():地址是0x4010f0
3、_libc_csu_init():地址是0x401270
4、_init():地址是0x401000
5、frame_dummy():地址为0x4011d0
6、register_tm_clones():地址为0x401160
7、mian():地址为0x4011d6
8、puts@plt():地址为0x401090
9、exit@plt():地址为0x4010d0
10、__do_global_dtors_aux():地址为0x4011a0
11、deregister_tm_clones():地址为0x401130
12、_fini():地址为0x4012e8
13、printf@plt():地址为0x4010a0
14、atoi@plt():地址为0x4010c0
15、sleep@plt():地址为0x4010e0
16、getchar@plt():地址为0x4010b0

hello的执行流程如上图所示。
在edb中加载hello,找到各个子程序
子程序名 | 程序地址 |
ld -2.33.so!_dl_start | 0x7f641a388df0 |
ld-2.33.so!_dl_init | 0x7f641a398c10 |
hello!_start | 0x4010f0 |
libc-2.33so!__libc_start_main | 0x7f6fe58bd550 |
Hello!puts@plt | 0x401090 |
hello!printf@plt | 0x4010a0 |
hello!sleep@plt | 0x4010e0 |
hello!getchar@plt | 0x4010b0 |
libc-2.33.so!exit | 0x7f6fe58b40d0 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要为其添加重定位记录,并等待动态链接器处理。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT 中存放函数目标地址,PLT使用 GO T中地址跳转到目标函数。
在加载时,动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。那么,通过观察edb,便可发现dl_init后.got.plt节发生的变化。
首先可以观察elf中.got.plt节的内容

然后在edb中查看
这是执行init之前的内容。
这是执行init之后的内容。
5.8 本章小结
本章介绍了链接的概念及作用,对hello的elf格式进行了详细的分析,介绍了hello的虚拟地址,分析了hello的重定位过程、执行流程、动态链接过程,详细阐述了hello.o链接成为一个可执行目标文件的过程。通过这一章,我了解了hello.o、静态库、动态链接库这三者是如何通过链接机制组合在一起的,同时也初步探索了一个C语言程序从被加载到程序退出的全过程。
第6章 hello进程管理
6.1 进程的概念与作用
- 概念:进程是执行中程序的抽象。
2、作用:在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象是通过进程的概念提供的。
进程提供给应用程序的关键抽象:
①一个独立的逻辑控制流,提供一个程序独占处理器的假象
②一个私有的地址空间,提供一个程序独占地使用内存系统的假象。
6.2 简述壳Shell-bash的作用与处理流程
Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。
其基本功能是解释并运行用户的指令,重复如下处理过程:
1、终端进程读取用户由键盘输入的命令行。
2、分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
3、检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
4、如果不是内部命令,调用fork( )创建新进程/子进程
5、在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
6、如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait...)等待作业终止后返回。
7、如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
在shell接受到./hello这个命令行后,它会对其进行解析,发现这条命令是加载并运行一个可执行文件的命令。于是它会先创建一个对应./hello的作业,再用fork()创建一个子进程,这个子进程继承了父进程所有信息,包括相同的代码段、数据段、堆、共享库、栈段以及已打开的文件等等。(虽然内容相同,但为了节省资源,linux系统并不会立刻将这些内容复制一份,而是执行“写时复制”的策略,这样可以尽量晚的执行拷贝操作)。父进程与子进程的pid不同,并且fork的返回值也不同,fork返回0的是子进程,fork返回一个pid值的是父进程。fork执行完之后,父进程(即shell主进程)会将新创建的子进程用setpgid()放在一个新的进程组中,这样这个进程组就对应./hello这个作业,shell可以通过向进程组中的所有进程发信号的方式管理作业。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
在这个子进程创建出来后,子进程会去调用execve()来加载可执行文件到当前进程。
函数原型:
int execve(const char *filename, const char *argv[], const char envp[]);
execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量列表envp,execve调用一次并从不返回。

当main程序开始执行时,用户栈的组织结构如上图所示。可以观察到,argv指向一个指针数组,这个指针数组中的每一个指针指向一个参数字符串。其中argv[0]是我们所运行程序的名字。envp指向一个指针数组,这个数组里面的每一个指针指向一个环境变量字符串。环境变量字符串的格式为“name=value”。可以使用getenv函数获取环境变量,setenv、unsetenv来设置、删除环境变量。
execve会调用调用启动加载器。加载器会删除子进程现有的虚拟内存段,创建一组新的代码、数据、堆、栈。新的栈和堆被初始化为0。通过虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码数据初始化。最后,跳转到libc_start_main,最终调用main函数。

execve()执行过程大致包括以下几个步骤:
1、删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2、映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的、写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3、映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
4、设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.5.1逻辑控制流:
进程为每个程序提供了一种假象,好像程序在独占的使用处理器。一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。如图每个竖直的条表示一个进程的逻辑控制流的一部分。

一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流称为并发地运行。进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。
6.5.2 用户模式和内核模式:
处理器通常使用某个控制寄存器中的一个模式位来提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存的位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不允许直接引用地址空间中内核区的代码和数据。
运行应用程序的代码的进程开始处于用户模式中。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式改为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式改回用户模式。
6.5.3上下文和上下文切换:
内核为每个进程维持一个上下文。它由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈等。在进程执行的某些时刻,内核可以决定抢占当前进程,并开始一个先前被抢占的进程,这种决策就叫调度。在内核调度了一个新的进程运行后,它他就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。执行hello程序时,初始时,控制流再hello内,处于用户模式。调用系统函数sleep后,进入内核态,此时间片停止。停顿对应时间后后,发送中断信号,转回用户模式,继续执行指令。

6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常和信号异常可以分为四类:中断、陷阱、故障、终止
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
在发生异常时会发出信号,比如缺页故障会导致OS发生SIGSEGV信号给用户进程,而用户进程以段错误退出。

处理方式:
中断的处理:

陷阱的处理:

故障的处理:

终止的处理:

6.6.1 正常运行:

输入指令:./hello 袁野 120L020829 1
此时argc==4,所以会执行循环,将对应信息以1s间隔打印八次,结果如上图所示。循环结束后,执行到gerchar(),任意输入一个字符后程序运行结束并回到shell中。
6.6.2不停乱按:


这里可以看到,如果在程序执行过程中不停乱按,这些输入会被作为hello执行结束之后的命令输入。如上图所示,由于是随意按的,所以会提示command not found。
6.6.3 Ctrl-C:

这段程序原本需要将对应输出打印八次,但是按下Ctrl-C后会导致内核给前台进程组中的每个进程发送一个SIGINT信号,默认情况下结果是终止前台作业,所以上面只打印了两次进程便终止了。
6.6.4 Ctrl-Z/fg:

按下Ctrl-Z之后,会导致内核给前台进程组的每个进程发送一个SIGTSTP信号,默认情况下结果是停止(挂起)前台作业。

当使用fg命令重新将该进程放到前台运行时,进程会从挂起的位置继续执行,由上图可以看到,挂起前打印了3次,fg之后打印了5次。
6.6.5 ps命令/jobs命令:

ps命令用于显示当前进程的状态,类似于 windows 的任务管理器。

jobs命令查看当前任务列表及任务状态。
6.6.6 pstree命令:

pstree命令以树形结构显示程序和进程之间的关系。

还可以查看指定pid的进程关系。
6.6.7 kill命令:

通过kill向hello所在进程组的每一个进程发送终止信号,如上图,再次打印进程信息可以发现hello进程已经是killed状态。

由于ps进程已经执行结束,所以当kill它的时候,bash会提示找不到改进程。
6.7本章小结
本章阐述了进程的概念与作用,Shell的一般处理流程,主要介绍了hello可执行文件的执行过程,包括进程创建、加载和终止,以及通过键盘输入等过程。从创建进程到进程并回收进程,这一整个过程中需要各种各样的异常和中断等信息。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持hello能够顺利地在计算机上运行。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.1.1.逻辑地址
逻辑地址(Logical Address)是指由程序hello产生的与段相关的偏移地址部分(hello.o)。
7.1.2.线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
7.1.3.虚拟地址
有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是hello中的虚拟地址。
7.1.4.物理地址
物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么hello的线性地址会使用页目录和页表中的项变换成hello的物理地址;如果没有启用分页机制,那么hello的线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
实模式下:逻辑地址CS:EA到物理地址CS*16+EA
保护模式下:以段描述符作为下标,到GDT(全局描述符表)/LDT(局部描述符表)表查表获得段地址,段地址+偏移地址=线性地址。
段选择符各字段含义:
15 14 | 32 | 10 |
索引 | TI | RPL |
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置,被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
段式管理特点:
1.段式管理以段为单位分配内存,每段分配一个连续的内存区。
2.由于各段长度不等,所以这些存储区的大小不一。
3.同一进程包含的各段之间不要求连续。
4.段式管理的内存分配与释放在作业或进程的执行过程中动态进行。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。首先,MMU从线性地址中抽取出VPN,并且检查TLB,看他是否因为前面某个内存引用缓存了PTE的一个副本。TLB从VPN中抽取出TLB索引和TLB标记,查找对应组中是否有匹配的条目。若命中,将缓存的PPN返回给MMU。若不命中,MMU需从页表中的PTE中取出PPN,若得到的PTE无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出PPN。最后将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,例如VPN 0选择PTE 0。根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。这里的VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。

7.5 三级Cache支持下的物理内存访问

高速缓存存储器结构如上图所示。它将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位。

Cashe的物理访存大致过程如下:
1.组选择取出虚拟地址的组索引位,将二进制组索引转化为一个无符号整数,找到相应的组
2.行匹配把虚拟地址的标记为拿去和相应的组中所有行的标记位进行比较,当虚拟地址的标记位和高速缓存行的标记位匹配时,而且高速缓存行的有效位是1,则高速缓存命中。
3.字选择一旦高速缓存命中,我们就知道我们要找的字节在这个块的某个地方。因此块偏移位提供了第一个字节的偏移。把这个字节的内容取出返回给CPU
4.不命中如果高速缓存不命中,那么需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。一种简单的 放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略 LFU 进行替换。
7.6 hello进程fork时的内存映射
当前进程调用fork时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的“写时复制”。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。

7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,加载并运行 hello 需要以下几个步骤:

1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构。
2.映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
3.映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的。
处理流程:
1. 处理器生成一个虚拟地址,并将它传送给MMU
2. MMU生成PTE地址,并从高速缓存/主存请求得到它
3. 高速缓存/主存向MMU返回PTE
4. PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5. 缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6. 缺页处理程序页面调入新的页面,并更新内存中的PTE
7. 缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。

7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格,两种风格都是要求显式的释放分配块。
1.显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
2.隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。

动态内存分配器
基本方法与策略:
1.带边界标签的隐式空闲链表分配器管理:
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个符合大小的空闲块来放置这个请求块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。在释放一个已分配块的时候需要考虑是否能与前后空闲块合并,减少系统中碎片的出现。
2.显示空间链表管理:
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。放置策略与上述放置策略一致。
在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章介绍了存储管理的有关内容。介绍了存储器的地址空间:物理地址、虚拟地址、逻辑地址、线性地址,然后对段式管理和页式管理进行了较为详细的描述,同时还讨论了VA到PA的变换、物理内存访问、fork和execve的内存映射、缺页故障和缺页处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件:所有的I/O设备都被模型化为文件,甚至内核也被映射为文件。
设备管理:unix io接口:这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek等
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口:
1、打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
2、Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO和STDERR_FILENO, 它们可用来代替显式的描述符值。
3、改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。
4、读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。
5、关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix IO函数:
1、打开文件:
int open(char *filename, int flags, mode_t mode);
Open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程当中没有打开的最小描述符,若打开失败则返回-1。
Flags参数指明了进程打算如何访问这个文件,同时也可以是一个或者更多为掩码的或,为写提供给一些额外的指示,包括O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读写)。
Mode参数指定了新文件的访问权限位。
2、关闭文件:
int close(int fd);
调用close函数,通知内核结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。
3、读文件:
ssize_t read(int fd, void *buf, size_t n);
调用read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则返回值表示的是实际传送的字节数量。
4、写文件:
ssize_t write(int fd, const void *buf, size_t n);
调用从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示内存向文件fd输出的字节的数量。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf函数实现:
int printf(const char *fmt, ...)
{
int i;
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
在形参列表里有这么一个token:...,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。在printf内存调用了两个函数,一个是vsprintf,一个是write。
va_list的定义:typedef char *va_list ,这说明它是一个字符指针。
printf执行的流程就是:首先,printf开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。
vsprintf函数实现:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char *p;
chartmp[256];
va_listp_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);
}
}
vsprintf函数(在printf函数内部调用),vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar函数实现:
int getchar(void)
{
static char buf[BUFSIZ];
static char *bb = buf;
static int n = 0;
if(n == 0)
{
n = read(0, buf, BUFSIZ);
bb = buf;
}
return(--n >= 0)?(unsigned char) *bb++ : EOF;
}
getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为int型,返回值为用户输入字符的ASCII码或EOF。getchar可用宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时,首先,getchar会开辟一块静态的输入缓冲区,若输入缓冲区为空,则调用read向输入缓冲区中读入一行字符串。而read会通过syscall陷阱跳到内核,内核会使得调用方不断等待。当按下键盘后,键盘中断处理程序执行,向输入缓冲区中放入由键盘端口读入的扫描码转换成的字符,直到按下回车后调用方不再等待。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,Unix I/O 接口及其函数,printf 函数和 getchar 函数的工作过程。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
Hello程序的一生:
1、编写:程序员使用C语言,编写出了C程序hello.c。
2、预处理:hello.c经过预处理器cpp预处理,扩展得到hello.i文本文件。
3、编译:hello.i经过编译器ccl编译,得到hello.s汇编文件。
4、汇编:hello.s经过汇编器as汇编,得到可重定位目标文件hello.o。
5、链接: hello.o与可重定位目标文件、动态链接库,经链接器ld链接生成可执 行文件hello。
6、创建子进程:bash进程调用fork函数,生成子进程;并由execve函数加载运行当前进程的上下文中加载并运行新程序hello。
7、运行: execve 调用启动加载器,加映射虚拟内存,进入程序入口后,程序载入物理内存进入 main 函数。
8、 IO:hello在运行时会调用一些函数,比如printf函数,这些函数与linux I/O 的设备模拟化密切相关。
9、回收: hello最终被shell父进程回收,内核会收回为其创建的所有信息。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 内容 |
hello.i | hello.c经过预处理得到的文本文件 |
hello.s | hello.i经过编译器编译得到的文本文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
elf.txt | hello.o通过readelf生成的elf文件 |
hello_objdump.txt | hello.o反汇编代码文件 |
hello | 链接器链接得到的可执行文件 |
hello.elf | hello可执行程序的elf文件 |
hello_asm.txt | hello的反汇编代码文件 |
参考文献
- Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018-1-737
- Stallings.计算机组成与体系结构:性能设计(原书第8版). 北京:机械工业出版社,2011.
- https://gcc.gnu.org/onlinedocs/cpp/index.html#Top
- The Four Stages of Compiling a C Program
- [转]printf 函数实现的深入剖析 - Pianistx - 博客园