本文的主要说明对象是CPU和内存。为什么学C语言之前必懂呢,因为C语言是非常贴近底层原理的语言,明白了CPU和内存的原理,对学C语言有很大帮助。
其实我个人是比较主张计算机专业本科应该先学计算机组成原理然后再学C语言的,不过好像没有这么干的,而且学C语言之前并不需要学完整个计算机组成原理才能学C,对于想快速入门的来说,理解了CPU、内存和一些相应的概念就足够了。这就是本文存在的目的。运行一个程序,少了CPU或者内存都不行,甚至少了外存也不行,不过外存就不在我们的讨论范围内了。

1、CPU

CPU是计算机最核心的部件了,比如我们要做一次加法,那这次加法就是CPU来做的。

CPU中我们主要能感知到的就是寄存器了。以32位MIPS的CPU为例,有r0到r31,共32个普通寄存器(还有一些不普通的,后面会提到),每个寄存器可以放一个32位二进制数。(我们知道,在计算机里什么都是二进制的。32位的含义是每个普通寄存器都是32位的,至于正好有32个普通寄存器,那是个巧合而已,32位ARM只有16个寄存器。)

那CPU能干什么事呢?举个例子吧,例如把r12里的数字和1做加法,结果存入r17。例如把r13和r7做减法,减法结果又存入r7。没错,之前r7里的数就没有了。

这里面的加法和减法都是典型的算术运算,除了加减法还有别的,像逻辑运算等等。乘法和除法比较复杂,这里就不讨论了。

以及发现没,这些寄存器里的数都是整数。如果是小数怎么办呢?计算机有个浮点数的概念,小数是浮点数的一种。浮点数有其它的寄存器和运算部件,这里也不讨论了。但是要指出,整数加法和浮点数加法是用两种不同的方式来做的,虽然在编程语言里可能都是用一个加号。

这还不是CPU的全部操作,后面讲了内存,还会讲到跟内存有关的操作。

2、内存
先想想储物柜吧,有很多很多个小柜子,每个柜子能放若干行李。为了区分不同的小柜子,每个柜子都有一个编号。

内存差不多就是个这样的东西,里面有很多内存单元,每个单元可以存一个8位二进制数,也就是十进制的0到255。发现没,前面讲的一个32位CPU寄存器是32位的,是内存单元位数的4倍。每个内存单元也有一个编号,一般称为地址。地址是C语言、汇编语言等底层语言里面一个非常重要的概念。地址也是二进制的,后面还会再讨论。

内存的基本操作只有读和写两种(有些时候还有别的,但简单起见咱们只讨论读和写,因为有这两个足够了),就像储物柜可以存物可以取物一样。读就是从内存单元读进CPU寄存器,写就是从CPU寄存器写进内存单元。嗯,寄存器跟内存单元一个32位一个8位,大小不一样,这咋办呢?那就一次读写4个内存单元。当然也可以把高8位存进1个单元,或者把1个单元读进来再扩展为32位,等等,也可以。

读或者写的时候,咱得告诉人家,要读写哪一个单元,这时就要通过内存单元的地址。例如某块内存有40亿个内存单元(别觉得很大,现在主流的内存大小可能都不只这么大了),它们的地址就是从0到40亿-1(注意是从0开始,在计算机或C语言里很多东西都是从0开始的)。地址只是一个数字,不要想得太复杂。

顺便说一下,一个内存单元的大小已经成了一个基本单位,因为存放数据的时候一般都需要占用整数个内存单元。于是,一个内存单元的数据大小称为一个字节,或说1B。描述内存大小,就是有多少个内存单元,就是多少字节。描述一个文件的大小,也用字节作为单位。

数据大小的单位除了B以外,还有KB、MB、GB等,1KB=1000或1024B,1MB=1000或1024KB,1GB=1000或1024MB。至于是1000还是1024,我只能说,两种都有,我也很无奈……

刚才说了内存里的数可以读进CPU寄存器,寄存器里的数也可以写进内存。下面就要来说这种典型操作。

读(内存到寄存器)的时候,需要“汇报”两点:读哪个单元(即内存地址),读进哪个寄存器。当然,如果要读4个单元的话,这4个单元是连续的,所以只需要1个地址而不是4个。一般来说,地址必须通过寄存器提供。例如,以r10里面的数作为地址,把这个地址对应的4个单元读进r18。不过实际用的要多一个步骤,是r10里的数再加上一个自己指定的常数(可正可负)作为地址,而不是r0直接作为地址。当然,想让r0直接作为地址的话给个常数0就行了,不过这种先加上一个常数再作为地址的操作确实很常见。

写(寄存器到内存)操作是类似的,例如把r20里的数加上20作为地址,把r10里的数存进这个地址的4个单元。

