0%

你好呀!

这里记录我的学习经历和内容

希望能够帮到你们

: )

五月份恍恍惚惚就过去了,都不太记得发生了什么,本来说好的是中旬写一些反思的。但是当时应该是在打游戏,所以就这么拖到了现在。也不知道说点什么比较好,慢慢想一想吧。上个月说要学这个学那个,也没学成什么,C++也没学多少,不记得是卡在哪里了。学了一段时间蒋炎岩老师的课,但是学不太下去。

但是也发现了一些,经历了一些好玩的东西吧,零零散散学了很多东西,本来想更进一步深入的学习,但是不记得卡在了哪里。这么一想,感觉学了好多东西,但是又没沉下心来学什么东西。倒是遇到许多好玩的游戏,什么怪物猎人,艾尔登法环,还有星露谷物语,都挺好玩的。怎么玩都不累吧,我感觉还是很开心的,至少现在还能玩一玩游戏,以前都不太想玩。

可能最近有点散漫吧,不知道是颓废还是散漫。但是心理还算开心,所以就没啥感觉,有时候会有点焦虑,但是没啥感觉。这样的生活总感觉有什么东西越来越清晰吧,不知道是什么东西,但我很喜欢这种感觉,就是混混日子。但是我最近突然想找点事情做了,Emmmm可能是太无聊了,也可能是慢慢的有一些比较在乎的事情了。总之有想法是好事,就应该开始去做了,不过得先明确一下要做的事情。我就随便列一下好了:

  1. 一个是绩点的事情吧,什么高数线代,最近越学越好玩。如果不把它们当成一个课程来完成的话,我觉得其实还是很有趣得。比如什么线代的应用,还有高数在计算机里的一些应用,我觉的数据建模什么的使用范围应该很广泛吧。既然想做,对自己就应该有一点要求,绩点的事情其实我也不太明白,什么平时分什么的,但我还是打算加把油,争取靠近年级的前十
  2. 下一个是找导师做课题的事情吧,我打算试试联系一个老师,方向是密码学,据说挺难的,但是我感觉很有意思。其实机器学习也挺好的,什么人工智能,感觉很神奇。但是学校做的人太多了,遂放弃
  3. 然后是看书的事情吧,我暑假想围绕两本书展开我的学习活动,一本是CSAPP,还有一本是数据结构的C++描述,如果时间有多的话,最好再把计算机网络看一下
  4. 今天刚考完驾照,还是挺期待练车的,打算暑假赶快点搞完,感觉会很好玩

马上就要暑假了,我虽然最近散漫,但是我对自己的要求还是很高的。像暑假这么长而且完整的而且一个人的时间确实很难得,我打算沉下心来好好沉淀一段时间,整个大一基本上都是漫无目的的学习,等下个学期就要把好刚用在刀刃上了,得卷一点,准备一下考研保研之类的事情。

就先写这么多吧,差不多要准备学习一下了

上一部分中我们实现了对于程序的汇编级别的调试,但是这种调试,过程繁琐,难度大,且调试过程的可读性较差。我们需要拓展我们的调试器的性能,所以我们需要用到DWARF格式以实现源码级别的调试。

Dwarf和Elf

在此之前我们需要对这些知识有一定程度的掌握。ELF(可执行和链接格式)是Linux中最广泛使用的对象文件格式,其指定了一种存储二进制文件不同部分方式,如代码,数据,调试信息和字符串等。它还告诉加载器如何将二进制文件准备执行,这包括之处二进制文件不同部分应该放置再内存中的位置。不过这里我们并不着重讨论ELF的文件格式

DWARF是ELF常用的调试信息格式。它和ELF是同步开发的所以适配性比较好。这种格式对应了调试器源代码与二进制之间的关系。这些信息分布在不同的ELF段中,每个段有着不同的信息内容:

  • .debug_abbrev:存储.debug_info节中的缩写表
  • .debug_aranges:提供内存地址与编译单元之间的映射
  • .debug_frame:存储调用栈信息
  • .debug_info:存储DWARF调试信息的核心数据,包含调试信息条目(DIES)
  • .debug_line:存储源代码行号信息
  • .debug_loc:存储位置描述信息
  • .debug_macinfo:存储宏定义信息
  • .debug_pubnames:提供全局对象和函数的查找表
  • .debug_pubtypes:提供全局类型的查找表
  • .debug_ranges:存储地址范围信息
  • .debug_str:存储字符串表
  • .debug_types:存储类型描述信息

