从操作系统OS的视角来观察hello程序

从操作系统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;
}

运行的结果如下所示:

8f66d5e08a2d898c7e8a1ed3d259e2f.png

能够很清楚的展现出当前进程是由更早出现的父进程来创建执行并能够参与调度的。

创建进程控制块通过linux内核中的struct task_struct来具体实现,实现的时候调用对应的父进程为子进程的一系列的值来做赋值并为进程控制块分配对应的空间,实现的方式如copy_process所执行的一致。

image.png

程序键入的时候给copy_process函数打上断点,可以发现此时的进程控制块的值已经被替换了,标识此时已经新建了一个新的进程控制块,正在完成初始化的工作。

image.png

就像这样,完成包含程序计数器(p->counter)/寄存器/文件描述符和TSS相关参数的配置。

程序的内存分配

在建立了进程控制块并为当前的Hello程序分配了进程控制块准备执行程序之后,OS将为新的进程分配对应的内存地址空间。此时需要知道准备运行的程序的大小,具体来说就是代码段/数据段/堆和栈的空间位置大小。通过查阅资料得知,可以使用readelf -a hello 指令来查看当前可执行文件的elf头部字段。在linux内核中,elf内核字段的结构表示如下:(在wsl中的elf头结构,所用的内核为内核版本: 5.15.133.1-1)

image.png

每个区域的头部的位置:

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参数里面看出,读出的段的大小是0x35fdB,也就是32775B的大小,大约是32KB的大小的字段加载进了刚读入的ELF头部文件中

在当前的linux 0.11有一些不同的地方,现在先分析其对应的字段:

image.png

根据参考资料,执行文件的头部结构是:

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调整为从进程逻辑地址空间开始的地方算起的参数和环境变量占用的长度

image.png

在执行hello的过程中两次进行了调用copy_string()的更新,调试的结果显示p的变化情况如下:

image.png

最开始的时候,p的初始值为 4096 × 32 − 4 4096\times32-4 4096×324也就是对应了131068的位置,第二次更新在130889的位置,说明此时进程加载的命令行参数和环境变量串信息块的大小为131068-130889=179B,加载进入了内存中。然后调用create_table()函数来执行,用于确定堆栈的指针值p/参数变量的个数值argc/环境变量的个数值envc。最后能够得到对应的初始堆栈指针sp(如下图所示)。

image.png

此时的sp的值可以计算得出:

image.png

当do_exceve返回的时候,会将原调用系统终端程序在堆栈上的代码指针eip替换成新执行程序的代码入口点,将栈指针替换成新的执行文件的栈指针esp,此后系统调用的返回指令最终会弹出这些栈中的数据,并使得CPU去执行新的执行文件。

在return之前的寄存器值如下所示 eip = <do_exceve + 2694>
image.png

从系统调用中返回的函数的位置如下所示 eip = 0x0

image.png

中断程序正常返回,继续执行。

进程调度过程

现在hello所运行的进程已经成为了就绪态并加载进入了就绪队列中,现在开始执行hello语句,执行的过程如下所示:

d634b62463813a2c46b380c035edce3.png

使用实验五跟踪打印进程的状态信息,同时重启调试之后得到:

8f66d5e08a2d898c7e8a1ed3d259e2f.png

可以观察到进程号为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函数来进行打印。

image.png

首先键入./hello命令,然后观察con_write()的函数调用过程,此时 nr = 14 ,表示开始打印 “Hello world!” ,开始打印的第一个H的状态图如下所示:

image.png

此后的Hello, world!打印成功如下所示:

image.png

跟踪程序的过程中梳理函数的调用过程,首先是使用函数tty_write()作为上层调用的接口函数,接收到"Hello, world!"字符串,然后通过tty的函数指针write定位到控制终端输出的程序con_write(),调用其进行打印字符。

文件系统

操作系统在执行文件操作的过程中会检查可执行文件是否存在,查询文件的访问权限等。所以在这个例子中需要跟踪程序的系统调用。

