justtreee / blog

31 stars 8 forks source link

【操作系统实验笔记】Hello World程序在Linux下的诞生与消亡 #4

Open justtreee opened 6 years ago

justtreee commented 6 years ago

一、从源代码到可执行文件

#include<stdio.h>
int main()
{
        printf("hello world\n");
        return 0;
}

1. 编译全过程解析

自C,C++等高级语言诞生之后,程序的编写基本脱离了硬件系统,使人更能读懂与理解。但对于机器来说,源代码需要经过多个步骤,转换为一系列低级机器语言指令,然后将这些指令按照可执行程序的格式打包,并以二进制文件形式储存起来。

命令:gcc -o h hello.c 作用:在Linux下,可用gcc的命令编译hello.c,使其转化为可执行文件h

gcc 编译器驱动程序读取源文件hello.c,经过预处理、编译、汇编、链接(分别使用预处理器、编译器、汇编器、链接器,这四个程序构成了编译系统)四个步骤,将其翻译成可执行目标程序hello。如下图所示:

2. 预处理

预处理器(CPP)根据源程序中以字符”#”开头的命令,修改源程序,得到另一个源程序,常以.i作为文件扩展名。修改主要包括#include、#define和条件编译三个方面。

命令:gcc -o hello.i -E hello.c 作用:编译器将C源代码中的包含的头文件如stdio.h编译进来,将hello.c预处理输出hello.i文件。

查看hello.i,如下图所示:

he_i

3. 编译

第二步,编译器(CCL)将经过预处理器处理得到的文本文件hello.i翻译成hello.s,在这个阶段中,gcc首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc把代码翻译成汇编语言。汇编语言程序以一种标准的文本格式确切描述一条低级机器语言指令。

命令:gcc -S hello.c 作用:将预处理输出文件hello.i汇编成hello.s文件。

        .file   "hello.c"
        .section        .rodata
.LC0:
        .string "hello world"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    $.LC0, %edi
        call    puts
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.5) 5.4.0 20160609"
        .section        .note.GNU-stack,"",@progbits                                              

4. 汇编

汇编阶段是把编译阶段生成的”.s”文件转成二进制目标代码。汇编器(AS)将hello.s翻译成机器语言指令,并打包成可重定位目标程序,一般以.o为文件扩展名。可重定位目标程序是二进制文件,它的字节编码是机器语言指令而不是字符。

命令:gcc -c hello.s 作用:将汇编输出文件hello.s编译输出hello.o文件。

vim打开hello.o发现文件是乱码,说明此时已经是二进制文件。

5. 链接

链接程序(LD)将hello.o以及一些其他必要的目标文件组合起来,创建可执行目标文件。

命令:gcc -o he hello.o 作用:生成可执行文件he

root@Treee:~# ./he
hello world

在这里涉及到一个重要的概念:函数库。 读者可以重新查看这个小程序,在这个程序中并没有定义”printf”的函数实现,且在预编译中包含进的”stdio.h”中也只有该函数的声明,而没有定义函数的实现,那么,是在哪里实现”printf”函数的呢?最后的答案是:系统把这些函数实现都被做到名为libc.so.6的库文件中去了,在没有特别指定时,gcc会到系统默认的搜索路径”/usr/lib”下进行查找,也就是链接到libc.so.6库函数中去,这样就能实现函数”printf” 了,而这也就是链接的作用。 你可以用ldd命令查看动态库加载情况: [root]# ldd hello.exe libc.so.6 => /lib/tls/libc.so.6 (0x42000000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000) 函数库一般分为静态库和动态库两种。静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了。其后缀名一般为”.a”。动态库与之相反,在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以节省系统的开销。动态库一般后缀名为”.so”,如前面所述的libc.so.6就是动态库。gcc在编译时默认使用动态库。 参考链接

二、可执行程序的加载及执行

1. 可执行文件的存储器映像

通过链接,我们获得了可执行文件,可执行文件是保存在磁盘中的。

21

在可执行文件中,有一个程序头表。在程序头表当中,描述了可执行文件中的只读代码(图中的 .text )和数据 (.data.bss) 的存储映像。 而它在Linux系统下的虚拟地址结构如上图右侧所示,分为两个空间:

  1. 最上面1GB是内核的虚存区,即从 0xC00000000 向上的区域。
  2. 下面的3GB空间是用户的虚存区。在用户的虚存区中又分为栈区(用户栈)和堆区,栈向下生长,堆向上生长,中间是共享库的区域。
  3. 最下面是可读可写的数据段和只读代码。 读写数据段与左图中的 .data.bss 相映射。 从 0x08048000 开始的只读代码段与左图中从0开始的 .text 以及最终的只读数据节 .rodata相映射。 如下图所示:

22

2. 程序的加载与运行

execve系统调用函数:

  • UNIX/Linux系统中,可通过调用execve()函数来启动加载器。
  • execve()函数的功能是在当前进程上下文中加载并运行一个新程序。 execve()函数的用法如下: *`int execve(char filename, char argv[], envp[]);** filename是加载并运行的可执行文件名(/.hello),可带参数列表argv和环境变量列表envp。若错误(如找不到指定文件filename),则返回-1,并将控制权交给调用程序;若函数执行成功,则不返回,最终将控制权传递到可执行目标中的主函数main` 。
  • 主函数 main() 的原型形式如下: `int main(int argc, char argv, char envp);` 或者: int main(int argc, char *argv[], char *envp[])
  • main函数中的参数是由execve()函数传递下来的。argc指定参数个数,参数列表中第一个总是命令名(可执行文件名)。 例如:命令行为 ld -o test main.o test.o 时,argc=6

回到 hello world 程序

hello程序的加载与运行过程:

  1. 在shell命令行提示符后输入命令:./hello 后回车
  2. shell命令行解释器构造argvenpv shell命令行解释器先从键盘接受这些字符,然后碰到最后一个回车之后,就分割这个命令行。而 。/hello 只有一个字符串,因此构造的数组实际上是这样的: 23 它的第一个元素指向的是这个字符串的首地址,然后是一个以空结尾的一个指针类型数组。其中每一个数组都是一个指针,里面实际只有一个字符串,所以只有第一个数组元素是有意义的,指针指向这个字符串。
  3. 调用 fork() 函数,创建一个子进程,与父进程 shell 完全相同(只读/共享,就是shell命令行解释器),包括只读代码段、可读写数据段、堆以及用户栈等。
  4. 调用 execve() 函数,在当前进程(新创建的子进程)的上下文中加载并运行 hello 程序。将 hello 中的 .text 节、.data节 、.bss节等内容加载到当前进程的虚拟地址空间(仅修改当前进程上下文中关于存储映像的一些数据结构,不从磁盘中拷贝代码,数据等内容)
  5. 调用hello程序的main()函数,hello 程序开始在一个进程的上下文中运行。

如下图所示:

24

本节参考链接

三、Hello在内存中的镜像

可执行文件在内存中的加载已经在 二.1 可执行文件的存储器映像 中简单介绍过了。 在Linux操作系统加载程序是,将程序所使用的内存分为5段:text(程序段),data(数据段),bss(bbs数据段),heap(堆),stack(栈)。

1. text segment(程序段) 程序段用于存放程序指令本身,Linux为程序代码分配长度固定的内存,且程序段内存位于整个程序所占内存的最上方。 2. data segment(数据段) 数据段用于存放代码中已经被赋值的全局变量和静态变量,因为这类变量的所需内存(这点由变量数据类型决定)和其数值都已经在代码中确定了,因此data segment 紧跟 text segment ,并且操作系统清楚的知道数据段需要多少内存,从而赋予长度固定的内存。

这里可以使用ll命令查看: 25

第一个栏位,表示文件的属性。Linux的文件基本上分为三个属性:可读(r),可写(w),可执行(x)。 详情 第二个栏位,表示文件个数。如果是文件的话,那这个数目自然是1了,如果是目录的话,那它的数目就是该目录中的文件个数了。 第三个栏位,表示该文件或目录的拥有者。若使用者目前处于自己的Home,那这一栏大概都是它的账号名称。 第四个栏位,表示所属的组(group)。每一个使用者都可以拥有一个以上的组,不过大部分的使用者应该都只属于一个组,只有当系统管理员希望给予某使用者特殊权限时,才可能会给他另一个组。 第五栏位,表示文件大小。文件大小用byte来表示,而空目录一般都是1024byte,当然可以用其它参数 使文件显示的单位不同,如使用ls –k就是用kb莱显示一个文件的大小单位,不过一般我们还是以byte为主。   第六个栏位,表示最后一次修改时间。以“月,日,时间”的格式表示,如Aug 15 5:46表示8月15日早上5:46分。 第七个栏位,表示文件名。我们可以用ls –a显示隐藏的文件名。

3. bss segment(bss数据段) 此处引用一篇博客的内容:

bss segment用于存放未赋值的全局变量和静态变量。这块挨着data segment,长度固定。 bss是指那些没有初始化的和初始化为0的全局变量

