引言
- C++冷知识系列文章将从实践的角度出发,介绍C++各个特性的底层实现,让大家在学会C++语法的基础上进一步理解计算机的工作原理
工具与环境介绍
WSL2
介绍
- 即 Windows Subsystem for Linux ,可以在Windows 平台上非常方便地运行Linux 系统。并且Windows 系统的盘符会被挂载在这个Linux 系统上,可以非常方便的操作硬盘文件。
- 网上有关WSL2 安装方法的文章非常多,这里就不再赘述了
Compiler Explorer
介绍
- 神仙网站,可以很方便地预览C/C++ 代码通过各种不同的编译器编译后获得的汇编代码
GDB
介绍
- 全名 GNU symbolic debugger,可以用于调试 C/C++ 代码。如果大家平常主要用IDE的话可能对它比较陌生。事实上在 Linux 系统上GDB 是及其强大的调试工具。下面我会简单介绍GDB的使用方法
补充
-
使用命令行编译 C/C++ 程序
g++ main.cpp -g -o main
gcc main.c -g -o main
g++
用于编译 C++ 程序,gcc
用于编译 C 程序。当然也可以使用g++
编译 C 程序。但是由于C++ 中引入了函数重载等特性,导致编译器生成的汇编程序中函数的符号会被C++编译器进一步处理成一个复杂的字符串,不易看出其对应的函数名。-g
选项是告诉编译器生成 GDB 调试所需的信息
GDB的简单使用
-
准备下面的C++ 代码,并使用
g++
对其进行编译#include <iostream> using namespace std; int main(){ int lhs = 1; int rhs = 2; cout<<"hello world: "<<lhs+rhs<<endl; return 0; }
-
输入下面的指令运行GDB
gdb ./main
-
输入指令
l
查看源代码: -
输入指令
b 4
在 第 4 行的位置打上断点,之后程序会运行到第4行时停下 -
输入指令
r
运行程序 -
输入指令
s
可以单步执行,调到下一条C++语句 -
输入指令
p lhs
可以打印变量lhs
的值 -
输入指令
shell clear
可以清空屏幕 -
输入指令
disassemble
可以查看反汇编代码 -
输入指令
i register
可以查看所有寄存器的值
汇编快速入门
- 弄懂本系列不需要太深奥的汇编知识,只需要大概了解一下汇编代码是怎么执行的就可以了
计算机如何执行程序?
- 为了把重心放在如何执行汇编指令和机器码上,我们在这里极其简化地描述了冯诺依曼架构:
- CPU中的控制器会不断从存储器中取出指令以及数据,交给运算器进行运算,之后运算结果会再存储到存储器中
计算机指令
-
指令用于指导计算机的工作,其本质是一些约定俗称的二进制机器码,这些指令包括一些算数运算比如加减乘除,也包含一些指令跳转。
-
机器码不便阅读,因此人们约定用一系列标记符来表示这些计算机指令,比如加法可以用注记符
add
来表示
计算机内存
-
相信大家对内存并不陌生,C++编程中,变量、类等都需要存储在内存中。但是在PC 电脑上内存中的数据可能并不像我们想象的那样存储。下面我会通过
gdb
来分别查看整数 32, 237, 327 在内存中是怎么显示的-
首先编写下面的程序:
#include <iostream> using namespace std; int main(){ int val = 32; int val1 = 237; int val2 = 327; return 0; }
-
使用下面的命令编译程序
g++ main.cpp -g -o main
-
运行
gdb
并打断点到第4行,然后运行:gdb ./main b 4 r
-
查看汇编指令,我们需要通过汇编指令推断出变量的位置:
dissasemble
-
这里的汇编指令与我们后面介绍的汇编指令不太一样,这是因为这里的汇编指令是
AT&T
风格的,而我们后面介绍的汇编指令是intel
风格的。不过问题不大,它们本质是相同的,只不过操作数是相反的,比如intel
风格的汇编中mov rbp, rsp
对应的AT&T
风格汇编就是mov rsp, rbp
。如果看的不习惯还可以使用下面的命令可以在intel
风格和AT&T
风格之间切换:# 切换为 intel 风格 set disassembly-flavor intel # 切换成 AT&T 风格 set disassembly-flavor att
-
查看第4行到第7行的汇编指令,可以看到三个十六进制数:
0x20
、0xed
、0x147
它们对应的十进制数分别是 32、237、327。对于mov
指令的含义的分析可以参考上手实践部分。因此可以推断出这些变量存储在rbp - 0xc
到rbp - 0x4
区间的内存上。
-
-
通过命令
i register
查看寄存器rbp
的值,获取内存地址:rbp
的值是0x7fffffffdc10
,
-
可以先让程序执行到
return 0;
的位置。 -
输入下面的命令查看从
0x7fffffffdc00
到0x7fffffffdc10
的内存- 这个命令表示查看从
0x7fffffffdc00
的0x10
(十进制16)个字节的数据
x/10xb 0x7fffffffdc00
- 可以看到从上到下,从左到右内存地址逐渐增加
- 这个命令表示查看从
-
接下来找找变量
val
的位置(值为十进制32
十六进制20
)。没错在第一行的第五列 -
然后找找变量
val1
的位置(值为十进制237
十六进制ed
)。在第二行的第一列 -
然后找找变量
val2
的位置(值为十进制327
十六进制147
)。在第二行的第五和第六列。不过貌似有什么地方不太对劲…… 确实,数据好像反过来了。不对!0x01
相对于0x47
内存地址更大。事实上并没有出问题,只是这里内存显示上产生的误解。这里的显示是从上到下从左到右地址逐渐增大,而每个字节中是从右到左位数增大。所以如果地址在显示时是从下到上,从右到左地址依次增大,顺序就没有问题。 -
我们赋值的变量都是
int
占4个字节,如果转换成二进制的形式,对应的变量的高位字节都是0。实际上也gdb显示的结果与我们所想的一样(第一行的 5 到 8 列存储的是变量val
,第二行的 1 到 4 列存储的是变量val1
,第二行的 5 到 8 存储的是变量val2
)x/32tb 0x7fffffffdc00
-
寄存器
参考 一口气看完45个寄存器 ——x86/x64架构 - 知乎 (zhihu.com)
- 计算机指令可以直接操作寄存器,比如一条加法汇编指令可以写成:
add ax, bx
。这句汇编表示将通用寄存器bx
中的数据加到通用寄存器ax
中。- 不同的寄存器会有不同的功能,x86 寄存器的分类还是挺多的,不过我们只需要大致了解以下两种中的几个即可。
- 通用寄存器 :
- 分为 16 位,32 位,64 位,我们只需要大概了解以下几个(以16位为例,32 位和 64 位是在16位基础上加上
e
和r
前缀,比如16位的ax
对应 32位的eax
对应 64位的rax
)ax
:数据存储,并且还有存储函数调用返回值的作用bx
:数据存储,并且还可以作为指针进行数据寻址(类似数组的索引)cx
:数据存储,并且还用用于循环计数的功能dx
:数据存储,并且还用于在乘除法中进行数据累加di
:主要用于存放地址,和bx
功能类似si
:主要用于存放地址,和bx
功能类似sp
:作为指向栈段寄存器的指针,用于标记栈帧的帧顶bp
:作为指向栈段寄存器的指针,用于标记栈帧的帧底
- 分为 16 位,32 位,64 位,我们只需要大概了解以下几个(以16位为例,32 位和 64 位是在16位基础上加上
- 指令寄存器
- 分为 16 位,32 位, 64位,像通用寄存器一样使用
e
和r
来区分 32 位和 64 位ip
:用于定位指令的逻辑地址,可以和代码段寄存器结合计算出对应指令的物理地址,从而让控制器去执行对应的指令
- 分为 16 位,32 位, 64位,像通用寄存器一样使用
- 段寄存器 :
- 段寄存器大小与CPU硬件支持的位数有关,而且没有像通用寄存器中一样用
e
和r
来区分 32 位和 64位。对CPU来说,代码和数据都是二进制数,没有本质区别。为了区分代码和数据,内存在逻辑上被段寄存器分成了一个个段,为它们赋予了具体的含义。cs
:代码段寄存器,与指令寄存器一起用于定位物理内存地址,一般使用cs:ip
的形式表示对应的指令物理地址ds
:数据段寄存器,结合bx
、di
、si
等寄存器用于定位数据的位置。数据寻址的形式比较多,本系列文章主要用到 寄存器相对寻址 :[si+0x8]
可以理解成数组取值ds[si+0x8]
即取出ds
数组si+0x8
索引处的数据ss
:栈段寄存器,常被用与函数调用时存储调用者和被调用者局部变量
- 段寄存器大小与CPU硬件支持的位数有关,而且没有像通用寄存器中一样用
- 通用寄存器 :
- 这些概念比较繁杂,不过不必去记忆,之后我们会通过C++ 代码的汇编结果来带大家在实际应用中理解它们的作用。
- 不同的寄存器会有不同的功能,x86 寄存器的分类还是挺多的,不过我们只需要大致了解以下两种中的几个即可。
- 计算机指令可以直接操作寄存器,并且可以通过寄存器间接操作内存。至于为什么不直接操作内存,是因为内存的存取速度比不上寄存器,而寄存器又不能做的很大(现在的64位计算机的通用寄存器最大只能存储64位的数据)。当然如果未来科技足够发达可以做到存算一体,就可以直接在内存中进行运算而省去通过寄存器导出数据再进行运算的步骤,这样也许能进一步优化计算机的效率。
上手实践
- 下面将结合C++ 代码的汇编结果来简单介绍几个寄存器的作用,剩余的寄存器作用会在后续的文章中具体介绍
基础指令
mov
-
首先打开 Compiler Explorer 在左边选择
C++
右边选择x86-64 gcc 11.4
,然后在左边输入以下代码:void test(){ int a = 10; } int main(){ test(); return 0; }
-
我们只需要看函数 test 的部分,test 中只有一个语句就是对变量 a 的赋值。可以看到右边生成的test 部分代码:
test(): push rbp mov rbp,rsp mov DWORD PTR [rbp-0x4],0xa nop pop rbp ret
-
由于 10 的 十六进制表示下为
0xa
所以我们可以很快地从汇编中分辨出int a = 10;
是第 4 行:mov DWORD PTR [rbp-0x4],0xa
-
我们来简单介绍一下这几个指令的含义:
-
mov
顾名思义就是 move 的意思,根据C++代码int a = 10;
可以推断出,mov
的作用是赋值,而且是将右边的数据赋值给左边,这一点与C++ 的赋值语句是一致的 -
DWORD PTR [rbp-0x4]
可以看到[rbp-0x4]
刚好是我们在介绍数据段寄存器ds
时介绍的 寄存器相对寻址 ,即表示数据段寄存器ds
表示的 ’数组‘ 的rbp-0x4
上的数据。另外我们还看到有一个奇怪的东西DWORD PTR
,我们可以先思考一下:C++ 中的数组是一片连续的内存,这一点与数据段寄存器ds
类似,C++ 中可以根据数组的类型来判断数组中元素的大小,而数据段寄存器ds
貌似没有指定数据类型的能力,那在从数据段寄存器指向的内存中取元素的时候如何知道需要取出多大的数据?事实上数据段寄存器并不需要指定数据类型来确定其中的元素的大小。在C++中我们应该常常使用到 强制类型转换 。
DWORD PTR
实际上就类似这样一个强制类型转换,其中的DWORD
含义是 double words ,word 和 byte 类似都是用来作为二进制位的计数单位,byte 表示 8 个二进制位而 word 表示 16 个二进制位,即 2 个字节(byte),那么 double word 就是 4 个字节,刚好C++ 中一个int
类型的变量大小就是 4 个字节。这种 强制类型转换 一共包含以下几种:
BYTE PTR
:byte pointer 即按照byte (字节,占八个二进制位)大小读内存WORD PTR
:word pointer 即按照word (字,占两字节)大小读内存DWORD PTR
:double words pointer 即按照double words(双字,占四字节)大小读内存QWORD PTR
:quadruple words pointer 剑招 quadruple words(四字,占八字节)大小读内存
PTR
的含义是 pointer ,对应段寄存器和寻址寄存器类似C++ 中指针的概念 -
0xa
就是十六进制的 10 -
故这条指令可以翻译成:**对从数据段寄存器指向的内存开始,偏移
rbp-0x4
处的大小为四个字节的内存赋值为 10 **
-
-
-
通过上面的分析我们可以知道
mov
可以直接操作内存,将数据放入内存。mov
也可以操作寄存器中的数据,比如上面的mov rbp,rsp
等价于C++ 赋值语句rbp = rsp;
它的具体含义我们会在push
指令哪里进行解释。
push
-
介绍
mov
时的C++ 代码汇编结果中有一条push
指令:test(): push rbp ; push 指令 mov rbp,rsp mov DWORD PTR [rbp-0x4],0xa nop pop rbp ret
-
没错这表示的就是栈的操作,与
std::stack<>
一样,push
会将右边的数据压入一个栈中,这个栈实际上是栈段寄存器ss
所标识的一段内存。事实上每一次函数调用都会进行一次压栈的操作,每次函数调用结束都会进行一次出栈的操作。
栈帧
-
下面是栈帧的示意图,截取自 CSAPP
-
可以看到栈是朝着地址减小的方向增长的。栈帧是由两个通用寄存器
bp
和sp
表示的。其中bp
表示栈帧的帧底,sp
表示栈帧的帧顶,这中间的一片内存存储的是函数内定义的局部变量。下面将通过一个示例,来验证这一点:
-
首先打开 Compiler Explorer 并输入以下的代码:
void test(){ char _1byteVal = 1; short _2byteVal = 2; int _4byteVal = 4; long _8byteVal = 8; } int main(){ test(); return 0; }
-
可以得到下面的汇编代码(只看 test 部分):
test(): push rbp mov rbp,rsp mov BYTE PTR [rbp-0x1],0x1 ; char _1byteVal = 1; mov WORD PTR [rbp-0x4],0x2 ; short _2byteVal = 2; mov DWORD PTR [rbp-0x8],0x4 ; int _4byteVal = 4; mov QWORD PTR [rbp-0x10],0x8 ; long _8byteVal = 8; nop pop rbp ret
push rbp
会把调用者函数的栈帧帧底保存在栈段内存中mov rbp, rsp
意味着原来的栈顶将变成当前函数所在的栈帧帧底- 另外我们提到栈帧的增长方向是朝着地址减小的地方,因此我们看到汇编代码中在取内存的时候是向
rbp
负方向偏移的
其它的指令将在后续的文章中介绍