不忘初心,重新认识hello world

对于程序员来说,hello world的含义,可能并不只是一句简单的打招呼,更多的是学习一门编程语言的第一个示例.和编程接触了这么多年,如果要学习一门编程语言,我也会情不自禁地敲下hello world程序,看下程序的的输出.如今回过头的来重新看hello world,就会发现hello world背后隐藏的许多秘密;

首页显示

路走的远了,就需要不断回头审视自己来时的路,以及眺望未来之路,不忘初心,重新(心)出发.所以才有了这篇文章,好好窥探hello world的秘密;

这篇文章主要分为以下几块,

  1. 什么是程序?
  2. 代码如何转换为二进制?
  3. 程序是怎么装载进内存?
  4. 进程是怎么执行的?
  5. 进程怎么退出?

【版权声明】博客内容由罗道文的私房菜拥有版权,允许转载,但请标明原文链接http://luodw.cc/2016/07/02/helloworld/

其实就是hello world的一生;都是我自己的理解,有误的地方,希望看者能提出来,互相进步;这里就是做个引子,介绍下,希望可以帮助大家更好的理解进程的运行,如果想深入学习,需要看相关书籍;

什么是程序


很多时候,人们会把程序和进程的概念弄混了,其实可以这么理解,程序是一堆无生命的二进制代码,而这些二进制代码被读取进内存,跑起来之后,就是进程了;也就是说,程序是躺在磁盘里无生命活力的二进制代码,而进程是在内存中有生命活力的二进制代码;ok,我写个hello world程序hello.c,作为例子:

1
2
3
4
5
6
7
#include<stdio.h>
int main(void)
{
printf("hello world!\n");
return 0;
}

看到这剪短的代码,有没很熟悉?如果有种很熟悉的感觉,那恭喜你,你肯定也是个程序员(不用回头,没错,就是你!!!). 这段代码很简单,就是简单的输出一句"hello world"就退出了.按我的理解,这其实还不能算是程序,可以称呼为代码;我理解的是程序应该是能被操作系统认识理解,运行的代码.而操作系统只能认识二进制的东西,所以像我们编写的C/C++,JAVA,GO等等代码,最后都要被转换为二进制的代码,才能够执行;

代码如何转换为二进制


紧接着上述的话题,当我们写好一段代码之后,如果将其转换为二进制了?这就是我们默默无闻的编译器了.为什么说默默无闻了?因为编译器做着非常重要的工作,但是又有多少人真正打开编译器的心扉,好好认识编译器?(当然包括我,羞愧低下了头!!!).编译C代码分为几个步奏,有技术背景应该听过,预处理,编译,汇编,链接;

预处理阶段为:

gcc -E hello.c -o hello.i

我们可以打开hello.i看看,里面的头文件stdio.h已经被展开了,包括一些类型定义函数定义等等,截取片段如下:

1
2
3
4
5
6
typedef unsigned int __socklen_t;
# 36 "/usr/include/stdio.h" 2 3 4
# 44 "/usr/include/stdio.h" 3 4
struct _IO_FILE;
typedef struct _IO_FILE FILE;

从这个片段,我们可以看出来了,我们平时引用的stdio.h文件存在与/usr/include/stdio.h,所以想研究stdio.h的朋友,可以打开看看;这个代码片段很重要的还有FILE的定义,这就是我们用c语言打开一个文件返回的句柄结构,所以想理解C语言中缓冲流是怎么实现的,可以先看看_IO_FILE结构体中io流缓冲区的定义,再结合代码即可理解;

预处理之后,接下来,就需要将这些代码编译成汇编代码:

gcc -S hello.i -o hello.s

这个过程,就是将上述的代码转换为汇编代码,其实就是一条条汇编指令,这个过程是c代码转换为二进制最为重要的过程,也是最复杂的过程;我们可以打开hello.s看看内部的东东

1
2
3
4
5
6
7
8
9
10
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

这就是汇编指令,但是直接打开hello.s看,显示不是很友好,我们可以用objdump或者gdb的反汇编指令打开obj文件,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
charles@charles-Lenovo:~$ objdump -d hello.o
hello.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
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

