16.可移植性

优质
小牛编辑
138浏览
2023-12-01

"C语言结合了汇编的强大功能和可移植性" -- 无名氏,暗指比尔.萨克。

可移植代码的好处是有目共睹的。这一节将阐述一些编写可移植代码的指导原则。这里"可移植的"是指一个源码文件能够在不同机器上被编译和执行,其 前提仅仅是在不同平台上可能包含不同的头文件,使用不同的编译器开关选项罢了。头文件包含的#define和typedef可能因机器而异。一般 来说,一个新"机器"是指一种不同的硬件,一种不同的操作系统,一个不同的编译器,或者是这些的任意组合。参考[1]包含了很多关于风格和可移植 性方面的有用信息。下面是一个隐患列表,当你设计可移植代码时应该考虑避免这些隐患:

  • 编写可移植的代码。只有当被证明是必要的情况下才考虑优化的细节。优化后的代码往往是模糊不清、难以理解的。在一台机器上经过优化后的代码,在其他机器上 可能变得更加糟糕。将采用的性能优化手段记录下来并尽可能多地本地化。文档应该解释这些手段的工作原理以及引入它们的原因(例如:"循环执行了无 数次")

  • 要意识到很多东西天生就是不可移植的。比如处理类似程序状态字这样的特定硬件寄存器的代码,以及被设计用于支持某特定硬件部件的代码,诸如汇编器以及 I/O驱动。即使在这种情况下,许多例程和数据仍然可以被设计成机器无关的。

    • 组织源文件时将机器无关与机器相关的代码分别放在不同文件中。之后如果这个程序需要被移植到一个新机器上时,我们就可以很容易判断出来哪些需要被改变。为 一些文件的头文件中机器依赖相关的代码添加注释。

    • 任何"实现相关"的行为都应该作为机器(编译器)依赖对待。假设编译器或硬件以一种十分古怪的方式实现它。

    • 注意机器字长。对象的大小可能不直观,指针大小也不总是与整型大小相同,也不总是彼此大小相同,或者可相互自由转换。下面的表中列举了C语言基本类型在不 同机器和编译器下的大小(以bit为单位)。

|=type |=pdp11 |=VAX/11 |=68000 |=Cray-2 |=Unisys |=Harris |=80386| | |series | |family | |1100 |H800 | | |char |8 |8 |8 |8 |9 |8 |8 |short | 16 |16 |8/16 |64(32) |18 |24 |8/16 |int |16 |32 |16/32 |64(32) |36 |24 |16/32 |long |32 |32 |32 |64 |36 |48 |32 |char |16 |32 |32 |64 |72 |24 |16/32/48 |int |16 |32 |32 |64(24) |72 |24 |16/32/48 |int(*)()|16 |32 |32 |64 |576 |24 |16/32/48

有些机器针对某一类型可能有不止一个大小。其类型大小取决于编译器和不同的编译期标志。下面表展示了大多数系统的"安全"类型大小。无符号与带符 号数具有相同的大小(单位:bit)。

|=Type |=Minimum |=No Smaller| | |# Bits |Than | |char |8 | | |short |16 |char | |int |16 |short | |long |32 |int | |float |24 | | |double |38 |float | |any |14 | | |char |15 |any | |vvoid |15 |any * |

  • void类型可以保证有足够位精度来表示一个指向任意数据对象的指针。void()()类型可以保证表示一个指向任意函数的指针。当你需要通用指针时 可以使用这些类型(在一些旧的编译器里,分别用char和char()()表示)。确保在使用这些指针类型之前将其转换回正确的类型。

  • 即使说一个int和一个char类型大小相同,它们仍可能具有不同的格式。例如,下面例子在一些sizeof(int)等于 sizeof(char)的机器上可能失败。其原因在与free函数期望一个char,但却传入了一个int

int *p = (int *) malloc (sizeof(int));
free (p);
  • 注意,一个对象的大小不能保证这个对象的精度。Cray-2可能使用64位来存储一个整型,但一个长整型转换为一个整型并且再转换回长整型后可能会被截断 为32位。

  • 整型常量0可以强制转型为任何指针类型。转换后的指针称为对应那个类型的空指针,并且与那个类型的其他指针不同。空指针比较总是与常量0相当。空指针不应 该与一个值为0的变量比较。空指针不总是使用全0的位模式表示。两个不同类型的空指针有些时候可能不同。某个类型的空指针被强制转换为另外一个类 型的指针,其结果是该指针转换为第二个类型的空指针。

  • 对于ANSI编译器,当两个类型相同的指针访问同一块存储区时,则它们比较是相等的。当一个非0整型常量被转换为指针类型时,它们可能与其他指针相等。对 于非ANSI编译器,访问同一块存储区的两个指针比较可能并不相同。例如,下面两个指针比较可能相等或不相等,并且他们可能或可能没有访问同一块 存储区域。

((int *) 2 )
((int *) 3 )

如果你需要'magic'指针而不是NULL,要么分配一些内存,要么将指针视为机器相关的。

