2
学习C语言的预备知识

我们在第1章已经大致介绍了C语言的概念以及编译、连接流程。我们知道C语言是高级语言中比较偏硬件底层的编程语言,因此对于用C语言的编程人员而言,了解一些关于处理器架构方面的知识是很有必要的,对于嵌入式系统开发的程序员而言更是如此了。

另外,C语言中有很多按位计算以及逻辑计算,所以对于初学者来说,如果对整数编码方式等计算机基础知识不熟悉,那么对这些操作的理解也会变得十分困难。因此,本章将主要给C语言初学者、同时也是计算机编程初学者,提供计算机编程中会涉及的基本知识,这样,在本书后面讲解到一系列相关概念时,初学者也不会感到陌生。

2.1 计算机体系结构简介

图2-1为一个简单的计算机体系结构图。

图2-1 简单的计算机体系结构图

一个简单的计算机系统包含了中央处理器(CPU)以及存储器和其他外部设备。而在CPU内部则由计算单元、通用目的寄存器、程序序列器、数据地址生成器等部件构成。下面我们将从外到内分别简单地介绍这些组件。

2.1.1 贮存器

贮存器(Storage)尽管在图2-1中没有表示出来,但我们对它一定不会陌生,比如我们在PC上使用的硬盘(Hard Disk)就是一种贮存器。贮存器是一种存储器,不过它可用于持久保存数据而不丢失。因此我们通常把具有可持久保存的存储器统称为贮存器。现在PC上用得比较现代化的贮存器就是SSD(Solid-State Disk)了,俗称固态硬盘。当然,贮存器就其存储介质来说属于ROM(Read-Only Memory),即只读存储器。这类存储器的特点是数据能持久保留,比如我们PC上的文件,即便在关闭计算机之后也一直会保存在你的硬盘上,而且PC上的软件往往也是以可执行文件的形式保存在硬盘上的。但是它的读写速度非常缓慢,尤其是老式的SATA磁盘,写操作则更慢。因为通常对ROM的数据修改都要通过先读取某段数据所在的扇区,然后对该数据进行修改,再擦除所涉及的扇区,最后把修改好的数据所包含的扇区再写回去。而对于ROM来说,其扇区是有写入次数限制的,所以写入次数越多,损耗就越大。当我们发现一个硬盘访问很慢的时候,通常就是其扇区(或磁道)已经破损严重了,这是在不断纠错并交换良好的扇区所引发的延迟。在嵌入式系统中,我们用的ROM一般是EPROM、EEPROM、Flash ROM等。这些硬件的详细资料各位可以从网上轻易获得,这里不再赘述。

2.1.2 存储器

存储器(Memory)一般是指我们通常所说的内存或主存(Main Memory)。其存储介质属于RAM(Random Access Memory),即随机访问存储器。它的特点是访问速度快,可对单个字节进行读写,这与ROM需要擦除整个扇区再对整个扇区写入的方式有所不同,因此更高效、灵活。但是RAM的数据无法持久化,掉电之后就会消失。此外,RAM的成本也比ROM高昂得多,我们对比一下16GB的内存条与256GB SSD的价格就能知道。然而正因为RAM的访问速度快,并且离CPU更近,所以在许多系统中都是将程序代码与数据先读取到RAM中之后再让CPU去执行处理的。当然,在一些嵌入式系统中也有让CPU直接执行ROM中的代码并访问读ROM中常量数据的情况,因为这类系统中总线频率以及CPU频率都相对较低,并且ROM也是与CPU以SoC(System-On-Chip,系统级芯片)的方式整合在一块芯片上的,所以访问成本要低很多。而有些环境对ROM的读取速度甚至比读取RAM还更快些。

注意:在本书中所出现的“存储器”均表示内存,即RAM。而将可持久保存数据的存储器都一律称为“贮存器”。了解了这些概念后,我们在国外网站购买Mac或PC时,看到相关的术语就不会手足无措了。这里提供Apple美国官网的Mac配置信息网页,各位可以参考:www.apple.com/macbook-pro/specs/。

2.1.3 寄存器

寄存器是在CPU核心中的、用于暂存数据的存储单元。一般处理器内部对数据的算术逻辑计算往往都需要通过寄存器(Register),而不是直接对外部存储器进行操作。因此,如果我们要计算一个加法或乘法计算,需要先把相关数据从外部存储器读到处理器自己的通用目的寄存器中,然后对寄存器做计算操作,再将计算结果也放入寄存器,最后将结果寄存器中的数据再写入外部存储器。寄存器的访问速度非常快,它是这三种存储介质中速度最快的,但是数量也是最少的。像在传统的32位x86处理器体系结构下,程序员一般能直接用的通用目的寄存器只有EAX、EBX、ECX、EDX、ESI、EDI、EBP这7个。还有一个ESP用于操作堆栈,往往无法用来处理通用计算。

2.1.4 计算单元

