哈尔滨工业大学计算机系统大作业 2024Spring

摘  要

本文旨在解释C语言程序如何从源代码转换为可执行文件。以hello.c程序为例,本文详细追踪了hello的生命周期,深入分析了计算机生成hello可执行文件的过程,包括预处理、编译、汇编、链接、进程管理、存储管理、I/O管理等步骤。本文不仅从理论上探讨了这些过程的原理和方法,还通过工具进行了实际操作,帮助我们更深入地理解计算机系统的工作流程。

关键词:计算机系统; 进程管理;储存管理;程序执行                           

第1章 概述

1.1 Hello简介

P2P(From Program to Process)指的是从hello.c(程序)变为运行时进程。要运行hello.c这个C语言程序,首先需要将其转换为可执行文件。这个过程分为四个阶段:预处理、编译、汇编和链接。完成这些阶段后,得到的可执行文件就可以在shell中运行,shell会为其分配进程空间。

Hello程序的编译过程如图1所示。首先编写hello.c源代码文件。然后,C预处理器(cpp)对其进行预处理,生成hello.i文件。接着,C编译器(ccl)将hello.i编译成汇编语言文件hello.s。之后,汇编器(as)将hello.s汇编成可重定位目标程序hello.o。最后,链接器(ld)将hello.o与系统目标文件链接,生成最终的可执行文件hello。

图1 可执行程序的编译生成过程

020:即From Zero-0 to Zero-0。指最初内存并无hello文件的相关内容,最后也不会留下hello文件的内存。首先shell用fork函数创建一个新的进程,并用execve函数启动hello程序,把虚拟内存对应到物理内存,并从程序入口开始加载和运行,进入main函数执行目标代码。程序结束后,shell父进程回收hello进程,内核删除hello文件相关的所有数据结构。

1.2 环境与工具

  • 硬件环境:CPU: AMD Ryzen 7 6800H 3.20GHz; RAM: 16GB
  • 软件环境:Windows11 x64; VMware虚拟环境 with Ubuntu 22.04 LTS
  • 开发与调试工具:Visual Studio 2022 64位; vim edb objdump gcc等工具

1.3 中间结果

  • hello.c                源文件
  • hello.i                预处理后得到的文件
  • hello.s              编译后得到的汇编语言文件
  • hello.o         汇编后得到的可重定位目标文件
  • hello.out       链接后的可执行文件
  • hello.asm      反汇编hello.o得到的反汇编文件
  • hello1.asm     反汇编hello得到的反汇编文件
  • hello1.elf      hello.o的ELF文件
  • hello2.elf      hello的ELF文件

1.4 本章小结

本文介绍了hello的P2P和O2O的过程,详细讲解了预处理、编译、汇编和链接四个阶段,并描述了每个阶段生成的中间文件。通过这些步骤,hello.c最终转换为可以在shell中运行的hello.out可执行文件,并且在程序结束后,内存中不会残留任何与hello文件相关的内容,这叫做O2O。

第2章 预处理

2.1 预处理的概念与作用

预处理是C语言编译过程的第一个阶段,其主要作用是对源代码进行初步处理,以便后续的编译阶段能够更高效地进行。预处理器扫描源代码文件,并处理所有以#开头的预处理指令,这些指令包括宏定义、文件包含、条件编译、行控制和其他指令。通过宏替换、文件内容插入和条件编译,预处理器生成一个整理和扩展后的代码文件(通常带有.i扩展名)。这个文件包含了所有宏替换和文件包含后的代码,是编译器进一步处理的基础,从而使得编译过程更加高效和有序。

2.2在Ubuntu下预处理的命令

cpp hello.c hello.i 或 gcc -E hello.c -o hello.i

图2 hello.c的预处理命令执行

2.3 Hello的预处理结果解析

预处理器会包含一些源文件的信息,比如包含的头文件以及一些宏定义的处理结果。在这个例子中,hello.i文件由hello.c的24行扩展为3000多行,除了包括最初的.c源程序,还包括对头文件#include <stdio.h>、#include <unistd.h>、 #include <stdlib.h>用extern的方式进行了引用,如图3所示。

图3 hello.i中的部分内容

2.4 本章小结

预处理是 C 语言编译的第一阶段,通过宏替换、文件包含和条件编译,预处理器生成一个整理后的代码文件(.i文件),为编译器提供高效处理的基础。本章首先介绍了预处理的概念与作用,接着以hello.c为例,演示了在Ubuntu下如何预处理程序,得到hello.i文件,并对预处理结果进行分析。

第3章 编译

3.1 编译的概念与作用

编译是C语言编译过程的第二个阶段,其主要作用是将预处理后的源代码转换为目标机器能够理解的汇编代码。在这一阶段,编译器对源代码进行词法分析、语法分析和语义分析,检查代码的正确性,并将高层次的C语言结构转换为低层次的汇编语言指令。通过编译,生成一个汇编代码文件(通常带有.s扩展名),为后续的汇编阶段提供输入。编译的核心目的是将人类可读的代码转化为计算机可执行的指令格式,从而为程序的最终执行奠定基础。

3.2 在Ubuntu下编译的命令

ccl hello.i hello.s 或 gcc -S hello.i -o hello.s

