程序人生-Hello’s P2P

程序人生-Hello’s P2P

  • 第1章 概述
    • 1.1 Hello简介
    • 1.2 环境与工具
      • 1.2.1 硬件环境
      • 1.2.2 软件环境
      • 1.2.3 开发工具
    • 1.3 中间结果
    • 1.4 本章小结
  • 第2章 预处理
    • 2.1 预处理的概念与作用
    • 2.2在Ubuntu下预处理的命令
    • 2.3 Hello的预处理结果解析
    • 2.4 本章小结
  • 第3章 编译
    • 3.1 编译的概念与作用
    • 3.2 在Ubuntu下编译的命令
    • 3.3 Hello的编译结果解析
      • 3.3.1 数据
      • 3.3.2赋值
      • 3.3.3算术操作
      • 3.3.4 关系操作
      • 3.3.5 数组/指针/结构操作
      • 3.3.6 控制转移
      • 3.3.7 函数操作
    • 3.4 本章小结
  • 第4章 汇编
    • 4.1 汇编的概念与作用
    • 4.2 在Ubuntu下汇编的命令
    • 4.3 可重定位目标elf格式
    • 4.4 Hello.o的结果解析
      • 4.4.1 数据
      • 4.4.2 赋值
      • 4.4.3 算术操作
      • 4.4.4 关系操作
      • 4.4.5 数组/指针/结构操作
      • 4.4.6 控制转移
      • 4.4.7 函数操作
    • 4.5 本章小结
  • 第5章 链接
    • 5.1 链接的概念与作用
    • 5.2 在Ubuntu下链接的命令
    • 5.3 可执行目标文件hello的格式
    • 5.4 hello的虚拟地址空间
    • 5.5 链接的重定位过程分析
    • 5.6 hello的执行流程
    • 5.7 Hello的动态链接分析
    • 5.8 本章小结
  • 第6章 hello进程管理
    • 6.1 进程的概念与作用
    • 6.2 简述壳Shell-bash的作用与处理流程
    • 6.3 Hello的fork进程创建过程
    • 6.4 Hello的execve过程
    • 6.5 Hello的进程执行
    • 6.6 hello的异常与信号处理
    • 6.7本章小结
  • 第7章 hello的存储管理
    • 7.1 hello的存储器地址空间
    • 7.2 Intel逻辑地址到线性地址的变换-段式管理
    • 7.3 Hello的线性地址到物理地址的变换-页式管理
    • 7.4 TLB与四级页表支持下的VA到PA的变换
    • 7.5 三级Cache支持下的物理内存访问
    • 7.6 hello进程fork时的内存映射
    • 7.7 hello进程execve时的内存映射
    • 7.8 缺页故障与缺页中断处理
    • 7.9动态存储分配管理
    • 7.10本章小结
  • 第8章 hello的IO管理
    • 8.1 Linux的IO设备管理方法
    • 8.2 简述Unix IO接口及其函数
    • 8.3 printf的实现分析
    • 8.4 getchar的实现分析
    • 8.5本章小结
  • 结论
  • 附件
  • 参考文献

第1章 概述

1.1 Hello简介

P2P: From Program to Process
首先用文本编辑器编写c语言代码,hello.c就出生了,然后经过cpp预处理器处理,生成了hello.i,之后再经过编译器ccl编译生成汇编文件hello.s,再经过汇编器as处理生成可重定位二进制目标程序,通过链接器ld链接hello需要的可重定位目标程序生成hello二进制可执行目标程序。在shell中输入命令运行hello,shell便会调用fork函数创建一个子进程,在这个子进程中调用execve函数执行hello程序。
O2O: From Zero-0 to Zero-0
刚开始在内存空间中,程序是不存在。shell通过fock生成子进程后,在子进程中通过execve加载并执行hello时,操作系统为程序分配一块虚拟空间,将程序加载到虚拟空间所映射的物理内存空间中,然后执行目标程序,操作系统为运行hello进程分配执行的时间片和逻辑控制流并执行。进程终止时,shell回收hello进程,操作系统会释放hello的内存空间,回归zero。

1.2 环境与工具

1.2.1 硬件环境

CPU:I7-10870H 2.2GHz;
内存:16G;
存储器:512G SSD

1.2.2 软件环境

Win10;
Vmware16.1;
Ubuntu 20.04;

1.2.3 开发工具

GDB;EDB。

1.3 中间结果

hello.c :hello 源代码
hello.i:预处理生成的文本文件
hello.s:编译后得到的汇编语言文件
hello.o:汇编后得到的可重定位目标文件
hello.o.elf.txt:可重定位文件的elf信息
hello.o. objdump.txt:可重定位文件的反汇编信息
hello:链接生成的可执行目标文件
hello.elf.txt:可执行文件的elf信息
hello.objdump.txt:可执行文件的反汇编信息

1.4 本章小结

本章介绍了P2P和O2O过程,列出了大作业的环境和工具,介绍了中间结果文件的名字和作用。

第2章 预处理

2.1 预处理的概念与作用

