C++的可移植性和跨平台开发

本文探讨了C++代码在不同平台间的移植性问题,包括编译器选择、语法注意事项、异常处理、硬件体系差异及操作系统特性等内容,旨在帮助开发者写出更易跨平台使用的C++代码。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  今天聊聊C++的可移植性问题。如果你平时使用C++进行开发,并且你对C++的可移植性问题不是非常清楚,那么我建议你看看这个系列。即使你目前没有跨平台开发的需要,了解可移植性方面的知识对你还是很有帮助的。


  C++的可移植性这个话题很大,包括了编译器、操作系统、硬件体系等很多方面,每一个方面都有很多内容。鉴于本人能力、精力都有限,只能介绍每一个方面最容易碰到的问题,供大伙儿参考。
  后面我会分别从编译器、C++语法、操作系统、第三方库、辅助工具、开发流程等方面进行介绍。

  为了方便阅读,把本系列帖子的目录整理如下:

 

  1、编译器

 

  ★编译器的选择
  首先,GCC是优先要考虑支持的,因为几乎所有操作系统平台都有GCC可用。它基本上成了一个通用的编译器了。如果你的代码在A平台的GCC能够编译通过,之后拿到B平台用类似版本的GCC编译,一般也不会有太大问题。因此GCC是肯定要考虑支持的。
   其次,要考虑是否支持本地编译器。所谓本地编译器就是操作系统厂商自产的编译器。例如相对于Windows的本地编译器就是Visual C++。相对于Solaris的本地编译器就是SUN的CC。如果你对性能比较敏感或者想用到某些本地编译器的高级功能,可能就得考虑在支持GCC的同时 也支持本地编译器。

  ★编译警告
  编译器是程序员的朋友,很多潜在的问题(包括可移植性),编译器都是可以发现并给出警告的,如果你平时注意这些警告信息,可以减少很多麻烦。因此我强烈建议:1把编译器的警告级别调高;2不要轻易忽略编译器的警告信息。

  ★交叉编译器
  交叉编译器的定义参见“维基百科 ”。 通俗地说,就是在A平台上编译出运行在B平台上的二进制程序。假设你要开发的应用是运行在Solaris上,但是你手头没有能够运行Solaris的 SPARC机器,这时候交叉编译器就可以派上用场了。一般情况下都使用GCC来制作一个交叉编译器,限于篇幅,这里就不深入聊了。有兴趣的同学可以参见"这里 "。

 

  2、语法

 


  ★小心for循环变量的作用域(不支持新标准)
  在C++98标准中,for循环变量的作用域局限在循环体内。而某些老的编译器(例如Visual C++ 6)认为for循环变量的作用域在循环体外。所以如下的代码可能导致移植问题。


 
建议修改为不同的循环变量,如下所示:


  ★不要使用全局类对象,改用单键(标准未定义)
  全局类对象的构造函数先于main()函数执行,如果某个模块中同时包含若干个全局类对象,则它们的构造函数的调用顺序是不确定的。另外,单键也是有隐患的。如果你的程序包含多个单键,则单键对象的析构顺序也是不确定的。

  ★保持inline函数尽量简单
  不要在inline函数内部使用局部静态变量,不要在inline函数使用可变参数。这些都有可能导致移植问题。

  ★不要依赖函数参数的求值顺序(标准未定义)
  标准没有明确规定函数参数的求值顺序。因此,如下的代码行为是不确定的。

void Foo(int a, int b);
int n = 1;
foo(++n, ++n);

  ★慎用模板特化(不支持新标准)
  有些 老式 编译器对偏特化或全特化支持不够。

  ★模板继承中,引用基类成员要小心(不支持新标准)
  看如下例子:


  ★慎用RTTI(不支持新标准、标准未定义)
  先声明一下,我这里说的RTTI主要是指typeid操作符和type_info类型。
  首先,由于某些老式编译器可能不支持typeid操作符和type_info类型,会导致移植性的问题,这是慎用RTTI的一个原因。(如果你用的是新式编译器,不用考虑这个因素)
  其次,由于标准对于type_info类型的约束比较简单。这导致了不同的编译器对type_info的实现有较大差异。如果你确实要使用type_info类型,建议仅仅使用它的operator==和operator!=这两个成员函数。
  所以,如果你确实需要在运行时确定类型,又不想碰到上述问题,可以考虑在自己的类体系中加入类型信息来实现。比如 wxWidgets 就是这么干的。

  ★慎用嵌套类(不支持新标准)
  如果在内部类访问外部类的非公有成员,要把内部类声明为外部类的friend。
如下代码存在移植问题。

应该改为如下代码


  ★不要定义参数类型相近的函数(标准未定义)