图5 hello.i的编译命令执行

3.3 Hello的编译结果解析

3.3.1 文件头

这行声明了源文件名 hello.c,表明这是由 hello.c 文件编译而来的。

   .file "hello.c"

3.3.2 文本段和只读数据段

.text 指示接下来的代码段是可执行代码,.section .rodata 定义了一个只读数据段,用于存放常量字符串等数据。.align 8 将接下来的数据对齐到 8 字节边界,提高内存访问效率。.LC0 和 .LC1 分别定义了两个字符串常量,用于后续的输出。

.text

.section .rodata

.align 8

.LC0:

    .string "\347\224\250\346\263\225: Hello 2022113523 zzy PhoneNumber 1\357\274\201"

.LC1:

    .string "Hello %s %s %s\n"

3.3.3 全局符号和类型定义

.globl main 声明 main 为全局符号,使其可以被链接器访问,.type main, @function 声明 main 是一个函数。这些指令告诉编译器和链接器如何处理 main 函数。

.globl main

.type main, @function

3.3.4 主函数代码段

主函数 main 从标签 main开始,.LFB6: 标记函数帧的开始。.cfi_startproc 开始函数的调试信息,endbr64 是用于控制流保护的指令。接下来的指令 pushq %rbp 和 movq %rsp, %rbp 保存栈帧指针并设置新的栈帧。subq $32, %rsp 为局部变量分配栈空间,movl %edi, -20(%rbp) 和 movq %rsi, -32(%rbp) 保存函数参数。cmpl $5, -20(%rbp) 检查第一个参数是否等于 5,如果是则跳转到 .L2。否则,leaq .LC0(%rip), %rdi 将字符串 .LC0 的地址加载到 rdi,然后调用 puts 函数打印该字符串。movl $1, %edi 设置退出码为 1,最后调用 exit 函数退出程序。

main:

.LFB6:

    .cfi_startproc

    endbr64

    pushq %rbp

    .cfi_def_cfa_offset 16

    .cfi_offset 6, -16

    movq %rsp, %rbp

    .cfi_def_cfa_register 6

    subq $32, %rsp

    movl %edi, -20(%rbp)

movq %rsi, -32(%rbp)

cmpl $5, -20(%rbp)

je .L2

leaq .LC0(%rip), %rdi

call puts@PLT

movl $1, %edi

call exit@PLT

其中赋值操作利用mov执行实现,这些指令分别完成了将栈指针保存到基址指针寄存器 %rbp 中,将函数参数保存到局部变量空间中,以及将立即数和寄存器值赋值给其他寄存器或内存位置的操作。

movq %rsp, %rbp

movl %edi, -20(%rbp)

movq %rsi, -32(%rbp)

movl $1, %edi

movl %eax, %edi

movl $0, %eax

movl $0, -4(%rbp)

算术操作通过加法、减法和比较指令实现。这些指令包括为局部变量分配栈空间的减法操作,将寄存器值增加特定数值的加法操作,递增局部变量值,以及比较局部变量与立即数大小的操作。

subq $32, %rsp

addq $24, %rax

addl $1, -4(%rbp)

cmpl $5, -20(%rbp)

cmpl $9, -4(%rbp)

数组和指针操作通过加载和存储指令实现。详细分析:

movq -32(%rbp), %rax:将局部变量 -32(%rbp) 中的指针(即 argv 数组的基地址)加载到寄存器 %rax 中。addq $8, %rax:将 %rax 中的地址加上 8,计算得到 argv[1] 的地址。movq (%rax), %rax:将 argv[1] 地址处的值(即 argv[1] 的内容)加载到寄存器 %rax。addq $8, %rax:将 %rax 中的地址加上 8,计算得到 argv[2] 的地址。movq (%rax), %rdx:将 argv[2] 地址处的值(即 argv[2] 的内容)加载到寄存器 %rdx。addq $8, %rax:将 %rax 中的地址再加上 8,计算得到 argv[3] 的地址。movq (%rax), %rcx:将 argv[3] 地址处的值(即 argv[3] 的内容)加载到寄存器 %rcx。

通过这些指令,程序实现了对命令行参数数组 argv 中各元素(argv[1]、argv[2] 和 argv[3])的读取,并将其值分别存储到相应的寄存器中。这种方式展示了数组和指针在汇编中的操作方式,即通过指针加偏移量的方式访问数组元素。

movq -32(%rbp), %rax

addq $24, %rax

movq (%rax), %rcx

movq -32(%rbp), %rax

addq $16, %rax

movq (%rax), %rdx

movq -32(%rbp), %rax

addq $8, %rax

movq (%rax), %rax

movq %rax, %rsi

类型转换通过寄存器间的传递和调用相关函数实现。这些指令展示了从一个寄存器到另一个寄存器的值传递,相当于隐式的类型转换。调用 atoi 函数将字符串转换为整数,然后将返回值传递给另一个寄存器,也是一种类型转换操作。

movq %rax, %rsi

call atoi@PLT

movl %eax, %edi

3.3.5 控制转移操作

