程序人生-Hello’s P2P

发布于:2025-06-05 ⋅ 阅读:(22) ⋅ 点赞:(0)

Hello的“自白”

俺是Hello,额……是每一个程序猿¤的初恋(羞羞……)

却在短短几分钟后惨遭每个菜鸟的无情抛弃(呜呜……),他们很快喜欢上sum、sort、matrix、PR、AI、IOT、BD、MIS……,从不回头。

只有我自己知道,我的出身有多么高贵,我的一生多么坎坷!

多年以后,那些真正懂我的大佬(也是曾经的菜鸟一枚),才恍然感悟我的伟大!

……………………想当年:            俺才是第一个玩 P2P的(From Program to Process)

懵懵懂懂的你笨笨磕磕的将我一字一键敲进电脑存成hello.c(Program),无意识中将我预处理、编译、汇编、链接,历经艰辛-神秘-高贵-欣喜,我——Hello,一个完美的生命诞生了。

你造吗?在壳(Bash)里,伟大的OS(进程管理)为我fork(Process),为我execve,为我mmap,分我时间片,让我得以在Hardware(CPU/RAM/IO)上驰骋(取指、译码、执行、流水线等);

你造吗?OS(存储管理)与MMU为VA到PA操碎了心;TLB、4级页表、3级Cache,Pagefile等等各显神通为我加速;IO管理与信号处理使尽了浑身解数,软硬结合,才使我能在键盘、主板、显卡、屏幕间游刃有余,虽然我在台上的表演只是一瞬间、演技看起来很Low、效果很惨白。

感谢 OS!感谢 Bash!在我完美谢幕后为我收尸。  

我赤条条来去无牵挂! 我朝 CS(计算机系统——Editor+Cpp+Compiler+AS+LD + OS + CPU/RAM/IO等)挥一挥手,不带走一片云彩!

想想俺也是 O2O(From Zero-0 to Zero-0)。 历史长河中一个个菜鸟与我擦肩而过,只有CS知道我的生、我的死,我的坎坷,“只有 CS 知道……我曾经……来…………过……”

程序人生-Hello’s P2P——哈尔滨工业大学CSAPP结课作业

摘要

本文跟随hello.c程序,走过了它从诞生到被运行到终止的整个“人生”。这“一生”包括hello.c程序的预处理、编译、汇编、链接、shell命令行输入、fork、execve、运行直到终止。使用gcc、gdb、edb、objdump、readelf等开发调试工具,去解读了hello程序的“一世浮沉”。从hello程序的生命周期中,我们会收获它不断演变的规则,分析计算机一步一步处理hello.c源文件的原理。最后,我们演示了其中一些环节的操作和结果,来借助hello锻炼我们的计算机系统思维!

关键词:hello;计算机系统;生命周期


目录

Hello的“自白”

程序人生-Hello’s P2P——哈尔滨工业大学CSAPP结课作业

摘要

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2 在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.3.1 数据

3.3.2 赋值

3.3.3 类型转换

3.3.4 算术操作

3.3.5 关系操作

3.3.6 指针操作

3.3.7 控制转移

3.3.8 函数操作

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.5.1 链接的过程

5.5.2 重定位分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7 本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9 本章小结

结论

附件

参考文献


第1章 概述

1.1 Hello简介

  • P2P:Program to Progress. Hello由一个C语言源程序(Program),经过预处理、编译、汇编、链接,最后在shell中运行可执行文件,shell会给hello分配进程空间,这个时候Hello便成为了一个进程(Progress)。这个过程就是Hello P2P的故事。
  • 020:From Zero-0 to Zero-0. 指的是,在程序执行之前,内存空间没有加载Hello进程,这是Zero-0;在Hello进程执行过后,内核也会删除hello文件相关的所有数据结构,父进程回收子进程,又回到了Zero-0.

1.2 环境与工具

  1. 硬件环境:
  • 处理器:13th Gen Intel(R) Core(TM) i9-13900HX    2.2GHz
  • 机带RAM:32GB
  • Ubuntu配置处理器内核:4*2 = 8核
  • Ubuntu分配RAM:8GB
  • Dell服务器硬件环境
  1. 软件环境:
  • VMWare - Ubuntu 20.04虚拟机
  • x86-64架构操作系统(Linux+Windows 11)
  • 开发调试工具:vim 8.1.1847;GNU objdump 2.34;gcc 9.4.0;GNU gdb 9.2;edb 1.1.0;GNU readelf 2.34.(版本以Ubuntu虚拟机安装列出)

1.3 中间结果

  • hello.i:预处理得到的文本文件
  • hello.s:编译得到的汇编语言文件
  • hello.o:汇编得到的可重定位目标文件
  • hello:链接得到的二进制可执行文件
  • hello.elf:hello.o的elf格式文件
  • hello.asm:对hello.o反汇编得到的反汇编文件
  • hello1.asm:对hello反汇编得到的反汇编文件

1.4 本章小结

本章介绍了Hello的P2P和020的经历,列举了完成此篇报告使用的软硬件环境以及生成的中间结果文件,简要介绍了文件的来源。


第2章 预处理