计算单元一般由算术逻辑单元(ALU)、乘法器、移位器构成。当然,像一般高级点的处理器还包含除法器,以及用于做浮点数计算的浮点处理单元(FPU)。它们一般都直接对寄存器进行操作。而涉及数据读写的指令会由专门的加载、存储处理单元进行操作。

2.1.5 程序执行流程

处理器在执行一段程序时,通常先从外部存储器取得指令,然后对指令进行译码处理,转换为相关的一系列操作。这些操作可能是对寄存器的算术逻辑运算,也可能是对存储器的读写操作,然后执行相关计算。最后把计算结果写回寄存器或写回到存储器。不过处理器在执行一系列指令的时候并不是每条指令都必须先经过上面所描述的整个过程才能执行下一条,而是采用流水线的方式执行,如图2-2所示。

图2-2 处理器执行流水线

图2-2体现了一个简单的处理器执行完一条指令的完整过程。我们这里假设从第一个取指令阶段到最后的写回阶段,这5个阶段均花费1个周期,倘若不是采用流水线的方式,而是每完成一条指令的执行再执行下一条指令,那么每条指令的处理都需要5个周期。而一旦采用流水线方式处理,那么我们可以看到,在第一条指令执行到译码阶段时,处理器可以对第二条指令做取指令操作;当第一条指令执行到执行阶段时,第二条指令执行到了译码阶段,此时第三条指令开始做取指令阶段,然后以此类推。这样,当整条流水线填充满之后,即执行到了第5条指令,那么对于后续指令而言,处理每一条指令的时间均只需要一个周期。

这里需要注意的是,并不是每条指令都需要访存操作,只有当需要对外部存储器做读写操作时才会动用访存执行单元。然而大部分指令都需要写回寄存器操作,即便像一条用于比较大小的指令,或一条系统中断指令,它们也会影响状态寄存器。当然,很多处理器会有空操作(NOP)指令,它仅仅占用一个时钟周期,而不会对除了指令指针寄存器以外的任何寄存器产生影响。

2.2 整数在计算机中的表示

我们日常用的整数都是十进制数(Decimal),也就是我们通常所说的逢十进一。因为我们人类有十根手指,所以自然而然地会想到采用十进制的计数和计算方式。然而,现在几乎所有计算机都采用二进制数(Binary)编码方式,所以我们日常所用到的整数如果要用计算机来表示的话,需要表示成二进制的方式。

二进制数则是逢二进一,所以在整串数中只有0和1两种数字。比如,十进制数0,对应二进制为0;十进制数1,对应二进制数1;十进制数2,对应二进制数10;十进制数3,对应二进制数11。因此,对于非负整数而言,二进制数第n位(n从0开始计)如果是1,那么就对应十进制数的2n,然后每个位计算得到的十进制数再依次相加得到最终十进制数的值。比如,一个5位二进制数10010,最低位为最右边的位,记为0号位,数值为0;最高位为最左边的位,记为4号位,数值为1。那么它所对应的十进制数为:24+21=18。因为该二进制数除了4号位和1号位为1之外,其余位都是0,因此0乘以2n肯定为0。图2-3为二进制数10010换算成十进制数的方法图。

图2-3 5位二进制数对应十进制的计算

在计算机术语中,把二进制数中的某一位数又称为一个比特(bit)。比特这个单位对于计算机而言,在度量上是最小的单位。除了比特之外,还有字节(byte)这个术语。一个字节由8个比特构成。在某些单片机架构下还引入了半字节(nybble或nibble)这个概念,表示4个比特。然后,还有字(word)这个术语。字在不同计算机架构下表示的含义不同。在x86架构下,一个字为2个字节;而在ARM等众多32位RISC体系结构下,一个字表示为4个字节。随着计算机带宽的提升,能被处理器一次处理的数据宽度也不断提升,因此出现了双字(double word)、四字(quad word)、八字(octa word)等概念。双字的宽度为2个字,四字宽度为4个字,所以它们在不同处理器体系结构下所占用的字节个数也会不同。

我们上面介绍了非负整数的二进制表达方法,那么对于负数,二进制又该如何表达呢?在计算机中有原码和补码两种表示方法,而最为常用的是补码的表示方法。下面我们分别对原码和补码进行介绍。

2.2.1 原码表示法

对于无正负符号的原码,其二进制表达如上节所述。而对于含有正负符号的原码,其二进制表示含有一位符号位,用于表示正负号。一般都是以二进制数的最高有效位(即最左边的比特)作为符号位,其余各位比特表示该数的绝对值大小。比如,十进制数6用一个8位的原码表示为00000110;如果是-6,则表示为10000110。二进制的原码表示示例如图2-4所示。

图2-4 二进制数的原码表示