movl $0, -4(%rbp) 初始化循环计数器,然后跳转到 .L3 进行条件检查。循环主体从 .L4: 开始,处理命令行参数,将其加载到寄存器并调用 printf函数、atoi 函数和 sleep 函数。每次循环结束时,计数器 -4(%rbp) 增加 1。循环条件在 .L3: 检查,如果计数器小于或等于 9,则跳回 .L4:。

.L4:

    movq -32(%rbp), %rax

    addq $24, %rax

    movq (%rax), %rcx

    movq -32(%rbp), %rax

    addq $16, %rax

    movq (%rax), %rdx

    movq -32(%rbp), %rax

    addq $8, %rax

    movq (%rax), %rax

    movq %rax, %rsi

    leaq .LC1(%rip), %rdi

    movl $0, %eax

    call printf@PLT

    movq -32(%rbp), %rax

    addq $32, %rax

    movq (%rax), %rax

    movq %rax, %rdi

    call atoi@PLT

    movl %eax, %edi

    call sleep@PLT

    addl $1, -4(%rbp)

.L3:

    cmpl $9, -4(%rbp)

jle .L4

3.4 本章小结

本章首先介绍了编译的概念及作用,接着在编译器中将hello.i 文件生成 hello.s 文件,并解析了 hello.s 程序中变量、运算以及各类函数操作的汇编代码。详细分析了文件头、文本段和只读数据段、全局符号和类型定义、主函数代码段中的赋值、算术、数组/指针操作以及类型转换等部分,即成功将预处理后的源代码转换为目标机器能够理解的汇编代码。

第4章 汇编

4.1 汇编的概念与作用

汇编是将高级语言编译后的汇编代码(.s 文件)转换为机器语言二进制程序(.o 文件),即可重定位目标文件的过程。在这个阶段,汇编器将汇编代码转换为可执行的机器指令,包括数据段、代码段和符号表等信息,为程序的最终执行提供了直接的指令序列。这一过程是将人类可读的汇编代码转化为计算机可执行的指令格式的关键步骤。

4.2 在Ubuntu下汇编的命令

as -o hello.o hello.s 或gcc -c hello.s -o hello.o

图6 hello.s的汇编命令执行

4.3 可重定位目标elf格式

readelf -a hello.o > hello1elf.txt

图7 hello.o重定位格式elf生成

  1. ELF头:首先Magic中的16个字节描述了生成文件的字大小为8字节,字节顺序为小端序。ELF头中还描述了文件类别、数据存放方式、版本号、操作系统,并且给出了入口点地址、程序头起点、节头表的偏移以及节头部表中条目的大小等信息,如图8所示。

图8 ELF头的具体内容

  1. 节头表:节头表中描述了文件中各个节的名称、类型、大小、地址、偏移、读写访问权限等信息,如图9所示。

图9 节头表的具体内容

  1.  重定位节与符号表:提供了编译后程序的静态信息,以便在链接时正确地解析符号引用和重定位信息。这个文件是一个64位的ELF可重定位文件,适用于AMD X86-64架构。它包含了两个重定位节,分别是.rela.text和.rela.eh_frame。这些重定位节存储了需要在链接或加载时修正的信息,包括目标符号的名称、修正的偏移量、修正的类型等。.symtab节是程序的符号表,它记录了程序中所有被定义和引用的全局变量和函数的信息。

在这个文件中,有六个被调用的函数的信息,包括puts、exit、printf、atoi、sleep、getchar。符号表的每个条目包括:符号的索引号、其地址偏移量、符号的大小、符号的类型、符号的绑定属性,表明其是局部的还是全局的、符号的可见性、符号的节索引、符号的名称。

其中值得注意的是,.symtab节只包含全局符号的信息,不包括局部变量的定义或引用,符号表的具体内容如图10所示。

图10 符号表的具体内容

4.4 Hello.o的结果解析

objdump -d -r hello.o > hello.asm

图11 hello.o反汇编的执行

与hello.s对比,其区别主要在以下几点:

  1. 操作数不同。hello.asm文件中运算使用的立即数是16进制表示的,而hello.s文件中运算使用的是10进制表示的。这种差异是因为汇编语言允许使用不同的数值表示方式,但在生成的机器码中,这些数值都会被正确编码为二进制形式,以便CPU能够正确执行。
  2. 分支转移函数调用方式不同。在hello.asm文件中,函数调用通过call指令加上函数的首地址来实现,这个地址是程序运行时的绝对地址。而在hello.s文件中,函数调用通过call指令加上函数的名称来调用,这些名称在汇编阶段会被转换成实际的地址。分支跳转指令在汇编中也类似,hello.asm文件中指定了跳转的地址,而hello.s文件中使用相对于当前位置的偏移量进行跳转。
  3. 指令格式不同。汇编语言中的每条指令都会被编译器或汇编器转换成机器指令的二进制编码形式,这些编码直接由CPU执行。因此,虽然在汇编语言中我们看到的是助记符和操作数的符号化表示,但最终都会转化为硬件可以执行的机器码。

