简谈C++中指针与引用的底层实现

引用是C++相对于C而引入的一个重要特性,它使得许多地方的语法变得更加简洁,但是它们的底层究竟是怎么实现的呢?

在Wikipedia中,对指针有如下介绍:

In computer science, a pointer is a programming language object that stores the memory address of another value located in computer memory. A pointer references a location in memory, and obtaining the value stored at that location is known as dereferencing the pointer.

从定义可以看出,指针从本质上来讲就是一个变量,其中所存储的就是其他变量的地址。 而C语言中的指针非常灵活,它可以任意指向某一个地址,不论这个地址究竟是否存在,或它究竟存储的是否为指针所代表类型的数据。

那么也不难想到,指针在实现的时候也是内存里的一个变量,它存有其他变量的地址。

在Wikipedia中,对引用有如下介绍:

In computer science, a reference is a value that enables a program to indirectly access a particular datum, such as a variable’s value or a record, in the computer’s memory or in some other storage device. The reference is said to refer to the datum, and accessing the datum is called dereferencing the reference.

In the C++ programming language, a reference is a simple reference datatype that is less powerful but safer than the pointer type inherited from C. The name C++ reference may cause confusion, as in computer science a reference is a general concept datatype, with pointers and C++ references being specific reference datatype implementations. The definition of a reference in C++ is such that it does not need to exist. It can be implemented as a new name for an existing object (similar to rename keyword in Ada).

从上面的定义可以看出,在C++中,引用可以狭义地认为是某一个变量的别名,它本身是并不存在的。

基于以上说法,我就一度认为引用只是C++编译器在编译时的一些黑魔法,它在运行的时候将两个解析到的符号链接成了一个,从而完成了引用,而在编译之后,引用与本体就是一个变量(一个寄存器或栈上的值)。

但是,事实却打了我的脸。

我们通过以下程序进行检验:

1
2
3
4
5
6
7
8
int main()
{
int a = 0;
int *pa = &a;
int &ra = a;
++(*pa);
++ra;
}

该程序声明了一个变量a,然后分别声明指针pa指向a的地址,生命引用ra指向a,最后分别使用指针和地址对a进行了一次自加操作。

