本文以C语言程序hello.c为例,系统性地分析了程序从源代码到可执行文件的完整生命周期,包括预处理、编译、汇编、链接等关键阶段,深入探讨其在Linux环境下的进程管理和存储管理机制。通过理论分析与实践演示相结合,详细阐述了进程创建(fork)、程序替换(execve)的内存映射,缺页中断处理和动态存储分配的管理机制,揭示了计算机系统各抽象层之间的交互关系。
本研究不仅帮助读者深入理解C程序的编译与执行过程,还为优化程序性能和调试复杂问题提供了实践指导,具有重要的理论与应用价值。
关键词:C程序;编译过程;进程管理;存储管理;动态链接
目 录
第1章 概述
1.1 Hello简介
1. P2P(From Program to Process)
Hello最初以C语言源代码(`hello.c`)的形式被编写。随后,它经历了以下阶段:(1)预处理:宏展开、头文件包含、条件编译处理,生成`.i`文件。
(2)编译:将预处理后的代码转换为汇编语言(`.s`文件)。
(3)汇编:将汇编代码翻译成机器指令,生成可重定位目标文件(`.o`文件)。
(4)链接:将目标文件与库文件(如`libc`)合并,生成可执行文件(`hello`)。
最终,当用户在Shell中运行`./hello`时,操作系统将其加载到内存,创建进程并分配资源,完成从程序(Program)到进程(Process)的转换。
2. 020(From Zero-0 to Zero-0)
从无到有(0→1):Shell通过`fork()`创建子进程,并调用`execve`加载`hello`程序。操作系统为其分配虚拟内存,建立页表映射,加载代码和数据,并跳转到`main`函数开始执行。
从有到无(1→0):程序执行完毕后,Shell父进程通过信号机制(如`SIGCHLD`)回收子进程资源,操作系统释放内存、文件描述符等数据结构,使系统状态回归初始(Zero)。
这一过程完整展现了程序从无到运行,再到完全消失的生命周期。
1.2 环境与工具
硬件环境:AMD Processor;
软件环境:Windows11 64位;VMware Workstation 17 Player;
开发和调试工具:gdb;edb;readelf;objdump;Visual Studio Code;
1.3 中间结果
hello.i 预处理后得到的文本文件
hello.s 编译后得到的汇编语言文件
hello.o 汇编后得到的可重定位目标文件
hello.asm 反汇编hello.o得到的反汇编文件
hello1.asm 反汇编hello可执行文件得到的反汇编文件
1.4 本章小结
本章概括并介绍了hello的P2P和020过程,并列出了本实验中使用的硬软件环境和开发调试工具以及生成的中间结果文件。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理是编译的第一步,由预处理器(`cpp`)对源代码进行初步处理,主要针对以`#`开头的指令进行文本替换和调整。具体过程如下:
- 宏展开与指令处理:
预处理器解析`#include`、`#define`等指令,将头文件(如`stdio.h`)的内容直接插入源代码,并展开宏定义。删除注释和冗余空白字符,仅保留有效代码。
2.生成`.i`文件:
例如,`hello.c`中的`#include <stdio.h>`会被替换为`stdio.h`的实际内容,生成扩展后的中间文件`hello.i`。
预处理后的代码仍是纯文本形式,但已消除宏和依赖,为后续编译阶段做好准备。
2.1.2预处理的作用
预处理过程中并不直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换,主要有以下作用:
头文件包含:将所包含头文件的指令替代。
宏定义:将宏定义替换为实际代码中的内容。
条件编译:根据条件判断是否编译某段代码。
其他:如注释删除等。
简单来说,预处理是一个文本插入与替换的过程预处理器。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,我们对比源程序和预处理后的程序。结果显示,代码被大量扩充,但是除了预处理指令被扩展成了几千行之外,源程序的其他部分都保持不变。
在程序执行过程中,main函数之前出现的大量代码实际上是由头文件<stdio.h>、<unistd.h>和<stdlib.h>依次展开生成的。以stdio.h的展开过程为例:
在预处理阶段,#include指令的主要功能是将指定头文件的全部内容原样插入到当前源文件中。stdio.h作为标准输入输出库的头文件,其中包含了大量与输入输出操作相关的函数原型、宏定义以及必要的类型声明等内容。
当预处理器扫描到#include<stdio.h>指令时,会按照以下步骤进行处理:
1.在系统预设的头文件搜索路径(通常是/usr/include目录)中定位stdio.h文件;
2.将该文件的所有内容逐字复制到当前源文件的对应位置;
3.如果stdio.h内部还包含其他#include指令(如<stddef.h>或<features.h>等),预处理器会递归地进行同样的展开操作
预处理器在此过程中仅执行简单的文本替换操作,不会对头文件中的代码进行任何语法分析或语义转换。这种机械式的展开方式最终会形成一个经过"膨胀"的中间文件,其中包含了所有必要的声明和定义,为后续的编译阶段做好准备。
这种头文件展开机制虽然简单直接,但却是C语言模块化编程的重要基础,使得开发者可以方便地复用标准库和第三方库的功能。
2.4 本章小结
本章系统性地介绍了C语言程序在Linux环境下的预处理过程及其核心作用。首先从理论层面阐述了预处理的基本概念和功能,包括宏替换、头文件包含、条件编译等关键机制。随后,通过一个具体的hello程序实例,详细演示了从源代码文件hello.c到预处理文件hello.i的完整转换过程。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译是指将高级语言编写的源程序转换为等价的汇编语言程序的过程。具体来说:1.这是一个翻译过程,把人类可读的高级语言代码转换为机器可理解的汇编语言代码,为后续的汇编过程做准备;
2.通过专门的编译程序(编译器)完成,编译器将预处理后生成的目标文件(.o文件)转换为汇编语言文件(.s文件)。
这一过程是程序从源代码到可执行文件转换的关键环节,使代码最终能够被计算机硬件执行。
3.1.2编译的作用
1. 提高编程效率:使开发者无需直接编写机器指令
2. 增强可移植性:为不同硬件平台提供统一的编程接口
3. 建立标准化桥梁:为各类高级语言的编译器提供通用的输出形式
编译的基本流程包含以下关键阶段:
1. 词法分析:将源代码分解为有意义的词素(tokens)
2. 语法分析:构建抽象语法树(AST),检查程序结构
3. 语义分析:验证程序逻辑的正确性
4. 中间代码生成:创建与机器无关的中间表示
5. 代码优化:提高生成代码的执行效率
6. 目标代码生成:最终输出汇编语言程序
该过程以预处理后的程序为输入,通过多阶段的转换处理,最终生成可被汇编器处理的汇编语言代码,实现了从高级语言到低级语言的完整转换,为后续的汇编和链接过程奠定了基础。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编初始部分
在main函数前有一部分字段展示了节名称:
.file 声明出源文件
.text 表示代码节
.section .rodata 表示只读数据段
.align 声明对指令或者数据的存放地址进行对齐的方式
.string 声明一个字符串
.globl 声明全局变量
.type 声明一个符号的类型
3.3.2 数据部分
(1)字符串程序有两个字符串存放在只读数据段中,hello.c中唯一的数组是main函数中的第二个参数(即char**argv),数组的每个元素都是一个指向字符类型的指针。由已知数组起始地址存放在栈中-32(%rbp)的位置,被两次调用作为参数传到printf中。
如图,分别将rdi设置为两个字符串的起始地址:
(2)参数argc
参数argc是main函数的第一个参数,被存放在寄存器%edi中,由语句
可见寄存器%edi地址被压入栈中,而语句
可知该地址上的数值与立即数4判断大小,从而得知argc被存放在寄存器并被压入栈中。
(3)局部变量
可知局部变量i是被存放在栈上-4(%rbp)的位置。
3.3.3全局函数
hello.c中只声明了一个全局函数int main(int arge,.char*argv[]),我们通过汇编代码
可知。
3.3.4赋值操作
hel1o.c中的赋值操作贝有for循环开头的i-0,该赋值操作体现在汇编代码上,则是用mov指令实现,如图:
。由于int型变量i是一个32位变量,使用movl传递双字实现。
3.3.5算术操作
hello.c中的算术操作为for循环的每次循环结束后i++,该操作体现在汇编代码则使用指令add实现,问样,由丁变量i为32位,使用指令addl。指令如下:
3.3.6关系操作
hello.c中存在两个关系操作,分别为:
- 条件判断语句if(argc!=4):汇编代码将这条代码翻译为:
使用了cmp指令比较立即数4和参数argc大小,并且设置了条件码。根据条件码,如果不相等则执行该指令后面的语句,否则跳转到.L2。
- 在for循环每次循环结束要判断一次i<8,判断循环条件被翻译为:
同(1),设置条件码,并通过条件码判断跳转到什么位置。
3.3.7控制转移指令
设置过条件码后,通过条件码来进行控制转移,在本程序中存在两个控制转移:
(1)
判断argc是否为4,如果不为4,则执行if语句,否则执行其他语句,在汇编代码中则表现为如果条件码为1,则跳到.L2,否则执行cmpl指令后的指令。
(2)
在for循环每次结束判断一次i<8,翻译为汇编语言后,通过条件码判断每次循环是否跳转到.L4。而在for循环初始要对i设置为0,如下:
然后直接无条件跳转到.L3循环体。
3.3.8函数操作
(1)main函数
参数传递:该函数的参数为int argc,,char*argv[]。具体参数传递地址和值都在前面阐述过。
函数调用:通过使用call内部指令调用语句进行函数调用,并且将要调用的函数地址数据写入栈中,然后自动跳转到这个调用函数内部。main函数里调用了printf、exit、sleep函数。
局部变量:使用了局部变量i用于for循环。具体局部变量的地址和值都在前面阐述过。
(2)printf函数
参数传递:printf函数调用参数argv[1],argv[2]。
函数调用:该函数调用了两次。第一次将寄存器%rdi设置为待传递字符串"用法:Hello学号姓名 秒数!\n"的起始地址;第二次将其设置为“Hello %s %s\n”的起始地址。具体已在前面讲过。使用寄存器%rsi完成对argv[1]的传递,用%rdx完成对argv[2]的传递。
(3)exit函数
参数传递与函数调用:
将rdi设置为1,再使用call指令调用函数。
(4)atoi、sleep函数
参数传递与函数调用:
可见,atoi函数将参数argv[3]放入寄存器%rdi中用作参数传递,简单使用call指令调用。
然后,将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。
(5)getchar函数
无参数传递,直接使用call调用即可。
3.3.9类型转换
atoi函数将宁字符中转换为sleep函数需要的整型参数.
3.4 本章小结
本章系统性地探讨了程序编译的核心概念与实际应用,主要内容包括:
1.编译基础理论:明确定义编译在程序构建流程中的关键地位:将高级语言转换为可执行文件的核心环节;详细解析编译的基本功能与转换机制。
2.Ubuntu环境下的编译实践:以hello程序为案例,完整演示从hello.i到hello.s的转换过程,具体展示gcc编译指令的使用方法与参数设置。
3.汇编代码深度解析:通过对比分析hello.s汇编文件与原始C代码,揭示:
不同数据类型(int、float等)的底层表示
各类运算(算术、关系、赋值)的机器级实现
函数调用机制与栈帧管理
程序控制流(分支、循环)的跳转实现
类型转换的底层处理方式
从高级语言到汇编指令的完整剖析,更加深入理解编译过程的本质,也为后续的优化调试工作奠定了坚实基础。通过具体案例的对比分析,直观展示了高级语言抽象与机器级实现之间的对应关系。
第4章 汇编
4.1 汇编的概念与作用
4.1.1概念
汇编是程序编译过程中的关键环节,主要完成从汇编语言到机器语言的转换。由于机器语言(二进制指令)难以直接编写,汇编语言作为其助记符形式应运而生,二者保持一一对应关系。汇编器(as)的核心功能就是将.s文件中的汇编指令逐条转换为机器指令,并打包生成可重定位目标文件(.o)。该文件采用二进制格式,包含函数(如main)的机器指令编码。
4.1.2作用
汇编阶段的核心价值在于搭建高级语言与机器执行之间的桥梁:
1. 实现汇编指令到二进制机器码的精确转换
2. 生成包含完整指令编码的可重定位目标文件
3. 为后续链接阶段提供标准化输入
通过这一过程,最终使高级语言代码能够被计算机硬件直接识别和执行。
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3 可重定位目标elf格式
在shell中输入readelf -a hello.o > hello.elf 指令获得hello.o文件的ELF格式:
4.3.1生成ELF格式的可重定位目标文件
典型的ELF格式的可重定位目标文件的结构如下:
4.3.2查看ELF格式文件的内容
(1)ELF头
ELF文件头(ELF header)以一个16字节的魔数序列开头,用于标识系统的字长和字节序,其余部分包含关键元数据:包括ELF头大小、文件类型(可重定位、可执行或共享)、机器架构(如x86-64)、节头表(section header table)的文件偏移量及其条目大小和数量。节头表作为ELF文件的核心索引结构,通过固定大小的条目详细记录文件中每个节区(section)的具体位置和尺寸信息,这些节区存储着程序代码、数据等不同内容。完整的ELF头结构如下图所示:
该设计使得链接器能够高效解析和重组目标文件的各个组成部分。
(2)节头(section header)
记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
(3)重定位节
.rel.text节记录了.text节中需要重定位的指令位置信息,这些位置在链接器将该目标文件与其他文件合并时需要被修改。具体而言,涉及外部函数调用或全局变量引用的指令都需要重定位调整,而调用本地函数的指令则保持不变。值得注意的是,最终生成的可执行目标文件中不会保留这些重定位信息。
如图所示:
典型的可重定位条目包括:外部函数调用地址、全局变量引用地址等需要链接时确定的具体位置。
(4)符号表
.symtab节中包含ELF符号表,这张符号表包含一个条目的数组,存放一个程序定义和引用的全局变量和函数的信息。该符号表不包含局部变量的信息。符号表如下:
4.4 Hello.o的结果解析
4.4.1命令
在shell中输入objdump -d -r hello.o > hello.asm 指令输出hello.o的反汇编文件,并与第3章的hello.s文件进行对照分析。
4.4.2与hel1o.s的对照分析
(1)增加机器语言
每一条指令增加了一个十六进制的表示,即该指令的机器语言。例如,在hello.s中的一个cmpl指令表示为
而在反汇编文件中表示为
(2)操作数进制
反汇编文件中的所有操作数都改为十六进制。如(1)中的例子,立即数由hello.s中的$4变为了$0x4,地址表示也由-20(%rbp)变为-0x14(%rbp)。可见只是进制表示改变,数值未发生改变。
(3)分支转移
反汇编的跳转指令中,所有跳转的位置被表示为主函数+段内偏移量这样确定的地址,而不再是段名称(例如.L3)。例如下面的jmp指令,反汇编文件中为
而hello.s文件中为
(4)函数调用
反汇编文件中对函数的调用与重定位条目相对应。观察下面两个call指令调用函数,在hello.s中为
而在反汇编文件中调用函数为
在可重定位文件中call后面不再是函数名称,而是一条重定位条目指引的信息。
4.5 本章小结
本章系统阐述了汇编的核心概念及实现过程:以Ubuntu平台的hello.s为例,首先演示了通过汇编器生成可重定位目标文件hello.o,并转换为ELF格式(hello.elf)进行观察分析;其次详细解析了ELF文件中各节区的结构特征,通过对比原始汇编文件hello.s与反汇编生成的hello.asm,直观展现了从汇编指令到机器码的转换细节,同时揭示了链接器所需的各类重定位信息,完整呈现了源代码经汇编处理为可链接目标文件的全过程及其实现机制。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接(linking)是将编译生成的代码模块和数据片段组合成可执行文件的关键过程,该文件能够被加载到内存中运行。根据执行时机的不同,链接可分为编译时(源代码翻译为机器码阶段)、加载时(程序载入内存阶段)和运行时三种方式。
5.1.2链接的作用
在现代开发中,链接器(linker)的引入实现了分离编译机制——开发者无需将所有代码维护在单一源文件中,而是可以将大型应用拆分为多个独立模块,每个模块可单独修改和编译,仅需重新链接即可更新应用。这种模块化方式不仅提升了代码的可维护性,还通过处理程序内外部依赖关系及最终代码布局,完成了从源代码到可执行程序的最后转化步骤。
5.2 在Ubuntu下链接的命令
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格式,得到hello的节信息和段信息:
(1)ELF头(ELF Header)
hello1.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以描述了生成该文件的系统的字的大小和字节顺序的16字节序列Magic开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello1.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
(2)节头
描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
(3)程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
(4)Dynamic section
(5)Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
5.4 hello的虚拟地址空间
观察程序头的LOAD可加载的程序段的地址为0x400000。如图:
使用edb打开hello从Data Dump窗口观察hello加载到虚拟地址的情况,查看各段信息。如图:
程序从地址0x400000开始到0x401000被载入,虚拟地址从0x4000000x400f0结束,根据5.3中的节头部表,可以通过edb找到各段的信息。
如.interp节,在hello.elf文件中能看到开始的虚拟地址:
在edb中找到对应的信息:
同样的,我们可以找到如.text节的信息:
5.5 链接的重定位过程分析
5.5.1分析helo与helo.o区别
在Shell中使用命令objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm
与第四章中生成的hello.asm文件进行比较,其不同之处如下:
(1)链接后函数数量增加
链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
(2)函数调用指令call的参数发生变化
在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
(3)跳转指令参数发生变化
在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
5.5.2重定位过程
重定位由两步组成:
(1)重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的聚合节。然后链接器将运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。至此程序中每条指令和全局变量都有唯一的运行内存地址。
(2)重定位节中的符号引用。这一步中链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。
(3)重定位过程地址计算方法如下:
5.6 hello的执行流程
5.6.1过程
通过edb的调试,一步一步地记录下call命令进入的函数。
(I)开始执行:_start、_libe_start_main
(2)执行main:_main、printf、_exit、_sleep、getchar
(3)退出:exit
5.6.2子程序名或地址
程序名 程序地址
_start 0x4010f0
_libc_start_main 0x2f12271d
main 0x401125
_printf 0x4010a0
_sleep 0x4010e0
_getchar 0x4010b0
_exit 0x4010d0
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。延迟绑定是通过GOT和PLT实现的,根据hello.elf文件可知,GOT起始表位置为:0x404000:
GOT表位置在调用dl_init之前0x404008后的16个字节均为0:
调用了dl_init之后字节改变了:
对于变量而言,利用代码段和数据段的相对位置不变的原则去计算正确地址。
对于库函数而言,需要plt、got合作。plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。接下来执行程序的过程中,就可以使用过程链接表plt和全局偏移量表got进行动态链接。
5.8 本章小结
本章系统性地介绍了链接技术的核心概念与实际应用:首先阐明链接作为合并代码与数据片段生成可执行文件的关键过程,其在模块化开发中的重要作用;然后以Ubuntu环境下的hello程序为案例,完整演示了从命令行链接生成可执行文件、解析ELF格式(包括ELF头、节头、重定位节和符号表等核心结构)到使用edb调试器分析虚拟地址空间的全过程;特别通过对比hello.o可重定位文件与最终hello可执行文件的差异,深入剖析了重定位的具体实现机制;最后结合程序执行流程,系统讲解了动态链接的工作原理及实现细节,完整呈现了源代码经编译链接到最终执行的完整技术链条。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是操作系统中最核心的执行单元,指正在运行中的程序实例。当程序被加载执行时,操作系统会为其创建一个或多个进程,每个进程都拥有独立的虚拟地址空间、数据段、堆栈以及执行上下文信息。作为系统资源分配和调度的基本单位,进程构成了现代操作系统的基础架构。传统操作系统中,进程既是资源分配的最小单元,也是程序执行的基本载体。
6.1.2进程的作用
1.资源虚拟化:为每个程序创造独占使用处理器和内存的假象,使程序看似连续执行
2.内存隔离:通过独立的虚拟地址空间实现进程间内存保护,防止相互干扰
3.并发执行:支持多进程并发运行,充分利用多核处理器实现真正并行
4.动态调度:作为基本调度单位,由操作系统决定进程的CPU时间分配
5.进程间通信:提供信号、管道、共享内存等多种机制支持协作
6.状态管理:维护程序执行上下文,确保执行流可被准确跟踪和恢复
这一设计既保证了系统资源的合理分配,又实现了程序执行的隔离性与安全性,是多任务操作系统的基石。通过进程抽象,操作系统能够高效地管理和协调各类应用程序的运行。进程的经典定义就是一个执行中程序的实例。进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。进程为程序提供了一种假象,程序好像是独占的使用处理器和内存,处理器好像是无间断地一条接一条地执行我们程序中的指令。进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用
Shell(以bash为例)是一个功能强大的命令行解释器,为用户提供与操作系统交互的界面。它不仅能解析并执行用户输入的命令,还能管理文件系统、启动和控制程序,并通过脚本实现任务自动化。作为Linux/Unix系统的核心组件,bash支持管道、重定向等高级功能,使命令组合和系统管理更加高效。
6.2.2 Shell-bash的处理流程
在处理命令时,bash首先读取并解析用户输入,展开通配符和变量。若命令为内置指令(如`cd`或`echo`),则直接执行;否则,通过`fork`创建子进程运行目标程序。程序执行时,bash会根据其前后台属性进行管理:前台程序需等待执行完成,而后台程序则立即返回,允许用户继续输入新命令。此外,bash还能处理键盘信号(如`Ctrl+C`中断),确保用户对进程的实时控制。这一流程结合了交互式操作的灵活性与脚本自动化的高效性,使其成为系统管理和开发的核心工具。
6.3 Hello的fork进程创建过程
当用户在Shell界面输入"./hello 2022110599 朱颖睿"时,Shell判断该指令不是内置命令,识别该命令为外部程序后,通过fork()系统调用创建子进程。该调用会产生以下关键行为:
子进程获得父进程用户级虚拟地址空间的完整副本,包括代码段、数据段、堆、共享库和用户栈
父子进程通过不同的PID(进程ID)区分
fork()的返回值具有特殊语义:父进程获得子进程PID,子进程获得0,返回值提供一个明确的方法来分辨程序是父进程还是在子进程中执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个程序。函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
该函数完成以下操作:
用hello程序完全替换当前进程的地址空间
建立新的程序计数器(PC)指向main函数
仅当加载失败时才返回,正常执行时不会返回
继承调用时的参数列表argv和环境变量envp
6.5 Hello的进程执行
每个进程获得独立的指令执行序列(PC值序列)
通过并发流机制实现多任务并行
调试器可见的PC值序列构成逻辑控制流
(2) 虚拟地址空间
每个进程独占4GB(32位)地址空间的假象
实际通过MMU实现物理内存映射
内核调度机制:程序执行涉及以下核心调度机制:
(1)进程上下文切换
内核保存被抢占进程的上下文(寄存器值、PC等);恢复新进程的上下文继续执行;上下文数据结构包含:
通用/浮点寄存器
程序计数器
用户/内核栈指针
状态寄存器
各种内核数据结构
(2)时间片调度
CPU时间被划分为时间片(通常10-100ms)
进程轮流获得时间片执行
实现宏观并行效果
(3)模式切换
用户模式:受限指令集,无法访问内核空间
内核模式:全指令集,可访问所有内存
通过系统调用/中断自动切换模式
5.hello程序执行实例
具体执行流程如下:
1. execve加载后建立全新地址空间
2. 初始运行于用户模式
3. 调用printf输出用户信息
4. 执行sleep引发模式切换:
1>用户模式→内核模式(处理sleep)
2>内核模式→用户模式(恢复执行)
5. CPU通过上下文切换实现多任务
6. 时间片机制保证公平调度
该案例完整展示了从进程创建、程序加载到实际执行的完整生命周期,体现了现代操作系统进程管理的核心机制。首先用户再shel1界面输入指令:./hel1o 2022110599 朱颖睿
6.6 hello的异常与信号处理
6.6.1异常的分类
6.6.2异常的处理方式
6.6.3运行结果及相关命令
(1)正常运行状态
在程序正常运行时,打印8次提示信息,以输入回车为标志结束程序,并回收进程。
(2)运行时按下Ctrl + C
按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
(3)运行时按下Ctrl + Z
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
(4)对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
(5)在Shell中输入pstree命令,可以将所有进程以树状图显示:
- 输入kill命令,则可以杀死指定(进程组的)进程:
6.7本章小结
本章以hello程序为案例,系统性地阐述了计算机系统中进程管理与Shell运行机制的核心原理,首先从基础概念入手,详细解析了进程作为程序执行实例的本质特征及其在系统资源分配中的关键作用,同时剖析了Shell作为命令解释器和进程调度器的双重功能;进而通过hello程序的全生命周期分析,深入探讨了fork()创建进程、execve()加载程序的具体实现机制,以及进程执行过程中的上下文切换、用户态/内核态转换和时间片调度等关键技术;最后针对程序运行中可能出现的各类异常情况,结合具体输入场景进行了实证分析,包括信号处理机制、错误返回码解析和异常恢复流程等,构建了一个从命令输入到程序执行的完整知识体系。
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
程序指令中直接使用的地址称为逻辑地址(或相对地址),在Hello程序中表现为由段选择符和段内偏移量组成的地址对。例如,当Hello程序访问全局变量时,编译器会生成基于数据段的逻辑地址。
7.1.2线性地址
通过段式管理单元转换后得到线性地址。Intel处理器使用全局描述符表(GDT)和局部描述符表(LDT)实现这一转换。如图1所示,段选择符的前13位索引段描述符,结合偏移量生成32位线性地址。
7.1.3虚拟地址
线性地址在分页机制下即为虚拟地址。Hello程序运行时,所有内存访问都使用虚拟地址,与实际的物理内存容量无关。这种抽象使得每个进程都拥有独立的4GB(32位系统)地址空间。
7.1.4物理地址
在存储器里以字节为单位存储信息,每一个字节单元给一个唯一的存储器地址,这个地址称为物理地址,是hello的实际地址或绝对地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址是两部分组成的,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。其中前13位是一个索引号,后3位为一些硬件细节。索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。
全局描述符表(GDT)整个系统只有一个,它包含:(1)操作系统使用的代码段、数据段、堆栈段的描述符(2)各任务、程序的LDT(局部描述符表)段。
每个任务程序有一个独立的LDT,包含:(1)对应任务/程序私有的代码段、数据段、堆栈段的描述符(2)对应任务/程序使用的门描述符:任务门、调用门等。
段式管理图示如下:
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,如果发生缺页,则从磁盘读取。
MMU利用页表来实现从虚拟地址到物理地址的翻译。
下面为页式管理的图示:
CR3寄存器指向第一级页表基址,VPN被划分为4个9位字段依次索引各级页表。如图所示,这种层次结构大幅减少了页表的内存占用。
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用包含L1、L2、L3的高速缓存层次,物理地址被划分为标记位、组索引和块偏移,通过组相联映射实现快速访问。
CPU产生虚拟地址VA,虚拟地址VA传送给MU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。工作原理如下:
多级页表的工作原理展示如下:
7.5 三级Cache支持下的物理内存访问
如图为高速缓存存储器组织结构:
高速缓存的结构将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位:
如果选中的组存在一行有效位为1,且标记位与地址中的标记位相匹配,我们就得到了一个缓存命中,否则就称为缓存不命中。如果缓存不命中,那么它需要从存储器层次结构的下一层中取出被请求的块,然后将新的块存储在组索引位指示组中的一个高速缓存行中,具体替换哪一行取决于替换策略,例如LRU策略会替换最后一次访问时间最久远的那一行。采用LRU算法管理缓存行替换,当发生缓存不命中时,从主存加载目标数据块并替换最近最少使用的缓存行。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_ struct、.区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个。后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
即fork创建的子进程初始共享父进程页表,仅当发生写入时才复制物理页,极大提升了进程创建效率。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)清除原用户空间区域。
(2)建立私有映射区域(代码段、数据段等)。为新程序的代码、数据、.bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享库(如libc.so)。hello程序与共享对象1ibc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。如图所示:
7.8 缺页故障与缺页中断处理
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
(1)检查地址合法性(触发段错误或保护异常)。
(2)检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
(3)两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
1.动态存储分配是指操作系统在程序运行时根据需要动态地分配和释放内存资源的过程。这对于应付程序运行中变化的内存需求十分重要,并且是防止内存被无限期占用的关键。基本方法与策略是通过维护虚拟内存(堆),分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。
2.分配器有两种基本风格:
(1)显式分配器:要求应用显式地释放任何分配的块,例如C标准库提供的malloc程序包。
(2)隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,也被称为垃圾收集器。
3.Printf调用malloc的原因是它可能需要为字符串格式化分配内存,因为格式化后的字符串长度在编译时未知,因此需要在运行时动态分配。一旦字符串被打印到目标输出(如控制台或文件),通常就会立即释放这块内存。
7.10本章小结
本章系统性地剖析了Hello程序在计算机系统中的内存管理机制,首先构建了从逻辑地址到物理地址的四级转换体系,详细阐释了Intel处理器的段式管理架构(含GDT/LDT等关键数据结构)和现代操作系统的页式管理原理,特别聚焦于Core i7处理器的四级页表设计与TLB加速转换机制;进而深入探讨了物理内存访问优化策略,包括三级Cache层次结构和智能替换算法;在进程管理层面,重点分析了fork系统调用的写时复制机制和execve加载程序时的内存空间重构过程,以及缺页异常处理流程。
通过这一完整知识体系的学习,不仅学习了虚拟地址转换的核心原理和多级缓存优化技术,更深刻理解了操作系统如何通过精妙的内存管理设计,在确保进程隔离安全性的同时,实现接近物理内存的访问性能,为后续分析和优化系统级内存问题奠定了坚实的理论基础。
结论
Hello程序从源代码到执行完毕的完整生命周期,深刻展现了计算机系统各层次的协同工作机制。整个过程始于程序员通过键盘输入C语言源代码hello.c,随后历经预处理(cpp)展开头文件生成hello.i,编译器(ccl)将其转换为汇编代码hello.s,汇编器(as)生成可重定位目标文件hello.o,最终通过链接器(ld)合并动态链接库生成可执行文件hello。当用户在Shell中输入"./hello 2021113211 郑文翔"时,系统首先通过fork创建子进程,再由execve完成程序加载,建立虚拟内存映射并转入main函数执行。
程序执行阶段,CPU通过时间片轮转机制为进程分配计算资源,MMU配合页表完成虚拟地址到物理地址的转换。运行过程中,用户可通过Ctrl+C发送SIGINT信号终止进程,或通过Ctrl+z发送SIGTSTP信号挂起进程。程序终止时,父进程负责回收子进程资源,内核清除所有相关数据结构。这个看似简单的"Hello World"程序,实际上经历了预处理、编译、汇编、链接、进程创建、内存管理、信号处理等完整的系统级操作流程,每个环节都体现了现代计算机系统精妙的设计思想。从gcc编译器的多阶段转换,到Shell的进程管理,再到操作系统的资源调度,层层相扣的系统机制共同确保了程序的正确执行,这种严密的系统级协作正是计算机科学魅力的集中体现。
附件
| 文件名 | 描述 |
| hello.c | 源文件 |
| hello.i | hello.c通过预处理器cpp预处理后的文本文件 |
| hello.s | hello.i通过编译器ccl编译后的汇编程序 |
| hello.o | hello.s通过汇编器as汇编后的文件 |
| hello1.txt | 反汇编hello.o得到的文件 |
| hello | hello.o通过链接器ld链接后的可执行文件 |
参考文献
[1] Randal E.Bryant David R.O'Hallaron.深入理解计算机系统(第三版).机械工业出版社,2016.
[2] https://www.cnblogs.com/buddy916/p/10291845.html
[3] [转]printf 函数实现的深入剖析 - Pianistx - 博客园
[4] printf背后的故事 - Florian - 博客园.
[5] linux2.6 内存管理——逻辑地址转换为线性地址(逻辑地址、线性地址、物理地址、虚拟地址) - 刁海威 - 博客园
[6] https://blog.csdn.net/spfLinux/article/details/54427494
[8] https://zhuanlan.zhihu.com/p/128654625
[9] [转]printf 函数实现的深入剖析 - Pianistx - 博客园
转载自CSDN-专业IT技术社区



