1. C/C++中函数的定义与类型

 一、C/C++中函数的定义与类型

在C/C++中,函数是一个执行特定任务的代码块。它使得代码可以被重用、组织和模块化。

1、函数的构成要素

一个标准的函数定义包含以下几个部分:

  • 返回类型 (Return Type): 函数执行完毕后返回给调用者的数据的类型。如果不需要返回任何值,则使用 void

  • 函数名 (Function Name): 用于唯一标识函数的名称。

  • 参数列表 (Parameter List): 传递给函数的数据。每个参数都包含一个类型和一个名称。如果函数不接受任何参数,则列表为空或使用 void

  • 函数体 (Function Body): 包含在 {} 中的一系列指令,定义了函数要执行的具体操作。

// 这是一个完整的函数定义
int add(int a, int b) {  // 返回类型是 int, 函数名是 add, 参数列表是 (int a, int b)
    int sum = a + b;   // 函数体
    return sum;        // 返回值
}

2、函数的常见类型

除了上面的标准函数,还有一些特殊的函数形式:

  • 内联函数 (Inline Functions): 使用 inline 关键字建议编译器在调用点将函数体展开,以减少函数调用的开销。这是一种空间换时间的策略。

  • 函数指针 (Function Pointers): 一种特殊的指针,它存储的不是数据的地址,而是函数的入口地址。这使得我们可以像传递普通变量一样传递函数。

  • 成员函数 (Member Functions): 在C++的类(class)或结构体(struct)中定义的函数,它们可以访问该对象的成员变量。

二、函数的底层实现 (汇编与内存视角)

当我们调用一个函数时,计算机底层发生了一系列复杂但有序的操作。这主要涉及到 调用栈 (Call Stack)CPU寄存器 (CPU Registers)。我们将以x86-64架构(现代64位PC的标准)为例来解释这个过程。

1、什么是调用栈 (The Call Stack)?

调用栈是内存中的一块特殊区域,遵循“后进先出”(LIFO, Last-In, First-Out)的原则。它主要用于:

  1. 存储函数参数:当参数数量过多,无法全放入寄存器时。

  2. 存储返回地址:记录函数执行完毕后应该回到哪里继续执行。

  3. 存储局部变量:函数内部定义的变量都存放在这里。

每次调用一个函数,都会在栈顶创建一个新的 栈帧 (Stack Frame)。这个栈帧包含了该次函数调用所需的所有信息。当函数返回时,它的栈帧就会被销毁。

2、关键的寄存器

在函数调用过程中,CPU中的一些寄存器扮演着至关重要的角色:

  • RSP (Stack Pointer): 栈指针,永远指向调用栈的 栈顶

  • RBP (Base Pointer): 基址指针,指向当前栈帧的 底部。通过 RBP 加上一个偏移量,可以稳定地访问局部变量和函数参数,即使 RSP 随着 push/pop 操作在不断变化。

  • RIP (Instruction Pointer): 指令指针,存储下一条将要执行的CPU指令的地址。

  • RAX: 通常用于存储函数的 返回值

  • RDI, RSI, RDX, RCX, R8, R9: 在x86-64 System V AMD64 ABI (Linux/macOS) 调用约定中,用于传递前六个整型或指针参数。

3、一个函数调用的生命周期 (以c = add(a, b);为例)

假设我们有以下C++代码:

#include <stdio.h>

int add(int a, int b) {
    int sum = a + b;
    return sum;
}

int main() {
    int a = 10;
    int b = 20;
    int c = add(a, b); // 我们关注这一行
    printf("a + b = %d\n", c);
    return 0;
}

我们在int b = 20;处下一个断点,同时把确保整个编译模式处于Debug模式下,如图所示:

按下F5开启调试后,程序在第十行中断的之后,按下快捷键Alt+8,仔细观察程序的汇编代码:

int main() {
00007FF6DE6418F0  push        rbp  
00007FF6DE6418F2  push        rdi  
00007FF6DE6418F3  sub         rsp,148h  
00007FF6DE6418FA  lea         rbp,[rsp+20h]  
00007FF6DE6418FF  lea         rcx,[__0CE2AB12_Test@cpp (07FF6DE652008h)]  
00007FF6DE641906  call        __CheckForDebuggerJustMyCode (07FF6DE641370h)  
00007FF6DE64190B  nop  
    int a = 10;
00007FF6DE64190C  mov         dword ptr [a],0Ah  
    int b = 20;
00007FF6DE641913  mov         dword ptr [b],14h  
    int c = add(a, b); // 我们关注这一行
00007FF6DE64191A  mov         edx,dword ptr [b]  
00007FF6DE64191D  mov         ecx,dword ptr [a]  
00007FF6DE641920  call        add (07FF6DE64131Bh)  
00007FF6DE641925  mov         dword ptr [c],eax  
    printf("a + b = %d\n", c);
00007FF6DE641928  mov         edx,dword ptr [c]  
00007FF6DE64192B  lea         rcx,[string "a + b = %d\n" (07FF6DE64AC28h)]  
00007FF6DE641932  call        printf (07FF6DE641195h)  
00007FF6DE641937  nop  
    return 0;
00007FF6DE641938  xor         eax,eax  
}
00007FF6DE64193A  lea         rsp,[rbp+128h]  
00007FF6DE641941  pop         rdi  
00007FF6DE641942  pop         rbp  
00007FF6DE641943  ret  

