重学计算机(六、程序是怎么运行的)

今天我们又来肝一个重要的主题。不知道大家有没有思考过,程序是怎么运行起来的?

肯定有同学说在linux下./hello_world就可以执行了,在windows下双点hello_world.exe文件就可以运行了。

是不是都不知道这里面发现了什么,今天就带着这个疑惑来寻找答案吧。

6.1 重温elf文件

首先,我们先来重温重温elf文件,看看还有多少记得这个东西,不记得也可以回去看:重学计算机(三、elf文件布局和符号表)

这里就重温几个下面用的,关于链接后那么多段,之后会分析一些,以后再看看。

首先是程序的入口地址:

Entry point address: 0x400430

这个地址就是程序执行的第一个代码,经过前面的分析,这个地址也刚好是.text段开始的地址。

接下来就是我们的program headers

root@ubuntu:~/c_test/05# readelf -l hello_world

Elf file type is EXEC (Executable file)
Entry point 0x400430
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x00000000000001f8 0x00000000000001f8  R E    8
  INTERP         0x0000000000000238 0x0000000000400238 0x0000000000400238
                 0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x000000000000080c 0x000000000000080c  R E    200000
  LOAD           0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x000000000000023c 0x0000000000000250  RW     200000
  DYNAMIC        0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
                 0x00000000000001d0 0x00000000000001d0  RW     8
  NOTE           0x0000000000000254 0x0000000000400254 0x0000000000400254
                 0x0000000000000044 0x0000000000000044  R      4
  GNU_EH_FRAME   0x0000000000000690 0x0000000000400690 0x0000000000400690
                 0x0000000000000044 0x0000000000000044  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x00000000000001f0 0x00000000000001f0  R      1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag .note.gnu.build-id 
   06     .eh_frame_hdr 
   07     
   08     .init_array .fini_array .jcr .dynamic .got

在elf文件中,已经存在了好多个section了,那为什么还需要添加一个segment段。

那是因为在elf文件装载的时候,如果每个section段都需要映射到内存中(内存是以页做为单位的,这个留到后面介绍),那么多的段,占的内存空间真多,所以操作系统就想到把属性相似的段再做合并,这样就形成了一个新的segment。

通过查看上面的属性,segment段的属性只是分为三种:

  1. 以代码段为代表的可读可执行段
  2. 以数据段和BSS段为代表的可读可写段
  3. 以只读数据为代表的只读段

看到合并后的segment也是以权限相似的方式合并。其中有两个segment段比较重要:

LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x000000000000080c 0x000000000000080c  R E    200000
LOAD           0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x000000000000023c 0x0000000000000250  RW     200000

这两个段都是加载阶段,需要加载的,看到对齐方式0x200000,8k对齐,怎么不是4k对齐了,奇怪。

上面这个从0x0000000000400000开始的是代码段(当然还有其他那些一大堆),然后从0x0000000000600e10开始的是数据段,通过权限也能看的出来。

6.2 程序是怎么运行的

再次重温了elf文件,是不是感觉这些设计真的好巧妙,一环扣一环。

我们在上面也说了,linux系统中,要执行目标文件hello_world,需要在命名行中执行:

linux> ./hello_world

当我们很愉快的输入上面的指令的时候,并且看到屏幕上输出hello world的时候,我们是不是很高兴,觉得C语言这么简单。

这样想的话,只能说,同学你飘了,很飘了。

就问一个简单的问题:./hello_world的背后做了啥?我们来一探究竟。

6.2.0 跟踪程序

linux系统提供了一个跟跟踪程序运行的命令,这个命令就是strace,下面就是这个命令的使用。

root@ubuntu:~/c_test/05# strace ./hello_world
execve("./hello_world", ["./hello_world"], [/* 22 vars */]) = 0
brk(NULL)                               = 0x1d2d000