概念:预处理是预处理器(cpp)对C语言源文件进行的操作过程。操作完成后,得到另一个文本文件,通常文件扩展名为.i。
作用:

  1. 将#include 所包含的文件直接加入到文本文件中(该过程可能是递归进行的,即文件内可能包含其他的“.h”文件)。
  2. 将#define宏定义进行替换。
  3. 处理条件编译指令(例如#if/#ifdef/#ifndef/#else/#elif/#endif)。
  4. 添加行号和文件标识符。用于显示调试信息:错误或警告的位置。
  5. 删除所有注释(/**/,//)。
  6. 处理#progma(和实现相关的杂注)编译指令。
  7. 处理#error编译指令。
  8. 处理#line(行控制)编译指令

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i
程序人生-Hello’s P2P_第1张图片
图2.1 生成hello.i文件

2.3 Hello的预处理结果解析

经过预处理后,hello.c文件从短短的28行骤然变成3065行的hello.i,在结果hello.i中主要有两部分,前一部分是#include指令插入的文件内容(stdio.h,unistd.h,stdlib.h),后一部分则是hello.c中的内容。
程序人生-Hello’s P2P_第2张图片

图2.2 hello.i中部分内容(1)
程序人生-Hello’s P2P_第3张图片

图2.3 hello.i中部分内容(2)

2.4 本章小结

本章介绍了预处理的概念和作用,在ubuntu环境下演示了hello.c的预处理过程,并解析了生成的hello.i预处理文件,验证了前面预处理的作用。

第3章 编译

3.1 编译的概念与作用

概念:编译器(ccl)将文本文件(C语言源代码预处理后的文件,例如hello.i)翻译成汇编指令的过程,得到另一个文本文件,通常文件扩展名为.s(例如hello.s)。
作用[1]:

  1. 扫描(词法分析):将源代码程序输入扫描器,将源代码的字符序列分割成一系列记号。
  2. 语法分析:基于词法分析得到的一系列记号,生成语法树。
  3. 语义分析:由语义分析器完成,指示判断是否合法,并不判断对错。分静态语义(不运行时分析)和动态语义(运行时分析)。
  4. 源代码优化(中间语言生成):中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的是一个前端对多个后端,适应不同平台。
  5. 代码生成:依赖目标机器的不同字长,寄存器,数据类型等生成机器码。
  6. 目标代码优化:例如选择合适的寻址方式,左移右移代替乘除,删除多余指令。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s
程序人生-Hello’s P2P_第4张图片

图3-1 生成hello.s

3.3 Hello的编译结果解析

3.3.1 数据

汇编文件
程序人生-Hello’s P2P_第5张图片

图3-2 hello.s中数据相关部分(.data和.rodata)
.data是可读可写的数据段,C程序的全局变量保存在.data段
.rodata是只读数据段,一般字符串等常量保存在.rodata中
除此之外,还有.comm段,用于保存未初始化的变量,下图是将sleepsecs未初始化的数据段。
在这里插入图片描述

图3-3 hello.c中声明int型变量sleepsecs
程序人生-Hello’s P2P_第6张图片

图3-4 hello.s中数据相关部分(.comm)
hello.c的数据类型主要有:全局int型变量sleepsecs,局部int型变量i,传入参数int型变量argc,传入参数char*数组argv和一些常量。
sleepsecs存在.data中,局部变量一般保存在栈或者寄存器中,这里i存在栈中,地址为-4(%rbp),传入参数一般保存在栈或者寄存器中,这里argc和argv全部保存在栈中,地址分别是-20(%rbp)和-32(%rbp)。常量一般存在.rodata段或直接以立即数的形式体现,这里字符串存在于代码的.rodata段,3,0,10等常量以立即数的形式体现在代码段中($3,$0,$10)。
程序人生-Hello’s P2P_第7张图片

图3-5 main函数编译结果

3.3.2赋值

在这里插入图片描述

图3-6 main函数编译结果中使用movl给局部变量i赋值
对局部变量i赋初值使用movl语句,对应hello.c中的i=0。

3.3.3算术操作

程序人生-Hello’s P2P_第8张图片

图3-7 main函数编译结果中使用addl给局部变量i自增
对应于i++,使用了addl语句。

3.3.4 关系操作

在这里插入图片描述

图3-8 main函数编译结果中实现关系操作
对应于i<10,先使用coml和jle语句,cmpl A,B用B-A更新标志位,再用jle语句检查标志位,实现如果B<=A跳转到.L4,实现比较。

3.3.5 数组/指针/结构操作

在hello.c中存在指针数组char *argv[],已知argv[0]保存程序路径和名称,所以从argv[1]开始保存命令行参数。第一句将数组地址传到rax中,指针占8个字节,所以通过数组地址加8和加16来获取argv[1]和argv[2]。
程序人生-Hello’s P2P_第9张图片

图3-9 main函数编译结果中使用数组

3.3.6 控制转移

在这里插入图片描述

图3-10 main函数编译结果中实现控制转移
cmpl 语句比较后设置条件码,判断 ZF 标志,满足条件则跳转到标签位置。

3.3.7 函数操作

1.main函数
传入参数argc和argv,通过-20(%rbp)和-32(%rbp)保存,被系统启动函数调用,返回值通过eax返回,将eax设置为0,实现return 0。
2.printf函数
参数通过rdi、rsi、rdx传入,rdi是格式串,如果argc==3,则rsi和rdx将是argv[1]和argv[2],否则不传入,通过main调用。
程序人生-Hello’s P2P_第10张图片

图3-11 printf函数的调用
3.exit函数通过edi传入参数1,由main调用。
在这里插入图片描述

图3-12 exit函数的调用
4.sleep函数通过edi传入参数sleepsecs,由main调用。
在这里插入图片描述

图3-13 sleep函数的调用
5.getchar函数由main调用,返回值存在eax中。
程序人生-Hello’s P2P_第11张图片

图3-14 getchar函数的调用

3.4 本章小结

本章介绍了编译的概念和作用,在ubuntu环境下演示了hello.c的编译过程,并与C源文件对比解析了生成的hello.s汇编文件, 知道了C语言的所有操作都是通过汇编指令实现。

第4章 汇编

4.1 汇编的概念与作用

概念:汇编器(as)将汇编文件(hello.s)翻译成机器指令,得到二进制可重定位目标程序,通常文件扩展名为.o(例如hello.o)。
作用:

  1. 根据汇编指令和特定的平台,把汇编指令翻译成机器代码
  2. 合并各个section,合并符号表

4.2 在Ubuntu下汇编的命令

gcc -c hello.s -o hello.o
程序人生-Hello’s P2P_第12张图片

图4-1 生成hello.o

4.3 可重定位目标elf格式

通常可重定位目标文件的elf格式如下:
表4-1 可重定位目标文件的elf格式

可重定位目标文件ELF格式
ELF头
.text(已编译程序的机器代码)
.rodata(只读数据)
.data(已初始化的全局和静态C变量)
.bss(未初始化的全局和静态C变量,初始化为0的全局或静态变量)
.symtab(符号表)
.rel.text(.text节的位置列表)
.rel.data(全局变量的重定位信息)
.debug(调试符号表)
.strtab(字符串表)
.line(C源程序与.text指令之间的映射)
节头部表

通过readelf -a hello.o > hello.o.elf.txt命令将elf所有信息输出到hello.o.elf.txt中。
ELF头描述了生成该文件的系统字节序、程序地址等系统和程序的信息。
程序人生-Hello’s P2P_第13张图片

图4-2 可重定位目标文件hello.o的ELF头
节头部表描述了各个节名称、大小、类型、地址、偏移量等信息。
程序人生-Hello’s P2P_第14张图片

图4-3 可重定位目标文件hello.o的节头部表
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存何处, 也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。 所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。这些用于重定位的符号存在于符号表(.symtab)中,代码的重定位条目放在.rel.text 中,已初始化数据的重定位条目放在.rel.data 中。
程序人生-Hello’s P2P_第15张图片

图4-4 可重定位目标文件hello.o的.symtab信息
程序人生-Hello’s P2P_第16张图片

图4-5 可重定位目标文件hello.o的.rela.text信息

4.4 Hello.o的结果解析

通过objdump -d -r hello.o > hello.o.objdump.txt命令反汇编信息输出到hello.o.objdump.txt中。
内容如下:
程序人生-Hello’s P2P_第17张图片

图4-6 main函数反汇编结果

4.4.1 数据

数据方面与汇编时略有区别,在汇编文件中,数据以10进制出现,在反汇编文件中数据以16进制出现。
在这里插入图片描述

图4-7 编译文件中的数据格式
在这里插入图片描述

图4-8 反汇编文件中的数据格式

4.4.2 赋值

赋值操作,反汇编和汇编文件操作类似,只是省略了表示数据大小的标识符。
在这里插入图片描述

图4-9 编译文件中的赋值操作
在这里插入图片描述

图4-10 反汇编文件中的赋值操作

4.4.3 算术操作

算术操作除了操作数用16进制表示外没有太大区别。
在这里插入图片描述

图4-11 编译文件中的算术操作
在这里插入图片描述

图4-12 反汇编文件中的算术操作

4.4.4 关系操作

关系操作都是用cmp实现,表示数变成16进制。
在这里插入图片描述

图4-13 编译文件中的关系操作
在这里插入图片描述

图4-14 反汇编文件中的关系操作

4.4.5 数组/指针/结构操作

数组操作除了操作符省略了操作数大小标志和数据使用16进制外没有太大不同。
程序人生-Hello’s P2P_第18张图片

图4-15 编译文件中的数据操作
程序人生-Hello’s P2P_第19张图片

图4-16 反汇编文件中的数据操作

4.4.6 控制转移

在汇编文件中,控制操作使用的是标签(.Lx),而在反汇编文件中则是确定了地址,使用的相对地址跳转。
在这里插入图片描述

图4-17 编译文件中的控制转移
在这里插入图片描述

图4-18 反汇编文件中的控制转移

4.4.7 函数操作

函数操作在汇编文件中,使用的是函数名,在反汇编文件中使用地址,但是由于还未链接,所以地址全部为0,并在调用函数的下一行标明要调用的函数,方便链接。
在这里插入图片描述

图4-19 编译文件中的函数操作
在这里插入图片描述

图4-20 反汇编文件中的函数操作
机器语言由操作码和操作数组成
机器语言与汇编语言是一一对应的映射关系
机器语言中的操作数使用的是十六进制格式,而汇编语言中的操作数使用的是十进制
机器语言的跳转对函数的引用是使用与头部相对偏移量值来表示位置的,一些外部函数由于没有链接,地址为0;而汇编代码中则是用标号表示位置.
机器语言中,全局变量的访问采用段名称+%rip的方式.
机器语言的反汇编代码为每条语句都加上了具体的地址

4.5 本章小结

本章介绍了汇编的概念和作用,在ubuntu环境下演示了hello.c的汇编过程,分析了可重定位目标elf格式,并对反汇编Hello.o的结果进行解析,并分析了机器语言和汇编语言的差异和联系。

第5章 链接

5.1 链接的概念与作用

概念:链接器(ld)将二进制可重定位目标文件(hello.o)中一些未定义的变量,函数,与其所在的可重定位目标程序文件(如printf.o)进行和合并,生成可执行文件(hello)。
作用:

  1. 合并各个.o文件的section
  2. 符号地址重定位
  3. 生成可执行文件

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
程序人生-Hello’s P2P_第20张图片

图5-1 生成链接后可执行文件hello

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

通常可执行文件的elf格式如下:
表5-1 可执行文件的ELF格式

可执行目标文件ELF格式
ELF头
程序头表(又称段头表)
.init(用于定义_init函数,用来进行可执行目标文件开始执行时的初始化工作)
.text(已编译程序的机器代码)
.rodata(只读数据)
.data(已初始化的全局和静态C变量)
.bss(未初始化的全局和静态C变量,初始化为0的全局或静态变量)
.symtab(符号表)
.debug(调试符号表)
.strtab(字符串表)
.line(C源程序与.text指令之间的映射)
节头部表

通过readelf -a hello > hello.elf.txt命令将elf所有信息输出到hello.elf.txt中。
ELF头描述了生成该文件的系统字节序、程序地址等系统和程序的信息。
程序人生-Hello’s P2P_第21张图片

图5-2 可执行文件hello的ELF头
节头部表描述了各个节名称、大小、类型、地址、偏移量等信息。

程序人生-Hello’s P2P_第22张图片

图5-3 可执行文件hello的节头部表
程序头部表描述了可执行文件的连续的片被映射到连续的物理地址的相关信息.(包括地址和大小)
程序人生-Hello’s P2P_第23张图片

图5-4可执行文件hello的程序头部表

5.4 hello的虚拟地址空间

在edb中打开hello,点击view→memory regions,即可看到本程序使用的虚拟内存地址。
程序人生-Hello’s P2P_第24张图片

图5-5 edb中hello的虚拟地址空间
由5.3知,.interp段起始位置为0x4002e0,.note.gnu.propert段起始位置为0x400300,.note.ABI-tag段起始位置为0x400320,.hash段起始位置为0x400340,.gnu.hash段起始位置为0x400378,.dynsym段起始位置为0x400398,.dynstr段起始位置为0x400458,.gun.version段起始位置为0x4004b0,.gnu.version_r段起始位置为0x4004c0,.rela.dyn段起始位置为0x4004e0,.rela.plt段起始位置为0x400510,在data dump如下
程序人生-Hello’s P2P_第25张图片

图5-6 edb中hello的400000-401000地址空间
由5.3知,.init段起始位置为0x401000,.plt段起始位置为0x401020,.plt.sec段起始位置为0x401080,.text段起始位置为0x4010d0,.fini段起始位置为0x401208,在data dump如下:
程序人生-Hello’s P2P_第26张图片

图5-7 edb中hello的401000-402000地址空间
由5.3知,.rodata段起始位置为0x402000,在data dump中成功找到了printf函数传入的格式字符串,验证了前面的结论,.eh_frame段起始位置为0x402030,在data dump如下:
程序人生-Hello’s P2P_第27张图片

图5-8 edb中hello的402000-403000地址空间
由5.3知,.dynamic段起始位置为0x403e50,.got段起始位置为0x403ff0,在data dump如下:
程序人生-Hello’s P2P_第28张图片

图5-9 edb中hello的403000-404000地址空间
由5.3知,.got.plt段起始位置为0x404000,.data段起始位置为0x404040,在data dump如下:
程序人生-Hello’s P2P_第29张图片

图5-10 edb中hello的404000-405000地址空间

5.5 链接的重定位过程分析

通过objdump -d -r hello > hello.objdump.txt命令将反汇编结果输出到hello.objdump.txt中
下面分析hello与hello.o的不同
程序人生-Hello’s P2P_第30张图片

图5-11 hello.o反汇编中main函数

程序人生-Hello’s P2P_第31张图片

图5-12 hello反汇编中main函数
上面分别给出了hello.o的反汇编和hello的反汇编,通过对比可以发现:

  1. main函数的起始地址发生改变,由原来的0x0变成了0x401105,从而main中所有代码地址发生改变(红色部分)。
  2. 只读数据段中的printf格式化字符串发生改变,并重新链接到代码中(绿色部分)
  3. 外部函数已经链接到代码中,将原来的0改成了正确的位置(黑色部分)。
    结合hello.o的重定位项目,分析hello中对其怎么重定位的。
    链接主要分为以下几个步骤
  4. 符号解析,符号有强符号和弱符号,强符号指函数和初始化全局变量,弱符号指未初始化的全局变量。关于强符号和弱符号还有以下几个规则:
    a) 不允许多个同名的强符号
    b) 若有一个强符号和多个弱符号同名,则选择强符号
    c) 如果有多个弱符号,选择任意一个
  5. 重定位,将所有可重定位目标文件的代码段,数据段等合并在一块,并根据可重定位条目,进行重定位。有两种重定位方法,分别是PC相对寻址和绝对地址,当使用PC相对寻址时,公式如下,refaddr = ADDR(s) + r.offset; refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);当使用绝对地址时,公式如下refptr = (unsigned)(ADDR(r.symbol) + r.addend);
    下面对hello.o的重定位项目进行分析:
    用readelf -r hello.o指令找到重定位条目
    程序人生-Hello’s P2P_第32张图片