void Foo(short n);
void Foo(long n);
Foo(0); //会导致二义性错误

  ★不要依赖标准类型的字长(标准未定义)
  某些标准类型(例如int、wchar_t)的字长会随着具体的平台而改变。

  ★用枚举代替类的静态成员常量(不支持新标准)
  某些 老式 的编译器不支持类的静态成员常量,可以用枚举来代替。

class CFoo
{
static const int MIN = 0; //不可移植
enum { MAX = 64 }; //可移植
};

 

 

  3、异常处理

 

  ★小心new分配内存失败
  早期的老式编译器生成的代码,如果new失败会返回空指针。我当年用 的Borland C++ 3.1似乎就是这样的,现在这种编译器应该不多见了。如果你目前用的编译器还有这种行为,那你就惨了。你可以考虑重载new操作符来抛出 bad_alloc异常,便于进行异常处理。
  稍微新式一点的编译器,就不是仅仅返回空指针了。当new操作符发现内存告急,按照标准的规定 (参见03标准18.4.2章节),它应该去调用new_handler函数(原型为typedef void (*new_handler)();)。标准建议new_handler函数干如下三件事:1、设法去多搞点内存来;2、抛出bad_alloc异 常;3、调用abort()或者exit()退出进程。由于new_handler函数是可以被重新设置的(通过调用set_new_handler), 所以上述的行为它都可能有。
  综上所述,new分配内存失败,有可能三种可能:1、返回空指针;2、抛出异常;3、进程立即终止。如果你希望你的代码具有较好的移植性,你就得把这三种情况都考虑到。
  ★慎用异常规格
   异常规格在我看来不是一个好东西,不信可以去看看《C++ Coding Standards - 101 Rules, Guidelines & Best Practices》的第75条。(具体有哪些坏处以后专门开一个C++异常和错误处理的帖子来聊)言归正传,按照标准(参见03标准18.6.2章 节),如果一个函数抛到外面的异常没有包含在该函数的异常规范中,那么应该调用unexcepted()。但是并非所有编译器生成的代码都遵守标准(比如 某些版本的VC编译器)。如果你的需要支持的编译器在异常规范上的行为不一致,那就得考虑去掉异常规范声明。
  ★不要跨模块抛出异常
  此处说的模块是指动态库。如果你的程序包含有多个动态库,不要把异常抛到模块的导出函数之外。毕竟现在C++还没有ABI标准(估计将来也未必会有),跨模块抛出异常会有很多不可预料的行为。
  ★不要使用结构化异常处理(SEH)
  如果你从来没有听说过SEH,那就当我没说,跳过这段。如果你以前习惯于用SEH,在你打算写跨平台代码之前,要改掉这个习惯。包含有SEH的代码只能在Windows平台上编译通过,肯定无法跨平台的。
  ★关于catch(...)
   照理说,catch(...)语句只能够捕获C++的异常类型,对于访问违例、除零错等非C++异常是无能为力的。但是某些情况下(比如某些VC编译 器),诸如访问违例、除零错也可以被catch(...)捕获。所以,你如果希望代码移植性好,就不能在程序逻辑中依赖上述catch(...)的行为。

 

 

  4、硬件体系

 

  ★基本类型的大小
  C++中基本类型的大小(占用的字节数)会随着CPU字长的变化而变化。所以,假如你要表示一个int占用的字节数,不要直接写“4 ”(顺便说一下,直接写“4 ”还犯了Magic Number的大忌,详见这里 ),而要写“sizeof(int) ”;反过来,如果你要定义一个大小必须 为4字节的有符号整数,也不要直接用int,要用预先typedef好的定长类型(比如boost 库的int32_t、ACE 库的ACE_INT32、等)。
  差点忘了,指针的大小也有上述的问题,也要小心。
  ★字节序
  如果你没听说过“字节序”这玩意儿,请看“维基百科 ”。通俗地打个比方,在一个大尾序的机器上有一个4字节的整数0x01020304,通过网络或者文件传到一台小尾序的机器上就会变成0x04030201;据说还有一种中尾序的机器(不过我没接触过),上述整数会变成0x02010403。
  如果你编写的应用程序中涉及网络通讯,一定要在记得进行主机序和网络序的翻译;如果涉及跨机器传输二进制文件,也要记得进行类似的转换。
  ★内存对齐
  如果你不晓得“内存对齐”是什么东东,请看“维基百科 ”。简单来说,出于CPU处理上的性能考虑,结构体中的数据不是紧挨着的,而是要空开一些间隔。这样的话,结构体中每个数据的地址正好都是某个字长的整数倍。
  由于C++标准中没有定义内存对齐的细节,因此,你的代码也不能依赖对齐的细节。凡是计算结构体大小的地方,都老老实实写上sizeof()。
  有些编译器支持#pragma pack预处理语句(可以用来修改对齐字长),不过这种语法不是所有编译器都支持,要慎用。
  ★移位操作
  对于有符号整数的右移操作,有些系统默认使用算数右移(最高的符号位不变),有些默认使用逻辑右移(最高的符号位补0)。所以,不要对有符号整数进行右移操作。顺便说一下,即使没有移植性问题,代码中也尽量不要使用移位运算操作,可读性太差。

 

 

 

  5、操作系统

 

 

  为了不绕口,以下把Linux和各种Unix统称为Posix 系统。

  ★文件系统(FileSystem以下简称FS)
  刚开始搞跨平台开发的新手,多半都会碰上和FS相关的问题。所以先来聊一下FS。归纳下来,开发中容易碰上的FS差异主要有如下几个:目录分隔符的差异;大小写敏感的差异;路径中禁用字符的差异。
  为了应对上述差异,你要注意如下几点:
  1、文件和目录命名要规范
  在给文件和目录命名时,尽量只使用字母和数字。不要在同一个目录下放两个名称相似(名称中只有大小写不同,例如foo.cpp与Foo.cpp)的文件。不要使用某些OS的保留字(例如aux、con、nul、prn)作文件名或目录名。
  补充一下,刚才说的命名,包括了源代码文件、二进制文件和运行时创建的其它文件。
  2、#include 语句要规范
  当你写#include 语句时,要注意使用正斜线"/"(比较通用)而不要使用反斜线"/"(仅在Windows可用)。#include 语句中的文件和目录名要和实际名称保持大小写完全 一致。
  3、代码中涉及FS操作,尽量使用现成的库
  已经有很多成熟的、用于FS的第三方库(比如boost::filesystem )。如果你的代码涉及到FS的操作(比如目录遍历),尽量使用这些第三方库,可以帮你省不少事情。

  ★文本文件的回车CR/换行LF
  由于几个知名的操作系统对回车/换行的处理不一致,导致了这个烦人的问题。目前的局面是:Windows同时使用CR和LF;Linux和大部分的Unix使用LF;苹果的Mac系列使用CR。
  对于源代码管理,好在很多版本管理软件(比如CVS、SVN)都会智能地处理这个问题,让你从代码库取回本地的源码能适应本地的格式。
  如果你的程序需要在运行时处理文本文件,要留意本文方式打开和二进制方式打开的区别。另外,如果涉及跨不同系统传输文本文件,要考虑进行适当的处理。

  ★文件搜索路径(包括搜索可执行文件和动态库)
  在Windows下,如果要执行文件或者加载动态库,一般会搜索当前目录;而Posix系统则不尽然。所以如果你的应用涉及到启动进程或加载动态库,就要小心这个差异。

  ★环境变量
  对于上述提到的搜索路径问题,有些同学想通过修改PATH和LD_LIBRARY_PATH来引入当前路径。假如使用这种方法,建议你只修改进程级的环境变量,不要修改系统级的环境变量(修改系统级有可能影响到同机的其它软件,产生副作用)。

  ★动态库
  如果你的应用程序使用动态库,强烈建议动态库导出标准C风格的函数(尽量不要导出类)。如果在Posix系统中加载动态库,切记慎用RTLD_GLOBAL 标志位。这个标志位会Enable全局符号表,有可能会导致多个动态库之间的符号名冲突(一旦碰到这种事,会出现匪夷所思的运行时错误,极难调试)。
  关于动态库的话题比较大,限于篇幅,以后单独写一个帖子讨论。

  ★服务/看守进程
  如果你不清楚服务和看守进程的概念,请看这里这里 。为了叙述方便,以下统称服务。
   由于C++开发的模块大部分是后台模块,经常会碰到服务的问题。编写服务需要调用好几个系统相关的API,导致了与操作系统的紧密耦合,很难用一套代码 搞定。因此比较好的办法是抽象出一个通用的服务外壳,然后把业务逻辑代码作为动态库挂载到它下面。这样的话,至少保证了业务逻辑的代码只需要一套;服务外 壳的代码虽然需要两套(一个用于Windows、一个用于Posix),但他们是业务无关的,可以很方便地重用。

  ★默认栈大小
  不同的操作系统,栈的默认大小差别很大,从几十KB(据说Symbian只有12K,真抠门)到几MB不等。因此你事先要打听一下目标系统的默认栈大小,如果碰上像Symbian这样抠门的,可以考虑用编译器选项调大。当然,养成"不在栈上定义大数组/大对象 "的好习惯也很重要,否则再大的栈也会被撑爆的。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值