虚拟内存和函数栈

我们知道一段程序要在计算机中运行,他需要占用内存,一部分用来存储程序内容,一部分用来运行程序(程序运行过程中也是需要申请内存的)。忽略部分细节,一段程序加载到linux内存后大概是这样存储的。

从图上我们看到linux将内存分成两部分:内核空间用户空间。用户态的程序在用户空间执行,内核态的程序在内核空间执行,自己跑自己的,互不影响。

当然,即使简单如hello world这样的程序也需要同时使用到用户空间和内核空间,关键在于printf函数,在用户看来只是一个库函数,其实他需要内核去调用驱动程序输出到屏幕上,内核的这部分工作就是在内核空间完成的。

我们可以看到用户空间和内核之间有一段灰色内容,操作系统向里面填写了随机值,当值被改变时就会终止用户程序,防止内核空间内容被篡改,这是系统的自我保护机制。当然你更不能在用户态直接去访问内核空间的地址!比如我们遇到很多的段错误就是因为这个原因。

其实,我们可以把操作系统比喻成一个渣男,他有4G的物理内存(实际的爱心上限),但是他告诉每个用户程序(单纯女孩)我只爱一个,我把所有的内存(爱)都给你。而且对于每个女孩来说,在操作系统完美的进程调度下,她们时感知不到他同时在跟多个妹子暧昧,操作系统的时间管理不是一般强啊!这种将物理内存同时映射到多个用户进程的技术叫虚拟内存,也就是上图所示的内容。

至于操作系统怎么做到的,这里简单举个例子,深入理解可以去看看《深入理解计算机系统》这本书。

假设你的计算机是32位,物理内存是1G,硬盘是20G。那么理论上你的地址总线可以访问0~4G(2的32次方),操作系统给进程A和进程B的虚拟内存也都是4G,首先操作系统创建一个页表,页是访问内存的最基本单位。比如这个页表范围是0~1023,即一个页表项映射4M的物理空间,什么?不够用?操作系统并没有很老实的把这0~1023都填上,而是进程申请了才会把对应的页表标记成已经使用

当物理内存映射完后,系统就会去硬盘上映射(swap空间),当然这个时候访问就会慢很多,机器会变得卡顿。

那当页表里的都用完了呢?操作系统会使用页置换算法—LRU算法,将不常用的内存和硬盘中的调换!将不长用的换到硬盘中去,常用的标记到页表中来!

其实这也是利用了程序的局部性原理,一个程序经常使用的内存和逻辑肯定只是部分。

32位系统虚拟内存分布

虚拟内存分布在上图中自上而下,从到高地址到低地址:0xFFFFFFFF ~ 0x00000000,

即:内核空间:0xFFFFFFFF ~ 0xC0000000  1G,用户空间:0x00000000 ~ 0xBFFFFFFF  3G.

64位系统虚拟内存分布

虚拟内存分布在上图中自上而下,从到高地址到低地址:0xFFFFFFFFFFFFFFFF ~ 0x0000000000000000

即:内核空间:0xFFFFFFFFFFFFFFFF ~ 0xFFFF000000000000  256T,用户空间:0x0000000000000000 ~ 0x0000FFFFFFFFFFFF  256T.

内核空间和用户空间的大小是可以在操作系统中配置的,不是写死的。

具体使用的区别可以参考下面的图:

从图可以看出64位有一半的内存时空着的,毕竟已经足够大了。

但是无论是32位还是64位各自区域用来存储的内容都是相同。

栈区:局部变量,函数参数,返回地址

映射区:动态库

堆区:动态分配的内存

bss段:初始化为0或者未初始化的全局变量和局部变量

数据段:初始化的全局变量和静态局部变量

代码段:可执行代码,字符串

下面用64位系统做个实验验证一下:

#include <stdio.h>
#include <stdlib.h>

int global_1 = 10;      //初始化的全局变量
int global_2 = 0;       //初始化为1的全局变量
int global_3;           //未初始化的全局变量

int main()
{
   static int static_global = 10;  //静态局部变量
   char *str = "12345";            //文本字符串
   int stack[10];                  //局部变量
   int *heap = (int*)malloc(sizeof(int)*10);  //动态分布的空间
   int const const_value = 99;          //只读变量

   printf("address:\n");
   printf("global_1 %p\n", &global_1);
   printf("global_2 %p\n", &global_2);
   printf("global_3 %p\n", &global_3);
   printf("static_global %p\n", &static_global);
   printf("str %p\n", str);
   printf("stack %p\n", stack);
   printf("heap %p\n", heap);
   printf("const_value %p\n", &const_value);
   printf("main %p\n",main);

   return 0;
}

