《深入BREW开发》——第二章 软件基础

来源:互联网 发布:软件压力测试 编辑:程序博客网 时间:2024/05/16 12:32

 第二章 软件基础

       我们正在向我们的软件王国进发,千万别急,在这条路上“枯燥”是我们最大的敌人,不知有多少人在它的面前臣服,但愿您不是其中之一。或许您觉得应该获得一些鼓励,写一些代码,能够看见一些诸如“Hello, World!”之类的信息。非常幸运,从这里开始您将能够看见它们了,我会将部分内容使用源程序的方式向您讲解。在这本书里,我将使用Visual Studio .Net2003的开发环境来执行这些测试程序。建立测试工程的步骤如下:
       1、首先打开Visual Studio .Net2003开发环境,选择新建项目。如图2.1,选择项目类型Visual C++项目/Win32控制台项目,选择路径,填写测试程序名称是Test1,点击确定按钮。
       2、在Win32应用程序向导中接受默认设置,点击完成按钮。如图2.2。

图2.1 建立测试程序
       这样就完成了一个测试用的应用程序,在以后的测试程序中我将不再重复这个步骤。由于我们主要是在C语言的基础上讲解,因此在这里创建的是Win32控制台应用程序,它使用Windows的命令窗口显示输出结果。不过请注意,虽然它使用Windows的命令窗口,但是它是一个Win32的应用程序,而不是DOS应用程序。
       在Windows环境下,应用程序分为控制台(Console)应用程序和窗口应用程序。控制台应用程序不显示窗口,其表现形式类似于DOS环境下的应用程序,但是由于它可以调用Windows的API来实现,所以它是一个Windows的应用程序而不是DOS应用程序。同时控制台应用程序可以使用标准C/C++的库,这样Windows下的控制台应用程序就和DOS环境下的C/C++十分的相似了。
       在这一部分我们将通过实例来演示一些C语言中较比令人“眩晕”的话题,期望能够通过这些实例让您弄明白这些问题的本质。由于本书是建立在您已经有一定的C语言基础之上的,因此主要是针对C语言中一些较难理解的概念进行讲解,毕竟我们不是一本专门讲解C语言的书籍。在本部分主要讲解的内容是指针、结构体、预处理和函数,因为在我们接下来的行程中必须要充分的理解它们才能继续前进。

图2.2 Win32应用程序向导
2.1 重温C语言的指针
       指针是一个精灵,以至于在我们刚刚接触它的时候有点不知所措,甚至有些人怀着敬畏的心情而决心远离它!不过,当我们掌握了它的时候,就会发现它能让我们随心所欲。尽管我们仍然面临着使用不当所带来的巨大风险,但是我还是会义无反顾的告诉您——一定要使用它。之所以我会在这里向您发出这样的号召,绝对不是因为我对指针的个人情感,我和指针也是非亲非故,只不过是因为透过它我们不但可以编写出灵活的程序,而且可以窥探到程序的真正世界——二进制世界的秘密。这就让我们开始拥抱它吧!
2.1.1指针的本质
       指针的本质是存储它所指向存储空间地址的变量,下面将通过一个测试程序来开始征服它的旅程。打开测试工程Test1,在Test1.cpp中添加如下代码:
#include "stdafx.h"
 
int _tmain(int argc, _TCHAR* argv[])
{
       int *pointer;
       int nNumber = 100;
      
       pointer = &nNumber;
       printf("&pointer = 0x%x pointer = 0x%x *pointer = %d /n",&pointer,pointer,*pointer);
return 0;
}


在这段代码中,我们定义了一个int型指针pointer和一个int变量nNumber,然后让pointer指向nNumber。编译、运行生成可执行文件,可以看到输出的&pointer、pointer和*pointer的值如下:
&pointer = 0x12fed4
pointer = 0x12fec8
*pointer = 100


*pointer = 100是我们知道的,也就是指针指向的内存地址的内容,我们在程序中将pointer指针指向nNumber的地址,那么与nNumber的值相等就不足为奇了。pointer = 1244872这个值就是指针指向的内存地址,是当前函数运行中存储nNumber的地址。&pointer就是这个指针变量的地址,由于我们定义了一个指针变量,因此,在函数运行时将会为这个变量分配一个存储空间,因此这个值是有意义的,而且它与pointer的值十分相近。我们可以这样理解指针,它是一个变量(因为指针也需要空间存储它所指向的地址),这个变量的值就是一个内存空间的地址。示意图如下:
100


0x12fec8
0x12fec8


0x12fed4
pointer
nNumber


