0%

学习虚拟内存,对于其中的malloc的实现有一定兴趣,所以深入研究一下

研究

首先我们知道malloc的函数原型:

1
void * malloc(size_t size);

我们可以用它申请一个指定大小的内存空间,其中size是我们申请的空间,于是我们我们可以简单的实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <assert.h>
#include <sys/types.h>
#include <unistd.h>

void * malloc(size_t size){
void *p = sbrk(0);
void *request = sbrk(size);
if(request == (void*)-1){
return NULL;
}else{
assert(request == p+size);
return p;
}
}

注意到这里我们使用了一个函数sbrk()

1
void* sbrk(intptr_t increment);

它可以它通过调整堆指针(brk)从而实现对堆的大小的动态调整,其中的参数increment是将要分配的内存空间大小。如果为正数就是增加,如果是负数就是减少,如果为0就返回当前的堆指针。

接下来回过头来看我们的malloc函数,这里我们只实现了分配空间的功能,我们并不能实现free这片空间的效果。

那么我们再来看看free函数是什么样的:

1
void free(void* ptr);

之前使用malloc分配的内存空间,我们可以通过向free提供指向内存块的指针,从而实现对这片空间的”释放”,本质上就是对这篇空间进行标记,标记为可分配的内存空间。

但是我们看看我们的malloc函数,我们将返回的指针传递给free,我们怎么知道应该释放多大的空间呢?所以我们需要一片额外的空间来存储这个内存块的信息。

为了实现这个功能,我们可以将关于内存区域的元信息存储在指针的下方。可能听起来很抽象,我们可以仔细理解一下。我们使用0x10大小的空间去存储这些信息,也就是说当我们分配0x400大小的空间时,实际上分配的是0x410大小的空间。假设指向这片内存空间的指针为p,那么从[p,p+0x10]的部分,存储了我们的元信息,而[p+0x10,p+0x410]的部分则是我们申请的空间,malloc最终返回的地址是p+0x10,也就是说这些信息被隐藏了起来,所以我们说 元信息被存储在指针的下方

现在我们可以将内存块给释放了,但是之后呢?我们从内存中的堆空间应该是连续的,所以我们不能直接将释放的内存块返回给操作系统,这样会导致内存的碎片话。也许你可能会想到,将下方的内存空间上移填补这个内存空缺,但是这样会导致我们难以管理我们的指针,因为内存块的指针仍然指向原来的地址,你也难以修改他。

实现

相反,我们不应该将内存块直接返回给操作系统。我们将其标记为已释放。然后我们尝试解决这个问题。

我们直接将整个内存块的元信息视作一个结构,从而使用链表来简化这个问题:

1
2
3
4
5
6
struct block_meta{
size_t size;
struct block_meta *next;
int free;
int magic; // for debug
}

我们可以通过它知道内存块的大小,下一个内存块是什么,以及该内存块是否被释放了,最后还有一个魔法数字(它可以是任何数,用于判断是谁修改了这个内存块)

同时我们为这个链表设置一个头节点

1
void * global_base = NULL;

对于我们的malloc,我们希望它尽可能的使用已经分配了的空间,如果不够再额外请求空间。我们有了这个链表结构之后,我们可以在接受空间申请的请求之后,遍历查询链表,查看我们是否有足够的空闲块。

1
2
3
4
5
6
7
8
9

struct block_meta * find_free_block(struct block_meta ** last,size_t size){
struct block_meta * current = global_base; //从第一个内存块开始
while (current && !(current->free && current->size >= size)){
*last = current;
current = current->next;
}
return current;
}

如果出现空闲块无法满足内存空间的申请需求时,我们使用sbrk()申请调用更多的堆空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct block_meta * request_space(struct block_meta * last,size_t size){
struct block_meta * block;
block = sbrk(0);
void * request = sbrk(size + META_SIZE);
// assert(request == (void *)block + size + META_SIZE);
if(request == (void *)-1){
return NULL;
}
if(last){ //如果不是第一次请求,就更新前一个块
last->next = block;
}
block->size = size;
block->next = NULL;
block->free = 0;
block->magic = 0x12345678;
return block;
}

现在我们有了检查空闲空间和分配空闲空间的辅助函数,我们可以在此基础上实现malloc。如果我们的全局基地指针是NULL,那么我们需要设置一个新的块作为我们的基地指针,如果不是NULL我们就可以检查现有的块。

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
void * malloc(size_t size){
struct block_meta * block;
if(size <= 0){
return NULL;
}

if(!global_base){
block = request_space(NULL,size);
if(!block){
return NULL;
}
global_base = block;
}else{
struct block_meta * last = global_base;
block = find_free_block(&last,size);
if(!block){
block = request_space(last,size);
if(!block){
return NULL;
}
}else{
block->free = 0;
block->magic = 0x77777777;
}
}
return (block + 1); //block实际上是结构体的指针,这里加上一,指针指向分配的内存空间起点
}

我们对malloc所作的一切都是为了我们的free,接下来,我们将在malloc的基础上实现我们的free。它要做的主要工作就是将结构体中的free设置为0,为了准确的定位到结构体的地址,我们先定义一个函数:

1
2
3
struct block_meta * get_block_ptr(void * ptr){
return (struct block_meta *)ptr - 1;
}

现在我们有了这个,我们就可以写出:

1
2
3
4
5
6
7
8
9
10
11

void free(void * ptr){
if(!ptr){ //free函数需要考虑free(NULL)的情况下,不会释放任何地方
return;
}
struct block_meta * block_ptr = get_block_ptr(ptr);
assert(block_ptr->free == 0);
assert(block_ptr->magic == 0x77777777 || block_ptr->magic == 0x12345678);
block_ptr->free = 1;
block_ptr->magic = 0x55555555;
}

现在我们就有了自己的mallocfree函数了

更多

既然我们已经实现了mallocfree,我们为什么不在此基础上实现其他的函数便于我们的内存分配和使用呢?

我们再实现一个realloccalloc

1
void *realloc(void *ptr, size_t size)

这个时realloc的函数原型,我们传递一个已分配的指针,然后重新分配它的大小。

基于我们的malloc,它的实现较为简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void * realloc(void *ptr,size_t size){
if(!ptr){ //包含malloc的功能
return malloc(size);
}
struct block_meta * block_ptr = get_block_ptr(ptr);
if(block_ptr->size >= size){ //如果现有的内存空间大于想要的内存空间,就返回当前指针
return ptr;
}

void * new_ptr;
new_ptr = malloc(size);
if(!new_ptr){
return NULL;
}
memcpy(new_ptr,ptr,block_ptr->size); //将原来的内存块中的程序移动到新的内存空间中
free(ptr);
return new_ptr;
}

这样我们就实现了realloc的实现,然后我们再尝试一下calloc:

1
void *calloc(size_t num, size_t size);

它的作用是分配num个大小为size的内存块,并将其内存初始化为0

malloc的基础上,它的实现也比较简单:

1
2
3
4
5
6
void *calloc(size_t num, size_t size){
size_t m_size = num * size;
void * ptr = malloc(m_size);
memset(ptr,0,m_size);
return ptr;
}

到此为止,我们就实现了基本的内存分配和释放管理,我们可以使用这些函数来进行一些日常的操作。

使用

写一个test函数测试以下我们的程序的功能性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include "malloc.h"

int main(){
int *arr = calloc(10,sizeof(int));
int **p_arr = calloc(10,sizeof(int*));
int i;
for(i=0;i<10;i++){
arr[i] = i;
p_arr[i] = &arr[i];
}

printf("arr : %p\n",arr);

for(i=0;i<10;i++){
printf("[%p] : %d\n",p_arr[i],*p_arr[i]);
}
}

这里记录一下输出:

image.png

中间那一串十六进制数据是我们内存块的结构体数据,还是很神奇的

刚送一个好兄弟回家,我们最后在分别时在探讨一个问题,大学的意义是什么?

如果是几个月前,我可以很清晰的回答这个问题,但是现在不能了。上了大学确实面对的了很多问题,即使已经做好了准备,但还是准备的不够。究其根本在于哪里——我们还有希望,尚未麻木。我们有理想有目标,有底线有准则。但是这是好的品质吗?是好的选择吗?我们以后不会改变吗?说不准。我没有能力去分析这些对与错,但我还是想思考下去。

现在的年轻人,或者说是大学生,我们需要的是什么?

首先我们可以订下几个基本的事实:

  • 大家追求的是利益
  • 当下的利益增长是缓慢的
  • 大多数人是盲目的

这里我从普遍的情况去进行分析。首先,比起名誉,利益是最直接的,更让人心动的,也是最容易获取的。由于大多数人盲从的特性,大多数人追求的是利益,用我们的话来说就是“搞钱”。但是我们也要意识到一个问题,现在的经济是下行的,是缓滞的,我们的蛋糕不会越来越大了,追求利益的过程从做大蛋糕变成了分大蛋糕。这是当今时代的主要问题,和七八十年代的欣欣向荣不同,在疫情之后的我们始终是暮气沉沉的。大家开始更卷了,因为蛋糕不会越来越大了,你追求更多的利益,就意味着剥夺他人的利益,在这样的社会背景之下,越来越卷是很正常的事情。首当其冲的是传统行业,它们创造的生产力始终是有限的,疫情加速了它们的崩溃,它们的退出导致了第一个问题高失业率。接着是市场环境,生产成本和平均的消费能力在时代背景的冲击下下降了,但是定价并没有下降,出现了又一个问题购买力下降了,大家都变穷了。这个时候社会上有一大批待就业的成员,他们需要钱,而不是一份“体面”的工作。这群人有着不差的工作经验,和初出茅庐的我们相比,他们有更低的底线和更沉重的枷锁。一个很简单的例子,以前你年薪一万,后面失业了,中间有很多一般的工作机会,但是要么工作待遇差,要么工资低,现在你失业半年多了,有一份五千块钱的工作你做不做?只能先这么做了。这种问题每天的都在发生,我们不得不承认,现在赚钱更难了。