原码的表示非常直观,但是对于计算机算术运算而言就带来了许多麻烦。比如,我们用上述的6与-6相加,即00000110+10000110,结果为10001100,也就是十进制数-12,显然不是我们想要的结果。所以,如果某个处理器用原码表示二进制数,那么它参与加减法的时候必须对两个操作数的正负符号加以判断,然后再判定使用加法操作还是减法操作,最后还要判定结果的正负符号,可谓相当麻烦。所以,当前计算机的处理器往往采用补码的方式来表达带符号的二进制数。

2.2.2 补码表示法

正由于原码含有上述缺点,所以人们开发出了另一种带符号的二进制码表示法——补码。补码与原码一样,用最高位比特表示符号位,其余各位比特则表示数值大小。如果符号位为0,说明整个二进制数为正数或零;如果为1,那么表示整个二进制数为负数。当符号位为0时,二进制补码表示法与原码一模一样,但是当符号位为负数时,情况就完全不同了。此时,对二进制数的补码表示需要按以下步骤进行:

1)先将该二进制数以绝对值的原码形式写好;

2)对整个二进制数(包括符号位),每一个比特都取反。所谓取反就是说,原来一个比特的数值为0时,则要变1;为1时,则要变0。

变换好之后,将二进制数做加1计算,最终结果就是该负数的补码值了。

下面我们还是用6来举例,+6的二进制补码跟原码一样,还是00000110。而-6的计算过程,按照上述流程如下:

1)先将-6用绝对值+6的形式表示:00000110;

2)对每个比特位取反,包括符号位在内,得到:11111001;

3)将变换好的数做加1计算,最终得到:11111010。

由于二进制补码的表示与通常我们可直接读懂的二进制数的表示有很大不同,所以给定一个二进制补码,我们往往需要先获得其绝对值大小才能知道它的具体数值。获得其绝对值的过程为:先判定符号位,如果符号位为0,那么就以通常的二进制数表示法来读即可。如果符号位为1,那么就以上述同样的过程得到其对应的绝对值。比如,如果给定11111010这个二进制数,我们看到最高位符号位为1,说明是负数,我们就以上述过程来求解:

1)先将该二进制数每个比特做取反计算,得到:00000101;

2)然后将变换得到的值做加1计算,最终获得:00000110。

所以11111010的绝对值为00000110,即6。

对于补码表示,我们已经知道最高位比特表示符号位,其余的表示具体数值。但是这里有一个特殊情况,即符号位为1,其余位比特为都为0的情况。比如一个8位二进制补码:10000000,此时它的值是多少?因为我们通过上述流程,求得其绝对值的大小也是10000000,所以当前大部分计算机处理器的实现将它作为-128,但估计仍然有一些处理器会把它作为-0。因为C语言标准中对于数值范围的表示已经明确表示出8位带符号的整数范围可以是-128到+127,也可以是-127到+127,但最小值不得大于-127,最大值不得小于+127。第5章会有更详细的描述。

补码的这种表示法的优点就是可以无视符号位,随意进行算术运算操作。比如,像我们上面所举的例子:6+(-6),计算结果:

00000110+11111010=00000000

最后,上述计算结果的最高位符号位所产生的进位被丢弃(在处理器中可能会设置相应的进位标志位)。我们自己计算的话也非常方便,在计算过程中,无需关心两个二进制补码的正负数的情况,也无需关心符号位所产生的影响。我们只需要像计算普通二进制数一样去计算即可。把最终的计算结果拿出来判断,是正数还是负数。当然,二进制补码会产生溢出情况,比如两个8位二进制补码加法:

120+50=01111000+00110010=10101010

然而,这个数并不是170,而是-86。首先,170已经超出了带符号8位二进制数可表示的最大范围了;其次,最高位变为1,用补码表示来讲就是负数表示形式。所以,这两个正数的加法计算就产生了负数结果,这种现象称为上溢。如果我们要避免在计算过程中出现上溢情况,需要用更高位宽的二进制数来表示,以提升精度。比如,如果我们将上述加法用16位二进制数表示,那么就不会有上溢问题了。

另外,在C语言标准中没有明确规定C语言编译器的实现以及运行时环境必须采用哪种二进制编码方式,而是对整数类型标明最大可表示的数值范围。目前大部分C语言实现都是对带符号整数采用补码的表示方式。这些会在第5章做进一步讲解。

2.2.3 八进制数与十六进制数

上面我们对二进制数编码形式做了比较详细的介绍。我们在编写程序或者查看一些计算机相关的技术文档时常常还会碰到八进制数与十六进制数的表示,尤其是十六进制数用得非常多。下面我们就简单介绍一下这两种基数(radix)的表示方法。

这里跟各位再分享一个术语——基数。基数也就是我们通常所说的,某一个数用多少进制表达。对于像“01001000是几进制数”这种话,如果用更专业的表达方式来说的话就是,“01001000的基数是几”。基数为2就是二进制;基数为10则是十进制。

