# 并发编程前序五

在《并发编程前序四》中我们知道了C语言的方法调用原理:

  1. 通过bp和sp寄存器分别用于保存栈底和栈顶
  2. 每个方法拥有自己的方法帧,在方法帧中保存本地变量
  3. 当调用方法时,我们使用call指令,该指令将会把下一条指令压入栈中,同时跳转到对应的函数执行
  4. 在新的函数中将会保存旧的栈底bp,然后将bp赋值为当前sp的值,这样就开辟了一个新的栈帧(pushq %rbp; movq %rsp, %rbp)
  5. 当我们需要从调用方法返回时,那么只需要将保存的旧的栈底bp还原,然后将放入的返回地址弹出即可(popq %rbp;ret)
  6. 对于参数传递而言,是根据寄存器的个数来决定的,32位机和64位机的寄存器个数不一样,所以,编译器会尽量使用寄存器来传参,如果参数个数小于寄存器个数,那么将会通过栈来传递参数
  7. eax寄存器用于保存函数返回值

那么,本文将详细解释如下知识点:

  1. C语言中数组与结构体的原理
  2. 指针原理
  3. 常量指针、指针常量的原理
  4. 值传递与引用传递原理

通过以上知识点,帮助读者不在害怕指针,同时使用汇编语言揭示它们底层的构建,这样知其先后,则近道矣。

前置知识

很多读者在研究C语言原理时,并不具备计算机组成原理的知识,所以感觉很蒙蔽,始终学不会,同时在其他书本和学校中C语言又是第一门编程课,因为它足够精华、足够简单,是的,但是简单并不意味着学得会。笔者认为:

  1. 学习Java原理,那么面向JVM学习,这就意味着JVM知识越弱,Java原理你越学不懂,CRUD再多又如何
  2. 那么学习JVM,那么必然面向OS学习,何为虚拟机?虚拟了一台计算机,计算机和操作系统都不会,那更不用说研究JVM了
  3. 那么学习C语言呢?那么必然需要面向操作系统编程,并且需要了解汇编语言。不过没关系,这里只需要介绍一点点操作系统和计算机组成的知识就好。好在,我们前面已经熟悉了常用的汇编语言,所以汇编语言问题不大

我们在研究C语言时,直接把内存当做一个字节数组,注意,是字节数组,一个单元为 1 byte = 8 位,那么它将会是这样,共4GB大小的字节数组,那么很容易理解,这里的基本操作单元为1byte(其实计算机组成原理中,内存的基础操作单元就是1byte,比如你想放4bit,那么也是需要1byte来存放)。

byte mem[]=new byte[4*1024*1024*1024];
1

那么这时就非常容易理解了。以后我们在研究C语言底层时,脑子里就放一个这个数组就行了。我们来看这样一个简单赋值操作,前面我们从汇编看到过,无外乎通过sp开辟一个4byte的空间,然后用mov指令将1放入该空间即可。那么现在读者想想sp和bp对应于这个数组的什么?答案必然是索引下标。比如我这样:bp=0,sp=4,这时是不是就开辟了一个4byte的空间 sp -bp ,那么我就把1放到里面即可。这个时候问题来了:十进制1表示为16进制为0 x 00 00 00 01,总共32位(int等于4byte,一个16进制等于4位二进制),那么我们开辟的4byte怎么放呢?00(0下标) 00(1下标) 00(2下标) 01(3下标)或者01 00 00 00 。我们可看到怎么放都OK,但是需要依赖于CPU架构,比如在Intel中,我们只能这么放:01 00 00 00。而我们知道数组下标由0开始向上递增:0GB - 4GB,这时我们称0GB这边为低地址,而4GB为高地址。而对于0 x 00 00 00 01来说,我们称0 x 00 00为高8位,0 x 00 01为低8位。嗯,这时我们就可以定义以下 字节序 知识:

  1. 存放数据的高位放置在内存的低地址,那么为大端序
  2. 存放数据的高位放置在内存的高地址,那么为小端序
int a=1;
1

C语言中数组与结构体

有时候,我们需要在内存中开辟一块空间,来放置内容,这时候有两种放置方式:

  1. 相同的数据内容
  2. 不相同的数据内容

而他们的唯一共同点,就是连续的内存,用上面的mem字节数组来说,就是放置内容的下标要连续,比如:0 - 4。不同点在于他们的命名方式不同:

  1. 数组用于存放相同的数据内容
  2. 结构体不相同的数据内容

我们来看个例子,并且看看他们的汇编代码。我们在C的源代码中创建了一个数组arr,一个结构体s。通过汇编代码来看,非常符合我们的描述:

  1. 空间连续
  2. 数组放置内容类型相同
  3. 结构体可以不同
  4. 均是操作rbp来在内存中放置数据
  5. 在汇编代码阶段来看,数组和结构体并没有什么不同:movl 数据, -地址(%rbp)

