# 并发编程前序三

在《并发编程前序二》中,我们看到了在内核中进程如何切换:分为主动和被动切换,被动切换是通过设置NEED_RESCHED标志位,然后在合适地方对标志位进行检测,然后调用schedule函数完成进程切换。所以我们称被动切换也是主动切换。同时,我们也了解到了什么是协程,无外乎就是在用户态这一层的多个代码块,我们要做的就是在多个代码块中切换执行,而这些操作与内核无关,这时我们称之为协程。而我们说协程可以用状态机来实现:通过switch case + state来切换代码执行路径,同理我们也可以像go语言那样,直接对代码块当时的CPU状态信息来个快照,然后切换其他代码块执行,当需要恢复协程时,只需要还原保存的CPU快照即可。

在了解以上的知识后,不免读者心中充满疑惑:

  1. 寄存器为何存在,它到底用来干嘛的?
  2. C语言的方法调用过程是什么?
  3. 进程在从用户态到内核态的转变、进程的切换、协程的切换,都需要保存寄存器呢?

那么,本文就以Intel CPU I386 这个32位机来解决这些问题(为何不用x86_64?因为随着位数增多,架构演变,寄存器个数变多,且较为复杂,所以用32位来描述。为何不用16位?原始老古董,跟32位机大相径庭,所以不了也罢(不过实模式倒是可以说说,不过那是另外一块内容了))。

寄存器的出现

先来看这样一个代码,极度简单,可以看出语言无关,你可以理解为:go、java、c等等,取决于你。那么我们来分析下,这干了什么?无外乎定义一个变量a,然后对a加1,很简单对吧。但是随着而来的问题:

  1. a变量为4byte,保存在哪里?
  2. 这个1怎么放进去的?
  3. a+1的动作是怎么做的?
  4. a+1的值又是如何放到a的变量位置处的?
int a=1;

a=a+1;
1
2
3

带着以上问题,我们来看汇编,通过汇编我们能解决以上问题:

  1. 4byte的变量放在栈中(这样想:一个内存的基础单元时1byte,我有这么一个地址:aa,那么我只需要 aa+4 或者 aa-4 便可开辟一个空间对吧?那么为何这里是-4呢?因为,栈空间是从高到低的,为何?限制高地址,避免栈溢出去搞内核代码,因为内核代码在高地址处~(这里只聊虚拟内存分布,不是物理地址,他两是映射关系,不清楚也没关系哈,与这里无关,只是打上标注给喜欢钻牛角尖的,稍微懂一些的朋友,不喜勿怪,减少争论))。所以,movl $1, -4(%rbp)就等于:将1放入到a变量中,读者应该可以看到,汇编中可没有变量这么一说,只有内存,你写的变量只不过代表了一个大小的内存,瞧瞧这设计:很合理对吧

  2. a+1 的动作,我们看到直接通过add指令,对这片地址+1即可:addl $1, -4(%rbp)

    main:
    
     pushq %rbp
    
     movq %rsp, %rbp
    
     #---分隔一下,打个todo吧,后面在分析C语言方法调用原理时再说以上的内容,现在你只需要关注分隔线以下的内容---
    
     movl $1, -4(%rbp)
    
     addl $1, -4(%rbp)
    
     #------分隔符-------
    
     movl $1, %eax
    
     popq %rbp
    
     ret
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19