这样看是不是好很多了.学习汇编,重要的不是说还让你去写汇编代码(当然有些C代码是有嵌入汇编的,例如内存屏障),而是让我们能看懂汇编代码.那有人又会问,看懂汇编代码有什么用?我最深的体会就是可以从汇编代码看懂函数栈的调用过程,我很早也有写过用gdb反汇编理解c函数栈调用过程,还有就是理解底层ABI的一些东西,例如系统调用嵌入内核时,参数是存在哪些寄存器,然后从内核内核返回用户态时,返回值是存在哪个寄存器(rax)等等;如果你还有问知道这些有什么用,我只能问你,为什么来学计算机?

ok,编译结束之后,接下来就是将汇编代码转换为目标文件:

gcc -c hello.s -o hello.o

目标文件已经是二进制代码了,这个hello.o也是之前用objdump打开查看汇编代码的文件;二进制代码是不能用文本编辑器打开查看的,可以试着用vim打开hello.o,可以看到一对乱码,可能会有几个看得懂的字符;我们可以用od -c hello.o命令看二进制所对应的每个字符.

1
2
3
4
5
6
7
8
9
10
11
12
charles@charles-Lenovo:~$ od -c hello.o
0000000 177 E L F 002 001 001 \0 \0 \0 \0 \0 \0 \0 \0 \0
0000020 001 \0 > \0 001 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
0000040 \0 \0 \0 \0 \0 \0 \0 \0 0 001 \0 \0 \0 \0 \0 \0
0000060 \0 \0 \0 \0 @ \0 \0 \0 \0 \0 @ \0 \r \0 \n \0
0000100 U H 211 345 277 \0 \0 \0 \0 350 \0 \0 \0 \0 270 \0
0000120 \0 \0 \0 ] 303 h e l l o w o r l d
0000140 ! \0 \0 G C C : ( U b u n t u
0000160 4 . 8 . 4 - 2 u b u n t u 1 ~ 1
0000200 4 . 0 4 . 3 ) 4 . 8 . 4 \0 \0 \0
0000220 024 \0 \0 \0 \0 \0 \0 \0 001 z R \0 001 x 020 001
0000240 033 \f \a \b 220 001 \0 \0 034 \0 \0 \0 034 \0 \0 \0

编译成目标文件之后,最后一步就是链接,生成可执行的目标文件.二进制代码文件有分为可重定向文件,可执行文件和可共享文件.一般情况下,hello.o是可重定向文件,多个.o文件链接在一起,就可以生成可执行文件;静态链接库和动态链接库为可共享的二进制代码.

在这个例子中,只有一个文件,所以就直接生成可执行文件就好了

gcc hello.o -o hello

此时就生成了可执行的程序;在linux下,可执行的程序属于ELF文件,可以使用file命令查看文件类型.这时还没有进程,还只是程序而已;之前看到有些问题,如"进程的的静态区是什么时候分配的,有些回答是编译时".我当时就醉了,编译的时候还没进程,哪来的内存;正确是编译时决定内存的如何分配,但是真正分配还是在进程跑起来之后.

程序是如何装载进内存


ok,到这为止,一个程序就已经准备好了,我们很习惯就会输出以下命令执行程序

./hello

就这么简短的一个运行命令,里面就有非常多的学问,涉及到程序的装载,操作系统内存分配,进程调度;在接下去讲进程装载时,有必要介绍下hello这个可执行文件的布局;很多人应该有听过进程的代码段,数据段,bbs,堆,栈等;相应的,在可执行文件中,也有代码段,数据段,bbs;而堆和栈在elf文件中没有相应的项,他俩属于匿名映射;当然elf文件不止之前说的三个段,还有很多,我们可以用readelf命令来查看elf文件中含有的sections:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
charles@charles-Lenovo:~$ readelf -S hello
共有 30 个节头,从偏移量 0x1178 开始:
节头:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000400238 00000238
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000400254 00000254
0000000000000020 0000000000000000 A 0 0 4
[ 3] .note.gnu.build-i NOTE 0000000000400274 00000274
0000000000000024 0000000000000000 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000400298 00000298
000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 00000000004002b8 000002b8
0000000000000060 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400318 00000318
000000000000003d 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000400356 00000356
0000000000000008 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000400360 00000360
0000000000000020 0000000000000000 A 6 1 8