接下来,使用gcc -S test.cpp -o test.s -O0将上面的C++程序编译为汇编,看一下这些操作具体都是怎么实现的。(环境为MacOS,LLVM 10.0.1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
	.section	__TEXT,__text,regular,pure_instructions
.build_version macos, 10, 14 sdk_version 10, 14
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
; 保护现场
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
; 保存栈指针
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
; 设置返回值为0
xorl %eax, %eax
; int a = 0
movl $0, -4(%rbp)
; t0 = &a
leaq -4(%rbp), %rcx
; int *pa = t0
movq %rcx, -16(%rbp)
; int &ra = t0
movq %rcx, -24(%rbp)
; t0 = pa
movq -16(%rbp), %rcx
; t1 = *t0 = *pa
movl (%rcx), %edx
; ++t1
addl $1, %edx
; *t0 = *pa = t1
movl %edx, (%rcx)
; t0 = &ra = &a
movq -24(%rbp), %rcx
; t1 = *t0 = *(&ra) = ra = a
movl (%rcx), %edx
; ++t1
addl $1, %edx
; *t0 = *(&ra) = ra = a = t1
movl %edx, (%rcx)
; 恢复现场
popq %rbp
; 返回
retq
.cfi_endproc
## -- End function

.subsections_via_symbols

我已经将汇编中的关键代码加上了注释,可以看出,变量a,指针pa,以及引用ra都位于栈上,index分别在-4、-16、-24。需要注意的是,引用并不是直接复用了变量a的-4(%rbp)地址,而是像指针一样,使用了一个新地址,并且将leaq计算得到的a的地址写入了其中。

而在进行自加的时候,除了最开始将指针中的内容拷贝到寄存器中所用的地址不同以外,指针和引用所使用的方式是完全相同的。

这个结果令我非常意外,编译器其实是将开发者对引用的操作翻译成了对指针的操作

最后,发现现代编译器还是很聪明的,如果将优化级别调到更高,就会发现它直接将中间的计算过程全部简化,直接返回,这是因为计算结果并没有任何输出,它是不必要的。如果将上面的代码从main函数转移到其他函数中,编译器这时虽然不能放弃计算其中的数值,但还是做了尽力的优化,直接返回结果(movl $2, %eax)。

进一步的实验

在文章发出后,有同学提出了质疑,认为可能只是MacOS上gcc编译器的特定操作,并不具有普适性,所以我在Linux和Windows上重复了上述实验。

在Linux环境中(发行版为Ubuntu 18.04,gcc版本为7.5.0)使用相同指令编译后得到的汇编码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
	.file	"test_ref.cpp"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
; 保护现场
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
; 保存栈指针
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
movq %fs:40, %rax
movq %rax, -8(%rbp)
; 设置返回值为0
xorl %eax, %eax
; int a = 0
movl $0, -28(%rbp)
; t0 = &a
leaq -28(%rbp), %rax
; int *pa = t0
movq %rax, -24(%rbp)
; t0 = &a
leaq -28(%rbp), %rax
; int &ra = t0
movq %rax, -16(%rbp)
; t0 = pa
movq -24(%rbp), %rax
; t1 = *t0 = *pa
movl (%rax), %eax
; t2 = *t0 + 1
leal 1(%rax), %edx
; t0 = pa
movq -24(%rbp), %rax
; *t0 = *pa = t2 = *t0 + 1
movl %edx, (%rax)
; t0 = ra
movq -16(%rbp), %rax
; t1 = *t0 = *pa
movl (%rax), %eax
; t2 = *t0 + 1
leal 1(%rax), %edx
; t0 = ra
movq -16(%rbp), %rax
; *t0 = *ra = t2 = *t0 + 1
movl %edx, (%rax)
; 返回值设置为0
movl $0, %eax
movq -8(%rbp), %rcx
xorq %fs:40, %rcx
je .L3
call __stack_chk_fail@PLT
.L3:
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits

可以看出与MacOS中gcc的编译结果基本相同。

在Windows环境中(Windows 10,vs2019,cl版本为19.23.28106.4),可以使用命令cl /Od /FA .\test_ref.cpp对源码进行编译,可以得到汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
; Listing generated by Microsoft (R) Optimizing Compiler Version 19.23.28106.4 

TITLE C:\Users\jason\test\test_ref.cpp
.686P
.XMM
include listing.inc
.model flat

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

PUBLIC _main
; Function compile flags: /Odtp
_TEXT SEGMENT
_ra$ = -12 ; size = 4
_pa$ = -8 ; size = 4
_a$ = -4 ; size = 4
_main PROC
; File C:\Users\jason\test\test_ref.cpp
; Line 2
push ebp
mov ebp, esp
sub esp, 12 ; 0000000cH
; Line 3
mov DWORD PTR _a$[ebp], 0
; Line 4
lea eax, DWORD PTR _a$[ebp]
mov DWORD PTR _pa$[ebp], eax
; Line 5
lea ecx, DWORD PTR _a$[ebp]
mov DWORD PTR _ra$[ebp], ecx
; Line 6
mov edx, DWORD PTR _pa$[ebp]
mov eax, DWORD PTR [edx]
add eax, 1
mov ecx, DWORD PTR _pa$[ebp]
mov DWORD PTR [ecx], eax
; Line 7
mov edx, DWORD PTR _ra$[ebp]
mov eax, DWORD PTR [edx]
add eax, 1
mov ecx, DWORD PTR _ra$[ebp]
mov DWORD PTR [ecx], eax
; Line 8
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
END

cl编译器的汇编代码格式和gcc略有不同,但含义相近,并且可以比较轻易地通过上面标出的代码行数确定汇编代码的含义。可以看出,它使用的方法也和前两种相同。

这里再进一步,引入知乎上“XZiar”同学的评论,她的评论更加深入地理解其中的机制:

其实不是说“把引用解释成指针”吧。

在机器码层面,也不存在指针,只存在地址(指针其实还隐含了类型信息)。变量这个概念也是不存在的,只有“无格式数据”,被带格式的指令操作而已。

所以你看到引用和指针的效果一样,是因为在机器码层面,没有多余的信息去表明他们的区别了。

而在语言层面,引用的确可以理解为const指针

另外,她针对为什么汇编代码中引用把地址复制了一遍也进行了更深入的解释:

另外引用把地址复制一遍也是很正常的,编译器也的确没法在编译期完全分析出引用的具体指向。考虑如下代码:

int a=0,b=1; int& c = flag ? a : b;

引用只不过因为const所以不能被重置,但具体指向什么,是可以运行期决定的。

到这里,对于指针和引用底层实现的探索也基本结束了,可以看出,在不启用编译器优化的情况下,主流编译器都会选择将C++中的引用解释为“const指针”。

但是,如果在启动编译器优化的情况下会是如何呢?在MacOS中,将源代码中的返回值改为a后(为了防止编译器优化后认为没有输出于是什么都不做),同时将编译器优化选项调整为O1和O2,其结果是相同的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
	.section	__TEXT,__text,regular,pure_instructions
.build_version macos, 10, 15 sdk_version 10, 15, 4
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $2, %eax
popq %rbp
retq
.cfi_endproc
## -- End function

可以看出汇编版本的代码中省略了所有和指针、引用及内存操作相关的代码,直接将返回值设置为2。

从这里可以看出,编译器的作用是将语言编写的代码翻译为合理的汇编代码,只要汇编代码可以源代码的真实意图执行即可。由于机器码可以表达的概念有限(基本上就是对于寄存器和内存的运算),而高级语言可以表达的概念十分多样,所以编译器就需要将高级语言中的各种复杂概念映射(也可以看做是翻译)为机器码中的简单概念,映射的过程可能会有多种方案,其最终选择是由编译器来决定的。在C++指针和引用的翻译中,主流的C++编译器都选择将它们映射为机器码中的地址,而舍弃了其中的类型信息。