程序人生-Hello’s P2P

摘 要
Hello.c为一个简单的C语言程序,其在计算机中变为可执行文件经历了预处理、编译、汇编、链接过程,在执行中过程中,与进程管理、内存管理、IO管理紧密相关。本文以程序员的视角,使用Archlinux操作系统,介绍了hello程序从C语言源代码于是在操作系统中执行的全过程,对计算机系统有了一个简要的整体性的介绍。

关键词:计算机系统;Linux;程序;

文章目录

  • 程序人生-Hello’s P2P
    • 第1章 概述
      • 1.1 Hello简介
      • 1.2 环境与工具
      • 1.3 中间结果
      • 1.4 本章小结
    • 第2章 预处理
      • 2.1 预处理的概念与作用
        • 2.1.1文件包含
        • 2.1.2宏定义
        • 2.1.3条件编译
        • 2.1.4特殊控制
      • 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.3.1 ELF头
        • 4.3.2节头表
        • 4.3.3 重定位节
        • 4.3.4 符号表
        • 4.4 Hello.o的结果解析
      • 4.5 本章小结
    • 第5章 链接
      • 5.1 链接的概念与作用
      • 5.2 在Ubuntu下链接的命令
      • 5.3 可执行目标文件hello的格式
        • 5.3.1 ELF头
        • 5.3.2节头表
        • 5.3.3 程序头
      • 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.5.1上下文
        • 6.5.2进程时间片
        • 6.5.3用户态与核心态转换
      • 6.6 hello的异常与信号处理
        • 6.6.1正常执行
        • 6.6.2 Ctrl – C
        • 6.6.3 Ctrl – Z
      • 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简介

Hello的一生从我们编写好的hello.c源代码程序(Program)开始,当我们在IDE中按下编译并运行,Hello进行了一系列的处理,最终变成为一个进程(Process)。这个过程称为From Program to Process。

hello.c源程序首先经过预处理器(cpp)将其转换为hello.i,再经过编译器(ccl)将其转换为汇编程序hello.s,再经过汇编器(as)将其转换为hello.o,再经过链接器(ld)与其他可重定位目标程序链接成为可执行目标程序hello。然后在shell中执行hello程序,OS将为其创建一个进程(Process)。

在运行程序后,经过了一系列过程,最后我们在shell中看到了程序的输出。在这一系列的复杂过程中,对于程序员来是不可见的,但却程序员却可以不了解这些过程,就可以写出程序(虽然不能写出更优秀的程序)。所以我们称其为From Zero to Zero。

在这一系列过程中,初始时shell程序等待指令的输入,我们在shell中输入字符串./hello,敲下回车后shell执行一系列指令。这期间,会从磁盘加载执行文件到内存,会由操作系统来创建进程、管理文件、处理异常、处理硬件之间的通信等等。

1.2 环境与工具

硬件:
HUAWEI Laptop 14
CPU: Intel i5-10210U
L1data cache:128kB, L1instruct cache:128kB,L2:1MB,L3:6MB
Memory:15738MB,Swap:16383MB

软件:
Archlinux,VScode for Linux,edb

1.3 中间结果

序号文件名称描述
1hello.c源程序
2hello.i预处理后程序
3hello.s编译后汇编程序
4hello.o汇编后elf格式文件
5elf.txthello.o的elf文件信息
6hello.asmhello.o的反汇编代码
7hello链接后生成的可执行文件
8elf2.txthello的elf文件信息

1.4 本章小结

本章简要介绍了实验的基本内容——Hello的一生。然后介绍了实验所需要的软件环境,以及生成的中间结果。

第2章 预处理

2.1 预处理的概念与作用

预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。
预处理主要包括以下功能:

  • 1、文件包含:将源文件中以“include”格式包含的文件复制到编译的源文件中。
  • 2、宏定义:包括定义宏#define和宏删除#undef。
  • 3、条件编译:根据“#if”后面的条件决定需要编译的代码。
  • 4、特殊控制:执行一些有特殊作用的预处理指令,如 #error、#pragma。

下面分别对其进行详细介绍。

2.1.1文件包含