hello.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:

   0: f3 0f 1e fa            endbr64

   4: 55                      push   %rbp

   5: 48 89 e5               mov    %rsp,%rbp

   8: 48 83 ec 20            sub    $0x20,%rsp

   c: 89 7d ec               mov    %edi,-0x14(%rbp)

   f: 48 89 75 e0            mov    %rsi,-0x20(%rbp)

  13: 83 7d ec 05            cmpl   $0x5,-0x14(%rbp)

  17: 74 16                   je     2f <main+0x2f>

  19: 48 8d 3d 00 00 00 00 lea    0x0(%rip),%rdi        # 20 <main+0x20>

         1c: R_X86_64_PC32  .rodata-0x4

  20: e8 00 00 00 00         callq  25 <main+0x25>

         21: R_X86_64_PLT32 puts-0x4

  25: bf 01 00 00 00         mov    $0x1,%edi

  2a: e8 00 00 00 00         callq  2f <main+0x2f>

         2b: R_X86_64_PLT32 exit-0x4

  2f: c7 45 fc 00 00 00 00 movl   $0x0,-0x4(%rbp)

  36: eb 53                   jmp    8b <main+0x8b>

  38: 48 8b 45 e0            mov    -0x20(%rbp),%rax

  3c: 48 83 c0 18            add    $0x18,%rax

  40: 48 8b 08               mov    (%rax),%rcx

  43: 48 8b 45 e0            mov    -0x20(%rbp),%rax

  47: 48 83 c0 10            add    $0x10,%rax

  4b: 48 8b 10               mov    (%rax),%rdx

  4e: 48 8b 45 e0            mov    -0x20(%rbp),%rax

  52: 48 83 c0 08            add    $0x8,%rax

  56: 48 8b 00               mov    (%rax),%rax

  59: 48 89 c6               mov    %rax,%rsi

  5c: 48 8d 3d 00 00 00 00 lea    0x0(%rip),%rdi        # 63 <main+0x63>

         5f: R_X86_64_PC32  .rodata+0x2a

  63: b8 00 00 00 00         mov    $0x0,%eax

  68: e8 00 00 00 00         callq  6d <main+0x6d>

         69: R_X86_64_PLT32 printf-0x4

  6d: 48 8b 45 e0            mov    -0x20(%rbp),%rax

  71: 48 83 c0 20            add    $0x20,%rax

  75: 48 8b 00               mov    (%rax),%rax

  78: 48 89 c7               mov    %rax,%rdi

  7b: e8 00 00 00 00         callq  80 <main+0x80>

         7c: R_X86_64_PLT32 atoi-0x4

  80: 89 c7                   mov    %eax,%edi

  82: e8 00 00 00 00         callq  87 <main+0x87>

         83: R_X86_64_PLT32 sleep-0x4

  87: 83 45 fc 01            addl   $0x1,-0x4(%rbp)

  8b: 83 7d fc 09            cmpl   $0x9,-0x4(%rbp)

  8f: 7e a7                   jle    38 <main+0x38>

  91: e8 00 00 00 00         callq  96 <main+0x96>

         92: R_X86_64_PLT32 getchar-0x4

4.5 本章小结

本章介绍了汇编语言的基本概念和作用,以及如何将汇编程序 hello.s 转换为可重定位目标文件 hello.o。通过使用 readelf 分析了 hello.o 的ELF格式,包括ELF头部、节头表、重定位节和符号表的结构和作用。最后,使用 objdump 生成了 hello.asm 反汇编文件,并与原始汇编程序进行比对分析,以验证编译和链接的正确性。

5章 链接

5.1 链接的概念与作用

链接指的是将编译生成的可重定位目标文件 hello.o 转化为最终的可执行文件 hello 的过程。在这个过程中,链接器首先解析 hello.o 中的符号,确定它们的定义和引用关系,然后根据这些信息进行地址重定位,确保所有符号引用指向正确的内存地址。最终,链接器将经过重定位的目标文件合并成一个完整的可执行程序 hello,包含了程序的入口点和所有必要的代码和数据段。链接过程不仅整合了程序的各个模块,还支持优化和动态加载,是软件开发中不可或缺的关键步骤之一。

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

图12 hello.o链接的执行

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

readelf -a hello >  hello2elf.txt

与hello.o可执行目标文件相比,hello文件无需rel.text和rel.data节,因为其在链接中完成了重定位,舍弃了重定位节。

其余部分由于链接的作用也发生了变化。在ELF文件头中,文件类型变成了EXEC可执行文件,与hello.o的REL可重定位文件类型不同。符号表中也保存了定位、重定位程序中符号定义和引用的信息。其具体分布如图13所示。

图13 可执行目标文件hello的格式

5.4 hello的虚拟地址空间

使用edb打开hello,从Data Dump窗口可以观察到hello加载到虚拟地址的情况,如图14所示。对照hello2elf.txt文件与edb中的信息。例如,从可执行文件中得知,.interp节从地址0x4002e0开始,我们到edb窗口中找到对应地址,如图14所示,对照得出其一致性。图15为edb中显示的虚拟地址空间。

图14 edb在虚拟地址空间的一致性

图15 hello的虚拟地址空间分布

5.5 链接的重定位过程分析

objdump -d -r hello > hello1.asm

将得到的hello1.asm文件与hello.asm作比较,两文件内容如图16、17所示。

图16 hello.asm文件内容

图17 hello1.asm相对hello.asm增加的文件内容

