Understanding C++ References from an Assembly Perspective: The Pointer Nature Behind Syntax Sugar

1. Introduction

When learning C++, we often hear the phrase: “A reference is an alias for a variable.” This statement is quite intuitive at the beginner level, but if we stop at this understanding, it becomes difficult to grasp how it operates at a lower level. Under the compiler’s handling, references may behave similarly to pointers and can sometimes even completely disappear. Their existence is more about semantics than just a simple “alias replacement.” This article will help you understand the true implementation of references in different scenarios and their differences from pointers through an assembly-level observation, thereby helping you build a deeper understanding.

2. Review of Basic Characteristics of References

Before diving into assembly, let’s quickly review several syntactical features of C++ references.

1. Must be Initialized A reference must be bound to an object at the time of definition.

int a = 10;int& r = a;   // ✅ Correctint& r2;      // ❌ Error, must be initialized

2. Cannot be Rebound Once bound, a reference will always point to that object:

int a = 10, b = 20;int& r = a;r = b;        // Here, the value of b is assigned to a, not changing r's reference

3. Cannot be Null

A reference cannot point to nullptr; it is always semantically valid:

int* p = nullptr; // ✅ Pointer can be nullint& r = *p;      // ❌ Reference cannot bind to null

4. Used Like a Normal Variable Operations on references do not require explicit dereferencing:

int a = 5;int& r = a;r = 10;       // Actually assigns 10 to a

These features make references appear more intuitive and safer at the code level. However, from a low-level implementation perspective, these rules are not protections enforced by the CPU but rather semantic constraints imposed by the compiler. Next, let’s examine how references are processed in memory and registers from an assembly perspective.

3. Assembly Implementation of Local References

To intuitively compare, we will use two logically identical examples to contrast the assembly implementations of references and pointers:

Program Example

Reference Version:

#include 
int main(){    volatile int a = 10;    auto& b = a;               // b is a reference to a    b = 20;    return 0;}

Pointer Version:

#include 
int main(){    volatile int a = 10;    auto* b = &a             // b is a pointer to a    *b = 20;    return 0;}

Unoptimized (-O0) Case

Disassembling the executable files of the reference and pointer versions (GCC 11.5.0) reveals that the contents are identical, as shown in the following image:

Understanding C++ References from an Assembly Perspective: The Pointer Nature Behind Syntax Sugar

As we can see:

  • Reference b actually occupies stack space equivalent to a pointer size, and at the assembly level, there is no difference from a pointer;

  • The operation b = 20 is completely consistent with pointer *b = 20: first dereference the pointer, then write the value.

In other words, in unoptimized mode, references and pointers are indistinguishable; they are essentially pointer variables.

Optimized (-O2) Case

Now let’s look at the assembly after -O2 optimization:

0000000000401060 <main>:#include 
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</main>

There are two key points here:

1. No additional space is allocated for b

(1) In -O0, we see that b occupies local stack space equivalent to a pointer size, storing the address of a;

(2) In -O2, the compiler realizes that b is merely an alias for a, thus completely eliminating this extra variable.

2. b=20 directly translates to a write operation

(1) The instruction movl $0x14, -0x4(%rsp) directly writes 20 to the location of a;

(2) This indicates that at this optimization level, references no longer exist independently but degenerate into direct access to the original variable.

Summary

From the above practices and analyses, we can conclude the following:

  • In -O0, local references are typically implemented as hidden pointers, with the compiler allocating stack space for them and accessing the original variable indirectly through pointers;

  • In -O2 and other high optimization levels, if the lifecycle and access pattern of the reference are simple enough, the compiler can prove that it is merely an alias for the original variable, and thus the reference may be directly eliminated, with read and write operations directly affecting the original variable without occupying independent space.

📌 In summary, references at the assembly level are not necessarily independent entities: at low optimization levels, they manifest as hidden pointers; at high optimization levels, they are often optimized into direct operations on the original variable, thereby enhancing execution efficiency.

4. Implementation of References in Function Parameters

Program Example

#include 
__attribute__((noinline))void increment(int& x) {    x += 1;}
int main() {    int a = 10;    increment(a);    return 0;}

Disassembly

First, compile the above program with -O2 (GCC 11.5.0) and then disassemble (objdump -S), the result is as follows:Understanding C++ References from an Assembly Perspective: The Pointer Nature Behind Syntax SugarUnderstanding C++ References from an Assembly Perspective: The Pointer Nature Behind Syntax Sugar

From the disassembly results, we can see that the compiler places the address of variable a into the rdi register (line 55) as the first parameter of the increment function, which is called on line 60.

Summary

1. References as function parameters are essentially passing addresses

(1) The compiler treats reference x as a pointer, passing the object’s address through registers/stack in assembly;

(2) lea 0xc(%rsp),%rdi indicates that the address of the passed reference is loaded into the rdi register as the first parameter of the increment function.

2. Operations directly affect the original variable

(1) addl $0x1,(%rdi) modifies the value of the original variable a through its address;

(2) This reflects the “alias” semantics of references: modifications to x within the function will directly reflect on a.

5. References in Class Members

In function local variables and parameters, references are often just an “alias”; the compiler may even eliminate them directly during optimization. However, in class members, the situation is different:

1. When a reference is a class member, it must occupy space in the object’s memory layout (essentially storing a pointer), otherwise the compiler cannot guarantee its lifecycle and semantics;

2. Reference members must be initialized in the constructor’s initialization list and cannot be changed once bound.

5.1 Example Code

#include 
struct Foo {    int& ref;   // Reference member    __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 Memory Layout

For Foo, the member only has one int& ref. At a low level, it is implemented as a member variable of pointer size. The following execution result shows that on a 64-bit platform, sizeof(Foo) == 8 (equivalent to the size of a pointer), which is consistent with the notion that “references are syntax sugar”: in a class, it must materialize as a real address storage.

[root@instance-bguv65e0 reference]# g++ test5.cpp -o test5 -O2 -g[root@instance-bguv65e0 reference]# ./test520sizeof(Foo) = 8

5.3 Assembly of the Constructor

After compiling (GCC 11.5.0, -O2), the key part of Foo::Foo(int&) is as follows:

 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

Explanation:

  • rdi: this pointer, pointing to the starting address of the Foo object, assigned in line 87 of the above assembly code;

  • rsi: the constructor parameter, i.e., the address of the passed int& r, assigned in line 86 of the above assembly code;

  • mov %rsi,(%rdi): stores the address in rsi into the first member position of the object, as seen in line 318 of the above assembly code.

In other words, what the Foo object actually stores is a pointer pointing to the temporary variable a in the main function.

5.4 Assembly of Member Functions

Now let’s look at 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

Explanation:

  • rdi: this pointer, assigned in line 87 of the above assembly code;

  • (%rdi): the ref member in the object (which is actually the value of the pointer, the address of variable a);

  • esi: the parameter v of set, assigned in line 95 of the above assembly code;

  • mov %esi,(%rdi): writes the value of the reference target, as seen in line 229 of the above assembly code.

Here we can clearly see: the reference member ref is translated into the address of a.

5.5 Summary

  • References in classes must occupy space, stored as a pointer;
  • Initialization during construction = writing the target address into that pointer;
  • Operations during use = first retrieve this pointer, then access the memory it points to;
  • Unlike local references (which may disappear during optimization), class member references will “materialize” as a pointer field.

6. Comparison of References and Pointers

Although references and pointers have many similarities at the assembly level, there are still significant differences in semantics and usage. Below, we will compare them from multiple perspectives.

6.1 Syntax and Semantics

Feature Reference Pointer
Can be Null No (must be bound to an object) Yes (<span>nullptr</span> is valid)
Can be Rebound No (fixed to point to the same object once bound) Yes (<span>p = &b</span> is valid)
Usage Used directly as a variable Requires <span>*</span> for dereferencing
Initialization Requirement Must be Initialized Can be assigned later

6.2 Low-Level Implementation

Scenario Low-Level Representation of References Low-Level Representation of Pointers
Local Variable Reference <span>-O0</span> typically implemented as a hidden pointer;<span>-O2</span> may be optimized away Occupies registers/memory, may be optimized away in -O2
Function Parameter Reference Passed as an address (ABI is essentially a pointer) Passed as an address
Class Member Reference Stored as a pointer member Pointer member itself
Memory Usage Depends on context; local/parameter may be optimized away, but member references must materialize as addresses Occupies space equivalent to a pointer size

7. Conclusion

References are a unique mechanism in C++, seemingly simple yet related to low-level implementation, optimization strategies, and design trade-offs. Most people regard them as “safer pointers,” but understanding the principles behind them is essential for making the right choices in complex projects. This article has demonstrated the performance of references in different scenarios, from syntax to compiler optimization, from class members to comparisons with pointers. The real value lies not in remembering that “references equal hidden pointers,” but in contemplating:

  • Why can the compiler eliminate references?

  • Why must class member references be initialized?

  • When should references be used semantically, and when should pointers be used?

📌 Recommendations for Readers

  • Do not stop at the syntactical level; understand how the compiler processes them.

  • When using references or pointers, first consider the semantic intent.

  • Experiment more, observe assembly, and cultivate a low-level intuition.

Understanding these principles is not just for exams or interviews, but to write efficient and maintainable engineering code.

📬 Feel free to follow the WeChat public account “Hankin-Liu’s Technical Research Room” for mentorship and sharing. Continuously sharing valuable technical content related to innovation, software performance testing, optimization, programming skills, and software debugging techniques.

Leave a Comment