关注

程序人生-Hello’s P2P

摘  要

本篇论文以hello.c程序为例,系统阐释 C 语言程序从源代码转换为可执行文件的完整过程。通过剖析预处理、编译、汇编、链接及进程管理等关键环节,结合理论原理与实际操作演示,深入探讨计算机系统在程序生命周期中的工作机制与体系结构。旨在帮助读者清晰理解 C 语言程序的编译执行逻辑,掌握从源代码到可执行文件的全流程技术细节,揭示计算机系统底层的协同工作原理。

关键词:程序生命周期计算机系统;体系结构

第1章 概述

1.1 Hello简介

Hello 的 P2P(From Program to Process)过程

P2P(From Program to Process)指从hello.c程序变为运行进程:经预处理、编译、汇编、链接生成可执行文件,在 shell 中执行时,shell 通过fork()创建子进程,再用execve()加载程序,分配进程空间并运行。

Hello 的 O2O (From Zero-0 to Zero-0)过程

O2O(From Zero-0 to Zero-0)指内存从无到有再归零:初始内存无 hello 数据,execve()启动程序后,虚拟内存映射到物理内存并加载运行;程序结束后,shell 父进程回收进程,内核删除相关数据结构,资源归零。

1.2 环境与工具

硬件环境:

处理器:13th Gen Intel(R) Core(TM)i5-13500H   2.60 GHz

机带RAM:32.0GB

系统类型:64 位操作系统, 基于 x64 的处理器

软件环境:Windows11 64位,Ubuntu 24.04.2 LTS

开发与调试工具:VScode,vim objump gdb gcc readelf等工具

1.3 中间结果

hello.c         源程序

hello.i         预处理后得到的文本文件

hello.s         编译后得到的汇编语言文件

hello.o         汇编后得到的可重定位目标文件

hello.elf        用readelf读取hello.o得到的ELF格式信息

hello1.elf       用readelf读取hello得到的ELF格式信息

hello.asm       反汇编hello.o得到的反汇编文件

hello1.asm      反汇编hello可执行文件得到的反汇编文件

hello          可执行文件

1.4 本章小结

  本章简要介绍了hello的P2P,020流程,详细说明了本实验所用到的硬件配置、软件平台、开发工具以及本实验生成的各个中间结果文件的名称和功能。


第2章 预处理

2.1 预处理的概念与作用

预处理的概念:预处理是指在编译之前,由预处理器对源代码进行文本级处理的过程。它不涉及语法分析或程序逻辑,而是对代码进行文本替换、文件包含、条件编译等操作,最终生成一个经过修改的源代码文件,供编译器进一步编译。预处理将处理以#开头的指令(如#include#define#ifdef不检查代码语法仅做文本替换在编译前完成

预处理的作用:

头文件包含(#include):将指定的头文件内容直接插入到当前文件中。

宏定义与替换(#define):定义宏,在编译前进行文本替换。

条件编译(#ifdef,#if,#else,#endif):根据条件决定是否编译某段代码,常用于跨平台兼容或调试。

注释删除与空白字符处理:预处理器会删除注释(//和/**/)和多余的空白字符,但不影响代码逻辑。

其他预处理指令:#pragma:编译器特定指令(如优化选项);#error:强制编译错误(用于条件检查);#line:修改行号信息(用于调试)

2.2在Ubuntu下预处理的命令

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

2.3 Hello的预处理结果解析

我们发现hello.c经过预处理除了预处理指令被扩展成了几千行之外,源程序的其他部分都保持不变

为什么hello.i有几千行代码呢?

在main函数代码出现之前的大段代码源自于的头文件<stdio.h>  <unistd.h>  <stdlib.h> 的依次展开。

以#include <stdio.h>为例,预处理时,gcc -E 会将 stdio.h 整个头文件的内容直接插入到 hello.i 中。而 stdio.h 本身又会包含其他头文件(如 stddef.hstdarg.htypes.h 等),形成头文件递归展开,最终导致 hello.i 变得非常庞大。

预处理器不会对头文件中的内容做任何计算或转换,只是简单地复制和替换。

此外,还进行了:删除所有注释压缩空白字符插入调试用#line标记main函数代码原样保留在文件末尾。

2.4 本章小结

本章以Linux环境为实验平台,研究了预处理的概念和作用,以及C语言程序的预处理机制在Ubuntu上通过预处理命令实现了从hello.c到hello.i的过程,并根据实际代码分析了预处理后的结果。通过分析,我们发现预处理阶段#include指令的处理是一个系统化的文本替换过程。预处理将定位头文件,将找到的头文件内容逐字插入到#include指令的位置,若头文件包含其他#include,则递归处理,最终生成一个连续的文本流。同时,预处理还完成了删除注释,压缩空白字符,修改行号信息等功能,本程序不涉及条件编译。

第3章 编译

3.1 编译的概念与作用

编译的概念:编译指将高级语言编写的源程序(如C语言)翻译成等价的汇编语言程序的过程。这一过程由编译器完成,其核心任务是将人类可读的代码转换为机器可理解的底层指令,同时确保语义的正确性。

编译的作用:

转换代码形式:将高级语言转换为汇编语言,为后续生成机器码提供基础。

提高编程效率:开发者只需关注高级语言逻辑。

增强可移植性:代码可通过不同平台的编译器生成对应的汇编代码,适配多种硬件架构。

基本流程:词法分析,语法分析,语义分析,中间代码生成,代码优化,汇编代码生成。

3.2 在Ubuntu下编译的命令

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

3.3 Hello的编译结果解析

3.3.1数据处理

(1)字符串常量编译器将字符串常量存储在只读数据段(.rodata),中文字符被转换为UTF-8编码的字节序列。

分别将rax设置为两个字符串的起始地址

(2)变量

①局部变量:程序中只有一个局部变量i,被存放在栈上-4(%rbp)的位置。

②参数:函数参数argc(32位)和argv(64位指针)通过寄存器传入后保存到栈中

参数argc是main函数的第一个参数,被存放在寄存器%edi中,寄存器%edi地址被压入栈中。

3.3.2赋值操作

原代码:    for(i=0;i<10;i++){

进行了i=0赋值

汇编代码:

将栈帧中偏移-4处(变量i)赋值为0l表示32位操作,-4(%rbp)是局部变量的栈地址。

3.3.3类型转换

atoi实现字符串到整数的显式转换

eax(32位)到edi(64位)的零扩展是System V ABI要求的隐式行为

3.3.4算术操作:

hello.c中的算术操作为for循环的每次循环结束后i++

for(i=0;i<10;i++){

汇编代码中指令如下:使用指令addl

3.3.5关系操作

(1)判断argc是否等于5

if(argc!=5){

汇编代码:

使用了cmp指令比较立即数5和参数argc大小,并根据条件码je(等于),如果不相等则执行该指令后面的语句,否则跳转到.L2

(2)在for循环每次循环结束要判断一次i<10

for(i=0;i<10;i++){

汇编代码:

根据条件码jle(小于等于),如果符合<10,即≤9,跳转到.L4

3.3.6数组操作:

数组访问:

原代码:

printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);

movq -32(%rbp), %rax      ; 加载argv数组地址到%rax

addq $24, %rax            ; 计算argv[3]的地址(偏移24字节)

movq (%rax), %rcx         ; 加载argv[3](手机号)到%rcx

movq -32(%rbp), %rax      ; 重新加载argv数组地址

addq $16, %rax            ; 计算argv[2]的地址(偏移16字节)

movq (%rax), %rdx         ; 加载argv[2](姓名)到%rdx

movq -32(%rbp), %rax      ; 再次加载argv数组地址

addq $8, %rax             ; 计算argv[1]的地址(偏移8字节)

movq (%rax), %rax         ; 加载argv[1](学号)到%rax

数组访问分解为:基地址+偏移量计算→解引用

每个指针偏移+8(64位系统指针大小)

指针解引用

3.3.7控制转移:

设置条件码,通过条件码来进行控制转移

(1)判断argc是否等于5,条件码je

if(argc!=5){

汇编代码:

使用了cmp指令比较立即数5和参数argc大小,并根据条件码je(等于),如果不相等则执行该指令后面的语句,否则跳转到.L2

(2)在for循环每次循环结束要判断一次i<10,条件码jle

for(i=0;i<10;i++){

汇编代码:

根据条件码jle(小于等于),如果符合<10,即≤9,跳转到.L4

(3)在for循环初始对i设置为0,无条件跳转到.L3循环体

3.3.8函数操作:

  1. main函数

参数传递:int argc,,char*argv[]

argcint 类型)存储在 -20(%rbp)movl %edi, -20(%rbp))。

argvchar*[] 类型)存储在 -32(%rbp)movq %rsi, -32(%rbp))。

函数调用:main函数里通过call指令调用了printf、exit、sleep、getchar函数

局部变量:使用了局部变量i用于for循环,初始化并自增

(2)printf函数

第一次调用: printf("用法: Hello 2023112613 李晗婧 13898186009 4!\n");

将寄存器%rax设置为待传递字符串的起始地址:

第二次:printf("Hello %s %s %s\n",argv[1],argv[2],argv[3]);

%rdi:格式化字符串地址

%rsi%rdx%rcx:分别对应 argv[1]argv[2]argv[3]

(3)exit函数

参数传递与函数调用:

将edi设置为1,再使用call指令调用函数。

(4)atoi、sleep函数

atoi

调用 atoi 函数将 argv[4](字符串)转换为整数,用于后续的 sleep 函数。

-32(%rbp):存储的是 argvchar*[] 的起始地址)

addq $32, %rax

argv 是指针数组,每个元素占 8 字节;argv[4] 的偏移量 = 4 × 8 = 32 字节。

此时 %rax 指向 argv[4] 的存储位置

movq (%rax), %rax

(%rax):解引用 %rax得到 argv[4] 指向的字符串地址

sleep:

sleep(atoi(argv[4]))

将转换完成的秒数从%eax传递到%edi中,edi存放sleep的参数,再使用call调用。

(5)getchar函数

无参数传递,直接call调用

3.4 本章小结

 这一章介绍了C源程序编译生成汇编代码的全过程,简要说明了编译的含义和功能,演示了编译的指令,并通过对比分析生成的hello.s文件中的汇编代码和hello.c源代码,探讨了编译器将高级语言转换为底层机器指令的实现机制,剖析了变量存储,运算操作,函数调用,控制结构等在机器级的实现方式。本章表明,编译过程本质上是将程序逻辑从抽象的高级语言转换为具体的寄存器操作和内存访问指令。

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:

汇编是指 汇编器将 .s 汇编语言文件翻译成机器指令,并打包为可重定位目标文件(.o 文件)的过程。生成的 .o 文件是二进制格式,包含程序的机器码,可直接由计算机硬件执行。

汇编的作用:

实现指令的转换,将人类可读的汇编代码转换为机器可执行的二进制指令;生成可重定位目标文件.o文件,包含代码,数据及重定位信息,供链接器进一步处理;最终生成 CPU 可直接解码和运行的机器语言,完成高级语言到硬件的关键转换。

4.2 在Ubuntu下汇编的命令

在Ubuntu系统下,对hello.s进行汇编的命令为:

gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o

4.3 可重定位目标elf格式

readelf -a hello.o > hello.elf

4.3.1 查看ELF头

ELF 头是 ELF(Executable and Linkable Format)文件的起始部分,它描述了整个文件的基本属性和组织结构。

ELF 头的主要功能是:

标识文件格式:确认这是一个合法的 ELF 文件。

描述文件的基本属性:如目标架构、字节序、文件类型等。

定位其他关键部分:如节头部表(Section Header Table)的位置,从而帮助链接器或加载器解析文件内容。

4.3.2节头(section header)

节头部表(Section Header Table)是 ELF 文件中最重要的结构之一,它描述了文件中所有节(Section)的详细信息。每个节头部条目(Entry)对应一个具体的节,记录了该节的名称、类型、在文件中的偏移量、大小、内存地址、访问权限等信息。

4.3.3 重定位节

重定位节 .rela.text 是 ELF 可重定位目标文件(.o)的核心组成部分,用于指导链接器在合并多个目标文件时如何修正代码和数据中的地址引用。

重定位节的作用在编译阶段,编译器无法确定外部函数(如 puts)和全局数据的最终地址,因此会生成占位符(如 00 00 00 00)。重定位节记录这些占位符的位置Offset、依赖的符号Sym. Name以及修正方式Type,链接器根据这些信息

填充正确的地址。

修正逻辑修正值=Sym. Address+Addend−Offset

4.3.4 符号表

符号表(.symtab)是 ELF 文件的核心数据结构之一,它记录了目标文件中定义的符号(如函数、全局变量)和需要从外部引用的符号(如库函数)。

符号表的作用

定义符号:记录本文件定义的全局函数/变量(如 main)。

引用符号:记录需要从外部链接的符号(如 putsprintf)。

辅助链接:链接器通过符号表解析跨文件的符号引用。

符号表告诉链接器“有哪些符号需要解析”(如 puts 的地址)。重定位表:告诉链接器“在哪里修正这些符号的引用”(如 call puts 的指令位置)。

4.4 Hello.o的结果解析

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

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

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

4.4.1机器语言表示对比

 (1)数据移动指令示例

Hello.s

Hello.asm

机器码:89 7d ec

89:mov操作码

7d:ModR/M字节(指定%edi和%rbp带偏移)

ec:偏移量-0x14的补码表示

 (2)算术运算指令示例

hello.s:

hello.asm:

机器码:83 45 fc 01

83:带符号扩展的算术操作

45:ModR/M字节

fc:偏移量-0x4

01:立即数1

4.4.2操作数进制对比

1立即数表示

每一条指令增加了一个十六进制的表示,即该指令的机器语言。

例如,在hello.s中的一个cmpl指令表示为

hello.s:

hello.asm:

立即数5在反汇编中统一为十六进制0x5

偏移地址-20转换为十六进制-0x14只是进制表示改变,数值未发生改变。

2内存地址表示

hello.s:

hello.asm:

偏移地址-32转换为十六进制-0x20

4.4.3分支转移实现对比

反汇编文件hello.asm的跳转指令中,所有跳转的位置被表示为主函数+段内偏移量这样确定的地址,而不再是段名称(例如.L3)。

而hello.s文件中

4.4.4函数调用机制对比

反汇编文件hello.asm中对函数的调用与重定位条目相对应。

例如exit函数在hello.s中是这样调用的:

hello.asm

在可重定位文件中call后面不再是函数名称,而是一条重定位条目指引的信息。

4.5 本章小结

本章以hello程序为例,系统讲解了汇编语言到机器代码的转换过程。通过对比分析hello.s汇编源文件和hello.o反汇编文件,揭示了汇编器将符号化指令转换为二进制机器码的具体实现,包括操作数统一转为十六进制、分支跳转采用相对地址编码、函数调用关联重定位条目等关键转换机制,并深入剖析了ELF格式可重定位目标文件中为链接器准备的节区信息和重定位表,完整展现了从高级汇编到可执行二进制文件的转换过程及其底层原理。

5链接

5.1 链接的概念与作用

链接的概念:

将目标文件 hello.o 中包含的代码、数据等片段,与程序运行所依赖的其他代码(如标准库函数等)和数据片段,通过链接器进行收集、组合,形成一个完整的、可被操作系统加载到内存并执行的可执行文件 hello 的过程。它会处理目标文件间的符号引用关系,确保程序各部分能正确协同工作。

链接的作用:

1实现分离编译

在开发大型程序时,可将其拆分为多个源文件分别编译成目标文件当某个源文件修改后,只需重新编译该文件,再进行链接,无需重新编译整个项目,提高开发效率。

2符号解析与重定位

目标文件中存在对其他函数、变量等的符号引用,链接过程会解析这些符号的实际地址,进行重定位。比如 hello.o 中调用了标准库函数 printf,链接器会找到 printf 函数在库中的实际位置,将调用指令与该位置正确关联起来,保证程序运行时能准确调用到相应功能。

3整合资源

把不同目标文件以及库文件中的代码和数据整合在一起,形成一个可独立运行的程序实体。像 hello.o 可能依赖标准输入输出库、运行时库等,链接器会将这些库中的相关部分整合进 hello 可执行文件,使其具备完整功能。

5.2 在Ubuntu下链接的命令

在Ubuntu系统下,链接的命令为:

ld -o hello -dynamic-linker /lib/ld-linux.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等列出其各段的基本信息,包括各段的起始地址,大小等信息

5.3.1 ELF头(ELF Header)

与hello.elf相比较,hello1.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址0x4010f0。

5.3.2 节头(Section Header)

描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。


5.3.3 程序头(program headers)

程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。


5.3.4 动态节区Dynamic section

主要用于描述程序运行时动态链接所需的关键信息。

5.3.5 重定位表Relocation section

记录了程序在链接时或运行时需要修正的地址信息。

在程序加载或运行时,动态链接器会根据这些条目修改代码/数据中的地址,确保外部函数(如printf)和全局符号能被正确调用。

5.3.6 符号表Symbol table

保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。


5.4 hello的虚拟地址空间

使用gdb/edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。   

程序执行的起始地址

展示了 hello 可执行文件的 ELF 段布局 入口地址0x4010f0

给出了各关键段的地址范围,和hello.elf文件一致

根据5.3中的节头部表,可以通过gdb找到各段的信息

如.text节,在hello1.elf文件中能看到开始的虚拟地址:为0x4010f0,与gdb显示的一致

再比如.rodata段,开始的虚拟的地址为0x4020000,与gdb显示的一致

我们可以找到如.text节和.rodata节的信息:

5.5 链接的重定位过程分析

在Shell中使用命令objdump -d -r hello > hello1.asm生成反汇编文件hello1.asm


5.5.1与hello.asm比较

hello.asm 和 hello1.asm 的关键区别在于它们反汇编的是不同阶段的二进制文件,

文件

类型

说明

hello.o

可重定位目标文件

编译器生成的中间文件,包含未链接的机器码,地址从0开始(未分配最终地址)

hello

可执行文件

链接器处理后的完整程序,已分配实际内存地址,包含所有运行时依赖

与hello.asm文件进行比较,其不同之处如下:

  1. 地址分配

Hello.asm

未分配实际内存地址,需链接器重定位。

Hello1.asm

已分配运行时内存地址

(2)符号和重定位

hello.asm

保留重定位条目(如R_X86_64_PC32),提示链接器后续填充真实地址。

Hello1.asm所有符号地址已解析(如0xec3(%rip)直接计算偏移)。链接过程中,链接器解析

了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。

(3)函数数量增加

Hello1.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。动态链接器将共享库中hello.c用到的函数加入可执行文件中。

综上:链接的核心步骤

(1)符号解析

将每个符号引用(如puts)绑定到唯一的符号定义(来自其他目标文件或库)。

(2)地址分配

合并相同段

将所有.o文件的.text段合并到可执行文件的.text段,.data同理。

分配虚拟地址

操作系统为程序指定加载基址(如0x400000),各段按对齐规则分配地址(如.text0x401000)。

(3)重定位

修改代码中的引用:根据最终地址调整指令中的偏移量。例如:

hello.o中的call 0x0(未解析)→ hello中的call 0x401090(指向puts@plt

处理重定位条目

目标文件中的R_X86_64_PC32等标记指导链接器如何修正地址。

5.5.2 hello是如何重定位的

(1)收集重定位条目

目标文件(.o)的重定位段(如.rela.text)记录了哪些指令需要修正。

每条重定位条目包含:

需要修正的指令位置(如call指令的偏移地址)。

引用的符号名(如puts)。

重定位类型(如R_X86_64_PC32)。

示例

修正位置0x1clea指令的操作数)。

符号.rodata(只读数据段)。

类型R_X86_64_PC32(32位相对地址偏移)。

(2)分配最终地址

链接器合并所有.o文件的段后,为每个段分配运行时虚拟地址

(3)修正指令引用

根据重定位类型,链接器计算符号的最终地址,并修改指令中的占位符(如0x0)。

常见重定位类型

类型

说明

计算公式

R_X86_64_PC32

32位相对地址偏移(用于call等指令)

符号地址 - 指令地址 - 4

R_X86_64_64

64位绝对地址(用于全局变量)

符号地址

R_X86_64_PLT32

跳转到PLT表(动态库函数)

PLT入口地址 - 指令地址 - 4

原始指令(hello.o

链接后(hello

puts的PLT入口地址为0x401090

地址计算逻辑

0x401090 (puts@plt) - 0x401148 (call指令地址) - 4 = 偏移量

5.6 hello的执行流程

5.6 Hello的动态链接分析

 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序。

根据hello1.elf

.got.plt起始地址:0x403fe8

.got起始地址:0x403fd8

启动 GDB 并加载程序

 动态链接前的符号地址(未解析状态)

查看got段

查看got.plt段

5.7 本章小结

本章围绕程序链接相关知识展开。开篇介绍链接基本概念与作用,随后通过命令演示生成 hello 可执行文件过程。接着深入剖析,观察 hello 文件的 ELF 格式内容,借助 gdb 工具探究其虚拟地址空间使用状况。最后以 hello 程序为典型案例,详细分析重定位、执行及动态链接过程,系统呈现链接技术在程序构建与运行中的原理和应用。

6hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念​

进程它是程序在特定数据集合上的一次实际运行过程。。它不仅包含了程序的代码、当前执行的指令位置、处理器寄存器状态等运行时信息,还拥有自己独立的内存空间。进程是操作系统进行资源分配和调度的最小单位,这意味着系统会为每个进程分配诸如 CPU 时间、内存空间、磁盘 I/O 权限等必要资源,以保证程序能顺利运行。​

6.1.2 进程的作用​

进程的首要作用是为程序创造一个独立的运行环境,让每个程序都仿佛 “独占” 计算机资源,互不干扰地运行。进程通过操作系统的调度机制,分时复用 CPU 等资源,给用户一种多个程序同时运行的错觉。操作系统根据进程的优先级和资源需求,动态分配 CPU 时间片和内存空间,避免资源浪费与冲突。此外,进程还提供了程序执行的上下文管理,保存程序运行过程中的各种状态信息。

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

6.2.1 Shell-bash 的作用​

Shell是一个交互型应用级程序,也被称为命令解析器,是用户与操作系统内核沟通的桥梁。用户在终端输入的指令,都由 Shell-bash 接收并解析,将人类可读的命令转换为计算机可执行的操作,进而调度系统资源、启动对应程序,让用户无需深入了解底层原理,就能便捷地操控计算机。​

6.2.2 Shell-bash 的处理流程​

Shell-bash 先从终端捕获用户输入的命令,随后对命令进行解析,若判断为内置命令,则直接在 Shell 进程内执行;若是外部程序命令,会通过fork系统调用创建子进程,在子进程环境中加载并运行程序。接着,Shell-bash 会判断程序是前台运行还是后台运行,前台程序需等待其执行完毕,Shell 才恢复接收新命令;后台程序则在启动后立即返回控制权,让用户能继续输入新指令。

6.3 Hello的fork进程创建过程

当用户在 Shell 界面输入指令,Shell 首先对该指令进行识别判断。若并非 Shell 的内置命令,Shell 便让父进程调用fork函数开启进程创建流程。​

fork函数执行时,会创建一个全新的子进程。该子进程如同父进程的 “克隆体”,会获得与父进程用户级虚拟地址空间完全相同的副本,其中涵盖了代码和数据段、堆、共享库以及用户栈。不过,子进程与父进程存在一个关键区别 —— 进程标识符(PID)不同,每个进程都拥有独一无二的 PID 作为身份标识。​

在fork函数执行后,会产生不同的返回值来区分父子进程。在父进程中,fork返回新创建子进程的 PID;而在子进程中,fork返回 0。通过fork的返回值,程序有了明确方式判断当前是在父进程还是子进程环境中执行,从而各自执行后续不同的操作逻辑,实现程序的并发处理与功能扩展。

6.4 Hello的execve过程

execve函数是程序加载运行的关键,它直接在当前进程的上下文中,替换掉原有的程序镜像,转而加载并执行新的可执行目标文件。

函数声明如下:int execve(const char *filename, const char *argv[], const char *envp[]);

filename参数指向 “Hello” 程序对应的可执行文件路径,如"./hello",它明确告知系统要运行的程序文件;argv[]参数是一个字符串数组,用于传递程序运行所需的参数;envp[]参数则包含了程序运行所需的环境变量。

execve函数执行时,会先检查filename指向的文件是否存在、是否具备可执行权限等。若一切正常,它将清空当前进程原有的代码、数据、堆和栈等空间,然后将 “Hello” 程序的内容加载进来,重新初始化进程的执行环境,并从程序的入口点开始执行。与fork函数调用一次返回两次不同,execve函数一旦成功执行,就会完全替换当前进程的执行内容,不会再返回到调用处。只有当出现错误,execve函数才会返回 -1 ,并设置相应的错误码,以便调用程序进行错误处理。

6.5 Hello的进程执行

进程为Hello程序构建了两大核心抽象。其一,提供独立的逻辑控制流,让Hello程序仿佛能独占处理器,按顺序依次执行指令;其二,分配私有的地址空间,使程序 “以为” 自己独享内存资源,安全存储和处理数据,避免与其他程序的数据冲突。​

逻辑控制流定义了Hello程序指令执行的序列,程序计数器(PC)按此序列指引指令执行。上下文切换机制允许操作系统在内核层面暂停、恢复进程执行,当Hello程序进程被其他进程抢占时,内核保存其当前上下文,,后续再恢复这些信息,使程序能从断点处继续执行。时间片机制将Hello程序进程的执行时间切分成小段,与其他进程交替使用 CPU,实现多任务并发执行。​

在Hello程序执行过程中,进程调用execve函数后,为程序分配新的虚拟地址空间,并使其初始运行于用户模式。程序执行printf函数输出时,在用户模式下完成数据输出操作;当调用sleep函数时,进程切换至内核模式,由内核处理睡眠相关操作、运行信号处理程序,之后再返回用户模式继续执行。

整个过程中,CPU 依据调度策略,不断切换进程上下文,将Hello程序进程的运行切分为一个个时间片,与其他进程分时复用 CPU 资源,确保系统高效、稳定地运行多个程序 。

6.6 hello的异常与信号处理

6.6.1异常

(1)异常的分类

异步异常:中断(来自I/O设备的信号)

同步异常:陷阱(有意的异常),故障(潜在可恢复的错误),终止(不可恢复的错误)

(2)异常的处理

中断的处理

陷阱的处理

故障的处理

终止的处理

6.6.2信号

signal就是一条小消息,它通知进程系统中发生了一个某种类型的事件。

常见的信号如下图:

当目的进程接收到内核发送的信号时,有以下几种反应方式:

①忽略这个信号

进程选择对某些信号不做任何处理。比如常见的 SIGCHLD、SIGURG、SIGWINCH等信号,可通过调用 signal 函数并将信号处理函数设置为 SIG_IGN 来实现忽略 。②终止进程

②许多信号的默认处理方式是终止进程,比如 SIGINT(通常由 Ctrl + C 产生 )、SIGTERM(终止信号 )等。当进程收到这类信号,就会结束运行。

通过执行一个称为信号处理程序的用户层函数捕获这个信号

进程可以为特定信号设置自定义的信号处理函数。

6.6.3运行结果及相关命令

(1)正常运行状态

在程序正常运行时,程序会每 4 秒输出一次信息,循环 10 次后等待用户输入。

(2)运行时按下Ctrl + Z

按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。

(3)运行时按下Ctrl + C

按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。

对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。

(4)在Shell中输入pstree命令,可以将所有进程以树状图显示:

(5)输入kill命令,则可以杀死指定(进程组的)进程:

(6) 输入fg 1则命令将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。

(7)不停乱按

       在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。

(8)输入回车

当用户按下回车键时,getchar() 会读取换行符 '\n'(ASCII 码为 10),并返回该字符的整数值。程序继续执行后续代码(return 0;),最终正常退出。

6.7本章小结

本章主要探讨计算机系统中的进程与shell。首先通过简单的 hello 程序切入,阐述进程概念与作用,以及 shell 的功能和处理流程。进而深入剖析 hello 程序,涵盖进程创建、启动及执行的全流程。最后,对hello程序运行时可能遭遇的异常情形,以及各类输入在运行结果中的表现予以阐释说明,系统呈现了进程与 shell 在程序运行中的原理与机制。

7hello的存储管理

7.1 hello的存储器地址空间

在Hello程序运行中,逻辑地址是程序代码生成的相对地址,由段标识符和段内偏移量组成。线性地址是逻辑地址到物理地址转换的中间态,通过将逻辑地址的段内偏移与段基址相加得到,构建连续线性空间。虚拟地址即程序访问存储器使用的逻辑地址,与实际物理内存容量无关。物理地址则是Hello程序数据在物理内存中的真实存储位置,经一系列地址转换后最终确定。

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

在 Intel 架构中,段式管理将程序按逻辑功能划分为多个独立的段,每个段都是独立的逻辑实体,便于程序的模块化管理与内存保护。段表记录了每个段的关键信息。

逻辑地址由段标识符和段内偏移量两部分构成。其中段标识符用于在段描述符表中定位具体段描述符;段描述符详细定义了段的基地址、长度、访问权限等属性,众多段描述符组成段描述符表。​

系统中存在全局描述符表(GDT)和局部描述符表(LDT)。GDT 全局唯一,存储操作系统使用的关键段描述符,以及各任务程序的 LDT 段描述符;而每个任务程序拥有独立的 LDT,存放该任务私有的段描述符和门描述符。

当进行逻辑地址到线性地址的变换时,CPU 先依据段选择符的前13位在 GDT 或 LDT 中找到对应的段描述符,获取段的基地址;再将段内偏移量与基地址相加,从而得到线性地址。例如,若Hello程序的代码段在段描述符表中对应的基地址为0x1000,某条指令在代码段内的偏移量为0x0100,两者相加后得到线性地址0x1100 。

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

虚拟内存被抽象为一个从磁盘上连续编址的字节数组,通过分页(Paging)将虚拟内存(VM)和物理内存(DRAM)分割为固定大小的块,称为虚拟页(VP)和物理页(PP)。

页表本质:是一个由页表条目(PTE)组成的数组.

PTE 的核心字段:​

有效位:标识虚拟页是否当前被缓存到物理内存中。​

地址字段:若有效位为1,该字段存储虚拟页对应的物理页号(PPN),即物理页在 DRAM 中的起始地址偏移量;若有效位为0,地址字段可能存储虚拟页在磁盘上的磁盘块地址。

当 CPU 访问虚拟地址VA时,MMU通过以下步骤完成地址翻译:​

1.虚拟地址拆分:将VA拆分为虚拟页号(VPN)和页内偏移量(VPO)。​

2.VPN:用于索引页表,定位对应的 PTE。​

3.VPO:在物理页内的偏移量,直接映射到物理地址的低地址部分。​

4.查询页表:根据 VPN 找到对应的 PTE:​

5.若 PTE 有效位为1:取出地址字段中的 PPN,与 VPO 拼接得到物理地址PA = PPN << VPO位数 + VPO。​

6.若 PTE 有效位为0:触发缺页故障,操作系统从磁盘中将对应的虚拟页加载到物理内存,更新页表后重新执行访问指令。​

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

在 Core i7 处理器中,采用四级页表的层次结构实现虚拟地址到物理地址的转换。当 CPU 产生虚拟地址 VA 后,该地址首先被传送给内存管理单元(MMU)。

MMU 会使用虚拟地址 VA 中的虚拟页号(VPN)高位部分作为标记(TLBT)和索引(TLBI),在TLB中查找匹配项。如果 TLB 命中,MMU 能迅速获取对

应的物理页号(PPN),并与虚拟地址中的页内偏移量(VPO)组合,直接生成物理地址 PA,完成地址转换。

若 TLB 未命中,MMU 则需要通过四级页表进行查询。首先,控制寄存器 CR3 确定第一级页表的起始地址,虚拟地址中的 VPN1 部分作为偏移量,在第一级页表中定位对应的页表项(PTE)。接着,根据第一级页表项的指引,找到第二级页表的起始地址,再由 VPN2 确定在第二级页表中的位置,依此类推,经过第三级、第四级页表的查询,最终在第四级页表中找到物理页号 PPN。最后,将 PPN 与虚拟地址中的 VPO 组合,形成最终的物理地址 PA.

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

Hello程序访问物理内存时,三级 Cache 发挥作用。数据先在 L1、L2、L3 Cache 中查找,若命中则快速读取,未命中才访问物理内存。例如Hello程序频繁读取的数据,可缓存于 Cache 中,减少物理内存访问次数,提高程序执行效率。

cache的结构

高速缓存的结构将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位:

如果选中的组有效位为1,且标记位与地址中的标记位相匹配,则缓存命中。如果缓存不命中,那么它需要从下一层中取出被请求的块,然后将新的块存储在高速缓存行中,具体替换哪一行取决于替换策略。

7.6 hello进程fork时的内存映射

当Hello进程执行fork时,子进程获得与父进程相同的用户级虚拟地址空间副本,采用写时复制(COW)技术,父子进程共享物理内存页,仅在某进程尝试修改数据时,才为其分配独立物理页,实现高效内存利用,同时保证父子进程数据一致性和独立性。

7.7 hello进程execve时的内存映射

首先,execve函数删除当前进程虚拟地址用户部分已存在的区域结构。

接着,开始映射私有区域。为Hello程序的代码、数据、.bss和栈区域创建新的区域结构,这些新区域采用写时复制机制,在未修改时可共享物理内存,节省资源。

其中,代码和数据区域对应映射到Hello文件中的.text和.data区;.bss区域用于存储未初始化的全局变量和静态变量,它请求二进制零,被映射到匿名文件,其大小在Hello程序中已确定;栈和堆初始状态下请求二进制零,长度为零,后续根据程序运行需求动态增长。​

然后,映射共享区域。Hello程序与动态链接库libc.so链接。execve将libc.so映射到用户虚拟地址空间的共享区域,多个进程可共享该库的同一份物理内存副本,提升内存利用效率。​

最后,execve设置当前进程上下文的程序计数器,使其指向Hello程序代码区域的入口点。至此,进程准备就绪,可从入口点开始按顺序执行程序指令 。

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

在Hello程序执行过程中,若访问的虚拟地址对应的页面不在物理内存,便会触发缺页故障,此时内核调用缺页处理程序介入处理。​

处理程序首先验证虚拟地址的合法性,若该地址超出程序地址空间范围或不被系统认可,将触发段错误,直接终止Hello进程。

接着,处理程序检查Hello进程对目标页面的访问权限,若进程试图读取、写入或执行无权访问的页面,会触发保护异常,同样导致程序终止。

当上述两步检查均通过,内核会从现有页面中挑选一个 ,内核先将其数据写回磁盘,再从磁盘调入所需页面,并更新页表。

最后,内核将控制权交还给Hello进程,使程序重新执行触发缺页故障的指令,让Hello程序得以继续运行。

7.9动态存储分配管理

对于Hello程序,动态存储分配管理用于在程序运行时按需分配和回收内存。常见方式有堆分配,Hello程序运行期间,若需要额外内存,可通过调用相关函数(如malloc)从堆空间申请内存。

操作系统会维护空闲内存块链表,根据程序请求大小,采用首次适应、最佳适应等算法,从链表中找到合适的空闲块分配给Hello程序。当程序不再使用这些内存时,通过free函数释放,操作系统将其重新加入空闲链表,以便后续再次分配,确保内存资源的高效利用。​

7.10本章小结

本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core i7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射、hello进程、execve时的内存映射、缺页故障与缺页中断处理。

结论

hello所经历的过程:

1.预处理(cpp)。将hello.c进行预处理,将文件调用的所有外部库文件合并展开,生成一个hello.i文件,所有预处理指令已被处理,仅剩纯C代码和插入的外部库声明。

2、编译(ccl)。将hello.i文件翻译成为一个包含汇编语言的文件hello.s,实现:高级语言到汇编语言的语义转换

3、汇编(as)。将hello.s翻译成为一个可重定位目标文件hello.o,实现符号化汇编到机器码的线性转换

4、链接(ld)。将hello.o文件和可重定位目标文件和动态链接库链接起来,生成一个可执行目标文件hello。

5、运行

在 shell 中输入./hello 2023112613 李晗婧 13898186009 4 ,shell 会对输入内容进行解析,识别出要执行的可执行文件./hello以及对应的命令行参数,同时会依据环境变量PATH来确认执行权限。

6、创建进程

shell判断输入指令并非内置指令后,调用 fork 函数。内核为新进程分配进程控制块,复制父进程的资源,新创建的子进程会获得独一无二的进程标识符(PID),此后父子进程依据 fork 函数的返回值来区分各自的执行路径。

7、加载程序

shell 调用 execve 函数,它会先清空子进程原有的地址空间。接着根据可执行文件(hello )的 ELF 格式文件头信息,映射虚拟内存,并创建堆和栈区域。同时设置程序入口点,加载像 libc.so 这样的动态链接库,通过动态链接器(ld - linux.so)来解析符号引用。

8、执行指令

进程等待 CPU 调度,内核运用时间片轮转算法为进程分配 CPU 时间,在进行上下文切换时会保存和恢复寄存器状态。在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。

9、访问内存

CPU 借助内存管理单元(MMU)处理虚拟地址,通过查询页表来完成虚拟地址到物理地址的转换,查询过程涉及页目录、页表等。若出现缺页异常,即所需页面不在物理内存中,就会从磁盘交换区将页面加载到物理内存,并更新快表(TLB)缓存。

10、信号管理

当程序运行时,按下 Ctrl + C,会触发键盘中断,发送 SIGINT 信号,内核在进程从内核态转回用户态时检查信号队列,按默认处理方式,该信号会终止进程;当按下 Ctrl + Z ,会产生 SIGTSTP 信号,内核检查信号队列,按默认处理将前台作业暂停。用户也可通过 signal () 或 sigaction () 函数来自定义信号处理函数。

11、终止

当子进程执行完毕,main 函数的返回值通过 exit_group () 系统调用传递出来。子进程进入僵尸状态,父进程调用 wait () 或 waitpid () 获取子进程的退出状态,内核删除为这个进程创建的所有数据结构。

感悟:

通过本次实验,我深刻领略到计算机系统的精密与强大 —— 每一个看似简单的任务背后,都需要计算机通过无数复杂操作协同完成。这一过程不仅展现出严谨的逻辑架构,更彰显了现代工艺对细节的极致雕琢,让人不得不为计算机系统设计的精巧性与科学性所折服。

参考文献

[1]  林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.

[2]  辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.

[3]  赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).

[4]  谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.

[5]  KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.

[6]  CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/lee_0923/article/details/148213975

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--