八进制数是逢八进一,因此每位数的范围是从0~7。八进制数转十进制数也很简单,我们可以用二进制数转十进制数类似的方法来炮制八进制数转十进制数——以一个八进制数每位数值作为系数,然后乘以8n,然后计算得到的结果全都相加,最后得到相应的十进制数。其中,n表示当前该位所对应的位置索引(同样以0开始计)。比如,八进制数5271对应的十进制数的计算过程如图2-5所示。

图2-5 八进制数转十进制数

八进制数对应于二进制数的话正好占用3个比特(范围从000~111),一般在通信领域以及信息加密等领域会用到八进制编码方式。而十六进制数比八进制数用得更多,因为十六进制数正好占用4个比特,即4位二进制数(范围从0000~1111)。4个比特相当于半个字节。所以,无论是开发工具还是程序调试工具,一般都会用十六进制数来表示计算机内部的二进制数据,这样更易读,而且也更省显示空间(因为一个字节原本需要8位二进制数,而十六进制数只要两位即可表示)。下面就介绍一下十六机制数的表示方法。

十六进制数逢十六进一,因此每一位数的范围是从0到15。由于我们通常在数学上所用的十进制数无法用一位来表示10~15这6个数,因而在计算机领域中,我们通常用英文字母A(或小写a)来表示10; B(或小写b)来表示11; C(或小写c)来表示12;D(或小写d)来表示13; E(或小写e)来表示14; F(或小写f)来表示15。十六机制数转十进制数的方式与八进制数转十进制数类似——以一个十六进制数每位数值作为系数,然后乘以16n,然后计算得到的结果全都相加,最后得到相应的十进制数。其中,n表示当前位所对应的位置索引(同样以0开始计)。比如,一个4位十六进制数C0DE的计算过程如图2-6所示:

图2-6 十六进制数转十进制数

上述4位十六进制数C0DE,倘若用二进制数表示,则为:1100000011011110。可见,用十六进制数表示要简洁得多,而且换算成十进制数也相对比较容易,尤其对于一个字节长度的整数来说。为了能更快速地换算二进制数、十进制数与十六进制数,请各位读者务必熟记下表:

表2-1 二进制数、十进制数与十六进制数的换算表

习惯上,用0或0o打头的数表示八进制数,0x打头的数表示十六进制数。比如,0123、0777表示八进制数;0x123,0xABCD表示十六进制数。

2.3 浮点数在计算机中的表示

当前主流处理器一般都能支持32位的单精度浮点数与64位的双精度浮点数的表示和计算,并且能遵循IEEE754-1985工业标准。现在此标准最新的版本是2008,其中增加了对16位半精度浮点数以及128位四精度浮点数的描述。C语言标准引入了一个浮点模型,可用来表达任意精度的浮点数,尽管当前主流C语言编译器尚未很好地支持半精度浮点数与四精度浮点数的表示和计算。关于C语言标准对浮点数的描述,我们稍后将在5.2节做更详细的介绍。

为了更好地理解IEEE754-1985中规格化(normalized)浮点数的表示法,我们先来介绍一下浮点数用一般二进制数的表示方法。一个浮点数包含了整数部分和尾数(即小数)部分。整数部分的表示与我们之前所讨论过的一样,第n位就表示2n, n从0开始计。而尾数部分则是第m位表示2-m, m从1开始计。对于一个0101.1010的二进制浮点数对应十进制数的计算如图2-7所示:

图2-7 二进制浮点数转十进制数

图2-7中,整i位即表示第i位整数;尾i位即表示第i位尾数。其中,第3位整数为最高位整数;第4位尾数表示最低位尾数。对二进制浮点数的表示有了概念之后,我们就可以看IEEE754-1985标准中对规格化浮点数的描述了。IEEE754-1985对32位单精度与64位双精度两种精度的浮点数进行描述。32位单精度浮点可表示的数值范围在±1.18×10-38到±3.4×1038,大约含有7位十进制有效数;64位双精度浮点可表示的数值范围在±2.23×10-308到±1.80×10308,大约含有15位十进制有效数。我们看到IEEE定义的浮点数的绝对值范围可以是一个远大于1的数,也可以是一个大于零但远小于1的数,即它的小数精度是可浮动的,所以称之为浮点数。如果说是定点数的话,它也可表示一个小数,但是其整数位数与小数位数的精度都是固定的。比如一个16.16的定点数表示整数部分采用16个比特,尾数部分也采用16个比特。而对于一个32位浮点数来说,既能使用16.16的格式,也能使用30.2的格式(即30个比特表示整数,2个比特表示尾数)或其他各种形式。而IEEE754-1985对规格化单精度浮点数的格式如下定义:

1)1位符号位,一般是最高位(31位),表示正负号。0表示正数,1表示负数。

2)8位指数位,又称阶码,位于23到30位。(阶码的计算后面会详细介绍。)

3)23位尾数,位于0到22位。

我们下面举一个实际的例子来详细说明一个十进制小数5.625如何表示成IEEE754标准的规格化32位单精度浮点数。

