HIT CSAPP 2018 大作业 程序人生 Hello's P2P

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/hahalidaxin/article/details/85144974

Github:https://github.com/hahalidaxin/ProgramLessons/tree/master/CSAPP/FinalWork

声明update :反汇编直接使用的是 ld 链接生成的可执行目标文件,没有使用 gcc –m64 –no-pie –fno-PIC 选项 --12/31/2018

1.1 Hello简介

       在Editor中键入代码得到hello.c程序。

在linux中,hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork,产生子进程,于是hello便从Program摇身一变成为Process,这便是P2P的过程。

之后shell为其execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构,以上全部便是020的过程。

1.2 环境与工具

硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD.

软件环境:Ubuntu18.04.1 LTS

开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

文件名称

文件作用

hello.i

预处理之后文本文件

hello.s

编译之后的汇编文件

hello.o

汇编之后的可重定位目标执行

hello

链接之后的可执行目标文件

hello2.c

测试程序代码

hello2

测试程序

helloo.objdmp

Hello.o的反汇编代码

helloo.elf

Hello.o的ELF格式

hello.objdmp

Hello的反汇编代码

hello.elf

Hellode ELF格式

tmp.txt

存放临时数据

 

 

1.4 本章小结

       本章主要简单介绍了hello的p2p,020过程,列出了本次实验信息:环境、中间结果。

 

(第1章0.5分)

 

 

第2章 预处理

2.1 预处理的概念与作用

概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。

主要功能如下:

  1. 将源文件中用#include形式声明的文件复制到新的程序中。比如hello.c第6-8行中的#include<stdio.h> 等命令告诉预处理器读取系统头文件stdio.h unistd.h stdlib.h 的内容,并把它直接插入到程序文本中。
  2. 用实际值替换用#define定义的字符串
  3. 根据#if后面的条件决定需要编译的代码

 

2.2在Ubuntu下预处理的命令

       命令:cpp hello.c > hello.i

 

             图2.1 使用cpp命令生成hello.i文件

2.3 Hello的预处理结果解析

       使用vim打开hello.i之后发现,整个hello.i程序已经拓展为3188行,main函数出现在hello.c中的代码自3099行开始。如下:

 

      图2.2 hello.i中main函数的位置

 

在这之前出现的是stdio.h unistd.h stdlib.h的依次展开,以stdio.h的展开为例,cpp到默认的环境变量下寻找stdio.h,打开/usr/include/stdio.h 发现其中依然使用了#define语句,cpp对此递归展开,所以最终.i程序中是没有#define的。而且发现其中使用了大量的#ifdef #ifndef的语句,cpp会对条件值进行判断来决定是否执行包含其中的逻辑。其他类似。

2.4 本章小结

       hello.c需要用到许多不是自身的“前件儿”,在真正投入造 程序 的汪洋大海之前还需要装备整齐,体体面面……

       本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

编译器将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。这个过程称为编译,同时也是编译的作用。   

编译器的构建流程主要分为3个步骤:

  1. 词法分析器,用于将字符串转化成内部的表示结构。
  2. 语法分析器,将词法分析得到的标记流(token)生成一棵语法树。
  3. 目标代码的生成,将语法树转化成目标代码。

 

3.2 在Ubuntu下编译的命令

       命令:gcc -S hello.i -o hello.s

 

      图3.1 使用gcc命令生成64位的hello.s文件

3.3 Hello的编译结果解析

3.3.0 汇编指令

      

指令

含义

.file

声明源文件

.text

以下是代码段

.section .rodata

以下是rodata节

.globl

声明一个全局变量

.type

用来指定是函数类型或是对象类型

.size

声明大小

.long、.string

声明一个long、string类型

.align

声明对指令或者数据的存放地址进行对齐的方式

 

3.3.1 数据

       hello.s中用到的C数据类型有:整数、字符串、数组。

  • 字符串

程序中的字符串分别是:

  1. “Usage: Hello 学号 姓名!\n”,第一个printf传入的输出格式化参数,在hello.s中声明如图3.2,可以发现字符串被编码成UTF-8格式,一个汉字在utf-8编码中占三个字节,一个\代表一个字节。
  2. “Hello %s %s\n”,第二个printf传入的输出格式化参数,在hello.s中声明如图3.2。

             其中后两个字符串都声明在了.rodata只读数据节。

       图3.2 hello.s中声明在.LC0和.LC1段中的字符串

                    

  • 整数

