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

近日聆听了在科锐钱老师上C语言时所讲的函数调用约定,于是将课后笔记整理成此处男贴.虽力求完美,但难免因本人的学 […]

近日聆听了在科锐钱老师上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]\r\n",nNumA,&nNumA);
    printf("nNumB = %d at [0x%p]\r\n",nNumB,&nNumB);
    printf("nNumC = %d at [0x%p]\r\n",nNumC,&nNumC);
    return 0;
}

运行结果

nNumA = 100 at [0x010FFDF0]
nNumB = 200 at [0x010FFDF4]
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++->所有选项->调用约定

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

int __cdecl Fun(int nNumA,int nNumB,int nNumC)
{
    printf("nNumA = %d at [0x%p]\r\n",nNumA,&nNumA);
    printf("nNumB = %d at [0x%p]\r\n",nNumB,&nNumB);
    printf("nNumC = %d at [0x%p]\r\n",nNumC,&nNumC);
    return 0;
}

或许您会奇怪为什么我们平时写代码的时候一般不写函数调用方式,因为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,使其指向函数调用后的下一条指令.该地址随后被放入栈中,作为函数返回时的返回地址.
  ( 程序中的所有代码都保存在程序代码区中,也就是将代码编译成二进制形式加载到内存中的程序代码区,其中每行源程序会编译成一条或若干条二进制指令,每行指令都有一个地址,指令指针就是用来存放即将要执行的下一条指令地址.)

第18行调用FunA函数,假设该指令地址是10086,指令指针先保存该地址,然后将该地址值+1,使其指向下一条指令,也就是第19行语句,该地址随后被放置在栈中,当FunA函数调用结束,将返回到第19行执行.
将下一条指令的地址保存到栈中是第一步,跳转到FunA函数定义处执行是第二步(跳到第22行去执行).程序又是如何知道应该跳到第22行去执行呢?
  因为函数也有地址,该地址存在目标文件(多以.obj为扩展名)的符号表中(函数名是一个符号,该符号对应着一个标号,这个标号就是函数的地址,一般为相对地址,即函数第一条指令相对于程序代码区起始位置的偏移量),当调用某个函数时,就从符号表中提取该函数的地址,也就是该函数第1条指令地址,然后由寄存器中的指令指针来保存.接着系统将根据符号表中所秒速的函数返回值类型,在栈中开辟一块内存存储返回值,而栈顶的地址被记录下来保存在栈顶指针中.而从这时起,在函数返回之前进入栈的所有数据被视为局部变量.
   调用者会按调用约定传入形参,返回地址,上级调用者栈底(__Cdecl自又往左的顺序将形参压入栈中,栈顶指针始终要指向栈顶,因此栈顶指针不断向上移动),当所有参数都压入栈后,才开始执行函数的第一条指令.

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

F10单步走一次

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

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

接下来就开始执行函数体

函数入栈就已经完成了.

当函数结束时,栈区中数据要进行清理,以释放内存,因此按照后进先出的原则,后进入的数据先弹出栈,每弹出一个栈,栈顶指针向下移,直到所有数据都弹出栈区.
函数体执行完后开始出栈,首先还原栈中保存的寄存器信息,然后更新当前栈顶为栈底,并恢复上级调用者栈底,取出栈顶内容,作为返回地址.按返回地址流程回到调用方.值得注意的是,函数出栈数据该还的还,该释放的释放,但不会清零,也没有必要清零,把栈顶降到相应的地址上,如果还有函数调用,再分配再覆盖就完事了.

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

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

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

/*
*Copyright (c)2015,
*All rights reserved.
*文件名称:FuncPush.cpp
*作 者:韩逸
*完成日期: 20161020
*版 本 号:Debug
*编译环境:Visual Studio 2012
*问题描述:观察函数调用流程
*/
#include <stdio.h>
#include <windows.h>
int __cdecl FunA(int nFunANum);
void fnShellCode();
int __cdecl main(int argc,char* argv[])
{
    int nMainNum=8;
    FunA(nMainNum);
    return 0;
}
int __cdecl FunA(int nFunANum)
{
    nFunANum=16;
    char szFunAName[18]="www.51asm.com";
    *((DWORD*)(szFunAName+24))=(DWORD)fnShellCode;
    return 0;
}
void fnShellCode()
{
    system("calc");
    exit(0);
}

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

我猜测是因为编译器加入了安全机制,将函数返回地址备份,如果发现返回地址与备份地址不一致则...
    但我只是想验证自己的想法是否正确,能否溢出覆盖返回地址然后执行fnShellCode()函数.
    那把编译器的安全机制关闭吧,具体方法如下...
    右键项目->属性->C/C++ –>代码生成 ->安全检查 ->否

在函数出桟的时候读取返回地址却跳到了我构造的函数中,那么之前的推断成立,函数入栈的时
候可以用局部变量越界覆盖返回地址执行自己想要的代码.(前提是要关闭安全检查,不然只能
自娱自乐...)

本文固定链接: http://blog.050k.com/?p=59 | 简单生活's Blog

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

发表评论

快捷键:Ctrl+Enter