前言

  • 机器语言程序被加载到内存,然后由CPU进行解释和执行,从而完成对计算机系统的控制和数据运算等任务。
  • 了解本质非常重要,这句话对任何事物都是成立的。

本书的结构

  • 各章由“热身准备”“本章要点”和正文三个部分组成。对专业术语的解析放在了正文的脚注部分。
  • 带着问题来阅读正文了。
  • 向别人讲解可以确认自己是否已经完全理解了这些知识。

第1章 对程序员来说,CPU到底是什么

  • 什么是程序?程序是由什么组成的?什么是机器语言?运行中的程序存放在什么地方?什么是内存地址?在计算机的组成部件中,负责对程序进行解释和运行的是哪个?
  • 指示计算机每一步动作的一组指令指令和数据CPU可以直接解释执行的语言内存(主存)用来表示指令和数据在内存中存放位置的数值CPU。
  • 一般意义上的程序,比如运动会、音乐会的程序,表示“事情进行的先后顺序”。在这一点上,计算机程序也是一样的。
  • CPU是Central Processing Unit(中央处理器)的缩写。
  • 要理解CPU,关键是要了解存放指令和数据的寄存器(register)的原理。

1.1 看一看CPU的内部构造

  • CPU和内存本质上都是名为集成电路(Integrated Circuit,IC)的电子部件,由大量晶体管构成。从功能上来说,如图1-2所示,CPU内部是由寄存器、控制器(control unit)、运算器(arithmetic unit)和时钟(clock)四个部分组成的,它们之间通过电流信号相互连通。
  • 寄存器是用来存放指令、数据这些操作对象的空间。一个CPU内部通常有几个到几十个不等的寄存器。控制器负责将内存中的指令和数据读入寄存器,并根据指令的执行结果对计算机进行控制。运算器负责运算从内存中读入寄存器的数据。时钟负责产生控制CPU工作节律的时钟信号。
  • [插图]
  • 内存指的是主存储器(main memory)[插图],简称主存。它通过一些控制电路与CPU相连,用于存储指令和数据。
  • 当程序启动时,CPU中的控制器会根据时钟信号从内存中读取指令和数据。通过对指令进行解释和执行,运算器会对数据进行运算,控制器根据运算结果控制计算机进行指定的操作。

1.2 CPU是寄存器的集合体

  • 寄存器是程序的描述对象。
  • 机器语言[插图]指令的本质是电子信号,我们用英语单词或其缩写(称为助记符)表示每一种信号的功能,就构成了汇编语言。例如,mov和add分别代表传送(move)数据和加法运算(addition)操作。
  • 将汇编语言程序转换成机器语言的过程称为汇编(assemble),反过来,将机器语言程序转换成汇编语言的过程称为反汇编(disassemble)。
  • 其中,eax和ebp都是CPU内部的寄存器的名称。内存中存储数据的位置是用地址来区分的,寄存器则是用名称来区分的。
  • aa = 1 + 2这样的高级编程语言程序,在转换成机器语言之后就是用寄存器来进行存储处理和加法运算操作的。
  • 寄存器中存放的值可以是指令,也可以是数据,其中数据又分为“用于运算的数值”和“表示内存地址的数值”。不同类型的值会存放在不同类型的寄存器中。CPU中的每个寄存器都有不同的功能,例如用于运算的值存放在累加器中,表示内存地址的值存放在基址寄存器和变址寄存器中。代码清单1-1中出现的eax是累加器,ebp是基址寄存器。
  • 一般来说,程序计数器、累加器、标志寄存器、指令寄存器、栈寄存器各仅有一个,其他类型的寄存器可以有多个。其中,程序计数器和标志寄存器属于比较特殊的寄存器。

1.3 决定程序流程的程序计数器

  • 然后,程序开始运行。CPU每执行一条指令,程序计数器的值就会自动加1。
  • 如果执行的指令占用多个内存地址,那么程序计数器的值也会根据指令的长度增加相应的值。

1.4 条件分支和循环的原理

  • 程序的流程分为顺序执行、条件分支和循环三种。顺序执行就是按照地址的数值顺序执行指令。条件分支就是按照条件执行任意地址的指令。循环就是重复执行同一地址的指令。
  • 此时如果累加器的值是正数,则会执行一条跳转到地址0104的指令(跳转指令)。
  • “跳转到地址0104”这条指令,实际上就是间接地执行了“将程序计数器的值设为地址0104”的操作。
  • [插图]
  • 条件分支中所使用的跳转指令需要根据前一条指令的运算结果来判断是否进行跳转。
  • 标志寄存器会根据上次运算的结果,保存累加器和通用寄存器的值,无论值是正数、零还是负数,都会将其保存(也会保存溢出[插图]和奇偶校验[插图]的结果)。
  • 图1-6是32位CPU(寄存器的长度为32比特)的标志寄存器示例。在这个标志寄存器中,第0个、第1个、第2个比特的值若为1,则代表运算结果分别为正数、零、负数。
  • [插图]
  • 程序中的比较指令在CPU内部实际上是通过减法运算来实现的。

1.5 函数调用的原理

  • 函数调用的原理。
  • 在用高级编程语言编写的程序中对函数[插图]进行调用,也是通过将程序计数器的值设置为存放函数的地址来实现的。
  • 当完成函数内部的处理之后,必须让程序流程返回函数被调用的地方(也就是函数调用指令的下一条指令所在的地址)继续执行。
  • 图中所示的地址是假设将C语言程序编译成机器语言后运行时的地址,由于一行C语言程序通常会被编译成多条机器语言指令,所以这里的地址并不是连续的。
  • [插图]
  • 调用MyFunc函数的部分也是通过跳转指令将程序计数器的值设置为地址0260来实现的。函数调用指令(地址0132)和被调用的函数(地址0260)之间的数据传递是通过内存和寄存器来完成的。
  • 当执行到函数体的出口地址0354时,需要将程序计数器的值设置为函数调用指令的下一条指令所在的地址0154才行,但这一操作无法实现。那么该怎么做才好呢?要解决这个问题,我们需要使用调用指令和返回指令这两条机器语言指令。
  • 函数调用时使用的不是跳转指令,而是调用指令。
  • 返回指令的功能是将保存在栈中的地址设置到程序计数器中。
  • MyFunc函数执行完毕后,程序会从栈中读出地址0154,然后将其设置到程序计数器中(图1-8)。
  • [插图]
  • 编译高级编程语言的程序后,函数调用会转换成调用指令,函数结束的处理则会转换成返回指令。
  • 2025/05/08 发表想法:保存到先进后出的栈结构中,保证程序在函数入口调用函数之后,直接获取到函数调用指令的下一条指令所在的地址。

调用指令在将函数入口地址设置到程序计数器之前,会将函数调用的下一条指令的地址保存到名为栈[插图]的内存空间中。

1.6 用基址和变址实现数组

  • 基址寄存器和变址寄存器的功能。使用这一对寄存器,我们可以对特定的内存空间进行划分,按照数组的方式对其进行使用。
  • 这里我们将计算机的内存地址按十六进制[插图]编码为00000000~ FFFFFFFF。在这个范围内,使用一个32位寄存器就可以查看所有的地址,但要访问类似于数组的连续的内存空间,使用两个寄存器会更方便。
  • 例如,在访问地址10000000~1000FFFF时,如图1-9所示,可以将基址寄存器设为10000000,然后让变址寄存器的值在00000000~ 0000FFFF变化。
  • CPU会将基址寄存器和变址寄存器的值相加计算出实际的内存地址,其中变址寄存器的值就相当于高级编程语言程序中数组的下标。
  • [插图]

1.7 CPU的处理其实很简单

  • CPU能执行的机器语言指令按功能可以大致分为四种类型。
  • [插图]
  • 有了整体印象后,相信大家的编程能力和应用能力会得到切实的提高。自己之前随意编写的程序,现在再看也变得活灵活现了吧。
  • 1比特代表1位二进制数,这一点对于理解计算机的运算原理非常重要。
  • 下一章将以比特为基础,为大家讲解二进制数、浮点数等数据形式,以及逻辑运算、移位运算等操作。
  • 数组是一种长度相同的数据在内存中连续排列所构成的数据结构。统一使用一个数组名来表示所有数据,使用下标来表示其中每个数据(元素)。

第2章 用二进制来理解数据

  • 8比特=1字节
  • 将二进制数的各位数字乘以其对应位权并求和就可以转换成十进制数。
  • 二进制数左移1位,结果变成原来的2倍,因此左移两位就会变成原来的4倍。
  • 在2的补码形式中,所有位都是1的二进制数表示十进制的 -1。
  • 逻辑异或运算会将所有1对应的位反转,逻辑非运算则会将所有的位反转。
  • 要在头脑中理解程序的工作原理,非常重要的一点就是要了解信息(数据)在计算机内部是以怎样的形式表示的,又是用怎样的方法来运算的。
  • 程序中所描述的数值、字符串、图案等信息,在计算机内部都是用二进制来处理的。

2.1 计算机用二进制处理信息的原因

  • 计算机内部是由称为集成电路的电子元器件构成的。
  • [插图]
  • CPU(微处理器)和内存都是一种集成电路。
  • 集成电路的所有引脚都有直流电压0V或 +5V[插图]两种状态。也就是说,集成电路的每根引脚都只能表示两种状态。
  • 由于集成电路具有这样的特性,所以计算机必然要使用二进制来处理信息。
  • 计算机处理信息的最小单位是比特,它相当于1位二进制数。比特的英文bit是binary digit(二进制数)的缩写。
  • 一般来说,二进制数的位数是以8的倍数来增长的,比如8位、16位、32位……这是因为计算机处理信息的基本单位是8位二进制数。8位二进制数也称为字节(byte)。
  • 字节是信息的基本单位。再强调一下,比特是最小单位,字节是基本单位。在内存和硬盘等设备中,数据是以字节为单位存储的,也是以字节为单位读写的,不能以比特为单位来读写。因此,字节是信息的基本单位。
  • 在以字节为单位处理数据时,当要处理的数值比容器的字节数(即能容纳的二进制位数)小时,就需要在高位补0。
  • 例如,100111是一个6位二进制数,如果用8位(即1字节)来表示就需要写成00100111,用16位(即2字节)来表示就需要写成0000000000100111。
  • 对于用二进制数表示的信息,无论它原本是数值、字符,还是某种图案,计算机都不做任何区分。
  • 对于01000001这个二进制数,我们既可以把它当作数值来进行加法运算,也可以把它当作字符“A”显示出来,还可以把它当作“□■□□□□□■”这样的一个图案打印出来。

2.2 二进制到底是什么

  • 我们在表示位权时使用了“○○的 ×× 次幂”这种说法,其中“○○”的部分,在十进制的情况下是10,在二进制的情况下是2,这部分叫作基数。
  • [插图]
  • “○○ 的 ×× 次幂”中“××”部分的取值,无论几进制都是“位数 - 1”。

2.3 移位运算与乘除运算的关系

  • 移位运算是一种对二进制数的各位数字进行平移(shift)的运算。将各位数字向左(高位)移位称为左移,向右(低位)移位称为右移。一次运算可以对数字平移多位。
  • 运算符<< 代表左移运算,右移运算的运算符是 >>。
  • 空出来的低位会用0来填充。但这只适用于左移的情况。
  • 在移位运算中,最高位或最低位多出来的数字(称为溢出)会被直接舍弃。
  • 移位运算有点类似于由二进制数组成的点阵图案像霓虹灯牌一样左右滚动的感觉。
  • 通过数位的移动,移位运算也可以用来代替乘法运算和除法运算。

2.4 便于计算机处理的“2的补码”

  • 在右移运算中空出来的高位上填充的数字有0和1两种情况。要区分这两种情况,需要先了解一下二进制中如何表示负数。
  • 要在二进制中表示负数,一般的方法是将最高位用来表示符号,这时最高位被称为符号位。
  • 符号位为0时表示正数,符号位为1时表示负数。
  • 1用8位二进制数表示应该是11111111。
  • 计算机是用加法运算来实现减法运算的。为了实现这一点,在表示负数的时候,我们需要使用“2的补码”这一特殊的方法。
  • 2的补码是在二进制中用正数来表示负数的一种神奇的方法。
  • 要得到2的补码,我们需要先将二进制数的各位数字反转,然后再将结果加1。
  • [插图]
  • 将-1用8位二进制数表示,就相当于求1(即00000001)的2的补码。00000001的2的补码,就是将其各位数字中的0变成1,1变成0,然后将得到的结果加1,也就是11111111
  • [插图]
  • “00000001 + 11111111”的结果正好是0(= 00000000)。在这个运算中,最高的第9位的数字溢出了,我们之前讲过,计算机会舍弃溢出的数字,因此在8位范围内计算的话,100000000这个9位二进制数就会变成00000000(图2-7)。
  • [插图]
  • 对于求2的补码的方法,大家可以用“反转 + 1”的口诀来记。
  • 将一个二进制数反转后再加1,然后和原数相加的结果一定为0。
  • [插图]
  • 结果不为0的运算,使用2的补码也可以得到正确的结果。但需要注意的是,如果运算结果是负数,那么这个负数也是以2的补码来表示的。
  • 如果11111110是负 ××,那么11111110的2的补码就是正 ××,因此只要求2的补码的补码,就可以得到其绝对值。
  • 11111110的2的补码,就是反转再加1,得到00000010,也就是十进制的2。
  • C语言的数据类型中,有不能处理负数的unsigned short型,也有能处理负数的short型。这两种类型的变量长度都是2字节(= 16比特),都能表示2的16次幂 = 65536种不同的值。但是,它们能表示的值的范围不同,short型是 - 32768~32767,而unsigned short型是0~65535。
  • 这是因为short型将最高位为1的值按照2的补码来处理,而unsigned short型则将其作为32768以上的正数来处理。
  • 最高位为0的数有0~32767,共32768个,其中已经包含了0,而最高位为1的数都是负数,即 - 1~- 32768,也是32768个,其中不包含0。
  • 由于0被包含在正数的范围内,所以负数比正数要多一个。尽管0不是正数,但从符号位的角度来看,它和正数属于同一类。

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

  • [插图]
  • 在需要将二进制数作为有符号的数值来运算时,右移时用原数符号位的值(0或1)来填充高位,这种做法称为算术右移。
  • 当原数是以2的补码形式表示的负数时,右移时空出来的高位就会用1填充,从而对有符号数进行正确的1/2、1/4、1/8等数值运算。而当原数是正数时,高位用0来填充就可以了。
  • 假设我们要将 - 4(= 11111100)右移2位,如果是逻辑右移,结果就是00111111,即十进制的63。但是,这个结果并不是 - 4的1/4,如果用算术右移,结果就是11111111,正好是以2的补码表示的 - 1,这样我们就能正确计算出 - 4的1/4了
  • 只有在右移运算中才需要区分逻辑移位和算术移位。
  • [插图]
  • 逻辑右移与算术右移的区别。
  • 符号扩展(sign extension)。当我们要将一个8位二进制数在不改变其值的情况下转换成16位二进制数或者32位二进制数时,就需要使用符号扩展。
  • 无论是正数,还是以2的补码表示的负数,进行符号扩展时都只要用符号位的值(0或1)填充高位即可,也就是将符号位直接扩展到高位(图2-11)。

2.6 掌握逻辑运算的窍门

  • 在运算中,“逻辑”是与“算术”相对的概念。我们可以这样认为:将二进制数所表示的信息当作四则运算中的数值来处理就是算术,而像图案这样,将其当作单纯由0和1组成的序列来处理就是逻辑。
  • 计算机能够执行的运算包括移位运算、算术运算和逻辑运算。算术运算指的就是加减乘除四则运算,逻辑运算指的是对二进制数各位的0和1分别进行运算,包括逻辑非(NOT运算)、逻辑与(AND运算)、逻辑或(OR运算)和逻辑异或(XOR运算[插图])4种。
  • 逻辑非就是将0反转为1,将1反转为0。逻辑与就是在两者都为1时运算结果为1,否则运算结果为0。逻辑或就是在至少有一方为1时运算结果为1,否则运算结果为0。逻辑异或是一种排他的,也就是不喜欢对方和自己相同的运算,当两者不同,即一方为1,一方为0时,运算结果为1,否则运算结果为0。
  • 逻辑运算的对象不是数值,因此不会产生进位。
  • [插图]
  • 向完全没有编程经验的人讲解程序的工作原理。如果理解了程序的本质,就应该能用任何人都可以理解的简单语言来讲解清楚。
  • 各位读者可以在阅读时思考一下,如果是自己的话会怎样讲。

