计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2021111247
班 级 2103601
学 生 韩晨烨
指 导 教 师 郑贵滨
计算机科学与技术学院
2023年5月
"Hello World"是几乎所有程序员编写的第一个程序。本文通过详细分析hello.c从编写到运行终止的一生,主要内容包括预处理、编译、汇编、链接、在进程中运行的全过程,跟踪"Hello World"的整个生命周期,从代码编辑器开始到运行结束。再从进程管理、存储管理、IO管理几个方面分析了hello过程的各种情况。根据hello的一生向我们展现了程序的具体实现以及计算机在这过程中是如何工作的。
关键词:hello;编译;汇编;链接;进程;存储;IO;
目 录
4.4.3 机器代码与汇编语言的映射关系... - 22 -
4.4.4 hello.o反汇编与hello.s对比... - 22 -
5.4.2 edb中与hello各节的对应... - 30 -
5.5.2 hello与hello.o的不同... - 34 -
6.2 简述壳Shell-bash的作用与处理流程... - 38 -
6.2.1 壳Shell-bash的作用... - 38 -
6.2.2 壳Shell-bash的处理流程... - 38 -
6.3 Hello的fork进程创建过程... - 39 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 46 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 47 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 48 -
7.5 三级Cache支持下的物理内存访问... - 49 -
7.6 hello进程fork时的内存映射... - 49 -
7.7 hello进程execve时的内存映射... - 50 -
第1章 概述
1.1 Hello简介
图1-1 hello编译过程
1.1.1 P2P
"P2P"即"From Program to Process",意为从程序到进程的转变。"Hello"从源文件转化为目标文件是由编译器驱动程序完成的。在此过程中,它历经预处理、编译、汇编和链接等步骤才最终转化为可执行目标程序,并保存在磁盘中。之后在运行阶段,在shell中输入命令"./hello"后,操作系统(OS)的进程会调用fork函数来创建一个新的子进程,从而实现"Hello"从程序到进程的转变。这个过程称为P2P过程。
1.1.2 020
"020"即"From Zero-0 to Zero-0"。在shell中,通过调用fork函数创建子进程,再通过execve函数进行虚拟内存映射。随后分配物理内存,在代码段中执行程序,实现打印printf等操作。在此过程中,内存管理器和CPU在L1、L2、L3高速缓存和TLB多级页表中取数据以提升运行速度。程序运行完成后,shell会回收子进程并删除内核中有关的数据结构,并从系统中清除。这个过程最终实现了"020"。
1.2 环境与工具
- 硬件环境:11th Gen Intel(R) Core(TM) i5-11300H @ 3.10GHz 3.11 GHz
64 位操作系统, 基于 x64 的处理器
RAM:16.0 GB
- 软件环境:Windows 11 64位 Ubuntu22.04
- 开发工具:GDB,EDB,Visual Studio Code,Vim,gcc
1.3 中间结果
表1-1 中间结果文件的名字及作用
文件名称 | 文件作用 |
hello.c | hello的源程序文件 |
hello.i | hello.c预处理之后文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编之后的可重定位目标文件 |
hello | 链接之后的可执行目标文件 |
hello.elf | hello的ELF格式 |
hello_o.elf | hello.o的ELF格式 |
hello_o.asm | hello.o的反汇编代码 |
hello.asm | 对hello进行反汇编得到的文件 |
1.4 本章小结
本章主要简单介绍了"hello"程序的P2P和020过程,并列出了本次实验的环境和中间结果。同时,也大致概括了"Hello"程序从c程序"hello.c"到可执行目标文件"hello"的经过。
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念
预处理是指在程序编译之前对源代码进行诸如宏展开、条件编译、头文件包含等操作的过程。预处理器会在编译器对源代码进行词法分析和语法分析之前,将预处理指令处理成适合编译器处理的形式,生成编译所需要的中间代码文件。这一过程被称为预处理。
2.1.2 预处理的作用
- 增强代码的可读性:使用预处理指令可以通过宏定义或条件编译优化代码结构,使代码更容易阅读和理解。
- 提高代码的复用性:通过宏定义和头文件包含,可以将代码模块化,方便在不同的程序中进行重用。
- 增强程序的灵活性:通过条件编译和宏定义,可以根据需要选择性地编译某些代码,常见的例子是在不同平台上使用不同的函数。
- 优化程序的运行效率:通过预处理指令可以将一些常量或表达式在程序编译时进行替换,以减少程序运行时的计算量,从而提高程序的性能。
需要注意的是,预处理虽然有上述优点,但也会增大编译时间和程序体积,因此需要在编写代码时谨慎使用预处理指令。
2.2在Ubuntu下预处理的命令
2.2.1 在Ubuntu下的预处理命令
cpp hello.c > hello.i或gcc -E hello.c -o hello.
2.2.2 预处理结果展示
图2-1 预处理结果展示
2.3 Hello的预处理结果解析
图2-2 hello.i中头文件的部分
从图2-2中,我们可以看出cpp读取了stdio.h、unistd.h、stdlib.h中的内容,并将其直接插入了hello.i中。
图2-3 hello.i中的外部变量信息
从图5-3中可以看出,还引入了外部变量等信息。
图2-4 hello.i中的main函数部分
通过比较发现hello.i和hello.c中的main函数几乎一样,唯一的区别是hello.i文件中将所有#include的内容和注释部分的内容删除掉了,因为在之前已经把头文件中的内容引用了。
且在预处理后文件的代码长度大大增加变为了3091行
2.4 本章小结
本章介绍了hello.c的预处理过程,学习了在ubuntu中用cpp指令对hello.c文件进行预处理,将其重定向到hello.i中,分析了预处理后形成的hello.i文件,并得知预处理进行了宏定义替换的操作。
第3章 编译
3.1 编译的概念与作用
3.1.1 编译的概念
此处的编译是指编译器(cc1)将预处理得到的.i文件编译为.s文件的过程。其中.s文件是一种用文本描述机器指令的集合。
3.1.2 编译的作用
将预处理后得到的预处理文件(如hello.i)进行词法分析、语法分析、语义分析、优化后,生成汇编代码文件。经过编译后,得到的汇编代码文件(如,hello.S)还是一个可读的文本文件。将源程序转换为更易于机器理解的汇编语言代码,为后续汇编语言程序铺垫。
3.2 在Ubuntu下编译的命令
3.2.1 常见的编译指令
(1)gcc -S hello.c -o hello.s
(2)cc1 main.i -Og -o -main.s
3.2.2 编译的过程
图3-1 hello.i编译的过程和产生的结果文件
3.3 Hello的编译结果解析
3.3.1 文件内容
进行编译后生成的hello.s中的内容如下:
- .file "hello.c"
- .text
- .section .rodata
- .align 8
- .LC0:
- .string "\347\224\250\346\263\225: Hello 2021111247 \351\237\251\346\231\250\347\203\250 \347\247\222\346\225\260\357\274\201"
- .LC1:
- .string "Hello %s %s\n"
- .text
- .globl main
- .type main, @function
- 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 $4, -20(%rbp)
- je .L2
- leaq .LC0(%rip), %rax
- movq %rax, %rdi
- call puts@PLT
- movl $1, %edi
- call exit@PLT
- .L2:
- movl $0, -4(%rbp)
- jmp .L3
- .L4:
- 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), %rax
- movq %rax, %rdi
- movl $0, %eax
- call printf@PLT
- movq -32(%rbp), %rax
- addq $24, %rax
- movq (%rax), %rax
- movq %rax, %rdi
- call atoi@PLT
- movl %eax, %edi
- call sleep@PLT
- addl $1, -4(%rbp)
- .L3:
- cmpl $7, -4(%rbp)
- jle .L4
- call getchar@PLT
- movl $0, %eax
- leave
- .cfi_def_cfa 7, 8
- ret
- .cfi_endproc
- .LFE6:
- .size main, .-main
- .ident "GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0"
- .section .note.GNU-stack,"",@progbits
- .section .note.gnu.property,"a"
- .align 8
- .long 1f - 0f
- .long 4f - 1f
- .long 5
- 0:
- .string "GNU"
- 1:
- .align 8
- .long 0xc0000002
- .long 3f - 2f
- 2:
- .long 0x3
- 3:
- .align 8
- 4:
表3-1 hello.s中的标记符号解析
.file | 文件命名 |
.text | 代码段 |
.section .rodata | 只读数据段 |
.align | 对齐方式 |
.global | 全局变量 |
.type | 类型 |
.long | long类型变量 |
.string | 字符串类型变量 |
3.3.2 数据部分
- 常量
图3-2 hello.s中.rodata节
hello.s中的printf打印的字符串“用法: Hello 2021111247 韩晨烨 秒数 \n”被存储.rodata的.LC0中。而后续打印的字符串“Hello %s %s\n”同样存储在.rodata
中,被存放在.LC1节中。而hello.s中的其他数字常量则在编译阶段作为立即数在汇编代码段中出现。
- 变量
因为程序中无全局变量和静态变量所以hello.s中只有局部变量的内容
图3-3 hello.s中局部变量
对于程序而言,初始化的全局变量存储在.data中,没有被初始化的全局变量存储在.bss中。而局部变量一般存储在寄存器或者栈中。如图3-3所示i被存储在距离栈帧4字节的部分,并跳转到.L3开始循环,可知变量保存在栈中
- 立即数
图3-4 hello.s中的立即数
如图3-3所示,立即数在编译过程中是直接写在代码里的,16和8都是直接出现在汇编语言中的。
- 数组
图3-5 hello.s中的数组
在hello程序中,整型变量argc和字符串数组argv作为main函数的参数,当调用main函数时,它们分别通过寄存器rdi和rsi传递数据。如图3-4所示,argv指针指向一个被分配的连续空间,其中包含字符指针的起始地址argv。在main函数中访问数组元素argv[1]和argv[2]时,可以通过相对于rbp的偏移量和大小为8B的argv起始地址计算数据。在hello.s中,(%rax)被引用两次(分别是argv[1]和argv[2]的地址)。
3.3.3 赋值操作
图3-6 hello.s中赋值操作
赋值语句通过"mov"指令实现,比如对变量i进行赋值:"movl $0, -4(%rbp)"。而"leaq"指令则是加载有效地址,将地址写入数据,相当于指针。在hello.c中的赋值操作是针对for循环中的i变量的操作。 实现方式主要是使用mov指令来传递变量值。
3.3.4 算术操作
对i进行的加1操作:addl $1, -4(%rbp)
3.3.5 类型转换
在程序中用到了C标准库的atoi函数,把argv[3]中的字符串内容转换为一个整型数;
图3-7 hello.s中使用atoi进行类型在转换
3.3.6 比较与跳转
图3-8 hello.s中的比较与跳转指令
在hello.s中,利用的是cmpl指令进行的,同时后跟jmp跳转指令
3.3.7 循环
图3-9 hello.s中的循环
循环变量i从0开始,每次执行循环时都会将i增加1。在每次循环的开始处,会使用cmpl指令来判断i是否小于8。如果i小于8,程序将跳转到.L4标记,执行循环体中的程序内容。如果i不小于8,程序将退出循环。
3.3.8 函数操作
图3-10 hello.s中的函数
该程序调用了两个C标准库函数:printf、atoi和unistd.h中包含的sleep函数。如图3-5所示,printf有三个输入参数:%rdi为.L1节中的格式字符串,%rsi为argv[1]的地址,%rdx为argv[2]的地址;atoi函数的参数%rdi为argv[3]的值;sleep函数的参数是atoi的返回值。
3.4 本章小结
本章使用命令行命令将hello.i转换成hello.s,并结合编译的概念和作用,分析hello.s中出现的数据(全局变量、局部变量、数组、立即数)、赋值操作、算术操作、比较与跳转、条件控制、循环控制、函数操作(函数调用、函数返回、参数传递)等。解释了汇编语言中常见的形式,有利于理解编译机制。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 汇编的概念
编译完成生成 hello.s 文件后,驱动程序运行汇编器 as,将 hello.s 翻译成一个可重定位目标文件 hello.o,这个过程就是汇编。。
4.1.2 汇编的作用
将汇编语言文本文件转换为机器代码的二进制文件,汇编的结果是一个可重定位目标文件(如hello.o)其中包含的是不可读的二进制代码。
4.2 在Ubuntu下汇编的命令
4.2.1 常见的编译指令
(1)gcc -c –m64 –no-pie –fno-PIC hello.s -o hello.o
(2)as -o main.o main.s
4.2.2 编译的过程
图4-1 编译的过程及结果
4.3 可重定位目标elf格式
4.3.1 可重定位目标文件ELF格式
表4-1 可重定位目标文件ELF格式
ELF头 | 包括16字节标识信息、文件类型、机器类型、节头表的偏移、表项大小以及个数 |
.text节 | 编译后的代码部分 |
.rodata节 | 制度数据 |
.data节 | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态变量 |
.symtab节 | 符号表,存放在程序中定义和引用的函数和全局变量的信息 |
.rel.txt节 | 一个.text节中位置的列表 |
.debug节 | 一个调试符号表,条目是程序中定义的局部变量和类型定义 |
.strtab节 | 一个字符串表,内容包括.symtab和.debug节中的符号表,以及节头部中节的名字 |
.line节 | 原始C源程序中的行号和.text节中机器指令之间的映射 |
Section header table (节头部表) | 每个节的节名、偏移和大小 |
4.3.2 ELF文件中各个节的信息
- ELF头
图4-2 hello.o中的ELF头
可知文件类型为可重定位文件,包含了13个节。
- hello.o的节
通过readelf -S hello.o的指令,我们可以看到hello.o的各个节的信息如下:
图4-3 hello.o中的各个节的信息
- hello.o的符号表
通过readelf -s hello.o指令我们可以看到ELF文件中的符号表信息如下:
图4-4 hello.o中的符号表
从表中我们可以得知程序中涉及的符号名称以及所占大小、类型、存在在哪个节中。
- hello.o中的可重定位节
使用readelf -r hello.o指令我们可以看到hello.o中的可重定位节的信息:
图4-5 hello.o中的可重定位节
在ELF表中有两个.rel节,分别是.rela.text和.rela.eh_frame。内容有偏移量、信息、类型、符号值、符号名称等等
4.4 Hello.o的结果解析
4.4.1 反汇编hello.o的结果
使用objdump -d -r hello.o反汇编hello.o得到:
hello.o: 文件格式 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 04 cmpl $0x4,-0x14(%rbp)
- 17: 74 19 je 32 <main+0x32>
- 19: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 20 <main+0x20>
- 1c: R_X86_64_PC32 .rodata-0x4
- 20: 48 89 c7 mov %rax,%rdi
- 23: e8 00 00 00 00 call 28 <main+0x28>
- 24: R_X86_64_PLT32 puts-0x4
- 28: bf 01 00 00 00 mov $0x1,%edi
- 2d: e8 00 00 00 00 call 32 <main+0x32>
- 2e: R_X86_64_PLT32 exit-0x4
- 32: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
- 39: eb 4b jmp 86 <main+0x86>
- 3b: 48 8b 45 e0 mov -0x20(%rbp),%rax
- 3f: 48 83 c0 10 add $0x10,%rax
- 43: 48 8b 10 mov (%rax),%rdx
- 46: 48 8b 45 e0 mov -0x20(%rbp),%rax
- 4a: 48 83 c0 08 add $0x8,%rax
- 4e: 48 8b 00 mov (%rax),%rax
- 51: 48 89 c6 mov %rax,%rsi
- 54: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 5b <main+0x5b>
- 57: R_X86_64_PC32 .rodata+0x29
- 5b: 48 89 c7 mov %rax,%rdi
- 5e: b8 00 00 00 00 mov $0x0,%eax
- 63: e8 00 00 00 00 call 68 <main+0x68>
- 64: R_X86_64_PLT32 printf-0x4
- 68: 48 8b 45 e0 mov -0x20(%rbp),%rax
- 6c: 48 83 c0 18 add $0x18,%rax
- 70: 48 8b 00 mov (%rax),%rax
- 73: 48 89 c7 mov %rax,%rdi
- 76: e8 00 00 00 00 call 7b <main+0x7b>
- 77: R_X86_64_PLT32 atoi-0x4
- 7b: 89 c7 mov %eax,%edi
- 7d: e8 00 00 00 00 call 82 <main+0x82>
- 7e: R_X86_64_PLT32 sleep-0x4
- 82: 83 45 fc 01 addl $0x1,-0x4(%rbp)
- 86: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
- 8a: 7e af jle 3b <main+0x3b>
- 8c: e8 00 00 00 00 call 91 <main+0x91>
- 8d: R_X86_64_PLT32 getchar-0x4
- 91: b8 00 00 00 00 mov $0x0,%eax
- 96: c9 leave
- 97: c3 ret
4.4.2 机器语言的构成
机器语言由被计算机直接识别的二进制代码构成。不同型号的计算机其机器语言不相容,因此按照某种计算机的机器指令编写的程序无法在另一种计算机上运行。机器语言用由 0 和 1 组成的序列表示数据和指令。机器语言由以下几个部分构成:
(1) 操作码。它具体说明了操作的性质及功能。一台计算机可能有几十条至几百条指令,每一条指令都有一个相应的操作码,计算机通过识别该操作码来完成不同的操作。
(2) 操作数的地址。CPU通过该地址就可以取得所需的操作数。
(3) 操作结果的存储地址。把对操作数的处理所产生的结果保存在该地址中,以便再次使用。
(4) 下条指令的地址。执行程序时,大多数指令按顺序依次从主存中取出执行,只有在遇到转移指令时,程序的执行顺序才会改变。为了压缩指令的长度,可以用一个程序计数器存放指令地址。每执行一条指令,PC的指令地址就自动加一,指出将要执行的下一条指令的地址。当遇到执行转移指令时,则用转移地址修改PC的内容。由于使用了PC,指令中就不必明显地给出下一条将要执行指令的地址。
4.4.3 机器代码与汇编语言的映射关系
机器代码和汇编语言是一对一的关系。每一个机器指令都对应一个汇编指令,反之亦然。程序通过汇编器将汇编语言转换为机器语言后,才能被计算机执行。汇编语言相对于机器语言来说更便于人类阅读和编写,因为它使用了一些助记符(mnemonics),代表不同的机器指令操作码,从而减轻了对机器指令二进制代码的依赖。
4.4.4 hello.o反汇编与hello.s对比
以下是hello.s中的内容:
- .file "hello.c"
- .text
- .section .rodata
- .align 8
- .LC0:
- .string "\347\224\250\346\263\225: Hello 2021111247 \351\237\251\346\231\250\347\203\250 \347\247\222\346\225\260\357\274\201"
- .LC1:
- .string "Hello %s %s\n"
- .text
- .globl main
- .type main, @function
- 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 $4, -20(%rbp)
- je .L2
- leaq .LC0(%rip), %rax
- movq %rax, %rdi
- call puts@PLT
- movl $1, %edi
- call exit@PLT
- .L2:
- movl $0, -4(%rbp)
- jmp .L3
- .L4:
- 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), %rax
- movq %rax, %rdi
- movl $0, %eax
- call printf@PLT
- movq -32(%rbp), %rax
- addq $24, %rax
- movq (%rax), %rax
- movq %rax, %rdi
- call atoi@PLT
- movl %eax, %edi
- call sleep@PLT
- addl $1, -4(%rbp)
- .L3:
- cmpl $7, -4(%rbp)
- jle .L4
- call getchar@PLT
- movl $0, %eax
- leave
- .cfi_def_cfa 7, 8
- ret
- .cfi_endproc
- .LFE6:
- .size main, .-main
- .ident "GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0"
- .section .note.GNU-stack,"",@progbits
- .section .note.gnu.property,"a"
- .align 8
- .long 1f - 0f
- .long 4f - 1f
- .long 5
- 0:
- .string "GNU"
- 1:
- .align 8
- .long 0xc0000002
- .long 3f - 2f
- 2:
- .long 0x3
- 3:
- .align 8
- 4:
通过比较发现汇编与反汇编代码有以下几点不同:
(1) 操作数进制不同:汇编语言中的操作数是十进制数,而反汇编结果中的是十六进制数;
(2) 分支转移:汇编语言的分支跳转后跟的是段的名字,而由于以“. ”开头的行是指导汇编器和链接器工作的伪指令,在反汇编代码中没有这些,而且hello.o是可重定位文件,因此其跳转的时候用的是其要跳转的目的地址;
(3) 函数调用:汇编代码的函数调用时后面跟着函数名字,而反汇编代码调用函数时后面跟着的也是相对于main函数的偏移地址;
(4) 重定位条目:反汇编代码采用重定向的方式进行跳转,机器代码在此处留下一个地址以供链接时重定向。或者采用PC相对寻址,或者直接寻址,根据地址的更新和寻址的计算,实现跳转和调用。
(5) 对栈的利用:.s文件由于要采用对齐,对栈的利用较低,而反汇编对栈的利用率较高。
4.5 本章小结
本章主要介绍汇编语言的概念和作用。以"hello.s"到"hello.o"的示例为例,详细讲解和分析了目标文件的ELF格式,并进行了解析。对"hello.o"文件内的编译结果和反汇编代码进行了比较,理解了汇编代码和反汇编代码在结构和内容上的差异。通过这些讨论,提高了对汇编过程的理解。
第5章 链接
5.1 链接的概念与作用
5.1.1 链接的概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行干编译时(compiletime),也就是在源代码被翻译成机器代码时;也可以执行干加载时(loadtime),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(runtime),也就是由应用程序来执行。
5.1.2 汇编的作用
链接可以将各种代码和数据片段手机并组合策划归纳成一个可以加载到内存并执行的单一文件。它使得分离编译成为可能,可以将一个大型的应用程序分解为更小,更好管理的模块,便于独立修改和编译,链接让程序员能够利用共享库,通过动态链接为程序提供动态的内容。
5.2 在Ubuntu下链接的命令
5.2.1 常见的链接指令
由于hello中涉及到了动态链接库lib.so中的文件,所以链接的命令如下:
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.2.2 编译的过程
图5-1 链接的过程及结果
5.3 可执行目标文件hello的格式
5.3.1 可执行性目标文件ELF格式
表5-1 可执行目标文件ELF格式
ELF头 | 字段e_entry给出执行程序时第一条指令的地址 | 只读代码段 |
程序头表 | 结构数组 | |
.init节 | 用于定义_init函数,该函数用来进行可执行目标文件开始执行的初始化工作 | |
.text节 | 编译后的代码部分 | |
.rodata节 | 只读数据 | |
.data节 | 已初始化的全局和静态C变量 | 读写数据段 |
.bss节 | 未初始化的全局和静态C变量 | |
.symtab节 | 符号表,存放在程序中定义和引用的函数和全局变量的信息 | 无需装入到存储空间的信息 |
.debug节 | 一个调试符号表,条目是程序中定义的局部变量和类型定义 | |
.strtab节 | 一个字符串表,内容包括.symtab和.debug节中的符号表,以及节头部中的节名字 | |
.line节 | 原始C源程序中的行号和.text节中机器指令之间的映射 | |
节头表 | 每个节的节名、偏移和大小 |
使用指令readelf -h hello 可得到ELF头的信息:
图5-2 hello中的ELF头
由图中信息可知文件类型为可执行文件,包含了27个节。
5.3.3 hello中的各个节
使用readelf -S hello 可以得到hello中的各个节的信息:
图5-3 hello中的各个节
从图中可以看出在经过动态链接后增加了许多节。
(1).dynsym:动态链接符号表,.dynsym节保存在text段中。其保存了从共享库导入的动态符号表。节类型为SHT_DYNSYM。
(2).dynstr:动态链接字符串表,保存了动态链接字符串表,表中存放了一系列字符串,这些字符串代表了符号名称,以空字符作为终止符。
(3) .got:.got节保存了全局偏移表。
(4).gotplt:全局偏移表-过程链接表
(5).hash:.hash节也称为.gnu.hash,其保存了一个用于查找符号的散列表。
5.3.4 hello中的符号表
使用readelf -s hello命令可以得到文件中的符号表如下:
图5-4 hello中的符号表
5.3.5 hello中的程序头表
ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。
图5-5 hello中的程序头表
程序头表,给定了各个部分的具体信息,包括虚拟地址与物理地址
5.3.6 hello中的段节
图5-6 hello中的段节
5.3.7 hello中的动态节
图5-7 hello中的动态节
动态节保存动态链接所需要的基本信息,存储动态链接会用到的所有表的位置信息。
5.3.8 hello中的重定位节
图5-8 hello中的重定位节
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
5.4.1 edb中加载hello
图5-9 在edb中加载hello
5.4.2 edb中与hello各节的对应
(1).init节:起始地址为0x401000,大小为0x1b
图5-10 .init在虚拟空间中的位置
(2).plt节:起始地址0x401020,大小为0x70
图5-11 .plt在虚拟空间中的位置
(3).text节:起始地址为0x4010f0,大小为0x147
图5-12 .text在虚拟空间中的位置
5.5 链接的重定位过程分析
5.5.1 反汇编hello的结果
使用objdump -d -r hello可以得到hello的反汇编文件:
- hello: 文件格式 elf64-x86-64
- Disassembly of section .init:
- 0000000000401000 <_init>:
- 401000: f3 0f 1e fa endbr64
- 401004: 48 83 ec 08 sub $0x8,%rsp
- 401008: 48 8b 05 e9 2f 00 00 mov 0x2fe9(%rip),%rax # 403ff8 <__gmon_start__@Base>
- 40100f: 48 85 c0 test %rax,%rax
- 401012: 74 02 je 401016 <_init+0x16>
- 401014: ff d0 call *%rax
- 401016: 48 83 c4 08 add $0x8,%rsp
- 40101a: c3 ret
- Disassembly of section .plt:
- 0000000000401020 <.plt>:
- 401020: ff 35 e2 2f 00 00 push 0x2fe2(%rip) # 404008 <_GLOBAL_OFFSET_TABLE_+0x8>
- 401026: f2 ff 25 e3 2f 00 00 bnd jmp *0x2fe3(%rip) # 404010 <_GLOBAL_OFFSET_TABLE_+0x10>
- 40102d: 0f 1f 00 nopl (%rax)
- 401030: f3 0f 1e fa endbr64
- 401034: 68 00 00 00 00 push $0x0
- 401039: f2 e9 e1 ff ff ff bnd jmp 401020 <_init+0x20>
- 40103f: 90 nop
- 401040: f3 0f 1e fa endbr64
- 401044: 68 01 00 00 00 push $0x1
- 401049: f2 e9 d1 ff ff ff bnd jmp 401020 <_init+0x20>
- 40104f: 90 nop
- 401050: f3 0f 1e fa endbr64
- 401054: 68 02 00 00 00 push $0x2
- 401059: f2 e9 c1 ff ff ff bnd jmp 401020 <_init+0x20>
- 40105f: 90 nop
- 401060: f3 0f 1e fa endbr64
- 401064: 68 03 00 00 00 push $0x3
- 401069: f2 e9 b1 ff ff ff bnd jmp 401020 <_init+0x20>
- 40106f: 90 nop
- 401070: f3 0f 1e fa endbr64
- 401074: 68 04 00 00 00 push $0x4
- 401079: f2 e9 a1 ff ff ff bnd jmp 401020 <_init+0x20>
- 40107f: 90 nop
- 401080: f3 0f 1e fa endbr64
- 401084: 68 05 00 00 00 push $0x5
- 401089: f2 e9 91 ff ff ff bnd jmp 401020 <_init+0x20>
- 40108f: 90 nop
- Disassembly of section .plt.sec:
- 0000000000401090 <puts@plt>:
- 401090: f3 0f 1e fa endbr64
- 401094: f2 ff 25 7d 2f 00 00 bnd jmp *0x2f7d(%rip) # 404018 <puts@GLIBC_2.2.5>
- 40109b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
- 00000000004010a0 <printf@plt>:
- 4010a0: f3 0f 1e fa endbr64
- 4010a4: f2 ff 25 75 2f 00 00 bnd jmp *0x2f75(%rip) # 404020 <printf@GLIBC_2.2.5>
- 4010ab: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
- 00000000004010b0 <getchar@plt>:
- 4010b0: f3 0f 1e fa endbr64
- 4010b4: f2 ff 25 6d 2f 00 00 bnd jmp *0x2f6d(%rip) # 404028 <getchar@GLIBC_2.2.5>
- 4010bb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
- 00000000004010c0 <atoi@plt>:
- 4010c0: f3 0f 1e fa endbr64
- 4010c4: f2 ff 25 65 2f 00 00 bnd jmp *0x2f65(%rip) # 404030 <atoi@GLIBC_2.2.5>
- 4010cb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
- 00000000004010d0 <exit@plt>:
- 4010d0: f3 0f 1e fa endbr64
- 4010d4: f2 ff 25 5d 2f 00 00 bnd jmp *0x2f5d(%rip) # 404038 <exit@GLIBC_2.2.5>
- 4010db: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
- 00000000004010e0 <sleep@plt>:
- 4010e0: f3 0f 1e fa endbr64
- 4010e4: f2 ff 25 55 2f 00 00 bnd jmp *0x2f55(%rip) # 404040 <sleep@GLIBC_2.2.5>
- 4010eb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
- Disassembly of section .text:
- 00000000004010f0 <_start>:
- 4010f0: f3 0f 1e fa endbr64
- 4010f4: 31 ed xor %ebp,%ebp
- 4010f6: 49 89 d1 mov %rdx,%r9
- 4010f9: 5e pop %rsi
- 4010fa: 48 89 e2 mov %rsp,%rdx
- 4010fd: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
- 401101: 50 push %rax
- 401102: 54 push %rsp
- 401103: 45 31 c0 xor %r8d,%r8d
- 401106: 31 c9 xor %ecx,%ecx
- 401108: 48 c7 c7 25 11 40 00 mov $0x401125,%rdi
- 40110f: ff 15 db 2e 00 00 call *0x2edb(%rip) # 403ff0 <__libc_start_main@GLIBC_2.34>
- 401115: f4 hlt
- 401116: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
- 40111d: 00 00 00
- 0000000000401120 <_dl_relocate_static_pie>:
- 401120: f3 0f 1e fa endbr64
- 401124: c3 ret
- 0000000000401125 <main>:
- 401125: f3 0f 1e fa endbr64
- 401129: 55 push %rbp
- 40112a: 48 89 e5 mov %rsp,%rbp
- 40112d: 48 83 ec 20 sub $0x20,%rsp
- 401131: 89 7d ec mov %edi,-0x14(%rbp)
- 401134: 48 89 75 e0 mov %rsi,-0x20(%rbp)
- 401138: 83 7d ec 04 cmpl $0x4,-0x14(%rbp)
- 40113c: 74 19 je 401157 <main+0x32>
- 40113e: 48 8d 05 c3 0e 00 00 lea 0xec3(%rip),%rax # 402008 <_IO_stdin_used+0x8>
- 401145: 48 89 c7 mov %rax,%rdi
- 401148: e8 43 ff ff ff call 401090 <puts@plt>
- 40114d: bf 01 00 00 00 mov $0x1,%edi
- 401152: e8 79 ff ff ff call 4010d0 <exit@plt>
- 401157: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
- 40115e: eb 4b jmp 4011ab <main+0x86>
- 401160: 48 8b 45 e0 mov -0x20(%rbp),%rax
- 401164: 48 83 c0 10 add $0x10,%rax
- 401168: 48 8b 10 mov (%rax),%rdx
- 40116b: 48 8b 45 e0 mov -0x20(%rbp),%rax
- 40116f: 48 83 c0 08 add $0x8,%rax
- 401173: 48 8b 00 mov (%rax),%rax
- 401176: 48 89 c6 mov %rax,%rsi
- 401179: 48 8d 05 b5 0e 00 00 lea 0xeb5(%rip),%rax # 402035 <_IO_stdin_used+0x35>
- 401180: 48 89 c7 mov %rax,%rdi
- 401183: b8 00 00 00 00 mov $0x0,%eax
- 401188: e8 13 ff ff ff call 4010a0 <printf@plt>
- 40118d: 48 8b 45 e0 mov -0x20(%rbp),%rax
- 401191: 48 83 c0 18 add $0x18,%rax
- 401195: 48 8b 00 mov (%rax),%rax
- 401198: 48 89 c7 mov %rax,%rdi
- 40119b: e8 20 ff ff ff call 4010c0 <atoi@plt>
- 4011a0: 89 c7 mov %eax,%edi
- 4011a2: e8 39 ff ff ff call 4010e0 <sleep@plt>
- 4011a7: 83 45 fc 01 addl $0x1,-0x4(%rbp)
- 4011ab: 83 7d fc 07 cmpl $0x7,-0x4(%rbp)
- 4011af: 7e af jle 401160 <main+0x3b>
- 4011b1: e8 fa fe ff ff call 4010b0 <getchar@plt>
- 4011b6: b8 00 00 00 00 mov $0x0,%eax
- 4011bb: c9 leave
- 4011bc: c3 ret
- Disassembly of section .fini:
- 00000000004011c0 <_fini>:
- 4011c0: f3 0f 1e fa endbr64
- 4011c4: 48 83 ec 08 sub $0x8,%rsp
- 4011c8: 48 83 c4 08 add $0x8,%rsp
- 4011cc: c3 ret
5.5.2 hello与hello.o的不同
- hello多出init和plt这两节。
(1)init:包含程序初始化时需要的代码
(2)plt:.plt节也称为过程链接表(Procedure Linkage Table),其包含了动态链接器调用从共享库导入的函数所必需的相关代码。由于.plt节保存了代码,所以节类型为SHT_PROGBITS。
- 函数增加:在链接过程中,会将所用到的动态链接库中定义的函数加载到程序中。如图46所示,hello中增加了put、printf、sleep、getchar、exit、atoi、_start和_dl_relocate_static_pie的定义内容。
- 在hello中,.o文件中的重定位条目全部消失,全部更改为对应的虚拟内存中的地址。
- hello.out反汇编地址从0x400000开始,而hello.o从0开始;
5.5.3 链接过程
- 符号解析
链接器将每个符号引用精确匹配到符号定义。各种对象文件定义并引用符号,符号可以对应到函数、全局变量或者静态变量(在C语言中就是以 static 属性声明的变量)。编译时,编译器将每个全局符号输出成强符号或弱符号,而汇编器会隐含的将这个信息记录在目标文件的符号表中。有初始化值的全局变量和函数被视作强符号,没有初始化值的全局变量被视作弱符号。Linux链接器按照强符号和弱符号的定义遵循以下规则来处理多重符号定义问题。
规则1:同名的强符号只能有一个。
规则2:如果一个强符号和多个同名的弱符号出现时,选择强符号。
规则3:如果有多个同名的弱符号,则任选一个。
- 重定位
将多个代码段和数据段分别合并为一个完整的代码段和数据段,计算每一个定义 的符号在虚拟地址空间的绝对地址而不是相对偏移量,将可执行文件中的符号引用处修改为重定位后的地址信息。
重定位时,hello主要依靠重定位条目来进行修改。ELF定义了32种不同的重定位类型,其中最主要的两种为R_x86_64_PC32和R_X86_64_32。
(1)R_x86_64_PC32:重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如call指令的目标),PC值通常是下一条指令在内存中的地址。
(2)R_X86_64_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
5.6 hello的执行流程
表5-2 hello执行过程中的各程序名称和地址
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp | 0x7fce8c884b70 |
--libc-2.27.so!__sigjmp_save | 0x7fce8c884bd0 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
*hello!printf@plt | -- |
*hello!sleep@plt | -- |
*hello!getchar@plt | -- |
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x | 0x7fce8cc420b0 |
libc-2.27.so!exit | 0x7fce8c889128 |
5.7 Hello的动态链接分析
在动态链接项目中,观察dl_init前后项目变化。对于动态共享链接库中的PIC函数,编译器加强了重定位记录,等待动态链接器处理。为避免在运行时修改调用模块的代码段,链接器采用了延迟绑定策略,将过程地址的绑定推迟到第一次调用该过程。动态链接器使用过程链接表和全局偏移量表来实现函数的动态链接。GOT包含每个函数的目标地址,PLT使用GOT中的地址跳转到目标函数。
在调用dl_init函数之前,对于每个PIC函数调用,目标地址实际上指向PLT中的代码逻辑。最初,GOT中的每个条目都指向相应PLT条目的第二条指令。
在调用dl_init函数之后,0x404000和0x404010处分别改变了8个字节的数据。
当与PLT结合使用时,GOT[0]和GOT[1]包含动态链接器用于解析函数地址的信息。GOT[1]指向重定位表(.plt节中需要重定位的函数的运行时地址),用于确定所调用函数的地址。GOT[2]是动态链接器ld-linux.so模块的入口点。
在后续的函数调用中,程序首先跳转到PLT中执行.plt中的逻辑。第一次访问该函数时,GOT地址指向下一条指令。函数号被压入堆栈,然后程序跳转到PLT[0]。在PLT[0]中,重定位表地址被压入堆栈。然后通过GOT[2] 间接跳转进动态链接器中。动态链接器使用两个栈条目来确定 dl_init 的运行时位置,用这个地址重写GOT[4],再把控制流传递给 dl_init。之后,如果调用同样的函数,程序将直接跳转到目标函数。
图5-13 dl_init前的GOT
图5-14 dl_init后的GOT
5.8 本章小结
本章介绍了链接的概念和作用,以及以“hello”程序为例,分析了可执行文件的ELF格式和虚拟地址空间。该章还比较了“hello”程序的反汇编文件,计算了重定位和动态链接的过程,并简要说明了通过EDB调试链接“hello”程序时发生的一系列变化。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 进程的概念
进程是操作系统中的一种基本概念,指正在运行中的程序实例。在操作系统中,每个进程都有其独立的地址空间、程序计数器、寄存器和打开的文件句柄等状态。进程之间是互相独立的,它们的执行是并发的,可以同时执行多个进程。。
6.1.2 进程的作用
提供给操作系统(OS)上的程序两个抽象:一个独立的逻辑控制流和一个私有的虚拟地址空间。实现多任务处理和资源管理,让计算机能够同时运行多个程序,提高计算机资源的利用率和运行效率。操作系统通过给每个进程分配资源(如内存和CPU时间片),以及通过进程间的通信和同步机制,保证了各个进程之间的独立性和安全性,使得它们能够协同工作完成各种任务。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 壳Shell-bash的作用
shell 最重要的功能是命令解释。shell 是一个命令解释器。用户提交了一个命令后,shell 首先判断它是否为内置命令,如果是就通过 shell 内部的解释器将其解 释为系统功能调用并转交给内核执行;若是外部命令或使用程序就试图在硬盘中 查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。
6.2.2 壳Shell-bash的处理流程
命令输入的处理流程大致可以归结为以下几个步骤:
- 读取输入:shell接收用户输入的命令,存储在缓冲区中。
- 词法分析:shell对输入的命令进行分词,将其拆分成多个标记(tokens),如命令名、参数和选项等。
- 语法分析:shell将输入的命令标记转换成语法单元,将其解析成完整的命令,并进行语法检查。
- 执行:shell将解析的命令传递给操作系统内核,调用相应的系统服务和资源,完成指定任务。
在处理命令时,shell还支持一些额外的功能和特性,如管道、重定向和通配符等,以及各种内置命令。这些功能和特性可以帮助用户更加方便和高效地利用操作系统资源和提高工作效率。
6.3 Hello的fork进程创建过程
- 程序使用fork()函数创建一个新的进程。
- 在fork()函数调用之后,内核会复制当前进程的所有资源,包括代码段、数据段、堆栈、打开的文件和信号处理程序等,生成一个新的进程结构体,并为其分配一个唯一的进程ID。
- 内核将新的进程插入到调度队列中,等待被操作系统调度执行。
- 在父子进程中,fork()函数的返回值不同。在父进程中,fork()会返回新创建子进程的进程ID,在子进程中,fork()会返回0。如果fork()调用失败,会返回一个负数。
- 在父进程中,可以通过fork()的返回值获取子进程的PID,并且可以通过wait()等待子进程结束,并获取其退出状态。
- 在子进程中,可以通过获取fork()返回的值来判断自己是否为子进程。如果是子进程则继续执行后续代码,如果不是则可以执行其他操作。
- 父子进程是相互独立的,它们有各自独立的地址空间和资源。子进程中,fork()函数返回值为0,因此可以用来执行不同的代码,实现多任务处理。
使用fork()可以创建一个新的进程,使得原先的进程和新的进程在某个时刻同时运行。创建的新的进程结构体和原进程具有相同的代码、数据和上下文,但它们有不同的进程ID,并各自独立运行。这样,就能够实现多进程并发执行,提高计算机系统的资源利用率和运行效率。
6.4 Hello的execve过程
execve是一个系统调用函数,用于在一个新的进程中执行一个指定的可执行文件。它的函数原型为:int execve(const char *filename, char *const argv[], char *const envp[]);
参数说明: - filename: 指向一个字符串,包含要执行的可执行文件的路径; - argv: 指向一个指针数组,其中包含了可执行文件的命令行参数; - envp: 指向一个指针数组,其中包含了环境变量。
函数返回值为执行成功时为0,执行失败时为-1,并设置相应的errno错误代码。
当程序调用了execve()函数时,该进程的代码段、数据段等都会被换成可执行文件的代码段、数据段等。也就是说,execve()函数替换了整个进程映像,所以原有的程序代码和数据都不再存在。
在执行execve()函数之前,通常需要调用fork()函数创建一个新进程,然后在新进程中调用execve()函数来运行指定的可执行文件。这样,新进程就能继承父进程中的所有数据等信息,并且基于这个信息执行新的指令集。
execve()函数执行的一般过程:
- 执行进程将执行的可执行文件的路径(filename)、命令行参数(argv)和环境变量(envp)传递给execve()函数;
- execve()函数创建一个新的进程来执行可执行文件;
- 该进程基于可执行文件中的代码和数据等信息执行指令;
- 执行完毕后,新进程终止。
如果execve()函数执行成功,那么该函数会替换进程中的代码和数据等信息,因此,它的调用者和可执行文件之间的联系就被切断了,也就是说,运行execve()后,原进程被彻底销毁,新的进程已经创建,因此该函数不会再返回。
当main开始执行的时候,用户栈的组织结构如下。从从栈底(高地址)往栈顶(低 地址)依次观察。首先是参数和环境字符串。栈往上是以 null 结尾的指针数组, 其中每个指针都指向栈中的一个环境变量字符串。全局变量 environ 指向这些指针 中的第一个envp[0]。紧随环境变量数组之后的是以 null 结尾的 argv[]数组,其中 每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main 的栈帧。
图6-1 用户栈的情况
6.5 Hello的进程执行
- 进程上下文:操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。在这个机制中,内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
上下文切换:
(1) 保存当前进程的上下文
(2) 恢复某个先前被抢占的进程被保存的上下文
(3) 将控制传递给这个新恢复的进程。
- 进程时间片:一个进程执行它的控制流的一部分的每一个时间段叫做时间片。
- 进程的调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。
当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
操作系统的任务调度是采用时间片轮转的抢占式调度方式,也就是说一个任务执行一小段时间后强制暂停去执行下一个任务,每个任务轮流执行。任务执行的一小段时间叫做时间片,任务正在执行时的状态叫运行状态,任务执行一段时间后强制暂停去执行下一个任务,被暂停的任务就处于就绪状态等待下一个属于它的时间片的到来。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发。
- 用户态与核心态的转换:进程调度的过程中,还涉及到从用户态(user mode)切换到核心态(kernel mode)的过程,也就是进入操作系统内核的执行环境。当进程需要使用操作系统提供的服务或访问受保护的资源时,必须从用户态转换到核心态。这一需要转换时,进程为 hello 程序分配了虚拟地址空间,并将 hello的代码节和数据节分配到虚拟地址空间的代码区和数据区。hello 在用户模式下运行,调用系统函数 sleep,显式地请求让调用进程休眠。这时就发生了进程的调度。这过程中CPU会保存当前进程的上下文信息,并切换到内核态执行相关代码。当操作完成后,CPU会恢复进程的上下文信息,将进程切换回用户态。
6.6 hello的异常与信号处理
6.6.1 异常与信号
- 异常
hello 执行过程中可能出现四类异常:中断、陷阱、故障和终止。
(1) 中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返
回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
(2) 陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指
令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
(3) 故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成
功,则将控制返回到引起故障的指令,否则将终止程序。
(4) 终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程
序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。
- 信号
Linux中存在的信号格式如图所示:
图6-2 Linux中的各种信号
linux发送信号的方式共有四种,分别是
-
- 利用/bin/kill发送信号。
- 从键盘发送信号,在键盘上输入Ctrl+C会导致内核发送一个SOGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。输入Ctrl+Z会发送一个SIGTSTP的信号到前台进程组中的每个进程,默认情况下是挂起前台作业。
- 通过kill函数发送信
- 通过alarm函数发送信号
6.6.2 hello的异常与信号处理
- 正常运行
图6-3 正常运行
- ctrl+c
图6-4 运行时输入ctrl+c
在hello的前台运行的时候,按下Ctrl+C会向它发送SIGINT信号,这个进程就会被终止
- ctrl+z
图6-5 运行时输入ctrl+z
在程序运行中,ctrl+c会使内核发送SIGTSTP信号给到前台进程组中的每个进程,从而使前台所有的进程停止。
- jobs
图6-6 jobs
使用jobs命令可以查看进程的信息
- pstree
图6-7 pstree
使用pstree指令可以查看进程树
- fg
图6-8 fg
Fg指令是使第一个后台进程变为前台重新运行,在上述过程中,第一个后台作业为hello。
- kill
图6-9 kill
Ctrlz后运行kill:
Hello的进程号是5006,可通过kill -9 5006发送信号SIGKILL给进程5006,它会导致该进程被杀死。然后再运行ps,可发现已被杀死的进程hello。
- 回车+乱按
图6-10 回车+乱按
在随意输入的过程中,如果识别到了回车,程序中的getchar函数会将回车读入,并将之前的字符当作shell的命令进行相应的操作。
6.7本章小结
本章主要运用异常控制流和信号控制来操作应用程序。简要介绍了进程和Shell的概念及其作用,介绍了使用fork和execve函数创建、加载和运行程序的过程,并简要描述了进程调用的流程。此外,还测试了Hello程序的执行,分析了各种异常情况,并进行了相应的程序处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址:逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。在 hello 中,生成的 hello.o 文件中的地址即偏移量,都是逻辑地址。
- 线性地址:CPU加载程序后,会为这个程序分配内存,所分配内存又分为代码段内存和数据段内存。代码段内存的基址保存在CS中,数据段内存的基址保存在DS中。段基址+逻辑地址=线性地址。
- 虚拟地址:虚拟地址是一个抽象的地址空间,虚拟地址对应虚拟页,虚拟页会映射磁盘空间的一页,如果要使用该页上的数据,则会将该页载入内存,虚拟地址就对应了物理地址。
- 物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由两部分组成:段标识符和段内偏移量。段标识符是一个16位字段。通过使用段标识符的前13位,可以直接在段描述符表中找到特定的段描述符,该描述符描述了一个段。该索引号是“段描述符”的索引,段的具体地址由段描述符描述。许多段描述符组成了一个数组,称为“段描述符表”,通过使用段标识符的前13位,可以直接在段描述符表中找到特定的段描述符。每个段描述符由8个字节组成。全局段描述符存储在“全局描述符表(GDT)”中,而某些本地段描述符存储在“本地描述符表(LDT)”中。Linux通过分段机制将逻辑地址转换为线性地址。给出完整的逻辑地址[段选择符:偏移地址],首先检查段选择符的T1是否为0或1,以确定是否转换在GDT或LDT中的段,然后通过相应的寄存器获取其地址和大小。我们然后获得一个数组。接下来,通过取段选择符的前13位,在此数组中可以找到相应的段描述符,从而知道基地址。最后,将基地址和偏移量相加,得到所需的线性地址。
图7-1 逻辑地址到线性地址的变换
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟地址(VA)到物理地址(PA)的转换是通过页面机制实现的。页面机制是将虚拟地址内存空间划分为页面的一种方式。
在内存中存储了一个称为页表的数据结构作为相应的索引。因此,我们可以让每个进程都有一个页表,每个页表中的条目都记录了进程中相应页投影的物理地址,无论其是否有效,以及一些其他信息。
但是,页表占用的空间确实太大了。此外,许多页表条目实际上应该为空,因为进程通常不占用大的地址空间。因此,采用了多级页表结构来执行索引。
该系统将虚拟页面视为数据传输的单元。在Linux下,每个虚拟页面的大小为4KB。物理内存也被划分为物理页面。MMU(内存管理单元)负责地址转换,并使用页表将虚拟页面映射到物理页面,即将虚拟地址映射到物理地址。
每当虚拟地址转换为物理地址时,将查询页表以确定虚拟页面是否缓存在DRAM中。如果没有缓存,可以通过查询页表条目来确定磁盘上虚拟页面的位置。页表将虚拟页面映射到物理页面。页表是页表条目的数组,每个页表条目都由有效性位和n位地址字段组成。有效性位表示虚拟页面是否缓存在DRAM中,n位地址字段是物理页面的起始地址或磁盘上虚拟页面的起始地址。
n位虚拟地址包含两个部分:p位虚拟页面偏移量(VPO)和n-p位虚拟页面号(VPN)。 MMU使用VPN选择相应的PTE。根据PTE,我们知道虚拟页面的信息。如果虚拟页面被缓存,页表条目的物理页面号和虚拟地址的VPO被连接以获得相应的物理地址。在这里,VPO和PPO是相同的。如果虚拟页面未被缓存,则触发页面错误。通过调用页面错误处理子例程将缺失的页面从磁盘重新加载到内存中,然后执行导致页面错误的指令。
图7-2 线性地址到物理地址的变换
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:翻译后备缓冲器,是在MMU中的一个关于 PTE 的小的缓存。TLB 是 一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个 PTE 组成的块。
当TLB命中时的地址翻译步骤为:
- CPU产生一个虚拟地址。
- MMU从 TLB 中取出相应的 PTE。
- MMU将这个虚拟地址翻译成一个物理地址,并将它发送到高速缓存或主 存。
- 高速缓存或主存将所请求的数据字返回给 CPU。
当TLB不命中时地址翻译步骤为:
- MMU向页表中查询
- CR3确定第一级页表的起始地址
- VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE
- 如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推
- 最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
图7-3 TLB命中
图7-4 TLB不命中
7.5 三级Cache支持下的物理内存访问
- 组匹配:用虚拟地址中的组索引位作为索引,寻找相应位置。
- 行选择:选定组后,将虚拟地址中的标记位与选定组中的每一行进行比较。匹配上后,若有效位为1,则高速缓存命中。
- 字抽取:选定目标行后,利用虚拟地址中的字偏移量即可选择所想选取的字
- 若高速缓存未命中,则向低一层的存储结构寻找
图7-5 三级cache
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在进程中加载并运行hello需要以下几个步骤∶
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello文件中的.text 和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图9-31概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc. so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7-6 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
缺页其实就是DRAM缓存未命中。当我们的指令中对取出一个虚拟地址时,若我们发现对该页的内存访问是合法的,而找对应的页表项式发现有效位为0,则说明该页并没有保存在主存中,出现了缺页故障。
图7-7 缺页前
缺页时的操作:
- 处理器生成一个虚拟地址,并把它传送给MMU.
- MMU生成PTE地址,并从高速缓存/主存请求得到它.
- 高速缓存/主存向MMU返回PTE.
- PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序.
- 缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘.
- 缺页处理程序页面调入新的页面,并更新内存中的PTE.
- 缺页处理程序返回到原来的进程,再次执行导致缺页的指令,CPU将地址重新发送给MMU.因为虚拟页面现在已经缓存在物理内存中,所以会命中,主存将所请求字返回给处理器.
图7-8 缺页后
7.9本章小结
本章重点介绍了计算机中的存储,包括地址空间的分类,地址的变换规则, 虚拟内存的原理,cache 的工作,和动态内存的分配。虚拟内存存在于磁盘中,处 理器产生一个虚拟地址,然后虚拟地址通过页表映射等相关规则被转化为物理地 址,再通过 cache 和主存访问物理地址内保存的内容,返回给处理器。还有进程fork和execve时的内存映射的内容。描述了系统如何应对那些缺页异常。
结论
通过对hello程序的分析,我们可以逐步了解计算机系统的设计与实现。我们对程序从预处理、编译、汇编、链接、加载、执行等不同的角度进行了深入的探讨。从中我们可以得到以下结论:
- 预处理是对源代码进行文本替换、宏定义等操作,以生成编译器可以处理的文件。
- 编译是将预处理后的文件转换成汇编代码。
- 汇编将汇编代码转换成机器码并生成目标文件。
- 链接将目标文件和库文件组合成一个可执行程序。
- 加载将可执行程序从磁盘读入内存中。
- 执行将程序从内存中运行起来。
通过这个分析过程,我们更深刻地理解了计算机系统中各个组成部分之间的关系,了解了系统的整体工作流程与原理。
在hello运行的过程中需要在shell中通过fork创建子进程,通过execve加载并运行程序。运行程序的过程中又设计到存储的管理,包括利用程序空间局部性与时间局部性的缓存cache与虚拟内存。
在个人的计算机系统设计思路方面,我认为创新与改进是至关重要的。在设计与实现方面,我认为差异化和可持续性是两个最为重要的关键词。因此,我会关注不同计算机系统需求的差异性,开发能够完美解决这些需求的新的设计与实现方法,同时,以用户为中心的设计思想,不断地为用户提供高质量、可靠且长效的解决方案。
附件
hello.i | 对hello.c进行预处理得到的文件 |
hello.s | 对hello.i进行编译得到的文件 |
hello.o | 对hello.s进行汇编得到的文件 |
hello | 对hello.o进行链接得到的可执行文件 |
hello.o.asm | 对hello.o进行反汇编得到的文件 |
hello.asm | 对hello进行反汇编得到的文件 |
参考文献
[1] 深入了解计算机系统(第三版)2016 Bryant,R.E. 机械工业出版社
[2] 袁春风. 计算机系统基础. 北京:机械工业出版社,2018.7(2019.8重印)