两者的不同主要有以下几个方面:

  1.  链接后函数数量增加。链接后的反汇编文件helloasm中多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数,这说明链接过程中动态链接器引入了很多其他库和函数,使反汇编文件中多出了许多其他的函数。
  2.  函数调用的方式不一样。在hello1.asm文件中,函数调用通过call指令加上函数的首地址来实现,这个地址是程序运行时的绝对地址。而在hello.asm文件中,函数调用通过call指令加上相对偏移量来调用。
  3.  跳转指令不同。hello1.asm使用重定位后的绝对地址,而hello.asm使用相对main的相对地址。

5.6 hello的执行流程

具体顺序为:

_start、_libc_start_main、main、printf、_exit、_sleep、getchar、exit

程序名

程序地址

_start

0x4010f0

_libc_start_main

0x2f12271d

main

0x401125

_printf

0x4010a0

_sleep

0x4010e0

_getchar

0x4010b0

_exit

0x4010d0

5.7 Hello的动态链接分析

动态链接的基本思想是将程序的链接过程推迟到程序运行时进行,而不是在编译时完成。这种方法使得程序可以在启动时加载所需的动态链接库(如 .so 文件或 DLL 文件),而不必将库的代码在编译时静态地链接到程序中。通过动态链接,多个程序可以共享同一个库的实现,减少内存占用和磁盘空间使用,并且使得库的更新和维护更为灵活和简便。在运行时,动态链接器负责解析程序对库的符号引用,将它们链接到正确的库实现,从而确保程序能够正常运行并访问所需的函数和数据。动态链接技术不仅提高了程序的效率和资源利用率,还支持灵活的库版本管理和动态加载与卸载,为软件开发和维护带来了便利。

       对于具体程序,我们使用edb调试,对比程序动态链接前后相应节的变化。例如对于使用了延迟绑定的.got节,我们使用edb查看该节在运行dl_init前后的文件内容差异,如图18、19所示。

dl_init前:

dl_init后:

图18、19 运行dl_init前后的代码段

可以观察到,部分原为00的位变为了相应链接后的参数,说明动态链接将库的代码映射到了对应的可执行的程序中,便于进程的运行。

5.8 本章小结

本章首先阐述了链接的基本概念和作用,并随后使用链接命令生成hello可执行文件,除此之外,还观察了hello文件ELF格式下的内容,并利用edb探索了hello文件的虚拟地址空间使用情况,最后对hello的重定位和动态链接过程进行了分析。

6章 hello进程管理

6.1 进程的概念与作用

进程定义为运行中的程序实例,每个程序在操作系统中以进程形式存在,包含程序代码、数据、栈、寄存器状态、程序计数器、环境变量和打开的文件描述符集合等运行所需状态。

进程的作用是提供两个重要抽象。首先,提供独立的逻辑控制流,使程序似乎独占处理器资源;其次,提供私有的地址空间,使程序似乎独占内存系统资源。这种抽象使得操作系统能够有效地管理和调度多个程序,并确保它们之间的互相隔离和安全执行。

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

Shell是一个交互型应用级程序,也被称为命令解析器,它为用户提供一个操作界面,接受用户输入的命令,并调度相应的应用程序。Shell的灵活性和强大功能使得它成为操作系统中不可或缺的一部分,用户通过Shell可以方便地与计算机系统进行交互和管理。

6.3 Hello的fork进程创建过程

首先读取用户在终端中输入./hello命令,shell判断该指令不是内置指令,于是调用fork函数创建一个新的子进程。子进程得到与父进程用户级虚拟地址空间相同的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程与父进程最大的区别就是具有不同的PID,在父进程中fork返回子进程的PID,而在子进程中fork返回0。子进程与父进程并发执行,具有相同但独立的地址空间,并且共享文件。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序,函数格式为:

int execve(const char *filename, const char *argv[], const char *envp[]);

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并不返回。

execve函数启动加载器,映射虚拟内存,进入程序入口后,程序开始载入物理内存,然后进入main函数。

在main开始执行时,用户栈的组织结构如图20所示

图20 新程序开始时的用户栈

6.5 Hello的进程执行

分析hello在shell中执行的整体过程:

  1. 在shell命令行中输入命令:$ ./hello
  2. Shell命令行解释器解析命令:当用户在命令行输入./hello后,shell命令行解释器(例如bash)会解析这个命令。它识别出./hello是一个可执行文件,并准备执行该文件。
  3. 构造argv和envp:在执行./hello之前,shell会为将要执行的程序构造argv和envp数组。argv是一个指向参数的数组,通常包括程序名本身和传递给程序的任何命令行参数。envp是一个指向环境变量的数组,其中包含了环境变量的键值对信息。
  4. 调用fork()函数创建子进程:Shell调用fork()系统调用创建一个新的子进程。子进程的地址空间与父进程(即Shell进程)完全相同,包括代码段、数据段、堆和用户栈等。此时,子进程是一个几乎完全复制了父进程的副本,但处于相同的运行状态。

  1. 调用execve()函数加载并运行hello程序:子进程在调用execve()系统调用时,会加载并执行./hello程序。execve()会在当前进程(即新创建的子进程)的上下文中加载指定的可执行文件,并且将该文件的内容加载到当前进程的虚拟地址空间中。包括:.text节(代码段):包含程序的可执行指令。.data节(数据段):包含初始化的全局和静态变量。.bss节:包含未初始化的全局和静态变量。
  2. 调用hello程序的main()函数:一旦execve()成功加载并运行./hello程序,程序控制流将转移到该程序的入口点,即main()函数。这标志着./hello程序开始在一个进程的上下文中运行,执行其预定的任务。