程序中涉及的整数有:

  1. int sleepsecs:sleepsecs在C程序中被声明为全局变量,且已经被赋值,编译器处理时在.data节声明该变量,.data节存放已经初始化的全局和静态C变量。在图3.3中,可以看到,编译器首先将sleepsecs在.text代码段中声明为全局变量,其次在.data段中,设置对齐方式为4、设置类型为对象、设置大小为4字节、设置为long类型其值为2(long类型在linux下与int相同为4B,将int声明为long应该是编译器偏好)。

       图3.3 hello.s中sleepsecs的声明

 

  1. int i:编译器将局部变量存储在寄存器或者栈空间中,在hello.s中编译器将i存储在栈上空间-4(%rbp)中,可以看出i占据了栈中的4B。
  2. int argc:作为第一个参数传入。
  3. 立即数:其他整形数据的出现都是以立即数的形式出现的,直接硬编码在汇编代码中。

 

  • 数组

程序中涉及数组的是:char *argv[] main,函数执行时输入的命令行,argv作为存放char指针的数组同时是第二个参数传入。

argv单个元素char*大小为8B,argv指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为argv,main函数中访问数组元素argv[1],argv[2]时,按照起始地址argv大小8B计算数据地址取数据,在hello.s中,使用两次(%rax)(两次rax分别为argv[1]和argv[2]的地址)取出其值。如图3.4。

 

                            图3.4 计算地址取出数组值

 

 

 

3.3.2 赋值

       程序中涉及的赋值操作有:

  1. int sleepsecs=2.5 :因为sleepsecs是全局变量,所以直接在.data节中将sleepsecs声明为值2的long类型数据。
  2. i=0:整型数据的赋值使用mov指令完成,根据数据的大小不同使用不同后缀,分别为:

指令

b

w

l

q

大小

8b (1B)

16b (2B)

32b (4B)

64b (8B)

因为i是4B的int类型,所以使用movl进行赋值,汇编代码如图3.5。

 

  图3.5 hello.s中变量i的赋值

3.3.3 类型转换

       程序中涉及隐式类型转换的是:int sleepsecs=2.5,将浮点数类型的2.5转换为int类型。

当在double或float向int进行类型转换的时候,程序改变数值和位模式的原则是:值会向零舍入。例如1.999将被转换成1,-1.999将被转换成-1。进一步来讲,可能会产生值溢出的情况,与Intel兼容的微处理器指定位模式[10…000]为整数不确定值,一个浮点数到整数的转换,如果不能为该浮点数找到一个合适的整数近似值,就会产生一个整数不确定值。

浮点数默认类型为double,所以上述强制转化是double强制转化为int类型。遵从向零舍入的原则,将2.5舍入为2。

3.3.4算数操作

       进行数据算数操作的汇编指令有:

      

指令

效果

leaq S,D

D=&S

INC D

D+=1

DEC D

D-=1

NEG D

D=-D

ADD S,D

D=D+S

SUB S,D

D=D-S

IMULQ S

R[%rdx]:R[%rax]=S*R[%rax](有符号)

MULQ S

R[%rdx]:R[%rax]=S*R[%rax](无符号)

IDIVQ S

R[%rdx]=R[%rdx]:R[%rax] mod S(有符号)

R[%rax]=R[%rdx]:R[%rax] div S

DIVQ S

R[%rdx]=R[%rdx]:R[%rax] mod S(无符号)

R[%rax]=R[%rdx]:R[%rax] div S

 

       程序中涉及的算数操作有:

  1. i++,对计数器i自增,使用程序指令addl,后缀l代表操作数是一个4B大小的数据。
  2. 汇编中使用leaq .LC1(%rip),%rdi,使用了加载有效地址指令leaq计算LC1的段地址%rip+.LC1并传递给%rdi。

3.3.5 关系操作

       进行关系操作的汇编指令有:

指令

效果

描述

CMP S1,S2

S2-S1

