码农翻身讲操作系统1:CPU与内存的那些事


转自  码农翻身

本系列文章全部摘选自“码农翻身”公众号,仅供个人学习和分享之用。文章会给出原文的链接地址,希望不会涉及到版权问题。

个人感言:真正的知识是深入浅出的,码农翻身” 公共号将苦涩难懂的计算机知识,用形象有趣的生活中实例呈现给我们,让我们更好地理解。感谢“码农翻身” 公共号,感谢你们的成果,谢谢你们的分享。

本文源地址:http://mp.weixin.qq.com/s?__biz=MzAxOTc0NzExNg==&mid=2665513017&idx=1&sn=5550ee714abd36d0b580713f673e670b&scene=21#wechat_redirect

在本篇文章的基础上,本人增加了CPU的内部结构方面的介绍,以及CPU流水线的工作原理等,并给出相关图解,希望原作者能够包含。

CPU的内部结构如下图所示:

图1 cpu内部结构框图

前言

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

启动

我住在一个机箱里,每天早上一阵电流把我叫醒, 还夹杂着嗡嗡的声音, 我知道我忠实的护卫电风扇又开始工作了, 我特别怕热,又运行的飞快, 如果没有电风扇给我降温,我很快就会中暑的,中暑的后果很严重,那就是我的伙伴们像内存了,硬盘了...,全部都要罢工了。没有我,这个系统就会陷入的一片死寂。
我听说有些CPU的福利很好,竟然待在恒温恒湿,一尘不染的托管机房里,让我好生羡慕。
我的脑容量很小, 所以醒来后只想起了我的创造者告诉我的几件事情 :

  1. 你的工作就是运行指令;
  2. 你不能保存指令, 你的指令全在内存里;
  3. 你的第一条指令在内存的最顶端处0xFFFFFFF0

图2 CPU工作流程图

启动操作系统

那还有什么可说的, 赶紧打电话给内存要指令,电话通过系统总线,还得通过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老大笑了 
“这问题好解决, 给你举个例子,你听说过洗车没有?  和你差不多, 也是先喷水, 再打洗洁剂, 再擦洗, 最后烘干,  但人家的工作方式和你不一样,人家是流水线作业, 你想想, 一辆车在烘干的时候, 后边是不是还有三辆车,分别在喷水, 打清洁剂 和擦洗 , 每个步骤都不会空闲。 ”
"这么简单的道理我怎么都没有想到呢? 我也可以搞个流水线啊, 这样每只手都利用起来了"
别人都说我们高科技, 但其实原理都蕴含在生活之中啊。 
有了缓存和流水线的帮助, 让我的工作大大的加快了,大家都对我刮目相看。

他们想给我起个新名字:超人,不过我还是更喜欢 他们叫我“阿甘”, 多亲切。

--------------------------------------------------------------------------------------------------------------------个人添加-----------------------------------------------------------------------------------------------------------

现在剖析一下CPU的四只手,其实也就是一条指令的执行需要经历的四个阶段:取指令、译码、执行、写回。假设每一个阶段都要花费一个时钟周期,如果没有采用流水线技术,那么N条这样的指令需要4N个时钟周期,如下图所示:

图3 没有采用流水线技术的时钟周期

而采用流水线技术时,N条指令需要N+3个时钟周期,如下图所示:

图4 指令流水线示意图

 那么N+3是如何算出来的呢?请往下看:

从图4可以看出,第一条指令执行完需要4个时钟周期,以后都是每一个时钟周期执行完一条指令。所以需要总的时钟周期数为:

1*4+(N-1)*1 = N+3。

可见流水线技术大大地提高了CPU的工作效率。

--------------------------------------------------------------------------------------------------------------------个人添加-----------------------------------------------------------------------------------------------------------

尾声

我一丝不苟,兢兢业业的运行指令, 时不时和伙伴们聊天, 很快一天就过去了, 又到了晚上,我知道关机的时刻到了, 赶紧挨个给他们道别。 
很快那些让我兴奋的电流消失了,风扇的嗡嗡声也没有了, 我再也无法打出电话,整个世界沉寂了。
明天将会是新的一天。