图5-13 hello的重定位条目
在这里插入图片描述

图5-14 hello中main地址
在这里插入图片描述

图5-15 hello中puts地址
对于puts符号
r.offset = 0x21
r.symbol = puts
r.type = R_X86_64_PLT32(相对寻址)
r.addend = -4
ADDR(s)=0x401105
ADDR(r.symbol) = 0x401080
refaddr = ADDR(s) + r.offset=0x401105+0x21=0x401126
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)
=0x401080+(-0x4)-0x401126=ffffff56
所以
在这里插入图片描述

图5-16 hello中调用puts函数
其他符号重定位类推,不再赘述。

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
0x00007f090ee42df0
0x00007fae59f02c10
0x00007f090ee52af0
0x00007f5737f8bf30
0x00007f57380870e0
0x00007f5737f8ba20
0x00007f090ec5dfc0
0x00007f090ec80f60
0x000055fe0db0b170
0x00007f5737faae00
0x000055fe0db0b149
0x0000558b2a416050< hellolinux! .plt.got+0x10>
0x00007fca3b422750
0x00007fca3b3d9bc0
0x00007fc986f8e930
0x00007fc987160d50
0x00007fc986fdaee0

5.7 Hello的动态链接分析

分析hello的动态链接过程,需关注所加载的符号中的_GLOBAL_OFFSET_TABLE这一项,通过readelf找到.got.plt起始地址0x404000
在这里插入图片描述
图5-17 hello中.got和.got.plt地址 程序人生-Hello’s P2P_第33张图片
图5-18 调用dl_init之前.got内容 程序人生-Hello’s P2P_第34张图片