图2.3 指针示意图
在上面的代码中,&pointer是一个指向指针的指针,这样的称呼实在是过于繁琐了,我们就称它为“二重指针”吧。顺延的,如果是指向(指针的指针)的指针我们就叫它“三重指针”。在C语言中,二重指针是一个非常有用的东西,很多高阶的C语言应用都会使用到它。二重指针的主要作用是做为参数为一个指针变量赋值,在后面的章节中我们还会经常使用到它。
对上面的图2.3作一个说明,pointer和nNumber分别位于两个不同的内存区域,nNumber中存储的值是100,这是程序中指定的;pointer内存储的值是0x12fec8,这个值正好是nNumber所在的内存地址;图左边的0x12fed4是存储pointer值的指针变量的地址,我们可以通过定义一个二重指针获得它的内容,在本程序中我们通过&pointer来获得它的内容。
从本质上来说,指针、二重指针、三重指针等等,都是一样的,从代码的层次(我们将在“编译器基础”部分详细讲解软件的层次问题)来讲都是一个“地址”型的变量,只不过使用的时候会由编译器做一下合法性的检查,目的是为了防止程序出现错误;在二进制层次来说它们就更加没有分别了,都是一个存储内容的容器。打个比方,现在有两个盒子,一个规定放置篮球,另一个规定放置足球。体现在C语言中这个“规定”就是定义了两个变量,一个是int型的,另一个是指针型的;这两个盒子就是两块存储空间。在现实生活中“规定”是由人来制定的,在程序中就是由C语言定义的。那么我违反规定放置篮球的盒子我放置足球,放置足球的盒子我放置篮球。这是没什么问题的,不过会发生错误,因为会让一场足球比赛变成了踢篮球的比赛,让一场篮球比赛变成了足球投篮的比赛。同样的对于指针和变量的关系也是,它们的内容在二进制层次是可以互换的。但是我们在这里要考虑两件事情,一是这个错误发生的前提条件,这个错误要发生必须是在球的管理者不知道错的情况下。如果球的管理者知道哪个盒子放了篮球哪个盒子放了足球也不会出错。体现在程序上,就是增加强制类型转换来告诉编译器“我知道我要在int变量中放置一个指针的值”。否则编译器将检查到这个错误并报错。二是要考虑“盒子”容量的问题,因为足球比篮球小,所以互换的时候不能让篮球撑坏了足球盒子。在程序中也就是32位的指针变量不能放到char型变量中去存储,因为这样会丢掉地址信息。
综上所述,虽然变量的二进制本质是一样的,但是在代码层次要精确控制变量的类型来避免错误的发生。指针也是一个变量,一个32位的指针变量也可以存储在一个4字节的无符号整型变量里,前提是我们要知道我们是采用这种方式来存储它们的。
最后使用一个例子来做个演示,新建工程Test2,输入如下代码:
#include "stdafx.h"
 
int _tmain(int argc, _TCHAR* argv[])
{
       int nNumber = 100;
       int *pointer = &nNumber;
       unsigned int   dwBox;
       unsigned short wBox;
   
       // 将指针的值分别赋给无符号整型变量
       dwBox =(unsigned int)pointer;
       wBox =(unsigned short)pointer; // 地址内容将被裁减!!!
      
       // 输出Box变量的值,注意wBox与dwBox之间的不同
       printf("wBox = 0x%x dwBox = 0x%x/n",wBox,dwBox);
 
       // 输出Box变量指向地址的值,其中*((int *)dwBox)相当于*pointer
       // 此时不能够使用*((int *)wBox)输出值,因为它的地址是经过裁减的
       printf("*dwBox = %d/n",*((int *)dwBox));
       return 0;
}


     编译后运行,输出如下结果:
       wBox = 0xfed4 dwBox = 0x12fed4
       *dwBox = 100
请仔细体会上面的代码,它真正揭示了变量与指针之间微妙的关系,还有类型转换时所发生的数据裁减。
2.1.2指针的增减
关于指针的增减是一个比较容易让人迷惑的问题,指针本身也是一个变量,它里面存储的是一个地址,那么这个变量的自增是将这个地址值加1吗?自增操作和直接加1的操作是一样的吗?
为了弄清这个迷惑,这里我们在Test2工程中增加一个Test2-1的项目(请设置Test2-1为启动项目),然后输入如下代码:
#include "stdafx.h"
 
typedef struct _Test2{
       char Test2[100];
}Test2;
 
int _tmain(int argc, _TCHAR* argv[])
{
       int   nValue = 1;
       char cValue = 'A';
       Test2 sValue;
       int   *intPtr   = &nValue;
       char *charPtr      = &cValue;
       Test2 *structPtr= &sValue;
 
       printf("Int %d Char %d Struct %d/n",(int)intPtr,(int)charPtr,(int)structPtr);
 
       intPtr++;
       charPtr++;
       structPtr++;
       printf("Int %d Char %d Struct %d/n",(int)intPtr,(int)charPtr,(int)structPtr);
 
       intPtr   += 1;
       charPtr += 1;
       structPtr+= 1;
       printf("Int %d Char %d Struct %d/n",(int)intPtr,(int)charPtr,(int)structPtr);
       return 0;
}


       编译运行输出的结果如下:
       Int 1244884 Char 1244875 Struct 1244764
       Int 1244888 Char 1244876 Struct 1244864
       Int 1244892 Char 1244877 Struct 1244964
       从这个结果中,我们可以看出,指针的加减是与指针所指的变量类型有关的,它所增加的步数是所指变量的大小,而且指针的+1和++操作是一样的。那么void型的没有类型的指针怎么处理呢?答案是不处理,编译器会通知我们“未知大小”的错误而终止程序的生成。
2.2 重温C语言的结构
       结构是C语言中组织不同数据类型的一种方式,它将不同的数据类型组织到一个相邻的地址空间内。每个定义的结构体变量就是一个多种数据的存储空间。在这里我们主要讲述几个相对“高阶”问题,之所以在这里加上引号,是因为对于某些传说中的高手来说,这不过是小菜一碟。