寄存器跟内存相比,除了数量少,还有一个局限性,就是寄存器的编号不能像内存的地址那样放寄存器里,只能用常数。你想把寄存器编号放在r6里,把这个编号的寄存器里的数读出来存进r7,对不起,没有这种操作。于是遇到需要这种特点的操作,就必须用内存,哪怕实际占用的空间很小。这也是内存和寄存器的另一个区别。

跟寄存器相比,内存读写操作是非常慢的(不过跟外存磁盘等相比内存就真的太快了),当然现在有一些办法能一定程度上解决这个问题,但是嘛,只要能用寄存器就尽量用寄存器就是了。

但是就这么点儿寄存器,根本不够啊,内存必须得有的。而且CPU要工作必须有内存(后面会说原因)。
32位寄存器能表示的地址只有2^32个(事实上加上那个常数虽然有可能超过2^32但并没有把这个作为增加地址个数的手段),那意味着什么呢,如果内存单元超过2^32个,多出来的就跟没有一样了。这也是为什么很多32位CPU最多只支持4GB内存的原因。

3、指令

前面一直说,我们要告诉CPU做什么操作,什么加法,什么读写内存,等等,那么怎么“告诉”呢?通过指令。前面说的,把r12和常数1相加,加法结果存入r17,这就可以写成一条指令(对于32位MIPS)。把r14里的数加上12作为地址,把这个内存地址里的数读进r6,这也是一条指令。

对于32位MIPS,所有的指令都是用32位二进制数表示的。没错,又是32位二进制数,不过这个应该理论上可以不是32位。这32位里,有些位表示了指令的类型(做加法还是减法,还是读写内存什么的),有些位表示了寄存器编号等。反正只要知道指令能用二进制数表示就可以了。相比之下,对于x86的CPU,每条指令长度并不固定,有的指令8位,有的很长。
好了,指令写完了,怎么发给CPU?其实这个“发”字用的并不准确。指令是按顺序放在内存里的,对的,又是内存。是CPU主动上内存里找指令,而不是谁把指令“发”给CPU。这就是CPU要工作必须要有内存的原因。CPU需要把指令从内存中读进来,然后分析这条指令是干什么,是运算还是内存读写,还是什么别的。

要说具体的过程呢,就要说一个特殊寄存器PC(在MIPS中叫PC,在x86中叫IP,可能还有别的名字,但只要是CPU都会有一个做这种事的寄存器)。对的,PC跟前面说的32个普通寄存器不一样了,它没有编号,不能写进指令里。它的作用是给出取指令的地址。CPU运行时,一直在循环做这么几件事:先把PC里的值作为地址,上内存里把这个地址的数(也就是指令)取出来,然后把PC改成下一条指令的地址(对于32位MIPS一般是把PC的值加上4,因为每条指令32位4字节),接下来就是分析指令和执行了。然后再重复这个过程。

这里要插一句,C语言写的程序,要先转化成这样一条一条的指令之后,才能运行,当然这个转化的过程不需要我们自己来做,有软件来做这个事(其实就是编译器,可能还加上链接器、汇编器等)。C语言里很多操作都能直接对应到这些指令的操作,因此了解这些指令对学习C语言很有帮助。

4、跳转
程序里除了顺序执行以外,还有跳转。例如判断r10是否大于r11,如果是,就跳转到程序另一个地方开始执行。这事能不能做到呢?可以,通过改PC的值就能做到。当然,刚才说了在MIPS里PC没有编号,不能直接写进指令,所以直接改肯定不行。不过MIPS有其它的指令,可以间接改PC。无条件跳转比较简单,可以是把PC加上某个常数(可正可负),可以是把PC直接改成另一个寄存器的值,等等。条件跳转则是先判断一个条件是否成立,如果成立就改PC。条件有哪些呢?一般是判断一个数是否大于0,是否大于另一个数,是否等于0,等等,其实在指令层面这些都很容易实现,在这里不展开说了。总之跳转的方法就是改PC。

5、过程调用
有一种特殊的跳转指令要重点说,这种指令能先保存当前PC(指向跳转指令之后的那一条指令)然后再改PC。这种跳转我们叫它过程调用,或者就叫调用。C语言里有个概念叫函数调用,就是用这种指令实现的。对于MIPS,执行调用指令后,旧的PC会被保存到一个普通寄存器里(就是r31)。对于x86,旧的IP则保存到了栈里(后面会说什么是栈)。跳转之后,执行一段代码,执行完了还可以跳回来(因为保存了旧的PC)。如果有好几个地方都需要跳到这段代码执行然后再回到原来的地方,这种指令就起作用了。