图5-19 调用dl_init函数之前的.got.plt内容
程序人生-Hello’s P2P_第35张图片

图5-20 调用dl_init函数之后.got内容
程序人生-Hello’s P2P_第36张图片

图5-21 调用dl_init函数之后.got.plt内容

5.8 本章小结

本章介绍了链接的概念和作用,在ubuntu环境下演示了hello.o的链接过程,分析了可执行文件的elf格式,演示了虚拟内存空间和重定位过程,分析了hello的执行流程和动态链接。

第6章 hello进程管理

6.1 进程的概念与作用

进程的概念:一个执行中程序的实例。
进程的作用:
进程提供给应用程序的两个关键抽象:一个是独立的逻辑控制流(程序独占处理器);另一个是私有的地址空间(程序独占内存)。

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

Shell指“为使用者提供操作界面”的软件(command interpreter,命令解析器),接受用户输入的命令并把它送到内核执行。
作用:
1.命令解释器
接受用户输入的命令并把它送到内核执行。
2.内置命令
内置命令(builtin command)是 Shell 自己实现的命令, 有的是因为某些功能仅能通过内置化实现, 有的则是追求性能而内置化实现。
3.作业控制
作业控制(job control)是指, 有选择地停止(挂起)某些进程的执行, 以及继续(恢复)某些停止进程执行的能力。
4.重定向
在 UNIX 系统中, 文件对象是以文件描述符(file descriptor)来引用的, 操作系统会默认打开进程的 3 个文件描述符: 文件描述符0 - 标准输入; 文件描述符1 - 标准输出; 文件描述符2 - 标准出错 (后文将分别以stdin, stdout, stderr表示). 命令在执行之前, 它的文件描述符可以被复制, 打开, 关闭, 指向不同的文件等, 文件重定向便利了文件 IO 的操作.
5.管道
管道线(pipeline)是由一个或多个由|或|&符号分割的命令序列, 其格式如下.command [ [| 或 |&] command2 … ]。很多命令从stdin读入, 经过处理, 写到stdout, 这样的命令都可以通过管道连接起来以实现复杂功能的处理, 非常简洁实用。
处理流程:
shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。

