C语言:函数
C语言是面向过程的编程语言,其核心概念之一就是函数。函数是组织代码的基本单位,它将一组相关的语句封装在一起,以实现特定的功能。通过函数,程序员可以提高代码的可读性、可维护性和重用性。简单来说,函数就是将重复使用的代码块进行封装,方便调用和管理。
面向过程与面向对象的区别
面向过程(POP):以流程和步骤为中心,将问题拆解为一系列按顺序执行的函数,数据与操作分离,强调执行效率和控制过程。
面向对象(OOP):以对象和职责为中心,将数据与行为封装在对象中,通过抽象、封装、多态和继承组织系统,强调模块化、可扩展性和可维护性。
函数
函数的三大属性
函数有三个重要的属性:函数名、参数和返回值。
1. 函数名(地址)
2. 输入参数(可多个)
3. 返回值(最多一个)
int function(int, char){
***
}
Tips: 编译器只要看到有这三个属性,就会认定这是一个函数。- 函数名本质上是一个地址标签。 当程序执行到函数调用时,CPU会跳转到该地址开始执行函数体内的代码。
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5); // 调用函数add
printf("Result: %d\n", result);
return 0;
}Tips: 汇编与反汇编的区别
- 汇编:由 C 语言或其他高级语言通过编译器(或手写)生成,包含汇编指令和可选符号名(函数名、标签、全局变量名),逻辑清晰,接近程序设计思路。
- 反汇编:从已编译的二进制文件(机器码)通过反汇编工具生成,包含内存地址/偏移:指令在程序中的虚拟地址,用于调试和控制流分析;机器码:CPU实际执行的二进制编码;汇编指令:对应的汇编语言指令,便于阅读和理解程序逻辑。源代码中原有的函数名、局部变量名可能变为匿名或地址符号。
编译之后查看反汇编代码,可以看到add函数的地址标签:
gcc -o bin 1.c
objdump -d bin > bin.s0000000000001149 <add>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 89 7d fc mov %edi,-0x4(%rbp)
1154: 89 75 f8 mov %esi,-0x8(%rbp)
1157: 8b 55 fc mov -0x4(%rbp),%edx
115a: 8b 45 f8 mov -0x8(%rbp),%eax
115d: 01 d0 add %edx,%eax
115f: 5d pop %rbp
1160: c3 ret
0000000000001161 <main>:
1161: f3 0f 1e fa endbr64
1165: 55 push %rbp
1166: 48 89 e5 mov %rsp,%rbp
1169: 48 83 ec 10 sub $0x10,%rsp
116d: be 05 00 00 00 mov $0x5,%esi
1172: bf 03 00 00 00 mov $0x3,%edi
1177: e8 cd ff ff ff call 1149 <add>
117c: 89 45 fc mov %eax,-0x4(%rbp)
117f: 8b 45 fc mov -0x4(%rbp),%eax
1182: 89 c6 mov %eax,%esi
1184: 48 8d 05 79 0e 00 00 lea 0xe79(%rip),%rax # 2004 <_IO_stdin_used+0x4>
118b: 48 89 c7 mov %rax,%rdi
118e: b8 00 00 00 00 mov $0x0,%eax
1193: e8 b8 fe ff ff call 1050 <printf@plt>
1198: b8 00 00 00 00 mov $0x0,%eax
119d: c9 leave
119e: c3 ret反汇编代码格式:
[内存地址] : [机器码] 汇编指令0000000000001149表示add函数在内存中的起始虚拟地址(VA), ELF 可执行文件在加载到内存后,函数入口的地址。1149表示接下来这一条机器码指令在内存中的具体地址。地址+偏移指明每条指令在程序内存的真实位置,便于调试、断点和控制流分析。
f3 0f 1e fa是endbr64指令对应的十六进制机器码,是CPU 执行的真实二进制编码。
汇编代码执行逻辑:
%rbp寄存器保存调用方函数的栈帧基地址;%rsp寄存器指向当前栈顶;rip指令寄存器- 在X86-64架构中,栈是向下增长的,即栈顶地址随着数据的压入而减小,随着数据的弹出而增大。
push rbp:将%rbp寄存器的值(即调用方的栈帧基地址)压入栈中,保存调用者的栈帧基地址。压入操作会使栈指针%rsp的值减少 8 字节(在 64 位系统中,一个寄存器的值是 8 字节)。栈顶上现在存储的是调用方的旧%rbp值。mov %rsp,%rbp:将当前栈顶指针%rsp的值复制到%rbp寄存器中,建立当前函数的栈帧基地址。此时,%rbp指向当前函数的栈顶位置,一个新的栈帧就此建立。高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- %rbp (main 的栈底) <- %rsp | | +-----------------+ 低地址sub $0x10,%rsp:为局部变量分配空间,将栈指针%rsp向下移动 16 字节(0x10),为函数的局部变量腾出空间。高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- %rbp (main 的栈底) | 16字节 | - 局部变量空间 +-----------------+ <- %rsp(main的栈顶) | | +-----------------+ 低地址mov $0x5,%esi和mov $0x3,%edi:将实参 5 和 3 分别加载到寄存器%esi和%edi中,准备传递给add函数的形参。call 1149 <add>:调用add函数。call指令会将下一条指令的地址(即返回地址)压入栈中(栈顶存储0x117c),然后跳转到add函数的入口地址1149开始执行。调用add函数之前,main函数的栈帧布局如下:高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- %rbp (main 的栈底) | 16字节 | - 局部变量空间 +-----------------+ | 0x117c | - 返回地址, 占 8 字节 (假设地址: 0x7fffffffe4e8) +-----------------+ | | <- 这是 16 字节空间中剩下的 8 字节 (假设地址: 0x7fffffffe4f0) | | +-----------------+ <- %rsp (0x7fffffffe4e8) | | +-----------------+ 低地址push %rbp(add函数):将调用方main函数的%rbp值压入栈中,保存调用者的栈帧基地址。此时,栈帧布局如下:高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- %rbp (main 的栈底) | 16字节 | - 局部变量空间 +-----------------+ | 0x117c | - 返回地址 +-----------------+ | main的%rbp值 | - 占 8 字节 +-----------------+ <- %rsp | | +-----------------+ 低地址mov %rsp, %rbp(add函数):将当前栈顶指针%rsp的值复制到%rbp寄存器中,建立add函数的栈帧基地址。此时,栈帧布局如下:高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- main 的 %rbp | 16字节 | - 局部变量空间 +-----------------+ | 0x117c | - 返回地址 +-----------------+ | main的%rbp值 | - 占 8 字节 +-----------------+ <- %rbp (add 的栈底) <- %rsp | | +-----------------+ 低地址mov %edi,-0x4(%rbp)和mov %esi,-0x8(%rbp): 将传入的参数值从寄存器%edi和%esi存储到add函数的栈帧中,分别存储在相对于%rbp的偏移位置-0x4和-0x8处。此时,栈帧布局如下:高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- main 的 %rbp | 16字节 | - 局部变量空间 +-----------------+ | 0x117c | - 返回地址 +-----------------+ | main的%rbp值 | - 占 8 字节 +-----------------+ <- %rbp (add 的栈底) <- %rsp | %edi的值(4字节) | +-----------------+ <- %rbp - 0x4 | %esi的值(4字节) | +-----------------+ <- %rbp - 0x8 低地址mov -0x4(%rbp), %edx和mov -0x8(%rbp), %eax:将存储在栈帧中的参数值加载回寄存器%edx和%eax,准备进行加法运算。add %edx, %eax:将寄存器%edx和%eax中的值相加,结果存储在%eax中。此时,%eax寄存器中保存了add函数的返回值。pop %rbp:将栈顶的值弹出到%rbp寄存器中,恢复调用方main函数的栈帧基地址,%rsp寄存器向上移动8字节。此时,栈帧布局如下:高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- %rbp(重新指向main的栈底) | 16字节 | - 局部变量空间 +-----------------+ | 0x117c | - 返回地址 +-----------------+ <- %rsp | | +-----------------+ | | +-----------------+ 低地址ret:从函数返回。ret指令会从栈顶弹出返回地址(即之前压入的0x117c),%rsp寄存器向上移动8字节,并将程序技术器%rip的值设置为这个返回地址,跳转到该地址继续执行main函数的后续代码。此时,栈帧布局如下:高地址 ... +-----------------+ | 调用函数的%rbp值 | +-----------------+ <- %rbp | 16字节 | - 局部变量空间 +-----------------+ <- %rsp | | +-----------------+ 低地址add函数调用完毕,返回到main函数继续执行。
函数的参数传递
参数传递的本质
调用函数时,需要传入和返回参数。传入和返回参数过程本质上是:内存拷贝(将实参的值复制到形参对应的内存位置或寄存器中)。内存拷贝需要两个对象:目的地和源。在C语言中,传入参数时,目的地叫形参,源叫实参;返回参数时,目的地和源都是返回值。
值传递
值传递是函数参数传递的常见方式之一。具体传递过程见上述汇编代码执行逻辑的add函数调用部分。
从上面的汇编分析可以知道,add的栈帧与main的栈帧是分开的,当add函数调用结束后,其局部变量生命周期结束。但是在那块内存被覆盖之前,局部变量的数据仍存储在内存中,如果要分析局部变量的值,可以在main函数中获取add函数栈帧所在内存区域的数据。
#include <stdio.h>
unsigned long addr = 0;
void show(void)
{
int a = 10086;
int b = 10010;
asm volatile("movq %rbp, addr(%rip)"); // 获取当前栈帧基地址
}
int main()
{
show();
int *p = (int *)(addr); // 计算局部变量 b 的地址
printf("a = %d, b = %d\n", p[-1], p[-2]); // 输出局部变量的值
return 0;
}汇编代码,分别将10086, 10010压入show函数的栈帧中:
movl $10086, -8(%rbp)
movl $10010, -4(%rbp)运行结果:
a = 10010, b = 10086在show函数运行时,将其栈帧基地址保存到全局变量addr中。然后在main函数中,通过计算偏移地址访问show函数的栈帧,获取局部变量a和b的值。
地址传递
地址传递和值传递是一样的,区别在于传递的实参是地址编号。地址传递一般用于返回结果和连续空间传递。
多值返回
#include <stdio.h>
int func(int *a, int *b)
{
if (!a || !b){
return -1;
}
*a = 100 * 2;
*b = 200 * 2;
return 0;
}
int main(void)
{
int ret;
int ret2;
func(&ret, &ret2);
printf("ret = %d \n", ret);
return 0;
}反汇编代码如下:
0000000000001169 <func>:
1169: f3 0f 1e fa endbr64
116d: 55 push %rbp
116e: 48 89 e5 mov %rsp,%rbp
1171: 48 89 7d f8 mov %rdi,-0x8(%rbp)
1175: 48 89 75 f0 mov %rsi,-0x10(%rbp)
1179: 48 83 7d f8 00 cmpq $0x0,-0x8(%rbp)
117e: 74 07 je 1187 <func+0x1e>
1180: 48 83 7d f0 00 cmpq $0x0,-0x10(%rbp)
1185: 75 07 jne 118e <func+0x25>
1187: b8 ff ff ff ff mov $0xffffffff,%eax
118c: eb 19 jmp 11a7 <func+0x3e>
118e: 48 8b 45 f8 mov -0x8(%rbp),%rax
1192: c7 00 c8 00 00 00 movl $0xc8,(%rax)
1198: 48 8b 45 f0 mov -0x10(%rbp),%rax
119c: c7 00 90 01 00 00 movl $0x190,(%rax)
11a2: b8 00 00 00 00 mov $0x0,%eax
11a7: 5d pop %rbp
11a8: c3 ret
00000000000011a9 <main>:
11a9: f3 0f 1e fa endbr64
11ad: 55 push %rbp
11ae: 48 89 e5 mov %rsp,%rbp
11b1: 48 83 ec 10 sub $0x10,%rsp
11b5: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
11bc: 00 00
11be: 48 89 45 f8 mov %rax,-0x8(%rbp)
11c2: 31 c0 xor %eax,%eax
11c4: 48 8d 55 f4 lea -0xc(%rbp),%rdx
11c8: 48 8d 45 f0 lea -0x10(%rbp),%rax
11cc: 48 89 d6 mov %rdx,%rsi
11cf: 48 89 c7 mov %rax,%rdi
11d2: e8 92 ff ff ff call 1169 <func>
11d7: 8b 45 f0 mov -0x10(%rbp),%eax
11da: 89 c6 mov %eax,%esi
11dc: 48 8d 05 21 0e 00 00 lea 0xe21(%rip),%rax # 2004 <_IO_stdin_used+0x4>
11e3: 48 89 c7 mov %rax,%rdi
11e6: b8 00 00 00 00 mov $0x0,%eax
11eb: e8 80 fe ff ff call 1070 <printf@plt>
11f0: b8 00 00 00 00 mov $0x0,%eax
11f5: 48 8b 55 f8 mov -0x8(%rbp),%rdx
11f9: 64 48 2b 14 25 28 00 sub %fs:0x28,%rdx
1200: 00 00
1202: 74 05 je 1209 <main+0x60>
1204: e8 57 fe ff ff call 1060 <__stack_chk_fail@plt>
1209: c9 leave
120a: c3 ret在main函数中建立栈帧后,初始化栈金丝雀(读取当前线程 TLS(线程本地存储)中偏移 0x28 位置的到 %rax),用于栈溢出保护。然后开始准备参数并传递:
lea -0xc(%rbp),%rdx和lea -0x10(%rbp),%rax:计算局部变量ret2和ret的地址并存入寄存器。mov %rdx, %rsi和mov %rax, %rdi:准备func的指针参数(%rdi和%rsi分别是 ABI 约定的第1、2个寄存器参数)call 1169 <func>:调用func函数- 进入
func函数建立栈帧 mov %rdi, -0x8(%rbp)和mov %rsi, -0x10(%rbp):将指针参数保存至当前栈帧- 判断指针是否为空,若是,返回;否则继续执行
mov -0x8(%rbp), %rax和movl $0xc8, (%rax):将第一个指针加载到%rax,通过寄存器间接寻址,向指针指向的地址写值mov -0x10(%rbp), %rax和movl $0x190, (%rax):将第二个指针加载到%rax,通过寄存器间接寻址,向指针指向的地址写值- 返回成功码,销毁栈帧,返回函数。
连续空间传递
因为参数传递是内存拷贝,所以如果传入的参数是一片连续的空间,那每次调用都会进行冗余的内存分配,造成内存不必要的浪费。所以连续空间,一般都是传入这片空间的首地址。