【小白打造编译器系列11】运行时机制

程序运行环境

程序运行的过程中,主要是跟 两个硬件(CPU 和内存) 以及 一个软件(操作系统) 打交道。

关注 CPU 和内存

我们重点关注的是 寄存器 以及 高速缓存,它们跟程序的执行机制和优化密切相关。

寄存器是 CPU 指令在进行计算的时候,临时数据存储的地方。CPU 指令一般都会用到寄存器,比如,典型的一个加法计算(c = a + b)的过程是这样的:

  • 指令 1(mov):从内取取 a 的值放到寄存器中;
  • 指令 2(add):再把内存中 b 的值取出来与这个寄存器中的值相加,仍然保存在寄存器中;
  • 指令 3(mov):最后再把寄存器中的数据写回内存中 c 的地址;

寄存器的速度也很快,所以能用寄存器就别用内存。尽量充分利用寄存器,是编译器做优化的内容之一。

高速缓存可以弥补 CPU 的处理速度和内存访问速度之间的差距。所以,我们的指令在内存读一个数据的时候,它不是老老实实地只读进当前指令所需要的数据,而是把跟这个数据相邻的一组数据都读进高速缓存了。

我们写程序时,尽量把某个操作所需的数据都放在内存中的连续区域中,不要零零散散地到处放,这样有利于充分利用高速缓存。这种优化思路叫做数据的局部性

程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它在运行期可以使用。我们目前使用的都是 64 位的机器,你可以用一个 64 位的长整型来表示内存地址,它能够表示的所有地址,我们叫做 寻址空间。64 位机器的寻址空间就有 2 的 64 次方那么大,也就是有很多很多个 TB(Terabyte),大到你的程序根本用不完。不过,操作系统一般会给予一定的限制,不会给你这么大的寻址空间。

在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存的时候,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址。有的物理内存区域会映射进多个进程的地址空间。对于不太常用的内存数据,操作系统会写到磁盘上,以便腾出更多可用的物理内存。

内存的使用

本质上来说,你想怎么用就怎么用,并没有什么特别的限制。一个编译器的作者,可以决定在哪儿放代码,在哪儿放数据,当然了,别的作者也可能采用其他的策略。实际上,C 语言和 Java 虚拟机对内存的管理和使用策略就是不同的。大多数语言还是会采用一些通用的内存管理模式。以 C 语言为例,会把内存划分为代码区、静态数据区、栈和堆

代码区是在最低的地址区域,然后是静态数据区,然后是堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。

  • 代码区(也叫文本段)存放编译完成以后的机器码。这个内存区域是只读的,不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码,还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。
  • 静态数据区保存程序中全局的变量和常量。它的地址在编译期就是确定的,在生成的代码里直接使用这个地址就可以访问它们,它们的生存期是从程序启动一直到程序结束。它又可以细分为 Data 和 BSS 两个段。Data 段中的变量是在编译期就初始化好的,直接从程序装在进内存。BSS 段中是那些没有声明初始化值的变量,都会被初始化成 0。
  • 堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。比如,我们在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。
  • 栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉。

程序与操作系统

程序需要遵守的约定包括:程序文件的二进制格式约定,这样操作系统才能程序正确地加载进来,并为同一个程序的多个进程共享代码区。在使用寄存器和栈的时候也要遵守一些约定,便于操作系统在不同的进程之间切换的时候、在做系统调用的时候,做好上下文的保护。所以,我们编译程序的时候,要知道需要遵守哪些约定。因为就算是使用同样的 CPU,针对不同的操作系统,编译的结果也是非常不同的。

程序运行的细节

首先,可运行的程序一般是由操作系统加载到内存的,并且定位到代码区里程序的入口开始执行。比如,C 语言的 main 函数的第一行代码。每次加载一条代码,程序都会顺序执行,碰到跳转语句,才会跳到另一个地址执行。CPU 里有一个指令寄存器,里面保存了下一条指令的地址。

假设我们运行这样一段代码编译后形成的程序:

int main(){
  int a = 1;
  foo(3);
  bar();
}

int foo(int c){
    int b = 2;
    return b+c;
}

int bar(){
    return foo(4) + 1;
}

我们首先激活 main() 函数,main() 函数又激活 foo() 函数,然后又激活 bar() 函数,bar() 函数还会激活 foo() 函数,其中 foo() 函数被两次以不同的路径激活。

我们把每次调用一个函数的过程,叫做一次活动(Activation)。每个活动都对应一个活动记录(Activation Record),这个活动记录里有这个函数运行所需要的信息,比如参数、返回值、本地变量等。

目前我们用栈来管理内存,所以可以把活动记录等价于栈桢。栈桢是活动记录的实现方式,我们可以自由设计活动记录或栈桢的结构,下图是一个常见的设计:

  • 返回值:一般放在最顶上,这样它的地址是固定的。foo() 函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中,我们会优先通过寄存器来传递返回值,比通过内存传递性能更高。
  • 参数:在调用 foo 函数时,把参数写到这个地址里。同样,我们也可以通过寄存器来传递,而不是内存。
  • 控制链接:就是上一级栈桢的地址。如果用到了上一级作用域中的变量,就可以顺着这个链接找到上一级栈桢,并找到变量的值。
  • 返回地址:foo 函数执行完毕以后,继续执行哪条指令。同样,我们可以用寄存器来保存这个信息。
  • 本地变量:foo 函数的本地变量 b 的存储空间。
  • 寄存器信息:我们还经常在栈桢里保存寄存器的数据。
    • 如果在 foo 函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。这种约定叫做被调用者责任,也就是使用寄存器的人要保护好寄存器里原有的信息。
    • 某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,要自己保存在栈桢中。这种约定叫做调用者责任

每个栈桢的长度是不一样的。用到的参数和本地变量多,栈桢就要长一点。但是,栈桢的长度和结构是在编译期就能完全确定的。这样就便于我们计算地址的偏移量,获取栈桢里某个数据。


总结

  • CPU 上运行程序的指令,运行过程中要用到寄存器、高速缓存来提高指令和数据的存取效率。
  • 内存可以划分成不同的区域保存代码、静态数据,并用栈和堆来存放运行时产生的动态数据。
  • 操作系统会把物理的内存映射成进程的寻址空间,同一份代码会被映射进多个进程的内存空间,操作系统的公共库也会被映射进进程的内存空间,操作系统还会自动维护栈。

参考:《极客时间-编译原理之美》

发布了63 篇原创文章 · 获赞 35 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_41960890/article/details/105306863