6.3 Hello的fork进程创建过程

Shell中输入命令.\hello后,父进程(shell)判断不是内部指令,通过 fork 函数(pid_t fork( void);)创建子进程。子进程得到一份与父进程(shell)相同且独立的副本(包括数据段、代码、共享库、堆和用户栈)。父进程打开的文件,子进程也可读写。二者之间最大的区别是有不同的PID。 Fork 函数只会被调用一次,但会返回两次,在父进程中,fork 返回子进程的 PID,在子进程中,fork 返回 0。

6.4 Hello的execve过程

int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
filename是一个二进制的可执行文件,或者是一个脚本以#!格式开头的解释器参数参数。如果是后者,这个解释器必须是一个可执行的有效的路径名,但是不是脚本本身,它将调用解释器作为文件名。
argv是要调用的程序执行的参数序列,也就是我们要调用的程序需要传入的参数。
envp 同样也是参数序列,一般来说是一种键值对的形式 key=value. 作为我们是新程序的环境[2]。
当shell为我们的hello程序fork一个子进程后,在子进程中调用execve函数,分别传入hello文件路径,命令行参数和环境变量。当找不到hello的时候,返回到子进程中,否则不会返回。在 execve 加载了 Hello 之后,它调用启动代码,启动代码删除已存在的用户区域,为 Hello 的代码、数据、.bss 和栈区域创建新的区域结构,并将控制传递给新程序的主函数。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
Hello被执行并不是独占CPU,而是被分配一个个时间片,当hello程序被调度时,寄存器等恢复hello上一次停止时的上下文信息,控制权交给hello程序,处于用户态运行,当发生异常时,会进行上下文切换,控制权交给异常处理程序,此时处于内核模式,异常处理程序可能导致三种结果,第一种是重复执行导致异常的那个指令,第二种是执行异常指令的下一条指令,第三种是程序结束。当hello这个时间片结束时,系统将它的上下文信息保存到内存中,以备下一次调度。

6.6 hello的异常与信号处理

当内核打算调度hello进程时,会检查hello是否有要处理的信号,下面列举hello执行过程中可能会出现的异常:
(1) 不停乱按,没有影响程序的运行,但程序运行后,除了第一行的11,其他的都被当成命令,试图在shell中运行,这是因为C语言源文件中有一个getchar()语句,这个语句把11和回车读进了自己的缓冲区,其他的留给了shell。
程序人生-Hello’s P2P_第37张图片

图6-1 运行时不停乱按
(2) Ctrl-Z:按下Ctrl-Z后,shell向hello程序发送SIGSTP信号,默认处理方式是挂起程序。
程序人生-Hello’s P2P_第38张图片

图6-2 运行时按下Ctrl-Z
a) 运行ps时
程序人生-Hello’s P2P_第39张图片

图6-3 运行ps
b) 运行jobs时
在这里插入图片描述

图6-4 运行jobs
c) 运行pstree时
程序人生-Hello’s P2P_第40张图片

图6-5 运行pstree(1)
程序人生-Hello’s P2P_第41张图片

图6-6 运行pstree(2)
d) 运行fg时
程序人生-Hello’s P2P_第42张图片