看了刘欣老师的文章,真是醍醐灌顶。

 

        1    批处理系统

        CPU阿甘最近很烦。 原因很简单,内存和硬盘看他不顺眼。

        阿甘心里很清楚,是自己干活太快了,干完了活就歇着喝茶,这时候内存和硬盘还在辛辛苦苦的忙活,他们肯定觉得很不爽了。中国有句古话叫什么来着? “木秀于林,风必摧之”,“不患贫而患不均”,这就是阿甘的处境。虽然阿甘自己也于心不忍,可是有什么办法?谁让他们那么慢!一个比自己慢100倍,另外一个比自己慢100万倍!这个世界的造物主为什么不把我们的速度弄的一样呢?

        阿甘所在的是一个批处理的计算机系统,操作系统老大收集了一批任务以后,就会把这一批任务的程序逐个装载的内存中,让CPU去运行,大部分时候这些程序都是单纯的科学计算,计算弹道轨迹什么的,但有时候也会有IO相关的操作,这时候,内存和硬盘都在疯狂的加班Load数据,可是阿甘只能等待数据到来,只能坐那儿喝茶了。 

        没多久,内存向操作系统老大告了阿甘一状,阿甘被老大叫去训话了:“阿甘,你就不能多干一点?  老是歇着喝茶算是怎么回事?”

        阿甘委屈的说:“老大,这不能怪我啊,你看你每次只把一个程序搬到内存那里让我运行,正常情况下,我可以跑的飞快,可以是一旦遇到IO相关的指令,势必要去硬盘那里找数据,硬盘实在是太慢了,我不得不等待啊”

        操作系统说:“卧槽,听你的口气还是我的问题啊,  一个程序遇到了IO指令,你不能把它挂起,存到到硬盘里,然后再找另外一个运行吗?”

        阿甘笑了:“老大我看你是气昏头了,我要是把正在运行的程序存到硬盘里,暂时挂起,然后再从硬盘装载另外一个,这可都是IO操作啊,岂不更慢?”

        “这?!” 操作系统语塞了,沉默了半天说:“这样吧,我以后在内存里多给你装载几个程序,一个程序被IO阻塞住了,  你就去运行另外一个如何?”

        “这得问问内存,看他愿不愿意了,我把内存叫来,我们一起商量商量” 阿甘觉得这个主意不错。

        内存心思缜密,听了这个想法,心想:自己也没什么损失啊,原来同一时间在内存里只有一个程序,现在要装载多个,对我都一样。 

        可是往深处一想,如果有多个程序,内存的分配可不是个简单的事情,比如说下面这个例子:

        (1) 内存一共90k, 一开始有三个程序运行,占据了80k的空间,剩余10k

        (2) 然后第二个程序运行完了,空闲出来20k , 现在总空闲是30K,但这两块空闲内存是不连续的。

         (3) 第4个程序需要25k, 没办法只好把第三个程序往下移动,腾出空间让第四个程序来使用了。

        内存把自己的想法给操作系统老大说了说。 老大说:阿甘,你要向内存学习啊,看看他思考的多么深入,不过这个问题我有解决办法,需要涉及到几个内存的分配算法,你们不用管了。咱们就这么确定下来,先跑两个程序试试。 

                 2    地址重定位

        第二天一大早,试验正式开始,老大同时装载了两个程序到内存中:

        第一个程序被装载到了内存的开始处,也就是地址0,运行了一会,遇到了一个IO指令,在等待数据的时候,老大立刻让CPU开始运行第二个程序,这个程序被装载到了地址10000处,刚开始运行的好好的,突然就来了这么一条指令:

        MOV AX  [1000] ( AX是一个寄存器,你可以理解成在CPU内部的一个高速的存储单位,这个指令的含义是把AX寄存器的值写到内存地址1000处)

        阿甘觉得似曾相识,隐隐约约的记得第一个程序中也这么一条类似的指令:    

        MOV BX  [1000]

        “老大,坏了,这两个程序操作了同一个地址!数据会被覆盖掉!” 阿甘赶紧向操作系统汇报。 

        操作系统一看就明白了,原来这个系统的程序引用的都是物理的内存地址,在批处理系统中,所有的程序都是从地址0开始装载,现在是多道程序在内存中,第二个程序被装载到了10000这个地址,但是程序没有变化啊,还是假定从0开始,自然就出错了。 

        “看来老大在装载的时候得修改一下第二个程序的指令了,把每个地址都加上10000(即第二个程序的开始处),原来的指令就会变成 MOV AX [11000] ” 内存确实反应很快。(直接修改程序的指令,这叫静态重定位)

        阿甘说:“ 如果用这种办法,那做内存紧缩的时候可就麻烦了,因为老大要到处移动程序啊,对每个移动的程序岂不还都得做重定位?这多累啊!”

        操作系统老大陷入了沉思,阿甘说的没错,这个静态重定位是很不方便,看来想在内存中运行多道程序不是想象的那么容易。但是能不能改变下思路,在运行时把地址重定位呢?

        首先得记录下每个程序的起始地址,可以让阿甘再增加一个寄存器,专门用来保存初始地址。 

        例如对第一个程序,这个地址是0 ,  对第二个程序,这个地址是10000, 运行第一个程序的时候,把寄存器的值置为0 ,当切换到第二个程序的时候,寄存器的值也应该切换成10000。 只要遇到了地址有关的指令,都需要把地址加上寄存器的值,这样才得到真正的内存地址,然后去访问。(这叫地址的动态重定位)

        操作系统赶紧让阿甘去加一个新的寄存器,重新装载两个程序,记录下他们的开始地址,然后切换程序,这次成功了,不在有数据覆盖的问题了。 

        只是阿甘有些不高兴:“老大,这一下子我这里的活可多了不少啊,你看每次访问内存,我都得额外的做一次加法运算啊。”

        老大说:“没办法,能者多劳嘛,你看看我,我既需要考虑内存分配算法,还得做内存紧缩,还得记住每个程序的开始地址,切换程序的时候,才能刷新你的寄存器,我比你麻烦多了!”

        内存突然说到:”老大,我想到一个问题,假设有个不怀好意的恶意程序,它去访问别人的空间怎么办?  比如说地址2000至3000属于一个程序,但是这个程序来了一条这样的指令MOV AX [1500],  我们在运行时会翻译成MOV AX [3500]  , 这个3500有可能是别的程序的空间啊“

        “唉,那就只好再加个寄存器了,阿甘,用这个新寄存器来记录程序在内存中的长度吧,这样每次访问的时候拿那个地址和这个长度比较一下,我们就知道是不是越界了” 老大无可奈何了。 

        “好吧” 阿甘答应了,“ 我可以把这连个寄存器,以及计算内存地址的方法,封装成一个新的模块,就叫MMU (内存管理单元)吧,不过这个东西听起来好像应该内存来管啊”

        内存笑着说:“那是不行的,阿甘,能够高速访问的寄存器只有你这里才有啊,我就是一个比你慢100倍的存储器而已!”。

                3分块装入程序

        多道程序最近在内存中运行的挺好,阿甘没法闲下来喝茶了,经常是一个还没运行完,很快就切换到另外一个。那些程序也都是好事之徒,听说了这个新的系统,都拼了命,挤破头的往内存中钻。内存很小,很快就会挤满,操作系统老大忙于调度,也是忙的不可开交。 

        更有甚者,程序开始越长越大,有些图形处理的程序,还有些什么叫Java的程序,动不动就要几百M内存,就这还嚷嚷着说不够。 操作系统头都大了,把CPU和内存叫来商量。 

        “世风日下,人心不古啊” 内存一边叹气一遍说“原来批处理的时候那些程序规规矩矩的,现在是怎么了?”

        “这也不能怪那些程序,现在硬件的确比原来好多了,内存,你原来只有几十K, 现在都好几G了,CPU在摩尔定律的关照下,发展的更快,每隔18个月,你的速度就翻一翻”  操作系统老大说。    

        “那也赶不上这些程序的发展速度,他们对我要求越来越高,可是把我累坏了” CPU垂头丧气的。 

        “我们还是考虑下怎么让有限的内存装下更多的程序吧” 

        “我有一个提议” 阿甘说“对每个程序,不要全部装入内存,要分块装载,例如先把最重要的代码指令装载进来,在运行中按需装载别的东西。”

        内存嘲笑说:“阿甘,看来你又想偷懒喝茶了,哈哈,如果每个程序都这样,IO操作得多频繁,我和硬盘累死,你就整天歇着吧”

        阿甘脸红了,沉默了。 

        “慢着”老大说“阿甘,你之前不是发现过什么原理嘛,就是从几千亿条指令中总结出的那个,叫什么来着?”

        “奥,那是局部性原理,有两个:

        (1)  时间局部性:如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行; 如果某数据被访问,则不久之后该数据可能再次被访问。

        (2) 空间局部性:指一旦程序访问了某个存储单元,则不久之后。其附近的存储单元也将被访问。“

        “这个局部性原理应该能拯救我们,阿甘,我们完全可以把一个程序分成一个个小块,然后按块来装载到内存中,由于局部性原理的存在,程序会倾向于在这一块或几块上执行,性能上应该不会有太大的损失。”

        “这能行吗?” 内存和阿甘不约而同的问。“试一试就知道了,这样我们把这一个个小块叫做页框(page frame),每个暂定4k大小,装载程序的时候也按照页框大小来”

        实验了几天,果然不出老大所料,那些程序在大部分时间真的只运行在几个页框中,于是老大把这些页称为工作集(working set)

                    4虚拟内存:分页

        “既然一个程序可以用分块的技术逐步调入内存,而不太影响性能,那就意味着,一个程序可以比实际的内存大的多啊”。阿甘躺在床上,突然间想到这一层,心头突突直跳,这绝对是一个超级想法。 

        “我们可以给每个程序都提供一个超级大的空间,例如4G,只不过这个空间是虚拟的,程序中的指令使用的就是这些虚拟的地址,然后我的MMU把它们映射到真实的物理的内存地址上,那些程序们浑然不觉,哈哈,实在是太棒了”

        内存听说了这个想法,惊讶的瞪大了双眼:“阿甘,你疯了吧”

        “阿甘的想法是有道理的” 老大说“只是我们还要坚持一点,那就是分块装入程序,我们把虚拟的地址也得分块,就叫做页(page), 大小和物理内存的页框一样,这样好映射。”

        “老大,看来你又要麻烦了,你得维持一个页表,用来映射虚拟页面和物理页面”

        “不仅如此,我还得记录一个程序哪些页已经被装载到了物理内存,哪些没有被装载,如果程序访问了这些没被装载的页面,我还得从内存中找到一块空闲的地方,如果内存已满,只好把现有的页框置换一个到硬盘上了,可是,怎么确定那个物理内存的页框可以置换呢?唉,又涉及到很多复杂的算法,需要大费一番周折。你看看,老大不是这么容易当的。”

        (这就是分页的工作原理,需要注意的是虚拟地址的#4页,在物理内存中不存在,如果程序访问第4页,就会产生缺页的中断,由操作系统去硬盘调取)

        内存想起来一个问题:  “如果程序运行时,每次都得查页表来获得物理的内存页,而页表也是在内存里,而我比你慢100倍,你受得了吗,阿甘?”

        阿甘笑了:“这个问题其实我也考虑了,所以我打算增强我的内存管理单元,把那些最常访问的页表项放到缓存里,这样不就快了吗。  ”

        内存想想也是,还是局部性原理,太牛了。

                    5分段+分页

        分页系统运行了一段时间以后,又有程序表示不爽了,这些程序嚷嚷着说:

        “你们能不能把程序“分家”啊,例如代码段,数据段,堆栈段,这多么自然,并且有利于保护,要是程序试图去写这个只读的代码段,立刻就可以抛出保护异常!”

        还有程序说:“页面太小了,实在不利于共享,我和哥们共享的那个图形库,高达几十M , 得分成好多页来共享,太麻烦了,你们要是做一个共享段该多好!”......这样的聒噪声多了,大家都不胜其烦,那就“分家”吧。 

        当然对每个程序都需要标准化,一个程序被分成代码段,数据段和堆栈段等,操作系统老大记录下每个段的开始和结束地址,每个段的保护位。 

        但是在每个段的内部,仍然按分页的系统来处理,除了页表之外,操作系统老大又被迫维护了一个段表这样的东西。

        一个虚拟的内存地址来了以后,首先根据地址中的段号先找到相应的段描述表,其中有页表的地址,然后再从页表中找到物理内存,过程类似这样:

        所有事情都设置好了,大家都喘了口气,觉得这样的结构大家应该没什么异议了。 

        老大心情大好,觉得一切尽在掌握,他笑着对CPU阿甘说: 

        “阿甘,从今天开始,如果有程序想非法的访问内存,例如一个不属于他的段,我就立刻给他一个警告:Segmentation Fault !”阿甘说:“那程序收到Segmentation Fault以后怎么处理?”

        老大说:“通常情况下就被我杀死,然后给他产生一个叫core dump的尸体,让那些码农们拿走分析去吧!”

 

 

 我是CPU阿甘,上次我给大家承诺过,要讲一讲函数调用的秘密,这个确实有点复杂,想透彻的理解机器代码层面的函数调用不容易。

       我也是从无数的指令中悟出这个函数调用的秘密的, 所以慢慢来,不要急。放松心情,慢慢的品味, 你可能需要多看几遍才能明白。但是你一旦理解了,绝对物超所值,因为你会了解到汇编,寄存器,指针,以及他们在一起到底是怎么工作的。

       首先,一个程序一条一条的指令都的老老实实的放在内存的一个地方,这个地方是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;

              在我看来,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;

      ....

              }

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

              “把esp 指向的数据弹出的ebx寄存器”

              “把esp 指向的数据弹出到ebp寄存器”

              你看add 函数帧已经消失了,或者换句话说,add 函数帧的数据还在内存里,只是我们不在关心了!

