HIT CSAPP 2021 大作业 程序人生 Hello‘s P2P

题     目  程序人生-Hello’s P2P 

专       业        计算机学院      

学     号        ********      

班     级          *********       

学       生          *******     

摘  要

本文主要参照《深入理解计算机系统》一书,结合CSAPP课程内容来介绍hello程序的一生,并对该过程中的知识点进行梳理,所有操作均在Ubuntu虚拟机完成,运用了多种调试工具,有助于加深对计算机系统的理解。

关键词:Ubuntu;CSAPP;深入理解计算机系统;程序生命周期                           

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

1.2 环境与工具... - 4 -

1.2.1 硬件环境... - 4 -

1.2.2 软件环境... - 4 -

1.2.3 开发工具... - 4 -

1.3 中间结果... - 4 -

1.4 本章小结... - 5 -

第2章 预处理... - 6 -

2.1 预处理的概念与作用... - 6 -

2.2在Ubuntu下预处理的命令... - 6 -

2.3 Hello的预处理结果解析... - 7 -

2.4 本章小结... - 9 -

第3章 编译... - 10 -

3.1 编译的概念与作用... - 10 -

3.2 在Ubuntu下编译的命令... - 10 -

3.3 Hello的编译结果解析... - 11 -

3.3.1 部分汇编伪指令... - 11 -

3.3.2 数据... - 11 -

3.3.3 赋值... - 12 -

3.3.4 类型转换... - 13 -

3.3.5 算数操作... - 13 -

3.3.6 关系操作... - 14 -

3.3.7 数组、指针、结构操作... - 14 -

3.3.8 控制转移操作... - 14 -

3.3.9 函数操作... - 15 -

3.4 本章小结... - 15 -

第4章 汇编... - 16 -

4.1 汇编的概念与作用... - 16 -

4.2 在Ubuntu下汇编的命令... - 16 -

4.3 可重定位目标elf格式... - 17 -

4.4 Hello.o的结果解析... - 19 -

4.5 本章小结... - 20 -

第5章 链接... - 21 -

5.1 链接的概念与作用... - 21 -

5.2 在Ubuntu下链接的命令... - 21 -

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

5.4 hello的虚拟地址空间... - 22 -

5.5 链接的重定位过程分析... - 23 -

5.6 hello的执行流程... - 24 -

5.7 Hello的动态链接分析... - 25 -

5.8 本章小结... - 25 -

第6章 hello进程管理... - 26 -

6.1 进程的概念与作用... - 26 -

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

6.3 Hello的fork进程创建过程... - 26 -

6.4 Hello的execve过程... - 27 -

6.5 Hello的进程执行... - 27 -

6.6 hello的异常与信号处理... - 28 -

6.7本章小结... - 31 -

第7章 hello的存储管理... - 32 -

7.1 hello的存储器地址空间... - 32 -

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

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

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

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

7.6 hello进程fork时的内存映射... - 35 -

7.7 hello进程execve时的内存映射... - 35 -

7.8 缺页故障与缺页中断处理... - 36 -

7.9动态存储分配管理... - 36 -

7.10本章小结... - 38 -

第8章 hello的IO管理... - 39 -

8.1 Linux的IO设备管理方法... - 39 -

8.2 简述Unix IO接口及其函数... - 39 -

8.3 printf的实现分析... - 39 -

8.4 getchar的实现分析... - 40 -

8.5本章小结... - 41 -

结论... - 41 -

附件... - 42 -

参考文献... - 43 -

第1章 概述

1.1 Hello简介

P2P简介

P2P,英文是From program to process。用户首先通过各种文本编辑器编写代码,得到hello.c文件,然后在Ubuntu下,调用C预处理器得到ASCII码的中间文件hello.i,然后调用C编译器得到ASCII汇编语言文件hello.s,然后运行汇编器得到可重定位目标文件hello.o,最后通过链接器得到可执行目标文件hello,用户可以在终端输入./hello来启动程序。

O2O简介

O2O,英文是From Zero-0 to Zero-0。用户输入命令后,shell根据命令调用fork函数生成子进程,子进程中execve函数运行hello,这要为hello各段创建新的空间。首次加载会产生缺页,调用缺页中断处理子程序,多次调用后hello就加载到内存中。在运行过程中会收到外部的信号(比如用户输入),因此还要相对的信号处理程序。hello执行完毕,hello结束,等待被父进程回收。

1.2 环境与工具