图6-7 运行fg
程序继续运行
e) 运行kill命令时
程序人生-Hello’s P2P_第43张图片

图6-8 运行kill
只是kill不会立即杀死程序,只有程序调回前台才能自动终止。
如果使用kill -9 PID则会立即杀死程序。
程序人生-Hello’s P2P_第44张图片

图6-9 运行kill -9
(3) Ctrl-C:按下Ctrl-C后,shell向hello程序发送SIGINT信号,默认处理方式是终止程序。
程序人生-Hello’s P2P_第45张图片

图6-10 运行运行时按下Ctrl-C

6.7本章小结

本章介绍了进程的概念与作用,简述了shell的作用和处理流程,分析了hello在shell中的执行过程(从shlle调用fork函数到子进程调用execve运行hello程序),接着分析了hello的进程执行,最后分析了hello的异常与信号处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于当前进程数据段的地址,不和绝对物理地址相干(实模式除外),在hello程序中体现为,相对于段的偏移地址。
线性地址(Linear Address) 是逻辑地址到物理地址变换之间的中间层。段的基地址和偏移地址构成了线性地址,如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址(Virtual Address):CPU产生的需要访问主存的地址,使用虚拟地址可以抽象出一个比内存大得多的内存空间,实际使用时是使用一部分载入一部分,当内存不够用时,按照一些规则替换出一部分内存,继续运行程序。总而言之就是拆东墙补西墙的方法,在这里的hello运行就是使用的虚拟地址。
物理地址(Physical Address) 是指出现CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。

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

段式管理通过段寄存器(如CS、DS、ES或SS等)与偏移地址(EA)的组合来完成逻辑地址到线性地址的变换。在实模式下通过段寄存器和偏移地址计算真实物理地址(例如CS * 16 + EA)。在保护模式下,以段描述符作为索引,到GDT/LDT表中查表获得段地址,将段地址加上偏移地址,得到线性地址,完成转换。
在实地址模式下:
▪ CS用于存放16位的代码段的段地址/基地址
▪ DS用于存放16位的数据段的段地址/基地址
▪ SS用于存放16位的堆栈段的段地址/基地址
▪ ES用于存放16位的附加段的段地址/基地址
在保护模式下:
▪ CS 代码段描述符的索引值
▪ DS 数据段描述符的索引值
▪ SS 堆栈段描述符的索引值
全局描述符表GDT(Global Descriptor Table)
整个系统只有一个,包含:
▪ 操作系统使用的代码段、数据段、堆栈段的描述符
▪ 各任务/程序的LDT(局部描述符表)段
局部描述符表LDT(Local Descriptor Table)
每个任务/程序有一个独立的LDT(intel 80386),包含:
▪ 对应任务/程序私有的代码段、数据段、堆栈段的描述符
▪ 对应任务/程序使用的门描述符:任务门、调用门等。
48位的全局描述符表寄存器GDTR
▪ 指向GDT(Global Descriptor Table),即GDT在内存中的具体位置
16位的局部描述符表寄存器LDTR
▪ 指向LDT段在GDT中的位置(索引)
段寄存器为全局描述符表项的寻址:1-2-3
段寄存器为局部描述符表项的寻址:1’- 2’- 3’-4’-5’
程序人生-Hello’s P2P_第46张图片

图7-1 保护模式下寻址方式

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

虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。虚拟内存和主存以页(通常为4K大小)为单位进行传输,虚拟内存通过页表的方式转换到物理地址。当得到一个虚拟地址后,通过规定可以得到VPN和VPO,通过VPN到页表中查询PPN,PPO与VPO相同,这样就得到物理地址PPN:PPO。
程序人生-Hello’s P2P_第47张图片

图7-2 虚拟内存与物理内存映射

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

为了提升查找页表的速度,减少不必要的开销,引入TLB(翻译后被缓冲器)。在得到一个虚拟地址后,会将其拆分成TLBT、TLBI和VPO,用TLBI确定TLB中的组,用TLBT来匹配组中的各路,当匹配成功且那个路有效时则称TLB命中,得到PTE,PTE和VPO可以合成物理地址,到高速缓存中取数据。
程序人生-Hello’s P2P_第48张图片

图7-3 用TLB获取PTE
由于虚拟内存往往较大,如果将所有页表存到主存中不现实(例如:4KB (212) 页面, 48位地址空间, 8字节 PTE,将需要一个大小为 512 GB 的页表),所以采用分级页表的方式,将一级页表常驻内存,这样只需最少4K的内存。
四级页表从VA到PA的变换与一级页表相似,通过页表基址寄存器找到一级页表的地址,根据VPN1找到对应二级页表地址,在二级页表中根据VPN2找到对应三级页表的索引,如此重复直到在四级页表中获取PPN,利用PPN与VPO合成物理地址。
程序人生-Hello’s P2P_第49张图片

图7-4 四级页表寻址

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

程序人生-Hello’s P2P_第50张图片

图7-5 处理器结构
程序人生-Hello’s P2P_第51张图片

图7-6 CPU使用TLB和L1cache过程
在获取物理地址后,将其拆分为CT(标志位)、CI(组索引)、CO(组偏移),首先根据CI选择对应的组,之后根据CT选择路,若存在某一块,标志位与CT相等且有效位为有效,则称命中,取出CO位置相应字节的数据,否则未命中,需从下一级高速缓存(L2、L3、主存……)中取数据,与之前类似,不再赘述。

7.6 hello进程fork时的内存映射

当fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID 。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

当用execve函数加载hello程序时,需要经历以下几个步骤:
•删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
• 映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
• 映射共享区域。hello程序与共享对象(或目标)链接,比如标准C 库libc.so。这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
• 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux 将根据需要换入代码和数据页面。
程序人生-Hello’s P2P_第52张图片