int bss_array[1024 * 1024] = {0};

int main(int argc, char* argv[])
{
    return 0;
}
[root@localhost bss]# gcc -g bss.c -o bss.exe
[root@localhost bss]# ll
total 12
-rw-r--r-- 1 root root   84 Jun 22 14:32 bss.c
-rwxr-xr-x 1 root root 5683 Jun 22 14:32 bss.exe

变量bss_array的大小为4M,而可执行文件的大小只有5K。 由此可见,bss类型的全局变量只占运行时的内存空间,而不占文件空间。

4. heap(堆) 用于存放程序所需的动态内存空间,如malloc()函数申请的内存空间。这块内存挨着bss,并向上生长。 5. stack(栈) 用于存放局部变量,当程序调用一个函数(包括main函数)时,将函数内部的变量入栈,当调用完成之后,函数内的局部变量就没用了,故要出栈。所以在递归的程序中,若函数调用次数(递归深度)过多,会有爆栈风险。

四、系统调用

本节主要介绍printf()函数的调用。

1. printf()的代码在哪里?

一.2 预处理中,预处理器预编译源代码,得到另一个源程序,可以发现printf()函数的声明。

31

然后如一.3 编译所示,编译成汇编:

        .file   "hello.c"
        .section        .rodata
.LC0:
        .string "hello world"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    $.LC0, %edi
        **call    puts**
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.5) 5.4.0 20160609"
        .section        .note.GNU-stack,"",@progbits                                              

我们发现printf()函数调用被转化为call puts指令,而不是call printf指令,这好像有点出乎意料。不过不用担心,这是编译器对printf()的一种优化。实践证明,对于printf()的参数如果是以\n结束的纯字符串,printf会被优化为puts函数,而字符串的结尾\n符号被消除。除此之外,都会正常生成call printf指令。

编译阶段之后就是汇编了,但hello.o文件我们并不能通过vim等工具查看,这时候就需要gcc工具链的objdump命令查看二进制信息。

命令:objdump -d hello.o。 作用:查看二进制文件。


hello.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000

: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: bf 00 00 00 00 mov $0x0,%edi 9: e8 00 00 00 00 callq e <main+0xe> e: b8 00 00 00 00 mov $0x0,%eax 13: 5d pop %rbp 14: c3 retq


而链接器最终会将puts的符号地址修正。由于链接方式分为静态链接和动态链接两种,此处`callq  e <main+0xe>`应该是动态链接。虽然链接方式不同,但是不影响最终代码对库函数的调用。