第3章 计算机在计算小数时会出错的原因

  • 二进制数小数点后第1位的位权是2 - 1 = 0.5,因此二进制数0.1 → 1×0.5 →十进制数0.5。
  • 十进制数0.625转换成二进制数是0.101。
  • 浮点数是用“符号 尾数×基数的指数次幂”的格式来表示小数的。
  • 无论是整数部分还是小数部分,每4位二进制数都相当于1位十六进制数。

3.2 如何用二进制表示小数

  • 只要将各位数字乘以对应的位权[插图],然后将结果全部加起来就可以了。
  • [插图]
  • 小数部分的位权,第1位是2的 - 1次幂,第2位是2的 - 2次幂,以此类推。
  • 不仅是二进制,十进制和十六进制里也是一样的。

3.3 计算机计算出错的原因

  • 有一些十进制小数无法准确转换成二进制数。
  • 例如,十进制数0.1就无法用二进制数来准确表示,即使用几百位小数也表示不了。
  • 在十进制数中,0的后面就是0.0625,也就是说,这两个数之间的数,都不能用4位二进制小数来表示,而0.0625的下一个数一下子就到了0.125。
  • 无论增加多少位小数,都无法通过让2的负 ×× 次幂相加来凑出0.1。
  • 将十进制数0.1转换成二进制数,会得到0.00011001100…(之后1100不断重复)这样一个循环小数[插图]。
  • 计算机能力有限,无法处理无限的循环小数,只能根据变量所对应的数据类型的比特数,对数值进行截断或者采取四舍五入的处理。

3.4 什么是浮点数

  • 双精度浮点型的长度为64位,单精度浮点型的长度为32位。
  • 浮点数将小数分为符号、尾数、基数和指数4个部分来表示(图3-3)。
  • [插图]
  • [插图]
  • 双精度浮点数和单精度浮点数在表示数值时所使用的位数不同,双精度浮点数能表示的数值的范围比单精度浮点数的大。
  • 符号部分使用1位来表示数值的符号。其中“1”表示“负数”,“0”表示“正数或0”,这和二进制整数中的符号位是相同的。
  • 数值的大小通过尾数部分和指数部分来表示。于是,整个小数的值就可以用“尾数部分 × 2的指数部分次幂”的形式表示。
  • 尾数部分和指数部分都不是单纯的二进制整数。其中,尾数部分使用的是整数部分固定为1的规格化(normal)表示法,指数部分则使用移码(excess)表示法。

3.5 规格化表示法与移码表示法

  • 尾数部分使用的规格化表示法[插图],其作用是将表示形式不一致的浮点数用统一的形式来表示。
  • 根据特定的规则来表示小数的方法就叫作规格化表示法。
  • 二进制中使用的是整数部分固定为1的规格化表示法。
  • 我们需要将要表示的二进制小数左移或右移若干位(此处为逻辑移位,符号位是独立的[插图]),使其整数部分的第1位为1,第2位及之后都为0(即从第2位起不存在有效数字)。此外,整数部分的1在实际的数据中是不存在的,因为整数部分一定是1,所以我们可以将其省略,从而节省1比特,这样就可以(少许)扩大能表示的数的范围。
  • 单精度浮点数的尾数部分有23位,由于省略了整数部分的1,所以实际上可以表示24位的数值。双精度浮点数的规格化表示也是如此,只是使用的位数不同而已。
  • [插图]
  • 指数部分有时需要表示“负 ×× 次幂”这样的负数,移码表示法就是将指数部分表示范围的中间值规定为0,从而可以在不使用符号位的情况下表示负数。
  • 单精度浮点数的指数部分为8位,我们将其最大值11111111 = 255的一半,即01111111 = 127(向下取整)规定为0;而双精度浮点数的指数部分为11位,我们将其最大值11111111111 = 2047的一半,即01111111111 = 1023(向下取整)规定为0。
  • 例如,指数部分为二进制数11111111(十进制数255)时,它在移码表示法中就代表128,因为255 - 127 = 128。因此,8位的指数部分可以表示的指数范围为 - 127~128。
  • [插图]

3.6 用程序来实际确认一下吧

  • 十进制小数0.75用单精度浮点数表示出来是0-01111110-10000000000000000000000。
  • 0.75是一个正数,因此符号部分为0。指数部分01111110是十进制数126,按照移码表示法,它代表的指数是 - 1(126 - 127 = - 1)。尾数部分10000000000000000000000采用了整数部分为1的规格化表示法,实际上代表的二进制数是1.10000000000000000000000,将其转换成十进制数就是 (1 × 2的0次幂)+(1×2的-1次幂 )=1.5。因此,单精度浮点数0-01111110-10000000000000000000000所表示的数值就是“1.5×2的-1次幂”。
  • 看一看十进制小数0.1用单精度浮点数是如何表示的,结果是0-01111011-10011001100110011001101。
  • [插图]

3.7 如何避免计算机计算出错

  • 第一种是回避策略,也就是忽略错误。
  • 第二种方法是用整数替代小数进行计算。
  • 我们可以在计算时临时使用整数,然后将计算结果用小数表示。例如,本章开头提到的将0.1累加100次的计算,只要将0.1扩大10倍变成整数1,然后将1累加100次,再将结果除以10显示出来就可以了。
  • BCD[插图]就是一种用整数替代小数的格式,它用4比特来表示0~9的1位十进制数,具体方式在这里不做赘述。
  • BCD格式常用于不允许有计算误差的金融领域。

3.8 二进制与十六进制

  • 二进制在以比特为单位表示数据时很有用,但它也有一个缺点,那就是位数太多的时候看起来会十分费劲。因此,在实际编程中,我们经常使用十六进制来代替二进制。在C语言程序中,在数值前面加上“0x”前缀就可以表示十六进制数。
  • 2025/05/11 发表想法:十六进制从小到大依次为:0123456789ABCDEF

4位二进制数正好相当于1位十六进制数。例如,32位的二进制数00111101110011001100110011001101就可以写成8位十六进制数3DCCCCCD。

  • 二进制小数在转换成十六进制时,在小数部分,4位二进制数同样相当于1位十六进制数。如果不满4位,则在二进制数的末尾补0。例如,1011.011可以先在末尾补0变成1011.0110,然后就可以写成十六进制数B.6了(图3-11)。
  • 十六进制数小数部分第1位的位权是16-1,即1/16 = 0.0625,大家应该能理解吧。
  • [插图]
  • [插图]
  • 小数点位置和实际位置不同的表示方式称为浮点数。相对地,用实际的小数点位置来表示小数的方式称为定点数。将0.12345×103和0.12345×10-1写成定点数就是123.45和0.012345。
  • 双精度浮点型的表示范围为4.94065645841247×10-324~1.79769313486232×10308的正数和-1.79769313486232×10308~-4.94065645841247×10-324的负数。单精度浮点型的表示范围为1.401298×10-45~3.402823×1038的正数和-3.402823×1038~-1.401298×10-45的负数。

第4章 让内存化方为圆

  • 10根地址信号引脚能够表示2 10 = 24个地址。
  • 指针变量存储的内容是内存地址。
  • 内存在物理上是以1字节为单位存储数据的。
  • 栈是一种后进先出(LIFO=Last In First Out)的数据结构。
  • 计算机是处理数据的机器,而程序负责规定处理步骤和数据结构。作为处理对象的数据存储在内存和磁盘中,因此程序员必须能够灵活地使用内存和磁盘。为此,我们不仅要理解内存和磁盘的物理(硬件)结构,也要理解它们的逻辑(软件)结构。
  • 本章的主题是内存(磁盘将在第5章中介绍)。从物理上看,内存的结构其实非常简单,但通过程序的设计,我们也可以让内存变身为各种不同的数据结构来使用。

4.1 内存的物理结构十分简单

  • 内存本质上是一种名为内存芯片的装置。内存芯片分为RAM、ROM[插图]等不同类型,但从外部来看,它们的基本原理是相同的。
  • 内存芯片上有很多引脚,这些引脚负责连接电源,以及输入输出地址信号、数据信号和控制信号,通过指定地址,就可以对数据进行读写。
  • 在这些引脚中,VCC和GND是电源,A0~A9是地址信号,D0~D7是数据信号,RD(read,读取的简写)和WR(write,写入的简写)是控制信号。VCC和GND连接电源,其他引脚则有0或1的信号。
  • 5V表示1,0V表示0。
  • [插图]
  • 数据信号引脚有D0~D7,共8根,因此我们知道它一次可以输入输出的数据长度为8比特( = 1字节)。地址信号引脚有A0~A9,共10根,因此可以指定0000000000~ 1111111111这1024个地址。
  • 地址表示的是数据存储的位置,因此这块内存芯片能够存储1024个1字节的数据。由于1024 = 1 K,所以这块内存芯片的容量是1 KB。
  • 假设要向这块内存芯片中写入1字节的数据,我们需要先给VCC接上 + 5V电源,给GND接上0V电源,然后通过A0~A9的地址信号指定数据的存储位置,将要写入的数据值输入数据信号D0~D7,最后将WR信号设置为1。
  • 当需要读取数据时,我们需要通过地址信号A0~A9指定数据存储位置,将RD信号设置为1,这时,指定地址中存储的数据就会通过D0~D7的数据信号引脚输出(图4-2 b)。
  • WR、RD这种让电路执行操作的信号称为控制信号。WR和RD都设为0时,电路不会进行任何操作。
  • [插图]

4.2 内存的逻辑结构像一幢大楼

  • 很多讲编程的书会用类似于一幢大楼的图来表示内存。在这幢“大楼”中,每一层都可以存储1字节的数据,楼层编号就是地址。
  • 。程序员并不需要关心内存芯片的电源和控制信号。
  • [插图]
  • 程序员眼中的内存有一个物理上的内存所不存在的概念,那就是数据类型。
  • 在编程语言中,数据类型代表要存储哪一类数据,以及该数据在内存中占多少空间(大楼的层数)。
  • 从物理上说,内存是以1字节为单位读写数据的,但在程序中,我们通过指定类型(变量的数据类型),就可以以特定的字节数为单位来读写数据。
  • Windows等操作系统会在程序运行时为变量分配物理内存地址。
  • 这3个变量的数据类型分别为长度为1字节的char型、长度为2字节的short型,以及长度为4字节的long型[插图]。因此,同样是123这个数,赋值给不同的变量之后占用的内存空间大小也不同。
  • 在这里,我们采用将数据的低位存放在内存低地址的小端序[插图]方式(图4-4)。
  • [插图]
  • 在程序中通过指定变量的数据类型就可以改变读写物理内存的单位长度,确实非常方便。

4.3 指针其实很简单

  • 指针是一种变量,它不存储数据本身的值,而是存储数据所在的内存地址。使用指针可以读写任意地址的数据。
  • 一般所使用的PC上运行的程序大多是使用32比特(4字节)来表示内存地址的,这时指针变量的长度就是32位。
  • 指针在声明时需要在变量名前面加上一个星号(*)。
  • 这些数据类型所代表的是从指针中存储的地址一次读写多少字节的数据。
  • [插图]

4.4 用好内存先从数组开始

  • 数组是将相同数据类型(长度)的多个数据连续排列在内存中的一个元素序列。
  • 通过下标可以读写相应的内存空间[插图]。将下标转换成实际内存地址的操作是由编译器自动完成的。
  • 声明数组时所指定的类型也代表了对内存读写一次的长度。
  • 数组之所以是使用内存的基础,是因为它反映的就是内存的物理结构本身。特别是1字节型的数组,和内存的物理结构完全一致。
  • 但是,如果只能以1字节为单位来进行读写的话,程序编写起来会非常麻烦,因此才提供了通过指定数据类型来声明数组的功能,这有点像将每个部门只占一层的大楼结构改造成每个部门占多层的结构(图4-6)。
  • [插图]
  • 仅通过指定下标来访问数组元素,这种用法和对内存进行物理读写大同小异。

4.5 栈与队列,以及环形缓冲区

  • 栈[插图]和队列都是无须指定地址和下标就可以对数组元素进行读写的结构。
  • 为了保存临时数据,每次都指定地址和下标非常麻烦,因此人们才设计了这些方式加以改善。
  • 栈采用的是LIFO(Last In First Out,后进先出)方式,而队列采用的是FIFO(First In First Out,先进先出)方式。
  • 事先在内存中预留栈和队列所需要的空间,并确定数据的读写顺序,就不需要指定地址和下标了。
  • 将数据写入栈的函数Push、从栈中读取数据的函数Pop[插图]、将数据写入队列的函数EnQueue、从队列读取数据的函数DeQueue[插图]。Push和Pop,以及EnQueue和DeQueue都是成对使用的。
  • Push和EnQueue需要在参数中指定要写入的数据,Pop和DeQueue会将读取的数据作为返回值返回。
  • 使用这些函数就可以临时保存(写入)数据,然后在需要的时候读取出来(代码清单4-4、代码清单4-5)。
  • [插图]
  • 栈的英文是stack,原意为干草堆。当我们把干草一捆一捆堆起来的时候,从上面先拿下来的干草就是最后堆上去的那捆。
  • 需要将数据临时保存起来,稍后再恢复的时候,就可以使用栈。栈还可以用于需要反转输入数据顺序的情况,因为当以123、456的顺序存入数据时,取出的顺序就变成了相反的456、123。
  • [插图]
  • 队列的英文是queue,就是排队的意思,就像我们乘车时在自动售票机前排队买票的队列。
  • 在排队时,先进入队列的人会先买到票离开。
  • 由于买票的人到来的时机是不确定的,所以当自动售票机来不及处理时,就需要队列来充当缓冲(buffer)机制。
  • 使用这样的结构来调整数据输入和处理之间的时间差会非常方便,而队列就是这种结构在内存中的实现方式。
  • 在处理通信中接收到的数据或同时运行的多个程序产生的数据时,就可以将这些不定期产生的数据存放到队列中,然后逐个进行处理。
  • 队列通常会以环形缓冲区(ring buffer)的形式使用。
  • 假设我们用一个包含6个元素的数组来实现一个队列。数据会按顺序从数组开头存放进来,并按照存放的顺序取出。当数据存放到数组的末尾时,下一个数据就会回到数组开头进行存放(此时数组开头原本存放的数据已被取出,因此这个位置是空的)。通过这样的方式,数组的末尾和开头就连接在了一起,从而实现了一种可以循环存放和取出数据的结构(图4-9)。
  • [插图]

4.6 在链表中添加和删除元素很容易

  • 链表和二叉查找树都是不按下标顺序对数组进行读写操作的数据结构。
  • 使用链表可以高效地向数组中添加和删除数据(元素)。使用二叉查找树可以高效查找数组中存放的数据。
  • 链表的实现方式是对于数组中的每个元素,不仅保存它的值,还要额外保存其下一个元素的下标。
  • 链表的末尾元素后面没有其他元素了,因此下一个元素的下标可以设为一个不存在的值(示例中是 - 1)(图4-10)。
  • [插图]
  • 假设我们要将图4-10所示链表中的正数第3个数据删除,此时只要将第2个数据中的“下一个元素:2”修改成“下一个元素:3”即可。
  • 尽管从物理上看,第3个元素依然保留在内存中,但从逻辑上看,它已经从链表中删除了(图4-11)。
  • [插图]
  • 假设我们要在图4-10所示链表的正数第5个位置添加一个新元素,此时首先需要将新的数据存放到之前删除的第3个元素的位置,然后将第4个元素的“下一个元素:5”改成“下一个元素:2”,最后将新添加的元素的下标设置为“下一个元素:5”。
  • 尽管新添加的元素在物理上处于第3个位置,但在逻辑上它处于第5个位置(图4-12)。
  • [插图]

4.7 用二叉查找树高效地查找数据

  • 二叉查找树[插图]在链表的基础上做进一步的扩展,当向数组中添加元素时,根据其大小关系向左右两个方向分支。
  • 让数组中的每个元素除了保存其本身的值,再额外保存两个下标就可以了。
  • 只要巧妙地编写程序,就能够将原本是方形的内存变成圆形、“干草堆”,也能将其变成项链或树的形状。
  • 不要忘记数组是所有数据结构的基础。
  • 磁盘在物理上只能以扇区为单位进行读写,但通过程序的巧妙设计,它也可以以各种不同的形态来使用。
  • 在计算机领域,人们习惯按照1024而不是1000进位,因为1024可以用2的整数次幂(210)表示。通常,小写的“k”表示1000进位,大写的“K”表示1024进位。

