《程序是如何跑起来的》笔记

CPU是什么

程序是计算机每一步动作的指令。程序由指令和数据组成,运行的程序是存储在内存中的,构成程序的指令和数据存储位置的整数数值就是内存地址;而CPU(中央处理器)负责解释和运行被转换成机器语言的程序内容;

CPU由控制器、寄存器、运算器、时钟组成;
控制器负责把内存上的指令和数据读入寄存器,并对指令和数据加以解释和运行,最终根据运算器对数据运行的结果控制计算器;可以看出控制一词就是指数据运算以外的处理,数据运算是由运算器负责的,其他的比如内存的输入输出,键盘和鼠标的输入,显示器和打印机的输出等等都是控制的内容;
寄存器用来暂存指令和数据;
运算器负责运算寄存器的数据;
时钟负责发出CPU开始计时的时钟信号,控制器就是根据时钟信号知道程序开始运行,然后从内存中读取指令和数据;

内存就是计算机中的主存储器,简称主存,主要负责存储指令和数据;主存中每个字节都带有一个地址编号,通过控制芯片与主存相连的CPU就是通过地址读取主存中的指令和数据,当然也可以写入数据;因为程序运行时指令和数据才被存储在内存中,所以当计算机关闭的时候,指令和数据会被清楚;

也就是说程序的运行如下所示:
程序员用C语言等高级语言编写程序 ——> 将高级语言编译成机器语言存储在EXE文件中 ——> 程序运行时,在内存中会生成EXE文件的副本 ——> CPU解释并执行程序的内容。
即高级语言经过编译以后转换成机器语言,机器语言级别的程序通过寄存器来处理,从而控制器可以对机器语言级别的程序中的指令和数据加以解释和运行;

可以看出,寄存器是最重要,最需要了解的。不同类型CPU的寄存器数量、类型、所能存储的数值范围都是不同的;但是根据功能不同,可以分为8类寄存器:

种类 功能
累加寄存器(只有一个) 存储执行运算的数据和运算后的结果
程序寄存器(只有一个) 存储下一条指令所在的内存地址
标志寄存器(只有一个) 存储运算处理后的CPU的状态
指令寄存器(只有一个) 存储指令,CPU内部使用,程序员不能对该寄存器读写
基址寄存器 存储数据内存的起始地址
变址寄存器 存储基址寄存器的相对地址
栈寄存器(只有一个) 存储栈区域的起始地址
通用寄存器 存储任意数据

实际地址 = 基址寄存器的值 + 变址寄存器的值 ;
比如我要查看内存地址范围为10000000 ~1000FFFF的地址,那么就将10000000存入基址寄存器,将00000000 ~ 0000FFFF 的地址存入变址寄存器中;

例1:
上表中的程序寄存器决定程序的流程:系统将硬盘中的程序(比如此次的程序是将123和456两个数值相加)复制到内存中 ——> 比如该程序运行的开始位置是0100,程序寄存器就被设置为0100 ——> CPU每运行一条指令,该数值就加1;比如0101就是将123存入累加寄存器,0102就是将456存入通用寄存器,0103就是将累加寄存器中的值和通用寄存器中的值相加,0104就是将结果显示在显示屏上,0105就是结束程序;

程序的流程分为顺序执行、条件分支、循环三种;
顺序执行是指按照地址内容的顺序执行指令;这个执行情况比较简单,就如上面的列子来说,每执行一个指令,程序寄存器的值就加一;
条件分支是指根据条件执行任意地址的指令;程序寄存器的值就设定为任意地址;
循环是指重复执行同一地址的指令;程序寄存器的值就设定为上一个地址重复执行;

例2:
上一个列子说了顺序执行的情况,下面来说以下分支条件的执行情况:
要把内存中一个数的绝对值输出到屏幕上,假设程序开始的地址是0100(程序寄存器)
——> 随着程序寄存器的数值的累加,假设到达0120地址时,无论该数值是整数或者负数或者零,都会被保存在标志寄存器中,CPU根据标志寄存器的结果(即0,1,2三种结果分别代表正,零,负)来决定是否跳转;如果为正数,则执行跳转指令跳转到0104地址(直接在显示屏上显示数值),也就是程序寄存器中的值直接设定为0104地址
——> 如果是负数,则先到0103(即先转成整数),然后在到0104地址;

上面的例子讲到了跳转指令,但是如果不是比较两者的正负,而是中途要调用某个函数,那么就不是使用跳转指令了,单纯的跳转就是跳转到函数的入口,执行完程序就不知道应该返回哪里;函数的调用处理则会把流程再返回到函数调用指令的下一个地址;就是函数调用使用的是机器语言的call指令,call指令会把函数调用后要执行的地址存储在名为栈的内存中,函数执行完毕以后,return命令会把保存在栈中的地址设置到程序寄存器中;

