C++:栈帧、命名空间、引用
一、前置知识
1.1、栈区(Stack)
1.1.1、内存分配与回收机制
- 分配方式:由编译器自动管理,通过调整栈指针(
ESP
/RSP
)实现。
- 函数调用时,栈指针下移(栈从高地址向低地址增长),分配栈帧空间。
- 函数返回时,栈指针上移,回收栈帧(不擦除数据,仅移动指针)。
- 存储内容:
- 局部变量(非
static
) - 函数参数(x86 下部分通过栈传递)
- 返回地址(Caller 的
EIP
/RIP
) - 上一栈帧的基址(
EBP
/RBP
)
1.1.2、栈帧销毁时的行为
- 栈指针回退:
RET
指令执行后,栈指针恢复到调用前的状态。 - 内存未清零:栈内存只是“逻辑释放”,数据仍残留(可能被后续函数覆盖)。
- 局部变量失效:
- 普通变量(如
int x
):直接失效,访问会导致未定义行为(UB)。 - 指针变量(如
int* p
):若指向栈内存(如p = &x
),则成为 悬空指针。
1.1.3、底层汇编视角
; 函数调用时
push ebp ; 保存调用者的栈基址
mov ebp, esp ; 设置当前栈帧基址
sub esp, 16 ; 分配16字节栈空间(局部变量); 函数返回时
mov esp, ebp ; 恢复栈指针(销毁栈帧)
pop ebp ; 恢复调用者的栈基址
ret ; 返回
1.1.4、典型问题
- 栈溢出(Stack Overflow):递归过深或局部变量过大(如
int a[1000000]
)导致栈耗尽。 - 返回栈地址的陷阱:
int* foo() {int x = 42;return &x; // 返回局部变量地址(危险!)
}
int* p = foo(); // p现在是悬垂指针
2.1、堆区(Heap)
2.1.1、内存分配与回收机制
- 分配方式:手动管理(
malloc
/free
、new
/delete
)。
malloc
调用brk
或mmap
向操作系统申请内存。- 堆内存由 内存管理器(如 glibc 的 ptmalloc) 维护,可能存在碎片。
- 存储内容:
- 动态分配的数据(如
int* p = malloc(sizeof(int))
)。 - 需要显式释放,否则泄漏。
2.1.2、栈帧销毁时的行为
- 堆内存不受影响:
- 栈帧销毁仅回收栈上的指针变量(如
int* p
),但堆内存仍存在。 - 若未调用
free
,则内存泄漏。 - 若已
free
但继续访问,则导致 野指针。
- 指针变量的生命周期:
void func() {int* p = malloc(sizeof(int)); // p在栈上,指向堆内存*p = 100;
} // p被销毁,但堆内存未释放(泄漏!)
2.1.3、底层实现(Linux glibc)
malloc
内部可能调用brk
(扩展堆)或mmap
(大内存映射)。free
不会立即归还内存给 OS,而是由内存池管理(提高复用效率)。
2.1.4、典型问题
- 内存泄漏
void leak() {while (1) malloc(1024); // 持续泄漏,最终OOM
}
- 双重释放
int* p = malloc(sizeof(int));
free(p);
free(p); // 崩溃或安全漏洞(如Use-After-Free)
- 野指针
int* p = malloc(sizeof(int));
free(p);
*p = 42; // 未定义行为(可能崩溃或数据损坏)
3.1、静态区(Static/全局存储区)
3.1.1、内存分配与回收机制
- 分配方式:
- 全局变量:程序启动时分配,程序结束时释放。
- 静态变量(
static
修饰):首次执行到定义处时初始化(C++11 后线程安全)。
- 存储位置:
.data
段(已初始化的全局/静态变量).bss
段(未初始化的全局/静态变量,默认零值)- 常量区(如字符串字面量
"hello"
)
3.1.2、栈帧销毁时的行为
- 完全不受影响:
- 静态变量的生命周期与程序相同,栈帧销毁后仍可访问。
- 局部静态变量(
static int x
)仅作用域受限,但内存持久。
void counter() {static int count = 0; // 只初始化一次count++;printf("%d\n", count);
}
// 多次调用counter()会输出递增的count
3.1.3、底层实现
- 编译时确定地址,运行时直接通过固定地址访问。
- 示例(反汇编):
mov eax, DWORD PTR [0x404000] ; 访问静态变量
3.1.4、典型问题
- 线程安全问题
static int shared = 0;
void thread_func() {shared++; // 多线程竞争(需加锁)
}
- 初始化顺序问题
extern int a; // 定义在其他文件
static int b = a + 1; // a的初始化顺序不确定
4.1、销毁与返回值传递的顺序
就以这段代码为例讲解一下两者之间的关系:
int* foo() {int x = 42;return &x; // 返回局部变量地址(危险!)
}
int* p = foo(); // p现在是悬空指针
结论:先传递,后销毁。【先将返回值存储在临时变量里面(有的话),再销毁函数栈帧】
4.1.1、执行顺序
-
计算返回值:
-
return &x;
会先计算x
的地址(即栈上的某个位置)。 - 这个地址会被 临时存储(通常在寄存器
EAX
/RAX
中,或者某个临时内存位置)。
-
销毁栈帧:
-
函数
foo
的栈帧被销毁(mov esp, ebp
+pop ebp
)。 -
局部变量
x
的内存被“逻辑释放”(栈指针回退,但数据可能残留)。
-
返回值传递:
-
计算好的地址(
&x
)从临时存储(如EAX
)传递给调用者p
。 -
此时
p
指向的x
的地址 已经失效(因为栈帧已销毁)。
4.1.2、为什么返回值还能“正确”访问?(未定义行为的陷阱)
即使 p
是悬空指针,以下代码可能偶尔“工作”:
int* p = foo();
printf("%d\n", *p); // 可能输出42(未定义行为!)
原因:
- 栈内存未被其他数据覆盖(残留值仍在、在下文介绍引用的时候,也会涉及到这一点)。
- 但这不合法,任何修改(如调用其他函数)可能导致崩溃或数据错误。
5.5、总结
特性 | 栈区 | 堆区 | 静态区 |
分配方式 | 自动(编译器) | 手动(malloc、free) | 自动(程序启动,首次使用) |
释放时机 | 函数返回时 | 显示调用free | 程序结束时 |
访问速度 | 极快(寄存器,栈指针访问) | 较慢(间接寻址) | 快(固定寻址) |
生命周期 | 函数作用域内 | 手动控制 | 程序整个生命周期 |
线程安全 | 是(每个线程独立) | 需同步 | 需同步(全局变量) |
典型问题 | 栈溢出,悬空指针 | 内存泄漏,野指针 | 初始化顺序,线程竞争 |
二、C++关键字(C++98)
asm | do | if | return | try | continue |
auto | double | inline | short | typedef | for |
bool | dynamic_cast | int | signed | typeid | public |
break | else | long | sizeof | typename | throw |
case | enum | mutable | static | union | wchar_t |
catch | explicit | namespace | static_cast | unsigned | default |
char | export | new | struct | using | friend |
class | extern | operator | switch | virtual | register |
const | false | private | template | void | true |
const_cast | float | protected | this | volatile | while |
delete | goto | reinterpret_cat |
三、命名空间
#include <stdio.h>
#include <stdlib.h>
int rand = 10;
// C语言没办法解决类似这样的命名冲突问题,所以C++提出了namespace来解决
int main()
{printf("%d\n", rand);return 0;
}
// 编译后后报错:error C2365: “rand”: 重定义;以前的定义是“函数”
3.1、命名空间定义
// ljt是命名空间的名字,一般开发中是用项目名字做命名空间名。
// 1. 正常的命名空间定义
namespace ljt
{// 命名空间中可以定义变量/函数/类型int rand = 10;int Add(int left, int right){return left + right;}struct Node{struct Node* next;int val;};
}
//2. 命名空间可以嵌套
// test.cpp
namespace N1
{int a;int b;int Add(int left, int right){return left + right;}namespace N2{int c;int d;int Sub(int left, int right){return left - right;}}
}
//3. 同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
// ps:一个工程中的test.h和上面test.cpp中两个N1会被合并成一个
// test.h
namespace N1
{int Mul(int left, int right){return left * right;}
}
3.2、命名空间的使用
namespace ljt
{// 命名空间中可以定义变量/函数/类型int a = 0;int b = 1;int Add(int left, int right){return left + right;}struct Node{struct Node* next;int val;};
}
int main()
{// 编译报错:error C2065: “a”: 未声明的标识符printf("%d\n", a);return 0;
}
- 加命名空间名称及作用域限定符
int main()
{printf("%d\n", N::a);return 0;
}
- 使用using将命名空间中某个成员引入(工程中常用)
using N::b;
int main()
{printf("%d\n", N::a);printf("%d\n", b);return 0;
}
- 使用using namespace 命名空间名称 引入
using namespce N;
int main()
{printf("%d\n", N::a);printf("%d\n", b);Add(10, 20);return 0;
}
四、引用
4.1、引用的概念
类型& 引用变量名(对象名) = 引用实体;
void TestRef()
{int a = 10;int& ra = a;//<====定义引用类型printf("%p\n", &a);printf("%p\n", &ra);
}
4.2、引用特性
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其他实体
#include <iostream>
//using std::cout;using namespace std;
int main()
{int a = 5;int& b = a;cout << &a << " " << & b << endl;//引用即取别名,内存地址是一样的//引用一旦引用一个实体,再不能引用其他实体int x = 10;a = x;//并没有改变引用,只是赋值。cout << a << " " << b << endl;return 0;
}