第5章 内存与磁盘的密切联系

  • 现在的计算机基本上采用的是存储程序方式。
  • 磁盘缓存指将从磁盘中读取的数据暂时保存在内存中,当需要再次读取相同的数据时,就可以不访问磁盘,而是直接从内存中快速读取。
  • 虚拟内存可以让内存容量小的计算机运行大型程序。
  • DLL是Dynamic Link Library(动态链接库)的缩写。
  • 函数的链接方式分为静态链接和动态链接两种。
  • 扇区(sector)是磁盘的物理存储单位。
  • 在计算机的五大部件[插图]中,内存和磁盘都属于存储器。
  • 利用电流实现存储的内存和利用磁实现存储的磁盘还是有所不同的。
  • 在下面的内容中,内存指主存(用于存储由CPU执行的程序指令和处理的数据的存储器),磁盘主要指硬盘。

5.1 程序加载到内存后才能运行

  • 程序要先存储在存储器中,然后才被依次读取执行。这种方式称为存储程序方式。
  • 在此之前,计算机只有通过重新连接线路才能修改程序。
  • 存储在磁盘中的程序需要先加载到内存才能运行,不能在磁盘上直接运行。这是因为CPU在对程序内容进行解释和运行时,是通过其内部的程序计数器指定内存地址来读取程序的[插图]。
  • 即便CPU能够直接读取并运行磁盘上的程序,由于磁盘读取速度慢,所以程序的运行速度也会很慢。
  • 存储在磁盘中的程序需要先加载到内存后才能运行。
  • [插图]

5.2 提高磁盘访问速度的磁盘缓存

  • 磁盘缓存 [插图]是一块内存空间,用于临时存放从磁盘读取出来的数据。下次需要读取相同的数据时,就不需要实际访问磁盘,而是从磁盘缓存中读取数据就可以了。
  • 有了磁盘缓存,就能够提高磁盘数据的访问速度了(图5-2)。
  • [插图]
  • 2025/05/13 发表想法:类似地,游戏客户端会保存一部分不经常被修改的数据到本地缓存,避免每次打开某些界面都需要网络请求与服务端交互。redis也是将数据库中的常用数据进行缓存,避免每次使用都查询数据库。

将低速设备中的数据保存在高速设备中,当需要相同数据时直接从高速设备中读取,这样的设计就叫作缓存。

5.3 将磁盘当成内存使用的虚拟内存

  • 虚拟内存是将磁盘的一部分模拟成内存来使用的机制。磁盘缓存是将内存看成虚拟的磁盘,与之相对,虚拟内存是将磁盘看成虚拟的内存。
  • 有了虚拟内存,我们就可以在内存不足的状态下运行程序。
  • CPU只能运行已经加载到内存中的程序,因此,即使通过虚拟内存用磁盘来代替内存使用,实际运行的程序部分在运行时也必须存放在内存中。
  • 于是,为了实现虚拟内存,就需要在运行程序的过程中,对实际内存(物理内存)和磁盘上的虚拟内存中的部分内容进行置换。
  • 虚拟内存的实现方式分为分页式和分段式[插图],Windows采用的是分页式。在这种方式中,要运行的程序无论结构如何,都会被划分成一定大小的“页面”,并以页面为单位在内存和磁盘之间进行置换。
  • 在分页式中,将磁盘中的内容读入内存称为页面换入(page in),将内存中的内容写入磁盘称为页面换出(page out)。
  • [插图]
  • 在Windows中,为了实现虚拟内存,需要在磁盘上生成一个虚拟内存文件(页面文件)。
  • 这个文件是由Windows自动生成和管理的。文件的大小,即虚拟内存的大小,一般是物理内存大小的1~2倍。

5.4 将内存当成磁盘使用的固态硬盘

  • 固态硬盘是将一种可读写的且断开电源后内容不会丢失的闪存(flash memory)作为硬盘来使用的产品。固态硬盘的本质是内存。
  • [插图]
  • USB驱动器、SD卡等也是用闪存来存储的设备。
  • 和机械硬盘相比,固态硬盘具有速度快、能耗低、无噪声、耐冲击、重量轻等优点。

5.5 节约内存的编程技巧

  • 基于GUI(Graphical User Interface,图形用户界面)[插图]的Windows可以说是一个巨大的操作系统。
  • 虚拟内存所产生的的页面换入换出操作都涉及访问低速的磁盘,在这个过程中,应用程序会发生卡顿。
  • 要彻底解决内存不足的问题,只能增加内存容量,或是缩减应用程序的大小。

5.5.1 通过DLL文件共享函数

  • 所谓DLL文件[插图],顾名思义,就是在程序运行时进行动态链接的库(函数和数据的集合),但除此之外,大家还需要关注一点,那就是多个应用程序可以共享同一个DLL文件。这就可以达到节约内存的效果。
  • 如果在每个应用程序的可执行文件中都嵌入MyFunc()函数(这被称为静态链接),当两个应用程序同时运行时,内存中就会同时存在两个MyFunc()函数。这会降低内存的利用效率,存放两个一模一样的东西是对空间的浪费(图5-5)。
  • 同一个DLL文件中的内容会被多个运行中的应用程序共享,由此内存中就只有一个MyFunc()函数的程序。
  • Windows操作系统本身就是由很多DLL文件构成的集合体。
  • 在安装新的应用程序时也会添加一些DLL文件。应用程序就是依靠这些DLL文件来工作的。
  • DLL文件还有另一个优点,那就是在版本升级时,有时不需要更换EXE文件,只要更换DLL文件就可以了。
  • [插图]

5.5.2 通过_stdcall调用缩减程序大小

  • 通过 _stdcall[插图]调用缩小程序大小是C语言程序开发中的一种高级技巧。但是,同样的思路应该也适用于其他编程语言
  • 在C语言中,调用函数之后需要执行栈清理操作[插图]。
  • 所谓栈清理操作,就是从内存里用于传递函数参数的栈空间中清理不用的数据。这个指令不需要程序员编写,而是由编译器在编译程序时自动添加的。在默认设置下,编译器会让函数的调用方来执行这一操作。
  • C语言中是使用栈[插图]来传递函数参数的。使用subl $ 8, %esp指令将esp寄存器[插图]的值减8,从而为存放两个int型(4字节)参数分配空间。然后使用movl $456, 4(%esp)和movl $ 123, (%esp)两条指令将456和123两个参数存入栈中。接下来使用calll _MyFunc指令调用MyFunc函数。MyFunc函数执行完毕之后,存放在栈中的数据就没用了,此时程序会执行addl $8, %esp指令,将表示栈顶位置的esp寄存器值加8(即将栈顶位置向上移动8字节),从而将栈中的数据删除。
  • 栈是需要在各种场景中反复使用的内存空间,因此用完之后需要恢复到原来的状态,这就是栈清理操作。
  • 对于重复执行的栈清理操作,相比放在调用方来执行,放在被调用的函数一方来执行,可以缩减程序整体的大小。
  • 只要将 _stdcall加在函数前面,就可以指定由被调用的函数一方来执行栈清理操作。
  • 代码清单5-2中addl $ 8, %esp这样的指令被移到了MyFunc()一方。尽管这种方法只能为程序缩减3字节(addl $8, %esp指令在机器语言中占3字节)的大小,但对反复调用同一函数的程序来说,整体上还是有效的(图5-7)。
  • [插图]

5.6 了解一下磁盘的物理结构

  • 所谓磁盘的物理结构,就是指磁盘中数据的存储形式。
  • 磁盘的表面在物理上被划分成若干区域,划分方法分为按固定长度划分的扇区方式,以及按可变长度划分的可变长方式。
  • 一般PC所使用的硬盘是采用扇区方式来进行划分的。在扇区方式中,磁盘表面被划分成若干同心圆状的磁道,每条磁道再被划分成若干固定长度(存储的数据长度相等)的扇区(图5-8)。
  • 扇区是磁盘在物理上可读写的最小单位。Windows中的磁盘,一个扇区的长度一般为512字节。
  • Windows在逻辑(软件)上读写磁盘的单位是簇(cluster),它的长度是扇区的整数倍,其实际长度根据硬盘容量确定,有512字节(1个簇 = 1个扇区)、1 KB(1个簇 = 2个扇区)、2 KB、4 KB、8 KB、16 KB、32 KB(1个簇 = 64个扇区)等多种情况。磁盘容量越大,簇的长度也越大。
  • 同一个簇中不能存放不同的文件,否则无法只删除簇中的部分文件。因此,无论多小的文件,都要占用一个簇的空间,所有文件实际占用的磁盘空间是簇的整数倍。
  • 根据笔者的计算机硬盘设置,1个簇 = 8个扇区 = 4 KB(4096字节),因此,无论多小的文件,在硬盘上应该也会占用4 KB的空间。
  • 我们可以发现其中文件的“大小”显示为1000字节,但“占用空间”显示为4096字节(图5-9)。
  • [插图]
  • 当字符数不超过4096个( = 4096字节)时,“占用空间”会维持4096字节不变,但当字符数达到4097个时,“占用空间”就会一下子变成8192字节 = 2个簇(图5-10)。
  • 通过这个实验,我们可以看出数据在磁盘上确实是以簇为单位来存储的。
  • 在以簇为单位读写磁盘的情况下,一个簇中没有占满的空间就只能被闲置。尽管看起来很浪费,但按照目前的设计来说,也没有什么解决的办法。
  • 如果将簇的长度变小,就会增加磁盘的访问次数,造成文件读写速度下降。
  • 由于磁盘需要额外的空间记录扇区的划分方式,所以如果簇的长度太小,磁盘整体的存储容量就会减少。扇区和簇的大小需要在处理速度和存储容量之间寻找平衡。

第6章 自己动手压缩数据

  • 文件是字节数据的集合体。
  • zip是Windows标准支持的压缩文件扩展名。例如,“AAABB”压缩后会变成“A3B2”。半角英文、数字和符号都是用1字节表示的,汉字等全角字符用2字节表示。BMP格式的图片文件是没有经过压缩的,因此比PNG等压缩格式的图片文件要大。像照片这样只要恢复出来的数据人眼几乎看不出差别,就可以使用有损压缩。

6.1 文件是以字节为单位记录的

  • 文件是在磁盘等存储媒体中存储数据的一种形式。程序是以字节为单位向文件中存储数据的。文件的大小之所以表示为 ×× KB或 ×× MB等形式,就是出于这个原因[插图]。
  • 文件是字节数据的集合体。1字节(=8比特)能够表示的字节数据共有256种,也就是二进制数00000000~11111111所表示的范围。

6.2 游程编码的原理

  • 将文件内容用“数据 × 重复次数”来表示的压缩方法称为游程编码(run length encoding)(图6-2)。
  • 游程编码是一种很好用的压缩方法,常用在传真的图像压缩等领域[插图]。
  • [插图]

6.3 游程编码的缺点

  • 对于相同数据连续重复的情况较多的图片文件,游程编码的效果比较好,但它并不适合用来压缩文本文件。
  • 相对于文本文件,图片文件(黑白的BMP文件)的压缩率[插图]就可以达到14%,这是因为表示黑或白的数据经常会连续重复出现。

6.4 从莫尔斯码中发现哈夫曼算法的基础

  • ZIP格式[插图]也是使用哈夫曼算法来进行压缩的。
  • 哈夫曼算法压缩的要点在于,我们可以将出现次数多的数据用小于8比特的编码来表示,将出现次数少的数据用大于8比特的编码来表示。
  • 无论是不足8比特的数据,还是超过8比特的数据,最终都要以8比特为单位存储到文件中,因为磁盘按1字节为单位来存储数据的事实是无法改变的(图6-3)。
  • [插图]
  • [插图]
  • 莫尔斯码的编码对象是英文字母,在一般的文本中出现频率越高的字母,其编码长度越短。

6.5 使用树来构建哈夫曼编码

  • 莫尔斯码是根据字母在一般文本中的出现频率来确定它们的编码长度的。
  • 哈夫曼算法的要点是根据不同的压缩对象文件来构建最优的编码系统,并基于这一编码系统来进行压缩。
  • 具体为哪个数据分配哪个编码(哈夫曼编码),在不同的文件中是不同的。在由哈夫曼算法压缩的文件中,同时保存着哈夫曼编码的信息以及压缩后的数据(图6-4)。
  • 用长度短的编码来表示出现频率高的字符。
  • 在表6-3的编码方案中,随着字符出现频率从高到低,其编码长度按照1比特、2比特递增。然而,这个编码系统是有问题的。例如,3比特的编码“100”,可以表示“1”“0”“0”,即“E”“A”“A”三个字符,也可以表示“10”“0”,即“B”“A”两个字符,也可以看成一个整体“100”,表示字符“C”,我们无法区分它到底表示的是哪一种。因此,这个编码系统必须加入分隔符才能使用。
  • [插图]
  • 哈夫曼算法使用哈夫曼树(Huffman tree)来构建编码系统,从而实现了不用分隔符就能区分字符的编码系统。在使用哈夫曼树的情况下,即便每个字符的编码长度不同,不同的字符也能正确分隔开来。
  • [插图]

6.6 通过哈夫曼算法大幅提高压缩效率

  • 我们在编码时采用了“将出现频率最低的数据连接起来”的方法,这意味着出现频率低的数据要到达根需要经过更多的分支。经过的分支的数量多,就意味着编码的比特数多。
  • 对于10001这一串5比特的数据,按照图6-5的哈夫曼编码进行查找,其中100会找到字符B,剩下的01会找到字符E,在这个过程中,字符之间的间隔已经被正确分辨出来。

6.7 无损压缩与有损压缩

  • Windows标准图像数据的格式是BMP[插图],这是一种完全未经压缩的格式。由于显示器或打印机输出的点(bit)可以直接进行映射(mapping),所以使用了BMP(bitmap)这个名称。
  • JPEG[插图]格式、GIF[插图]格式、PNG[插图]格式等。BMP之外的大多数图像数据格式采用了一定的方法对数据进行压缩。
  • EXE程序文件,以及每个字符和数字都有意义的文本文件,必须能够准确地恢复为压缩前的内容,而图片文件即便无法准确恢复到压缩前的状态,只要人眼感觉不到差异,就允许损失一些质量。能够恢复到压缩前状态的压缩方式称为无损压缩,不能恢复到压缩前状态的压缩方式称为有损压缩(图6-6)。
  • 和原始文件相比,JPEG格式和GIF格式的文件的质量都有所下降。JPEG格式[插图]的文件采用了有损压缩,因此会损失一部分信息,导致图像变得模糊。GIF格式的文件虽然采用了无损压缩,但它最多只能存储256种颜色,由此损失了一部分颜色信息,导致图像失真。PNG格式的文件采用了无损压缩,而且能够存储与BMP格式相同数量的颜色,因此图像能够保持原状。
  • 对文本文件是不能使用有损压缩的。

第7章 程序在怎样的环境下运行

  • 一般来说,应用程序的运行环境是指操作系统的类型以及硬件(CPU、内存等)的类型和性能指标。
  • 应用程序是为了在特定操作系统上运行而开发的。PC上也可以安装Ubuntu、RHEL(Red Hat Enterprise Linux)等Linux发行版操作系统。只要针对不同的环境准备专用的Java虚拟机,就可以让相同的字节码在各种环境中运行。SaaS提供应用程序,PaaS提供操作系统,IaaS提供硬件。计算机内部ROM中存储的BIOS程序负责启动引导装入程序,引导装入程序负责启动存储在硬盘等媒体中的操作系统。

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

  • 运行环境=操作系统+硬件
  • 程序的运行环境是通过操作系统和硬件(处理器、内存等)来表示的,也就是说,操作系统和硬件决定了程序的运行环境。
  • PC[插图]不仅可以安装Windows,还可以安装Linux[插图]操作系统。
  • 因此,Office 2019的运行环境需要同时规定操作系统和硬件类型(图7-1)。
  • CPU只能解释特定种类的机器语言,不同类型的CPU能解释的机器语言也不同。除了x86,CPU的种类还包括MIPS、SPARC、PowerPC等[插图],它们各自所使用的机器语言都是不同的。
  • 机器语言的程序也称为本机代码(native code)。程序员使用C语言等编写的程序,在编写阶段都只是普通的文本文件。在任何环境下文本文件(不考虑字符编码问题的话)都可以显示和编辑,这样的文件被称为源代码(source code)。
  • 对源代码进行编译,可以得到本机代码。在大多数情况下,应用程序不是以源代码的形式分发的,而是以本机代码[插图]的形式分发的(图7-2)。
  • [插图]