图7-7 程序内存映像

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

在虚拟内存中,请求物理页时缓存未命中的现象被称为缺页。
程序人生-Hello’s P2P_第53张图片

图7-8 虚拟内存寻址物理内存
例如某个指令需要访问VP3,则对VP3中的字的引用不命中,从而触发缺页——VM缺页,进行如下操作:

  1. 缺页异常处理程序选择一个牺牲页,用VP3替换(如果还有空闲的页则直接加载VP3)
  2. 重新执行触发缺页的指令,此时会页面命中

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) (见图9-33) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk( 读做"break"), 它指向堆的顶部。分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器(explicit allocator) , 要求应用显式地释放任何已分配的块。例如, C 标准库提供一种叫做malloc 程序包的显式分配器。C 程序通过调用malloc 函数来分配一个块,并通过调用free 函数来释放一个块。C++ 中的new 和delete 操作符与C 中的malloc 和free 相当。
隐式分配器(implicit allocator), 另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection) 。例如,诸如Lisp 、ML 以及Java 之类的高级语言就依赖垃圾收集来释放已分配的块。
Printf会调用malloc,请简述动态内存管理的基本方法与策略。

7.10本章小结

本章介绍了hello的存储器地址空间(逻辑地址、线性地址、虚拟地址、物理地址),讲解了从逻辑地址到线性地址的段式管理,从线性地址到物理地址的页式管理,并介绍了TLB和四级页表下的VA到PA的变换以及得到PA后如何从Cache中获取数据,最后于hello结合,介绍hello从fork到execve进行的操作,最后介绍了缺页故障与缺页中断处理和动态存储分配管理。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件
设备管理:unix io接口
所有的 I/O 设备都被抽象为文件(例如网络、磁盘和终端),而所有的输入和输出都被抽象为相应文件的读和写来完成,这种将设备映射为文件的方式,允许 Linux 内核引出一个简单的,低级的应用接口,成为 Unix I/O,这样做可以使所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