比较-设置条件码

TEST S1,S2

S1&S2

测试-设置条件码

SET**  D

D=**

按照**将条件码设置D

J**

——

根据**与条件码进行跳转

 

       程序中涉及的关系运算为:

  1. argc!=3:判断argc不等于3。hello.s中使用cmpl $3,-20(%rbp),计算argc-3然后设置条件码,为下一步je利用条件码进行跳转作准备。
  2. i<10:判断i小于10。hello.s中使用cmpl $9,-4(%rbp),计算i-9然后设置条件码,为下一步jle利用条件码进行跳转做准备。

3.3.6 控制转移

       程序中涉及的控制转移有:

  1. if (argv!=3):当argv不等于3的时候执行程序段中的代码。如图3.6,对于if判断,编译器使用跳转指令实现,首先cmpl比较argv和3,设置条件码,使用je判断ZF标志位,如果为0,说明argv-3=0 argv==3,则不执行if中的代码直接跳转到.L2,否则顺序执行下一条语句,即执行if中的代码。

       图3.6 if语句的编译

 

  1. for(i=0;i<10;i++):使用计数变量i循环10次。如图3.7,编译器的编译逻辑是,首先无条件跳转到位于循环体.L4之后的比较代码,使用cmpl进行比较,如果i<=9,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。

       图3.7 for循环的编译

3.3.7 函数操作

       函数是一种过程,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。P中调用函数Q包含以下动作:

  1. 传递控制:进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
  2. 传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值。
  3. 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。

 

64位程序参数存储顺序(浮点数使用xmm,不包含):

1

2

3

4

5

6

7

%rdi

%rsi

%rdx

%rcx

%r8

%r9

栈空间

 

 

程序中涉及函数操作的有:

  1. main函数:
    1. 传递控制,main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址dest压栈,然后跳转到main函数。
    2. 传递数据,外部调用过程向main函数传递参数argc和argv,分别使用%rdi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
    3. 分配和释放内存,使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令,leave相当于mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP,将下一条要执行指令的地址设置为dest。
  2. printf函数:
    1. 传递数据:第一次printf将%rdi设置为“Usage: Hello 学号 姓名!\n”字符串的首地址。第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rsi为argv[1],%rdx为argv[2]。
    2. 控制传递:第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
  3. exit函数:
    1. 传递数据:将%edi设置为1。
    2. 控制传递:call exit@PLT。
  4. sleep函数:
    1. 传递数据:将%edi设置为sleepsecs。
    2. 控制传递:call sleep@PLT。
  5. getchar函数:
    1. 控制传递:call gethcar@PLT

 

3.4 本章小结

       造 程序 首先要承受ccl的“降维打击”,如果你问为什么,还不是因为要让as看得懂……

 

本章主要阐述了编译器是如何处理C语言的各个数据类型以及各类操作的,基本都是先给出原理然后结合hello.c C程序到hello.s汇编代码之间的映射关系作出合理解释。

编译器将.i的拓展程序编译为.s的汇编代码。经过编译之后,我们的hello自C语言解构为更加低级的汇编语言。

 

(第32分)

第4章 汇编

4.1 汇编的概念与作用

       汇编器(as)将.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。这个过程称为汇编,亦即汇编的作用。

4.2 在Ubuntu下汇编的命令

指令:as hello.s -o hello.o

      

       图4.1 使用as指令生成hello.o文件

4.3 可重定位目标elf格式

       使用readelf -a hello.o > helloo.elf 指令获得hello.o文件的ELF格式。其组成如下:

  1. ELF Header:以16B的序列Magic开始,Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。

      

       图4.2 ELF Header

 

 

  1. Section Headers:节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。

       图4.3 节头部表Section Headers

  1. 重定位节.rela.text ,一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。如图4.4,图中8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、sleepsecs、sleep函数、getchar函数进行重定位声明。

       图4.4 重定位节.rela.text

 

.rela节的包含的信息有(readelf显示与hello.o中的编码不同,以hello.o为准):

 

offset

需要进行重定向的代码在.text.data节中的偏移位置,8个字节。

Info

包括symboltype两部分,其中symbol占前4个字节,type占后4个字节,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型

