写点什么

读《程序是怎样跑起来的》体会

作者:听风
  • 2022-11-29
    广东
  • 本文字数:16760 字

    阅读完需:约 55 分钟

缘起

在几年的工作和学习中,发现计算机知识体系尤为重要,自己还有所欠缺,想要从头到尾好好的夯实一下计算整个体系的相关知识,搭建计算机知识体系架构。因此决定细致的重读阅读这些计算机经典书籍,该书是上本书<<计算机是怎样跑起来的>>同系列计算机原理基础书本,上一本书主要关注与计算机硬件构造部分为切入点进行书写,本书则主要从程序是如何在计算机运行以及运行背后都经历了那些历程来进行书写,是上本书的进阶。在此记录读书文摘。

个人总结感悟:

先说说看完全书的感受吧,达到了学习目的,对计算机的原理,程序的构成有了更进一步的理解。这本书用浅显易懂的语言加上生动的图解方法把程序如何在计算机里运行的流程原理给大致全面解释,使初学者可以通过此书更进一步了解程序是如何在计算机中工作的,为后面的程序编写打好基础,做到知其然,知其所以然,可以说对初学者启发很多,是真真正正的入门书。



以下文字为阅读全书后各章节要点总结

1.对程序员来说 CPU 是什么

1.1 CPU 构成

  1. 物理层面: CPU 由具有开关功能的晶体管构成

  2. 逻辑层面: 内部由、寄存器控制器运算器时钟四个部分构成,各部分通过电信号相互连通


寄存器: 用于暂存指令、数据等处理对象,可以认为是内存的一种,一个 cpu 存在多个寄存器


控制器: 负责把内存上的指令、数据等读入寄存器,并根据指令的运行结果来控制整个计算机


运算器: 负责运算从内存读入寄存器的数据


时钟: 负责发出 cpu 开始计时信号(时钟信号,该部件也可以放在cpu外部),时钟信号频率越高,cpu 运行越快


程序运行流程:


程序启动后,根据时钟信号控制器会从内存中读取指令与数据,通过对这些指令进行解释执行,运算器对数据进行运算,控制器根据运算结果来控制计算机

1.2 CPU 是寄存器的集合体

因为程序是通过操作抽象后的寄存器来实现各种控制的,因此在编写程序角度来看,CPU 是寄存器的集合体,编程是对大量的寄存器做操作。不同 CPU 其内部寄存器数量以及可存储的数值范围不同,但根据不同功能可以将寄存器分为八大块



寄存器可以存放指令,也可以存放数据,其中数据分为用于运算的数值表示内存地址的数值两种,同时每个寄存器的功能都不同,


用于计算的数据存放在累加寄存器,内存地址放在基址寄存器变址寄存器

1.3 程序计数器决定程序运行流程

程序启动流程:


  1. 用户发出启动程序的指令

  2. 操作系统将保存在硬盘的程序复制到内存中

  3. 操作系统将程序计数器(cpu寄存器的一种)的值设置为程序运行开始位置内存地址

  4. 程序开始运行,cpu每执行一条指令程序计数器的值就会自动加1,(当执行的指令占据多个内存地址时增加与指令长度相应的数值)

  5. cpu 的控制器参照程序计数器的值,从内存中读取命令并执行


结论: 因此程序计数器决定着程序的执行流程

1.4 条件分支与循环机制

程序的运行流程分为: 顺序执行条件分支循环三种


顺序执行: 按照地址的内容顺序执行指令,每执行一条指令,程序计数器的值自动加一


实现机制为通过程序计数器值自动加 1 的特性实现。


条件分支: 根据条件执行任意地址的指令,将程序计数器的值设置为任意地址


循环: 重复执行同一地址的指令,程序计数器的值每次都设置为同一地址


分支与循环的实现机制为使用跳转指令实现,会参照当前执行的运算结果标志寄存器的值进行比较运算做减法来判断是否跳转


标志寄存器: 用于存储cpu运算结果的状态值,cpu 进行运算时,该值会根据运算结果自动设置


标志寄存器常见存储状态有: 运算结果的正、零、负(三种状态由标志寄存器的三个位表示(位:0|1)),是否溢出(运算结果超出了寄存器的范围),奇偶校验结果(检查运算结果的值是奇数还是偶数)等。


比较运算: 在程序中进行比较运算就是做减法,cpu 运算装置会在内部进行减法运算,结果正负零保存在标志寄存器中

1.5 函数的调用机制

函数在调用完后,处理流程需要回到函数的调用点(函数调用指令的下一地址)


在计算机中,机器语言借用栈内存使用call指令return指令进行函数的调用与返回


call 指令: 函数调用时使用该指令,该指令会在程序计数器值变为函数入口地址前,将调用函数后要执行的指令地址存储在栈中


return 指令: 函数执行完毕后,通过在函数的出口执行该指令,将保存在栈中的地址设定到程序计数器中实现回到调用地址

1.6 通过地址和索引实现数组

通过基址寄存器变址寄存器,可以对内存上特定的区域进行划分,实现类似数组的操作


例如: 查看 10000000----1000FFFF 地址时,将 10000000 存入基址寄存器, 在使变址寄存器的值在 0000000---0000FFFF 变化,cpu 会将


基址寄存器+变址寄存器的值作为实际的内存地址,因为就实现了类似数组的功能,基址寄存器相当于数组的起始地址,变值寄存器相当于数组的索引

1.7 机器语言指令的主要类型和功能

2. 数据是用二进制表示的

2.1 为什么要用二进制表示数据

原因: 由于 IC(集成电路) 的所有引脚只有直流电压0V或5V两个状态(也就是每个引脚只能表示两个状态),由于这个特性,因此决定了在计算机中的信息数据只能使用二进制来处理表示 注意: 现在 IC 大部分电压为为+5V,因此存在三种状态,0V/5V/高阻抗状态(不接收电流信号)


计算机处理信息的最小单位为,bit位,相当于二进制中的一位,二进制数一般是 8 位,16 位,32 位(8的倍数)


