ICS大作业论文

计算机系统

大作业

题 目 程序人生-Hello’s P2P

专 业 数据科学与大数据技术

学   号

班   级

学 生

指 导 教 师

计算机科学与技术学院

2024年5月

摘 要

本文详细阐述了C语言程序“hello”从编写C代码开始,经过预处理、编译、汇编、链接等步骤,最终形成可执行文件的过程。随后,文章进一步描述了该可执行文件如何被操作系统加载,创建进程,执行程序,直至进程结束的全生命周期。通过这一过程,文章展示了Linux系统中程序从代码到进程的完整转换(Program to Process,简称P2P)以及从零到零的过程(Zero to Zero,简称020)。通过对“hello”程序生命周期的深入剖析,揭示了计算机系统底层的工作原理。

关键词:计算机系统;编译系统;进程管理;存储管理;IO管理

目 录

第1章 概述 - 4 -

1.1 Hello简介 - 4 -

1.2 环境与工具 - 4 -

1.3 中间结果 - 5 -

1.4 本章小结 - 5 -

第2章 预处理 - 6 -

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

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

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

2.4 本章小结 - 9 -

第3章 编译 - 10 -**

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

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

3.3 Hello的编译结果解析 - 10 -

3.4 本章小结 - 15 -

第4章 汇编 - 16 -

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

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

4.3 可重定位目标elf格式 - 16 -

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

4.5 本章小结 - 24 -

第5章 链接 - 25 -

5.1 链接的概念与作用 - 25 -

5.2 在Ubuntu下链接的命令 - 25 -

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

5.4 hello的虚拟地址空间 - 29 -

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

5.6 hello的执行流程 - 32 -

5.7 Hello的动态链接分析 - 34 -

5.8 本章小结 - 35 -

第6章 HELLO进程管理 - 37 -

6.1 进程的概念与作用 - 37 -

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

6.3 Hello的fork进程创建过程 - 37 -

6.4 Hello的execve过程 - 38 -

6.5 Hello的进程执行 - 38 -

6.6 hello的异常与信号处理 - 40 -

6.7本章小结 - 46 -

第7章 HELLO的存储管理 - 47 -

7.1 hello的存储器地址空间 - 47 -

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

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

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

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

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

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

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

7.9动态存储分配管理 - 53 -

7.10本章小结 - 55 -

第8章 HELLO的IO管理 - 58 -

8.1 Linux的IO设备管理方法 - 58 -

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

8.3 printf的实现分析 - 60 -

8.4 getchar的实现分析 - 61 -

8.5本章小结 - 62 -

结论 - 64 -

附件 - 64 -

参考文献 - 66 -

第1章 概述

1.1 Hello简介

1.1.1 Hello的P2P(From Program to Process)
在这里插入图片描述

图 1-1编译系统

首先,hello作为一个程序(Program),最初以源代码的形式存在于.c文件中(例如hello.c)。程序员通过编辑器(Editor)编写并保存源代码。

然后,经过预处理、编译、汇编、链接的过程,将原始的C程序hello.c被转化为可执行文件hello,随后将可执行程序hello通过shell加载到内存中,建立进程。

在预处理阶段,预处理器(cpp)负责处理#include指令,将头文件内容插入程序文本中,同时进行宏替换,得到hello.i文件;

在编译阶段,编译器(cc1)将C代码转化为汇编代码,形成文本文件hello.s;

在汇编阶段,汇编器将hello.s翻译成为机器语言指令,并将这些指令打包,形成可重定位目标程序的形式,并将结果存在hello.o中;

在链接阶段,链接器(ld)将程序中使用过的库函数所在目标文件与hello.o进行合并,得到一个可执行文件。

最终,shell调用fork函数和execve函数,创建进程,在进程上下文中运行这个程序,完成加载入内存并运行的任务。

1.1.2Hello的020(From Zero-0 to Zero-0)

020的过程,指的是一个程序在运行过程中从无到有再到无的过程。主要分为以下几个阶段:

1.创建进程:Shell创建进程,通过fork函数为hello生成子进程。

2.加载可执行文件:子进程调用execve函数加载hello的可执行文件,建立虚拟地址空间等进程上下文,这是hello从无到有的过程。

3.执行进程:hello进程在虚拟地址空间中开始运行,然后将会经历异常、信号、存储器访问等机制,其中包括对TLB、页表、Cache的一系列操作,并与IO端口和外设交互。

4.结束程序: hello进程正常退出或收到信号后终止,操作系统结束hello进程,释放占用的资源。

5.回收程序:操作系统内核删除与hello相关的数据结构,释放其占据的资源。

1.2环境与工具

1.2.1 硬件环境

  • X64 CPU
  • 2GHz
  • 2G RAM
  • 256GHD Disk 以上

1.2.2 软件环境

  • Windows10 64位
  • Vmware 17
  • Ubuntu 18.04 LTS 64位

1.2.3 开发工具

  • Visual Studio 2022 64位

  • CodeBlocks

  • gcc、g++

  • vi/vim/gedit

    1.3中间结果

    表 1-1 中间结果

    文件名功能
    hello.i预处理后得到的文本文件
    hello.s编译后得到的汇编语言文件
    hello.o汇编后得到的可重定位目标文件
    hello.o.elf用readelf读取hello.o得到的ELF格式信息
    obj_hello.o.s反汇编hello.o得到的反汇编文件
    hello.elf由hello可执行文件生成的.elf文件
    obj_hello.s反汇编hello可执行文件得到的反汇编文件
    1. 本章小结

      本章通过简单介绍hello.c程序一生中的P2P过程和020过程,展示了一个源程序是如何经过预处理、编译、汇编、链接等阶段,生成各种各样的中间文件,最终成为一个可执行目标文件的。本章也介绍了本次实验所用到的硬件环境、软件环境以及开发工具等。

      第2章 预处理

      2.1 预处理的概念与作用

      2.1.1预处理的概念

      预处理是指预处理器(cpp)依据以字符#开头的命令,修改原始的C程序的过程。预处理在编译前进行,具体包括宏替换、文件包含、条件编译等操作。

      2.2.2预处理的作用

      1.头文件展开过程:预处理器读取头文件内容,并将其直接插入程序文本中。使用#include指令将其他文件的内容直接插入程序文本中,方便代码的组织和复用,实现模块化设计。

      2.去注释过程:预处理器将程序的注释部分全部替换成空格。删除程序中的注释和多余的空白字符,可以减小程序文件的大小,提高代码的紧凑性。

      3.宏替换过程:通过#define定义宏,预处理器将程序所使用的宏定义在源代码出现的任何位置都进行替换;

      4.条件编译过程:利用#if、#ifdef、#ifndef、#else、#elif、#endif等指令进行条件编译,预处理器会对使用条件编译的代码块进行条件判断,若条件满足则保留,否则将代码裁剪,使其不在后续阶段被编译。根据条件选择性地包含或排除部分代码,可用于实现跨平台、不同配置的代码适配。

      5.其他预处理指令:使用#pragma等指令提供编译器相关的信息,如禁用某些警告、优化选项等。

      预处理阶段是编译过程的第一步,它确保了源代码在编译前已经准备好,并且符合编译器的要求。预处理后的代码通常被称为“预处理后的源代码”或“扩展后的源代码”,然后才会被编译器进一步处理。

      2.2在Ubuntu下预处理的命令

预处理命令:gcc -E hello.c -o hello.i

键入命令后得到预处理后的文件hello.i。
在这里插入图片描述

图 2-1预处理指令

在这里插入图片描述

图 2-2预处理的得到的hello.i

2.3 Hello的预处理结果解析

