为16、32、64位架构编写可移植代码
与16位相比,32位意味着程序更快、可直接寻址访问更多的内存和更好的处理器架构。鉴于此,越来越多的程序员已经开始考虑利用64位处理器所带来的巨大优势了。
克雷研究(Cray Research 应为品牌名)计算机已经开始使用64位字,并可访问更大的内存地址。然而,作为正在向开发标准软件和与其他操作系统相互努力的一部分,我们已经停止了移植那些原本基于32位处理器的代码。事实上,我们不断遇到我们称之为“32位主义”的代码,这些代码都是在假定机器字长为32位的情况下编写的,因而很难移植这种代码,所以必须确立一些简单的指导方针,以便助于编写跨16、32、64位处理器平台的代码。
由于有一些遗留问题,
C语言在数据类型和数据构造方面,显得有点过剩了。可以使用的不仅仅是char、short、int和long类型,还有相应的unsigned(无符号)类型,当然你可在结构(structure)和联合(union)中混合使用,可以在联合中包含结构,再在结构中包含联合,如果还嫌数据类型不够复杂,还可以转换成比特位,当然也可以把一种数据
类型转换成另一种你想要的数据类型。正是因为这些工具太强大,如果不安全地使用它们,就有可能会伤到自己了。
高级代码的高级结构 在Kernighan和Plauger经典的《The Elements of Programming Style》一书中,他们的建议是“选择使程序看上去简单的数据表示法”。对个人而言,这意味着为高级编程使用高级
数据结构,而低级编程使用低级数据结构。
在我们移植的程序中,有一个常见的32位主义bug,不如拿它来做个例子,科内尔大学编写的用于网间互访的路由协议引擎,在伯克利网络环境下(指TCP/IP),自然是使用inet_addr( )来把表示Internet地址的
字符串转换成二进制形式。Internet地址碰巧也是32位的,就如运行伯克利网络系统的其他计算机一样,都是有着同样的字宽。
但也存在着有关Internet地址的高级定义:in_ addr结构。这个结构定义包括了子域s_ addr,它是一个包含了Internet地址的unsigned long标量。inet_addr()接受一个指向字符的指针,并且返回一个unsigned long,在转换地址字符串过程中如果发生错误,inet_addr将返回-1。
程序Gated读取以文本格式存放Internet地址的配置文件,并把它们放入sockaddr_in(这是一个包含了结构in_addr的高级结构)。例1(a)中的代码可以在32位电脑上正常运行,但移植到克雷研究计算机上,却无法运行,为什么呢?
例1:高级代码的高级结构
(a)
struct sockaddr_in saddrin char *str;
if ((saddrin.sin_addr.s_addr = inet_addr(str)) == (unsigned long)-1) { do_some_error_handling; } |
(b)
struct sockaddr_in saddrin char *str;
if (inet_aton(str, &saddrin.sin_addr) ! = OK) { do_some_error_handling; } |
因为只要inet_addr能够正确地解析字符串,那么一切OK。当inet_addr在64位计算机上返回一个错误时,这段代码却未能捕捉到。你必须要考虑比较语句中的数据位宽,来确定到底是哪出了错。
首先,inet_addr返回错误值——unsigned long -1,在64位中表示为比特位全为1,这个值被存储在结构in_addr下的子域s_addr中,而in_addr必须是32位来匹配Internet地址,所以它是一个32比特位的unsigned int(在我们的编译器上,int是64位)。现在我们存储进32个1,存储进的值将与unsigned long -1比较。当我们存储32个1于unsigned int时,编译器自动把32位提升为64位;这样,数值0x00000000 ffffffff与0xffffffff ffffffff的比较当然就失败了。这是一个很难发现的bug,特别是在这种因为32位到64位的隐式提升上。
那我们对这个bug怎么办呢?一个解决方法是在语句中比较0xffffffff,而不是-1,但这又会使代码更加依赖于特定大小的对象。另一个方法是,使用一个中间的unsigned long变量,从而在把结果存入sockaddr_in前,执行比较,但这会让程序代码更复杂。
真正的问题所在是,我们期望一个unsigned long值与一个32位量(如Internet地址)相等。Internet地址必须以32位形式进行存储,但有些时候用一个标量,来访问这个地址的一部分,是非常方便的。在32位字长的电脑中,用一个long数值(常被当作32位)来访问这个地址,看上去没什么问题。让我们暂时不想一个低级的数据项(32位Internet地址)是否与一个机器字相等,那么高级数据类型结构in_addr就应该被一直使用。因为in_addr中没有无效值,那么应有一个单独的状态用作返回值。
解决方案是定义一个新的函数,就像inet_addr那样,但返回一个状态值,而且接受一个结构in_addr作为参数,参见例1(b)。因为高级的数据元素可以一直使用,而返回的值是没有溢出的,所以这个代码是可以跨架构移植的,而不管字长是多少。虽然伯克利发布了NET2,其中的确定义了一个新的函数inet_aton(),但如果试着改变inet_addr()中的代码,将会损坏许多程序。
低级代码的低级结构 低级编程意味着直接操纵物理设备或者特定协议的通讯格式,例如,设备驱动程序经常使用非常精确的位模式来操纵控制寄存器。此外,
网络协议通过特定的比特位模式传输数据项时,也必须适当地转译。
为了操纵物理数据项,此处的数据结构必须准确地反映被操纵的内容。比特位是一个不错的选择,因为它们正好指定了比特的位数及排列。事实上,正是这种精确度,使比特位相对于short、int、long,更好地映像了物理结构(short、int、long会因为电脑的不同而改变,而比特位不会)。
当映像一个物理结构时,是通过定义格式来使比特位达到这种精度的,这就使你必须一直使用一种编码风格来访问结构。此时的每个位域都是命名的,你写出的代码可直接访问这些位域。当访问物理结构时,有件事可能你并不想去做,那就是使用标量数组(short、int、or long),访问这些数组的代码都假定存在一个特定的比特位宽,当移植这些代码到一台使用不同字宽的电脑上时,就可能不正确了。
在我们移植PEX图像库时遇到的一个问题,就涉及到其映像的协议消息结构。在某台电脑上,如果int整型的长度与消息中的元素一样,那例2(a)中的代码就会工作得很正常。32位的数据元素在32字长的电脑上没有问题,拿到64位的克雷计算机上,它就出错了。对例2(b)而言,不单要改变结构定义,还要改变所有涉及到coord数组的代码。这样,摆在我们面前就有两个选择,要么重写涉及此消息的所有代码,要么定义一个低级的结构和一个高级的结构,然后用一段特殊的代码把数据从一个拷贝到另一个当中,不过我也不期望可以找出每个对zcoord = draw_ msg.coord[2]的引用,而且,当现在需要移植到一个新架构上时,把所有的代码都改写成如例2(c)所示无疑是一项艰苦的工作。这个特殊的问题是由于忽视字长的不同而带来的,所以不能假设在可移植的代码中机器字长、short、int、long都是具有同样的大小。
例2:低级代码的低级结构
(a)
struct draw_msg { int objectid; int coord[3]; } |
(b)
struct draw_msg { int objectid:32; int coord1:32; int coord2:32; int coord3:32; } |
(c)
int *iptr, *optr, *limit; int xyz[3];
iptr = draw_msg.coord; limit = draw_msg.coord + sizeof(draw_msg.coord);
optr = xyz; while (iptr < limit) *optr++ = *iptr++; |
结构打包和字对齐
正是因为编译器会对结构进行打包,所以不同计算机上字长的变化,还导致了另一个问题。C编译器在字(word)的边界上对齐字长,当具有一个字长的数据后面紧接着一个较小的数据时,这种方法会产生内存空缺(不过也有例外,比如说当有足够多的小数据刚好可以填充一个字时)。
一些聪明的程序员在声明联合时,往往在其中会带有两个或更多的结构,其中一个结构刚好填充联合,另一个则可以用来从不同的角度来看待这个联合,参见例3(a)。假设这段代码是为16位字长的计算机所写,int为16位,long为32位,那么存取这个结构的代码将会得到正常的映射关系(如图1),而例3(b)中的代码也会按预期的那样工作。可是,如果这段代码一旦移植到另一台具有32位字长的计算机上时,映射关系就改变了。如果新计算机上的编译器允许你使用16位的int,那么字的对齐就会像图2所示了,或者如果编译器遵循K&R约定,那么int将会和一个字(32比特)一样长,对齐就如图3所示,在任一情况下,这都将导致问题。
例3:结构打包和字对齐
(a)
union parse_hdr { struct hdr { char data1; char data2; int data3; int data4; } hdr; struct tkn { int class; long tag; } tkn; } parse_item; |
(b)
char *ptr = msgbuf;
parse_item.hdr.data1 = *ptr++; parse_item.hdr.data2 = *ptr++; parse_item.hdr.data3 = (*ptr++ << 8 | *ptr++); parse_item.hdr.data4 = (*ptr++ << 8 | *ptr++);
if (parse.tkn.class >= MIN_TOKEN_CLASS && parse.tkn.class <= MAX_TOKEN_CLASS) { interpret_tag(parse.tkn.tag); } |
在第一个情况中(图2),tag域不是像期望的那样线性拉长,而被填充了一些垃圾。而在第二个情况中(图3),无论是class还是tag域,都不再有意义,两个char值因为被打包成一个int,所以也都不再正确。再次强调,首先不要假设标准数据类型大小一样,其次还要了解它们是怎样被映射成其他数据类型的,这才是书写可移植代码的最好方法。
机器寻址特性 几乎所有的处理器都在字边界上以字为单位进行寻址,而且通常都为此作了一些优化。另有一些的处理器允许其他类型的寻址,如以字节为单位寻址、或在半个字边界上以半字为单位寻址,甚至还有一些处理器有辅助硬件允许在奇数边界上同时以字和半字进行寻址。
寻址机制在不同计算机上会有所变化,最快的寻址模式是在字边界上以字为单位进行寻址。其他方式的寻址需要辅助硬件,通常都会对内存访问增加了一些时钟周期。而这些过多的模式和特殊硬件的支持,是与RISC处理器的设计初衷背道而驰的,就拿克雷计算机来说,就只支持在字边界上以字为单位进行寻址。
在那些不提供多种数据类型寻址方式的计算机上,编译器可以提供一些模拟。例如:编译器可以生成一些指令,当读取一个字时,通过移位和屏蔽,来找到所想要的位置,以此来模拟在字中的半字寻址,但这会需要额外的时钟周期,并且代码体积会更大。
从这点上来说,位域的效率是非常低的,在以位域来取出一个字时,它们产生的代码体积最大。当你存取同一个字中的其他位域时,又需要对包含这个位域字的内存,重新进行一遍寻址,这就是典型的以空间换时间。
当在设计数据结构时,我们总是想用可以保存数据的最小数据类型,来达到节省空间的目的。我们有时小气得经常使用char和short,来存取位特域,这就像是为了节省一角钱,而花了一元钱。储存空间上的高效,会付出在程序速度和体积上隐藏的代价。
试想你只为一个紧凑结构分配了一小点的空间,但却产生了大量的代码来存取结构中的域,而且这段代码还是经常执行的,那么,会因为非字寻址,而导致代码运行缓慢,而且充斥了大量用于提取域的代码,程序体积也因此增大。这些额外代码所占的空间,会让你前面为节省空间所做的努力付之东流。
在高级数据结构中,特定的比特定位已不是必须的了,应在所有的域中都使用字(word),而不要操心它们所占用的空间。特别是在程序某些依赖于机器的部分,应该为字准备一个typedef,如下:
/*在这台计算机上,int是一个字长*/ typedef word int; |
在高级结构中,对所有域都使用字有如下好处:
a.. 对其他计算机架构的可移植性
b.. 编译器可能生成最快的代码
c.. 处理器可能最快地访问到所需内存
d.. 绝对没有结构对齐的意外发生
必须也承认,在某些时候,是不能做到全部使用字的。例如,有一个很大的结构,但不会被经常存取,如果使用了数千个字的话,体积将会增大25%,但使用字通常会节省空间、提高执行速度,而且更具移植性。
以下是我们的结论: 书写跨平台移植的代码,其实是件简单的事情。最基本的规则是,尽可能地隐藏机器字长的细节,用非常精确的数据元素位大小来映射物理数据结构。或者像前面所建议的,为高级编程使用高级数据结构,而低级编程使用低级数据结构,当阐明高级数据结构时,对标准C的标量类型,不要作任何假设