概述
为了缩短开发过程、降低开发成本,我们提倡软件复用。软件复用中常见的一个场景就是在不同产品或平台上使用同一套代码。这里就引入一个软件可移植性的概念。广义上来说,软件的可移植性是指软件从某一环境转移到另一环境下的难易程度。这里的环境可能是不同的硬件、软件……
在本文中,可移植性特指从C语言角度而言,具体是指同一功能的C语言程序代码从某一平台(设备)移植到其他平台(设备)上时,为了保证其代码正常运行而需要进行修改的难易程度。良好的移植性需要满足以下两点
- 代码不需要修改,只需重新编译
- 程序可以正确运行
数据长度
概念
C语言中short、int、long等被称为基本数据类型,它们的长度在不同的平台间是不确定的,因此它们不具备可移植性。大多数现代机器都有8位字符,但有些有7位或9位字符。long通常至少为32位长,因此可以使用长整数来表示文件的大小。int通常至少为16位长,因为较短的整数会对数组的最大大小施加太多限制。short几乎总是16位。
不同平台对相同的类型长度可能有不同的定义,如下表所示
|
type |
32 bit |
64 bit(Linux) |
64 bit(Win64, MinGW-w64) |
|
sizeof(int) |
4 bytes |
4 bytes |
4 bytes |
|
sizeof(long) |
4 bytes |
8 bytes |
4 bytes |
|
sizeof(int*) |
4 bytes |
8 bytes |
8 bytes |
为了解决相同类型在不同平台长度不同的问题,各个平台中都会定义类似int32_t,int16_t这样的数据类型,他们都是不同的整型,但是长度是固定的,这是他们被称为可移植性数据类型的原因。他们的实现原理如下,比如在A平台中int是4字节的,那么A平台的int32_t是这么定义的:
|
typedef int int32_t; |
在B平台中,long long是4字节的,因此B平台的int32_t就是这么定义的:
|
typedef long long int32_t; |
这样就保证了不管用户程序在哪个平台运行,只要定义了int32_t的数据类型,就一定是4个字节的了。
问题说明
关于数据长度最常见的问题之一就是忽视了其在不同平台上的差异性。如下代码所示,该代码在64位平台上运行就会出错
|
void memcpy_unalign(unsigned char *dest, unsigned char *src, uint len) { unaligned_ul *s, *d; int size;
s = (unaligned_ul *)src; d = (unaligned_ul *)dest; size = (int)len; while (size > 0) { *d = *s; size -= 4; <- 这里大小只减去了4 s++; <- 指针++操作移动了8个字节 d++; } } |
与之类似的还有用int类型来储存指针或用指针来存储int类型的值。为了避免上述问题,我们需要使用可移植的数据类型。以Linux内核为例,当知道你定义的数据的大小时,可以使用内核提供的下列数据类型:
|
u8; /* unsigned byte (8 bits) */ u16; /* unsigned word (16 bits) */ u32; /* unsigned 32-bit value */ u64; /* unsigned 64-bit value */ |
虽然很少用到有符号类型,但是如果需要,只要用”s”代替”u”即可。在用户空间中,若一个程序用户空间需要使用这些类型,可在符号前加一个双下划线: __u8。在不同的平台、不同的编译器下,整型数据的取值范围可能会有所不同,用户可以读取该头文件中的宏来了解当前环境下整型数据的取值范围。C语言标准委员会发明了 <limits.h> 头文件来捕捉标量类型在不同计算机体系结构之间的变化。<limits.h> 头文件只对整数类型的取值范围进行了收集:
如果想获取与浮点数有关的特性,请转到 <float.h> 头文件;如果查看指定长度的整数类型,请转到 <stdint.h> 头文件。C99中,<stdint.h>中定义了几种扩展的整数类型和宏,规则如下(其中N可以为8,16,32,64):
intN_t, int_leastN_t, int_fastN_t:表示长度为N位的整型数;
uintN_t, uint_leastN_t, uint_fastN_t表示长度为N位的无符号整型数 ;
intN_t格式的宏指一个有N位的整数。例如,int16_t指一个16位的有符号的整数。uintN_t格式的宏指定一个有N位的无符号的整数。例如,uint32_t指定一个32位的无符号的整数。N值等于8、16、32和64的宏可在所有提供这些宽度的整数的环境中找到。
数据对齐
概念
现代计算机中内存空间都是按照byte划分的,理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定变量的时候经常在特定的内存地址访问,这就需要各类型数据按照一定的规则在空间上排列。具体到各个硬件平台上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。例如,ARM文档描述访问规则如下:
1. 一次访问4字节内容,该内容的起始地址必须是4字节对齐的位置上;
2. 一次访问2字节内容,该内容的起始地址必须是2字节对齐的位置上;
其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台的要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为 32位)如果存放在偶地址开始的地方,那么一个读周期就可以读出,而如果存放在奇地址开始的地方,就可能会需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该int数据,在读取效率上下降很多。
关于对齐有以下3个原则:
- 数据成员对齐规则:结构(struct或联合union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储)。
- 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。struct a里存有struct b,b里有char,int,double等元素,那b应该从8的整数倍开始存储。
- 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍,不足的要补齐。
问题说明
同样的数据结构在不同的平台上可能被不同地编译,比如相同的结构在32位和64位系统上大小可能是不同的。为了编写可以跨体系移植的数据结构,应当始终强制数据项的自然对齐。自然对齐(natural alignment)指的是:数据项大小的整数倍的地址上存储数据项。应当使用填充符避免强制对齐时编译器移动数据结构的字段,在数据结构中留下空洞。
内存分配对齐的几种方式如下:
|
#pragma pack(n) n的取值可以为1、2、4、8、16,在编译过程中按照n个字结对齐
#pragma pack() 取消对齐,按照编译器的优化对齐方式对齐
__attribute__ ((packed)); 是说取消结构在编译过程中的优化对齐
__attribute__ ((aligned (n))); 让所作用的成员对齐在n字节自然边界上,如果结构中有成员的长度大于n,则按照最大成员的长度来对齐 |
还有一个问题是如何访问未对齐的数据。存取不对齐的数据应当使用下列宏:
|
#include <asm/unaligned.h> get_unaligned(ptr); put_unaligned(val, ptr); |
这些宏是无类型的,并对各总数据项,不管是 1、2、4或 8 个字节,他们都有效,并且在所有内核版本中都有定义。
编译器可能悄悄地插入填充符到结构中,来保证每个成员是对齐的。若定义一个和设备要求的结构体相匹配结构,自动填充符会破坏这个意图。解决这个问题的方法是告诉编译器这个结构必须是"紧凑的", 不能增加填充符。例如下列的定义:
|
struct{ u16 id; u64 lun; u16 reserved1; u32 reserved2; }__attribute__ ((packed)) scsi; |
如果在 64-位平台上编译这个结构,且没有__attribute__ ((packed)), lun成员可能在前面被添加2个或6个填充符字节。
字节序
概念
字节序是指多字节数据在计算机内存中存储或者网络传输时各字节的存储顺序,也就是面向多字节类型定义的,比如2字节、4字节、8字节整型、长整型等。其分为大端和小端,大端(Big-Endian)就是最高有效位(Most Significant Bit)在前,内存存储体现为数据的高位更加靠近低地址
小端(Little-Endian)就是最低有效位(Most Significant Bit)在前,内存存储体现为数据的低位更加靠近低地址
网络字节序一般是指大端传输,主机字节序则依据不同主机而不同,如Intel x86都是小端字节序,而PowerPC是大端字节序。
问题说明
代码应该编写成不依赖所操作数据的字节序的方式,Linux内核中定义了__BIG_ENDIAN和__LITTLE_ENDIAN来表示当前系统是大端还是小端字节序,当处理字节序问题时,需要编码一堆类似#ifdef __LITTTLE_ENDIAN的条件语句。
在网络上传输数据时,由于数据传输的两端可能对应不同的硬件平台,采用的存储字节顺序也可能不一致,因此TCP/IP协议规定了在网络上必须采用网络字节顺序(也就是大端模式)。
对于IP地址、端口号等非char型数据,必须在数据发送到网络上之前将其转换成大端模式,在接收到数据之后再将其转换成符合接收端主机的存储模式。Linux系统为大小端模式的转换提供了4个函数,输入man byteorder命令可得函数原型:
|
#include <arpa/inet.h> uint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort); uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort); |
htonl表示host to network long,用于将主机unsigned int型数据转换成网络字节顺序;htons表示host to network short,用于将主机unsigned short型数据转换成网络字节顺序;ntohl、ntohs的功能分别与htonl、htons相反。
位域和位序
概念
首先,我们回顾下位域的概念。有些信息并不需要占用一个完整的字节,而只需占几个或一个二进制位。例如在存放一个开关变量时,只有0和1两种状态,用一位二进位即可。为了节省存储空间并使处理简便,C语言提供了一种数据结构,称为“位域”或“位段”,即把一个字节中的二进位划分为几个不同的区域,并说明每个区域的位数。每个域有一个域名,允许在程序中按域名进行操作。这样就可以把几个不同的对象用一个字节的二进制位域来表示,其形式为:
|
struct 位域结构名 { 位域列表 }; 其中位域列表的形式为: 类型说明符 位域名:位域长度
例如: struct bs { int a:8; int b:2; int c:6; }; |
接下来说明下位序的概念,上一节提到字节序是一个对象中的多个字节之间的顺序问题,那么位序就是一个字节中的8个比特位(bit)之间的顺序问题。一般情况下系统的位序和字节序是保持一致的。一个字节由8个bit组成,这8个bit也存在如何排序的情况,跟字节序类似的有最高有效比特位、最低有效比特位。
问题说明
机器的字长和字节序,会直接影响到“位域”的值。例如
|
typedef struct sntp_li_vn_mode_s{ union{ struct{ uchar li:2; uchar vn:3; uchar mode:3; }bit_id; uchar id; }; }sntp_li_vn_mode_t; |
按照这种方式添加这个三个字段值,在大端网络序的机器中,发出去报文的格式按照li、vn、mode,在小端主机序(网络序和主机序定义见上节)的机器中,发出去报文的格式就变成mode、vn、li。解决办法是改成下面这种形式:
|
typedef struct sntp_li_vn_mode_s{ union{ struct{ #if BYTE_ORDER == BIG_ENDIAN uchar li:2; uchar vn:3; uchar mode:3; #endif #if BYTE_ORDER == LITTLE_ENDIAN uchar mode:3; uchar vn:3; uchar li:2; #endif }bit_id; uchar id; }; }sntp_li_vn_mode_t; |
平台和接口
平台
要编写可移植性的代码,先要知道当前编译的系统,一般可以通过编译器内置的预编译宏进行确认,如下
|
#if !defined(__LINUX__) && (defined(__linux__) || defined(__KERNEL__) \ || defined(_LINUX) || defined(LINUX) || defined(__linux)) #define __LINUX__ (1) #elif !defined(__APPLE__) && (defined(__MacOS__) || defined(__apple__)) #define __APPLE__ (1) #elif !defined(__CYGWIN__) && (defined(__CYGWIN32__) || defined(CYGWIN)) #define __CYGWIN__ (1) #elif !defined(__WINDOWS__) && (defined(_WIN32) || defined(WIN32) \ || defined(_window_) || defined(_WIN64) || defined(WIN64)) #define __WINDOWS__ (1) #elif !(defined(__LINUX__) || defined(__APPLE__) \ || defined(__CYGWIN__) || defined(__WINDOWS__)) #error "`not support this platform`" #endif |
不同平台在一些细节上存在若干差异,如下表所示
|
|
Windows |
Linux |
MacOS |
|
目录分隔符 |
“\” |
“/” |
“/” |
|
文本换行符 |
“\r\n” |
“\n” |
“\r” |
接口
在跨平台移植C代码时,可能会遇到一些接口上的问题。例如,fopen系列是标准的C库函数,而open系列是 POSIX 定义的,是UNIX系统里的系统调用。也就是说,fopen系列更具有可移植性;而open系列只能用在 POSIX 的操作系统上。UNIX系统中I / O调用是creat()、open()、read()、write()、close(),ioctl()等,但这些不是ANSI C的一部分,不会存在于非UNIX环境中,因此ANSI C指定了一组标准I / O库调用保程序的可移植性,stdio I / O调用fopen()、fclose()、putc()、fseek()等,这些例程名称中的大多数都以“f”开头。
另外,注意尽量不要使用非标准函数。有些函数大多数平台上都有,它们使用得太广泛了,以至于大家都把它们当成标准了,比如strdup(克隆字符串)、alloca(在栈分配自动内存)等,通过man命令可以方便的看出哪些是标准函数,哪些不是标准函数。
|
NAME strdup, strndup, strdupa, strndupa - duplicate a string CONFORMING TO strdup() conforms to SVr4, 4.3BSD, POSIX.1-2001. strndup() conforms to POSIX.1-2008. strdupa() and strndupa() are GNU extensions.
NAME alloca - allocate memory that is automatically freed CONFORMING TO This function is not in POSIX.1-2001. There is evidence that the alloca() function appeared in 32V, PWB, PWB.2, 3BSD, and 4BSD. There is a man page for it in 4.3BSD. Linux uses the GNU version. |
其他
C语言标准中包含了一些未说明行为(Unspecified behavior)和未定义行为(Undefined behavior),这些行为在不同平台上可能会有不同的表现,编码遇到这些行为时有2个建议
- 通过确定的式方式来进行操作:例如char类型转int类型,或是有符号类型数向右移位时,都先转成无符号类型再操作。
- 通过提前检查避免这种行为:例如strcmp中避免传入一个空指针,避免对负数做除法
具体的行为列表,可以参考C11标准附录J。
参考文献
https://blog.csdn.net/kkkkkkkooooooo111/article/details/53709620
https://blog.csdn.net/summerhust/article/details/6615404
https://www.cnblogs.com/tsw123/p/5837273.html
https://blog.csdn.net/gll028/article/details/39896353
http://blog.chinaunix.net/uid-25909722-id-2749575.html
https://blog.csdn.net/liuxingen/article/details/45420455/
https://blog.csdn.net/godleading/article/details/78876639
http://c.biancheng.net/ref/limits_h
https://blog.csdn.net/WarEric/article/details/76407920
https://blog.csdn.net/u014659656/article/details/46779381
https://blog.csdn.net/tengxiaoming/article/details/5588972
http://clc-wiki.net/wiki/The_C_Standard#Obtaining_the_Standard
《C陷阱和缺陷》
《C11标准》