使用命令在虚拟机中运行strace ./hello 2>&1 | grep "open\|write\|close"

image.png

分析涉及的文件操作:

  1. 首先打开的是共享库的缓存文件ld.so.cache
  2. 关闭文件描述符3
  3. 打开C库的共享库"/lib/x86_64-linux-gnu/libc.so.6"
  4. 关闭文件描述符3
  5. 使用write操作将字符串写入标准输出,也就是文件描述符为1的状况,表示写入的字节数为31;此处使用的hello程序是进行进程跟踪的调度程序,打印的内容是:
Hello, world!
current pid = 201623
current ppid = 188872

系统调用

通过查阅资料可知,通过命令strace ./hello -f得到的结果可以检测在hello运行的过程中执行的系统调用函数,所有的函数调用情况如下所示:

杂项
结束
资源加载
标准库
程序
mprotect
mprotect
mprotect
rseq
set_robust_list
set_tid_address
arch_prctl
exit_group
write
write
write
brk()
brk(NULL)
getrandom
newfstatat
getppid
getpid
munmap
prlimit64
mmap
mmap
mmap
newfstatat
openat
close
mmap
newfstatat
openat
access
mmap
arch_prctl
brk(NULL)
execve

具体的调用日志文件放在code文件夹中。

缺页故障

image.png

找到pid = 6的进程的内容,也就是为hello创建的进程。在do_no_page()中打上断点检测执行的情况,它首先判断指定的线性地址在一个进程空间中相对于进程基址的偏移长度值。如果它大于代码加数据长度,或者进程刚开始创建,则立刻申请一页物理内存,并映射到进程线性地址中,然后返回;接着尝试进行页面共享操作,若成功,则立刻返回; 否则申请一页内存并从设备中读入一页信息;若加入该页信息时,指定线性地址+1 页长度超过了进程代 码加数据的长度,则将超过的部分清零。然后将该页映射到指定的线性地址处。完成了hello的缺页处理。

进程退出

当程序执行完毕,或者调用exit系统调用时,操作系统收回进程的资源。这包括释放内存、关闭文件描述符、终止进程等操作。

总结

总体上,hello程序的执行过程可以分为以下几个步骤:

  1. 编译与加载: 使用gcc编译hello.c源文件,生成可执行文件hello。在Linux中,可执行文件是一种ELF格式的文件。

  2. 进程创建: 当在终端中输入./hello时,操作系统会创建一个新的进程。这个过程涉及到调用fork()函数来创建新的进程,并继承父进程的一些属性。

  3. 进程控制块初始化: 操作系统为新进程分配进程控制块(struct task_struct),并初始化其中的一些参数,包括程序计数器、寄存器、文件描述符等。

  4. 内存分配: 操作系统为新进程分配内存空间,包括代码段、数据段、堆和栈等。ELF文件的头部信息用于确定这些段的位置和大小。

  5. 加载程序: 执行do_execve()函数,将程序加载到新进程的地址空间中。这一步包括将命令行参数和环境变量复制到内核空间,确定堆栈指针等。

  6. 进程调度: 操作系统使用调度算法选择下一个要执行的进程。这可能涉及到进程的优先级、时间片等概念。

  7. I/O操作: 在程序执行过程中,可能涉及到I/O操作。例如,在hello程序中,打印输出就是一次I/O操作,通过con_write()函数实现。

  8. 系统调用: 程序执行中可能会涉及到系统调用,例如文件的打开、关闭等。通过strace命令可以观察程序执行时的系统调用。

  9. 缺页故障处理: 当程序访问未加载到内存的页时,会触发缺页故障。操作系统会根据需要将相应的页面加载到内存中。

  10. 进程退出: 程序执行完毕或者调用exit系统调用时,操作系统会回收进程的资源,包括释放内存、关闭文件描述符等。

总体而言,hello程序的执行过程涉及了编译、进程创建、内存分配、程序加载、进程调度、I/O操作、系统调用、缺页故障处理和进程退出等多个步骤。这些步骤展示了操作系统如何管理和执行用户程序。

  • 21
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值