8位二进制数被称为一个字节字节是最基本的信息计量单位,位是最小单位,字节是基本单位


内存,磁盘都使用字节来存储读取数据,用字节处理数据时,如果数字小于存储数据的字节数(二进制数的位数,小于 8 位),那么高位用0填充,列如1001116 位二进制数,用字节 8 位表示时为00100111


计算机内部所有的数据都使用二进制数进行处理表示,具体用于表示什么数据取决于编写的程序如何进行信息的处理运算


进制转换:


任何进制之间相互转换,都遵循转换进制数的各数位的值与位权相乘,然后将相乘的结果相加得到的和就是目标进制数

2.1.1 位权与基数

案列: 10 进制 39 分解为位权相加模式= 3x10^1 + 9x10^0 = 39


10^1、10^0 就是位权,数字的位数不同,位权也不相同,第一位(最右边一位),为 10 的 0 次方=1(数值的位数减一,此时为第一位-1=0),第二位为 10 的 1 次方=10,依次类推


在这里 10 为基数,几进制基数就是几,如二进制基数为 2

2.2 移位运算与乘除运算

移位运算: 将二进制数值的个数位进行左右移位的运算,左移(像高位方向),右移(像低位方向)


在计算机中,左移后,低位补零右移使用补码表示高位,移位后溢出部分直接丢弃即可


通过左移与右移可以实现乘法与除法


列如: 10 进制数左移后会变为原来的 10 倍、100 倍、1000 倍、右移会变为原来的 1/10, 1/100, 1/100 依次类推


列如: 2 进制数左移后会变为原来的 2 倍、4 倍、8 倍、右移会变为原来的 1/2, 1/4, 1/8 依次类推

2.2.1 补数表负数

在计算机中只存在加法,因此减法也使用加法表示,a-3 = a + (-3),为此在使用负数时,就使用补数表示(用正数来表示负数)


负数==补数(按位取反+1),负数时最高位作为符号位,为1表示负数,为0表示正数


因此,右移后高位数有可能为 1 或者 0 填充空出来的数值,列如:-1在计算机中表示为111111111


补数的计算:进二机制数的各位数的数值按位取反,然后在将结果加 1 即可得到补数


按位取反: 把二进制数各数位的0变为1,1变为0,如 00000001 取反后 11111110


计算实例:


// 用8位二进制数表示-1,只需要求1,也就是00000001的补数即可原始数值1二进制: 00000001按位取反二进制: 11111110加1获取补数: 11111110 + 1 = 11111111补数:11111111//计算3-53二进制表示:000000115二进制表示:00000101    补数:取反+1=11111011因此3-5二进制表示; 00000011 + 11111011结果为:11111110   //高位为1,表示负数利用负负得正的原理,通过求补数的补数就可以得出绝对值补数11111110  的补数: 取反+1 = 0000001000000010十进制表示2,因此补数11111110表示-2
复制代码


补数与原来的数相加为 0,溢出位丢弃


当运算结果为负时,运算结果的值也是使用补数表示

2.3 逻辑右移与算术右移的区别

逻辑右移: 当二进制数的值表示图形模式而非数值模式时,移位后需要在最高位补0,这就是逻辑右移,类似霓虹灯往右滚动的效果


算术右移: 将二进制数作为带符号的数值进行运算时,移位后要在高位补0或者1,如果数值是用补数表示的负数值,那么右移后在空出来的高位补1


例如:-4(11111100)右移两位,**逻辑右移后=**00111111=63,**算术右移=**11111111


只有在右移运算时,才区分逻辑右移或者算术右移,左移不区分,只需在空出的低位补零

2.4 逻辑运算

与-AND: 两个都为 1,结果为 1,其它为 0:1,1= 1, 1,0=0 , 0,0=0


或-OR: 至少有一个为 1,结果为 1:1,0=1, 1,1=1, 0,0=0


非-NOT: 取反 0 变 1,1 变 0:1=0, 0=1


异或-XOR: 相同为 0,不同为 1:1,0=1 1,1=0, 0,0=0

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

3.1 使用二进制数表示小数

与二进制表示整数类似,二进制表示小数部分的转换方法也是小数位各数值乘以位权相加表示


例如将 1011.0011 二进制小数转换为十进制数,转换过程如下


//二进制小数1011.0011转换为十进制小数//1. 计算各位权值:1*2^3 = 1*8 = 80*2^2 = 1*4 = 01*2^1 = 1*2 = 21*2^0 = 1*1 = 10*2^-1 = 1*0.5 = 00*2^-2 = 1*0.25 = 01*2^-3 = 1*0.125 = 0.1251*2^-4 = 1*0.0625 = 0.0625//2. 各位权值相加就得到十进进制小数8 +0 +2 +1 +0 +0 +0.125 +0.0625 = 11.1875结果为:11.1875
复制代码


规律: 此规律在其它进制中同样适用


  1. 0 次方前面的位的位权按照 1,2,3 方式向左高位递增(小数点前)

  2. 0 次方后面的位的位权按照-1,-2,-3 方式向右低位递减(小数点后)

3.2 计算机计算小数出错的原因

原因: 有一些十进制数的小数无法转换成二进制数,只能取近似值


列如:小数点后 4 位用二进制数表示时的数值范围 0.0000----0.1111,这里只能表示 0.5, 0.25, 0.125, 0.0625 这 4 个二进制数小数点后面位权组合而成的小数,因此,部分中间的小数无法表示,只能取近似值,类似十进制中的循环小数

3.3 浮点数

浮点数: 用符号、尾数、基数、指数这 4 部分来表示的小数就是浮点数


在计算中因为内部只使用二进制,因此计算机中省略掉了基数,只使用符号、尾数、指数 3 部分表示浮点数


IEEE 标准浮点数表现形式:


符号部分: 占用一个数据位,该为=1 表示负数,=0 表示正数,数值的大小用尾数部分与指数部分表示