这里我们着重关注.debug_line.debug_info的部分,让我们看看一个简单的程序的DWARF信息

1
2
3
4
5
6
int main(){
long a = 3;
long b = 2;
long c = a + b;
a = 4;
}

DWARF行表

想要查看程序的DWARF信息,我们需要在编译时加入一个-g选项,可以看到以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.debug_line: line number info for a single cu
Source lines (from CU-DIE at .debug_info offset 0x0000000c):

NS new statement, BB new basic block, ET end of text sequence
PE prologue end, EB epilogue begin
IS=val ISA number, DI=val discriminator value
<pc> [lno,col] NS BB ET PE EB IS= DI= uri: "filepath"
0x00001129 [ 1, 11] NS uri: "/home/ylin/Program/LearnDwarf/test.c"
0x00001131 [ 2, 10] NS
0x00001139 [ 3, 10] NS
0x00001141 [ 4, 10] NS
0x00001150 [ 5, 7] NS
0x0000115d [ 6, 1] NS
0x0000115f [ 6, 1] NS ET

这一行信息<pc> [lno,col] NS BB ET PE EB IS= DI= uri: "filepath"告诉了我们内容的格式与含义。其中NS表示该地址标记了新语句的开始,通常用于设置断点或者单步执行。PE标记了函数序言的结束,用于设置函数的入口断点。ET标记了翻译单元的结束。其他的信息在上方均有显示。

假如我们想在test.c的第四行设置一个断点,我们该怎么做?我们寻找与该文件对应的条目,然后寻找相关的行的条目,查找也与之对应的地址,并设置断点。这里我们对应的条目是:

1
0x00001139  [   3, 10] NS

所以我们需要在程序加载地址处偏移地址为0x00001139的地方,下断点,就可以实现在第四行下断点。

我们查看objdump的内容,也的确如此:

image.png

反向的操作也是如此。如果我们有一个内存位置——比如说,一个程序计数器的值,我们想知道他在源码中的位置,我们可以通过行表信息中最近的映射地址,并从那里获取行。

DWARF 调试信息

.debug_info是DWARF的核心。它提供了关于程序中存在的类型、函数、变量等信息。这个节中的基本单元是DWARF信息条目,通常称为DIES。一个DIE由一个标签组成,告诉你它所表示的源码级的实体类型,后面跟着有一系列使用与该实体的属性。下面是我们程序的.debug_info节:

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
.debug_info

COMPILE_UNIT<header overall offset = 0x00000000>:
< 0><0x0000000c> DW_TAG_compile_unit
DW_AT_producer GNU C17 13.3.0 -mtune=generic -march=x86-64 -g -fasynchronous-unwind-tables -fstack-protector-strong -fstack-clash-protection -fcf-protection
DW_AT_language DW_LANG_C11
DW_AT_name test.c
DW_AT_comp_dir /home/ylin/Program/LearnDwarf
DW_AT_low_pc 0x00001129
DW_AT_high_pc <offset-from-lowpc> 54 <highpc: 0x0000115f>
DW_AT_stmt_list 0x00000000