Addend

计算重定位位置的辅助信息,共占8个字节

Type

重定位到的目标的类型

Name

重定向到的目标的名称

 

              下面以.L1的重定位为例阐述之后的重定位过程:链接器根据info信息向.symtab节中查询链接目标的符号,由info.symbol=0x05,可以发现重定位目标链接到.rodata的.L1,设重定位条目为r,根据图4.5知r的构造为:

r.offset=0x18, r.symbol=.rodata, r.type=R_X86_64_PC32, r.addend=-4,

重定位一个使用32位PC相对地址的引用。计算重定位目标地址的算法如下(设需要重定位的.text节中的位置为src,设重定位的目的位置dst):

       refptr = s +r.offset (1)

refaddr = ADDR(s) + r.offset (2)

*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)(3)

其中(1)指向src的指针(2)计算src的运行时地址,(3)中,ADDR(r.symbol)计算dst的运行时地址,在本例中,ADDR(r.symbol)获得的是dst的运行时地址,因为需要设置的是绝对地址,即dst与下一条指令之间的地址之差,所以需要加上r.addend=-4。

之后将src处设置为运行时值*refptr,完成该处重定位。

      

             

                     图4.5 通过HexEdit查看hello.o中的.rela.text节

             

对于其他符号的重定位过程,情况类似。

       3).rela.eh_frame : eh_frame节的重定位信息。

       4).symtab:符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。

4.4 Hello.o的结果解析

       使用 objdump -d -r hello.o > helloo.objdump获得反汇编代码。

       总体观察图4.6后发现,除去显示格式之外两者差别不大,主要差别如下:

  1. 分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
  2. 函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。     
  3. 全局变量访问:在.s文件中,访问rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。

      图4.6 hello.s与反汇编代码main函数对照

 

4.5 本章小结

       啥,还要“降维打击”,我……

本章介绍了hello从hello.s到hello.o的汇编过程,通过查看hello.o的elf格式和使用objdump得到反汇编代码与hello.s进行比较的方式,间接了解到从汇编语言映射到机器语言汇编器需要实现的转换。

(第41分)

5章 链接

5.1 链接的概念与作用

       链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。

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.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o

 

注意:因为需要生成的是64位的程序,所以,使用的动态链接器和链接的目标文件都应该是64位的。

 

       图5.1 使用ld命令链接生成可执行程序hello

 

      

5.3 可执行目标文件hello的格式

       使用readelf -a hello > hello.elf 命令生成hello程序的ELF格式文件。

             

       在ELF格式文件中,Section Headers对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。

 

       图5.2 hello ELF格式中的Section Headers Table

      

5.4 hello的虚拟地址空间

       使用edb打开hello程序,通过edb的Data Dump窗口查看加载到虚拟地址中的hello程序。

       在0x400000~0x401000段中,程序被载入,自虚拟地址0x400000开始,自0x400fff结束,这之间每个节(开始 ~ .eh_frame节)的排列即开始结束同图5.2中Address中声明。

       如图5.3,查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在下面可以看出,程序包含8个段:

  1. PHDR保存程序头表。
  2. INTERP指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
  3. LOAD表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。
  4. DYNAMIC保存了由动态链接器使用的信息。
  5. NOTE保存辅助信息。
  6. GNU_STACK:权限标志,标志栈是否是可执行的。
  7. GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。

       图5.3 ELF格式文件中的Program Headers Table

 

通过Data Dump查看虚拟地址段0x600000~0x602000,在0~fff空间中,与0x400000~0x401000段的存放的程序相同,在fff之后存放的是.dynamic~.shstrtab节。

 

5.5 链接的重定位过程分析

       使用objdump -d -r hello > hello.objdump 获得hello的反汇编代码。

       与hello.o反汇编文本helloo.objdump相比,在hello.objdump中多了许多节,列在下面。

      

节名称

描述

.interp

保存ld.so的路径

.note.ABI-tag

Linux下特有的section

.hash

符号的哈希表

.gnu.hash

GNU拓展的符号的哈希表

.dynsym

运行时/动态符号表

.dynstr

存放.dynsym节中的符号名称

.gnu.version

符号版本

.gnu.version_r