整体看来,hello.i文件与hello.c文件相比变大,3061行。hello.i文件把include格式包含的文件复制到了main函数之前,include文件的源代码在文件前面部分,源代码在后面,而且删除了hello.c 文件的注释部分。
![在这里插入图片描述](https://img-blog.csdnimg.cn/direct/96b5fda054654f9bb59f3f764142ac50.png#pic_center
图 2-3 源代码

具体结果解析如下:

在这里插入图片描述

图 2-3头文件展开

如上图,hello.i文件中将#include进行了展开,包含了对文件的引用。

在这里插入图片描述

图 2-4 数据类型别名

上图中展现了一系列typedef语句,具体格式为“typedef+标准数据类型+别名”(其中别名是引入的头文件中使用的类型定义)。提高了程序和代码的可移植性。

在这里插入图片描述

图 2-5内部函数声明

文件后面声明了内部函数,包括对系统内核提供的接口进行封装的操作,使其更易于使用和与其他部分进行交互。最后便是hello.c的源码。

2.4 本章小结

本章详细介绍了预处理的概念、作用以及在Ubuntu环境下进行预处理的方法。预处理是编译过程的第一步,它通过宏替换、文件包含、条件编译等操作,确保源代码在编译前已经准备好,并且符合编译器的要求。

通过本章的学习,我们可以了解到预处理的重要性以及它如何帮助我们更好地组织代码、提高代码的可移植性和可维护性。同时,我们还学习了如何在Ubuntu环境下使用gcc命令进行预处理,并解析了预处理后的代码结构。

预处理是C语言编程中不可或缺的一部分,掌握预处理的概念和操作对于编写高质量的C程序至关重要。希望本章的内容能够帮助读者更好地理解预处理,并在实际编程中灵活运用预处理技术。

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译是指对经过预处理之后的源程序代码进行分析检查,确认所有语句均符合语法规则后将其翻译成等价的中间代码或汇编代码的过程,在此处指编译器将 hello.i 翻译成 hello.s。

3.1. 2编译的步骤与作用

词法分析(Lexical Analysis):将源代码文本分解成一系列的词法单元(tokens),如关键字、标识符、运算符、字面量等。

语法分析(Syntax Analysis):根据语言的语法规则,将词法单元组织成语法结构,生成抽象语法树(Abstract Syntax Tree, AST)。检查源代码的语法,确保其符合语法规则,从而确定代码的实际要执行的工作。

语义分析(Semantic Analysis):检查源代码的语义,如变量声明、类型检查、作用域规则等,确保代码在语义上是正确的。

中间代码生成(Intermediate Code Generation): 将AST转换为中间代码, 这种代码通常更接近机器语言,但保持与具体机器无关,即平台无关代码。

代码优化(Code Optimization): 对中间代码进行优化,以提高程序的执行效率,减少资源消耗。

目标代码生成(Code Generation):将优化后的中间代码转换为特定机器的机器语言或汇编语言。

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s

键入命令后得到编译后的文件hello.s。

在这里插入图片描述

图 3-1 编译命令

3.3 Hello的编译结果解析

3.3.1文件和段定义

在这里插入图片描述

图 3-2 文件部分内容

.file “hello.c” 指定源文件名。

.text 指定接下来的代码属于代码段。

.section .rodata 指定接下来的数据属于只读数据段。

.align 8 指定数据对齐方式,这里是8字节对齐。

3.3.2字符串常量

在这里插入图片描述

图 3-3 文件部分内容

在汇编代码中,常量通常被定义在 .rodata 段,这是一个只读数据段,用于存储程序中的字符串字面量和全局常量。

.LC0 和 .LC1 是字符串常量的标签,用于存储字符串数据。

.string 指令用于定义字符串常量。

.LC0 是一个字符串常量,包含了错误信息提示用户如何使用程序。这个字符串在源代码中是 “用法: Hello 学号 姓名 手机号 秒数!\n”,但在汇编代码中,它被转换成了对应的ASCII码表示。例如,\347\224\250 是“用”的ASCII码表示,\346\263\225是“法”的ASCII码表示。

.LC1 是一个格式化字符串常量,用于 printf 函数来打印信息。在源代码中,这个字符串是 “Hello %s %s %s\n”,它包含了三个 %s,这是 printf 函数用于插入字符串参数的占位符。

3.3.3变量

在汇编代码中,变量通常不会像在高级语言中那样显式声明,而是通过寄存器和栈上的空间来间接表示。

在这里插入图片描述

图 3-4代码部分内容

在这里插入图片描述

图 3-5文件部分内容

在这里插入图片描述

图 3-6文件部分内容

main函数中定义了int型局部变量i,局部变量存储在栈上,从汇编代码中可见,i存储在-4(%rbp)的地址上,初始值为0,每一次循环+1,跳出循环的条件为i>9。

3.3.4赋值操作

在这里插入图片描述

图 3-7文件部分内容

赋值操作一般由mov指令完成,根据操作数大小,选择不同的mov指令,比如下面两个赋值操作:

  • movq %rsp, %rbp 将栈指针 %rsp 的值赋给基指针 %rbp。
  • movl $0,-4(%rbp) 对局部变量 i 的赋初值0。

3.3.5算术操作

在这里插入图片描述

图 3-8文件部分内容

算术操作包括加减乘除等,由add,sub,imul,div等指令完成,汇编代码中涉及的算数操作举例如下:

  • addq 16, %rax 将 %rax 寄存器的值增加 16。
  • addq 1, -4(%rbp) 将栈上 -4(%rbp) 的值增加 1。
  • subq 5, -20(%rbp)在栈上分配空间,用于局部变量。

3.3.6数组/指针操作

在这里插入图片描述

图 3-9代码部分内容

在hello.c文件中,main 函数的参数有一个字符串数组argv[]

在这里插入图片描述

图 3-10文件部分内容

对于数组进行操作,首先需要获得数组首地址,如上图中指令

movq -32(%rbp),%rax获得argv[]的首地址。当调用数组元素时,只需在数组首地址基础上增加偏移量,如上图35、38、41、48四行指令,将%rax分别加上偏移量24、16、8、32得到了argv[1]到argv[4]。获得数组元素后将其存入寄存器,等待后面调用。

3.3.7关系操作

关系操作包括关系操作:== != > < >= <=等,本程序中的关系操作主要有以下两个操作:

在这里插入图片描述

图 3-11代码部分内容

在这里插入图片描述

图 3-12文件部分内容

在这里插入图片描述

图 3-13文件部分内容

3.3.8控制转移

在这里插入图片描述

图 3-14文件部分内容

hello.c中判断if(argc!=5)语句对于汇编代码,je根据cmpl产生的条件码选择是否跳转,若两个操作数的值相等则执行.L2。

在这里插入图片描述

图 3-15文件部分内容

for 循环,循环变量 i 从 0 开始,每次循环加 1,并判断是否 i<10。汇编代码中用cmpl判断 i 是否小于等于 9,如果小于等于则继续执行循环体中的内容,否则则跳出循环。

3.3.9函数操作

在调用函数前,编译器将参数先放置在寄存器中,前六个参数依次放置在:rdi, rsi, rdx, rcx, r8, r9中,大于6个的参数压入栈中存储。本程序使用了使用了printf函数,exit函数,atoi函数和sleep函数等。
(1) main函数

main函数被系统的启动函数调用, 传入参数argc和 argv,返回值为 0。

在这里插入图片描述

图 3-16文件部分内容

(2) printf函数

设置寄存器 %rdi 和 %rsi 的值来传入参数并调用。

在这里插入图片描述

图 3-17文件部分内容

(3) atoi函数和sleep函数

sleep函数将atoi的返回结果作为参数,atoi将argv[3]作为参数

在这里插入图片描述

图 3-18文件部分内容

(4) exit函数

exit函数将1传入%edi作为参数

在这里插入图片描述

图 3-19文件部分内容

3.4 本章小结

本章主要介绍了编译的概念、作用以及编译的步骤,并通过Ubuntu下的gcc命令展示了如何将C语言源代码编译成汇编代码。同时,本章还详细解析了编译器如何处理C语言中的数据类型和操作,包括文件和段定义、字符串常量、变量、赋值操作、算术操作、数组/指针操作、关系操作、控制转移和函数操作等。

通过本章的学习,我们可以了解到编译器在将高级语言代码转换为机器语言代码的过程中,是如何进行词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成的。同时,我们也学习了汇编代码的基本结构和指令,以及如何通过汇编代码来理解C语言程序的具体执行过程。

第4章 汇编

4.1 汇编的概念与作用

4.1.1汇编的概念

汇编是指汇编器as(assembler)将以.s结尾的汇编程序翻译成机器语言指令的过程。在这个过程中,汇编器会将汇编语言的助记符(Mnemonics)转换成对应的机器码,同时处理地址或常量等符号,最终生成可重定位目标程序格式的文件,通常是以.o结尾的文件。

4.1.2汇编的作用

汇编的主要作用是将人类可读的汇编语言代码翻译成计算机可执行的机器语言指令,以便计算机能够理解和执行程序。此外,汇编器还负责生成可重定位目标文件,处理符号与地址,并为链接过程做准备,以便最终生成可执行的机器语言程序。

4.2 在Ubuntu下汇编的命令

编译命令:gcc -c hello.s -o hello.o

键入命令后得到汇编后的文件hello.o。

在这里插入图片描述

图 4-1汇编命令

4.3 可重定位目标elf格式

键入命令readelf -a hello.o > hello.o.elf得到hello.o 文件的ELF格式信息。

在这里插入图片描述

图 4-2读取命令

elf 可重定位目标文件格式如下图:

在这里插入图片描述

图 4-3 elf 可重定位目标文件格式

4.3.1 elf头

在这里插入图片描述

图 4-4 elf头

ELF(Executable and Linkable Format)头是ELF文件的开头部分,包含了描述文件信息的数据。

  • Magic:7f 45 4c 46 是ELF文件的魔数,用于标识这是一个ELF文件。
  • 类别:ELF64,表示这是一个64位的ELF文件。
  • 数据:2补码,小端序(little endian),表示文件中的数据采用小端字节序存储。
  • Version:1(current),表示ELF头的版本。
  • OS/ABI:UNIX - System V,表示操作系统类型和ABI(应用二进制接口)。
  • ABI 版本:0,表示ABI的版本。
  • 类型:REL(可重定位文件),表示这是一个需要进一步链接才能执行的文件。
  • 系统架构:Advanced Micro Devices X86-64,表示目标架构是AMD的x86-64。
  • 版本:0x1,表示文件的版本。
  • 入口点地址:0x0,对于可重定位文件,入口点地址通常是0,因为还没有确定。
  • 程序头起点:0(bytes into file),表示程序头表在文件中的偏移量为0,但这里程序头表大小为0,意味着没有程序头。
  • Start of section headers:1264(bytes into file),表示节头表在文件中的偏移量。
  • 标志:0x0,表示没有特定的标志。
  • Size of this header:64(bytes),表示ELF头的大小。
  • Size of program headers:0(bytes),表示程序头表的大小为0,即没有程序头。
  • Number of program headers:0,表示程序头表中的条目数为0。
  • Size of section headers:64(bytes),表示每个节头的大小。
  • Number of section headers:14,表示节头表中的条目数。
  • Section header string table index:13,表示节头表中字符串表的索引。

总结来说,这是一个64位的ELF可重定位文件,用于UNIX系统,采用小端字节序,没有程序头表,但有一个包含14个条目的节头表。这种文件通常用于链接阶段,需要与其他目标文件或库文件链接后才能生成可执行文件。

4.3.2节头

在这里插入图片描述

图 4-5 节头

节头提供了ELF文件中各个节的详细描述,包括节名称、类型、虚拟地址、文件偏移量、大小、全体大小、旗标、链接、信息和对齐方式。以下是对这些节的解释:

  • [ 0] (NULL): 这是一个空的或未使用的节。
  • [ 1] .text: 包含程序的可执行代码。
  • [ 2] .rela.text: 包含.text节的重新定位信息。
  • [ 3] .data: 包含已初始化的全局和静态变量。
  • [ 4] .bss:包含未初始化的全局和静态变量,这些变量在程序启动时分配空间。
  • [ 5] .rodata: 包含只读数据,如常量字符串。
  • [ 6] .comment: 包含编译器的版本信息或注释。
  • [ 7] .note.GNU-stack: 标记栈是否可执行,通常为空。
  • [ 8] .note.gnu.property: 包含额外的编译器或链接器信息。
  • [ 9] .eh_frame: 包含异常处理信息。
  • [10] .rela.eh_frame: 包含.eh_frame节的重新定位信息。
  • [11] .symtab: 包含符号表,用于存储变量和函数的信息。
  • [12] .strtab: 包含符号表中符号名称的字符串表。
  • [13] .shstrtab: 包含节名称的字符串表。

旗标(Flags)的含义如下:

  • W (write): 节可写。
  • A (alloc): 节需要分配内存。
  • X (execute): 节可执行。
  • M (merge): 节可合并。
  • S (strings): 节包含字符串。
  • I (info): 节包含信息。
  • L (link order): 节按链接顺序排列。
  • O (extra OS processing required): 节需要额外的操作系统处理。
  • G (group): 节属于一个组。
  • T (TLS): 节包含线程局部存储。
  • C (compressed): 节被压缩。
  • x (unknown): 未知。
  • o (OS specific): 操作系统特定。
  • E (exclude): 节被排除。
  • l (large): 节较大。
  • p (processor specific): 处理器特定。

4.3.3重定位节

在这里插入图片描述

图 4-6 重定位节

重定位节的信息是关于ELF(可执行和可链接格式)文件的.rela.text和.rela.eh_frame节的重定位条目的列表。重定位条目是在运行时调整符号地址以指向内存中正确位置的信息。从图中可以看出.rodata中的模式串:puts,exit,printf,slepsecs,sleep,getchar等符号需要重定位。

4.3.4符号表

在这里插入图片描述

图 4-7 符号表

符号表.symtab包含了程序的结构信息,包括源文件名、各个节的信息、定义的函数(如main)以及引用的外部函数或变量(如puts, exit等)。这些信息在链接和执行时用于解析符号引用,确保程序正确运行。每个符号在符号表中都有一些属性,这些属性描述了符号的特性和位置。以下是符号表属性的含义:

Num (编号):这是符号在符号表中的唯一编号。

Value (值):对于函数和变量,这个值通常表示符号在内存中的地址。对于文件和节,这个值通常是0。

Size (大小):这个值表示符号的大小,对于函数和变量,通常是其占用的字节数。

Type (类型):这个值表示符号的类型,如NOTYPE(无类型)、OBJECT(数据对象,如变量)、FUNC(函数)等。

Bind (绑定):这个值表示符号的绑定类型,如LOCAL(局部)或GLOBAL(全局)。

Vis (可见性):这个值表示符号的可见性,如DEFAULT(默认)或HIDDEN(隐藏)。

Ndx (索引):这个值表示符号所在的节(section)的索引。对于未定义的符号,这个值通常是UND(未定义)。

Name (名称):这是符号的名称,如变量名、函数名等。

符号表中的每个符号都对应程序中的一个实体,如变量、函数、文件等。符号表在程序的编译、链接和调试过程中发挥着重要作用,它帮助编译器和链接器解析符号引用,确保程序正确运行。同时,符号表也为调试器提供了必要的信息,帮助开发者定位和解决问题。

4.4 Hello.o的结果解析

objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

键入命令objdump -d -r hello.o > obj_hello.o.s,得到反汇编后的文obj_hello.o.s。

在这里插入图片描述

图 4-8 反汇编指令

在这里插入图片描述

图 4-9 反汇编结果

4.4.1机器语言与汇编语言的构成

机器语言是计算机能够理解和执行的唯一语言,它完全由0和1的二进制代码组成。每种计算机架构都有其独特的机器语言,由一系列的指令组成,这些指令定义了计算机可以执行的基本操作。

汇编语言是一种低级编程语言,它是机器语言的文本表示形式。汇编语言使用助记符(Mnemonics)来代替机器语言中的操作码(Opcodes),使用地址符号或标号来代替操作数或地址。汇编语言需要通过汇编器(Assembler)转换成机器语言才能被计算机执行。

机器语言的构成:

  • 操作码(Opcode):指示CPU要执行的操作,如加法、减法、跳转等。
  • 操作数(Operand):提供操作码需要操作的数据或位置。操作数可以是立即数(Immediate values)、寄存器(Registers)、寄存器间接(Register indirect)、寄存器相对(Register relative)等。

4.4.2汇编语言与机器语言的映射关系

  • 助记符与操作码:汇编语言中的每个助记符都对应一个特定的操作码。例如,在x86架构中,“mov” 是一个助记符,它对应于机器语言中的一个操作码,用于移动数据。
  • 操作数:汇编语言中的操作数通常以十进制、十六进制或符号的形式表示,而机器语言中的操作数则是二进制数。在汇编过程中,汇编器会将汇编语言中的操作数转换为机器语言中的操作数。

4.4.3汇编代码(hello.s)和反汇编代码(hello.o.s)的异同

相似点:汇编代码和反汇编代码在重定位部分(如 .rela.text)都有条目,这表明在链接过程中需要进一步调整地址以正确引用全局符号。

不同点:在汇编代码和反汇编代码之间,存在一些关键的差异,这些差异反映了它们在表示机器代码时的不同侧重点。

操作数的表示

  1. 汇编代码:汇编代码使用十进制或十六进制来表示操作数,这取决于汇编器的配置和编程习惯。例如,mov eax, 1 中的 1 是一个十进制数。
  2. 反汇编代码:反汇编代码通常使用十六进制来表示操作数,因为这是机器代码中操作数和地址的标准表示方式。例如,mov eax, 0x1 中的 0x1 是一个十六进制数。

分支转移

  1. 汇编代码:汇编代码使用标签(如 .L2、.LC1)来指示分支指令的目标位置。
  2. 反汇编代码:反汇编代码直接使用指令中包含的虚拟地址来表示跳转目标。这些地址在汇编时可能尚未确定,它们将在链接过程中被替换为实际的物理地址。

函数调用

  1. 汇编代码:汇编代码中的 call 指令直接引用函数的名称。
  2. 反汇编代码:反汇编代码中的 call 指令使用的是函数的相对偏移地址。在汇编时,这些地址可能尚未确定,因此它们在反汇编代码中显示为零,并在链接时被替换为实际的函数入口地址。

全局变量访问

  1. 汇编代码:汇编代码使用段名称(如 .rodata)加上 %rip(相对指令指针)来访问全局变量。
  2. 反汇编代码:反汇编代码使用 0 + %rip 来访问全局变量。这是因为在汇编时,全局变量的实际地址可能尚未确定,需要通过链接过程来解决。

其他差异

  1. 伪指令:汇编代码包含伪指令(如 .L1),这些指令用于指导汇编器和链接器。反汇编代码不包含伪指令,因为它仅提供机器代码的文本表示。
  2. 分支表示:反汇编代码中的分支指令不使用标签,而是直接使用地址。
  3. 全局变量表示:反汇编代码使用 0x0(%rip) 来定位全局变量,实际地址在链接时确定。

总的来说,反汇编代码提供了机器代码的直接文本表示,而汇编代码则包含了更多的元数据和指令级细节。反汇编代码的这种简洁性反映了它在链接和加载过程中的作用,即提供一个可以直接执行的机器代码表示,而无需额外的汇编步骤。

4.5 本章小结

本章深入探讨了汇编的概念、作用以及在Ubuntu系统下的具体操作。通过对比汇编代码与反汇编代码的差异,我们理解了汇编器如何将人类可读的汇编语言转换为计算机可执行的机器语言,并处理了符号与地址的重定位问题。

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接是指链接器(Linker)将各种代码和数据块收集并整理成为一个单一文件,生成可执行的目标文件的过程。在此过程中,链接器负责解决模块间的符号引用、地址重定位等问题,将多个目标文件(例如 hello.o)合并成一个可执行文件(例如 hello)。这个过程通常包括符号解析、地址重定位和文件格式转换等步骤。

5.1.2 链接的作用

符号解析:链接器会解析目标文件中的符号(变量名、函数等)引用,将引用的符号与定义的符号进行匹配,确保程序中的各个部分能够正确地调用和访问彼此的函数和变量。

**地址重定位:**链接器重新分配目标文件中的数据和指令的地址,使得它们能够在内存中正确地定位和访问。这包括调整各个模块的相对地址,确保它们在组成最终可执行文件时不会发生地址冲突。

**库合并:**链接器会将多个库文件合并到程序中,使得程序能够使用库中的函数和数据。

优化:链接器可能会进行一些优化,例如删除未使用的代码和数据,减少程序的体积。

生成可执行文件:链接器最终生成一个可执行文件,。这个文件可以被操作系统加载到内存中,用户可以直接运行这个文件来执行程序。

**模块化与可维护性:**提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合。这使得更改源文件时可以进行分开编译,减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。

链接的方式一般有两种:静态链接和动态链接。静态链接在编译时将所有的目标文件和库文件链接成一个完整的可执行文件,而动态链接在编译时只将目标文件链接成一个不完整的可执行文件,在运行时需要依赖于其他的动态链接库(DLL)文件。

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。

在这里插入图片描述

图 5-1 链接命令

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

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

键入命令readelf -a hello > hello.elf,得到hello的ELF格式。

5.3.1ELF头

在这里插入图片描述

图 5-2 ELF 头

由上图可知,hello.elf与hello.o.elf的elf头基本一致,基本信息未发生改变(如Magic,Class等),但类型变为了EXEC(可执行文件),入口地址变为0x4010f0。

5.3.2节头

链接之后的内容更加丰富,链接器将各个文件对应的段合并并重新分配和计算了相应节的类型、位置和大小等信息。与hello.o的节头不同的是,在这里每一节都有了实际地址,说明重定向已经完成。

在这里插入图片描述

图 5-3 节头

.

5.3.3程序头

在这里插入图片描述

图 5-4 程序头

在这里插入图片描述

图 5-5 段节

关于hello.o的ELF文件,需要指出的是,它并未包含程序头(Program Headers)信息。然而,当讨论hello的可执行文件时,我们可以确认这是一个可执行的目标文件,其中详细列出了12个程序头表项。在这12个表项中,特别引起注意的是其中的4个,它们被定义为用于加载的段(类型标识为LOAD)。

这些加载段的特性在于,它们的虚拟地址(VirtAddr)与物理地址(在加载到内存时)是相匹配的,这表示这些段在内存中是直接映射的,无需进行地址转换。每个这样的段都遵循一个对齐要求,具体来说是要求以4KB(即十六进制表示为0x1000)的边界进行对齐。

以首个加载段为例,它起始于文件的第0x00000字节位置,并持续至第0x005f0字节,占据了总共0x5f0字节的存储空间。当它被加载到内存中时,将从虚拟地址0x400000开始,同样占用0x5f0字节的内存空间。由于对齐要求为0x1000字节,实际分配的物理内存地址将会是满足4KB边界条件的、从0x400000开始的下一个地址。

这个特定的加载段被赋予了只读(R)权限,这表明它包含了程序的静态代码部分。在ELF文件的上下文中,只读代码段通常存储了程序在运行时不会改变的基础指令和数据。

5.3.4符号表

在这里插入图片描述

图 5-6 符号表

在符号表的处理过程中,观察到函数名在“hello.o”中被更新为“函数名_GLIBC_2.2.5”,这一变更指示了该函数定义来源于某个库。此外,与“hello.o”的符号表相比,“hello”文件新增了一个名为“.dynsym”的节,它专门用于存储与动态链接相关的导入和导出符号,但不涵盖模块内部的符号。

在链接阶段,为了使用共享库,指定了“-dynamic-linker /lib64/ld-linux-x86-64.so.2”选项,而“.dynsym”节正是用来保存这些与动态链接紧密相关的符号。值得注意的是,“.dynsym”节中的符号在链接阶段均未进行初始化,因为它是“.symtab”节的一个子集,动态链接机制的设计允许将符号的初始化和连接过程推迟到程序加载或运行时。

与此同时,“.symtab”节不仅包含了来自“.dynsym”的符号,还容纳了来自其他可重定位目标文件(如“.o”文件)的符号。这些额外的符号丰富了程序的符号表,为动态链接和运行时加载提供了必要的支持。

这两类符号表的特点反映了动态链接机制的核心设计原则,即“.dynsym”专注于动态链接相关的符号,而“.symtab”则负责存储整个程序所需的所有符号。这种设计使得链接和初始化过程能够延迟到需要时才进行,增强了程序的灵活性和效率。

5.4 hello的虚拟地址空间

使用 edb 打开 hello,查看Data Dump:

在这里插入图片描述

图 5-7 Data Dump

可以看出 hello 的虚拟地址起始为 0x401000。我们可以通过elf的每个section的地址在Data Dump中找到相应数据。

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

首先,使用objdump -d -r hello > obj_hello.s对hello进行反汇编:

在这里插入图片描述

图 5-8 反汇编指令

在这里插入图片描述

图 5-9 反汇编文件

一方面,链接前,hello.o 反汇编程序只有main函数。在链接后,hello反汇编有各种函数,可执行文件的函数数量增加。由于动态链接器将共享库中 hello.c 所需的函数添加到可执行文件中,出现了一些以 .plt 结尾的函数,如 puts@plt、printf@plt 等。

在这里插入图片描述

图 5-10 反汇编文件

在这里插入图片描述

图 5-11 反汇编文件

从另一个视角审视,如上图示,函数调用指令call和跳转指令的参数在链接阶段经历了关键性的调整。在链接过程中,链接器对重定位条目进行了细致的分析,通过计算目标地址与后续指令地址之间的相对偏移量,对call指令之后的字节码进行了修正。这一修正确保了指令能够准确地指向相应的代码段,实现了函数调用。具体来说,链接器将字节码更新为指向过程链接表(PLT)中相应函数与下一条指令的相对地址,从而完成了反汇编代码的完整构建。

在对比hello和hello.o两个文件时,可以观察到,尽管汇编指令的语义未发生根本性变化,但地址的标识方式却有所转变。在链接之前,.o文件中的main函数反汇编代码起始于地址0,这实际上是相对于文件内部的偏移地址。然而,在链接之后,由于main函数与其他库文件的关联,hello中的main函数起始地址变为了0x401125。此时,main函数中每一条指令的地址,以及每一个函数的地址,均转变为绝对地址,即CPU可以直接访问的物理内存地址。这些绝对地址是通过将可重定位文件中的相对偏移量与新的起始地址相加计算得出的。

链接过程主要涵盖了两个核心步骤:符号解析和重定位。在重定位之前,汇编器在hello.o文件的重定位段中详细记录了需要重定位的符号及其类型和偏移量。链接器通过解析这些符号(包括局部和全局符号),将符号的引用与其定义建立了一一对应的关系。进入重定位阶段后,链接器将相同类型的节合并成新的聚合节,并为这些新的聚合节以及每个定义的节和符号分配了运行时内存地址。此时,程序中的每一条指令和全局变量都获得了唯一的运行时内存地址。最后,链接器更新了重定位节中的符号引用,确保代码节和数据节中对每个符号的引用都指向了正确的运行时地址。

5.6 hello的执行流程

在这里插入图片描述

图 5-12 edb界面

在深入研究程序的执行流程时,我们发现在程序开始执行之前,系统首先进行了一系列的初始化工作。这一过程主要通过调用_init函数来实现。该函数的主要任务是完成动态链接器的重定位工作,确保程序在运行时能够正确地找到并加载所需的共享库。

在_init函数执行完毕后,我们可以观察到程序开始调用一系列的库函数,如printf、exit、atoi等。这些函数在程序的代码段中仅以符号形式存在,它们的实际内容位于共享库的高地址处。

随后,程序调用了_start函数,这是程序执行的起始地址。在_start函数中,程序开始准备执行main函数的内容。main函数是程序的入口点,负责执行程序的主要任务。

在main函数执行完毕后,程序还会执行__libc_csu_init和__libc_csu_fini函数。这两个函数是C标准库的初始化和清理函数,用于在程序开始执行和结束时进行必要的资源管理和清理工作。

最后,程序调用了_fini函数,这是程序结束前的最后一个函数。在_fini函数执行完毕后,程序正式结束执行。

综上所述,程序的执行流程可以概括为:首先进行初始化工作,然后调用库函数,接着执行main函数,最后执行清理工作并结束程序。这一过程揭示了程序执行的底层细节,为我们深入理解程序的运行机制提供了宝贵的信息。

下面的表格列出了在使用edb执行hello时,从加载hello到_start,到call main,以及程序终止的过程中调用与跳转的各个子程序名或程序地址:

表 5-1子程序名与地址

子程序名子程序地址
_init0x401000
puts@plt0x401030
printf@plt0x401040
getchar@plt0x401050
atoi@plt0x401060
exit@plt0x401070
sleep@plt0x401080
_start0x4010f0
_dl_relocate_static_pie0x401120
main0x401125
_fini0x4011c0
_IO_stdin_used0x402000
_DYNAMIC0x403e50
GLOBAL_OFFSET_TABLE0x404000
_data_start0x404048
data_start0x404048
_bss_start0x40404c
_edata0x40404c
_end0x404050

5.7 Hello的动态链接分析

PLT和GOT是与动态链接最相关的两个节,观察他们所在地址段的变化即可反应动态链接的内容变化。

在这里插入图片描述

图 5-13 PLT和GOT

在这里插入图片描述

图 5-14 GOT与PLT的作用过程

got.plt的起始地址为0x40400

执行init 前内容如下:

在这里插入图片描述

图 5-15执行init 前内容

执行init后:

在这里插入图片描述

图 5-16执行init 后内容

在构建可执行目标文件hello的过程中,我们首先执行静态链接步骤,以生成一个部分链接的可执行文件。在这个阶段,共享库中的代码和数据并不会直接合并到hello文件中。随后,在加载hello文件时,动态链接器将发挥作用,它会对共享目标文件中的相关代码和数据进行重定位操作,同时加载必要的共享库,进而形成一个完全链接的可执行目标文件。在这个过程中,hello中的函数如printf、sleep、atoi等都会通过动态链接与源程序建立调用关系。

另外,由于共享库函数的运行时地址在调用时是无法预先知道的,因此,在编译时,系统会为这些引用生成重定位记录。这些记录将由动态链接器在程序实际加载到内存时进行解析。在Linux系统中,为了优化性能,通常使用延迟绑定的机制,这意味着函数地址的绑定操作会被推迟到该函数第一次被调用时执行。

延迟绑定的实现依赖于两个关键的数据结构:全局偏移表(GOT)和过程链接表(PLT)。在PLT中,每个条目占据16字节的空间,其中PLT[0]是一个特殊的条目,负责跳转到动态链接器中进行处理;每个后续的条目则负责调用特定的函数。而在GOT中,每个条目存放着一个8字节大小的地址,其中GOT[0]和GOT[1]包含了动态链接器在解析函数地址时所需的信息,而GOT[2]则指向了1d-linux.so模块中的动态链接器入口点。至于GOT中的其他条目,则与那些被调用的函数一一对应,并在运行时进行地址的解析。

5.8 本章小结

本章概述了链接的概念、作用及其过程,包括静态链接和动态链接的区别。链接是将多个目标文件合并成可执行文件的过程,涉及符号解析、地址重定位、库合并、优化和生成可执行文件等步骤。在Ubuntu系统中,使用ld命令进行链接操作。

通过分析ELF格式的可执行文件,可以了解链接后的文件结构,包括ELF头、节头、程序头和符号表等。链接的重定位过程涉及符号解析和地址重定位,确保程序中的指令和数据在内存中正确布局。

程序的执行流程从初始化开始,经过库函数调用、main函数的执行,到最后的清理和程序终止。动态链接通过PLT和GOT节实现,允许程序在运行时动态加载和链接库函数。

本章内容有助于理解链接的机制,对程序开发和调试具有重要意义。

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

进程是操作系统中程序执行的一个动态实例,代表了一个正在运行的程序及其与特定数据集的交互。当程序在系统中被执行时,它会以一个或多个进程的形式存在。这些进程是操作系统进行资源分配和调度的基本单元,它们各自独立,并享有系统赋予的特定资源。

每个进程都拥有其独立的地址空间,这意味着它们可以访问自己的内存区域,而不会与其他进程冲突。此外,进程还拥有自己的程序计数器、寄存器集合和堆栈,这些资源用于存储进程的当前执行状态、局部变量和函数调用等信息。

6.1.2 进程的作用

提供独立的逻辑控制流:进程为每个程序提供了一个独立的逻辑控制流,确保程序在运行时仿佛独占处理器资源。每个进程都有自己独立的指令序列,即指令流,其执行过程不会受到其他进程的影响。这使得多个程序能够并发执行,提高了系统的资源利用率和吞吐量。

提供私有的地址空间:每个进程都拥有自己的私有地址空间,这为程序提供了一个假象,即程序在运行时仿佛独占整个内存系统。这种地址空间的隔离性确保了不同进程之间的数据不会相互干扰,增强了系统的稳定性和安全性。每个进程只能访问自己的地址空间和数据,从而避免了因数据冲突而导致的错误。

资源分配和调度的基本单位:进程是操作系统进行资源分配和调度的基本单位。操作系统通过管理进程来确保每个程序都能获得所需的资源(如CPU、内存、磁盘等),并在适当的时候执行。这种基于进程的资源管理方式有助于系统实现高效、公平的资源分配,提高系统的整体性能。

提供隔离性和保护机制:**进程之间是相互隔离的,一个进程的错误或崩溃不会影响其他进程的正常运行。这种隔离性保证了系统的稳定性和可靠性。同时,操作系统通过访问权限和保护机制来确保每个进程只能访问自己的地址空间和数据,防止了恶意程序或错误操作对其他进程造成损害,进一步增强了系统的安全性。

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

6.2.1 Shell-bash的作用

Bash Shell(Bourne Again SHell)是一个在用户与操作系统内核之间起桥梁作用的应用程序。它提供了一个交互式的命令行界面,使得用户可以方便地与操作系统进行交互。作为Linux系统的默认Shell,Bash不仅负责接收用户输入的命令,还负责解释和执行这些命令,为用户提供了一个直观、灵活的操作环境。

6.2.2 Shell-bash的处理流程

用户输入:用户在Bash Shell的终端界面中输入命令,例如执行某个脚本文件./hello。

命令解析:Bash Shell接收到用户输入的命令后,会对其进行解析。首先,Bash会检查该命令是否为内置命令,如果是,则直接执行;如果不是,Bash会尝试将其解析为一个可执行的目标文件或脚本。

参数与环境变量准备:一旦确定了要执行的命令,Bash会构造命令行参数(argv)和环境变量参数(envp),这些参数将用于后续的执行过程。

子进程创建:Bash通过调用fork()系统调用创建一个新的子进程。这个子进程是Bash Shell的一个副本,它拥有与父进程(即Bash Shell)相同的地址空间,包括代码段、数据段、堆和栈等。

加载并执行程序:在子进程中,Bash调用execve()系统调用来加载并执行用户指定的程序(如hello程序)。这个过程会替换子进程的当前映像(包括代码、数据和环境等)为用户程序的映像,并在子进程的上下文中开始执行该程序的main()函数。

通过上述流程,Bash Shell不仅为用户提供了一个与操作系统交互的接口,还通过其强大的命令处理和脚本执行能力,极大地丰富了用户的操作体验。

6.3 Hello的fork进程创建过程

在操作系统中,父进程通过调用fork()系统调用来创建一个新的子进程。这个新创建的子进程在多个方面与父进程相似,但并不完全相同。具体来说,子进程会获得父进程用户级虚拟地址空间的一个独立副本,这包括代码和数据段、堆、共享库以及用户栈。这些区域在物理内存中是分开的,但在逻辑上保持了相同的结构和内容。

除了虚拟地址空间的副本外,子进程还会继承父进程所有打开的文件描述符的副本。这意味着,当父进程调用fork()时,子进程可以继续读写父进程已经打开的文件。

然而,父进程和子进程之间最显著的区别在于它们拥有不同的进程标识符(PID)。PID是操作系统用来唯一标识一个进程的数字。

值得注意的是,fork()函数的调用是特殊的:它只被调用一次,但会返回两次。在父进程中,fork()返回新创建的子进程的PID;而在子进程中,fork()则返回0。通过检查fork()的返回值,程序可以确定自己是在父进程中执行还是在子进程中执行。

父进程和子进程是并发运行的独立进程。这意味着操作系统内核会交替执行它们的逻辑控制流指令,而具体的执行顺序则取决于操作系统的调度策略和当前的系统负载。

6.4 Hello的execve过程

调用:execve:当一个进程需要执行一个新的程序时,它会调用execve系统调用。这个调用提供了新程序的路径、参数列表和环境变量列表。

内核检查和加载: 内核首先检查提供的程序路径是否指向一个存在的可执行文件,并且调用进程是否有权限执行这个文件。然后,内核确认文件格式是否为可执行的格式,如ELF。

清理当前进程状态: 根据文件描述符的close-on-exec标志,内核关闭那些在调用execve后被标记为关闭的文件描述符。然后,内核释放当前进程的用户空间内存,包括堆、栈、数据段和代码段。

加载新程序: 内核将新程序的代码段、数据段等映射到进程的地址空间中。这包括为程序设置堆栈,并将提供的参数和环境变量复制到新的堆栈中。

更新进程状态: 内核更新进程的状态,包括设置程序计数器为新程序的入口地址,并重置CPU寄存器以匹配新程序的要求。进程的标识符(如PID)保持不变,但进程的命令行参数和环境变量更新为新程序的。

执行新程序: 最后,内核将控制权转移到新程序,从其入口地址开始执行。

6.5 Hello的进程执行

6.5.1 相关信息

在这里插入图片描述

图 6-1 上下文切换

(1)进程上下文信息

进程上下文信息指的是进程在CPU上执行时的完整环境状态。这包括进程的状态信息(如进程标识符、内存布局等)、CPU寄存器的当前值(如指令指针、栈指针、通用寄存器等)、程序计数器(用于指示下一条要执行的指令的地址)等。当操作系统决定调度一个新进程来执行时,它必须保存当前进程的上下文信息,以便在稍后需要时能够恢复该进程的执行状态。

(2)用户态与核心态

现代操作系统通常将处理器的执行模式分为用户态(User Mode)和核心态(Kernel Mode),也被称为用户模式和内核模式。处理器通过控制寄存器中的模式位来切换这两种模式。在用户态下,进程受到操作系统的限制,不能执行特权指令(如修改系统表、访问物理内存等),也不能直接访问内核区域的代码和数据。这确保了系统的安全性和稳定性。而核心态下的进程拥有更高的权限,可以执行任何指令并访问系统内存,从而完成诸如进程调度、内存管理、I/O操作等系统级任务。

(3)时间片

时间片是操作系统分配给每个进程在CPU上执行的时间段。当一个进程的时间片用尽时,操作系统会暂停该进程的执行,并将其上下文信息保存到内存中。然后,操作系统会根据调度算法选择另一个进程来执行,确保所有进程都有机会使用CPU资源。时间片轮转是一种常见的调度算法,它按照预定的时间片长度依次执行每个进程,从而实现进程间的公平调度。这种调度方式有助于避免某个进程长时间占用CPU资源,提高系统的响应性和吞吐量。

6.5.2 进程调度的过程

就绪队列:操作系统维护一个就绪队列,里面包含了等待执行的进程。这些进程已经准备就绪,但尚未被分配CPU资源。

进程选择:调度器基于特定的调度算法(如先进先出、优先级调度或时间片轮转等)从就绪队列中挑选出一个进程来执行。

上下文保存:如果当前有进程正在执行,其执行状态(如CPU寄存器值、程序计数器、栈信息等)会被保存起来,以便后续能够恢复执行。

上下文切换:调度器加载所选进程的上下文信息,将CPU控制权转移给该进程,使其开始执行。

进程执行:被选中的进程开始运行,直到它自愿放弃CPU(如等待I/O操作),或者其分配的时间片用完,或是被更高优先级的进程抢占。

循环调度:上述过程不断循环进行,确保每个进程都能公平地获得CPU资源,并有效地管理系统中的并发执行。通过这种方式,操作系统实现了对CPU资源的动态分配和高效利用。

6.5.3 用户态与核心态转换

(1)从用户态到核心态
当进程需要进行特权操作,如访问硬件设备、进行文件读写或执行系统调用时,它不能直接在用户态下完成这些任务。这时,进程会触发一个系统调用,请求操作系统介入。为了执行这些特权操作,操作系统会暂停当前进程的用户态执行,保存其上下文信息,然后将处理器的模式从用户态切换到核心态。在核心态下,操作系统能够执行任何指令,包括特权指令,从而完成必要的内核操作。

(2)从核心态到用户态
当操作系统在核心态下完成了相应的内核任务后,它会恢复先前保存的用户态进程的上下文信息,并将处理器的模式从核心态切换回用户态。这一转换过程确保了用户程序无法直接访问或修改操作系统内核,从而维护了系统的安全性和稳定性。同时,它也使得用户程序能够继续在其自己的上下文中执行,而不受内核操作的影响。这种从核心态到用户态的转换是操作系统实现进程调度、资源管理和权限控制等关键功能的基础。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

6.6.1异常

在这里插入图片描述

图 6-2 异常的类别

中断(Interrupts)
中断是由外部设备(如键盘、鼠标、磁盘等)或处理器内部定时器产生的事件,它们会打断CPU当前正在执行的程序流程,并将控制权转移到特定的中断服务程序(Interrupt Service Routine, ISR)。中断通常用于处理异步事件,这些事件无法预测并且需要立即响应,如I/O操作的完成、定时器到期等。中断可以看作是硬件向软件发送的一种异步通知。

陷阱(Traps)
陷阱是一种由计算机程序有意触发的异常。它们通常是由特定的指令(如x86架构中的int指令)引起的,这些指令被设计用于产生陷阱。陷阱常用于实现系统调用(System Calls),系统调用是用户空间程序与内核空间操作系统之间通信的机制,它允许用户程序请求操作系统执行某些服务,如文件操作、进程管理等。

故障(Faults)
故障是由程序错误或特定异常条件导致的异常。这些异常通常与程序的执行结果相关,例如,当程序试图执行一个无法计算的指令(如除以零)、访问未对齐的内存地址或超出内存边界时,就会触发故障。故障可能导致程序终止,但在此之前,操作系统通常会尝试恢复程序的执行(如通过异常处理机制),或者终止程序并通知用户。

终止(Abort)
终止是一种严重的异常,它表示程序在执行过程中遇到了无法恢复的错误。这些错误可能是由非法指令(如尝试执行不存在的指令)、非法操作数(如使用无效的内存地址)等引起的。终止异常通常会导致程序被操作系统立即终止,并可能生成错误报告和日志记录,以帮助用户或开发者诊断问题。

6.6.2信号

在这里插入图片描述

图 6-2 信号的种类

6.6.3 各命令以及运行结果

(1)正常运行结果

在这里插入图片描述

图 6-3 运行示例

  1. 在程序执行过程中乱按

    在这里插入图片描述

    图 6-4 运行示例

    由上图可知,在程序执行过程中,可以在命令行中乱按,包括回车,对于程序运行来说没有影响。实际上,按键操作输入到缓冲区。

    (3)Ctrl-C和Ctrl-Z

    在这里插入图片描述

    图 6-5 运行示例

    Ctrl-C进程中断:当用户输入ctrl-c时产生中断信号,导致内核发送一个SIGINT信号到前台工作组中的每个进程,默认终止前台作业,在这里,hello被终止。

    Ctrl-Z进程直接停止:输入ctrl-z会发送一个SIGTSTP信号到前台进程组中的每个进程,默认情况下,停止(挂起)前台作业。

    (4)Ctrl-z后运行ps

    在这里插入图片描述

    图 6-6 运行示例

    ps命令输出相关信息

    (5)Ctrl-z后运行jobs

    在这里插入图片描述

    图 6-7 运行示例

  2. Ctrl-z后运行pstree

    在这里插入图片描述

    图 6-8 运行示例

    pstree命令可以以树状图形式显示所有进程

    (7)Ctrl-z后运行fg

    在这里插入图片描述

    图 6-9 运行示例

    由上图可见,ctrl-z可以让hello进程再次运行。

    (8)Ctrl-z后运行kill

    在这里插入图片描述

    图 6-10 运行示例

    使用kill -9 pid命令可以杀死指定进程。

    6.7本章小结

    第6章主要介绍了进程的概念与作用、Shell-bash的作用与处理流程、Hello的fork进程创建过程、Hello的execve过程、Hello的进程执行、用户态与核心态转换以及Hello的异常与信号处理。

    进程是操作系统进行资源分配和调度的基本单位,提供了独立的逻辑控制流、私有的地址空间、资源分配和调度的基本单位以及隔离性和保护机制。Shell-bash作为用户级的应用程序,充当用户与操作系统内核之间的接口,接受用户输入的命令并执行相应的操作。fork函数用于创建新的子进程,execve函数用于加载并运行新的程序。用户态与核心态的转换保证了用户程序无法直接操作系统内核,同时保护了系统的稳定性和安全性。异常与信号处理是程序运行过程中常见的问题,通过不同的信号和命令可以进行处理。

    通过本章的学习,读者可以了解进程的基本概念和作用,掌握Shell-bash的使用和处理流程,了解fork和execve函数的使用,理解用户态与核心态的转换,以及掌握异常与信号的处理方法。这些知识对于深入理解操作系统的工作原理和提高编程能力具有重要意义。

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址(Logical Address):逻辑地址是程序在编写或执行时所使用的地址,它通常基于程序的某个起始点(如代码段的起始地址)进行相对定位。在“hello”程序中,逻辑地址表示的是程序中指令和数据相对于其所在段(如代码段、数据段等)的偏移量。当CPU需要访问这些指令或数据时,它会将逻辑地址与段寄存器中存储的段基址相结合,从而计算出线性地址。

线性地址(Linear Address):线性地址是逻辑地址与段基址相加后得到的地址。在没有启用分页机制的系统中,线性地址直接对应着物理地址,即可以直接用来访问物理内存。在“hello”程序中,如果操作系统不使用分页机制,那么程序中的逻辑地址通过段机制转换后得到的线性地址就是实际的物理地址。

虚拟地址(Virtual Address):虚拟地址是现代操作系统为了实现内存管理、保护和多任务处理而引入的一种地址空间。在“hello”程序中,程序运行时所使用的地址都是虚拟地址。这些虚拟地址通过操作系统的内存管理单元(MMU)和页表机制映射到实际的物理地址。虚拟地址空间为每个进程提供了一个独立、连续且受保护的地址范围,使得进程可以像拥有整个内存一样运行,而不用担心与其他进程产生冲突。

物理地址(Physical Address):物理地址是内存中实际存在的地址,是CPU通过地址总线直接访问内存单元所使用的地址。在“hello”程序中,当分页机制被启用时,程序使用的虚拟地址会通过页表转换成物理地址,然后CPU才能根据这个物理地址访问内存中的数据。物理地址是内存单元的实际位置,是所有地址转换的最终目标。

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

7.2.1逻辑地址的构成

1.在 Intel 平台下的实模式中,逻辑地址为:CS:EA,CS 是段寄存器,将 CS 里的值左移四位,再加上 EA 就是线性地址。

2.保护模式下,要用段描述符作为下标,到 GDT(全局描述符表)/LAT(局部描述符表)中查表获得段地址,段地址+偏移地址就是线性地址。

7.2.2.段描述符表

1.逻辑地址中的段选择符用于在段描述符表(Segment Descriptor Table,简称SDT)中查找相应的段描述符。SDT包括GDT和LDT,它们分别存储全局和局部的段描述符。

2.GDT的地址和大小存储在CPU的gdtr控制寄存器中,而LDT的地址和大小则存储在ldtr寄存器中。

段描述符是一个 16 位字长的字段,如图:

在这里插入图片描述

图 7-1 段描述符

7.2.3.变换过程

1.确定描述符表:根据段选择符中的TI字段,确定使用GDT还是LDT。

2.计算描述符地址:使用段选择符中的索引号(index)乘以描述符的大小(通常为8字节),然后与相应的GDT或LDT的基址相加,得到段描述符的地址。

3.读取Base字段:从段描述符中读取Base字段,它表示该段的开始位置的线性地址。

4.计算线性地址:将逻辑地址中的段内偏移量与Base字段的值相加,得到最终的线性地址。

在这里插入图片描述

图 7-2 运行示例

7.2.4.总结

Intel的段式管理通过逻辑地址到线性地址的变换,实现了程序内存空间的逻辑划分和访问控制。这种变换过程不仅保证了程序能够正确地访问其内存空间,还提供了必要的保护和隔离机制,防止了不同程序之间的非法访问和冲突。

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

1.页式管理是一种内存空间存储管理的技术,它将进程的虚拟空间划分成若干个长度相等的页(page),并将内存空间也按页的大小划分成片或页面(page frame)。每个页都有一个唯一的页号(page number),而每个页面也有一个唯一的物理地址。虚拟页面就可以作为缓存的工具,被分为三个部分:

  • 未分配的:VM 系统还未分配的页
  • 已缓存的:当前已缓存在物理内存中的已分配页
  • 未缓存的:未缓存在物理内存的已分配页

2.在分页式存储管理系统中,允许将作业的每一页离散地存储在主存的物理块中,但系统必须能够保证作业的正确运行,即能在主存中找到每个页面所对应的物理块。为此,系统为每个作业建立了一张页面映像表,简称页表。页表实现了从页号到主存块号的地址映像。作业中的所有页(0~n)依次地在页表中记录了相应页在主存中对应的物理块号。页表的长度由进程或作业拥有的页面数决定。

在这里插入图片描述

图 7-3 页式管理

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

页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中,地址字段表明 DRAM 中相应物理页的起始位置,它分为两个部分:VPO(虚拟页面偏移)和 VPN(虚拟页号),如图:

在这里插入图片描述

图 7-4 页表地址翻译

7.4.1 TLB加速地址翻译

为了优化 CPU 产生一个虚拟地址后,MMU 查阅 PTE的过程,在 MMU 中设置一个关于 PTE 的小缓存,称为 TLB(翻译后备缓冲器)。像普通的缓存一样,TLB 的索引和标记是从 PTE 中的 VPN 提取出来的,如图:

在这里插入图片描述

图 7-5 TLB组成

7.4.2 四级页表翻译

Core i7 采用四级页表层次结构,如图:

在这里插入图片描述

图 7-6 四级页表示例

开始时,CPU 产生一个虚拟地址VA,然后根据VA的VPN来判断TLB是否缓存,如果命中,直接得到 PPN,将虚拟地址中的 VPO 作为物理页偏移,构建出物理地址;如果 TLB 不命中,则经过四级页表的查找得到最终的PTE,从而得到 PPN,进而构建出物理地址。

在这里插入图片描述

图 7-7 TLB命中与不命中示意图

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

得到物理地址后,将物理地址分为 CT(标记位)、CI(组索引) 和 CO(块偏移)。根据 CI 查找 L1 缓存中的组,依次与组中每一行的数据比较,如果有效位有效(为1)且标记位一致,则命中。如果命中,直接返回数据。若不命中,则依次去 L2、L3 缓存判断是否命中,命中时将数据传给 CPU 同时更新各级缓存。

在这里插入图片描述

图 7-8 Cache组选择

在这里插入图片描述

图 7-9 Cache行匹配与字选择

在这里插入图片描述

图 7-10 全过程访存

7.6 hello进程fork时的内存映射

在Shell中输入命令后,内核会利用fork函数生成一个子进程,为hello程序的执行设置环境,并为这个新进程分配一个独特的PID。这个子进程会复制父进程的内存布局、页表等内容。此外,子进程还有权访问父进程已打开的所有文件。当fork函数在新创建的进程中返回时,其虚拟内存的状态与fork被调用时父进程的虚拟内存状态完全一致。若任一进程尝试进行写操作,写时复制机制会介入,创建新的内存页面,确保每个进程都拥有独立的地址空间。

7.7 hello进程execve时的内存映射

当execve()函数被调用时,它会执行一系列操作来在当前进程中加载和运行位于可执行目标文件hello中的程序,从而有效地用hello程序替换当前正在运行的程序。以下是这一过程的详细步骤:

清除现有用户空间:execve()首先会删除当前进程虚拟地址空间中用户部分的所有已存在区域结构。这是为了确保旧程序的内存布局不会干扰新程序的执行。

创建新的私有区域:为新程序的代码、数据、未初始化数据(bss)以及栈区域创建新的区域结构。这些新的区域都是私有的,并采用写时复制(Copy-on-Write, COW)策略。具体来说:

  1. 代码区域(.text)和数据区域(.data)将从hello文件的相应部分映射到新的区域。
  2. 未初始化数据(bss)区域会请求分配一块二进制零的内存,其大小由hello文件指定。
  3. 栈区域也会请求分配一块二进制零的内存,初始长度为零,但会随着程序的执行而增长。
  4. 堆区域(虽然execve()不直接处理堆的分配,但通常堆区域也会在程序加载时初始化)。

映射共享库:如果hello程序与共享对象(如libc.so)链接,execve()会将这些共享对象映射到用户虚拟地址空间的共享区域内。共享对象允许多个进程共享相同的代码和数据,从而节省内存。

设置程序计数器:最后,execve()会设置当前进程上下文的程序计数器(Program Counter, PC),使其指向新代码区域的入口点。这确保了当进程恢复执行时,它将从hello程序的开始处执行,而不是之前被替换的程序。

在这里插入图片描述

图 7-11 加载器映射用户地址空间

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

第1步:处理器生成一个虚拟地址,并把它传送给MMU。
第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它
第3步:高速缓存/主存向MMU返回PTE。
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。

第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了则把它换出到磁盘。

第6步:缺页处理程序页面调人新的页面,并更新内存中的PTE

第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。

在这里插入图片描述

图 7-12 缺页故障示意

7.9动态存储分配管理

7.9.1 堆

动态内存分配器负责管理一个进程中的特定虚拟内存区域,这个区域通常被称为堆(heap)。分配器将堆视为由不同大小的内存块组成的集合,每个块都是一块连续的虚拟内存片段。这些内存块分为两类:已分配块和空闲块。

已分配块是那些已经被应用程序明确保留并正在使用的内存块。它们包含应用程序的数据,并且直到被应用程序显式释放之前,都保持已分配状态。应用程序通过调用如malloc、calloc或realloc等函数来请求已分配块,并在不再需要时通过调用free函数来释放它们。

空闲块是堆中当前未被任何应用程序使用的内存块。它们等待被分配,以便在需要时供应用程序使用。空闲块保持空闲状态,直到它们被应用程序显式地请求分配。动态内存分配器负责跟踪哪些块是空闲的,并在应用程序请求内存时,从空闲块中选择一个足够大的块进行分配。

已分配的块在应用程序显式调用free函数之前,将一直保持已分配状态。此外,有些内存分配器可能还实现了垃圾回收机制,这种机制能够隐式地释放那些不再被引用的已分配块,从而回收内存供后续使用。然而,这种隐式释放通常不是动态内存分配器的主要功能,而是由更高级别的语言运行时系统或垃圾回收器来处理的。

在这里插入图片描述

图 7-13 堆

7.9.2 隐式空闲链表管理

隐式空闲链表是一种管理堆中已分配和空闲内存块的数据结构,它通过每个空闲块头部的一个大小字段来隐含地链接这些空闲块。在这种结构中,空闲块在物理上是连续排列的,但逻辑上通过它们头部的大小字段来连接。

为了提高空闲块的管理效率,带边界标签的隐式空闲链表在空闲块的尾部(脚部)也存储了一个与头部相同的大小字段。这个脚部总是位于当前空闲块开始位置的一个固定偏移量(通常是一个字的大小)之后。通过检查一个空闲块的脚部,分配器可以快速地确定前一个空闲块的起始位置和状态。

当一个应用程序请求一个特定大小(k字节)的内存块时,分配器会遍历隐式空闲链表,根据某种放置策略(如首次适配、下一次适配或最佳适配)来查找一个足够大的空闲块。首次适配策略会搜索链表直到找到第一个足够大的空闲块,下一次适配策略会从上次分配的位置开始搜索,而最佳适配策略会寻找与请求大小最接近的空闲块。

一旦分配器找到了一个合适的空闲块,它会决定如何分配这个空闲块的空间。通常,如果空闲块比请求的大小大很多,分配器会选择将空闲块分割成两部分,其中一部分用于满足当前的请求,而剩余的部分则成为一个新的空闲块。

当应用程序释放一个已分配的内存块时,分配器需要处理可能出现的相邻空闲块合并问题,以避免假碎片(即大量小空闲块分散在堆中,导致无法为大块请求提供足够的连续内存空间)。带有边界标签的隐式空闲链表分配器可以高效地处理这种情况,因为它可以通过检查新释放块的脚部来快速确定前一个块的状态,并在常数时间内合并相邻的空闲块。这种合并操作是双向的,即新释放的块可以与其前面的空闲块合并,也可以与其后面的空闲块合并。

在这里插入图片描述

图 7-14 堆块格式

7.9.3 显式空闲链表管理

在真实的操作系统中,为了更高效地管理堆内存,通常采用的是显示空闲链表(Explicit Free List)策略。这种方法的核心思想是通过维护多个空闲链表来管理不同大小的内存块,每个链表中的块具有大致相等的大小。分配器维护一个空闲链表数组,其中每个数组元素对应一个大小类,而每个大小类都关联一个空闲链表。

当应用程序请求分配内存块时,分配器只需在对应大小类的空闲链表中查找合适的空闲块。有两种主要的分离存储(Slab Allocation)方法来实现这种管理:

  1. 简单分离存储(Simple Slab Allocation)

在这种方法中,每个大小类的空闲链表中的块从不进行合并和分割。每个块的大小都固定为该大小类中的最大元素大小。例如,如果大小类为17到32字节,那么无论请求多少字节(只要在这个范围内),都会分配一个大小为32字节的块。这种方法使得分配和释放操作的时间复杂度都是常数级的,因为分配器无需搜索或合并块。然而,由于总是分配最大尺寸的块,这种方法的空间利用率可能较低,特别是在请求小块内存时。

  1. 分离适配(Slab Allocation with Fragmentation)

这种方法试图在搜索时间和空间利用率之间找到一个平衡。每个大小类的空闲链表中的块可以有不同的大小,但它们都位于该大小类所指定的范围内。当分配一个块时,分配器会尝试找到一个足够大的空闲块来满足请求。如果找到的块比请求的大小大很多,分配器会将其分割,并将剩余的部分作为新的空闲块插入到适当大小类的空闲链表中。这种方法允许更灵活的内存使用,因为它可以适应不同大小的请求,并尝试最大化空间利用率。C标准库中的GNU malloc包就是采用这种方法的典型例子。通过智能地分割和合并块,GNU malloc在保持分配和释放效率的同时,也提高了空间利用率。

7.10本章小结

本章深入探讨了“hello”程序的存储管理,涵盖了从逻辑地址到物理地址的转换过程,以及内存管理中的关键技术。在Intel架构中,逻辑地址通过段式管理转换为线性地址,再通过页式管理映射到物理地址。为了加速地址翻译,MMU中设置了TLB缓存,现代处理器如Core i7采用四级页表结构。物理地址通过三级Cache进行访问,实现数据的高速缓存。在进程创建和程序执行过程中,内存映射机制确保了进程拥有独立的地址空间。动态内存分配器负责管理堆内存,包括分配、释放和合并空闲块。隐式空闲链表和显式空闲链表是两种常见的堆内存管理策略。通过本章的学习,我们深入了解了“hello”程序的存储管理机制,以及内存管理中的关键技术。这些知识对于理解操作系统和程序执行过程至关重要。第8章 hello的IO管理

8.1 Linux的IO设备管理方法

(1)设备的模型化:文件

一个Linux文件被看作是一个字节序列,表示为B0,B1,B2,…,Bk,…,Bm-1。所有的I/O设备,包括网络、磁盘和终端等,都被模型化为文件。例如,磁盘分区可以用类似/dev/sda2的文件路径表示。这种模型化方式将内核本身也映射为文件。

(2)设备管理:unix io接口

所有的输入和输出操作都被当作对相应文件的读和写来执行。Unix I/O接口是Linux内核引入的一个简单、低级的应用接口,它允许以一种统一、一致的方式执行所有的输入和输出操作。操作包括打开文件、改变当前文件位置、读写文件和关闭文件。

8.2 简述Unix IO接口及其函数

8.2.1 Unix IO接口

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

②Linux Shell创建的每个进程都有三个打开的文件:标准输入、标准输出、标准错误。

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

④读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时,执行读操作会触发EOF,应用程序能检测到它。类似地,写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

⑤关闭文件。内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。

8.2.2 Unix IO函数

在Unix系统中,IO操作是通过一系列系统调用来实现的,这些系统调用提供了文件打开、关闭、读写以及文件状态查询等功能。下面是对这些函数的详细描述:

int open(const char *filename, int flags, mode_t mode):

  1. filename:指向要打开或创建的文件名的指针。
  2. flags:指定文件的打开模式,如只读、只写、读写等,以及是否创建新文件等。
  3. mode:当创建新文件时,用于指定文件的权限设置。
  4. 返回值:成功时返回文件描述符,失败时返回-1。
  5. 逻辑:如果filename指定的文件不存在且flags中包含O_CREAT标志,则创建新文件;否则,打开已存在的文件。文件描述符是系统分配给打开文件的唯一标识符,用于后续的读写操作。

int close(int fd):

  1. fd:要关闭的文件的描述符。
  2. 返回值:成功时返回0,失败时返回-1。
  3. 逻辑:关闭与文件描述符fd关联的文件,释放系统资源,并使该描述符可用于后续的文件打开操作。

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

  1. fd:要读取的文件的描述符。
  2. buf:指向内存中用于存放读取数据的缓冲区的指针。
  3. n:要读取的最大字节数。
  4. 返回值:成功时返回实际读取的字节数,失败时返回-1,读取到文件末尾时返回0。
  5. 逻辑:从文件描述符fd指向的文件中读取最多n个字节到buf指向的缓冲区。读取的字节数可能小于n,这取决于文件当前位置和文件大小。

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

  1. fd:要写入的文件的描述符。
  2. buf:指向内存中包含要写入数据的缓冲区的指针。
  3. n:要写入的最大字节数。
  4. 返回值:成功时返回实际写入的字节数,失败时返回-1。
  5. 逻辑:将buf指向的缓冲区中的最多n个字节写入到文件描述符fd指向的文件中。写入的字节数可能小于n,这取决于系统缓冲区的状态和文件系统限制。

off_t lseek(int fd, off_t offset, int whence):

  1. fd:要修改文件位置的文件描述符。
  2. offset:相对于whence指定的位置偏移的字节数。
  3. whence:指定offset的基准位置,可以是SEEK_SET(文件开始)、SEEK_CUR(当前文件位置)或SEEK_END(文件末尾)。
  4. 返回值:成功时返回新的文件位置,失败时返回-1。
  5. 逻辑:改变文件描述符fd指向的文件的当前读写位置。whence参数决定了offset是相对于文件的哪个位置进行偏移。

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

  1. filename:指向要查询的文件名的指针。
  2. buf:指向stat结构体的指针,用于存放文件状态信息。
  3. 返回值:成功时返回0,失败时返回-1。
  4. 逻辑:获取filename指定的文件的状态信息,并将这些信息填充到buf指向的stat结构体中。stat结构体包含了文件的大小、权限、所有者、创建时间等信息。

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 函数接收一个格式字符串(fmt)和可变数量的参数(…)。

  • 使用 va_list 和 va_start(在你的代码示例中缺失了 va_start)来初始化参数列表。

  • 调用 vsprintf 函数,将格式化的字符串写入缓冲区 buf。

    再分析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);

    }

