1.1 嵌入式环境下的C语言使用技巧
本节主要结合嵌入式开发的特点,讲解C语言的应用要点。随着进一步的学习和实践,读者将会发现本章总结的几个基本技巧是很有用的。
1.1.1 重要的位(bit)操作
位(bit)是程序设计中可以操作的最小数据单位,理论上可以用“位运算”来完成所有的运算和操作。在PC程序设计中涉及到对位操作的机会比较少。但如上面提到的,在嵌入式程序开发中常常操作的是以位为单位的数据,因此首先讨论C语言位操作的两个重要作用。
位操作的作用之一是可以减少除法和取模的运算。灵活的位操作可以有效地提高程序运行的效率,这对于处理器能力相对较弱的嵌入式开发来讲是非常有必要的。举例如下:
/* 方法1 */ /* 方法2 */ unsigned int i,j; unsigned int i,j; i = 156 / 16; i = 156 >> 4; j = 562 % 32; j = 562 - (562 >> 5 << 5);
对于以2的指数次方为“*”、“/”或“%”因子的数学运算,右边的方法2采用移位运算“<<”及“>>”通常可以提高算法效率。这是因为乘除运算的处理器指令周期通常比移位运算大,在读一些嵌入式程序代码时经常会看到这样的移位运算,实际作用就是降低代码的运行时间,提高效率。
移位操作有两种,即左移位和右移位。左移位操作比较简单,左移几位就在右边空出的位置补上几个零,即对于一个数NUM的位表示[Xn-1,Xn-2,…,X0],如果NUM << k过后,NUM的位表示应该为[Xn-k-1,Xn-k-2,…,X0,0,…,0],一共在右边填充了k个零。而相比之下右移位操作要复杂一些,有两种情况即:算数右移和逻辑右移,逻辑右移在左边补k个零,对于NUM>>k后得到的结果为[0,…,0,Xn-1,Xn-2,…,Xk],而算数右移NUM>>k后得到的结果为[Xn-1,…,Xn-1,Xn-1,Xn-2,…,Xk],结果是在左边补充k个最高位Xn-1。一般而言,编译程序对于有符号数的右移都是算数右移,而对于无符号数的右移都是逻辑右移,在做嵌入式程序设计中常定义一个无符号数,如上面的例子,使用unsigned int类型,而不直接使用int类型,其目的就是为使用逻辑右移。
C语言位运算除了可以提高运算效率外,在嵌入式程序设计中,它的第二个重要作用是实现位间的与(&)、或(|)、非(~)操作。这跟嵌入式系统的程序设计特点有很大关系。因为对底层硬件(处理器,内存,外围器件)的操作实际上都是通过读写相关寄存器进行,而对寄存器的读写,实际上就是按照硬件的器件手册(Data Sheet)的描述对该寄存器的某个或某些位进行与、或、非操作后置位。这一点,会在第2章中进一步解释。例如,通过将AM186ER型80186处理器的中断屏蔽控制寄存器的低6位设置为0(开中断2),最通用的做法是:
#define INT_I2_MASK 0x0040 wTemp = inword(INT_MASK); outword(INT_MASK, wTemp & INT_I2_MASK);
而将该位设置为1的做法是:
#define INT_I2_MASK 0x0040 wTemp = inword(INT_MASK); outword(INT_MASK, wTemp | ~INT_I2_MASK);
判断该位是否为1的做法是:
#define INT_I2_MASK 0x0040 wTemp = inword(INT_MASK); if(wTemp & ~INT_I2_MASK) { … /* 该位为1 */ }
1.1.2 正确使用数据指针
在嵌入式系统的程序设计中,常常要求在特定的存储单元读写内容或者直接访问嵌入式处理器的某个经过地址编址的寄存器(Register),通常汇编有对应的MOV指令,而除C/C++以外的其他程序设计语言基本没有直接访问绝对地址的能力。在嵌入式系统的实际调试中,多借助C语言指针所具有的对绝对地址单元内容的读写能力。以指针直接操作内存多发生在如下几种情况:①某I/O芯片被定位在CPU的存储空间而非I/O空间,而且寄存器对应于某特定地址;②两个CPU之间以双埠RAM通信,CPU需要在双埠口RAM的特定单元(称为mail box)书写内容以在对方CPU产生中断;③在利用字符迭加做自字符及简单图形显示时,需要读取在ROM或Flash的特定单元所刻录的汉字和英文字母。例如:
unsigned char *p = (unsigned char *)0xF000FF00; *p=11;
以上程序的意义为在绝对地址0xF000+0xFF00(这里假设处理器为16位的)写入11。在使用绝对地址指标时,要注意指标自增自减操作的结果取决于指针指向的数据类别。上例中p++后的结果是p=0xF000FF01,若p指向int,即:int *p = (int *)0xF000FF00;p++(或++p)的结果等同于:p = p+sizeof(int),而p-(或-p)的结果是p = p-sizeof(int)。同理,若执行:long int *p = (long int *)0xF000FF00; 则p++(或++p)的结果等同于:p = p+sizeof (long int),而p-(或-p)的结果是p = p-sizeof(long int)。
通过上面的解释,可以看到处理器是以字节为单位编址,而C语言指针以指向的数据类型长度做自增和自减。理解这一点对于以指标直接操作存储空间或硬件的地址空间是相当重要的。
1.1.3 函数等价于指令的集合
首先要理解以下3个问题:
① 语言中函数名直接对应于函数生成的指令代码在内存中的地址,因此函数名可以直接赋给指向函数的指针。
② 调用函数实际上等同于“调转指令+参数传递处理+回归位置入栈”,本质上最核心的操作是将函数生成的目标代码的首地址赋给CPU的PC寄存器。
③ 因为函数调用的本质是跳转到某一个地址单元的code去执行,所以可以“调用”一个根本就不存在的函数实体。大学里的《计算机组成原理》课程中曾经讲到,186 CPU启动后跳转至绝对地址0xFFFF0(对应C语言指针是0xF000FFF0,0xF000为段地址,0xFFF0为段内偏移)执行,请看下面的代码:
typedef void (*lpFunction) ( ); /* 定义一个无参数、无返回类型的 */ /* 函数指针类型 */ lpFunction lpReset = (lpFunction)0xF000FFF0; /* 定义一个函数指针,指向*/ /*CPU启动后所执行第一条指令的位置 */ lpReset(); /* 调用函数 */
在以上的程序中,根本没有看到任何一个函数实体,但是却执行了这样的函数调用lpReset(),它实际上起到了“软重启”的作用,跳转到CPU启动后第一条要执行的指令的位置。所以应该理解函数的本质,实际上函数就是一个指令集合;你可以调用一个没有函数体的函数,本质上只是换一个地址开始执行指令。
1.1.4 操作有限的存储空间
在嵌入式系统中易失存储器申请比一般系统程序设计时有更严格的要求,这是因为嵌入式系统的存储空间往往十分有限,不经意的存储空间泄露可能会很快导致整个系统崩溃。所以如果要采用动态分配策略操作存储空间,一定要小心以下几种情况。
① 一定要保证malloc和free语句成对出现,谁申请的空间就要由谁来释放。如有下面这样的一段程序:
char * function(void) { char *p; p = (char *)malloc(…); if(p==NULL) …; … /* 一系列针对p的操作 */ return p; }
在某处调用function(),用完function中动态申请的内存后将其free,如下:
char *q = function(); … free(q);
上述代码实际上存在很大的隐患,不小心很容易导致存储空间泄露。存储空间由function来申请,却由另外一个函数来释放。如果在外函数中没有使用free(q),那么就会导致每使用一次function函数就会损失一块存储空间,不满足malloc和free成对出现的原则。另外,这样使用还会导致代码的耦合度增大,因为用户在调用function函数时需要知道其内部细节。
正确的操作方法是在调用子函数时申请存储空间,并把指针传入function函数,如下:
char *p=malloc(…); if(p==NULL) …; function(p); … free(p); p=NULL; /*避免野指针*/ void function(char *p) /*函数接收指针p*/ { … /* 一系列针对p的操作 */ }
② 不要用指针动态申请存储空间。先看如下一段代码,这种错误的使用方法比较常见。
void GetMem(char *p, int num) { p = (char *)malloc(sizeof(char) * num); } void mian(void) { char *str = NULL; GetMem (str, 100); strcpy(str, "embedded programming"); }
问题出在函数GetMem中。编译程序总是要为函数的每个参数制作临时副本,指针参数p的副本是q,编译程序使q = p。如果函数体内的程序修改了q的内容,就会导致参数p的内容做相应的修改,这就是指标可以用做输出参数的原因。在上面代码中,q申请了新的存储空间,只是把q所指的内存地址改变了,但是p丝毫未变。所以函数GetMem并不能输出任何东西。事实上,每执行一次GetMem同样会损失一块存储空间,因为没有用free释放。正确的做法如下:
char *GetMem(int num) { char *p = (char *)malloc(sizeof(char) * num); return p; } void main(void) { char *str = NULL; str = GetMem(100); strcpy(str, "embedded programming"); ……. /*其他针对str的操作*/ free(str); }
上面代码中str及调用GetMem后返回的指标实际上指向的是同一块存储空间区域。
基本上,动态申请内存方式可以用较大的数组替换。对于程序设计新手,建议尽量采用数组。嵌入式系统可以以博大的胸襟接收瑕疵,而无法“海纳”错误。所以给出记忆体操作的基本原则:①尽可能的选用数组,数组不能越界访问;②如果使用动态申请,则申请后一定要判断是否申请成功了,并且malloc和free应成对出现。
1.1.5 理解栈空间(Stack)和堆空间(Heap)
在C语言程序中获取的存储空间主要分为栈区(Stack)和堆区(Heap),栈区由编译程序自动分配释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈。堆区一般由程序员分配释放,若程序员不释放,只有程序结束后可能才会被操作系统回收。它与数据结构中的堆是不一样的,分配方式类似于链表。
main() { int i; char str[10]; unsigned char *p1, *p2; for(i = 0; i < 10; i++) s[i] = 'a' + i; p1 = (unsigned char *)malloc(10*sizeof(unsigned char)); p2 = p1; }
如上这段代码,str[10]申请的空间是位于栈区的,而由p1指向的10个单位的unsigned char是位于堆区的。要注意,p1、p2本身是位于栈区的。
那么在嵌入式开发中,什么时候使用堆空间,什么时候使用栈空间?继续分析一下采用两种分配方式的区别,首先看看申请后的系统响应。
① 申请后系统的响应
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的free语句才能正确地释放本内存空间。另外,由于找到的堆结点大小不一定正好等于申请的大小,系统会自动将多余的那部分重新放入空闲链表中。
② 申请大小的限制
栈:栈是向低地址扩展的数据结构,是一块连续的内存区域。也就是栈顶的地址和栈的最大容量是系统预先规定好的,如果申请的空间超过栈的剩余空间时,将提示越界。因此,能从栈获得的空间较小。
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。由此可见,堆获得的空间比较灵活,也比较大。
所以,在嵌入式开发中,一般系统设置的默认栈空间都比较小,如果需要的存储空间比较大,那么应该优先考虑采用动态分配存储空间的策略,而如果需要的空间比较小,那么直接采用数组方式分配效率更高。例如:经常设计一个buffer(一段存储空间)对数据进行缓存后再进行处理,如果实时数据量比较大的情况下,一般采用动态申请空间。
1.1.6 关键词const的使用
const意味着“只读”。区别如下代码的功能非常重要:
const int a; int const a; const int *a; int * const a; int const * a const;
关键词const的作用是为给读你代码的人传达非常有用的信息。例如,在函数的形参前添加const关键词意味着这个参数在函数体内不会被修改,属于“输入参数”。在有多个形参的时候,函数的调用者可以凭借参数前是否有const关键词,清晰的辨别哪些是输入参数,哪些是可能的输出参数。
合理地使用关键词const可以使编译程序很自然地保护那些不希望被改变的参数,防止其被无意的代码修改,这样可以减少bug的出现。另外,const在C++语言中则包含了更丰富的含义,而在C语言中仅意味着“只能读的普通变量”,可以称其为“不能改变的变量”(这个说法似乎很拗口,但却最准确的表达了C语言中const的本质),在编译阶段需要的常数仍然只能以#define宏定义!故在C语言中如下程序是非法的:
const int SIZE = 10; char a[SIZE]; /* 非法:编译阶段不能用到变量 */
1.1.7 关键词volatile
为了提高程序代码的执行效率或者为了减少程序占用的内存空间,C语言编译程序常会对开发人员编写的代码进行优化。如下代码:
int a,b,c; a = inWord(0x100); /*读取I/O空间0x100埠的内容存入a变量*/ b = a; a = inWord (0x100); /*再次读取I/O空间0x100埠的内容存入a变量*/ c = a;
很可能被编译程序优化为:
int a,b,c; a = inWord(0x100); /*读取I/O空间0x100埠的内容存入a变量*/ b = a; c = a;
但是这样的优化结果可能导致错误,因为很可能系统的I/O空间0x100埠的内容在执行完第1次读操作后已经被其他写入新值,则第2次读操作读出的内容与第1次不同,b和c的值应该不同。在变量a的定义前加上volatile关键词可以防止编译程序的类似优化,正确的做法是在定义a变量前先:volatile int a;
在嵌入式开发中使用volatile变量可用于如下几种情况:① 并行设备的硬件寄存器(如:状态寄存器,例中的代码属于此类);② 一个中断服务子程序中会访问到的非自动变量(也就是全局变量);③ 多线程应用中被几个任务共享的变量。
1.1.8 处理器字长与内存位宽不一致处理
在PC平台的程序设计中是不用考虑处理器和内存的位宽是否匹配这一问题,但在嵌入式应用开发中却有必要对这一问题加以考虑,假如处理器和存储芯片的字长操作是不一致的,在编写底层代码的时候就需要解决这一问题。如:MSP430处理器的字长为16,而NVRAM的位宽为8,在这种情况下,需要为NVRAM提供读写字节、字的接口,如下:
typedef unsigned char BYTE; typedef unsigned int WORD; /* 函数功能:读NVRAM中字节 * 参数:wOffset,读取位置相对NVRAM基地址的偏移 * 返回:读取到的字节值 */ extern BYTE ReadByteNVRAM(WORD wOffset) { LPBYTE lpAddr = (BYTE*)(NVRAM + wOffset * 2); return *lpAddr; } /* 函数功能:读NVRAM中字节 * 参数:wOffset,读取位置相对NVRAM基地址的偏移 * 返回:读取到的字节值 */ extern WORD ReadWordNVRAM(WORD wOffset) { WORD wTmp = 0; LPBYTE lpAddr; /* 读取高位位元组 */ lpAddr = (BYTE*)(NVRAM + wOffset * 2); /*为什么偏移要×2? */ wTmp += (*lpAddr)*256; /* 读取低位字节 */ lpAddr = (BYTE*)(NVRAM + (wOffset +1) * 2); /*为什么偏移要×2? */ wTmp += *lpAddr; return wTmp; } /* 函数功能:向NVRAM中写一个字节 *参数:wOffset,写入位置相对NVRAM基地址的偏移 * byData,欲写入的字节 */ extern void WriteByteNVRAM(WORD wOffset, BYTE byData) { … } /* *函数功能:向NVRAM中写一个字 */ *参数:wOffset,写入位置相对NVRAM基地址的偏移 * wData,欲写入的字节 */ extern void WriteWordNVRAM(WORD wOffset, WORD wData) { … }
上述的使用方法在嵌入式系统的开发过程中经常会碰到,需要读者牢固掌握。
1.1.9 struct{ }结构体的使用
如果一个C/C++嵌入式开发项目的代码量比较大,那么可能需要大量的使用struct{}结构体。巧妙的使用struct{}不但可以使程序的代码量降低,还可以使得整个程序的结构更加清晰,更方便代码阅读和维护。
在嵌入式系统、网络协议、通信控制的C/C++编程中,经常要传送的不是简单的字节流(char型数组),而是多种数据组合起来的一个整体,其表现形式是一个结构体。经验不足的开发人员往往将所有需要传送的内容依顺序保存在char型数组中,通过指针偏移的方法传送网络报文等信息。这样做编程复杂,易出错,而且一旦控制方式及通信协议有所变化,程序就要进行非常细致的修改。
而一个懂得灵活使用struct{}的开发者则通常会采用另一种方法。举一个例子,假设网络或控制协议中需要传送三种报文,其格式分别为packetA、packetB、packetC。一般的开发者会采用如下的方式来分别定义三种报文。
struct structA { int a; char b; }; struct structB { char a; short b; }; struct structC { int a; char b; float c; };
而优秀的程序设计者会这样设计传送的报文:
struct CommuPacket { int PacketType; //报文类型标志 union //每次传送的是三种报文中的一种,使用union { struct structA packetA; struct structB packetB; struct structC packetC; }pUn; };
在需要进行报文传送时,直接传送struct CommuPacket这个整体。假设发送函数的原形如下:
// pSendData:发送字节流的首地址,iLen:要发送的长度 Send(char *pSendData, unsigned int iLen);
发送方可以直接进行如下调用发送struct CommuPacket的一个实例sendCommuPacket。得到Send()函数的另一种更好的写法:
Send((char *)&sendCommuPacket, sizeof(CommuPacket));
假设接收函数的原形如下:
// pRecvData:发送字节流的首地址,iLen:要接收的长度 // 返回值:实际接收到的字节数 unsigned int Recv(char *pRecvData, unsigned int iLen);
接收方可以直接进行如下调用将接收到的数据保存在struct CommuPacket的一个实例recvCommuPacket中:
Recv((char *)&recvCommuPacket, sizeof(CommuPacket));
接着判断报文类型进行相应处理:
switch(recvCommuPacket.PacketType) { case PACKET_A: … //A 类报文处理 break; case PACKET_B: … //B 类报文处理 break; case PACKET_C: … //C 类报文处理 break; }
以上程序中最值得注意的是Send((char *)&sendCommuPacket,sizeof (CommuPacket));Recv((char *)&recvCommuPacket , sizeof(CommuPacket));中的强制类型转换:(char *)&sendCommuPacket、(char *)&recvCommuPacket,先取地址,再转化为char型指针,这样就可以直接利用处理字节流的函数。利用这种强制类型转化,还可以方便程序的编写,例如:要对sendCommuPacket所处内存初始化为0,可以这样调用标准库函数memset():
memset((char *)&sendCommuPacket,0, sizeof(CommuPacket));