从苹果A7处理器开始,就支持着两种不同的指令集:第一种为原有处理器所支持的32-bit ARM指令集,第二种为崭新的64-bit ARM体系结构。这种64-bit体系结构拥有更大的地址空间,最大支持16GB内存,同时它一次性可提取64位数据,比32-bit体系提高了一倍。现如今,苹果的LLVM编译器已经能够充分支持64-bit指令集。
正如苹果A7处理器一样,支持64-bit指令集的处理器已经很普遍了,如AMD公司的AMD-64、Intel公司的EM64T及IA-64。处理器属于硬件,那么硬件的更新必然引起软件的更新。微软的Windows 7就提供了64位版本的操作系统以支持64位处理器,类Unix操作系统方面也有相应的64位版本,如Ubuntu 12.04。除了操作系统外,我们在操作系统中运行的软件也需要更新,以充分利用64位处理给我们带来的便利。
接下来,我们主要研究一些C类语言代码从32位平台移植到64位平台的案例,分析其中存在的问题,再给出解决方案。
2分析及实现2.1平台的相关变化
不同平台之间的代码移植通常需要满足以下几点:首先,兼容原来的平台;其次,能在新的平台上运行良好;再次,不同平台之间的程序能够正常交互。
我们现在的任务是要实现32位平台的代码向64位平台移植,上述要求也是移植后需要达到的性能目标。我们移植的步骤如下:
- 编译生成64位代码;
- 消除编译错误与警告;
- 性能目标测试。
2.1.1 ILP32、LP64及LLP64数据模型
32位平台使用的是ILP32数据模型,而64位平台使用的是LP64数据模型。由于32位平台上的C数据类型int、long及指针的长度为32位,因此称为ILP32数据模型;而64位平台上的C数据类型long及指针的长度是64位,故称为LP64数据模型,64位的Windows上称为LLP64(long long、pointer)数据模型。
现如今,所有64位的Unix-like平台均使用LP64数据模型,64位Windows使用的是LLP64数据模型。ILP32与LP64(或LLP64)之间的差异是实现代码移植需要关注的焦点之一。
表1-1 ILP32、LP64(或LLP64)数据模型的类型长度与对齐差异
类型 |
ILP32长度(byte) |
ILP32对齐(byte) |
LP64长度(byte) |
LP64对齐(byte) |
char |
1 |
1 |
1 |
1 |
short |
2 |
2 |
2 |
2 |
int |
4 |
4 |
4 |
4 |
long |
4 |
4 |
8 |
8 |
long long |
8 |
4 |
8 |
8 |
pinter |
4 |
4 |
8 |
8 |
从上表可以看出,从32平台到64平台主要变化在于long、long long及指针这三种类型。它们可能带来的影响集中在类型截断、格式化输出及类型对齐这几方面。
2.2如何编译生成64位代码
在一些情况中,32位和64位程序在源代码级别的接口上很难区分。不少头文件中,都是通过一些测试宏来区分它们,不幸的是,这些特定的宏依赖于特定的平台、特定的编译器或特定的编译器版本。举例来说,GCC 3.4或之后的版本都定义了__LP64__,以便为所有的64位平台通过选项-m64编译产生64位代码。然而,GCC 3.4之前的版本却是特定于平台和操作系统的。 也许你的编译器使用了不同于__LP64__的宏,例如IBM XL的编译器当用-q64编译程序时,使用了__64bit__宏,而另一些平台使用_LP64,具体情况可用__WORDSIZE来测试一下。请查看相关编译器文档,以便找出最适合的宏。例2.1可适用于多种平台和编译器:
例2.1:类unix系统上的64位代码测试宏
#if defined (__LP64__) || defined (__64BIT__) || defined (_LP64) || (__WORDSIZE == 64)
printf("I am LP64\n");
#else
printf("I am ILP32 \n");
#endif
上例使用的测试宏并不适用于Windows平台,Windows上的64位测试宏使用的是_WIN64,见例2.2。
例2.2:Windows上的64位代码测试宏
#ifdef _WIN64
printf("I am LLP64\n");
#else
printf("I am ILP32 \n");
#endif
此外,打开一些编译警告,有助于我们发现移植过程中的潜在问题。以gcc编译器为例,-Wall可以发现大部分的警告信息;-Wconversion可以发现不同尺寸的数据类型之间的转换警告信息,这些信息对我们的移植过程是很有帮助的。
最后,消除编译时期产生的警告也有助于我们查看编译信息,尤其是当我们修改了代码以后产生的新的编译信息。
2.2字节序差异
2.2.1 大端字节序(Big-Endian)、小端字节序(Little-Endian)与网络字节序
大端字节序:高位字节存放在内存的低地址段,低位字节存放在内存的高地址端;
小端字节序:低位字节存放在内存的低地址段,高位字节存放在内存的高地址端;
网络字节序:TCP/IP各层协议将字节序定义为Big-Endian,因此TCP/IP协议中使用的字节序通常称之为网络字节序。
以 int = 0x12345678为例,则Big-Endian与Little-Endian在内存中的存储方式可表示为:
字节号: 3 2 1 0
大端字节序: 0x78 0x56 0x34 0x12
小端字节序: 0x12 0x34 0x56 0x78
由此可知,小端字节序是符合我们通常二进制运算习惯的(从右往左地址递增),而大端字节序刚好相反(从右往左地址递减)。
2.2.2字节序引起的截断
因64位平台的差异,在移植32位程序时,可能会失败,原因可归咎于机器上字节序的不同。Intel、IBM PC等CISC芯片使用的是小端字节序,而Apple之类的RISC芯片使用的是Big-endian;小端字节序通常会隐藏移植过程中的截断bug。
例2.3: 大端字节序内存操作引起的截断
int main(void)
{
long k = 2;
int *ptr = (int*)&k;
printf("k has the value %lx, value pointed to by ptr is %x\n", k, *ptr);
return 0;
}
一个声明指向int的指针,却不经意间指向了long。在ILP32模型中,这段代码打印出2,因为int与long长度一样。但在LP64模型中,由于int与long的长度不一样,导致指针被截断。在小端字节序的系统中,代码依旧会给出k的正确答案2,但在大端字节序系统中,k的值却是0。
2.2.3字节序截断误区
看到上面的例子,可能会给我们带来很大的疑惑:在大端字节序机器上的LP64数据模型中,若将long型变量(值小于231-1)赋给int型变量,结果会不会是0呢?我们可以将上述代码改成下面这样:
例2.4:算术运算跟字节序无关
int main(void)
{
long k = 2;
int a = (int)k;
printf(“k has the value %ld, value pointed to by a is %d\n”, k, a);
return 0;
}
幸运的是,a的值是2不是0。为什么说是幸运?为什么a的值不向前一个例子那样为0?这就涉及到算术运算与内存操作的问题。
算术运算是不改变被运算数据的字节序的,此时我们不用关心所操作的数据到底是什么字节序的。但内存操作就需要注意了,若我们将一个大类型指针强制转换为一个小类型的指针(如例1.1中的long*转换为int*),这时候我们就必须关注字节序问题。不然写的代码要么是错误的,要么一到其他机器上就不能正常运行。
常见的算术运算有:+、-、*、/、%、&、|、~、<<、>>、=等,请注意位运算符(&、|、~、<<及>>等)是属于算术运算的范畴而不是内存操作,所以他们进行混合运算时不用关心字节序问题。赋值运算符只有在数据类型兼容的时候才不涉及字节序问题,才属于算术运算范畴。
常见的内存操作有:强制转换后对碎片数据的算术运算、内存读写及文件读写等。
3解决结果准备工作已就绪,接下来可以开始移植了。移植过程的主要内容就是消除编译错误与告警信息,真正需要修改的地方其实较少。这些问题中,又以截断问题最为突出。
3.1截断问题
截断问题的常见情况是把类型兼容的长类型值经过算术运算后赋给短类型值,我把它称为类型兼容的截断,这类截断在编译时已警告形式给出,如果结果值没有超出结果类型所能表达的范围,那么这类截断是安全的,我们可以通过在赋值前将其强制转化为结果类型以消除警告(见例3.1)。
例3.1:类型兼容的转换截断
char str[] = “Hello World”;
unsigned int a = strlen(str) ; // 在LP64模型中截断告警
unsigned int a1 = (unsigned int)strlen(str) ; // 消除警告(不推荐)
size_t a2 = strlen(str); // 消除警告(推荐)
或
int nRet = recv(hSock, &str, sizeof(str), 0); // 在LP64模型中截断警告
int nRet1 = (int)recv(hSock, &str, sizeof(str), 0); // 消除浸膏(不推荐)
ssize_t nRet2 = recv(hSock, &str, sizeof(str), 0); // 消除警告(推荐)
例3.1中strlen的返回值是size_t型,其在ILP32模型中是unsigned int型,但在LP64(或LLP64)模型中是unsigned long(或unsigned long long)型。由于我们知道str的长度不会超过unsigned int型所能表达的最大值232-1,因此可以直接在赋值前现将strlen的返回值强制转化为结果类型unsigned int以消除警告,这种消除警告的方式在我们不知道结果值的长度时(如STL中的size()函数)就不推荐了。
例3.1中还出现了一种ssize_t型转化为int型的截断,这类截断通常发生在LP64模型中的fwrite/fread,recv/send,recvfrom/sendto之类的操作中出现。ssize_t意为signed size_t,其在ILP32模型中是int型,在LP64模型中为long型。
另一类截断是将指针类型赋给int型变量引起的截断,可把它称为类型不兼容的转换截断(见例3.2)。
例3.2:类型不兼容的转换截断
char str[] = “Hello world”;
int para1 = (int)str; // 错误,在LP64(或LLP64)模型中编译报错
intptr_t para2 = (intptr_t)str; // 正确
由于str为指针类型,强制转换操作属于类型不兼容的,因此需要考虑字节序问题。在代码中我们应该避免将指针类型转化为int型变量,因为在ILP32模型中,指针占4个字节与int型长度相同;但在LP64模型中指针占8字节,我们把8字节的指针地址转化为4字节的整形值是类型不兼容的,幸好编译器能够发现这一错误。
因此,在获取指针地址值的时候我们应当使用多态类型intptr_t或uintptr_t,它们两个的尺寸分别与ssize_t与size_t相同,只是名称赋给了它们特殊的意义。值得注意的是,intptr_t与uintptr_t不是指针类型,不能按字面意思去理解,它们通常的作用就是获取指针的地址。
例3.3:缺少原型的截断
// 参数类型与原型不符的截断
long a,b ;
scanf(“%d”, &a); // 编译告警
scanf(“%ld”, &b); //正确
// 返回值与原型不符的截断
char str[128] = {0};
int nRet = recv(hSock, &str, sizeof(str), 0);
上述代码中scanf函数试图在变量a中插入一个32位的值,剩下的32位就不管了,这在LP64模型中编译会告警。如果格式化字符串与实际类型不匹配的话,类似的告警信息也会出现在printf()等格式化输入输出函数中。在格式化输出多态类型(如size_t等)时,我们需要针对不同数据模型进行不同的格式化输出(见例3.4)。
缺少原型的截断这类情况主要发生在函数调用时,传递的参数或返回值类型与原型不符所致。
3.2符号扩展与零扩展问题
所谓符号扩展是指,短数据类型的符号位填充到长数据类型的高字节位(比短数据类型多出的那部分),以保证扩展后的数值大小不变。
零扩展是指,用零来填充长数据类型的高字节位。
当将短数据类型值赋给长数据类型变量时,C系列的语言使用一些符号扩展规则来决定目标是否有符号位。扩展规则如下:
- 相同尺寸的有符号与无符号值相加为无符号值;
- 无符号值转化为长类型后是零扩展的;
- 符号值转化为长类型后是符号扩展的,即使结果值是无符号类型的;
- 常量(除非有后缀修饰,如0x8L)作为能容纳其值的最小尺寸类型对待。以16进制书写的值将被编译为signed或unsigned的int、long与long long类型,而十进制数字常被编译为有符号类型。
- 当数据类型转换时,同时需要在不同数据大小,以及无符号和有符号之间转换时,C语言标准要求先进行数据大小的转换,之后再进行无符号和有符号之间的转换。
- char,short,枚举类及位域等短类型的提升结果都是int型;
short a = 2;
short b = a – 1; // 编译告警:int 型转short会损失精度
例3.5:符号扩展
int a = -2;
unsigned int b = 1;
long c = a + b; // 警告,无符号扩展
long long d = c;
printf(“%lld\n”, d);
这段代码在32位平台上的输出结果为-1(0xffffffff),而在64位平台输出的结果将是4294967295(0x00000000ffffffff)。为什么会这样呢?首先,a+b得到的结果是无符号扩展的(规则1);随后,无符号结果提升为long型,是无符号扩展的(规则2),因此c是无符号扩展的。针对此类问题,我们的解决方法是,在进行赋值运算前将b强制转化为符号扩展的long型(即 long c = a + (long)b;)。
例3.6:零扩展
unsigned short a = 1;
unsigned long b = (a << 31);
unsigned long long c = b;
printf(“%llx\n”, c);
这段代码在32位平台上运行的结果是0x80000000,而在64位平台上的结果将是0xffffffff80000000。这一过程是这样的:首先,根据规则5,a<<31运算值范围在int型以内,因此结果为符号扩展的;随后,符号值结果值提升为类型较长的unsigned long型,根据规则3,结果依然是符号扩展的,即b是有符号值,同理,c也是符号扩展的。
3.3 对齐问题
由于ILP32与LP64模型中类型对齐不一致,因此若果在联合体中出现long型数据,在代码移植时就会存在问题。
例3.7:联合体对齐问题
union{
unsigned long bytes;
unsigned short len[2];
}size;
由于long型数据在64位平台上占用8个字节,而len数组占用的空间为4个字节,这会引起错误。要修正这一问题,只需把bytes的类型改为unsigned int。
类似的对齐问题也会出现在结构体中,见例3.8。
例3.8:结构体对齐问题
struct {
int foo0;
int foo1;
int foo2;
long long bar;
};
当例2.8中的代码在32位平台中编译时,成员bar的起始地址在结构体首地址偏移12字节处;而在64位平台上编译时,偏移值变成了16。这是由于long long型变量在32位于64位平台上的对齐方式不一样所致(分别为4字节与8字节)。
要修正这一问题,我们可以将结构体的对齐方式通过#pragma pack(4)强制指定为按4字节对齐,这只有在需要的情况下才应该这样做。
3.4共享数据问题
共享数据主要是指进程间的通信数据,如套接字通信。这其中的主要问题就是字节序问题及数据对齐问题。
进程间通信数据应当避免使用long型,以免造成32位平台与64位平台通信时产生不正确的结果。
在套接字通讯时,发送整形数据前需要调用htonl()或htons()函数将主机序转化为网络序,接收整形数据时需要调用ntohl()或ntohs()函数将网络序转化为主机序。
3.5文件操作问题
文件操作问题类似于共享数据问题,向文件中读写的内容中不应该包含long型,如果非得这么做的话,可以通过将32位平台与64位平台的数据分别存放在不同的文件中。
4总结通过前面的分析,并结合自身实践,我们可以总结出以下一些有助于代码移的经验规则。
1.不要将指针转化为整形;
2.避免使用long型;
3.使用相同的符号类型(signed或unsigned)来进行逻辑运算;
4.使用固定的尺寸及对齐方式来创建数据结构;
5.使用sizeof来计算变量或类型的长度;
6.更新格式化字符串的输入输出;
7.使用函数原型;
8.确保代码在32位平台运行良好。