尾数部分: 定义一种统一的正则表达式表示,常用将小数点前面的值固定位 1 的方式,列如0.75=0.75 x 10^0


指数部分: 使用 excess 系统表现,主要是为了表示负数时不使用符号位,通过折半中间范围值表示为 0 来区分,列如[-1, 0, 1]


二进制数与十六进制:


实际中由于二进制位数太多,通常也会使用十六进制进行表示,二进制的4位数表示一位十六进制


使用十六进制表示二进制小数时,小数点后四位也相当于十六进制一位,不够 4 位使用0填充二进制数的低位


如1011.011  小数部分不足4位进行补零填充: 结果为1011.0110  转为十六进制: B.6
复制代码

4. 熟练使用计算机内存

4.1 内存的物理模型

内存: 一种 IC电子元件,常见 DRAM(需要经常刷新保存数据)、SRAM(不需要刷新电路既可以保存数据),可以读取与写入,ROM(只能用来读取的内存)等形式


内存物理组成: 电源、地址信号、数据信号、控制信号,用来输入输出的 IC 引脚(通过为引脚指定地址,进行数据读取)


  1. 地址信号引脚数决定了内存能够表示的地址范围:表示地址范围 = 2^地址信号引脚数(次方)

  2. 数据信号引脚数决定了一次能够输入输出多少字节的数据(8位=1字节)输入输出数据字节数 = 数据信号引脚数/8(一个引脚表示一位二进制)

  3. 例如: 数据信号引脚数=8, 8/8 = 1 字节 地址信号引脚数=10, 2^10 = 1024,因此改内存可以存储的大小为 1024*1 个字节=1Kb


控制信号: 该信号引脚主要负责表示数据的读写


内存数据读写流程:


接入电源后进行内存读写


  1. 写入流程: 使用地址信号引脚指定数据的存储场所,把数据输入给数据信号引脚,把控制信号引脚写入信号置为1,执行完改流程数据写入内存结束

  2. 读取流程: 使用地址信号引脚指定数据的存储场所,把控制信号引脚读取信号置为1,执行完改流程后,数据会被输出到数据信号引脚,读取结束

  3. 禁止读写: 当控制信号读、写引脚同时置 0 时,无法进行读写


总结: 内存 IC 内部有大量存储 8 位数据的空间,通过地址指定这些空间,之后就可以进行数据读写

4.2 内存的逻辑模型

内存的逻辑模型: 内存类似楼房,在这栋楼房中,1 层可以存储 1 字节的数据(内存为1kb时,且有1024层),楼层号表示的就是地址


数据类型: 编程语言中表示存储何种类型的数据,从内存角度看就是占用多大内存的意思(占有的楼层数目)


内存地址程序执行时由操作系统而定,变量的数据类型不同,所占用的内存大小也不一样


低字节序: 将多字节数据的低位字节存储在内存低位地址的方式成为低字节序


高字节序: 将多字节数据的高位字节存储在内存低位地址的方式成为高字节序


指针: 指针变量存储数据的内存地址,通过使用它可以对任意指定地址的数据进行读写,指针的数据类型,表示一次可以读写的长度

4.2.1 高效使用内存的基础数组

数组: 多个同样数据类型的数据在内存中连续排列的形式,作为数组元素的各个数据通过连续的编号区分开,这个编号就是数组的索引


通过索引,可以实现对该索引所对应的内存地址上的数据进行读写,索引与内存地址的变换操作由编译器自动实现(cpu通过、基址、变址寄存器来指定内存地址)


数组定义中所指定的类型也是表示一次可以读写的内存大小


由于数组内存物理构造一样,特别是一字节类型的数组,它与内存的物理构造完全一致,因此说数组是内存使用方法的基础,通过对数组的变形,出现了许多方便内存管理的数据结构,如、栈、队列、链表、二叉树等。

4.2.2 栈、队列以及环形缓冲区

栈: 读写数据时,先入后出 队列: 先入先出(一般以环形缓冲区的方式实现,模型,是连接数组的头尾,使其成为一个环形进行读取)


在程序中实现栈与队列,需要以适当的元素数定义一个用来存储数据的数组,以及对该数组进行读写的函数对(入栈、出栈、入队、出队)

4.2.3 链表与二叉查找树

使用链表与二叉查找树的优势: 使用链表可以对数组数据进行高效添加删除,使用二叉查找树可以高效的读数据数据进行查找


链表实现: 在数组的基础上,在数组的个元素中除了数据的数值外还为其附带上下一个元素的索引,即可实现链表


二叉查找树: 在链表的基础上,往数组追加数据时考虑数据的大小关系,需要追加的数数值比先前保存的数值大放右边,小放左边(逻辑上实现,实际的内存不会分为两个方向),因为由链表编写而来,因此追加删除同样高效

5. 内存与磁盘

内存利用电流实现:高速高价磁盘利用磁效实现:低速低价


磁盘中存储的程序:必须加载到内存中才能运行,因为负责解释和运行程序内容的CPU需要通过内部程序计数器来指定内存地址,然后才能读出程序,其次,磁盘的速度慢即使可以直接读取,效率也比较低下。


内存与磁盘相互促进


  1. 磁盘缓存: 加快磁盘访问速度(磁盘读出的数据保存到内存,下次读取同一数据直接内存读取)

  2. 虚拟内存: 把磁盘作为部分内存使用,在内存不足时也可以运行程序


虚拟内存的实现方式:


虚拟内存虽说是将磁盘当内存一部分使用,但实际上在正在运行的程序部分,在这个时间点上是必须存在内存中的,也就是说为了实现虚拟内存,就必须把实际内存的内容和磁盘上的虚拟内存的内容进行部分置换(swap),并同时运行程序。


虚拟内存的方法分页式分段式两种,windows 使用的是分页式。


  1. 分页技术: 在不考虑程序构造的情况下,把运行的程序按照一定大小的进行分割,并以为单位在内存和磁盘间进行置换


把磁盘内容读取到内存成为Page in ,内存内容写入到磁盘称为Page out


