程序执行与内存

用递归和动态数据结构串起算法、函数调用、CPU、进程地址空间与虚拟存储。

一句话模型

递归在算法层把一个问题不断拆成规模更小的同类问题。程序运行时,每次递归调用都会创建一个栈帧,用来保存这一层调用的参数、局部变量和返回位置。若递归过程中通过 malloc 创建链表或树的结点,这些对象会保存在堆中;程序使用指针访问结点时,虚拟地址经过页表翻译,最终访问 Cache 或主存中的数据。

三层递进

  1. 简单:只看一次递归调用如何创建栈帧,并在递归返回时销毁栈帧。
  2. 中等:加入动态创建的树结点,区分“栈上的执行现场”和“堆上的长期对象”。
  3. 复杂:从 C 源码出发,经过编译器、指令、CPU、虚拟地址翻译、Cache 和主存,解释一次函数调用和指针访问如何真正执行。

简单图谱:递归与函数调用栈

递归并不是 CPU 的特殊功能。递归函数会被编译为普通的函数调用,每次调用建立一个新的栈帧;到达终止条件后,再按照后进先出的顺序逐层返回。

RecursionCallStackMapcall递归函数 f(n)base满足终止条件?call->baseframe1栈帧 f(n)参数 / 局部变量 / 返回地址base->frame1否:CALLreturn1恢复 f(n) 并返回调用者base->return1是:直接返回frame2栈帧 f(n-1)frame1->frame2递归调用frame3栈帧 f(n-2)frame2->frame3递归调用return3返回 f(n-2)销毁最上层栈帧frame3->return3到达终止条件return2恢复 f(n-1)return3->return2RETreturn2->return1RET
# 递归执行

## 算法层

- 定义子问题
- 设置递归终止条件
- 递归调用
- 合并子问题结果

## 运行时层

- CALL 保存返回位置并转移控制流
- 建立 callee 栈帧
- 保存参数、局部变量和寄存器
- RET 恢复 caller 并继续执行

## 复杂度

- 同时存在的栈帧数量等于递归深度
- 每层栈帧空间乘以最大深度得到调用栈空间
- 递归过深可能发生栈溢出

基础知识页面:算法复杂度函数定义和调用函数调用时内存结构

中等图谱:栈帧与堆对象同时存在

主干问题是:递归创建一棵树时,哪些数据随着函数返回而消失,哪些数据在返回后仍然存在?

先把递归调用和 malloc 放回完整的进程虚拟地址空间:递归调用使用栈区保存执行现场,malloc 从堆区或内存映射区域取得动态对象空间。

RecursionMallocProcessMemoryMaprecursion递归调用build(depth - 1)callCALL / RETSP / BP 调整recursion->callmemory进程虚拟地址空间内核空间(用户态不可直接访问)栈区:栈帧、参数、局部变量、返回地址通常向低地址增长内存映射区域:共享库、文件映射、大块动态分配堆区:动态分配对象通常向高地址增长数据区 / BSS:全局变量、静态变量代码区:编译后的机器指令call->memory:stack建立 / 销毁栈帧mallocmalloc(sizeof(TreeNode))allocator用户态堆分配器查找 / 切分空闲块malloc->allocatorallocator->memory:heap复用堆中空闲块allocator->memory:mmap必要时扩展映射pointer栈帧中的 node 指针保存堆对象的虚拟地址memory:stack->pointer局部指针变量object堆中的 TreeNode 对象left | data | rightmemory:heap->object对象实际存放位置stack_end函数 RET当前栈帧自动失效memory:stack->stack_end生命周期随调用结束pointer->object指向heap_endfree(node)对象空间交还分配器object->heap_end需要显式释放

图中最重要的关系是:栈帧中的 node 是一个指针变量,而它指向的 TreeNode 才是堆对象。两者位于不同的内存区域,也具有不同的生命周期。

再放大递归构造二叉树的过程,观察多个栈帧如何创建堆中的多个结点:

RecursiveTreeMemoryMapcluster_stack调用栈:保存执行现场,返回时自动销毁cluster_heap堆:保存动态对象,直到显式 freef2build(2) 栈帧depth / node 指针 / 返回地址f1lbuild(1) 左子调用栈帧f2->f1l递归f1rbuild(1) 右子调用栈帧f2->f1r递归mallocmalloc(sizeof(TreeNode))f2->malloc申请结点left左结点f1l->left创建right右结点f1r->right创建root根结点left | data | rightroot->leftleft 指针root->rightright 指针result返回根指针栈帧消失,树仍存在root->resultmalloc->root返回堆地址

可以用下面的简化代码理解:

TreeNode *build(int depth) {
    if (depth == 0) return NULL;

    TreeNode *node = malloc(sizeof(TreeNode));
    node->left = build(depth - 1);
    node->right = build(depth - 1);
    return node;
}
  • depthnode 这个指针变量和返回地址属于当前调用的栈帧。
  • malloc 创建的 TreeNode 对象位于堆中,栈帧只保存指向它的指针。
  • 递归返回会销毁当前栈帧,但不会自动释放堆中的树结点。
  • 最外层调用者仍可通过根指针访问整棵树;不再使用时需要遍历并 free 每个结点。

