当前位置: 首页 > 编程 > 正文

        近日聆听了在科锐钱老师上 […]

        近日聆听了在科锐钱老师上C语言时所讲的函数调用约定,于是将课后笔记整理成此处男贴.虽力求完美,但难免因本人的学识浅薄而存在不足之处,望大神们批评指正…

                                                                                                           –题记

 

1 函数调用流程

       函数调用大家都不陌生,调用者向被调者传递一些参数,然后执行被调者的函数体代码,最后被调者向调用者返回结果.还有一句话是大家比较熟悉的,就是函数调用是在栈上发生的,那么C语言中的函数调用是如何实现的呢,下面我们一起分析分析…

 

1.1 C语言中函数参数的入栈顺序

       为了能有个感官的认识,我们先通过一个小程序看看.

/*
*Copyright (c)2015,
*All rights reserved.
*文件名称:PushOrder.c
*作 者:韩逸
*完成日期:20161020
*版 本 号:Debug
*编译环境:Visual Studio 2012
*问题描述:C语言函数参数入栈的顺序
*/
#include <stdio.h>
int Fun(int nNumA,int nNumB,int nNumC);
int main(int argc,char* argv[])
{
    Fun(100,200,300);
    return 0;
}
int Fun(int nNumA,int nNumB,int nNumC)
{
    printf("nNumA = %d at [0x%p]rn",nNumA,&nNumA);
    printf("nNumB = %d at [0x%p]rn",nNumB,&nNumB);
    printf("nNumC = %d at [0x%p]rn",nNumC,&nNumC);
    return 0;
}

 

运行结果

  1. nNumA = 100 at [0x010FFDF0]
  2. nNumB = 200 at [0x010FFDF4]
  3. nNumC = 300 at [0x010FFDF8]

总结:

   C语言中栈底为高地址,栈顶为低地址,因此可知上述例子函数入栈顺序是从右往左.通过钱老师的讲解以及百度所查到的文献,参数的入栈顺序是和编译器的调用约定相关的.比如,Pascal语言中参数就是从左往右入栈,而有些语言还可以定义修饰符进行置顶的传参顺序,如VC++就可以两种方式传参,那么C语言为什么要从右往左传参呢?

    进一步发现Pascal语言不支持可变长参数,而C语言支持这种特色,正是因为这个原因使得C语言函数参数入栈顺序是从右往左,具体原因是C方式参数入栈顺序(从右往左)的好处是可以动态变化参数的个数,通过堆栈分析可知,最前面的参数被压在栈底,除非知道参数个数,否则无法通过栈指针的相对位移得到最左边的参数.这样左边的参数就不确定,正好和动态参数的个数方向相反.

    显然,C语言函数参数采用自右向左的入栈顺序,主要原因是为了支持可变长参的形式.换句话说,如果C语言不支持这种特色,那么C语言完全和Pascal一样,采用自左向右的参数入栈.

 

1.2 函数调用约定

C语言中常用的调用方式有: __Cdecl , __Stdcall , __Fastcall   

(VC编译器默认函数调用方式是__Cdecl,Windows API使用的是__Stdcall调用方式)

 

1.2.1 如何设置调用约定

本人所选的编译环境为Visual Studio 2012.有两种方法选择调用约定.

 

1.右键项目属性->配置属性->C/C++->所有选项->调用约定

736b0076-13c5-46b7-ab42-bf1c2164cf84

2.在定义函数名的时候规定调用约定

  1. int __cdecl Fun(int nNumA,int nNumB,int nNumC)
  2. {
  3. printf("nNumA = %d at [0x%p]rn",nNumA,&nNumA);
  4. printf("nNumB = %d at [0x%p]rn",nNumB,&nNumB);
  5. printf("nNumC = %d at [0x%p]rn",nNumC,&nNumC);
  6. return 0;
  7. }

  或许您会奇怪为什么我们平时写代码的时候一般不写函数调用方式,因为VC++中C/C++缺省调用方式就是__cdecl函数约定,所以即使我们不写,编译器也会默认帮我们选择__cdecl调用方式.

1.2.2 调用方式详解

下面咱聊聊3种调用方式,分别分别是__Cdecl,__Stdcall,__Fastcall.

__Cdecl:

(1)压栈顺序:函数参数从右往左传递到栈顶

(2)返回值:返回值在寄存器

(3)参数空间谁释放:由调用方把参数弹出栈,调用方负责释放参数空间

备注:正因为如此,实现可变参数(如Printf)只能使用该调用约定

__Stdcall:

(1)压栈顺序:函数参数从右往左传递到栈顶

(2)返回值:返回值在寄存器

(3)参数空间谁释放:被调方负责释放参数空间(在退出时情况堆栈)

__Fastcall:

(1)压栈顺序:用ECX和EDX寄存器传送前两个双字(DWORD)或更小的参数,剩下的参数仍旧

从右往左传递

(2)返回值:返回值在寄存器

(3)参数空间谁释放:被调方负责释放参数空间

(PS:如果返回值过大,寄存器放不下放栈顶)

 

2.函数的运行原理

  很认真的灌水了一大篇,现在咱结合示例代码看看函数如何入栈与出栈吧.

   当函数被调用时,指令指针的地制值增加1,使其指向函数调用后的下一条指令.该地址随后被放入栈中,作为函数返回时的返回地址.

  ( 程序中的所有代码都保存在程序代码区中,也就是将代码编译成二进制形式加载到内存中的程序代码区,其中每行源程序会编译成一条或若干条二进制指令,每行指令都有一个地址,指令指针就是用来存放即将要执行的下一条指令地址.)

 