1)5.625是一个正数,所以符号位为0,即第31位为0。

2)我们将5.625依照图2-7那样写成一般小数的表示法——0101.101。

3)我们将此二进制浮点数用科学计数法来表示,使得二进制整数位为最高位的1。这里最高位为1的比特是从左往右数是第二个比特,所以将小数点就放到该比特的后面,得到1.01101×22。二进制数的科学记数法,底数的值显然就是2。

4)此时,我们能看到尾数部分是小数点后面的那串二进制数,即01101,而指数为2。现在我们来求阶码。阶码用的是中经指数偏差(exponent bias)处理后的指数,即用上述得到的指数加上偏差值所求得的和。IEEE754在单精度浮点中规定,偏差值为127。所以本例中,阶码部分为2+127=129,用二进制数表示就是10000001。

5)尾数部分从大到小照抄,低位的用0填充即可,所以这里的尾数部分二进制数为:01101000000000000000000。

6)将整个处理完的二进制数串起来获得:0(符号位)10000001(阶码)01101000000000000000000(尾数),用十六进制数表达就是:40B40000。

十进制小数转64位双精度浮点数的方法与上述雷同,只不过阶码用11位比特来表示,尾数则用52位比特表示,而偏差值则规定为1023。

2.4 地址与字节对齐

由于C语言是一门接近底层硬件的编程语言,它能直接对存储器地址进行访问(当前大部分处理器在操作系统的应用层所访问到的逻辑地址,而部分嵌入式系统由于不含带存储器管理单元,因此可直接访问物理地址)。在计算机中,所谓“地址”就是用来标识存储单元的一个编号,就好比我们住房的门牌号。没有门牌号,快递就没法发货;如果门牌号记错了,那么快递就会把货物送错地方。计算机中的地址也是一样,我们为了要访问存储器中特定单元的一个数据,那么我们首先要获悉该数据所在的地址,然后我们通过这个地址来访问它。访问存储器,我们也简称为“访存”(Memory Access)。访问地址,我们也简称为“寻址”(Addressing)。我们在图2-1中也看到,一般计算机架构中都会有地址总线和数据总线。CPU先通过地址总线发送寻址信号,以指定所要访问存储器单元的地址。然后再通过数据总线向该地址读写数据,这样就完成了一次访存操作。这好比于快递送货,我们先打电话告诉快递通信地址,然后快递员把货送到该地址(写数据),或者去该地址拿货(读数据)送到别家。

一般对于32位系统来说,处理器一次可访问1个(8比特)字节、2个字节或4个字节。当访问单个字节时,对CPU不做对齐限制;而当访问多个字节时,比如要访问N个字节,由于计算机总线设计等诸多因素,要求CPU所访问的起始地址满足N个字节的倍数来访问存储器。如果在访问存储器时没有按照特定要求做字节对齐,那么可能会引发访存性能问题,甚至直接导致寻址错误而引发异常(引发异常后通常会导致当前应用意外退出,在嵌入式系统中可能就直接死机或复位)。

下面我们给出一张图2-8来描述,看看一般对32位系统而言如何正确地做到访存字节对齐。

图2-8展示了如何正确对齐访问1个字节、2个字节和4个字节的情况。图中画出了6个存储单元内容,地址低16位从0x1000到0x1005,每个存储单元为1个字节。对于仅访问1个字节的情况,图2-8所有地址都能直接访问并满足字节对齐的情况。对于一次访问2个字节的情况,要满足对齐要求,只能访问0x1000、0x1002、0x1004等必须要能被2整除的地址。对于一次访问4字节的情况,要满足对齐要求,则只能访问0x1000、0x1004等必须要能被4整除的地址。

图2-8 字节对齐

然而,并不是说要访问多少字节,就必须要保证访问能被多少整除的地址才能满足对齐要求。如果一次访问8字节,对于32位系统而言,通过32位通用目的寄存器来读写存储器的话,某些CPU会自动将8字节的访存分为两次进行操作,每次为4字节,因此只要保证4字节对齐就能满足对齐要求。这些都根据特定的处理器来做具体处理。

就笔者用过的一些处理器而言,像x86、ARM等处理器,当访存不满足对齐要求时并不会引发总线异常,但是访问性能会降低很多。因为原本可一次通信的数据传输可能需要拆分为多次,并且前后还要保证数据的一致性,所以还可能会有锁步之类的操作。而像Blackf in DSP则会直接引发总线异常,导致整个系统的崩溃(如果不对此异常做处理的话)。另外,像ARMv5或更低版本的处理器,在对非对齐的存储器地址进行访问时,CPU会先自动向下定位到对齐地址,然后通过向右循环移位的方式处理数据,这就使得传输数据并不是原本想一次传输的数据内容,也就是说写入的或读出的数据是失真的。比如,根据图2-8所示内容,如果我们要对一款ARM7EJ-S处理器(ARMv5TEJ架构)从地址0x1002读4字节内容,那么实际获取到的数据为0x02010403;而在x86架构或ARMv7架构的处理器下,则能获得0x06050403。