当一个C语言程序由多个文件模块组成时,主模块中一般包含main函数和一些当前程序专用的函数。程序从main函数开始执行,在执行过程中,可调用当前文件中的函数,也可调用其他文件模块中的函数。如果在模块中要调用其他文件模块中的函数,首先必须在主模块中声明该函数原型。一般都是采用文件包含的方法,包含其他文件模块的头文件。文件包含中指定的文件名即可以用引号括起来,也可以用尖括号括起来,格式如下是#include或#include“文件名”如果使用尖括号括起文件名,则编译程序将到C语言开发环境中设置好的 include文件中去找指定的文件。因为C语言的标准头文件都存放在include文件夹中,所以一般对标准头文件采用尖括号;对编程自己编写的文件,则使用双引号。如果自己编写的文件不是存放在当前工作文件夹,可以在#include命令后面加在路径。

#include命令的作用是把指定的文件模块内容插入到#include所在的位置,当程序编译链接时,系统会把所有#include指定的文件链接生成可执行代码。文件包含必须以#开头,表示这是编译预处理命令,行尾不能用分号结束。#include所包含的文件,其扩展名可以是“.c”,表示包含普通C语言源程序。也可以是 “.h”,表示C语言程序的头文件。C语言系统中大量的定义与声明是以头文件形式提供的。通过#define包含进来的文件模块中还可以再包含其他文件,这种用法称为嵌套包含。嵌套的层数与具体C语言系统有关,但是一般可以嵌套8层以上。

2.1.2宏定义

无参数的宏定义格式:#define 宏名 字符串。其中的字符串:可以是常数,表达式,格式串等。

#define命令定义宏时,还可以为宏设置参数。与函数中的参数类似,在宏定于中的参数为形式参数,在宏调用中的参数称为实际参数。对带参数的宏,在调用中,不仅要宏展开,还要用实参去代换形参。

带参宏定义的一般形式为:#define 宏名(形参表) 字符串。例如:#define ABS(x) (x) < 0 ? -(x) : (x)。

带参的宏和带参的函数相似,但其本质是不同的。使用带参宏时,在预处理时将程序源代码替换到相应的位置,编译时得到完整的目标代码,而不进行函数调用,因此程序执行效率要高些。而函数调用只需要编译一次函数,代码量较少,一般情况下,对于简单的功能,可使用宏替换的形式来使用。

2.1.3条件编译

预处理器还提供了条件编译功能。在预处理时,按照不同的条件去编译程序的不同部分,从而得到不同的目标代码。使用条件编译,可方便地处理程序的调试版本和正式版本,也可使用条件编译使程序的移植更方便。

2.1.4特殊控制

#error:使预处理器输出指定的错误信息,通常用于调试程序。
#pragma:是功能比较丰富且灵活的指令,可以有不同的参数选择,从而完成相应的特 定功能操作。调用格式为:#pragma 参数。

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

在hello.i中,hello.c的源代码在文件最后的7033行到7048行之间,前面都是我们include的三个头文件stdio.h, unistd.h , stdlib.h 部分。我们在hello.c中的注释在hello.i中并没有进行保留。

在前面的部分中,不只有我们直接include的三个头文件,还有许多其他的头文件,说明在这三个头文件中也include了许多其他的头文件。

include的头文件会在hello.i中看到其在本地计算机中的绝对路径。

2.4 本章小结

本章介绍了预处理程序的作用,预处理程序作为程序开始处理的第一步,主要进行了文件包含、宏定义、选择编译、特殊控制。在预处理后生成hello.i文件以便进入下一步的编译。

我们对hello.c源文件通过Linux系统的gcc生成了hello.i文件。

第3章 编译

3.1 编译的概念与作用

编译指的是利用编译程序从源语言编写的源程序产生汇编语言代码的过程。

编译系统,除了基本功能之外,还应具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人-机联系等重要功能。

语法检查指的是检查源程序是否合乎语法。如果不符合语法,编译程序要指出语法错误的部位、性质和有关信息。调试措施指编译器需要提供调试程序的工具。为此,要求编译程序在编译出的目标程序中安置一些输出指令,以便在目标程序运行时能输出程序动态执行情况的信息,如变量值的更改、程序执行时所经历的线路等。修改手段指的是为用户提供简便的修改源程序的手段。编译程序通常要提供批量修改手段和现场修改手段。覆盖处理主要是为处理程序长、数据量大的大型问题程序而设置的。基本思想是让一些程序段和数据公用某些存储区,其中只存放当前要用的程序或数据;其余暂时不用的程序和数据,先存放在磁盘等辅助存储器中,待需要时动态地调入。目标程序优化指提高目标程序的质量,即占用的存储空间少,程序的运行时间短。依据优化目标的不同,编译程序可选择实现表达式优化、循环优化或程序全局优化。目标程序优化有的在源程序级上进行,有的在目标程序级上进行。不同语言合用是为了让用户利用多种程序设计语言编写应用程序或套用已有的不同语言书写的程序模块。最为常见的是高级语言和汇编语言的合用。这不但可以弥补高级语言难于表达某些非数值加工操作或直接控制、访问外围设备和硬件寄存器之不足,而且还有利于用汇编语言编写核心部分程序,以提高运行效率。人-机联系是为了确定编译程序实现方案时达到精心设计的功能。目的是便于用户在编译和运行阶段及时了解内部工作情况,有效地监督、控制系统的运行。

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1 数据