对的,结构体和数组本身就没有什么区别(从汇编角度)。那么我们现在来用mem字节数组分析下这个movl $3, -32(%rbp)指令干了什么:

  1. 假如rbp保存了数组下标 64,那么该下标就是栈基址
  2. 那么我们用 64 - 32 ,那么得到下标 32,该下标就是用于存放这个4字节的3
  3. movl指令等于 arr[32] = 0x03,arr[33] = 0x00,arr[34] = 0x00,arr[35] = 0x00
struct test{

 int a;

 long b;

};



void func(){

 int arr[]={3,5};

 struct test s = {7,9};

};

func:

 pushq %rbp

 movq %rsp, %rbp

 # 数组

 movl $3, -32(%rbp) # 栈内存由高地址向低地址扩张,所以减32

 movl $5, -28(%rbp) # 32-28 = 4 正好 一个 整形的长度

 # 结构体

 movl $7, -16(%rbp)

 movq $9, -8(%rbp)

 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
32
33
34
35
36
37
38
39

指针原理

很多读者特别怕这东西,但是它真的很简单。我们先来看语法推理:

  1. 以下代码我们声明一个变量a,那么这时肯定通过bp或者sp减掉一个4byte的空间来存放a。想想mem字节数组,找一个下标index 比如为 64给bp,然后用bp - 4 ,也即 64 - 4 = 60,此时我用index为60 - 64的空间来存放4byte的变量a
  2. 核心来了,&符号是啥?*p又是啥?
  3. 我们知道a变量的起始地址在下标index为60处,那么&运算符就是获取下标60,注意,不是变量a的值
  4. 那么*p呢?就是开辟了一个空间,来存放这个下标60
  5. 那么这时,我们称存储下标60,也即变量a的地址为指针(尼玛?不就是个地址么?)
  6. 这时只需要解决一个问题就行了:存储下标60的空间需要多大?读者可以想想,数组长度有多大?我们以4GB为例,那么这时可以很轻松的得出,这个空间必须能容纳最大为4GB的index下标对吧?这时很容易就推理出:32位机中地址最大为2^32,那么空间必须为4byte = 32位,同理 64位机,空间必须为 8byte = 64位
  7. *p为一个空间,那么int *p呢?很容易理解,这个空间有多大。int表明这个地址指向的空间,也即变量a的大小为4byte
void func(){

 int a=1;

 int *p=&a;

}
1
2
3
4
5
6
7

那么我们知道了,指针就是一个地址而已。我们来看下汇编代码。可以得出以下结论:

  1. &运算法等于leaq -12(%rbp), %rax ,lea指令就是取-12(%rbp)的地址,然后放入rax寄存器
  2. *p就是将a的地址,也即rax中的值,放入栈中保存
  3. 那么此时-8(%rbp)中就保存了8byte的变量a的地址值
func:

 pushq %rbp

 movq %rsp, %rbp

 movl $1, -12(%rbp)

 leaq -12(%rbp), %rax # 取a的地址放入rax

 movq %rax, -8(%rbp) # 将a的地址放入栈中保存

 popq %rbp

 ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

接下来我们来看一个C程序。推理一下:

  1. 我们说指针就是个地址,那么这里笔者将数组的第一个元素的地址取了出来(&arr[0])
  2. 那么这时读者考虑下:arr[]和*p的地址是否相同呢?也即arr[N]运算符,和直接操作指针p有何区别?
void func(){

 int arr[]={3,5};

 int *p = &arr[0];

}
1
2
3
4
5
6
7

不管怎样,还得看汇编代码。我们看到这时*p空间中,存放的地址是不是就是数组的首地址。那么,由于数组空间是连续的,且间隔都相同,等于数组的类型。比如我们这里的arr类型为整形,那么此时空间就是:4+4,而我们的指针p指向首地址,那么好玩的就来了,我们是不是可以操作这个指针p对数组进行遍历:p地址 + 4byte x N 。简单吧,确实可以这样。

func:

 pushq %rbp

 movq %rsp, %rbp

 # 数组arr数据

 movl $3, -16(%rbp)

 movl $5, -12(%rbp)

 leaq -16(%rbp), %rax # 取数组中第一个变量为3的地址放入rax

 movq %rax, -8(%rbp) # 将rax的值放入栈中

 popq %rbp

 ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

我们继续看C代码。推理一番:

  1. arr[0]和arr[1]大家都不陌生,获取数组下标为0和1的值
  2. 那么对于int c = *p呢?读者想想,p这个空间是不是存放指向数组首地址的值?那么这时我将其取出来,然后获取该地址对应的值,是不是就是数组下标为0的值呢?
  3. 再看*(p+1),还记得:p地址 + 4byte x N公式么?我们只需要将p保存的地址 + 4byte就行了,这时就可以得到下标为1的值,嗯是的,我们只需要p+1,这时编译器会自动对这个p保存的地址值加上int的大小,因为它指向的空间大小为4