LOCAL_SYMBOLS:
< 1><0x0000002e> DW_TAG_subprogram
DW_AT_external yes(1)
DW_AT_name main
DW_AT_decl_file 0x00000001 /home/ylin/Program/LearnDwarf/test.c
DW_AT_decl_line 0x00000001
DW_AT_decl_column 0x00000005
DW_AT_type <0x00000072>
DW_AT_low_pc 0x00001129
DW_AT_high_pc <offset-from-lowpc> 54 <highpc: 0x0000115f>
DW_AT_frame_base len 0x0001: 0x9c:
DW_OP_call_frame_cfa
DW_AT_call_all_calls yes(1)
DW_AT_sibling <0x00000072>
< 2><0x00000050> DW_TAG_variable
DW_AT_name a
DW_AT_decl_file 0x00000001
DW_AT_decl_line 0x00000002
DW_AT_decl_column 0x0000000a
DW_AT_type <0x00000079>
DW_AT_location len 0x0002: 0x9158:
DW_OP_fbreg -40
< 2><0x0000005b> DW_TAG_variable
DW_AT_name b
DW_AT_decl_file 0x00000001
DW_AT_decl_line 0x00000003
DW_AT_decl_column 0x0000000a
DW_AT_type <0x00000079>
DW_AT_location len 0x0002: 0x9160:
DW_OP_fbreg -32
< 2><0x00000066> DW_TAG_variable
DW_AT_name c
DW_AT_decl_file 0x00000001
DW_AT_decl_line 0x00000004
DW_AT_decl_column 0x0000000a
DW_AT_type <0x00000079>
DW_AT_location len 0x0002: 0x9168:
DW_OP_fbreg -24
< 1><0x00000072> DW_TAG_base_type
DW_AT_byte_size 0x00000004
DW_AT_encoding DW_ATE_signed
DW_AT_name int
< 1><0x00000079> DW_TAG_base_type
DW_AT_byte_size 0x00000008
DW_AT_encoding DW_ATE_signed
DW_AT_name long int

第一个DIE代表一个编译单元(CU),下面是一些属性的含义:

  • DW_AT_producer:生成的此可执行文件的编译器
  • DW_AT_language:源语言
  • DW_AT_name:此编译单元所代表的文件名
  • DW_AT_comp_dir:编译目录
  • DW_AT_low_pc:此CU的代码开始
  • DW_AT_high_pc:此CU的代码结束

其他的DIES属性遵循差不多的方案,你可以推断出不同属性的含义。接下来我们使用DWARF的知识解决一些实际的问题

定位我们所在的函数

假设我们已有当前的程序计数器的值,我们想要弄清楚我们处在哪个函数之中,有一个算法是这样的:

1
2
3
4
5
for each compile unit:
if the pc is between DW_AT_low_pc and DW_AT_high_pc:
for each function in the compile uint:
if the pc is between DW_AT_low_pc and DW_AT_high_pc:
return function information

这个算法适用于各种用途,但在存在成员函数和内联的情况下,事情会变得比较困难。在内联的情况下,一但我们找到了包含我们PC的函数,我们需要递归遍历该DIE的子节点,看看是否有内联函数匹配。

在一个函数上设置断点

我们要找到的函数,你可以在DW_AT_low_pc给出的内存地址上设置断点。但是一般这样做,会导致断点被下在函数序言(一般是用于保存调用者的上下文和设置函数的局部变量空间)的开始处中断,但是我们实际想要的效果是在用户代码处中断。

解决这个问题的办法就是从DW_AT_low_pc开始,从行号表中逐条读取,直到找到标记为序言结束(PE)的条目,可以确定代码的起始位置 。不过有时候,有些编译器不会输出这些信息,所以另一种做法是在该函数的第二行条目给出的地址上设置断点。

比如我们想在示例程序中的main上设置断点。我们搜索名为main的函数,并得到这个DIE:

1
2
3
4
5
6
7
8
9
10
11
12
< 1><0x0000002e>    DW_TAG_subprogram
DW_AT_external yes(1)
DW_AT_name main
DW_AT_decl_file 0x00000001 /home/ylin/Program/LearnDwarf/test.c
DW_AT_decl_line 0x00000001
DW_AT_decl_column 0x00000005
DW_AT_type <0x00000072>
DW_AT_low_pc 0x00001129
DW_AT_high_pc <offset-from-lowpc> 54 <highpc: 0x0000115f>
DW_AT_frame_base len 0x0001: 0x9c:
DW_OP_call_frame_cfa
DW_AT_call_all_calls yes(1)

这告诉我们函数从0x00001129的偏移值开始,我们去行目表中查找,可以得到这个信息