7.2 Windows消除了CPU之外的硬件差异

  • Windows消除了CPU之外的硬件差异。
  • 计算机的硬件并不只有CPU,还有用来存储程序指令和数据的内存,通过I/O[插图]连接的键盘、显示器、硬盘、打印机等外部设备。
  • 在不同的计算机中,这些外部设备的访问方式也有所不同。
  • 上面这些型号的计算机配备的都是x86架构的CPU,但由于内存、I/O等硬件的差异,MS-DOS下的应用软件也必须为每一种机型专门进行开发。
  • CPU有一块专门用于外部设备输入/ 输出的I/O地址空间,对于哪一个外部设备分配到哪一个地址,不同的机型也有所不同。
  • 如果要使用这款软件,就必须根据计算机的机型购买对应的版本(图7-3 a)。这是因为在应用程序中存在对计算机硬件直接进行操作的部分。
  • 只要能运行Windows,在不同的机型上也可以使用相同的应用程序。
  • 在Windows应用程序中,键盘输入、显示器输出等操作不是通过直接访问硬件来实现的,而是通过向Windows发出请求来间接地实现的。
  • Windows代替了应用程序对各种不同机型的硬件进行操作(图7-4)。
  • [插图]
  • 2025/05/15 发表想法:Windows操作系统将软件和硬件进行了解耦,相当于对软件提供了接口,软件只要符合Windows操作系统接口的规则就可以运行,软件不需要关心硬件的操作逻辑。

MS-DOS应用程序中,不经过操作系统直接访问硬件的部分较多,而Windows应用程序则基本上将硬件访问全部交给Windows来完成

7.3 每种操作系统的API都是不同的

  • 同一种机型的计算机也可以安装多种操作系统。以PC为例,除Windows之外,它也可以安装Ubuntu、RHEL等Linux发行版[插图]。
  • 应用程序也要根据各种不同的操作系统来提供相应的版本。
  • 如果说CPU类型的差异代表机器语言的差异,那么操作系统的差异就代表应用程序向操作系统发出请求方式的差异。
  • 应用程序向操作系统发出请求的方式是由API(Application Programming Interface,应用程序接口)[插图]来决定的。
  • Windows和Linux的API提供了可被任意应用程序使用的函数集合。由于不同的操作系统提供的API不同,所以如果要将一个应用程序移植到另一个操作系统上,就必须重新编写其中使用API的部分。
  • API提供了键盘输入、鼠标输入、显示器输出、文件输入/ 输出等与外部设备之间输入/ 输出的功能。
  • 在同一个操作系统中,无论使用怎样的硬件,API都是基本相同的。因此,按照操作系统的API编写的程序,在任何硬件上都可以运行。当然,如果CPU类型不同,机器语言也会不同,本机代码不可能保持不变。
  • 在这种情况下,我们需要使用对应的编译器重新编译源代码,以便生成适配各种CPU的本机代码。
  • 程序(本机代码)的运行环境是由操作系统和硬件共同决定的。

7.4 使用源代码进行安装

  • 在Linux中安装新程序时,我们可以选择包含所有必要程序的软件包,也可以选择通过源代码来安装。其中第二种方法就是将源代码在本机上编译后再使用。
  • Linux程序的源代码大多是用C语言来编写的,这些源代码可以从遍布互联网的Linux 仓库[插图]中获取。
  • Linux内置了标准的C语言编译器,使用该编译器就可以按照当前Linux的运行环境生成对应的本机代码(图7-5)。
  • [插图]
  • 在Linux中,源代码可以编译后使用

7.5 在任何地方都能提供相同运行环境的Java虚拟机

  • 不将源代码编译为本机代码,而是一种中间代码,就可以提供不依赖特定操作系统和硬件的运行环境了,Java使用的就是这种方法。
  • Java这个词有两个含义,一个是Java编程语言,另一个是Java程序运行环境。
  • 和其他编程语言一样,用Java编写的源代码也需要经过编译才能运行,但是编译后生成的并不是针对特定CPU的本机代码,而是一种称为字节码(bytecode)的代码。
  • 字节码的运行环境称为Java虚拟机(Java Virtual Machine,Java VM)。Java虚拟机会将Java字节码逐一转换为本机代码来执行。
  • 在安装Windows的PC上使用Java编译器和Java虚拟机时,编译器会将程序员编写的源代码编译成字节码,然后由Java虚拟机将字节码转换成x86架构CPU的本机代码以及Windows的API,最后由x86架构CPU和Windows来执行实际的操作。
  • 编译后的字节码需要在运行时转换成本机代码,这个方法看起来有点绕弯子,但它可以让相同的字节码在不同的环境中运行。只要为各种操作系统和硬件开发对应版本的Java虚拟机,就可以让相同的字节码应用程序在所有环境中运行了。
  • Java的这种特性被称为“Write once, run anywhere”(一次编写,处处运行)(图7-6)。
  • Windows中有Windows版的Java虚拟机,Mac中有Mac版的Java虚拟机。
  • 从操作系统的角度来看,Java虚拟机也是一种应用程序,但从Java应用程序的角度来看,Java虚拟机就是其运行环境,也就是操作系统 + 硬件的结合体。
  • 不同的Java虚拟机之间并不能做到完全兼容。Java虚拟机很难做到运行任何字节码程序这一点。其次是运行速度,需要在运行时将字节码转换成本机代码的Java程序,在运行速度上比直接编译成本机代码的C语言程序要慢。

7.6 云计算平台提供的虚拟运行环境

  • 通过互联网来使用硬件、操作系统、应用程序等计算机资源的技术称为云计算(cloud computing)。
  • 云计算可分为SaaS(Software as a Service,软件即服务)、PaaS(Platform as a Service,平台即服务)和IaaS(Infrastructure as a Service,基础设施即服务)[插图]几种类型。
  • SaaS提供的是应用程序,PaaS提供的是操作系统,IaaS提供的是硬件。
  • 在SaaS、PaaS和IaaS中,PaaS和IaaS可作为程序的运行环境使用。
  • PaaS提供的是操作系统,因此我们可以在这个操作系统上运行开发的程序。IaaS提供的是硬件,因此我们可以在这个硬件上安装Windows、Linux等操作系统,然后在安装的操作系统中运行开发的程序。
  • 在“操作系统”中可选择Windows或Linux,在“实例”中可选择CPU数量、内存容量、硬盘容量等硬件规格。根据这些选项,微软公司的服务器集群中就可以生成一个虚拟的运行环境。

7.7 BIOS与引导装入程序

  • 程序的运行环境还包括BIOS(Basic Input Output System,基本输入输出系统)。BIOS存储在ROM中,是预先内置在计算机中的一段程序。BIOS除了提供键盘和磁盘设备的基本控制程序,还负责启动引导装入程序。
  • 引导装入程序是存储在启动磁盘开头的一段很短的程序。启动磁盘一般是硬盘,但光盘和USB驱动器也可以作为启动磁盘使用。
  • 打开计算机电源后,BIOS会先检查硬件是否能够正常工作,如果一切正常就启动引导装入程序。
  • 引导装入程序的功能是将存储在硬盘上的操作系统加载到内存并运行。启动应用程序是操作系统的工作,而操作系统不能启动自己,因此操作系统的启动需要由引导装入程序来完成。
  • 引导装入程序的英文是bootstrap loader。
  • 短小的引导装入程序(靴襻)启动(提起来)巨大的操作系统(靴子),bootstrap这个词表达的就是这个意思(图7-8)。
  • 当操作系统进入工作状态后,程序员就不需要关注BIOS和引导装入程序了,但大家还是要知道它们的存在。

第8章 从源文件到可执行文件

  • 包含DLL文件中的函数调用信息的文件叫什么?
  • 对源代码进行编译后可得到本机代码。通过编译和链接可得到EXE文件。对源文件进行编译可得到目标文件。例如,对源文件sample.c进行编译可得到目标文件sample.obj。目标文件的内容就是本机代码。链接器会从库文件中提取必要的目标文件并将它们拼接成一个EXE文件。在程序运行时进行动态链接的DLL文件也属于库文件。将导入库中的信息链接到EXE文件,由此程序就可以在运行时调用DLL中的函数了。堆是一种可以根据程序自身的请求来分配和释放的内存空间。
  • 编写好源文件之后,对源文件进行编译和链接就可以生成可执行文件了。编译和链接的操作需要使用编译器和链接器来完成。
  • 最开始我们会看一下源文件是如何转变成可执行文件的,然后看一看可执行文件加载到内存并运行的内部机制。笔者也会讲解程序运行时在内存中生成的栈和堆分别是什么样的。在这里,我们会看到用C语言编译器[插图]生成Windows可执行文件(EXE文件)的具体示例。

8.1 计算机只能执行本机代码

  • 计算机只能执行本机代码。
  • 用某种编程语言编写的程序称为源代码(source code)[插图],将源代码保存成一个文件就称为源文件(source file)。
  • C语言的源文件扩展名约定为“.c”。
  • 代码清单8-1的源代码是不能直接运行的,因为CPU能直接解释和执行的只有本机代码。
  • 本机代码的英文是native code,其中native包含母语的意思。
  • 对CPU来说,用它的母语机器语言来编写的程序就是本机代码。用其他编程语言编写的源代码,必须翻译成本机代码才能够被CPU理解和执行(图8-2)。
  • 不同编程语言所编写的源代码翻译成本机代码之后就变成了同一种语言(机器语言)。

8.2 看一看本机代码的内容

  • 转储(dump)是指将文件内容按1字节2位十六进制数的形式显示出来。我们发现,本机代码的内容就是数值序列,这就是本机代码的本质。其中每个数值都代表某个指令或者数据(图8-4)。
  • 对计算机来说,任何信息都是作为数值的集合来处理的。例如,字符“A”就用十六进制数41来表示。同样,程序中的指令也是数值序列。这就是本机代码。

8.3 编译器负责翻译源代码

  • 编译器负责翻译源代码负责将用C语言等高级语言编写的源代码翻译成本机代码的程序称为编译器。
  • 用不同的编程语言编写的源代码需要使用该语言专用的编译器来进行编译。
  • 大家可以大致理解为编译器中有一张源代码和本机代码的对应表,但实际上仅靠对应表是无法生成本机代码的。编译器需要对读取的源代码进行词法分析、语法分析、语义分析等处理,这样才能够生成本机代码。
  • 不仅不同的编程语言所使用的编译器不同,不同类型的CPU所使用的编译器也不同。
  • 编译器本身也是一种程序,因此也有其对应的运行环境。例如,有Windows版的C编译器,也有Linux版的C编译器。同时,也有一些编译器本身运行在一种CPU上,但它能够生成适配另一种CPU的本机代码,这样的编译器称为交叉编译器(cross compiler)。
  • 要编译用哪种编程语言编写的源代码(例如C语言)、编译器生成的本机代码适配的是哪种CPU(例如x86架构CPU),以及这个编译器是在什么样的环境下使用的(例如Windows)。

8.4 仅靠编译无法得到可执行文件

  • 编译器生成的是包含本机代码的文件,但这个文件不能直接运行。要得到可执行的EXE文件,在编译之后还需要进行链接操作。
  • 在Windows命令提示符中运行以下命令,C语言源文件sample.c就会被编译。bcc32c -W -c sample.c其中,“-W”选项代表编译为Windows程序,“-c”选项代表仅执行编译。这里的选项(option)指的是对编译器的要求。选项也称为开关(switch)。
  • 编译后生成的并不是EXE文件,而是扩展名为“.obj”的目标文件(object file)[插图]。
  • 如果不将包含sprintf()和MessageBox()函数实际内容的目标文件与sample.obj拼接在一起的话,就会因为缺少某些函数而无法生成完整的EXE文件。
  • 将多个目标文件拼接在一起生成一个EXE文件的过程称为链接,用于完成这一操作的程序称为链接器(又称链接编辑器或链接程序)。
  • 在Windows命令提示符中执行以下命令,就可以将所有必要的目标文件链接起来,生成名为sample.exe的EXE文件。ilink32 -Tpe -c -x -aa c0w32.obj sample.obj, sample.exe,, import32.lib cw32.lib。

8.5 启动代码与库文件

  • 链接时的选项“-Tpe -c -x –aa”代表要生成用于Windows的EXE文件。在这些选项后面,我们指定了要链接的目标文件。可以看到指定了c0w32.obj和sample.obj这两个目标文件。
  • sample.obj是sample.c编译后生成的目标文件,c0w32.obj包含了一些通用代码,需要链接在所有程序的开头,这些代码被称为启动代码(startup code)。
  • 即便一个程序没有调用位于其他目标文件中的函数,也必须链接启动代码。
  • import32.lib和cw32.lib这样的文件称为库文件。库文件是由多个目标文件打包而成的。在链接时指定库文件,链接器就可以从中提取所需的目标文件,并将其与其他目标文件一起链接生成EXE文件。
  • 下面我们试一下在链接时不指定这两个库文件会发生什么。
  • 错误信息表示无法解析sample.obj所需的外部符号。外部符号(external symbol)是指位于其他目标文件中的变量和函数。_sprintf和MessageBoxA就是sprintf()和MessageBox()在目标文件中的名称。
  • 错误信息中的“无法解析外部符号”的意思是因为找不到包含目标变量和函数的目标文件而无法完成链接。
  • sprintf()等函数并不包含在源代码中,而是以库文件的形式随编译器一起被分发的。这样的函数称为标准函数。使用库文件可以避免在链接器的参数中指定一大堆目标文件。
  • 一个库文件中可以打包很多个目标文件,因此我们在链接器的命令行参数中只要指定几个库文件就够了。
  • 将目标文件打包成库文件的形式分发还有另外一个好处,那就是可以隐藏标准函数的源代码。标准函数的源代码中包含了编译器开发者的技术和经验,是一项宝贵的资产。源代码被其他公司盗用就可能会带来损失。

8.6 DLL文件与导入库

  • Windows操作系统中包含可供应用程序使用的各种功能,这些功能都是以函数的形式来提供的,这样的函数称为Windows API(Application Programming Interface,应用程序接口)。
  • sample.c中调用的MessageBox()并不是C语言规范中的标准函数,而是Windows提供的API的一部分。
  • Windows API的目标文件通常不是以库文件的形式存在的,而是以一种称为DLL(动态链接库)的特殊库文件的形式存在的。
  • DLL文件是在程序运行时才进行链接的。之前讲过,MessageBox()的目标文件位于import32.lib中,但实际上import32.lib中只包含MessageBox()位于DLL文件user32.dll中这一信息,以及这个DLL文件所在的目录,并不包含MessageBox()的目标文件本身。像import32.lib这样的库文件称为导入库。
  • 包含目标文件本身,可以直接链接到EXE文件的库文件称为静态链接库(static link library),其中“静态”与“动态”是一对反义词。sprintf()的目标文件所在的cw32.lib就属于静态链接库。
  • 将导入库链接到EXE文件,就相当于链接了运行时从DLL文件中调用MessageBox()函数所需的信息。因此,链接器在链接时不会报错,成功生成了EXE文件。
  • [插图]

8.7 运行可执行文件需要什么

  • EXE文件作为一个独立的文件存储在硬盘中,当我们在资源管理器中双击EXE文件时,EXE文件中的内容会被加载到内存并运行。
  • 尽管EXE文件中包含完整的本机代码程序,但变量和函数在内存中的实际地址是不确定的。像Windows这种支持同时加载多个可执行程序的操作系统,每次运行程序时都会为程序内部的变量和函数分配不同的内存地址。既然如此,变量和函数的内存地址在EXE文件中又是如何表示的呢?
  • 在EXE文件中,变量和函数被分配的内存地址都是虚拟的,在程序运行时,这些虚拟的内存地址会转换成实际的内存地址。链接器会在EXE文件的开头记录需要进行内存地址转换的各个位置,这些信息被称为重定位信息。
  • 在EXE文件中,重定位信息中记录的是变量和函数的相对地址。所谓相对地址,就是某个地址与基地址之间的相对距离,也就是偏移量。要想使用相对地址,就需要进行一些额外的处理。
  • 在源代码中,变量和函数都是分散在各个位置的,但在链接后的EXE文件中,变量和函数会被集中起来分成两组连续排列。于是,每个变量的内存地址就可以表示为该变量相对于变量区起始位置的偏移量,每个函数的内存地址也可以表示为该函数相对于函数区起始位置的偏移量。
  • 每个区的基地址是在程序运行时确定的(图8-9)。
  • [插图]