运行结果:




[root@VM-0-2-centos virmem]

# ./a.out address: global_1 0x601044 global_2 0x601050 global_3 0x601054 static_global 0x601048 str 0x400740 stack 0x7fffd87e71c0 heap 0x1f89010 const_value 0x7fffd87e71bc main 0x4005bd

可以看到:

栈自高地址向下分配:stack 0x7fffd87e71c0

堆地址自下而上分配:heap 0x1f89010

未初始化(初始化为0)全局:global_2  0x601050

初始化(静态局部):static_global 0x601048

代码段,字符串:main 0x4005bd     str 0x400740

函数栈溢出

程序的运行无非就是一群函数,你调用我,我调用你,这个调用过程是在栈中完成的,把一个函数在栈中分配空间成为函数栈帧。

看下面的例子,我们无限递归调用,会发生什么事情?

#include <stdio.h>
void fun()
{
  static unsigned int i = 0;
  i++;
  printf("i = %u\n", i);
  fun();
}
int main()
{
  fun();
  return 0;
}

输出结果:

i = 523989
i = 523990
i = 523991
i = 523992
i = 523993
i = 523994

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7a8725e in _IO_new_file_write () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.x86_64
(gdb) bt
#0  0x00007ffff7a8725e in _IO_new_file_write () from /lib64/libc.so.6
#1  0x00007ffff7a88a7e in __GI__IO_do_write () from /lib64/libc.so.6
#2  0x00007ffff7a879c0 in __GI__IO_file_xsputn () from /lib64/libc.so.6

调用到523895次时发生了溢出,实际linux中的栈大小并没有前面说的那么大

查看系统设置的栈大小:

[root@VM-0-2-centos ~]# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7269
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 100001
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192        =>栈大小8M
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7269
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

仅仅8M而已,当然可以配置,改成10M 




[root@VM-0-2-centos ~]

# ulimit -s 10240

[root@VM-0-2-centos ~]

# ulimit -s 10240

再跑一下


i = 655061
i = 655062
i = 655063
i = 655064
i = 655065
i = 655066

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7a8725e in _IO_new_file_write () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.x86_64
(gdb) bt
#0  0x00007ffff7a8725e in _IO_new_file_write () from /lib64/libc.so.6
#1  0x00007ffff7a88a7e in __GI__IO_do_write () from /lib64/libc.so.6
#2  0x00007ffff7a879c0 in __GI__IO_file_xsputn () from /lib64/libc.so.6

次数增加了,如果函数中有局部变量或者参数调用次数会更少。

函数因为调用完要返回,所以必须在栈中记录调用者的返回地址,以便调用完成后返回。

函数调用过程

#include <stdio.h>
int say_hello(char *name)
{
    printf("helloc %s\n", name);
    return 0;
}

int main()
{
   char name[10]= "sanme";
   say_hello(name);
   return 0;
}

使用gdb查看两个函数的汇编:

/* rbp寄存器函数栈基地址 */
/* rsp寄存器用来保存函数栈顶地址 */
/* rdi寄存器用来保存函数参数 */
/* eax寄存器用来保存函数返回值 */
/* rip函数当前运行地址 */

(gdb) disass main
Dump of assembler code for function main:
   0x0000000000400556 <+0>:     push   %rbp                //rbp 入栈  等价于 sub $8 %rsp   movq %rbp (%rsp)               
   0x0000000000400557 <+1>:     mov    %rsp,%rbp           //将rsp拷贝到rbp
   0x000000000040055a <+4>:     sub    $0x10,%rsp          //将rsp寄存器移动到减0x10位置,相当于给name变量开辟栈空间
   0x000000000040055e <+8>:     movabs $0x656d6e6173,%rax  //将“sanme”复制到rax中,字符串“emnas”的十六进制0x656d6e6173,这里跟系统大小端有关系
   0x0000000000400568 <+18>:    mov    %rax,-0x10(%rbp)    //这几句将“sanme”复制到开辟的栈内存中
   0x000000000040056c <+22>:    movw   $0x0,-0x8(%rbp)
   0x0000000000400572 <+28>:    lea    -0x10(%rbp),%rax
   0x0000000000400576 <+32>:    mov    %rax,%rdi           //将name变量地址拷贝到rdi寄存器
   0x0000000000400579 <+35>:    callq  0x40052d <say_hello> //调用say_hello函数,并将0x000000000040057e保存作为say_hello的返回地址
   0x000000000040057e <+40>:    mov    $0x0,%eax            //将eax置0
   0x0000000000400583 <+45>:    leaveq                      //等价于mov %rbp %rsp,准备结束了,指向函数开头rbp
   0x0000000000400584 <+46>:    retq                        //等价于popq %rbp