符号引用版本

.rela.dyn

运行时/动态重定位表

.rela.plt

.plt节的重定位条目

.init

程序初始化需要执行的代码

.plt

动态链接-过程链接表

.fini

当程序正常终止时需要执行的代码

.eh_frame

contains exception unwinding and source language information.

.dynamic

存放被ld.so使用的动态链接信息

.got

动态链接-全局偏移量表-存放变量

.got.plt

动态链接-全局偏移量表-存放函数

.data

初始化了的数据

.comment

一串包含编译器的NULL-terminated字符串

      

       通过比较hello.objdump和helloo.objdump了解链接器。

       1)函数个数:在使用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。链接器将上述函数加入。

       2)函数调用:链接器解析重定条目时发现对外部函数调用的类型为R_X86_64_PLT32的重定位,此时动态链接库中的函数已经加入到了PLT中,.text与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。对于此类重定位链接器为其构造.plt与.got.plt。

       3).rodata引用:链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。这里以计算第一条字符串相对地址为例说明计算相对地址的算法(算法说明同4.3节):

       refptr = s + r.offset = Pointer to 0x40054A

refaddr = ADDR(s) + r.offset= ADDR(main)+r.offset=0x400532+0x18=0x40054A

*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr) = ADDR(str1)+r.addend-refaddr=0x400644+(-0x4)-0x40054A=(unsigned) 0xF6,

观察反汇编验证计算:

       其他.rodata引用,函数调用原理类似。

5.6 hello的执行流程

       使用edb执行hello,观察函数执行流程,将过程中执行的主要函数列在下面:

      

程序名称

程序地址

ld-2.27.so!_dl_start

0x7fce 8cc38ea0

ld-2.27.so!_dl_init

0x7fce 8cc47630

hello!_start

0x400500

libc-2.27.so!__libc_start_main

0x7fce 8c867ab0

-libc-2.27.so!__cxa_atexit

0x7fce 8c889430

-libc-2.27.so!__libc_csu_init

0x4005c0

hello!_init

0x400488

libc-2.27.so!_setjmp

0x7fce 8c884c10

-libc-2.27.so!_sigsetjmp

0x7fce 8c884b70

--libc-2.27.so!__sigjmp_save

0x7fce 8c884bd0

hello!main

0x400532

hello!puts@plt

0x4004b0

hello!exit@plt

0x4004e0

*hello!printf@plt

--

*hello!sleep@plt

--

*hello!getchar@plt

--

ld-2.27.so!_dl_runtime_resolve_xsave

0x7fce 8cc4e680

-ld-2.27.so!_dl_fixup

0x7fce 8cc46df0

--ld-2.27.so!_dl_lookup_symbol_x

0x7fce 8cc420b0

libc-2.27.so!exit

0x7fce 8c889128  

 

5.7 Hello的动态链接分析

       对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。

       在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。如在图5.4 (a)。

在dl_init调用之后,如图5.4 (b),0x601008和0x601010处的两个8B数据分别发生改变为0x7fd9 d3925170和0x7fd9 d3713680,如图5.4(c)其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址,如图5.4(d)GOT[2]指向动态链接器ld-linux.so运行时地址。

       图5.4 (a) 没有调用dl_init之前的全局偏移量表.got.plt

(根据.plt中exit@plt jmp的引用地址0x601030可以得到其.got.plt条目为0x4004e6,正是其下条指令地址)

 

       图5.4(b)调用dl_init之后的全局偏移量表.got.plt

 

       图5.3(c)0x7fd9 d3925170指向的重定位表

 

       图5.4(d) 0x7fd9 d3713680目标程序-动态链接器

      

       在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。

       因为在PLT中使用的jmp,所以执行完目标函数之后的返回地址为最近call指令下一条指令地址,即在main中的调用完成地址。

5.8 本章小结

“大贤者”ld赋予了hello.o“捕食者”技能,hello.o不是仇家crt1.o,crti.o,crtn.o,libc.so中需要的技能成为可执行程序hello,最终消灭“仇人笔记本”上所有仇家,但真正的链接不止于此,对于hello中需要的PIC函数调用则需要动态链接器/lib64/ld-linux-x86-64.so.2,ld.so是个懒家伙,只有在函数调用的时候才会进行实际上的重定位,这就是动态链接中的延迟绑定。