8.8 加载时生成的栈和堆

  • 栈是用来存放函数内部临时使用的变量(局部变量[插图])以及调用函数时传递的参数等数据的内存空间。堆是在程序运行时用来存放任意数据的内存空间。
  • EXE文件中并不包含栈和堆的区域,EXE文件加载到内存并运行的那一刻,栈和堆所需的内存空间才得到分配。
  • 内存中的程序是由变量空间、函数空间、栈空间和堆空间共4个区域组成的。当然,内存中还有另外一块专门用于加载Windows操作系统的空间(图8-10)。
  • [插图]
  • 任何程序的内容都是由代码和数据构成的。在很多编程语言中,函数代表代码,变量代表数据。
  • 栈和堆都是在程序运行时分配的内存空间,在这一点上两者是相似的[插图]。
  • 但是,两者在对内存的使用方法上稍有区别。栈数据的存放和丢弃(清空操作)是由编译器自动生成的代码来完成的,不需要程序员关注。一个函数被调用时,会自动分配栈空间来存放数据,并在函数执行完毕返回时自动释放。与之相对,内存中的堆空间需要程序员通过程序显式地进行分配和释放。
  • 在C语言中,堆空间可以使用malloc()函数来进行分配,使用free()函数来进行释放。在C++ 中,堆空间可以使用new运算符来进行分配,使用delete运算符来进行释放。
  • 无论是C语言还是C++,如果不在程序中显式地释放堆空间,那在程序运行结束后,这些空间就依然处于占用状态。这一现象称为内存泄漏(memory leak)。
  • 如果内存泄漏一直存在,就有可能造成内存不足,从而导致宕机。

8.9 进阶问答

  • 编译器和解释器的区别是什么?
  • 编译器是在程序运行之前对整个源代码进行翻译,而解释器则是在程序运行时对源代码逐行进行翻译。
  • C语言和C++ 都属于编译型语言,第12章中提到的Python则属于解释型语言。
  • 什么是多文件编译?
  • 多文件编译是指将一个程序分为多个源文件,并对其分别进行编译,最后合并生成一个EXE文件。这样做的好处是可以缩短单个源文件的长度,方便对程序代码进行管理。
  • 什么是构建(build)?
  • 在某些开发工具中,点击菜单中的“构建”命令就可以生成EXE文件。在这里,构建就是指连续执行编译和链接这两个操作。
  • DLL文件中的函数可以被多个程序共享,从而节约内存和磁盘空间。
  • 如果修改了函数的内容,则不需要重新链接(静态链接)调用该函数的程序[插图]。
  • 即使不链接导入库,程序也可以使用LoadLibrary()和GetProcAddress()等API在运行时调用DLL文件中的函数,但使用导入库比较简单。
  • 垃圾收集(garbage collection)是指将堆空间中已经不再需要的数据进行清理,从而释放被占用的内存空间。这里将不再需要的数据比作垃圾。在Java、C# 等编程语言中,程序会在运行时自动执行垃圾收集,这一机制是为了防止程序员粗心(忘记释放内存)导致的内存泄漏。

第9章 操作系统与应用程序的关系

  • 监控程序可以说是操作系统的原型。文字处理软件、表格处理软件等都属于应用程序。应用程序通过系统调用来间接地控制硬件。Windows 10有32位和64位两种版本。可以通过用鼠标点击屏幕上的窗口、图标等可视化方式进行操作的用户界面。WYSIWYG的意思是,显示器上显示的东西可以直接通过打印机打印出来,即“所见即所得”,这是Windows的特点之一。
  • 程序员在编写应用程序时需要使用操作系统提供的功能。
  • 本章,笔者将介绍操作系统有什么作用以及应用程序是如何使用操作系统的功能的。

9.1 从历史发展看操作系统的功能

  • 很久以前操作系统还不存在的时候,程序员需要从零开始编写能够完成各种操作的程序。这实在是太麻烦了。于是,有人开发了操作系统的原型,这是一种只具备加载和运行程序功能的监控程序。只要先启动监控程序,就可以根据需要将各种程序加载到内存中并运行。比起从零开始开发程序,这已经方便很多了
  • [插图]
  • 人们在使用监控程序的前提下开发了各种程序,并在此过程中发现了很多程序通用的部分。例如,从键盘输入字符,将字符输出到屏幕的部分等。
  • 即使程序的类型不同,这些部分的逻辑往往也是通用的。如果每次编写新的程序都要重新编写这部分逻辑,就太浪费时间了,因此人们将提供基本输入输出功能的程序添加到了监控程序中,这就是早期的操作系统(图9-2)。
  • [插图]
  • 为了给程序员提供便利,人们又在操作系统中增加了硬件控制程序、语言处理器(汇编器、编译器、解释器)以及各种工具,使其最终形成了接近现代操作系统的形态。操作系统不是一个单独的程序,而是多个程序的集合体(图9-3)。
  • [插图]

9.2 关注操作系统的存在

  • 操作系统的出现使程序员不必关注硬件,这也使程序员的人数大大增多。很多不了解硬件的“技术小白”也可以编写出像样的应用程序。
  • 本机代码并不会直接访问计算机的时钟芯片、显示器I/O等硬件。
  • 在操作系统环境中运行的应用程序并不会直接访问硬件,而是通过操作系统间接地访问硬件。
  • 操作系统接受并解析来自应用程序的请求,然后分别访问时钟芯片(实时时钟[插图])和显示器I/O(图9-5)。
  • [插图]

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

  • 操作系统的硬件访问功能通常会以大量小型函数的集合体的形式来提供。这些函数及调用这些函数的行为统称为系统调用(sytem call)[插图],也就是应用程序调用(call)操作系统(system)的功能。
  • 在Windows操作系统中,用来返回当前日期和时间的系统调用,以及在屏幕上显示字符串的系统调用,并不是我们所使用的time()和printf()。time()和printf()内部使用了系统调用去完成相应的功能。
  • 在高级编程语言中就需要使用专用的函数名,并在编译时将其转换成对应操作系统的系统调用(或多个系统调用的组合)。也就是说,用高级编程语言编写的程序在编译后会变成包含系统调用的本机代码(图9-6)。
  • [插图]

9.4 操作系统和高级编程语言对硬件进行了抽象化

  • 文件实际上就是操作系统将磁盘空间抽象化之后的形态。
  • 从硬件的角度来说,磁盘表面像树木的年轮一样被划分为扇区,数据的读写是以扇区为单位来进行的。如果要直接访问硬件的话,就需要向磁盘I/O指定扇区的位置来读写数据。
  • 在读写磁盘媒体时,我们采用了文件的概念,将磁盘读写的操作抽象成打开文件fopen()、写入数据fputs()和关闭文件fclose()这几个步骤(图9-7)。
  • 变量fp中存放的是fopen()函数的返回值,这个值称为文件指针(file pointer)。当应用程序打开文件时,操作系统会自动分配用于管理文件读写的内存空间,这块内存空间的地址可以通过fopen()函数的返回值获取。用fopen()打开文件后,就可以通过指定文件指针来操作文件了。因此,fputs()和fclose()的参数中都需要指定文件指针(变量fp)。

9.5 Windows操作系统的特点

  • Windows操作系统的主要特点如下。
    • 有32位和64位两个版本;
    • 通过API函数集提供系统调用;
    • 采用GUI;
    • 能以WYSIWYG[插图]的方式打印输出;
    • 提供多任务功能;
    • 提供网络和数据库功能;
    • 可通过即插即用自动安装设备驱动程序;
  • 这里的32位或64位,指的是能够最为有效地进行处理的数据长度。
  • Windows处理数据的基本单位,对32位版来说就是32位,对64位版来说就是64位。但是,64位版Windows中也可以运行32位版Windows的应用程序,因此目前,为了保证兼容性,很多应用程序是32位的,很多C编译器生成的也是适配32位CPU的本机代码。
  • 在开发应用程序时需要注意在大多数情况下还是要以32位版来分发。
  • Windows是通过名为API的函数集来提供系统调用的。API是连接应用程序开发者与操作系统的窗口(接口),因此得名API。
  • 32位版Windows的API称为Win32 API,64位版Windows的API称为Win64 API。
  • 在Win32 API中,每个函数的参数和返回值的数据长度基本上是32位。
  • API是以若干DLL文件的形式来提供的,每个API的本体都是C语言编写的函数,因此C语言程序很容易使用这些API。
  • GUI是指能够通过用鼠标点击屏幕上的窗口、图标等元素来进行可视化操作的用户界面。
  • 2025/05/29 发表想法:what you see is what you get.

WYSIWYG是指屏幕上显示的内容可以按原样打印出来。

  • 在Windows中,屏幕和打印机在图像的输出上被视作同等的设备,由此实现了WYSIWYG。
  • 多任务(multitask)是指同时运行多个程序的功能。Windows使用时间片的方式来实现多任务。时间片是指以很短的时间间隔在多个程序之间切换运行,在用户看来就好像是多个程序在同时运行一样。
  • Windows会负责在多个运行的程序之间进行切换(图9-9)。Windows还提供了以单个函数为单位分割时间片的多线程功能。
  • [插图]
  • Windows系统内置了标准的网络功能,服务器版Windows还可以添加数据库(数据库服务器)功能。
  • 数据库并不是操作系统不可或缺的功能,但它与操作系统很接近,所以一般不将其称为应用程序,而是称为中间件(middleware),也就是介于操作系统和应用程序中间(middle)的软件。
  • 操作系统和中间件也统称为系统软件(system software)。应用程序除了可以直接使用操作系统的功能,还可以使用中间件提供的功能(图9-10)。
  • 即插即用(plug-and-play)是一种让新设备插入(plug)之后就可以立即使用(play)的机制。
  • 当新设备连接到计算机后,操作系统可以自动安装并配置用于控制该设备的设备驱动程序(device driver)。
  • 设备驱动程序是操作系统的一部分,负责提供对硬件的基本输入输出功能。
  • 设备驱动程序会在安装其主体文件时,一起安装DLL文件。这些DLL文件中包含了用来访问新硬件设备的API(函数集)。使用这些API,就可以开发能够使用新硬件功能的应用程序。
  • 能够任意添加设备驱动程序和API的机制提高了Windows操作系统的灵活性。所谓的灵活,就是指能够适配将来会出现的新硬件设备。
  • 操作系统、中间件、应用程序等各种软件可以统称为程序,而程序员所编写的程序通常属于应用程序,而不是操作系统。
  • 当应用程序没有正常工作时,大多数情况下并不是因为没有正确使用硬件,而是因为没有正确使用操作系统。
  • 专门为应用程序提供这些通用功能的程序,这个程序就叫操作系统。而根据需要实现各自功能的程序,就是应用程序。
  • Windows就是操作系统。之后购买安装的文字处理软件、游戏等就是应用程序。

第10章 通过汇编语言认识程序的真面目

  • 汇编语言是使用助记符来编写程序的。汇编需要使用汇编器来完成。通过反汇编可以得到人类能够理解的源代码。汇编语言源文件的扩展名在Windows中主要是 .asm,在Linux中主要是 .s。不过,本章中使用的C语言编译器BCC32虽然是在Windows环境下运行的,但使用了 .s作为汇编语言源文件的扩展名。在高级编程语言的源代码中,指令和数据都是分散在各个位置的,但在编译后它们会被分别汇总到不同的段中。汇编语言中可以使用跳转指令实现循环和条件分支。

10.1 汇编语言和本机代码是一一对应的

  • 计算机的CPU能够直接解释执行的只有本机代码(机器语言)。用C语言等编写的源代码,需要使用各个编程语言相对应的编译器进行编译,转换成本机代码。
  • 为每个本机代码的指令分配一个英语缩写来表示其功能。例如,把对32位数据进行加法运算的本机代码称为addl(addition long的缩写),把进行比较的本机代码称为cmpl(compare long的缩写)。
  • 这些缩写称为助记符[插图],使用助记符的编程语言称为汇编语言。
  • 用汇编语言编写的源代码,最终也必须转换成本机代码才能运行。用来完成这种转换的程序称为汇编器,这个转换的过程称为汇编。
  • 2025/06/01 发表想法:汇编器是将汇编语言转换为本机代码,编译器是将c等程序语言转换为本机代码,本质上是类似的。

在将源代码转换成本机代码这一点上,可以说汇编器和编译器的功能是相同的。

  • 我们也可以将本机代码反过来转换成汇编语言的源代码。具有这种反向转换功能的程序称为反汇编器(disassembler),这种反向转换的过程称为反汇编(图10-1)。
  • [插图]
  • 汇编语言源代码和本机代码是一一对应的
  • 将本机代码反编译成C语言源代码要比反汇编困难得多。这是因为C语言源代码和本机代码并不是一一对应的,我们不能保证得到和编译之前相同的源代码[插图]。

10.2 用C编译器输出汇编语言源代码

  • 用C编译器输出汇编语言源代码
  • 在BCC32中,只要指定编译选项“-S”就可以生成汇编语言源代码。
  • 从Windows开始菜单中运行Windows系统工具中的命令提示符,将当前目录[插图]切换到list10_1.c所在的目录,然后输入下面的命令并按下回车键。bcc32c -c -O1 -S list10_1.c
  • bcc32c是启动BCC32编译器的命令。“-c”选项表示仅编译,不链接[插图],也就是不生成EXE文件。“-O1”(大写字母O和数字1)选项表示不生成冗余代码[插图]。“-S”选项表示生成汇编语言源代码。
  • 编译后,在当前目录中会生成名为list10_1.s的汇编语言源代码。汇编语言源文件的扩展名一般为“.asm”或“.s”。
  • .file “list10_1.c” .def _AddNum; .scl 2; .type 32; .endef .section _TEXT,“xr” .globl _AddNum .align 16, 0x90_AddNum: # @AddNum
    BB#0: movl 8(%esp), %eax addl 4(%esp), %eax ret .def _MyFunc; .scl 2; .type 32; .endef .globl _MyFunc .align 16, 0x90_MyFunc: # @MyFunc# BB#0:subl $ 8, %espmovl $456, 4(%esp) # imm = 0x1C8movl $ 123, (%esp)calll _AddNumaddl $8, %espret

10.3 伪指令与注释

  • 伪指令(pseudo instruction)和注释。
  • 汇编语言源代码中的指令分为两种,一种是会被转换成本机代码的一般指令,另一种是专门针对汇编器的伪指令。
  • 伪指令负责告诉汇编器程序的结构和汇编的方法,因此也被称为汇编程序指令(assembler directive)。
  • 在代码清单10-2中,开头有一个句点(.)的 .file和 .def等就是伪指令。这里我们不需要知道所有伪指令的意义,大家只要记住 .section就可以了。.section的功能是标记接下来的程序属于哪个段。段就是一组指令和数据的集合。
  • 段的定义语法为 .section段名, ” 属性 “。在属性的部分中,“x” 表示可执行,“r” 代表可读,“w” 代表可写[插图]。
  • 在代码清单10-2中,.section_TEXT, “xr” 这条伪指令的意思是,接下来的程序是一个名为 _TEXT、属性为可执行且只读的段。
  • 在汇编语言源代码中,以# 开头的部分表示注释。
  • 代码清单10-2中就有几处注释,这些注释都是BCC32自动生成的。在每个函数的入口位置都加上了# @AddNum或# @MyFunc这样的注释,是为了让程序更易读。

10.4 汇编语言的语法是“操作码 操作数”

  • 在汇编语言中,每一行都表示CPU要执行的一个指令。汇编语言指令的语法是“操作码 操作数”[插图](也有一些指令只有操作码,没有操作数),其中操作码表示指令的动作,操作数表示指令的操作对象。
  • 如果我们将操作码类比为谓语动词,将操作数类比为宾语,就会发现它和英语中祈使句的语法是相同的。
  • 我们可以使用哪些操作码取决于CPU的类型。
  • 表10-1列出了代码清单10-2中出现的操作码的功能。这些操作码都用于32位x86架构CPU。
  • 操作数可以是数值、内存地址、寄存器名等。
  • 表10-1中的A、B、L就表示操作数。movl、addl、subl、calll末尾的l表示long,这代表作为操作对象的数据和地址的长度为32位[插图]。
  • 当操作数有两个时,处理是按照从前往后的顺序进行的[插图]。
  • [插图]
  • 本机代码需要加载到内存后运行。本机代码中的指令和数据都存放在内存中,当程序运行时,CPU会从内存中读取指令和数据,并将其存入CPU内部的寄存器中进行处理,最后将结果写回内存(图10-2)。
  • [插图]
  • 寄存器是CPU内部的存储空间,但是寄存器的功能并不仅限于存储指令和数据,寄存器还可以参与运算。
  • 表10-2列出了32位x86架构CPU内部的主要寄存器的类型和功能[插图]。
  • 汇编语言源代码中,充当操作数的寄存器名前面会加上%[插图],如 %eax、%ebx等。内存中的空间是用地址来区分的,而CPU内部的寄存器则是用eax、ebx这样的名称来区分的。
  • [插图]
  • 32位x86架构CPU的寄存器的名称都是以e开头的,如eax、ebx、ecx、edx等,这是因为16位x86架构CPU中对应的寄存器的名称分别为ax、bx、cx、dx,这个e表示扩展(extended)。另外,64位x86架构CPU的对应寄存器的名称分别是rax、rbx、rcx、rdx,都以r开头,这里的r代表寄存器(register)。