2.2.1结构体变量赋值
       我们已经习惯了为结构体变量中的每个成员赋值,那么我们可以在两个结构体变更之间直接使用“=”号赋值吗? 答案是肯定的,因为编译器支持。例如定义一个表示矩形的结构体:
       typedef struct _Rectangle{
              int x;       // 左上角x坐标
              int y;       // 左上角y坐标
              int dx;     // 矩形宽度
              int dy;     // 矩形高度
       } Rectangle;
      
       定义两个矩形结构体变量并赋值:
       Rectangle Rect1, Rect2;
       Rect1.x = 100;
       Rect1.y = 100;
       Rect1.dx = 100;
       Rect1.dy =100;
      
       Rect2 = Rect1;
      
       上面的赋值在C语言中是支持的,编译器会将Rect2 = Rect1中的值转化成内存拷贝的CPU指令来实现赋值操作。可以想象,对于简单的变量赋值,CPU只需要执行一个MOV指令就可以完成了,因此对于包含多个简单变量的结构体来说,使用多个循环的MOV指令就在情理之中了(在早期16位CPU中,如果对一个32位的int变量执行赋值操作都需要两条MOV指令)。在使用这种赋值方法的时候需要注意的是,在这个结构体变量中最好不要有指针变量,因为指针变量可能在变量1中指向一个分配的内存区域,当变量2通过赋值操作获得了这个指针值的时候,有可能这个指针已经释放了,这样就导致了空指针情况的发生,后果是使用这个指针的时候将会导致程序崩溃。举例说明如下:
       1、定义一个包含指针类型的结构体:
 
typedef struct _TestStruct{
       int nMember;
       int *Ptr;
}TestStruct;
 


2、定义两个这种类型的变量并采用如下使用方法:
 
TestStruct Struct1, Struct2;
Struct1.nMember = 100;
Struct1.Ptr = (int *)malloc(15*sizeof(int)); // 分配15个int变量的空间
 
// 结构体赋值
Struct2 = Struct1; //此时Struct2.Ptr与 Struct1.Ptr的值相等
 
if(Struct1.Ptr)
{
       free(Struct1.Ptr);
       Strict1.Ptr = NULL;
}
 
// 这里有很复杂的处理,其中包含了malloc等操作
 
Struct2.Ptr[0] = 2; // 错误的赋值操作,因为此时Struct2.Ptr所指向的内容已经被释放了。
 


对于程序来说,修改一个已经释放了空间的内存地址内容是十分危险的。当然,如果程序只有上面那么简单的话也不会出现什么严重的问题,顶多只是非法使用了一块内存区域;但是,如果中间含有复杂的处理,Struct2.Ptr[0] = 2将修改程序其他部分使用的内存区域,那么这样就可能会有莫名其妙的死机之类的事情发生了。由于其发生问题的时间不固定,因此这类问题调试起来也十分的困难。
2.2.2结构体嵌套
       在一个结构体中可以声明另一个结构体,形成结构体嵌套,如果将内部嵌套的子结构体变量放在父结构体的顶部,那么两个结构体之间还可以进行类型互换。这个特性为实现C语言的数据封装提供了一种方法。例如定义如下结构体:
 
typedef struct _Point{
              int x;
              int y;
       }Point;
      
typedef struct _Rectangle{
       Point LeftTop;
       int   dx;
       int   dy;
}Rectangle;
 


由于结构体中Rectangle嵌套了结构体Point,因此如果定义变量Rectangle Rect1则Rect1可以转化成Point使用。例如:
Rectangle Rect1 = {{100, 100}, 100, 100};
Rectangle *pRect = &Rect1;
Point *pPoint = (Point *)&Rect1;


如果需要访问这个矩形的左上角的x坐标值可以有两种方法:pRect->LeftTop.x或者pPoint->x。Rectangle结构体的内存模式如图2.4所示:
x
y
dx
dy
Rectangle
Point


图2.4 结构体内存模型
从图2.4可以看出Point是嵌入在Rectangle中的,两个结构体的顶端地址是一样的,因此他们之间的指针可以互换,并且可以正常操作。
2.3 重温C语言的预处理
       在编译器编译源文件之前,会首先通过预处理器来处理源程序中的预处理选项。大名鼎鼎的宏也是预处理的一种,预处理器采用直接替换的方式来处理宏,也就是说将宏定义的内容替换到源文件中之后才开始编译,在每一个调用宏的地方就有一个宏的替换体。例如定义如下宏:
       #define MAX(a, b) (a > b ? a : b)
       在程序中使用一次MAX(a, b)对应的预处理器就把(a > b ? a : b)替换到相应的位置,结果是增加了可执行程序的大小(因为有n个重复的代码段)。
如果定义的需要多行的宏,则使用“/”做为行与行之间的连接符,请看下面的宏定义:
       #define INTI_RECT_VALUE( a, b )          /
{                                             /
           a = 0;                               /
           b = 0;                                     /
}
注意最后一行就不再使用“/”了。
除了宏之外还有代码的选择编译也是预处理器的主要功能之一。在一个大型的软件项目中,有许多功能需要根据不同的硬件平台或者软件用途来进行选择,例如一个软件的中文版和英文版,在发布的时候就需要使用定义的中文或英文标签来决定。
C语言的预编译器使用的关键词和功能描述如下表:
关键词 功能描述
#define  用来进行宏和符号或常量的定义。
#undef 取消通过#define定义过的符号。
#if 用来判断预处理条件,需要#endif做为结束标记。相对应的#ifdef 和#if defined用来判断符号是否定义;#ifndef 和#if !defined()是判断符号是否未定义。
#else #ifdef、#ifndef的条件分支语句
#endif #ifdef、#ifndef的条件结束语句,只要有#if就需要有#endif
#error 无条件的向预处理器报错。通常用在#if…#endif之间,用来判断是否符合编译条件。例如:
#ifndef ENABLE_COMPILE
#error Disable compile
#endif
#include 用来包含文件。通常是用来包含头文件,但实际上它什么文件都可以包含。它直接将文件的内容引入到当前的包含文件中,这些包含都是由预处理器完成的。
#pragma 指定编译器的参数,这个和具体的编译器有关。例如有些编译器支持startup和exit pragmas,允许用户指定在程序开始和结束时执行的函数。
#pragma startup load_data
#pragma exit close_files
__FILE__ 预处理常量,代表当前编译的文件名。例如可以使用如下代码输出当前的文件名:printf(“This file name is %s”,__FILE__);
__LINE__ 预处理常量,代表当前编译的行数。例如可以使用如下代码输出当前的行数:printf(“Current line is %d”,__LINE__);
__DATE__ 预处理常量,代表当前编译的日期。例如可以使用如下代码输出当前的编译日期:printf(“Current compile date is %s”,__DATE__);
__TIME__ 预处理常量,代表当前编译的时间。例如可以使用如下代码输出当前的编译时间:printf(“Current compile time is %s”,__TIME__);