由例1和例2可以看出机器语言的指令一共有四种:
数据转送指令:寄存器和内存,内存和内存,寄存器和外围设备之间数据的读写;
运算指令:用累加寄存器执行算术运算,逻辑运算,比较运算和移位运算;
跳转指令:实现条件分支,循环等;
call/return指令:函数的调用/返回调用前下一个指令的地址;

数据是用二进制数表示的

在C和JAVA等高级语言编写的程序中,数值、字符串、图像等信息在计算机内部都是使用二进制数值的形式来表现的;但是为什么要用二进制数值来表示计算机要处理的信息(数值、字符串、图像)呢?
因为计算机内部都是由IC这种电子部件构成的,CPU和内存也是,IC的形状有时候是蜈蚣状,两边有引脚出来;有时候是针盘状,四周有引脚;IC的引脚只有0V和5V两个状态,这种特性,导致了计算机的信息数据只能用二进制来表示;
计算机处理信息的最小单位是位,计算机处理信息的基本单位是8位(一个引脚代表一位)二进制数,8位二进制数被称为一个字节;也就是说位是最小单位,字节(8位)是基本单位;
内存和磁盘都是使用字节基本单位来读写数据的,在用字节单位来处理信息数据的时候,如果数字小于二进制数的位数,高位上就用0填补;比如110101,用8位表示时,就是00110101,用十六位表示时,前面就要用零填补,凑齐十六位;生活中所说的32位微处理器就是指有32个引脚,可以同时处理32位二进制数信息;

用二进制表示负数,是将该二进制取反后加1;最高位是1的为负数,最高位是0的为正数;
如果某个二进制数进行左移,那么左移后的二进制数转换成十进制以后是原来二进制数转为十进制数后的2n(n表示左移的位数);左移后后面的位数用0补全;
如果某个二进制数右移,要看是逻辑右移还是算术右移;当二进制数值表示图像的时候,发生的是逻辑右移,最高位用0补全;当二进制作为带符号的数值的数值进行算术运算的时候,发生的是算术右移,那么原来的数值最高位是1,则右移后高位全部补全位1,反之,补全0;
符号扩充就是在保持数值不变的前提下,将8位数扩展成32位或者16位;如果是将正数扩展成16位或者32位,很简单,在前面加零就可以了,但是如果是表示负数的二进制数,那么就用1全部补充;即用最高位的符号老扩充!

计算机能处理的运算分为算术运算和逻辑运算;
逻辑运算是指单纯的对0和1进行处理;包括逻辑与、逻辑非、逻辑或、逻辑异四种;
逻辑非指0变成1,1变成0的取反操作;
逻辑与是指两者都为1时运算结果为1,其余结果都为0;
逻辑或是指其中一方为1时,运算结果为1,其余情况为0;
逻辑异是指两个数值不同的情况下运算结果为1,其余情况下为0;
在逻辑运算中,就不能将二进制数看成数值,而是将其看成图形或者开关上的ON/OFF

计算机进行小数运算时出错的原因

计算机在进行小数运算时也会出错,比如将0.1相加100次,然后将最终值显示在屏幕上,理想中的结果应该是10,但是可能最终显示的结果是10.000002;想要解释这个原因就要了解计算机处理小数的机制;
由上一小节可知,计算机内部的所有信息都是以二进制形式来处理的;但是用二进制数来表示小数和整数却有大不同;有些十进制小数无法转换成二进制数,例如十进制数0.1;
小数点后四位用二进制表示时,数值范围为:0.0000~0.1111,因此十进制数只能是0.5,0.25,0.125,0.0625这四个数自由组合相加后的结果之一;不在此内的,都是计算机不能表示的;
回到上面的0.1,因为无法用二进制数表示,最后会显示一个循环小数;
在编程语言中提供了两种表示小数的形式:采用64位表示的双精度(double)浮点数和采用32位的单精度(float)浮点数;
浮点数是指由符号、尾数、基数、指数这四部分来表示的小数;因为计算机采用的是二进制数,所以基数自然为2;那么64位和32位会被分成三部分使用;
64位:符号1位(1为负,0为正),指数11位,尾数52位;
32位:符号1位(1为负,0为正),指数8位,尾数23位;
指数和尾数并不是用整数表达那样简单;
试想十进制数来表达0.75这个小数,有以下几种表达方式:

0.75 = 0.75 * 10^0^;
0.75 = 75 * 10^-2^;
0.75 = 0.075 * 10^1^;

表达方式很多,二进制数也是;在二进制数中,会如下例(以32位为例子)一样

1011.0011      //原来的数
0001.0110011  //使整数部分的第一位为1
0001.01100110000000000000000000000000   //确保小数是32位

32位浮点数指数部分是8位,那么最高的值是11111111(255),而在EXCESS系统中的十进制数不是255,而是128,EXCESS系统是以00000000 ~ 11111111的中间数作为0的,而00000000到中间值用来表示负数,中间值到11111111用来表示正数。因此在8位情况下,指数的范围就是-127次幂~-128次幂;
接下来看一个完整的例子:

0.75 //十进制原数
0-01111110-10000000000000000000000000000000 //-是为了看的更清楚,计算机用二进制数表示,0表示正数,01111110为指数部分,在EXCESS系统表示-1,后面的尾数实际上是1.10000000000000000000000000000000即十进制数1.5;那么1.5*2^-1^,正好是0.75

0.75能这样正确的表示是因为0.75=0.5+0.25;换成0.1,那么最终将计算机中的二进制代码换成十进制的,结果是错误的;

因为计算机采用二进制数来表示数值,且用浮点数来表示小数,导致了小数计算出错;避免的方法是先将小数转换成正数进行运算,在转换成成最终的小数;如:需要将0.1相加100次,可以先将0.1扩大十倍变成1,相加后变成100,最后将100/10,那么得出的结果就是10;

熟练使用有棱有角的内存

内存实际上是一种名为内存IC的电子元件;内存IC中有电源、地址信号、数据信号、控制信号等用于输入输出的大量引脚;通过为其指定地址来进行数据的读写;
现在有一个虚拟的内存IC,VCC和GND表示电源;A0 ~ A9表示地址信号的引脚;D0 ~ D7 表示数据信号的引脚,表示一次可输入输出8位数据;RD和WR表示控制信号的引脚;用5v直流电压表示1,0v表示0;
由于A0 ~ A9共有10个,就是0000000000 ~ 1111111111,共1024个地址(1024个1字节的数据),1024个字节 = 1K,所以该内存IC的容量就是1K;当然通常情况下,计算机中使用的内存IC有更多的地址信号引脚;
输入数据时,给VCC接入5V,GND接入0V;用A0 ~ A9 的地址信号来指定数据的存储场所,将WR信号设定为1,就可以通过给D0 ~ D7的数据信号输入数据往内存IC输入数据了;
读出数据时,将RD的信号设为1,再将A0 ~ A9 的地址信号所指定的数据场所中存储的数据通过D0 ~ D7 数据引脚输出;当WR和RD为0时,输入和输出都无法操作;

如果用楼房来表示内存IC,那么A0 ~ A9的1024个地址可以用1024层楼房来表示(从上往下逐渐变大),每一层都是一字节的数据;编程语言中不同的数据类型占用的内存大小(楼层数量)不一样,物理上我们是以1字节为单位逐一读写内存中的数据,但是在程序中我们也可以通过指定的数据类型,以特定的字节数为单位来进行读写;假设有3个不同数据类型的变量分别为a,b,c,因为数据类型不同,所以占据的内存大小也不一样,那么假设a是一字节(一层楼),b是2字节(二层楼),c是4字节(四层楼),如果程序逐个对数据进行读写会非常不方便;
指针是C语言中的重要特征,指针也是一种变量,它表示的不是数据的值,而是存储着数据内存的地址;使用指针可以对任意指定的地址进行数据的读写;
回归到指针,在C语言种可以使用指针变量一次性读取大于1字节长度的数据;需要在变量前面加上*,表示指针;在计算机上的程序一般使用的都是32位的内存地址,这种情况下,指针变量的长度就是32位;假设d,e,f,都是用来存储地址的变量,但是d,e,f的数据类型不同导致了从相应指针存储的地址中一次能够读写的数据;

现在回到本章题目——使用有棱有角的内存;解释这个需要用到数组;数组中每个元素都对应一个索引,这样就可以通过对索引所对应地址的内存进行读写操作(CPU是通过基址寄存器和变址寄存器来指定内存地址);
数组和内存的物理构造就很像;