//动态链接库
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=40175, ...}) = 0
mmap(NULL, 40175, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f5592f48000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
// c库
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\t\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=1868984, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5592f47000
mmap(NULL, 3971488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f5592963000
mprotect(0x7f5592b23000, 2097152, PROT_NONE) = 0
mmap(0x7f5592d23000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1c0000) = 0x7f5592d23000
mmap(0x7f5592d29000, 14752, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f5592d29000
close(3)                                = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5592f46000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5592f45000
arch_prctl(ARCH_SET_FS, 0x7f5592f46700) = 0
mprotect(0x7f5592d23000, 16384, PROT_READ) = 0
mprotect(0x600000, 4096, PROT_READ)     = 0
mprotect(0x7f5592f52000, 4096, PROT_READ) = 0
munmap(0x7f5592f48000, 40175)           = 0
fstat(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(136, 0), ...}) = 0

brk(NULL)                               = 0x1d2d000
brk(0x1d4e000)                          = 0x1d4e000
write(1, "i = 0\n", 6i = 0
)                  = 6
write(1, "i = 85 0 84\n", 12i = 85 0 84
)           = 12
write(1, "hello world 0 1 0\n", 18hello world 0 1 0
)     = 18

exit_group(0)                           = ?
+++ exited with 0 +++

突然觉得这是给自己找了一个坑啊,想不到这个命令这么给力,这是把整个hello_world程序的运行过程都描述出来了,下面就开始苦逼之旅吧。

6.2.1 fork函数出场

其实在linux系统中,执行一个命令时,首先是由shell调用fork函数,strace是追踪程序的,所以并没有追踪到这个fork函数。

6.2.1.1 程序和进程的区别

其实这个fork函数是创建一个进程的,这里是不是就有一个疑问了,我们不是说程序是怎么运行的,怎么扯到进程了?

这个就要好好解释一波了,程序和进程由什么区别?

  • 程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件。
  • 进程则是一个动态概念,它是程序运行时的一个过程。

所以第一步bash进程会调用fork系统调用创建一个新的进程,这个进程其实就是父进程的复制品,就是啥都是复制父进程的东西,不过后来引入了一个写时复制技术,就是在用的时候,才复制,刚调用fork的时候,就是复制个地址(其实就是跟父进程共享)。

6.2.1.2 fork的原型
#include <unistd.h>
pid_t fork(void);

关于fork的详细介绍可以看后面的章节。

6.2.2 execve()加载

一般我们运行程序,都是先调用fork之后,紧接着就调用这个execve()函数,这个函数就是运行一个新的程序,上面的fork是创建一个新的进程,然后execve就是在这个新进程里,运行一个新程序。

这个函数都是做一个重活累活,包括有:将新程序加载到进程的地址空间,丢弃旧有的程序,进程的栈、数据段、堆栈等会被新程序替换。

execve函数原型:

#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp);

看看这个函数才像是创建进程的函数,参数这么多,其实也确实是这个进程主要是执行程序,fork()更多是复制父进程,复制父进程的话肯定不需要额外的参数了(就像我们天天在复制粘贴写代码,也就不要太多的技术了)。

现在介绍一下参数,这些参数后面都会提到:

  • const char *filename:程序文件名。好像是需要绝对路径和相对于当前工作目录的相对路径
  • char *const argv[]:这个变量是不是很熟悉,就是我们main函数中的参数。
  • char *const envp:最后一个是环境变量,我们之前写代码,是不是可以直接用环境变量,就是这里传参的,但是这个需要我们传入环境变量,感觉还是不是很方便。

所以Glibc对execve()函数进行了封装,封装了5个函数,不过最后还是会调用这个函数的,这个我们以后再讲。

6.2.3 读取可执行文件头信息

我们知道linux下可以执行很多种程序,比如elf格式的,shell,python,还有java程序,那我们的linux系统是怎么知道这些格式的?

没错,就是一个文件的头信息,我记得当初有分析过hex,bin,还有elf文件,每种格式文件都有自己的头信息,所以linux就是根据这个头信息来执行的。

比如ELF的头就是0x7f、‘e’、‘l’、‘f’,java的可执行文件格式的头4个字节为’c’、‘a’、‘f’、‘e’,解释型语言,第一行就是"#!/bin/sh"或"#!/usr/bin/prel"或"#!/usr/bin/python"。

竟然头信息都找到,那接下来就,根据文件的类型,去匹配不同的加载器,当然我们主要讲ELF文件,所以后面都是讲跟ELF文件相关的。

6.2.4 寻找动态库

会寻找动态链接的".interp"段,设置动态链接器路径。我们可以查看一下:

root@ubuntu:~/c_test/05# readelf --string-dump=1 hello_world

String dump of section '.interp':
  [     0]  /lib64/ld-linux-x86-64.so.2

root@ubuntu:~/c_test/05# 

动态链接库在后一节才讲,现在就先放下,不过我们可以看strace命令追踪的过程:

//动态链接库
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=40175, ...}) = 0
mmap(NULL, 40175, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f5592f48000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
// c库
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\t\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=1868984, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5592f47000
mmap(NULL, 3971488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f5592963000
mprotect(0x7f5592b23000, 2097152, PROT_NONE) = 0
mmap(0x7f5592d23000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1c0000) = 0x7f5592d23000
mmap(0x7f5592d29000, 14752, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f5592d29000
close(3)                                = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5592f46000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5592f45000
arch_prctl(ARCH_SET_FS, 0x7f5592f46700) = 0
mprotect(0x7f5592d23000, 16384, PROT_READ) = 0
mprotect(0x600000, 4096, PROT_READ)     = 0
mprotect(0x7f5592f52000, 4096, PROT_READ) = 0
munmap(0x7f5592f48000, 40175)           = 0
fstat(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(136, 0), ...}) = 0

其中这部分就是在搞动态库的:

  • 如果ld.so.nohwcap存在,则ld会加载其中未优化版本的库。

  • 如果ld.so.preload存在,ld会加载其中的库——在一些项目中,我们需要拦截或替换系统调用或c库,此时就会使用这个机制,使用LD_PERLOAD来实现。

    大家可以百度看看这个机制:

    [【Linux】LD_PRELOAD用法](https://www.cnblogs.com/saolv/p/9761371.html)

  • ld.so.cache,这个文件中保存了库的路径,并且程序在运行中,打开了这个文件,还用mmap把文件内容,映射到内存中,如果不知道mmap这个函数也是后面讲。大家可以看看这个链接:

    [LD_LIBRARY_PATH 和 /etc /ld.so.cache文件](http://blog.chinaunix.net/uid-25304914-id-3046279.html)

c库也是一个动态库,也到下一节讲吧。

分析了一下这个命令strace,发现处理都是处理动态库的,怀着这个疑惑,我们来静态链接一次。

# 静态链接的hello_world
root@ubuntu:~/c_test/05# strace ./hello_world
execve("./hello_world", ["./hello_world"], [/* 22 vars */]) = 0
uname({sysname="Linux", nodename="ubuntu", ...}) = 0
brk(NULL)                               = 0x2358000
brk(0x23591c0)                          = 0x23591c0
arch_prctl(ARCH_SET_FS, 0x2358880)      = 0
readlink("/proc/self/exe", "/root/c_test/05/hello_world", 4096) = 27
brk(0x237a1c0)                          = 0x237a1c0
brk(0x237b000)                          = 0x237b000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
fstat(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(136, 0), ...}) = 0
write(1, "i = 0\n", 6i = 0
)                  = 6
write(1, "i = 85 0 84\n", 12i = 85 0 84
)           = 12
write(1, "hello world 0 1 0\n", 18hello world 0 1 0
)     = 18

发现上面用mmap和mprotect都是在处理动态库,下一节会讲,这样就先忽略。

6.2.5 程序加载

前面那些装备工作做完了之后,这一步开始才是真正的程序加载,这一步我们会看到加载器根据ELF文件的program head,来对程序的代码段和数据段进行加载。

在6.1中就已经描述了,加载器会根据load段把程序加载到内存中,我们可以看看进程下面的maps,就清楚了。

root@ubuntu:/proc/1647# cat maps 
00400000-004ca000 r-xp 00000000 08:01 11548956                           /root/c_test/05/hello_world     # 代码段
006c9000-006cc000 rw-p 000c9000 08:01 11548956                           /root/c_test/05/hello_world	# 数据段
006cc000-006ce000 rw-p 00000000 00:00 0 
00aa9000-00acc000 rw-p 00000000 00:00 0                                  [heap]
7fff71cc8000-7fff71ce9000 rw-p 00000000 00:00 0                          [stack]
7fff71d1e000-7fff71d21000 r--p 00000000 00:00 0                          [vvar]
7fff71d21000-7fff71d23000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

前面两个就是描述的是两个load段,可以对比一下虚拟地址,发现是一模一样的。

当然还有进程准备的栈和堆的内存地址,在maps都可以看到。

6.2.6 初始化进程环境和跳转

程序准备运行的时候,还是需要初始化一些进程环境,最基本的就是系统环境变量和进程的运行参数。

设置栈顶寄存器,设置PC寄存器等。

这个PC寄存器设置的就是前面说的程序入口地址。

设置成功后,可以跳转了,开始执行程序了。(当然好多细节都省略了)

6.2.7 总结

  1. bash进程会调用fork系统调用创建一个新的进程。(这个后面详细讲)

  2. 新进程会调用execve()系统调用执行指定的ELF文件(hello_world文件)

  3. 在进入exevce()系统调用之后,linux就会调用加载器,进行真正的装载工作。

  4. 加载器会删除子进程现有的虚拟内存段。

  5. 加载器会首先查找被执行的文件,如果找到,就读取头。(现在知道elf头信息的用处了吧)

  6. 加载器会根据不同的头,去做不同文件的解析。

  7. 寻找动态链接的".interp"段,设置动态链接器路径。(这个下一篇介绍)

  8. 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据

  9. 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该说是DT_FINI的地址。

  10. 修改PC寄存器的值,如果是静态链接的ELF文件,就在在文件头的程序入口地址,如果是动态链接的ELF文件,程序入口点是动态链接器。

  11. 跳转执行

6.3 总结

这一波不总结不行啊,这篇文章写的就有点不太清楚明白,更像是后面的文章的开头篇,其实也正是这样,后面会介绍进程,内存等东西,这篇文章就先这样吧,等到写到后面之后,发现这篇文章需要调整,再回来调整,感觉还是写的不好。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值