void func(){

 int arr[]={3,5};

 int *p = &arr[0];



 int a = arr[0];

 int b = arr[1];



 int c = *p;

 int d = *(p+1);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

最后我们看看汇编代码,直接就明白了:

  1. &取地址 ,脑子里马上闪出 lea 指令
  2. *p 解引用,脑子里马上闪出 (寄存器) 操作,注意:汇编中()运算符,用于将括号中的值当做地址,寻址
func:

 pushq %rbp

 movq %rsp, %rbp

 # 数组arr

 movl $3, -16(%rbp)

 movl $5, -12(%rbp)

 # 保存数组arr的首地址到-8(%rbp)中

 leaq -16(%rbp), %rax 

 movq %rax, -8(%rbp)

 # int a = arr[0]; 

 movl -16(%rbp), %eax

 movl %eax, -32(%rbp)

 # int a = arr[1];

 movl -12(%rbp), %eax

 movl %eax, -28(%rbp)

 # int c = *p; 注意:-8(%rbp)中保存了数组的首地址

 movq -8(%rbp), %rax

 movl (%rax), %eax

 movl %eax, -24(%rbp)

 # int d = *(p+1);

 movq -8(%rbp), %rax

 movl 4(%rax), %eax # 注意这里的 4(%rax) 等于 %rax + 4 ,so 你写的 (p+1) 就会自动给你在指令中加上单元大小

 movl %eax, -20(%rbp)

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

常量指针、指针常量的原理

了解了指针就是个地址之后,我们来看看这两个概念:

  1. 常量指针:指向常量的指针。也即它不能指向变量,它指向的内容不能被改变,不能通过指针来修改它指向的内容。说白了,就是这个空间保存了一个地址,这个地址指向的内容不能改变
  2. 指针常量:指针本身是常量。它指向的地址是不可改变的,但地址里的内容可以通过指针改变。它指向的地址将伴其一生,直到生命周期结束。指针常量在定义时必须同时赋初值。说白了,就是这个空间保存的地址不能变,永远指向一片区域,但这个区域的值是可以改变的

直接看例子。读者可以动手通过gcc反编译看看,其实在汇编层面,一样,只不过,编译时做了检查。

// 常量指针例子

int b=1;

int const *a; // 声明常量指针,我们可以获取*a的值,但是不能修改,比如*a=3是非法的

a = &b;

// 指针常量例子

int b;

int * const p = &a; // 声明常量指针,指针常量一旦赋值,那么将不能再用p指向其他变量,如 p = &b 非法。但是我们却可以 *p=3 修改其中的内容
1
2
3
4
5
6
7
8
9
10
11
12
13

那么,读者想想能不能两个联合起来使用呢?比如:int const * const p = &a,答案是必须的,肯定可以,这样的话,指针p不能被改变,同时指向的内容也不能改变。

值传递与引用传递原理

害,我相信,都了解了指针的原理了,就是个地址而已,那么这里值传递和引用传递原理,不说都可以,读者一看便知。我们先来看引用传递的原理:传递地址值而已。那么这时,用于操作的单元值为同一个,所以在func1中的3,将会影响func2中的a变量。

void func1(int *a){ // 这里表明接收一个地址

 *a = 3;

}



void func2(){

 int a=1;

 func1(&a);

}

func1:

 pushq %rbp

 movq %rsp, %rbp

 movq %rdi, -8(%rbp) # 将a的地址放入栈中

 movq -8(%rbp), %rax # 取a的地址放入rax

 movl $3, (%rax) # 对a的地址的值赋值3

 popq %rbp

 ret

 

func2:

 pushq %rbp

 movq %rsp, %rbp

 subq $16, %rsp

 movl $1, -4(%rbp)

 leaq -4(%rbp), %rax # 将a的地址放入rax

 movq %rax, %rdi # 将a的地址放入rdi

 call func1 # 调用函数

 leave

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

再来看值传递。可以看到值传递,将不会在func1中修改影响到func2的值,因为他们不是同一片空间,相当于变量a有两个空间,一个在func2中( -4(%rbp) ),一个在func1中( -20(%rbp) )。

void func1(int a){

 a = 3;

}



void func2(){

 int a=1;

 func1(a);

}

func1:

 pushq %rbp

 movq %rsp, %rbp

 movl %edi, -20(%rbp) # 将a的值放入func1的栈帧中

 movl $3, -4(%rbp) # 将3放入rbp中,为何这里不是-20(%rbp)中呢?这时因为我们已经修改了变量a的值,这时变量a只不过代表了一个空间而已,这个空间可以不是-20(%rbp),这个地址只是保存传入参数而已

 popq %rbp

 ret



func2:

 pushq %rbp

 movq %rsp, %rbp

 subq $16, %rsp

 movl $1, -4(%rbp) 

 movl -4(%rbp), %eax # 将a的值放入eax寄存器

 movl %eax, %edi # 将a的值放入edi寄存器

 call func1

 leave

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51