这样的情况也影响到了现在的年轻人,我们首先意识到,学历不值钱了,然后意识到工作不好找了。对于将要毕业的大学生,我们通常有两个选择,一是直接就业,步入社会。二是继续读研,或者考公,等待机会。可是在这样的环境下,大家都知道怎么选择更好。我相信大多数人会选择第二条,提升自己的学历,等待更好的就业机会。这就迎来了一个问题,在我们的教育机制中有各种各样的分流机制,现在它失效了,没有人想被分出去(他们认为这是不好的,但真的是这样的吗?)也就是说,有一批并不适合学术研究的人选择向上了。对于学术研究我们要意识到,他需要的并不一定是聪明的人,它更需要的是踏实的人,有耐心的人。可是现在它成了一个避风港,因为这是一个较好的选择,大多数人便选择了这条路,他们不知道也不在乎自己走的是一条什么样的路,只是知道这是对的,就这么走下去。这也正好撞在了学阀们的枪口上,他们就喜欢这样的学生,好压榨,利益至上,可以说是臭味相投。在这样的学术氛围之下,又能做出什么样的学术成果呢?又能培养出什么样的人才呢?可想而知,社会又怎会进步?谁来把蛋糕做大呢?

究其根本,是什么导致了以上的悲剧呢?我还不清楚,但我知道年轻人的悲剧是什么,是盲目与短浅。盲目的人多年后会幡然醒悟,面对现实的苦痛;短浅的人迟早会吃上自己的恶果。清醒的人却在当下苦痛不已,走在成为前者的路上。无法改变太多,只能在现在尽可能的记录,也许现在的想法不是很成熟,但至少能成为将来图自己一笑的谈资。

看到一篇很好的实验性质的文章,在这里学习复现一下:破解虚拟内存

使用环境:

  • Ubuntu 24.04 noble(on the Windows Subsystem for Linux
  • gcc
  • python3.12.3

虚拟内存

首先我们需要明确虚拟内存是什么?

我们的内存在物理地址上实际上并不是连续的,为了方便程序的运行和对内存的使用,我们在使用虚拟内存技术。将物理内存映射到虚拟内存中,这个过程是通过内存管理单元(MMU)进行的。同时,在实际的运行过程中,我们并不希望进程之间互相影响,所以我们需要将它们的内存空间隔离开来,所以在进程眼中,他们都是独占内存的。

image.png

现在你应该大致可以理解,虚拟内存就是为了使应用程序免于管理共享内存、方便内存隔离而使用的一种技术。

接下来,我们可以开始我们的研究实验了,在此之前,我们需要明确以下几点:

  • 每个进程都有自己的虚拟内存
  • 虚拟内存的大小取决于你的计算机架构
  • 每个操作系统处理虚拟内存的方式并不一样,对于大多数现代操作系统而言吗,它们是这样的:
image.png

其中高地址存放了 命令行参数和环境变量栈空间 低地址存放了 可执行文件的部分内容

这个理解可能比较粗糙,之后再进一步进行理解。知道这些,我们的实验就可以开始进行了。

C程序

我们从一个简单的C程序开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>

int main(){

char * s;
unsigned long int i;

s = strdup("Hello World!");
if(s==NULL){
fprintf(stderr,"Can't allocate mem with malloc");
return(EXIT_FAILURE);
}
printf("start:\n");
i = 0;
while(s){
printf("[%ld] %s (%p)\n",i,s,(void *)s);
sleep(1);
i++;
}
return(EXIT_SUCCESS);
}

我们注意到函数strdup,这个函数的原理是:

  • malloc一块内存空间
  • 将字符串复制过去
  • 返回该副本的地址

也就是说我们在,堆上创建了一个字符串,并返回了它的地址。运行效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ ./loop
start:
[0] Hello World! (0x55f033fc82a0)
[1] Hello World! (0x55f033fc82a0)
[2] Hello World! (0x55f033fc82a0)
[3] Hello World! (0x55f033fc82a0)
[4] Hello World! (0x55f033fc82a0)
[5] Hello World! (0x55f033fc82a0)
[6] Hello World! (0x55f033fc82a0)
[7] Hello World! (0x55f033fc82a0)
[8] Hello World! (0x55f033fc82a0)
[9] Hello World! (0x55f033fc82a0)
[10] Hello World! (0x55f033fc82a0)
[11] Hello World! (0x55f033fc82a0)
[12] Hello World! (0x55f033fc82a0)
[13] Hello World! (0x55f033fc82a0)
[14] Hello World! (0x55f033fc82a0)
[15] Hello World! (0x55f033fc82a0)
[16] Hello World! (0x55f033fc82a0)
...

但是怎么证明地址0x55f033fc82a0是在堆上的呢?

我们需要使用我们的文件系统 /proc

文件系统

在linux的根目录下面,有一个目录叫做/proc,我们可以通过操作手册了解他的内容和作用,这里不过多讲述。我们查看/proc下的内容: image.png

我们注意到这些数字,它们是进程标识符(PID),我们可以通过ps aux显示PID对应的进程:

image.png

我们可以看到我们正在运行的C语言程序loop对应的PID是5390

我们回到/proc目录,进入loop对应的PID 的文件夹查看里面的内容

image.png

这里面的文件内容存储着当前进程的信息和内容,通过它们,我们可以深入了解这个进程,这里我们需要关注这两个文件:

  • /proc/[pid]/maps:进程的内存映射详情
  • /proc/[pid]/mem:进程的内存数据

我们可以看看loop进程的内存映射状态:

image.png

可以看到右边的字符串地址出现处于[heap]的地址范围中,所以这里验证了我们strdup确实在堆上创建的了一个字符串数组

现在,我们可以尝试覆写虚拟内存中的字符串了!

覆写字符串

首先我们需要以下信息:

  • 进程的PID
  • 字符串地址在堆上的偏移值
  • 要覆写的内容

然后可以写出参数处理部分:

1
2
3
4
5
6
7
8
9
int main(int argc,char *argv[]){
if(argc!=4){
printf("Usage: ... [pid] [offset](hex) [write]\n");
exit(EXIT_FAILURE);
}
pid = argv[1];
write2mem = argv[3];
offset = strtol(argv[2],NULL,16);
}

接着获取堆的首地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc,char *argv[]){
FILE * mpp;
...
sprintf(maps_filename,"/proc/%s/maps",pid);

char sd[20] = {'0','x'};
long int start_addr;
int j = 2;
mpp = fopen(maps_filename,"r");
/* while (fgets(buffer,sizeof(buffer),mpp) != NULL){
printf("%s",buffer);
}*/
for(int i=0;i<5;i++){
while((c=fgetc(mpp)) != '\n');
}
while((c=fgetc(mpp)) != '-'){
sd[j++] = c;
}
sd[j] = '\0';
start_addr = strtol(sd,NULL,16);
fclose(mpp);
...
}

然后利用偏移值和首地址计算出字符串的地址,从而实现字符串的覆写:

1
2
3
4
5
6
7
8
9
10
int main(int argc,char *argv[]){
FILE * mmp;
sprintf(mem_filename,"/proc/%s/mem",pid);
...
mmp = fopen(mem_filename,"rb+");
fseek(mmp,start_addr+offset,SEEK_SET);
fwrite(write2mem,sizeof(char),strlen(write2mem),mmp);
fclose(mmp);
...
}

完整的程序是:

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
#include<stdio.h>
#include<string.h>
#include<stdlib.h>

char *pid;
char *write2mem;
char mem_filename[20];
char maps_filename[20];
long offset;

void ret();

int main(int argc,char *argv[]){
FILE * mmp;
FILE * mpp;
char buffer[1024];
char c;

if(argc!=4){
ret();
}
pid = argv[1];
write2mem = argv[3];
offset = strtol(argv[2],NULL,16);

sprintf(maps_filename,"/proc/%s/maps",pid);
sprintf(mem_filename,"/proc/%s/mem",pid);

printf("[*] pid = %s\n[*] offset = %ld\n[*] write = %s\n",pid,offset,write2mem);
printf("[*] maps_filename = %s\n",maps_filename);
printf("[*] mem_filename = %s\n",mem_filename);

char sd[20] = {'0','x'};
long int start_addr;
int j = 2;
mpp = fopen(maps_filename,"r");
/* while (fgets(buffer,sizeof(buffer),mpp) != NULL){
printf("%s",buffer);
}*/
for(int i=0;i<5;i++){
while((c=fgetc(mpp)) != '\n');
}
while((c=fgetc(mpp)) != '-'){
sd[j++] = c;
}
sd[j] = '\0';
start_addr = strtol(sd,NULL,16);
fclose(mpp);

mmp = fopen(mem_filename,"rb+");
fseek(mmp,start_addr+offset,SEEK_SET);
fwrite(write2mem,sizeof(char),strlen(write2mem),mmp);
fclose(mmp);
}

void ret(){
printf("Usage: ... [pid] [offset](hex) [write]\n");
exit(EXIT_FAILURE);
}

我们可以运行以下看看效果

image.png

可以看到效果很成功。我们成功的修改了指定的内存

附录

这里提供一下作者用Python 写的一个覆写程序,更好用:

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#!/usr/bin/env python3
'''
Locates and replaces the first occurrence of a string in the heap
of a process

Usage: ./read_write_heap.py PID search_string replace_by_string
Where:
- PID is the pid of the target process
- search_string is the ASCII string you are looking to overwrite
- replace_by_string is the ASCII string you want to replace
search_string with
'''

import sys

def print_usage_and_exit():
print('Usage: {} [pid] [search_string] [write_string]'.format(sys.argv[0]))
sys.exit(1)

# check usage
if len(sys.argv) != 4:
print_usage_and_exit()

# get the pid from args
pid = int(sys.argv[1])
if pid <= 0:
print_usage_and_exit()
search_string = str(sys.argv[2])
if search_string == "":
print_usage_and_exit()
write_string = str(sys.argv[3])
if search_string == "":
print_usage_and_exit()

