首页 > theory > CST > > 正文

CPU阿甘:函数调用的秘密

发布人:zhoulujun    点击:

原创2016-04-15 老刘 码农翻身 收藏了,找的时候方便些前言上帝为你关闭了一扇门,就一定会为你打开一扇窗这句话来形容我最合适不过了。

原创2016-04-15 老刘 码农翻身  收藏了,找的时候方便些

 前言


上帝为你关闭了一扇门,就一定会为你打开一扇窗这句话来形容我最合适不过了。
我是CPU, 他们都叫我阿甘, 因为我和《阿甘正传》里的阿甘一样,  有点傻里傻气的。
上帝把我制造出来, 给我了一个很小的脑容量, 为数不多的寄存器能临时的记一点东西, 但是上帝给我打开了一扇特别的窗户, 那就是像阿甘一样,跑的飞快。
到底有多快呢?  我这么比喻一下吧,  我的工作都是以纳秒为单位的, 你们人间的一秒, 我可能已经做了1000,000,000 (10亿)次动作了。
相比而言, 内存比我慢100倍, 硬盘比我慢1000多万倍, 你说我快不快?

20161123211814313.jpeg

 启动


我住在一个机箱里,每天早上一阵电流把我叫醒, 还夹杂着嗡嗡的声音, 我知道我忠实的护卫电风扇又开始工作了, 我特别怕热, 又运行的飞快, 如果没有电风扇给我降温, 我很快就会生病, 生病的后果很严重, 那就是我的伙伴们像内存了,硬盘了。。全部都要罢工了, 没有我这个系统就会陷入的一片死寂。
我听说有些CPU的福利很好,竟然待在恒温恒湿,一尘不染的托管机房里,让我好生羡慕。
我的脑容量很小, 所以醒来后只想起了我的创造者告诉我的几件事情 :1. 你的工作就是运行指令2. 你不能保存指令, 你的指令全在内存里3. 你的第一条指令在内存的最顶端处0xFFFFFFF0
那还有什么可说的, 赶紧打电话给内存要指令,电话通过系统总线, 还得通过I/O桥电话局需要转接一下, 再通过存储总线接通 内存。 
"哥们, 把这个地址处的指令给我说一下吧"
"你是谁?" 内存竟然把我忘了, 当然,他断了电和我一样,失忆了。
"我是阿甘啊, 我们经常聊天来着, 你忘了?"
内存磨磨唧唧半天才把数据发了过来(比我慢100倍啊), 这是一条跳转指令,  我立刻回忆起来了, 这是我的老朋友BIOS 等着我去运行他那一堆指令呢。
我给BIOS打电话:  “老弟,今天干点啥?”
“阿甘,早上好 "   BIOS从不失忆,把所有人都记得清清楚楚   “ 还不是老一套啊,无非做一下系统的自检, 看看内存,硬盘,显卡等这些老伙计们有没有问题, 有问题的话用小喇叭提示一下主人 ”
这些过程我已经轻车熟路了, 很快搞定, 像往常一样,没有问题, 我还把一个叫做中断向量表的东西给弄好了, 我知道一会而要用
这些东西都搞完了,BIOS果然告诉: "阿甘, int 0x19"
我赶紧去刚弄好的中断向量表中去查第19号, 顺藤摸瓜又找到对应0x19的一大堆指令。
执行吧,  这堆指令把将磁盘的第一扇区(磁盘最开始的512字节)运到内存的0X0000:0X7C00处,然后我就从此处接着执行。
我想起来了, 接下来有一大堆精巧的指令把迷迷糊糊的操作系统从硬盘中唤醒, 运输到内存中来。(此处实在是复杂, 略去10万字。。。。)
你看这就是为啥他们叫我阿甘, 我做事飞快,但非得别人告诉去哪里执行才行, 要不然我就只会坐在那里无所适从。

 运行