在使用预处理的时候需要注意两件事情:
1、在定义宏或常量时候尽可能的使用括号。这是因为预处理器是将宏和常量采用直接替换的方式,如果不是用括号则有可能产生错误的程序处理。看下面的代码:
#define DISPLAY1_HEIGHT 320
#define DISPLAY2_HEIGHT 240
#define DISPLAY_SUM DISPLAY1_HEIGHT+ DISPLAY2_HEIGHT

if(DISPLAY_SUM*2 >200)
{
       …
}

上面的判断语句的真实想法是如果显示高度之和的2倍大于200则进行相应的处理。而实际上进行预处理后上面的代码变成了if(DISPLAY1_HEIGHT+ DISPLAY2_HEIGHT*2)也就是if(320+240*2),这与我们期望的值相差太远了。因此在定义的时候最好加上括号,这样就不会出问题了。
2、在使用宏的时候必须不能出现参数变化。请看下面的代码:
#define SQUARE( a ) ((a) * (a))
 
int a = 2;
int b;
b = SQUARE( a++ ); // 结果:a = 4,即执行了两次增1。
 
正确的用法是:
b = SQUARE( a );
a++; // 结果:a = 3,即只执行了一次增1。
2.4 重温C语言的函数
       理论上我们可以只使用一个main函数,因为不管多少程序我们都可以就写在一个main里面。但是您能设想有一个10万行的main函数吗?所以我们不得不把程序分成各个模块,不但要分成函数,而且还要将不同类型的函数放在不同的文件中。就我的经验而言,通常程序多于200行的时候应该考虑分成函数,而一个文件中的总共代码最好不要超过5000行。多了就不利于调试和阅读。当然,这些内容只是为了增强代码的可读性和易管理性的问题,但是您也要知道,软件在达到一定规模后就不单单是技术问题了,还有管理的问题。
2.4.1形参和实参
直到现在我还我还记得在我初学C语言的时候形参和实参给我带来的困惑,我期望能够用非常简单明了的语言来描述它们,让我们来试试吧!
形参:在函数定义的时候使用的参数叫做形参。
实参:在使用函数的时候传递的参数叫做实参。
举例说明:
int add(int a, int b) // 这里是函数的定义,a,b都属于形参
{
       // 这里全部都是函数的实现
       return(a+b);
}
       上面的函数展示了一个函数的各个部分——定义和实现,在函数定义里的a和b都是形参,在函数的实现体内,也就是a+b,中的a和b是实参的替身。这里是最容易让人困惑的地方,看一个例子吧,假设有一个函数calc需要调用add函数来实现加法的运算功能:
       int calc(int calctype, int p1, int p2) // calctype、p1和p2都是形参
{
       if(calctype == 1)
{
       return add(p1, p2);    // 在这里的p1和p2是形参还是实参?
}
return 0;
}
根据我们对形参和实参的定义,p1和p2对于calc函数来说是形参,但是对于add函数来说是实参。哦,恍然大悟了没有?对于判断到底是实参还是形参是有参考坐标的。同样的此时在函数add内部a+b等同于p1+p2,因此说在函数的实现体内形参(形式上的参数)是调用时实参(实际上的参数)的替身。相当于形参为实参占了一个位子,使用函数的时候实参就坐在了这个位子上。
如果您理解了上面的内容,那么在使用函数的时候就再也没有必要纠缠于实参和形参了,因为它们合起来完成了一个参数的传递过程。调用函数的时候,按照形参的类型传递相应的变量就可以了。形参和实参只是一个概念上的区分,在实际的二进制层次并没有这个概念,因此我们不要过分拘泥于此,理解了就好。
2.4.2宏与函数的比较
       对于一个初级的程序员来说,什么时候使用宏,什么时候使用函数还是有些晕的,这里我也顺带的提一下吧。从前面可以知道,当预处理器遇到宏的引用时,都是将它用宏语句进行替换。因此,在每一个宏引用的地方都会增加相应的宏代码,结果就是使得编译后的代码加大。而函数则是直接调用函数的代码,不会增加编译后的代码大小。不过使用函数的缺点是在函数调用的时候会增加一些额外的处理,这使得执行函数的时间比相应的宏代码的执行时间要长一些。因此如果在需要高效率的时候就应该使用宏,如果需要程序变得更小则使用函数。关于函数的额外处理部分的信息将在下一节编译器基础中进行介绍,到那个时候我们就会对函数是如何执行的问题有一个全面的了解了。