printf中通过write函数将vsprintf生成的字符串写到终端。write函数通过系统调用(INT_VECTOR_SYS_CALL)将字符串中的字节从寄存器通过总线复制到显卡的显存中。

mov eax, _NR_write

mov ebx, [esp + 4]
mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

syscall负责将字符串中的字节从寄存器通过总线复制到显卡的显存中,,显存中存储的是字符的 ASCII 码。

  • 当 write 函数被调用时,它可能会触发一个系统调用,这通常是通过一个中断(如 INT_VECTOR_SYS_CALL)来完成的。
  • 系统调用处理程序(在内核中)会接收参数(如要写入的缓冲区地址和长度),并将数据从用户空间复制到内核空间。
  • 如果目标是显存,系统调用处理程序会进一步将数据从内核空间复制到显存(VRAM)。

call save

push dword [p_proc_ready]

sti

push ecx

push ebx

call [sys_call_table + eax * 4]

add esp, 4 * 3

mov [esi + EAXREG - P_STACKBASE], eax

cli

ret

最后,字符显示驱动程序将ASCII码转换为字模库中的点阵信息,并将这些点阵信息存储到vram中。显示芯片按照刷新频率逐行读取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;

}

8.4.1键盘中断处理子程序

当用户在键盘上按下某个键时,会生成一个键盘中断。这个中断会被操作系统捕获,并触发键盘中断处理子程序。这个子程序的主要职责是读取按键的扫描码(Scan Code),然后将它转换成对应的ASCII码。