我们这里关注printf函数背后的原理,因此使用更易说明问题的静态链接的方式阐述。这里引用[原文内容](http://www.cnblogs.com/fanzhidongyzby/p/3519838.html)

> ```
> $/usr/lib/gcc/i686-linux-gnu/4.7/collect2                   \
>     -static -o main                                         \
>     /usr/lib/i386-linux-gnu/crt1.o                          \
>     /usr/lib/i386-linux-gnu/crti.o                          \
>     /usr/lib/gcc/i686-linux-gnu/4.7/crtbeginT.o             \
>     main.o                                                  \
>     --start-group                                           \
>     /usr/lib/gcc/i686-linux-gnu/4.7/libgcc.a                \
>     /usr/lib/gcc/i686-linux-gnu/4.7/libgcc_eh.a             \
>     /usr/lib/i386-linux-gnu/libc.a                          \
>     --end-group                                             \
>     /usr/lib/gcc/i686-linux-gnu/4.7/crtend.o                \
>     /usr/lib/i386-linux-gnu/crtn.o
> $objdump –sd main
> ```
> 
> ```
> Disassembly of section .text:
> ...
> 08048ea4 <main>:
>  8048ea4:  55                     push   %ebp
>  8048ea5:  89 e5                  mov    %esp,%ebp
>  8048ea7:  83 e4 f0               and    $0xfffffff0,%esp
>  8048eaa:  83 ec 10               sub    $0x10,%esp
>  8048ead:  c7 04 24 e8 86 0c 08   movl   $0x80c86e8,(%esp)
>  8048eb4:  e8 57 0a 00 00         call   8049910 <_IO_puts>
>  8048eb9:  b8 00 00 00 00         mov    $0x0,%eax
>  8048ebe:  c9                     leave 
>  8048ebf:  c3                     ret
> ```
> 静态链接时,链接器将C语言的运行库(CRT)链接到可执行文件,其中crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o便是这五个核心的文件,它们按照上述命令显示的顺序分居在用户目标文件和库文件的两侧。由于我们使用了库函数puts,因此需要库文件libc.a,而libc.a与libgcc.a和libgcc_eh.a有相互依赖关系,因此需要使用-start-group和-end-group将它们包含起来。
> 链接后,call puts的地址被修正,但是反汇编显示的符号是_IO_puts而不是puts!难道我们找的文件不对吗?当然不是,我们使用readelf命令查看一下main的符号表。竟然发现puts和_IO_puts这两个符号的性质是等价的!objdump命令只是显示了全局的符号_IO_puts而已。

## 2. `printf()`的调用轨迹

[本小节引用原文](http://www.cnblogs.com/fanzhidongyzby/p/3519838.html)

> 我们知道对于"Hello World !\n"的printf调用被转化为puts函数,并且我们找到了puts的实现代码是在库文件libc.a内的,并且知道它是以二进制的形式存储在文件ioputs.o内的,那么我们如何寻找printf函数的调用轨迹呢?换句话说,printf函数是如何一步步执行,最终使用Linux的int 0x80软中断进行系统调用陷入内核的呢?
> 
> 如果让我们向终端输出一段字符串信息,我们一般会使用系统调用write()。那么打印Helloworld的printf最终是这样做的吗?我们借助于gdb来追踪这个过程,不过我们需要在编译源文件的时候添加-g选项,支持调试时使用符号表。
> ```
> gdb ./main
> (gdb)break main
> (gdb)run
> (gdb)stepi
> ```
> 在main函数内下断点,然后调试执行,接着不断的使用stepi指令执行代码,直到看到Hello World !输出为止。这也是为什么我们使用puts作为示例而不是使用printf的原因。
> ```
> (gdb)
> 0xb7fff419 in __kernel_vsyscall ()
> (gdb)
> Hello World!
> ```
> 我们发现Hello World!打印位置的上一行代码的执行位置为0xb7fff419。我们查看此处的反汇编代码。
> `(gdb)disassemble`
> 
> ```
> Dump of assembler code for function __kernel_vsyscall:
>    0xb7fff414 <+0>:  push   %ecx
>    0xb7fff415 <+1>:  push   %edx
>    0xb7fff416 <+2>:  push   %ebp
>    0xb7fff417 <+3>:  mov    %esp,%ebp
>    0xb7fff419 <+5>:  sysenter
>    0xb7fff41b <+7>:  nop
>    0xb7fff41c <+8>:  nop
>    0xb7fff41d <+9>:  nop
>    0xb7fff41e <+10>: nop
>    0xb7fff41f <+11>: nop
>    0xb7fff420 <+12>: nop
>    0xb7fff421 <+13>: nop
>    0xb7fff422 <+14>: int    $0x80
> => 0xb7fff424 <+16>: pop    %ebp
>    0xb7fff425 <+17>: pop    %edx
>    0xb7fff426 <+18>: pop    %ecx
>    0xb7fff427 <+19>: ret   
> End of assembler dump.
> ```
> 
> 我们惊奇的发现,地址0xb7fff419正是指向sysenter指令的位置!这里便是系统调用的入口。如果想了解这里为什么不是int 0x80指令,请参考文章[《Linux 2.6 对新型 CPU 快速系统调用的支持》](https://www.ibm.com/developerworks/cn/linux/kernel/l-k26ncpu/)。或者参考Linus在邮件列表里的文章[《Intel P6 vs P7 system call performance》](https://lkml.org/lkml/2002/12/18/218)。
> 
> 系统调用的位置已经是printf函数调用的末端了,我们只需要按照函数调用关系便能得到printf的调用轨迹了。
> 
> `(gdb)backtrace`
> 
> ```
> #0  0xb7fff424 in __kernel_vsyscall ()
> #1  0x080588b2 in __write_nocancel ()
> #2  0x0806fa11 in _IO_new_file_write ()
> #3  0x0806f8ed in new_do_write ()
> #4  0x080708dd in _IO_new_do_write ()
> #5  0x08070aa5 in _IO_new_file_overflow ()
> #6  0x08049a37 in puts ()
> #7  0x08048eb9 in main () at main.c:4
> ```
> 
> 我们发现系统调用前执行的函数是__write_nocancel,它执行了系统调用__write!
> 

[本节参考链接](http://www.cnblogs.com/fanzhidongyzby/p/3519838.html)
# 五、程序卸载
在**二. 2程序的加载与运行**已经讲过,`hello world`程序需要调度`fork()`函数创建一个子进程,而`main`结束并返回后,就需要调用系统调用终止当前进程。

> 当进程执行完它的工作后,就需要执行退出操作,释放进程占用的资源。ucore分了两步来完成这个工作,首先由进程本身完成大部分资源的占用内存回收工作,然后由此进程的父进程完成剩余资源占用内存的回收工作。为何不让进程本身完成所有的资源回收工作呢?这是因为进程要执行回收操作,就表明此进程还存在,还在执行指令,这就需要内核栈的空间不能释放,且表示进程存在的进程控制块不能释放。所以需要父进程来帮忙释放子进程无法完成的这两个资源回收工作。
> 
> 为此在用户态的函数库中提供了`exit`函数,此函数最终访问`sys_exit`系统调用接口让操作系统来帮助当前进程执行退出过程中的部分资源回收。我们来看看ucore是如何做进程退出工作的。
> 
> 首先,`exit`函数会把一个退出码`error_code`传递给ucore,ucore通过执行内核函数`do_exit`来完成对当前进程的退出处理,主要工作简单地说就是回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作,具体流程如下:
> 
> 1. 如果`current->mm != NULL`,表示是用户进程,则开始回收此用户进程所占用的用户态虚拟内存空间;
> 
> > a) 首先执行`lcr3(boot_cr3)`,切换到内核态的页表上,这样当前用户进程目前只能在内核虚拟地址空间执行了,这是为了确保后续释放用户态内存和进程页表的工作能够正常执行;
> > b) 如果当前进程控制块的成员变量`mm`的成员变量`mm_count`减1后为0(表明这个`mm`没有再被其他进程共享,可以彻底释放进程所占的用户虚拟空间了。),则开始**回收用户进程所占的内存资源**:
> > > i. 调用`exit_mmap`函数释放`current->mm->vma`链表中每个`vma`描述的进程合法空间中实际分配的内存,然后把对应的页表项内容清空,最后还把页表所占用的空间释放并把对应的页目录表项清空;
> > > ii. 调用`put_pgdir`函数释放当前进程的页目录所占的内存;
> > > iii. 调用`mm_destroy`函数释放`mm`中的`vma`所占内存,最后释放`mm`所占内存;
> > c) 此时设置`current->mm`为`NULL`,表示与当前进程相关的用户虚拟内存空间和对应的内存管理成员变量所占的内核虚拟内存空间已经回收完毕;
> 
> 2. 这时,设置当前进程的执行状态`current->state=PROC_ZOMBIE`,当前进程的退出码`current-> exit_code=error_code`。此时当前进程已经不能被调度了,需要此进程的父进程来做最后的回收工作(即回收描述此进程的内核栈和进程控制块);
> 
> 3. 如果当前进程的父进程`current->parent`处于等待子进程状态:
> 
> `current->parent->wait_state==WT_CHILD`,
> 
> 则唤醒父进程(即执行`wakup_proc(current->parent)`),让父进程帮助自己完成最后的资源回收;
> 
> 4. 如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程`initproc`,且各个子进程指针需要插入到`initproc`的子进程链表中。如果某个子进程的执行状态是`PROC_ZOMBIE`,则需要唤醒`initproc`来完成对此子进程的最后回收工作。
> 
> 5. 执行`schedule()`函数,选择新的进程执行。
> 
> 那么**父进程如何完成对子进程的最后回收工作**呢?这要求父进程要执行`wait`用户函数或`wait_pid`用户函数,这两个函数的区别是,`wait`函数等待任意子进程的结束通知,而`wait_pid`函数等待进程id号为pid的子进程结束通知。这两个函数最终访问`sys_wait`系统调用接口让ucore来完成对子进程的最后回收工作,即回收子进程的内核栈和进程控制块所占内存空间,具体流程如下:
> 
> 1. 如果`pid!=0`,表示只找一个进程id号为pid的退出状态的子进程,否则找任意一个处于退出状态的子进程;
> 
> 2. 如果此子进程的执行状态不为`PROC_ZOMBIE`,表明此子进程还没有退出,则当前进程只好设置自己的执行状态为`PROC_SLEEPING`,睡眠原因为`WT_CHILD`(即等待子进程退出),调用`schedule()`函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤1处执行;
> 
> 3. 如果此子进程的执行状态为`PROC_ZOMBIE`,表明此子进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列`proc_list`和`hash_list`中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,消除了它所占用的所有资源。

[参考链接](https://objectkuan.gitbooks.io/ucore-docs/lab5/lab5_3_3_process_exit_wait.html)

可以这样概括:

> - 调用系统调用终止当前进程
> - 回收用户态空间分页
> - 销毁进程句柄
> - 通知父进程,该进程结束。