关闭

深入函数

83人阅读 评论(0) 收藏 举报

前面一篇我们介绍了结构体,这篇终于能够介绍函数了。为什么这么说呢?因为函数非常重要。就这么简单。嘿嘿!之所以在这时才讲函数,是因为本篇将联系到前面的每一篇,这样函数才能体现的透彻。那我们就迫不及待的切入正题。

 

从第一篇Helloworld开始到现在,就没有脱离函数。那就是我们的main函数。main函数也是一个普通的函数,只不过通常把它作为我们写的程序的入口。也就是说我们就当它最先执行。那这样一来为什么说它又是一个普通的函数呢?原因是我们可以通过写代码改变这个入口。让我们的程序一开始不执行main函数而先执行我们自定义的函数。具体怎么实现不是本篇的内容,大家知道有这么回事便可。记得main函数并不是一个特殊的函数,它只是被认为的定为程序的入口函数而已。

 

那么,什么是函数?通俗的理解,它就是一段代码块,被我们将零散的语句集中在一起而用于支持某个功能。比如我们的strcpy也是一个函数,这个函数的作用是字符串拷贝。它里面有很多语句。这些语句被用一个函数的形式集中在一起而已。说到这里又不得不强调一点,那就是我们在接触一个新的东西的时候尽量往其本质想,这样便不会感到抽象和陌生。就比如函数,我们就理解它就是一个代码块集中管理的方案。一个函数名,参数列表加返回值用大括号将代码括起来就成了函数。虽然是括起来了,但是函数可以说是不存在的。当编译器将我们的CC++代码编译成汇编语言的时候,每个函数都只是一段代码,什么函数名,参数列表,返回值将不再清晰可见。那就是一段集中在一块儿的代码。我们也就这么理解。至于为什么在CC++语法上函数要有名字、参数、返回值。这点是可以理解的。因为是高级语言嘛,这样一来代码的模块性将很强。总不可能这样写哟:

有函数:

void fun( void )

{

     int a = 0;

}

 

void fun1( void )

{

     int b = 0;

}

 

int main( void )

{

      if ( ... )

         fun();

      else

         fun1();

     

       return 0;

}

 

没函数:

int main( void )

{

      if ( ... )

         goto fun;

      else

         goto fun1;

     

      return 0;

 

   

      fun:

            int a = 0;

            return 0;

     

      fun1:

            int b = 0;

            return 0;

}

 

上面的代码显而易见,函数的基本作用就得以体现了。模块化方便管理与维护。

 

知道了函数的概念及其本质后,我们再看具体的一些用法。首先从返回值上面说。

void         fun();

int*         fun();

struct A   fun();

int           fun();

char        fun();

看到上面不同返回值的函数。有指针,有类型,有空,有字符,还有结构体。C语言只能返回一个值,如果想返回多个,就只能用地址的形式返回给调用者了。其它花骚的办法原理也都差不多。这里不一一说明。函数的返回值在CC++层面上都是用return关键字进行返回的。返回值是为了能够被需要一些结果的调用者获得这个结果值。返回值在很多时候非常重要。void类型就不用返回,如果想在函数中间某处就返回的话可以这样:

void fun()

{

    int a = 0;

    return;          // 执行完a = 0就直接返回了,这里不返回任何值,只是充当一个结束此函数的作用。

    a = 10;

}

函数一旦return后,不管是否有返回值,都立即结束。因此,return通常用来返回值也用来终结函数。上面诸多返回值类型,我们只需要看两个就够了,一是返回指针,而是返回结构体。我们将一一追究起本质和一些注意事项。

 

首先看返回指针:

int* fun( void )
{
    int a = 100;
    return &a;
}

看上面这个程序,返回指针的形式很简单。直接return a变量的地址。在外层调用的时候可以:

int*  p = fun();

这样便把p指向了函数返回的地址上。

 

假如我们要返回一个数组,在前面我们讲了指针和数组。我们又知道不可能有 int[] fun( void )或者 int[ 3 ] fun( void )这样的函数定义。那么我们便联想到了指针和数组的共性,我们是否可以返回一个数组的首地址?然后再调用者取得这个首地址。在我们知道数组大小的情况下就能挨个访问这个数组的每一个元素。有的人又会问,假如不知道大小了怎么办呢?不知道大小基本是不可能的。你的大小是否可以使用宏定义或者全局变量呢?我们为何要往死胡同里面钻呢?对吧。

因此,就有如下代码:

int* fun( void )

{

    int a[ 3 ] = { 1, 2, 3 };

    return a;

}

在外层:

int* pArray = fun();