Unix IO接口允许程序统一且一致地使用IO设备,下面是一些基础的Unix I/O操作,都是系统调用(syscall):
int open(const charpath, int oflags, …/ mode_t mode /);
int openat(int fd, const char
path, int oflags, …/* mode_t mode /);
open:一个应用程序通过此方法来要求内核打开相应的文件,内核返回一个非负整数,叫做文件描述符,后续所有的操作都基于这个文件描述符。
path是要打开或创建的文件的名字,oflags是用来说明此函数的多个选项: 
  O_RDONLY  只读打开;
  O_WRONLY  只写打开;
  O_RDWR  读、写打开;
  O_EXEC  只执行打开;
  O_SEARCH  只搜索打开(应用于目录);
以上5个必选且只能选一个。
  O_APPEND  每次写时都追加到文件的尾端;
  O_CLOEXEC  把FD_CLOEXEC常量设置为文件描述符标志;
  O_CREAT  若此文件不存在则创建它。使用此选项时,须指定mode为文件的访问权限位;
  O_DIRECTORY  如果path不是目录则出错;
  O_EXCL  如果同时指定了O_CREAT,而文件已经存在,则出错。用此选项可以测试一个文件是否存在,不存在则创建此文件,这使得测试和创建两者为一个原子操作;
  O_NOCTTY  如果path引用的是终端设备,则不将该设备分配作为此进程的控制终端;
  O_NOFOLLOW  如果path是一个符号链接,则出错;
  O_NONBLOCK  如果path引用的是一个FIFO、一个块特殊文件或一个字符特殊文件,则此选项为文件的本次打开操作和后续的I/O操作设置非阻塞方式;
  O_SYNC  使每次write等待物理I/O操作完成,包括由该write操作引起的文件属性更新所需的I/O;
  O_TRUNC  如果此文件存在,而且为只写或读-写成功打开,则将其长度截断为0;
  O_TTY_INIT  如果打开一个还未打开的终端设备,设置非标准termios参数值,使其符合single Unix specification.
  O_DSYNC  使每次write要等待物理I/O操作完成,但是如果该写操作并不影响读取刚写入的数据,则不需要等待文件属性更新;
  O_RSYNC  使每一个以文件描述符作为参数进行的read操作等待,直至所有对文件同一部分挂起的写操作都完成。
int close(int fd);
close: 成功的时候返回0,在异常的时候返回-1。关闭文件会通知内核已经完成访问该文件,不能重复关闭同一个文件,务必检查返回码,当一个进程终止时,内核自动关闭她所有的打开文件。
ssize_t read(int fd, void
buf, size_t nbytes);
read:成功时返回读到的字节数,如已到达文件的尾端则返回0。
有多种情况可使实际读到的字节数少于要求读到的字节数:

  1. 读普通文件时,在读到要求字节数之前就已到达文件尾端。
  2. 当从终端设备读时,通常一次最多读一行。(可改变)
  3. 当从网络读时,网络中的缓冲机制可能造成返回值小于所要求读的字节数。
  4. 当从管道或FIFO读时,如若管道包含的字节数少于所需的数量,那么read将只返回实际可用的字节数。
  5. 当从某些面向记录的设备(如磁带)读时,一次最多返回一个记录。
  6. 当一个信号造成中断,而已经读了部分数据量时。
    ssize_t write(int fd, const void*buf, size_t nbytes);
    write :写入文件会将字节从内存复制到当前文件位置,然后更新当前文件位置,返回从buf写入文件fd的字节数,小于0表示失败,其返回值通常与参数nbytes的值相同,否则表示出错。write出错的一个常见原因是磁盘以写满,或者超过了一个给定进程的文件长度限制。

8.3 printf的实现分析

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;
}
va_list的定义:typedef char va_list 这说明它是一个字符指针。
(char
)(&fmt) + 4) 表示的是…中的第一个参数。
C语言中,参数压栈的方向是从右往左。在调用printf函数的前,先是最右边的参数入栈。
fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
vsprintf返回要打印出来的字符串的长度,并将要打印的字符串存到buf中。
write函数将buf中长度i的字符打印出来,这个过程十分复杂,简而言之,就是字符显示驱动子程序将要打印的字符从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)做转换,然后显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
最后返回打印字符串的长度。

8.4 getchar的实现分析

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回,具体做法是如果缓冲区不为零,则直接读取缓冲区第一个字符,否则调用read函数。

8.5本章小结

本章介绍了hello的IO管理,介绍了Linux的IO设备管理方法,简述了Unix IO接口及其基本函数,并分析了printf和getchar函数的实现。

结论

最后,hello一生经历了如下的几个过程:
(1) 编写源程序hello.c,小小的身材有着大大的潜力
(2) 新出生的它还很弱小,决定用头文件中的内容丰富自己,hello.c预处理生成hello.i
(3) 为了和机器交流,它学了一门外语—汇编语言,hello.i编译生成hello.s,
(4) 为了进一步被机器认可,又将自己转换成了01组成的可重定位目标文件,hello.s汇编生成可重定位目标文件hello.o
(5) Hello.o最终与其他可重定位目标文件朋友链接,生成了hello可执行程序,成为可执行程序王国的公民
(6) 有人发现了hello,需要它的帮助。在linux中终端shell中运行hello,shell调用fork函数创建一个子进程,随后子进程通过execve加载运行hello
(7) Hello在运行时要与动态库的朋友(内存中的常客)打招呼,进行动态链接
(8) 在运行过程,hello好像是这块资源主人(它自己认为的,然而CPU并不是这么认为,他只是在特定的时间片调用它罢了)
(9) 在机器的世界中,国王CPU在任用(执行)hello的时候,并不能直接读取它的臣民内存,他需要户部尚书MMU(内存管理丹单元)的帮助,户部尚书将国王经常点名的臣民的名册(TLB)留在身边,来提高工作效率,博得国王的欢心,当国王点到一些生僻的人,户部尚书MMU只好派人去查(TLB未命中),若那个人的出国了(缺页,不在内存中),则需要将他接回国内(缺的页载入到内存),国王CPU重新执行他要做的指令。
(10) 在朝堂(L1,L2,L3高速缓存)上,国王(CPU)留下了那些他经常需要的人(高速缓存中的数据)。当需要新人时(缓存不命中),会替换上一次需要时间最早的那个人(LRU替换策略)。
(11) hello在职(运行)期间中还会有其他的朋友拜访(信号:SIGSTP等),他需要先招呼这些朋友,再干自己的事(处理信号)。不过有些不速之客,hello也不愿理睬(忽略信号)
(12) 最终收到SIGINT信号,宣布hello这一生结束了,虽然可能短暂,但对于hello也是一种美好的回忆吧。最终它停下来,微笑着被父进程shell回收了,想象着下一次的再出现的场景。
感悟:计算机的所有操作都是明确的,执行的程序都是用CPU执行一个个汇编指令完成的,通过虚拟内存计算机有看上去更大的内存,通过IO,我们能显式的看到执行结果和输入我们想要输入的内容,通过高速缓存解决不同器件的速度差异,通过进程给每一个进程制造两个假象——独占控制流和内存。
创新理念:通过这门课,学习了调解不同速度的器件之间速度差异的方法,即通过缓存的方式,让我想到了内存和机械硬盘之间有巨大的速度差异,那么我认为,如果用固态或者其他速度介于内存和机械硬盘之间其他存储器件作为机械硬盘和内存之间的高速缓存,应该可以提高读写盘的效率。

附件

hello.c :hello 源代码
hello.i:预处理生成的文本文件
hello.s:编译后得到的汇编语言文件
hello.o:汇编后得到的可重定位目标文件
hello.o.elf.txt:可重定位文件的elf信息
hello.o. objdump.txt:可重定位文件的反汇编信息
hello:链接生成的可执行目标文件
hello.elf.txt:可执行文件的elf信息
hello.objdump.txt:可执行文件的反汇编信息

参考文献

[1]. huoyahuoya.gcc——预处理(预编译),编译,汇编,链接. https://blog.csdn.net/shiyongraow/article/details/81454995
[2]. C语言 execve()函数使用方法https://blog.csdn.net/chichoxian/article/details/53486131
[3]. Randal E.Bryant / David O’Hallaron. Computer Systems: A Programmer’s Perspective (3rd Edition)
[4]. 东方之既白.Shell 简介: Bash 的功能与解释过程(二) Bash功能.
https://mp.weixin.qq.com/s?__biz=MzA3NDQ1ODM5Mw==&mid=2247483966&idx=2&sn=eb53dbd173b25a98f771ee368f28b089&chksm=9f7e3648a809bf5ef88e051ba09710b13ad2d6bdcaede36cd6c9b8319e270016c7ed01c22401&token=2041459210&lang=zh_CN#rd
[5]. 百度百科.逻辑地址.
https://baike.baidu.com/item/%E9%80%BB%E8%BE%91%E5%9C%B0%E5%9D%80/3283849?fr=aladdin
[6]. Pianistx.printf 函数实现的深入剖析.
https://www.cnblogs.com/pianist/p/3315801.html
[7]. 吉吉boy. Unix3-文件I/O接口. https://www.cnblogs.com/cjj-ggboy/p/12261439.html

你可能感兴趣的:(c语言)