main 函数调用 add(a, b) 时,底层大致会发生以下步骤

①参数传递
  • 调用者 (main) 将第一个参数 a (值10) 放入 RDI 寄存器。

  • 调用者将第二个参数 b (值20) 放入 RSI 寄存器。

    int a = 10;
00007FF6DE64190C  mov         dword ptr [a],0Ah              //把十六进制的10传到地址a
    int b = 20;
00007FF6DE641913  mov         dword ptr [b],14h              //把十六进制的20传到地址b
    int c = add(a, b);                                      
00007FF6DE64191A  mov         edx,dword ptr [b]              //传递b到edx
00007FF6DE64191D  mov         ecx,dword ptr [a]              //传递a到ecx
00007FF6DE641920  call        add (07FF6DE64131Bh)           //call调用add函数入口
00007FF6DE641925  mov         dword ptr [c],eax              //把结果从eax拷贝到地址c

按下F11,逐步语句执行到    00007FF6DE641920  call        add (07FF6DE64131Bh)  处,进去看看。

注意:执行到call add这一步的时候暂停,观察寄存器如下:

RAX = 0000000000000001 RBX = 0000000000000000 RCX = 000000000000000A 
RDX = 0000000000000014 RSI = 0000000000000000 RDI = 0000000000000000 
R8  = 000001B3CD037DC0 R9  = 00000021E89DFB48 R10 = 0000000000000012 
R11 = 00000021E89DFC00 R12 = 0000000000000000 R13 = 0000000000000000 
R14 = 0000000000000000 R15 = 0000000000000000 RIP = 00007FF6DE641920
 RSP = 00000021E89DFB00 RBP = 00000021E89DFB20 EFL = 00000202 

当我们再次按下F11,执行call指令的时候,再次观察寄存器情况:

RAX = 0000000000000001 RBX = 0000000000000000 RCX = 000000000000000A
 RDX = 0000000000000014 RSI = 0000000000000000 RDI = 0000000000000000
 R8  = 000001B3CD037DC0 R9  = 00000021E89DFB48 R10 = 0000000000000012
 R11 = 00000021E89DFC00 R12 = 0000000000000000 R13 = 0000000000000000
 R14 = 0000000000000000 R15 = 0000000000000000 RIP = 00007FF6DE6417B0 
RSP = 00000021E89DFAF8 RBP = 00000021E89DFB20 EFL = 00000202 

调用指令 (call)
  • CPU执行 call add 指令。

  • 这个指令会做两件事:

    • 保存返回地址: 将 call 指令的下一条指令的地址(即 add 返回后应该执行的代码地址)压入栈顶。

    • 跳转: 修改 RIP 指令指针,使其指向 add 函数的第一条指令。CPU即将开始执行 add 函数的代码。

③核心部分
int add(int a, int b) {
00007FF6DE6417B0  mov         dword ptr [rsp+10h],edx  
00007FF6DE6417B4  mov         dword ptr [rsp+8],ecx  
00007FF6DE6417B8  push        rbp  
00007FF6DE6417B9  push        rdi  
00007FF6DE6417BA  sub         rsp,108h  
00007FF6DE6417C1  lea         rbp,[rsp+20h]  
00007FF6DE6417C6  lea         rcx,[__0CE2AB12_Test@cpp (07FF6DE652008h)]  
00007FF6DE6417CD  call        __CheckForDebuggerJustMyCode (07FF6DE641370h)  
00007FF6DE6417D2  nop  
    int sum = a + b;
00007FF6DE6417D3  mov         eax,dword ptr [b]  
00007FF6DE6417D9  mov         ecx,dword ptr [a]  
00007FF6DE6417DF  add         ecx,eax  
00007FF6DE6417E1  mov         eax,ecx  
00007FF6DE6417E3  mov         dword ptr [sum],eax  
    return sum;
00007FF6DE6417E6  mov         eax,dword ptr [sum]  
}
00007FF6DE6417E9  lea         rsp,[rbp+0E8h]  
00007FF6DE6417F0  pop         rdi  
00007FF6DE6417F1  pop         rbp  
00007FF6DE6417F2  ret  
Part 1: 函数前奏 (Prologue) - 建立栈帧和环境