1.2.1 硬件环境

X64 CPU;2.10 GHz;16G RAM;512G SSD Disk

1.2.2 软件环境

Windows10 64位;VirtualBox6.0;Ubuntu 20.04 LTS 64位

1.2.3 开发工具

Gcc、vim、gdb、edb、readelf、HexEdit等

1.3 中间结果

中间结果文件   文件作用

hello.i   预处理生成的文本文件hello.i

hello.s   编译产生的文本文件hello.s

hello.o  汇编后生成的可重定位目标文件hello.o

helloo.elf     hello.o的elf文件

helloo.objdump  objdump hello.o生成的hello.o的反汇编代码文件

hello     链接生成的可执行文件hello

hello.elf       hello的elf文件

hello.objdump    objdump hello生成的hello的反汇编代码文件

1.4 本章小结

本章简要介绍了hello.c的P2P和O2O,然后介绍了hello从开始到结束的过程及中间生成的一些文件。可以大致知道其过程:hello.c经过预处理生成hello.i,然后经过编译生成hello.s,再汇编生成hello.o,再链接生成hello这个可执行文件。接着hello运行O2O的过程,最后被父进程回收,结束hello。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

预处理的概念:

预处理器(即cpp)根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将原始C程序引用的所有库展开,处理所有原始C程序的条件编译,并执行原始C程序所有的宏定义,得到另一个通常是以.i作为文件扩展名的、文件名与原始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(或gcc -E hello.c -o hello.i)

图 1 使用cpp命令

图 2 使用gcc命令

(经过简单比对,两文件都有3065行,故认为两文件相同,为简单起见,后文只使用cpp命令得到的文件进行实验)

2.3 Hello的预处理结果解析

使用文本编辑器打开hello.i,发现此时程序由之前的28行扩展为3065行,我们使用ctrl+f查找main函数所在位置。

图 3 main函数所在位置

可以看到main函数于3049行出现,hello.c文件原始代码于3046行出现。

经过预处理器处理后,hello.c文件转化为hello.i文件,打开文件后发现文件的内容大大增加,但该文件还是可以使用文本编辑器正常打开阅读的文本文件,进行粗略观察,发现hello.i对原文件中的宏进行了宏展开,将头文件中的内容包含进了该文件,比如一些标准库中的函数、结构体的定义、变量的定义等。

如果代码中有#define命令,预处理器还会对相应的符号进行替换。

我们在文本编辑器中使用ctrl+f查找头文件stdio.h,

图 4 查找stdio.h

去该路径找到该文件,可以看到该文件中还是有很多#开头的宏定义内容

图 5 <stdio.h>的内容

这是由于cpp对引入头文件是进行递归展开,所以最终.i程序中是没有#define语句的。我们还发现其中使用了大量的#ifdef #ifndef的语句,cpp会对条件值进行判断来决定是否执行包含其中的逻辑。其他宏定义是类似的。

2.4 本章小结

Hello.c程序要想真正的运行起来,需要很多的前置准备。

本章主要介绍了预处理的概念及作用,并结合hello.c处理后的hello.i对处理过程进行分析。根据hello.i文件的内容对cpp预处理的机制进行了简单的探索。

