一、前言
学习 C++ 时,我们常常听到一句话:“引用是变量的别名。”
这句话在入门阶段很直观,但如果只停留在这一层理解,就难以真正把握它在底层的运行方式。
引用在编译器的处理下,可能表现得和指针类似,有时甚至会彻底消失。它的存在更多是语义上的,而不是简单的“别名替换”。
本文将通过汇编层面的观察,带你理解引用在不同场景中的真实实现,以及它与指针的区别,从而帮助你建立更深层次的认知。
二、引用的基本特性回顾
在深入汇编之前,我们先快速回顾一下 C++ 引用的几个语法特性。
1. 必须初始化
引用在定义时必须绑定到一个对象
int a = 10;
int& r = a; // ✅ 正确
int& r2; // ❌ 错误,必须初始化
2. 不可重新绑定
一旦绑定,引用就永远指向这个对象:
int a = 10, b = 20;
int& r = a;
r = b; // 这里是把 b 的值赋给 a,不是让 r 改指向
3. 不会为空
引用不能指向 nullptr,语义上总是合法的:
int* p = nullptr; // ✅ 指针可以为空
int& r = *p; // ❌ 引用不能绑定到空
4. 使用时像普通变量
引用的操作不需要显式解引用:
int a = 5;
int& r = a;
r = 10; // 实际上是给 a 赋值
这些特性让引用在代码层面看起来更直观、更安全。但从底层实现来看,这些规则并不是 CPU 在保护你,而是编译器强制施加的语义约束。
接下来,我们就从汇编层面来看看:引用究竟在内存和寄存器里是怎么被处理的。
三、局部引用的汇编实现
为了直观对比,我们用两个逻辑完全一致的例子对比引用和指针的汇编实现:
程序示例
引用版本:
#include <iostream>
int main()
{
volatile int a = 10;
auto& b = a; // b是a的引用
b = 20;
return 0;
}
指针版本:
#include <iostream>
int main()
{
volatile int a = 10;
auto* b = &a; // b是指向a的指针
*b = 20;
return 0;
}
无优化(-O0)情况
对引用版本和指针版本的可执行文件(GCC 11.5.0,-O2)进行反汇编(objdump -S)对比,发现内容完全一致,如下图所示:
可以看到:
- 引用 b 实际上占用了一个指针大小的栈空间,在汇编层面上与指针没有区别;
- b = 20 的操作和指针 *b = 20 完全一致:先取指针,再写值。
也就是说,在无优化模式下,引用和指针没有任何区别,本质都是指针变量。
优化(-O2)情况
我们再来看 -O2 优化后的汇编:
0000000000401060 <main>:
#include <iostream>
int main()
{
volatile int a = 10;
401060: c7 44 24 fc 0a 00 00 movl $0xa,-0x4(%rsp)
401067: 00
auto& b = a;
b = 20;
return 0;
}
401068: 31 c0 xor %eax,%eax
b = 20;
40106a: c7 44 24 fc 14 00 00 movl $0x14,-0x4(%rsp)
401071: 00
}
401072: c3 retq
在这里有两个关键点:
-
没有再分配额外空间存放 b
- 在 -O0 下,我们看到 b 占用一个指针大小的局部栈空间,保存 a 的地址;
- 在 -O2 下,编译器发现 b 永远只是 a 的别名,于是完全消除了这个额外变量。
-
b=20 直接转化成对 a 的写操作
- 指令 movl $0x14, -0x4(%rsp) 就是直接把 20 写到 a 的位置;
- 这说明在本例的优化层面,引用根本不再单独存在,而是退化成了对原变量的直接访问。
小结
通过以上实践和分析,可得出如下结论:
-
在 -O0 下,局部引用通常被实现为一个隐藏指针,编译器会为其分配栈空间,通过指针间接访问原变量;
-
在 -O2 等高优化等级下,如果引用的生命周期和访问方式足够简单,编译器能够证明它只是原变量的别名,于是引用可能被直接消除,读写操作直接作用于原变量,不再占用独立空间。
📌 总结来看,引用在汇编层面并不一定是独立实体:在低优化等级下,它表现为隐藏指针;在高优化等级下,它往往被优化为对原变量的直接操作,从而提升执行效率。
四、函数参数中的引用实现
程序示例
#include <iostream>
__attribute__((noinline))
void increment(int& x) {
x += 1;
}
int main() {
int a = 10;
increment(a);
return 0;
}
反汇编
首先,使用-O2对以上程序进行编译(GCC 11.5.0,-O2),再进行反汇编(objdump -S),结果如下:
从反汇编的结果来看,编译器将变量a的地址放到了rdi寄存器(55行),作为increment函数的第一个参数,在60行调用increment函数。
小结
-
引用作为函数参数,本质是传递地址
- 编译器将引用 x 当作指针处理,在汇编里通过寄存器/栈传递对象地址;
- lea 0xc(%rsp),%rdi 表示把传入的引用地址载入rdi寄存器,作为increment函数的第一个参数。
-
操作直接作用于原变量
- addl $0x1,(%rdi) 就是通过地址修改原变量 a 的值;
- 体现了引用的“别名”语义:函数内部对 x 的修改会直接反映到 a 上。
五、类成员中的引用
在函数局部变量和参数中,引用往往只是一个“别名”,编译器有时甚至会在优化阶段直接消除掉。但在类成员中,情况就不同了:
- 引用作为类成员时,必须在对象的内存布局中占有空间(本质上是存储一个指针),否则编译器无法保证其生命周期和语义;
- 引用成员必须在构造函数初始化列表中初始化,且一旦绑定,不能再改变。
5.1 示例代码
#include <iostream>
struct Foo {
int& ref; // 引用成员
__attribute__((noinline))
Foo(int& r) : ref(r) {}
__attribute__((noinline))
void set(int v) { ref = v; }
};
int main() {
int a = 10;
Foo f(a);
f.set(20);
std::cout << a << std::endl;
std::cout << "sizeof(Foo) = " << sizeof(Foo) << std::endl;
return 0;
}
5.2 内存布局
对于 Foo 而言,成员只有一个 int& ref。在底层,它被实现为一个 指针大小的成员变量。
可见以下执行结果,在 64 位平台上,sizeof(Foo) == 8(等同于一个指针大小),这与“引用是语法糖”保持一致:在类里,它必须落地为一个真实的地址存储。
[root@instance-bguv65e0 reference]# g++ test5.cpp -o test5 -O2 -g
[root@instance-bguv65e0 reference]# ./test5
20
sizeof(Foo) = 8
5.3 构造函数的汇编
编译(GCC 11.5.0,-O2)后,Foo::Foo(int&) 的关键部分:
82 int main() {
83 4010d0: 48 83 ec 18 sub $0x18,%rsp
84 int a = 10;
85 Foo f(a);
86 4010d4: 48 8d 74 24 04 lea 0x4(%rsp),%rsi
87 4010d9: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
88 int a = 10;
89 4010de: c7 44 24 04 0a 00 00 movl $0xa,0x4(%rsp)
90 4010e5: 00
91 Foo f(a);
92 4010e6: e8 15 02 00 00 callq 401300 <_ZN3FooC1ERi>
......
316 0000000000401300 <_ZN3FooC1ERi>:
317 Foo(int& r) : ref(r) {}
318 401300: 48 89 37 mov %rsi,(%rdi)
319 401303: c3 retq
解释:
- rdi:this 指针,指向 Foo 对象的起始地址,在上述汇编代码的87行赋值;
- rsi:构造函数参数,即传入的 int& r 的地址,在上述汇编代码的86行赋值;
- mov %rsi,(%rdi):把 rsi 中的地址存到对象的首个成员位置,在上述汇编代码的318行。
也就是说,Foo 对象中真正存的就是指针,指向main函数中的临时变量a。
5.4 成员函数的汇编
再看 set(int v):
82 int main() {
83 4010d0: 48 83 ec 18 sub $0x18,%rsp
84 int a = 10;
85 Foo f(a);
86 4010d4: 48 8d 74 24 04 lea 0x4(%rsp),%rsi
87 4010d9: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
88 int a = 10;
89 4010de: c7 44 24 04 0a 00 00 movl $0xa,0x4(%rsp)
90 4010e5: 00
91 Foo f(a);
92 4010e6: e8 15 02 00 00 callq 401300 <_ZN3FooC1ERi>
93 f.set(20);
94 4010eb: 48 8b 7c 24 08 mov 0x8(%rsp),%rdi
95 4010f0: be 14 00 00 00 mov $0x14,%esi
96 4010f5: e8 66 01 00 00 callq 401260 <_ZN3Foo3setEi.isra.0>
......
227 0000000000401260 <_ZN3Foo3setEi.isra.0>:
228 void set(int v) { ref = v; }
229 401260: 89 37 mov %esi,(%rdi)
230 401262: c3 retq
解释:
- rdi:this 指针,在上述汇编代码的87行赋值;
- (%rdi):对象中的 ref 成员(其实就是指针的值,变量a的地址);
- esi:set 的参数 v,在上述汇编代码的95行赋值;
- mov %esi,(%rdi):写入引用目标的值,在上述汇编代码的229行。
这里可以清晰地看到:引用成员 ref 被翻译成a的地址。
5.5 小结
- 类中的引用 必须占有空间,存储为一个指针;
- 构造时初始化 = 把目标地址写入该指针;
- 使用时操作 = 先取出这个指针,再访问它指向的内存;
- 与局部引用(可能在优化中消失)不同,类成员引用会“实化”为一个指针字段。
六、引用与指针的对比
虽然在汇编层面,引用和指针有很多相似之处,但在 语义 和 使用方式 上,二者仍然存在明显差别。下面我们从多个角度进行对比。
6.1 语法与语义
| 特性 | 引用(Reference) | 指针(Pointer) |
|---|---|---|
| 可否为空 | 不能(必须绑定对象) | 可以(nullptr 合法) |
| 可否重新绑定 | 不能(绑定后固定指向同一对象) | 可以(p = &b; 合法) |
| 使用方式 | 直接当作变量使用 | 需要 * 解引用 |
| 初始化要求 | 必须初始化 | 可以延后赋值 |
6.2 底层实现
| 场景 | 引用的底层表现 | 指针的底层表现 |
|---|---|---|
| 局部变量引用 | -O0 下通常实现为隐藏指针;-O2 下可能被优化掉 | 占用寄存器/内存,-O2 下可能被优化掉 |
| 函数参数引用 | 以地址形式传递(ABI 上就是指针) | 以地址形式传递 |
| 类成员引用 | 以指针成员存储 | 指针成员本身 |
| 内存占用 | 视上下文而定,局部/参数可被优化消除,但成员引用必须落地为地址 | 占用一个指针大小的空间 |
七、总结
引用是 C++ 的独特机制,看似简单,却关联底层实现、优化策略和设计取舍。多数人把它当“更安全的指针”,但理解其背后原理,才能在复杂工程中做出正确选择。
本文从语法到编译器优化,从类成员到与指针对比,展示了引用在不同场景下的表现。真正的价值不在于记住“引用等于隐藏指针”,而在于思考:
- 编译器为何能消除引用?
- 类成员引用为何必须初始化?
- 语义上何时用引用,何时用指针?
📌 建议读者
- 不止停留在语法层面,要理解编译器处理方式。
- 在使用引用或指针时,先考虑语义意图。
- 多做实验、观察汇编,培养底层直觉。
理解这些原理,不是为了考试或面试,而是为了写出既高效又可维护的工程代码。
📬 欢迎关注公众号“Hankin-Liu的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货。