# open the maps and mem files of the process
maps_filename = "/proc/{}/maps".format(pid)
print("[*] maps: {}".format(maps_filename))
mem_filename = "/proc/{}/mem".format(pid)
print("[*] mem: {}".format(mem_filename))

# try opening the maps file
try:
maps_file = open('/proc/{}/maps'.format(pid), 'r')
except IOError as e:
print("[ERROR] Can not open file {}:".format(maps_filename))
print(" I/O error({}): {}".format(e.errno, e.strerror))
sys.exit(1)

for line in maps_file:
sline = line.split(' ')
# check if we found the heap
if sline[-1][:-1] != "[heap]":
continue
print("[*] Found [heap]:")

# parse line
addr = sline[0]
perm = sline[1]
offset = sline[2]
device = sline[3]
inode = sline[4]
pathname = sline[-1][:-1]
print("\tpathname = {}".format(pathname))
print("\taddresses = {}".format(addr))
print("\tpermisions = {}".format(perm))
print("\toffset = {}".format(offset))
print("\tinode = {}".format(inode))

# check if there is read and write permission
if perm[0] != 'r' or perm[1] != 'w':
print("[*] {} does not have read/write permission".format(pathname))
maps_file.close()
exit(0)

# get start and end of the heap in the virtual memory
addr = addr.split("-")
if len(addr) != 2: # never trust anyone, not even your OS :)
print("[*] Wrong addr format")
maps_file.close()
exit(1)
addr_start = int(addr[0], 16)
addr_end = int(addr[1], 16)
print("\tAddr start [{:x}] | end [{:x}]".format(addr_start, addr_end))

# open and read mem
try:
mem_file = open(mem_filename, 'rb+')
except IOError as e:
print("[ERROR] Can not open file {}:".format(mem_filename))
print(" I/O error({}): {}".format(e.errno, e.strerror))
maps_file.close()
exit(1)

# read heap
mem_file.seek(addr_start)
heap = mem_file.read(addr_end - addr_start)

# find string
try:
i = heap.index(bytes(search_string, "ASCII"))
except Exception:
print("Can't find '{}'".format(search_string))
maps_file.close()
mem_file.close()
exit(0)
print("[*] Found '{}' at {:x}".format(search_string, i))

# write the new string
print("[*] Writing '{}' at {:x}".format(write_string, addr_start + i))
mem_file.seek(addr_start + i)
mem_file.write(bytes(write_string, "ASCII"))

# close files
maps_file.close()
mem_file.close()

# there is only one heap in our example
break

上一篇中学习了基本的本地的版本控制,现在进一步的学习Git的使用。

远程仓库

现在我们需要找一个网站用来为我们提供Git仓库托管服务,这个网站叫做Github,这个网站为我们提供了Git仓库的托管服务,不过我们首先需要注册一个账号,这里我已经搞完了。

本地的Git仓库和Github仓库之间的传输时通过SSH设置的,所以我们需要设置:

创建SSH Key:这个我在本机上已经搞过了,这次在WSL上也搞一次,输入指令

1
$ ssh-keygen -t rsa -C "youremail@example.com"

然后你就会看到这个界面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ ssh-keygen -t rsa -C "3280661240@qq.com"
Generating public/private rsa key pair.
Enter file in which to save the key (/home/ylin/.ssh/id_rsa):
Created directory '/home/ylin/.ssh'.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/ylin/.ssh/id_rsa
Your public key has been saved in /home/ylin/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:7Y9JTYk3YxznFF9ODtZ5xak0GKB5t4Llw1eK/HRabBw 3280661240@qq.com
The key's randomart image is:
+---[RSA 3072]----+
| ...o +oB|
| o . + X=|
| o o .oE= =|
| B.oo*B. |
| .SBo=O*. |
| .*==o |
| oo. |
| . + |
| o . |
+----[SHA256]-----+

Enter passphrase处可以输入你的密码(用于生成私钥)

配置公钥文件:获取公钥的内容

1
2
$ cat id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCg9NIo/g5YB3ghujWZyhqN4XL4Zu4lowZh66OefI0jwdqJ6LRPcxQfmM7zw6EKK6HZID0OtrkfP7Rohcwo4D1rZi4R6I6V8hXSsM3OxP8NlDo4OvG6sheJS4SWNF5ajjjAzZaFYxPvtR7zvVaw0640w6iAOKlc55hNvFf35a647W0o3OzCK/B+/knduY4WYdn7ApBBPM8Ktwf4BHVS5098PpJeu8w4SZIMe59O4iRbpICrnmeKaPkf/U3bLqvhOAwFkyW7W/ql6B7uh7hzbPmTbKNvT12Zykk8JcbJv5Wd5PVVULfFNbmVqckrdJ+xNs6RqVfUFG0cuhI7b16WGcoNWnCW...

然后复制内容到这里:

image.png
image.png

现在我们就添加成功啦。

SSH Key的作用是,Github需要识别处你推送的提交是你的推送的,而不是别人冒充的。同时由于RSA加密的特性,公钥是可以公开的,但是私钥是由自己保管的,从而确保了加密的安全性。在此基础上,我们可以开始远程仓库的使用了。

添加远程库

现在我们在本地已经有了一个版本库,我们再到Github创建一个仓库,让这两个仓库进行远程同步。这样Github上的仓库既可以作为备份,也可以让其他人通过仓库来协作。

首先在Github上面创建一个新的仓库:

image.png

一个Github仓库就这样建好了,现在我们将其和我们的本地仓库关联起来。根据Github下面的提示,我们在本地的仓库里面运行这些命令:

1
$ git remote add origin git@github.com:Ylin07/Learn_Git.git

添加后,远程库的名字就是origin,这是Git的默认叫法。下一步,我们将本地库的所有内容推送到远程库上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git push -u origin master
The authenticity of host 'github.com (140.82.112.4)' can't be established.
ED25519 key fingerprint is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'github.com' (ED25519) to the list of known hosts.
Enter passphrase for key '/home/ylin/.ssh/id_rsa':
------------------------------------
Enumerating objects: 22, done.
Counting objects: 100% (22/22), done.
Delta compression using up to 32 threads
Compressing objects: 100% (14/14), done.
Writing objects: 100% (22/22), 1.72 KiB | 1.72 MiB/s, done.
Total 22 (delta 3), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (3/3), done.
To github.com:Ylin07/Learn_Git.git
* [new branch] master -> master
branch 'master' set up to track 'origin/master'.

其中由于,这是第一次关联,我们需要验证公钥指纹,然后将身份添加到~/.ssh/known_hosts中,同时需要输入密钥验证,这里我用分割线把这一部分区分出来。下面则是我们将本地仓库推送上去的信息。

由于远程库是空的,我们第一次推送master分支,使用了-u参数,Git不但会把本地的master分支推送上去,还会把本地的master和远程的master分支关联起来,这样在之后的推送和拉去就可以简化命令。

然后我们就可以在Github页面中看到远程库的内容和本地是一样的

image.png

从现在开始,只要本地作了提交,就可以通过命令:

1
$ git push origin master

把本地的master分支的最新修改推送到GIthub,现在我们就有了完整的分布式版本库。

删除远程库

如果添加的时候地址写错了,或者是想删除远程库,可以使用git remote rm <name>命令。使用前,我们先使用git remote -v来查看远程库的信息:

1
2
3
$ git remote -v
origin git@github.com:Ylin07/Learn_Git.git (fetch)
origin git@github.com:Ylin07/Learn_Git.git (push)

然后根据名字删除,比如删除origin:

1
$ git remote rm origin

此处的删除实际上是解除了本地和远程的关联状态,并不是删除了远程库。远程库本身并没有改动,如果想要删除远程库,应该到Github后台删除。

克隆远程仓库

上次我们讲了现有本地库,再有远程库的时候,如何关联远程库。现在我们从头开始,假如我们从远程库开始克隆该怎么做呢?

我们把这个网址的项目给clone下来8086

1
2
3
4
5
6
7
8
9
$ git clone git@github.com:Rexicon226/8086.git
Cloning into '8086'...
Enter passphrase for key '/home/ylin/.ssh/id_rsa':
remote: Enumerating objects: 31, done.
remote: Counting objects: 100% (31/31), done.
remote: Compressing objects: 100% (26/26), done.
remote: Total 31 (delta 6), reused 27 (delta 4), pack-reused 0 (from 0)
Receiving objects: 100% (31/31), 6.61 KiB | 1.65 MiB/s, done.
Resolving deltas: 100% (6/6), done.

OK,然后看看文件夹,已经被远程库的内容已经被拉下来了:

1
2
3
$ cd 8086
$ ls
bootloader CMakeLists.txt emulator README.md

当然我们还可以使用其他的方法,比如https协议,只不过这个对网络环境有一定的要求。

1
git clone https://github.com/Rexicon226/8086.git

现在我们就掌握了对于远程仓库的基本操作啦。

分支管理

当你和别人共同开发一个项目时,你们可以各自创建一个分支,不同的分支拥有自己的进度,互不打扰。再各自的项目完成之后,再将分支合并,从而实现同时开发的效果,这就是Git的分支管理功能。

创建与合并分支

在版本回退中我们知道,Git把提交串成一条时间线,这个时间线就是一个分支。当目前为止,我们只有一个分支,这个分支就叫主分支master,而其中的HEAD指针并不直接指向提交的,它指向的是master,而master指向的是提交,所以我们说HEAD指向的是当前的分支。

一开始的时候,master分支是一条线,Git用master指向最新的提交,再用HEAD指向master,就能确定当前分支,以及当前分支的提交点:

1
2
3
       HEAD --> master
|
[ ]-->[ ]-->[ ]

每次提交master分支就向前移动一步,这样随这不断的提交,master分支也会越来越长。

当我们创建新的分支new时,Git新建了一个指针叫new,指向master相同的提交,再把HEAD指向new,就表示当前分支在new上:

1
2
3
4
5
               master
|
[ ]-->[ ]-->[ ]
|
HEAD --> new