(第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

图 6 使用gcc -S命令进行编译

3.3 Hello的编译结果解析

3.3.1 部分汇编伪指令

指令

含义

.file

声明源文件

.text

以下是代码段

.section .rodata

以下是rodata节

.globl

声明一个全局变量

.type

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

.size

声明大小

.long、.string

声明一个long、string类型

.align

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

3.3.2 数据

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

字符串

在hello.c程序中使用到的字符串有:

“Usage: Hello 学号 姓名!\n”

“Hello %s %s\n”

编译器一般会将printf打印输出的格式串放在.rodata节,在hello.s中,这两个字符串存储格式如下:其中可以看到汉字被编码为UTF-8格式,一个汉字占三个字节,每个字节以\开始。

图 7 字符串在hello.s中的存储格式

整数

在hello.c中使用到的整形变量有sleepsecs、argc和i。

sleepsecs在C程序中被声明为全局变量,且已经被赋值,编译器处理时将会在.data节声明该变量,.data节存放已经初始化的全局和静态C变量。

在图 8 sleepsecs在hello.s中可以看到,编译器首先将sleepsecs在.text代码段中声明为全局变量,其次在.data段中,设置对齐方式为4、设置类型为对象、设置大小为4字节、设置为long类型其值为2(进行了隐式转换)(long类型在linux下与int相同为4字节,将int声明为long应该是编译器的偏好选择)。

8 sleepsecs在hello.s中

argc是从终端传入的参数个数,也是main函数的第一个参数,故由寄存器%edi进行保存。

i是局部变量,编译器将局部变量存储在寄存器或者栈空间中,在hello.s中编译器将i存储在栈上空间-4(%rbp)中,可以看出i占据了栈中的4字节。

数组

在hello.c程序中使用到的数组有:char *argv[] main

该数组存储函数执行时输入的命令行,argv作为存放char指针的数组,也同时作为第二个参数传入。

图 9 数组argv[]的使用

argv单个元素char*大小为8B,argv指针指向已经分配好的、一片存放着字符指针的连续空间,起始地址为argv,main函数中每次先获得数组的起始地址,然后通过加8*i来访问之后的字符指针,之后通过获得的字符指针寻找字符串在hello.s中,使用两次(%rax)寻址取值(两次rax分别为argv[1]和argv[2]的地址)取出其值。

3.3.3 赋值

在hello.c程序中涉及的赋值操作有:

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

i为4B双字的int类型,故使用movl进行赋值:

图 10 对局部变量i进行0赋值

3.3.4 类型转换

在hello.c程序中涉及隐式类型转换的是:

int sleepsecs=2.5,本条语句将浮点数类型的2.5转换为int类型。

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

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

3.3.5 算数操作

汇编语言中有如下几种算术操作:

指令

行为

描述

inc D

D=D+1

加1

dec D

D=D-1

减1

neg D

D=-D

取反

add S,G

D=D+S

D加S

sub S,D

D=D-S

D减S

imul S,D

D=D*S

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

无符号触发

leaq S,D

D = &S

加载有效地址

在hello.c程序中涉及算数操作的是:循环变量i的自增,如下:

图 11 循环变量i的自增

3.3.6 关系操作

指令

效果

描述

CMP S1,S2

S2-S1

比较S2-S1设置条件码

TEST S1,S2

S1&S2

测试S1&S2设置条件码

SETXX  D

D=XX

按照XX将条件码设置D

JXX

——

根据XX与条件码进行跳转

C语言中的关系操作有==、!=、>、<、>=、<=,这些操作在汇编语言中主要依赖于cmp和test指令实现,cmp指令根据两个操作数之差来设置条件码。cmp指令与SUB指令的行为是一样,而test指令的行为与and指令一样,除了它们只设置条件码而不改变目的寄存器的值。

在hello.c中有两处用到了关系操作,在cmp之后设置条件码,为之后的je和jle提供判断依据。

  

3.3.7 数组、指针、结构操作

hello.s中取argv首地址,通过首地址加8字节找到argv[1]的地址,然后通过argv[1]中的内容找到对应的字符串,保存在寄存器%rax中,对argv数组其他元素所指的字符串也同理。

3.3.8 控制转移操作

Hello.c程序涉及到的控制转移有两处。

第一处是判断argc是否与3相等,在hello.s中如图所示,cmpl比较argc和3设置条件码之后,通过判断条件码ZF位是否为零决定是否跳转到.L2,如果为0,说明argc等于3,代码跳转到.L2继续执行,如果不为0,则顺序执行指令。另一处基本同理。

图 12 控制转移

3.3.9 函数操作

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

传递控制:进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。

传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值。

分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。

函数的程序参数存储顺序如下表:

第一个

第二个

第三个

第四个

第五个

第六个

第七个及之后

%rdi

%rsi

%rdx

%rcx

%r8

%r9

栈中

函数调用使用call指令即可。

3.4 本章小结

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

编译器将.i的拓展程序编译为.s的汇编代码。经过编译之后,我们的hello自C语言解构为更加低级的汇编语言。通过理解了这些编译器编译的机制,我们可以很容易的将汇编语言翻译成伪C语言。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

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

4.2 在Ubuntu下汇编的命令

汇编的命令:as hello.s -o hello.o(gcc -c hello.s -o hello.o)

图 13 汇编的命令

图 14 使用gcc进行汇编

4.3 可重定位目标elf格式

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

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

图 15 ELF 头信息

  1. 节头部表

节头部表包含了文件中出现的各个节的含义,包括节的地址、偏移量、大小等信息。

图 16 节头部表

  1. .rela.text节

一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

图 17 .rela.text节

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

Offset

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

Info

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

Addend

有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

Type

重定位到的目标的类型。

Name

重定向到的目标的名称。

  1. .rela.eh_frame节

.eh_frame节的重定位信息。

  1. .symtab节

符号表,用来存放程序中的定义和引用函数的全局变量的信息。重定位需要引用的符号都在其中声明。name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字,value是符号的地址,对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址,size是目标的大小,type通常要么是数据,要么是函数,binding表示符号是本地的还是全局的。ABS代表不该被重定位的符号,UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号,COMMON表示还未被分配位置的未初始化的数据目标。

图 18 .symtab节

4.4 Hello.o的结果解析

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

机器语言是计算机能直接理解的语言,完全由二进制数构成,为了阅读的方便显示成了16 进制。每两个16进制数构成一个字节编码,是机器语言中能解释一个运算符或操作数的最小单位。

机器语言由三种数据构成。一是操作码,它具体说明了操作的性质和功能,每一条指令都有一个相应的操作码,计算机通过识别该操作码来完成不同的操作;二是操作数的地址,CPU通过地址取得所需的操作数;三是操作结果的存储地址,把对操作数的处理所产生的结果保存在该地址中,以便再次使用。

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

分支转移:反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。

函数调用:在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。    

全局变量访问:在.s文件中,访问rodata(printf中的字符串),使用段名称+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。

4.5 本章小结

本章主要介绍了hello.s hello.o的汇编过程,通过查看hello.oelf格式和使用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

图 19 链接的过程

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

使用使用readelf -a hello > hello.elf 命令生成hello程序的ELF格式文件。查看hello的elf格式,节头表记录了各个节的信息,Address是程序被载入到虚拟地址的起始地址,off是在程序中的偏移量,size是节的大小。

图 20 hello文件的节头

5.4 hello的虚拟地址空间

用edb查看程序hello,发现程序在地址0x400000~0x401000中被载入,从0x400000开始到0x400fff结束,在0x400fff之后存放的是.dynamic~.shstrtab节。在Data Dump中查看地址0x400000开始的内容,可以看到开头是ELF头部分。

图 21 ELF头部分

查看地址0x0x4002e0,发现是.interp节,保存着linux动态共享库的路径。

图 22 linux动态共享库

查看地址0x0x402000,发现是.rodata节,其中保存着hello.c中的两个字符串。

图 23 .rodata节

       其他节也可以通过节头部表用Data Dump找到,这里不再赘述。

5.5 链接的重定位过程分析

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

图 24 两份反汇编代码

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

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

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

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

refptr=s+r.offset=Pointerto0x4010dd

refaddr=ADDR(s)+r.offset=ADDR(main)+r.offset=0x4010c1+0x1c=0x4010dd

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

=ADDR(str1)+r.addend-refaddr

=0x402008-0x4010dd=(unsigned)0xf2b

5.6 hello的执行流程

使用edb执行hello

子程序名:

ld-2.27.so!_dl_start

ld-2.27.so!_dl_init

hello!_start

libc-2.27.so!__libc_start_main

libc-2.27.so!__cxa_atexit

libc-2.27.so!__libc_csu_init

libc-2.27.so!_setjmp

hello!main

hello!puts@plt

hello!exit@plt

hello!printf@plt

hello!sleep@plt

hello!getchar@plt

ld-2.27.so!_dl_runtime_resolve_xsave

ld-2.27.so!_dl_fixup

ld-2.27.so!_dl_lookup_symbol_x

libc-2.27.so!exit

5.7 Hello的动态链接分析

在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。

动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。

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

5.8 本章小结

在本章中介绍了链接的方法,分析了可执行文件hello的elf格式,同时反汇编hello文件,将其与hello.o的反汇编文件进行对比,最后对hello进行动态链接分析。涉及了重定位和符号解析的知识,加强了对重定位和链接的理解。

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

进程的概念

进程就是一个执行中的程序的示例,系统中的每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态(包括存放在内存中的程序的代码和数据、栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符等)组成的。

进程的作用

进程提供给应用程序关键的抽象:一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

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

Shell的作用:Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell是用户与操作系统之间完成交互式操作的一个接口程序,为用户提供简化了的操作,进一步开发出Borne Again Shell,简称bash,是Linux系统中默认的shell程序。提供了一个界面,用户通过这个界面访问操作系统内核的服务。

处理流程:

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

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

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

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

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

6.3 Hello的fork进程创建过程

执行中的进程调用fork()函数,就创建了一个子进程。

函数原型:pid_t fork(void);

返回值:若成功调用则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。

对于hello进程,终端的输入被判定为非内置命令,shell试图在硬盘上查找该命令(即hello可执行程序),并将其调入内存,然后shell将其解释为系统功能调用并转交给内核执行。shell执行fork函数,创建一个子进程,这时候程序开始运行。hello子进程是父进程的副本,将获得父进程数据空间、堆、栈等资源的副本。同时linux将复制子进程的地址空间给子进程,因此,hello进程就有了独立的地址空间。

6.4 Hello的execve过程

函数原型int execve(const char *filename, const char *argv[], const char *envp[]);

如果成功,则不返回;如果错误,则返回-1。

在execve加载hello之后,它调用启动代码。启动代码设置栈,并将控制传递给hello主函数(main函数)。

main函数原型:int main(int argc, char *argv[], char *envp[])。

过程如下:hello子进程通过execve系统调用启动加载器;加载器删除子进程所有的虚拟地址段,并创建一组新的代码、数据、堆段。新的栈和堆段被初始化为0;通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容;最后加载器跳到_start地址,最终调用hello的main函数。

除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制,直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

6.5 Hello的进程执行

进程时间片:一个进程执行他的控制流的一部分的每一个时间段叫做时间片,多任务也叫时间分片。

进程上下文切换:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策叫做调度。在内核调度一个新的进程运行时,它抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。

过程:1.保存当前进程的上下文。2.恢复某个先前被强占的进程被保存的上下文。3.将控制传递给这个新恢复的进程。

hello在刚开始运行时内核为其保存一个上下文,进程在用户模式下运行,当没有异常或中断信号的产生,hello将一直正常地执行,而当出现异常或系统中断时,内核将启用调度器休眠当前进程,并在内核模式中完成上下文切换,将控制传递给其他进程。

6.6 hello的异常与信号处理

hello的异常:

1)中断:来自处理器外部的I/O设备的信号的结果(例如:在键盘上敲击Ctrl-C或者Ctrl-Z)

