程序人生-Hello’s P2P

  要

本文以简单的hello程序的生成、运行和退出的过程为主线,对本学期计算机系统这门课程之所学进行了全面的回顾和部分延伸,也揭示了简单程序的不简单之处。程序的运行需要计算机硬件系统与软件系统的共同努力才能完成。计算机的软件系统负责给程序运行分配独立内存空间、分配时间

片,并利用一些硬件系统的特性来加速程序的运行。而计算机的硬件系统负责给程序的运行提供平台和资源,给软件系统也提供了便捷的接口,使软件系统不必关注硬件实现的细节。通过对hello程序的分析,揭示了计算机系统中软硬件的协同工作原理,深入理解了计算机系统,这也为我们后续在计算机专业的学习打下了坚实的基础。

关键词:程序的一生;计算机系统;计算机抽象层次结构;软硬件协同                           

1.1 Hello简介

      1. P2P(From Program to Process)

Hello的P2P是指From Program to Process,即从源代码文件(program经过预处理、编译、汇编、链接、生成可执行程序,然后在执行该程序时,作为一个进程(process加载到内存中由系统执行。

      1. O2O (From Zero-0 to Zero-0)

在程序执行之前,bash利用操作系统(Operating System,OS)的进程管理功能提供的fork函数,创建一个子进程,然后执行execve在子进程的上下文中加载并执行这个程序,调用mmap函数创建一个新的虚拟地址空间映射,OS在将文件加载到这个新分配的内存后,剩余内存将全部复制为零(Zero)。OS的进程管理为这个新进程划分时间片,在时间片内进程得到CPU资源,执行该程序。main函数结束后,Hello的父进程回收分配给Hello的资源,利用OS继续发挥进程管理功能,杀死僵尸进程,将其占用的内存清零(Zero)。程序执行前和执行后没有在内存里留下其痕迹,Hello完成了从无到无的过程。

1.2 环境与工具

硬件:

Intel(R) Core(TM) i7-9750H CPU

RAM: 32GB

软件:

Windows 10 Pro for Workstations (64位)

openSUSE Leap 15.2(64位)

VMware Workstation 16

开发与调试工具:

GCC GDB EDB

as ld

readelf objdump

1.3 中间结果

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

文件名

作用

hello.i

预处理后的文件

hello.s

编译后的文件

hello.o

汇编后的可重定位目标文件

hello

链接后的可执行文件

elf_hello.txt

hello的elf头输出

as_hello.s

hello反汇编后的输出

hello_s.txt

hello.o反汇编

1.4 本章小结

            本章对hello程序的生成和运行过程做了一个初步概括,以计算机系统的语言解释了P2P(From Program to Process)和O2O (From Zero-0 to Zero-0)的含义。本章还列举了实验所使用的软硬件、开发与调试工具以及生成中间结果文件与作用。

2.1 预处理的概念与作用

预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,得到另一个C程序,通常以.i作为文件扩展名。

例如,#include是告知预处理器读取一个文件的内容,并将其直接插入程序文本中;#define则可以将一串字符定义为指定的另一串字符,在预处理中将用后者替换全部前者;#ifndef、#ifdef等语句还可以实现条件编译。

2.2在Ubuntu下预处理的命令

这里通过gcc -m64 -no-pie -fno-PIC -save-temps hello.c -o hello命令保存编译过程中生成的所有文件,包括预处理后的文件。如果要单独预处理,可以使用gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i命令

Hello.i文件内容很多,展示部分如下:

图 2.1 预处理指令及生成的文件

2.3 Hello的预处理结果解析

hello.i文件比hello.c体积大了不少,原本数十行的代码如今变成数千行,这些多余的代码语句是预处理时进行的include操作带来的。预处理过程将include的文件内容插入源文件中,原有代码中的宏定义在这一步中进行替换。

2.4 本章小结

本章利用Linux系统中的gcc完成了对hello.c文件的预处理工作,通过查看生成的hello.i文件,观察到了#include、#define等预处理语句的作用,了解了预处理的概念及作用,进一步深化了对编译前准备过程的了解。 

  • 第 3 章  编译

3.1 编译的概念与作用

            编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译过程将预处理后的程序转换为汇编程序。

            汇编将高级语言编写的程序转化为统一的汇编语言程序。汇编程序是机器语言程序更直观的表示,其本质是相同的。在这个过程中,编译器也会检查语法的正确性。

3.2 在Ubuntu下编译的命令

这里通过gcc -m64 -no-pie -fno-PIC -save-temps hello.c -o hello命令保存编译过程中生成的所有文件,包括编译后生成的hello.s文件。

当然也可以使用gcc -m64 -no-pie -fno-PIC -S hello.c -o hello.s单独实现编译,命令执行结果及汇编代码部分展示如下:

图 3.1 编译的命令及生成文件

3.3 Hello的编译结果解析

  1. 数据
  1. 字符串常量

图 3.2 字符串常量的表示

我们可以看到,字符串常量"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"均存在了.rodata段里

  1. 普通整形常量

图 3.3 普通整形常量的表示

我们可以看到,这里将argc与4比较时,在汇编代码中是直接以立即数的方式表示($后加上数字),后面调用的exit(1)中的常量1、return 0中的常量0、for循环中i < 8中的8(实际优化为7)等,都是以立即数的方式表示。

  1. 作为数组下标的整形常量

   这部分将在3.3.8节中描述。

  1. 变量

图 3.4 变量的表示

这里注意临时变量i存放在内存里,即-4(%rbp)中。

argc和argv变量将在3.3.10部分描述。

  1. 赋值

图 3.5 赋值语句的表示

借用上一节的图,这里利用mov指令将0赋值给了变量i。

  1. 算术操作

图 3.6 算术操作的表示

这里利用add指令实现了变量i的自增。

对地址偏移量的算术操作将在3.3.6节中描述。

  1. 关系操作

图 3.7 关系操作的表示

这里利用cmp指令将argc与4比较,再利用je进行条件跳转。

图 3.8 关系操作的表示

这里进行了一些巧妙的变化,将i<8转换为等价的i<=7,因此利用cmp指令将i与7比较,然后利用jle在i<=7时跳转到.L4部分的代码。

  1. 数组、指针、结构操作

图 3.9 数组的访问

argv数组的基地址存放在-32(%rbp)中。我们知道,argv数组中存放的是指针类型,并且在64位系统中指针变量为8字节,因此要访问argv[3],偏移量应该设置为3*8=24字节,这里的汇编代码第一步获得了argv数组的基地址,然后在基地址的基础上加24得到argv[3]的地址,利用mov访问内存,将该地址中存放的内容存入%rax寄存器中。由于%rax寄存器会被被调用者使用,因此将%rax的内容转存入%rdi中作为参数传递。

argv[1]和argv[2]的访问原理同上,不再赘述。

  1. 控制转移

图 3.10 控制转移的表示

在这个if语句中,若argc!=4,则je条件跳转处不跳转,执行if块内的语句;若argc==4,则je处跳转到.L2块进行初始化,进而跳转到.L3块中开始for循环,这里恰好又是一个控制转移……

图 3.11 条件跳转的表示

在这里,若i>=7,则jle条件跳转处不跳转,跳出了for块;反之则跳转到.L4,进入for块中执行。

  1. 函数操作

在继续之前,我们先复习一下64位计算机系统中各寄存器的作用:

图 3.12 寄存器的使用惯例约定

在main函数被调用时,argc和argv参数将预先存放在%edi(argc是int类型)和%rsi中

图 3.13 参数传递

这里展示的是main函数内将其参数保存在内存中的代码,此后每次访问这两个参数时将直接在内存中访问。

图 3.14 函数调用

接下来看到第一个printf的调用。编译器这里进行了优化,使用puts函数代替printf函数。这里将字符串的地址传递到%edi中作为第一个参数,然后使用call指令来调用puts函数。

我们顺便可以看到这里紧接着就是将整形常量1存入%edi中作为第一个参数并使用call指令调用exit函数。

图 3.15 函数调用

接下来可以看到调用了稍复杂一点的printf函数,我们可以看到给printf函数的三个参数分别存放在%edi、%rsi、%rdi寄存器中,然后使用call调用printf函数(这就是为什么我们应该先复习寄存器作用的原因)。接下来的atoi和sleep函数调用都与前文类似,不再赘述。

图 3.16 函数调用

最后一次函数调用没有传递参数,直接call调用函数,最后main函数返回。

3.4 本章小结

本章我们了解了编译的概念与作用,并结合实际的汇编程序与源程序对比,讨论了C语言中数据、赋值、算术操作、关系操作、数组操作、控制转移和函数操作在汇编语言中的表示,加深了我们对汇编与C之间关系的理解。同时我们还观察到编译器对源代码也有一定的优化操作,可以增强程序的运行效率。

  • 第 4 章  汇编

4.1 汇编的概念与作用

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

4.2 在Ubuntu下汇编的命令

先前我们已经生成了.s文件,于是利用汇编器as进行汇编,命令为as hello.s -o hello.o

图 4.1 汇编命令

4.3 可重定位目标elf格式

利用readelf hello.o -a > elf_hello.txt将可重定位目标程序的elf信息输出到elf_hello.txt中。

ELF文件头:前16个字节的序列描述生成该文件的系统的字的大小和字节顺序,余下部分包含帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

图 4.2 ELF头

节头描述了文件中出现的各个节的类型、位置、所占空间大小等信息。

图 4.3 节头

重定位节:

这里存放了外部符号及其在.text节中的偏移量等,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般来说,任何调用外部函数或者引用全局变量的指令都需要修改。

图 4.4 重定位节

图 4.5 重定位节

符号表:存放在程序中定义和引用的函数和全局变量的信息,不包含局部变量的信息。

图 4.6 符号表

4.4 Hello.o的结果解析

利用命令objdump -d -r hello.o > hello_s.txt 将输出重定义到文件hello_s.txt中。

  1. hello.o:     file format elf64-x86-64
  2. Disassembly of section .text:
  3. 0000000000000000 :
  4.    0: 55                    push   %rbp
  5.    1: 48 89 e5              mov    %rsp,%rbp
  6.    4: 48 83 ec 20           sub    $0x20,%rsp
  7.    8: 89 7d ec              mov    %edi,-0x14(%rbp)
  8.    b: 48 89 75 e0           mov    %rsi,-0x20(%rbp)
  9.    f: 83 7d ec 04           cmpl   $0x4,-0x14(%rbp)
  10.   13: 74 14                 je     29 
  11.   15: bf 00 00 00 00        mov    $0x0,%edi
  12.    16: R_X86_64_32 .rodata
  13.   1a: e8 00 00 00 00        callq  1f 
  14.    1b: R_X86_64_PC32 puts-0x4
  15.   1f: bf 01 00 00 00        mov    $0x1,%edi
  16.   24: e8 00 00 00 00        callq  29 
  17.    25: R_X86_64_PC32 exit-0x4
  18.   29: c7 45 fc 00 00 00 00  movl   $0x0,-0x4(%rbp)
  19.   30: eb 46                 jmp    78 
  20.   32: 48 8b 45 e0           mov    -0x20(%rbp),%rax
  21.   36: 48 83 c0 10           add    $0x10,%rax
  22.   3a: 48 8b 10              mov    (%rax),%rdx
  23.   3d: 48 8b 45 e0           mov    -0x20(%rbp),%rax
  24.   41: 48 83 c0 08           add    $0x8,%rax
  25.   45: 48 8b 00              mov    (%rax),%rax
  26.   48: 48 89 c6              mov    %rax,%rsi
  27.   4b: bf 00 00 00 00        mov    $0x0,%edi
  28.    4c: R_X86_64_32 .rodata+0x26
  29.   50: b8 00 00 00 00        mov    $0x0,%eax
  30.   55: e8 00 00 00 00        callq  5a 
  31.    56: R_X86_64_PC32 printf-0x4
  32.   5a: 48 8b 45 e0           mov    -0x20(%rbp),%rax
  33.   5e: 48 83 c0 18           add    $0x18,%rax
  34.   62: 48 8b 00              mov    (%rax),%rax
  35.   65: 48 89 c7              mov    %rax,%rdi
  36.   68: e8 00 00 00 00        callq  6d 
  37.    69: R_X86_64_PC32 atoi-0x4
  38.   6d: 89 c7                 mov    %eax,%edi
  39.   6f: e8 00 00 00 00        callq  74 
  40.    70: R_X86_64_PC32 sleep-0x4
  41.   74: 83 45 fc 01           addl   $0x1,-0x4(%rbp)
  42.   78: 83 7d fc 07           cmpl   $0x7,-0x4(%rbp)
  43.   7c: 7e b4                 jle    32 
  44.   7e: e8 00 00 00 00        callq  83 
  45.    7f: R_X86_64_PC32 getchar-0x4
  46.   83: b8 00 00 00 00        mov    $0x0,%eax
  47.   88: c9                    leaveq 
  48.   89: c3                    retq   

反汇编后与hello.s文件对比分析:

  1. hello.s中的立即数是十进制,而hello_s.txt中的立即数变为了十六进制;
  2. hello.s的跳转目标是.L2等段名称,而hello_s.txt中的跳转目标是一个计算后的偏移量;
  3. hello.s的函数调用直接使用了函数名称,而hello_s.txt中则是暂时全零的地址(因为此时函数是外部引用)。但在出现外部引用的地方会写明此处的重定位条目,以便链接器进行链接工作确定应填的地址;
  4. 某些全局变量在hello.s的代码中是利用.LC0作为标记,而在hello_s.txt中则是待重定位的形式,与函数调用类似。

4.5 本章小结

            本章对程序汇编后生成的可重定位目标文件进行了介绍。经过汇编后,汇编语言转化为机器语言,生成的可重定位文件可用于之后的链接。通过对比编译后和从可重定位文件反汇编的文件,了解了汇编器所做的工作。

            本章还对可重定位目标文件的elf格式进行了详细分析,看到了可重定位目标的“可重定位”之处。

5.1 链接的概念与作用

链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。

链接使分离编译成为可能。我们不用将一个大型应用程序组织为一个巨大的源文件,而是把它分解为更小、更好管理的模块。在更新某个模块时,可以独立地修改和编译这些模块,并重新链接应用,不必重新编译其他文件。

5.2 在Ubuntu下链接的命令

ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib64/crt1.o /usr/lib64/crti.o -L /usr/lib64 -lc /usr/lib64/crtn.o hello.o -o hello

图 5.1 链接的命令

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

利用realelf -a hello > elf_hello.txt得到可执行目标文件hello的elf头内容。

ELF头:

图 5.2 ELF头

节头部表:

图 5.3 节头部表

5.4 hello的虚拟地址空间

图 5.4 虚拟地址空间

图中vsyscall为内核内存,stack表示用户栈,libc为共享库的内存映射区域,0x400000开始为只读代码段。我们看到permissions中,0x401000-0x402000是只读可执行的,因此程序代码在这个区域。0x404000-0x405000是可读写的,用户栈是可读写的。Linux x86-64的运行时内存映像如下图所示:

图 5.5 运行时内存映像

5.5 链接的重定位过程分析

利用objdump -d -r hello > as_hello.s得到hello的反汇编程序。这里取main函数的内容分析。

图 5.6 hello的反汇编

Hello与hello.o相比的变化:

  1. 可以观察到,外部函数的地址已经链接到了该程序中;
  2. 虽然我们只取了main部分进行展示,但在反汇编的程序中还出现了_init、_start等函数;
  3. 原重定位条目消失,但由于程序是运行时链接,此时还不能知道库函数的真正执行地址,因此在.rela.plt节中又增加了重定位条目。

Hello的重定位:

  1. 重定位节和符号定义。链接器将所有相同类型的节合并为同一类型的聚合节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。这一步完成后,程序中的每条指令和全局变量都有唯一的运行时地址;
  2. 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。

5.6 hello的执行流程

加载时:

401090 _start

401200 __libc_csu_init

401000 _init

401170

401100

401176 main

终止时:

exit

_dl_runtime_resolve_xsavec

_dl_fixup

_dl_lookup_symbol_x

do_lookup_x

__run_exit_handlers

__call_tls_dtors

_dl_fini

rtld_lock_default_lock_recursive

_IO_cleanup

__GI__IO_flush_all

_exit

5.7 Hello的动态链接分析

下面是链接前后的地址空间图对比:

  

图 5.7 链接前地址空间

图 5.8 链接后地址空间

可见在调用dl_init后,libc库被链接映射到程序的内存空间中。并且其链接的某些库函数地址也写入了程序的内存空间,如下图所示(上图为链接前的内存内容,下图为链接后的内存内容):

图 5.9 链接前内存内容

图 5.10 链接后内存内容

5.8 本章小结

本章主要讨论了Linux系统中的链接过程。通过edb-debugger调试工具查看程序运行时的内存地址空间和内存内容的变化,了解了动态链接的基本过程。本章还通过对比hello与hello.o的elf头,分析了链接的重定位过程。

6.1 进程的概念与作用

进程就是一个执行中的程序实例。进程提供给应用程序两个关键抽象:一个独立的逻辑控制流和一个私有的地址空间,他们给程序提供了一个假象:程序好像独占地使用处理器和内存系统。对OS而言,进程也方便了OS对运行中程序的管理。

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

Bash是GNU操作系统中默认的shell,或者说是命令解释器。Shell是一种编程语言,也可以指命令解释器。Shell作为一个命令解释器,为用户提供了接口,使得用户可以方便地利用GNU工具。

Shell可以交互运行,也可以非交互运行。在交互运行的情况下,Shell从用户的键盘中接受输入,而在非交互运行的情况下,Shell从文件中读取输入。Shell还允许同步或异步地处理GNU命令、重定向输入输出等。

Shell的处理流程:

Shell的功能十分丰富,这里只以执行内部命令或外部程序为例,简要说明其处理流程。

  1. 在输入一个句子后,进行解析,如果命令名不包含斜杠,则Shell就开始定位,将其分割成各个字符串。如果分割完毕后发现这是一个命令和一串参数,那么Shell开始准备执行命令;
  2. 若这个命令名不是一个函数(Shell的功能很丰富,没错,你甚至可以用Shell写函数),那么Shell就会在其内置命令中搜索。如果匹配到了一个合适的内置命令,那么就执行;
  3. 如果这个命令名既不是一个Shell函数也不是一个内置函数,还没有斜杠,那么Bash就会在环境变量$PATH中的各个路径搜索这个名字。Bash利用哈希查找法提高查找的效率。如果查无此程序,那么shell就会转过头查找名为command_not_found_handle的Shell函数。如果这个函数存在,那么原来输入的句子就会作为其参数,然后在一个全新的处理环境中执行这个函数,并且这个函数的退出状态就是新处理环境的退出状态。如果连这个函数都没有定义,那么Shell只好输出一个错误信息并返回退出状态127;
  4. 如果查有此程序,或者这个句子的包含至少一个斜杠,那么Shell就会衍生一个新的处理环境,在新环境中处理这个程序(即熟悉的fork-execve过程)。第0个参数被设置为这个程序的名字,剩余参数是用户提供的参数;
  5. 若因为定位到的文件不是可执行的而执行失败,并且这个文件不是一个目录,那么Shell就认为这个文件的内容是shell命令,并尝试执行;
  6. 若程序不是异步执行的,那么Shell等待程序运行结束并得到其返回值。

6.3 Hello的fork进程创建过程

在6.2中的第4步创建新环境时,实际上执行了fork函数。Fork函数的作用是创建一个与当前进程一模一样的新进程,然后返回——即fork在子进程与父进程中都会返回,但其返回值不同。在父进程中返回值是子进程的PID,而子进程中返回0。此后,子进程和父进程就成为了并发运行的独立进程,OS内核可以以任意方式交替执行它们的逻辑控制流中的指令。子进程与父进程具有相同但是独立的地址空间,此后子进程和父进程对各自内存的改动都是相互独立的。子进程和父进程还会具有相同的已打开文件的文件描述符。

Fork为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程的每个区域结构丢标记为私有的写时复制。当这两个进程的任一个后来进行写操作时,写时复制机制会创建新页面。这样既为每个进程保持了私有地址空间的抽象概念,又节省了进行完全内存复制所需的时间和空间。

6.4 Hello的execve过程

根据fork返回值的不同可以区分出父进程和子进程。这时子进程可以调用execve函数开始执行新程序。调用者需要提供三个参数:程序名、参数列表和环境变量。execve将在当前进程中加载并运行包含在可执行目标文件中的程序,即用hello替代当前程序。加载并运行hello需要以下几个步骤:

  1. 删除当前进程虚拟地址的用户部分中已存在的区域结构;
  2. 为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域结构都是私有、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名问卷,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零;
  3. 将hello的共享对象映射到用户虚拟地址空间中的共享区域内(动态链接);
  4. 设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

在execve加载了程序名后,将调用加载器,创建如图所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它初始化执行环节,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。

图 6.1 进程地址空间

当main开始执行时,用户栈的组织结构为

图 6.2 用户栈的典型组织结构

6.5 Hello的进程执行

处理器是计算机系统中最宝贵的资源,是历代进程的“必争之地”。但是进程有很多个,处理器只有一个(也可能有很多个,总之数量远小于进程),那么怎么安排进程的运行呢?

下图是一个进程的状态图示意。不同的OS参考书有不同的表示方法,但都大同小异。操作系统为每个进程分配了时间片,在这个时间片内进程可以占领CPU。

图 6.3 进程状态转换图

操作系统内核使用上下文切换来实现多任务(的假象)。

内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,如描述地址空间的页表、包含当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程(即调度)。当内核调度了一个新的进程运行时,则进行上下文切换将控制转移到新的进程。上下文切换需要保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文,将控制传递给这个新恢复的进程。

当内核代表用户执行调用时,可能产生上下文切换。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是把控制返回给调用进程。

中断也可能引发上下文切换。例如,每次发生定时器中断时,内核都能判定当前进程的时间片已经用完,并切换到一个新的进程。

下图是一个进程切换的实例,PCB表示程序状态字。进程运行在用户态下,只能执行用户级指令,而OS运行在内核态下,可以执行特权命令。在用户态下,也不允许直接引用内核区的代码和数据,用户只能通过系统调用接口来间接访问内核代码。

进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器由用户模式转为内核模式。当它返回到应用程序代码时,处理器由内核模式转换回到用户模式。

图 6.4 进程切换

6.6 hello的异常与信号处理

异常有四类,如图所示:

图 6.5 异常的种类

中断的处理:

图 6.6 中断异常处理

陷阱和系统调用的处理:

图 6.7 陷阱和系统调用处理

故障、终止处理:

图 6.8 故障、终止处理

下图是正常运行程序的过程中,胡乱输入后按回车的效果:

图 6.9 程序运行时胡乱输入

这时用户的输入存入了缓冲区中,由于程序没有设计接受输入,因此用户输入会一直保存在缓冲区中,对程序的运行没有效果。

下图是按下Ctrl+Z的效果:

图 6.10 发送SIGTSTP信号

这时向进程发送了SIGTSTP(来自终端的停止信号),进程停止运行。运行jobs查看此时作业列表中有一个停止运行的作业。

图 6.11 查看作业状态

这时运行ps命令,发现hello进程仍然存在,只是没有在运行,获取其PID为57567。

图 6.12 ps命令

运行pstree,得到一个进程树,观察到所有进程都源于systemd这一父进程,而高亮的就是此时正在执行的进程,我们可以看到bash有两个子进程,一个是正在运行的pstree,另一个就是已经停止的hello。

图 6.13 进程树

现在利用fg恢复hello的运行,hello接收到SIGCONT信号,继续运行。

图 6.14 继续程序的执行

在运行一段时间后按下Ctrl+C,hello接收到SIGINT信号,终止运行。

图 6.15 发送SIGINT信号

重新开始运行hello程序,先发送SIGTSTP使其停止运行,然后用kill发送信号SIGINT,由于进程此时暂停运行了,OS只是将其相应信号位设置为1,进程并不能处理信号。

利用命令fg使进程重新开始运行,进程收到信号,开始执行对SIGINT信号的信号处理程序,默认行为是终止该程序的运行,于是程序终止。

图 6.16 用kill在进程停止后发送信号

注意SIGINT是比较“温和”的终止信号,允许进程“临死前继续挣扎”,给进程保存状态的处理时间,而SIGKILL(利用kill -9发送)就是强制将进程终止,进程没有“临死挣扎”的机会。

6.7本章小结

本章运行hello,讨论了hello程序的加载、异常与信号处理,借此了解了OS的进程管理机制的冰山一角。OS负责进程调度,决定每个时间何时运行多长时间,为进程划分各自的时间片。对不同的异常信号,进程可以以不同的方式进行处理,信号处理机制也允许程序在收到某些信号时做出相应的处理动作,而不是默认行为。

7.1 hello的存储器地址空间

IA-32架构的内存管理机制示意图:

图 7.1 IA-32架构的内存管理机制

逻辑地址:处理器产生的内存地址,即hello(认为自己)要访问的某个地址。

线性地址:线性地址空间中的一个地址,而线性地址空间是指处理器可寻址的地址空间。根据Intel处理器手册,处理器可以不分页,但不可以不分段。

虚拟地址:有时也可以认为这是线性地址。他们几乎是一回事。虚拟地址空间通常比物理地址空间要大,因为虚拟地址的访问目标可能是磁盘。在hello中就是处理器产生出的一个虚拟地址,还需要经过OS的分页机制才能转为物理地址。

物理地址:处理器通过地址总线可以访问到的地址空间称为物理地址空间,物理空间中的地址称为物理地址。通过一个物理地址可以访问到可读写内存、只读内存和I/O设备。在hello中就是程序实际访问的内存空间地址。

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

图 7.2 段式管理

逻辑地址是由16位的段选择符和32位的偏移量组成。段选择符标记了待访问字节所在段的位置,而偏移量指定了字节相对于段基址的位置。

线性地址是一个32位未分段的“裸”地址,线性地址空间包含了所有段和为系统定义的系统表。

逻辑地址转变为线性地址时,处理器需要做的有:

  1. 利用段选择符的偏移量选择GDT或LDT中的段描述符,并存入处理器;(只有在段寄存器要读入一个新的段选择符时,才会进行这一步)
  2. 检查段描述符,查看访问权限和段的范围,确保在当前权限下可以访问目标段,并且偏移量在段的最大偏移范围之内;
  3. 从段描述符中获取段的基地址,将其与偏移量相加,就得到了线性地址。

段选择符:

图 7.3 段选择符的表示

下标可以选择在GDT或LDT中8192个描述符中的一个。处理器在获取下标时,会自动将其乘8(段描述符的字节数),并将结果加上GDT或者LDT的基地址。

TI用于选择选用全局描述符表或局部描述符表。

RPL指定选择符的访问权限。0是级别最高的,3是级别最低的。

段描述符(64位):

图 7.4 段描述符的表示

基地址分三块存,非常奇妙的设计。

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

分页(线性地址的转换)可以将线性地址转换为可以访问内存或者I/O设备的地址。分页机制将一个线性地址转换为一个物理地址,并且检查这个线性地址的访问权限,以及这次访问的缓存类型。

Intel-64位处理器支持三种分页模式:32位分页、PAE分页和四级页表分页(最近更新的文档中增加了五级页表,其线性地址为57位,但工作原理与四级页表类似)。这三种模式都支持多级页表。

一个页表的大小是4KB,由若干项组成。32位分页模式下,每项是4字节,一共有1024项;PAE和四级页表分页模式下,每项是8字节,一共有512项。(PAE分页有一个例外,包含了一种大小为32字节的页表,一共4个8字节的项)。

处理器使用线性地址的高位部分来识别一系列页表条目,最后的条目也就是线性地址最终转换成的物理地址的相应区域(页框)。低位部分确定了相应物理地址的低位部分。

每个页表条目中包含了一个物理地址,这要么是指向另一个页表的,要么是页框地址。前者中,该条目被称为是指向另一个页表的引用;后者中,该条目被称为映射到一个页上。

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

图 7.5 四级页表总览(完全体)

在四级页表访问模式下,48位的线性地址转换为52位物理地址。尽管52位的地址可以表示4PB,但线性地址空间的限制使得同一时间内“只有”256T的内存空间可访问。

四级页表结构的页面大小有三种:1GB、2MB、4KB。

CR3(Control Register)存放了一级页表的基地址,线性地址的47:39位作为一级页表的偏移量,这样就可以从一级页表中得到二级页表(又叫页目录指针表,Page-Directory-Pointer Table,PDPT)的基地址。

线性地址的38:30位作为二级页表的偏移量,利用PDPT基地址+偏移量访问PDPT的相应内容得到三级页表——页目录(Page-Directory,PD)的基地址。

线性地址的29:21位作为三级页表的偏移量,再次将PD的基地址+偏移量访问PD的相应位置的内容,得到四级页表(Page Table,PT)的基地址。

线性地址的20:12位作为四级页表的偏移量,用PT的基地址+偏移量访问PT的相应项,得到对应物理地址的基地址,胜利的曙光就在眼前。

线性地址的11:0位作为偏移量,物理地址+偏移量即可得到目标物理地址。

图 7.6 页表项各位表示的意义

在四级页表模式下,为了访问一次内存,竟然需要额外再访问四次内存,这极大地增加了内存的访问时间。尽管有Cache可以缓存一部分页表,但原本用来存数据的Cache现在要分出一部分用来存页表,不免有些浪费,于是就有了专门划分出来的Cache,这个Cache是专门用于缓存页表的,名为TLB(Translation Lookaside Buffer)。TLB中的每一项都保存了用于地址转换的信息。通过页号,可以访问到TLB对应项(如果有)。如果在TLB中没有找到对应项,就只能亲自访问内存中的页表进行访问了。事实上,TLB与Cache的原理几乎完全一致,正如Cache可以分为指令Cache和数据Cache一样,TLB也可以被认为是地址Cache,这样就不难理解TLB是怎样发挥其作用的了。下图中可以看出TLB与Cache工作模式的相似性。

图 7.7 一次完整的内存访问

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

            在历经千辛万苦,获取到物理地址后,终于可以开始正式访问所需的物理地址了。物理地址可以分为三部分:标记、组索引、块偏移。考虑到空间局部性,每一组Cache中会有很多字节,这些字节在物理内存中都是相邻的。为了弄清楚我们要访问的是哪个字节,必须指定块偏移,这样就确定应该从这一组的哪一块开始访问。

            Cache中当然不止一组,为了确定要访问哪组Cache,必须指定组索引,随后在同组Cache中同时比较标记位是否和访问目标的标记位相同。每行Cache中还会有一个有效位,用1表示这行Cache中存放的内容确实是已缓存的有效内存内容。

图 7.8 Cache组的结构

每次发生(对数据)物理内存访问时,先在L1-d中通过组索引找到Cache的对应组,然后将标记位与组中的每一行同时进行比较,如果某一行的标记位与其相同,并且其有效位为1,那么这时就发生了缓存命中,CPU可以从指定块偏移中开始访问缓存的内存字节。

如果L1-d中没有发生缓存命中,那么CPU就必须进入L2中寻找目标,原理与在L1-d中相同。如果在L2中找到了目标,那么CPU可以将其传入L1-d中,并同时提供给CPU使用。

如果在L2中也没有访问到目标,CPU就不得不进入L3中寻找目标,流程与在L2中一样,如果找到了目标,就向上一级Cache复制内容并提供给CPU,如果没找到就只好宣告三级缓存也不命中,必须访问内存了。在数千个周期的苦苦等待后,内存提供的信息存入L3并提供给CPU,内存访问完成。

图 7.9 Cache系统

7.6 hello进程fork时的内存映射

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

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

图 7.10 私有的写时复制对象工作机制

7.7 hello进程execve时的内存映射

在Shell中运行hello程序时,执行了函数execve(“./hello”, argv, envp);

此时,execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代当前程序。加载并运行hello需要以下几个步骤:

  1. 删除当前进程虚拟地址的用户部分中已存在的区域结构;
  2. 为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域结构都是私有、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名问卷,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零;
  3. 将hello的共享对象映射到用户虚拟地址空间中的共享区域内(动态链接);
  4. 设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

图 7.11 加载器映射的用户地址空间区域

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

缺页故障的产生有两种原因:

  1. 从该线性地址无法得到一个有效物理地址;
  2. 虽然可以得到有效物理地址,但是访问权限过低,不允许访问该物理地址。

缺页中断处理需要CPU与OS共同完成。在发生缺页故障时,CPU将开始执行异常处理程序,这部分可以由OS提供。

图 7.12 缺页故障时产生的故障码及其含义

如上图所示,CPU可以产生故障码,OS利用故障码进行相应的缺页异常处理流程。故障码的最低位P即可指示我们这里讨论的缺页故障产生原因。

对于缺页故障产生的原因2,我们无能为力,因为这可能是程序试图访问不该访问的内存地址(如OS内核、空指针、野指针)造成的错误,我们不应该随便访问别人的空间。OS必须终止这次访问。

图 7.13 缺页异常处理——加载页

对于缺页故障产生的原因1,这说明可能发生了DRAM缓存不命中,这个原因产生的缺页也是我们在虚拟内存中习惯的那个缺页。这时OS内核中的缺页异常处理程序会选择一个牺牲页,将其复制回磁盘,然后从磁盘中复制目标页到牺牲页所在位置,更新页表条目(表明该页已经缓存到了内存中,下次访问不会因为缓存不命中再产生缺页故障),然后返回。此时处理器会重新启动导致缺页的指令,将导致缺页的虚拟地址重新开始转换为物理地址。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk,它指向堆的顶部。

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

            分配器有两种基本风格。两种分隔都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

            显式分配器,要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被内存所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。

隐式空闲链表:

 

图 7.14 堆块的格式

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

图 7.15 隐式空闲链表,阴影为空闲块

     

空闲块是通过头部中的大小字段隐含地连接着的,因此这种结构被称为隐式空闲链表。分配器可以遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块,我们可以采用一个设置了已分配位而大小为零的终止头部。

隐式空闲链表的优点是简单。显著的缺点是任何操作的开销都要求对空闲链表进行搜索,该搜索所需要的的时间与堆中已分配块和空闲块的总数呈线性关系。

显式空闲链表:

由于块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的。一种更好的方法是将空闲块组织为某种形式的显式数据结构。根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。

图 7.16 使用双向空闲链表的堆块格式

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可以是个常数,这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

另一种方式是按照地址顺序来维护链表。其中链表中每个每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

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

分离的空闲链表

维护多个空闲链表,其中每个链表中的块有大致相等的大小。这里采用2的幂作为划分,即{1},{2},{3~4},{5~8},{9,16}…作为等价类。分配器维护一个空闲链表数组void *List[LEN_LIST],数组的每一项都是一个链表,其空间大小按序排列。当分配器需要一个大小为n的块时,它就搜索相应空闲链表。每一个空闲块还需要留出空间保存在链表中上一项和下一项的地址。

分离链表设计如图所示:

图 7.17 分离链表设计示意图

动态内存分配管理的基本方法:

放置已分配的块

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配是从上一次查询结束的地方开始,。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

分割空闲块

一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分会变成分割块,而剩下的一个变成一个新的空闲块。

获取额外的堆内存

如果分配器不能为请求块找到合适的空闲块大小,一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块。如果这样还是不能生成一个足够大的块,那么分配器就会通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化为一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

合并空闲块

当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻,这样可能会形成假碎片。因此任何实际的分配器都必须合并相邻的空闲块。分配器可以选择立即合并,也可以选择推迟合并。

Knuth提出了一种边界标记的技术,允许在常数时间内进行对前面块的合并。

 

图 7.18 使用边界标记的堆块

            这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。通过这个脚部,分配器可以判断前一个块的起始位置和状态。这样就可以方便地对四种情况进行合并:

 

图 7.19 使用边界标记的合并

7.10本章小结

本章介绍了hello的存储器地址空间,以Intel处理器为例,讨论了处理器的逻辑地址到线性地址的变换(段式管理),再从线性地址到物理地址的变换(页式管理)。四级页表增加了对内存的访问次数,利用TLB缓存技术可以大大减少页表的不命中率,使得快速获取物理地址而不实际访问四次内存成为可能,抵消了一部分四级页表带来的开销。程序运行时访问内存的方式具有明显的局部性,为此可以使用Cache技术降低内存访问带来的开销。本章简要介绍了CPU利用三级Cache访问物理内存的模式。本章还简要介绍了fork和execve函数执行时的内存映射,以及在发生缺页故障时的缺页中断处理,最后探讨了一些动态内存管理的基本方法与策略。

8.1 Linux的IO设备管理方法

设备的模型化:文件

在Linux中,设备就像是普通文件一样。用户访问设备就像访问文件一样。设备的访问权限也如同文件的访问权限一样可以设置。管理员可以为每个设备设置相应的访问权限。

设备管理:利用unix io接口,在下节中简要介绍。

8.2 简述Unix IO接口及其函数

Linux内核引出一个简单、低级的应用接口,成为Unix I/O,这使得所有的输入和输出都可以以一种统一且一致的方式来执行。

打开一个已存在的文件或者创建一个新文件:

int open(char *filename, int flags, mode_t mode);

flags参数指明进程如何访问这个文件,flags参数可以是一个或更多位掩码的或,为写提供给一些额外的指示。

返回:若成功则为新文件描述符,出错为-1.

关闭一个打开的文件:

int close(int fd);

fd为文件描述符。若成功关闭则返回0,出错则返回-1.

读取:

ssize_t read(int fd, void* buf, size_t n);

从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。

写入:

ssize_t write(int fd, const void* buf, size_t n);

从内存位置buf复制至多n个字节到描述符fd的当前文件位置。成功时返回一个写的字节数,出错则为-1.

检索关于文件的信息(文件的元数据)

int stat(const char* filename, struct stat *buf);

int fstat(int fd, struct stat *buf);

返回值:若成功则为0,若出错则为-1.

读取目录内容:

DIR *opendir(const char *name);

若成功,则返回处理的指针;若出错,则返回NULL。

关闭打开的目录并释放其所有资源:

int closedir(DIR *dirp);

若成功,则返回0;错误为-1.

8.3 printf的实现分析

实现来自https://www.cnblogs.com/pianist/p/3315801.html

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;

}

查看其调用的vsprintf函数:

int vsprintf(char* buf, const char* fmt, va_list args)

{

    char* p;

    char tmp[256];

    va_list p_next_arg = args;

    for (p = buf; *fmt; fmt++) {

        if (*fmt != '%') {

            *p++ = *fmt;

            continue;

        }

        fmt++;

        switch (*fmt) {

        case 'x':

            itoa(tmp, *((int*)p_next_arg));

            strcpy(p, tmp);

            p_next_arg += 4;

            p += strlen(tmp);

            break;

        case 's':

            break;

        default:

            break;

        }

    }

    return (p - buf);

}

vsprintf的功能是格式化,接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出存入buf,返回要打印出来字符串的长度。

printf紧接着调用write函数,将buf中的i个元素写入I/O设备(感谢设备==文件,现在显示器也可以是文件了)。之后write函数将准备好syscall系统调用,将剩下的任务交给OS:

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

当然这个printf可能只是一般人自己实现的一个微型版本而已,真正的printf会调用__vfprintf_internal函数,而这2000+行的vfprintf-internal.c就只能交给有兴趣的人分析了……

8.4 getchar的实现分析

int

getchar(void)

{

    int result;

    if (!_IO_need_lock(stdin))

        return _IO_getc_unlocked(stdin);

    _IO_acquire_lock(stdin);

    result = _IO_getc_unlocked(stdin);

    _IO_release_lock(stdin);

    return result;

}

getchar的功能是从输入缓冲区中每次读取一个字符,用户可以输入多个字符,但多余的字符仍然会保存在缓冲区中。

getchar等调用read系统函数(再次感谢设备==文件的天才想法),通过系统调用读取按键ascii码,直到接受到回车键才返回。在等待期间进程进入等待态,进程不会被调度为运行态,直到接收到I/O设备的输入。

在用户从键盘输入时,会发生异步异常-键盘中断,OS负责调用键盘中断处理子程序,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。当系统读取到用户敲下回车键后,宣告I/O设备输入完成,OS将进程转为就绪态,等待调度继续运行。

源码中似乎还有对I/O设备加锁的操作,这或许与程序的并发相关,但这超出了我们的讨论范围。

8.5本章小结

本章先介绍了Linux的“一切设备皆文件”的思想,然后简要讨论了一些Unix I/O接口函数,以printf和getchar函数为例窥探了C语言标准库中输入输出实现的冰山一角,不得不感叹一个小小的“printf”背后竟有整个计算机系统齐心协力为其撑腰。

结论

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

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

hello的一生,还要从一个刚学C语言的未来程序员说起。当他敲下return 0;并保存为hello.c文件,按下了IDE界面上运行的按钮时,hello开始了它的一生……

预处理器在hello.c里寻找所有以“#”开头的预处理指令,并进行相应的预处理,生成中间产物hello.i,完成了预处理阶段的工作。

编译器开始了它作为翻译家的工作。它将程序中的每一句C程序代码,都编译为汇编语言,并生成中间产物hello.s。

接力棒接下来交给了汇编器。汇编器将汇编语句逐句转为机器语言,把人话转换成机器可识别的二进制机器指令,生成可重定位目标文件hello.o。

链接器将hello.o和它调用的外部库函数链接起来,并加入了程序的启动代码,生成了可执行文件hello,hello程序准备就绪。链接器可以选择静态链接,也可以选择动态链接。Hello选择了用动态链接,这样就可以在未来以最小的代价用上最新的库函数版本,并且也减小了可执行文件本身的大小。

当用户敲下“./hello 1190200207 LuoYihua 6”时,hello的运行就开始了。Shell调用fork函数,OS为其产生一个子进程,再利用execve函数为hello程序开辟一个崭新的内存空间,并将用户所提供的参数传递给hello程序,加载器将hello加载进内存,设置PC指向程序的启动代码位置。

当OS调度到hello进程时,该程序就占领了宝贵的CPU资源。PC从启动代码开始,根据读入的每一条指令而变化。CPU访问内存的速度非常慢,每访问一次内存就会消耗数千个周期。为了能更快得到程序的指令和数据,CPU将最近访问的内存地址及其附近的内容存入Cache中。“远亲不如近邻”,CPU离Cache更近,访问速度比内存也快很多。

如果一次只能同时运行一个程序,用户会相当不满意。因此当hello用完了其短暂的时间片后,发生了时钟信号中断,OS保存hello进程的程序状态字,开始上下文切换,调度另一个进程获取时间片,hello暂停运行,另一个进程开始占用CPU,开始了它的表演。每秒钟进程切换的次数非常多,用户完全无法感知到hello进程和其他进程竟然暂停过。

利用虚拟内存技术,每个程序在运行时都认为自己拥有独立的内存地址空间,也保护了每个程序的私有内存空间不受其他程序侵犯。CPU在访问内存时,需要先通过段式管理将逻辑地址翻译为线性地址,再利用页式管理将线性地址翻译为物理地址,物理地址才是内存的真正地址。

为了减少页式管理中访问页表带来的额外内存访问开销,就有了TLB,专门用于缓存逻辑地址到物理地址的映射,这样就既享受到了虚拟内存技术的长处,又付出了尽可能小的代价。事实上,TLB是一种特殊的Cache,这种Cache专门用来存放对线性地址的映射,其原理与一般的Cache毫无区别。

在程序的运行过程中,用户可能在Shell里敲入了某些快捷键以发送信号。当OS内核将进程从内核模式切换到用户模式时,检查到该进程接收到了这个信号,于是就会跳转到相应的信号处理程序。

历经千辛万苦,程序终于执行到了printf语句,用户看到了Hello语句。在Linux系统中,“一切设备皆文件”,向屏幕输出内容就相当于对显示设备这个文件的I/O操作。

终于,程序执行完毕,到了谢幕的时候了。进程终止运行,Shell接收到信号,开始回收hello,主动进行操作系统调用,OS清理hello所使用的内存空间,hello完美谢幕,完成了它本次的O2O。hello即人生。

事实上,计算机系统是十分复杂精密的系统。课程中介绍的和本报告中提及的只是沧海一粟而已。每一个部分深挖都是一片汪洋大海。我们还有一个很重要的部分没有提及——并发。并发中还需要考虑对各个资源的占用情况,互斥,还需要考虑Cache的一致性等等。当然,通过这门课的学习,我们还是可以对整个计算机系统一个大致的理解,在今后的学习与工作中,我们遇到困难,也会联系计算机系统的角度解决问题,即使暂时无法解决,也能知道是系统的哪一方面出了问题,这样我们就有了一个方向,可以查找这个方向的相关资料,做进一步的学习,而不是像理解计算机系统之前一样是无头苍蝇到处乱找。

计算机的水很深,你把握不住。

附件

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

文件名

作用

hello.i

预处理后的文件

hello.s

编译后的文件

hello.o

汇编后的可重定位目标文件

hello

链接后的可执行文件

elf_hello.txt

hello的elf头输出

as_hello.s

hello反汇编后的输出

hello_s.txt

hello.o反汇编

参考文献

  1. E. Bryant, David R. O'Hallaron.Computer Systems: A Programmer's Perspective, 3rd Edition[M].Pearson:New York,2015.

[2]https://stackoverflow.com/questions/6656317/linking-a-c-program-directly-with-ld-fails-with-undefined-reference-to-libc-c

[3] A. Silberschatz, P. Galvin, and G. Gagne. Operating Systems Concepts, Ninth Edition. Wiley, 2014.

[4]https://www.gnu.org/software/bash/manual/bash.html

[5]https://www.cnblogs.com/pianist/p/3315801.html

[6]https://www.gnu.org/software/libc/

[7] Intel Corporation. Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 3a: System Programming Guide, Part 3.

[8]沃兹基,HITICS-lab8实验报告

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全评估测试题大模型安全评估测试题关键词库生成内容测试题库应拒答测试题库非拒答测试题大模型安全
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值