10.5 最常用的movl指令

  • 用于向寄存器和内存存放数据的movl指令可以说是最常用的指令。movl指令有两个操作数,分别表示数据取出和存放的目标位置。操作数可以是数值、标签(命名的地址)、寄存器名,我们也可以在它们的左右两边加上圆括号 ( )[插图]来使用。
  • 操作数左右两边没有括号时,表示直接处理这个数值,有括号时表示将括号中的值作为内存地址来解释,并对该内存地址进行读写。
  • 当括号前面有数值时,movl指令会将这个数值与括号内的地址相加。
  • movl $ 456, 4(%esp)movl $123, (%esp)movl $456, 4(%esp)这条指令表示将456这个数值存入esp寄存器的值再加4所代表的内存地址中。
  • movl的l表示其操作对象数据的长度为32位,也就是说,从104地址开始的32位 =4字节空间会用来存放数据[插图]。
  • 当需要直接指定一个数值时,需要在数值的前面加上$ 符号,例如这里的 $456[插图]。
  • movl $123,(%esp)这条指令表示将123这个数值存入esp寄存器的值所代表的内存地址中。
  • 假如我们把这条指令中的括号去掉,变成movl $123, %esp,这条指令就代表将123这个数值存入esp寄存器中。

10.6 将数据存入栈中

  • 程序在运行时会分配一块名为栈的内存空间。
  • 数据在栈中是从下(编号较大的地址)往上(编号较小的地址)堆积起来,然后从上往下取出的。
  • esp寄存器(栈指针寄存器)会记录当前栈顶数据的内存地址(图10-3)。
  • [插图]
  • 栈是临时存放数据的内存空间,我们马上会讲到的函数调用,以及本章后半部分会讲到的局部变量,都会使用栈来存放数据。
  • 当需要在栈中存放多个数据时,需要以esp寄存器所指向的地址为起点,计算出数据应该存放在哪个地址,然后将数据写入该地址。
  • movl $456, 4(%esp)这条指令就表示将456这个数值写入从esp寄存器所指向的地址起向后4字节的地址中。而movl 8(%esp), %eax这条指令表示将从esp寄存器所指向的地址起向后8字节的地址中的值读取出来并存入eax寄存器中。

10.7 函数调用的工作原理

  • 代码清单10-3 函数调用的汇编语言源代码_MyFunc: # MyFunc函数的入口 ——————————————(1) subl $ 8, %esp # 将 esp的值减 8 ———————————————(2) movl $456, 4(%esp) # 将 456 存入 esp+4 地址 ————————————(3) movl $123, (%esp) # 将 123 存入 esp地址 —————————————(4)
  • calll _AddNum # 调用 _AddNum ————————————————(5) addl $8, %esp # 将 esp的值加 8 ———————————————(6) ret # 返回函数被调用的位置 —————————————(7)
  • (1)处的 _MyFunc: 是表示函数入口的标签。标签的格式是“标签名:”,当程序运行时这些标签会被替换成相应的内存地址。
  • 函数名的前面加下划线(_)是BCC32编译器的规定。
  • 标签不是指令,它只用来表示某个位置。当调用函数时,我们就可以指定要调用的函数的入口位置的标签作为call指令的操作数。
  • (2)处的subl $ 8, %esp表示将esp寄存器的值减8,也就是在栈中分配一块长度为8字节的内存空间。(3)处的movl $456, 4(%esp)表示将456这个值存入已分配内存空间的esp寄存器的值 +4所代表的地址中。(4)处的movl $123,(%esp)表示将123这个值存入已分配内存空间的esp寄存器的值所代表的地址(esp寄存器值 +0的地址)中。456和123都是传递给AddNum函数的参数。我们可以看出,参数就是通过栈空间来传递的。
  • (5)处的calll _AddNum表示调用 _AddNum: 标签所在位置的函数。当我们在程序任意位置设置标签时,需要在标签名的末尾加上一个冒号(:),如 _AddNum:,但在call指令的操作数中,标签是不加冒号的,如calll _Addnum。
  • 执行call指令时,指向call指令的下一条指令的内存地址(也就是函数返回的目标地址)会被自动保存到栈中,esp寄存器的值也会随之更新。这里,call指令的下一条指令是(6)处的addl $ 8, %esp,因此这条指令的内存地址会被保存到栈中。读取这个内存地址,程序就可以从被调用的函数返回到(6)处的addl $8, %esp位置继续执行。
  • (6)处的addl $ 8, %esp表示将esp寄存器的值加8,也就是将在栈中分配的8字节的内存空间释放出来。在函数入口(2)处的subl $8, %esp分配的内存空间,需要在函数执行完毕时释放,这就是我们在第5章提到的栈清理操作。
  • (7)处的ret表示调用AddNum函数的MyFunc函数执行完毕。MyFunc函数也会被其他函数调用,因此最后执行完毕后会返回被调用的位置。
  • 上面这些就是函数调用的工作原理。其中的重点是将参数和返回地址保存在栈中。
  • [插图]
  • 调用AddNum函数时栈的内容

10.8 被调用函数的工作原理

  • 下面我们通过AddNum函数的汇编语言源代码看一看函数接收参数和传递返回值的原理。在这个过程中,栈和eax寄存器发挥了很大的作用。
  • 被调用函数的汇编语言源代码_AddNum: # AddNum函数入口 ———————————————(1) movl 8(%esp), %eax # 将 esp+8 地址处的值存入 eax —————————(2) addl 4(%esp), %eax # 将 esp+4 地址处的值累加到 eax ————————(3) ret # 返回函数被调用的位置 ——————————————(4)
  • (1)处的 _AddNum: 是表示函数入口的标签。在使用calll _AddNum指令调用函数时,栈的状态如图10-4的左侧所示。现在esp所指向的位置存放的是返回目标地址,而地址的长度是32位 =4字节,因此参数123位于esp + 4地址,参数456位于esp + 8地址。
  • (2)处的movl 8(%esp), %eax表示将esp + 8地址处的值456存入eax寄存器。eax寄存器(累加器)的主要功能是参与运算。
  • (3)处的addl 4(%esp), %eax表示将esp + 4地址处的值123与eax寄存器中的值相加,此时eax寄存器中的值为456与123的和579。BCC32中规定,函数的返回值保存在eax寄存器中,因此在此时eax寄存器的值就是函数的返回值。
  • (4)处的ret表示AddNum函数执行完毕,流程跳转到调用方MyFunc函数。ret指令会从esp寄存器所指向的地址取出返回目标地址(在这里就是MyFunc函数的calll _AddNum指令的下一条指令的地址)[插图],从而让流程返回函数被调用的位置。
  • 以上就是被调用函数的工作原理,其中的重点是从栈中取出参数并进行运算,将返回值存入eax寄存器,以及从栈中取出返回目标地址并让流程返回。

10.9 全局变量和局部变量的工作原理

  • 全局变量和局部变量的工作原理
  • 在函数外部声明的变量称为全局变量,在函数内部声明的变量称为局部变量。全局变量(global variable)可以在程序的所有函数中访问,而局部变量(local variable)只能在声明它的函数中访问。
  • // 全局变量声明int x = 123;int y = 456;// 使用全局变量和局部变量的函数int MyFunc() { int a; a = x + y; return a;}
  • 我们将list10_5.s中重点部分的代码抽出来,并为每一行添加注释,结果如代码清单10-6所示。其中冗余代码用灰色字体表示,这些代码的功能是将寄存器的值暂存到栈,或是从栈恢复寄存器的值[插图]。
  • 代码清单10-6 代码清单10-5转换成汇编语言的结果(节选) .section _TEXT,“xr” # 指令段开始 ————————————————(1)_MyFunc: # MyFunc函数的入口 ————————————(2) pushl %ebp # 将 ebp的值暂存到栈 movl %esp, %ebp # 将 esp的值存入 ebp ———————————(3) pushl %eax # 将 eax的值暂存到栈 movl _x, %eax # 将 _x的值存入 eax ————————————(4) addl _y, %eax # 将 _y的值累加到 eax ———————————(5) movl %eax, -4(%ebp) # 将 eax的值存入 ebp-4 地址 ————————(6) movl -4(%ebp), %eax # 将 ebp-4 地址的值存入 eax addl $4, %esp # 将 esp加 4 popl %ebp # 从栈中恢复 ebp的值 ret # 返回函数被调用的位置 ———————————(7) .section _DATA,“w” # 数据段开始 ————————————————(8)_x: # 全局变量 x的标签—————————————(9) .long 123 # 全局变量 x的值_y: # 全局变量 y的标签—————————————(10) .long 456 # 全局变量 y的值
  • 编译后的程序会被分成段。在上面的程序中,我们可以看到一个存放指令的段和一个存放数据的段。
  • (1)处的 .section _TEXT, “xr” 后面的部分是指令段,(8)处的 .section _DATA, “w” 后面的部分是数据段。存放数据的段被命名为 _DATA,属性为 “w”,所以是可写的。数据段中的(9)和(10)分别是 _x: 和 y: 两个标签,它们代表全局变量x和y。变量名前面有一个下划线()是BCC32编译器的规定。.long 123和 .long 456是两条伪指令,表示在这个位置上存放两个32位的值123和456。伪指令是面向汇编器的指示,所以汇编器会在生成本机代码时将123和456这两个值附加在程序后面。
  • 全局变量就是事先附加在程序数据段的数据。当程序运行时,指令段和数据段会被一起加载到内存中,并在程序运行过程中一直驻留内存,因此程序中所有的函数都可以访问全局变量。
  • 局部变量则是在调用函数时,由函数的代码临时存入栈中的。
  • 下面我们再来看看代码清单10-6的(2)处 _MyFunc: 后面的MyFunc函数的具体内容。(3)处的movl %esp, %ebp表示将栈顶指针esp寄存器的值存入ebp寄存器。通过这条指令,我们就可以使用ebp寄存器对栈空间进行读写。当然我们也可以直接使用esp寄存器来读写栈空间。大家或许会觉得这里的代码是冗余的,有这样的感觉是因为我们开启了生成冗余代码的选项。
  • MyFunc函数会将全局变量x和y的和赋值给局部变量a,并将a作为返回值返回。BCC32规定函数的返回值必须放在eax寄存器中,因此变量a的值保存在eax寄存器中。
  • (4)处的movl _x, %eax和(5)处的addl _y, %eax两条指令表示将变量x和变量y的和存入eax寄存器。此时,由于计算结果已经位于eax寄存器中,所以不需要赋值给变量a,但因为我们指定了生成冗余代码的编译选项,所以这里生成了一些无用的代码。
  • 请大家注意(6)处的movl %eax, - 4(%ebp),这条指令表示将eax寄存器的值存入栈顶指针ebp寄存器的值减去4所对应的地址[插图]。ebp寄存器的值减去4所对应的地址,正是局部变量a的内存地址(图10-5)。
  • [插图]
  • 局部变量是在函数执行过程中存放在栈中的。在代码清单10-6中,(7)处的ret指令让流程返回函数被调用的位置。此时栈中的局部变量值并没有被清理,当出于其他目的再次使用栈空间时,这个值会被覆盖掉。局部变量只能在其被声明的函数中使用,这一点是毫无问题的。

10.10 循环的工作原理

  • 在循环和条件分支的实现上,尚未介绍的比较指令和转跳转指令发挥了很大的作用。
  • 执行循环的C语言源代码// MySub函数的定义void MySub(){ // 不执行任何操作}// MyFunc函数的定义void MyFunc(){ int i; for (i = 0; i < 10; i++)
  • { // 循环调用 10 次 MySub函数 MySub(); }}
  • 将代码清单10-7另存为list10_7.c文件,然后在命令提示符中输入以下命令并按下回车键。在这里我们依然指定了用于生成冗余代码的“-Od”选项。bcc32c -c -Od -S list10_7.c
  • 在C语言的for语句中,圆括号中的3个表达式分别表示循环变量初始化(i = 0)、循环执行条件(i < 10)以及循环变量更新(i++),花括号({ })中的部分为循环实际执行的操作(循环体)。与之相对,在汇编语言源代码中,循环是通过比较指令和跳转指令来实现的。
  • 代码清单10-7转换成汇编语言的结果(节选) movl $ 0, -4(%ebp) # 将 0 存入循环变量 ————————————(1)LBB1_1: # 表示循环体入口的标签 ——————————(2) cmpl $10, -4(%ebp) # 将 10 与循环变量的值进行比较 ——————(3) jge LBB1_4 # 若 10 ≤ 循环变量的值则跳转到 LBB1_4 ———(4) calll _MySub # 调用 MySub函数 —————————————(5) movl -4(%ebp), %eax # 将循环变量的值存入 eax寄存器 ——————(6) addl $1, %eax # 将 eax寄存器的值加 1 ——————————(7) movl %eax, -4(%ebp) # 将 eax寄存器的值存入循环变量 ——————(8) jmp LBB1_1 # 无条件跳转到 LBB1_1 ———————————(9)LBB1_4: # 表示循环结束的标签 ————————————(10)
  • [插图]
  • 操作码末尾的l=long,表示作为操作对象的数据和地址的长度为32位。
  • 这个程序中使用的局部变量只有一个i,它是用于计算循环次数的循环变量。(1)之前的部分在节选的代码中没有展示,其实在(1)之前我们已经将esp寄存器的值存入了ebp寄存器中,因此现在我们可以通过ebp寄存器来访问栈空间。在(1)处的movl $0, -4(%ebp)中,-4(%ebp)就是为局部变量i分配的空间,因此这条命令会将循环变量i初始化为0。
  • (2)处的LBB1_1: 和 (10)处的LBB1_4: 分别定义了两个用于表示跳转指令跳转目标的标签。(3)处的cmpl $10, -4(%ebp)表示将10与循环变量的值进行比较,比较结果会存入CPU内部的标志寄存器。(4)处的jge LBB1_4表示当标志寄存器中保存的比较结果为“大于等于”(greater or equal)时,程序则跳转到LBB1_4标签处。在 (4)之前,我们已经将10与循环变量的值进行了比较,因此当“10≤循环变量的值”时[插图],程序就会跳转到LBB1_4标签处。于是,当循环变量的值在0~9时,程序会循环调用MySub函数,当循环变量的值到达10时,会跳出循环。(4)处指令的功能是判断是否要跳出循环。
  • 当(4)处的判断结果为不跳转(继续执行循环)时,程序就会来到(5)处。(5)处的calll _MySub表示调用MySub函数,从函数返回后继续执行(6)。(6)处的movl -4(%ebp), %eax表示将当前循环变量的值存入eax寄存器,(7)处的addl $1, %eax表示将eax寄存器的值加1,(8)处的movl %eax, -4(%ebp)表示将eax寄存器的值存入循环变量。(6)~(8)这几条指令有点绕,其实就是把循环变量的值加1。
  • (9)处的jmp LBB1_1表示无条件(不查询标志寄存器)跳转到LBB1_1标签处。因为是从(9)回到(2),所以也就是继续执行下一次循环。当循环变量的值到达10时,程序就会通过(4)处的jge LBB1_4指令结束循环。
  • C语言中的for循环在计算机内部就是这样通过比较指令和跳转指令来实现的,是不是感觉和C语言的for语句差别很大呢?如果将代码清单10-8的汇编语言源代码按照同样的流程改写为C语言源代码,就是代码清单10-9的样子。C语言的goto语句表示跳转到指定的标签。
  • i = 0; // 将 0 赋值给循环变量LBB1_1: // 循环入口标签if (10 <= i) // 判断 10 ≤ i是否成立{ goto LBB1_4; // 判断结果为真则跳转到 LBB1_4}MySub(); // 调用 MySub函数i++; // 将循环变量的值加 1goto LBB1_1; // 无条件跳转到 LBB1_1LBB1_4: // 表示循环结束的标签
  • 汇编语言是描述CPU工作流程的低级编程语言,而C语言是更符合人类习惯的高级编程语言

10.11 条件分支的工作原理

  • 代码清单10-10是一段C语言程序,它的功能是当局部变量a的值大于100时调用MySubA函数,否则调用MySubB函数。C语言的条件分支是通过if语句来描述的,程序中调用的两个函数都是空函数。
  • // MySubA函数的定义void MySubA(){ // 不执行任何操作}// MySubB函数的定义void MySubB(){ // 不执行任何操作}// MyFunc函数的定义void MyFunc(){ int a = 123; // 根据条件调用对应的函数 if (a > 100) { MySubA(); }
  • else { MySubB(); }}
  • 代码清单10-10转换成汇编语言的结果(节选) movl $ 123, -4(%ebp) # 将 123 存入局部变量 ———————————(1) cmpl $100, -4(%ebp) # 比较 100 与局部变量的值 —————————(2) jle LBB2_2 # 如果 100 ≥ 局部变量的值则跳转到 LBB2_2 —(3)calll _MySubA # 调用 MySubA函数 ————————————(4) jmp LBB2_3 # 无条件跳转到 LBB2_3 ———————————(5)LBB2_2: # 跳转目标标签 ——————————————(6)calll _MySub B # 调用 MySubB函数 ————————————(7)LBB2_3: # 跳转目标标签 ——————————————(8)
  • 这个程序中使用的局部变量只有一个a,程序会将a的值与100进行比较并执行条件分支。(1)之前的部分在节选的代码中没有展示,其实在(1)之前我们已经将esp寄存器的值存入ebp寄存器了,因此现在我们可以通过ebp寄存器来访问栈空间。(1)处的movl $ 123, - 4(%ebp)中,- 4(%ebp)就是为局部变量a分配的空间,这里我们将它赋值为值123。(6)处的LBB2_2: 和 (8)处的LBB2_3: 分别定义了两个用于表示跳转指令跳转目标的标签。(2)处的cmpl $100, - 4(%ebp)表示将100与局部变量a的值进行比较,比较结果会存入CPU内部的标志寄存器。(3)处的jle LBB2_2表示当标志寄存器中保存的比较结果为“小于等于”(less or equal)时,程序则跳转到LBB2_2标签处。跳转目标 (6)LBB2_2后面的指令是 (7),即calll _MySubB,表示调用MySubB函数。
  • 当 (3)处的判断结果为不跳转时,程序会来到 (4)处。(4)处的calll _MySubA表示调用MySubA函数。从函数返回后,程序继续执行 (5)。(6)处的jmp LBB2_3表示无条件跳转到 (8)LBB2_3处,如果没有这条跳转指令的话,程序就会依次执行 (6)和 (7),(7)处的calll _MySubB指令又会调用MySubB函数(这是错误的)。
  • 在C语言源代码中,我们指定当if (a > 100),即“变量a的值大于100”这个条件为真时调用MySubA函数,否则调用MySubB函数。与之相对,在汇编语言源代码中,当“变量a的值小于等于100”这个条件为真时调用MySubB,否则调用MySubA。二者对条件的描述是相反的。这是因为汇编语言中只有“条件为真时跳转”这种描述形式。
  • 将代码清单10-11中的流程用C语言描述a = 123; // 将 123 赋值给局部变量if (100 >= a) // 判断 100 ≥ 局部变量的值是否成立{ goto LBB2_2; // 判断结果为真则跳转到 LBB2_2}MySubA(); // 调用 MySubA函数goto LBB2_3; // 无条件跳转到 LBB2_3LBB2_2: // 跳转目标标签MySubB() // 调用 MySubB函数LBB2_3: // 跳转目标标签