2)陷阱:有益的,执行指令的结果(例如:系统调用)

产生的信号:

SIGINT,SIGSTP,SIGCONT,SIGWINCH

下面我们对hello执行过程中的几种可能异常进行分析:

首先尝试运行过程中敲击回车,除了换行外没有其他情况:

图 25 敲击回车

之后在ctrl-z后测试ps jobs pstree fg kill命令:

图 26 使用ctrl+z挂起进程

       使用ps命令查看,发现hello进程在后台正常存在

图 27 ps命令查看进程

       使用jobs命令,可以看到已停止的hello进程

图 28 jobs命令查看进程

       Pstree查看:

图 29 pstree命令

       Fg命令恢复

图 30 fg命令恢复进程

Kill命令杀死进程:

图 31 kill命令杀死进程

       测试ctrl+c:

图 32 程序ctrl+c被终止

       测试任意字符:不影响运行。

图 33 进行任意字符输入

信号处理:

1)Ctrl-C Ctrl-Z:键盘输入后,内核发送SIGINT(默认终止前台job即程序hello)或者SIGSTP(默认挂起前台hello作业)。

2)fg信号:内核发送SIGCONT信号,让刚刚挂起的程序hello重新在前台运行。

3)kill -9 23166:内核发送SIGKILL信号给我们指定的pid,这样就杀死了hello程序。