这部分代码的目的是在调用栈上为 add 函数建立一个专属的工作空间(栈帧)。

; --- 参数传递与入栈 ---
00007FF6DE6417B0  mov       dword ptr [rsp+10h], edx
00007FF6DE6417B4  mov       dword ptr [rsp+8], ecx
  • 调用约定: 在 Windows x64 调用约定中,前四个整型/指针参数按顺序由 RCX, RDX, R8, R9 寄存器传递。因此,参数 aecx 中,参数 bedx 中。

  • mov dword ptr [rsp+8], ecx: 将寄存器 ecx 的值(也就是参数 a)保存到栈上,位置是当前栈顶指针 rsp 向上偏移 8 字节的地方。

  • mov dword ptr [rsp+10h], edx: 将寄存器 edx 的值(也就是参数 b)保存到栈上,位置是 rsp 向上偏移 16 字节(10h = 16)的地方。

  • 为什么这么做? 这就是Debug模式的特点。虽然参数已经通过寄存器高效地传进来了,但为了方便调试器随时查看,编译器还是把它们复制到了内存(栈)里。这块区域被称为“参数的home space”或“shadow space”。

; --- 保存调用者的栈帧信息 ---
00007FF6DE6417B8  push        rbp
00007FF6DE6417B9  push        rdi
  • push rbp: rbp 寄存器保存着 调用者(比如 main 函数)的栈帧基址。这里将其压入栈中,是为了在 add 函数执行完毕后能够完美地恢复 main 函数的栈帧。

  • push rdi: rdi 是一个“非易失性”寄存器,意味着如果函数要使用它,必须在返回前恢复它的原始值。编译器在这里保存它,以备后用。

; --- 为局部变量和调试信息分配空间 ---
00007FF6DE6417BA  sub         rsp, 108h
00007FF6DE6417C1  lea         rbp, [rsp+20h]
  • sub rsp, 108h: 这是为 add 函数的局部变量等数据分配栈空间。108h 等于 264 字节。虽然我们的函数只有一个局部变量 sum(4字节),但编译器在Debug模式下会分配远超需求的内存,用于存放调试信息、栈保护cookie等。

  • lea rbp, [rsp+20h]: lea (Load Effective Address) 指令用于计算地址。这行代码的意思是,将 rsp+20h (即 rsp 向上偏移32字节) 这个地址本身,作为 add 函数新的栈帧基址,存入 rbp 寄存器。从此以后,rbp 就固定地指向 add 函数栈帧的“底部”,所有局部变量的访问都将通过 rbp 进行。

  • 小结: 此时,一个完整的栈帧已经建立。参数 ab 被存放在栈上,调用者的 rbp 被保存,并且为 add 函数自己分配了充足的内存空间。

; --- 调试器支持代码 ---
00007FF6DE6417C6  lea         rcx, [__0CE2AB12_Test@cpp (07FF6DE652008h)]
00007FF6DE6417CD  call        __CheckForDebuggerJustMyCode (07FF6DE641370h)
00007FF6DE6417D2  nop
  • 这三行是纯粹的 Debug 代码,与你的 add 函数逻辑无关。它在调用一个内部函数 __CheckForDebuggerJustMyCode,这是 Visual Studio 实现“仅我的代码”(Just My Code)调试功能的一部分。

  • nop (No Operation) 是一个空指令,通常用于对齐内存地址或为调试器提供一个设置断点的稳定位置。

Part 2: 函数体 (Body) - 执行核心逻辑
00007FF6DE6417D3  mov         eax, dword ptr [b]
00007FF6DE6417D9  mov         ecx, dword ptr [a]
00007FF6DE6417DF  add         ecx, eax
00007FF6DE6417E1  mov         eax, ecx
00007FF6DE6417E3  mov         dword ptr [sum], eax
  • 这里的 [a], [b], [sum] 是调试器为了方便你阅读,为你显示的符号名。在底层,它们实际上是相对于 rbp 的某个内存地址,例如 [rbp-8]

  • mov eax, dword ptr [b]: 从内存中读取变量 b 的值,放入 eax 寄存器。

  • mov ecx, dword ptr [a]: 从内存中读取变量 a 的值,放入 ecx 寄存器。

  • add ecx, eax: 计算 ecx + eax (即 a + b),结果存回 ecx

  • mov eax, ecx: 将计算结果从 ecx 复制到 eax

  • mov dword ptr [sum], eax: 将 eax 中的最终结果存入为局部变量 sum 分配的内存地址中。

  • 为何如此冗长? 在Release(优化)模式下,a + b 可能只需要一条 lea eax, [rcx+rdx] 指令就能完成。但Debug模式为了保证每一步都能被跟踪,宁愿使用多条指令,频繁地在寄存器和内存之间读写数据。

