深入理解计算机系统---程序运行过程

本文详细介绍了C程序从编写到执行的全过程,包括预处理、编译、汇编、链接和装载执行。通过分析hello.c程序,阐述了预处理如何处理头文件、宏定义等,编译阶段如何生成汇编代码,汇编阶段如何转换为机器指令,链接阶段如何处理目标文件并生成可执行文件,以及装载执行时的内存分配和程序启动流程。文章还探讨了静态链接和动态链接的区别,并对进程的内存管理和执行流程进行了简要说明。

一个简单的C程序从编写到执行输出hello world!其中间经历的是诸多处理过程,而不仅仅是显示黑屏上的几个字符。这个过程透露着计算机系统的运行本质。

个人对该过程进行了一些分析和总结,如果有不对的地方,请在评论区留言交流。

程序运行过程

用一个Linux下的hello.c程序来依次说明上述过程,使用的编译器是gcc。

#include <stdio.h>

int main()
{
    printf("hello world");
    return 0;
}

预处理

预编译过程对源代码做了如下的操作

  • 删除所有的注释信息
  • 删除所有的 #define 并展开所有宏定义
  • 插入所有 #include 文件注 1 的内容到源文件中的对应位置,include 过程是递归执行的
gcc -E hello.c -o hello.i

使用vim查看后,如下图所示,原本几行的程序被扩展成了800行,其中主要部分就是对于stdio.h头文件的内容进行文本替换

编译

编译就是对预处理之后的文件进行词法分析、语法分析、语义分析并优化后生成相应的汇编文件。

即从C语言变成汇编语言。

gcc -S hello.i -o hello.s

如下图所示,从pushq %rbp开始,都是汇编语言。所以学习汇编语言还是有必要的,因为任何语言的程序在编译之后,就会变成这样的代码,看懂汇编,就能看懂程序运行的底层过程。 

汇编

汇编的目的是把汇编代码转化为机器指令,因为几乎每一条汇编指令都对应着一条机器指令。

机器指令就是0/1二进制。而在计算机中常常会以16进制的形式去存储数据,再转为二进制。

即所有程序到最后都是一串0101的码流,这也是大家在看电脑黑客之类的影视时,大部分看到的都是01码流的原因。

gcc -c hello.s -o hello.o

这里最后生成.o文件,但是不能再用vim去查看了,使用 objdump 命令

文件内容如下:右边是对应的汇编代码,而左边则是16进制的数字。

ubuntu@VM-0-16-ubuntu:/home/test$ sudo objdump -d hello.o

hello.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # b <main+0xb>
   b:	b8 00 00 00 00       	mov    $0x0,%eax
  10:	e8 00 00 00 00       	callq  15 <main+0x15>
  15:	b8 00 00 00 00       	mov    $0x0,%eax
  1a:	5d                   	pop    %rbp
  1b:	c3                   	retq

链接

在链接之前,需要补充一点额外的知识,关于目标文件

(1)目标文件

有三种形式:

1.可重定位目标文件

一般为.o文件,保护二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

2.可执行目标文件

一般在Windows下为.exe,在Linux下为.o

包含二进制代码和数据,其形式可以直接被复制到内存并执行

3.共享目标文件

一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接

(2)目标文件格式

不同系统的目标文件格式不同。第一个Unix系统使用的是a.out格式;Windows使用可移植可执行PE格式;Mac使用Mach-O格式;现代Linux使用 可执行可链接ELF  格式

因此,可重定位目标文件,可执行目标文件,共享目标文件三种目标文件都是ELF格式的文件。

ELF文件分析

 我们可以使用readelf来查看刚刚汇编之后的可重定位目标文件hello.o的构成