10.12 体验汇编语言的意义

  • 体验过汇编语言的程序员就像精通汽车工作原理的驾驶员,不但能够自己解决问题,开起车来油耗也更低。这就是体验汇编语言的意义。
  • 已经掌握C语言的读者,不妨自己动手编写一些简短的程序,研究一下C语言的各种语法转换成汇编语言之后会变成什么样子。笔者个人感觉通过这样的体验大大提升了自己的编程技术。
  • 汇编语言的语法分为AT&T和Intel两种格式。BCC32生成的汇编语言源代码采用了AT&T格式。AT&T和Intel格式在 % 和 $ 的有无、操作数的顺序、注释写法等方面有差别。例如,分别用两种格式来描述将123存入eax寄存器的指令就是下面这样。  # AT&T格式   movl $123, %eax # 将前面的 123 存入后面的 eax   ; Intel格式   mov eax, 123 ; 将后面的 123 存入前面的 eax

第11章 访问硬件的方法

  • 汇编语言中用于外部设备输入输出的指令是什么?I/O的全称是什么?用于区分外部设备的编号叫什么?IRQ的全称是什么?DMA的全称是什么?用于区分使用DMA的外部设备的编号叫什么?
  • in指令和out指令Input/Output(输入 / 输出)I/O地址或I/O端口号Interrupt Request(中断请求)Direct Memory Access(直接内存访问)DMA通道(DMA channel)
  • 在用于x86架构CPU的汇编语言中,用in指令进行I/O输入,用out指令进行I/O输出。负责在计算机主机与外部设备之间进行输入输出的芯片称为I/O控制器,简称I/O。为了区分连接到计算机上的不同外部设备,每个设备会被分配一个I/O地址。IRQ是指用于区分发出中断请求的外部设备的编号。DMA是指外部设备不经过CPU中转,直接与计算机内存传输数据。网络、磁盘等数据量大的外部设备会使用DMA,不同设备会通过DMA通道来进行区分。
  • 前面的章节中,我们了解了要访问CPU这个硬件,需要使用编译器或者汇编器生成相应的本机代码,再将其加载到内存中运行。那么,我们又该如何访问CPU和内存之外的硬件设备呢?本章就来解答这个疑问。

11.1 应用程序是否与硬件有关

  • 在使用C语言等高级编程语言编写Windows应用程序时,很少会见到直接访问硬件的指令。这是因为对硬件的访问已经由Window操作系统一手包办了。
  • 2025/07/29 发表想法:Application Programming Interface,应用程序编程接口

操作系统还是为应用程序提供了间接访问硬件的方法,那就是使用系统调用。在Windows中,系统调用也被称为API(图11-1)。每个API都是一个能够被应用程序调用的函数,这些函数的本体位于DLL文件中。

  • [插图]
  • BOOL TextOut( HDC hdc, // 设备上下文句柄
  • int nXStart, // 显示字符串的 x坐标 int nYStart, // 显示字符串的 y坐标 LPCTSTR lpString, // 指向字符串的指针 int cbString // 字符串长度);

11.2 负责硬件输入输出的in指令和out指令

  • Windows使用输入输出指令来对硬件进行访问[插图],其中最具代表性的两个指令就是in和out。这两个指令都是汇编语言的助记符,但应用程序并不能直接使用in和out指令,因为Windows禁止应用程序直接访问硬件。
  • 这是x86架构CPU的in指令和out指令的语法。in指令可以从指定编号的端口输入数据,并将其存入CPU内部的寄存器。out指令可以将CPU寄存器中的数据输出到指定编号的端口。
  • [插图]
  • 计算机主机上有用于连接显示器、键盘等外部设备的接口,这些接口内部都装有用于对主机和外部设备的电信号进行相互转换的芯片[插图],这些芯片统称为I/O控制器(简称为I/O)。由于数据格式和电压不同,所以计算机主机并不能和外部设备直接相连,为此我们需要使用I/O控制器。
  • I/O控制器中有用于临时存放输入输出数据的存储器,这种存储器被称为端口。
  • I/O控制器内部的存储器有时也被称为寄存器,但这种寄存器和CPU内部的寄存器功能不同。CPU内部的寄存器可以参与运算,但I/O控制器内部的寄存器基本上只能用来临时存放数据。
  • I/O控制器芯片中有多个端口。计算机可以连接多个外部设备,于是就有多个I/O控制器,也就有多个端口。
  • in指令和out指令通过端口号可以在指定端口和CPU之间输入和输出数据,这与通过内存地址来读写内存是一样的(图11-3)。
  • [插图]
  • 在属性对话框中点击“资源”[插图]选项卡,在“资源设置”的“I/O范围”右侧显示的数值就是端口号(图11-4)。只要在in指令和out指令中指定这个端口号,就可以访问显示器的I/O控制器,完成输入输出操作。
  • [插图]

11.3 外部设备的中断请求

  • IRQ是一种让当前正在运行的程序暂停,转而运行其他程序的机制,这被称为中断处理。中断处理在硬件控制中扮演着重要的角色。如果没有中断处理,有些任务就无法顺利进行。
  • 在进行中断处理时,被中断的程序(主程序)会暂停运行,直到中断处理程序运行完毕。这有点像在办公室处理文件时有电话打进来的情况,接听电话就相当于中断处理。如果没有中断功能的话,那么只能等到文件处理完之后再接听电话,这太不方便了,中断处理的价值就体现在这里。
  • 中断处理程序运行完毕之后,被暂停的主程序就可以恢复运行(图11-5)。
  • 发出中断请求的是连接外部设备的I/O控制器,运行中断处理程序的是CPU。要识别具体是哪个设备发出的中断请求,我们需要使用名为中断号的编号,而不是端口号。
  • 设备管理器属性的IRQ项目中显示的0xFFFFFFF7(- 9),就表示由显示器发出的中断请求编号为0xFFFFFFF7(- 9)。
  • 在设备管理器的“查看”菜单中选择“依类型排序资源”,点击并展开“中断请求 (IRQ)”就可以查看设备和中断号的清单了(图11-6)。
  • [插图]
  • 如果多个外部设备同时发出中断请求,CPU就会陷入混乱。因此在I/O控制器和CPU之间还有一个中断控制器进行协调。中断控制器会将来自多个外部设备的中断请求依次交给CPU来处理(图11-7)。
  • [插图]
  • 中断处理程序要做的第一件事,就是将CPU中所有寄存器的值都暂存到内存的栈空间中。当中断处理程序完成与外部设备之间的输入输出操作之后,最后还要将暂存到栈中的值恢复到寄存器中,继续运行主程序。如果不将CPU寄存器的值恢复到中断处理之前的状态,主程序的运行就会受到影响,在最坏的情况下程序会卡死或者出现混乱,导致系统崩溃。
  • 在主程序运行过程中,一定会出于某种目的使用CPU内的寄存器,如果这时突然切换到另一个程序,在中断处理结束后所有寄存器的值就必须恢复到中断前的状态,因为只要寄存器的值没变,主程序就可以像什么事都没有发生过一样继续运行(图11-8)。
  • [插图]

11.4 通过中断实现实时处理

  • 几乎所有的外部设备都会频繁地发出中断请求,这是因为外部设备输入的数据需要实时进行处理。当然,不使用中断也可以从外部设备输入数据,但在这种情况下主程序就需要不断查询外部设备有没有要输入的数据。
  • 外部设备的数量很多,因此需要依次查询。依次查询多个外部设备状态的操作称为轮询(polling)。
  • 如果在查询鼠标有没有输入数据的时候按下了键盘会怎样呢?输入的字符就无法实时显示在屏幕上了。实际上,使用中断来处理键盘输入,就可以将输入的字符实时显示在屏幕上了。
  • 2025/07/30 发表想法:四种I/O方式:
    • 程序直接控制方式(需要CPU不断轮询检查,i/o设备的状态,CPU利用率很低)。
    • 中断处理方式(当数据处理完毕后,I/O控制器会向CPU发送中断信号,CPU在自动保存当前程序运行现场环境后,转向内核态运行中断处理程序,也就是将数据从内存中输出或者输入内存)这两种方式的主要缺点是,都需要通过CPU(寄存器)的中转才能到达内存,其次就是两者每次都只能传输一个字的数据,数据传输速率低。
    • DMA方式(直接存储器存取)。
    • 通道方式。

如果仅当CPU收到中断请求时才发送数据,主程序就不必一直去查询设备的状态,CPU就可以有更多的时间来运行其他程序了。中断处理可真方便。

11.5 能够快速传输大量数据的DMA

  • DMA。DMA是指外部设备不经过CPU中转,直接和内存进行数据传输,常用于网络、磁盘等设备。
  • 使用DMA可以将大量数据快速传输到内存中,它能够节省CPU中转所需的时间,而且还可以避免高速的CPU等待低速的外部设备,从而提高其他任务的处理效率。
  • DMA是通过名为DMA控制器(DMA Controller,DMAC)的芯片实现的。DMA控制器中有多个用于进行DMA的窗口,这些窗口通过名为DMA通道的编号来进行区分,进行DMA的外部设备也是通过分配给它们的DMA通道来进行区分的。
  • 通过CPU在外部设备和内存之间传输数据的方式称为PIO(Programmed I/O)。
  • [插图]
  • I/O端口号、IRQ和DMA通道可以说是识别外部设备的“三件套”。但是,IRQ和DMA通道并不是每个外部设备都必备的。计算机主机通过软件访问硬件所必需的信息其实只有I/O端口号。只有需要进行中断处理的外部设备才需要IRQ,只有需要进行DMA的外部设备才需要DMA通道。
  • 如果多个设备被设置为同一个I/O端口号、IRQ或DMA通道,计算机就无法正常工作。出现这种情况时,设备管理器中就会出现“设备冲突”的提示,这里的冲突就是使用了相同编号的意思。

11.6 显示字符和图像的原理

  • 计算机中有一个用于保存要显示的信息的存储器,这一存储器称为显存(Video RAM,VRAM)。程序只要将数据写入显存,数据就可以在显示器上显示出来。
  • [插图]
  • 2025/07/31 发表想法:计算机所能完成的工作,无非是从外部设备输入数据,将数据存入内存,用CPU进行运算,然后将数据输出到外部设备,这些东西从未改变。程序的内容究其根本也只是数据的输入、存储、运算和输出而已[插图]。无论是计算机还是程序,其本质都是十分简单的。

在计算机世界中,新的技术不断涌现,但计算机所能完成的工作,无非是从外部设备输入数据,将数据存入内存,用CPU进行运算,然后将数据输出到外部设备,这些东西从未改变。程序的内容究其根本也只是数据的输入、存储、运算和输出而已[插图]。无论是计算机还是程序,其本质都是十分简单的。

第12章 如何让计算机“学习”

  • 什么是机器学习(Machine Learning,ML)?分类问题是机器学习的主题之一,那么什么是分类问题呢?SVM是一种机器学习算法,它的全称是什么?为什么在机器学习领域经常使用Python?在分类问题的机器学习中,学习器和分类器分别是什么?机器学习中的cross validation中文叫什么?
  • 让计算机自己进行学习对数据进行正确的识别和分类支持向量机(Support Vector Machine)因为Python提供了很多机器学习相关的库,我们可以通过解释器方便地使用这些功能学习算法和学习好的模型交叉验证
  • 在分类问题的机器学习中,我们将学习算法称为学习器,将作为学习结果得到的模型称为分类器。模型就是用于识别的机制。
  • 交叉验证是一种不断轮换编写学习器所使用的训练数据和分类器所使用的测试数据来进行机器学习的方法。由此,我们可以检验学习模型的识别率是否存在因学习数据的类型而出现偏差的情况。

12.1 什么是机器学习

  • 有监督学习就是给计算机提供大量带正确答案的数据。以识别手写数字为例,我们可以给计算机提供大量手写数字的图片,并为每张图片配上它所代表的0~9中的正确数字。这里的正确答案就充当了“监督者”的角色。有监督学习适用于手写数字识别这样的分类问题[插图]领域。
  • [插图]
  • 使用训练数据,通过学习算法让计算机进行学习并生成模型。

12.2 支持向量机

  • 为了使用图12-2中的图来识别猫和狗,我们可以在图上画出一条边界线。这条边界线需要让位于边界附近的猫和狗的数据点和边界线保持尽量大的间隔(距离)。这些位于边界线附近的猫和狗的数据点称为支持向量,支持向量机算法就用来求出与支持向量的间隔最大的边界线。
  • 我们假设这条边界线为一条用y = ax + b表示的直线[插图]。支持向量机的学习器就可以根据提供的学习数据,求出上述表达式中的a和b。
  • [插图]
  • 一个数据所拥有的用于分类的信息称为特征量,特征量的数量称为维数。

12.3 Python交互模式的使用方法

  • Python是一种基于解释器[插图]的语言,这意味着我们可以用简短的程序来试验这些库的功能。
  • Python运行程序的方法分为两种,一种是用Python解释器对事先编写好的源代码进行解释执行的脚本模式(script mode),另一种是直接启动Python解释器,通过键盘逐行输入程序并解释执行的交互模式(interactive mode)。我们会在后面体验机器学习的过程中使用交互模式。
  • Python中可供程序使用的各种功能都以函数或对象的形式来提供。函数一般提供单一功能,对象一般提供复合功能。使用对象功能的语法是“对象名 . 功能名”。Python标准的内置函数和对象是可以直接使用的,但机器学习中使用的特殊函数和对象,需要通过import命令导入后使用。
  • 模块就是包含多个函数和对象的文件。

12.4 准备学习数据

from sklearn import datasets ———————————————————————(1)>>> digits = datasets.load_digits() ——————————————————————(2)>>> dir(digits) ————————————————————————————————(3)[‘DESCR’, ‘data’, ‘feature_names’, ‘frame’, ‘images’, ‘target’, ‘target_names’]

  • (3)处使用Python内置的dir函数提取出变量digits的数据集中所包含的字段。显示结果中的DESCR、data等就是字段的名称。
  • DESCR是数据集的描述(description)。data是手写数字的图像数据。images是将手写数字图像数据按8行8列格式化后的数据。target是手写数字的答案数据。target_names是答案数据的含义(这里是数字0~9)。

12.5 查看手写数字数据的内容

  • (3)处从matplotlib模块中导入pyplot对象,并给它设置了较短的别名plt。(4)处将digits.images[1234] 的内容以灰度[插图]形式绘制出来。(5)处将绘制好的图像显示出来。

from sklearn import datasets ———————————————————(1)>>> digits = datasets.load_digits() ——————————————————(2)>>> import matplotlib.pyplot as plt ——————————————————(3)>>> plt.imshow(digits.images[1234], cmap=“Greys”) ———————————(4)<matplotlib.image.AxesImage object at 0x00000290C7707190>>>> plt.show() ————————————————————————————(5)

  • [插图]

12.6 通过机器学习识别手写数字

  • 机器学习的步骤(1)将学习数据和答案数据划分为训练数据和测试数据(2)用学习算法学习训练数据并生成模型(3)用测试数据评估模型的性能

from sklearn import datasets ———————————————————— (1)>>> digits = datasets.load_digits() —————————————————— (2)>>> from sklearn.model_selection import train_test_split ———————— (3)>>> d_train, d_test, t_train, t_test = \ ———————————————— (4)… train_test_split(digits.data, digits.target, train_size=2/3)>>> from sklearn import svm ——————————————————————— (5)>>> clf = svm.SVC() ——————————————————————————— (6)>>> clf.fit(d_train, t_train) —————————————————————— (7)SVC()>>> clf.score(d_test, t_test) —————————————————————— (8)0.9803600654664485

  • train_test_split(digits.data, digits.target, train_sizet 2/3)表示将手写数字图像数据digits.data和答案数据digits.target按照2/3的比例(train_size = 2/3)随机分割出一部分生成训练数据。随机分割后的数据会依次赋值给左边的d_train、d_test、t_train、t_test变量(表12-1)。
  • [插图]
  • (5)处从sklearn模块中导入了svm对象(svm代表support vector machine,也就是支持向量机)。svm对象提供了支持向量机相关的各种功能。
  • (6)处使用svm对象的SVC方法(SVC = SVM Classification,用支持向量机分类)来生成学习器对象,并将其命名为clf(clf = classifier,分类器的意思)。
  • (7)处以训练数据d_train和t_train为参数,调用学习器对象的fit方法,这样计算机就会进行机器学习并生成模型(分类器)。学习得到的模型保存在学习器对象内部。
  • (8)处以测试数据d_test和t_test为参数,调用学习器对象的score方法。由此对学习得到的模型进行性能评估,得到模型的识别率(测试数据中识别正确的百分比)。运行后的结果显示识别率为0.9803600654664485(约98%)。由于训练数据和测试数据是随机分割的,所以每次运行程序所得到的识别率会有所不同。

12.7 尝试交叉验证

  • 交叉验证是一种不断轮换训练数据和测试数据来进行机器学习的方法。
  • 由此,我们可以检验学习模型的识别率是否存在因学习数据的类型而出现偏差的情况。
  • [插图]
  • (5)处从sklearn.model_selection模块导入了cross_val_score函数。
  • (6)处使用cross_val_score函数进行三轮交叉验证。cross_val_score函数的参数分别为学习器clf、手写数字图像数据digits.data、答案数据digits.target,cv = 3表示交叉验证的轮数(数据分割的份数)。
  • 程序显示的运行结果为0.96494157、0.97996661、0.96494157,这表示三轮交叉验证得到的识别率分别约为96%、98%、96%,我们可以认为不存在因学习数据类型而出现较大偏差的情况。

from sklearn import datasets ————————————————— (1)>>> digits = datasets.load_digits() ———————————————— (2)>>> from sklearn import svm ———————————————————— (3)>>> clf = svm.SVC() ———————————————————————— (4)>>> from sklearn.model_selection import cross_val_score —————— (5)>>> cross_val_score(clf, digits.data, digits.target, cv=3) ———— (6)array([0.96494157, 0.97996661, 0.96494157])

  • 强化学习就是如果做得好就给他奖励的学习方法。

C语言的特点

  • 同样由AT&T贝尔实验室开发的UNIX操作系统,最早是用汇编语言编写的,但后来其大部分代码又重新用C语言编写,由此提高了UNIX的可移植性,使很多不同类型的计算机可以使用UNIX。UNIX系的操作系统Linux也是用C语言编写的。
  • Java和C# 并不是被全新设计的编程语言,它们都是在C语言的扩展语言C++(C Plus Plus)的基础上被设计出来的。因此,只要掌握了C语言,学习Java和C# 就会非常容易。此外,很多C编译器提供了将C语言源代码转换成汇编语言源代码的功能,而且C语言源代码中还可以混用汇编语言源代码,由此可见,C语言和汇编语言之间的相容性非常高。

变量与函数

  • 无论使用哪种编程语言,程序的内容都是由数据和操作构成的。
  • 在C语言中,数据用变量来表示,操作用函数来表示。因此,C语言的程序是由变量和函数构成的
  • x、y、z这样的变量,在数学上代表“某个值”,但在程序中则代表“数据的容器”。f(x)这样的函数,在数学上代表“由参数x确定的值”,但在程序中则代表“对变量x用函数f进行操作”。
  • 数学中的y = f(x)表达式表示“y是x的函数”,但在程序中则代表“将变量x用函数f进行操作的结果赋值给y”。数学中的等号代表等于,但等号在程序中代表赋值。C语言中如果要表示等于,需要使用两个等号(==)。
  • 标准函数库是可供程序使用的各种通用功能的函数集合。例如,从键盘输入数据的scanf,以及在屏幕上显示数据的printf等都是标准函数库中的函数。

数据类型

  • char、short、int都是用来表示整数的类型,float和double则是用来表示小数的类型。
  • [插图]
  • 在程序中使用变量(赋值、运算、显示等)时,需要事先声明变量的数据类型和变量名。
  • 在C语言中,每条语句的末尾需要加上分号(;)

输入、运算、输出

  • 函数的括号中不仅可以放变量,还可以放字符串和数值数据,它们都称为函数的参数。函数返回的处理结果称为返回值。
  • 计算机有4种基本操作:输入数据、运算数据、输出结果和存储数据。
  • int a, b, ave; // 声明 int型变量 a、b、avescanf(“%d”, &a); // 从键盘输入 ascanf(“%d”, &b); // 从键盘输入 bave = (a + b) / 2; // 将 a和 b的平均值赋值给 aveprintf(“%d\n”, ave); // 在屏幕上显示 ave的值

创建和使用函数

  • main是程序启动后执行的第一个函数。由多个函数构成的程序,在程序启动时也会先执行main函数,然后从main函数中调用其他函数,其他函数又可以调用别的函数,以此类推,形成一连串的函数调用。
  • 函数的内容用花括号 { 和 } 包围。由{ 和 } 包围的部分称为块,块就是一个整体的意思。
  • #include <stdio.h>int main(void) { int a, b, ave; // 声明 int型变量 a、b、ave scanf(“%d”, &a); // 从键盘输入 a scanf(“%d”, &b); // 从键盘输入 b ave = (a + b) / 2; // 将 a和 b的平均值赋值给 ave printf(“%d\n”, ave); // 在屏幕上显示 ave的值 return 0; // 返回 0}
  • int main(void)代表main函数的返回值为int型且没有参数,其中void是“空”的意思。表示没有参数的void是可以省略的,因此int main(void)也可以写成int main()。当void出现在函数名的前面时,如void func(),代表这个函数没有返回值,这时,void是不能省略的。
  • 开头的 #include <stdio.h> 表示引用stdio.h文件。include是“包含”的意思。stdio.h中含有标准函数printf和scanf的声明,这样的文件称为头文件(header files)。头文件的扩展名是header的首字母“.h”。各种标准函数库所需要的头文件已经随编译器一起安装好了。
  • #include <stdio.h>int average(int, int); // average函数原型声明int main(void) { int a, b, ave; // 声明 int型变量 a、b、ave scanf(“%d”, &a); // 从键盘输入 a scanf(“%d”, &b); // 从键盘输入 b ave = average(a, b); // 将 average函数的返回值赋值给 ave printf(“%d\n”, ave); // 在屏幕上显示 ave的值 return 0; // 返回 0}int average(int a, int b) { return (a + b) / 2; // 返回两个参数的平均值}
  • 编译器是按照从上往下的顺序来读取和编译源代码的,如果main函数中直接调用average函数,编译器就会因为找不到相应的函数而报错。因此,我们需要在源代码的开头加上int average(int, int); 告诉编译器“接下来需要一个名叫average、返回值为int型、有两个int型参数的函数”。这就是函数原型声明。

局部变量与全局变量

  • 在函数块内部声明的变量只能在该函数中使用,这样的变量称为局部变量。
  • 在函数块外部也可以声明变量(其他语句只能写在函数内部,但变量可以在函数外部声明),这样的变量称为全局变量。全局变量可以被程序中的所有函数使用,因此我们也可以使用全局变量将数据传递给其他函数。但是,如果在大型程序中过多地使用全局变量,就会让程序的内容变得复杂(不知道哪个函数使用了全局变量的值),请大家注意这一点。
  • #include <stdio.h>int average(void); // average函数原型声明int a, b; // 声明全局变量 a、bint main(void) { int ave; // 声明局部变量 ave scanf(“%d”, &a); // 从键盘输入 a scanf(“%d”, &b); // 从键盘输入 b ave = average(); // 将 average函数的返回值赋值给 ave printf(“%d\n”, ave); // 在屏幕上显示 ave的值 return 0; // 返回 0}int average(void) { return (a + b) / 2; // 返回两个参数的平均值
  • }