2.5 字符编码

我们从2.2节到2.4节讲述的都是数值信息(整数与浮点数),本小节我们将讨论字符信息。在计算机中我们所处理的字符信息,即文本信息(包括数字、字母、文字、标点符号等)是以一种特定编码格式来定义的。为了使世界各国的文本信息能够通用,就需要对字符编码做标准化。我们现在最常用也最基本的字符编码系统是ASCII码(American Standard Code for Information Interchange,美国信息交换标准码)。ASCII码定义每个字符仅占一个字节,可表示阿拉伯数字0~9、26个大小写英文字母,以及我们现在在标准键盘上能看到的所有标点符号、一些控制字符(比如换行、回车、换页、振铃等)。ASCII码最高位是奇偶校验位,用于通信校验,所以真正有编码意义的是低7个比特,因此只能用于表示128个字符(值从0~127)。由于ASCII是美国国家标准,所以后来国际化标准组织将它进行国际标准化,定义为了ISO/IEC 646标准。两者所定义的内容是等价的。

ISO/IEC 646对于英文系国家而言是基本够用了,但是对于拉丁语系、希腊等国家来说就不够用了。所以后来ISO组织就把原先ISO/IEC 646所定义字符的最高位也用上了,这样就又能增加128个不同的字符,发布了ISO/IEC 8859标准。然而,欧洲大陆虽小,但国家却有数百个,128种扩展字符仍然不够用。因此后来就在8859的基础上,引入了8859-n, n从1~16,每一种都支持了一定数量的不同的字母,这样基本能满足欧美国家的文字表示需求。当然,有些国家之间仍然需要切换编码格式,比如ISO/IEC8859-1的语言环境看8859-2的就可能显示乱码,所以,还得切换到8859-2的字符编码格式下才能正常显示。

而在中国大陆,我们自己也定义了一套用于显示简体中文的字符集——GB2312。它在1981年5月1日开始实施,是中国国家标准的简体中文字符集,全称为《信息交换用汉字编码字符集·基本集》。它收录了6763个汉字,包括拉丁字母、希腊字母、日语假名、俄语和蒙古语用的西里尔字母在内的682个全角字符。然后又出现了GBK字符集,GBK1.0收录了21886个符号,其中汉字就包含了21003个。GBK字符集主要扩展了繁体中文字。由于像GB2312与GBK能表示成千上万种字符,因此这已经远超1个字节所能表示的范围。它们所采用的是动态变长字节编码,并且与ASCII码兼容。如果表示ASCII码部分,那么仅1个字节即可,并且该字节最高位为0。如果要表示汉字等扩展字符,那么头1个字节的最高位为1,然后再增加一个字节(即用两个字节)进行表示。所以,理论上,除了第1个字节的最高位不能动之外,其余比特都能表示具体的字符信息,因而最多可表示27+215=32896种字符。

当然,正由于GB2312与GBK主要用于亚洲国家,所以当欧美国家的人看到这些字符信息时显示的是乱码,他们必须切换到相应的汉字编码环境下看才能看到正确的文本信息。为了能真正将全球各国语言进行互换通信,出现了Unicode(Universal Character Set, UCS)标准。它对应于编码标准ISO/IEC 10646。Unicode前后也出现了多个版本。早先的UCS-2采用固定的双字节编码方式,理论上可表示216=65536种字符,因此极大地涵盖了各种语言的文字符号。

不过后来,标准委员会意识到,对于像希伯来字母、拉丁字母等压根就不需要用两个字节表示,而且定长的双字节表示与原有的ASCII码又不兼容,因此后来出现了现在用得更多的UTF-8编码标准。UTF-8属于变长的编码方式,它最少可用1个字节表示1个字符,最多用4个字节表示1个字符,判别依据就是看第1个字节的最高位有多少个1。如果第1个字节的最高位是0,那么该字符用1个字节表示;最高3位是110,那么用2个字节表示;最高4位是1110,那么用3个字节表示;最高位是11110,那么该字符由4个字节来表示。所以UTF-8现在大量用于网络通信的字符编码格式,包括大多数网页用的默认字符编码也都是UTF-8编码。尽管UTF-8更为灵活,而且也与ASCII码完全兼容,但不利于程序解析。所以现在很多编程语言的编译器以及运行时库用得更多的是UTF-16编码来处理源代码解析以及各类文本解析,它与之前的UCS-2编码完全兼容,但也是变长编码方式,可用双字节或四字节来表示一个字符。如果用双字节表示UTF-16编码的话,范围从0x0000到0xD7FF,以及从0xE000到0xFFFF。这里留出0xD800到0xDFFF,不作为具体字符的编码表示,而是用于四字节编码时的编码替换。当UTF-16表示0x10000到0x10FFFF之间的字符时,先将该范围内的值减去0x10000,使得结果落在0x00000到0xFFFFF范围内。然后将结果划分为高10位与低10位两组。将低10位的值与0xDC00相加,获得低16位;高10位与0xD800相加,获得高16位。比如,一个Unicode定义的码点(code point)为0x10437的字符,用UTF-16编码表示的步骤如下。