1
0x00001129  [   1,11] NS uri: "/home/ylin/Program/LearnDwarf/test.c"

我们要跳过程序前文,所以读取下一个条目:

1
0x00001131  [   2,10] NS

这是我们要下断点的位置

我们该如何读取变量的内容

读取变量是一件很复杂的事情,它可能存在于不同的栈帧之中,被放置在寄存器中,或者在某个内存之中,也有可能被优化掉。不过我们还是可以通过一些简单的方法去找到它,我们可以查看变量a 的DW_AT_location属性:

1
2
3
4
5
6
7
8
9
10
11
12
< 2><0x00000050>      DW_TAG_variable
DW_AT_name a
DW_AT_decl_file 0x00000001
DW_AT_decl_line 0x00000002
DW_AT_decl_column 0x0000000a
DW_AT_type <0x00000079>
DW_AT_location len 0x0002: 0x9158:
DW_OP_fbreg -40
< 1><0x00000079> DW_TAG_base_type
DW_AT_byte_size 0x00000008
DW_AT_encoding DW_ATE_signed
DW_AT_name long int

这表示我们的a被存储在栈基地址的-40偏移地址。为了计算出这个及地址在那里,我们查看该函数的DW_AT_frame_base属性:

1
2
DW_AT_frame_base            len 0x0001: 0x9c:
DW_OP_call_frame_cfa

这意味着我们的栈基指针,是通过CFA计算得到的。

我们现在找到了这个变量,但是我们需要知道它的信息和类型,我们可以通过DW_AT_type找到,这告诉我们类型是一个8字节的有符号整数类型,因此我们可以将这个字节解释为int64_t并显示给用户。

我们对DWARF的初步理解就到此为止,接下来尝试使用它吧

上次把断点基本的设置搞完了,接下来需要进一步的对寄存器进行操作。我们将添加读取和写入寄存器和内存的功能,这样我们就可以操作程序寄存器(PC),从而实现程序状态的观察和操作。

寄存器和内存

寄存器结构

我们需要建立x86_64架构寄存器的元数据,以便通过ptrace系统调用和DWARF调试信息于寄存器交互

首先我们需要明确我们需要用到的寄存器,我们主要使用一些比较常用的寄存器,忽略对于架构非必须的寄存器,我们先创建一个寄存器强类型枚举,用于确定我们要用到的寄存器

1
2
3
4
5
6
7
8
9
10
11
12
enum class reg {
rax,rbx,rcx,rdx,
rdi,rsi,rbp,rsp,
r8,r9,r10,r11,
r12,r13,r14,r15,
rip,rflags,cs,
orig_rax,fs_base,
gs_rax,
fs,gs,ss,ds,es
};

constexpr std::size_t n_registers = 27; //constexpr用来优化编译

接下来我们来建立一下寄存器的描述结构,方便我们存储信息:

1
2
3
4
5
struct reg_descriptor {
reg r;
int dwarf_r;
std::string name;
};

然后我们要设置一个全局寄存器描述符数组,其元素顺序需要和ptrace返回的寄存器结构的顺序一样,这样便于我们直接通过数组索引直接访问ptrace返回的结构字段。

image.png
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
const std::array<reg_descriptor,n_registers> g_register_descriptors{{
{reg::r15,15,"r15"},
{reg::r14,14,"r14"},
{reg::r13,13,"r13"},
{reg::r12,12,"r12"},
{reg::rbp,6,"rbp"},
{reg::rbx,3,"rbx"},
{reg::r11,11,"r11"},
{reg::r10,10,"r10"},
{reg::r9,9,"r9"},
{reg::r8,8,"r8"},
{reg::rax,0,"rax"},
{reg::rcx,2,"rcx"},
{reg::rdx,1,"rdx"},
{reg::rsi,4,"rsi"},
{reg::rdi,5,"rdi"},
{reg::orig_rax,-1,"orig_rax"},
{reg::rip,-1,"rip"},
{reg::cs,51,"cs"},
{reg::rflags,49,"eflags"},
{reg::rsp,7,"rsp"},
{reg::ss,52,"ss"},
{reg::fs_base,58,"fs_base"},
{reg::gs_base,59,"gs_base"},
{reg::ds,53,"ds"},
{reg::es,50,"es"},
{reg::fs,54,"fs"},
{reg::gs,55,"gs"},
}};