2.1 预处理的概念与作用

  • 预处理的概念:使用“源.c”文件生成“源.i”文件(预处理文件)。这个过程是编译器处理源程序的第一个阶段。这个阶段中,编译器会将“源.c”文件中涉及到的宏、头文件等语句一一处理,比如,我们熟知的程序中头文件(#include<...>)语句,宏替换(#define ...)语句,以及一些条件编译语句(如#ifdef、#if、#endif等),都会在预处理阶段进行展开或替换。最后,这个阶段编译器也会去除代码中所有注释的部分,并且会生成一个文本文件
  • 预处理的作用:预处理对于程序的编译、执行具有很强的意义。#include的头文件内容直接插入、#define的宏替换已被直接定义为实际值、注释的全部清除,这些操作可以获得一个十分干净、十分完整的代码文件供编译器继续处理。这样会大大减轻编译环节编译器的处理负担。

2.2 在Ubuntu下预处理的命令

生成预处理后文件的命令为:gcc -E hello.c -o hello.i(以hello.c - hello.i为例)。

图 1  预处理操作

2.3 Hello的预处理结果解析

hello.c文件中使用了三个头文件,分别为<stdio.h>、<unistd.h>、<stdlib.h>,使用Linux的文本编辑器打开hello.i文件,可以看到头文件被展开的内容:

图 2  预处理文件分析

Linux系统头文件展开过程中,预处理器会根据头文件的名称在(默认的标准)路径"/usr/include"或"/usr/local/include"下搜索;而用户头文件展开过程中,预处理器则会优先搜索源文件所在目录,以获得相应的头文件。

随后,预处理器会将用户头文件的内容插入到hello.c中的对应位置;系统头文件会被递归展开插入到文件中,并且在展开的过程中预处理器还会添加# <行号> <文件名> <标志>这样的标记(如图所见)。最后,预处理器会删除所有的注释内容,将代码部分完整地放入与处理文件中。

有必要指出的是,预处理的过程不会进行任何的计算或变换,只会进行简单的复制和替换。

2.4 本章小结

本章对Linux环境下hello.c的预处理操作进行了详细的分析,提到了获得预处理文件的命令,同时针对预处理文件找到了三个头文件展开的起始位置,对比了主程序部分的变化(没有变化)。通过分析,我们可以得出预处理阶段,预处理器会对C程序文件进行一些基本的文本复制和替换,比如将头文件的内容复制到文本中,将宏替换的内容替换为实际值等。最后会得到一个hello.i文本文件。


第3章 编译

3.1 编译的概念与作用

  • 编译的概念:使用“源.i”文件生成“源.s”文件(汇编语言程序)。这个过程是代码翻译过程的核心阶段,会将预处理后的源代码转换为汇编语言程序,由编译器(如GCC的cc1组件)来完成。该阶段的主要工作有:词法分析与语法分析、语义分析、中间代码生成与优化、汇编程序生成。最后会生成一个由机器指令组成的与源程序等价的汇编语言程序。
  • 编译的作用:编译是高级语言与机器语言的桥梁。将机器不能直接“读懂”的高级语言程序转化为符合机器运行逻辑的汇编语言程序。另外,通过进行-O1、-O2、-Og等优化,编译器也可以通过删除冗余代码、调整执行顺序等方式提升程序效率。

3.2 在Ubuntu下编译的命令

Linux环境下生成汇编语言程序的命令为:gcc -S hello.i -o hello.s

图 3  编译操作

3.3 Hello的编译结果解析

3.3.1 数据
  • 常量:从hello.s文件中可知,程序有两个字符串常量,分别为"用法: Hello 学号 姓名 手机号 秒数!\n"和"Hello %s %s %s\n"。

图 4  字符串常量的存储形式

这两个字符串常量会存放在.rodata只读数据段。

图 5  字符串常量的存储位置

  • 局部变量:程序中只有一个局部变量i,在程序运行过程中存放在栈内,

图 6  局部变量的赋值和存储位置

由hello.s可知,该局部变量存放在栈上-4(%rbp)的位置。

  • 表达式:程序中在if判断处和for循环处共有两个逻辑表达式,分别为"argc != 5"和"i < 10",执行的位置分别为:

图 7  逻辑表达式的实现

  • 类型:程序中共涉及了int、string两个变量类型,对于int类型的局部变量,编译器会分配4个字节的栈空间并将其压入栈中;而对于字符串常量类型的数据,编译器在程序的只读数据段给字符串常量分配连续的一块空间存储字符串。

图 8  局部变量i的存储方式

当希望读取局部变量时,直接访问栈指针偏移所在的地址;当访问string常 量时,则通过使用RIP相对寻址读取字符串常量。

图 9  RIP相对寻址

3.3.2 赋值
  • 变量‘i’:编译器将局部变量存储在栈中,根据我们的源程序,定义i时并未进行赋初值,首次对i赋值是在for循环时;再从hello.s文件中可得,机器只对i进行了一次赋值如图6所示。所以可以发现,局部变量的定义并不会单独分配机器指令执行,只有在赋值时才会执行相关机器指令。
3.3.3 类型转换
  • atoi()函数:该函数会将传递给它的字符串转化为整型数,作为参数传递给sleep()函数。
3.3.4 算术操作
  • 循环累加:在每次循环结束后,程序会执行i++的算术操作,每次对-4(%rbp)加一运算。

图 10  操作i++

3.3.5 关系操作
  • 条件判断:如图9,程序中共有两个关系操作,分别为"argc != 5"和"i < 10"。
3.3.6 指针操作
  • main函数参数*argv[]的传递:指针在64位操作系统中以8字节形式存储,并且通过其存储的地址访问对应的字符串。从hello.s中得到,*argv[]的存储位置如下图:

图 11  argv[]在栈中的存储

 

图 12  argv[1~4]的内容读取

3.3.7 控制转移
  • 条件判断语句:程序的两个条件判断转移语句分别为"if (argc != 5)"和"for (i = 0; i < 10; i++)",实现方式如图7。
3.3.8 函数操作
  • main(参数传递):main函数共有两个参数argc,argv[],argc、argv[]的传递如下

图 13  argc与argv[]的参数传递

  • printf(参数传递、函数调用):程序中共有两个printf函数,第一个printf函数参数只有一个字符串常量,该函数以及第二个printf第一个参数的参数传递见图9;第二个printf函数还包括argv[1]、argv[2]、argv[3]三个参数的传递,见图11。printf在调用函数时,会跳转到相应函数内容的地址,具体如下:

图 14  printf函数调用

  • exit(参数传递、函数调用):exit函数传递了一个参数为1,表示立即终止整个程序并返回“失败”信号1。参数传递和函数调用部分如图:

图 15  exit函数参数传递、函数调用

  • sleep(参数传递、函数调用):sleep函数的参数即atoi函数的返回值,由于函数的返回值存在寄存器,则从hello.s中可找到:

图16  sleep函数参数传递、函数调用

  • atoi(参数传递、函数调用、局部变量、函数返回):atoi函数是将字符串转化为整型数的函数,参数传递见图11;函数调用见图16;传入的参数是argv[4],这个参数在函数atoi中属于局部变量,可以修改指针的值,但是不能修改指针指向的字符串的内容;函数返回见图16,返回值作为sleep函数的参数。
  • getchar(函数调用):getchar没有参数传入,函数调用如图:

图 17  getchar函数调用

3.4 本章小结

本章中,对预处理文件进行编译,得到了hello.s汇编语言程序,并且对hello.s文件进行了详尽的分析。分析包括了数据、赋值、类型转换、算术操作、关系操作、指针操作、控制转移、函数操作几大方面。通过对汇编语言程序的分析,可以更加清晰的理解机器指令执行的逻辑,感受计算机程序的执行方式。


第4章 汇编

4.1 汇编的概念与作用

  • 汇编的概念:使用“源.s”文件生成“源.o”文件(机器语言二进制程序)。这个过程由汇编器完成,将人类可读的汇编指令逐条翻译为对应的二进制机器码,以供计算机执行;同时会处理标签和符号,将其转换为内存地址或偏移量。最后会生成一个包含机器码、符号表、重定位信息等内容的可重定位目标文件,供后续链接器使用。
  • 汇编的作用:填补高级语言与机器语言的鸿沟,是编译流程的关键步骤。并且汇编语言可以精确操作寄存器、内存等硬件资源,适用于底层开发;最后,通过手写汇编代码或分析编译器生成的汇编输出,可优化关键代码段的执行效率。

4.2 在Ubuntu下汇编的命令

Linux环境下生成机器语言二进制程序的命令为:gcc -C hello.s -o hello.o

图 18  机器语言二进制程序的生成

4.3 可重定位目标elf格式

  • ELF头

图 19  ELF头相关信息

  • 节头:使用readelf -S命令,获得各个节的基本信息:其中.text、.rodata、.data、.plt是关键节区,.text和.plt是代码节区,.data和.rodata是数据节区,这两个节区是ELF文件的最核心部分,分别存储可执行指令和数据。

 

图 20  hello.o的elf格式节头基本信息

  • 重定位节:从elf文件中可以看出,其包含了两个重定位节:.rela.dyn以及.rela.plt。.rela.dyn为处理动态链接时的数据引用,.rela.plt为处理动态链接时的函数跳转。ELF重定位信息是链接器修正外部符号引用的"地址补丁"。编译时,遇到未解析的函数或变量会生成重定位记录;链接时,这些记录指导链接器将符号引用替换为真实地址[2]。

重定位节rela.dyn中,类型R_X86_64_RELATIVE用于位置无关代码(PIC), 计算方式:基地址 + 加数;R_X86_64_GLOB_DAT会在动态链接时,将全局 偏移表(GOT)中的条目绑定到符号的实际地址。在重定位节rela.plt中,R _X86_64_JUMP_SLO会在动态链接时,修正PLT(过程链接表)中的跳转地 址,同时首次调用函数时,通过延迟绑定解析实际地址(如 printf@GLIBC_2.2.5)。

图 21  elf重定位项目信息

  • 符号表:记录了所有函数、变量等符号的关键信息,主要用于链接和调试。链接时通过符号表查找未定义的函数名和变量名,确保它们能正确绑定到库或其它模块中的实现。将符号名与其在内存或文件中的位置(如.text中的函数地址、.data中的变量地址)建立映射,供链接器修正引用。保留符号名与源码的对应关系(需编译时加-g),崩溃时能输出函数名而非裸地址。

图 22  ELF符号表

4.4 Hello.o的结果解析

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

  • 机器指令:机器语言由二进制数构成,结构为操作代码+操作数。机器语言为小端到大端表示;
  • 映射关系:机器语言的操作代码对应汇编语言的操作,而操作数部分对应汇编语言的源操作数或目标操作数;
  • 操作数形式:汇编语言中操作数为$加一个十进制数,而机器语言中的操作数为16进制表示,带有0x前缀;
  • 分支转移、函数调用:在汇编语言中,分支转移以及函数调用涉及到的跳转都有每个行为自身的操作指令,比如jne、jl、call、ret等;而在机器语言中,地址跳转操作都会转化为二进制操作代码,不同代码之间的区分更依靠跳转的目标地址位置(相对地址、寄存器、内存等)以及跳转的偏移大小。所以相同的机器跳转指令代码可能代表了不同的操作。最后,汇编语言中,跳转的目标为某个标记位置(如图中的.L1 .L2 .L3),而机器指令是跳转到某个具体地址。

图 23  hello.s(左)与hello.o反汇编(右)比较

4.5 本章小结

本章进行了机器指令二进制程序的生成,并且着重利用生成的二进制程序查看其ELF格式文件以及进行反汇编。通过分析其ELF格式文件,我们了解了节头、重定位节、符号表这些成分,更对重定位进行了详细的分析。最后通过对比hello.o的反汇编文件和hello.s文件,得到了汇编语句和机器语言的映射关系。


第5章 链接

5.1 链接的概念与作用

  • 链接的概念:使用“源.o”文件生成“”文件(可执行程序)。这个阶段是整个程序由代码变成能够运行流程的最后一步。这个过程中,链接器会将一个或多个目标文件(如 hello.o)与所需的库文件组合起来,生成可执行程序。
  • 链接的作用:链接十分灵活,可以执行与多个阶段,也正因为链接器的存在,使得可以将一个大型的程序模块化,并且可以进行分离编译,尤其是在需要修改某个大型程序的某一个模块时或在某几个程序使用了相同的库文件时有显著的影响。链接器具体的作用包括符号解析、地址分配与重定位、合并目标文件、链接库文件、生成可执行格式等。

5.2 在Ubuntu下链接的命令

手动链接的实现命令(包含指定动态链接器、链接hello.o、启动文件、动态链接依赖文件):

ld -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 -lc \

/usr/lib/x86_64-linux-gnu/crtn.o \

-o hello

图 24  手动链接生成可执行文件

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

  • ELF头:可执行目标文件hello中,ELF头的TYPE、入口点地址、程序头起点等会有不同。如图

图 25  可执行程序ELF头

  • 程序头(段):在目标文件(hello.o)中并没有段信息,通过可执行程序,可以获得各段的类型、起始地址、大小等信息,见下图:

图 26  程序头(段)信息

  • 节表:可执行程序的节表会将某些调试符号、链接符号去除,因此会比目标文件的节表内容略少,在此不做展示。
  • 动态节:目标文件ELF格式同样没有动态节,可执行文件的动态节部分可以告诉loader要加载哪些共享库;指明函数重定位表(如 GOT、PLT)的地址,以便于loader 根据这些地址替换函数调用跳转位置;还会为 dlopen()、dlsym() 这些运行时调用机制提供符号支持;并且可以提高共享库的灵活性与重用性,例如多个程序可以同时共享 libc.so.6,只需加载一次。

图 27  动态表信息

  • 符号表:符号表有两类,.symtab(符号表)和 .strtab(字符串表)以及.dynsym(动态符号表)和 .dynstr(动态字符串表)。目标文件通常包含全部的两类,而可执行文件一般只包含后面的.dynsym和.dynstr部分。当我们使用gcc -s hello.c -o hello1以及gcc hello.c -o hello2时,分别调取符号表分析可知,可以通过剥离可执行文件的符号表以达到减小文件体积并保护符号信息的功能。

图 28  hello2的符号表

5.4 hello的虚拟地址空间

从图26观察程序头LOAD的起始地址,得到可加载程序段的地址为0x400000。

程序从地址0x400000开始到0x401000被载入,虚拟地址从0x400fff结束。根据节头表,可以通过edb找到各段的信息。使用edb工具的Data Dump窗口观察可执行程序加载到虚拟地址的情况:

图 29  节头与edb Data Dump窗口0x401000-0x402000

我们可以找到如.text节的信息:可以对比其机器码内容验证。

图 30  .text节的edb虚拟地址信息及验证

其他节均可以通过此方式获得其虚拟地址空间。

5.5 链接的重定位过程分析

5.5.1 链接的过程
  • 编译阶段:先通过gcc生成目标文件hello.o;
  • 链接阶段(生成动态可执行文件):通过对比图29与图30,可以发现,可执行文件的开头添加了.plt跳板,首次调用时通过.plt跳板跳转至链接器函数进行实际地址查找,后续调用直接跳转至正确的printf实现。以我们的hello.c程序printf为例,链接器会执行以下操作:
  • 检查程序中的符号(如printf),发现未定义;
  • 从标准共享库(如/lib/x86_64-linux-gnu/libc.so.6)中找到printf是libc的一个导出符号;
  • 保留这些符号为“动态符号”,在可执行文件中不写入实际地址,而是留一个重定位项;
  • 在可执行文件中写入.plt段(延迟绑定跳板表)、.got段(实际调用地址跳板)、.dynamic, .dynsym, .dynstr, .rela.plt等辅助段(用于动态链接);
  • 写入需要依赖的共享库列表(如NEEDED libc.so.6)。

图 31  hello.o反汇编的起始部分

 

图 32  hello反汇编起始部分

  • 程序运行阶段:目标文件中,动态链接器会保留对printf的引用,不会解析成具体地址,并且插入 .got.plt,为运行时“延迟填地址”留位置;在可执行文件中,链接器则会初始化 .got.plt 中 printf 的位置以供跳转,这个过程的辨识如图30以及图31,流程如下:
  • 程序加载后,ld-linux.so动态链接器开始工作;
  • 它查找可执行文件中记录的.dynamic段,确定所需共享库,并加载对应.so文件;
  • 查找.rela.plt中的重定位项,对.got表进行填充;
  • 当程序第一次调用printf时,跳转到.plt中的延迟绑定跳板,该跳板调用动态链接器,查找实际地址,一旦找到,会修改.got表中该项,以后再调用就直接跳转,无需再查找(延迟绑定机制)。

图 33  printf函数在目标文件和可执行程序内的不同体现

5.5.2 重定位分析

hello.o的重定位项目如下图:

图 34  hello.o的重定位表

动态链接的流程(以printf为例)如下:

  • 编译阶段编译器只生成调用符号puts、exit等的伪地址,例如使用call puts@PLT,但实际地址未知,因此插入一个重定位项(如你看到的 R_X86_64_PLT32 puts-4 图32中黄色虚线圈出),并保存在.o文件的重定位表中。
  • 链接阶段(ld动态链接):链接器不会将这些函数地址直接填入机器码中,而是保留call puts@PLT指令不变,在生成的可执行文件(如hello)中构造.plt和.got.plt表,随后修改.got.plt表,使之在程序启动时指向.plt中的延迟绑定逻辑。
  • 程序运行时(由动态链接器ld.so负责)
  • 第一次调用puts时,进入.plt中的跳板代码;
  • 跳板发现.got.plt中地址尚未初始化,转而跳到动态链接器;
  • 动态链接器查找真正的puts函数地址,并将其写回.got.plt;

以后再次调用puts时就不再进入跳板,而是直接跳到真正地址。

5.6 hello的执行流程

5.6.1 程序执行过程

使用GDB进行调试,并在_start处设置断点;在此函数中,会跳转到__libc_start_main函数(地址为0x7ffff7de5081),同时将main函数地址0x4011d6传参给__libc_start_main,在该函数中,会进行callq *%rax  # main。

图 35  加载Hello程序

图 36  __libc_start_main到main的跳转

在main函数中,对于一个合法输入,执行分别经过printf、sleep、atoi、getchar,输入enter执行过后,程序会回到__libc_start_main,并很快进入__GI_exit函数,并最后退出程序。

5.6.2 程序执行子程序及地址

流程及地址总结如下:

_start:                            0x4010f0                 

→ __libc_start_main:    0x7ffff7de4f90

→ main:                        0x4011d6

→ __printf:                     0x7ffff7e22c90          

→ __sleep:                    0x7ffff7ea3dc0

→ __GI_atoi:                 0x7ffff7e05bb0        

→ getchar:                     0x7ffff7e4c560  

→ __libc_start_main:     0x7ffff7de4f90  

→ __GI_exit:                  0x7ffff7e07a40

→  退出

5.7 Hello的动态链接分析

动态链接的目的是将一个大型程序拆分为数个较小的模块分别处理,在最后生成可执行文件时进行链接形成一个完整的程序。在动态链接前,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。延迟绑定是通过GOT和PLT实现的,在动态链接前后,我们来分析.got.plt的数值变化。

从hello程序的elf格式文件中我们可以得到,.got.plt节的起始地址为0x404000:

图 37  .got.plt节信息

在_dl_fixup函数执行前后,0x404000内容为:

图 38  动态链接前后.got.plt一部分数值变化

在程序执行过程中,库函数的调用通常采用一种间接跳转的方式来实现动态链接。第一次调用某个库函数时,程序并不会立刻跳转到该函数的真实地址,而是先通过一段中转代码跳转到链接器。链接器负责在运行时查找该函数在共享库中的实际地址,并将这个地址写入一个跳转表中。这样,后续对同一个函数的调用就可以直接通过这个表跳转到正确的函数位置,无需再次查询,从而兼顾了程序启动时的效率和运行时的灵活性。这一机制是实现延迟绑定和动态链接的关键。

5.8 本章小结

本章主要对链接环节进行了非常详细的分解分析,包括连接前后产生的变化,针对目标文件和可执行文件进行了对比。同时对重定位、程序运行的流程以及动态链接的实现进行了拆解分析,更清楚的展现了可执行程序生成过程中虚拟地址空间的分配和使用。


第6章 hello进程管理

6.1 进程的概念与作用

  • 进程的概念:进程是正在运行的程序,是操作系统资源分配和调度的基本单位。一个进程拥有自己的地址空间、代码、数据段、堆栈和一组相关的系统资源。它可以与其他进程并发执行,是操作系统实现多任务的重要基础。
  • 进程的作用:实现多任务并发执行;提高 CPU 利用率与系统吞吐量;支持资源独立与安全隔离;支持系统服务与用户程序的并行运行。

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

  1. 壳Shell-bash的作用:Shell是用户与操作系统之间的桥梁,是一种命令解释器。用户输入命令后,Shell会进行解析、查找可执行文件、创建子进程、加载程序并执行。
  2. 处理流程
  • 用户在终端输入命令:例如 ./hello 202311699 丁懿 15734597159 1;
  • Shell 解析命令行,将字符串分解为命令及参数;
  • 创建子进程(fork),在子进程中调用execve执行目标程序;
  • 父进程等待子进程结束(通过wait/waitpid);
  • 子进程执行结束,返回结果给Shell,Shell显示输出并准备下一次交互。

6.3 Hello的fork进程创建过程

当在bash中运行./hello时,Shell会执行指令pid_t pid = fork()创建一个子进程,该子进程复制了父进程的大部分上下文(代码段、数据段、栈等);子进程获得一个新的 PID,进入就绪态,等待CPU调度。

在父进程中,fork返回子进程的PID,而在子进程中fork返回0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。

6.4 Hello的execve过程

创建子进程后,Shell使用execve()系统调用将当前子进程的映像替换为hello程序的映像:execve("./hello", argv, envp);原有bash子进程的代码和数据被新的hello程序替换;PID保持不变,但代码段、数据段、堆、栈被重新加载;进入用户态继续执行hello的main()函数。

图 39  内存分布图[3]

当子进程执行 execve("./hello", argv, envp) 时,会从用户态切换到内核态,进入内核中的 sys_execve()(或 do_execve())函数。接下来会查找和验证可执行文件,验证通过则会清空当前进程的用户空间内存,包括:代码段、数据段、堆和栈,随后加载hello程序的代码和数据到相应内存区域,完成内存页面的分配和映射。

6.5 Hello的进程执行
 

  1. 进程生命周期:当hello程序被execve成功加载后,进程进入正式运行阶段。它在整个生命周期中,会经历如下几种状态:就绪态(等待被调度到CPU上执行)、运行态(占用CPU正在执行)、阻塞态(等待某个事件,如sleep())、终止态(执行完毕或收到终止信号),这些状态转换由操作系统的进程调度器(Scheduler)根据优先级、时间片等策略控制。
  2. 进程上下文:每个进程都有一个上下文信息集合,表示该进程在某一时刻的运行状态,包含:用户上下文(程序计数器、寄存器值、堆栈指针等)、核心上下文(系统调用时,保存内核堆栈、系统调用参数、文件描述符表、调度信息等)和进程控制块(操作系统内核中维护每个进程的全局信息)。当调度发生时,操作系统会保存旧进程上下文,加载新进程上下文,从而实现进程切换。
  3. 时间片与调度过程时间片是每个进程能连续占用CPU的最长时间段。Linux中,调度器(如CFS)为每个进程分配时间片,调度流程如下(以hello.c文件为例):
  • hello进程从就绪队列中被调度器选中,分配CPU;
  • 开始执行,调用printf()输出内容,由用户态进入内核态,结束后返回用户态
  • 执行sleep(n)系统调用,此时进程进入阻塞态,放弃CPU,操作系统调度其他进程运行,进入内核态
  • sleep时间结束,hello被唤醒,返回用户态,回到就绪队列,等待再次被调度运行;
  • 重复以上过程直至运行完10次printf();
  • 调用getchar(),等待用户输入(阻塞),进入内核态
  • 用户按回车后,hello结束,进入终止态

6.6 hello的异常与信号处理

1. 异常种类与信号的产生:

异常类

原因

异步/同步

返回行为

中断

来自I/O设备的信号

异步

总是返回到下一条指令

陷阱

有意的异常

同步

总是返回到下一条指令

故障

潜在可恢复的错误

同步

可能返回到当前的指令

终止

不可恢复的错误

同步

不会返回

表 1  异常类型及其触发信号

信号

产生原因

默认行为

SIGINT

2

用户按下 Ctrl+C

中断进程

SIGTSTP

20

用户按下 Ctrl+Z

挂起进程

SIGTERM

15

使用 kill 命令发送终止请求

终止进程

SIGSEGV

11

内存非法访问(如访问空指针、未分配内存)

段错误终止

SIGALRM

14

sleep() 超时内部依赖

默认终止

SIGHUP

1

终端断开、控制终端关闭

终止进程

SIGPIPE

13

向已关闭的管道写入数据

终止进程

表 2  信号种类及其产生原因与行为

2. 异常信号的处理

  • 中断:检测到中断信号时,程序立即响应,完成当前指令后,执行中断处理例程,完成后继续执行下一条指令;

图40  中断处理[1]

  • 陷阱:程序主动触发,如系统调用,执行特定处理例程后返回原指令;

图 41  陷阱处理[1]

  • 故障:检测到故障信号时,程序尝试修复错误,若可恢复则继续执行当前指令,否则处理程序返回到内核中的abort例程,abort例程会终止引起的故障的应用程序。

图 42 故障处理[1]

  • 终止:收到终止信号后,程序无条件结束运行。

图 43  终止处理[1]

3. 异常操作运行以及结果:

  • Ctrl-C:中断信号,控制程序停止执行;

图44  Ctrl-C信号运行结果

  • Ctrl-Z:挂起进程,暂停执行,可用fg恢复;

图 45  Ctrl-Z+fg信号运行结果

  • Ctrl-Z+ps+jobs+bg:挂起进程,暂停执行,查看当前后台任务,恢复挂起进程至后台运行;

图 46  Ctrl-Z+ps+jobs+bg

  • pstree:

图 47  pstree运行结果

  • kill:“杀死”程序。可以添加-SIGINT强制终止。

图 48  kill终止

6.7 本章小结

本章介绍了一个执行文件在shell中执行整个进程的流程,包括创建子进程、加载、执行、可能进行异常处理等步骤。最后使用hello执行文件进行异常处理测试,使用了Ctrl-C、Ctrl-Z、kill等命令,验证了信号处理机制,展示了进程在不同信号下的响应行为,进一步验证了操作系统对异常信号的精确控制和高效处理能力,确保系统稳定运行。


第7章 hello的存储管理

7.1 hello的存储器地址空间

  • 逻辑地址:逻辑地址是编译器看到的地址,由段寄存器 + 偏移量组成,形式为 (段选择子:offset)。例如,在hello中,CPU执行printf所在指令时,指令位于.text段中,数据(字符串常量)位于.rodata段中,如果段选择子是DS,偏移量是0x1234,则逻辑地址为 (DS:0x1234)。
  • 线性地址:逻辑地址经过段式管理后得到的地址,线性地址 = 段基址 + 偏移量。现代操作系统(如Linux)采用平坦内存模式:段基址均为 0,因此线性地址 = 偏移量,所以程序访问 "Hello %s %s %s\n" 实际上就是访问某个线性地址。
  • 虚拟地址:现代操作系统为每个进程分配的4GB(32位)或更大(64位)的地址空间。线性地址就是进程看到的虚拟地址。在hello中,程序访问的地址(如0x400654)就是虚拟地址,是进程私有的,并且多个进程可以使用相同虚拟地址而互不影响。
  • 物理地址:物理地址是真实存在于物理内存条上的地址,由MMU(内存管理单元)将虚拟地址映射而来。在hello中,通过页表(四级页表),虚拟地址0x4011d6会被映射到某个物理地址。

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

在段式管理中,程序被划分为若干具有独立功能的段,如代码段、数据段、共享段等,每个段由段描述符记录其起始地址、长度和状态。这些描述符集中存放在段描述符表中。

程序在运行时,其地址访问依赖于逻辑地址的结构,该地址由两个部分组成:段选择符与段内偏移量。其中,段选择符是一个 16 位的值,前 13 位用于索引段描述符表以定位特定段,后 3 位则涉及与硬件实现相关的标志和权限控制。

逻辑地址中的段标识可被用来从段描述符表中快速检索目标段的位置和属性。在 Intel 架构下,整个系统只维护一张全局描述符表(GDT),其中存放了操作系统层面的关键段信息,包括内核的代码段、数据段、栈段以及指向每个进程或任务的局部描述符表(LDT)的段描述符。

每一个用户进程或任务都拥有一张独立的局部描述符表(LDT),用于描述该任务专属的段内容。这些描述符覆盖了该任务的私有代码段、数据段和堆栈段等,同时也包含任务门、调用门等门类型的描述符,以支持任务切换或跨段调用等操作。

图 49  段式管理——地址转换

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

虚拟内存系统将整块地址空间视为由磁盘中连续字节单元构成的一维数组,总共划分为N个逻辑单元。该空间被划分为固定大小的片段,称作虚拟页;与之对应,实际的物理内存也按照相同大小划分成多个物理页。

为了映射和管理虚拟页,系统维护了一张页表,它是由多个页表项(Page Table Entry, PTE)组成的结构。每个PTE通常包含一个标志位(表示该页当前是否驻留于主存)和一个地址字段(若指明驻留,物理内存中该页的位置)。下面是页式管理的示意图

图 50  页式管理

当一个进程访问某个虚拟地址时,MMU(内存管理单元)会查阅页表完成虚拟地址到物理地址的转换。如果对应的页表项显示该页尚未加载至主存,即无效,则触发一次缺页异常,操作系统负责将其从磁盘调入内存。

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

在计算机系统中,地址转换通常由专用硬件模块完成,其中关键的一环是一个称为翻译后备缓冲区的高速缓存结构。TLB用于加速虚拟地址到物理地址的映射过程,它本质上是一个缓存页表项(PTE)的集合,通过缓存常用页的映射关系,避免频繁访问主存中的页表。

TLB以虚拟页号(VPN)作为索引关键字,每个条目存储一个页表项,包括对应的物理页号等信息。为实现高效查找,TLB多采用组相联缓存结构。假设TLB包含2^T个组,则使用VPN的最低T位作为组号(索引),而高位作为标签进行匹配操作。

当处理器执行地址翻译时,MMU首先向TLB提交当前虚拟页号,若找到匹配项,即发生TLB命中,则直接提取对应页表项中的物理页号(PPN),并与虚拟地址中的页偏移(VPO)拼接,立即生成完整的物理地址。由于该过程完全在芯片内部完成,无需访问内存,因此速度极快。如果未命中TLB,系统则回退至传统的多级页表查询流程,从内存中逐层解析页表,直到定位目标物理页。

图 51  四级页表与TLB支持下VA-PA转换

为了节省存储空间并支持更大规模的虚拟地址空间,现代系统通常不再使用平面页表,而是采用分级页表结构。在K级页表设计中,虚拟页号被划分为K个部分(VPN1 ~ VPNk),每部分用于索引对应层级的页表条目。第1层至第K−1层的PTE指向下一层页表的起始地址,而最后一层的PTE则直接存储实际的物理页帧号,或在页不驻留主存时存储其在磁盘中的位置。

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

在现代处理器中,虚拟地址经过MMU转换为物理地址后,访问数据时并不会直接进入主存,而是优先通过多级缓存系统以提升效率。以Intel Core i7为例,该处理器采用三级缓存架构(L1、L2、L3)来加速对物理内存的访问。

每次读取数据,CPU首先将物理地址提交给L1缓存查找。L1是一组相联缓存,假设它由64个组组成,每组含8行,每行缓存一个64字节的数据块。物理地址在此被分成三部分:低6位用于块内偏移,中间6位用于选择缓存组,高40位则作为标签用于识别数据是否匹配。

在L1中查找时,先定位组,再在该组的多行中比对标签和有效位。若找到匹配条目,即发生缓存命中,数据便可直接从缓存读取;若未命中,则需向更高层的缓存(L2)请求数据。

L2与L1工作原理相似,也采用组相联结构。如果数据在L2也无法命中,接着查询更大的L3缓存。L3通常是共享缓存,面向整个处理器核心。只有当所有缓存层级都未命中时,系统才会访问主存,从物理内存中取回目标数据块,并在缓存中更新对应内容。

7.6 hello进程fork时的内存映射

当一个进程调用fork()创建子进程时,操作系统会为新进程分配一个唯一的进程标识符(PID)并初始化相关的内核数据结构。接下来,虚拟地址空间的映射成为关键步骤。

操作系统会为子进程创建一份父进程内存映射(即mm_struct)、虚拟内存区域(vma)、页表等结构的副本。需要注意的是,此时这些副本只是结构上的复制,它们仍然指向相同的物理页面,并不会立刻复制实际的内存内容。

为了避免两个进程直接共享物理内存导致的数据冲突,系统会将共享的页面标记为只读,同时将其内存区域设置为私有的写时复制类型。这样,父子进程可以同时读取相同的物理页面。

一旦父进程或子进程尝试对某个共享页面执行写操作,就会触发写时复制机制:操作系统分配一个新的物理页面,将原始页面内容复制到新页面中,然后将写操作重定向到该新页面,并将其映射到写入进程的虚拟地址空间中。这样,两个进程在逻辑上就拥有了独立的内存副本。

图 52  两个进程对同一块物理内存的映射

因此,fork()在执行后,子进程最初拥有与父进程相同的虚拟内存布局和内容,但在随后的写入过程中,依赖COW保证彼此内存的隔离性,从而维护了“每个进程拥有独立虚拟地址空间”的抽象。

7.7 hello进程execve时的内存映射

当前进程调用execve("a.out",NULL,NULL)时,本质上是要求操作系统用新的程序a.out替换当前进程正在执行的程序,但进程本身不会被销毁(其PID不变),只是它的虚拟地址空间结构和执行上下文会被重新建立。整个过程具体包括以下几个步骤:

  • 清除旧的用户空间映射:execve首先会清除当前进程虚拟地址空间中用户空间部分的所有映射区域(即.text、.data、堆、栈等),这包括对应的vm_area_struct和页表项。这样做的目的是彻底移除原有程序的所有内存痕迹,以便为加载新的程序做准备。
  • 建立新的私有映射区域(写时复制):对于即将执行的程序a.out,操作系统会重新创建用于支持其运行的私有内存区域,包括.text、.data、.bss区域、堆和栈。所有这些区域都被设置为私有、写时复制类型,从而保证后续可能发生的写操作不会影响底层文件或其他进程。
  • 加载共享库(共享映射区域):如果a.out是一个使用了动态链接库的可执行文件,例如链接了标准C运行库libc.so,那么系统会将这些动态库文件作为共享映射(sharedmapping)加载到进程的虚拟地址空间。

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

设置入口点(程序计数器PC):内核最后会更新当前进程的执行上下文,将程序计数器(PC,或称eip/rip)设置为a.out的入口点(由ELF头提供),并准备在下次被调度运行时从这个入口地址开始执行新程序。

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

MMU在试图翻译某个虚拟地址A时,若触发了一个缺页,这个异常会导致控制转移到内核的缺页处理程序,缺页处理程序的做法如下:

图 54  缺页异常行为

  • 合法地址检查:内核首先判断触发异常的虚拟地址A是否属于进程已分配的地址空间区域,即是否落在任一vm_area_struct描述的有效区间内。该判断通过遍历或搜索虚拟内存区域结构完成。若地址A不属于任何有效区域,说明访问非法,处理程序将引发段错误(segmentation fault),终止当前进程。为提高效率,Linux实际上使用平衡树等结构优化了对内存区域的查找过程。
  • 访问权限验证:若地址合法,内核继续检查当前访问操作是否符合该区域设定的访问权限,例如是否试图写入只读段、或在用户态访问内核地址空间。若发现权限冲突,将触发保护异常(protection fault),同样导致进程终止。
  • 缺页调入与页表更新:当内核确认地址与权限均合法时,会执行缺页的处理逻辑。这包括选择并回收一页物理内存(若有必要进行页面置换),将所需页面从磁盘或匿名区域加载到内存,并更新页表中的映射。缺页处理完成后,异常返回,CPU会重新执行原始指令,此时MMU能够正确完成虚拟地址的翻译,不再引发异常。

图 55  缺页异常处理

7.9 本章小结

本章细致地分析了hello进程的存储器地址空间,包括段式管理和页式管理,页式管理又包括TLB支持、三级cache访问物理内存、多级页表。并且讲解了hello进程中fork和execve过程内存映射的原理。最后讨论了缺页异常时机器的处理方法。


结论

Hello所经历的过程:

  1. hello.c文件由程序员输入代码生成;
  2. 预处理(hello.i):由hello.c文件预处理生成,将头文件,宏替换,条件编译语句等进行替换,去除注释,生成一个干净的源程序文件;
  3. 编译(hello.s):由hello.i文件编译生成,将高级语言程序转化成汇编语言程序,以便于后续的处理,同时还可能会进行一些简单的优化;
  4. 汇编(hello.o):由hello.s文件汇编生成,是二进制可重定位目标文件,文件中对于函数调用的部分会余留重定位标志,等待链接;
  5. 链接(hello):由hello.o、动态链接器、动态链接依赖库、C程序启动库等链接而成的可执行文件,可以通过shell直接运行;
  6. fork创建子进程:接收到hello程序的运行命令时,shell会创建一个子进程分给hello,用于hello的运行;
  7. execve加载程序:创建子进程后,加载hello的ELF头相关数据到内存,进入hello的入口点;
  8. 执行指令:CPU为进程分配时间片,hello可以使用CPU资源,顺序执行自己的机器指令流;
  9. 访问内存:MU将程序中的虚拟内存映射到物理内存地址;
  10. 信号处理:在程序运行过程中对外界输入作出反应,比如Ctrl-C、Ctrl-Z等;
  11. 终止:main函数执行过后,经过非常漫长的中止函数部分,退出程序,内核会将hello所有数据结构信息全部清除,父进程回收子进程。

对计算机系统设计与实现的感悟:看似十分简单的一个C程序,实际由计算机实现起来却要复杂无数倍,感受深切,故对计算机发展历史上,无数推动计算机一点点发展到今天的水平的计算机学前辈们表示崇敬。对于计算机系统的专业知识上,暂时并没有创新理念,不过大作业让我感受到,既然一个程序的执行需要如此多的过程,那么也决定了计算机的发展仍然没有达到上限,依然有无限的潜力,未来应当在这半个学期并没怎么学透的计算机系统学科上继续深耕,因为往往更高的上限来源于底层的创新。最后,精巧严谨的机器汇编语言也让我触摸到了“用计算机的思维”去研究程序,研究计算机的意识。


附件

中间附件名称

作用

hello

可执行文件,hello.o与动态库、启动库等链接得到,机器可以直接运行

hello.i

预处理文件,头文件展开、宏替换等

hello.s

汇编语言文件,面对机器架构的单步控制程序

hello.o

二进制可重定位目标文件,机器能读懂的机器指令(16进制)

hello.elf

hello.o的ELF格式文件,用于分析hello.o的内容

hello.arm

hello.o与hello反汇编文件,用于对比学习hello.o中机器指令与hello中的差异,感受重定位、动态链接等过程的行为

hello1.arm


参考文献

[1]  Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.

[2]  https://www.cnblogs.com/theseventhson/p/15668338.html

[3]  https://zhuanlan.zhihu.com/p/466602063

[4]  https://www.cnblogs.com/sky-heaven/p/15891042.html


网站公告

今日签到

点亮在社区的每一天
去签到