0%

昨天写了一些基本的架构,写到一半我突然有很多灵感,为什么一定要按照教程的内容去做呢。我应该有自己的想法,而且我现在也有能力去实现,有错误也可以慢慢调试。再加上AI的帮助,我可以以这个教程为蓝本,学习各种知识。

所以现在我要从头开始尝试这个过程,OK基本上自己重新实现了一遍,优化了一些功能和拓展了一些程序。接下来需要进一步学习怎么制作一个断点,这个还是要老老实实学原理了

断点

怎么生成一个断点

程序中的断点,在我们调试代码时,使程序停止到指定位置。但是这是什么原理呢?首先程序的断点分为两种:一种是软件断点,还有一种是硬件断点。这里我们使用的是软件断点,接下来我们研究,断点是怎么生成的?

这里我们使用x86的内置的中断操作int 3,这个操作会向程序发出信号。在现代操作系统中,当处理器执行到int 3时,会触发一个SIGTRAP信号,通知我们的调试程序。当然,如果我们想要实现一个断点,我们就需要在指定位置修改内存,将内存覆盖为int 3,然后运行,触发中断,等执行完这个断点之后,我们再将原来的内存覆写回来。

下面这张图很好的展现了这个过程,我们按照这个原理来实现断点的生成:

image.png

但是,这里我们怎么利用系统发出的SIGTRAP信号,来通知我们的调试进程,断点的发生呢。我们可以使用waitpid来实现,设置断点,程序执行,调用waitpid等待SIGTRAP的信号发生。然后可以将断点信息传给用户。

实现程序断点

首先我们设置一个断点的类,方便我们的设置和使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class breakpoint{
public:
breakpoint (pid_t pid, std::intptr_t addr) : m_pid(pid),m_addr(addr),m_enable(false),m_saved_data(0) {}
//启用禁用
void enable();
void disable();
//检查启用
auto is_enable() const->bool {return m_enable;}
//获取地址
auto get_address() const->std::intptr_t {return m_addr;}

private:
pid_t m_pid;
std::intptr_t m_addr;
bool m_enable;
uint8_t m_saved_data; //用来存储被覆盖的函数
};

这里主要是用于跟踪存储断点的状态,断点设置的主要实现还是在enablediable中,我们来尝试实现他们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void breakpoint::enable(){
auto data = ptrace(PTRACE_PEEKDATA,m_pid,m_addr,nullptr); //返回的data是64位
m_saved_data = static_cast<uint8_t> (data & 0xff); //8位是用来存储64位信息的最低位数据
uint64_t int3 = 0xcc; //64位的int3方便对齐
uint64_t data_with_int3 = ((data & ~0xff) | int3);
ptrace(PTRACE_POKEDATA,m_pid,m_addr,data_with_int3);

m_enable = true;
}

void breakpoint::disable(){
auto data = ptrace(PTRACE_PEEKDATA,m_pid,m_addr,nullptr);
auto restore_data = ((data & ~0xff) | m_saved_data);
ptrace(PTRACE_POKEDATA,m_pid,m_addr,restore_data);

m_enable = false;
}

这里我们用到的原理是使用PTRACE_PEEKDATA的请求到ptrace,给定指定的进程和内存地址,会返回该地址前的一个64位的数据。我们先将要被覆盖的地址保存下来,然后将int3的数据0xcc覆写上去,然后使用PTRACE_POKEDATA请求,将内容覆写会程序的内存中,从而实现了断点的设置。取消断点则反之,将数据恢复后再写会去。这个过程中一定要注意内存地址的对齐,不要覆盖错了位置。

添加到调试器中

我们已经简单的实现了断点的方法和属性,接下来我们将其添加到调试器中,来实现我们对程序的控制,我们需要做到以下几点:

  • 在debugger类中添加存储断点信息的数据结构
  • 创建一个方法函数用来设置断点set_breakpoint_at_address
  • 修改我们的handle_cammand函数,使其支持断点操作
1
2
3
4
5
6
7
8
9
10
class debugger{
public:
...
//设置断点
void set_breakpoint_at_address(std::intptr_t addr);

private:
...
std::unordered_map<std::intptr_t,breakpoint> m_breakpoint;
};