a = pArray[ 0 ];

是不是很方便。所以我们在使用指针和数组的时候要灵活。C语言虽然不能返回多个值,但是我们有办法实现这个功能。

 

写到这里,大家知道了返回指针,欣喜若狂。运行之。结果意料之外的事情发生了。为什么返回的指针、数组元素乱了?数据错误了?

这里就牵涉到一个注意事项了。

 

我们上面写的这两个返回指针的函数,都是有问题的。说它是错误的也完全不过分。

为什么这么说呢?大家仔细观察,我返回的数组和返回的a的地址都是属于临时变量的地址。语法上这两个函数确实没有问题,错误的原因就在于我返回了临时数据的内存地址。所谓临时变量,也就是生命周期比较短,这里的数组和a在函数结束后生命便终结了。所以称之为临时变量。既然生命终结了,那么这块内存将会被重新利用。就会被任意代码或者操作重新赋值。这里就是所谓的栈内存。这里的栈不是数据结构里面的那个栈。这里通常指存放临时变量的内存空间。一般很小,默认是1MB,也有2MB的。这个可以自己设置。这里就不多说了。假如有这样一段代码:

int* fun( void )
{
    int a = 100;
    return &a;
}

int* fun1( void )
{
    int b = 200;
    return &b;
}

 

int main( void )
{
    int* p;
    int* p1;
    int aa, bb;

 

    p = fun();
    p1 = fun1();

 

    aa = *p;
    bb = *p1;

 

    return 0;
}

 

在我的机器上,这两个函数fun和fun1由于代码基本相似。我故意构造了一个能够体现栈内存被修改的错误。在这个程序结束后,aa和bb的值都是200。为什么?原因很简单,我们在调用了fun函数后,p指向的栈内存比如是0x0012ffd4,当调用了fun1后,因为fun1跟fun区别很小,临时变量b所在的栈内存地址刚好也被指定到了0x0012ffd4这个内存地址上。p1也便指向了这个内存地址。所以这里aa和bb必然是相同的值了。为什么是200原因也很简单,临时变量b把0x0012ffd4这个内存地址下的值赋值成了200,便覆盖了之前的100。

 

那么,如果我要改变这两个函数,让它们不会出错该怎么办呢?如下:

int* fun( void )
{
    int* a = ( int* )malloc( sizeof( int ) );

    *a  = 100;


    return a;
}

这样的话就不存在被覆盖了,大家知道这里使用的malloc函数申请的空间,此函数申请的空间将不在栈空间上,而是在堆内存中。我们不手工调用free函数,这个内存值将永远存在。知道程序结束被回收。当然这样做的的话,在外层获得了这个a指针,在使用完后。记得把它free调。不然将造成内存泄露(一直申请,用完不释放,内存被占用逐渐耗尽。)。

 

问题一:写出正确的返回数组的函数fun1。

 

在了解了指针返回后,可能有的朋友会提问假如我要返回二级指针该怎么写呢?我这里只说一句,二级指针也是指针,没有什么特别的。跟以及指针同样一个道理返回,记得一点指针变量也可以是临时变量。具体还不清楚的话建议看看前面两篇关于指针的文章。

 

好了,返回指针说完了,再来说返回结构体。

大家由于看了上面的返回指针,心里可能就会在猜想了。结构体以一组成员的集合,跟数组类似。我们要返回的时候,是不是也必须得用指针的方式返回首地址呢?或者还有其它方法?先看程序:

struct A
{
    int a;
    int b;
    int c;
};

 

struct A fun( void )
{
    struct A a = { 1, 2, 3 };
    return a;
}

 

int main( void )
{
    struct A ret = fun();

    return 0;
}

 

有这样一段代码,我们的目的是想返回临时的结构体变量a的值给main函数里面的临时变量ret。这里我故意强调了临时变量这个词。希望不要引起大家的误解。这里虽然a是一个临时变量,但是我返回变量a到ret中,并不是指向。而是拷贝。意思就是说将临时变量a的3个成员值拷贝到ret变量的对应的成员里。跟指针是有区别的。我们前面说了C语言是不能返回多个值的,要返回就用指针。那么这里我没有用指针很明显的返回了3个值1、2、3.这是为什么呢? 答案可能在这里讲不是怎么适合,我先说在这里,能理解就理解。不能理解就记住结构体变量返回能实现返回多个值,就把结构体变量当着是一个值,不要想到它的成员。那么其本质上来说,结构体变量是怎么返回3个值的呢?

 