接下来的指令非常的关键:

              "返回"

              我就会取出那个返回地址,也就是100,去这里找指令接着执行。其实就是这条语句: printf("the sum is %d\n",sum);

              问你一个问题,sum的值在那里保存着呢? 

              对,是在eax寄存器里!

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

              (1)把参数和返回地址准备好,      

              (2)然后大家都遵循约定,每次新函数都要建立新的函数帧:

              "把寄存器ebp的值压到栈里去“

      "把esp的值赋给ebp"

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

好了,今天就到此为止,把我也累坏了。

 

 

 

 

CPU阿甘之缓冲区溢出

 

原创: 老刘 码农翻身 1月25日

 

我是大家的老朋友CPU阿甘, 每天你一开机,我就忙得不亦乐乎,从内存中读取一条条的指令,挨个执行。

 

最早的时候我认为程序都是顺序执行的,后来发现并不是这样,经常会出现一条跳转指令,让我到另外一个内存地址处去下一条指令去执行。

 

 

 

时间久了我就明白这是人类代码中的if ... else ,或者for ,while等循环导致的。

 

这样跳来跳去,让我觉得有点头晕,不过没有办法,这是人类做出的规定。

 

后来我发现,有些指令经常会出现重复,尤其是下面这几个:

 

pushl    %ebp