4.3、常引用
void TestConstRef()
{const int a = 10;//int& ra = a; // 该语句编译时会出错,a为常量const int& ra = a;// int& b = 10; // 该语句编译时会出错,b为常量const int& b = 10;double d = 12.34;//int& rd = d; // 该语句编译时会出错,类型不同const int& rd = d;
}
注:对于(常)引用,你只要明白引用拥有两个权限,一个是写(改写),另一个是读。如果我写的是注释中的那种形式,那么对于一个常数有了写与读的权限,但是常数它只能读,不能改写,则权限被你放大了,需要加上const修饰,限制写的权限。
int main()
{// 不可以// 引用过程中,权限不能放大const int a = 0;//int& b = a;// 可以,c拷贝给d,没有放大权限,因为d的改变不影响cconst int c = 0;int d = c;// 不可以// 引用过程中,权限可以平移或者缩小int x = 0;int& y = x;const int& z = x;++x;++y;cout << z << endl;//++z;//常值引用const int& m = 10;double dd = 1.11;int ii = dd;const int& rii = dd;return 0;
}int func1()
{static int x = 0;return x;
}int& func2()
{static int x = 0;return x;
}int main()
{//int& ret1 = func1(); // 权限放大//const int& ret1 = func1(); // 权限平移// int ret1 = func1(); // 拷贝int& ret2 = func2(); // 权限平移const int& rret2 = func2(); // 权限缩小return 0;
}
像这些代码,本质上都是一样的,无论是引用作为返回值,还是什么情况,都存在权限的放大,平移,缩小的问题,看你的函数想实现的是什么功能,写 or 读 or 写 + 读,再来判断是否要加const修饰。
4.4、使用场景
- 做参数
引用做参数(减少拷贝提高效率)(大对象/深拷贝类对象)
#include <iostream>
using namespace std;void swap1(int x, int y)
{int temp = x;x = y;y = temp;
}void swap2(int* x, int* y)
{int temp = *x;*x = *y;*y = temp;
}void swap3(int& x, int& y)
{int temp = x;x = y;y = temp;
}
int main()
{int a = 1, b = 2;//传值cout << "a = " << a << " " << "b = " << b << endl;swap1(a, b);cout << "a = " << a << " " << "b = " << b << endl;//指针传参(C语言)cout << "a = " << a << " " << "b = " << b << endl;swap2(&a, &b);cout << "a = " << a << " " << "b = " << b << endl;//引用做参数cout << "a = " << a << " " << "b = " << b << endl;swap3(a, b);cout << "a = " << a << " " << "b = " << b << endl;return 0;
}
三种方式可以比较一下,传值,C语言传地址,C++引用。
- 做返回值
//引用做返回值 (减少拷贝提高效率)(大对象/深拷贝类对象--什么是深拷贝以后会讲)
//引用做返回值 修改返回值+获取返回值(权限)
//做返回值
#include <iostream>
using namespace std;
//传值返回
int count()
{static int n = 0;n++;return n;
}int main()
{//并不是直接把值返回给ret,栈帧销毁的时候,先把数据放在临时变量里面,再赋值给retint ret = count();return 0;
}
解释在注释中,下面讲一个与栈帧联系比较紧密的代码。
#include <iostream>
#include <ctime>
#include <cassert>
using namespace std;int& Count(int x)
{int n = x;n++;return n;
}int main()
{int& ret = Count(10);cout << ret << endl;rand();Count(20);cout << ret << endl;return 0;
}
这段代码乍一看好像没有问题,确实,如果栈帧没有销毁干净,有数据残留,是可以正常打印11,21的。但是,这段代码大错特错,问题一堆,BUG也非常难以察觉。
首先你要明白返回值是int&类型,返回的时候,没有临时变量的产生(int作为返回值时候有),这段Count函数代码返回的是n的引用,但是n是int类型,内存开辟在栈区,函数结束,生命周期结束,后续会造成非法访问(UB:未定义行为),而你的接收值也是int&类型,即是引用的引用,本质上还是指那个销毁的变量,你现在要去打印ret,一个已经被销毁的变量,如果栈帧销毁干净,没有数据残留,就是随机值,但也有可能成功打印,栈帧未销毁干净。
其次,如果你中间没有调用其他函数(如printf,rand等等),你再去调用Count函数,它大概率还会开辟在原来的那块栈区,还是有可能打印出21,但也可能是随机值,道理和上面那段话一样。但如果你中间加了其他函数,就会存在栈帧的覆盖,将原来Count的栈帧占据,使再次调用Count的时候,是一块新的栈空间,会导致随机值的产生,但也有可能打印出21,就是并没有占据核心的栈区部分。
总之,这种写法非常危险,漏洞百出,在大型项目里还非常难察觉,尽量减少这种写法。
也可以看一下下面这张图来理解:
4.5、传值、传引用的效率
typedef struct A
{int a[2000];
}A;//效率问题
//拷贝//test1(a)(传值调用):
//每次调用都会在栈上复制整个 A 结构体(800KB)。
//10000 次循环 = 8GB 数据拷贝(非常低效)。//test2(a)(传引用):
//只传递指针(8字节),没有数据拷贝。
//10000 次循环 = 80KB 数据传递(高效)
void test1(A a)
{}//不需要拷贝
void test2(A& a)
{}void TestRefAndValue()
{A a;// 以值作为函数参数size_t begin1 = clock();for (size_t i = 0; i < 10000; ++i)test1(a);size_t end1 = clock();// 以引用作为函数参数size_t begin2 = clock();for (size_t i = 0; i < 10000; ++i)test2(a);size_t end2 = clock();// 分别计算两个函数运行结束后的时间cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}int main()
{TestRefAndValue();return 0;
}
通过上述代码的比较,发现传值和指针在作为传参以及返回值类型上效率相差很大。
4.6、引用和指针的区别
int main()
{int a = 10;int& ra = a;cout << "&a = " << &a << endl;cout << "&ra = " << &ra << endl;return 0;
}
int main()
{int a = 10;int& ra = a;ra = 20;int* pa = &a; *pa = 20;return 0;
}

引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
- 没有NULL引用,但有NULL指针
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全