原因在于,这里C语言默认帮我们做了很多事情,在后台其实还是返回的只是一个地址,也就是结构体变量a的首地址,这个首地址不是存储在我们定义的变量上的,而是通过CPU寄存器传递的。然后将寄存器指向的那个内存地址的值赋值给ret变量的成员a,然后再寄存器所指向的地址+偏移(这里是4,都是int型)就是b所在的内存地址,然后将b的值取出来赋值给ret中的b。c也是一个道理。这样就把值传递过来了。我们可以理解为编译器编译后,程序会在内存中构建一个临时的结构体。把函数要返回的结构体变量里面的值都复制到这个临时的结构体里。我们是看不到这个结构体的。在函数执行完成后将这个临时结构体的值赋值给我们的接收变量。这里可能有点不好理解,什么是临时结构体,我之前不是一直强调本质吗。结构体就是一块连续的内存空间,我们这里A结构体占用12个字节,因此我可以随便在内存的某个地方构建一个12字节的空间。放置这个结构体的3个成员的值。所以这里叫临时结构体。

 

说到这里,又得提醒一点了。这里我们要返回多个结构体变量的话,同样也可以采用指针。原理跟上面基本类型指针返回一个道理。也存在临时栈内存的问题。返回指针(返回地址)跟返回值(拷贝)大家要区分清楚。

 

好了。返回值我们就说完了。下面说参数。

参数可以有多个,还可以有不定参数,比如我们常用的printf函数就是不定参数。也就是动态的参数个数哈。

固定的参数个数多个和一个是一样的道理,我在这里只列举一个参数的情况或者两个参数的情况。

void fun( int* p );

void fun( int a );

void fun( void );

void fun( int* p, int size );

上面我没有写返回值,返回值不用说了。在了解参数之前我们先看一个例子:

void fun( int var )
{
    var = 100;
}

int main( void )
{
    int a = 1;
    fun( a );

    return 0;
}

在这个程序中,我们调用了fun函数,试图去改变a的值。但是出乎意料的是,在调用了fun函数后a的值改变。这是为什么?可能很多初学的读者一直很纳闷。或者就死记硬背这样不会改变a的值。我们在研究一个东西只有知道了本质才能记得更牢,而且不用记都会一直明白。那么我们先说说a没有被改变的原因。

 

也许大家都听说过值传递,地址传递,引用传递。引用传递我们在本篇不说,那牵涉到C++的相关概念了。以后我们在讲引用的时候再说。

那么先说说什么叫值传递。

 

我们通过上面的内容了解到了栈内存,也就是临时数据存放的地方。函数内部的临时变量都是放在这里面的。这里传参数,又不得不明白一点就是。不管我们传的是指针还是值。程序在调用函数之前都会先将参数压入函数内部的栈空间里。意思就是说函数会把这些参数当着函数内部的临时变量来处理。这里将参数压入我们函数内部所在的栈空间里的过程叫传递,压入的地方(内存地址)里的值通常称为参数的副本。这里别想到游戏里面下FB哈,总结出来的意思就是说,我们在跟函数传参数的时候会将参数一个一个压入到函数内部所在的栈内存中。这里的压入也可以理解成向栈内存里面写值。

 

上面的fun( a ),首先是将a的值压入到栈内存,比如0x0012ffec这个内存里。这个内存地址下面的值就是1,也就是通常所说的a的副本(克隆体)。然后执行到函数内部的var = 100; 这里的var所取值的内存地址就是0x0012ffec,也就是传进来的参数的那个内存地址。这一切都是编译器给安排好的。然后我们将这个0x0012ffec内存地址里面的值赋值为100。好了,var变成了100。之后函数fun便执行完毕了。到这里大家可能已经知道为什么a的值不会改变了。原因就是函数内部只知道去改变0x0012ffec这个内存地址里面的值,而改变了这个值并不会影响到a,因为a又属于main函数的局部变量,a所在的内存地址并不是0x0012ffec。0x0012ffec这个地址之所以能够将a的值传进函数是因为在压参数的时候是将a的值1拷贝到0x0012ffec内存里。注意这里是拷贝。

 

那么,到这里我们想了想,要是我们想改变a的值怎么办呢?如下:

void fun( int* var )
{
    *var = 100;
}

int main( void )
{
    int a = 1;
    fun( &a );

    return 0;
}