中间的各种数字是对应的DWARF寄存器编号,你可以在这里找到他们psABI-x86_64.pdf

寄存器内容读取

现在我们可以尝试编写一些函数用来和寄存器交互。我们希望能够读取寄存器、向寄存器中写入数据、从DWARF寄存器编号中检索值,以及按名称查找寄存器。总之我们先实现一个函数用来读取get_register_value的值开始。

我们还是通过ptracePTRACE_GETREGS 实现对寄存器数据的访问。我们用一个user_regs_struct的类型来存储ptrace的返回值。

1
2
3
4
5
6
7
8
9
10
11
uint64_t get_register_value(pid_t pid,reg r) {
user_regs_struct regs;
ptrace(PTRACE_GETREGS,pid,nullptr,&regs);
auto it = std::find_if(
begin(g_register_descriptors), //全局标识符的起点
end(g_register_descriptors), //全局标识符的终点
[r](const reg_descriptor & rd){return rd.r==r;}//lambda表达式用来匹配条件
);
// 本质上是*(ptr + offset)
return *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors)));
}

这里我们之所以把传递的user_regs_struct结构按uint64_t的类型理解,因为这样是安全的,返回的user_regs_struct在内存上是连续分布的,我们可以将它按uint64_t的类型处理。

现在我们可以获取regs中的任意的指定寄存器的值了。

接着我们编写set_register_value的函数实现,和刚刚差不多的过程:

1
2
3
4
5
6
7
8
9
10
11
void set_register_value(pid_t pid,reg r,uint64_t value){
user_regs_struct regs;
ptrace(PTRACE_GETREGS,pid,nullptr,&regs);
auto it = std::find_if(
begin(g_register_descriptors),
end(g_register_descriptors),
[r](const reg_descriptor & rd){return rd.r==r;}
);
*(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors))) = value;
ptrace(PTRACE_SETREGS,pid,nullptr,&regs); //返回regs表
}

然后再拓展一下,刚刚是根据枚举变量进行查找,现在我们还可以根据DWARF值查找,或者根据寄存器名进行查找:

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
uint64_t get_register_from_dwarf_register(pid_t pid,unsigned regnum){
auto it = std::find_if(
begin(g_register_descriptors),
end(g_register_descriptors),
[regnum](const reg_descriptor & rd){return rd.dwarf_r == regnum;}
);
return get_register_value(pid,it->r);
}

std::string get_register_name(reg r){
auto it = std::find_if(
begin(g_register_descriptors),
end(g_register_descriptors),
[r](const reg_descriptor & rd){return rd.r == r;}
);
return it->name;
}

reg get_register_from_name(const std::string & name){
auto it = std::find_if(
begin(g_register_descriptors),
end(g_register_descriptors),
[name](const reg_descriptor & rd){return rd.name == name;}
);
return it->r;
}

这里中间有个辅助函数get_register_name可加可不加。

现在我们开始获取我们寄存器中的信息了!

操作寄存器

首先我们向调试器加入一个dump_register函数,用来获取我们的寄存器信息:

1
2
3
4
5
6
7
8
void debugger::dump_register(){
for(const reg_descriptor& rd: g_register_descriptors){
//输出格式 [reg_name] [0x0000000000000000]
std::cout << rd.name << " 0x" << std::setfill('0') <<
std::setw(16) << std::hex <<
get_register_value(m_pid,rd.r) << std::endl;
}
}

我们修改handle_command函数,将我们的读写寄存器的操作功能加入进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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,"register")){
if (is_prefix(args[1],"dump")){
dump_register();
}else if(is_prefix(args[1],"read")){
std::cout << args[2] << " = " << get_register_value(m_pid,get_register_from_name(args[2])) << std::endl;
}else if(is_prefix(args[1],"write")){
std::string val {args[3],2};
set_register_value(m_pid,get_register_from_name(args[2]),std::stol(val,0,16));
std::cout << "0x" << val << " -> " << args[2] << std::endl;
}
}else{
...
}
}

