计算机科学与技术学院
2024年5月
本文系统分析了HELLO程序从源代码到执行的完整生命周期,揭示了编译系统与操作系统协同工作的底层机制。研究以Ubuntu环境和GCC工具链为基础,覆盖预处理、编译、汇编、链接四大阶段:预处理阶段展开宏与头文件,生成纯净的中间代码;编译阶段将C代码转换为平台相关的汇编指令;汇编阶段进一步生成机器可读的二进制目标文件;链接阶段则通过合并库函数与解析符号引用,最终形成可执行程序,并深入探讨了ELF文件格式与虚拟地址空间的内存布局。
程序执行阶段涉及操作系统的核心管理机制:进程管理通过函数加载HELLO,由调度器分配CPU资源;存储管理实现虚拟内存映射与动态库加载,保障高效安全的内存访问;I/O管理则处理printf等函数的内核态切换与缓冲区优化。全文通过串联代码转换、系统调用和硬件交互,完整呈现了“按键到屏幕输出”背后的复杂链路,为理解程序底层运行提供了清晰框架。
关键词:HELLO程序,编译,汇编,链接,进程管理,存储管理,IO管理
目 录
第1章 概述............................................................................................................. - 4 -
1.1 Hello简介...................................................................................................... - 4 -
1.2 环境与工具..................................................................................................... - 4 -
1.3 中间结果......................................................................................................... - 4 -
1.4 本章小结......................................................................................................... - 4 -
第2章 预处理......................................................................................................... - 5 -
2.1 预处理的概念与作用..................................................................................... - 5 -
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
2.3 Hello的预处理结果解析.............................................................................. - 5 -
2.4 本章小结......................................................................................................... - 5 -
第3章 编译............................................................................................................. - 6 -
3.1 编译的概念与作用......................................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
3.3 Hello的编译结果解析.................................................................................. - 6 -
3.4 本章小结......................................................................................................... - 6 -
第4章 汇编............................................................................................................. - 7 -
4.1 汇编的概念与作用......................................................................................... - 7 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
4.3 可重定位目标elf格式................................................................................. - 7 -
4.4 Hello.o的结果解析...................................................................................... - 7 -
4.5 本章小结......................................................................................................... - 7 -
第5章 链接............................................................................................................. - 8 -
5.1 链接的概念与作用......................................................................................... - 8 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
5.4 hello的虚拟地址空间.................................................................................. - 8 -
5.5 链接的重定位过程分析................................................................................. - 8 -
5.6 hello的执行流程.......................................................................................... - 8 -
5.7 Hello的动态链接分析.................................................................................. - 8 -
5.8 本章小结......................................................................................................... - 9 -
第6章 hello进程管理................................................................................... - 10 -
6.1 进程的概念与作用....................................................................................... - 10 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.4 Hello的execve过程................................................................................. - 10 -
6.5 Hello的进程执行........................................................................................ - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
6.7本章小结....................................................................................................... - 10 -
第7章 hello的存储管理................................................................................ - 11 -
7.1 hello的存储器地址空间............................................................................ - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理....................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 11 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 11 -
7.6 hello进程fork时的内存映射.................................................................. - 11 -
7.7 hello进程execve时的内存映射.............................................................. - 11 -
7.8 缺页故障与缺页中断处理........................................................................... - 11 -
7.9动态存储分配管理....................................................................................... - 11 -
7.10本章小结..................................................................................................... - 12 -
第8章 hello的IO管理................................................................................. - 13 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
8.3 printf的实现分析........................................................................................ - 13 -
8.4 getchar的实现分析.................................................................................... - 13 -
8.5本章小结....................................................................................................... - 13 -
结论......................................................................................................................... - 14 -
附件......................................................................................................................... - 15 -
参考文献................................................................................................................. - 16 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
当源代码被转化为可执行的目标文件时,要经过一系列关键的处理步骤,主要包括以下四个阶段:预处理(Preprocessing)、编译(Compilation)、汇编(Assembly) 和 链接(Linking)。下面是每个步骤的详细说明:
1. 预处理(Preprocessing)
目的:处理以 # 开头的指令(称为预处理指令)。
常见操作:
展开头文件(#include)
宏定义替换(#define)
条件编译(#ifdef、#ifndef 等)
输出:一个扩展后的纯文本文件(通常仍是 .c 文件,但无预处理指令)
举例:#define MAX 100 会被替换为 100。
2. 编译(Compilation)
目的:将预处理后的源码翻译成汇编代码。
过程:
语法分析与语义检查
中间代码生成与优化
生成目标平台的汇编语言
输出:汇编代码文件(通常为 .s 文件)
例如,int x = 3 + 5; 会被翻译为相应的汇编指令。
3. 汇编(Assembly)
目的:将汇编代码转换成机器指令(二进制形式)。
工具:汇编器(如 GNU 的 as)
输出:目标文件(.o 或 .obj),包含机器码和符号表,但尚未完成连接。
4. 链接(Linking)
目的:将多个目标文件(包括系统库或第三方库)整合为一个完整的可执行文件。
任务:
符号解析(解决函数或变量的引用)
地址重定位
生成最终的可执行文件(如 .exe、无扩展的 ELF 文件等)
工具:链接器(如 GNU 的 ld 或 gcc 自带的)
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
Vs code dell服务器
Windows10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 24.04 LTS 64位/优麒麟 64位 以上;
13th Gen Intel(R) Core(TM) i7-13700H With Xe Graphics
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名称 | 文件含义 |
Hello.c | 源文件 |
Hello.i | 预处理文件 |
Hello.s | 汇编文件 |
Hello.o | 可重定位文件 |
Hello | 目标文件 |
Hello.elf | 可重定位目标文件的elf文件 |
Hello2.elf | 目标文件的elf文件 |
1.4 本章小结
本章介绍了hello的P2P 020的过程,概括了实验的硬件环境和软件环境,以及提供了实验过程用到的中间文件。
第2章 预处理
2.1 预处理的概念与作用
预处理是程序开发中的一个重要阶段,主要在程序编译之前进行。它涉及到一系列的操作,如宏替换、特殊符号处理、条件编译等,旨在为编译器提供一个清晰、准确的代码版本,以便于编译器进行后续的编译过程。
预处理是将源代码在正式编译前进行的一种文本替换处理,由预处理器完成。它不涉及语法分析,仅对代码中的预处理指令(以 # 开头)进行处理,输出一个纯文本形式的中间代码文件。
预处理的主要作用包括:
头文件展开:将 #include 指令包含的文件内容直接插入源代码,实现代码复用与模块化。
宏替换:将定义的宏(如 #define PI 3.14)在代码中展开,简化代码书写。
条件编译:通过 #ifdef、#ifndef 等控制代码是否编译,常用于跨平台或调试功能控制。
预处理增强了代码的灵活性、可读性和可维护性,是源代码构建过程中不可或缺的第一步。
2.2在Ubuntu下预处理的命令
Cpp hello.c >hello.i,如下
2.3 Hello的预处理结果解析
这是我们的hello.c
这是我们的hello.i的一部分
可见Hello.i从三千多行开始,后面才是hello主程序的代码。前面总的大概三千多行均为预处理插入的宏定义等。Cpp执行预处理指令时,从系统中寻找头文件stdio.h,将hello.c中的预处理语句替换为stdio.h中的语句。对于define预处理,则检查程序中每一次出现的位置,做宏定义替换。
2.4 本章小结
本章简要介绍预处理基本概念和实际流程,以hello.c的预处理和hello.i的内容展示为例进行阐述。
第3章 编译
3.1 编译的概念与作用
编译是将预处理后的高级语言代码(如 C/C++)转换为汇编语言代码的过程,由编译器完成。它涉及语法和语义分析,并生成与目标平台兼容的中间指令或汇编指令。
编译的主要作用包括:
语法与语义检查:检测代码中的语法错误和类型不匹配,保障代码逻辑的正确性。
中间代码生成与优化:将代码翻译成更接近底层的表示形式,同时进行优化,提高运行效率。
生成汇编代码:输出对应平台的汇编语言文件,为后续汇编做准备。
编译阶段是源代码转化为机器可执行形式的重要桥梁,决定了程序的结构与执行效率。
3.2 在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S hello.c -o hello.s
3.3 Hello的编译结果解析
文件结构可分为若干部分
部分 | 含义 |
.file | 文件内容 |
.text | 代码段 |
.global | 全局变量 |
.section.rodata | 只读变量 |
.type | 数据类型 |
.string | string类型 |
3.3.1 常量
在汇编结果中,常量是指在程序运行期间其值不可更改的固定数据。这些常量通常在汇编代码中直接体现为立即数(immediate value),并以具体数值形式嵌入到指令或数据段中。
3.3.2 局部变量
通常保存在栈中或者是寄存器之中
3.3.3 赋值
使用mov指令对其赋值
把1赋值给edi寄存器
3.3.4算数操作
于加法操作而言,通过每一次对(%rbp-4)进行加一操作,判断与5的关系并决定是否进入for循环
3.3.5关系操作
同上,与立即数进行比较
3.3.6 数组 指针 结构操作
在栈上获取连续的数据进行处理,即获得argv中的成员
3.3.7控制转移
与判断结果进行比较,判断是否跳转,通过jump可以加以实现
3.3.8 函数
根据参数然后调用函数
3.4 本章小结
编译是将预处理后的源代码翻译为汇编代码的过程,完成语法检查和逻辑转换。它生成的 .s 文件清晰展现了变量、常量和控制结构的底层实现。通过分析 hello.s我们能更直观地理解高级语言到底层指令的映射关系。
第4章 汇编
4.1 汇编的概念与作用
汇编是将汇编语言代码(如 .s 文件)翻译成机器指令(二进制格式)的过程,由汇编器(如 as)完成。它将人类可读的指令转换为处理器可执行的目标代码。
汇编的主要作用包括:
指令翻译:将汇编指令逐一转化为对应的机器码,构成程序的执行核心。
生成目标文件:输出 .o 或 .obj 文件,包含机器指令、符号表和段信息,为链接做准备。
保持结构清晰:各段(如 .text、.data、.bss)在目标文件中被明确区分,便于后续链接与调试。
汇编是源程序走向底层机器执行的关键一步,它实现了从“可读”到“可运行”的质变。
4.2 在Ubuntu下汇编的命令
-m64 -no-pie -fno-PIC -c hello.c -o hello.o
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
elf文件
开头四个字节是elf magic魔数,分别对应ascii码中的del控制符、字符E、字符L、字符F。魔数用来确认文件类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确拒绝加载。
第五个字节表示elf文件类型,0x1表示32位,0x2表示64位。
第六个字节表示字节序,0x1表示小端法,0x2表示大端法
第七个字节表示版本号,通常是1。
剩下的没有定义,用0填充。
Type: 可重定位文件(还有可执行文件、共享文件)
Start of section headers: section header在elf文件中的起始地址是1184
Size of this header: elf header大小:64字节(section在elf文件中的起始位置是0x40)
Size of section headers: 每个表项大小是64个字节
Number of section heasers:一共包含14个表项
Section header offset表示每个sect的起始位置,size表示大小
Sections
.text section 存放已经编译好的机器代码
.data section存放已初始化的全局变量和静态变量的值
.bss section(better save space)存放未初始化的全局变量和静态变量(被初始化为0的全局变量和静态变量也存放在bss中
bss section并不占据实际的空间,仅仅是一个占位符,区分已初始化和未初始化的变量是为了节省空间。当程序运行时,会在内存中分配这些变量,并把初始值设为0
.rodata section(read only)存放只读数据,例如printf语句中的字符串和switch语句中的跳转表
符号表:
1. num序号
2. value表示函数相对于.text section起始位置的偏移量(16进制)
3. size表示所占字节数
4. type数据类型
a. object是数据对象,例如变量和数组在符号表的类型都为object
b. func函数
5. bind:global全局符号,local局部符号
6. vis在c语言中并未使用,可以忽略
7. ndx(index)索引值
a. 索引值可以通过查看header来获得
b. printf虽然也是函数,但只是被引用,ndx是undifine类型
c. common和bss的区别:common仅用来存放未初始化的全局变量,.bss用来存放未初始化的静态变量以及初始化为0的全局或者静态变量
- name名称
4.4 Hello.o的结果解析
使用 objdump -d -r hello.o 生成的反汇编输出,展示了:
.text 段中指令的机器码(以十六进制形式显示)
每条机器指令对应的汇编语义(如 mov, call, jmp)
某些指令的重定位信息(来自 -r,例如函数或跳转目标)
1. 跳转指令(如 jmp, je, jne)
汇编语言:直接写为 jmp label 或 je .L1,人类可读。
机器语言:使用相对偏移编码。例如 eb fe 表示跳转到当前地址的前一个指令(形成死循环),偏移量以补码方式存储。
重定位提示:目标是 label 时,编译器不知道其最终位置,需在链接阶段修正,所以 objdump -r 会列出重定位项。
2. 函数调用指令(如 call)
汇编语言:如 call printf
机器语言:使用 e8 XX XX XX XX,表示跳转到某地址,地址以相对偏移形式编码。
重定位:若调用外部函数(如 libc 中的 printf),目标地址不在当前文件中,反汇编中会出现 R_X86_64_PC32 printf 重定位项。
3. 数制表示(立即数与地址)
汇编中立即数通常以十进制书写(如 mov $1, %eax),但机器码中以 补码的十六进制形式 存储。
地址类数据(如字符串、函数入口)在汇编中用符号表示,在机器语言中则需通过重定位将符号替换成最终地址。
大端/小端序:x86 系统使用小端序,常见机器码 b8 01 00 00 00 实际是 mov $1, %eax
4.5 本章小结
本章围绕从源代码到可执行文件的转换过程,依次介绍了预处理、编译、汇编和链接四个关键步骤,帮助我们理解程序是如何逐步靠近硬件的。在分析 hello.s
和 hello.o
的过程中,我们掌握了汇编语言与机器语言之间的映射关系,特别是在跳转指令、函数调用和数值表示方面的区别。通过对反汇编结果的解析,我们不仅理解了底层指令的构成,也进一步认识了链接器在符号解析与重定位中的核心作用,为深入理解程序执行打下了坚实基础。
第5章 链接
5.1 链接的概念与作用
链接是将一个或多个目标文件(如 hello.o)和库文件组合为一个完整可执行程序(如 hello)的过程,通常由链接器(如 ld,或通过 gcc hello.o -o hello 间接调用)完成。
在将 hello.o 链接为 hello 的过程中,链接器主要完成以下几项任务:
符号解析:识别和匹配程序中使用的外部符号(如 printf、_start),并从标准库中找到其定义,解决函数调用的目标地址。
地址重定位:将各个目标文件中的代码与数据重新安置到最终程序中的合适位置,修正跳转和调用中的相对/绝对地址。
节合并与布局:把所有 .text(代码段)、.data(已初始化数据段)、.bss(未初始化数据段)等合并为统一的内存映像,生成最终的 ELF 或可执行格式。
举例说明:hello.o 中的 call printf 指令在编译时并不知道 printf 的地址,仅留下重定位信息。链接阶段会将 printf 的地址从 libc 中导入,并填入对应位置,生成完整可执行程序。
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
命令:readelf hello -a > hello2.elf
开头四个字节是elf magic魔数,分别对应ascii码中的del控制符、字符E、字符L、字符F。魔数用来确认文件类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确拒绝加载。
第五个字节表示elf文件类型,0x1表示32位,0x2表示64位。
第六个字节表示字节序,0x1表示小端法,0x2表示大端法
第七个字节表示版本号,通常是1。
剩下的没有定义,用0填充。
Type: 可重定位文件(还有可执行文件、共享文件)
Start of section headers: section header在elf文件中的起始地址是1184
Size of this header: elf header大小:64字节(section在elf文件中的起始位置是0x40)
Size of section headers: 每个表项大小是64个字节
Number of section heasers:一共包含14个表项
Section header offset表示每个sect的起始位置,size表示大小
Sections
.text section 存放已经编译好的机器代码
.data section存放已初始化的全局变量和静态变量的值
.bss section(better save space)存放未初始化的全局变量和静态变量(被初始化为0的全局变量和静态变量也存放在bss中
bss section并不占据实际的空间,仅仅是一个占位符,区分已初始化和未初始化的变量是为了节省空间。当程序运行时,会在内存中分配这些变量,并把初始值设为0
.rodata section(read only)存放只读数据,例如printf语句中的字符串和switch语句中的跳转表
符号表:
1. num序号
2. value表示函数相对于.text section起始位置的偏移量(16进制)
3. size表示所占字节数
4. type数据类型
a. object是数据对象,例如变量和数组在符号表的类型都为object
b. func函数
5. bind:global全局符号,local局部符号
6. vis在c语言中并未使用,可以忽略
7. ndx(index)索引值
a. 索引值可以通过查看header来获得
b. printf虽然也是函数,但只是被引用,ndx是undifine类型
c. common和bss的区别:common仅用来存放未初始化的全局变量,.bss用来存放未初始化的静态变量以及初始化为0的全局或者静态变量
- name名称
动态链接表
Program headers
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
分析
可执行文件 hello 是一个 ELF 格式文件,它将各节区(Section)组织为段(Segment),如 .text, .data 等
链接器在构建 hello 时,按段信息生成运行时的虚拟地址布局。
EDB 展示的进程虚拟地址空间正是链接器所构造的 ELF 文件在内存中的映射结果。
某些段如 .bss 虽不占磁盘空间,但在运行时会映射成真实内存区域,初值为 0。
动态链接库(如 libc)在运行时由加载器(ld.so)映射到地址空间,表现为额外的 r-x/r-- 区段。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
1.为指令分配虚拟地址
在hello.o的反汇编程序中,函数中的语句前面的地址都是从函数开始从依次递增的,而不是虚拟地址;而经过链接后,在右侧的返回变种每一条指令都被分配了虚拟地址。
- 函数
如图5所示,在左侧helllo.o的反汇编程序中,图中选中的函数调用指令由于还没有分配虚拟地址,所以只能用偏移量跳转,而右侧链接后已经分配好了虚拟地址,可以直接用call虚拟地址进行跳转.
- 跳转 链接后可以采用虚拟地址跳转
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
阶段 函数 / 地址 作用
程序入口 _start(在 crt1.o) 设置环境,调用 libc 初始化
初始化运行环境 __libc_start_main 设置参数、调用 main、注册清理
用户逻辑 main 用户自定义功能入口
程序退出 exit → _exit 清理资源、返回系统
5.7 Hello的动态链接分析
(以下格式自行编排,编辑时删除)
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
当使用 GCC 默认方式编译 hello.c 时,得到的 hello 是一个 动态链接的 ELF 文件,它依赖于如 libc.so 提供的 printf、exit 等函数。
动态链接相关的关键结构:
项目名 描述
.plt(Procedure Linkage Table) 延迟绑定函数的跳转入口(用于调用外部函数,如 printf)
.got(Global Offset Table) 存储外部函数的真实地址(由动态链接器填充)
.rel.plt 或 .rela.plt 存储待修正的函数地址位置(重定位信息)
.dynsym / .dynstr 存储动态符号表与字符串表
- 在 .plt 中对 printf 的调用:
实际跳转到 .plt 的入口:首次进入会跳到动态链接器 stub(通常是 _dl_runtime_resolve);动态链接器会查找真实地址并写入 .got。此时 .got 中对应 printf 的地址尚未填充。
2. 2. 动态链接后(首次调用 printf 后)
.got 表中 printf 的地址被 动态链接器填入真实地址(libc 中);此后 call printf@plt 会直接跳转到 libc 中的 printf 实现,不再经过链接器。
5.8 本章小结
本章介绍了链接的基本概念,通过对目标文件、可重定位目标文件、反汇编文件的内容对比,发现其异同,并推断出链接过程的具体实现。
第6章 hello进程管理
6.1 进程的概念与作用
进程(Process) 是操作系统中资源分配和调度的基本单位。它是一个正在执行的程序实例,包含代码、数据、打开的文件、寄存器状态、堆栈等信息。
进程的主要作用:
资源分配的最小单位
操作系统为每个进程分配独立的内存空间、文件描述符、CPU 时间等系统资源。
实现程序的并发执行
操作系统通过进程调度机制,使多个程序可以“同时”运行,提高系统效率。
保护和隔离
每个进程的地址空间彼此独立,避免互相干扰,增强系统稳定性和安全性。
作为用户与操作系统的接口
用户通过进程来执行程序,操作系统通过控制进程管理程序的生命周期。
简单来说,进程是程序的“运行版”,它让静态的代码变得动态、有状态,并能安全、高效地运行在多任务系统中
6.2 简述壳Shell-bash的作用与处理流程
Shell(如 bash) 是用户与操作系统之间的接口。它是一个命令行解释器,接收用户输入命令,将其解释并交给内核执行。
Shell 的作用:
接收用户输入(如:./hello);
解析命令行,寻找可执行文件路径;
创建子进程来执行命令;
等待子进程执行结束,返回控制权。
bash 处理流程如下:
用户输入 ./hello
↓
bash 解析命令 → 查找可执行文件(PATH 路径)
↓
调用 fork() → 创建子进程
↓
子进程调用 execve() → 用 hello 程序替换子进程
↓
父进程(bash)等待子进程结束(wait)
6.3 Hello的fork进程创建过程
当用户输入 ./hello 时,bash 会通过 fork() 系统调用来创建一个子进程。
fork 的作用:
创建一个与当前进程几乎完全相同的新进程;
子进程拥有独立的虚拟地址空间;
返回两次:一次在父进程返回子进程 PID,一次在子进程返回 0。
示例如下:
pid_t pid = fork();
if (pid == 0) {
// 子进程
execve("./hello", ...);
} else {
// 父进程(bash)
wait(&status);
}
6.4 Hello的execve过程
execve 是一个系统调用,用于用另一个程序替换当前进程映像。
在 hello 程序中:
bash 的子进程通过 execve("./hello", ...) 加载并执行 ELF 文件;
原来的 bash 子进程代码、数据段会被 hello 程序的代码和数据替换;
进程 PID 不变,但映像变成了 hello。
行为总结:
fork() 创建子进程
↓
execve("./hello") → 替换映像 → 加载 ELF → 执行 _start
6.5 Hello的进程执行
从 execve 开始到程序终止,是 hello 程序的完整运行期。
execve → 加载 hello(ELF 文件)
↓
入口点 _start(由crt1.o提供)
↓
调用 __libc_start_main(main)
↓
执行用户编写的 main()
↓
main 返回 → exit() 终止进程
↓
控制权返回给父进程(bash)
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
在Hello程序的执行过程中,可能会遇到各种类型的异常和信号。下面将详细讨论这些异常和信号。
1. 异常类型
运行时异常:如除以零、空指针引用等,这类异常会导致程序崩溃。
资源异常:如文件未找到、内存不足等,这类异常通常需要程序进行适当的错误处理。
输入异常:用户输入了不符合程序要求的数据。
2. 产生的信号
SIGINT:当用户按下Ctrl+C时发送,通常用于中断程序。
SIGTSTP:当用户按下Ctrl+Z时发送,用于暂停程序。
SIGTERM:请求程序终止的正常信号。
-
- 乱按
3.2复制
3.3粘贴
3.4 ps
3.5jobs
3.6pestree
3.7fg
- 示例
在程序执行过程中,异常输入与信号中断(如Ctrl-C终止、Ctrl-Z挂起)会直接影响HELLO进程的生命周期。操作系统通过信号机制(如SIGINT、SIGTSTP)将用户输入转化为进程控制指令:挂起的进程可通过fg恢复前台运行,或通过ps/kill管理后台状态;未处理的信号可能导致程序意外终止。这种交互揭示了用户态与内核态的协作——从键盘驱动捕获输入到信号处理例程的触发,最终由进程管理模块响应。
健壮性设计的关键在于主动拦截和处理信号(如忽略SIGINT或清理资源后退出),同时结合系统工具(如strace跟踪系统调用、gdb调试信号事件)诊断问题。信号机制本质是操作系统对突发事件的抽象,其管理能力直接体现了程序对执行环境不确定性的适应水平,与进程调度、内存安全共同构成可靠软件的底层支柱。
6.7 本章小结
简要介绍进程的基本概念,介绍fork函数和execve函数。介绍信号处理和异常处理的基本知识,在程序上对多种键盘输入进行测试。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1. 逻辑地址(Logical Address)
定义:程序代码在编译后生成的地址,表现为汇编指令中的操作数或跳转目标(如 mov eax, [0x8048000])。
特点:
是 相对地址,通常基于段寄存器(如 CS、DS)的偏移量。
需通过 段式内存管理 转换为线性地址(在x86架构中)。
2. 线性地址(Linear Address,即虚拟地址)
定义:由逻辑地址经段机制转换后得到的地址,是 CPU 视角的连续地址空间。
特点:
32位系统范围:0x00000000 ~ 0xFFFFFFFF(4GB)。
需通过 页式内存管理(MMU+页表)转换为物理地址
作用:
为每个进程提供独立的虚拟内存空间,实现进程隔离。
支持内存保护(如只读代码段)和共享(如动态库)。
3. 物理地址(Physical Address)
定义:实际用于访问内存芯片的地址,由 CPU 地址总线 传输。
转换过程:
逻辑地址 →(段机制)→ 线性地址 →(页机制)→ 物理地址
关键点:
操作系统通过 页表(Page Table) 管理虚拟地址到物理地址的映射。
多个进程的线性地址可能映射到同一物理地址(共享内存)。
地址转换流程(以x86为例)
逻辑地址 → 线性地址
CPU 根据段选择子(如 CS)从 段描述符表(GDT/LDT) 中获取段基址
段基址 + 逻辑偏移量 = 线性地址。
线性地址 → 物理地址
MMU 通过 多级页表 查询物理页框号(PFN)。
页框号 + 页内偏移 = 物理地址。
若页表项不存在,触发 缺页异常(Page Fault),由操作系统处理。
操作系统的作用
内存管理 维护进程页表,实现虚拟内存(如交换空间 Swap)。处理缺页异常,动态加载数据到物理内存。
进程隔离 每个进程拥有独立的虚拟地址空间,互不干扰。
硬件抽象 对应用程序隐藏物理地址细节,仅暴露线性地址。
总结
逻辑地址:编译生成的程序内部地址,需段机制转换。
线性地址:进程视角的连续虚拟空间,需页机制转换。
物理地址:硬件实际使用的内存地址。
核心价值:
虚拟内存机制使得程序无需关心物理内存布局。地址转换是操作系统内存安全与多任务并发的基石。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel x86架构采用段式内存管理技术,将程序划分为多个逻辑段(如代码段、数据段等),每个段作为独立的逻辑实体。逻辑地址由段选择符和段内偏移地址组成,转换过程首先通过段选择符的TI位确定查询全局描述符表(GDT)还是局部描述符表(LDT),再利用前13位索引定位具体的段描述符,获取段基址后与偏移量相加即得到线性地址。
这种机制通过段描述符中定义的访问权限和界限属性,为操作系统提供了内存隔离和保护能力。每个段可设置不同的特权级和访问限制,既保障了系统安全,又支持程序访问完整的4GB地址空间。现代操作系统虽多采用平坦内存模式简化段管理,但该技术仍是x86架构内存保护和多任务支持的底层基础。
7.3 Hello的线性地址到物理地址的变换-页式管理
在现代操作系统中,Hello 程序的线性地址到物理地址的变换是通过页式管理机制来实现的。页式管理是一种将物理内存划分为固定大小的页框(page frame),并将程序使用的线性地址空间划分为相同大小的页面(page)的内存管理方式。这种机制实现了逻辑地址与物理地址之间的灵活映射。
地址转换的第一步是从CR3 控制寄存器获取当前活动页目录的物理基地址。CR3 中存储的内容指向页目录的起始位置,整个地址转换过程由此展开。
接下来,处理器取线性地址的高 10 位,将其作为页目录的索引。通过索引,查找到对应的页目录项(Page Directory Entry),该项包含目标页表的物理地址和相关控制信息。
随后,处理器继续使用线性地址的中间 10 位作为索引,访问所选页表中的对应页表项(Page Table Entry)。页表项中存储了目标页面的起始物理地址,同时还包含该页面的属性信息,例如是否可写、是否在内存中等。
最后,处理器将线性地址的低 12 位作为页内偏移,并与页表项中提供的物理页框基地址相加,得到最终的物理地址,完成地址转换。
通过页式管理,操作系统可以将程序的线性地址空间灵活地映射到不连续的物理内存区域,提高了内存的利用率和系统的调度能力。此外,页表还为每一页提供独立的访问权限,实现了内存隔离和保护机制,有效防止了非法访问或数据破坏的发生。
7.4 TLB与四级页表支持下的VA到PA的变换
在现代操作系统的内存管理中,TLB(Translation Lookaside Buffer,快表)是一种关键的硬件缓存机制,用于缓存最近访问的页表条目。它能显著减少从虚拟地址(VA)到物理地址(PA)转换时访问多级页表所带来的性能开销,特别是在支持四级页表结构的系统中。
当处理器需要访问一个虚拟地址时,会首先查询 TLB,判断是否已有对应的页表条目被缓存。如果命中,处理器便可直接得到物理地址,从而绕过对页表的访问,极大提高了地址转换效率。
如果 TLB 未命中,地址转换流程会依次访问以下四级页表结构:
首先,处理器使用虚拟地址的高位索引到页全局目录(PGD),该目录项指出下一级页上级目录(PUD)的物理地址;接着,利用虚拟地址的次高位访问 PUD,定位到页中间目录(PMD)。
随后,处理器继续使用中间位访问 PMD,找到最终的页表(Page Table, PT)基地址。最后,根据虚拟地址的较低位在 PT 中查找具体的页框(Page Frame),获取实际的物理页面起始地址。
在上述每一级访问中,处理器都可尝试在 TLB 中查找是否已缓存相应的转换结果。若在任意一级命中,后续转换过程便可省略,直接完成虚拟地址到物理地址的映射。
完成页表查找后,处理器将虚拟地址的最低若干位作为页内偏移,与页框地址相加,形成最终的物理地址(PA)。
通过四级页表结构,操作系统可以将虚拟地址空间划分得更为精细,支持更大的地址范围,并增强对内存访问权限和页面属性的控制。而 TLB 的引入则显著优化了多级页表访问的性能,确保系统既能高效运行,又具备良好的灵活性与安全性。
7.5 三级Cache支持下的物理内存访问
在现代处理器中,CPU访问数据的路径如下:
L1 Cache(一级缓存):
是最快速、最接近CPU的缓存(通常分为指令缓存和数据缓存)。
容量较小(一般为32KB-128KB)。
当CPU请求某个数据时,首先查找L1 Cache。
如果命中(Cache Hit),数据立即返回,延迟极低。
L2 Cache(二级缓存):
容量比L1大,速度略慢(如256KB-1MB)。
如果L1 Cache未命中,则继续在L2 Cache中查找。
命中则返回,仍比访问内存快得多。
L3 Cache(三级缓存):
通常为多核共享缓存,容量更大(几MB到几十MB),速度比L1和L2慢,但远快于主内存。
L1和L2都未命中时,会访问L3 Cache。
主内存(DRAM)
若三级缓存都未命中,处理器只能访问主内存。
此时访问延迟最大(约几十到上百纳秒),可能影响性能。
总结:
在三级Cache支持下,物理内存访问遵循局部性原理,即:
空间局部性:程序更可能访问与当前数据相邻的地址;
时间局部性:程序更可能重复访问最近使用的数据。
通过多级缓存,处理器可以显著减少对主内存的访问次数,从而提高整体运行效率。
7.6 hello进程fork时的内存映射
在操作系统中,当一个进程调用 fork() 系统调用时,会创建一个新的子进程,该子进程几乎完全复制了父进程的执行环境。最关键的一点是,子进程会继承父进程的内存映射结构,包括代码段、数据段、堆和栈等。
首先,在代码段方面,子进程会获得父进程的代码副本。这是为了确保子进程可以从与父进程相同的程序入口开始执行,拥有完整的执行逻辑。由于代码段通常是只读的,因此父子进程可以共享相同的物理页面,直到发生写入操作(写时复制机制)。
其次,在数据段中,包括全局变量和静态变量等内容,子进程也会得到复制。这使得它拥有与父进程当前状态一致的变量初值。然而,在执行过程中,子进程和父进程对这些数据的修改将彼此独立。
对于堆内存,子进程通常会复制父进程的堆区映射。堆用于动态内存分配,虽然复制的是相同的结构,但实际运行时,父子进程管理和使用的是各自独立的堆空间。
在栈空间方面,子进程也会继承父进程的栈内容,包括函数调用帧、局部变量和返回地址。这使得子进程在创建时处于与父进程几乎相同的执行上下文。
值得强调的是,尽管父子进程的内存映射在结构上相同,但它们拥有独立的虚拟地址空间。现代操作系统通过写时复制(Copy-On-Write)机制,使得在没有修改行为发生之前,父子进程共享物理内存,从而节省资源。一旦任一进程对共享页面执行写操作,操作系统会为其分配一个新的物理页面,以确保彼此数据的独立性。
此外,父子进程可以分别调用 exec() 系列函数,将自身的内存空间替换为新的可执行程序映像,实现程序的切换和调度。
7.7 hello进程execve时的内存映射
在操作系统中,execve() 是一种重要的系统调用,用于在当前进程中加载并执行一个新的程序。调用 execve() 后,原进程的内存映像将被彻底清除,整个虚拟地址空间将被新程序的内存映像所取代,从而实现程序的“重载”执行。
具体来说,execve() 调用后,进程的内存映射会发生如下变化:
首先,原进程的代码段会被新的程序代码替换。这段代码就是新程序的主函数和相关逻辑所在的位置,负责控制程序从入口点(如 _start 或 main)开始执行。
接着,原有的数据段也会被清空,取而代之的是新程序的数据段,包括它所定义的全局变量和静态变量。这些数据在加载时根据新程序的初始化状态进行配置,旧程序的数据则不再存在。
随后,进程的堆区(heap)也会被重新配置。新程序在运行过程中可以继续通过如 malloc()、calloc() 等函数动态申请内存,但这些内存区域完全独立于旧程序的堆结构。
对于栈区(stack),execve() 也会重建一个新的栈,用于存储新程序运行时的函数调用帧、局部变量和系统调用参数等。这确保了新程序从干净、独立的调用环境开始运行。
需要特别指出的是:execve() 并不会创建新的进程,而是在当前进程上下文中用新程序替换旧程序。这意味着进程的 PID(进程号)保持不变,但内存映像、代码逻辑、变量内容全部被覆盖,原程序的所有执行上下文都将丢失,原进程代码将无法再执行。
7.8 缺页故障与缺页中断处理
在虚拟内存管理机制中,缺页故障(Page Fault)是一种常见且关键的运行时事件。当一个进程访问某个尚未加载到主存(RAM)中的虚拟页面时,系统无法在物理内存中找到对应页面,便会触发缺页故障。缺页故障并不代表程序错误,而是操作系统正常处理虚拟地址与物理地址映射不一致的机制。
一旦发生缺页故障,处理器会引发一个缺页中断(Page Fault Exception),通知操作系统介入处理。缺页中断是分页机制中的核心组件,它通过中断机制实现对内存资源的动态调度和高效管理。
缺页中断的处理流程大致分为以下几个步骤:
判断是否允许中断处理:系统首先判断当前进程是否处于允许接收中断的状态,例如不能在某些关键内核区间或关闭中断时处理。这决定了是否立即处理,或将中断推迟到合适时机。
保存处理器上下文:为了保证进程执行的连贯性,系统会保存中断发生时的处理器上下文,包括程序计数器(PC)、通用寄存器、状态寄存器等,确保处理中断后能够准确恢复。
确定缺页源:操作系统通过中断号、页表异常码、中断地址等信息,定位发生缺页的虚拟地址、所属进程及其访问权限,从而判断缺页的性质(如读写权限异常、页面不存在、非法访问等)。
执行缺页处理逻辑:如果缺页合法(例如程序首次访问合法区域),系统将从磁盘或其他辅助存储中读取目标页面,加载到空闲物理页框中,并更新页表。如果内存不足,则可能采用页面置换算法(如LRU)将冷页面换出。
恢复上下文信息:完成缺页加载后,系统恢复中断前保存的处理器状态,确保进程从中断点准确继续执行。
结束中断返回用户态:最后操作系统执行中断返回指令,使控制权回归用户进程,继续执行引发缺页的那条指令。
总之,缺页中断机制是现代操作系统实现虚拟内存的重要保障。它允许程序按需加载页面,提高内存利用率,并通过页表、TLB 与页面置换策略,实现对大规模内存空间的高效管理。该机制使得进程在表面上拥有完整连续的地址空间,实际则是在后台动态映射物理页面,保证了系统性能与灵活性的统一。
7.9动态存储分配管理
在C语言中,动态内存管理是指在程序运行过程中根据需要分配和释放内存的一种机制。与静态内存分配在编译期间就确定不同,动态内存管理提供了灵活性,使程序能够应对运行时的不确定性。
1. 基本函数介绍
C语言提供了一组标准库函数来实现动态内存管理:
malloc(size):从堆上分配size字节的未初始化内存,成功时返回指针,否则返回NULL。
calloc(n, size):分配n个元素、每个size字节的内存,并初始化为零。
realloc(ptr, size):调整由malloc或calloc分配的内存块的大小。扩展时新区域未初始化,缩小时可能丢弃部分数据。
free(ptr):释放之前分配的内存块,释放后该指针不应再被使用。
这些函数是动态内存操作的基础,但其使用必须格外小心,以防止内存泄漏、使用已释放内存等问题。
2. 内存管理中的关键概念
内存碎片:频繁分配和释放不同大小的内存可能导致碎片问题,降低内存利用率。开发者可以通过合并释放操作或自定义分配器来缓解。
内存泄漏:忘记调用free()会导致分配的内存永远不能回收,占用资源。工具如valgrind可用于检测此类问题。
双重释放与悬垂指针:重复free()或使用已释放的指针是严重错误,可能引起程序崩溃或未定义行为。
3. 其他策略与注意事项
内存对齐:为提高访问效率,数据常需按特定边界对齐。编译器一般自动处理,也可通过指令调整结构体布局。
预分配缓冲区:对于内存使用频繁的应用,如图像处理或数据缓存,采用大块预分配减少系统调用开销,可提高性能。
brk/sbrk函数:较低层级的接口,用于控制堆的起始地址,但已较少直接使用,现代程序多依赖malloc系列。
4. 手动管理与垃圾回收的差异
C语言中没有内置的垃圾回收机制,开发者需自行追踪每一次分配与释放。这与Java、Python等语言中的自动垃圾回收机制形成鲜明对比,增加了程序设计的复杂度,也强调了内存管理能力对C语言开发者的重要性。
总结:动态内存管理是C语言程序设计的核心组成部分。它提供了运行时的灵活性,但也带来了更高的复杂度。通过熟练掌握malloc等函数,并合理安排内存释放策略,程序员可以编写出高效且稳定的程序。
7.10本章小结
本章简要介绍存储相关的知识。介绍不同地址概念以及他们之间的转换,讲述函数的存储映射,最后介绍缺页处理和动态内存分配,通过这些问题加深了理解
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
(以下格式自行编排,编辑时删除)
8.3 printf的实现分析
(以下格式自行编排,编辑时删除)
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
(以下格式自行编排,编辑时删除)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
1.源代码编写(C语言)
开发者编写 hello.c,源代码以 ASCII 文本形式保存在文件系统中。
2.预处理(Preprocessing)
编译器前端运行预处理器(如 cpp),将 #include 头文件展开、处理宏定义和条件编译,生成纯净的 C 源文件。
3.编译(Compilation)
编译器将预处理后的 C 代码翻译为汇编语言(如 x86-64 汇编),生成 .s 汇编文件。
4.汇编(Assembly)
汇编器(如 as)将汇编语言转换为机器指令(即目标文件 .o),包含重定位信息和符号表。
5.链接(Linking)
链接器(如 ld)将目标文件与标准库(如 libc)进行链接,解析符号引用,生成可执行文件 hello,格式为 ELF。
6.加载(Loading)
当执行 ./hello 时,内核通过 ELF 加载器将程序加载到虚拟地址空间中,进行段映射,设置初始栈、堆、环境变量等。
7.执行(Execution)
从 _start 进入程序执行流程,运行启动代码(crt0),准备运行时环境,调用 main() 函数。
8.系统调用(System Call)
printf 调用封装系统调用如 write,将字符串输出到标准输出(文件描述符 1),通过用户态到内核态的上下文切换。
9.进程调度(Scheduling)
内核通过调度器决定 hello 进程的运行时机和 CPU 分配,支持多任务并发执行。
10.结束与回收(Termination)
main() 返回后,调用 exit() 系统调用,内核清理进程资源,将退出码传递给父进程(如 shell)。
11.内存管理支持(Virtual Memory)
整个过程中,地址变换由页表完成,TLB 提升了映射效率,Cache 减少了物理内存访问延迟。
12文件系统支持(File System)
程序的加载、库文件查找、printf 输出都依赖底层文件系统(如 ext4)。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
1. 系统设计的本质是“分层抽象 + 明确接口”
计算机系统之所以庞大却有序,关键在于合理的层次化设计。从源代码到执行,每一层(编译器、操作系统、硬件)都有清晰的职责和接口。正是这种分层让我们可以在一个层面上创新,而不破坏整个系统。
2. 软硬协同是未来发展的关键
CPU 的微结构设计与操作系统调度策略必须协同优化,例如 Cache 亲和性、TLB 命中率、NUMA 优化等。如果能让编译器或运行时系统更智能地感知硬件结构,系统将变得更加高效。
3. 创新理念:自适应可重构运行时(Adaptive Reconfigurable Runtime)
提出一个方向:构建一种能够根据程序行为动态改变执行路径和调度策略的运行时。例如根据 IO 密集型/CPU 密集型自动切换线程模型,甚至在轻量任务中替代 syscall 为用户态实现以减少上下文切换。
4. 面向系统教育的创新:可视化系统沙盒(System Sandbox Visualizer)
为了让初学者直观理解系统全过程,可设计一个模拟平台,把预处理→编译→链接→加载→执行每一步以图形方式动态展示,点击任意元素可显示对应的汇编、二进制或内核行为,提升教学效果。
最后想说的是
程序是人生的隐喻:代码需要逻辑与调试,人生需要规划与修正,两者都在不断迭代中追求最优解。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名称 | 文件含义 |
Hello.c | 源文件 |
Hello.i | 预处理文件 |
Hello.s | 汇编文件 |
Hello.o | 可重定位文件 |
Hello | 目标文件 |
Hello.elf | 可重定位目标文件的elf文件 |
Hello2.elf | 目标文件的elf文件 |
具体内容见附件
参考文献
[1] 兰德尔 E.布莱恩特, 大卫 R.奥哈拉伦.深入理解计算机系统:a programmer's perspective[M].机械工业出版社.2016