从操作系统OS的视角来观察hello程序
实验过程
我们根据hello在一个LinuxOS中的运行顺序来考虑其在OS中的各种特质
要运行一个hello程序,需要先使用gcc
来进行编译的工作,操作系统会通过fork()
函数语句来创建一个新的进程,以hello.c作为参数,通过操作系统的额一系列调用将hello程序写回到磁盘的相应位置并分配磁盘块和i节点以便进行继续访问。由于不是本文重点,暂时进行简略的说明。
创建进程控制块
当在操作系统中键入./hello
的时候,就会触发操作系统为即将等待执行的进程在内存中分配进程控制块,并在进程控制块中初始化一系列的数值;操作系统在这个过程中通常调用处理用户指令的进程来创建子进程,所以在创建进程块的过程中是继承的关系。
下面是修改了的hello.c
程序,添加了对父进程和当前进程的显示。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t current_id;
pid_t p_current_id;
current_id = getpid();
p_current_id = getppid();
printf("Hello, world!\n");
printf("current pid = %5d\n",current_id);
printf("current ppid = %4d\n",p_current_id);
return 0;
}
运行的结果如下所示:
能够很清楚的展现出当前进程是由更早出现的父进程来创建执行并能够参与调度的。
创建进程控制块通过linux内核中的struct task_struct
来具体实现,实现的时候调用对应的父进程为子进程的一系列的值来做赋值并为进程控制块分配对应的空间,实现的方式如copy_process
所执行的一致。
程序键入的时候给copy_process函数打上断点,可以发现此时的进程控制块的值已经被替换了,标识此时已经新建了一个新的进程控制块,正在完成初始化的工作。
就像这样,完成包含程序计数器(p->counter
)/寄存器/文件描述符和TSS相关参数的配置。
程序的内存分配
在建立了进程控制块并为当前的Hello程序分配了进程控制块准备执行程序之后,OS将为新的进程分配对应的内存地址空间。此时需要知道准备运行的程序的大小,具体来说就是代码段/数据段/堆和栈的空间位置大小。通过查阅资料得知,可以使用readelf -a hello
指令来查看当前可执行文件的elf头部字段。在linux内核中,elf内核字段的结构表示如下:(在wsl中的elf头结构,所用的内核为内核版本: 5.15.133.1-1
)
每个区域的头部的位置:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000000318 00000318
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.pr[...] NOTE 0000000000000338 00000338
0000000000000030 0000000000000000 A 0 0 8
[ 3] .note.gnu.bu[...] NOTE 0000000000000368 00000368
0000000000000024 0000000000000000 A 0 0 4
[ 4] .note.ABI-tag NOTE 000000000000038c 0000038c
0000000000000020 0000000000000000 A 0 0 4
[ 5] .gnu.hash GNU_HASH 00000000000003b0 000003b0
0000000000000024 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 00000000000003d8 000003d8
00000000000000f0 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 00000000000004c8 000004c8
00000000000000a3 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 000000000000056c 0000056c
0000000000000014 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 0000000000000580 00000580
0000000000000030 0000000000000000 A 7 1 8
[10] .rela.dyn RELA 00000000000005b0 000005b0
00000000000000c0 0000000000000018 A 6 0 8
[11] .rela.plt RELA 0000000000000670 00000670
0000000000000060 0000000000000018 AI 6 24 8
[12] .init PROGBITS 0000000000001000 00001000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000001020 00001020
0000000000000050 0000000000000010 AX 0 0 16
[14] .plt.got PROGBITS 0000000000001070 00001070
0000000000000010 0000000000000010 AX 0 0 16
[15] .plt.sec PROGBITS 0000000000001080 00001080
0000000000000040 0000000000000010 AX 0 0 16
[16] .text PROGBITS 00000000000010c0 000010c0
000000000000014d 0000000000000000 AX 0 0 16
[17] .fini PROGBITS 0000000000001210 00001210
000000000000000d 0000000000000000 AX 0 0 4
[18] .rodata PROGBITS 0000000000002000 00002000
0000000000000039 0000000000000000 A 0 0 4
[19] .eh_frame_hdr PROGBITS 000000000000203c 0000203c
0000000000000034 0000000000000000 A 0 0 4
[20] .eh_frame PROGBITS 0000000000002070 00002070
00000000000000ac 0000000000000000 A 0 0 8
[21] .init_array INIT_ARRAY 0000000000003da0 00002da0
0000000000000008 0000000000000008 WA 0 0 8
[22] .fini_array FINI_ARRAY 0000000000003da8 00002da8
0000000000000008 0000000000000008 WA 0 0 8
[23] .dynamic DYNAMIC 0000000000003db0 00002db0
00000000000001f0 0000000000000010 WA 7 0 8
[24] .got PROGBITS 0000000000003fa0 00002fa0
0000000000000060 0000000000000008 WA 0 0 8
[25] .data PROGBITS 0000000000004000 00003000
0000000000000010 0000000000000000 WA 0 0 8
[26] .bss NOBITS 0000000000004010 00003010
0000000000000008 0000000000000000 WA 0 0 1
[27] .comment PROGBITS 0000000000000000 00003010
000000000000002b 0000000000000001 MS 0 0 1
[28] .symtab SYMTAB 0000000000000000 00003040
00000000000003a8 0000000000000018 29 18 8
[29] .strtab STRTAB 0000000000000000 000033e8
0000000000000215 0000000000000000 0 0 1
[30] .shstrtab STRTAB 0000000000000000 000035fd
000000000000011a 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
There are no section groups in this file.
可以看到,上面的每一个section和symbol表构成了ELF文件的主要部分,在上面的section列表里面的常用段为:
.text
段表示代码段,用于保存可以执行的指令
.data
段表示数据段,用于保存有初始值的全局变量和静态变量
.bss
段表示没有初始值或者初始值为0的全局变量和静态变量,程序加载的时候都会被变成0
然后可以从offset
参数里面看出,读出的段的大小是0x35fd
B,也就是32775B的大小,大约是32KB的大小的字段加载进了刚读入的ELF头部文件中
在当前的linux 0.11
有一些不同的地方,现在先分析其对应的字段:
根据参考资料,执行文件的头部结构是:
struct exec{
unsigned long a_magic; // 文件魔数
unsigned long a_text;
unsigned long a_data;
unsigned long a_bss;
unsigned long a_syms;
unsigned long a_entry;
unsigned long a_trsize;
unsigned long a_drsize;
}
程序的加载
操作系统加载程序到hello
进程的地址空间,调用do_execve()
函数来大量参数和环境空间的处理。每个进程或者任务的参数和环境空间一共可以有 MAX_ARG_PAGES
个页面(如下图定义),然后执行copy_string()
函数,作用是从用户内存空间拷贝命令行参数和环境字符串到内核空闲页面中,执行完copy_string()
之后内存会把p调整为从进程逻辑地址空间开始的地方算起的参数和环境变量占用的长度
在执行hello
的过程中两次进行了调用copy_string()
的更新,调试的结果显示p的变化情况如下:
最开始的时候,p的初始值为
4096
×
32
−
4
4096\times32-4
4096×32−4也就是对应了131068的位置,第二次更新在130889的位置,说明此时进程加载的命令行参数和环境变量串信息块的大小为131068-130889=179B,加载进入了内存中。然后调用create_table()
函数来执行,用于确定堆栈的指针值p/参数变量的个数值argc/环境变量的个数值envc。最后能够得到对应的初始堆栈指针sp(如下图所示)。
此时的sp的值可以计算得出:
当do_exceve返回的时候,会将原调用系统终端程序在堆栈上的代码指针eip替换成新执行程序的代码入口点,将栈指针替换成新的执行文件的栈指针esp,此后系统调用的返回指令最终会弹出这些栈中的数据,并使得CPU去执行新的执行文件。
在return之前的寄存器值如下所示 eip = <do_exceve + 2694>
:
从系统调用中返回的函数的位置如下所示 eip = 0x0
:
中断程序正常返回,继续执行。
进程调度过程
现在hello所运行的进程已经成为了就绪态并加载进入了就绪队列中,现在开始执行hello语句,执行的过程如下所示:
使用实验五跟踪打印进程的状态信息,同时重启调试之后得到:
可以观察到进程号为6的进程在时钟为5757的时候被创建,5758的时候进入就绪态并加入调度队列,在运行之后进入僵死状态,可以看到宏观上进程调度的过程;从代码层面来分析:
调度算法的核心:
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
也就是对应的counter
和优先级加权得到的总体优先级进行的优先级队列的调度,后面的switch_to()
函数对应的是任务切换的代码,使用的是之前实验2中的任务0和任务1切换的ljmp语句来进行任务的切换,也就是进程之间的切换方式;在此处打断点比较困难,因为时刻都在进行时钟中断和调度。
I/O操作
在I/O操作的时候会调用console.c
程序,追踪一下程序的运行过程;通过查阅资料得知,在打印字符的时候调用的是con_write
函数来进行打印。
首先键入./hello命令,然后观察con_write()
的函数调用过程,此时 nr = 14
,表示开始打印 “Hello world!” ,开始打印的第一个H的状态图如下所示:
此后的Hello, world!打印成功如下所示:
跟踪程序的过程中梳理函数的调用过程,首先是使用函数tty_write()
作为上层调用的接口函数,接收到"Hello, world!"字符串,然后通过tty的函数指针write
定位到控制终端输出的程序con_write()
,调用其进行打印字符。
文件系统
操作系统在执行文件操作的过程中会检查可执行文件是否存在,查询文件的访问权限等。所以在这个例子中需要跟踪程序的系统调用。
使用命令在虚拟机中运行strace ./hello 2>&1 | grep "open\|write\|close"
分析涉及的文件操作:
- 首先打开的是共享库的缓存文件
ld.so.cache
- 关闭文件描述符3
- 打开C库的共享库"/lib/x86_64-linux-gnu/libc.so.6"
- 关闭文件描述符3
- 使用
write
操作将字符串写入标准输出,也就是文件描述符为1的状况,表示写入的字节数为31;此处使用的hello程序是进行进程跟踪的调度程序,打印的内容是:
Hello, world!
current pid = 201623
current ppid = 188872
系统调用
通过查阅资料可知,通过命令strace ./hello -f
得到的结果可以检测在hello运行的过程中执行的系统调用函数,所有的函数调用情况如下所示:
具体的调用日志文件放在code文件夹中。
缺页故障
找到pid = 6
的进程的内容,也就是为hello创建的进程。在do_no_page()
中打上断点检测执行的情况,它首先判断指定的线性地址在一个进程空间中相对于进程基址的偏移长度值。如果它大于代码加数据长度,或者进程刚开始创建,则立刻申请一页物理内存,并映射到进程线性地址中,然后返回;接着尝试进行页面共享操作,若成功,则立刻返回; 否则申请一页内存并从设备中读入一页信息;若加入该页信息时,指定线性地址+1 页长度超过了进程代 码加数据的长度,则将超过的部分清零。然后将该页映射到指定的线性地址处。完成了hello的缺页处理。
进程退出
当程序执行完毕,或者调用exit
系统调用时,操作系统收回进程的资源。这包括释放内存、关闭文件描述符、终止进程等操作。
总结
总体上,hello
程序的执行过程可以分为以下几个步骤:
-
编译与加载: 使用
gcc
编译hello.c
源文件,生成可执行文件hello
。在Linux中,可执行文件是一种ELF格式的文件。 -
进程创建: 当在终端中输入
./hello
时,操作系统会创建一个新的进程。这个过程涉及到调用fork()
函数来创建新的进程,并继承父进程的一些属性。 -
进程控制块初始化: 操作系统为新进程分配进程控制块(
struct task_struct
),并初始化其中的一些参数,包括程序计数器、寄存器、文件描述符等。 -
内存分配: 操作系统为新进程分配内存空间,包括代码段、数据段、堆和栈等。ELF文件的头部信息用于确定这些段的位置和大小。
-
加载程序: 执行
do_execve()
函数,将程序加载到新进程的地址空间中。这一步包括将命令行参数和环境变量复制到内核空间,确定堆栈指针等。 -
进程调度: 操作系统使用调度算法选择下一个要执行的进程。这可能涉及到进程的优先级、时间片等概念。
-
I/O操作: 在程序执行过程中,可能涉及到I/O操作。例如,在
hello
程序中,打印输出就是一次I/O操作,通过con_write()
函数实现。 -
系统调用: 程序执行中可能会涉及到系统调用,例如文件的打开、关闭等。通过
strace
命令可以观察程序执行时的系统调用。 -
缺页故障处理: 当程序访问未加载到内存的页时,会触发缺页故障。操作系统会根据需要将相应的页面加载到内存中。
-
进程退出: 程序执行完毕或者调用
exit
系统调用时,操作系统会回收进程的资源,包括释放内存、关闭文件描述符等。
总体而言,hello
程序的执行过程涉及了编译、进程创建、内存分配、程序加载、进程调度、I/O操作、系统调用、缺页故障处理和进程退出等多个步骤。这些步骤展示了操作系统如何管理和执行用户程序。