Part 3: 准备返回值
00007FF6DE6417E6  mov         eax, dword ptr [sum]
  • 根据调用约定,函数的返回值(如果是整型或指针)必须放在 rax (或 eax for 32-bit) 寄存器中。

  • 这行代码将局部变量 sum 的值从内存中再次读取到 eax 寄存器,准备返回给调用者。

Part 4: 函数尾声 (Epilogue) - 销毁栈帧并返回
00007FF6DE6417E9  lea         rsp, [rbp+0E8h]
00007FF6DE6417F0  pop         rdi
00007FF6DE6417F1  pop         rbp
00007FF6DE6417F2  ret
  • 这部分操作与函数前奏 (Prologue) 完全相反,目的是恢复到调用 add 之前的状态。

  • lea rsp, [rbp+0E8h]: 快速恢复 rsp。这行指令的效果等同于 add rsp, 108h 加上其他一些偏移,它直接将栈顶指针 rsp 指向 add 函数分配栈帧之前的位置,从而一次性“释放”所有为局部变量分配的空间。

  • pop rdi: 从栈顶弹出一个值,恢复 rdi 寄存器。

  • pop rbp: 从栈顶弹出一个值,恢复 rbp 寄存器。现在 rbp 重新指向了调用者(main 函数)的栈帧基址。

  • ret: 这是函数返回指令。它会从栈顶弹出 返回地址(这个地址在 main 函数执行 call add 指令时被自动压入栈中),然后跳转到这个地址继续执行。

至此,add 函数的整个生命周期结束,程序控制权回到了 main 函数,并且 main 函数可以从 eax 寄存器中得到计算结果。

4、函数内再调用其他函数的情况

让我们看一个具体关于调用栈 (Call Stack)的例子:

void functionB() {
    // ... 做一些事情 ...
    // B结束
}

void functionA() {
    // ... 做一些事情 ...
    functionB(); // A 调用 B
    // ... A 在 B 返回后继续做一些事情 ...
    // A结束
}

int main() {
    functionA(); // main 调用 A
    return 0;
    // main结束
}
①执行流程与栈的变化
  1. main 函数开始执行:

    • 程序的调用栈上会为 main 创建第一个栈帧。

    • 栈状态: [ Stack Frame for main ]

  2. main 调用 functionA:

    • main 的当前状态被保存。

    • call functionA 指令会将返回地址(main 函数中的下一行代码)压入栈中。

    • 一个新的栈帧为 functionA栈顶 创建。

    • 栈状态: [ Stack Frame for functionA ] <-- 栈顶 [ Stack Frame for main ]

  3. functionA 调用 functionB:

    • functionA 的当前状态被保存。

    • call functionB 指令会将返回地址(functionA 中的下一行代码)压入栈中。

    • 一个新的栈帧为 functionB栈顶 再次创建。

    • 栈状态: [ Stack Frame for functionB ] <-- 栈顶 [ Stack Frame for functionA ] [ Stack Frame for main ]

现在,functionB 位于栈顶,是当前正在执行的函数。

②返回流程:后进先出
  1. functionB 执行完毕:

    • functionB 的栈帧被销毁(从栈顶弹出)。

    • ret 指令读取栈顶的返回地址,程序跳转回 functionA 继续执行。

    • 栈状态: [ Stack Frame for functionA ] <-- 栈顶 [ Stack Frame for main ]

  2. functionA 执行完毕:

    • functionA 的栈帧被销毁(从栈顶弹出)。

    • ret 指令读取栈顶的返回地址,程序跳转回 main 继续执行。

    • 栈状态: [ Stack Frame for main ] <-- 栈顶

  3. main 执行完毕:

    • main 的栈帧被销毁。

    • 栈变为空,程序结束。

③总结:为什么必须是 LIFO?

因为函数调用是一个嵌套的、有依赖关系的结构。

main 函数的执行依赖于 functionA 的完成,而 functionA 的执行又依赖于 functionB 的完成。计算机需要一种机制来记住这个“调用链”,以便在内层函数执行完毕后,能够准确地回到它被调用的地方继续执行。

调用栈 (Call Stack) 完美地解决了这个问题。通过在栈顶添加和移除栈帧,它确保了程序总是从最内层的调用返回到上一层,这个“后进先出”的机制是实现现代编程语言中函数顺序调用与返回的根本。

如果不是 LIFO,比如 functionAfunctionB 还没结束时就返回了,那么 functionB 执行完毕后该回到哪里去呢?程序的执行流就会彻底混乱。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