历经艰辛,hello可算诞生了呦QWQ

 

在本章中主要介绍了链接的概念与作用、hello的ELF格式,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。

(第51分)

 

6章 hello进程管理

6.1 进程的概念与作用

       进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。

进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。

      

6.2 简述壳Shell-bash的作用与处理流程

       Shell的作用:Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell 是指一种应用程序,Shell应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。

       处理流程:

1)从终端读入输入的命令。

2)将输入字符串切分获得所有的参数

3)如果是内置命令则立即执行

4)否则调用相应的程序为其分配子进程并运行

5)shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

       在终端Gnome-Terminal中键入 ./hello 1170300825 lidaxin,运行的终端程序会对输入的命令行进行解析,因为hello不是一个内置的shell命令所以解析之后终端程序判断./hello的语义为执行当前目录下的可执行目标文件hello,之后终端程序首先会调用fork函数创建一个新的运行的子进程,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。

       父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。

       简单进程图如下:

      

              图6.1 终端程序的简单进程图

6.4 Hello的execve过程

       当fork之后,子进程调用execve函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

       加载器创建的内存映像如下:

 

              图6.2 启动加载器创建的系统映像

6.5 Hello的进程执行

       逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

       时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。

       用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

       上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。

 

       简单看hello sleep进程调度的过程:当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行1)保存以前进程的上下文2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程 ,来完成上下文切换。

       如图6.3,hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。

                     图6.3 hello进程sleep上下文切换的简单理解

             

之后的9个sleep进程调度如上。

当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。进程切换如图6.3,省略。

6.6 hello的异常与信号处理

       如图6.4(a),是正常执行hello程序的结果,当程序执行完成之后,进程被回收。

       如图6.4(b),是在程序输出2条info之后按下ctrl-z的结果,当按下ctrl-z之后,shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起,通过ps命令我们可以看出hello进程没有被回收,此时他的后台job号是1,调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行打印剩下的8条info,之后输入字串,程序结束,同时进程被回收。

       如图6.4(c)是在程序输出3条info之后按下ctrl-c的结果,当按下ctrl-c之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是结束hello,并回收hello进程。

       如图6.4(d)是在程序运行中途乱按的结果,可以发现,乱按只是将屏幕的输入缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入。

       图6.4 (a) 正常运行hello程序

       图6.4(b)运行中途按下ctrl-z

       图6.4(c)运行中途按下ctrl-c

 

       图6.4(d)运行中途乱按

 

6.7本章小结

       Shell(Gnome-Terminal)下达命令,进程管理为hello提供了活动空间,Shell为其fork,为其execve,为其分配时间片,Linux是繁忙的,但是却依靠进程调度使得每个进程安稳运行,在庞大但有序的Linux都市中,hello还只是个naive的孩子呀,too young,too simple……

 

       在本章中,阐明了进程的定义与作用,介绍了Shell的一般处理流程,调用fork创建新进程,调用execve执行hello,hello的进程执行,hello的异常与信号处理。

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

       物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。 CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。

逻辑地址:程序代码经过编译后出现在 汇编程序中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述符的选择符)和偏移量(偏移部分)组成。

线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。

至于虚拟地址,只关注CSAPP课本中提到的虚拟地址,实际上就是这里的线性地址。

       图7.1[转] 三种地址之间的关系

      

7.2 Intel逻辑地址到线性地址的变换-段式管理

最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。

分段功能在实模式和保护模式下有所不同。

实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。

在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段,构造如下:

      图7.2[转] 段描述符表中的一个条目的构造

Base:基地址,32位线性地址指向段的开始。Limit:段界限,段的大小。 DPL:描述符的特权级0(内核模式)-3(用户模式)。

所有的段描述符被保存在两个表中:全局描述符表GDT和局部描述符表LDT。gdtr寄存器指向GDT表基址。

 

段选择符构造如下:

      图7.3[转] 段选择符的构造