movl     %esp %ebp

call  xxxx

ret

 

正当我疑惑的时候,内存炫耀地说:这些指令是为了函数调用,建立栈帧所所必需的啊。

 

“函数调用?这是什么鬼?”

 

“函数调用你都不知道? 我告诉你吧,现在的计算机语言,甭管你是面向对象还是函数式、动态还是静态、解释还是编译,只要想在我们冯诺依曼体系结构下运行,最终都得变成顺序、循环、分支,以及函数调用!”

 

内存说着给我举了一个例子:

 

 

这个例子非常简单,一看就明白。

 

“但是栈帧是什么?”

 

“阿甘你知道栈是什么意思吧?”

 

“不就是一个先进后出的数据结构吗?”

 

 

“对,通俗来说:一个栈帧就是这个栈中的一个元素,表示了一个函数在运行时的结构。” 内存继续给我科普:

 

 

“你这种画法好古怪,怎么倒过来了,栈底在上方,栈顶反而在下方!”

 

“这也是人类规定的,一个进程的虚拟内存中有个区域,就是栈,这个栈就是从高地址向低地址发展的啊。”

 

“奥,原来我执行的代码在一个叫做代码区的地方存放着啊,执行的时候会操作你的栈,对不对?”

 

“没错,我再给你看看那个栈帧的内部结构吧!”

 

 