队列都可以不通过地址和索引来对数组的元素进行读写;临时保存计算过程中的数据或者输入输出数据时,都可以通过它们来使用内存;
在对数据进行读写时,栈用的时后入先出数据存储方式,队列用的是先入先出的数据存储方式;队列方式在第一个数据被读出时,数组末尾又添加一个数据,这样的方式使得数据的读入和读出循环起来,形成了一个环状的缓冲区,也就是本章的标题所说的——使用有棱有角的内存;
除了栈和队列,还有链表二叉树的数据存储方式;在数组的各个元素中,除了数据的值以外还附带下一个元素的索引,这样数组元素相连就构成了链表;
使用链表方法删除数据时,比如要删除起始位置开始的第三个数值,只需将第二个数值的下一个元素的索引由2改为3,那么第三个元素就会删除;使用链表方法追加数据时,比如在前一个例子中的第五个元素的前一个位置添加元素,只需将追加元素添加到删除的第三个元素的位置,设置下一个元素的索引为5,然后将原删除后数组的第四个元素的下一个元素索引设置为2;
二分查找树使得数据搜索更有效;二分查找树是指在链表的基础上,往数组中追加元素时考虑了数据的大小关系,将其分成了左右两个表现形式;数组中每个元素除了包括自身的值以外,还包括左边元素索引的值,右边元素的索引的值;

内存和磁盘的亲密关系

从存储程序指令和数据来看,内存和磁盘的功能是相同的;不过利用电流来实现存储的内存和利用磁效应存储的磁盘还是有区别的,内存是高速高价的,而磁盘是低速廉价的;在计算机中内存和磁盘进行协同作业,即磁盘中的内容必须先加载到内存以后才能被CPU解析和运行,一来因为磁盘运行的非常慢,二来CPU必须通过内部程序计数器来指定内存地址,才能读取程序;
磁盘缓存指的是从磁盘中读取数据存储到内存空间的方式,当需要同意数据时就不用通过实际的磁盘中再次读取,而是从磁盘缓存中把内容读,大大改善了磁盘数据的访问速度;不过随着硬盘访问速度的加快,磁盘缓存的效果也没之前那么明显了;
虚拟内存是指把磁盘的一部分作为假想的内存来使用,即虚拟内存就是假想的内存;虚拟内存虽然说可以把磁盘的一部分当作内存来用,但实际上运行的程序部分在这个时间点上必须存在于内存中,即把实际内存和虚拟内存进行交换,并同时运行内存;
从根本上解决内存不足的问题要么增加内存,要么使用节约内存的编程方法:
1.使用DLL文件实现函数共有;多个应用可以共有同一个DLL文件,以达到节约内存的效果;而且DLL文件还有一个优点,就是可以在更改EXE应用执行文件的情况下,只通过升级DLL文件就可以更新;
2.通过使用_stdcall来减小程序文件的大小;在C语言中调用函数后会使用栈清理指令;把接受函数参数或者传递函数参数时使用的内存从栈区域清除;这个命令时编译器在编译时,由编译器自动附加到程序中的;

亲自尝试压缩机制

压缩文件的扩展名由LZH和ZIP等;
1.RLE算法压缩机制:如果一串英文字:AAABC,因为有三个A是重复的,这时就会采用数据*重复次数来进行压缩变成A3BC,这就是RLE算法压缩机制;但是这种方法也有缺点,在实际的文本文件中,同样字符多次重复的情况并不多见,并且有的时候,如果一串字符串没有任何重复,那么压缩后的文本就会变成每个字母后面跟一个数字,导致文件大小有可能是原来的两倍;也就是说这种方法不适合用于文本压缩;
2.哈夫曼算法是指为各压缩对象文件分别构造最佳的编码体系,并以该编码体系为基础来压缩;通过哈夫曼树构造编码体系,出现频率越高的数据占用的位数越少;这个方法可以适用于任何文件;

图像文件的压缩方法可以使用与文件压缩方法不同的方法;能还原到压缩前状态的压缩成为可逆压缩,反之就是不可逆压缩;
BMP格式是未压缩格式(原图像);JPEG未不可逆压缩,会有一点点模糊;GIF虽然是可逆的,但是如果超过256色,颜色的缺失也会造成图像的模糊;

程序是在何种环境中运行的

运行环境 = 操作系统 + 硬件;

从源文件到可执行文件

用适合某运行环境,某种CPU,某编程语言的编译器将源代码编译成本地代码(机器语言);此时的本地文件是无法运行的,需要进行“链接”处理,得到扩展名为“.obj”的目标文件;把多个目标文件结合,生成一个EXE文件的处理就是链接;运行链接的程序就称为连接器;
栈是用来存储函数内部临时使用的变量和函数调用时所用参数的内存区域;堆用来存储程序临时运行时的任意数据及对象的内存领域;

推荐阅读更多精彩内容