太长了,我就截取这部分sections.在这么多sections,很多我们并不知道他们有什么用,但是有几个setcion我们是必须了解的,因为在理解静态链接库和动态链接库时,需要他们;例如header,.symtab,.rel.text,.rel.data,.dynamic等等;rela.txt和rela.data一般是在可重定向文件才会有,链接生成的可执行文件一般是没有的;但是因为很多section的读写权限是一样的,所以在装载进内存时,这些sections会合并成一个segment,例如下图所示: 进程地址空间和elf文件的映射

图片取自,程序员的自我修养从这幅图可以看出,进程的地址空间的一个段,就相当于elf文件中多个sections;所以我们看到的代码段,并不只是代码,还有可能是字符串常量或者一些常量,因为这些都是可读不可写的,所以可以合并在代码段中.

ok,理解上面所说之后,接着可以说说进程是如何被载入内存了.因为终端也是一个程序,所以当我们在终端输入

./hello

这时,终端会调用fork创建一个子进程,fork函数是一个系统调用,这时会嵌入内核,调用的是clone函数,这个clone函数接着会调用do_fork函数,这个函数做了大部分创建工作,例如创建task_struct,内核栈,thread_info等结构,因为fork函数是采用写时复制技术,因此此时子进程task_struct大部分的属性还是和父进程task_struct一样,主要就是没有为子进程开辟内存空间;当子进程内核结构创建好之后,这时进程调度系统会优先调度子进程,因为一般情况下,子进程会直接调用exec函数避免写时拷贝开销.

这里提下,一次fork调用为什么会返回两个值;因为当fork在内核调用成功,要返回用户态时,如果此时调度子进程执行,那么会把0放入rax寄存器中,等fork返回用户态执行子进程时,从rax得到的就是0;当内核调度的是父进程时,这时会把子进程的id号放入rax寄存器中,等返回到用户态执行父进程时,此时从rax获得的就是子进程的id号;

到这里,还是只是创建了子进程内核的一些结构,接下来,在终端fork的子进程中,会调用exec系列函数

execl("./hello","hello","",NULL);

这个函数会会为子进程hello单独开辟一块内存(之前是和父进程共用内存空间),其实最主要就是为mm_struct结构以及页表重新赋值;具体怎么做了,最主要是调用mmap函数;我们可以把上述图的左边看成是躺在磁盘中的可执行文件,右边对应的是进程在内存中布局;当内核要将可执行文件的代码段映射到进程空间时,内核会先把code segment读取进内核的cache中,然后给hello进程的code段分配一块vma虚拟内存,并把这块虚拟内存映射到在cache中code segment,并把这块vma放入mm_struct的红黑树和链表中.链表适合当需要遍历所有vma内存区域时,而红黑树适合快速获取某个特定内存区域;我们经常查看/proc/<pid>/maps某个进程的内存布局,其实就是便利这个进程的vma链表即可.

数据段也是采用同样的方式载入.但是堆和栈不是采用这种方式,因为他俩在elf文件中没有相应的栈,所以他俩是通过mmap匿名映射的方式分配内存,同时也加入到mm_struct中vma链表和红黑树结构中.

下面这张图更好说明了用户进程空间: 进程用户态控空间

到目前为止,内核已经为hello这个子进程创建了内核task_struct结构,内核栈,thread_info以及用户空间内存结构.按理说应该开始执行程序了吧,但是并没有;在真正执行hello程序之前,首先会把权限交给动态链接器,也就是我们在输出/proc/<pid>/maps的时候,看到的

/lib/x86_64-linux-gnu/ld-2.19.so

这个链接器会把hello这个程序中用到的动态链接库载入用户空间的共享存储区,采用的方法也是内存映射,也会为这些动态链接库生成一个vma结构,放入mm_struct中vma链表和红黑树中.hello这个进程用的最主要就是C运行时库,

/lib/x86_64-linux-gnu/libc-2.19.so