用指针就可以将a 的值改变。大家又疑惑了。为什么这里指针就能改变呢?原因跟上面一样,首先我们传入的是a的内存地址,比如是0x0012ffff,将这个地址传给了函数,通过我们上面知道,虽然是传的地址,可它还是将这个地址当着值压入函数内部栈空间,比如压到了0x0012eeee这个内存里。注意每个函数都有自己独立的那块栈空间提供给自己用,用完就丢弃。所以这里压入后的内存地址跟变量a本身的内存地址不可能相同。然后我们再看fun函数,它是一个指针取值操作然后再赋值为100。看看流程,首先var我们知道它的内存地址就是0x0012eeee(上面说的编译器安排的),而这个内存地址里面的值就是0x0012ffff这个内存地址。var是一个指针,在前面指针篇我们知道var有它自己的内存地址(这里就是0x0012eeee),它自己又保存了它所指向的内存地址(这里就是0x0012ffff)。这里这个内存地址也就是传进来的a变量的地址,我们在间接访问(*var)时,实际就是操作的a变量本身。因此这里将会直接指向a的地址将其值改变为100。

 

这个例子在我没有打招呼的情况下我们已经就讲了地址传递的方法。地址传递就是将一个变量的地址传递给函数,函数内部在访问压入的这个参数时,读写的是外部变量的地址值。因此可以改变传入参数的值。

 

问题二:假如上面的程序中a是一个指针,我们将a传进函数fun,然后在fun函数里改变指针的指向(指针的值)。外面的a指针是否会改变? 为什么? (提示:原理跟上面一样,必要时用二级指针进行地址传递)

 

说到数组,我们又不得不想到如果我们想传一个一维数组到函数内部,供函数取值或者写值。又该怎么做?

void fun( int* a )
{
    a[ 0 ] = 100;
    a[ 1 ] = 100;
    a[ 2 ] = 100;
}

int main( void )
{
    int array[ 3 ] = { 1, 2, 3 };
    fun( array );

    return 0;
}

以上代码中,我们的意图是想将array的值改成100。我们的目的达到了,结果一切正常。为什么呢?可能有的读者已经被上面的值传递和地址传递给弄混了。在这里我们不用多想,就应该知道这里传入的是array数组的首地址,在函数内部会将这个地址里面的值进行修改,然后加上偏移逐个修改。这里也是通过地址直接操作的。原理跟上面一样我就不多说了。这里的fun函数是我知道array数组有3个元素的情况下,假如不知道,那么我们就该再添加一个数组元素个数的参数。这样既安全又得体。比如:void fun( int* pArray, int size );

fun( array, 3 ); 这样函数内部就不会怕读写越界了。

 

问题三:怎么传二维数组到函数内部?

 

 

下面我就来举一个越界带来的可怕后果之一:

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

 

int main( void )
{
    int array[ 1 ] = { 1 };
    array[ 3 ] = ( unsigned int )fun;
    
    return 0;
}

 

就上面一个简单的程序,已经诠释了一个经典的缓冲区溢出攻击基本原理了。先解释下程序,这里定义了一个数组array,它是有一个元素,下面的一句    array[ 3 ] = ( unsigned int )fun; 我这里是故意将fun函数的地址越界赋值给array数组后面的第3个内存地址里。占用4个字节。这样做的目的,大家运行了便知道,神奇般的在我没有调用fun函数的情况下进入了fun函数并输出了I‘m Come In!!!字符串。可能很多人就傻了,为什么会这样?我这里并没有调用。

 

原因很简单,我们每个函数在执行完以后都会跳转回来,回到调用此函数的下一条语句继续往下执行,函数之所以能跳转回来是因为我们在调用函数的时候就已经将要返回到的代码地址给保存到函数栈内存中了。我这里将数组写越界的目的就是为了将这个返回地址值改变成我的目标函数fun函数的地址(函数也是有首地址的)。这里强制类型转换fun函数首地址为无符号整数覆盖掉main函数的返回地址。这样在main函数返回时便会跳转到fun函数并执行该函数。输出字符串。我们可以联想一下,假如这个fun函数是我们的黑客想操作一些事情的函数,那将是非常危险的。这里就是经典的“缓冲区溢出攻击”的基本原理。

 

假如我这里不是array数组,而是一个字符数组,我们在strcpy的时候没有检查长度,黑客通过修改函数传入的字符串参数,让其拷贝越界,覆盖掉返回地址,覆盖的内容就是黑客自己实现的函数的地址。我们程序将神不知鬼不觉的调用它的函数。当然上面我写的这个在执行输出后,fun函数在返回时,由于不是正常调用,他的返回地址没有谁给他压入,将返回到错误的地址最后崩溃掉。这里我没有处理堆栈平衡和返回地址。处理之后将不会崩溃,跟正常流程一样顺利。

 