1)先将它减去0x10000——0x10437-0x10000=0x0437。

2)将该结果分为低10位与高10位,0x0437用20位二进制表示为00000000010000110111,因此高10位是00000000 01=0x01;低10位则是0000110111,即0x037。

3)将高10位与0xD800相加,得到0xD801;将低10位与0xDC00相加,获得0xDC37。因此最终UTF-16编码为0xD801DC37。

我们看到,尽管UTF-16也是变长编码表示,但是仅低16位就能表示很多字符符号,况且即便要表示更广范围的字符,也只是第二种四字节的表示方法,这远比UTF-8四种不同的编码方式要简洁很多。因此,UTF-16用在很多编程语言运行时系统字符编码的场合比较多。像现在的Java、Objective-C等编程语言环境内部系统所表示的字符都是UTF-16编码方式。

另外,现在还有UTF-32编码方式,这一开始也是Unicode标准搞出来的UCS-4标准,它与UCS-2一样,是定长编码方式,但每个字符用固定的4字节来表示。不过现在此格式用得很少,而且HTML5标准组织也公开声明开发者应当尽量避免在页面中使用UTF-32编码格式,因为在HTML5规范中所描述的编码侦测算法,故意不对它与UTF-16编码做区分。

2.6 大端与小端

现代计算机系统中含有两种存放数据的字节序:大端(Big-endian)和小端(Little-endian)。所谓大端字节序是指在读写一个大于1个字节的数据时,其数据的最高字节存放在起始地址单元处,数据的最低字节存放在最高地址单元处。所谓小端字节序是指在读写一个大于1个字节的数据时,其数据的最低字节存放在起始地址单元处,而数据的最高字节存放在最高地址单元处。比如,我们要在地址0x00001000处存放一个0x04030201的32位整数,其大端、小端存放情况如图2-9所示。

图2-9 大端与小端

当前,通用桌面处理器以及智能移动设备的处理器一般都用小端字节序。通信设备中用大端字节序比较普遍。

本书后续所要叙述的内容中,若无特殊说明,都是基于小端字节序进行描述。

2.7 按位逻辑运算

按位逻辑运算在计算机编程中会经常涉及,这些运算都是针对二进制比特进行操作的。所谓的“按位”计算就是指对一组数据的每个比特逐位进行计算,并且对每个比特的计算结果不会影响其他位。常用的按位逻辑运算包括“按位与”、“按位或”、“按位异或”以及“按位取反”四种。下面将分别介绍这4种运算方式。

1)按位与:它是一个双目操作,需要两个操作数,在C语言中用&表示。两个比特的按位与结果如下:

0 & 0=0; 0 & 1=0; 1 & 0=0; 1 & 1=1

也就是说,两个比特中如果有一个比特是0,那么按位与的结果就是0,只有当两个比特都为1的时候,按位与的结果才为1。比如,对两个字节01001010和11110011进行按位与的结果为01000010。按位与一般可用于判定某个标志位是否被设置。比如,我们假定处理一个游戏手柄的按键事件,用一个字节来存放按键被按下的标志,前4个比特分别表示“上”、“下”、“左”、“右”。比特4表示按下了“A”键,比特5表示按下了“B”键,比特6表示按下了“X”键,比特7表示按下了“Y”键。那么当我们接收到二进制数01010100时,说明用户同时按下了“左”方向键、“A”键和“X”键。那么我们判定按键标志时可以通过按位与二进制数1来判定是否按下了“上”键,按位与二进制数10做按位与操作来判定是否按下了“下”键,跟二进制数100做与操作来判定是否按下了“左”键,以此类推。如果按位与的结果是0,说明当前此按键没有被按下,如果结果不为零,说明此按键被按下。

2)按位或:它是一个双目操作符,需要两个操作数,在C语言中用“|”表示。两个比特的按位或结果如下:

0 | 0=0; 0 | 1=1; 1 | 0=1; 1 | 1=1

也就是说,只要有一个比特的值是1,那么按位或的结果就是1,只有当两个比特的值都为0的时候,按位或的结果才是0。比如,对于两个字节01001010和11110011进行按位或的结果为11111011。按位或一般可用于设置标志位。就如同上述例子,如果用户按下了“上”键,那么系统底层会将最低位设置为1;如果用户按下了“Y”键,那么系统底层会将最高位设置为1。随后系统会将这串信息发送到应用UI层。

3)按位异或:它是一个双目操作,需要两个操作数,在C语言中用^表示。两个比特的按位异或结果如下

0^0=0; 0^1=1; 1^0=1; 1^1=0