(1)常量
hello.c中出现的常数,在hello.s中以立即数的形式表示。示例如下:

对于字符串常量,是出现在.LC0和.LC1中

(2)变量(全局/局部/静态)
程序中未出现全局变量。
程序中有i, argc, *argv[]中三个局部变量。分析程序后可以发现,他们都存储在栈中,且位置分别为-4(%rbp), -20(%rbp), -32(%rbp)。



程序中未出现静态变量。

3.3.2 赋值

程序仅在句对变量i进行了赋值。其汇编代码为,可见汇编代码用movl指令对变量i进行了直接赋值。同时在for循环中的i++中,每次的循环都对i进行自述去运算的同时,对i进行了重新赋值,其汇编代码为这一语句同时完成了计算与赋值的功能。

3.3.3算术操作

对于循环变量i的加法操作采用的是addl指令,汇编代码为

3.3.4 控制转移:

首先程序中存在一个判断语句:

在汇编代码中为:

说明这个argc != 4 的判断,其实现方法为:将变量argc与立即数4进行比较,这一步会改变标志位寄存器的值,在下一条语句中,采用有条件跳转指令je(相等时跳转),这样就实现了判断。
在程序中还存在一个for循环:

在汇编代码中为:

首先在31行为变量i赋值1,然后无条件跳转到.L3部分,在.L3中首先判断i与7的大小关系,如果i<7则利用jle指令跳转到.L4中,循环体都在.L4中。否则则循环结束,继续执行循环外下面的代码。

3.3.5 数组/指针/结构操作

在程序中涉及到了对数组argv[]的操作:

在汇编代码中,argv[1], argv[2], argv[3]分别为:

所以可以看到,数组的首地址存放在了栈里,需要引用数组时通过栈指针寄存器%rbp来找到数组。

3.3.6 关系操作

在程序中有一次是否相等的判断:

在上面部分已经解释过,其是用有条件跳转实现的:

3.3.7 函数操作

首先在程序中有main函数的调用,在汇编代码中:

有与main函数有关的常量的定义,还有mian函数的入口。这些措施保证了函数可以在main()函数开始正常运行。
然后,在程序中有关于printf(), exit(), sleep(), atoi(), getchar()函数的调用。在汇编代码中为:




说明他们都由内置的库函数来实现。
在调用时,X86 系统中函数参数储存的规则为第 1~6 个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9 这六个寄存器中,其余的参数保存在栈中的某些位置。汇编代码中确实如此,以下面两个函数为例:

3.4 本章小结

本章主要介绍了编译的过程与功能,其中详细分析了hello.i编译后产生的hello.s汇编代码文件,对程序中的各部分内容在汇编语言中的实现进行了分析。编译器的功能概括说来就是将将预处理后的原代码处理为汇编代码。

第4章 汇编

4.1 汇编的概念与作用

汇编程序是指把汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。汇编语言是为特定计算机或计算机系列设计的一种面向机器的语言,由汇编执行指令和汇编伪指令组成。采用汇编语言编写程序虽不如高级程序设计语言简便、直观,但是汇编出的目标程序占用内存较少、运行效率较高,且能直接引用计算机的各种设备资源。它通常用于编写系统的核心部分程序,或编写需要耗费大量运行时间和实时性要求较高的程序段。

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

使用以下命令来得到hello.o的elf文件的详细信息:

下面我们分析一下elf.txt的详细信息。

4.3.1 ELF头

ELF 头以一个 16 字节的序列开始,这个序列描述了生成该文件系统下的字的 大小以及一些其他信息。ELF 头剩下的部分包含帮助链接器语法分析和解释目标 文件的信息:包括 ELF 头的大小、目标文件的类型、机器类型、节头部表的文件 偏移,以及节头部表中条目的大小和数量。其内容如下:

4.3.2节头表

节头表描述了.o 文件中每一个节出现的位置,大小,目标文件中的每一个节都有一个固定大小的条目。其内容如下:

4.3.3 重定位节

重定位节中包含了在代码中使用的一些外部变量等信息,在链接的时候需要根 据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方法计算正确的地址,通过偏移量等信息计 算出正确的地址。
本程序需要重定位的信息有:.rodata 中的模式串,puts,exit,printf,slepsecs,
sleep,getchar 这些符号同样需要与相应的相对地址进行重定位。其内容如下:

4.3.4 符号表

.symtab 是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。 例如本程序中的 getchar、puts、exit 等函数名都需要在这一部分体现。其内容如下:

4.4 Hello.o的结果解析

使用以下命令得到反汇编代码hello.asm,

得到的反汇编代码如下:

我们将其与hello.s进行比对,发现有以下几个不同之处:

  • 1.hello.s 反汇编之后对于数字的表示是十进制的,而 hello.o 反汇编之后数字的表示是十六进制的。
  • 2.对于条件跳转,hello.s 反汇编中给出的是段的名字,来表示跳转的地址,而 hello.o由于已经是可重定位文件,对于每一行都已经分配 了相应的地址,因此跳转命令后跟着的直接是目标地址。
  • 3.hello.s 中,call指令后跟的是需要调用的函数的名称,而 hello.o 反汇编代码中 call 指令使用的是main函数的相对偏移地址。
    观察汇编代码对应的与机器代码时我们发现,在hello.o 反汇编代码中调用函数的操作数都为0,即函数的相对地址为0,因为再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用0代替。

4.5 本章小结

本章介绍了汇编阶段生成的hello.o的ELF文件的信息,还对hello.o的反汇编程序hello.asm与上一章的hello.s进行了对比。其中重点分析了汇编阶段生成的hello.o的ELF文件的信息。

第5章 链接

5.1 链接的概念与作用

C语言代码经过编译以后,并没有生成最终的可执行文件(.exe 文件),而是生成了一种叫做目标文件(Object File)的中间文件(或者说临时文件)。目标文件也是二进制形式的,它和可执行文件的格式是一样的。对于 Visual C++,目标文件的后缀是.obj;对于 GCC,目标文件的后缀是.o。
编译只是将我们自己写的代码变成了二进制形式,它还需要和系统组件(比如标准库、动态链接库等)结合起来,这些组件都是程序运行所必须的。
链接(Link)其实就是一个“打包”的过程,它将所有二进制形式的目标文件和系统组件组合成一个可执行文件。完成链接的过程也需要一个特殊的软件,叫做链接器(Linker)。
随着我们学习的深入,我们编写的代码越来越多,最终需要将它们分散到多个源文件中,编译器每次只能编译一个源文件,生成一个目标文件,这个时候,链接器除了将目标文件和系统组件组合起来,还需要将编译器生成的多个目标文件组合起来。

5.2 在Ubuntu下链接的命令

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

使用以下命令生成elf2.txt:

其结构与4.3节基本相同,下面我们只分析其与elf.txt的不同之处。下面的对比上边为elf.txt,下边为elf2.txt

5.3.1 ELF头


对比发现hello为可执行文件,hello.o为可重定位文件。hello中有了入口点的地址和程序头的起点。

5.3.2节头表

链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。其内容如下:

5.3.3 程序头

在hello中多出了程序头的部分

5.4 hello的虚拟地址空间

正常的elf文件应该有如下的格式:

我们用edb 打开hello,观察虚拟地址空间的分配的情况如下:

程序的地址是从 0x401000 开始的,对应.init节。接下来的:其 中PHDR保存的是程序头表;INTERP保存了程序执行前需要调用的解释器;LOAD 记录程序目标代码和常量信息;DYNAMIC 储存了动态链接器所使用的信息;NOTE 记录的是一些辅助信息;GNU_STACK 使 用系统栈所需要的权限信息;GNU_RELRO 保存在重定位之后只读信息的位置。

5.5 链接的重定位过程分析

使用以下命令生成hello2.asm反汇编代码:

其内容如下:

两个反汇编代码有以下几个不同:

  • 1.对于全局变量的引用,由于 hello.o 中还未对全局变量进行定位,因此 hello.o 中 用 0 加上%rip 的值来表示全局变量的位置,而在 hello 中,由于已经进行了定位, 因此全局变量的的值使用一个确切的值加上%rip 表示全局变量的位置。
  • 2.hello 中无 hello.o 中的重定位条目,并且跳转和函数调用的地址在 hello 中都变成了虚拟内存地址。这是由于 hello.o 中对于函数还未进行定位,只是在.rel.text 中 添加了重定位条目,而hello进行定位之后不需要重定位条目。
  • 3.在链接过程中,hello 中加入了代码中调用的一些库函数,例如 getchar,puts,printf等,同时每一个函数都有了相应的虚拟地址。
  • 4.hello中增加了.init 和.plt 节,和一些节中定义的函数。
    所以说链接部分主要完成了符号解析和重定位两个任务。

5.6 hello的执行流程

hello!main 0x00401176
hello!printf@plt 0x004011d5
下面三行循环
hello!atoi@plt 0x004011e8
hello!sleep@plt 0x004011ef
hello!printf@plt 0x004011d5

hello!puts@plt 0x00401195
hello!exit@plt 0x004011a4

5.7 Hello的动态链接分析

当程序调用一个由共享库定义的函数时,由于编译器无法预测这时候函数的地址是什么,GNU编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时。通过 GOT 和过程链接表 PLT 的协作来解析函数的地址。在加载时,动态链接器会重定位 GOT 中的每个条目,使它包含正确的绝对地址,而PLT 中的每个函数负责调用不同函数。那么,通过观察 edb,便可发现 dl_init后.got.plt 节发生的变化。

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个条目都负责调用一个具体的函数。PLT[[1]]调用系统启动函数 (__libc_start_main)。从PLT[[2]]开始的条目调用用户代码调用的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[[1]]包含动态链接器在解析函数地址时会使用的信息。GOT[[2]]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。

通过观察两次调用puts的跳转地址可以看到,调用后printf链接到了动态库。

5.8 本章小结

本章主要研究了链接过程的内容,其中重点研究了链接后的hello文件与hello.o文件的区别。利用链接器,可以做到文件的分离编译。经过链接,已经得到了一个可执行文件,接下来只需要在shell 中调用命令就可以为这一文件创建进程并执行该文件。

第6章 hello进程管理

6.1 进程的概念与作用

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

程序只是被动实体,如存储在磁盘上包含一系列指令的文件(经常称为可执行文件)。相反,进程是活动实体,具有一个程序计数器,用于表示下个执行命令和一组相关资源。当一个可执行文件被加载到内存时,这个程序就成为进程。

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

在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。它接收用户命令,然后调用相应的应用程序。

shell的处理流程如下:

  • 1.读取输入。Shell输入的来源有三种:文件(Shell脚本),调用bash命令时-c选项提供的参数,用户终端。
  • 2.根据引号规则,将输入分为word和operator。word和operator统称为token,token之间用metacharacter分隔(space, tab, newline, |, &, ;, (, ), )
  • 3.将tokens(words和operators)解析为简单命令或复合命令
  • 4.执行各种shell扩展,将扩展的tokens分解为文件名、命令和参数列表。
  • 5.执行任何必要的重定向,并从参数列表中删除重定向运算符及其操作数。
  • 6.执行命令
  • 7.(可选)等待命令完成并收集其exit status。

6.3 Hello的fork进程创建过程

父进程通过调用fork函数创建一个新的运行的子进程。

fork函数被调用一次,返回两次。一次返回是在调用进程(父进程)中,另一次返回是在新创建的子进程中。父进程中,fork返回子进程的PID,子进程中fork返回0。

当一个进程由于某种原因终止了,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。

在终端中输入./hello 学号 姓名,shell判断它不是内置命令,于是会加载并运行当前目录下的可执行文件hello.此时shell通过fork创建一个新的子进程。

6.4 Hello的execve过程

exceve 函数在当前进程的上下文中加载并运行一个新程序。exceve 函数加载并运行可执行目标文件,并带参数列表和环境变量列表。只有当出现错误时,exceve才会返回到调用程序。所以,与 fork 一次调用返回两次不同,在 exceve 调用一次并从不返回。当加载可执行目标文件后,exceve 调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制传递给新程序的主函数。

6.5 Hello的进程执行

无论是在批处理系统还是分时系统中,用户进程数一般都多于处理机数、这将导致它们互相争夺处理机。另外,系统进程也同样需要使用处理机。这就要求进程调度程序按一定的策略,动态地把处理机分配给处于就绪队列中的某一个进程,以使之执行。