数组与循环

  • 数组就是对所有数据赋予同一个名称(数组名),从0开始按顺序对其中的每个数据编号(称为下标),以此来区分各个数据。
  • #include <stdio.h>int main(void) { int data[10]; // 声明包含 10 个元素的 int型数组 data int sum, ave, i; // 声明 int型变量 sum、ave、i sum = 0; // 将用来保存累加结
  • 果的变量清零 // 让 i在 0~ 9 的范围内重复,每次 +1 for (i = 0; i < 10; i++) { scanf(“%d”, &data[i]); // 从键盘输入 data[i] sum += data[i]; // 将 data[i] 的值累加到 sum } ave = sum / 10; // 将 sum除以 10 的结果赋值给 ave printf(“%d\n”, ave); // 在屏幕上显示 ave的值 return 0; // 返回 0}
  • int data[10]; 是数组的声明,意思是“准备一个包含10个元素的名为data的int型数组”。我们可以通过data[0]~data[9] 来访问这10个元素,这种表示方式就像在data身后贴上号码一样。数组中的每个元素,其用法和普通变量完全一样。
  • 在对数组进行操作时,通常的做法是在for的圆括号中让表示下标的变量(这里是i)从0开始每次加1。这样的变量称为循环变量。for(i=0; i<10; i++)的意思是“开始循环时将i的值设为0”“i < 10条件成立时循环继续执行”“每次循环结束后将i的值 +1”。

其他语法

  • ANSI将表A-2中的单词规定为C语言的保留字(reserved word,就是包含特殊意义的单词,也叫关键字)。
  • [插图]
  • [插图]
  • 学习任何编程语言的语法,都不能死记硬背,而是要反复亲手编写程序,观察运行结果,在这个过程中掌握编程语言。我们不能像记公式一样只记住语法的形式,要理解其具体的用法。懂语法但编写不出程序与懂英语的语法但无法用英语交流是一样的。无论是C语言还是英语,实践才是唯一有效的学习方法。
  • 在C语言的语法中,指针和结构体通常被认为是最难的部分,不过这里我们并没有学习这部分内容。要征服指针和结构体,就要关注它们具体的使用场景,并大量编写程序。
  • 在编写程序的时候,要在头脑中对程序的结果有一个预期,如果没有得到和预期相符的结果,要仔细思考原因并不断挑战。在这个过程中要反复盯着一段程序看,这样语法也就自然而然地印在脑中了。至于如何找出程序无法得出预期结果的原因,本书中介绍的CPU和内存原理的相关知识应该会派上用场。
  • 编程的本质是将程序员的思路用编程语言的语法表达出来,然后交给计算机来执行。

Python的特点

  • 在C语言中,我们需要用编译器将编写好的源代码转换成机器语言的可执行文件,然后运行这个可执行文件。在Python中,我们要用解释器读取并运行编写好的源代码,这称为脚本模式。我们还可以先启动Python解释器,然后逐行输入语句并执行,这称为交互模式。

一切皆对象

  • 在Python中,无论是数据还是操作,在内存中的实际形态都是对象,变量中存储的实际上是对象的识别信息。
  • 对象所拥有的数据和操作是通过类来定义的,类就相当于对象的数据类型。反过来说,对象就是类的实例(instance)。
  • 我们可以使用Python内置的type函数来查看一个对象是哪一个类的实例,可以使用id函数查看对象的识别信息,还可以使用dir函数查看对象所具有的功能(数据和操作)。

a = 123 # 将 123 赋值给变量 a>>> a # 查看对象的值123 # 值为 123>>> type(a) # 查看对象的类<class ‘int’> # int类>>> id(a) # 查看对象的识别信息140715393042016 # 识别信息为 140715393042016>>> dir(a) # 查看对象的功能[‘abs’, ‘add’, ‘and’, ‘bool’, ‘ceil’,‘class’, ‘delattr’, ‘dir’, ‘divmod’,‘doc’,( 中间省略 )‘as_integer_ratio’, ‘bit_length’, ‘conjugate’, ‘denominator’, ‘from_bytes’, ‘imag’, ‘numerator’, ‘real’, ‘to_bytes’] # 这个对象具有很多功能

  • 123并不是一个单纯的数值,而是int类的对象,具有很多功能。对象所具有的功能称为方法(method)。使用方法的语法是“对象名 . 方法名(参数)”。
  • bit_length方法可以返回数据用二进制表示时所需的比特数,例如123用二进制表示为1111011,一共7比特,因此结果会返回7。

a = 123 # 将 123 赋值给变量 a>>> a.bit_length() # 调用 a的 bit_length方法7 # 显示比特数

  • Python事先准备好的函数和类称为内置函数(built-in function)和内置类(built-in class,也称内置类型、内置对象)。
  • 在Python中,除了内置函数和内置类,我们还可以使用其他的函数和类,这些函数和类称为库。Python标准提供的库称为标准库,根据需要另外安装的库称为外部库(external library)。包含库程序的文件称为模块,当我们需要使用某个库时,可以使用“import模块名”语句导入指定的模块。

数据类型

  • Python的主要数据类型(类)如表B-1所示。如果是整数就使用int类,如果是小数就使用float类。
  • 在C语言中,变量在使用之前必须声明其数据类型和变量名,但在Python中,变量无须声明即可使用。这是因为任何变量在内存中都只用来储存对象的识别信息,变量本身的数据类型是相同的。

输入、运算、输出

  • a = input() # 从键盘输入 aa = int(a) # 将 a转换成整数b = input() # 从键盘输入 bb = int(b) # 将 b转换成整数ave = (a + b) / 2 # 将 a和 b的平均值赋值给 aveprint(ave) # 在屏幕上显示 ave的值

创建和使用函数

  • 在Python中,我们需要使用“def函数名 (参数):”来定义函数。下面我们就定义一个返回参数a和b平均值的average函数。
  • 在C语言中,块是用 { 和 } 包围起来再加上缩进来表示的[插图],而在Python中,块仅通过缩进来表示。在C语言中,函数需要指定参数和返回值的数据类型,但在Python中则不需要指定,因为无论是参数还是返回值,其本质上都是对象。和C语言一样,Python中也是用return语句来返回返回值的。

局部变量与全局变量

  • 在Python中,函数块内部声明的变量就是只能在该函数中使用的局部变量,而在函数块外部声明的变量就是整个程序的函数和类都可以使用的全局变量。
  • 要在函数块中对全局变量进行赋值,我们需要使用“global全局变量名”这样的形式进行全局声明。

数组与循环

  • 在Python中表示大量数据时并不使用数组,而是使用列表、元组、字符串、字典、集合等类,其中列表和C语言中的数组用法相同。

其他语法

  • [插图]
  • [插图]
  • [插图]

后记

  • 在看清本质之前,人们往往觉得程序很难,很可怕。
    — 来自微信读书