如何写出移植性好的C程序(一)

1 前言

本人工作快五年了,一直奋战在嵌入式软件开发第一线(注意,此处的嵌入式软件是指广义的嵌入式软件,不是最近几年特火的嵌入式Linux开发),每天用汇编和C干活,写着相对底层的代码。由于工作需要,经常在各种芯片、各种编译器之间来回移植程序。我发现大多数程序在第一次写的时候,并没有将容易移植放在考虑范畴中,这导致移植一次就痛苦一次。

 

在不断的痛苦中,本人积累了一些经验,此处想总结总结,以免日后忘记,也与和我一样奋战在第一线诸位分享。

 

特别地,我要说明本文只涉及移植性,只涉及C语言。诸如设计原则之类高深的不讲,诸如指针如何正确使用之类无关的不讲。

 

C语言是一种能力强大,而又陷阱重重的语言。C语言的书多如牛毛,即使经典的,列出一打也不是难事。我这里要特别推荐一本书——《MISRA-C:2004》。称其是书也不恰当,这是MISRA制定的C语言编程规范,用于指导如何写出高可靠性、高移植性、高安全性的嵌入式C程序。其一点不玩虚的,从头到尾字字命中要害!感兴趣的同志们可以上他们官网去买一份,虽然要掏出10英镑(单用户版),但相对于其质量,个人认为实在是太便宜了!

 

本人水平有限,若文中有错,望不吝赐教!

2 项目组织

一个项目会有很多源文件,.c、.h、.asm。首先要做的事情是规划——哪些代码是硬件相关的,哪些是硬件无关的。

 

硬件相关的代码放到专门的源文件中,然后放到专门的文件夹中。

 

通常来说,硬件相关的代码是少数,但即便如此,也要精心设计,将硬件相关的代码缩小成一个个功能单一的函数或宏。如何将硬件相关的代码缩得尽量小,却足以覆盖不同的硬件,是一种需要长期磨练的能力。这件事做得越好,以后移植的工作量也就越小。

 

注意,一个源文件要么全放硬件相关的代码,要么全放硬件无关的代码,千万不要混在一起,那样做会给移植带来麻烦。对一个文件夹也是如此,要么其中的源文件全是硬件相关的,要么其中的源文件全是硬件无关的。同时,注释要写得足够清楚,每个函数的入口出口,哪些事是应该做的,哪些事是不能做的。这样一来,今后在需要移植时,只需将对应文件夹下的代码进行修改即可。

 

在项目中建立一个头文件,比如命名为inc.h,同时规定每个.c文件都必须include它。inc.h本身不要包含任何宏定义、声明等,只include常用的头文件。在被include的头文件中再来做文章。

 

下面举一个inc.h的例子:

 

 

我常常看到一个项目在第一次开发时基于A芯片,所以项目中的很多.c文件中都包含了A芯片的寄存器定义头文件A.h,即很多.c文件都有这样一行——#include "A.h"。过了一段时间,项目升级了,芯片换成A芯片同系列的高配置版——B芯片。好了,所有.c文件中的#include "A.h"都必须改成#include "B.h"。虽然编辑器可以帮我们“全部查找”、“全部替换”,但这样做显得很不干净。

 

定义inc.h并规定所有.c文件都必须include它,就能适应这样的变化。当从芯片A升级到新芯片B时,只需要修改chip_resgs.h一个文件即可,其他所有的.c文件就会自动受益。

3 统一类型

int 类型的长度是多少比特?short 呢?long 呢?

 

对于有些研发经验的人来说,这不是多难的问题。一般来说,在32位机上,int 是32位;在16位机和8位机上,int 是16位。具体到某款芯片、某款编译器,还是以其手册为准。

 

嵌入式程序在大多数时候,都希望精确控制,而不希望换了芯片之后,数据长度会自动发生变化。因此,在项目中统一数据类型定义成为常用手段。

 

即在一个头文件(例如 platform_type.h)中,定义出常用的数据类型,然后在其他文件中使用定义后的类型,而不直接使用 int 、short等。下面是一个例子:

 

 

这样一来,移植的时候,只需修改 platform_type.h 中的数据类型定义即可,其余程序都不用动。

 

这个招数应该说比较常见,但这里要说三件事。

 

1、用 typedef,不要用 #define

 

我上学那会儿看过不少单片机编程的书,无一例外都是用的 #define。用宏定义这样的做法在只针对整型时,和 typedef 没有什么差别,但如果定义指针类型可就差得远了。

 

 

上面的程序想定义两个指向8位无符号整型的指针。但是很遗憾,pb不是指针,pb是 unsigned char 类型。

 

#define 不是用来干这个活的,虽然其看上去也能干,但实际上干得不彻底。所以,请用地道的 typedef 吧。

 

2、char 的类型

 

很多人写程序喜欢写得越短越好,似乎越短水平越高。我曾经也是如此认为。因此,这样的定义很容易出现——

 

typedef char SBYTE;

 

很多情况下,其也能正常运行。但实际上,这是不准确的。

 

char 是有符号型,还是无符号型,是平台相关的,是编译器决定的。

 

因此,准确的写法是——

 

typedef signed char SBYTE;

 

3、统一类型的名称

 

这个应该说,没有标准,只要一个项目中统一了就行了。我这里只说说个人好恶。

 

我见过三类名称:

 

第一类如前面的例子所述:BYTE、WORD、DWORD……

 

这类名称首先是有歧义,WORD在这里代表16位,但究其名称的意思为字长,32位处理器的字长是32位而非16。第二是不够直观,一眼看不出位数来。

 

第二类常在书上看到:UINT8、UINT16、UINT32……

 

这类名称的位宽一目了然,简明扼要,非常好。但缺点是长度比较长,而且都是大写字母,敲起来费劲。

 

第三类我是从 SUN 公司的 java card 代码中看来:u8、s8、u16、s16、u32、s32……

 

直观、简洁到极致。从那以后,我一直用它。

4 整型的欺骗

嵌入式程序中,用得最多的就是整型。也许多数人会说,前面我们已经统一了数据类型,在程序只使用 u8、s16 之类的类型,难道还不能高枕无忧么?

 

很遗憾,答案是不行。

 

整型的问题,在涉及到不同字长的处理器时,会变得十分复杂,以至于我时常会认为C语言并不适合需要经常移植的嵌入式场合。

 

下面我分几个方面来讲这个问题。

4.1 整型提升(Integer Promotion)

什么是整型提升?为了解释这个问题,首先要解释一下整形的等级(Rank)。

  1. 每一个有符号整型的等级和与其对应的无符号整型的等级一致,如 unsigned char = signed char;
  2. 有符号整型的等级排列为—— signed char < short < int < long 。

整型提升——在一个表达式中,任何等级低于 int 的整型变量都将被编译器进行自动的整型提升,如果 int 的位宽足够,则提升到 int;如果 int 的位宽不足,则提升到 unsigned int。

 

有点绕,没关系,我们看一个例子,就明白了。

 

 

这个例子看上去非常简单,但编译器实际上要自动地、默默地对第二行进行整型提升。

 

假设在32位处理器上,short 是 16 位,int 是 32 位。则编译器会做如下处理:

 

由于 var 的类型 unsigned short 的等级低于 int,且 int 的位宽大于 unsigned short,足以表达 unsigned short 的所有可能,所以编译器将 var 的类型提升到 int,然后再进行加法运算。

 

当同样的这段程序运行在 16 位处理器上,却有所不同。常见的 16 位处理器,short 和 int 的位宽都是 16 位,则编译器会做如下处理:

 

可见,由于 short 和 int 的位宽一样,所以 int 不足以表达 unsigned short 的所有可能,因此 var 被提升为 unsigned int,然后再进行加法操作。

 

由于整型提升是编译器自动进行的,多数情况下不会有警告,因此很容易引入不易察觉的 bug。见如下的例子:

 

 

请问 c 等于多少?

 

在32位处理器上,a 和 b 会首先被整型提升到 int (位宽32),因此 c 等于80000。

 

在16位处理器上,a 和 b 会首先被整型提升到 unsigned int(位宽16),因此 a + b 会发生溢出,于是 c 等于 14464。

 

正确的写法是

 

 

这样一来,无论在32位还是16位处理器上,c 都等于80000。

4.2 隐式类型转换

对于一个表达式,如果对表达式中每个整型都进行了整型提升之后,大家的类型仍然不同,则编译会按照以下规则进行隐式类型转换:

  1. 如果一个变量的类型 T 是无符号整型,且 T 的等级不低于另外一个变量的类型,则另外一个变量的类型转换为 T,否则转第2条;
  2. 当第1条不满足时,说明存在一个变量,其类型 T 为有符号整型,且 T 的等级高于另外一个变量,那么如果 T 足以表达另外一个变量,则另外一个变量的类型转换为 T,如果 T 不足以表达另外一个变量,则两个变量的类型都转换为 T 对应的无符号整型。

这个规则确实更绕一点,下面我们来看一个例子,就容易明白了:

 

 

在16位处理器上,char 为8位,short 和 int 为16位,long 为32位,则上面的程序会被编译器处理为:

先来分析第4行,首先 b 会被整型提升为 int,显然 b 和 a 的类型不同,然后应用本小节的第 1 条规则,unsigned int 的等级不低于于 int 的等级,因此 b 又被转换为 unsigned int。

 

第5行,没有变量需要整型提升,由于 unsigned int 的等级低于 long,且 long 的位宽足以表达 unsigned int,所以应用本小节的第 2 条规则, a 被转换为 long。

 

在32位处理器上,情况略有不同,假设 char 为 8位,short 为 16 位,int 和 long 为 32 位,则编译器会处理为:

 

第4行和16位处理器一致。

 

第5行,没有变量需要整型提升,但 long 的等级高于 unsigned int,且 long 不足以表达 unsigned int,则应用本小节的第2条规则,c 和 a 都转换为 unsigned long。

 

隐式类型转换因为一个特点,很容易引入 bug ——有符号数可能被转换为位宽相同的无符号数。

 

看下面的例子:

 

 

请问返回值是 0 还是 1 ?

 

在 32 位处理器上,第 3 行中的 a 和 b 都会被整型提升为 int,则 a 仍然是 1,b 仍然是 -1。条件判断为真,返回0。

 

在 16 位处理器上,第 3 行中的 a 和 b 会被分别整型提升为 unsigned int 和 int,然后 b 会被隐式转换为 unsigned int。这下就麻烦了,a 仍然是 1 , b 却变成了 0xFFFF, 则条件表达式为假,返回 1。

 

正确的写法是,加上强制类型转换,可以写成

 

a > (u16)b

 

也可以写成

 

(s16)a > b

 

这样一来,无论在 32 位处理器,还是在 16 位处理器上,都能得到一样的结果。

4.3 常数的类型

在C语言中使用一个变量之前必须先定义它,这样一来我们自然而然地接受了变量有类型这一事实。而常数就不那么自然了,“a = 5”这样一条语句是很自然的,“5”是什么类型通常不会引起我们的注意。

 

然而,事实上“5”是有类型的,它的类型是 int。多数情况下,我们不用关心常数的类型,但是如果完全忽略,在某些情况下会出问题的。

 

 

在上面的例子中,定义了一个宏,在32位处理器上,50和1024都是 int,且 int 是32位的,所以宏等于50K。而在16位处理器上,50和1024虽然仍然是 int,但 int 的长度是16位,所以此宏的值是一个负数。

 

显然,如果在程序中使用了此宏,则可能导致在不同的处理器上有不同的效果。

 

C语言规定,常数如果不带后缀,则为 int;后缀为u,则为 unsigned int;后缀为l,则为 long;后缀为 ul,则为 unsigned long。

 

因此,上面的例子应该写为:

 

 

这样,无论在32位处理器还是16位处理器上,该宏的值都为 50K。

 

在嵌入式领域,通常来说,无符号整数是用得最多的类型,因此,最好的做法是常数按照意图,把后缀带上。这样一来,防患于未然。虽然程序中有很多u,刚开始看着会有点不适应。

 

另外,一定要注意后缀只能规定常数是int,还是long,不能规定常数是16位还是32位。因此,在某些可能溢出的场合,要果断采用更长的数据类型,尽管这样做可能导致在一些处理器上程序变长。

4.4 有符号数和无符号数混用

在一个表达式中,最好不要将有符号数和无符号数进行混用。C语言在进行表达式的计算之前,首先会将不同类型的操作数统一为同一种类型。而在涉及到有符号数和无符号数的统一时,规则又相对隐晦(参见4.1和4.2)。因此,我们最好不要给编译器这样的机会,以免造成潜在的风险。

 

混用的一个例子,参见4.2中大于比较的代码。

4.5 有符号数的右移位

一个有符号数,如果是负数,在右移位时,高位是补0,还是做符号位扩展,是有编译器决定的。C语言规范认为两种方式都是可以的。

 

举例来说:

 

 

-32 的补码表示为 0xFFFFFFE0。如果是符号位扩展的右移位,则右移四位之后变为 0xFFFFFFFE,其值为 -2;如果不是符号位扩展,则右移四位之后变为 0x0FFFFFFE,这是一个相当大的正数。

 

因此,程序中应该尽量避免有符号数的右移位。如果一定要右移位,那么一定要在负数情况下,由程序来处理高位(要么高位与成0,要么高位或成1),以确保不会依赖编译器的特定行为。

4.6 / 和 %

/ 和 % 在处理两个正数或两个负数的时候一切正常。在处理一个正数、一个负数的时候却有问题了。

 

-5/3,商是多少,余数是多少?

 

按照C语言规范,商-1余-2是可以的,商-2余-1也是可以的。

 

这就麻烦了。不同的编译器有不同的方式,并且这方式不像4.5,没办法规避。

 

因此,折中的处理方式是程序中尽量规避一个负数和一个正数做除法和取余。如果遇到了非用不可的情况,那么一定要在项目的文档中注明,程序中哪里有这样的程序,其依赖于哪种除法的处理方式,以便在移植的时候对这些地方进行移植。

4.7 整型小结

写了这么多,可见整型虽然看上去很简单,但在编译器中的处理却是非常复杂的。一些隐性的行为,如果不对具体情况进行具体分析,很难分辨出其是否存在潜在的风险。

 

这里,总结几条写程序时的规则,防患于未然:

 

  1. 将程序员的意志显式地贯彻到程序中。常数带上后缀,会发生类型转换的地方采用显式的强制类型转换,移位后显式的补位……总之,不给编译器自作主张的机会。
  2. 对可能引起溢出的大数运算高度重视。2 + 2 不管什么情况当然都不会发生溢出,20000 + 20000 就很难说了。
  3. 能用无符号数就用无符号数,同时对有符号数高度重视。对有符号数,规避混用,规避右移位,规避除法和取余。
  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值