我还是输出hello这个进程的内存结构吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
root@charles-Lenovo:/home/charles# cat /proc/1658/maps
00400000-00401000 r-xp 00000000 08:09 130893 /home/charles/hello
00600000-00601000 r--p 00000000 08:09 130893 /home/charles/hello
00601000-00602000 rw-p 00001000 08:09 130893 /home/charles/hello
7f1a47bc6000-7f1a47d80000 r-xp 00000000 08:06 28902 /lib/x86_64-linux-gnu/libc-2.19.so
7f1a47d80000-7f1a47f80000 ---p 001ba000 08:06 28902 /lib/x86_64-linux-gnu/libc-2.19.so
7f1a47f80000-7f1a47f84000 r--p 001ba000 08:06 28902 /lib/x86_64-linux-gnu/libc-2.19.so
7f1a47f84000-7f1a47f86000 rw-p 001be000 08:06 28902 /lib/x86_64-linux-gnu/libc-2.19.so
7f1a47f86000-7f1a47f8b000 rw-p 00000000 00:00 0
7f1a47f8b000-7f1a47fae000 r-xp 00000000 08:06 28893 /lib/x86_64-linux-gnu/ld-2.19.so
7f1a4818d000-7f1a48190000 rw-p 00000000 00:00 0
7f1a481aa000-7f1a481ad000 rw-p 00000000 00:00 0
7f1a481ad000-7f1a481ae000 r--p 00022000 08:06 28893 /lib/x86_64-linux-gnu/ld-2.19.so
7f1a481ae000-7f1a481af000 rw-p 00023000 08:06 28893 /lib/x86_64-linux-gnu/ld-2.19.so
7f1a481af000-7f1a481b0000 rw-p 00000000 00:00 0
7fff82079000-7fff8209a000 rw-p 00000000 00:00 0 [stack]
7fff820ed000-7fff820ef000 r-xp 00000000 00:00 0 [vdso]
7fff820ef000-7fff820f1000 r--p 00000000 00:00 0 [vvar]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

这些vma段和上述图片是一样的,图片看得更形象;hello这个进程没有堆结构,因为进程运行过程中,并没有申请堆内存.ok,当动态链接库也转载进内存之后,就把控制权交给hello进程执行.

进程是如何执行的?


进程的执行其实就是cpu获取指令以及数据,并进行计算,这里cpu各个寄存器的使用和虚拟内存与物理内存转换等等;而hello这个进程非常简单,就是调用C库函数printf输出一句话.但是其中涉及的过程还是相当复杂的.

首先当执行到printf时,因为hello.c中没有定义这个函数,所以进程会去C库的动态链接库查找,当找到printf之后,进程会跳转到printf函数执行;在printf函数内部,会执行系统调用

write(1, "hello world!\n", 13)

其中1是STDOUT_FILENO表示标准输,对应的就是输出到显示器;到这里,我们可以聊聊系统调用;当执行这个write函数时,因为write是个系统调用,在执行这个write之前,会将参数放入寄存器中,例如1放在rdi,"hello world!\n"字符串指针放入rsi,13放入rdx寄存器中.linux在执行系统调用时,会触发一次int80软中断,并把系统调用号放入rax寄存器中;这时cpu切换到内核软中断处理函数中,怎么找到这个软中断函数?这个说起来,话又很多了(IDTR寄存器和中断描述符表).在中断函数中,找到rax寄存器对应的系统调用,write对应的是sys_write函数,开始执行sys_write函数.在sys_write函数中,会通过fd找到file结构,inode结构等等,最后输出到显示器.

进程是如何退出的


当进程执行结束之后,会调用exit函数,而这个函数调用的系统调用函数_exit()会嵌入内核,进行清除工作.例如释放进程打开的文件,释放进程mm_struct对应的内存(如果没有共享内存)等等,最后只剩下task_struct,内核栈和thread_info三个结构.子进程会给父进程发送一个SIGCHLD信号,表示进程退出;父进程在收到这个信号之后,会调用wait或waitpid函数回收子进程的资源,task_struct,内核栈以及thread_info.到这里,hello进程的生命就算走完了.

总结


这篇文章主要就是介绍linux下面,一个进程的生命过程,从诞生到终结.涉及到非常多的知识,也希望能给大家对进程的一个比较清晰的认识;我并没有写得很深入,因为那样的话,会非常长,大部分人会看不懂.我自己发现有些知识点还需要好好巩固,因为有时候,我并不能非常熟练的表达出来,还需要多研究.

还是那句话,如果有什么出错的地方,还希望各位能够指出来,共同进步.