其中,上下文切换是一种建立在较低层异常机制上的一种异常控制流,用于实现多任务,内核为每一个进程维持一个上下文,用于储存一些进程的状态。而如果内核决定抢占当前进程,就会使用一种称为上下文切换的机制来将控制转移到 新的进程。

具体原理剖析如图21所示,在运行原有进程时,由于我们的键盘输入,内核希望将进程切换到hello,于是这时内核进行上下文切换,此时从用户模式切换到内核模式,并返回用户模式执行进程hello,直到hello执行完毕,再进行一次上下文切换,恢复到进程1运行。

而进程时间片就是一个进程执行他控制流的每一时间段,在这里指的就是进程hello执行的每一段时间。

图21 进程上下文切换的剖析

6.6 hello的异常与信号处理

6.6.1 异常

     异常就是控制流中的突变,用来响应处理器状态中的某些变化。异常的类型包括中断、陷阱、故障和终止,其行为如图22所示。

图22 异常的类别

       对于异常的处理过程,处理器状态中的变化(事件)触发从应用程序到异常处理程序的突发的控制转移(异常)。在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止,处理的过程剖析如图23所示。

图23 异常过程的剖析

       实际上,在执行hello程序过程中,可能出现由于用户键盘输入信息,由于出现来自I/O设备的信号,导致中断出现,异常处理后总是返回到下一条指令。也有可能出现系统调用,例如execve执行程序等,异常处理完成后总是返回到下一条指令。除此之外,也可能出现故障,例如读取指令时的缺页故障。最后,进程hello终止时也会出现终止异常,处理完后不会返回。

6.6.2 信号

信号是一种高层的软件形式的异常,允许进程和内核中断其他进程。一个信号就是一条小消息,通知进程系统发生了一个某种类型的事件。Linux信号类型如图24所示。

图24 Linux信号

在hello运行过程中,其作为子进程结束后,需要给其父进程发送SIGCHLD信号,等待父进程的回收。同时,如果收到用户键盘输入的Ctrl+C或者Ctrl+Z,会分别收到SIGINT或者SIGSTOP信号,如果收到的是SIGSTOP还会等待下一个SIGCONT,等待继续的信号继续执行程序。

6.6.3 具体运行

  1. 正常运行:每隔1秒输出argc中的4个参数,共10次,最后输入回车后程序正常退出,如图25所示。

图25 程序正常运行的过程

  1. 按回车运行:共输入三次回车,最后一次回车被getchar()读入作为程序结束标志,前两次则会输出出来,程序结束后产生两个换行符,如图26所示

图26 在程序运行过程按回车的运行过程

  1. 输入Ctrl+C:程序会终止执行,此时由于键盘输入,会让内核发送一个SIGINT信号给hello,终止了前台的hello作业,如图27所示

图27 在程序运行过程按Ctrl+C的运行过程

4.  输入Ctrl+Z:程序停止并挂起,因为从键盘输入Crtl+Z会使内核发送一个SIGTSTP信号给hello,停止(挂起)了前台的hello作业,如图28所示

图28 在程序运行过程按Ctrl+Z的运行过程

5.  输入 ps jobs:ps打印各进程的PID,jobs显示后台挂起作业hello,如图29所示

图29 程序挂起后ps/jobs的运行结果

6.  输入pstree:打印进程树,如图30所示

图30 进程树

7.  输入fg:让挂起的作业继续执行,如图31所示

图31 让挂起的作业继续执行

8.  输入kill:发送SIGKILL信号,杀死hello进程,如图32所示

图32 发送kill信号

6.7本章小结

本章首先介绍了进程的概念及其作用,然后结合fork和execve函数,概述了shell如何创建hello子进程的过程。随后分析了hello的进程调度,并对执行过程中的异常和信号处理进行了简单介绍。最后,通过在hello运行时调用多种命令,了解了不同信号的处理结果。
7章 hello的存储管理

7.1 hello的存储器地址空间

1.逻辑地址

逻辑地址是由CPU生成的地址。在程序中使用的指针和数组索引等就是逻辑地址,这些地址在编译时或运行时被分配和管理,是程序员可以直接看到和使用的地址。

2.线性地址

线性地址是指经过段选择子和段偏移计算得到的地址。在x86架构中,CPU首先使用段寄存器(如CS、DS)和段偏移计算出线性地址。这是内存管理单元(MMU)中的一个中间地址。例如,如果hello程序使用段式内存管理模型,程序指令和数据的逻辑地址首先通过段寄存器和段偏移计算出线性地址。

       3.虚拟地址(Virtual Address)

虚拟地址是操作系统使用虚拟内存机制将逻辑地址映射到物理内存时所用的地址。虚拟地址空间是进程独立的,每个进程有自己的虚拟地址空间,通过页表映射到物理内存,即MMU映射前的地址。

       4.物理地址(Physical Address)