方便起见,假设在主程序中,执行了一次调用指令,跳转到了func位置。如果程序正确的话,显然,func开始的程序,到后面一定会遇到一条类似jump r31这样的指令,就是无条件跳转到r31地址,这样,就回到了主程序,而且是过程调用指令之后的那条指令。

func理论上干什么都行,简单到做一次加法,复杂到几百几千行,都可以,我们用加法来举个例子好了。例如func把r4和r5里的值相加,存入r2,然后就跳回去。那么主程序需要把r4和r5设置好,然后调用func,调用之后,要的结果自然就在r2里了。

6、过程调用中的数据保存问题

这里面有个问题:调用完了以后,除了r2是结果,其它的寄存器会怎么样?会变吗?有可能。func里可是什么都能干,事实上主程序和func也可能压根就不是一个人写的,互相不知道对方会怎么干,要是把某个重要寄存器给改了,可不行。典型的就是r31,只要一调用,r31立马变。所以呢,主程序在调用func之前,如果r2或者r31里存着重要的数据,要先把它们保存到别的地方(别的寄存器或内存里)再调用。除了r31呢?其它的呢?

(这个说起来有点复杂啦,跟本文主要内容关系也没那么大。这个问题需要约定,有两种约定思路:一种是跟r2和r31一样,主程序要保证寄存器里的重要数据都保存了以后再调用func。另一种则是func保证不改变寄存器里的数据——就是如果需要临时修改某个寄存器,就先保存,跳转回去之前要从内存里恢复。其实主要区别就是由主程序来保存还是由func来保存。实际采用的约定是,有些寄存器(如r2和r31等)是第一种约定,还有些是第二种约定。对于前者的寄存器自然就是,func不需要保证它们不变,所以主程序调用func之前必须保存那里面的重要数据。对于后者的寄存器,则主程序无需担心里面的数据丢失,而func要保证不能破坏它们。要强调的一点是,前者和后者的寄存器没有本质区别,只是人为的约定。)

另外,如果某一段程序中,变量太多了,寄存器不够用,需要用内存来存变量,这时候遇到的本质问题就类似于寄存器保存的问题,都是要在内存中开辟一片空间。

下面是重点:在内存中保存数据时,应该保存到什么位置?

首先,最基本的要求就是,新保存的位置不能已经存在了重要的数据,否则一保存,原来的数据也没了。这肯定不行。

于是可能会产生一个想法:让每一段代码对应一块内存,例如func代码中,有一步要保存r16,于是定死了r16保存的位置(跟其它保存地址都不能冲突)。但是,事实上,这种方式是有致命缺陷的。有一个概念叫递归,学编程应该都会遇到的,这里不展开说了,总之如果递归中遇到了这种保存方式,就会死的很惨。

7、栈

现在的主流做法是什么呢?

首先,在内存中分出一片比较大的空间,称为栈空间,专门用来干这种保存寄存器之类的事情,不管在哪段代码,保存寄存器都在这段空间中。最初假设这片空间是空的,然后保存第一个寄存器的时候,就存到这片空间最开始的位置。保存第二个的时候,就存到下一个位置,后面的以此类推。

可能是因为一些历史原因吧,这片空间是从高地址往低地址开始用的,先保存的位于最高地址,然后不断向低地址保存。

于是就需要一个专门的寄存器来标记目前这片空间用到哪了,它指示着下一次保存数据保存的地址(不完全相等,后面解释),以及下一次恢复数据从哪里恢复。在MIPS中,寄存器r29就是干这个用的。在ARM中则是r13。这里管它叫SP。

在32位MIPS中,假设某一时刻SP的值是10000,这意味着什么呢,意味着(栈空间内)地址低于10000的还空着,10000和高于10000的已经被用了。所以下一次要保存一个寄存器(32位)的时候,先把SP改成9996(就是减去4),然后把寄存器值写入9996的位置。如果下一步要调用func代码了,这时,有必要要求func不得改变SP寄存器,其实这很容易做到,也很自然。调用结束回来后,因为SP还是9996,我们可以轻松从SP位置恢复出刚才保存的值。恢复完以后别忘了把SP改回10000。(当然,如果保存的寄存器不是一个是两个,不需要每保存/恢复一个都改一次SP,可以先将SP改成9992,然后把两个寄存器分别存进SP和SP+4的位置,因为读写指令中寄存器的值是可以先加上一个常数再作为地址的。恢复的时候也是类似的。)

只要有过程调用的地方,基本都会用到栈,栈是一个非常重要的概念。这里我好像也没发下一个具体的定义,不过大家能理解SP寄存器的用法就可以了。

另外注意,这个栈跟数据结构中的栈还是有点区别的,如果你还没学到数据结构,就学了以后再体会吧。