扫描码是键盘发送给计算机的原始数据,每个键都有一个唯一的扫描码。但是,计算机程序通常使用ASCII码来处理字符,因为ASCII码是一种标准的字符编码方式。因此,键盘中断处理子程序需要将扫描码转换成ASCII码。

一旦转换完成,ASCII码就会被保存到系统的键盘缓冲区(通常是内核空间的一个区域)中,等待用户程序来读取。

8.4.2getchar函数

getchar函数是C语言标准库中的一个函数,用于从标准输入(通常是键盘)读取一个字符。当程序调用getchar函数时,它实际上是在调用底层的read系统调用。

read系统调用会尝试从键盘缓冲区中读取一个字符。但是,如果键盘缓冲区是空的(即用户还没有输入任何字符),那么read系统调用就会阻塞,等待用户输入。这意味着程序会暂停执行,直到有字符被写入键盘缓冲区。

一旦用户开始输入字符,每个字符的ASCII码都会被保存到键盘缓冲区中。但是,通常getchar函数(或者更底层的read系统调用)会等待用户按下回车键(Enter键)才返回。这是因为回车键是一个特殊的字符,它告诉程序用户已经完成了输入。

8.4.3设计考虑

  1. 效率:通过键盘缓冲区,操作系统可以批量处理键盘输入,而不是每次按键都触发一个中断。这减少了中断的频率,提高了系统的效率。
  2. 用户体验:用户可以在按下回车键之前输入多个字符,而不需要等待每个字符都被单独处理。这提高了用户输入的效率。
  3. 行缓冲模式:标准输入通常使用行缓冲模式,这意味着输入会在遇到换行符(通常是回车键)时被刷新到程序。这种设计使得getchar函数能够符合这种模式,从而与标准输入的行为保持一致。