2.4.3函数指针
       还记得指针吗?什么,忘了!对您竖起我的大拇指,恭喜您已经修成正果了,因为在您的眼里已经没有了对指针特别的概念。还记得我们在介绍指针的时候说的吗,指针就是一种变量。那么函数呢?其实每个函数也是一样,每个函数名就是一个函数的首地址,因此我们也可以定义一个指针变量来存储它,这个就是函数指针——指向一个函数的指针。这一节将让您从另一个深度上加深对指针的认识。
       函数指针在多任务的操作系统环境下是十分有用的。在多任务操作系统环境下,由于牵涉到多个任务之间的交互,因此通常使用函数指针的形式将一个函数做为参数传递给另一个任务,以便于当另一个任务完成操作之后可以调用当前任务的函数通知状态。这个由“另一个任务”调用的函数就是大名鼎鼎的回调(Callback)函数,这个回调函数的传递就是通过函数指针这个载体实现的。回调函数还常用在定时服务的程序里,在设置一个定时器的时候我们会指定一个回调函数,用来在到达定时时间的时候调用以做出相应的处理。
       通常情况下使用如下方式定义一个函数指针类型:
       typedef int (*FunctionType)(int param1, int param2);
       FunctionType pFunction;


       使用的时候可以为pFunction赋值,在这里FunctionType的函数类型与我们上面的add函数的形式相同,因此我们可以改用下面的代码来调用add函数:
int nResult;
       FunctionType pAddFunc = add;
      
       nResult = pAddFunc(1,2); // 返回的结果是3
       


       在这里之所以使用typedef来定义一个类型是为了保持在C语言环境下的函数调用一致。为了更好的理解函数指针,我将使用一个void型的函数指针来传递这个函数。打开Visual Studio 2003 .Net,新建VC控制台工程Test3(请参考前面Test1的创建过程),输入如下代码:
#include "stdafx.h"
 
typedef int (*FunctionType)(int param1, int param2);
 
int add(int a, int b)
{
       return(a+b);
}
 
int _tmain(int argc, _TCHAR* argv[])
{
       int nResult;
       void *pVoid = add; // 通过Void型的指针传递函数指针
      
       nResult = ((FunctionType)pVoid)(1,2);
 
       printf("Result is %d/n",nResult,0,0);
       return 0;
}


编译链接运行,可以看见输出结果是3。
从上面的例子可以看出,一个函数名其实就是一个地址,可以直接赋值给指针,并且通过指针类型的转换来实现函数的调用。进一步,如果FunctionType类型与函数add之间的参数和返回值不一样可以吗?还是让事实说话吧,将上面的程序修改成如下样式:
#include "stdafx.h"
 
typedef int (*FunctionType)(int param1);
 
int add(int a, int b)
{
       return(a+b);
}
 
int _tmain(int argc, _TCHAR* argv[])
{
       int nResult;
       void *pVoid = add; // 通过Void型的指针传递函数指针
      
       nResult = ((FunctionType)pVoid)(1);
 
       printf("Result is %d/n",nResult,0,0);
       return 0;
}


编译链接,可以通过。运行,输出结果是2012749654,好大的数字!
从上面的代码情况看出,程序是可以运行的,只不过输出的结果不对。这是因为add需要两个参数,而我们调用的时候却只有一个参数。那么另一个参数是什么呢?答案是一个随机数。前面我们说过,函数中会通过形参为实参留个“位子”,那么现在有两个“位子”却只用了一个,那么可以预见的,这个空的位子中就是一个随机数了。
如果到现在为止,您对这个例子还不是很明白的话,请您一定要再重新体会一下,因为这部分的内容对于您理解程序会有很大的帮助。
2.4.4不要使用结构体或数组做为函数的参数
在前面讲解函数参数的时候我们曾经提到过,函数会通过声明的形参为实参留“位子”,那么这是一个多大的“位子”呢?答案是和形参的数据类型的大小一样,而且这个位子是存放在系统的堆栈中的。结果是参数的数据类型越大,需要的堆栈空间就越大,这对于内存空间很小的系统来说是很不利的。而且在进行实参传递的时候还需要将实参的内容拷贝到对应的“位子”中,因此就增加了很多的调用函数时的额外处理。(这里称呼成堆栈,实际上应该是“栈”,因为我们目前习惯于称呼“栈”为堆栈。“堆”通常指的是一个使用malloc等函数分配的内存空间。)
基于上述原因,推荐不直接使用大的结构体或数组做为函数的参数,请使用指针进行传递,这样可以省掉很多调用函数的额外处理,提高函数的执行效率。同时占用的堆栈空间比较小,减少了堆栈溢出的可能性,因此使用指针传递参数是一个多赢的方法。千万不要因为害怕指针而不使用它,做为一名C语言的程序员是永远也逃脱不了指针的,况且一旦掌握了它的精髓,您就会对它爱不释手了。
2.4.5使用指针参数传递数据
指针做为C语言的灵魂具有很强的技巧性和灵活性,而做为参数传递数据则是它非常重要的应用之一。这里的“传递”有两层含义:传入和传出。
传入就是调用函数的地方将数据传递给函数进行处理,例如一个数组排序的函数,需要通过指针将数组传递给函数使用。这里以一个进行字母排序的函数为例,代码如下:
void CharSort(char *pString)
{
char *pCurrPtr;
char temp;
 
// 判断参数的合法性
if(pString == NULL)
{
    return;
}
 
// 排序算法
for(;*pString!= ‘/0’; pString++)
{
    for(pCurrPtr = pString+1; *pCurrPtr != ‘/0’; pCurrPtr++)
    {
        // 将最小的放在当前位置
        if(*pString > *pCurrPtr)
        {
            // 交换数据
            temp = *pString;
            *pString = *pCurrPtr;
            *pCurrPtr = temp;
        }
    }
}
}


       这个函数是一个简单的冒泡排序算法,使用这个函数需要传递一个char类型的数组。此时这个指针参数pString就包含了传递参数给函数使用的功能。正如上一节所讲的一样,这里面使用指针来传递数据是十分高效的,只需要在函数栈空间内分配一个指针空间就好了。
       指针参数的另一个作用是可以传出参数,返回函数的处理结果。通常情况下我们都是通过函数的返回值来返回处理结果的,但是如果遇到需要返回一个大数组或大数据结构的时候,使用返回值传递参数就显得效率低下了。我们仍旧以上面的CharSort函数为例,这个函数的参数其实就具有传入和传出数据两种功能,它首先通过pString参数将排序的字符串传递给函数,然后函数的处理结果也是直接通过pString来写入原存储空间的。这个过程的示意图如下:
CharSort函数
 
直接在pString所指向的Array空间内进行排序
Array[] = “asdfghjkl896”;
pString参数
Array[] = “689adfghjkls”;


图2.5 指针参数的传入传出数据功能
       使用指针参数传出数据在编写C语言程序时是十分普遍的,除了上面的传递数组外,还可以传递单个数据,也可以通过二重指针传递指针值。总之,指针做为参数使用是十分灵活多变的,各位读者可以仔细体会,关键的是理解指针的本质意义——地址类型的变量。
2.5 C语言中几个特殊的关键词
       在这里我只是简单地介绍一下volatile、__packed和const的作用,省得我们在看到它们的时候不知所措。
2.5.1 volatile关键词
       volatile的中文意思是“易挥发的”,它主要是给编译器提个醒,告诉编译器对于volatile变量不要轻易的进行优化,因为在程序运行过程中这个值会被其他的任务或硬件改变。在编译器中对于语句通常会做一些优化,例如有如下程序:
 
       bool bExit == FALSE;
       …
       for(;;)
       {
              …
              if(bExit)
              {
                     break;
              }
}
 


假设现在有另一个任务或线程通过bExit来控制程序的退出。如果此时变量不使用volatile关键字说明的话,编译时就会对if(bExit)进行优化,不再在每一次for循环中判断bExit了,这样就会导致程序运行错误。因此,此时应使用volatile关键字说明bExit变量,这样编译器就不会做这样的优化了。
2.5.2 __packed关键词
__packed用来声明结构体采用单字节偏移。并不是所有的编译器都支持这个选项。使用__packed声明的结构体会压缩空间。例如有下面一个结构体:
struct _Test{
       int a;
       char b;
       char c;
       int d;
}Test;


如果不使用__packed声明,在ARM编译器中sizeof(Test)等于12(在ARM编译器中是4字节偏移,int也是4字节变量)。加入__packed说明后,sizeof(Test)等于10,编译器会压缩Test结构体中b、c和d变量之间的padding字节。对比示意图如下:
       int a;
(4 B)
char b;(1B)
 
char c;(1B)
 
       intd;
(4 B)
 
int a;
(4 B)
 
char b;(1B)
char c;(1B)
 
       int d;
(4 B)
 
Padding
(2B)
 
无__packed
有__packed
 


图2.6 结构体内存映射
       从这个图中可以看出,经过__packed说明之后的结构体,相对于没有使用__packed说明的节省了2字节的padding存储空间,实际上这给我们提供了一种紧凑数据的方法。
2.5.3 const关键词
       使用const的好处在于它允许指定一种语意上的约束——某种数据不能被修改——编译器具体来实施这种约束。通过const,我们可以告知编译器和其他程序员某个值要保持不变。只要是这种情况,我们就要明确地使用const ,因为这样做就可以借助编译器的帮助确保这种约束不被破坏。
对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const,还有,两者都不指定为const:
char *p            = "hello";    // 非const指针, 非const数据
const char *p       = "hello";    // 非const指针, const数据
char * const p       = "hello";    // const指针,非const数据
const char * const p = "hello";    // const指针, const数据


语法并非看起来那么变化多端。一般来说,我们可以在头脑里画一条垂直线穿过指针声明中的星号(*)位置,如果const出现在线的左边,指针指向的数据为常量;如果const出现在线的右边,指针本身为常量;如果const在线的两边都出现,二者都是常量。标示为const的数据,在编译器中当作RO只读数据处理。
       在指针所指为常量的情况下,有些程序员喜欢把const放在类型名之前,有些则喜欢把const放在类型名之后、星号之前。所以,下面的函数取的是同种参数类型:
       void f1(const int *pw);      // f1取的是指向,widget常量对象的指针
       void f2(int const *pw);      // 同f2