操作系统一旦进入内存,立刻就是老大, 所有人都得听他指挥。
我也发现我的周围出现了一个屋子:进程屋屋里堆着一大堆东西, 什么进程描述信息包裹了, 进程控制信息包裹了, 我都不太关心, 我只关心最最重要的两件东西:1.  我工作必备的寄存器, 就放在我面前的工作台上。2.  程序计数器, 我用它记住我要执行的下一条指令地址。
"阿甘, 别来无恙啊" , 操作系统对我还是挺不错的, 先给我打招呼。
"Linux老大, 今天有什么活啊", 我每次都表现的积极主动。
"来,把这个hello world 程序给运行了"
Hello world 程序还在硬盘上睡着呢,  得先把他也装载到内存里, 要不然我怎么执行啊。
于是我就拿起电话打给硬盘, 电话通过系统总线来到IO桥电话局, 再转接到IO总线,这才来到硬盘这里。 
我在电话里请他把数据给我运过来, 然后我就无所事事的坐在那里等。
Linux 老大立刻就怒了 : 阿甘, 告诉你多少次了, 你小子怎么还在等硬盘给你发数据!
是的, 我忘了一件事,硬盘比我慢太多了, 我执行一条指令大概是1ns  ,在用来读磁盘的16ms里, 我能潜在的执行1600多万条指令啊。
我感到深深的愧疚, 赶紧拿起电话打给硬盘 : 哥们, 按我们之前商量好的,用直接内存访问(DMA)啊, 你直接把数据装载到内存吧, 不用经过我了,  装载完成以后给我发个信号。
"这还差不多"  Linux 老大心情好了些 “阿甘,数据还没来, 别闲着, 这有一个菲波那切数列数列, 来算一下吧”
"肥波纳妾数列?  这名字好古怪,老大, 其实你也知道, 我脑子小,懒得去理解那是啥意思, 你把进程屋切换下,把程序计数器设置好,指向下一条指令, 我一条条指令执行就得了“  我挺没追求的。
"真是个阿甘啊! ”老大感慨到。
我所处的进程屋立刻发生了变化(当然,这也是我辅助Linux老大干的), 各种包裹的信息都变了,  尤其是寄存器和程序计数器。
于是我就开始计算这个什么纳妾数列 ,但是这个数列似乎无穷无尽, 哪个无脑子的程序员写了个无限循环吧。
正在这时, 我便收到了一个电话, 说是Helloworld的数据已经装载到内存了,  让我去处理。
我放下手中的活, 保存好现场, 就去处理那个Helloworld,  果然数据已经都好了, 那就切换过去运行吧。
其实老大并不知道, 任何人,只要你运行了相当多的数量的指令以后, 你都能悟到这些程序的秘密。 我CPU阿甘虽然傻傻的, 但也架不住执行这数以万万亿的指令给我的熏陶啊。
这个秘密就是:程序都是由顺序,分支,循环来组成的。  其实分支和循环在我看来都是跳转而已。
所以我的工作就是打电话问内存要一条指令, 执行这个指令, 如果是个跳转指令的话,我就问内存要跳转的目标地址的那一条指令, 继续执行, 生活就是这么简单。
奥对了, 当然也有复杂的, 就是函数调用, 我得和内存紧密配合才能完成。  这个咱下回再说。

 新装备:缓存