由此可以看出,Git创建一个分支很快,实际上就是增加了一个new指针,然后改变一下HEAD的指向

不过接下来,对于工作区的修改与提交就是针对new了,比如提交一次之后会变成这样:

1
2
3
4
5
                master
|
[ ]-->[ ]-->[ ]-->[ ]
|
HEAD --> new

假如我们在分支new上的工作完成了,就可以把new合并到master上。Git怎么合并呢,实际上就是将master移动到和new指向相同的版本上,然后将HEAD指向master

1
2
3
4
5
               HEAD --> master
|
[ ]-->[ ]-->[ ]-->[ ]
|
new

合并完成之后,甚至可以删除new分支,我们直接将其new指针删除既可,这样就只剩下一个mater分支:

1
2
3
               HEAD --> master
|
[ ]-->[ ]-->[ ]-->[ ]

这样相当于用分支的功能完成了提交,这样更加安全。

现在我们来进行尝试:

首先创建一个new分支,然后切换过去

1
2
$ git checkout -b new
Switched to a new branch 'new'

git checkout命令加上-b参数表示创建并切换,相当于以下两个命令的组合

1
2
3
4
5
6
$ git branch new
$ git branch
* master
new
$ git checkout new
Switched to branch 'new'

git branch命令会列出所有的分支,当前分支前会有一个*号。然后我们在new分支上修改后正常提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "branch test"
[new 4c8096e] branch test
1 file changed, 1 insertion(+)

现在dev的分支工作完成,我们切换回master分支,然后再查看readme.txt

1
2
$ git checkout master
Switched to branch 'master'

然后我们打开readme.txt发现原来的修改怎么不见了。因为刚刚提交的修改在new分支上,此时master分支的提交点并没有变:

1
2
3
4
5
       HEAD --> master
|
[ ]-->[ ]-->[ ]-->[ ]
|
new

现在我们将new分支的工作成果合并到master分支上:

1
2
3
4
5
$ git merge new
Updating f940b04..4c8096e
Fast-forward
readme.txt | 1 +
1 file changed, 1 insertion(+)

git merge命令用于合并指定分支到当前分支。合并后,再查看readme.txt的内容,可以看到现在和最新提交是一样的了。

注意到上面的Fast-forward信息,它的意思是这次合并是“快进模式”,也就是把master指向dev的当前提交,所以很快

合并之后,我们将new分支删除,并查看branch,只剩下了master

1
2
3
4
$ git branch -d new
Deleted branch new (was 4c8096e).
$ git branch
* master

由于创建,合并和删除分支非常快,所以Git更加鼓励使用分支完成任务,然后再删除分支,这样比直接在master上工作的效果是一样的,但是过程更加的安全。

switch

切换分支,除了使用git checkout <branch>,还可以使用switch来实现:

  • 创建并切换到新得new分支,可以用:git switch -c new
  • 直接切换到已有得master分支,可以用:git switch master

解决冲突

有时候合并也会遇到各种冲突,现在我们手动制造一个冲突,我们创建一个新的分支n1:

1
2
$ git switch -c n1
Switched to a new branch 'n1'

最后一行加个01234,然后提交修改

1
2
3
4
$ git add readme.txt
$ git commit -m "01234"
[n1 6d5f5d1] 01234
1 file changed, 1 insertion(+)

然后切换回master分支,然后对readme.txt做不一样的修改,并提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "56789"
[master 38fe2c0] 56789
1 file changed, 1 insertion(+)

现在mastern1分支各自都分别有新的提交:

1
2
3
4
5
                  +-->[   ] <-- master <-- HEAD
|
[ ]-->[ ]-->[ ]
|
+-->[ ] <-- n1

这种情况下,Git没办法快速合并,只能视图把各自的修改合并起来:

1
2
3
4
$ git merge n1
Auto-merging readme.txt
CONFLICT (content): Merge conflict in readme.txt
Automatic merge failed; fix conflicts and then commit the result.

结果提示了冲突,我们需要手动解决冲突后再提交,我们可以用git status告诉我们冲突的文件:

1
2
3
4
5
6
7
8
9
10
11
$ git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)

Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

我们打开readme.txt看看:

1
2
3
4
5
6
7
8
9
10
11
12
Hello Git!!!
I love you!
Sorry I love her more.
Please forgive me.
The first modify.
The second modify.
Test new branch.
<<<<<<< HEAD
56789
=======
01234
>>>>>>> n1

Git 用<<<<<<<,=======,>>>>>>>标记出不同分支的内容,我们修改后再保存

我们修改之后再保存:

1
2
3
4
5
6
7
8
Hello Git!!!
I love you!
Sorry I love her more.
Please forgive me.
The first modify.
The second modify.
Test new branch.
00000

再提交:

1
2
3
$ git add readme.txt
$ git commit -m "conflict mixed"
[master f17b017] conflict mixed

现在我们的版本库变成了这样:

1
2
3
4
5
6
                  +-->[   ]-->[   ] <-- master <-- HEAD
| ^
[ ]-->[ ]-->[ ] |
| |
n1 --> +-->[ ] ———— +

我们可以用带参数的got log可视化的看到我们分支的合并情况:

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

$ git log --graph --pretty=oneline --abbrev-commit
* f17b017 (HEAD -> master) conflict mixed
|\
| * 6d5f5d1 (n1) 01234
* | 38fe2c0 56789
|/
* 4c8096e branch test
* f940b04 A test
* 4c16e7a The last patch
* c34bcbc git tracks change

* 6801700 add LICENSE & Pls
* da91da7 add sorry
* 20deae4 add my love
* 1428371 wrote a read file

解释以下标签的意思:

  • --graph:以字符可视化的方式打印分支流程
  • --pretty=oneline:以一行压缩打印,不显示id之外的信息
  • --abbrev-commit:简略id信息,只显示一部分

合并之后,我们删除n1分支:

1
2
3
4
$ git branch -d n1
Deleted branch n1 (was 6d5f5d1).
$ git branch
* master

分支管理策略

通常合并分支时,Git会优先使用Fast forward的模式,但这种模式下,删除分支,会丢失分支信息

如果我们要强制禁用Fast forward模式,Git就会在merge时生成一个新的commit,这样就从分支历史上可以看出分支信息,下面我们尝试一个--no-ff方法的git merge

首先创建一个分支,并修改内容,然后提交修改:

1
2
3
4
5
6
7
$ git switch -c dev
Switched to a new branch 'dev'
$ ni readme.txt
$ git add readme.txt
$ git commit -m "try new merge"
[dev f769ab7] try new merge
1 file changed, 1 insertion(+), 1 deletion(-)

然后我们切换回master并使用--no-ff参数,以禁用Fast forward:

1
2
3
4
5
6
$ git switch master
Switched to branch 'master'
$ git merge --no-ff -m "merge with no-ff" dev
Merge made by the 'ort' strategy.
readme.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

因为本次合并会产生新的commit,所以加上-m参数,把commit描述加进去。然后用git log查看历史:

1
2
3
4
5
6
7
8
$ git log --graph --pretty=oneline --abbrev-com
mit
* 806dc97 (HEAD -> master) merge with no-ff
|\
| * f769ab7 (dev) try new merge
|/
* f17b017 conflict mixed
...

可以看到不用Fast forward模式,merge后就像这样:

1
2
3
4
5
                  +-->[   ] <-- master <-- HEAD
| ^
[ ]-->[ ]-->[ ] |
| |
+-->[ ] <-- n1

分支策略

在实际开发种,我们应该明确几个基本原则,进行分支管理:

  • 首先master分支应该稳定的,仅用来发布新版本,不能在上面干活
  • 平时应该在dev分支上干活,只有特定版本可以发布时,我们再将devmaster分支进行合并
  • 每个人又自己的分支,当完成特定功能时,再向dev分支合并

因此,团队协作更像是这样的:

image.png

朋友圈里看到同学用Git,觉得图形化还是很帅的。加上最近刚好有需求,所以就来系统的学习一下git的使用。

这里参考的教程是:简介 - Git教程 - 廖雪峰的官方网站

先从git在本地的简单使用开始学习一下吧,明天再接着讲讲git的远程使用

Git

git是一款分布式的版本管理器。

分布式的意思就是一个每个开发者人的工作区就是一个完整的版本库。你的修改,和分支都可以再本地仓库里面完成,然后再推送到远程仓库中。不同的开发者之间通过向远程仓库push修改,或者从远程仓库pull操作,从而实现同步代码。

初见Git

这里我选择在WSL中使用git,可以通过sudo apt-get install git下载git

然后我们需要对我们git进行配置,我们需要初始化自己的信息:

1
2
$ git config --global user.name "Your Name"
$ git config --global user.email "email@example.com"

这里的--global参数指的是对本机器的所有仓库都使用这个信息,当然你也可以在不同的仓库中使用不同的用户信息。

版本库

我们刚刚所说的仓库实际上就是版本库(Repository),之后我们会频繁的看到这个单词。在这里,你可以讲仓库理解成一个目录,里面的内容被git管理起来(被其跟踪修改,记录历史,或者在之后用来”还原”)

首先我们创建一个版本库作为我们的练习:

1
2
3
4
$ mkdir learn_git
$ cd learn_git/
$ pwd
/home/ylin/Program/learn_git

然后使用git init对我们的仓库进行初始化:

1
2
$ git init
Initialized empty Git repository in /home/ylin/Program/learn_git/.git/

然后我们可以在当前目录下找到一个.git目录,不过它是隐藏的,所以我们使用ls -ah来找到它,这个目录用来跟踪管理版本库的。

向版本库中添加文件

这里我们需要搞清楚一件事,就是所有的版本控制器,只能管理文本文件,无法管理二进制文件。你可以管理文本文件,哪里被修改了,哪里增加了,但是你不能追踪一张图片被做了什么修改。

现在我们向我们的鹅目录下写一个readme.txt文件:

1
Hello Git!

接下来我们将其提交到我们的仓库,需要以下过程:

  • 将文件添加到仓库:git add readme.txt
  • 将文件提交到仓库:git commit -m "..."(-m 后面用来添加改动记录,也可以不加)