在 win 中页页大小为 4kb,也就是只将大程序分割为 4kb 大小的页进行切分,并以页为单位放入磁盘或内存


  1. 分段技术: 把要运行的程序分割成以处理集合及数据集合等为单位的段落,然后在以分割后的段落为单位在内存和磁盘间进行置换


虚拟内存的大小: 通常为实际内存的相同大小或者两倍程度

5.1 节约内存的方法

1、动态 DLL 链接文件:


通过 DLL 文件实现函数共有:DLL(动态链接文件),可以在程序运行时动态加载的库文件(函数和数据的集合),多个应用可以共有同一个 DLL 文件,通过共有,可以实现节约内存的效果


静态链接在各个应用程序中,内置库文件函数(成为了应用执行程序的一部分),同时运行各应用时,内存会存在具有同一库文件函数的多个程序,这会降低内存的利用效率,DLL 动态链接作为独立的文件而不是应用的执行文件,由于在内存中可以被多个应用共有,因此该 DLL 函数在内存中只存在一个,从而实现了节约内存,同时,DLL 文件还可以以在不变更应用执行文件情况下,通过只升级 DLL 文件就可实现程序的更新。


2、 使用-stdcall 标准调用减小程序文件大小


函数在调用完毕后,执行栈清理使用_stdcall调用方式,将该栈清理实现在被调用函数一方进行清理,栈清理处理在被调用方进行比在调用方进行程序整体要小一些,同时避免了在调用方程序存在多处调用时反复进行重复清理操作(在被调用方所有调用结束后,只需要执行一次清理)

5.2 磁盘的物理结构

磁盘的物理结构是指磁盘存储数据的形式,磁盘通过把其物理表面划分为多个空间来使用,划分的方式有扇区方式可变成方式两种


扇区方式: 将磁盘划分为固定长度的空间


可变长方式: 把磁盘划分为长度可变的空间


windows 中一般使用的扇区划分方式,扇区方式中,把磁盘表面分成若干个同心圆的空间就是磁道,把磁道按照固定大小(能存储的数据长度相同)划分而成的空间就是扇区,扇区是对磁盘物理读取的最小单位,win 中一般一个扇区 512 字节,不过逻辑方面,windows 对磁盘的读写单位是扇区的整数倍,1 簇可以 W 为 512 或 1024 或者其它的,磁盘容量越大,簇的容量越大


不同的文件不能存储在同一个簇中,不然会导致,只有一方的文件不能删(一个文件占用了2个簇),所以不管多小的文件都会占用一簇的空间,导致所有的文件都会占用一簇的整数倍的磁盘空间,造成部分浪费,一簇中没有被占满的区域会被保持不使用的状态,但是减少簇的数量,磁盘的访问会增加,降低磁盘速度,因此扇区和簇的数量是由磁盘的容量处理速度来综合衡量的。

6. 数据压缩

压缩分类: 压缩后的数据可以复原的称为可逆压缩,无法复原的称为不可逆压缩

6.1 文件以字节方式存储

文件是将数据存储在磁盘等存储媒介上的一种形式,其以字节为存储单位,文件就是数据集合的字节


任何情况下:文件的字节序列都是连续存储的

6.2 RLE 压缩算法

RLE 压缩算法: 将文件内容用 数据 X重复次数,从而实现数据压缩,常用于压缩传真图像等。


//列如:AAAAAABBCDDEEEEEF  //17字节进行压缩A6B2C1D2E5F1       //使用RLE压缩后:12字节  压缩率:12 / 17 = 70%  
复制代码


RLE 算法缺点: 在实际的文本文件中,同样字符重复出现的次数较低,因此该算法针对这样的文件压缩率不高,反而可能压缩后比原来更大,因为每个不同的部分都会加上重复的次数占位


//例如压缩:this is a pen  //压缩后: t1h1i1s1 i1s1 a1 p1e1n1  压缩后变为了原来的2倍
复制代码

6.3 哈夫曼压缩算法

哈夫曼压缩算法: 通过为各压缩对象文件分别构造最佳的编码体系,并以该编码体系为基础来进行压缩,用什么方式进行编码对数据进行分割,由各个文件而定,通过该压缩算法压缩后的文件中,存储着哈夫曼编码信息压缩后的数据


哈夫曼树: 通过哈夫曼树构造编码体系,之后通过该编码就可以实现哈夫曼压缩算法,该树的核心是通过叶子结点来生成树根,通过降低出现次数多的表示位数,而进行大幅度压缩降低暂用字节数


通过哈夫曼树编码后: 出现频率越高的数据所占用的数据位数越少,由于使用树枝连接数据从频率较少的开始,这就意味着频率越低的数据到达根部的枝条数越多,而枝条数越多,编码的位数也就是随之增加了可以表示更多数据,从哈夫曼算法压缩过的文件读取数据后,会以位为单位,对该数据进行排查,并与哈夫曼树进行对比看是否到达了目标编码。


哈夫曼树构造过程如下:


例如:将 AAAAAABBCDDEEEEEF 进行哈夫曼编码生成哈夫曼树,步骤如下


// 步骤1:列出数据及其出现的频率, ()里面表示出现的频率,这里按照降序排列出现频率:(6) (5) (2) (2) (1) (1)数据:    A   E   B   D   C   F// 步骤2:选择2个出现频率最小的数字,拉出两条线,并在交叉地方写上这个两位数字的和,当有多个时,任意选取即可                          (2)                                                /   \  出现频率:(6) (5) (2) (2) (1) (1)数据:    A   E   B   D   C   F//步骤3:重复步骤2,可以连接任何位置的数据                 (4)      (2)                                       /   \    /   \  出现频率:(6) (5) (2) (2) (1) (1)数据:    A   E   B   D   C   F//步骤4:最后这些数据会被汇聚到一个点上,那就是树根,这样该树就完成了,按照从根部到底部的叶子这一顺序,在左边的树枝线处写0,右边写1//,然后从根部开始沿着树枝到大目标文字后,在按照顺序把通过树枝上的0,1写下来,写下来得到的编码就是哈夫曼编码                (17)                      0/       \1                 /        (6)            /      0/     \1         (11)    (4)      (2)                               0/  \1  0/  \1  0/   \1 出现频率:(6) (5) (2) (2) (1) (1)数据:    A   E   B   D   C   F哈夫曼编码00  01  100 101 110 111
复制代码


