首先,要搞明白任何编程语言只会产生两种东西: 指令和数据。
比如Linux下的可执行文件,windows的exe文件。它们本质上是(CPU)指令和一组数据集。
那为什么要搞清楚进程的虚拟地址空间内存划分和布局呢?因为可执行文件是放在磁盘上的,而它们要执行需要加载到内存中,不可能直接加载到物理内存的!
现代操作系统往往引入一个概念比如Linux会给当前进程分配一个2的32次幂(4G)的一块空间。(64位操作系统位2的64次幂)。
这里可能会有人疑惑这不是存在的吗?为什么叫虚拟?这里我说一个口诀:
它存在,你能看得见,它是物理的
它存在,你不能看得见,它是透明的
它不存在,你却看得见,它是虚拟的
它不存在,你也看不见,它被删除了
虚拟进程地址空间的本质就是一个32位无符号的整型数,系统中每个进程所使用的地址就是虚拟空间地址。然后这个地址可能不存在于物理内存,只是操作系统分配的。
- 首先看第一段预留的不可用的,这一段解释了为什么野指针容易造成程序崩溃。看以下代码:
char *p = nullptr;
strlen(p);
char *src = nullptr;
strcpy(dest, src); // 由于使用了不可用的地址空间,程序会报错奔溃。
- 接下来的内存是.text和.rodata。生成的指令放在.text空间,而.rodata为只读data段不允许写入。下面再看一段代码
char *p = "hello world"; // hello world是只读的,而p却在stack上
*p = 'a'; // 挂掉,只能读
// 正确声明如下
const char *p = "hello world";
-
然后是.data和.bss段,.data存放初始化及初始化不为0的数据,.bss存放未初始化及初始化为0的数据。
-
在下一段是heap(堆)内存,是程序员自己通过分配内存函数(malloc,free,new,delete等)申请的空间
-
接着是加载共享库(.so)的空间。
-
然后是Stack(栈)空间,函数运行的空间,同时它是线程私有空间。
-
用户空间的最后还需要存储一些额外的变量,比如你有一个可执行文件
./a.out 192.168.1.100 9090
a.out后面的参数就是这一段存储的,这一部分存储命令行参数和环境变量。 -
最后是非用户空间,用来存储操作系统所需要的一些信息。
在这里要明确,每一个进程的用户空间是私有的,但内核空间却是共享的。
同时要明白进程之间有一些通信手段。比如匿名管道通信来实现进程的配合。
下面是我写好的代码,来判断一下它的数据属于什么空间吧。
下面是代码:
#include <iostream>
using namespace std;
// 这一部分全局变量(无论是否静态)都叫数据,都会产生符号
int gdata1 = 10; // 放在data段
int gdata2 = 0; // bss
int gdata3; // bss
static int gdata4 = 11; // data
static int gdata5 = 0; // bss
static int gdata6; // bss
// int main()在text字段
int main()
{ // 局部变量生成指令不会生成符号,a在栈上是指令运行后把a放在了栈上
int a = 12; // mov dword ptr[a], 0ch
int b = 0; // 这三个放在.text段因为是指令
int c;
// 静态局部变量,放在数据段,第一次运行时初始化
static int e = 13; // data
static int f = 0; // bss
static int g; // bss
// cout 也是指令
// cout << c << g << endl; 打印c有可能不为0,c相当于栈上的无效值,打印g一定为0
return 0; // 在text存放
}
看下面的代码 :
// 从汇编分析函数调用堆栈详细过程.cpp
#include <iostream>
using namespace std;
/*
问题一:main函数调用sum,sum执行完以后,怎么知道回到哪个函数中?
问题二:sum函数执行完,回到main以后,怎么知道从哪一行指令
继续运行的?
*/
int sum(int a, int b)
{
int temp = 0;
temp = a + b;
return temp;
}
int main()
{
int a = 10;
int b = 20;
int ret = sum(a, b);
cout << "ret:" << ret << endl;
return 0;
}
上面的代码需要弄清楚两个问题:
-
main函数调用sum,sum执行完以后,怎么知道回到哪个函数中?
-
sum函数执行完,回到main以后,怎么知道从哪一行指令继续运行的?
下面看一张图:
我把函数调用堆栈过程分为两个大部分,第一部分是执行函数前,第二部分是执行函数后。
先来看图左半部分,可以看到程序运行的堆栈空间是存在两个指针的,一个指向栈底rbp,一个指向栈顶rsp。
注: 这里理解rbp为栈底指针,rsp为栈顶指针即可。
堆栈空间变化步骤如图:
-
执行
int a = 10;
a 10入栈--第一步。 -
执行
int b = 20;
b 20入栈--第二步。 -
执行
int ret = sum(a, b);
ret入栈--第三步。1,2,3步rsp指针不一定移动了,因为rsp指针本来就和rbp指针有差距(这叫预留空间)。 -
进入到sum函数执行前,形参b入栈。--第四步,rsp上移。
-
形参a入栈。--第五步,rsp上移。
-
形参全部入栈后,指令地址入栈。(假设指令在栈中入栈地址为0x08124458)--第6步
其中第6步在汇编中(无论什么版本编译器)指令多为call sum。
这条指令做了两件事情: 1.这一行指令的下一行地址压栈(add rsp, 8)入栈 2. 进入到sum函数。
至此,给main函数开辟的栈帧空间就全部完成了。
接着看右半部分,sum函数栈空间开辟。
注:开辟空间之前得存放地址0x0018ff40,该地址是栈底的地址,这里不保存栈底地址rbp就无法回退到栈底,也就无法回到main函数了
-
压栈,20放入到栈顶,sum函数形参变量b的内存(调用方式开辟)就开辟出来了(给形参a开辟过程类似就没有在图或者文字中再描述)。此时栈底指针rbp移动到指向如上图,rsp上移。--第7,8步
-
给sum函数开辟栈帧空间--第9步
-
给局部变量temp开辟空间--第10步
-
执行sum函数里的内容,出栈操作,把出栈内容一次放入到CPU的PC寄存器里面(实际上根本没有生成符号表)。这一步具体就是temp值变为30然后返回给main函数--第11步
接下来是销毁sum函数的栈空间。
-
出栈操作后,rsp回退到栈顶(rbp指向的当前栈顶位置),rbp回退到main栈底(因为0x0018ff40保存了栈底地址)。--第12步
-
销毁sum函数的栈空间,rsp回退后再下移(销毁形参空间)。rsp回退到左半图第四步前指向的位置(回退到最初rsp指向的空间)。
以上就是函数调用堆栈的详细过程。
接着来看一个危险操作:
右半图红色部分
int *func()
{
int data;
return &data;
}
int *p = func(); // 此时相当于是新开辟了个func2()
cout << *p << endl; // 非法访问
// 函数所用的栈空间会回退
源代码变为可执行文件(程序)需要两个步骤,编译和链接。
有两个源文件一个为main.cpp,另一个为sum.cpp。
// sum.cpp
int gdata = 10; // gdata .data
int sum(int a, int b) // sum_int_int .text
{
return a+b;
}
// main.cpp
// 引用sum.cpp文件里定义的全局变量 以及函数
extern int gdata; // gdata *UND*
int sum(int, int) ; // sum *UND*
int data = 20; // data .data
int main() // main .text
{
int a = gdata;
int b = data;
int ret = sum(a,b);
return 0;
}
编译要经过以下几步:
- 预编译
预编译是处理 #
开头的命令,仅有一个特例比如 #pragma lib
, #pragma
的不在预编译处理(这些指令大多用于微软的编译器)。
- 编译
gcc和g++以及汇编通过编译,输出符号表,符号表存储着源文件变量的符号。当然源文件通过编译生成的文件是二进制可重定位目标文件(sum.o和main.o,这些文件就是符号表)
.o文件的格式组成有:(由elf文件头可知存在).text,.bss,.data,.symbal,section table...
.o文件是无法运行的,因为生成的符号没有分配虚拟地址?那么什么时候分配虚拟地址呢?链接过程中。
下面是实际操作:
g++ -c main.cpp
g++ -c sum.cpp
g++ main.o sum.o
前两句是编译指令,最后一句是链接这两个文件。
链接等于把编译完成的所有.o文件+静态库文件打包在一起。(比如main.o
和 *.a
linux下静态库后缀为.a)
链接操作可以当做
g++ sum.o main.o -o test # 注,这里加-o test是指定生成的可执行文件名,不加生成a.out
链接分为以下两步:
-
所有.o文件段的合并,符号表合并后,进行符号解析
-
链接的核心: 符号的重定位(重定向),重定向后生成可执行文件。
这里通过代码来解释
// sum.cpp
int gdata = 10; // gdata .data
int sum(int a, int b) // sum_int_int .text
{
return a+b;
}
// main.cpp
extern int gdata; // gdata *UND*
int sum(int, int) ; // sum *UND*
int data = 20; // data .data
int main() // main .text
{
int a = gdata;
int b = data;
int ret = sum(a,b);
return 0;
}
1·第一步其实是把main.o和sum.o的各个段进行合并 main的.text与sum的.text段,.data,.bss两个不同的源文件的这些段进行合并。
可以看到在sum.cpp中.data段的数据和.text段的指令,在main.cpp中变为了引用(*UND*)。这里要注意,sum和gdata在sum.cpp编译后生成了符号表,只能有唯一的一个。但是*UND* 可以多个。全局变量是不能重名的。
2·符号解析是所有对符号的引用(*UND*)都要找到该符号定义的.text,.data的地方(符号未定义或者符号重定义会出错)。
- 符号解析成功后 => 给所有符号分配虚拟地址。 data,gdata,sum -> 写入objdump(.o)生成的可执行文件
实际的操作如下:
# 在生成.o和a.out后
objdump -t main.o # 看main.o的符号表
objdump -t sum.o
# 注意对比代码与符号表
# 可以看到main.o符号表中*UND*是用到了却不知道怎么定义的意思(引用)
objdump -S main.o # 看常见段
readelf -S main.o # 看所有的段
readelf -h main.o # 看ELF头
可以多去尝试这些指令。
接下来,我将实际演示编译到链接的过程。
g++ -c main.cpp -g # 生成的main.o带调式信息
objdump -S main.o
g++ -c sum.cpp -g
objdump -S sum.o
ld -e main *.o # 链接生成a.out
objdump -t a.out
注意 objdump -S main.o
执行结果,如下图:
注意看红框中标识的部分,可以发现地址是0。这也说明了链接前没有分配虚拟空间地址。
这里只需要明白三个方面即可。
- a.out(可执行文件)在磁盘上,此时它所需的虚拟内存空间结构如下:
elf header <= 程序的入口地址
program headers <= 告诉程序要加载(load)的.text,.data
.text
.data
.bss
-
a.out加载到虚拟内存空间
-
cpu虚拟地址 => 做一个地址映射。
如果页面异常 => 执行地址映射页面异常处理程序
分配物理内存
readelf -s a.out
readelf -h a.out
readelf -l a.out # 这些指令处理可执行文件和.o执行结果的不同在于多了program headers
a.out 比 *.o 还多出来各种段组成的program headers段。
这个program headers段作用是 => 告诉系统,运行这个程序的时候,把那些内容加载到程序中。
编程语言本质上是生成cpu指令和数据,从进程的虚拟地址空间内存划分和布局,函数调用堆栈详细过程,程序编译连接原理,从汇编角度理解C代码的编译和链接原理可以更好的理解代码从而写出更好的程序。