上面说了越界缓冲区溢出乱调函数,也是为了引入函数指针,上面的例子我们初识函数也是有自己的地址的。既然有地址,那么指针必然就成立。既然是指针,又是普通函数,那么我随便怎么转换该指针都没有问题。这也是CC++的魅力所在。我上面就轻轻松松转换成了无符号整数然后覆盖了返回地址。是不是很方便?那么我们再看看正规的函数指针定义:

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

 

int main( void )
{
    typedef void( *PFUN )( void );   // 定义函数指针,这里使用typedef别名,PFUN就被声明为void返回,无参数类型函数的指针
    PFUN pfun = fun;
    
    ( *pfun )(); 

    pfun();           // 两种调用方式都是一样的
    
    return 0;

 

上面大家已经知道了函数指针的定义了吧,语法很简单。先定义一个函数指针pfun,将值赋值为fun函数的地址,函数名也代表函数指针,此指针就是指向的fun函数开始的代码地址。这里是代码地址。在我们的exe中,每一句代码都是有自己的代码地址的。这里的代码值的是汇编每条指令。这里我们不追究,只需要知道函数也是有首地址的。可以赋值给函数指针乃至任何一个指针。只不过赋值给函数指针之后我们就可以像( *pfun )();   pfun();这样调用它。跟函数调用没有什么区别。 假如你给我将fun函数赋值给一个void*指针p:
void* p = ( void* )fun;  

p();   // error

这样将是错误的,原因就不用说了吧。天下人都知道。

 

函数指针也很灵活,同样也可以由参数,有返回值。跟普通函数没有上面区别。

 

问题四:定义一个有参数,有返回值的函数指针,并调用它。

 

将函数指针作为参数也是有必要了解的:

typedef void( *PFUN )( void );

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

 

void call_func( PFUN pFun )
{
    pFun(); 
}

 

int main( void )
{
    call_func( fun );
    return 0;
}

 

上面的代码,反映了将函数指针作为参数传递给一个函数,让这个函数在另外一个地方被执行。这个过程通常称为回调。fun可以称为回调函数。我们将fun的函数指针传递给call_func,然后call_func再调用这个fun函数。原理大家清楚了吧。

 

回调函数在大型的项目中使用得非常多,最直接的就是我们的WIN32的消息回调函数。我们需要注册我们自己定义的函数给操作系统,这里的注册其实就是操作系统提供了一个函数指针给我们。我们将提供的这个函数指针赋值为我们自定义的函数的指针。操作系统内部又在不断的调用这个函数指针。因此我们就可以让操作系统调用我们的自定义函数了。大家可以自己试着写写这样的调用模型。比如一个函数指针的链表,里面存放了很多函数指针,我们遍历调用这个链表里面的所有函数指针。这些指针我们都赋值成我们想要调用的函数。

 

这里值得大家注意的是,使用函数指针的时候一定要小心,比如:

 

typedef int ( *PFUN )( void );

void fun( void )
{
    printf( "I'm Come In!!!/n" );
}

 

int call_func( PFUN pFun )
{
    int a = pFun(); 
    return a;
}

 

int main( void )
{
    int ret = call_func( ( PFUN )fun );
    return 0;
}

 

我将fun函数强制转换成int返回类型的函数指针,然后调用。这样执行完成后,ret的值将是废弃的。不可预测的。原因很简单,fun函数是没有返回值的。这里的返回值具体会是读取的哪儿的值我们就不在这里讲解了,知道有这么回事就可以了。这里假如不强制转换,编译器也只是会给一个警告而已。这种用法是绝对错误的。所以我们在使用回调函数的时候一定要注意参数的函数指针是声明的指向什么类型的函数。

 

另外函数的可变参数这里就不讲了,这不是重点,只是语法而已。大家通过查阅资料就可以明白了。

 

好了,函数我们就介绍完了。大家好好理解。有点长。又写了我5个小时左右。。。。休息。。

 

【C/C++入门篇系列】

【C/C++语言入门篇】-- 序言

【C/C++语言入门篇】-- HelloWorld思考

【C/C++语言入门篇】-- 基本数据类型

【C/C++语言入门篇】-- 调试基础

【C/C++语言入门篇】-- 深入指针

【C/C++语言入门篇】-- 数组与指针

【C/C++语言入门篇】-- 结构体

【C/C++语言入门篇】-- 深入函数

【C/C++语言入门篇】-- 位运算

【C/C++语言入门篇】-- 剖析浮点数

【C/C++语言入门篇】-- 文件操作


0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:26680次
    • 积分:1029
    • 等级:
    • 排名:千里之外
    • 原创:66篇
    • 转载:88篇
    • 译文:0篇
    • 评论:2条
    最新评论