ubuntu@VM-0-16-ubuntu:/home/test$ sudo readelf -S hello.o
There are 13 section headers, starting at offset 0x2d0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       000000000000001c  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000220
       0000000000000030  0000000000000018   I      10     1     8
  [ 3] .data             PROGBITS         0000000000000000  0000005c
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  0000005c
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  0000005c
       000000000000000d  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  00000069
       000000000000002a  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  00000093
       0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  00000098
       0000000000000038  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000250
       0000000000000018  0000000000000018   I      10     8     8
  [10] .symtab           SYMTAB           0000000000000000  000000d0
       0000000000000120  0000000000000018          11     9     8
  [11] .strtab           STRTAB           0000000000000000  000001f0
       000000000000002b  0000000000000000           0     0     1
  [12] .shstrtab         STRTAB           0000000000000000  00000268
       0000000000000061  0000000000000000           0     0     1

可以看到内容和上表基本上是一致的,对于ELF header,也可以查看

ubuntu@VM-0-16-ubuntu:/home/test$ sudo readelf -h hello.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          720 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         13
  Section header string table index: 12

对于ELF文件,就说到这里,关于里面具体的每部分含义,可以自行百度再深入了解。我主要再说几个比较重要的部分。

.text

.rodata

.data

.bss

第一个.text是代码段,后面三个合起来是.dat数据段,只不过将数据段分成了已初始化.data,未初始化.bss,和只读数据.rodata三个部分。

之所以说这几个部分,是因为它们是属于程序 内存四区模型中的 两个段,另外两个是堆栈。

最后再介绍两个和链接相关的部分.real.text  和  .symtab

重定位表

.rela.text 存放了需要被重定位的指令的信息;同样的如果是需要被重定位的数据则段名应该叫做.rela.data;使用readelf命令可以查看内容

符号表(.symtab)

目标文件中的某些部分是需要在链接的时候被使用到的 “粘合剂”,这些部分我们可以把其称之为 “符号”,符号就保存在符号表中。符号表中保存的符号很多,其中最重要的就是定义在本目标文件中的可以被其它目标文件引用的符号和在本目标文件中引用的全局符号,这两个符号呈现互补的关系;使用nm命令进行查看

ubuntu@VM-0-16-ubuntu:/home/test$ sudo nm hello.o
                 U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
                 U printf

其中 D 代表该符号是已经初始化的变量,T 表示该符号是指令,U 代表该符号尚未定义;

这里由于没有定义变量,所以没有D,但是需要知道有D这个符号。

总结:

重定位表和符号表之间是相互合作的关系,链接器首先要根据重定位表找到该目标文件中需要被重定位的符号,之后再根据符号表去其它的目标文件中找到可以相匹配的符号,最后对本目标文件中的符号进行重定位

在讲完目标文件之后,现在我们进入主题,链接。

链接可以分为2种:

1.静态链接

(1)为什么要进行静态链接
在我们的实际开发中,不可能将所有代码放在一个源文件中,所以会出现多个源文件,而且多个源文件之间不是独立的,而会存在多种依赖关系,如一个源文件可能要调用另一个源文件中定义的函数,但是每个源文件都是独立编译的,即每个*.c文件会形成一个*.o文件,为了满足前面说的依赖关系,则需要将这些源文件产生的目标文件进行链接,从而形成一个可以执行的程序。这个链接的过程就是静态链接

(2)静态链接的原理
由很多目标文件进行链接形成的是静态库,反之静态库也可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。

下图为最基本的静态链接过程示意图

链接过程主要包含了三个步骤:

1.地址与空间分配(Address and Storage Allocation)

现在使用的最多的方式是合并相似节;如下图:

比如:将所有输入文件的 .text合并到输出文件的 text

其中.bss节在目标文件和可执行文件中不占用文件的空间,但是它在装载时占用地址空间。事实上,这里的空间和地址有两层含义:

  1. 在输出的可执行文件中的空间
  2. 在装载后的虚拟地址中的空间

对于有实际数据的节,如.text.data,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;对于.bss来,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。我们在这里谈到的空间分配只关注于虚拟地址空间的分配,因为这关系到链接器后面的关于地址计算的步骤,而可执行文件本身的空间分配与链接的关系并不大。

