C语言04- 函数

12:函数

  1. 面向过程:procedure oriented programming POP;是分析解决问题的步骤,然后用函数把这些步骤一步一步的实现,然后在使用的时候一一调用则可。
  2. 面向对象:object oriented programming OOP;把构成问题的事务分解成各个对象,而建立对象的目的也不是为了完成一个个步骤,而是为了描述某个事物在解决整个问题的过程中所发生的行为。

12.1:函数定义与应用

函数注意事项:

  1. 函数的长度加上版权注释等信息一般不超过300行
  2. 明确输入输出
  3. 变量初始化
  4. 参数检测严进宽出
  5. assert使用来判断参数的有效性合法性
  6. 时间与空间复杂度
  7. 边界考虑
  8. 函数的功能测试
  9. return语句不可返回指向“栈内存”的“指针”或者“引用”,因为该内存单元在函数体结束时被自动释放

12.2:函数传参

调用函数将实参数据传递给函数的方式,分为三种:

  1. 传值
  2. 传指针
  3. C ++中的传引用

那么C和C++里为什么要提供3种不同的函数传参方式呢?

一,传值不能改变实参,传指针和传引用才能改变实参;
二,传指针和传引用效率比传值要高。因为,传指针和传引用都只是把地址传递给函数,这个过程,只涉及到4个(8个,X64)字节的传输。传值,会随着实参的类型不同,有时候不止传递或者拷贝4个字节。
三,传引用比传指针更安全。因为引用一旦初始化,就固定了,不能改变;传引用比传指针简单。因此,传引用既有传指针的高效,又比传指针安全,又有传值的方便。

传值无法改变实参的值,因为传值的情况下,存放在栈上的形参只是实参值的一个拷贝,无法改变实参。

传指针

//传指针的函数定义方式,形参x是一个指向整数类型数据的地址。在函数内部通过*运算符来引用实参。
void Func2(int *x) {
    *x = 2;
}

//传指针的时候,形参是实参地址的一个拷贝,因此通过*运算符来引用实参,实际上就是实参本身,因此可以通过传指针来修改实参的值。
int main(int argc, char *argv[]) {
    int a = 0;//a是实参    
    Func2(&a);//传a的指针

    printf("%d\n",a);//2

    return 0;
}

传引用

//在C++中的传引用调用方式,注意,形参部分使用的是&,而在函数内部,可以直接把形参当做实参来使用,此时形参就是对实参的一个引用。
void Func3(int &x) {
    x = 3;
}

//传引用实际上也是传的实参的地址,是可以直接修改实参的。下面是在main函数里直接通过传引用的方式调用函数的方式:
int main(int argc, char* argv[]) {
    int a = 0;
    Func3(a);

    printf("%d\n",a);

    return 0;
}

传值还是传指针?不以函数定义为标准,而以实参为标准,把实参直接传给函数式传值,把实参地址传给函数式传指针

void func1(char *c);
void func2(char **c);

int main(void) {
    char c1;//c1,c2是2个实参
    char *c2;//
    
    func1(&c1);//传实参c1的地址,所以是传指针
    func2(c2);//传c2本身,所以是传值
    //func2(&c2);//语法错误,c2本身就是个地址,用&再取地址相当于二级指针,与一级指针参数不匹配错误。
    
    func2(&c2);//传c2的地址,所以是传指针
}

数组做函数参数,防止溢出,有两个方法:

  1. 把数组的元素个数一起传入函数做参数
  2. 传数组的引用
int test_array(int a[], size_t len) {
    printf(“sizeof a:%d\n”,sizeof(a));//在函数内部,arr已经退化为指针,此时长度为4(x86)或者为8(X64)
}

//把数组的元素个数一起传入函数做参数
void printArr1(int a[], size_t len) {
    for (int i=0, i<len, i++) {
        printf("%d", a[i]);
    }
}

//传数组的引用
void printArr2(int (&a)[9]) {//&a是含9个元素的int数组
    for (int i=0, i<9, i++) {
        printf("%d", a[i]);
    } 
}