8.5本章小结

第8章 Hello的IO管理主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数、printf和getchar的实现分析。

Linux将所有I/O设备模型化为文件,使用Unix IO接口进行统一管理。Unix IO接口包括打开文件、改变文件位置、读写文件和关闭文件等操作。通过一系列系统调用,Unix IO接口提供了文件打开、关闭、读写以及文件状态查询等功能。

printf函数通过vsprintf生成显示信息,然后通过write系统函数将信息写入终端。write函数通过系统调用将字符串中的字节从寄存器复制到显卡的显存中。字符显示驱动程序将ASCII码转换为字模库中的点阵信息,并将这些点阵信息存储到vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点的RGB分量,实现字符的显示。

getchar函数通过read系统调用从标准输入读取一个字符。当用户在键盘上按下某个键时,会生成一个键盘中断。键盘中断处理子程序读取按键的扫描码,并将其转换成对应的ASCII码。ASCII码被保存到系统的键盘缓冲区中,等待用户程序来读取。getchar函数等待用户按下回车键才返回,符合标准输入的行缓冲模式。

本章通过介绍Linux的IO设备管理方法、Unix IO接口及其函数、printf和getchar的实现分析,帮助读者深入理解Linux的IO管理机制和C语言标准库函数的实现原理。这些知识对于开发高效的IO操作和编写高质量的程序具有重要意义。