操作内存

不像寄存器那么复杂,我们直接使用ptrace系统调用就可以进行对程序内存的读写,在这里我们将函数封装起来:

1
2
3
4
5
6
7
uint64_t debugger::read_memory(uint64_t address){
return ptrace(PTRACE_PEEKDATA,m_pid,address,nullptr);
}

void debugger::write_memory(uint64_t address,uint64_t value){
ptrace(PTRACE_POKEDATA,m_pid,address,value);
}

这里我们使用ptrace一次只能传递64位的信息,如果你想要传递更多的信息可以通过系统调用process_vm_readvprocess_vm_writev来实现这个功能,我们将内存操作添加到我们的handle_command函数中

1
2
3
4
5
6
7
8
9
10
}else if(is_prefix(command,"memory")){
std::string addr {args[2],2};
if(is_prefix(command,"read")){
std::cout << args[2] << " (mem)" << " = " << std::hex << read_memory(std::stol(addr,0,16)) << " (mem)" << std::endl;
}else if(is_prefix(command,"write")){
std::string val {args[3],2};
write_memory(std::stol(addr,0,16),std::stol(val,0,16));
std::cout << args[3] << " -> " << args[2] << " (mem)" << std::endl;
}
}

OK !!!

退出断点(完善我们的continue_exection)

我们的程序在执行到断点之后,没办法执行下去了,这是因为我们原来在执行int 3之后,pc计数器向后移动了1个字节,我们之前无法重新设置它,所以程序会停止在那里,但是现在可以了。我们可以通过获取并修改pc的值,将保存的数据重新覆写,以实现程序的继续运行。我们在执行时可以检查我们的断点映射,看看我们是否处于断点。如果是,我们可以取消断点,并继续执行。

首先我们先添加几个辅助函数,将它们封装起来以提高清晰和简洁性:

1
2
3
4
5
6
7
uint64_t debugger::get_pc(){
return get_register_value(m_pid,reg::rip);
}

void debugger::set_pc(uint64_t pc){
set_register_value(m_pid,reg::rip,pc);
}

我们先设置一个操作来读取pc的值,便于后面的操作,这里我们设置一个操作用来实现断点后的执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void debugger::step_over_breakpoint(){
auto possible_breakpoint_location = get_pc() - 1; //pc执行断点后增加了一个字节
if(m_breakpoint.count(possible_breakpoint_location)){
auto &bp = m_breakpoint[possible_breakpoint_location];
if(bp.is_enable()){
auto previous_instruction_address = possible_breakpoint_location;
set_pc(previous_instruction_address);
bp.disable();
ptrace(PTRACE_SINGLESTEP,m_pid,nullptr,nullptr); //单步执行
wait_for_signal();
bp.enable();
}
}
}

首先我们判断有没有断点,如果有,我们就退回到下断点之前,禁用它,并单步执行原始指令。然后,再把断点重新启用(我一开始也很疑惑为啥呀重新启用呢,这里我忽略了循环的情况)

这里有个wait_for_signal实际上这是我们对waitpid的封装:

1
2
3
4
5
void debugger::wait_for_signal(){	//等待子进程执行结束
int wait_status;
auto options = 0;
waitpid(m_pid,&wait_status,options);
}

现在我们可以重写我们的continue_execution函数了:

1
2
3
4
5
void debugger::continue_execution(){
step_over_breakpoint();
ptrace(PTRACE_CONT,m_pid,nullptr,nullptr);
wait_for_signal();
}

这样很好呀

测试

试了一下断点和查看寄存和内存的功能,能正常使用,不错:

image.png

昨天写了一些基本的架构,写到一半我突然有很多灵感,为什么一定要按照教程的内容去做呢。我应该有自己的想法,而且我现在也有能力去实现,有错误也可以慢慢调试。再加上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