物理地址是实际的内存硬件地址,虚拟地址通过MMU的页表映射到物理地址,是CPU最终访问的实际内存位置。

       5.结合hello具体说明

逻辑地址即程序代码中直接使用的地址。例如,argv[1]argv[2]等命令行参数的指针。线性地址指的是通过段寄存器和段偏移计算出的地址。虚拟地址和物理地址在寻址时会用到,先通过虚拟地址在页表中找到PPN,再与VPO组合形成PA到内存中寻址。

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

在保护模式下,以段描述符作为下标,到GDT或者LDT中查表获得段地址。段地址+偏移地址=线性地址。 

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

由线性地址变换到物理地址,由于物理地址PA=PPN+PPO,且PPO和VPO是相同的,所以只需要用VA在页表中找到PPN,即可变换到物理地址,如图33所示。

图33 地址翻译的过程

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

使用k级页表,即将第n-1级页表作为第n级页表的索引,使用多级页表有利于节省空间,也有利于加速页表的索引过程。

图34 使用k级页表的地址翻译

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

到目前为止,我们一直假设高速缓存只保存程序数据。不过实际上,高速缓存既保存数据,也保存指令。只保存指令的高速缓存称为i-cache,只保存程序数据的高速缓存称为d-cache,既保存指令又包括数据的高速缓存称为统一的高速缓存。Core i7处理器的高速缓存层次结构如图35所示,每个芯片有四个核,每个核有自己私有的L1 i-cache、L1-d-cache和L2统一的高速缓存。所有的核共享片上L3统一的高速缓存。这个层次结构的一个有趣的特性是所有的SRAM高速缓存存储器都在CPU芯片上。

图35 Core i7的高速缓存层级结构

7.6 hello进程fork时的内存映射

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

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

图36 私有的写时对象复制

7.7 hello进程execve时的内存映射

1.删除已存在的用户区域

删除当前进程虚拟地址的用户部分中的已存在的区域结构。

2.映射私有区域

为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区域。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也请求二进制0,初始长度为0。

3.映射共享区域

如果a.out让程序与共享对象(或目标)链接,比如标准C库libc,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器(PC)

execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。linux将根据需要换入代码和数据页面。

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

       在缓存中查找PTE时,其有效位为零,所以MMU触发一次异常,传递CPU中的控制到OS中的缺页异常处理程序,缺页异常处理程序确定物理内存中的牺牲页,如果这个页面已经被修改了,则把他换出到硬盘。最后缺页处理程序页面调入新的页面,并更新内存中的PTE,整体过程如图37所示。

图37 缺页异常处理过程

7.9动态存储分配管理

printf 调用 malloc 来动态分配内存,以便存储和格式化输出数据,这涉及到动态内存管理的基本方法与策略。动态内存管理是指在程序运行时,根据需要分配和释放内存的过程,主要通过 malloc、calloc 和 realloc 等函数来实现分配内存,通过 free 函数来释放内存。常见的内存分配策略包括首次适应、最佳适应、最差适应和快速适应等,这些策略各有优缺点,分别适用于不同的应用场景。动态内存管理面临的主要挑战包括内存碎片、内存泄漏和管理开销等问题。合理的内存管理可以提高程序的运行效率,减少资源浪费和潜在的内存问题,从而确保程序的稳定性和可靠性。动态内存分配器维护的堆如图38所示。

图38 动态内存分配器维护的堆

7.10本章小结

本章首先介绍了hello的存储器地址空间、hello的段式管理和页式管理,然后以intel Core i7环境为例介绍了虚拟地址VA到物理地址PA的转换以及三级Cache物理内存访问,并分析了hello进程fork时和execve时的内存映射,最后分析缺页故障与缺页中断处理的过程,以及动态存储分配的管理的相关内容。

8章 hello的I/O管理

8.1 Linux的IO设备管理方法

设备的模型化:在 UNIX 操作系统中,I/O设备(如硬盘、终端、打印机等)被抽象和模型化为文件。这种方法称为设备的文件化模型,它将设备统一视为文件进行处理,使得设备访问与文件操作一致,简化了设备管理和用户操作。

设备管理:在 UNIX 系统中,设备管理通过统一的 I/O 接口进行操作。这个接口提供了一组标准的系统调用,用于文件和设备的输入输出操作。

8.2 简述Unix IO接口及其函数

Unix I/O 接口通过一组标准系统调用来处理文件和设备的输入输出操作。主要的系统调用包括 open, read, write, close 等,它们为文件、设备和其他 I/O 操作提供了统一的接口。程序员可以通过这些调用来完成数据的读取、写入和设备控制等任务。

主要 I/O 函数:

  • int open(const char *pathname, int flags, mode_t mode);

功能: 打开文件或设备,返回文件描述符。

参数:

  • pathname: 要打开的文件或设备的路径。
  • flags: 文件打开的标志,如只读、只写、追加等。
  • mode: 创建文件时的权限,通常与文件创建标志如 O_CREAT 一起使用。

返回值: 成功返回文件描述符,失败返回 -1 并设置 errno

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

功能: 从文件或设备读取数据。