6.5.1上下文

—个进程的上下文(context)包括进程的状态、有关变量和数据结构的值、机器寄存器的值和PCB以及有关程序、数据等。一个进程的执行是在进程的上下文中执行。当正在执行的进程由于某种原因要让出处理机时,系统要做进程上下文切换,以使另一个进程得以执行。当进行上下文切换时点统要首先检查是否允许做上下文切换(在有些情况下,上下文切换是不允许的,例如系统正在执行某个不允许中断的原语时)。然后,系统要保留有关被切换进程的足够信息,以便以后切换回该进程时,顺利恢复该进程的执行。在系统保留了CPU现场之后,调度程序选择一个新的处于就绪状态的进程、并装配该进程的上下文,使CPU的控制权掌握在被选中进程手中。

6.5.2进程时间片

时间片(timeslice)又称为“量子(quantum)”或“处理器片(processor slice)”是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(在抢占内核中是:从进程开始运行直到被抢占的时间)。现代操作系统(如:Windows、Linux、Mac OS X等)允许同时运行多个进程 —— 例如,你可以在打开音乐播放器听音乐的同时用浏览器浏览网页并下载文件。事实上,虽然一台计算机通常可能有多个CPU,但是同一个CPU永远不可能真正地同时运行多个任务。在只考虑一个CPU的情况下,这些进程“看起来像”同时运行的,实则是轮番穿插地运行,由于时间片通常很短(在Linux上为5ms-800ms),用户不会感觉到。

时间片由操作系统内核的调度程序分配给每个进程。首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。

6.5.3用户态与核心态转换

用户程序有用户态和内核态两种状态。用户态就是执行在用户空间中,不能直接执行系统调用。必须先切换到内核态,也就是系统调用的相关数据信息必须存储在内核空间中,然后执行系统调用。

操作硬盘等资源属于敏感操作,为了内核安全,用户线程不能直接调用。而是采用了操作系统内核提供了系统调用接口,用户线程通过系统调用来实现文件读写。所以直接与硬盘打交道的是操作系统内核。

操作系统将线程分为了内核态和用户态,当用户线程调用了系统调用的时候,需要将线程从用户态切换到内核态。

无论是操作系统内核程序还是用户程序在运行的时候都需要申请内存来保存运行状态,调用方法信息、程序代码、数据等信息。

操作系统将内存分为内核空间和用户空间。内核空间中主要负责 操作系统内核线程以及用户程序系统调用。用户空间主要负责用户程序的非系统调用。内核空间比用户空间拥有更高的操作级别,只有在内核空间中才可以调用操作硬件等核心资源。

操作系统将内存按1:3的比例分为了内核空间和用户空间,用户态的运行栈信息保存在用户空间中,内核态的运行栈信息保存在内核空间中。运行栈中保存了当前线程的运行信息,比如执行到了哪些方法,局部变量等。

当发生用户态和内核态之间的切换的时候,运行栈的信息发生了变化,对应的CPU中的寄存器信息也要发生变换。但是用户线程完成系统调用的时候,还是要切换回用户态,继续执行代码的。所以要将发生系统调用之前的用户栈的信息保存起来,也就是将寄存器中的数据保存到线程所属的某块内存区域。这就涉及到了数据的拷贝,同时用户态切换到内核态还需要安全验证等操作。所以用户态和内核态之间的切换是十分耗费资源的。

6.6 hello的异常与信号处理

hello执行过程中会产生中断、陷阱、故障和终止四种异常。

下面我们尝试hello程序执行的几种情况:

6.6.1正常执行

6.6.2 Ctrl – C

进程收到 SIGINT 信号,结束 hello,清除这个Job。

6.6.3 Ctrl – Z

程序运行时按 Ctrl-Z,这时,产生中断异常,它的父进程会接收
到信号 SIGSTP 并运行信号处理程序,然后便发现程序在这时被挂起了,并打印了相关挂起信息。


运行ps命令发现此时hello的PID还存在:

运行jobs发现打印出了被挂起的hello:

运行pstree可以看到我的终端(zsh)中还有很多进程:

运行fg 1 可以将其调到前台来执行:

可以用kill -9 20288来发送信号SIGKILL给进程20288(hello),杀死这个进程:

6.7本章小结

本章介绍了hello可执行文件的执行过程,主要讨论了“进程”这个计算机科学中非常重要的概念。在进程中讨论了进程的创建、管理、回收、信号等内容。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。