6.7本章小结

本章主要介绍了进程的概念与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数 执行 hello,hello的进程执行,以及hello 的异常与信号处理。

(第61分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:在有地址变换功能的计算机中,访内指令给出的地址(操作数)叫逻辑地址,也叫相对地址。分为两个部分,一个部分为段基址,另一个部分为段偏移量。

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

虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。与物理地址相似,虚拟内存被组织为一个存放在磁盘上的N个连续的字节大小的单元组成的数组,其每个字节对应的地址成为虚拟地址。虚拟地址包括VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB索引)、TLBT(TLB标记)。

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

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

实模式下:逻辑地址CS:EA=->物理地址CS*16+EA

保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址,段地址+偏移地址=线性地址。

段选择符各字段含义,如图

TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)

RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级

高13-8K个索引用来确定当前使用的段描述符在描述符表中的位置,如下图:

被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址,如下图:

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

1.基本原理

将程序的逻辑地址空间划分为固定大小的页,而物理内存划分为同样大小的页框。程序加载时,可将任意一页放入内存中任意一个页框,这些页框不必连续,从而实现了离散分配。该方法需要CPU的硬件支持,来实现逻辑地址和物理地址之间的映射。在页式存储管理方式中地址结构由两部构成,前一部分是VPN(虚拟页号),后一部分是VPO(虚拟页偏移量)。