int main(void) {
    int a[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
    printArr1(a, 9);
    printArr2(a);
    
    //数组作为参数传入函数内部退化为指针。
    int arr[100] = {0};
    printf(“sizeof arr:%d\n”,sizeof(arr));//此时arr作为数组,它的长度为400
    test_array(a,sizeof(arr));
 
    return 0;
}


问题分析

/*
void get_memory(char *p) {
    p = (char *)malloc(100);
}

void test(char *s) {
    char *str = NULL;
    get_memory(str);
    strcpy(str, s);
    printf(str);
}
*/
/*
get_memory(str);//这里str是传值,传值不能改变实参。
p = (char *)malloc(100);//确实在堆上分配了内存,形参p在栈上指向分配的堆内存;get_memory调用之后,形参p回收,堆内存没有指向,造成内存泄漏。
strcpy(str, s);//会产生崩溃,str是null地址。strcpy有溢出风险
*/
void get_memory(char **p) {
    *p = (char *)malloc(100);
}

void test(char *s) {
    char *str = NULL;
    get_memory(&str);//不能直接传&str,str本身是一个指针,&str是二级指针;所以要将get_memory(char *p)改为get_memory(char **p)
    strcpy_s(str, 100, s);
    printf(str);
    free(str);
}

void get_memory1(char *&p) {//传引用
    *p = (char *)malloc(100);
}

void test1(char *s) {
    char *str = NULL;
    get_memory1(str);
    strcpy_s(str, 100, s);
    printf(str);
    free(str);
}

12.3:函数调用约定

函数调用约定(function Calling Convention)

规定调用者和被调用者是如何传递参数的,既调用者如何将参数按照什么样的规范传递给被调用者。

参数是怎么传递的:

  1. 当参数个数多于一个时,按照什么顺序把参数压入堆栈;
  2. 函数调用后,由谁来把堆栈恢复原装(栈平衡)。

调用约定常见类别:

  1. stdcall(C语言默认,变参函数)

  2. cdecl(Windows API、内核驱动)

  3. fastcall(x64)

  4. thiscall(C++中使用)

  5. naked call

    不同的调用规约,在参数的入栈顺序,堆栈的恢复,函数名字的命名上就会不同。在编译后的代码量,程序执行效率上也会受到影响。

12.3.1:cdecl

__cdecl调用方式由于是由调用者还原堆栈,所以这种方式支持可变参数。但是__cdecl的函数调用都会由编译器生成还原堆栈的代码,所以使用__cdecl方式编译的程序比使用__stdcall方式编译的程序要大很多

变参函数必须使用cdecl调用约定,如printf_s,scanf_s等

cdecl调用约定又称为C调用约定,是C语言缺省的调用约定,它的定义语法是:

int func(int x ,int y)//默认的C调用约定,没有写__cdecl,默认缺省
int __cdecl func(int x, int y) {}    //明确指出C调用约定

/*
该调用约定遵循下面的规则:
1. 参数入栈顺序:从右到左(先y栈,后x入栈;由于栈是由高地址到低地址向下增长,所以y在高地址,x在低地址)
2. 还原堆栈者:调用者修改堆栈
3. 函数名:前加下划线:_func

由于每次函数调用都要由编译器产生还原堆栈的代码,所以使用__cdecl方式编译的程序比使用__stdcall方式编译的程序要大很多,但是 __cdecl调用方式是由函数调用者负责清除栈中的函数参数,所以这种方式支持可变参数,比如printf()和Windows的API wsprintf()就是__cdecl调用方式。

由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据第一个后者后续的明确的参数确定下来,就可以使用不定参数了。
*/

int __cdecl func2(int x, int y) {
         return x+y;
}
/*
11: int __cdecl func2(int x, int y)

12: {
00401070 55                   push        ebp
00401071 8B EC                mov         ebp,esp
00401073 83 EC 40              sub         esp,40h
00401076 53                   push        ebx
00401077 56                   push        esi
00401078 57                   push        edi
00401079 8D 7D C0             lea         edi,[ebp-40h]
0040107C B9 10 00 00 00         mov         ecx,10h
00401081 B8 CC CC CC CC        mov         eax,0CCCCCCCCh
00401086 F3 AB                rep stos    dword ptr [edi]

13:       return x+y;
00401088 8B 45 08             mov         eax,dword ptr [ebp+8]
0040108B 03 45 0C             add         eax,dword ptr [ebp+0Ch]

14:   }
0040108E 5F                   pop         edi
0040108F 5E                   pop         esi
00401090 5B                   pop         ebx
00401091 8B E5                mov         esp,ebp
00401093 5D                   pop         ebp
00401094 C3                   ret;直接返回,由调用者负责平衡栈
*/

**栈的增长方向与内存增长方向相反,上面的func2(short x, int y)将x由int改为short入参也会将x在在内存中所占的字节数提升至4字节(内存对齐);char提升至4字节,float入栈时4个字节,double 8字节;而对printf,float会提升为double,入栈时8个字节。 **


当调用fun函数开始时,eip,ebp,esp系统寄存器的作用:

void fun() {
   printf("函数调用");
}

void main() {
  fun()
  printf("调用结束");
}
/*
1. eip寄存器:存储下一条指令
2. ebp寄存器:存放栈基址
3. esp寄存器:存放栈顶位置

当调用fun函数开始时,三者的作用。
1. eip寄存器:存储的是CPU下次要执行的指令的地址。也就是调用完fun函数后,让CPU知道应该执行main函数中的printf("函数调用结束")语句了。
2. ebp寄存器:存储的是是栈的栈底指针,通常叫栈基址,这个是一开始进行fun()函数调用之前,由esp传递给ebp的。(在函数调用前你可以这么理解:esp存储的是栈顶地址,也是栈底地址。)
3. esp寄存器:存储的是在调用函数fun()之后,栈的栈顶。并且始终指向栈顶。
 
当调用fun函数结束后,三者的作用:
1.系统根据eip寄存器里存储的地址,CPU就能够知道函数调用完,下一步应该做什么,也就是应该执行main函数中的printf(“函数调用结束”)。
2. ebp寄存器存储的是栈底地址,而这个地址是由esp在函数调用前传递给ebp的。等到调用结束,ebp会把其地址再次传回给esp。所以esp又一次指向了函数调用结束后,栈顶的地址。
*/

一般来说,下一条要执行的指令是存放在eip中的,所以返回地址入栈就是把当前eip寄存器中的值保存在栈上;返回地址入栈之后,ebp(栈基址寄存器)中的值入栈,我们称为老ebp(这是调试版,在release版中可能ebp不需要入栈用不到);老ebp入栈之后,指针就减(向栈顶移动),esp(栈顶寄存器)指向老ebp,新的ebp也指向这个位置;将esp的值改变,向栈顶部增长(即内存地址减小),留出局部变量区,函数内部的局部变量入栈。函数退出,esp往下走,走到ebp位置;之后 ebp出栈放到ebp中,ebp往下走;紧接着返回地址出栈,将下一条执行的语句恢复至eip中,esp指向参数1;此时,对于cdecl清理此空间,就是将esp继续往下移动

image.png


#include <stdio.h>

int main(int argc, char *argv[]) {
    char x, y, z;
    int i;
    int a[16];
    
    for(i=0; i<=16; i++) {
        a[i]=0;
        printf("\n");
    }
    
    return 0; 
}
/*
a[16]溢出(在栈中,数组a[0]在低地址,a[15]在高地址,a[16]恰好与i重合); 地址指向了i,赋值为0  ,造成死循环。
*/

12.3.2:stdcall

被调用函数自身修改堆栈

stdcall调用约定声明的格式:

int __stdcall func(int x, int y) {}
 
/*
stdcall的调用约定意味着:
1. 参数入栈规则:参数从右向左压入堆栈(先y入栈,后x入栈;由于栈是由高地址到低地址向下增长,所以y在高地址,x在低地址)
2. 还原堆栈者:被调用函数自身修改堆栈
3. 函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。
*/

12.3.3:fastcall

__fastcall调用方式运行速度快; x64平台默认使用facstcall调用约定

fastcall的声明语法为:

int __fastcall func (int x, int y)

\*
该调用约定遵循下面的规则:
1. 参数入栈顺序:函数的第一个和第二个参数通过ecx和edx传递(放在了寄存器里,速度快),剩余参数从右到左入栈
2. 还原堆栈者:被调用函数自身修改堆栈
3. 函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸

x86与x64的fastcall区别
1. 比ecx(rcx),edx(rdx)寄存器多了2个r8,r9;当一个函数的参数个数小于等于4时,可以直接用寄存器传递,速度快。 
2. x64参数入栈,会对齐到8个字节
3. x64函数的前4个参数存放rcx,rdx,r8,r9四个寄存器,剩下的参数从右至左顺序入栈;虽然将参数放在寄存器,但还是会在栈上还会预留4个空间
4. x86平台被调用者负责栈平衡,x64调用者负责栈的平衡(x64统一用fastcall,调用者负责栈平衡才能使用可变传参)
5. 栈需要16字节对齐
6. 局部变量空间的分配和初始化由调用者完成。调用者负责在栈上分配32字节的“shadow space”,用于存放那四个存放调用参数的寄存器的值(亦即前四个调用参数);小于64位(bit)的参数传递时高位并不填充零(例如只传递ecx),大于64位需要按照地址传递;
7. x64浮点前4个参数传入XMM0、XMM1、XMM2 和 XMM3 中。其他参数传递到堆栈中
8. x64被调用函数的返回值是整数时,则返回值会被存放于RAX;浮点数返回在xmm0中
9. RAX,RCX,RDX,R8,R9,R10,R11是“易挥发”的,不用特别保护(所谓保护就是使用前要push备份),其余寄存器需要保护。(x86下只有eax, ecx, edx是易挥发的)
*\
image.png

12.3.4:naked call

一个不常用的调用约定,编译器不会给这种函数增加初始化和清理代码,也不能用return语句返回值,只能程序员控制,插入汇编返回结果。因此它一般用于驱动程序设计,比如inline hook等。假设定义一个求减的减法程序,可以定义为:

__declspec(naked) int sub(int a,int b) {
   __asm mov eax,a
   __asm sub eax,b
   __asm ret
}

/*
如果定义的约定和使用的约定不一致,会导致栈被破坏。
最常见的调用规约错误是:
1. 函数原型声明和函数体定义不一致
2. DLL导入函数时声明了不同的函数约定
*/

12.3.5:thiscall

thiscall是C++类成员函数缺省的调用约定,但它没有显示的声明形式。因为在C++类中,成员函数调用还有一个this指针参数,因此必须特殊处理,thiscall意味着:

1. 参数入栈:参数从右向左入栈
2. this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入堆栈。
3. 堆栈恢复:对参数个数不定的,调用者清理堆栈,否则函数自己清理堆栈。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容