Hello程序是每个程序员起步的第一个程序。本文以“Hello的自白”为引子,来观测Hello的一生,由简单的程序开始去一步一步了解更加复杂,更加值得去探索的程序,我们将更好地理解计算机系统这个更为宏观的概念,以及看似简单的程序背后隐藏着的繁杂的工作。
关键词:计算机系统;hello.c;预处理;编译;汇编;链接。
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理.......................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 11 -
7.5 三级Cache支持下的物理内存访问............................................................. - 11 -
7.6 hello进程fork时的内存映射..................................................................... - 11 -
7.7 hello进程execve时的内存映射................................................................. - 11 -
7.8 缺页故障与缺页中断处理.............................................................................. - 11 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P即 From Program to Process,即文本文件hello. C(源程序,文本)文件通过预处理器cpp预处理变成hello.i文件(修改了的源程序,文本),然后通过编译器ccl编译成为hello.s文件(汇编程序,文本),而后通过汇编器as汇编得到hello.o文件(可重定位目标程序,二进制),最后通过链接器ld变成hello(可执行程序,二进制)。
hello在shell中输入“./hello”运行hello程序,shell为其fork,产生子进程, 此时hello.c从Program(程序)变为Process(进程)。然后, 调用 execve 函数在新的子进程中加载并运行 hello,调用函数,之后转移到hello的mian函数,CPU 为运行的 hello 分配时间片执行逻辑控制流。当程序运行结束时回收进程,释放内存,删除与执行程序相关的数据结构,便为hello的020。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件工具:X64 Intel CoreI7 CPU,8GRAM,512GHD DISK。
软件工具:Windows10 64位,VM VirtualBox ,Ubuntu 20.04。
开发者与调试工具:codeblocks,gcc,gdb,edb,gedit,Gvim,ld,readelf,objdump等。
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i 预处理产生文件
hello.s 编译产生文件
hello.o 汇编产生文件
hello 链接产生可执行文件
1.4 本章小结
解释了P2P和020的基本概念,介绍了软硬件环境以及产生的一系列中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位。
预处理的作用:
1.文件包含:通过#include为文件的引用(库文件)组合源程序正文
2.条件编译:#if、#endif等为进行编译时有选择的挑选,注释掉一些指定代码,以达到版本,防止对文件重复包含的作用。
3.布局控制:#progma为编译程序提供非常规的控制流信息。
4.宏替换:#define,可以定义符号常量、函数功能、重新命名、字符串拼接等功能。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
2.3 Hello的预处理结果解析
预处理将头文件stdio.h,unistd.h,stdlib.h的内容添加到hello.i中,并且删除所有注释。原本只有几十行的源代码现在变成了3000多行,源代码到了最后面。
2.4 本章小结
本章对hello.c程序进行预处理,生成hello.i文件。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译:利用编译程序从源语言编写的源程序产生目标程序的过程。
作用:将便于人编写阅读的高级程序语言转换成计算机易于运行的汇编语言以让计算机能够运行程序。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
Hello.c的源代码:
3.3.1 变量:
本程序包含一个局部变量“int i”。在hello.s中,i的初始化语句:
在之后对i的引用都是通过栈中的数据进行访问。
3.3.2 for循环:
在源代码中:
“i=0”:
为局部变量i分配为空间,使用栈中 %rbp所指的地址之前的-4字节处为开头,存储一个4字节的0。
“i<8”:
引用之前为局部变量i分配的栈空间中的地址。将i与7进行比较。当i<=7时跳转到.L4小节中。其中,.L4小节为循环体。通过.L3的两条汇编语句,实现了for循环中的条件判断功能。
L4小节即为循环体,其中最后一句实现了for中的“i++”语句。
3.3.3 if语句
如图语句实现了if条件判断,当argc==4时跳转至L2(即for循环)
当argc!=4时,25行后面的语句:
对应了if条件中的语句。
3.3.4函数调用:
如图对printf函数的调用发生在循环体中:
34句语句将栈中保存的argv[2]指针的地址保存到%rax中,然后36句将栈中保存的argv[2]的指针赋值给%rdx;
与此类似,37~40四句将argv[1]指针赋值给%rsi。
%rdi保存一个字符常量 "Hello %s %s\n"。其保存在.LC1标签中:
3.4 本章小结
通过对hello.s与hello.c的对比,分析了解了c语言程序的编译过程。
(第3章2分)
4章 汇编
4.1 汇编的概念与作用
概念:汇编是汇编器as将一个汇编语言文件(.s文件)转化为一个可重定位目标程序(.o文件)(机器语言)的过程。
作用:将文字形式的汇编代码转化为真正的机器可以执行的二进制代码。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o -v
4.3 可重定位目标elf格式
Elf头:
共有14个节头表,每个占用64bytes。
节头表:
包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。可得到是否可执行,是否可读写等信息。
符号表:
存放程序中定义和引用的函数和全局变量的信息。name是符号名称, size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的。
重定位节:
偏移:0x388;对齐:8字节;大小:0xc0。
4.4 Hello.o的结果解析
反汇编结果:
与hello.s相比,基本框架与关键指令都是相同的,只不过有些地方不一样了。
分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call 的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用, 将其 call 指令后的相对地址设置为全 0(目标地址正是下一条指令),然 后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
全局变量访问:在.s 文件中,访问 rodata(printf 中的字符串),使用段名称+%rip,在反汇编代码中 0+%rip,因为 rodata 中数据地址也是在运行时 确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
记录了从hello.s到机器指令hello.o的过程,通过反汇编手段,分析了机器指令和汇编指令的不同,同时展示了elf表中信息。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存中执行。链接可以执行于编译时,也就是源代码被翻译成机器代码时;链接也可以执行于加载时,也就是程序被加载器加载到内存中被执行时;甚至被执行于运行时,也就是由应用程序来执行。
作用:
使得分离编译成为可能;动态绑定(binding):使定义、实现、使用分离。
5.2 在Ubuntu下链接的命令
命令:
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.ohello.o/usr/lib/x86_64-linux-gnu/libc.so/usr/lib/x86_64-linux-gnu//crtn.o
5.3 可执行目标文件hello的格式
ELF头:
文件的类型由可重定位文件变为可执行文件。程序的入口点、程序开始点、节头表偏移都发生了变化。共有27个节头表。比之前的多出13个。
5.4 hello的虚拟地址空间
如图,各段的起始地址能够在edb中得到证实,这与5.3中结果一致。
5.5 链接的重定位过程分析
Hello与hello.o的不同之处:
1.hello.o中的相对偏移地址到了hello中变成了虚拟内存地址。
2.hello中将需要用到的一些库函数和外部函数写入进来。
3.Hello中使用的跳转地址和函数调用地址均为虚拟内存地址。
4.Hello中添加了许多节如.init,.plt等,而hello.o中只有.text节。
2.链接的过程:在使用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。链接器将上述函数加入。
3.重定位:链接器完成符号解析后,将代码中每个符号引用和一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来,此时,链接器就知道了它的输入目标模块中的代码节和数据节的确切大小。重定位首先应进行重定位节和符号定义,在这一步骤中,链接器将所有相同类型的节合并为同一类型的新的聚合节,然后程序将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,由此程序中的每一条指令和全局变量都有唯一的运行时内存地址。其次,进行重定位节中的符号引用,这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
通过书中概念我又总结了重定位地址的计算方法,更易理解,过程如下:
首先获取各节的大小,然后直接通过对应的节与程序头之间的相对距离大小确定节头的地址,节中对应的指令地址则由与节头之间的相对地址计算,各指令的具体地址均可由此获知。call指令后接的函数的绝对地址则应通过相对地址+PC(也就是下一条指令的地址)的方式进行计算。
5.6 hello的执行流程
ld-2.33.so!_dl_start 0x7ffff7fcdd70
ld-2.33.so!_dl_init 0x7ffff7fdc060
hello!_start 0x0000000000401090
hello!main 0x0000000000401172
hello!puts@plt 0x0000000000401030
hello!printf@plt 0x0000000000401040
hello!strtol@plt 0x0000000000401050
hello!sleep@plt 0x0000000000401070
hello!getc@plt 0x0000000000401080
5.7 Hello的动态链接分析
全局偏移量表(GOT):在GOT中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成 一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址,GOT[2]指向动态链接器ld-linux.so运行时地址。
5.8 本章小结
本章将hello.o链接成了hello,分析了hello的ELF格式,虚拟地址空间的分配,并详细分析了hello的执行流程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是正在运行的程序的实例,是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
作用:提供给应有程序一个独立的逻辑控制流和一个私有的地址空间,好像程序在独占的使用处理器和内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell提供了一个用户与系统内核进行交互的界面,用户通过这个界面访问操作系统内核的服务。
Shell-bash的处理流程:
(1)是运行shell程序;
(2)用户输入命令;
(3)解析用户输入的命令进行解析,判断是否为内置命令;
(4)如果是内置命令就直接执行,如果不是则试图将其当作一个可执行文件运行。
6.3 Hello的fork进程创建过程
1.Shell解析命令./hello,然后调用fork函数创建子进程;
2.子进程执行hello,父进程等待子进程结束;
3.子进程结束,调用exit()退出,父进程结束等待;
4.shell等待下一个命令的输入。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。在execve加载了hello之后,它调用_start,_start设置栈,并将控制传递给新程序的主函数。
6.5 Hello的进程执行
在hello运行时,也有一些其它进程在并行地运行,这些进程的逻辑流的执行时间与hello的逻辑流重叠,称为并发流。一个进程执行它的控制流的一部分的每一时间段叫时间片。
上下文是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
首先由父进程fork一个子进程,将其上下文,虚拟地址空间等信息复制一份给子进程,随即使用execve在当前的上下文中执行hello程序。此时hello的控制流被搬上了CPU的处理工作中。在hello运行一开始,这个进程是处于用户模式下的,但是当它调用sleep函数,他会通过一个陷阱执行系统调用函数sleep并进入内核模式。当sleep结束时,进程会由内核模式重新变成用户模式,并将控制交给用户进程。此时hello继续执行。同样,别的一些系统调用函数都是如此。当hello进程结束时,会由其父进程shell来进行回收释放hello进程占用的资源。
6.6 hello的异常与信号处理
Ctrl-Z
进程暂时挂起,输入ps命令符可以发现hello进程还没有被关闭。
Ctrl-C
这个操作向进程发送了一个sigint信号,让进程直接结束,输入ps命令可以发现当前hello进程已经被终止了。
随意乱按
程序直接忽视其他按键,正常运行后再出现执行随意输入的命令。
6.7本章小结
介绍了Shell的一般处理流程,调用fork创建新进程,调用execve执行hello,hello的进程执行,hello的异常,信号处理。
(以下格式自行编排,编辑时删除)
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,即程序的机器代码中保存的地址。
线性地址:线性地址是由逻辑地址加上一个基址得到的,hello的程序段内偏移地址加上段的基地址就生成了线性地址。
虚拟地址:虚拟地址的由来是基于进程提供的抽象。对于hello程序,进程提供给他一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统。这个假象就是虚拟地址空间。
物理地址:是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个 连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。
程序加载时,操作系统为 所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。在为某个段分配物理内存时,可以采用首先适配法、下次适配法、最佳适配法等方法。
在回收某个段所占用的空间时,要注意将收回的空间与其相邻的空间合并。段式存储管理也需要硬件支持,实现逻辑地址到物理地址的映射。
程序通过分段划分为多个模块,如代码段、数据段、共享段。这样做的优点是:可以分别编写和编译源程序的一个文件,并且可以针对不同类型的段采取不同的保护,也可以按段为单位来进行共享。总的来说,段式存储管理的优点是:没有内碎片,外碎片可以通过内存紧缩来消除;便于实现内存共享。缺点与页式存储管理的缺点相同,进程必须全部装入内存。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须査阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这又会要求从存储器取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,这就是TLB。
同样,四级页表的创建也是出于减少资源占用,这从两个方面减少了存储器要求。第一,如果低级页表中的一个PTE是空的,那么相应的高级页表就根本不会存在,这代表着一种巨大的潜在节约,因为对于一个典型的程序, 4GB的虚拟地址空间的大部分都将是未分配的。第二,只有低级页表才需要总是在主存中;虚拟存储器系统可以在需要时创建、页面调入或调出高级页表,这就减少了主存的压力;只有最经常使用的高级页表才需要缓存在主存中。
对于拥有TLB的过程:
开始时,MMU从虚拟地址中抽取出VPN ,并且检查TLB,看它是否因为前面的 某个存储器引用,缓存了该PTE的一个拷贝。TLB从VPN中梱取出TLB索引和 TLB标记,若组中的某个条目中有效匹配,所以命中,然后将缓存的PPN返回给MMU。
如果TLB不命中,那么MMU就需要从主存中取出相应的PTE。现在,MMU有了形成物理地址所需要的所有东西。它通过将来自PTE的PPN和来自虚拟地址的VPO连接起来,这就形成了物理地址。
7.5 三级Cache支持下的物理内存访问
Cache:cache是对主存的缓存,对于每一个cpu,都有一个L1数据cache与一个L1代码cache,和一个L2cache。所有的cpu会共享一个L3cache。在访问主存内容时,会先在缓存中找,若找到,则直接取数据,若没有找到,则会在主存中找,然后更新缓存。
对于缓存的使用方式,我们只讨论一级缓存,因为后面的取用方式都是一样的。
首先,cpu给出一个物理地址,这个物理地址可以被分为三个块,一个组索引,一个行标记,一个偏移位,它们的位数分别为s , n-s-b , b。其中n是物理地址的位数。而缓存大小即为S*E*B。其中S = 2^s ,B = 2^b。对于一个物理地址,先由它的组索引得到它在缓存的哪个组,然后再由行标记看它是否在缓存中。若在,则根据偏移位来获得目标数据。若不在,则更新缓存,这里涉及到了替换策略(一般是牺牲掉最久没有)。对于写操作,还有不同的命中与不命中处理方式。
若命中时,有两种
直写:在缓存中修改后直接在源数据中修。
写回:在缓存中修改后不在源数据中修改,直到该缓存块被驱逐时再写回到源数据中。
若不命中,有两种:
写分配:更新缓存,将源数据放入缓存中,然后修改缓存(没有改源数据)。
非写分配:直接修改源数据,不操作缓存。
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
Execve函数在当前进程中,加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
3.映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC) execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
MMU在试图翻译某个虚拟地址A时,发现内存中没有A所在的那一页。此时触发一个缺页异常,这个异常导致控制转移到内核的缺页处理程序。
处理程序首先执行2个判断:
判断虚拟地址A是合法的。即A是否在某个区域结构定义的区域。为了回答判断A的合法性,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的 vm start和vm end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
对地址A进行的内存访问是否合法。即进程是否有读、写或者执行这个区域内页面的权限。如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
以上两个判断之后,内核确定可以对A进行访问。于是进行第3个步骤:
选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,它将堆视为一组不同大小的块的集合来维护。每个块就是一个虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器又分为两种:显示分配器与隐式分配器。
显式分配器要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,像Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.10本章小结
整理了有关内存管理的知识,讲述了在hello运行的64位系统中内存管理方法,虚拟内存和物理内存之间的关系,了解了intel环境下的段式管理和页式管理,了解了fork和exceve的内存映射,知道了缺页故障和缺页中断管理机制,了解了如何根据缓存或页表寻找物理內存。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix IO 接口:
1. 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。Unix外壳创建的每个进程幵始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件定义了常量STDIN_FILENO、STD0UT—FILEN0和STDERR_FILENO,它们可用来代替显式的描述符值。
2. 改变当前的文件位置。对于每个打开的件,内核保持着一个文件位置初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置。
3. 读写文件。一个读操作就是从文件拷贝字节到存储器,从当前文件位置开始,然后增加。给定一个大小为m字节的文件,当> m时执行读操作会触发一个称为end-of-file (EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从存储器拷贝字节到一个文件,从当前文件位置开始, 然后更新。
4. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的存储器资源。
Unix IO 函数:
int open(const char* path, int oflag, .../*mode_t mode*/);
int openat(int fd, const char* path, int oflag, .../*mode_t mode*/)
若文件打开失败返回-1
若成功将返回最小的未用的文件描述符的值。
int create(const char *path, mode_t mode);
若文件创建失败返回-1;
若创建成功返回当前创建文件的文件描述符。
int close(int fd);
该函数的作用是关闭指定文件描述符的文件,关闭文件时还会释放该进程加在该文件上的所有的记录锁。
int lseek(int fd, off_t offset, int whence);
成功则返回新的文件的偏移量;
失败则返回-1.
使用lseek()函数显式的为一个打开的文件设置偏移量。
ssize_t read(int fd, void *buf, size_t nbytes);
ssize_t write(int fd, const void* buf, size_t ntyes);
fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,buf为写入内容的缓冲区,ntyes为期待写入的字节数
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
printf函数的函数体:
int printf(const char *fmt, ...){
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
可以看到这里有一个缓冲区数组buf,而且printf函数调用了vsprintf与write函数。
vsprintf函数:
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);
}
从中我们首先可以得知这个函数返回了要打印出来的字符串的长度。
其次,我们结合原来printf的代码可以明白这个函数的作用只是接受带输出的字符串,并对其进行格式化,产生格式化输出。
write函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
很明显这个write是给寄存器eax,ebx,ecx传递了参数,然后以一个int结束。实际上,最后一行代码的意思是要通过系统来调用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码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
这样一来printf的工作原理就显而易见了。他首先通过vsprintf来获得将要输出的字符串的格式化形式,然后通过write,sys_call等过程调用来辅助后面的显示芯片将输出显示在显示器上。对于hello来说,就会显示我们的姓名,学号等内容。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。CPU收到中断请求后,挂起当前进程,然后运行键盘中断子程序。键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符(\n)然后返回整个字串到stdin中。
8.5本章小结
Linux系统将IO设备抽象为一个一个的文件,简化了程序的书写。Printf函数与getchar函数都是通过系统调用将字符输入到终端的。
(第8章1分)
结论
Hello一生重要过程:
1.预处理:gcc执行hello.c中的预处理命令,合并库,宏展开、
2.编译:将hello.i编译成为汇编文件hello.s
3.汇编:将hello.s会变成为可重定位目标文件hello.o
4.链接:将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
5.运行:在shell中输入./hello1170801219 yangjin开始运行程序
6.创建子进程:shell进程调用fork为其创建子进程,分配pid。
7.运行程序:子进程shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
8.执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
9.访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
10.动态申请内存:调用malloc向动态内存分配器申请堆中的内存。
11.信号:运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。
12.结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
计算机中所有的程序的运行都会经历hello经历的一部分。在hello的一生中,我们可以看到计算机内部工作的严谨与精密。所有的函数、指令都一环扣一环,任何一个环节出错都将导致程序运行出错。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.i 预处理产生文件
hello.s 编译产生文件
hello.o 汇编产生文件
hello 链接产生可执行文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] 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.
(参考文献0分,缺失 -1分)