这个是加入仓库后的结果:

1
2
3
[master (root-commit) 1428371] wrote a read file
1 file changed, 1 insertion(+)
create mode 100644 readme.txt

其中1 file changed是因为我们添加了一个文件;1 insertion(+)是因为我们添加了一行内容

这里的git addgit commit之所以分开的原因后面会解释。现在我们只需要知道,add可以添加很多次很多内容,commit则是将添加的内容全部提交上去

版本管理

我们刚刚成功的添加并提交了readme.txt文件,现在我们继续工作,向其中添加内容,改成:

1
2
Hello Git!!!
I love you!

现在运行git status命令看看结果:

1
2
3
4
5
6
7
8
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

通过这个命令我们可以时刻掌握仓库当前的状态,例如上面的信息告诉我们,readme.txt的内容被修改了,但是我们还没有提交该修改。

可是我们只知道文件被修改了,但我们并不知道哪些地方被修改了,所以我们使用git diff来查看一下:

1
2
3
4
5
6
7
8
9
$ git diff readme.txt
diff --git a/readme.txt b/readme.txt
index 106287c..94d4f0e 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1 +1,2 @@
-Hello Git!
+Hello Git!!!
+I love you!

顾名思义这个指令是用来查看difference的,显示的格式是Unix通用的diff格式,注意其中的@@ -1 +1,2 @@-后面的两个值分别是原始文件的差异起始行和差异行数,+后面的两个值分别是修改文件的差异起始行和差异行数。这些信息常用于定位文件的修改内容和修改范围。知道文件哪些地方被修改之后,我们再次将我们的readme.txt提交到我们的仓库中:

1
2
3
4
5
6
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

我们再次运行git status看看当前仓库的状态,我们看到我们将要提交的修改的内容有readme.txt

现在我们将其提交git commit:

1
2
3
$ git commit -m "add my love"
[master 20deae4] add my love
1 file changed, 2 insertions(+), 1 deletion(-)

然后再看看仓库,git status:

1
2
3
$ git status
On branch master
nothing to commit, working tree clean

Git告诉我们,当前没有要提交的内容,且工作目录是干净的

版本回退

我现在已经了解了怎么修改,我们尝试对其进行再一次的修改和提交:

1
2
3
Hello Git!!!
I love you!
Sorry I love her more.

然后再次提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "add sorry"
[master da91da7] add sorry
1 file changed, 2 insertions(+), 1 deletion(-)

这个过程在实际的项目中会重复很多次,每当文件修改到一定的程度我们将其commit,即保存一个快照。一但我们的文件出现了错误,我们就可以用Git来回到之前的版本,接下来我们将演示这个过程。

现在我们的Git仓库里一共有三个版本,但是我们并不记得每次改动了哪些内容。我们现在可以使用git log命令来查看我们的版本控制系统中的历史记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git log
commit da91da7e64267eb6f293ffc8b0bde18626777d58 (HEAD -> master)
Author: Ylin07 <3280661240@qq.com>
Date: Fri Apr 18 23:22:35 2025 +0800

add sorry

commit 20deae4e44bc6786110d3beeda5c86ba55b75f23
Author: Ylin07 <3280661240@qq.com>
Date: Fri Apr 18 23:18:16 2025 +0800

add my love

commit 1428371d4f5f753c24e22e4311f6c11b3ff0656b
Author: Ylin07 <3280661240@qq.com>
Date: Fri Apr 18 22:47:36 2025 +0800

wrote a read file

git log命令显示从最近到最远的提交日志,如果你认为输出的信息太多,可以试试git log --pretty=oneline参数:

1
2
3
4
$ git log --pretty=oneline
da91da7e64267eb6f293ffc8b0bde18626777d58 (HEAD -> master) add sorry
20deae4e44bc6786110d3beeda5c86ba55b75f23 add my love
1428371d4f5f753c24e22e4311f6c11b3ff0656b wrote a read file

然后你会看到前面一大堆看不懂的东西,这个是commit id,每一次提交,都有对应的id,原理是SHA1计算

现在我们想要把readme.txt回到上一个版本,怎么办?首先需要知道,当前是什么版本(就是最新提交的14283…),当前的版本是HEAD,上一个版本是HEAD^,上上个版本就是HEAD^^,那么上n个版本就是HEAD~n

现在哦我们将当前版本add sorry回退到上一个版本add love,我们使用git reset:

1
2
$ git reset --hard HEAD^
HEAD is now at 20deae4 add my love

--hard参数会回退到上个版本的已提交状态,--soft会回退到上个版本的未提交状态,--mixed 会回退到上个版本已添加但未提交的状态,这里我们使用--hard

我们可以继续回退,但是我们现在也要面临一个问题,我们没办法回去了,有办法吗?有的兄弟,有的。我们先看下log

1
2
3
$ git log --pretty=oneline
20deae4e44bc6786110d3beeda5c86ba55b75f23 (HEAD -> master) add my love
1428371d4f5f753c24e22e4311f6c11b3ff0656b wrote a read file

发现之前的commit id没了,这咋办呢?我们使用git reflog查看HEAD指针的历史记录:

1
2
3
4
5
$ git reflog
20deae4 (HEAD -> master) HEAD@{0}: reset: moving to HEAD^
da91da7 HEAD@{1}: commit: add sorry
20deae4 (HEAD -> master) HEAD@{2}: commit: add my love
1428371 HEAD@{3}: commit (initial): wrote a read file

我们可以看到da91da7是先前版本的commmit id,我们使用git reset返回这个版本:

1
2
$ git reset --hard da91da7
HEAD is now at da91da7 add sorry

我们成功的回到了这个版本(这里版本号不需要写全,Git会自己去查找),就这样我们实现了git的版本控制

Git版本回退的速度特别快,你可能会好奇这是为啥,这是因为Git内部有一个指向当前版本的指针HEAD,当你回退版本的时候,仅仅只是把HEAD指向了前一个版本。然后顺便把工作区的文件更新了。

工作区和暂存区

这个概念是Git特有的一个概念,我们先对其进行解释:

  • 工作区:实际上就是再电脑中能看到的目录
  • 版本库:工作区中有一个隐藏的目录.git,这个不算工作区,而是Git的版本库。其中版本库里有很多东西,其中一个比较重要的内容就是stage(也叫index),也就是暂存区。以及Git为我们自动创建的一个分支master,以及指向master的一个指针HEAD
image.png

前面我们讲到,将文件加入Git版本库中的时候,是分两步执行的,现在我们可以解释了 :

  • git add 实际上就是将文件添加到暂缓区
  • git commit 实际上就是将暂存区中的所有内容提交到当前分支中

创建版本库的时候,Git为我们创建了一个mater分支,也就是当前的git commit都是向master分支上提交更改

现在,我们带着这个思考再次进行这个过程,我们修改一下readme.txt并添加一个LICENSE文件:

1
2
3
4
5
6
7
8
9
10
11
12
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

Untracked files:
(use "git add <file>..." to include in what will be committed)
LICENSE

no changes added to commit (use "git add" and/or "git commit -a")

我们可以看到,readme.txt被改动的信息,还有LICENSE被告知没有被添加过。我们用git add添加后再次查看:

1
2
3
4
5
6
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: LICENSE
modified: readme.txt

现在我们的暂存区变成了这样:

image.png

然后再将其提交,并查看status状态:

1
2
3
4
5
6
7
$ git commit -m "add LICENSE & Pls"
[master 6801700] add LICENSE & Pls
2 files changed, 2 insertions(+)
create mode 100644 LICENSE
$ git status
On branch master
nothing to commit, working tree clean

现在的我们的版本库变成了这样,暂存区没有内容了:

image.png

管理修改

讲到这里就不得不介绍一下,Git相较于其他版本控制系统的优越之处。这是因为Git管理的不是那文件内容,而是对于文件的修改内容。这样极大的减少了系统的管理成本。

我们尝试以下过程,以验证我们提交的是修改,而不是文件内容:

第一次修改后,将其添加至缓冲区:

1
2
3
4
5
6
$ git add readme.txt
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

然后我们再进行一次修改,接着我们直接提交到仓库:

1
2
3
4
5
6
7
8
9
10
11
$ git commit -m "git tracks change"
[master c34bcbc] git tracks change
1 file changed, 1 insertion(+)
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

但是我们发现我们的第二次修改并没有被提交,这就验证了我们的想法。我们提交的是第一次的修改,并不是最终的文件的样子,所以证明得到:提交的是修改而不是文件。

现在我们可以使用git diff HEAD -- readme.txt查看工作区和版本库中的最新版本的区别:

1
2
3
4
5
6
7
8
9
10
$ git diff HEAD -- readme.txt
diff --git a/readme.txt b/readme.txt
index 0394257..135beba 100644
--- a/readme.txt
+++ b/readme.txt
@@ -3,3 +3,4 @@ I love you!
Sorry I love her more.
Please forgive me.
The first modify.
+The second modify.

我们再次将其提交,git add –> git commit

撤销修改

撤销工作区的修改

有时候我们会犯错,如果错误发现的及时我们可以手动纠正回来,但是纠正前使用git status你可以看到以下信息:

1
2
3
4
5
6
7
8
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

这里提到可以使用git restore <file>...恢复工作区到暂存区的状态。

1
2
3
4
5
6
7
8
9
10
11
12
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")
$ git restore readme.txt
$ git status
On branch master
nothing to commit, working tree clean

当然你也可以进一步使用参数git restore --source=HEAD~n -- readme.txt来指定将工作区恢复到哪次提交的版本

撤回暂存区修改

当然,我们还可能遇到另外一种情况,就是我们已经将错误git add到了暂存区,我们在commit之前发现了这个错误,我们该怎么修改呢?我们使用git status:

1
2
3
4
5
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

Git 提醒我们可以使用git restore --staged <file>...来撤销最近一次的git add内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

$ git restore --staged -- readme.txt
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")
ylin@Ylin:~/Program/learn_git$