参数:

  • fd: 文件描述符
  • buf: 存储读取数据的缓冲区
  • count: 要读取的字节数

返回值: 成功返回文件描述符,失败返回 -1 并设置 errno

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

功能: 向文件或设备写入数据。

参数:

  • fd: 文件描述符
  • buf: 存储读取数据的缓冲区
  • count: 要读取的字节数

返回值: 成功返回文件描述符,失败返回 -1 并设置 errno

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 的核心流程:

    1. 定义一个 va_list 类型的变量 arg,用来处理可变参数。
    2. 使用 vsprintf 函数格式化字符串。
    3. 调用 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);

}

vsprintf 负责按照格式字符串 fmt,从 args 中读取参数并进行格式化,最终输出到 buf 中。之后,write 函数负责将格式化后的字符串输出到终端。在OS中,printf 函数最终会调用系统调用来完成实际的输出操作。例如,在 Linux 系统中,printf 通过系统调用 write 来将数据写入标准输出设备。在 x86 架构上,经典的 Linux 系统调用机制是通过 int 0x80 中断向量进入内核。

对于终端设备,字符输出最终会通过显示驱动程序将字符显示在屏幕上。驱动程序的角色是将字符或字符串转换为具体的像素信息存储在显存(VRAM)中,显示芯片(如显卡)负责从 VRAM 中读取像素数据,并将其转换为适合显示器的信号。最终,LCD 显示器接收到来自显示芯片的信号,并将其显示为图像。

8.4 getchar的实现分析

在现代操作系统中,键盘输入处理是通过异步异常(异步中断)机制实现的。键盘中断是硬件层面的一种中断,由键盘控制器生成,当用户按下键盘上的任意键时,键盘控制器会向 CPU 发送一个中断信号,通知系统有新的按键事件需要处理。

     当用户调用 getchar 时,函数会等待从标准输入读取一个字符。getchar 内部通过调用 read 系统调用来读取字符,read 系统调用负责从键盘缓冲区中读取数据。它会尝试从键盘缓冲区中读取一个或多个字符,read 会不断读取键盘缓冲区中的数据,直到读取到一个有效的字符(通常是 ASCII 码)。如果键盘缓冲区为空,read 会使当前进程阻塞,等待新的键盘输入。一旦读取到有效的字符,getchar 就会将其返回给调用者。

getchar 的实现依赖于操作系统的键盘中断机制和系统调用 read。当用户按下键盘上的键时,键盘控制器生成扫描码,通过中断处理程序将扫描码转换为 ASCII 码并保存到键盘缓冲区。getchar 函数通过 read 系统调用读取键盘缓冲区中的字符,直到用户按下回车键时才返回读取到的字符。这种机制确保了字符输入的实时性和准确性。

8.5本章小结

       本章以hello程序为例,首先介绍了Linux的I/O设备的管理方法,并简述了UNIX的I/O接口和函数,然后分析了printf和getchar的具体实现机制。通过本章的梳理,我们对I/O方面的知识有了更深刻的理解。

结论

hello所经历的过程:

  1. 将hello从键盘输入。
  2. 预处理。从hello.c进行库文件展开生成hello.i文件。
  3. 编译。将hello.i文件翻译成汇编语言文件hello.s。
  1. 汇编。将汇编语言文件hello.s翻译成为一个可重定位目标文件hello.o。
  2. 链接。将hello.o可重定位目标文件和动态链接库链接起来,生成可执行目标文件hello。
  3. 创建进程。shell调用fork创建子进程。
  4. 加载程序。shell调用execve启动加载器,映射虚拟内存,进入程序开头。
  5. 寻址。根据指令和指令地址寻址,如有缺页启动缺页处理程序。
  6. 执行指令。CPU为进程分配时间片,分配虚拟地址空间,依次执行。
  7. 终止。子进程完成后,发送SIGCHLD信号,等待父进程回收。

此外,在hello程序执行的过程中,也可能接受到不同类型的信号,例如SIGSTOP、SIGINT等,此时需要执行对应信号处理程序。

感悟:

本次大作业以hello执行为例,探讨计算机系统在其整个生命周期中的行为,从程序员视角理解程序的执行过程。我深知,只有理解计算机系统的本质,才能对代码的行为了然于心,在此基础上,才能对程序进行合理的优化,减少bug的出现,让我们编写出更美妙的代码。

附件

    • hello.c                源文件
    • hello.i                 预处理后得到的文件
    • hello.s               编译后得到的汇编语言文件
    • hello.o         汇编后得到的可重定位目标文件
    • hello.out       链接后的可执行文件
  • hello.asm      反汇编hello.o得到的反汇编文件

  • hello1.asm     反汇编hello得到的反汇编文件

  • hello1.elf      hello.o的ELF文件

  • hello2.elf      hello的ELF文件

参考文献

[1]  Computer Systems: A Programmer's Perspective Third Edition [M]. Randal E. Bryant, David R. O'Hallaron.

[2]  printf 函数实现深入剖析[OL]. https://www.cnblogs.com/pianist/p/3315801.html

[3]  getchar()原理及易错点解析[OL]. https://blog.csdn.net/weixin_44551646/article/98076863

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值