线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。线性地址是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x00000000到0xffffffff)。程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。当采用4KB分页大小的时候,线性地址的高10位为页目录项在页目录表中的编号,中间10位为页表中的页号,其低12位则为偏移地址。如果是使用4MB分页机制,则高10位页号,低22位为偏移地址。如果没有启用分页机制,那么线性地址直接就是物理地址。

操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。

在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。物理地址,也叫实地址(real address)、二进制地址(binary address),它是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。在和虚拟内存的计算机中,物理地址这个术语多用于区分虚拟地址。尤其是在使用内存管理单元(MMU)转换内存地址的计算机中,虚拟和物理地址分别指在经MMU转换之前和之后的地址

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

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。

段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。

虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址。而段描述符存放在描述符表中,也就是 GDT(全局描述符表)或LDT(局部描述符表)中。

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

分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。虚拟地址与物理地址之间通过页表来映射。

页是存储在内存力,由 CPU 的内存管理单元 (MMU) 负责映射转换的工作,这样CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址。

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

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。

我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

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

MMU 将物理地址发给 L1 缓存,缓存从物理地址中取出缓存偏移 CO、缓存组索引 CI 以及缓存标记 CT。若缓存中 CI 所指示的组有标记与 CT 匹配的条目且有效位为 1,则检测到一个命中条目,读出在偏移量 CO 处的数据字节,并把它返回给 MMU,随后 MMU 将它传递给 CPU。若不命中,则在下一级 cache 或是主存中寻找需要的内容,储存到上一级 cache 后再一次请求读取。

7.6 hello进程fork时的内存映射

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

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要:删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。映射私有区域:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。3映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。设置程序计数器(PC) :设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

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

  • 1.在 CPU⾥访问⼀条 Load M 指令,然后 CPU 会去找 M 所对应的⻚表项
  • 2.如果该⻚表项的状态位是有效的,那 CPU 就可以直接去访问物理内存了,如果状态位是⽆效的,则 CPU 则会发送缺⻚中断请求。
  • 3.操作系统收到了缺⻚中断,则会执⾏缺⻚中断处理函数,先会查找该⻚⾯在磁盘中的⻚⾯的位置
  • 4.找到磁盘中对应的⻚⾯后,需要把该⻚⾯换⼊到物理内存中,但是在换⼊前,需要在物理内存中找空 闲⻚,如果找到空闲⻚,就把⻚⾯换⼊到物理内存中
  • 5.⻚⾯从磁盘换⼊到物理内存完成后,则把⻚表项中的状态位修改为有效的
  • 6.最后,CPU 重新执⾏导致缺⻚异常的指令。

7.9动态存储分配管理

动态存储分配,即指在目标程序或操作系统运行阶段动态地为源程序中的量分配存储空间,动态存储分配包括栈式或堆两种分配方式。需要注要的是,采用动态存储分配进行处理的量,并非所有的工作全部放在运行时刻做,编译程序在编译阶段要为其设计好运行阶段存储组织形式,并为每一个数据项安排好它在数据区中的相对位置。

动态存储分配方式是不一次性将整个程序装入到主存中。可根据执行的需要,部分地动态装入。同时,在装入主存的程序不执行时,系统可以收回该程序所占据的主存空间。再者,用户程序装入主存后的位置,在运行期间可根据系统需要而发生改变。此外,用户程序在运行期间也可动态地申请存储空间以满足程序需求。由此可见,动态存储分配方式在存储空间的分配和释放上,表现得十分灵活,现代的操作系统常采用这种存储方式。

7.10本章小结

本章介绍了hello程序在执行时操作系统提供的内在管理机制。主要介绍了hello 进程在执行的过程中的虚拟内存与物理内存之间的转换关系,以及一些支持这些转换的硬件或软件机制。同时介绍了在发生缺页异常的时候系统将会如何处理这一异常。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件。所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当做对相应文件的读和写来执行。

设备管理:unix io接口。unix io 接口。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O。

8.2 简述Unix IO接口及其函数

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

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0.这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

函数:

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

8.3 printf的实现分析

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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; }

Printf 执行流程:
vsprintf 函数将所有的参数内容格式化之后存入 buf,然后返回格式化数组的长度。write 函数将 buf 中的 i 个元素写到终端。从 vsprintf 生成显示信息,到write 系统函数,到陷阱-系统调用 int 0x80 或 syscall.字符显示驱动子程序:从ASCII 到字模库到显示 vram(存储每一个点的 RGB 颜色信息)。显示芯片按照刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。