我们成功的撤销了暂存区的内容,接着我们再次撤销工作区的修改:

1
2
3
4
$ git restore readme.txt
$ git status
On branch master
nothing to commit, working tree clean

现在我们实现了对工作区和暂存区的撤回操作

版本库撤销

即版本回退,见上。

删除文件

在git中,删除也是一个修改操作,我们可以进行尝试。

我们向创建一个新文件test.txt并提交

1
2
3
4
5
$ git add test.txt
$ git commit -m "A test"
[master f940b04] A test
1 file changed, 1 insertion(+)
create mode 100644 test.txt

然后我将其从工作区中删除,再使用git status查看状态:

1
2
3
4
5
6
7
8
9
$ rm test.txt
$ git status
On branch master
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: test.txt

no changes added to commit (use "git add" and/or "git commit -a")

Git提醒我们这个文件被删除了,且指导我们可以使用git rm将其从版本库中删除,并且git commit:

1
2
3
4
5
6
$ git rm test.txt
rm 'test.txt'
$ git commit -m "remove git status"
[master 75303ec] remove git status
1 file changed, 1 deletion(-)
delete mode 100644 test.txt

现在文件就从版本库中删除了。

当然也有可能这个文件被你误删了,如果你上一次提交了这个文件的i需改,我们可以使用git restore --source=<commit_hash> <file_name>恢复到上一次提交的版本,不过还是会丢失你之后额外修改的内容。

至此,我们对于Git的基本使用就已经基本掌握了

今天是2025年4月17号,我打算从今天开始我未来的每月总结。

其实月中也挺好的,先忙半个月,然后看看有没有效果。然后再指正接下来半个月的要做什么。

我也不知道应该反省什么,我的上个月挺糟糕的。月初还算高兴吧,后面我女朋友的寝室出了点事情,她的室友意外去世了。我女朋友因为这个心理状态也不是很好,接下来的半个月,我一直在陪她。中途我和室友出去玩了一趟,从宣城,到马鞍山,再到南京。回来之后,我的心情挺好的,我的女朋友心情也好受了很多。但是后面她突然开始一直发烧,白天低烧,晚上高烧,一直到清明节之后。这段时间过的挺煎熬的,我自己也很多事情,很多时候我没有很好的照顾到她。她也很理解我。

然后再是这个月,我刚结束了图形学的基本学习。然后休息了两天,一直到现在,我觉得我好累,有点迷茫了。现阶段要学习的和要处理的东西特别多,所以我打算停下来缕一缕。

接下来打算学习的内容比较多,现在不知道学什么好,可以大致的列一下:

  • 计算机系统深入学习(CSAPP阅读,PWN的深入理解)
  • 巩固语言体系(C++继续学习,尝试学习Go)
  • 算法学习(C++深入学习,算法知识学习)
  • 学校课程学习(高等数学,线性代数,大学物理,英语四级准备)
  • 项目学习(Linux程序调试器, 2D物理引擎)

我现在有点搞不清学习顺序了,都做的话是不太可能的,所以现在慢慢理一理。

其中算法学习可以拖延至暑假,学校课程学习可以放到五六月开始,项目学习可以在计算机学习之后的一段空余时间继续。嗯,然后C++必须尽快学完,这是项目练习和算法学习的基础。

这么看来优先是这样的:

  1. C++深入学习
  2. 计算机系统学习
  3. Go语言学习

然后这个月我打算上完蒋炎岩的操作系统课程,看看能不能写出一个简单的操作系统。暂时就这些吧,然后是开始准备上高数课,不然进度上有点跟不上,英语记得的话背点单词吧。

关于我的心情,最近也是好多了,最近开始玩泰拉瑞亚,慢慢玩吧,要是能通关就好了,不急呐。还有怪物猎人,记得的话就玩一下呗。

马上五一劳动节,打算没事就学习,然后陪陪女朋友,和好兄弟出去玩一会。

就这样吧,写一写还是很好玩的。

两个星期过去了,我们的图形学入门之旅也是迎来了终章。

最后就以一个完美的作品来作为纪念我们图形学学习的短暂结束。

我们设置main函数:

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
66
67
68
69
#include "rtweekend.h"

#include "camera.h"
#include "hittable.h"
#include "hittable_list.h"
#include "material.h"
#include "sphere.h"

int main(){
hittable_list world;

auto ground_material = make_shared<lambertian>(color(0.5,0.5,0.5));
world.add(make_shared<sphere>(point3(0,-1000,0),1000,ground_material));

for(int a = -11;a < 11;a++){
for(int b = -11;b < 11;b++){
auto choose_mat = random_double();
point3 center(a + 0.9*random_double(), 0.2, b + 0.9*random_double());

if((center - point3(4,0.2,0)).length() > 1.0){
shared_ptr<material> sphere_material;

if(choose_mat < 0.8){
//漫反射
auto albedo = color::random() * color::random();
sphere_material = make_shared<lambertian>(albedo);
world.add(make_shared<sphere>(center,0.2,sphere_material));
}else if(choose_mat < 0.95) {
//金属
auto albedo = color::random(0.5,1);
auto fuzz = random_double(0,0.5);
sphere_material = make_shared<metal>(albedo,fuzz);
world.add(make_shared<sphere>(center,0.2,sphere_material));
}else{
//玻璃
sphere_material = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(center,0.2,sphere_material));
}
}
}
}

auto material1 = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(point3(0,1,0),1.0,material1));

auto material2 = make_shared<lambertian>(color(0.4, 0.2, 0.1));
world.add(make_shared<sphere>(point3(-4, 1, 0), 1.0, material2));

auto material3 = make_shared<metal>(color(0.7, 0.6, 0.5), 0.0);
world.add(make_shared<sphere>(point3(4, 1, 0), 1.0, material3));

camera cam;

cam.aspect_ratio = 16.0 / 9.0;
cam.image_width = 800;
cam.samples_per_pixel = 100;
cam.max_depth = 50;


cam.vfov = 20;
cam.lookfrom = point3(13,2,3);
cam.lookat = point3(0,0,0);
cam.vup = vec3(0,1,0);

cam.defocus_angle = 0.6;
cam.focus_dist = 10.0;

cam.render(world);
}
image.png

两个星期的学习,都在这张图片之中了

今日收官之战,图形学之旅差不多到此为止了

可定位相机

我们的相机目前是固定视角的,比较单调。由于程序的复杂性,所以在这里我们最后实现它。首先,我们需要为我们的相机添加视场(fov)效果。这是从渲染图像一边到另外一边的视觉角度。由于我们的图像不是正方形视图,所以水平和垂直的fov是不同的。这里我们选择开发垂直fov。我们用度数来指定它,然后再构造函数中将其转换为弧度。

计算机视几何

我们继续保持从原点发出的光线,指向z轴上的平面,然后使h与这个距离的比例保持一致:

image.png

其中theta是我们的视野,即fov,这里表示h = tan(theta/2)

我们将其应用到我们的相机类中:

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
class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

double vfov = 90; //垂直视角(视场)
...
private:
...
void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = point3 (0,0,0);

//确认视窗的设置
auto focal_length = 1.0; //焦距设置
auto theta = degree_to_radius(vfov);
auto h = std::tan(theta/2);
auto viewport_height = 2*h*focal_length;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//视图边缘的向量计算
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}
...
};

这个原理就是实现广角,图像的宽高比不变,但是视口的大小改变了,使得图像有拉伸压缩的视觉效果

我们用广角来渲染一下我们之前的图片试试,发现渲染出来的效果和之前是一样的,这是为什么呢? 因为之前设置的视口高度为2.0,而焦距为1.0,所以计算可以得到当时的视角也是90°,自然和现在是一样的了,同理我们可以通过缩小垂直视野来看到更远处的东西,这就是放大缩小的原理

摄像机的定位和定向

如果我们想要任意摆放,获得任意的视角,我们首先需要确定两个点,一个是我们放置计算机的点lookfrom,还有一个是我们想要看到点lookat

然后我们还需要定义一个方向作为摄像机的倾斜角度,即lookat-lookfrom轴的旋转。但其实还有一种方法,我们保持lookfromlookat两点不变,然后定义一个向上的方向向量,作为我们的摄像方向。

image.png

我们可以指定任何我们想要的向上向量,只要它不与视图方向平行。将这个向上向量投影到与视图方向垂直的平面上,以获得相对于摄像机的向上向量。我们将其命名为vup即向上向量。我们可以通过叉乘和向量的单位化,得到一个完整的正交归一基,然后我们就可以用(u,v,w)来描述摄像机的方向了。

这里u指的是摄像机右侧的单位向量,v指的是摄像机向上的单位向量,w指的是视图方向相反的单位向量,摄像机中心位于原点

image.png

之前我们需要让-Z和-w在同一水平面,以实现水平摄像叫角度,现在我们只需要指定我们的向上向量vup指向(0,1,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
class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

double vfov = 90; //垂直视角(视场)
point3 lookfrom = point3 (0,0,0); //相机位置
point3 lookat = point3 (0,0,-1); //观察点
vec3 vup = vec3(0,1,0); //相机相对向上的位置
...
private:
int image_height; //渲染图像的高度
double pixel_samples_scale; //每次采样的颜色权重
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值
vec3 u,v,w; //相机的相对坐标系

void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = lookfrom;

//确认视窗的设置
auto focal_length = (lookfrom - lookat).length(); //焦距设置
auto theta = degree_to_radius(vfov);
auto h = std::tan(theta/2);
auto viewport_height = 2*h*focal_length;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//计算摄像机的相对基底
w = unit_vector(lookfrom-lookat);
u = unit_vector(cross(vup,w));
v = cross(w,u);;

//视图边缘的向量计算
auto viewport_u = viewport_width * u;
auto viewport_v = viewport_height * -v;
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - (focal_length * w) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}
...
};

然后我们在main函数中调整我们的视角:

1
2
3
4
5
6
7
8
int main(){
...
cam.vfov = 90;
cam.lookfrom = point3 (-2,2,1);
cam.lookat = point3 (0,0,1);
cam.vup = vec3 (0,1,0);
...
}
image.png