复杂图谱:从源码到物理内存

复杂图把算法、指令、进程地址空间和存储系统叠加起来。一条 C 语句并不会直接操作“树”,CPU 最终执行的是调用、算术、加载和存储指令。

ProgramExecutionMemoryFullMapcluster_source算法与语言cluster_cpu指令与 CPUcluster_process进程虚拟地址空间cluster_memory地址翻译与存储系统source递归 C 源码build / traversalcompiler编译器生成 CALL / RET / LOAD / STOREsource->compilercontrolPC 改变控制流SP/BP 管理栈帧compiler->controlaccess寄存器 + ALU计算对象虚拟地址control->accessstack栈区参数 / 局部变量 / 返回地址control->stack建立 / 销毁栈帧heap堆 / mmap 区域动态树结点access->heap指针读写allocator用户态分配器切分与合并空闲块access->allocatormalloc / freevmTLB + 页表VA → PAstack->vm栈地址heap->vm堆地址allocator->heapcacheCache缓存主存块vm->cachedram主存物理页框cache->dramdram->vm缺页后分配页框

阅读时沿三条路径追踪:

  • 控制流路径:递归源码 → 编译器生成 CALL/RET → PC 改变 → SP/BP 建立或销毁栈帧。
  • 数据生命周期路径malloc → 用户态堆分配器 → 堆或内存映射区 → 树结点 → free 回收。
  • 实际访存路径:虚拟地址 → TLB/页表 → 物理地址 → Cache → 主存;页面尚未驻留时触发缺页。

子模块图谱:动态数据生命周期

malloc 不是“每调用一次就向操作系统申请一个物理页”。它通常先从用户态分配器已经管理的堆块中分配;空间不足时才通过 brkmmap 等机制扩展虚拟地址空间,页面也可能在第一次访问时才真正获得物理页框。

DynamicDataLifecycleMaprequestmalloc(size)申请动态对象freeblock分配器有合适空闲块?request->freeblocksplit选择并切分空闲块返回虚拟地址freeblock->splitexpand通过 brk / mmap 等扩展虚拟地址空间freeblock->expandtouch程序首次读写对象split->touchexpand->splitpresent页面已驻留?touch->presentfault缺页处理分配物理页框并更新页表present->faultuse对象参与链表 / 树 / 图由指针保持可达present->usefault->usereleasefree(pointer)标记为空闲块use->release生命周期结束merge与相邻空闲块合并可能归还部分页面release->mergemerge->freeblock供后续申请复用

动态对象的完整生命周期是:提出大小需求 → 分配器查找并切分空闲块 → 必要时向操作系统扩展虚拟空间 → 首次访问触发按需分配 → 指针连接成链表/树/图 → 删除结构时逐个 free → 合并空闲块 → 条件允许时归还部分页面。

子模块图谱:三个容易混淆的“栈”

ThreeStackConceptsMapadt栈 ADT后进先出的逻辑结构impl顺序栈 / 链栈由程序显式 push / popadt->impl一种实现callstack函数调用栈进程虚拟地址空间的一部分adt->callstack都体现 LIFO但不是同一抽象recursion递归调用函数调用自身或子问题recursion->callstack通常依赖frame栈帧参数 / 局部变量 / 返回地址callstack->frame由多个栈帧组成instructionsCALL / RETSP / BP 寄存器instructions->callstack维护
  • 栈 ADT 是后进先出的逻辑结构,可以用数组或链表实现。
  • 函数调用栈 是进程虚拟地址空间中的运行时区域,由编译器约定和 CPU 指令共同使用。
  • 递归调用 是一种程序控制结构,通常借助函数调用栈保存每层执行现场。

关键问题

递归为什么会消耗额外空间?

每个尚未返回的调用都要保留参数、局部变量、返回地址和部分寄存器。最大递归深度决定同时存在的栈帧数量,而不是递归调用的总次数。

栈上的指针和堆上的对象是什么关系?

指针变量可以位于栈帧,但它保存的虚拟地址可以指向堆对象。栈帧销毁只会让指针变量失效,不会自动释放它指向的堆内存。

为什么 malloc 成功不代表物理内存已经准备好?

分配器可能只返回一段可用虚拟地址。进程第一次读写对应页面时,页表项尚未建立或页面尚未驻留,操作系统才通过缺页处理分配物理页框。

递归、栈溢出和内存泄漏有什么区别?

递归过深可能耗尽有限的调用栈;内存泄漏是堆对象失去可达指针却没有被释放。前者与调用深度有关,后者与动态对象生命周期管理有关。

旁路清单

  • 数据结构:树/图递归遍历、链式存储、递归空间复杂度
  • 指令系统:CALLRETPUSHPOP、栈寻址和调用约定
  • CPU:PC、SP、BP、通用寄存器、加载/存储指令
  • 进程内存:代码区、数据区、堆、栈和内存映射区域
  • 动态分配:空闲链表、块切分、相邻合并、碎片和伙伴算法
  • 虚拟存储:页表、TLB、缺页、页框分配和页面置换
  • 存储层次:Cache 命中/未命中、主存访问和局部性

对应页面:二叉树链表函数调用进程内存空间内存管理虚拟存储器