AAAAAABBCDDEEEEEF 利用哈夫曼编码进行表示为 000000000000100100110101101010101010111 40 位=5 字节


压缩前 17 字符=17 字节,压缩率= 5/17 = 29%,压缩效率大大提高


注意: 文本文件不可以进行非可逆压缩,因为文本文件每个字符,数字都有具体含义,必须能够还原,否则就会出现损坏,

7. 程序是在何种环境运行的

程序的运行环境 = 操作系统 + 具体硬件


硬件核心考虑参数为CPU,不同的 CPU 指令集不同,能解释的机器语言种类也不同,其只能解释自身的机器语言,也就是常说的cpu指令集


常见的有: x86、MIPS、SPARC、PowerPC


本地代码: 通过将源代码进行编译后生成的机器语言的程序成为本地代码(机器直接运行的代码)


源代码: 通过文本编辑工具书写的各类语言代码(高级代码,人更容易理解编写)


windows 应用程序的本地代码,通常是 exe 文件及 dll 文件等形式,CPU 负责解析并运行从源代码编译而来的本地代码

7.1 操作系统克服了处 CPU 以外的硬件差异

由于内存、IO 等不同的硬件设备地址构成不同,因此在早期操作系统不够完善时,所开发的应用软件存在直接操作计算机硬件的部分,导致了不同硬件的计算机需要针对改硬件进行应用软件适配,造成了资源浪费。随着操作系统的晚上,这种情况得到改善,应用并不直接操作计算机硬件,而是通过操作系统提供的API接口进行实现相关功能,与计算机硬件的操作全部交予操作系统进行处理,在适配上针对不同的硬件,只需要进行操作系统的适配,只要操作系统能正常运行,同样的软件就可以运行,大大提高了利用效率。


由于应用软件都是使用特定的CPU的本地代码完成,因此操作系统当前还不能克服CPU的差异。


不同的操作系统 API 不同:


由于不同的操作系统所提供的 API 路径调用方式等不同,因此应用软件必须根据不同操作系统来做开发,将程序移植到其它操作系统时,需要重写应用中利用到 API 的部分,(像键盘输入,鼠标输入,显示器。文件等外围输入输出设备都是通过操作系统 API 提供的)


相同操作系统下:


API 调用方式一致,因而针对某特定类型操作系统开发的应用,在任何安装改操作系统的硬件上都可以运行


通过源代码分发实现克服 CPU 差异:


通过结合当前运行的硬件环境来编译应用的源代码,生成本地代码,实现克服 CPU 差异


通过虚拟机克服 CPU 差异:


例如使用 java 虚拟机,将 java 源代码编译成字节码后进行分发,在不同的操作系统上安装相适应的java虚拟机,在程序运行时,java 虚拟机在将字节码解释成本地代码进行运行,实现了一次编译到处运行,但也存在在影响运行速度的缺点(因为运行时需要去将字节码解释成本地机器码)

7.2 BIOS 和引导

BIOS: 基本输入输出系统,存储在 ROM 中,是预先内置在计算机主机内部的程序,可以控制键盘、磁盘显卡和启动引导程序的功能


引导程序: 存储在启动驱动器起始区域的程序,操作系统的启动器一般是硬盘或者 CD-ROM 或者软盘。


电脑开机后,BIOS会确认硬件是否正常,没有问题就启动引导程序,引导程序的功能就是将存在硬盘上的 OS加载到内存中运行,虽然启动应用是 OS 的功能,但是 OS 不能自己启动自己,需要靠引导程序进行启动。

8. 从源文件到可执行文件

计算机只能运行本地代码

8.1 编译器

本地代码: 本地代码的内容就是数值的罗列集合(二进制的数据展示)


编译器: 负责将源代码转换为本地代码,每种语言都有其专用的编译器


由于 cpu 类型不同,本地代码的类型也不同,编译器可以将源代码编译成不同 cpu 类型的本地代码


交叉编译: 在当前平台编译另外平台的本地代码,例如在 win 上编译 linux 平台运行的本地代码

8. 2 文件链接

编译器转换源代码后就会生成本地文件,这个文件不能直接运行(处于未完成状态),还需要进行链接处理


链接器: 将多个目标文件结合,生成最终的可执行程序,这个处理过程称为链接


需要链接的原因: 将源代码编译后只是对程序员书写的代码进行了编译,还需要将在源代码中进行引入的库文件,函数这些引入的功能进行编译整合在一起,整合后的文件才是完整的执行程序,因此需要进行链接操作。当前的语言通常在编译的同时进行了链接,因此只需要编译后就可以得到可执行程序。


库文件: 把多个目标文件集成保存到一个文件中的形式,链接器指定库文件后会将需要用到的库文件函数提取出来与其它目标文件结合生成可执行程序


标准函数: 不是通过源码形式,而是通过库文件和编译器一起提供的。这样的函数称为标准函数,如语言自带的标准库函数


动态链接库 DLL 及导入库: 程序运行时动态结合,不存在库文件的静态代码(只存在 DLL 文件目标路径信息),运行时才导入库文件的相关功能,因此也成导入库


静态链接库: 存储着目标文件的实体,并直接与可执行程序结合的库文件形式


可执行程序运行的必要条件: 需要程序内函数与变量的内存地址


虚拟内存地址: 在可执行程序加载到内存执行时,由于每次程序运行时,系统分配给程序内部的变量与函数的内存地址都不相同,因此为了避免这个问题,系统给程序内的变量与函数分配的为虚拟内存地址