页式管理方式的优点:没有外碎片;一个程序不必连续存放;便于改变程序占用空间的大小(主要指随着程序运行,动态生成的数据增多,所要求的地址空间相应增长)。

页式管理方式的缺点:要求程序全部装入内存,没有足够的内存,程序就不能执行。

2.页式管理的数据结构

在页式系统中进程建立时,操作系统为进程中所有的页分配页框。当进程撤销时收回所有分配给它的页框。在程序的运行期间,如果允许进程动态地申请空间,操作系统还要为进程申请的空间分配物理页框。操作系统为了完成这些功能,必须记录系统内存中实际的页框使用情况。操作系统还要在进程切换时,正确地切换两个不同的进程地址空间到物理内存空间的映射。这就要求操作系统要记录每个进程页表的相关信息。为了完成上述的功能,—个页式系统中,一般要采用如下的数据结构。

页表:页表将虚拟内存映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。页表是一个页表条目(PTE)的数组。虚拟地址空间的每个页在页表中一个固定偏移量处都有一个PTE。假设每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。

3.页式管理地址变换

MMU利用VPN来选择适当的PTE,将列表条目中PPN和虚拟地址中的VPO串联起来,就得到相应的物理地址。

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

为了消除每次CPU产生一个虚拟地址MMU就查阅一个PTE带来的时间开销,许多系统都在MMU中包括了一个关于PTE的小的缓存,称为翻译后被缓冲器(TLB),TLB的速度快于L1 cache。

TLB通过虚拟地址VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。

同时,为了减少页表太大而造成的空间损失,可以使用层次结构的页表页压缩页表大小。

Core i7使用的是四级页表。如图7.9所示,在四级页表层次结构的地址翻译中,虚拟地址被划分为4个VPN和1个VPO。每个第i个VPN都是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问四个PTE。

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

得到了物理地址PA,首先使用物理地址的CI进行组索引(每组8路),对8路的块分别匹配CT进行标志位匹配,如果匹配成功且块的valid标志位为1,则命中hit,然后根据数据偏移量CO取出数据并返回。

若没找到想匹配的或者标志位为0,则miss,那么cache向下一级cache,这里是二级cache甚至三级cache中寻找查询数据,然后逐级写入cache。

在更新cache的时候,需要判断是否有空闲块:若有空闲块(即有效位为0),则写入;若不存在,则进行驱逐一个块(LRU策略)。

7.6 hello进程fork时的内存映射

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

7.7 hello进程execve时的内存映射

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

删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。

映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。

映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。

设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

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

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

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

7.9动态存储分配管理

1.动态内存分配器的基本原理

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

分配器的类型有两种:显式分配器和隐式分配器。

显式分配器:要求应用显式地释放任何已分配的块。例如,C语言中的malloc函数申请了一块空间之后需要free函数释放这个块

隐式分配器:应用检测到已分配块不再被程序所使用,就释放这个块。比如Java,ML和Lisp等高级语言中的垃圾收集。

2.带边界标签的隐式空闲链表分配器原理

带边界标签的隐式空闲链表的堆块结构如图7.13。一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。

寻找一个空闲块的方式有三种:

(1)首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块:可以取总块数(包括已分配和空闲块)的线性时间,但是会在靠近链表起始处留下小空闲块的“碎片”。

(2)下一次适配:和首次适配相似,只是从链表中上一次查询结束的地方开始,优点是比首次适应更快:避免重复扫描那些无用块。但是一些研究表明,下一次适配的内存利用率要比首次适配低得多。

(3)最佳适配:查询链表,选择一个最好的空闲块适配,剩余最少空闲空间,优点是可以保证碎片最小——提高内存利用率,但是通常运行速度会慢于首次适配。

3.关于堆块的合并有四种情况。在情况1中,两个邻接的块都是已分配的,因此不可能进行合并。所以当前块的状态只是简单地从已分配变成空闲。在情况2中,当前块与后面的块合并。用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部。在情况3中,前面的块和当前块合并。用两个块大小的和来更新前面块的头部和当前块的脚部。在情况4中,要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每种情况中,合并都是在常数时间内完成的。