a44fac8c-58ee-4cfb-92d4-f7ebc45cf83e

第18行调用FunA函数,假设该指令地址是10086,指令指针先保存该地址,然后将该地址值+1,使其指向下一条指令,也就是第19行语句,该地址随后被放置在栈中,当FunA函数调用结束,将返回到第19行执行.

将下一条指令的地址保存到栈中是第一步,跳转到FunA函数定义处执行是第二步(跳到第22行去执行).程序又是如何知道应该跳到第22行去执行呢?

  因为函数也有地址,该地址存在目标文件(多以.obj为扩展名)的符号表中(函数名是一个符号,该符号对应着一个标号,这个标号就是函数的地址,一般为相对地址,即函数第一条指令相对于程序代码区起始位置的偏移量),当调用某个函数时,就从符号表中提取该函数的地址,也就是该函数第1条指令地址,然后由寄存器中的指令指针来保存.接着系统将根据符号表中所秒速的函数返回值类型,在栈中开辟一块内存存储返回值,而栈顶的地址被记录下来保存在栈顶指针中.而从这时起,在函数返回之前进入栈的所有数据被视为局部变量.

   调用者会按调用约定传入形参,返回地址,上级调用者栈底(__Cdecl自又往左的顺序将形参压入栈中,栈顶指针始终要指向栈顶,因此栈顶指针不断向上移动),当所有参数都压入栈后,才开始执行函数的第一条指令.

 

1.我们先分析分析Main函数的情况.(取Main函数中变量地址拖到内存窗口)

7931d555-856e-4cc2-bc61-494b88dd5eb3

 F10单步走一次

a7ea3970-3241-4472-88a1-31d880124e86

7d7be82a-c7c3-4eca-b2b3-9165dac76c0a

 接下去就开始分析FunA函数的入栈情况了.F10单步,

62003f99-4791-48b2-8c1e-2970772277b6

可以看到此时形参和返回地址已经入栈,再单步一次应该会压入Main函数栈底地址:9CFAB8,并且为局部变量申请空间(依据局部变量的总大小抬高栈顶)(备注:如果是Debug编译选项组(/ZI+/OD),将局部变量全部初始化为0XCCCCCCCC),以及保存寄存器环境.F10单步

e365ab2a-1770-4c4e-b8a2-f465fcca0384

接下来就开始执行函数体

2db0416e-2fb1-4324-ac20-99a46d8f93f3

函数入栈就已经完成了.

当函数结束时,栈区中数据要进行清理,以释放内存,因此按照后进先出的原则,后进入的数据先弹出栈,每弹出一个栈,栈顶指针向下移,直到所有数据都弹出栈区.

函数体执行完后开始出栈,首先还原栈中保存的寄存器信息,然后更新当前栈顶为栈底,并恢复上级调用者栈底,取出栈顶内容,作为返回地址.按返回地址流程回到调用方.值得注意的是,函数出栈数据该还的还,该释放的释放,但不会清零,也没有必要清零,把栈顶降到相应的地址上,如果还有函数调用,再分配再覆盖就完事了.

总结:

    纵欲知道为什么定义变量要养成初始化的习惯,因为函数入栈出栈只是把栈顶和栈底移位操作,并未将值清空,就像腾空转体360度弹鼻屎,也不知道谁会倒霉.

思维发散

  突然想到,函数入栈的第二步是要传入返回地址,如果构造一个地址越界覆盖到返回地址,能不能执行代码呢…

就用上面的例子修改下吧…

  1. /*
  2. *Copyright (c)2015,
  3. *All rights reserved.
  4. *文件名称:FuncPush.cpp
  5. *作 者:韩逸
  6. *完成日期: 20161020
  7. *版 本 号:Debug
  8. *编译环境:Visual Studio 2012
  9. *问题描述:观察函数调用流程
  10. */
  11. #include <stdio.h>
  12. #include <windows.h>
  13. int __cdecl FunA(int nFunANum);
  14. void fnShellCode();
  15. int __cdecl main(int argc,char* argv[])
  16. {
  17. int nMainNum=8;
  18. FunA(nMainNum);
  19. return 0;
  20. }
  21. int __cdecl FunA(int nFunANum)
  22. {
  23. nFunANum=16;
  24. char szFunAName[18]="www.51asm.com";
  25. *((DWORD*)(szFunAName+24))=(DWORD)fnShellCode;
  26. return 0;
  27. }
  28. void fnShellCode()
  29. {
  30. system("calc");
  31. exit(0);
  32. }

编译提示失败,提示缓冲区即将溢出…

0859435c-1f71-446f-beb2-48a54f62fcc3

8975f361-4c39-45f4-b37a-e9eed12bb67d

   我猜测是因为编译器加入了安全机制,将函数返回地址备份,如果发现返回地址与备份地址不一致则…

    但我只是想验证自己的想法是否正确,能否溢出覆盖返回地址然后执行fnShellCode()函数.

    那把编译器的安全机制关闭吧,具体方法如下…

    右键项目->属性->C/C++ –>代码生成 ->安全检查 ->否

9a0f0a52-b67b-43a7-bfa3-c0561ae3da7b

8b41e964-ce23-4f59-8ca7-a495d9cbf773

在函数出桟的时候读取返回地址却跳到了我构造的函数中,那么之前的推断成立,函数入栈的时

候可以用局部变量越界覆盖返回地址执行自己想要的代码.(前提是要关闭安全检查,不然只能

自娱自乐…)

本文固定链接: http://blog.050k.com/?p=198 | RotShell's Blog
标签:, ,

浅谈VC++中C语言函数入栈出栈的实现:等您坐沙发呢!

发表评论

快捷键:Ctrl+Enter