再配置信息: 在程序运行时,虚拟内存地址会转换成实际的内存地址进行运行,链接器会在可执行程序文件的开头,追加转换内存地址所需要的必要信息,称为再配置信息,该再配置信息为变量和函数的相对地址(相对基址的偏移量),通过基址和相对地址就可知道当前的实际内存地址

8.3 程序运行时生成的堆/栈

当程序加载到内存后,会分配生成内存空间来进行运行时的数据保存


堆: 用来存储程序运行时的任意数据及对象的内存领域(全局变量)


栈: 用来存储函数内部临时使用的变量(局部变量),已经函数调用时所用的参数内存领域


内存中的程序构成: 由用于变量的内存空间、用于函数的内存空间(这两部分用于复制可执行程序到内存)、用于栈的内存空间、用于堆的内存空间(程序运行时申请分配用于存储运行时数据)四部分构成


程序运行时所需的内存分配: 程序运行时所需的内存空间分为 固定部分,和可变部分,如下(此处参考代码随想录文章总结进行补充)



固定部分的内存消耗 是不会随着代码运行产生变化的, 可变部分则是产生变化的


更具体一些,一个由 C/C++编译的程序占用的内存分为以下几个部分:


  • 栈区(Stack) :由编译器自动分配释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈。

  • 堆区(Heap) :一般由程序员分配释放,若程序员不释放,程序结束时可能由 OS 收回

  • 未初始化数据区(Uninitialized Data): 存放未初始化的全局变量和静态变量

  • 初始化数据区(Initialized Data):存放已经初始化的全局变量和静态变量

  • 程序代码区(Text):存放函数体的二进制代码


代码区和数据区所占空间都是固定的,而且占用的空间非常小,那么看运行时消耗的内存主要看可变部分


在可变部分中,栈区间的数据在代码块执行结束之后,系统会自动回收,而堆区间数据是需要程序员自己回收


总结:


  1. 使用栈的数据内存空间,每当函数调用时都会进行申请分配,并在函数处理完毕后自动释放(编译器管理)

  2. 使用堆的内存空,需要根据程序员编写的程序明确进行分配释放(程序员自己管理)


内存泄露: 申请的堆空间没有在程序后明确指定释放,那么即使处理完毕后该内存空间任然会一直残留,这个现象称为内存泄露,如果一直泄露就会导致内存不足,内存溢出,程序崩溃(当前除了c,c++等,其它的高级语言很多拥有自动gc垃圾回收机制,解放程序员手动去释放堆内存)


解释器编译器的区别:


  1. 编译器是在运行前对所有的源代码进行解释处理

  2. 解释器是在运行时对源代码内容进行一行一行的解释处理

9. 操作系统与应用的关系

9.1 操作系统发展简述

早期不存在操作系统,因此需要编写出处理业务相关的所有程序,由于太麻烦,后来开发了仅具有加载运行功能的监控程序,这个就是操作系统的原形,通过事先启动监控程序,就可以根据需要将各种程序加载到内存运行,随着发展出现了早期的操作系统,早期的操作系统主要有监控程序(加载运行程序),基本输入输出程序(负责通过键盘输入显示器输出)构成。


操作系统构成:


  1. 控制程序: 硬件控制、程序运行控制

  2. 编程语言处理器: 汇编、编译、解析

  3. 实用程序: 文本编辑器、调试工具、Dump 程序等


操作系统本身不是单独的程序,而是多个程序的集合体,这个运行环境下,应用不直接控制硬件,而是通过操作系统来间接控制。


系统调用与高级编程语言的移植性:


  1. 系统调用:


操作系统的硬件控制功能,通常通过一些小的函数集合体的形式提供,这些函数及调用函数的行为成为系统调用


  1. 可以移植性:


为了实现一份源代码可以在不同的操作系统运行,高级编程语言一般不依存于特定操作系统,而是使用独自的函数名,然后在编译时将其转换成相应操作系统的系统调用(高级语言编写的源代码在编译后转换成了利用系统调用的本地代码),也存在部分可以直接调用系统调用的编程语言,但是这样编写的程序移植性不好,比如在 win 下编写的直接调用 win 系统调用的程序不能在 linux 上运行。


操作系统和高级编程语言是硬件抽象化


提高了程序编写效率喝难度,编写程序时不需要关注底层硬件直接是如何协作的,如文件是操作系统对磁盘媒介空间的抽象化,编写代码时,只需要执行相应的函数调用就可以实现文件的读写,而不用去关注底层的磁盘硬件

9.2 Windows 操作系统的特征

  1. 是 32 位或者 64 位操作系统

  2. 通过 Api 函数集来提供系统调用(API通过多个DLL文件提供,如Win32 api)

  3. 采用 GUI 图形用户界面(gui开发的难点,在任何操作顺序下都能正常运行,因为程序不知道用户会点击哪一部操作选项)

  4. 通过 WYSIWYG(显示器上显示的文本几图形等)可以通过打印机直接打印输出,最初显示与打印需要编写 2 个不同的程序实现

  5. 提供多任务功能(同时运行多个程序的功能,windows通过时钟分割技术实现:短时间间隔内多个程序的运行切换)与多线程功能(以程序中的函数为单位来进行时钟分割)

  6. 提供网络功能及数据库功能(虽不是操作系统所必须的,但是作为标准组件,由于与操作系统接近所以也称为中间件)

  7. 通过即插即用实现设备驱动的自动设定(设备驱动提供了同硬件进行基本输入输出的功能)

10. 通过汇编了解程序的实际构成

汇编语言与本地代码是一一对应的


将在本地代码中,附带上表示其功能的英语单词缩写,例如在加法运算的本地代码中加上 add,在比较运算时加上 cmp 等,这些缩写词称为助记符,使用助记符的编程语言称为汇编语言,因汇编语言与本地代码是一一对应,同一级别的,因此本地代码也可以反过来转换成汇编语言的源代码。


使用汇编语言编写的源代码最终也必须转换为本地代码才能运行,负责转换工作的程序成为汇编器,转换处理成为汇编,将本地代码反过来转换成汇编语言的源代码成为反汇编程序,该操作成为反汇编,高级语言编译后无法反汇编成原高级语言的源代码,因为与本地代码不是一一对应的


