本质上,我们的程序只关心CPU和内存这两个硬件。你可能说:“不对啊,计算机还有其他硬件,比如显示器和硬盘啊。”但对我们的程序来说,操作这些硬件,也只是执行某些特定的驱动代码,跟执行其他代码并没有什么差异。
#### 1.关注CPU和内存
CPU的内部有很多组成部分,对于本课程来说,我们重点关注的是**寄存器以及高速缓存,**它们跟程序的执行机制和优化密切相关。
**寄存器**是CPU指令在进行计算的时候,临时数据存储的地方。CPU指令一般都会用到寄存器,比如,典型的一个加法计算(c=a+b)的过程是这样的:
>
指令1(mov):从内存取a的值放到寄存器中;
指令2(add):再把内存中b的值取出来与这个寄存器中的值相加,仍然保存在寄存器中;
指令3(mov):最后再把寄存器中的数据写回内存中c的地址。
对于不太常用的内存数据,操作系统会写到磁盘上,以便腾出更多可用的物理内存。
当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。
**对于这个内存,该怎么用呢?**
本质上来说,你想怎么用就怎么用,并没有什么特别的限制。一个编译器的作者,可以决定在哪儿放代码,在哪儿放数据,当然了,别的作者也可能采用其他的策略。实际上,C语言和Java虚拟机对内存的管理和使用策略就是不同的。
尽管如此,大多数语言还是会采用一些通用的内存管理模式。以C语言为例,会把内存划分为代码区、静态数据区、栈和堆。
一般来讲,代码区是在最低的地址区域,然后是静态数据区,然后是堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。
**代码区(也叫文本段)存放编译完成以后的机器码。**这个内存区域是只读的,不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码,还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。
**静态数据区保存程序中全局的变量和常量。**它的地址在编译期就是确定的,在生成的代码里直接使用这个地址就可以访问它们,它们的生存期是从程序启动一直到程序结束。它又可以细分为Data和BSS两个段。Data段中的变量是在编译期就初始化好的,直接从程序装在进内存。BSS段中是那些没有声明初始化值的变量,都会被初始化成0。
**堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。**比如,我们在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。
**而栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。**它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉。
讲完了CPU和内存之后,我们再来看看跟程序打交道的操作系统。
#### 2.程序和操作系统的关系
程序跟操作系统的关系比较微妙:
假设我们运行这样一段代码编译后形成的程序:
```
int main(){
int a = 1;
foo(3);
bar();
}
int foo(int c){
int b = 2;
return b+c;
}
int bar(){
return foo(4) + 1;
}
```
我们首先激活(Activate)main()函数,main()函数又激活foo()函数,然后又激活bar()函数,bar()函数还会激活foo()函数,其中foo()函数被两次以不同的路径激活。
我们把每次调用一个函数的过程,叫做一次活动(Activation)。每个活动都对应一个活动记录(Activation Record),这个活动记录里有这个函数运行所需要的信息,比如参数、返回值、本地变量等。
目前我们用栈来管理内存,所以可以把活动记录等价于栈桢。栈桢是活动记录的实现方式,我们可以自由设计活动记录或栈桢的结构,下图是一个常见的设计:
你可以看到,每个栈桢的长度是不一样的。
用到的参数和本地变量多,栈桢就要长一点。但是,栈桢的长度和结构是在编译期就能完全确定的。这样就便于我们计算地址的偏移量,获取栈桢里某个数据。
总的来说,栈桢的设计很自由。但是,你要考虑不同语言编译形成的模块要能够链接在一起,所以还是要遵守一些公共的约定的,否则,你写的函数,别人就没办法调用了。
在[08讲](https://time.geekbang.org/column/article/128623),我提到过栈桢,这次我们用了更加贴近具体实现的描述:栈桢就是一块确定的内存,变量就是这块内存里的地址。在下一讲,我会带你动手实现我们的栈桢。
#### 2.从全局角度看整个运行过程
了解了栈桢的实现之后,我们再来看一个更大的场景,从全局的角度看看整个运行过程中都发生了什么。
代码区里存储了一些代码,main函数、bar函数和foo函数各自有一段连续的区域来存储代码,我用了一些汇编指令来表示这些代码(实际运行时这里其实是机器码)。
假设我们执行到foo函数中的一段指令,来计算“b+c”的值,并返回。这里用到了mov、add、jmp这三个指令。mov是把某个值从一个地方拷贝到另一个地方,add是往某个地方加一个值,jmp是改变代码执行的顺序,跳转到另一个地方去执行(汇编命令的细节,我们下节再讲,你现在简单了解一下就行了)。
```
mov b的地址 寄存器1
add c的地址 寄存器1
mov 寄存器1 foo的返回值地址
jmp 返回地址 //或ret指令
```
执行完这几个指令以后,foo的返回值位置就写入了6,并跳转到bar函数中执行foo之后的代码。
这时,foo的栈桢就没用了,新的栈顶是bar的栈桢的顶部。理论上讲,操作系统这时可以把foo的栈桢所占的内存收回了。比如,可以映射到另一个程序的寻址空间,让另一个程序使用。但是在这个例子中你会看到,即使返回了bar函数,我们仍要访问栈顶之外的一个内存地址,也就是返回值的地址。
所以,目前的调用约定都规定,程序的栈顶之外,仍然会有一小块内存(比如128K)是可以由程序访问的,比如我们可以拿来存储返回值。这一小段内存操作系统并不会回收。
我们目前只讲了栈,堆的使用也类似,只不过是要手工进行申请和释放,比栈要多一些维护工作。
## 课程小结
本节课,我带你了解了程序运行的环境和过程,我们的程序主要跟CPU、内存,以及操作系统打交道。你需要了解的重点如下: