Contents

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.s
0000000000001149 <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 faendbr64指令对应的十六进制机器码,是CPU 执行的真实二进制编码。

汇编代码执行逻辑:

  • %rbp寄存器保存调用方函数的栈帧基地址%rsp寄存器指向当前栈顶rip指令寄存器
  • 在X86-64架构中,栈是向下增长的,即栈顶地址随着数据的压入而减小,随着数据的弹出而增大
  1. push rbp:将 %rbp 寄存器的值(即调用方的栈帧基地址)压入栈中,保存调用者的栈帧基地址。压入操作会使栈指针 %rsp 的值减少 8 字节(在 64 位系统中,一个寄存器的值是 8 字节)。栈顶上现在存储的是调用方的旧 %rbp 值。

  2. mov %rsp,%rbp:将当前栈顶指针 %rsp 的值复制到 %rbp 寄存器中,建立当前函数的栈帧基地址。此时,%rbp 指向当前函数的栈顶位置,一个新的栈帧就此建立。

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |  
    +-----------------+  <- %rbp (main 的栈底) <- %rsp
    |                 |  
    +-----------------+
    低地址
  3. sub $0x10,%rsp:为局部变量分配空间,将栈指针 %rsp 向下移动 16 字节(0x10),为函数的局部变量腾出空间。

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |  
    +-----------------+  <- %rbp (main 的栈底)
    |      16字节      |  - 局部变量空间  
    +-----------------+  <- %rsp(main的栈顶)
    |                 |  
    +-----------------+
    低地址
  4. mov $0x5,%esimov $0x3,%edi:将实参 5 和 3 分别加载到寄存器 %esi%edi 中,准备传递给 add 函数的形参。

  5. call 1149 <add>:调用 add 函数。call 指令会将下一条指令的地址(即返回地址)压入栈中(栈顶存储0x117c),然后跳转到 add 函数的入口地址 1149 开始执行。调用add函数之前,main函数的栈帧布局如下:

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |  
    +-----------------+  <- %rbp (main 的栈底)
    |      16字节      |  - 局部变量空间  
    +-----------------+
    |     0x117c      |  - 返回地址, 占 8 字节 (假设地址: 0x7fffffffe4e8)
    +-----------------+
    |                 |  <- 这是 16 字节空间中剩下的 8 字节 (假设地址: 0x7fffffffe4f0)
    |                 |
    +-----------------+  <- %rsp (0x7fffffffe4e8)
    |                 |  
    +-----------------+
    低地址
  6. push %rbp(add函数):将调用方main函数的%rbp值压入栈中,保存调用者的栈帧基地址。此时,栈帧布局如下:

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |
    +-----------------+  <- %rbp (main 的栈底)
    |      16字节      |  - 局部变量空间  
    +-----------------+
    |     0x117c      |  - 返回地址 
    +-----------------+
    |  main的%rbp值    |  - 占 8 字节
    +-----------------+  <- %rsp 
    |                 |  
    +-----------------+
    低地址
  7. mov %rsp, %rbp(add函数):将当前栈顶指针 %rsp 的值复制到 %rbp 寄存器中,建立 add 函数的栈帧基地址。此时,栈帧布局如下:

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |  
    +-----------------+  <- main 的 %rbp
    |      16字节      |  - 局部变量空间  
    +-----------------+
    |     0x117c      |  - 返回地址
    +-----------------+
    |  main的%rbp值    |  - 占 8 字节
    +-----------------+  <- %rbp (add 的栈底) <- %rsp
    |                 |  
    +-----------------+
    低地址
  8. 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
    低地址
  9. mov -0x4(%rbp), %edxmov -0x8(%rbp), %eax:将存储在栈帧中的参数值加载回寄存器 %edx%eax,准备进行加法运算。

  10. add %edx, %eax:将寄存器 %edx%eax 中的值相加,结果存储在 %eax 中。此时,%eax 寄存器中保存了 add 函数的返回值。

  11. pop %rbp:将栈顶的值弹出到 %rbp 寄存器中,恢复调用方 main 函数的栈帧基地址,%rsp寄存器向上移动8字节。此时,栈帧布局如下:

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |  
    +-----------------+  <- %rbp(重新指向main的栈底)
    |      16字节      |  - 局部变量空间  
    +-----------------+
    |     0x117c      |  - 返回地址
    +-----------------+  <- %rsp
    |                 |  
    +-----------------+   
    |                 |  
    +-----------------+
    低地址
  12. ret:从函数返回。ret 指令会从栈顶弹出返回地址(即之前压入的 0x117c),%rsp寄存器向上移动8字节,并将程序技术器%rip的值设置为这个返回地址,跳转到该地址继续执行 main 函数的后续代码。此时,栈帧布局如下:

    高地址
    ...
    +-----------------+
    | 调用函数的%rbp值  |  
    +-----------------+  <- %rbp
    |      16字节      |  - 局部变量空间  
    +-----------------+  <- %rsp
    |                 |  
    +-----------------+   
    低地址
  13. 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函数的栈帧,获取局部变量ab的值。

地址传递

地址传递和值传递是一样的,区别在于传递的实参是地址编号。地址传递一般用于返回结果连续空间传递

多值返回

#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),用于栈溢出保护。然后开始准备参数并传递:

  1. lea -0xc(%rbp),%rdxlea -0x10(%rbp),%rax:计算局部变量ret2ret的地址并存入寄存器。
  2. mov %rdx, %rsimov %rax, %rdi:准备func的指针参数(%rdi%rsi分别是 ABI 约定的第1、2个寄存器参数)
  3. call 1169 <func>:调用func函数
  4. 进入func函数建立栈帧
  5. mov %rdi, -0x8(%rbp)mov %rsi, -0x10(%rbp):将指针参数保存至当前栈帧
  6. 判断指针是否为空,若是,返回;否则继续执行
  7. mov -0x8(%rbp), %raxmovl $0xc8, (%rax):将第一个指针加载到%rax,通过寄存器间接寻址,向指针指向的地址写值
  8. mov -0x10(%rbp), %raxmovl $0x190, (%rax):将第二个指针加载到%rax,通过寄存器间接寻址,向指针指向的地址写值
  9. 返回成功码,销毁栈帧,返回函数。

连续空间传递

因为参数传递是内存拷贝,所以如果传入的参数是一片连续的空间,那每次调用都会进行冗余的内存分配,造成内存不必要的浪费。所以连续空间,一般都是传入这片空间的首地址