HIT计算机系统大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2022111342
班 级 2203601
学 生 宋泱
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
摘要
Hello作为几乎所有程序员都会编写、运行的经典程序,其能够很好地利用C语言较为贴近机器语言的特点,进而为研究计算机程序在系统中的运行过程提供方便。本文即以hello.c程序为例,详细研究介绍其P2P过程,即通过在Linux系统下的预处理、编译、汇编、链接等的过程,全面了解一个程序从诞生到执行再到消亡的典型过程,在理论与实际两方面入手,深入了解C语言程序原理以及计算机系统的相关知识。
关键词:计算机系统;C语言;Hello程序;汇编语言;Linux
目录
第1章 概述
1.1 Hello简介
P2P (From Program to Process) :指从C语言程序文件(Program)hello.c变为一个在计算机中运行的进程(Process)的过程。这一过程是从用户编辑的C语言文件开始,经过预处理、编译、汇编、链接,生成可执行文件hello;在shell中通过./hello运行,shell程序经过分析命令行、初始化环境变量、fork创建进程、execve加载程序,系统为该程序分配内存空间等过程,以hello为基础创建出一个在计算机中运行的进程,即完成了hello.c的P2P过程。
020 (From Zero to Zero) :程序从无开始,经过编写、P2P过程在内存中创建了进程,在运行结束后进程终止、被回收,释放内存,又回到无的过程即为020。
1.2 环境与工具
1.2.1硬件环境:
处理器:12th Gen Intel(R) Core(TM)i9-12900H
RAM:32.0GB
系统类型:64位操作系统,基于x64的处理器
1.2.2软件环境:
Windows11 64位,VMware Workstation, Ubuntu 22
1.2.3开发与调试工具:
Visual Studio 2022, vim objump edb gcc readelf等工具
1.3 中间结果
中间结果文件名 | 含义(作用) |
hello.c | C语言源程序文件 |
hello.i | 预处理结果文件 |
hello.s | 编译生成的汇编语言文件 |
hello.o | 汇编生成的可重定位文件 |
hello | 链接生成的可执行文件 |
hello_O.asm | hello.o反汇编得到的反汇编文件 |
hello.asm | hello反汇编得到的反汇编文件 |
hello.elf | hello.o的elf格式文件 |
hello_OUT.elf | hello的elf格式文件 |
1.4 本章小结
本章首先引入了本文的研究问题,即介绍了P2P和020过程,简要的研究思路;并简要介绍了研究过程的部分产物的名称及其含义、性质;并列出了所用到的研究工具,包括硬件配置、软件环境、测试工具等。
第2章 预处理
2.1 预处理的概念与作用
预处理器是在C语言程序源代码变为可执行文件的过程中,在变为二进制代码之前首先经历的过程;其主要由预处理程序(preprocessor,C语言为cpp预处理器)完成,主要处理#开头的命令,也会处理注释和多余空白字符,以此得到.i结尾扩展名的程序。
其功能主要有:
- 处理#include预编译指令:将所指文件加入源文件中。
- 处理#define:用实际参数值替换
- 处理#if, #elif等
- 删除注释:// 及/*...*/
- 添加行号、文件标识:便于调试
- 保留#pragma指令:以指定某些编译器状态
常见预处理名称:
预处理名称 | 含义 |
#include | 包含其他文件到源文件中 |
#define | 宏定义 |
#undef | 取消宏定义 |
#if;#else;#elif;#endif | 与C语言中类似:通过判断#if后表达式是否为真选择是否编译其与#endif间的代码,#else建立另一选择,#elif则与C语言中else if意义相同,即并列的另一选项。 |
#ifdef;#ifndef | 与#if类似,这二者以是否有宏定义为判断条件 |
#error | 生成一个编译错误,并输出指定的错误消息。 |
#line | 用于修改行号和文件名信息,通常用于调试目的 |
#pragma | 用于向编译器传递特定的指令或控制信息。 |
这些预处理器指令可以在编译之前对源代码进行操作,使得程序在编译时能够根据不同的条件生成不同的代码,提高了代码的灵活性和可移植性。
2.2在Ubuntu下预处理的命令
预处理的命令:cpp hello.c -o hello.i
或者使用gcc编译选项:gcc -E hello.c -o hello.i
其中-E即预处理选项。
运行预处理过程截图如下:
图 1:Linux下预处理命令与结果
图 2:hello.i部分内容
2.3 Hello的预处理结果解析
分析图2中hello.i的内容,可以发现:hello.c文件经过预处理后由24行变为3092行,其中3079行至3092行为C语言main函数:
图 3:hello.i文件中main函数部分
main之前为hello.c在预处理命令中引用的头文件stdio.h等的内容,同时原程序中注释也被去除。
以stdio.h为例,预处理器遇到#include <stdio.h>预处理命令时,其在头文件路径下查找stdio.h文件,将其内容复制到源文件中。接着解析下一条include指令,将其指定的文件依次复制进源文件中。其他命令也有其处理流程。
2.4 本章小结
本章首先介绍了预处理的概念和作用,并在Linux系统下,用gcc选项对源程序hello.c进行了预处理得到了中间产物hello.i文件,并通过阅读其内容简要了解了预处理的某些过程、原理,对比源程序发现其处理了预处理命令,删除了注释,引入了要求的库文件。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
在程序设计中,编译是指将预处理后的源代码转换为汇编代码的过程,是预处理的下一步骤。此处编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序的过程。
3.1.2编译的作用
编译过程提高编程效率和可移植性;在编译过程中,编译器将对预处理后的源代码进行词法分析、语法分析、语义分析、优化和代码生成等步骤,最终生成汇编代码或目标代码。
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
图 4:Linux下编译命令及结果
3.3 Hello的编译结果解析
图 5:hello.s文件部分内容
可以发现该汇编语言程序行数为85,下面分阶段分析汇编代码内容。
3.3.1初始部分
汇编代码开始一部分显示了文件的一些信息:
.file | 显示源文件 |
.text | 指代码节 |
.section .rodata | 表示只读数据段 |
.align | 声明对指令或者数据的存放地址进行对齐的方式 |
.string | 声明一个字符串 |
.globl | 声明全局变量 |
.type | 声明一个符号的类型 |
3.3.2数据部分
3.3.2.1程序中有两个字符串常量,存于只读数据段中:
汇编程序中,对两个字符串处理如下:
- "用法: Hello 学号 姓名 手机号 秒数!\n"
汇编程序:leaq .LC0(%rip), %rax
movq %rax, %rdi
将rax设置为字符串起始地址,传给rdi,后续传入puts函数
- Hello %s %s %s\n
汇编程序:leaq .LC1(%rip), %rax
movq %rax, %rdi
将rax设置为字符串起始地址,传给rdi,后续传入printf函数
3.3.2.2整数:
- 局部变量i:
如上图,这一段程序中,i存于栈中rbp-4位置,赋初值0;
是在循环末端,56行为i++,58行则比较其与9大小,小于等于则跳转继续循环。
- main参数int argc
汇编代码:
这一段程序中,argc由edi(main第一个参数)传入rbp-20的栈位置存储,并比较其与立即数5的关系,若不相等则输出第一个字符串即介绍程序用法;若相等则跳转继续执行输出代码,即分支控制。
- main参数数组char *argv[]
汇编代码:
与上述argc存储代码相邻,在23行argv存于rbp-32的栈位置;由addq $8, %rax可看出每个地址为8字节(Linux中)。
- 立即数
显然,从上述代码中可以看出程序中常量都以立即数形式在代码段出现。
3.3.2.3全局函数:
hello.c中仅一个全局函数int main(int argc,.char*argv[]),查看汇编代码,其中
.global main即为存储区域。
3.3.3赋值部分
hello.c程序中赋值操作有for中的i=0;查看汇编代码:
其由movl赋值为0。由于其为一int型变量,用movl传送双字实现。
3.3.4类型转换:
由atoi函数实现,将argv[4]中的字符串转为整型数值以传入sleep函数。
3.3.5算术操作:
hello.c程序中的算术操作有for循环中每次循环结束后的i++;如上图汇编代码:
发现其在56行,循环节结束时由addl加上立即数1实现,由于i是int型,用addl加双字实现。
3.3.6关系操作:
程序中有两处关系操作:
- if(argc!=5):
条件判断语句,查看汇编程序:
使用cmpl比较立即数5和参数argc大小,设置条件码,后续je指令在argc等于5条件下跳至.L2,否则继续执行。
- for(i=0;i<10;i++):
是for循环的结束条件判断,汇编程序:
使用cmpl比较局部变量i与立即数9的大小(i<10由编译器变为i<=9),并改变条件码,后续jle指令在i<=9的条件下跳转.L4,否则继续执行。
3.3.7数组操作
本程序中数组即参数argv,汇编程序:
可以发现其存于栈中,使用时传出再转移到参数寄存器中使用。
3.3.8控制转移指令
程序中存在两处控制转移:
- if(argc!=5):
判断argc是否为5,根据cmpl设置的条件码,用je指令在argc等于5条件下跳至.L2,否则继续执行。
- for(i=0;i<10;i++):
首先程序在为i赋值0后无条件跳至.L3执行程序;
其次在每次循环结束时,比较i与9,根据cmpl设置的条件码,用jle指令在i小于等于9条件下跳至.L4继续循环内程序,否则循环体结束,继续执行后续指令。
3.3.9函数操作
- main函数:
参数:参数为int argc,,char*argv[],这两个参数传递方式、存储位置已在3.3.2数据部分中介绍过。
调用:main函数被系统启动函数 __libc_start_main 调用,call指令将main函数的地址分配给%rip,随后调用main函数。main函数的两个参数argc, *argv[](首地址)分别储存于%rdi 和%rsi (在数据部分介绍过)。正常运行时,函数以return 0为出口,将%eax 设置 0 返回。局部变量已经介绍过。
- printf函数:
该函数调用了两次。
第一次:参数为字符串”用法: Hello 学号 姓名 手机号 秒数!\n”,用rdi传入起始地址.LC0;由main主函数在argc不等于5时调用,介绍程序用法。正常返回。编译器将其优化为puts。
第二次:汇编如图:
参数为字符串"Hello %s %s %s\n"、argv[1],argv[2],argv[3],字符串用rdi传入起始地址.LC1;argv[1],argv[2],argv[3]用rsi、rdx、rcx从栈中赋值、传入。由主程序在i=5时循环调用,打印信息。正常返回。
- exit函数:
如图,参数1用rdi传入,表示错误结束,参数数量不对。结束程序。
- atoi函数:
如图,参数用rdi从栈处赋值并传入。返回变为整型的数值。
- sleep函数:
如上图,参数用edi由eax传入,其为上述atoi函数的返回值存在rax中。正常返回。
- getchar函数:
无参数,直接调用。
3.4 本章小结
本章首先介绍了编译的概念和作用,并在Linux系统下用gcc对hello.i进行编译得到汇编语言程序hello.s,并详细研究了其内容,归纳了初始、数据处理、赋值、类型转换、算术、关系、数组、控制跳转和函数调用等方面。结合源代码详细分析其在汇编代码中如何实现,加深了汇编语言的掌握程度,有了更深的理解。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编概念
程序设计中,汇编是编译的下一步,其将汇编代码翻译为机器语言指令,并打包为一可重定位目标文件格式,即从.s文件变为.o文件(后者为一个二进制文件)。
此处汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.4.2汇编作用
汇编指的是将高级的语言如汇编语言转化成机器可以识别的二进制机器指令并打包为可重定位目标文件的过程。
其作用除了定义中的转换功能,还有:
- 提供更接近底层硬件的编程接口,可直接操作计算机硬件资源;
- 允许程序员对代码执行进行更加精准的控制,进行手动优化;
- 提供了一种理解高级语言代码背后底层运行机制的方法,有助于程序员理解程序工作原理和性能特征。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -m64 -no-pie -fno-stack-protector -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
输入命令:readelf -a hello.o > hello.elf获得hello.o的elf格式。
以下为其内容分析:
4.3.1 ELF头(ELF header)
如图,ELF头以一个Magic Number开头,其为一个十六字节的十六进制序列,用于识别文件格式,这个序列描述了生成该文件的系统的字的大小和字节顺序;其余部分包括帮助链接器分析程序的某些信息,如文件的位模式(32位或64位)、文件的大小端序(小端)、文件的版本号(目前为1)、机器类型(X86-64)、节头部表文件偏移、各条目大小数量等。
4.3.2 节头(section header)
如图,每个节都包含了特定类型的数据,例如代码、数据、符号表等。节头部提供了关于每个节的描述,包括节的名称、类型、大小、偏移量、地址、全体大小等信息。
4.3.3重定位节
如图,.rel.text是一个.text节的重定位信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般来说,调用本地函数的指令不虚修改,依靠相对于起始地址偏移量计算,而对外部函数的调用则需要修改:当可执行文件被加载到内存中时,它可能被加载到的地址与编译时指定的地址不同。因此,需要进行重定位,使得程序能够正确地在内存中执行。
重定位节包含了一系列的重定位条目,每个重定位条目指定了一个需要进行重定位的地址以及如何进行重定位。
4.3.4 符号表
如图,即.symtab表,用于存储程序中定义的各种符号以及与这些符号相关的信息,包括符号的名称、类型、绑定、所在节的索引等。常见的符号类型包括函数、变量、外部符号等。该符号表不包含局部变量信息。
4.4 Hello.o的结果解析
4.4.1命令
输入命令:objdump -d -r hello.o > hello_O.asm得到hello.o的反汇编文件,与hello.s对比分析。
4.4.2对照分析
4.4.2.1 程序语言
每一条指令加上了一个十六进制的串,为该指令的机器语言,同时在段前。语句前加上了地址与相对偏移。
4.4.2.2 数据
所有立即数、参与运算的操作数都被转为了十六进制,如-32(%rbp)变为-0x20(%rbp),$5变为$0x5。
4.4.2.3 分支跳转
反汇编的跳转指令中,目标位置变为了主函数+偏移量的形式,而非.s中的段名称(如.L3)。如上图左侧33行jmp .L3变为右侧25行jmp 91 <main+0x91>。
4.4.2.4全局变量访问
反汇编对于全局变量访问变为了0x0(%rip),.s文件中则是.LC0,这是由于运行时全局变量位置已经确认。如上图左侧26行leaq .LC0(%rip), %rax变为右侧16行lea 0x0(%rip),%rax # 20 <main+0x20>。
4.4.2.5函数调用
反汇编代码中用相对于main函数的偏移地址表示函数地址,而不是hello.s中的函数名称。如上图左侧.s中为call exit@PLT,右侧反汇编为call 32 <main+0x32>,在可重定位文件中call后面不再是函数名称,而是一条重定位条目指引的信息。
4.5 本章小结
这一章介绍了汇编这一过程的功能,并通过反汇编这一手段了解了汇编过程为程序产生的变化,以hello.s到hello.o及其反汇编文件hello_O.asm为例,分析了汇编代码与反汇编代码,简单解析了每个节的内容,对比了二者的区别,了解了其原因,揭示了汇编语言到机器语言的转变过程,同时初步探索了机器为链接做的准备,受益匪浅。
第5章 链接
5.1 链接的概念与作用
5.1.1链接概念
在程序设计中,链接(Linking)是编译过程中的最后一个阶段,它负责将多个目标文件(包括汇编生成的目标文件、C源码编译生成的目标文件、库文件等)合并成一个完整的可执行文件,这个文件可被加载到内存并执行。这一过程可以发生在形成可执行文件过程中(静态);也可以发生在加载时乃至运行时(动态)。
5.1.2链接作用
链接由链接器(Linker)完成,这一功能使得程序员可以将巨大的源文件分离为一个个小的模块分别完成,而不需要每次都进行修改整个部分,更容易管理,修改其中一个模块时只需重新编译,并链接应用。
在程序方面,它的功能包括符号解析、符号重定位、目标文件合并、库文件链接、地址空间分配等内容,最终生成可执行文件或共享库。
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
5.3 可执行目标文件hello的格式
用readelf指令生成hello的elf格式文件:readelf -a hello > hello_OUT.elf
5.3.1 ELF头
与上文中hello.o的elf格式类似,包含的信息种类基本相同,同样包含了Magic Number等帮助链接器分析的信息,其中基本信息如系统、大小端等未有改变,而下方类型信息有所改变:
文件类型发生改变:由REL(可重定位文件)变为EXEC(可执行文件)
程序入口点:由0x0变为0x4010f0,此外还有程序开始点、节头表偏移等改变
节头表增加到27个。
5.3.2节头
包含了关于文件中各个节(Section)的信息。每个节都包含了特定类型的数据,例如代码、数据、符号表等。节头部提供了关于每个节的描述,包括节的名称、类型、大小、偏移量等信息。
5.3.3程序头
程序头部分是一个结构数组,用于描述可执行文件在内存中的布局和加载信息。程序头表包含了一系列的程序头条目,每个程序头条目描述了一个段(Segment)的相关信息。
5.3.4 动态节
它包含了与程序的动态链接和运行时环境相关的信息。动态节是一个可选的节,如果可执行文件需要使用动态链接库(dynamic shared objects,DSOs)或者具有其他动态链接需求,则会包含该节。
5.3.5符号表
符号表(Symbol Table Section)用于存储程序中定义的各种符号以及与这些符号相关的信息。符号表部分通常包含两个主要部分:符号表和字符串表。
5.4 hello的虚拟地址空间
观察上文程序头的LOAD可加载的程序段的地址为0x400000。
用edb打开:
对照上文节头表信息可以找到启示地址,如.text段位于0x4010f0处:
可以找到其信息。
5.5 链接的重定位过程分析
分析hello与hello.o的区别:
使用objdump命令得到hello的反汇编文件hello.asm:
objdump -d -r hello > hello.asm
分析得到以下结果:
5.5.1代码量
代码量在链接后显著增加,主要表现在函数增多:多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码,因为链接器将共享库中hello.c调用的库函数加入了可执行文件中。
5.5.2函数调用地址变化
call指令的参数发生变化:十六进制机器代码中的操作数位置被链接器变为目标地址与下一条指令地址之差,从而得到完整的代码。
5.5.3 jmp地址发生变化
与call类似,链接器计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
下面分析重定位过程:
大体分两步:
- 重定位符号定义:链接器需要确定文件对应的符号定义。这包括在已经编译的目标文件中查找符号定义,以及在链接器的符号表中查找外部符号定义。合并相同的节:链接器首先将所有相同类型的节合并成为同一类型的新节,例如所有文件的.data节合并成一个新的.data节,合并完成后该新节即为可执行文件hello的.data节。
- 重定位符号引用:链接器利用重定位条目修改代码节和数据节中对于符号的引用,使之指向正确的地址。
5.6 hello的执行流程
5.6.1过程:
用edb调试,记录每次进入的函数:
开始:_start、_libe_start_main
main执行:_main、printf、_exit、_sleep、getchar
退出:exit
5.6.2子程序名与地址:
子程序名 | 地址 |
_start | 0x4010f0 |
main | 0x401125 |
_printf | 0x4010a0 |
_sleep | 0x4010e0 |
_getchar | 0x4010b0 |
_exit | 0x4010d0 |
5.7 Hello的动态链接分析
动态链接(Dynamic Linking)是一种在程序运行时将可执行文件与它所依赖的动态链接库(Dynamic Link Libraries,DLLs)或共享对象(Shared Objects)进行链接的机制。相比静态链接,在程序运行之前就将所有的依赖库链接到可执行文件中,动态链接的方式使得程序在运行时能够动态地加载和链接依赖库,从而提供了一些重要的优势。
查询GOT表:
起始表位置:0x404000
用gdb调试,调用_init前查看0x404000处信息:
程序进入_init后:
发现数据发生变化。
对于库函数链接过程,需要plt、got合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。
5.8 本章小结
本章首先介绍了链接这一过程的作用,其将程序从可重定位目标文件变为可执行文件,并通过阅读elf、反汇编等方式深入研究了其过程,在调试中探索,富有实践意义,加深了理解。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程概念:
进程是操作系统中的一个核心概念,它代表了正在运行的程序的实例。每个进程都有自己的地址空间、内存、资源、以及操作系统所需的其他数据结构。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2进程作用:
进程为程序提供了一种假象,程序好像是独占的使用处理器和内存,处理器好像是无间断地一条接一条地执行我们程序中的指令。进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中。进程是实现并发执行、资源管理、进程间通信、安全隔离和系统调度的基础单位,为操作系统提供了强大的功能和灵活性。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1壳Shell-bash的作用
Shell是一个交互型应用级程序,也被称为命令解析器,它为用户提供一个操作界面,接受用户输入的命令,并调度相应的应用程序。
6.2.2 Shell-bash的处理流程
首先从终端读入输入的命令,对输入的命令进行解析,如果该命令为内置命令,则立即执行命令,否则调用fork创建一个新的子进程,在该子进程的上下文中执行指定的程序。判断该程序为前台程序还是后台程序,如果为前台程序则等待程序执行结束,若为后台程序则将其放回后台并返回。在过程中shell可以接受从键盘输入的信号并对其进行处理。
6.3 Hello的fork进程创建过程
以hello可执行文件为例:
用户在shell中输入指令:./hello 2022111342 宋泱 13663496280 0
shell读入该指令字符串后经parseline函数分解,发现第一个参数不是内置命令,则父进程调用fork函数创建一个新进程,该子进程完全复制父进程虚拟地址空间,包括代码和数据段、堆、共享库以及用户栈,但具有不同的pid。在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。
6.4 Hello的execve过程
上述fork创建进程后,在该子进程中使用execve函数来加载命令要求的程序hello。execve声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载运行可执行文件filename,并带有参数列表argv[]和环境变量envp[]。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并不返回。
其步骤如下:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。即删除之前shell运行时已经存在的区域结构
- 映射私有区域。为hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域。hello程序与共享对象链接,比如标准C库1ibc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
6.5.1相关概念
时间片:时间片是指操作系统分配给每个进程使用 CPU 的时间段。大小取决于操作系统的调度算法和系统的配置。
用户模式和内核模式:计算机处理器在执行指令时可以处于两种不同的特权级别:用户模式和内核模式(也称为特权模式、系统模式或管态)。用户模式是进程执行时的默认模式,进程只能访问自己的地址空间和有限的系统资源,不能直接访问操作系统的核心功能或特权指令。内核模式是操作系统内核运行时的特权模式,操作系统可以访问系统的所有资源和功能,包括硬件设备、系统调用和内核数据结构。
控制流:控制流是程序执行过程中指令的执行顺序。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
6.5.2调度过程:
6.6 hello的异常与信号处理
正常运行:
6.6.1异常分类:
类别 | 原因 | 异步、同步 | 返回行为 |
中断 | 来自IO设备 | 异步 | 下一条指令 |
陷阱 | 有意的异常 | 同步 | 下一条指令 |
故障 | 潜在可恢复错误 | 同步 | 可能返回当前指令或终止 |
终止 | 不可恢复错误 | 同步 | 不返回 |
6.6.2处理方式:
中断:
陷阱:
故障:
终止:
6.6.3运行结果
- 正常运行:
以任意输入后键入回车为结束标志,回收进程,shell返回。
- 按下Ctrl+C:
按下Ctrl+C,进程收到SIGINT信号,Shell结束并回收hello进程。
- 按下Ctrl+Z:
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
用jobs命令查看,发现hello进程仍存在:
ps/pstree类似,前者显示进程,后者以树状图显示:
- 输入kill可以杀死指定进程:
- 输入fg 1 则将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
- 乱按:
可以发现乱按的字符均在缓冲区中,直至读入了’\n’
6.7本章小结
本章探讨了上文得到的可执行文件在计算机中的执行过程,深入研究了shell的运行原理,介绍了进程的概念和作用,详细分析了hello程序的进程创建、启动和执行过程,还分析了可能出现的异常情况,针对其进行了分析解释,完成了从C语言程序到可执行文件的过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
在有地址变换功能的计算机中,逻辑地址是由 CPU 生成的虚拟地址,用于访问进程中的内存位置,当进程执行时,它使用逻辑地址来访问内存中的指令和数据,而不需要关心这些地址在物理内存中的位置。逻辑地址是相对于进程而言的,每个进程都有自己的逻辑地址空间。
7.1.2线性地址(虚拟地址)
线性地址是指逻辑地址经过内存管理单元(MMU)转换后得到的地址,也称为虚拟地址。内存管理单元通过将逻辑地址转换为线性地址,以便操作系统能够对进程的内存访问进行管理和控制。
虚拟地址需要通过页部件电路转化为最终的物理地址。虚拟地址是CPU由N=2^n个地址空间中生成的,虚拟地址即为虚拟空间中的地址。
7.1.3物理地址
物理地址是指内存中存储数据的实际地址,是存储器芯片上的物理位置。无论CPU如何处理地址,最终访问的都是物理地址。CPU实模式下段地址+段内偏移地址即为物理地址,CPU可以使用此地址直接访问内存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是一种将内存地址空间划分为多个段的管理方式,每个段可以具有不同的大小和属性。段式管理是通过段表进行的,包括段号(段名),段起点,装入位,段的长度等。程序通过分段划分为多个块,如代码段,数据段,共享段等。
一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。其中前13位是一个索引号,后3位为一些硬件细节。索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。
全局描述符表(GDT)整个系统只有一个,它包含:(1)操作系统使用的代码段、数据段、堆栈段的描述符(2)各任务、程序的LDT(局部描述符表)段。
每个任务程序有一个独立的LDT,包含:(1)对应任务/程序私有的代码段、数据段、堆栈段的描述符(2)对应任务/程序使用的门描述符:任务门、调用门等。
段式管理图示如下:
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,如果发生缺页,则从磁盘读取。
MMU利用页表来实现从虚拟地址到物理地址的翻译。
下面为页式管理的图示:
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MMU,MMU使用VPN高位作为TLBT(TLB标记)和TLBI(TLB组号),向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。若页表中没有该页则MMU触发缺页异常,确定牺牲页后从磁盘读出至主存并建立页表映射。工作原理如下:
多级页表的工作原理展示如下:
7.5 三级Cache支持下的物理内存访问
CPU发送一条虚拟地址VA,经过上述过程得到物理地址PA,分为CT(cache标记),CI(cache组号)和CO(cache块偏移)三段,根据组号找到组,比较标记位是否正确、标记位是否有效,命中则直接返回数据,不命中则依次前往下级cache,以及主存判断,最终取出数据。同时根据替换策略将数据换入不命中的cache。如下为一cache结构图:
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_ struct、.区域结构和页表的原样副本。它将两个进程中每个页面标记位只读,将每个区域结构标记为私有的写时复制。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个。后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、.bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域。hello程序与共享对象1ibc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。如图所示:
7.8 缺页故障与缺页中断处理
如果程序执行过程中MMU触发了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
(1)检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
(2)检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
(3)两步检查都无误后,如果存在空闲主存页面,则直接调入;若没有空闲页面,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
动态内存管理是指在程序运行时动态地分配和释放内存空间,以满足程序的需求。以下是动态内存管理基本方法、策略。
动态内存分配器(dynamic memory allocator)更为方便,具有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组大小不同的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是以分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,两种风格都要求应用显式的分配快。它们的不同之处在于由哪个实体来负责释放已分配的快。
显式分配器,要求应用显式地释放任何已分配的块。例如C标准库提供一种叫malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作和C中的malloc和free相当。
隐式分配器,另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。
7.10本章小结
本章主要介绍了hello的存储器地址空间组织方式,介绍了其相关组织方法,如intel的段式管理、hello的页式管理,以intel Core i7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射、hello进程、execve时的内存映射等,加深了对计算机系统知识中存储空间管理的理解。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化——文件:文件是Linux中对IO设备的抽象,一个linux文件就是一个m字节的序列,所有I/O设备都被模型化为文件,所有的输入和输出都被当作对应文件的读和写来执行。文件有以下几种类型:
类型 | 描述 |
普通文件 | 包含任意数据,如文本文件和二进制文件。 |
目录 | 包含一组链接的文件,每个链接都将一个文件名映射到一个文件 |
套接字 | 用来与另外一个进程进行跨网络通信的文件 |
其他文件 | 命名通道,符号链接,字符和块设备 |
设备管理——unix io接口:使得所有的输入和输出都能以统一且一致的方式进行。有以下几种操作:
操作 | 描述 |
打开文件 | 应用程序通过要求内核打开相应文件来宣告它想要访问一个I/O设备 |
改变当前文件的位置 | 应用程序通过执行seek操作显式设置文件当前位置为k |
读写文件 | 读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n 写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始然后更新k |
关闭文件 | 内核收到关闭文件的请求后就释放文件打开时创建的数据结构并将这个描述符恢复到可用的描述符池中 |
8.2 简述Unix IO接口及其函数
8.2.1Unix IO接口:
打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。
改变当前文件的位置:对于每个打开的文件,内核保持着一个文件位置k、初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2.2函数
open():
函数 | open() |
功能 | 打开一个已经存在的文件或是创建一个新文件 |
函数原型 | int open(const char *pathname,int flags,int perms); |
返回值 | 成功:返回文件描述符 |
失败:返回-1 |
read():
函数 | read() |
功能 | 从文件读取数据,执行输出 |
函数原型 | ssize_t read(int fd, void *buf, size_t count); |
返回值 | 正常读取:读取的字节数 |
异常:0(读到EOF);-1(出错) |
write():
函数 | write() |
功能 | 向文件写入数据 |
函数原型 | ssize_t write(int fd, void *buf, size_t count); |
返回值 | 写入成功:写入文件的字节数 |
出错:-1 |
close():
函数 | close() |
功能 | 关闭一个被打开的的文件 |
函数原型 | int close(int fd); |
返回值 | 成功:0 |
出错:-1 |
lseek():
函数 | lseek() |
功能 | 用于在指定的文件描述符中将文件指针定位到相应位置 |
函数原型 | off_t lseek(int fd, off_t offset,int whence); |
返回值 | 成功:返回当前位移 |
失败:-1 |
8.3 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()函数将变长参数的指针arg作为参数,传给vsprintf函数。然后vsprintf函数解析格式化字符串,调用write()函数。
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);
}
函数最后返回的是打印字符串的长度,也就是说这句话得到了字符串的长度i在下一句传给write函数。write函数如下:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,最后,write函数调用syscall(int INT_VECTOR_SYS_CALL)。syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。
字符显示驱动子程序:从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;
}
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要阐述了Linux的IO设备管理方法,介绍了常见函数的接口、功能;具体分析了getchar()和printf()函数的实现。
结论
hello经历的过程:
程序员编辑并保存hello.c后,依次经过以下步骤:
- 预处理(cpp):将hello.c进行预处理,将文件调用的所有外部库文件合并展开,去除注释,生成一个经过修改的hello.i文件。
- 编译(ccl):将hello.i文件翻译成为一个汇编语言文件hello.s。
- 汇编(as)。将hello.s翻译成为一个可重定位目标文件hello.o。
- 链接(ld)。将hello.o文件和可重定位目标文件和动态链接库链接起来,生成一个可执行目标文件hello。
- 运行。在shel1中输入./hello 2022111342 宋泱 13663496280 0并回车(秒数=手机号%5)。
- 创建进程。终端判断输入的指令不是shell内置指令,于是调用fork函数创建一个新的子进程,读入后续参数。
- 加载程序。shell调用execve函数,带入参数,启动加载器,映射虚拟内存,然后进入main函数。
- 执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
- 访问内存:计算机按照其管理办法将虚拟地址映射为物理地址,并以此访问存储空间。
- 信号管理:当程序在运行的时候输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
- 终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构,释放内存。程序在进程中复归于0.
感悟:
在本次大作业中,我以hello这一程序为例,全面联系了计算机系统课程中诸多章节的知识点,一步步完成了从C语言程序到运行完成的整个过程,受益匪浅,深刻体会到计算机系统的整体性、严谨性与方便性,程序员的简单操作是建立在现代计算机这一精密强大的机器的基础上的,我的程序员之路任重而道远,不仅要在程序编写上下功夫,更要向下深入计算机底层逻辑,有百利而无一害。
附件
文件名 | 功能 |
hello.c | 源程序文件(C语言) |
hello.i | 预处理得到的文本文件 |
hello.s | 编译得到的汇编语言文件 |
hello.o | 汇编得到的可重定位目标文件 |
hello_O.asm | hello.o的反汇编文件 |
hello.elf | hello.o的elf格式文件 |
hello | 最终生成的可执行文件 |
hello_OUT.elf | hello的elf格式 |
hello.asm | hello的反汇编文件 |
参考文献
- Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
- https://www.cnblogs.com/buddy916/p/10291845.html
- https://www.cnblogs.com/pianist/p/3315801.html
- https://www.cnblogs.com/fanzhidongyzby/p/3519838.html
- Ubuntu系统预处理、编译、汇编、链接指令_ubuntu脚本对数据预处理-CSDN博客