extern int x_int_dummy;        /* in x.c */
#define X_FAIL    (NULL)
#define X_BUSY    (&x_int_dummy)
#define X_FAIL    (NULL)
#define X_BUSY    MD_PTR1        /* MD_PTR1 from "machdep.h" */
  • 浮点数字既包含精度也包含范围。这些都是数据对象大小无关的。但是,一个32位浮点数在不同机器上溢出时的值有所不同。同时,4.9乘以5.1在不同的机 器上可能产生两个不同的数字。在圆整(rounding)和截断方面的差异将给出特别不同的答案。

  • 在一些机器上,一个双精度浮点数在精度或范围方面可能比一个单精度浮点数还要低。

  • 在一些机器上,double值的前半部分可能是一个具有相同值的float类型。千万不要依赖于此。

  • 提防带符号字符。例如,在某些VAX系统上,用在表达式中的字符是符号扩展的,但在其他一些机器上并非如此。对有符号和无符号有依赖的代码是不可移植的。 例如,如果假设c是正值,array[c]在c为有符号且为负值时将无法正常工作。如果你一定要假设signed或unsigned字符的话,请 用SIGNED或UNSIGNED为其加上注释。无符号字符的行为可由unsigned char保证。

  • 避免对ASCII做假设。如果你必须假设,那么请将其记录下来并本地化。请记住字符很可能用不止8位表示。

  • 大多数机器采用2的补码表示数,但我们在代码中不应该利用这一特点。使用等价移位操作替代算术运算的优化尤其值得怀疑。如果必须这么做,那么机器相关的代 码应该用#ifdef定义,或者操作应该在#ifdef宏判定下执行。你应该衡量一下使用这种难以理解的代码所节省的时间与做代码移植时找bug 所花费的时间相比孰多孰少。

  • 一般情况下,如果字长或值范围非常重要,应该使用typedef定义具有特定大小的类型。大型程序应该具有一个统一的头文件用于提供通用的、大小 (size)敏感的类型的typedef定义,这样更加便于修改以及在紧急修复时查找大小敏感的代码。无符号类型比有符号整型更加编译器无关。如 果既可以用16bit也可以用32bit标识一个简单for循环的计数器,我们应该使用int。因为对于当前机器来说,通过整型可以获取更高效 (自然)的存储单元。

  • 数据对齐也很重要。例如,在不同的机器上,一个四字节的整型数的可能以任意地址作为起始地址,也可能只允许以偶数地址作为起始地址,或者只能以4的整数倍 的地址作为起始地址。因此,一个特定的结构体的各个元素在不同的机器上的偏移量有不同,即使给定的这些元素在所有机器上的大小相同。事实上,一个 包含一个32位指针和一个8位字符的结构提在三个不同的机器上可能有三个不同的大小。作为一个推论,对象指针可能无法自由互换;通过一个指向起始 地址为奇数地址长度为4个字节的指针保存一个整型数有时可以正常工作,但有时则会导致产生core,有些时候静悄悄地失败了(在这个过程中会破坏 其他数据)。在那些不按字节寻址的机器上,字符指针更是"事故高发地区"。对齐考虑以及加载器的特殊性使得很容易轻率地认为两个连续声明的变量在 内存中也是连在一起的,或者某个类型的变量已经被适当对齐并可以用作其他类型变量使用了。

  • 在一些机器上,诸如VAX(小端),一个字的字节随着地址的增加,其重要性提高;而另外一些机器上,诸如68000(大端),随着地址的增加,其重要性降 低。字或更大数据对象(诸如一个双精度字)的字节顺序可能并不相同。因此,任何依赖对象内从左到右方向位模式的代码都值得特别细致的审查。只有当 结构体中两个不同的位字段不被连接以及不被当作一个单元时,这些位字段才具备可移植性。事实上,连接任意两个变量都是不可移植的行为。

  • 结构体中有一些未使用的空洞。猜想联合体用于类型欺骗。尤其是,一个值不应该在存储时使用一个类型,而在读取时使用另外一种类型。对联合体来说,一个显式 的标签(tag)字段可能会很有用。

  • 不同的编译器在返回结构体时使用不同的约定。这就会导致代码在接受从不同编译器编译的库代码中返回的结构体值时会出现错误。结构体指针不是问题。

  • 不要假设参数传递机制。特别是指针大小以及参数求值顺序,大小等。例如,下面的代码就不具备可移植性。

    c = foo (getchar(), getchar());

    char
foo (c1, c2, c3)
    char c1, c2, c3;
{
    char bar = *(&c1 + 1);
    return (bar);            /* often won't return c2 */
}
  • 上面的例子有诸多问题。栈可能向上增长,也可能向下增长(事实上,甚至都不需要一个栈)。参数在传入时可能被扩大,例如一个char可能以int型被传 入。参数可能以从左到右,从右到左,或以任意顺序压入栈,或直接放在寄存器中(根本无需压栈)。参数求值的顺序也可能与压栈的次序有所不同。一个 编译器可能使用多种(不兼容的)调用约定。

  • 在某些机器上,空字符指针((char *)0)常被当作指向空字符串的指针对待。不要依赖于此。

  • 不要修改字符串常量。下面就是一个臭名昭著的例子

