大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存(virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间(virtual address space)。顾名思义,这个虚拟地址空间只是一个展现给机器级程序的概念性映像。实际的实现(见第九章)是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为程序提供一个看上去统一的字节数组。
在接下来的几章中,我们将讲述编译器和运行时系统时如何将存储器空间划分为更可管理的单元,来存放不同的程序对象(program object),即程序数据、指令和控制信息。可以用各种机制来分配和管理程序不同部分的存储。这种管理完全是在虚拟地址空间里完成的。例如,C语言中一个指针的值(无论它指向一个整数、一个结构体或是某个其他程序对象)都是某个存储块的第一个字节的虚拟地址。C编译器还把每个指针和类型信息联系起来,这样就可以根据指针值的类型,生成不同的机器级代码来访问存储在指针指向位置处的值。尽管C编译器维护着这个类型信息,但是它生成的实际机器级程序并不包含关于数据类型的信息。每个程序对象可以简单地视为一个字节块,而程序本身就是一个字节序列。
给C语言初学者:C语言中指针的作用
指针是C语言的一个重要特性。它提供了引用数据结构(包括数组)的元素的机制。与变量类似,指针也有两个方面:值和类型。它的值表示某个对象的位置,而它的类型表示那个位置上所存储对象的类型(比如整数或者浮点数)。
真正理解指针需要查看它们在机器级上的表示以及实现。这将是第三章的重点之一,三.10.1节将对其进行深入介绍。
二.1.1 十六进制表示法
一个字节由8位组成。在二进制表示法中,它的值域是00000000~11111111。如果看成十进制整数,它的值域就是0~255。这两种符号表示法对于描述位模式来说都不是非常方便。二进制表示法太冗长,而十进制表示法与位模式的互相转化很麻烦。替代的方法是,以16为基数,或者叫做十六进制(hexadecimal)数,来表示位模式。十六进制(简写为“hex”)使用数字0~9以及字符A~F来表示16个可能得值。图2-2展示了16个十六进制数字对应的十进制值和二进制值。用十六进制书写,一个字节的值域为00~FF。
在C语言中,以0x或0X开头的数字常量被认为是十六进制的值。字符A~F既可以是大写,也可以是小写。例如,我们可以将数字FA1D37B 写作0xFA1D37B,或者0xfa1d37b,甚至是大小写混合,比如,0xFa1D37b。在本书中,我们将使用C表示法来表示十六进制值。
编写机器级程序的一个常见任务就是在位模式的十进制、二进制、十六进制表示之间人工转换。二进制和十六进制之间的转换比较简单直接,因为可以一次执行一个十六进制数字的转换。数字的转换可以参考如图2-2所示的表。一个简单的敲门是,记住十六进制数字A、C和F相应的十进制值。而对于把十六进制值B、D和E转换成十进制值,则可以通过计算它们与前三个值的相对关系来完成。
比如,假设给你一个数字0x173A4C。可以通过展开每个十六进制数字,将它转换为二进制格式,如下所示:
十六进制 1 7 3 A 4 C
二进制 0001 0111 0011 1010 0100 1100
这样就得到了二进制表示:0001 0111 0011 1010 0100 1100。
反过来,如果给定一个二进制数字1111001010110110110011,可以通过首先把它分为每4位一组来转换为十六进制。不过要注意,如果位总数不是4的倍数,最左边的一组可以少于4位,前面用0补足。然后将每个4位组转换为相应的十六进制数字:
二进制 11 1100 1010 1101 1011 0011
十六进制 3 C A D B 3
练习题2.1 完成下面的数字转换:
A.将0x39A7F8转换为二进制。
B.将二进制1100 1001 0111 1011转换为十六进制。
C.将0xD5E4C转换为二进制。
D.将二进制10 0110 1110 0111 1011 0101转换为十六进制。
当值x是2的非负整数n次幂时,也就是x=2ⁿ,我们可以很容易地将x写成十六进制形式,只要记住x的二进制表示就是1后面跟n个0。十六进制数字0代表4个二进制0.所以,当n表示成i+4j形式,其中0≤i≤3,我们可以把x写成开头的十六进制数字位1(i=0)、2(i=1)、4(i=2)或者8(i=3),后面跟着j个十六进制的0.比如,x=2048=2^11,我们有n=11=3+4·2,从而得到十六进制表示0x800。
练习题2.2 填写下表中的空白项,给出2的不同次幂的二进制和十六进制表示:
n | 2^n(十进制) | 2^n(十六进制) |
9 | 512 | 0200 |
19 | ||
16384 | ||
0x10000 | ||
17 | ||
32 | ||
0x80 |
十进制和十六进制表示之间的转换需要使用乘法或者除法来处理一般情况。将一个十进制数字x转换为十六进制,可以反复地用16除x,得到一个商q和一个余数r,x=q·16+r。然后,我们用十六进制数字表示的r作为最低位数字,并且通过对q反复进行这个过程得到剩下的数字。例如,考虑十进制314156的转换:
314156 = 19634·16 + 12 (C)
19634 = 1227·16 + 2 (2)
1227 = 76·16 + 11 (B)
76 = 4·16 + 12 (C)
4 = 0·16 + 4 (4)
从这里,我们能读出十六进制表示为0x4CB2C。
反过来,将一个十六进制数转换为十进制数字,我们可以用相应的16的幂乘以每个十六进制数字。比如,给定数字0x7AF,我们计算它对应的十进制值为
7 · 16^2 + 10 · 16 + 15 = 7 · 256 + 10 · 16 + 15 = 1792 + 160 + 15 = 1967
练习题2.3 一个字节可以用两个十六进制数字来表示。填写下表中缺失的项,给出不同字节模式的十进制、二进制和十六进制
十进制 | 二进制 | 十六进制 |
0 | 0000 0000 | 0x00 |
167 | ||
62 | ||
188 | ||
0011 0111 | ||
1000 1000 | ||
1111 0011 | ||
0x52 | ||
0xAC | ||
0xE7 |
旁注:十进制和十六进制的转换
较大数值的十进制和十六进制之间的转换,最好是让计算机或者计算器来完成。有大量的工具可以完成这个工作。一个简单地方法就是利用任何标准的搜索引擎,比如查询:
把0xabcd转换为十进制数;或把123用十六进制表示。
练习题2.4 不降数字转换为十进制或者二进制,试着解答下面的算术题,答案要用十六进制表示。提示:只要将执行十进制加法和减法所使用的方法改成以16为基数。
A. 0x503c + 0x8 =
B. 0x503c - 0x40 =
C. 0x503c + 64 =
D. 0x50ea - 0x503c =
二.1.2 字数据大小
每台计算机都有一个字长(word size),指明指针数据的标称大小(nominal size)。因为虚拟地址是以这样的一个字编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为w位的机器而言,虚拟地址的范围为0~2^w - 1,程序最多访问2^w个字节。
最近这些年,出现了大规模的从32位字长机器到64位字长机器的迁移。这种情况首先出现在为大型科学和数据库应用设计的高端机器上,之后是台式机和笔记本电脑,最近则出现在智能手机的处理器上。32位字长限制虚拟地址空间为4千兆字节(写作4GB),也就是说,刚刚超过4X10^9字节。扩展到64位字长使虚拟地址空间为16EB,大约是1.84X10^19字节。
大多数64位机器也可以运行为32位机器编译的程序,这是一种向后兼容。因此,举例来说,当程序prog.c用以下伪指令编译后:
linux> gcc -m32 prog.c
该程序就可以在32位或64位机器上正确运行。另一方面,若程序用下述伪指令编译:
linux> gcc -m64 prog.c
那就只能在64位机器上运行。因此,我们将程序称为“32位程序”或“64位程序”时,区别在于该程序是如何编译的,而不是其运行的机器类型。
计算机和编译器支持多种不同方式编码的数字格式,如不同长度的整数和浮点数。比如,许多机器都有处理单个字节的指令,也有处理表示为2字节、4字节或者8字节整数的指令,还有些指令支持表示为4字节和8字节的浮点数。
C语言支持整数和浮点数的多种数据格式。图2-3展示了为C语言各种数据类型分配的字节数。(我们在2.2节讨论C标准保证的字节数和典型的字节数之间的关系。)有些数据类型的确切字节数依赖于程序是如何被编译的。我们给出的是32位和64位程序的典型值。整数或者为有符号的,即可以表示负数、零和正数;或者无符号的,即只能表示非负数。C的数据类型char表示一个单独的字节。尽管“char”是由于它被用来存储文本串中的单个字符这一事实而得名,但它也能被用来存储整数值。数据类型short、int和long可以提供各种数据大小。即使是为64位系统编译,数据类型int通常也只有4个字节。数据类型long一般在32位程序中为4字节,在64位程序中则位8字节。
为了避免由于以来“典型”大小和不同编译器设置带来的奇怪行为,ISO C99引入了一类数据类型,其数据大小是固定的,不随编译器和机器设置而变化。其中就有数据类型int32_t和int64_t,它们分别是4个字节和8个字节。使用确定大小的整数类型是程序员准确控制数据表示的最佳途径。
大部分数据类型都编码为有符号数值,除非有前缀关键字unsigned或对确定大小的数据类型使用了特定的无符号生命。数据类型char是一个例外。尽管大多数编译器和机器将他们视为有符号数,但C标准不保证这一点。相反,正如方括号指示的那样,程序员应该用有符号字符的声明来保证其为一个字节的有符号数值。不过,在很多情况下,程序行为对数据类型char是有符号的还是无符号的并不民管。
对关键字的顺序以及包括还是省略可选关键字来说,C语言允许存在多种形式。比如,下面所有的声明都是一个意思:
unsigned long
unsigned long int
long unsigned
long unsigned int
我们将始终使用图2-3给出的格式。
图2-3还展示了指针(例如一个被声明为类型为“char*”的变量)使用程序的全字长。大多数机器还支持两种不同的浮点数格式:单精度(在C中声明为float)和双精度(在C中声明为double)这些格式分别使用了4个字节和8个字节。
给C初学者: 声明指针
对于任何数据类型T,声明:T *p;
表明p是一个指针变量,指向一个类型为T的对象。例如:char *p;
就将一个指针声明为指向一个char类型的对象。
程序员应该力图使他们的程序在不同的机器和编译器上可移植。可移植性的一个方面就是使程序对不同数据类型的确切大小不敏感。C语言标准对不同数据类型的数字范围设置了下界(这点在后面还将讲到),但是却没有上界。因为从1980年左右到2010年左右,32位机器和32位程序是主流的组合,许多程序的编写都假设为图2-3中32位程序的字节分配。随着64位机器的日益普及,在这些程序移植到新机器上时,许多隐藏的对字长的依赖性就会显现出来,成为错误。比如,许多程序员假设一个声明为int类型的程序对象能被用来存储一个指针。这在大多数32位机器上能正常工作,但是在一台64位的机器上却会导致问题。
二.1.3 寻址和字节顺序
对于跨越多字节的程序对象,我们必须建立两个规则,以及在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。例如,假设一个类型为int的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100.那么,(假设数据类型int为32位表示)x的4个字节将将被存储在内存的0x100、0x101、0x102和0x103位置。
排列表示一个对象的字节有两个通用的规则。考虑一个w位的整数,其位表示为[x(w-1),x(w-2),……x1,x0],其中x(w-1)是最高有效位,而x0是最低有效位。假设w是8的倍数,这些位就能被分组成为字节,其中最高有效字节包含位[x(w-1),x(w-2),…,x(w-8)],而最低有效字节包含位[x7,x6,…,x0],其他字节包含中间的位。某些机器选择在内存中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效字节的顺序存储。前一种规则——最低有效字节在最前面的方式,称为小端法(little endian)。后一种规则——最高有效字节在最前面的方式,称为大端法(big endian)。
假设变量x的类型为int,位于地址0x100处,它的十六进制值为0x1234567。地址范围0x100~0x103的字节顺序依赖于机器的类型:
注意,在字0x01234567中,高位字节的十六进制值为0x01,而低位字节值为0x67。
大多数Intel兼容机都只用小端模式。另一方面,IBM和Oracle(从其2010年收购Sun Microsystems开始)的大多数机器则是按大端模式操作。注意我们说的是“大多数”。这些规则并没有严格按照企业界限来划分。比如,IBM和Oracle制造的个人计算机使用的是Intel兼容的处理器,因此使用小端法。许多比较新的微处理器是双端法(bi-endian)。也就是说可以把它们配置成作为大端或者小端的机器运行。然而,实际情况是:一旦选择了特定操作系统,那么字节顺序也就固定下来。比如,用于许多移动电话的ARM微处理器,其硬件可以按小端或大端两种模式操作,但是这些芯片上最常见的两种操作系统——Android(来自Google)和IOS(来自Apple)——却只能运行于小端模式。
令人吃惊的是,在哪种字节顺序是合适的这个问题上,人们表现得非常情绪化。实际上,术语“little endian(小端)”和“big endian(大端)”出自Jonathan Swift的《格利佛游记》(Gulliver‘s Travels)一书,其中交战的两个派别无法就应该从哪一端(小端还是大端)打开一个半熟的鸡蛋达成一致。就像鸡蛋的问题一样,选择何种字节顺序没有技术上的理由,因此争论沦为关于社会政治论题的争论。只要选择了一种规则并且始终如一地坚持,对于哪种字节排序的选择都是任意的。
旁注: “端”的起源
以下是Jonathan Swift在1726年关于大小端之争历史的描述:
“……我下面要告诉你的是,Lilliput和Blefuscu这两个大强国在过去的36个月里一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端,科室当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。老百姓们对这项命令极为反感。历史告诉我们,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位。这些叛乱大多都是由Blefuscu的过往大臣们煽动起来的。叛乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次由11000人情愿受死也不肯去打破鸡蛋较小的一端。关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派的任何人不得做官。”(此段译文摘自网上蒋剑锋译的《格利佛游记》第一卷第4章。)
在他那个时代,Swift是在讽刺英国(Lilliput)和法国(Blefuscu)之间的持续冲突。Danny Cohen,一位网络协议的早期开创者,第一次使用这两个术语来指代字节顺序,后来这个术语被广泛接纳了。
对于大多数应用程序员来说,其机器所使用的字节顺序是完全不可见的。无论为哪种类型的机器所编译的程序都会得到同样的结果。不过有时候,字节顺序会成为问题。首先是在不同类型的机器之间通过网络传送二进制数据时,一个常见的问题是当小端法机器产生的数据被发送到大端法机器或者反过来时,接收程序会发现,字里的字节成了反序的。 为了避免这类问题,网络应用程序的代码编写必须遵守已建立的关于字节顺序的规则,以确保发送方机器将它的内部表示转换成网络标准,而接收方机器则将网络标准转换为它的内部表示。我们将在第11章中看到这种转换的例子。
第二种情况是,当阅读表示整数数据的字节序列时字节顺序也很重要。这通常发生在检查机器级程序时。作为一个示例,从某个文件中摘出了下面这行代码,该文件给出了一个针对Intel x86-64处理器的机器级代码的文本表示:
4004d3: 01 05 43 0b 20 00 add %eax,0x200b43(%rip)
这一行是由反汇编器(disassembler)生成的,反汇编器是一种确定可执行程序文件所表示的指令序列的工具。我们将在第三章中学习有关这些工具的更多知识,以及怎样解释像这样的行。而现在,我们只是注意这行表述的意思是:十六进制字节串01 05 43 0b 20 00 是一条指令的字节级表示,这条指令是把一个字长的数据加到一个值上,该值的存储地址由0x200b43加上当前程序计数器的值得到,当前程序计数器的值即为下一条将要执行指令的地址。如果取出这个序列的最后4个字节:43 0b 20 00,这就是右边的数值。当阅读像此类小端法机器生成的机器级程序表示时,经常会将字节按照相反的顺序显示。书写字节序列的自然方式是最低位字节在左边,而最高位字节在右边,这正好和通常书写数字时最高有效位在左边,最低有效位在右边的方式相反。
字节顺序变得重要的第三种情况是当编写规避正常的类型系统的程序时。在C语言中,可以通过使用强制类型转换(cast)或联合(union)来允许以一种数据类型引用一个对象,而这种数据类型与创建这个对象时定义的数据类型不同。大多数应用编程都强烈不推荐这种编码技巧,但是它们对系统级编程来说是非常有用的,甚至是必须的。
图2-4展示了一段C代码,它使用强制类型转换来访问和打印不同程序对象的字节表示。我们用typedef将数据类型byte_pointer定义为一个指向类型为“unsigned char”的对象的指针。这样一个字节指针引用一个字节序列,其中每个字节都认为是一个非负数。第一个例程show_bytes的输入是一个字节序列的地址,它用一个字节指针以及一个字节数来指示。该字节数定义为数据类型size_t,表示数据结构大小的首选数据类型。show_bytes打印出每个以十六进制表示的字节。C格式化指令“%.2x”表明整数必须用至少两个数字的十六进制格式输出。
过程show_int、show_float和show_pointer展示了如何使用程序show_bytes来分别输出类型为int、float和void* 的C程序对象的字节表示。可以观察到它们仅仅传递给show_bytes一个指向它们参数x的指针&x,且这个指针被强制类型转换为“unsigned char *”。这种强制类型转换告诉编译器,程序应该把这个指针看成指向一个字节序列,而不是指向一个原始数据类型的对象。然后,这个指针会被看成是对象使用的最低字节地址。
这些过程使用C语言的运算符sizeof来确定对象使用的字节数。一般来说,表达式sizeof(T)返回存储一个类型为T的对象所需要的字节数。使用sizeof而不是一个固定的值,是向编写在不同机器类型上可移植的代码迈进了一步。
在几种不同的机器上运行如图2-5所示的代码,得到如图2-6所示的结果。我们使用以下几种机器:
linux32:运行linux的Intel IA32处理器。
Windows:运行Windows的Intel IA32处理器。
Sun:运行Solaris的Sun Microsystems SPARC处理器。(这些机器现在由Oracle生产。)
linux64:运行linux的Intel x86-64处理器。
参数12345的十六进制表示为0x00003039。对于int类型的数据,除了字节顺序以外,我们在所有机器上都得到相同的结果。特别地,我们可以看到linux 32、Windows和linux 64上,最低有效字节值0x39最先输出,这说明它们是小端法机器;而在Sun上最后输出,这说明Sun是大端法机器。同样地,float数据的字节,除了字节顺序以外,也都是相同的。另一方面,指针值却是完全不同的。不同的机器/操作系统配置使用不同的存储分配规则。一个值得注意的特性是linux 32、Windows和Sun的机器使用4字节地址,而linux 64使用8字节地址。
给C语言初学者: 试用typedef来命名数据类型
C语言中的typedef声明提供了一种给数据类型命名的方式。这能够极大地改善代码的可读性,因为深度嵌套的类型声明很难读懂。
typedef的语法与声明变量的语法十分相像,除了它使用的是类型名,而不是变量名。因此,图2-4中byte_pointer的声明和将一个变量声明为类型“unsigned char *”有相同的形式。
例如,声明:
typedef int *int_pointer; int_pointer ip;
将类型“int_pointer”定义为一个指向int的指针,并且声明了一个这种类型的变量ip。我们还可以将这个变量直接声明为:
int *ip;
给C语言初学者: 使用printf格式化输出
printf函数(还有它的同类fprintf和sprintf)提供了一种打印信息的方式,这种方式对格式化细节有相当大的控制能力。第一个参数是格式串(format string),而其余的参数都是要打印的值。在格式串里,每个以“%”开始的字符串序列都表示如何格式化下一个参数。典型的示例包括:‘%d’是输出一个十进制整数,‘%f’是输出一个浮点数,而‘%c’是输出一个字符,其编码由参数给出。
指定确定大小数据类型的格式,如int32_t,要更复杂一些,相关内容参见二.2.3节旁注
可以观察到,尽管浮点型和整形数据都是对数值12345编码,但是它们有截然不同的字节模式:整形为0x00003039,而浮点数为0x4640E400。一般而言,这两种格式使用不同的编码方法。如果我们将这些十六进制模式扩展为二进制形式,并且适当地将它门移位。就会发现一个有13个相匹配的位的序列,用一串星号标识出来:
这并不是巧合。当我们研究浮点数格式时,还将再回到这个例子。
给C语言初学者 指针和数组
在函数show_bytes(图2-4)中,我们看到指针和数组之间紧密的联系,这将在3.8节中详细描述。这个函数有一个类型为byte_pointer(被定义为一个指向unsigned char的指针)的参数start,但是我们在第8行上看到数组引用start[i]。在C语言中,我们能够用数组表示法来引用指针,同时我们也能用指针表示法来引用数组元素。在这个例子中,引用start[i]表示我们想要读取以start指向的位置为起始的第i个位置处的字节。
给C语言初学者 指针的创建和间接引用
在图2-4的第13、17和21行,我们看到对C和C++中两种独有操作的使用。C的“取地址”运算符&创建一个指针。在这三行中,表达式&x创建了一个指向保存变量x的位置的指针。这个指针的类型取决于x的类型,因此这三个指针的类型分别为int*、float*和void** 。(数据类型void*是一种特殊类型的指针,没有相关联的类型信息。)
强制类型转换运算符可以将一种数据类型转换为另一种。因此,强制类型转换(byte_pointer)&x表明无论指针&x以前是什么类型,它现在就是一个指向数据类型为unsigned char的指针。这里给出的这些强制类型转换不会改变真实的指针,它们只是告诉编译器以新的数据类型来看待被指向的数据。
旁注: 生成一张ASCII表
可以通过执行命令man ascii来得到一张ASCII字符码的表。
练习题2.5 思考下面对show_bytes的三次调用:
int val = 0x87654321;
byte_pointer valp = (byte_pointer) &val;
show_bytes(valp,1); /*A.*/
show_bytes(valp,2); /*B.*/
show_bytes(valp,3); /*C.*/
指出在小端法机器和大端法机器上,每次调用的输出值。
A. 小端法: 大端法:
B. 小端法: 大端法:
C. 小端法: 大端法:
练习题2.6 使用show_int和show_float,我们确定整数3510593的十六进制表示为0x00359141而浮点数3510593.0的十六进制表示为0x4A564504。
A. 写出这两个十六进制值的二进制表示。
B. 移动这两个二进制串的相对位置,使得它们相匹配的位数最多。有多少位相匹配呢?
C. 串中的什么部分不相匹配?