这张图看起来很复杂,但是和代码一对应,还是比较清楚的。

 

我心中模拟了一下这个执行过程,hello()函数正在被执行,当要调用add函数的时候,需要准备参数,即x = 10, y=20 。

 

还要记录下返回地址,即printf(....)这个指令在内存的地址。当add函数调用完成以后,就可以返回到这里执行了。

 

真正开始执行add函数的时候,也需要给它建立一个栈帧(其中要记录下上个函数栈帧的开始地址),还有这个函数的参数,在栈帧也会分配内存空间,例如sum, buf等。

 

等到执行结束,add函数的栈帧就废弃了(相当于从栈中弹出),找到返回地址,继续执行printf指令。

 

hello函数执行完毕,也会废弃掉,回到上一个函数的栈帧,继续执行,如此持续下去....

 

我对内存说:“明白了,我已经迫不及待地想执行一下这个函数,看看效果了。”

 

内存说:“真的明白了?正好,操作系统老大已经发出指令,让我们运行了,开始吧!”

 

建立hello函数的栈帧,调用add函数,建立add栈帧,执行add函数的代码, 一切都很顺利。

 

add函数中调用了scanf ,要求用户输入一些数据,人类是超级慢的,我耐心等待。

 

用户输入了8个字符A,我把他们都放到了buf所在的内存中:

 

 