结论

在计算机系统中,"hello"程序的完整执行流程可以详细概述如下:

  1. 源代码的编写: 开发者使用文本编辑器编写包含"hello"程序逻辑的源代码文件,如hello.c。这个文件包含了程序的基础结构和功能。
  2. 预处理阶段: 预处理器读取hello.c文件,并处理其中的预处理指令,如#include和#define。预处理过程会包含头文件的内容,并处理宏定义,生成一个预处理后的文件(如hello.i)。
  3. 编译阶段: 编译器读取预处理后的文件hello.i,并将其转换为汇编语言代码。编译过程会检查源代码的语法和语义,生成汇编文件(如hello.s),该文件包含了机器相关的低级指令。
  4. 汇编阶段: 汇编器读取汇编文件hello.s,将其转换为机器语言指令。汇编过程会生成一个可重定位的目标文件(如hello.o),该文件包含了程序的机器码,但尚未确定其在内存中的具体地址。
  5. 链接阶段: 链接器将目标文件hello.o与系统提供的库文件链接在一起,解决符号引用和地址重定位问题。链接过程会生成一个可执行文件(如hello),该文件包含了程序的所有代码和所需的库函数。
  6. 加载与执行: 操作系统创建一个新进程,并使用加载器(如ld-linux.so)将可执行文件hello加载到该进程的虚拟内存中。加载器设置程序的执行环境,并准备开始执行。CPU为进程分配时间片,并执行程序的指令。
  7. 程序执行细节: 在执行过程中,CPU通过内存管理单元(MMU)将程序使用的虚拟地址转换为物理地址。程序可能会使用如malloc等系统调用来动态分配内存。进程会处理来自操作系统或其他进程的信号,如中断或异常。
  8. 程序结束与资源回收: 当程序执行完所有指令后,控制权返回给操作系统。操作系统回收进程占用的所有资源,包括内存、文件描述符等,并结束该进程的生命周期。