在不同的高级语言中,还可以通过编译器直接输出汇编语言的源代码(方便分析程序),然后在将该汇编代码转换为本地代码,通过高级语言的源代码与编译输出的汇编语言源代码对比分析,可以知道程序在系统中的具体执行流程。

10.1 汇编代码

汇编语言的源代码组成: 由转换成本地代码的指令(操作码)与针对会汇编器的伪指令构成


伪指令: 负责把程序的构造及汇编的方法指示给汇编器,伪指令本身无法转换成本地代码.


段定义: 用来划定范围区域,是一个连续的内存空间,在程序中表示一段命令和数据等程序构成的集合体,一个程序由多个段构成


汇编语言的segment伪指令表示段定义的开始ends伪指令表示段定义的结束group伪指令将源代码中不同的段定义在本地代码程序中整合为一个(多个段定义汇总成一个,如功能类型的多个段定义),proc/endp伪指令包含的部分表示函数的处理过程,end源代码结束


在源代码中,即使指令数据是混杂编写的,经过编译或者汇编后的本地代码,都会转换成段定义划分整齐的本地代码

10.2 汇编语言的语法

cpu 与内存的关系:


  1. 本地代码加载到内存中才能运行,内存中存储着构成本地代码的指令和数据

  2. 程序运行时,cpu 从内存中把指令和数据读出,然后将其存储到 CPU 内部的寄存器中进行相应的处理

  3. cpu 中的寄存器通过 eax、ebx 等名称进行区分内存中的存储区域通过地址编号进行区分

  4. 汇编代码通过寄存器的名称指定给操作数


汇编语言的语法:


操作码(动作) + 操作数(对象)组成,不过也存在只有操作码的指令,多个操作数时使用逗号分割开一行表示对 CPU 的一个指令


操作数中指定了寄存器名、内存地址、常数等,能够使用何种形式的操作码,由 CPU 的架构类型决定


汇编语言中函数名表示的是函数所在的内存地址


常用操作码及功清单如下



在汇编中,通过使用 cmp 比较指令与 jl 跳转指令来实现的循环条件分支

10.2.1 最常用的 mov 指令

mov 指令: 对寄存器和内存进行数据存储,mov A,B 把B的值赋给A,


操作数 A, B 可以为寄存器、常数、标签(附加在地址前),以及用[地址]表示的内存地址


没有用方括号围起来的内容表示对该值进行处理,有方括号围起来的内容会被解释为内存地址,会对该内存地址对应的值进行读写操作


样列如下


mov ebp, esp    //esp寄存器的值赋给ebp寄存器,如果esp的值=100, 那么ebp的值也会=100mov eax, dword ptr [ebp+8] //ebp+8会被解释为数据内存地址为100+8=108的地址,然后将改地址的值赋给eax                           //如108改地址值=200,那么eax=200                           // dword ptr,修饰语,表示从指定内存地址读取出4字节的数据
复制代码

10.2.3 对栈进行 push 和 pop 的指令

程序运行时会在内存上申请分配一个称为栈的数据空间,是用于存储临时数据的空间(cpu的寄存器也可以临时保存)


如存储局部变量值,局部变量释放后,该空间也会自动释放(出栈),该空间保持栈的特性。


栈特性:


先进后出,通过 push 入栈与 pop 出栈指令进行数据的存储读出,这两个操作指令都只有一个操作数,表示push/pop 的是什么,


不需要指定对哪个地址编号的内存进行 push/pop,这是因为栈的内存地址由 esp 寄存器(栈指针)进行自动管理的,因此不需要指定


32 位 x86 cpu 进行一次 push/pop 既可以处理 32 位(4字节)的数据,64 位的表示 8 字节


函数的参数是通过传递,返回值是通过寄存器来返回的(c语言规定),因为在函数调用前,某个寄存器可能已经被使用了,函数内部在使用该寄存器后,需要尽量返回到函数调用前的状态,因此需要将其保存在栈中,函数执行完毕后再将其出栈,使其回到原来的状态。

10.2.4 多线程

线程: 操作系统分配给 CPU 的最小运行单位,源代码中的一个函数就相当于一个线程,多线程处理指在一个程序中同时运行多个函数


在多线程处理处理中,用汇编语言记述的代码每运行一行,处理都有可能切换到其它线程(函数)中,为了保证全局调用变量的一致性,防止数据被同时运行的线程覆盖,需要加锁进行处理,保证同一时刻只有一个线程能够修改变量,避免 A 线程处理到一半还未更新,B 线程就将变量值更新了,然后 A 又去更新值就不正确了

11. 硬件控制方法

硬件的控制一般通过操作系统进行,应用不直接与硬件进行交互,其通过调用操作系统提供的 api间接的控制硬件(也称系统调用)

11.1 支撑硬件输入输出的 IN 资料与 OUT 指令

Windows 控制硬件时主要借助的是输入输出指令,最具代表性的就是 IN 与 OUT 指令,这两个指令也是汇编语言的助记符


IN 指令: 通过指定端口号的端口输入数据,并将其存储在 CPU 内部的寄存器中


OUT 指令: 把 CPU 寄存器中存储的数据,输出到指定端口号的端口


端口号: IO 控制器中用于临时保存输入输出数据的内存,这个内存就是端口


I/O 控制器: 计算机内部用来连接主机同外围设备之间进行电流交换的IC连接器的总称(外围设备,鼠标、显示器、键盘等),此外 IO 控制器内部的内存也叫寄存器,不过该寄存器主要用于临时存放数据,CPU 中的寄存器主要用于数据运算


使用 IO 控制器的原因:


由于电压、数字信号、模拟信号电流特性等的不同,计算机和外网设备无法之间连接,因此需要使用 IO 控制器进行连接,外围设备也拥 有各自专用的IO控制器