3.显式空间链表的基本原理

显式空间链表是一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。双向链表使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以使线性的,也可以是一个常数,这取决于我们选择的空闲链表中块的排序策略。

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

一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部,这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。

malloc采用分离适配的方法。

适配块策略:首次适配、下一次适配、最佳适配。首次适配利用率较高,下一次适配时间较快,最佳适配可以很好的减少碎片的产生。在分离适配的时候采取的策略一般是首次试配(因为对分离空闲链表的简单首次适配的内存利用效率近似于整个堆的最佳适配的利用效率)。

7.10本章小结

本章引入了物理地址和虚拟地址的概念,以及相互转化的过程,需要了解段式空间和页面管理的相关知识。同时讨论了页命中和页不命中的相关操作,对fork和execve有了进一步的认知,了解了动态内存分配的方式和放置策略。

(第7 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m字节的序列。所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行:

8.2 简述Unix IO接口及其函数

接口:

1.打开文件:一个应用程序通过要求内核打开相应的文件来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个文件的所有信息,应用程序只需要记住这个描述符。

2.linux shell 创建进程的时候会打开三个文件——标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,可以代替显式的描述符值。

3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地改变当前文件位置k。

4.读写文件:一个读操作就是文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k~m时执行读操作会触发一个称为EOF的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF”符号,类似地,写操作就是不从内存复制n>0个字节到一个文件,从当前文件位置k开始,更新k。

5.关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

8.3 printf的实现分析

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; 

}

vsprintf作用是格式化,接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 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; 

  }

1.运行getchar()时,程序将控制权交给os,键入时,内容进入缓存并在屏幕显示,enter后,通知os输入完成,这时再将控制权交还给程序。

2.异步异常-键盘中断处理:键盘中断处理子程序,接受按键扫描码转成ASCII码,保存到系统的键盘缓冲区。

3.getchar调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。

8.5本章小结

本章介绍了Linux中I/O设备的管理方法,UnixI/O接口和函数,并且分析了printf和getchar函数是如何通过UnixI/O函数实现其功能的,最后对printf和getchar两个函数进行分析。

(第81分)

结论

用计算机系统的语言,逐条总结hello所经历的过程。

Hello.c程序首先经过程序员的编写,以文件形式存储在磁盘上,之后经过预处理得到hello.i文件,hello.i文件经过编译器后得到汇编代码hello.s汇编文件,hello.s经过汇编,得到二进制可重定位目标文件hello.o,hello.o经过链接,生成了可执行文件hello。

bash进程是一个shell,它调用fork函数,生成子进程,并由execve函数加载运行当前进程的上下文中加载并运行新程序hello,hello运行过程中会调用各种函数,例如printf函数,与linux I/O密切相关。hello最终被shell父进程回收,内核回收为其创建的所有信息。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

计算机系统是一个复杂却精密的系统,这一部分拥有着细碎繁多但却都不可忽视的知识点。《深入理解计算机系统》一书可以带着我们逐步理解计算机系统,但是这只是一种启蒙,为了更好的学习计算机系统,我们还需要更多的实践,我相信在未来课程的学习中,我会更好的利用、加深计算机系统知识。

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

附件

列出所有的中间产物的文件名,并予以说明起作用。

中间结果文件

文件作用

hello.i

预处理生成的文本文件hello.i

hello.s

编译产生的文本文件hello.s

hello.o

汇编后生成的可重定位目标文件hello.o

helloo.elf

hello.o的elf文件

helloo.objdump

objdump hello.o生成的hello.o的反汇编代码文件

hello

链接生成的可执行文件hello

hello.elf

hello的elf文件

hello.objdump

objdump hello生成的hello的反汇编代码文件

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

参考文献

为完成本次大作业你翻阅的书籍与网站等

[1]  兰德尔 E.布莱恩特. 深入理解计算机系统. 龚奕利 译.

[2]  库函数getchar()详解https://blog.csdn.net/hulifangjiayou/article/details/40480467

[3]  Linux进程虚拟地址空间https://www.cnblogs.com/xelatex/p/3491305.html

[4]  printf 函数实现的深入剖析.  https://www.cnblogs.com/pianist/p/3315801.html

[5]  getchar函数的分析 https://www.runoob.com/cprogramming/c-function-getchar.html

(参考文献0分,缺失 -1分)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值