首先添加新的属性和函数到程序调试器中,我们使用哈希表unoredered_map,以地址作为键,以断点作为值进行存储。

然后我们创建一个函数用来设置我们的断点set_breakpoint_at_adress:

1
2
3
4
5
6
void debugger::set_breakpoint_at_address(std::intptr_t addr){
std::cout << "设置断点到 " << std::hex << addr << "处"<< std::endl;
breakpoint bp {m_pid,addr};
bp.enable();
m_breakpoint[addr] = bp; //存储断点信息
}

然后我们修改我们的命令行处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void debugger::handle_command(const std::string& line){
if(line.empty()){
return;
}
auto args = split(line,' ');
auto command = args[0];
if(is_prefix(command,"continue")){
...
}else if(is_match(command,"exit")){
...
}else if(is_prefix(command,"breakpoint")){
std::string addr {args[1],2}; //去除16进制数据前的0x
set_breakpoint_at_address(std::stol(addr,0,16)); //从数据的开头开始按十六进制转换成intptr_t可读的长整数
}else{
...
}
}

至此为止,我们的断点就设置完成啦

test test test

让我们试试能不能正常的使用,虽然我们现在只能设置断点,还不能正常的执行它,也不能取消它,我们可以看一看它的表现:

image.png

现在我们希望将断点下在程序的call pmem处,由于地址的空间随机化,我们在objdump 中只能看到指令的偏移地址,但是我们可以通过进程的内存映射找到程序的加载入口,我们可以计算出0x558b09f02000+0x16bb就是我们要下断点的位置。

看一看效果:

image.png

非常成功!

不过由于我们还没有编写能够终止断点的函数,所以程序到这里就直接停下来了,我们之后再来完善

之前一段时间在研究Linux下的虚拟内存分布,还有一些比较细节的内容吧。现在打算更加深入了解这个内容,刚好看到了这篇教程,所以打算花一周左右的时间跟着学习一下,顺便做个简陋的翻译吧。这里我们使用的C++

这是项目的源地址: 编写 Linux 调试器第一部分:设置 — Writing a Linux Debugger Part 1: Setup

准备

环境准备

要用到Linenoise库和libelfin库,也不知道配好没有,先这么开始吧

启动可执行文件

在我们实际开始调试任何程序之前,我们首先需要启动被调试的进程。我们使用最经典的fork/exec来完成这个功能

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

int main(int argc,char* argv[]){
if(argc<2){
std::cerr << "请指定要调试的程序名称“;
return -1;
}

auto prog = argv[1];

auto pid = fork();
if(pid == 0){

}else if(pid >= 1){

}
}

我们使用fork将进程分裂,如果我们在子进程中,fork会向我们返回0。如果我们在父进程中,fork会向我们返回当前的进程号

如果我们在子进程中,我们将我们想要调试的程序替换当前正在执行的程序,以下是我们要用到的函数:

1
2
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
int execl(const char *path, const char *arg, ...);

其中pstrace允许我们通过读取寄存器,读取内存,的那部执行等方式来对一个程序实现调试执行。我们介绍一下它的使用:

  • request:指定对被跟踪进程执行的操作
  • pid:被跟踪的进程的进程号(PID)
  • addr:指定操作的内存地址
  • data:用于请求的附加数据
  • 返回值通常用于返回提供错误信息

我们以ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);的方式使用它,表示该进程应该允许父进程跟踪它。

execl则是众多exec风格中的一种,用于替换当前进程的进程映像为一个新的程序,并执行它:

  • path:要执行的程序的路径
  • arg:程序的第一个参数(通常是程序名)
  • ...:程序的其他参数,以nullptr结尾

我们以execl(prog, prog, nullptr);使用它,表示将当前子进程替换为指定的程序

这样我们就启动了我们的子进程,并将其替换为我们的被调试函数

调加调试器的循环

我们启动了子进程,但我们希望能够和它交互,所以我们需要创建一个debugger类,并给它一个循环来监听用户的输入,并在我们的父进程中启动它。