提到内存, 这真是我的好哥们, 没有他,我几乎什么事儿都干不成, 更重要的是他比硬盘快的多, 读取一次数据, 只需要 100 纳秒左右。 这样我们俩说起话来就轻松多了。
每次他都说: "阿甘, 幸亏有你给我聊天, 要不然我肯定被活活的闷死不可, 那个硬盘说话是在太慢了"
"它为啥那么慢?"  我每次都问
"硬盘是个机械是的玩意, 一个磁头在一碟高速旋转的磁片上挪来挪去,光定位就慢死了"
"那主人为什么要用硬盘?"
"人家虽然慢, 但是不怕停电, 哪像你和我,一停电全部都失去记忆了。"
确实是, 人不能把好事都占全了啊。
我的指令中有些完全用我的寄存器就能完成, 但是有很多都需要读写内存的数据, 再加上所有的指令都在内存中存着,  虽然它只比我慢个100倍, 但指令多了我还是有点受不了。
我给内存说:"哥们, 你能不能再快点!"
内存说: 拜托, 这已经是我的极限了, 阿甘, 你自己再想想办法吧 ! 我给你说啊, 我留意了你最近访问的指令和数据, 我发现了个规律“
"啥规律?"
"比如说吧, 你访问了我一个内存位置以后过不多久还会多次访问, 还有,一个内存位置被访问了, 附近的位置很快也会访问到"(码农翻身注: 这其实叫程序的局部性原理
我还以为是啥规律, 其实我早就注意到了。
"这有啥用啊?”
"既然你经常访问同一块区域的东西, 你想想如果把这些东西缓存在你那里会怎么样.... "
我一想有道理啊!  加个缓存试试!
从此以后,我每次读写指令和数据, 都问缓存要, 缓存没有才给内存打电话。
果然, 由于局部性原理的存在, 我发现的确是快了不少啊。
当然也有缺点, 那就是Linux老大在做程序切换的时候, 缓存就会失效,因为两个程序之间没什么联系,局部性原理不起作用,  所以需要重建缓存。

 自我提升:流水线


缓存让我的工作更有效率, 得到了Linux老大的表扬:"阿甘, 我看你很聪明嘛, 都会用缓存了"
"我哪有那么聪明,都是内存的点子。老大,不过我学会了一个重要的东西 :当你改变不了别人的话,抱怨也没用, 还是先改变一下自己吧"
"挺有哲理的吗, 希望你明天重启后还能想起来" Linux老大笑话我。
"我最近又发现了一个问题, 正苦恼着呢, 你看我有四只手, 第一只手负责打电话问内存要指令, 第二只手翻译指令, 第三只手真正执行, 第四只手有时候还得把结果写回内存。  问题是, 我发现经常只有一只手在忙活, 其他都在闲着, 你看第一只手取指令, 其他手只能等着。  第二只手翻译指令的时候,其他三只也得等“
"看来以后我们不能叫你阿甘了, 你已经开始思考了" Linux老大笑了 
“这问题好解决, 给你举个例子,你听说过洗车没有?  和你差不多, 也是先喷水, 再打洗洁剂, 再擦洗, 最后烘干,  但人家的工作方式和你不一样,人家是流水线作业, 你想想, 一辆车在烘干的时候, 后边是不是还有三辆车,分别在喷水, 打清洁剂 和擦洗 , 每个步骤都不会空闲。 ”
"这么简单的道理我怎么都没有想到呢? 我也可以搞个流水线啊, 这样每只手都利用起来了"
别人都说我们高科技, 但其实原理都蕴含在生活之中啊。 

有了缓存和流水线的帮助, 让我的工作大大的加快了,大家都对我刮目相看。 他们想给我起个新名字:超人 , 不过我还是更喜欢 他们叫我“阿甘”, 多亲切。


首先, 一个程序一条一条的指令都的老老实实的放在内存的一个地方,这个地方是Linux老大分配的, 我干涉不了, 但是这些指令都是我打电话给硬盘, 让他给运输到内存的。

然后linux老大就会告诉我程序的入口点, 其实就是第一条指令的存放地址,  我就打电话问内存要这个指令, 取到指令以后就开始执行。
这些指令当中无非有这么几类:

  1. 把数据从内存加载我的寄存器里(什么? 你不知道啥是寄存器?  寄存器就是我内部的一个临时的数据存储空间了)

  2. 对寄存器的数据进行运算, 例如把两个寄存器的数加起来

  3. 把我寄存器的数据再写到内存里

但是我一旦遇到像这样的指令。  
“把寄存器ebp的值压到栈里去“
我就知道好戏要上场了, 函数调用就会开始。

我们这些x86体系的机器有个特点,就是每个函数调用都会创建一个所谓的“帧”

哈哈, 不要被这些术语吓坏, 其实帧也就是我哥们内存中的一段连续的空间而已。
像这样:






多个函数帧在内存里排起来, 就像一个先进后出的栈一样, 不过,这个栈不像我们常见的栈, 栈底在下面。
相反,这个栈的栈底在上面, 是从上往下生长的 (或者说是从高地址向低地址生长的)

内存经常向我抱怨: “阿甘,你知道吗, 每次我看到这个栈, 都有一种真气逆行的感觉, 半天都调整不过来 ”

但内存不知道, 我有一个叫ebp的特殊寄存器, 一直会指向当前函数在一个栈的开始地址。  
我还有另外一个特殊寄存器,叫做esp , 他会随着指令的运行,指向函数帧的最后的地址, 像这样:

现在这个指令来了:
“把寄存器ebp的值压到栈里去“
“把esp的值赋给ebp”

你看看, 是不是新的函数帧生成了?
只不过现在只有一行数据。 ebp和esp指向同一地址。
函数帧的第一行的地址是800,  里边的内容是1000, 也就是上个函数帧的地址

注意, 我们每次操作的是4个字节,所以原来esp 的地址是804, 现在变成了800
我又问内存要下一条指令:
“把esp 的值减去24”

下面几条指令是这样的:
“把10放到ebp 减去4的地址” (其实就是796嘛)
“把20放到ebp减去8的地址” (其实就是792嘛)

你们知道这是干什么吗?  
我想了好久才明白这是干嘛, 这其实就是在分配函数的局部变量啊
我猜源代码应该是这样的:

int x = 10;int y = 20;1212

在我看来, x, y 只是变量, 他们叫什么根本不重要, 重要的是他们的值和地址!
下面几条指令很有意思:
” 把地址796作为数据放到 esp指向的地址“ (其实就是776嘛)
” 把地址792作为数据放到 esp+4指向的地址” (其实就是780嘛)




这又是在干嘛?
这其实就相当于把 x 的指针 &x和 y 的指针 &y ,放到了特定的地方, 准备着要做什么事情 , 可能要调用函数了。
所以,所谓的指针就是地址而已。
我猜程序员写的代码应该是这样:
int x = 10;
int y = 20;
int sum= add(&x, &y);  
接下来的指令是这样:
“调用函数 add”
我看到这样的函数就需要特别小心, 因为我必须要找到 add函数返回以后的那条指令的地址, 把它也压到栈里去。
int x = 10;
int y = 20;
int sum = add(&x, &y);  
printf(“the sum is %d\n”,sum); 假设这条指令的地址是100

注意啊, 把函数调用结束的以后的返回地址100压入栈以后, esp 也发生变化了, 指向了772的位置
我会找到函数Add 的指令,继续执行
“把寄存器ebp的值压到栈里去“
“把esp的值赋给ebp”
“把寄存器ebx的值压入栈”
你看每个函数的开始指令都是这样, 我猜这应该是一种约定吧
这里额外把ebx这个寄存器压入栈, 是因为ebx可能被上个函数使用, 但是在add函数中也会用 , 为了不破坏之前的值, 只有先委屈一下暂时放到内存里吧。

接下来的指令是:
“把ebp 加8的数据取出来放到 edx 寄存器” (ebp+8 不就是地址776嘛, 其中存放的是&x的地址, 这就是取参数了)

“把ebp 加12的数据取出来放到 ecx 寄存器” (ebp+12 不就是地址780嘛, 其中存放的是&y的地址)

注意啊, 现在edx的值是796, ecx的值是792 , 但他们仍然不是真正的数据, 而是指针(地址)!

“把edx 指向的内存地址(796)的数据取出来,放到ebx 寄存器”

“把ecx 指向的内存地址(792)的数据取出来,放到eax寄存器”

此时此刻, 终于取到了真正的值, ebx = 10, eax = 20
你晕了没有?
如果你到此已经晕了, 建议你再读一遍。
我想源代码应该非常的简单,就是这样:

int add(int *xp , int *yp){    int x = *xp;    int y = *yp;
    ....
}1234512345

“把ebx 和 eax 的值加起来,放到 eax寄存器中”  
这个指令我最擅长做了。
接下来的指令也很关键, add 函数已经调用完成, 准备返回了  
               “把esp 指向的数据弹出的ebx寄存器”
“把esp 指向的数据弹出到ebp寄存器”

你看add 函数帧已经消失了, 或者换句话说, add 函数帧的数据还在内存里, 只是我们不在关心了!
接下来的指令非常的关键:
“返回”
我就会取出那个返回地址, 也就是 100, 去这里找指令接着执行
其实就是这条语句: printf(“the sum is %d\n”,sum);
问你一个问题, sum的值在那里保存着呢?  
对, 是在eax寄存器里 !

搞定了,看着很复杂, 其实看透了也挺简单吧。 函数调用,关键就是

  • 把参数和返回地址准备好,

  • 然后大家都遵循约定, 每次新函数都要建立新的函数帧:
    “把寄存器ebp的值压到栈里去“
    “把esp的值赋给ebp”

  • 函数调用完了, 重置 ebp 和esp ,让他们重新指向调用着的栈帧。

好了,今天就到此为止 , 把我也累坏了,  主人又要关机了, 留一个问题吧:

C语言编译,链接以后直接就是机器码, 那函数调用的操作都是上面讲的。  

但是对于Python, Ruby 这样的解释型语言, 或者对于Java 这样的有虚拟机的语言, 他们的函数调用是什么样的?  和上面讲的有什么关系?