从汇编层面看 C++ 引用:语法糖背后的指针本质

133 阅读8分钟

一、前言

学习 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

在这里有两个关键点:

  1. 没有再分配额外空间存放 b

    • 在 -O0 下,我们看到 b 占用一个指针大小的局部栈空间,保存 a 的地址;
    • 在 -O2 下,编译器发现 b 永远只是 a 的别名,于是完全消除了这个额外变量。
  2. 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),结果如下: objdump increment

从反汇编的结果来看,编译器将变量a的地址放到了rdi寄存器(55行),作为increment函数的第一个参数,在60行调用increment函数。

小结

  1. 引用作为函数参数,本质是传递地址

    • 编译器将引用 x 当作指针处理,在汇编里通过寄存器/栈传递对象地址;
    • lea 0xc(%rsp),%rdi 表示把传入的引用地址载入rdi寄存器,作为increment函数的第一个参数。
  2. 操作直接作用于原变量

    • addl $0x1,(%rdi) 就是通过地址修改原变量 a 的值;
    • 体现了引用的“别名”语义:函数内部对 x 的修改会直接反映到 a 上。

五、类成员中的引用

在函数局部变量和参数中,引用往往只是一个“别名”,编译器有时甚至会在优化阶段直接消除掉。但在类成员中,情况就不同了:

  1. 引用作为类成员时,必须在对象的内存布局中占有空间(本质上是存储一个指针),否则编译器无法保证其生命周期和语义;
  2. 引用成员必须在构造函数初始化列表中初始化,且一旦绑定,不能再改变。

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的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货。