然后我们发现渲染出来的图片不是很清晰,我们可以通过修改垂直视野来放大

1
cam.vfov = 20;
image.png

太好看了

散焦模糊

最后,我们还需要实现摄像机的一个特性:散焦模糊。在摄影中,我们把这种效果称之为景深。

在真实相机中,散焦模糊的产生是因为相机需要一个较大的孔(光圈)来收集光线,而不是一个针孔。如果只有一个针孔,那么所有物体都会清晰地成像,但进入相机的光线会非常少,导致曝光不足。所以相机会在胶片/传感器前面加一个镜头,那么会有一个特定的距离,在这个距离上看到的物体都是清晰的,然后离这个距离越远,图像就越模糊。你可以这么理解镜头:所有从焦点距离的特定点发出的光线——并且击中镜头——都会弯曲回图像传感器上的一个单一点。

在这里我们需要区分两个概念:

  • 焦距:相机中心到视口的距离,焦距决定了视场的大小。焦距越长,视场越窄。
  • 焦点距离:焦点距离是相机中心到焦点平面的距离,在焦点平面上的所有物体看起来都是清晰的

不过这里为了简化模型,我们将焦点平面和视口平面重合。

“光圈”是一个孔,用于控制镜头的有效大小。对于真正的摄像机,如果你需要更多的光线,你会使光圈变大,这将导致远离焦距的物体产生更多的模糊。在我们的虚拟摄像机中,我们可以拥有一个完美的传感器,永远不需要更多的光线,所以我们只在想要产生失焦模糊时使用光圈。

薄透镜近似

真实的相机镜头十分复杂,有传感器,镜头,光圈,胶片等…

image.png

实际上,我们不需要模拟相机内的任何一个部分,这些对于我们而言太过复杂,我们可以简化这个过程。我们将其简化成:我们从一近似平面的圆形”透镜”发出光线,并将它们发送的焦点平面的对应点上(距离透镜focal_length),在这个平面上的3D世界中的所有物体都处于完美的焦点中。

image.png

我们将这个过程展示出来:

  • 焦平面和相机视向垂直
  • 焦距是相机中心与焦点平面之间的距离
  • 视口位于焦点平面上,位于相机视角方向向量为中心
  • 像素位置的网格位于视场内
  • 从当前像素位置周围的区域随机采样(抗锯齿)
  • 相机从镜头上的随机点发射光线,通过图像样本位置

生成样本光线

没有散焦模糊时,所有的场景光线都来自相机中心(lookfrom)。为了实现散焦模糊,我们在相机中心构造一个圆盘。半径越大,散焦模糊越明显。你可以把我们的原始相机想想象成一个半径为0的散焦圆盘,所以完全不模糊。

所以我们将散焦盘的设置作为相机类的一个参数。我们将其半径作为相机系数,同时还需注意一点,相机的焦点距离也会影响散焦模糊的效果。此时,为了控制散焦模糊的程度,可以选择以下两种方式:

  • 散焦圆盘的半径:但是散焦模糊的效果会随着焦点距离的改变而被影响
  • 锥角:指定一个锥角,锥的顶点位于视口中心,底面位于相机中心,我们可以通过计算得到相应的底面半径

由于我们将从失焦盘中选择随机点,我们需要一个函数来完成这个任务random_in_unit_disk()这个和我们在random_in_unit_sphere()用到方法一样,只不过这个是二维的:

1
2
3
4
5
6
7
8
//vec3.h
inline vec3 random_in_unit_disk(){
while (true){
auto p = vec3(random_double(-1,1),random_double(-1,1),0);
if(p.length_squared() < 1)
return p;
}
}

现在我们更新相机,加入失焦模糊的功能:

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class camera{
public:
double aspect_ratio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

double vfov = 90; //垂直视角(视场)
point3 lookfrom = point3 (0,0,0); //相机位置
point3 lookat = point3 (0,0,-1); //观察点
vec3 vup = vec3(0,1,0); //相机相对向上的位置

double defocus_angle = 0; //锥角
double focus_dist = 0; //从相机中心到焦点平面中心的距离
...
private:
int image_height; //渲染图像的高度
double pixel_samples_scale; //每次采样的颜色权重
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值
vec3 u,v,w; //相机的相对坐标系
vec3 defocus_disk_u; //散焦圆盘水平向量
vec3 defocus_disk_v; //散焦圆盘垂直向量

void initialize(){
image_height = int(image_width/aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = lookfrom;

//确认视窗的设置
// auto focal_length = (lookfrom - lookat).length(); //焦距设置
auto theta = degree_to_radius(vfov);
auto h = std::tan(theta/2);
auto viewport_height = 2*h*focus_dist; //确保视口和焦点平面重合
auto viewport_width = viewport_height*(double (image_width)/image_height);

//计算摄像机的相对基底
w = unit_vector(lookfrom-lookat);
u = unit_vector(cross(vup,w));
v = cross(w,u);

//视图边缘的向量计算
auto viewport_u = viewport_width * u;
auto viewport_v = viewport_height * -v;
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - (focus_dist * w) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);

//计算相机散焦圆盘的基向量
auto defocus_radius = focus_dist * std::tan(degree_to_radius(defocus_angle/2));
defocus_disk_u = u*defocus_radius;
defocus_disk_v = v*defocus_radius;
}

ray get_ray(int i,int j){
//构造一个从散焦圆盘开始的随机采样射线,指向(i,j)像素周围的采样点

auto offset = sample_square();
auto pixel_sample = pixel00_loc + ((i+offset.x())*pixel_delta_u) + ((j+offset.y())*pixel_delta_v);

auto ray_origin = (defocus_angle <= 0) ? camera_center :defocus_disk_sample();
auto ray_direction = pixel_sample - ray_origin;

return ray(ray_origin,ray_direction);
}
...
point3 defocus_disk_sample() const {
// 返回散焦盘的上的随机点
auto p = random_in_unit_disk();
return camera_center + (p[0]*defocus_disk_u) + (p[1]*defocus_disk_v);
}
...
};

现在我们的相机具备了景深的效果,然我们来渲染试试效果吧:

image.png

Good,到此为止,我们的相机终于完成了!

继续冲

介电质材料

乍一听可能感觉很厉害,但实际上水,玻璃,钻石一类的材料都属于介电质,他们通常有以下特点:

  • 不导电:但会光会与该材料发生相互作用,主要表现有 反射与折射
  • 折射率:是材料特有的属性,决定光线进入材料时的弯曲程度,不同的材料有不同的折射率

当光线击中他们时,会分成反射光线和折射(透射)光线。我们将通过随机选择的反射换和折射来处理这个情况,并确保每次交互只生成一个光线。

当光线从一种材料的周围环境进入该材料本身,如(玻璃和水)时,会发生折射,光线会弯曲。折射光线的弯曲程度由材料的折射率决定。通常,折射率n是一个描述光线从真空进入材料时弯曲程度的单一值。当一种透明材料嵌入另一种透明材料中时,可以用相对折射率来描述折射:物体的折射率/环境的折射率。如渲染一个水下的玻璃,那么其有效折射率为玻璃的折射率/水的折射率

斯涅尔定律

折射一般由斯涅尔定律来描述:

image.png

这里的符号我不好用Latex打出来,所以折射率用eta来描述,并且这里结合一张图来描述:

img

为了确定折射光线的方向,我们需要解出sin(theta’):

1
sin(theta0) = eta/eta0 * sin(theta)

我们可以将折射光线分解成垂直法线方向和平行法线方向:

1
2
3
R = R_parallel + R_vertical
后续简写成:
R = R_p + R_v

根据斯涅尔定理,折射光线的垂直分量与入射光线的垂直分量也成比例:

1
R_v0 = eta/eta0 * R_v

所以可以计算出

1
2
3
4
R_p = (R*n)*n 	//这里的n已经单位化了,所以不用除|n|,直接*n即可
R_v = R - R_p = R - (R*n)*n
R*n = -cos(theta0)
R_v0 = eta/eta0 * (R + cos(theta)*n)

由于单位向量下,|R|^2 = |R_p|^2 + |R_v|^2,我们有:

1
2
3
4
R_p0 = -sqrt(1 - |R_v0|)*n
//最终合并得到R0
R_v0 = eta/eta0 * (R + (-R*n)*n) //这里替换掉cos方便计算
R0 = R_p0 + R_v0

我们可以写出向量的折射函数:

1
2
3
4
5
6
7
//vec3.h
inline vec3 refract(const vec3& uv,const vec3& n,double etai_over_etat){
auto cos_theta = std::fmin(dot(-uv,n),1.0);
vec3 r_out_perp = etai_over_etat * (uv + cos_theta*n);
vec3 r_out_parallel = -std::sqrt(std::fabs(1.0-r_out_perp.length_squared()))*n;
return r_out_perp + r_out_parallel;
}

在此基础上,我们可以创建出我们的介电质材料类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class dielectric : public material{
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

vec3 unit_direction = unit_vector(r_in.direction());
vec3 refracted = refract(unit_direction,refracted,ri);

scattered = ray(rec.p,refracted);
return true;
}
private:
double refraction_index; //在真空中的折射率,或者材料的折射比例
};

然后我们更改main函数重新渲染看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8),0.3);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2),0.0);
auto material_front = make_shared<dielectric>(1.50);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
world.add(make_shared<sphere>(point3(0.0,-0.2,-1.0),0.3,material_front));
...
}
image.png

可以看到中间一个怪怪的就是我们的玻璃球体,现在它只有折射属性,所以看起来怪怪的,中间的小黑点则是因为其光线追踪到的未被遮挡的阴影。

全反射

如果介电材料只是折射到话,也会遇到一些问题,对于一些光线角度,它的计算并不符合斯涅尔定理。如果光线以较大的入射角进入交界处,可能会以大于90°的角度折射出来,这显然是不可能的,所以这里我们将要用到我们高中所学的知识——全反射,来解决这个问题。