TI:0为GDT,1为LDT。Index指出选择描述符表中的哪个条目,RPL请求特权级。

      

       所以在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。保护模式时分段机制图示如下:

              图7.4[转] 保护模式下分段机制

      

       当CPU位于32位模式时,内存4GB,寄存器和指令都可以寻址整个线性地址空间,所以这时候不再需要使用基地址,将基地址设置为0,此时逻辑地址=描述符=线性地址,Intel的文档中将其称为扁平模型(flat model),现代的x86系统内核使用的是基本扁平模型,等价于转换地址时关闭了分段功能。在CPU 64位模式中强制使用扁平的线性空间。逻辑地址与线性地址就合二为一了。所以分段机制也就成为时代的眼泪了(?

 

7.3 Hello的线性地址到物理地址的变换-页式管理

线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。

首先Linux系统有自己的虚拟内存系统,其虚拟内存组织形式如图7.5,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。

 

             图7.5 Linux是如何组织虚拟内存的

 

       系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧),虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。

       如图7.6,不考虑TLB与多级页表(在7.4节中包含这两者的综合考虑),虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO,根据位数限制分析(可以在7.4节中看到分析过程)可以确定VPN和VPO分别占多少位是多少。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。

      

              图7.6 地址翻译

7.4 TLB与四级页表支持下的VA到PA的变换

       在Intel Core i7环境下研究VA到PA的地址翻译问题。前提如下:

       虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB 4路16组相联。CR3指向第一级页表的起始位置(上下文一部分)。

       解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以VPO 12位;因为TLB共16组,所以TLBI需4位,因为VPN 36位,所以TLBT 32位。

 

       如图 ,CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配,如果命中,则得到PPN(40bit)与VPO(12bit)组合成PA(52bit)。

       如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。

       如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。

             

      

              图 TLB与4级页表下Core i7的地址翻译情况

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

       前提:只讨论L1 Cache的寻址细节,L2与L3Cache原理相同。L1 Cache是8路64组相联。块大小为64B。

       解析前提条件:因为共64组,所以需要6bit CI进行组寻址,因为共有8路,因为块大小为64B所以需要6bit CO表示数据偏移位置,因为VA共52bit,所以CT共40bit。

 

       在上一步中我们已经获得了物理地址VA,使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。

       如果没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。

      图 物理内存的访问

7.6 hello进程fork时的内存映射

       当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

7.7 hello进程execve时的内存映射

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

  1. 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
  3. 映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

 

      图 加载器是如何映射用户地址空间区域的

7.8 缺页故障与缺页中断处理

       缺页故障是一种常见的故障,当指令引用一个虚拟地址,在MMU中查找页表时发现与该地址相对应的物理地址不在内存中,因此必须从磁盘中取出的时候就会发生故障。其处理流程遵循图 所示的故障处理流程。   

      

              图 故障处理流程

 

       缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。

      

7.9动态存储分配管理

        printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

       分配器分为两种基本风格:显式分配器、隐式分配器。

       显式分配器:要求应用显式地释放任何已分配的块。

       隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。

  • 带边界标签的隐式空闲链表

1)堆及堆中内存块的组织结构:

在内存块中增加4B的Header和4B的Footer,其中Header用于寻找下一个blcok,Footer用于寻找上一个block。Footer的设计是专门为了合并空闲块方便的。因为Header和Footer大小已知,所以我们利用Header和Footer中存放的块大小就可以寻找上下block。

2)隐式链表

       所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中Header和Footer中的block大小间接起到了前驱、后继指针的作用。

3)空闲块合并

因为有了Footer,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变Header和Footer中的值就可以完成这一操作。

 

  • 显示空间链表基本原理

将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:

     

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。

维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。

7.10本章小结

       Linux可不是一个想呆在哪就呆在哪的地方,这个大都市是如此有序,公共活动场所又是如此宝贵,只有当真正要进行活动的时候,hello才能向MMU递交统一格式的虚拟地址来获得自己真正该玩耍的地方,活动时如果需要临时存放 个程序 物品,还需要特别地调用malloc申请堆空间。通过网上冲浪hello还了解到一个叫做段式管理的都市传说,也是个令 程序 摸不到头脑的东西 555。虽然繁琐,但Linux可真使 程序 感到安心呀……

 

    本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core7在指定环境下介绍了VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。