现在的链接器空间分配的策略基本上都采用“合并相似节”的方法,使用这种方法的链接器一般采用一种叫 两步链接(Two-pass Linking) 的方法。即整个链接过程分为两步:

  • 第一步 地址与空间分配
    扫描所有的输入目标文件,获得它们的各个节的长度、属性、位置,并将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局的符号表。这一步,链接器能够获得所有输入目标文件的节的长度,并将它们合并,计算出输出文件中各个节合并后的长度与位置,并建立映射关系。
  • 第二步 符号解析与重定位
    使用前一步中收集到的所有信息,读取输入文件中节的输数据、重定位信息,并且进行符号解析与重定位、调整代码、调整代码中的地址等。事实上,第二步是链接过程的核心,尤其是重定位。

上面这段引用的话其实不用看,我们知道链接的过程和目睹接口

2.符号解析(Symbol Resolution)

3.重定位(Relocation)

关于符号解析和重定位,在上述分析ELF文件的时候,相信已经讲清楚了,这里就不再展开,否则内容过多了。

总结:静态链接最后会生成可执行目标文件。

静态链接的缺点很明显:一是浪费空间;二是更新比较困难,每当库函数代码修改,就需要进行重新编译链接形成可执行程序。但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。

上述hello.c的程序,就使用静态链接,来生成可执行目标文件。

ubuntu@VM-0-16-ubuntu:/home/test$ sudo gcc hello.c -o hello
ubuntu@VM-0-16-ubuntu:/home/test$ ls
hello  hello.c  hello.i  hello.o  hello.s
ubuntu@VM-0-16-ubuntu:/home/test$ ./hello
hello world!

这里没有写额外的.c文件来制作静态链接库,默认使用stdio.h来进行静态链接。

最后执行可执行目标文件hello,看到输出了 hello world!

注意:这个hello仍然是一个ELF文件,可以使用readelf 或者 objdump  进行查看:

ubuntu@VM-0-16-ubuntu:/home/test$ sudo objdump -h hello

hello:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .interp       0000001c  0000000000000238  0000000000000238  00000238  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .note.ABI-tag 00000020  0000000000000254  0000000000000254  00000254  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .note.gnu.build-id 00000024  0000000000000274  0000000000000274  00000274  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .gnu.hash     0000001c  0000000000000298  0000000000000298  00000298  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .dynsym       000000a8  00000000000002b8  00000000000002b8  000002b8  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  5 .dynstr       00000082  0000000000000360  0000000000000360  00000360  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  6 .gnu.version  0000000e  00000000000003e2  00000000000003e2  000003e2  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  7 .gnu.version_r 00000020  00000000000003f0  00000000000003f0  000003f0  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  8 .rela.dyn     000000c0  0000000000000410  0000000000000410  00000410  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  9 .rela.plt     00000018  00000000000004d0  00000000000004d0  000004d0  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 10 .init         00000017  00000000000004e8  00000000000004e8  000004e8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 11 .plt          00000020  0000000000000500  0000000000000500  00000500  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 12 .plt.got      00000008  0000000000000520  0000000000000520  00000520  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 13 .text         000001a2  0000000000000530  0000000000000530  00000530  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 14 .fini         00000009  00000000000006d4  00000000000006d4  000006d4  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 15 .rodata       00000011  00000000000006e0  00000000000006e0  000006e0  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 16 .eh_frame_hdr 0000003c  00000000000006f4  00000000000006f4  000006f4  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 17 .eh_frame     00000108  0000000000000730  0000000000000730  00000730  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 18 .init_array   00000008  0000000000200db8  0000000000200db8  00000db8  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 19 .fini_array   00000008  0000000000200dc0  0000000000200dc0  00000dc0  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 20 .dynamic      000001f0  0000000000200dc8  0000000000200dc8  00000dc8  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 21 .got          00000048  0000000000200fb8  0000000000200fb8  00000fb8  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 22 .data         00000010  0000000000201000  0000000000201000  00001000  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 23 .bss          00000008  0000000000201010  0000000000201010  00001010  2**0
                  ALLOC
 24 .comment      00000029  0000000000000000  0000000000000000  00001010  2**0
                  CONTENTS, READONLY

2.动态链接

为了解决静态链接的两个缺点,产生了动态链接。

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

举个例子:

假设现在有两个程序program1.o和program2.o,这两者共用同一个库lib.o,假设首先运行程序program1,系统首先加载program1.o,当系统发现program1.o中用到了lib.o,即program1.o依赖于lib.o,那么系统接着加载lib.o,如果program1.o和lib.o还依赖于其他目标文件,则依次全部加载到内存中。当program2运行时,同样的加载program2.o,然后发现program2.o依赖于lib.o,但是此时lib.o已经存在于内存中,这个时候就不再进行重新加载,而是将内存中已经存在的lib.o映射到program2的虚拟地址空间中,从而进行链接(这个链接过程和静态链接类似)形成可执行程序

其实,程序到这里变成了可执行目标文件,基本已经结束了。但是在执行./hello这个命令,运行可执行目标文件的过程中,又发生了什么呢?

下面两个小节将对这个问题进行解释。

装载

得到可执行目标文件,想要执行它,就要先把它放到内存中去。而内存对于可执行目标文件中的诸多段是不在乎的,只关注它的读写权限:是可读不可写,还是可读可写。

现代操作系统多采用虚拟内存对内存进行分配和管理。内存分配采用段页式结合的方式来管理内存。所以操作系统只需要读取可执行文件的文件头,然后建立起可执行文件到虚拟内存的映射关系,而不需要真正的将程序载入内存。

即装载不会造成任何的数据复制行为(从磁盘到内存),只有在程序开始运行,产生第一个缺页开始,内存才会将页面从磁盘传送到内存。

在程序的运行过程中,CPU 发现有些内存页在物理内存中并不存在并因此触发缺页异常,此时 CPU 将控制权限转交给操作系统的异常处理函数,操作系统负责将此内存页的数据从磁盘上读取到物理内存中(在磁盘上读取文件的过程和操作系统的内存管理,文件系统知识相关)。数据读取完毕之后,操作系统让 CPU jmp 到触发了缺页异常的那条指令处继续执行,此时指令执行就不会再有缺页异常了。

两个问题

1.内存是怎么读取文件的?(或者说CPU和内存进行数据交互的过程)

2.磁盘是怎么读取文件的?(或者说缺页中断时,磁盘怎么找到文件的过程)

感兴趣的可以思考一下上述过程中涉及的这两个问题,怎么解释,如果知道了,那么就理解了计算机系统程序运行的完整流程了,可能还差个I/O,不过不重要。

最后给一个进程装载时的内存分配图,我在另外一篇文章中也提到过。

执行

在完成地址映射之后,操作系统会jmp 到进程的第一条指令并不是main方法,

而是别的代码。这些代码负责初始化 main 方法执行所需要的环境并调用 main 方法执行,运行这些代码的函数被称为入口函数或者入口点(Entry Point)。

一个程序的执行过程如下:

(1)操作系统在创建进行之后,jmp 到这个进程的入口函数

(2)入口函数对程序运行环境进行初始化,包括堆,I/O,线程,全局变量的构造,等等

(3)入口函数在执行完初始化之后,调用main函数,开始执行程序的主题

(4)main函数执行完毕后返回到入口函数,入口函数进行清理工作,最后通过系统调用结束进程。

总结:

最后以hello.c程序进行分析。

在链接完成之后,生成了hello这个可执行目标文件,它是怎么执行的呢。

首先,shell本身是一个进程,执行./hello命令时会将该文件复制到内存中,然后从入口函数开始执行,此时操作系统从shell这个进程跳转到./hello这个进程,./hello这个进程又创建了一个字线程来执行hello程序,当执行完毕之后,子线程结束运行,./hello进程也被销毁,又回到了shell进程之中。只打印一个字符串,速度很快,基本感受不到变化,我们可以加上大的延时,然后循环打印。再打开另一个shell终端查看运行中的进程。

要注意每新建一个进程就会创建新的地址空间。

附上参考链接:

程序的编译、链接、装载与运行 - 御坂研究所 (nosuchfield.com)

(10条消息) 计算机那些事(4)——linux可执行文件格式之ELF文件结构_jinking01的专栏-CSDN博客

书籍《深入理解计算机系统》

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值