2.6 地址对齐
       我们知道,在计算机系统中都是使用地址来管理数据,每一个数据实体都存储在一定的地址空间内,如一个长度为100的char型数组存储在一个100字节的连续空间中。一个连续的地址空间中既有奇数的地址也有偶数的地址,这中间就存在了一个数据以什么样的地址做为起始地址的问题。许多实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数(通常它为4或8)的倍数,这就是所谓的内存对齐。而这个系数则被称为该数据类型的对齐模数(alignment modulus)。
       这种强制的要求一来简化了处理器与内存之间传输系统的设计,二来可以提升读取数据的速度。比如有这样的一种处理器,它每次读写内存的时候都从某个8倍数的地址开始,一次读出或写入8个字节的数据,假如软件能保证double类型的数据都从8倍数地址开始,那么读或写一个double类型数据就只需要一次内存操作。否则,我们就可能需要两次内存操作才能完成这个动作,因为数据或许恰好横跨在两个符合对齐要求的8字节内存块上。某些处理器甚至在数据不满足地址对齐要求的情况还会出错,
       地址对齐的问题主要表现在两个方面:一是通过指针类型转换访问数据的情况;二是在结构体内部的数据对齐和访问。接下来我们将就这两个问题做一个阐述。
2.6.1指针的数据类型转换
       C语言的指针是非常灵活的,我可以将任何一种数据类型的指针转换成另一种数据类型的指针,例如,将一个(char *)转换成一个(int *),或者将一个(int *)转换成(char *)。这样的指针类型转换在C语言中十分的普遍,更有甚者,我们还可以定义一个无类型的指针(void *),可以不受限制的接受任何类型的指针,而编译器也不会提示任何错误。这既是C语言灵活的标志,但在其中也蕴藏着杀机!
       我们可以假设这样的一种情况,我们从一个串口设备或者网络设备上接收16个字节的数据,这些数据都是使用字节方式传送的。此时我们规定,这些数据的第一个字节表示数据的类型,而其后的数据则表示真正的数据内容。此时我们会定义这样的一个数组来接收数据:
    byte rsv_buf[16];


       现在,我们假设数据已经存储在这个数组中了,同时假设这个数组在内存地址中的0x0至0xF的空间内。我们通过对这个数组第一个字节rsv_buf[0]的判断,知道这里面存储的是一个word(半字,双字节)型的数据。于是我们使用下面的方式获得了数据:
    *((word *)&rsv_buf[1]);


       这里面发生了什么事情呢?我们先对rsv_buf[1]进行取址操作,然后转换成word型的指针,然后通过一个取值运算符“*”来获得这个word型指针中的数据。那么此时&rsv_buf[1]到底是一个什么样的值呢?我们知道rsv_buf的首地址是0,那么当然了&rsv_buf[1]的值就是1了。我们现在是要从一个奇地址中获得一个双字节的变量。
       这个时候地址对齐的问题出现了。当然对于一个支持这种访问方式的CPU来说,不会出现任何问题,那么对于不支持这种运行方式的CPU呢?一个错误产生了,或许系统崩溃了,或许获取了错误的数据。不管怎样这都将打破系统的正常运行,而且CPU不会为我们提供任何可以更正错误的机会。
       由于我们并不能准确地知道我们所写的程序将来会运行在什么类型的CPU上面,也不知道它是否支持非地址对齐方式的数据访问,因此我们应该尽可能的避免在程序中定义这样的数据结构。即便不可避免的定义了这样的数据结构,也要提供一种转换的机制。比如,在本例中我们可以在定义一个rsv_data来转存&rsv_buf[1]地址之后的数据,然后再进行类型转换,这样就不会出现问题了。当然,即便我们清楚的了解我们的程序将要运行的CPU支持非对齐方式的数据访问,那么也要尽可能的避免这种情况的发生,因为它将影响我们的程序的执行效率,通常CPU执行这些代码需要更多的指令周期。
2.6.2结构体内存布局
       地址对齐的问题也表现在结构体中。C语言规定一种结构类型的大小是它所有字段的大小以及字段之间或字段尾部的填充区大小之和。填充区?是的,这就是为了使结构体字段满足地址对齐要求而额外分配给结构体的空间。C语言的标准规定结构体类型的对齐要求不能比它所有字段中要求最严格的那个宽松,可以更严格。我们可以看下面的例子:
typedef struct _Example1
{
    byte   a;
    dword  b;
} Example1;


       我们现在定义了一个结构体Example1,在其中有两个成员变量a和b。假设这个结构体按照连续的方式布局,那么这个结构体将占用3个字节的空间,a成员在结构体中的偏移是0,那么结构体成员b的偏移则是1,而成员b是一个四字节的变量。这中间就存在了一个地址对齐的问题了。如果假设我们现在是编译器,我们将如何安排这个结构体的内存结构呢?按照C语言的标准,这个结构体内成员变量对齐要求取成员地址对齐要求之大者的原则,这个结构体中的地址对齐的模数应该是sizeof(word) = 4。实现这种对齐的唯一方式就是在成员a和b之间增加3个字节填充区。增加填充区后的内存模式如下图2.7所示。
a
padding…
b
0
1
2
3
4
5
6
7


图2.7 Example1的结构体内存模型
       这个方案在a与b之间多分配了3个填充(padding)字节,这样当整个结构体首地址满足4字节的对齐要求时,b字段也一定能满足dword型的4字节对齐规定。那么sizeof(Example1)显然就应该是8,而b字段相对于结构体首地址的偏移就是4。下面我们再来看一看将这两个成员变量调换位置之后的情况。