End of assembler dump.
(gdb) disass say_hello
Dump of assembler code for function say_hello:
   0x000000000040052d <+0>:     push   %rbp
   0x000000000040052e <+1>:     mov    %rsp,%rbp
   0x0000000000400531 <+4>:     sub    $0x10,%rsp
   0x0000000000400535 <+8>:     mov    %rdi,-0x8(%rbp)
   0x0000000000400539 <+12>:    mov    -0x8(%rbp),%rax
   0x000000000040053d <+16>:    mov    %rax,%rsi
   0x0000000000400540 <+19>:    mov    $0x400620,%edi
   0x0000000000400545 <+24>:    mov    $0x0,%eax
   0x000000000040054a <+29>:    callq  0x400410 <printf@plt>
   0x000000000040054f <+34>:    mov    $0x0,%eax
   0x0000000000400554 <+39>:    leaveq
   0x0000000000400555 <+40>:    retq
End of assembler dump.

注意汇编前面的地址< 0x0000000000400XXXXX>不是栈地址,而是代码段的地址,在程序加载到内存中时,每个函数在代码段中的地址已经分配好了。这样程序就可以按照既定的地址调用每个函数了。

(gdb) b amin
Function "amin" not defined.
Make breakpoint pending on future shared library load? (y or [n]) n
(gdb) b main
Breakpoint 1 at 0x40055e: file fun_call.c, line 10.
(gdb) r
Starting program: /luogf/virmem/./a.out

Breakpoint 1, main () at fun_call.c:10
10         char name[10]= "sanme";
Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.x86_64
(gdb) disassemble
Dump of assembler code for function main:
   0x0000000000400556 <+0>:     push   %rbp
   0x0000000000400557 <+1>:     mov    %rsp,%rbp
   0x000000000040055a <+4>:     sub    $0x10,%rsp
=> 0x000000000040055e <+8>:     movabs $0x656d6e6173,%rax
   0x0000000000400568 <+18>:    mov    %rax,-0x10(%rbp)
   0x000000000040056c <+22>:    movw   $0x0,-0x8(%rbp)
   0x0000000000400572 <+28>:    lea    -0x10(%rbp),%rax
   0x0000000000400576 <+32>:    mov    %rax,%rdi
   0x0000000000400579 <+35>:    callq  0x40052d <say_hello>
   0x000000000040057e <+40>:    mov    $0x0,%eax
   0x0000000000400583 <+45>:    leaveq
   0x0000000000400584 <+46>:    retq
End of assembler dump.
(gdb) i r
rax            0x400556 4195670
rbx            0x0      0
rcx            0x400590 4195728
rdx            0x7fffffffe638   140737488348728
rsi            0x7fffffffe628   140737488348712
rdi            0x1      1
rbp            0x7fffffffe540   0x7fffffffe540
rsp            0x7fffffffe530   0x7fffffffe530
r8             0x7ffff7dd5e80   140737351868032
r9             0x0      0
r10            0x7fffffffd9e0   140737488345568
r11            0x7ffff7a2f460   140737348039776
r12            0x400440 4195392
r13            0x7fffffffe620   140737488348704
r14            0x0      0
r15            0x0      0
rip            0x40055e 0x40055e <main+8>
eflags         0x206    [ PF IF ]
cs             0x33     51
ss             0x2b     43
ds             0x0      0
es             0x0      0
fs             0x0      0
gs             0x0      0
(gdb)

可以使用gdb不断下一步跟踪这几个寄存器的值来观察程序的运行。

整个say_hello调用过程是这样的:

1.参数name入栈

2.返回地址 0x000000000040057e 入栈

3.say_hello函数的局部变量入栈(这里没有)

4.调用printf

就是这样一层套一层,具体的实现过可参考这篇文章,讲的很细https://www.debugger.wiki/article/html/1556499600981380

关于函数参数

 X86 64位系统中有6个寄存器用来存储参数,当函数参数大于6个时,就要在栈中开辟空间来存储(数组一般使用栈空间)。而且参数入栈的顺序跟参数传入的顺序相反。