因为计算机连接很多外网设备,所以有多个IO 控制器,多个端口,因此实现 IO 控制器功能的 IC 中存在多个端口,一个 IO 控制器可以控制一个或多个外围设备,各端口之间通过端口号进行区分,端口号也称为IO地址,IN 与 OUT 指令在端口号指定的端口中CPU进行数据的输入输出(以端口号为桥梁来实现CPU和外围设备之间的数据传递),与通过内存地址与内存进行读写原理一样.


在大部分的 C 语言编译器中,使用_asm{和}括起来,就可以在其中使用助记符(可以编写C语言和汇编语言混合的源代码)例如下代码


// 下面代码是控制蜂鸣器发声void main(){    int i;    _asm{  //汇编代码,蜂鸣器发声        IN EAX, 61H        OR EAX, 03H        OUT 61H, EAX    }    // C代码    for (i = 0; i< 100; i++); // 等待一段时间        _asm{ // 汇编代码,蜂鸣器停止发声        IN EAX, 61H        AND EAX, OFCH        OUT 61H, EAX    }}
复制代码

11.2 外围设备的中断请求

IRQ 中断请求: 主要用于暂停当前正在运行的程序,并跳转到其它程序运行的机制,该机制称为中断处理


中断处理在硬件控制中尤为重要,没有中断,可能会出现程序无法顺畅运行,从中断处理开始到请求中断的程序(中断处理程序)运行结束之前,被中断的程序(主程序)的处理是停止的,类似于在处理文档时接电话,接完电话接着处理文档,没有中断的话必须等处理文档结束后才能接电话


实施中断请求的是连接外围设备的IO控制器,负责实施中断的是CPU


为了进行区分,外围设备的中断请求使用不同于 IO 端口的其它编号:该编号为中断编号(IRQ的值就是这个中断编号)


同时多个外网设备进行中断请求需要使用中断控制器的 IC 进行缓冲,中断控制器会把从多个外网设备发出的中断请求有序的传递给 CPU


中断处理流程:


CPU 接收到来自中断控制器的请求后,会把当前正在运行的主程序中断,并切换到中断处理程序


  1. 中断处理程序把 CPU 所有寄存器的函数值保存到内存的栈中

  2. 中断处理结束后,把栈中保存的数值还原到 CPU 寄存器,然后在继续进行对主程序的处理(主程序无感知就像没法生一样继续运行)


如果 CPU 寄存器的值没有还原可能会导致主程序运行异常或终止,因为中断处理插入的程序由可能使用到和主程序同样的 CPU 寄存器


使用中断还可以实现输入输出的实时处理,而不需要等待


DMA 直接内存访问机制:


在不通过 CPU 的情况下,外网设备直接与主内存进行数据传送,实现短时间内大量数据转送到主内存(一般磁盘都有使用到DMA),减少了通过 CPU 中转的耗时,在计算机中一般会存在 DMA 编号(这个编号也叫DMA通道),CPU 通过这个编号来识别是哪一个外围设备使用了 DMA


IO端口号IRQ中断请求DMA通道组成了识别外围设备的标识,IRQ/DAM 不是所有外围设备都需要,只有需要执行相应功能的外围设备需要,计算机的最低限度是 IO 端口号必须要有,如果多个外围设备设定相同的编号,就会出现设备冲突


文字图片的显示机制:


显示器中显示的信息一直存储在内存中,该内存称为VRAM,在程序中只要往VRAM中写入数据,数据就会在显示器中显示出来,实现该功能的程序,是由操作系统或者 BIOS 提供,并借助中断来实现,现在由于主内存的 VRAM 较小,显卡等专用硬件一般配备有与主内存独立的 VRAM 和 GPU(图形处理器)来进行图形处理,提升处理速度。


总结:


用软件控制硬件,实际上就是利用输入输出指令同外围设备进行输入输出处理,中断处理是根据需要来使用的选项功能,DMA 则直接交给对应的外围设备即可。

12. 让计算机思考的程序实现方式

程序的使用目的: 大致可以划分为作为工具代替执行人类思考两类


  1. 工具类: 如文字处理器,excel 等程序主要用于作为工具提升工作效率

  2. 代替人类思考类: 如微计算机控制电饭煲,根据米和水的分量自动调节火的大小与加热时间


常见用程序表示人类的思考方式:


  1. 随机性,用于模仿人思考的随意性,没有任何的策略性,每种选择都有可能(程序实现时使用随机程序)

  2. 习惯性,用于模仿人思考时的习惯性,每个人思考方式不一样,都有自己的习惯(程序实现时使用加权值实现)

  3. 记忆性,用于模仿人的记忆功能(经验),每个人的经历影响作出决定,(程序实现时使用程序的存储功能记录每次的操作,然后根据记录的数据进行作出决策)

  4. 节奏性,用于模仿人做决策的节奏性,每个人都有自己的做事规律节奏,(程序实现事,记录规律节奏,按照一定的规律进行决策)


通过公式产生的随机数称为伪随机数,具有周期行(一定周期后会重复,不是真正的随机),随机数的种子不同,产生的随机数也不同


常见生成伪随机数的方法: 线性同余发、乘同余法、M 系法、Kunuth 减算法等,伪随机数公式生成随机数的参数叫做随机数的种子

总结

记录到这,整本书就到此结束了,通过阅读全书,并做相应读书记录,达到了学习目的,对计算机的原理,程序的构成有了更进一步的理解,全书对计算机与程序的构成介绍做到了生动的叙述,做到了深入浅出,对计算机原理和计算机知识薄弱人值得一读。

发布于: 刚刚阅读数: 6
用户头像

听风

关注

自律才能自由,努力成为更好的自己! 2022-06-02 加入

听风go,努力成就期待着的自己。一名普通后端码农,经历过IT行业几大场景角色,现专注于后端。硕士就读于湖南农业大学,主要分享工作学习中的技术文章、读书总结、编程技术、系统,数据结构,深度学习,计算机视觉等

评论

发布
暂无评论
读《程序是怎样跑起来的》体会_听风_InfoQ写作社区