至于判断什么时候计算,我们计算一下折射角度的sin值,如果sin值大于1,就说明发生全反射。我们就不用折射函数来计算,用反射函数来计算出射光线。我们可以写出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = std::fmin(dot(-unit_direction,rec.normal),1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);

bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;

if(cannot_refract)
direction = reflect(unit_direction,rec.normal);
else
direction = refract(unit_direction,rec.normal,ri);

scattered = ray(rec.p,direction);
return true;
}

我们现在采用这个新的dielectric::scatter()函数渲染先前的场景,会发现没有任何变化。这是因为给定一个折射率大于空气的材料的球体,不存在入射角会导致全反射。这是由于球体的几何形状,入射和出射的角度经过两次折射还是一样的。

所以我们这里模拟空气的折射率和水一样,然后把球体的材料改为空气,所以我们设置它的折射系数为index of refraction of air / index of refraction of water,然后我们渲染试试:

image.png

Schlick近似

实际生活中,光线击中透明材质表面时,一部分光会反射,还有一部分光会折射,这个比例取决于入射角和两种介质的折射率。这个现象叫做菲尼尔效应,其严格的计算十分复杂,但好在我们有一个名为Schlick近似的计算方式,它是简化的替代方案,而且十分简便。我们可以看下它的内容:

image.png

我们在此基础之上可以改进我们的dielectric类:

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
class dielectric : public material{
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = std::fmin(dot(-unit_direction,rec.normal),1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);

bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;

//如果发生折射,还要根据Schlick近似,判断是否反射,如果反射率大于随机值则反射
if(cannot_refract || reflectance(cos_theta,ri) > random_double())
direction = reflect(unit_direction,rec.normal);
else
direction = refract(unit_direction,rec.normal,ri);

scattered = ray(rec.p,direction);
return true;
}
private:
double refraction_index; //在真空中的折射率,或者材料的折射比例

static double reflectance(double cosine,double refraction_index){
//使用Schlick近似计算反射率
auto r0 = (1 - refraction_index) / (1 + refraction_index);
r0 = r0*r0;
return r0 + (1-r0)*std::pow((1 - cosine),5);
}
};

对比一下:

image.png

左边确实更加逼真了。

建模一个空心玻璃球

我们建模一个空心玻璃球。这是一个由厚度的球体,里面有一个空气球。光线穿过这个物体,先击中外球,然后折射,然后穿过球内的空气。然后又击中球的内表面,折射,再击中外球的内表面,最后返回场景大气中。

我们设置外球,使用标准玻璃建模,折射率为1.50/1.00(从空气射入玻璃)。内球不同,内球使用空气建模,折射率设置为1.00/1.50(从玻璃射入空气)

我们设置一下main函数,再次渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.50);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2),0.0);
auto material_bubble = make_shared<dielectric>(1.00/1.50);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.0), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.4, material_bubble));
...
}
image.png

感觉不错啊,今天就到此为止吧

继续图形学的学习,我打算在下一周左右结束图形学的学习,因为要期中考试了(晕),现在赶赶进度

金属

一个材质的抽象类

如果想让不同的物体拥有不同的材质,我们可以设置一个通用的材质类,具有许多参数。或者我们可以有一个抽象的材质类,封装特定材质的独特行为,这里我们使用第二种方式,因为这样便于我们更好的组织代码,设置这么一个类,对于不同的材质,我们需要做两件事:

  • 产生一个散射光线(或者吸收入射光线)
  • 如果散射了,光线应该怎么衰减

在此基础之上,我们可以定义出我们的抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef RENDER_C___MATERIAL_H
#define RENDER_C___MATERIAL_H

#include "hittable.h"

class material{
public:
virtual ~material() = default;

virtual bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const{
return false;
}
};

#endif //RENDER_C___MATERIAL_H

描述光线-物体交点的数据结构

hit_record的设置目的是为了避免一大堆的参数,所以我们设置一个封装的类型,将信息参数放入其中。当然,由于hittable.hmaterials.h需要在代码中能够引用对方的类,为了避免他们的循环依赖,我们向hittable文件头中添加class material来指定类的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class material;

class hit_record{
public:
point3 p;
vec3 normal;
shared_ptr<material> mat; //附带了击中材料的材质的信息
double t;
bool front_face;

void set_face_normal(const ray& r,const vec3& outward_normal){
//设置交点的法线方向
front_face = dot(r.direction(),outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};

hit_record将一堆参数放入类中,然后作为一个组合发送。当光线击中一个表面是,hit_record中的材料指针被设置为球体在main中给定的指针材料。且当ray_color()获取hit_record时,它可以调用材料指针的成员函数来找出散射(如果有的话)

现在我们需要设置球体sphere类:

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
class sphere: public hittable{
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {

}

bool hit(const ray& r,interval ray_t,hit_record& rec) const override{
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;

auto discriminant = h*h - a*c;
if(discriminant < 0.0){
return false;
}

//解t并进行判断
auto sqrtd = std::sqrt(discriminant);
auto root = (h - sqrtd) / a;
if(!ray_t.surrounds(root)){
root = (h + sqrtd) / a;
if(!ray_t.surrounds(root))
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r,outward_normal);
rec.mat = mat;

return true;
}

private:
point3 center;
double radius;
shared_ptr<material> mat;
};

光线散射与反射率的建模

反射率是我们接下来需要关注的一件事情,反射率和材质 颜色有关,也会随入射光线方向变化

而我们先前的Lambertian 反射,它的反射有三种情况:

  • 根据反射率R,总是散射光线并衰减光线。
  • 光线有(1-R)的概率散射后不衰减
  • 综上两个情况

这里为了程序的简易性,我们选择第一种情况,来实现我们的Lambertian 材料:

1
2
3
4
5
6
7
8
9
10
11
12
13
class lambertian: public material{
public:
lambertian(const color& albedo) : albedo(albedo) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
auto scatter_direction = rec.normal + random_unit_vector();
scattered = ray(rec.p,scatter_direction);
attenuation = albedo;
return true;
}
private:
color albedo;
};

如果你仔细阅读会发现,我们使用的是random_unit_vector来生成一个随机的向量,这个向量可能和法线是等大反向的,从而导致零散射方向向量的情况,导致后许发生各种不良的情况。所以我们需要拦截这种可能性

所以我们创建一个新的向量方法——该方法返回一个布尔值,判断各个方向的维度会不会趋近0:

1
2
3
4
5
bool near_zero() const {
//判断各个分量是不是趋近于0
auto s = 1e-8;
return (std::fabs(e[0]) < s) && (std::fabs(e[1]) < s) && (std::fabs(e[2]) < s);
}

然后更新我们的Lambertian反射:

1
2
3
4
5
6
7
8
9
10
11
bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
auto scatter_direction = rec.normal + random_unit_vector();

//拦截危险向量
if(scatter_direction.near_zero())
scatter_direction = rec.normal;

scattered = ray(rec.p,scatter_direction);
attenuation = albedo;
return true;
}

镜像反射

对于抛光的金属,光线不会随机的散射,而是对称的反射:

image.png

这个过程我们可以用向量来实现计算:

  • 首先我们计算出向量v在n上的投影长度,然后取相反数得到b
  • 然后通过v+2b,来计算出反射后的光线

我们可以写出一下程序来计算反射函数:

1
2
3
4
5
//vec3.h
inline vec3 reflect(const vec3& v, const vec3& n){
//点积是v在n上的投影长度,即b的长度,*-2n则是为了校准方向
return v - 2* dot(v,n)*n;
}

然后我们用这个函数实现我们的金属材质:

1
2
3
4
5
6
7
8
9
10
11
12
13
class metal: public material{
public:
metal(const color& albedo) : albedo(albedo){}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
vec3 reflected = reflect(r_in.direction(),rec.normal);
scattered = ray(rec.p,reflected);
attenuation = albedo;
return true;
}
private:
color albedo;
};

我们接下来修改ray_color()函数以应用更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
ray scattered;
color attenuation;
if(rec.mat->scatter(r,rec,attenuation,scattered))
return attenuation* ray_color(scattered,depth-1,world);
return {0,0,0};
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后接着更行sphere的构造函数以初始化材质指针mat:

1
2
3
4
5
class sphere : public hittable {
public: sphere(const point3& center, double radius, shared_ptr<material> mat)
: center(center), radius(std::fmax(0,radius)), mat(mat) {}
...
};

金属球体在场景中

现在我们向场景中添加我们的金属球体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
hittable_list world;

auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8));
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2));

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
...
cam.render(world);
}

成功渲染了出来,看:

image.png

模糊反射

我们的金属球体太过完美,但是现实中我们往往也会看到各种磨砂材质的金属,所以在这里我们要引入一个新的变量fuzz,来随机化反射方向。我们使用一个随机点,以原始起点为中心,来模糊反射的光线。

image.png

我们用fuzz来决定模糊球体的大小,模糊球体越大,反射效果就越明显。当模糊球体较大或光线几乎平行于表面时(称为掠射光线),计算出的反射光线可能会指向物体内部,即在物体表面下方。对于这种情况,可以选择简单地吸收这些光线,即认为它们没有从物体表面反射出去。

为了确保模糊球体相对于反射光线的尺度是一致的,需要对反射光线进行归一化处理,即调整其长度为1。这样做可以确保无论反射光线的长度如何变化,模糊球体的尺度都是相对于光线方向的,而不是其长度。

于是我们可以更改以下程序以实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class metal: public material{
public:
metal(const color& albedo) : albedo(albedo){}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
vec3 reflected = reflect(r_in.direction(),rec.normal);
reflected = unit_vector(reflected) + (fuzz*random_unit_vector());
scattered = ray(rec.p,reflected);
attenuation = albedo;
return (dot(scattered.direction(),rec.normal) > 0);
}
private:
color albedo;
double fuzz;
};

然后调整一下我们的main函数:

1
2
3
4
5
6
7
8
int main(){
...
world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
...
}

渲染出来的是这样的:

image.png

可以看到左边有明显的模糊感