C++冷知识第【一】期 引言与目录

引言

  • 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的简单使用

  1. 准备下面的C++ 代码,并使用 g++ 对其进行编译

    #include <iostream>
    using namespace std;
    int main(){
        int lhs = 1;
        int rhs = 2;
        cout<<"hello world: "<<lhs+rhs<<endl;
        return 0;
    }
    
  2. 输入下面的指令运行GDB

    gdb ./main
    
  3. 输入指令 l 查看源代码:

    gdb-l指令查看源代码

  4. 输入指令 b 4 在 第 4 行的位置打上断点,之后程序会运行到第4行时停下

  5. 输入指令 r 运行程序

  6. 输入指令 s 可以单步执行,调到下一条C++语句

  7. 输入指令 p lhs 可以打印变量 lhs 的值

  8. 输入指令 shell clear 可以清空屏幕

  9. 输入指令 disassemble 可以查看反汇编代码

  10. 输入指令 i register 可以查看所有寄存器的值

汇编快速入门

  • 弄懂本系列不需要太深奥的汇编知识,只需要大概了解一下汇编代码是怎么执行的就可以了
计算机如何执行程序?
  • 为了把重心放在如何执行汇编指令和机器码上,我们在这里极其简化地描述了冯诺依曼架构:
数据
指令
控制运算
运算结果存储
控制器
存储器
运算器
  • CPU中的控制器会不断从存储器中取出指令以及数据,交给运算器进行运算,之后运算结果会再存储到存储器中
计算机指令
  • 指令用于指导计算机的工作,其本质是一些约定俗称的二进制机器码,这些指令包括一些算数运算比如加减乘除,也包含一些指令跳转。

  • 机器码不便阅读,因此人们约定用一系列标记符来表示这些计算机指令,比如加法可以用注记符 add 来表示

计算机内存

  • 相信大家对内存并不陌生,C++编程中,变量、类等都需要存储在内存中。但是在PC 电脑上内存中的数据可能并不像我们想象的那样存储。下面我会通过 gdb 来分别查看整数 32, 237, 327 在内存中是怎么显示的

    1. 首先编写下面的程序:

      #include <iostream>
      using namespace std;
      int main(){
          int val = 32;
          int val1 = 237;
          int val2 = 327;
          return 0;
      }
      
    2. 使用下面的命令编译程序

      g++ main.cpp -g -o main
      
    3. 运行 gdb 并打断点到第4行,然后运行:

      gdb ./main
      b 4
      r
      
    4. 查看汇编指令,我们需要通过汇编指令推断出变量的位置:

      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行的汇编指令,可以看到三个十六进制数:0x200xed0x147 它们对应的十进制数分别是 32、237、327。对于mov 指令的含义的分析可以参考上手实践部分。因此可以推断出这些变量存储在 rbp - 0xcrbp - 0x4 区间的内存上。

    5. 通过命令 i register 查看寄存器 rbp 的值,获取内存地址:

      查看寄存器rbp获得内存地址

      • rbp 的值是 0x7fffffffdc10
    6. 可以先让程序执行到 return 0; 的位置。

    7. 输入下面的命令查看从 0x7fffffffdc000x7fffffffdc10 的内存

      • 这个命令表示查看从 0x7fffffffdc000x10 (十进制16)个字节的数据
      x/10xb 0x7fffffffdc00
      

      查看内存

      • 可以看到从上到下,从左到右内存地址逐渐增加
    8. 接下来找找变量 val 的位置(值为十进制32 十六进制 20)。没错在第一行的第五列

    9. 然后找找变量 val1 的位置(值为十进制237 十六进制 ed )。在第二行的第一列

    10. 然后找找变量 val2 的位置(值为十进制327 十六进制 147 )。在第二行的第五和第六列。不过貌似有什么地方不太对劲…… 确实,数据好像反过来了。不对!0x01 相对于 0x47 内存地址更大。事实上并没有出问题,只是这里内存显示上产生的误解。这里的显示是从上到下从左到右地址逐渐增大,而每个字节中是从右到左位数增大。所以如果地址在显示时是从下到上,从右到左地址依次增大,顺序就没有问题。

    11. 我们赋值的变量都是 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位基础上加上 er 前缀,比如16位的 ax 对应 32位的 eax 对应 64位的 rax
          • ax :数据存储,并且还有存储函数调用返回值的作用
          • bx :数据存储,并且还可以作为指针进行数据寻址(类似数组的索引)
          • cx :数据存储,并且还用用于循环计数的功能
          • dx :数据存储,并且还用于在乘除法中进行数据累加
          • di :主要用于存放地址,和 bx 功能类似
          • si :主要用于存放地址,和 bx 功能类似
          • sp :作为指向栈段寄存器的指针,用于标记栈帧的帧顶
          • bp :作为指向栈段寄存器的指针,用于标记栈帧的帧底
      • 指令寄存器
        • 分为 16 位,32 位, 64位,像通用寄存器一样使用 er 来区分 32 位和 64 位
          • ip :用于定位指令的逻辑地址,可以和代码段寄存器结合计算出对应指令的物理地址,从而让控制器去执行对应的指令
      • 段寄存器
        • 段寄存器大小与CPU硬件支持的位数有关,而且没有像通用寄存器中一样用 er 来区分 32 位和 64位。对CPU来说,代码和数据都是二进制数,没有本质区别。为了区分代码和数据,内存在逻辑上被段寄存器分成了一个个段,为它们赋予了具体的含义。
          • cs :代码段寄存器,与指令寄存器一起用于定位物理内存地址,一般使用 cs:ip 的形式表示对应的指令物理地址
          • ds :数据段寄存器,结合 bxdisi 等寄存器用于定位数据的位置。数据寻址的形式比较多,本系列文章主要用到 寄存器相对寻址[si+0x8] 可以理解成数组取值 ds[si+0x8] 即取出ds数组si+0x8索引处的数据
          • ss :栈段寄存器,常被用与函数调用时存储调用者和被调用者局部变量
    • 这些概念比较繁杂,不过不必去记忆,之后我们会通过C++ 代码的汇编结果来带大家在实际应用中理解它们的作用。
  • 计算机指令可以直接操作寄存器,并且可以通过寄存器间接操作内存。至于为什么不直接操作内存,是因为内存的存取速度比不上寄存器,而寄存器又不能做的很大(现在的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

    栈帧示意图

  • 可以看到栈是朝着地址减小的方向增长的。栈帧是由两个通用寄存器 bpsp 表示的。其中 bp 表示栈帧的帧底,sp 表示栈帧的帧顶,这中间的一片内存存储的是函数内定义的局部变量。下面将通过一个示例,来验证这一点:

  1. 首先打开 Compiler Explorer 并输入以下的代码:

    void test(){
        char _1byteVal = 1;
        short _2byteVal = 2;
        int _4byteVal = 4;
        long _8byteVal = 8;
    }
    
    int main(){
        test();
        return 0;
    }
    
  2. 可以得到下面的汇编代码(只看 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 负方向偏移的

其它的指令将在后续的文章中介绍

系列文章目录

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学艺不精的Антон

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值