也就是说,如果两个比特的值相同,那么按位异或的结果为0,不同为1。比如,对于两个字节01001010和11110011进行按位或的结果为10111001。按位异或适用于多种场景,比如我们用一个输入比特与1进行异或就可以反转该输入比特的值,输入为0,那么结果为1;输入为1,那么结果为0。任一比特与0异或,那么结果还是原比特的值。按位异或跟按位与和按位或不同,它可以对数据信息进行叠加组合。因为给定任一比特,对于另外一个比特的输入,不同的输入值对应不同的输出,所以我们通过异或能还原信息。比如,我们有两个整数a和b,我们设c=a ^ b。对于c,我们可以通过c ^ a重新得到b,也可以通过c ^ b来重新得到a。所以异或在信息编码、数据加密等技术上应用得非常多。

4)按位取反:它是一个单目操作,只需要一个操作数,在C语言中用~表示。一个比特的按位取反结果如下:~0=1; ~1=0。比如,对一个字节01001010进行按位取反的结果为10110101。

2.8 移位操作

现代处理器的计算单元中一般都会包含移位器。移位器往往能执行算术左移(Arithmetic Shift Left)、算术右移(Arithmetic Shift Right)、逻辑左移(Logical Shift Left)、逻辑右移(Logical Shift Right)、循环右移(Rotational Shift Right)这些操作。

下面我们将分别介绍这些移位操作,这里需要提醒各位的是,移位操作一般总是对整数数据进行操作,并且移入移出的都是二进制比特。然而,不同的处理器架构对移位操作的实现可能会有一些不同。比如,如果对一个32位寄存器做移位操作,倘若指定要移动的比特数超过了31,那么在x86处理器中是将指定的比特移动位数做模32处理(也就是求除以32的余数,比如左移32位相当于左移0位、右移33位相当于右移1位);而在ARM、AVR等处理器中,对一个32位的整数做左移和逻辑右移超出31位的结果都将是零。

2.8.1 算术左移与逻辑左移

由于算术左移与逻辑左移操作基本是相同的,仅仅对标志位的影响有些区别,所以合并在一起讲。左移的操作步骤十分简单,假设我们要左移N位,那么先将整数的每个比特向左移动N位,然后空出的低N位填零。图2-10展示了对一个8位整数分别做左移1位与左移2位的过程。

图2-10 算术左移与逻辑左移

图2-10中间由小写字母a~h构成的方格图即表示一个8位二进制整数,每个小写字母表示一比特,并且字母a作为最高位比特,字母h作为最低位比特。左移1位后,原来的8位二进制数就变成了bcdefgh0;左移2位后,原来的8位二进制数就变成了cdefgh00。

2.8.2 逻辑右移

逻辑右移的操作步序是:先将整数的每一个比特向右移动N位,然后高N位用零来填补。图2-11展示了一个8位二进制整数分别逻辑右移1位和2位的过程。

图2-11 逻辑右移

图2-11中间由小写字母a~h构成的方格图即表示一个8位二进制整数,每个小写字母表示一位比特,并且字母a作为最高位比特,字母h作为最低位比特。将原始二进制8位数据逻辑右移1位后,二进制数据变为0abcdefg;逻辑右移2位后,二进制数据变为00abcdef。

2.8.3 算术右移

算术右移与逻辑右移类似,只不过移出N位之后,高N位不是用零来填充,而是根据原始整数的最高位,如果原始整数的最高位为1,那么移位后的高N位用1来填充;如果是0,则用0来填充。图2-12展示了一个8位二进制整数分别算术右移1位和2位的过程。

图2-12 算术右移

图2-12中间由小写字母a~h构成的方格图,即表示一个8位二进制整数,每个小写字母表示一位比特,并且字母a作为最高位比特,字母h作为最低位比特。将原始8位二进制整数算术右移1位之后,该二进制数变为aabcdefg;将它算术右移2位之后,变为aaabcdef。

2.8.4 循环右移

循环右移的步序是:先将原始二进制整数右移N位,移出的N位依次放入到高N位。图2-13展示了将一个8位二进制整数分别循环右移1位和2位的过程。

图2-13 循环右移

图2-13中间由小写字母a~h构成的方格图,即表示一个8位二进制整数,每个小写字母表示一位比特,并且字母a作为最高位比特,字母h作为最低位比特。将原始8位二进制整数循环右移1位之后,该二进制整数变为habcdefg;将它循环右移2位之后,该二进制整数变为ghabcdef。

2.9 本章小结

本章大致介绍了计算机体系结构以及程序执行的大致流程,然后描述了整数以及浮点数在计算机中的存储方式,之后还介绍了地址与字节对齐、字符编码、处理器大端与小端字节序,以及按位逻辑运算和移位操作。由于这些知识都是学习C语言必备的,C语言中有相关语法与这些概念对应,所以各位最好能先理解、掌握这些基本知识,这样对后续学习C语言将有很大帮助。