在工作和学习中,我们经常习惯性的
Ctrl + R
或者F5
一段代码就开始运行了,我们没有时间去思考,这些组合按键背后的逻辑和程序运行时,计算机的细微之处.这是我理思路时的一些细节记录,算不得一篇正式的文章。
作者:胤泽
前言
我们先看一段多态的程序。
1 |
|
程序编译
我们知道 g++ demo.cpp -o demo
会将码源文件编译为可执行程序,然后 ./demo
就可以运行这个程序。g++ 是Linux上默认的C/C编译器,可以指定多线程加速,可以指定C版本,可以指定编译的动态库,但是我很少主动的了解过一些具体的细节。
编译,拆字为 “编” 和 “译”,编解和翻译,将你用字符写的C++编程语言(就是一整串字符串,如果有多个码源文件.cpp / .h 的话就是多个字符串)理解后,翻译为计算机能看懂的语言(二进制机器码)
编译阶段
-
预处理
g++ -E demo.cpp > demo.txt
预处理阶段做的事编译前的准备工作,主要是将这些字符串处理为另外一些字符串, 让编译器编译起来没有障碍,分为三种类型
-
宏替换
宏替换,就是纯粹的字符串匹配与替换的过程。
#define MAX(A,B) (((A)<(B))?(B):(A))
编译器会扫描这些字符串去匹配这个pattern, 然后原地做代码替换。
-
文件包含
#include 的源文件内容拷贝到当前代码展开。
#pragma once 是非标准的预处理语句,指明当前文件只被包含一次,减少编译负担。
-
条件编译
条件编译常见于复杂系统和复杂场景,可以按照不同的条件去编译出不同的程序,已以适应不同的平台和不同场景,比如debug模式和release模式可以条件编译出不同的程序来辅助调试和精简发行代码。
-
去注释
-
-
编译
g++ -S demo.cpp -s demo
编译阶段最为复杂,也是核心中的核心。C++的编译器会试图按照某套规则(语法)来理解你在代码里的指令的意思。
int a
它会知道你想声明一个变量名为a
的局部int变量。当你写了一个for loop 或者do while 循环时,编译器的语法分析器也能按照一套语法产生式来推导出这段代码的含义。比如
statement -> IF ( expression ) statement ELSE statement
编译阶段是将高级语言代码翻译为汇编代码
-
汇编
g++ -c demo.s -o demo.o
汇编阶段将编译生成的汇编代码翻译为对应机器上的机器码(二进制代码)比如x86_64 ARM64 等平台。但是这个时候程序还是不能直接运行,因为单个码源文件是独立开的。当程序运行到
cout<<"base"<<end
的时候会不知所措,因为他不知道怎么去将这个"base"的字节流送到输出流输出到终端,因为cout的实现是在iostream里面。他不知道找不到调用这段代码的接口。此时代码会被分成两个部分:代码段 和 数据段
但是表现为生成一个.o 文件,可重定向目标文件,由各个数据节(section)组成,从0x0000000地址开始
大致布局如下:
汇编阶段生成的是一个可重定向目标文件,显然,下一步的工作就是 重定向 ,其中
.rel.text
和.rel.data
都储存着需要重定向的引用,此处不同于C++ 中的别名引用。此处的引用相当于一次函数调用。于是重定向就是将汇编码中对printf
的引用(调用)替换为这一程序进程中对虚拟地址空间的该函数入口的机器吗所在的程序段地址。 -
链接
我们知道链接的作用就是将一些目标文件链接起来,生成一整个可执行程序文件.out 或者.exe 能够在机器上直接运行。链接过程的主要操作就是在链接这些.o 可重定向文件,执行重定向操作。
-
符号解析
从上面的分析中,我们了解到了.symtab section 表示的符号表节。其实它是一个结构数组,记录add ebp ,B 的 B的一个节内偏移地址,绑定符号,重定向类型等。
这时候,我们在很多的.o文件中可以有很多的符号表,重定向表。
-
重定向
访问所有目标文件的地址重定向表,对其中记录的地址进行重定向到该编译单元在最终的可执行文件里的最终地址。然后遍历未解决符号表与导出符号表一一匹配,在未解决符号表上填写该编译单元再可执行文件里的起始地址。最后把目标问价的内容写在可执行文件里的各自位置上就行了。
有了链接的过程,慢慢的就衍生出了静态链接、动态链接。程序中有很多重复的工作,比如:数学函数、显示等等功能。人们就想到用链接来复用这些高重复的功能。下面我们就来介绍静态链接和动态链接。
-
-
静态链接
ar crv demo.a demo.o
在静态链接过程中,链接器创建E、U、D三个集合。
- E组成可执行文件的所有目标文件的集合;
- U当前所有未解析的引用符号集合;
- D当前所有定义符号的集合;
开始E、U、D为空,首先扫描main.o,把它加入E,同时把引用符号(myFunc)加入U,main加入D。接着扫描mylib.a,将U中的所有符号与mylib.a中所有目标模块依次匹配,如发现file1.o中定义了myFunc,故file1.o加入到E,myFunc从U转移到D。在file1.o中发现还有未解析的符号printf,将其加到U。file2.o中没有匹配的符号,因而它将被丢弃。printf从C标准静态库(libc.a->printf.o指令中无需明显指出)获得匹配。到最后,如果U还有符号,则出现链接错误。
-
动态链接
g++ -fPIC -shared -o ab.so ab.o
动态链接顾名思义,就是链接过程是动态的,由程序运行时动态地链接相应的链接库。有静态链接到定义就可以看出,静态链接的文件都比较大,特别是一些多次调用的场景下,某些函数(比如printf)的函数题反复出现在静态链接库里面,造成程序运行内存空间的极大占用。而且每次库文件升级都得更新。于是就有了动态链接的概念,一些重要的动态链接库比如libc,libm等都放在操作系统的环境变量里。可以直接终端访问。
运行程序
程序运行时,我们需要双击或者终端输入绝对地址就可以执行,这时终端会显示运行结果。
载入内存
首先,我们知道一个程序(a.exe , b.out)是单独二进制文件,但是可执行(executable) 。既然是文件它就得存在于硬盘之中,一个很简单的道理,电脑断电重启之后。为什么之前运行的软件都关闭了,但是文件还在硬盘上呢?很简单,程序文件运行在内存RAM,储存在硬盘Disk中,前者速度很快,断电丢失数据。后者速度较慢,可以长久保存数据。
比如我们在Linux的terminal输入这个可执行程序文件的绝对地址,那么这个程序就会在终端里运行起来。直观感受到的程序运行是和 shell 打交道,由shell帮帮我们运行程序,传递参数。
shell 就是一层壳。作用将终端输入解释为操作系统内核能理解的命令——命令行解释器。
程序加载器
程序加载器也是内核的一种系统调用,作用将程序文件载入内存运行。以Linux为例,程序加载器就是系统调用execve系统调用的句柄。
Linux里每一个程序任务的执行都是以进程为单位的。终端执行./demo.out 时发生了什么。
创建进程
前面的论述已经讲清楚了,可执行文件(可运行程序)只是代码段和数据段集合,是一个存储在硬盘中的静态实体概念。进程是程序在内存中的一次执行的实体,是一个动态的概念。
那么如何创建一进程呢?
内存中进程的存在,可以理解为两部分,一个复杂内核数据结构PCB(进程控制块task_struct)和一段内存空间。
首先,我们知道bash终端也是一个shell进程,任何在终端启动/创建的进程都是bash的子进程。创建终端主要哟三种方式
-
fork
fork创建一个进程时,子进程只是完全复制父进程的资源,复制出来的子进程有自己的task_struct结构和pid,但却复制父进程其它所有的资源。例如,要是父进程打开了五个文件,那么子进程也有五个打开的文件,而且这些文件的当前读写指针也停在相同的地方。所以,这一步所做的是复制。这样得到的子进程独立于父进程,具有良好的并发性,但是二者之间的通讯需要通过专门的通讯机制,如:pipe,共享内存等机制,另外通过fork创建子进程,需要将上面描述的每种资源都复制一个副本。这样看来,fork是一个开销十分大的系统调用,这些开销并不是所有的情况下都是必须的,比如某进程fork出一个子进程后,其子进程仅仅是为了调用exec执行另一个可执行文件,那么在fork过程中对于虚存空间的复制将是一个多余的过程。但由于现在Linux中是采取了copy-on-write(COW写时复制)技术,为了降低开销,fork最初并不会真的产生两个不同的拷贝,因为在那个时候,大量的数据其实完全是一样的。写时复制是在推迟真正的数据拷贝。若后来确实发生了写入,那意味着parent和child的数据不一致了,于是产生复制动作,每个进程拿到属于自己的那一份,这样就可以降低系统调用的开销。所以有了写时复制后呢,vfork其实现意义就不大了。
fork()调用执行一次返回两个值,对于父进程,fork函数返回子程序的进程号,而对于子程序,fork函数则返回零,这就是一个函数返回两次的本质。
在fork之后,子进程和父进程都会继续执行fork调用之后的指令。子进程是父进程的副本。它将获得父进程的数据空间,堆和栈的副本,这些都是副本,父子进程并不共享这部分的内存。也就是说,子进程对父进程中的同名变量进行修改并不会影响其在父进程中的值。但是父子进程又共享一些东西,简单说来就是程序的正文段。正文段存放着由cpu执行的机器指令,通常是read-only的
-
vfork
很多时候,我们做完fork之后,会系统调用exec()执行另外一个程序,这样子不会对父进程的地址空间有任何引用,这样fork对地址空间的复制是多余的。于是就有了vfork()系统调用,用vfork调用创建的子进程不需要拷贝地址空间,子进程与父进程共享。vfork()创建的子进程必须显示调用exit()来结束,否则子进程将不能结束,而fork()则不存在这个情况。
Vfork也是在父进程中返回子进程的进程号,在子进程中返回0。
用 vfork创建子进程后,父进程会被阻塞直到子进程调用exec(exec,将一个新的可执行文件载入到地址空间并执行之。)或exit
-
clone
系统调用fork()和vfork()是无参数的,而clone()则带有参数。fork()是全部复制,vfork()是共享内存,而clone()是则可以将父进程资源有选择地复制给子进程,而没有复制的数据结构则通过指针的复制让子进程共享,具体要复制哪些资源给子进程,由参数列表中的clone_flags来决定。另外,clone()返回的是子进程的pid。
你在终端执行命令行操作的子进程是bash进程子进程,程序加载入内存,此时,进程内存看起来应该是这样的
标为灰色的地方在进程虚拟地址空间中不可用,也就是说,没有为这些地方创建页表。
虚拟内存
内存RAM的概念是为了解决过快的CPU处理速度和较慢的磁盘读取速度造成了的CPU资源利用不充分的中间人。CPU直接和内存索要数据,内存向磁盘要数据。CPU还有多级缓存机制。那么为什么会有虚拟内存的概念呢?
物理内存就是你电脑上的内存条的内存大小(4G,8G,16G等)。
但是如果每一个进程的存在都是在内存上查找一段连续的物理内存时,很容易造成内存使用效率低下。还有对于进程隔离性的考虑,虚拟内存分页的存在保证了进程之间的相互不影响已经内存利用效率高。每一个进程都被分割成等大的内存页,用一个映射表(map)由操作系统负责将内存映射到真实的物理内存上,对于内存运行的逻辑来说,虚拟内存上是连续的,但是实际执行的物理内存是不同的
内存有分段分页机制。
程序运行
终于到程序运行的阶段。当前进程得到CPU时间后,Linux的用户空间里程序开始运行,CPU堆栈寄存器指针指向用户栈。当运行到输入输出时需要系统调用时,需要切换到内核态,这时,内核栈顶地址写入ESP寄存器(堆栈寄存器)https://www.cnblogs.com/justcxtoworld/p/3155741.html
现在回到上面的例子。
类A,B,C的对象都在main()函数的函数栈帧里面——就在进程的栈里。显然,由于B,C都实现了A的虚函数,B,C的对象(instance)都会存有一份虚表指针,这个虚表指针指向B类的虚函数表,这份虚函数表位于可执行文件的只读数据段(.rodata)中,在程序载入内存时会跟随进入数据段。
程序的动态绑定实现运行时多态。
第一次输出执行B::func1();
第二次输出执行C::func2();
程序结束
子进程如果作为vfork()的生成进程,如果用return()返回韩素,这个子进程将成为僵尸进程,无法被销毁。因为vfork 生成的父子进程共享地址空间的相同的所有资源。系统调用应该用系统返回exit() 正对应函数调用使用return返回。如果我们在子进程中用return返回,进程栈会弹出main函数调用的栈帧,但对于return返回,会清空进程栈帧,但是,父进程和子进程共享数据空间,如果return改变了父进程之前的栈帧的话,父进程就没法返回了。