那么我们用C语言,把源程序再完善一下,如下,我们用main函数包了一下,然后给出了一个返回值1,那么通过上面的汇编代码,我们看到返回值的1,存在那?eax寄存器:movl $1, %eax。嗯,现在我们解决了变量存储,变量加1,返回值存储的问题,那么还有如下问题没解决:

  1. rbp、rsp、eax寄存器是啥?啥是寄存器?

  2. pushq、popq、ret指令是啥?他们干了什么?

    int main(void) {
    
     int a=1;
    
     a=a+1;
    
     return 1;
    
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

我们可不忙解决上面这两个问题,因为如果直接解决了本文也就直接写完了。我们再来看Java语言的该字节码原理(混沌一下):

  1. iconst_1:我们首先看到,现将1放入操作数栈(八股文:方法帧由哪几部分组成:局部变量表、操作数栈、常量池指针)

  2. istore_1:然后将栈顶的1,放入局部变量表index为1处

  3. iload_1:将局部变量下标为1处的值放入栈顶

  4. iconst_1:再往栈里面放一个1(此时栈顶:a变量值 1,刚放入的加值 1)

  5. iadd:对栈顶两个数相加

  6. istore_1:将相加的值放入局部变量表下标为1处

    public class demo{
    
     public static void main(String[] args){
    
      int a=1;
    
      a=a+1;
    
     }
    
    }
    
    
    
     // 字节码描述
    
     public static void main(java.lang.String[]);
    
      0: iconst_1
    
      1: istore_1
    
      2: iload_1
    
      3: iconst_1
    
      4: iadd
    
      5: istore_1
    
      6: return
    
    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

通过Java的字节码,是不是感觉和C语言的不太一样,明显感觉C语言的汇编非常简洁,不过有没有发现java的字节码非常和我们写的代码对应,int a= 1,就往局部变量表里放1,a =a+1,就是将a的值从局部变量表里取出来,然后加1后再放回。那么我们再来看以下代码和它的字节码,嗯,和C语言基本类似了,我们只不过是修改了一个++a,同样与a=a+1一样的效果,不过字节码的生成大相径庭(这也就是为了csapp中优化程序性能一篇说的,面向编译器编码),为了方便读者观看,我直接将字节码的解释放在源码里了。

public class demo{

 public static void main(String[] args){

  int a=1;

  ++a;

 }

}



public static void main(java.lang.String[]);

  0: iconst_1 // 操作数栈顶放入1

  1: istore_1 // 将值存入局部变量表

  2: iinc   1, 1 // 对局部变量表index为1处自增1

  5: return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

那么现在我们可以来定义下寄存器为何出现了。我们在C语言中看到,完全可以直接对内存进行操作,但是我们知道内存的速度慢于cpu,如果没有寄存器来作为内存中的值的副本,那么每次运算都需要操作内存,那将会是一种折磨。寄存器的访问几乎等同于CPU的运行速度,这时我们将需要计算的内存数据,暂时寄存在CPU中,用于加速运算,这就是寄存器出现的原因,以及它的命名。我们再看一个C语言的例子,很简单,我们使用一个变量来循环自增,循环1000次。

int main(void) {

 int a=1;

 for(;a<1000;){

 a++;

 }

 return 1;

}
1
2
3
4
5
6
7
8
9
10
11
12
13

我们来继续观察汇编代码,我们只需要看分隔符之间的内容就好。通过汇编代码我们看到,每一次我们都访问了-4(%rbp) 也即a变量的值,对他进行判断和计算。代码如下。

main:

 pushq %rbp

 movq %rsp, %rbp

 #------分隔符-------

 movl $1, -4(%rbp) # 将1放入栈中,也即int a=1;

 jmp .L2 # 让CPU 跳转到 标号为.L2处执行代码

.L3:

 addl $1, -4(%rbp)

.L2:

 cmpl $999, -4(%rbp) # 看看a的值是否为999

 jle .L3 # 如果小于等于999,那么让CPU跳转到 标号.L3处执行指令

 #------分隔符-------

 movl $1, %eax # 返回值

 popq %rbp

 ret
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

那么我们能不能写成如下操作,我们将1放入eax寄存器,然后对这个寄存器进行运算,将最终值写入变量a的地址处( -4(%rbp)),这时读者可以对比下效率?这也是寄存器的妙用。那么为什么编译器不用我的这种方式呢?那是因为我没有开启优化,所以编译器生成的汇编会按照我编写的方式来生成。

main:

 pushq %rbp

 movq %rsp, %rbp

 #------分隔符-------

 movl $1, %eax; # 改变部分

 jmp .L2 # 让CPU 跳转到 标号为.L2处执行代码

.L3:

 addl $1, %eax; # 改变部分

.L2:

 cmpl $999, %eax # 改变部分 ,看看eax的值是否为999

 jle .L3 # 如果小于等于999,那么让CPU跳转到 标号.L3处执行指令

 movl %eax, -4(%rbp) # 将计算好的值放入内存

 #------分隔符-------

 movl $1, %eax # 返回值

 popq %rbp

 ret
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

寄存器分类

前面我们知道,寄存器是用于减少对缓存、内存的访问,因为他的速度仅次于CPU。我们可以将需要计算的数据放入到寄存器中,然后对寄存器的数据直接操作,完毕后,将值写入内存(读者只需要知道,我们需要尽可能的减少对内存的访问,来混沌一下?我们为了提高性能,也需要减少进入内核态,也即上下文切换)。那么我们先来看看I386中的寄存器。

  1. 8个32位的通用寄存器:
  2. 何为通用寄存器?答案是可以保存任何数据。但是有没有一些特定用途呢?我们知道什么东西都得有一个规范定义,在某些场景我们需要使用某个寄存器完成特定操作,一般情况,那么你随意。是的必须的,必须定义,定义的用途如下: 3. EAX :特定场景用作累加器(Accumulate) 2. EBX :特定场景用作基址寄存器(Base) 3. ECX :特定场景用来计数(Count) 4. EDX :特定场景用来存放数据(Data) 5. ESP :特定场景用作堆栈指针(Stack Pointer) 6. EBP : 特定场景用作基址指针(Base Pointer) 7. ESI : 特定场景用作源变址(Source Index) 8. EDI : 特定场景用作目标变址(Destinatin Index)
  3. 6个16位的段寄存器:
  4. 何为段寄存器,考虑下,我管理了一个城市,那么我肯定想对这个城市规划为不同的区:一个城市由多个区组成,那么我需要找人时,只需要区号,然后加上街道名字就可以寻找。同理,我们可以将一段内存,切割为不同部分,每个部分保存进程的不同数据,那么我们就可以这么来访问,比如我们想访问某个数据:DS:IP(指令指针寄存器,用于指示内存的具体地址),那么我们称这种访问为:地址(段寄存器)+偏移量(IP),同理,我们也可以使用CS:IP访问某个代码。enjoy it~ 13. CS 代码段寄存器。保存进程代码。 2. DS 数据段寄存器。保存进程数据。 3. SS 堆栈段寄存器。保存进程堆栈。 4. ES、FS及GS 附加数据段寄存器。用于特定场景的数据,我们在以后用到后再说。
  5. 特殊32位寄存器
  6. EIP 用于保存指令的地址。该寄存器非常重要,CPU不是要执行指令?得知道指令在哪?这个寄存器就是这个作用。混沌一下:执行Java字节码的bcp 字节码指针?
  7. Eflags 用于保存标志信息。考虑下,我们上面的cmp指令:cmpl $999, -4(%rbp),我们对变量a的值进行判断,那么我们来看看cmp指令的含义:第一个操作减去第二个操作数,但不影响第两个操作数的值,它影响flag的CF,ZF,OF,AF,PF。那么这些XF标志位,就是放置在Eflags寄存器中。我们可以在后面紧跟着:jle .L3 ,这种比较跳转指令,来读取Eflags中对应的标志位,来决定是否跳转,比如jle就读取了CF标志位(Carray Flag 进位/借位标志位),如果CF位为1,那么表明$999 < -4(%rbp),那么将不会跳转到.L3处执行指令,而是执行jle指令后的指令。

当然,还有其他寄存器,不过对于普通的程序研究,了解这些就可以了,我们在后面用到这些寄存器,或者其他的寄存器时再详细讲解,毕竟寄存器也是用多了就知道了,不用刻意去背,对于Eflags中的标志位不理解,也没关系,看看我上面的解释,然后我们在遇到对于其他标志位判断的指令时,根据对应的标志位查看即可。

对于寄存器相关的资料,可以在《intel开发手册》中获取。不过我相信了解了以上描述的这些就够用了,剩下的:CR控制寄存器、GDTR、IDTR、TR等等在描述实模式和保护模式时,我在一一讲解。