通过对"hello"程序生命周期的深入分析,我们可以更加清晰地理解计算机系统设计和执行的复杂性。从源代码的编写到可执行文件的生成,再到程序的加载和运行,每一个环节都展示了计算机系统的层次化设计和软硬件的协同工作。这一过程不仅体现了计算机系统的抽象层次,也展示了从高级语言到机器语言转换的完整流程。

附件

附件1:hello.i 预处理后的文本文件

附件2:hello.s 编译后的汇编语言文件

附件3:hello.o 汇编后的可重定位目标文件

附件4:hello.o.elf readelf读取hello.o得到的ELF格式信息

附件5:obj_hello.o.s 反汇编hello.o得到的反汇编文件

附件6:hello.elf 由hello可执行文件生成的.elf文件

附件7:obj_hello.s 反汇编hello可执行文件得到的反汇编文件

附件8:hello.c C语言源代码文件

附件9:hello 链接后的可执行目标文件

参考文献

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

[2] https://blog.csdn.net/X1009190387/article/details/108994778

[3] ld 运行时动态链接-CSDN博客

[4] 预处理、编译、汇编和链接_已知hello.h和hello.c两个文件,按所需命令写在下划线上-CSDN博客

[5] fork()创建子进程步骤、函数用法及常见考点(内附fork()过程图)_子进程创建时的第一个函数-CSDN博客

[6] 浅析shell的工作原理-CSDN博客

[7] Linux系统——fork()函数详解(看这一篇就够了!!!)_fork函数-CSDN博客

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值