8.2 简述Unix IO接口及其函数

Unix I/O接口统一操作:

  1. 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
  2. Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
  3. 改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
  4. 读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
  5. 关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。

Unix I/O函数:

  1. int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
  2. int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
  3. ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
  4. ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。

 

8.3 printf的实现分析

       前提:printf和vsprintf代码是windows下的。

       查看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;

}

 

首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。

查看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':     //只处理%x一种情况

                itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp

                strcpy(p, tmp);  //tmp字符串复制到p

                p_next_arg += 4; //下一个参数值地址

                p += strlen(tmp); //放下一个参数值的地址

                break;

            case 's':

                break;

            default:

                break;

        }

    }

  

    return (p - buf);   //返回最后生成的字符串的长度

}

 

则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。

在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:

 

write:

    mov eax, _NR_write

    mov ebx, [esp + 4]

    mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

 

在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,查看syscall的实现:

 

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

 

syscall将字符串中的字节“Hello 1170300825 lidaxin”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。

字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。

显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

于是我们的打印字符串“Hello 1170300825 lidaxin”就显示在了屏幕上。

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。

getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。

8.5本章小结

       对于printf和getchar,hello以前只知道调用之后一个能打印字符,一个能读入字符,可究竟为啥,不知道,学习完Linux都市的IO管理手册之后,hello多少明白了其中奥妙,原来他们都是Unix I/O的封装,而真正调用的是write和read这样的系统调用函数,而它们又都是由内核完成的,之所以键盘能输入是因为引发了异步异常,之所以屏幕上会有显示是因为字符串被复制到了屏幕赖以显示的显存当中,至于其中细节,也值得好好研究一番……

   

       本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。

 

(第8 1分)

 

结论

hello程序 终于 完成了它 艰辛 的一生。hello的一生大事记如下:

  1. 编写,通过editor将代码键入hello.c
  2. 预处理,将hello.c调用的所有外部的库展开合并到一个hello.i文件中
  3. 编译,将hello.i编译成为汇编文件hello.s
  4. 汇编,将hello.s会变成为可重定位目标文件hello.o
  5. 链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
  6. 运行:在shell中输入./hello 1170300825 lidaxin
  7. 创建子进程:shell进程调用fork为其创建子进程
  8. 运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
  9. 执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
  10. 访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
  11. 动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
  12. 信号:如果运行途中键入ctr-c ctr-z则调用shell的信号处理函数分别停止、挂起。
  13. 结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。

      

 

这一天,世界上终于响起那首关于Hello的歌曲“只有 CS 知道……我曾经……来…………过……”,但听到这首歌的绝非仅有CS……

还有我啊 啊 啊 啊 啊……

 

(结论0分,缺失 -1分,根据内容酌情加分)

附件

文件名称

文件作用

hello.i

预处理之后文本文件

hello.s

编译之后的汇编文件

hello.o

汇编之后的可重定位目标执行

hello

链接之后的可执行目标文件

hello2.c

测试程序代码

hello2

测试程序

helloo.objdmp

Hello.o的反汇编代码

helloo.elf

Hello.o的ELF格式

hello.objdmp

Hello的反汇编代码

hello.elf

Hellode ELF格式

hmp.txt

存放临时数据

(附件0分,缺失 -1分)

参考文献

 [1] ELF 构造:https://www.cs.stevens.edu/~jschauma/631/elf.html

 [1] 16进制计算器:http://www.99cankao.com/digital-computation/hex-calculator.php

 [2] Linux下进程的睡眠唤醒:https://blog.csdn.net/shengin/article/details/21530337

 [3]进程的睡眠、挂起和阻塞:https://www.zhihu.com/question/42962803

 [4]虚拟地址、逻辑地址、线性地址、物理地址:https://blog.csdn.net/rabbit_in_android/article/details/49976101

 [5] printf函数实现的深入剖析:https://blog.csdn.net/zhengqijun_/article/details/72454714

 [6] 内存地址转换与分段 https://blog.csdn.net/drshenlei/article/details/4261909

 

 

 

 

 

 

 

 

猜你喜欢

转载自blog.csdn.net/hahalidaxin/article/details/85144974
今日推荐