s = "/dev/tty??";
strcpy (&s[8], ttychars);
  • 地址空间可能有空洞。简单计算一个数组中未分配空间的元素(在数组实际存储区域之前或之后)的地址可能会导致程序崩溃。如果这个地址被用于比较,有时程序 可以运行,但会破坏数据,报错,或陷入死循环。在ANSI C中,指向一个对象数组的指针指向数组结尾后的第一个元素是合法的,这在一些老编译器上通常是安全的。不过这个"在外边"不可以被解引用。

  • 只有==和!=比较可用于某给定类型的所有指针。当两个指针指向同一个数组内的元素(或数组后第一个元素)时,使用<<、<=、& gt;或>=对两个指针进行比较是可移植的。同样,仅仅对指向同一个数组内的元素(或数组后第一个元素)的两个指针使用算术操作符才是可移 植的。

  • 字长(word size)也影响移位和掩码。下面代码在一些68000机器上只会将一个整型数的最右三个位清0,而在其他机器上它还会将高地址的两个字节清零。x &= 0177770 使用 x &= ~07可以在所有机器上正常工作。位字段(bitfield)没有这些问题。

  • 表达式内的副作用可能导致代码语义是编译器相关的,因为在大多数情况下C语言的求值顺序是没有显式定义的。下面是一个臭名昭著的例子:

    a[i] = b[i++];
    

    在上面的例子中,我们只知道b的下标值没有被增加。a的下标i值可能是自增后的值也可能是自增前的值。

struct bar_t { struct bar_t *next; } bar;
bar->next = bar = tmp;

在第二个例子中,bar->next的地址很可能在bar被赋值之前被计算使用。

bar = bar->next = tmp;

第三个例子中,bar可能在bar->next之前被赋值。虽然这可能有悖于"赋值从右到左处理"的规则,但这确是一个合法的解析。考虑下 面的例子:

long i;
short a[N];
i = old
i = a[i] = new;

赋给i的值必须是一个按照从右到左的处理顺序进行赋值处理后的值。但是i可能在a[i]被赋值前而被赋值为"(long) (short)new"。不同编译器作法不同。

  • 质疑代码中出现的数值(“魔数”)。

  • 避免使用预处理器技巧。一些诸如使用/ /粘和字符串以及依赖参数字符串展开的宏会破坏代码可靠性。

#define FOO(string)    (printf("string = %s",(string)))
 ...
FOO(filename);

只是在有些时候会扩展为

(printf("filename = %s",(filename)))

小心。诡异的预处理器在一些机器上可能导致宏异常中断。下面是一个宏的两种不同实现版本:

#define LOOKUP(chr)    (a['c'+(chr)])    /* Works as intended. */
#define LOOKUP(c)    (a['c'+(c)])        /* Sometimes breaks. */

第二个版本的LOOKUP可能以两种不同的方式扩展,并且会导致代码异常中断。

  • 熟悉现有的库函数和定义(但不用太熟悉。与其外部接口相反,库基础设施的内部细节常会改变并且没有警告,这些细节常常也是不可移植的)。你不应该再自己重 新编写字符串比较例程、终端控制例程或为系统结构编写你自己的定义。自己动手实现既浪费你的时间,又使得你的代码可读性变差,因为另外一个读者需 要知道你是否在新的实现中做了什么特殊的事情,并尝试证实它们的存在。同时这样做会使得你无法充分利用一些辅助的微代码或其他有助于提高系统例程 性能的方法。更进一步,它将是一个bug的高产源头。如果可能的话,要知道公共库之间的差异(如ANSI、POSIX等等)。

  • 如果lint可用,请使用lint。这个工具对于查找代码中机器相关的构造、其他不一致性以及顺利通过编译器检查的程序bug时具有很高价值。如果你的编 译器具备打开警告的开关,请打开它。

  • 质疑在代码块内部的与代码块外部switch或goto有关联的标签(Label)。

  • 无论类型在哪里,参数都应该被转换为适当的类型。当NULL用在没有原型的函数调用时,请对NULL进行转换。不要让函数调用成为类型欺骗发生的地方。C 语言的类型提升规则很是让人费解,所以尽量小心。例如,如果一个函数接受一个32位长的长整型做为参数,但实际传入的却是一个16位长的整型数, 函数栈可能会无法对齐,这个值也可能会被错误提升。

  • 在混用有符号和无符号值的算术计算时请使用显式类型转换

  • 应该谨慎使用跨程序的goto、longjmp。很多实现"忘记"恢复寄存器中的值了。尽可能将关键的值声明为volatile,或将它们注释为 VOLATILE。

  • 一些链接器将名字转换为小写,并且一些链接器只识别前六个字母作为唯一标识。在这些系统上程序可能会悄悄地中断运行。

  • 当心编译器扩展。如果使用了编译器扩展,请将他们视为机器依赖并用文档记录下来。

  • 通常程序无法在数据段执行代码或者无法将数据写入代码段。即使程序可以这么做,也无法保证这么做是可靠的。