8.4 getchar的实现分析

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

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar 是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为 int 型,为用户输入的 ASCII 码或 EOF。getchar 可用宏实现:#define getchar() getc(stdin)。getchar 有一个 int 型的返回值。当程序调用 getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar 才开始从 stdin流中每次读入一个字符。getchar 函数的返回值是用户输入的字符的 ASCII 码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成 ascii码,保存到系统的键盘缓冲区。getchar 等调用 read 系统函数,通过系统调用读取按键 ascii 码,直到接受到回车键才返回。

8.5本章小结

本章主要介绍了 linux 系统中的 I/O 设备基本概念和管理方法。其中重点介绍了 printf 和 getchar 函数在C语言中的实现。

结论

Hello的一生从我们编写好的hello.c源代码程序(Program)开始,当我们在IDE中按下编译并运行,Hello进行了一系列的处理,最终变成为一个进程(Process)。这个过程中有以下几个步骤:

  • 1.hello.c源程序首先经过预处理器(cpp)将其转换为hello.i
  • 2.经过编译器(ccl)将其转换为汇编程序hello.s
  • 3.经过汇编器(as)将其转换为hello.o
  • 4.经过链接器(ld)与其他可重定位目标程序链接成为可执行目标程序hello

在这一系列过程后,初始的shell程序等待指令的输入,我们在shell中输入字符串./hello 7203610326 sunhao,敲下回车后shell执行一系列指令。这个过程中有以下几个步骤:

  • 1.生成子进程,在 shell 中输入指定命令 shell 调用 fork 函数为 hello 生成进程。
  • 2.Execve 加载并运行 hello 程序,将它映射到对应虚拟内存区域,并依需求载入物理内存。
  • 3.I/O 设备,在 hello 程序中存在输入与输出,这些部分与 printf,getchar函数有关,这些函数与 linux 系统的 I/O 设备密切相关。
  • 4.内存管理,利用操作系统提供的内在管理功能,对内存进行读写。

附件

序号文件名称描述
1hello.c源程序
2hello.i预处理后程序
3hello.s编译后汇编程序
4hello.o汇编后elf格式文件
5elf.txthello.o的elf文件信息
6hello.asmhello.o的反汇编代码
7hello链接后生成的可执行文件
8elf2.txthello的elf文件信息

参考文献

[1]https://blog.csdn.net/xiaopangcame/article/details/118873760
[2]https://baijiahao.baidu.com/s?id=1687923180513685219&wfr=spider&for=pc
[3]https://baike.baidu.com/item/C%E8%AF%AD%E8%A8%80%E9%A2%84%E5%A4%84%E7%90%86%E7%A8%8B%E5%BA%8F/22057237?fr=aladdin
[4]https://blog.csdn.net/localhostcom/article/details/108165432
[5]https://wenku.baidu.com/view/e6a3956fcb50ad02de80d4d8d15abe23482f0379.html
[6]https://baike.baidu.com/item/%E7%BC%96%E8%AF%91%E7%A8%8B%E5%BA%8F/8290180?fr=aladdin
[7]https://baike.baidu.com/item/%E6%B1%87%E7%BC%96%E7%A8%8B%E5%BA%8F/298210?fromtitle=%E6%B1%87%E7%BC%96&fromid=627224&fr=aladdin
[8]https://blog.csdn.net/zj82448191/article/details/108441447
[9]http://c.biancheng.net/view/1736.html
[10]http://c.biancheng.net/view/1200.html
[11]https://baike.baidu.com/item/%E8%BF%9B%E7%A8%8B/382503?fr=aladdin
[12]https://blog.csdn.net/qq_40276626/article/details/119979930
[13]https://www.zhihu.com/question/290504400
[14]https://baike.baidu.com/item/%E7%89%A9%E7%90%86%E5%9C%B0%E5%9D%80/2901583
[15]https://www.zhihu.com/question/290504400
[16]https://blog.csdn.net/LINZEYU666/article/details/115420915
[17]https://blog.csdn.net/weixin_52008431/article/details/123919301
[18]https://baike.baidu.com/item/%E5%8A%A8%E6%80%81%E5%AD%98%E5%82%A8%E5%88%86%E9%85%8D/21306011?fr=aladdin
[19]CSAPP.