typedef struct _Example2
{
    dword  a;
    byte   b;
} Example2;


       或许您会认为Example2的内存布局会比Example1的简单,就是一个4字节的变量b加上一个1字节的变量,总共是5个字节长度。因为Example2结构同样要满足4字节对齐规定,而此时a的地址与结构体的首地址相等,所以a和b都一定也是4字节对齐。嗯,分析的有道理,可是不全面。让我们来考虑一下定义一个Example2类型的数组会出现什么问题。C标准规定,任何类型(包括自定义结构类型)的数组所占空间的大小一定等于一个单独的该类型数据的大小乘以数组元素的个数。换句话说,数组各元素之间不会有空隙。按照上面的方案,一个Example2数组的布局就是如图2.8中所示的一样。
b
a
0
1
2
3
4
5
6
8
a
b
7
9

10


图2.8 Example2数组内存模型
       我们可以看到,此数组的第一个成员变量已经是四字节对齐了,可是第二个成员变量的起始地址却是5开始的。这就不能满足C语言的要求,因此在Example2类型的数组中,依然要增加填充区,如图2.9所示。
b
padding…
a
0
1
2
3
4
5
6
7


图2.9 Example2的结构体内存模型
       现在无论是定义一个单独的Example2变量还是Example2数组,均能保证所有元素的所有字段都满足对齐规定。那么sizeof(Example2)仍然是8,而a的偏移为0,b的偏移是4。现在我们已经掌握了结构体内存布局的基本准则,尝试分析一个稍微复杂点的类型吧。定义一个Example3的结构体:
typedef struct _Example3
{
    byte   a;
word   b;
dword c;
} Example3;


       这里面有歧义的地方就是b这个变量采用什么样的对齐方式。在这个结构体中最大的偏移是4字节,那么成员b应该是满足4个字节的地址对齐方式吗?实际情况是变量b只需要满足它自身的对齐方式就可以了,也就是sizeof(word) = 2。因此现在我们可以得到图2.10的内存布局结构。
a
c
0
1
2
3
4
5
6
7

b


图2.10 Example3的结构体内存模型
       那么现在我们可以知道,在结构体内部成员变量地址对齐的要求就是满足自身的需求,单字节的变量可以紧接着前面的成员,双字节的要求偶数地址对齐,四字节的要求4字节对齐等等,可以依次类推,如果紧接着上一个成员的地址不符合要求就在中间添加填充字节。而对于整个结构体要求的地址对齐方式则取成员变量中要求地址对齐最大的那个。
       在实际开发中,我们可以通过指定编译选项来更改编译器的对齐规则(不同的编译器有不同的设置方式,请参考相应的编译器文档)。例如我们可以指定字节对齐的方式是8,也可以指定是4,甚至还可以是1。在设置对齐规则的时候,采用的是参数与默认取二者之小的方式。例如我们通过编译器参数设置结构体偏移量为2,那么对于Example1中的的内存布局就会变成图2.11中表示的那样。
a

b
0
1
2
3
4
5


图2.11 Example1的2字节偏移内存模型
       此时仅仅会增加一个字节的填充区。虽然结构体中b成员的偏移是4,但是由于我们设置了编译器的偏移参数为2,因此将会使用2作为此结构体的最大偏移。如果此时我们将这个结构体的偏移设置为1,那么无论这个结构体的成员排列如何,都不会有任何的填充字节。此时的情况就类似于我们前面所讲的编译器__packed关键词的意思了。
       在这种编译器设置的字节对齐要求比结构体中变量自然要求小的情况下,将会出现访问成员变量时的地址对齐问题。就像图2.11中所表示的一样,成员b是一个4字节的变量,但是它的首地址却是2,不能被4整除的一个地址,我们在使用这样的结构体成员的时候,会同指针的转换的时候一样出现地址对齐的问题吗?
       如果我们不做任何事情,肯定会出现问题。不过请您放心,这些事情已经由编译器为我们做了。这是可以理解的,我们使用编译器设置了结构体的字节对齐要求,不过出现问题当然要由编译器负责了。在这种情况下使用结构体成员的时候,编译器在编译相应的代码时,会额外的插入一些CPU指令来消除地址对齐问题的影响。典型的,在ARM C语言编译器中,如果访问一个使用__packed关键字声明的结构体成员变量的时候(如使用pointer->b语句访问b变量),每一个访问成员的C语句都会变为7个CPU指令,同时使用3个通用的CPU寄存器。这不但会影响程序执行效率,而且还会增加代码的尺寸。所以,不到万不得已,请不要使用这种方式的结构体。
2.7 小结
       噢,我要长长的松一口气了,终于将一些C语言的基础介绍给完了。在这一章里,主要介绍了C语言的一些特性,其中大部分是读者在使用中会遇到的令人迷惑的议题,如果我们能够将这一章的内容透彻的理解了,那么我们就基本上掌握了C语言本身的精髓(虽然还有很多高级的应用,不过那都不是C语言本身的了)。在这里我要提醒那些初级的程序员朋友们,在掌握一门程序语言技术的同时,千万不要忘记养成一个良好的编码风格。因为如果想成为一名优秀的程序员不但要能够写出复杂的程序,而且要能够将复杂的问题用容易阅读的程序来实现。最好的程序是要大家都能看懂的,而不是只有您一个人能看懂的程序。记住一个程序的KISS准则(此KISS非彼Kiss也):Keep It Simple and Stupid,也就是保持程序简单性和傻瓜性,别人能容易看懂的程序才是好程序。
思考题
1、指针和变量的相同点和区别的地方在哪里?
2、函数的名称可以赋值给一个int型的指针变量吗?
3、如果没有sizeof怎样能够获得一个结构体的大小?


本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/Gemsea/archive/2007/01/26/1495198.aspx

原创粉丝点击