但是人类还在输入,接下里是一些很奇怪的数据,其长度远远超过了char buf[8]中的8个字节。

 

可是我还得把数据给放到内存中啊,于是函数栈帧就变成了这个样子。

 

 

(注:用户输入的数据是从低地址向高地址存放的。)

 

我觉得特别古怪的是,这个返回地址也被冲掉了,被改写了。

 

这个用户到底要干啥?

 

add函数执行完毕,要返回到hello函数了, 我明明知道返回地址已经被改掉, 可是我没有选择,还得把那个新的(用户输入的)返回地址给取出来, 老老实实地去那个地址取出下一条指令去执行。

 

完了,这根本就不是原来的prinf函数,而是一段恶意代码的入口!

 

 

 

与此同时....

 

黑客三兄弟中的老三大叫: 大哥二哥,我的这次缓冲区溢出攻击实验成功了!

 

“不错啊,你是怎么搞的?” 老大问道。

 

“正如二哥说的,那个scanf函数没有边界检查,我成功地把代码注入到了栈帧中,并且修改了返回地址!于是程序就跳到我指定的地方执行了。”

 

推荐阅读:

黑客三兄弟

黑客三兄弟(续)

CPU阿甘

微信公众号

个人公众号:程序员黄小斜

微信公众号【程序员黄小斜】新生代青年聚集地,程序员成长充电站。作者黄小斜,职业是阿里程序员,身份是斜杠青年,希望和更多的程序员交朋友,一起进步和成长!专注于分享技术、面试、职场等成长干货,这一次,我们一起出发。

关注公众号后回复“2020”领取我这两年整理的学习资料,涵盖自学编程、求职面试、算法刷题、Java技术学习、计算机基础和考研等8000G资料合集。

技术公众号:Java技术江湖

微信公众号【Java技术江湖】一位阿里 Java 工程师的技术小站,专注于 Java 相关技术:SSM、SpringBoot、MySQL、分布式、中间件、集群、Linux、网络、多线程,偶尔讲点Docker、ELK,同时也分享技术干货和学习经验,致力于Java全栈开发!

关注公众号后回复“PDF”即可领取200+页的《Java工程师面试指南》强烈推荐,几乎涵盖所有Java工程师必知必会的知识点。

 

  • 5
    点赞
  • 0
    评论
  • 18
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 猿与汪的秘密 设计师:白松林 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值