1
2
3
4
5
6
7
8
9
10

class debugger{
public:
debugger(std::string prog_name,pid_t pid) : m_prog_name{std::move(prog_name)},m_pid{pid} {}

void run();
private:
std::string m_prog_name;
pid_t m_pid;
};

同时在父进程中启动它:

1
2
3
4
5
6
}else if(pid >= 1){
//parent
std::cout << "启动调试进程" << pid << '\n';
debugger dbg{prog,pid};
dbg.run();
}

在我们的run函数中,我们需要等待子进程的启动,然后使用linenoise获取输入直到EOF(ctrl+d)

1
2
3
4
5
6
7
8
9
10
11
void debugger::run(){
int wait_status;
auto options = 0;
waitpid(m_pid,&wait_status,options); //在指定的子进程结束之前,阻塞当前进程
char * line = nullptr;
while ((line = linenoise("Minidbg>>"))!=nullptr){
handle_command(line); //用来解析参数,这个函数尚未实现
linenoiseHistoryAdd(line); //将输入添加到输入历史中
linenoiseFree(line); //释放输入占用的空间
}
}

当被跟踪的进程启动时,他将收到一个SIGYTAP信号,这是一个跟踪或断点信号,我们使用waitpid等待这个信号的发送

当我我们直到进程准备号被调试之后,我们监听用户的输入。linenoise函数接受一个显示提示,并自行处理用户的输入。我们将输入交给handle_command函数处理,然后将输入添加到lineniose的历史输入中,并释放分配的空间资源

处理输入

我们的调试器支持的命令结构像GDB那样,我们用continuecontc来告诉他们继续执行。还有下断点之类的操作,我们用空格来实现对他们的分割:

1
2
3
4
5
6
7
8
9
10
void debugger::handle_command(const std::string& line){
auto args = split(line,' ');
auto command = args[0];

if(is_prefix(command,"continue")){
continue_execuion();
}else{
std::cerr << "未定义的操作";
}
}

接下来我们进一步完成handle_command的辅助函数is_prefixsplit函数:

1
2
3
4
5
6
7
8
9
std::vector<std::string> debugger::split(const std::string& s,char delimiter){
std::vector<std::string> out{};
std::stringstream ss {s};
std::string item;
while (std::getline(ss,item,delimiter)){ //从输入流中读取,直到分隔符结束读取
out.push_back(item);
}
return out;
}
1
2
3
4
bool debugger::is_prefix(const std::string& s,const std::string& of){
if(s.size() > of.size()) return false;
return std::equal(s.begin(),s.end(),of.begin());
}

最后我们再完成我们的continue_execution函数:

1
2
3
4
5
6
7
void debugger::continue_execuion(){
ptrace(PTRACE_CONT,m_pid,nullptr,nullptr);

int wait_status;
auto options = 0;
waitpid(m_pid,&wait_status,options);
}

我们的continue_execution将使用pstrace告诉进程继续,然后使用waitpid直到接受到信号

执行流程

到此为止,我们初步的框架就完成了,我们可以整理一下整个程序的执行流程

  1. 首先我们接受到两个参数./minigdb test
  2. 程序运行到fork会创建一个新的进程,我们将父进程作为调试器进程,将子进程作为被调试的进程
  3. 子进程因为ptrace(PTRACE_TRACEME,0,nullptr,nullptr);会等待父进程发出调试信息,才开始执行
  4. 父进程开始执行run(),其中waitpid会等待子进程的结束,但是由于此时子进程还在等待父进程的调试信息,还没有开始执行,所以父进程开始执行命令c
  5. 此时父进程调用了ptrace(PTRACE_CONT, m_pid, nullptr, nullptr)向子进程发出了继续执行的命令。且使用waitpid等待子进程的结束,并重新回到读取输入的循环

整个过程使用ptracewaitpid实现了控制父子进程的协同运行

我们可以看下程序的运行效果:

image.png

符合我们的设计,那么调试器的准备阶段就到此为止了

学习虚拟内存,对于其中的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,到此为止,我们的相机终于完成了!