Bomb
Bomb实验要求是给一个用C语言编写的可执行文件bomb,你可以看到它主函数的C语言代码,除此之外,一概不知,实验分为六个阶段,每个阶段需要输入一串字符,有点像破译密码,如果六次输入的密码都是对的,炸弹解除,否则炸弹会爆炸(退出,并打印爆炸信息)
解决思路
根据题目的要求以及提示,我们可以将bomb可执行文件反汇编,对汇编语言代码进行逆向分析,而gdb是我们调试汇编语言的有效工具。
gdb常用命令
命令 | 功能 |
---|---|
gdb filename | 开始调试 |
run | 开始运行 |
run 1 2 3 | 开始运行,并且传入参数1,2,3 |
kill | 停止运行 |
quit | 退出gdb |
break sum | 在sum函数的开头设置断点 |
break *0x8048c3 | 在0x8048c3的地址处设置断点 |
delete 1 | 删除断点1 |
clear sum | 删除在sum函数入口的断点 |
stepi | 运行一条指令 |
stepi 4 | 运行4条指令 |
continue | 运行到下一个断点 |
disas sum | 反汇编sum函数 |
disas 0X12345 | 反汇编入口在0x12345的函数 |
print /x /d /t $rax | 将rax里的内容以16进制,10进制,2进制的形式输出 |
print 0x888 | 输出0x888的十进制形式 |
print (int)0x123456 | 将0x123456地址所存储的内容以数字形式输出 |
print (char*)0x123456 | 输出存储在0x123456的字符串 |
x/w $rsp | 解析在rsp所指向位置的word |
x/2w $rsp | 解析在rsp所指向位置的两个word |
x/2wd $rsp | 解析在rsp所指向位置的word,以十进制形式输出 |
info registers | 寄存器信息 |
info functions | 函数信息 |
info stack | 栈信息 |
x86-64寄存器
解决流程
先通过objdump -d bomb > bomb.txt
将可执行文件反汇编出来观察一下,会在main函数里发现这样的汇编:
1 | 400e32: e8 67 06 00 00 callq 40149e <read_line> |
可以看到这个函数的作用是通过readline读取输入值然后调用phase_1函数,那我们再来看看phase_1函数:
1 | 0000000000400ee0 <phase_1>: |
那么我们有理由猜测explode_bomb函数所实现的功能就是“BOMB”于是我们开始设置断点找到关键词。
phase_1
设置断点然后用随便的test值运行一下:
1 | (gdb) break explode_bomb |
观察对应汇编代码:
1 | (gdb) disas |
read_line函数会将读入字符串地址存放在rdi 和rsi中,strings_not_equal函数会使用edi和esi中的值当做两个字符址,并且判断他们是否相等,相等返回0
1 | (gdb) stepi 2 |
phase_1函数首先将0x402400这个赋值给esi,然后调用strings_not_equal, 而在每次调用phase_n之前都会先调用read_line读入一行并且放在edi和esi。显然这里是调用字符串比较函数比较我们输入的字符串和存放在0x402400地址的字符串是否相等,紧接着调用test指令,如果eax为0也就是两个字符串相等就跳转到函数结尾,否则调用explode_bomb函数,这个就是引爆炸弹的函数。到这里答案也就出来了,我们需要输入的就是存放在0x402400处的字符串。
phase_2
接下来让我们开始第二个phase:
1 | (gdb) continue |
一开始调用了read_six_numbers函数,显然这次要求的输入是6个数字,但为了严谨我们还是跳进去看一眼:
1 | ...... |
由调用类scanf函数,参数是输入字符串和”%d %d %d %d %d %d”,可以确定输入格式是空格隔开的六个数字。
je 0x400f30
中的 je 为 jmp equal,可以理解为等于就跳转,cmpl $0x1,(%rsp) 比较栈指针指向的第一个数字是否为1,如果是1则跳转到 lea 0x4(%rsp),%rbx,否则执行 explode_bomb
然后第一个数字必须是1,否则会爆炸,数字为1则跳转到+52,即lea 0x4(%rsp),%rbx
把0x4(%rsp)偏移量传送给rbx,%rsp是栈指针的地址,每个int的数据长度为4个bytes,这句话的意思就是说读取下一个数字的地址,存入%rbx里。
然后57行把第六个数字的地址存入rbp里,再跳转到27行,取rbx上一个数字的地址也就是第一个数字存入eax,再让eax自增一个eax,即eax存的值*2,此时再比较rbx和eax存的值是否相等就是在比较第二个数字是否为第一个数字的两倍。如果没问题就取rbx下一位,即第三个数字,先和rbp进行比较判断,如果不等就跳转到27行,相等就跳到函数结尾。27行就是之前的取rbx前一个数然后乘二,看是否与rbx现在的数相等,由此可以判断这个输入应该是 1 2 4 8 16 32
phase_3
1 | Phase 1 defused. How about the next one? |
JA是无符号大于时跳转;jmpq d(a,b,c)=jmp a+b*c+d
这段代码一上来调用了sscanf函数,老规矩 print (char)0x4025cf,发现格式字符串为“%d %d”,也就是两个int数字,同时cmp $0x1,%eax
要求return值大于1,接下来39行读取第一个数字,要求小于等于7,否则也会引爆炸弹,再往下+50,发现跳转语句,根据第一个数字取值的不同跳转到不同的地址`0x402470(,%rax,8)` 后,获取一个值放入%eax里,要求第二个数必须等于放入的值。
由此我们顺序运行下去发现跳转到了=> 0x0000000000400fb9 <+118>: mov $0x137,%eax
这里,于是“rax 0x137 311”就是我们的最终答案,即 1 311 这个关键词可以过phase3这关。
phase_4
1 | Breakpoint 2, 0x000000000040100c in phase_4 () |
现在我们来看看phase4,首先print (char*)0x4025cf
可以发现 0x4025cf “%d %d”,说明本次还是两个int的输入。34行的 cmpl $0xe,0x8(%rsp) 要求第一个数字必须 <= 14, >= 0(无符号)然后到46行开始输入参数:
1 | (gdb) info register |
查看调用func4时的寄存器可以清楚看到,第一个参数是1即我们输入的第一个数字,第二个参数为0,第三个参数为14,那我们接下来进入func4函数中一看究竟:
1 | Breakpoint 3, 0x0000000000400fce in func4 () |
经过摸索,这段汇编代码大概是一个如下的二分:
1 | int func4(int n, int i, int j) { |
TEST 测试(两操作数作与运算,仅修改标志位,不回送结果) 由此 test ax,ax 即是判断 ax == 0?
然后test %eax,%eax
判断return是否为0,如果非0就爆炸。同时cmpl $0x0,0xc(%rsp)
要求第二个输入为0,由此最简单的输入就是 0 0,这样就可以过关
phase_5
1 | (gdb) disas |
首先通过cmp $0x6,%eax
我们可以确定字符串长度为6,然后跳转 movzbl (%rbx,%rax,1),%ecx
执行 mobzbl 指令(将一个源操作数低1字节长度的值0扩展到32位并存放在目标寄存器处),在这里就是取得字符串内第一个字符;而后将此字符(就是 %cl 内存放的 值,%cl 是 %ecx 的低8位寄存器)送到栈顶%rsp处。and $0xf,%edx
获取当前字符的后四位 ,movzbl 0x4024b0(%rdx),%edx
将edx后四位作为0x4024b0字符数组的索引值,依次拷贝字符数组到0x10((%rsp,%rax,1)),再用%rax循环6次。movb $0x0,0x16(%rsp)
字符串末尾添加”\0”后和字符串常量$0x40245e进行比较。
1 | (gdb) print (char*)0x4024b0 |
所以用来被截取的字符串为“maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?”,用来对比的字符串为“flyers”,我们发现flyers6个字符分别出现在str1的第9位,第15位,第14位,第5位,第6位,第7位,输入的字符串后四位的二进制只要分别表示9,15,14,5,6,7即可,翻查ASCII表,可以得到结果 ionefg (YONUFG)
phase_6
1 | (gdb) disas |
终于到最后一个了,这最后一个看起来有点劝退啊orz~,首先依然是callq 0x40145c <read_six_numbers>
读取六个数字。然后将栈顶指针赋给%eax,也就是要求 第一个数字 - 1 <= 5
,然后用%r12d作循环计数,到62行开始执行判断六个数字不相等的功能,然后跳到81行开始执行判断数字小于等于6的功能。到后面110行将数字与7求差并将结果放入原先位置。
然后抽象的来了,我们来细看这段汇编代码:
1 | 0x000000000040116f <+123>: mov $0x0,%esi |
这是一个循环,此循环依次将由前面步骤获得的 %rsp %rsp+4 等地址处存放的值与1比较,如果相等, 则将 0x6032d0 这个地址存放在 %rsp + %rsi*2 + 0x20 地址处,%rsi 中存放的数字用来判断是否终止循环,从0开始依次加4,等于24时终止此次循环;如果不相等,则再与2比较,并将 0x6032d0 加 8,这又是一个循环:不相等就递增, 直到相等为止,此时将获得的 0x6032d0 递增后的值放入相应地址处;
1 | 0x00000000004011ab <+183>: mov 0x20(%rsp),%rbx |
第5步中获得的值中,按顺序把下一个值放置于上一个值+8得到的地址中(其实就是在建立链表)
1 | 0x00000000004011da <+230>: mov $0x5,%ebp |
循环交替比较链表元素中的大小,前面的值必须大于等于后面的。
根据前面可知输入的字符串中6个数字只能是 123456 的排列组合;第5步中提及的 0x6032d0 中存放的是332,0x6032d8 处存放的是地址,0x6032e0 存放的是 168, 接下内依次是 924、691、477、443;根据第6步得出的结论即链表前一个元素必须比接下来的大,其实就是以我们输入的数据为索引,根据原有的链表建立新的降序链表,那么最后一个应该是 4 3 2 1 6 5
1 | (gdb) run |
最终可以看到我们成功解决了这6个bomb,这个lab让我对汇编和gdb都有了进一步的认知和了解,作为一个没学过编译原理的人,这个lab还是借鉴了很多网上的教程才一步步啃下来的,但整个lab确实很有意思,能感受到由浅到深精心设计出来的,寓教于乐。
Attack
Attack实验分为两个部分,注入攻击和返回值攻击,CI(代码注入)方式来破解的 Ctarget 可执行文件运行栈位置是不变的; ROP(返回值攻击)方式来破解的 Rtarget 这个可执行代码文件的运行时栈位置每次运行 都不同的;而且它在栈中的代码是不可执行的,否则会引起 Segmentation Fault,所以不能用代码注入的方式来破解。
打开Attack lab的文件夹,可以发现里面主要有四个文件:
- ctarget 用来进行代码注入攻击
- rtarget 用来进行返回值攻击
- cookie.txt 是一个唯一表示的字符串,可以理解为ID,主要是CMU用来防止学生作弊互相抄答案,里面很多需要攻击的函数,都要将这个作为参数传入
- hex2raw 用来将64进制Byte码变成字符串,可用来传入到需要攻击的程序中
Code Injection Attacks
这是lab的第一部分,代码注入攻击,ctarget是我们要攻击的程序,题目告诉我们ctarget里有一个叫做test的函数
1 | void test() { |
根据题目的意思这个函数应该是每次都会调用的,然后这个函数会调用一个不安全的getbuf函数,不检查读入的字符串的大小,也不加以任何保护,使得我们可以利用getbuf对其进行代码注入达到攻击的效果,原理如图所示:
在x86-64机器中,call Q
指令会把返回地址即紧跟在call
指令后的那条指令的地址压入栈中,并将程序计数器设置为Q的起始地址;对应的ret
指令会从栈中弹出返回地址,并把程序计数器设置为该返回地址。被调用者Q的栈帧自栈底(高地址)到栈顶(低地址)包括了被保存的寄存器,局部变量和参数构造区。而调用者Q的栈帧自栈底到栈顶包括了参数以及返回地址。对于getbuf函数来说,不存在被保存的寄存器,在缓冲区溢出之后,溢出的字符会直接覆盖调用者栈帧中的返回地址。
phase_1
第一关要求我们让test不返回,而是getbuf后直接运行一个叫做touch1的函数,其C语言代码如下:
1 | void test() { |
切入点是 getbuf 函数,它调用的 Gets 函数与C库函数 gets 功能类似,但引入了一个 漏洞。它为 buf 这个变量在栈中分配了空间,如果输入的字符串长度超过 BUFFER_SIZE 指定的长度,Gets 就会将多的字符数据覆盖到不属于这个 buf 变量的栈空间中(试试看 ./ ctarget 一个超长字符串
,会报 Segment Fault)在调用函数前,会将调用结束后的下一个指令的地址存放于栈中 push %rip
,当调用函数返回时,就会从栈中取回地址并继续运行。所以我们只需要把所需函数地址注入到运行栈,将原先的返回地址覆盖掉即可,在这里就是利用 Gets 函数的漏洞,将这个地址放置于输入的字符串中。
1 | 00000000004017a8 <getbuf>: |
将touch1用gdb反汇编,我们得出touch1的入口地址为 0x4017c0,反汇编getbuf 可以看出为了创建这个字符数组 buf 占用了40个内存地址的空间,也就是说最多只能输入40个字符,而当前的栈底部 +8 的位置就存储了此次调用结束后的返回地址。所以我们只需要将 0x4017c0 扩展成 0x004017c0,因为是64位系统,地址占据了8个地址的空间,小端法字节序反过来,所以输入时应该是 c0 17 40 00,前面还应该有40个字符,全填0就可以了,再将填好的文本用 hex2raw 将64进制Byte码变成字符串运行 ctarget 即可。
(然后我发现WSL做这个会出现 Couldn’t map stack to segment at 0x55586000 的问题,就改用了虚拟机)
1 | root@debian:~/ipads/training/ics-lab# ./hex2raw < test.txt > phase1.txt |
phase_2
1 | void touch2(unsigned val){ |
要求在 test 函数中调用 getbuf 函数返回后,进入 touch2 函数调用,同时要求不仅需要代码注入从而跳到别的函数处执行,还要在代码注入中为这个函数准备参数,也就是我们的cookie,即我们需要先把参数 %rdi 的数值设置为 0x59b997fa,再调用touch2,我们来看下touch2的汇编:
1 | 00000000004017ec <touch2>: |
考虑到在ctarget中,栈地址固定以及允许在栈上执行代码,所以我们可以通过缓冲区溢出漏洞将返回地址指定到栈上,在栈上执行相应的指令,为函数touch2设置参数,最后再从栈上返回至touch2函数即可。首先我们为getbuf设置断点拿到%rsp的值:
1 | (gdb) break getbuf |
可以看出 ctarget 在执行 getbuf 开辟空间后 %rsp 位置在 0x5561dc78 处,那么接下来我们就可以开始具体操作了。首先把攻击代码写好,也就是先将cookie值塞入%rdi ,再 pushq touch2 后 retq 即可:
1 | movq $0x59b997fa,%rdi |
然后就其编译再反汇编得到机器码:
1 | root@debian:~/ipads/training/ics-lab# vi phase2.s |
由此我们就可以得到我们最终的文本:
1 | 48 c7 c7 fa 97 b9 59 68 |
可以看到最后成功完成touch2的要求:
1 | root@debian:~/ipads/training/ics-lab# ./ctarget -qi phase2.txt |
phase_3
1 | /*Compare string to hex represention of unsigned value*/ |
这和上一题类似,只是传参需要是构造的字符串首地址,我们将目标字符串通过缓冲区溢出攻击注入到栈段,并且将其首地址设置为 %rdi 即可。然后根据题目advice可以知道,调用hexmatch和strncmp函数的时候会覆盖getbuf原先使用的内存空间。因为我们是在getbuf退栈返回后调用touch3函数,所以touch3及其内部调用的hexmatch会使栈重新增长,这块区域会包括之前getbuf分配到的40个地址空间。首先我们看一眼touch3的具体情况:
1 | 00000000004018fa <touch3>: |
所以 touch3 的地址是 0x4018fa
,然后cookie转ASCII结果为 59b997fa -> 35 39 62 39 39 37 66 61 00
汇编代码执行到touch3的时候栈顶会变成0x5561dca8
,如果在小于此地址的地方写数据在执行touch3的时候会被新压入栈的数据覆写掉中,所以最后注入的代码就是:
1 | movq $0x5561dca8,%rdi |
其余操作和上一题相同,最后可以发现执行成功。
1 | root@debian:~/ipads/training/ics-lab# ./ctarget -qi phase3.txt |
Return Oriented Programming
这一部分是攻击rtarget这个文件,和之前不同的是rtarget有一些保护措施,例如栈地址随机化和部分栈内容是只读的,因此像ctarget一样直接注入代码是没有用的,对于这种保护措施,我们依然有方法去进行攻击,这个就是Return Oriented Programming
这种攻击方式的原理是,程序的汇编语言代码中,会出现我们需要的代码片段,并且以0xc3,也就是返回为终止,这种代码片段叫做gadget,合理利用gadget,我们就能实现return oriented programming这种攻击模式。举个例子
1 | void setval_210(unsigned *p){ |
上述这段代码,是将一个unsigned指针的值改变成一个奇怪数字,我们来观察它的汇编语言代码:
1 | 0000000000400f15 <setval_210>: |
我们发现第一行是48 89 c7,这个在x86-64汇编语言中代表了movq %rax, %rdi这条语句,并且以c3也就是retq为结束,即如果我们能够让程序从400f18开始运行,则相当于运行了movq %rax, %rdi,并且返回。
我们可以利用程序本身的代码,构造出一组由不同的返回地址组成的攻击代码,每一个返回地址都指向了一个函数的最末尾的若干字节,由ret结尾。这样,程序就会按照我们设计的顺序依次执行这些代码片段,以达到修改程序行为的结果,这就是返回导向编程。这些代码片段被称作gadget,而这些gadgets共同组成了一个gadget farm。
phase_4
利用gadget实现phase2的攻击,也就是运行touch2,题目有一个提示,我们只需要用到start_farm和mid_farm中间的gadget就可以了。
我们首先观察gadget_farm中的相关gadgets,并决定其是否可以用作攻击。根据上述的思路,我们可以得到两个gadget set_val426及getval_280,它们的反汇编代码如下:
1 | 00000000004019c3 <setval_426>: |
setval_426中的48 89 c7 90 c3可以被解释为mov %rax,%rdi nop ret,而getval_280中的58 90 c3可以被解释为pop %rax nop ret,将这两个gadget结合,即可以得到阶段4的结果:
1 | 00 00 00 00 00 00 00 00 |
phase_5
首先将rsp存入某个寄存器之中,然后再将一个特定的常量pop至另一个寄存器之中,最后将这两个值分别存入%rsi和%rdi,调用add_xy将其相加得到字符串的首地址,并将结果%rax存入%rdi之中,最后再调用函数touch3即可。受制于gadget的种类,我们可能会用到多个gadget做中转。最终的攻击代码如下:
1 | 00 00 00 00 00 00 00 00 |
Shell
题目要求实现一个简易的shell加强对进程控制与信号传递的理解,shell程序框架已经帮你搭建好了,只需修改 tsh.c 文件完成以下六个函数,修改后记得用 make 编译一下:
- eval: 主要对命令行进行分词并执行指令
- builtin_cmd: 检查是否为内置指令
- do_bgfg: 执行fg、bg指令
- waitfg: 等待fg指令执行完毕
- sigchld_handler: SIGCHLD信号处理函数
- sigint_handler: SIGINT信号处理函数(ctrl-c)
- sigtstp_handler: SIGTSTP信号处理函数(ctrl-z)
前置知识
信号
信号是一种消息,用来通知进程发生了某种事件
类似于(akin to)异常和中断,这个消息是从内核发送至一个进程(有时是应另一个进程的要求)
信号类型是1~30的编号,信号传递的唯一信息是它的信号ID和它到达的事实。
发送信号的原因:
- 内核检测到一个系统事件,如Ctrl-C(SIGINT),除零(SIGFPE),或一个子进程的终止(SIGCHLD)
- 另一个进程调用了kill函数(kill函数是发送信号的函数,不要误认为是kill进程的函数)
- 当一个目标进程被内核强迫以某种方式对信号的传递做出反应时,它就会接收一个信号
- 接收信号是不排队的(信号存储是一个位图,每一位代表一种信号,所以多个同种信号也只会存一次)
- 信号接收的时间:上下文切换(上下文切换的时候,本来执行进程A,内核态准备切换到进程B执行时,会去检查是否有收到信号,并去处理)
对信号的三种反应: 忽略信号、终止进程、捕捉这个信号,执行用户级的信号处理程序(类似于硬件异常处理程序被调用以响应异步中断)
阻塞信号:有时代码需要运行在一个不能被中断的部分,用sigprocmask()函数实现
等待信号:有时,我们想暂停执行,直到得到一个特定的信号,用sigsuspend()实现
- SIGKILL and SIGSTOP不能被改变反应行为
步骤流程
01
1 | stardust@LAPTOP-4IJ6JI22:/mnt/e/Program/ipads/training/ics-lab$ make |
看起来没有什么问题,我们来进行下一步试试。
02
1 | stardust@LAPTOP-4IJ6JI22:/mnt/e/Program/ipads/training/ics-lab$ make test02 |
我们发现程序卡在了这一步上,于是我们打开 trace02 发现有一条 quit 指令没执行,然后发现eval和builtin_command函数空空如也,于是就开始照搬一下书上P525的代码:
1 | void eval(char *cmdline) |
然后 make 一下发现运行ok无误,于是开始下一个,发现03和04都没有问题,直到05开始。
05
1 |
|
到了05的时候,我们发现jobs这个命令需要我们自己实现,我们首先要在builtin_cmd
函数中,加入对jobs
指令的检测,接着调用listjobs(jobs);
(这个函数是lab里给你写好的,直接用就好了)
/\ listjobs - Print the job list */
void listjobs(struct job_t jobs)
如果要调用该函数,job list也必须进行相应的改变,这一实现必须在eval
的父进程中实现。思路是每执行一个任务,就在job list中添加一项,每结束一个任务,就将对应pid的任务在list中删除。在访问全局变量以及创建子进程的过程中需要将SIGCHLD信号进行阻塞,否则会发生意想不到的错误(因为僵尸进程的SIGCHLD信号,导致先运行deletejob再运行addjob产生意外错误)这里代码我们可以参考书本543页的示例,在fork前阻塞SIGCHLD信号,然后在调用addjob之后取消阻塞这种信号,保证了子进程在被加到job list之后回收该子进程。TIPS,子进程继承了其父进程的被阻塞set,所以要在调用exec前解除子进程被阻塞的SIGCHLD信号。
1
2
3 >
> int sigprocmask( int how, const sigset_t *restrict set, sigset_t *restrict oset );
>一个进程的信号屏蔽字规定了当前阻塞而不能递送给该进程的信号集。调用函数
sigprocmask
可以检测或更改其信号屏蔽字,不能阻塞SIGKILL和SIGSTOP信号。返回值:若成功则返回0,若出错则返回-1
若oset是非空指针,那么进程的当前信号屏蔽字通过oset返回。
若set是一个非空指针,则参数how指示如何修改当前信号屏蔽字。
how 说明 SIG_BLOCK 该进程新的信号屏蔽字是其当前信号屏蔽字和set指向信号集的并集。set包含了我们希望阻塞的附加信号 SIG_UNBLOCK 该进程新的信号屏蔽字是其当前信号屏蔽字和set所指向信号集补集的交集。set包含了我希望解除阻塞的信号 SIG_SETMASK 该进程新的信号屏蔽字将被set指向的信号集的值代替 由此来举例子就是,
sigprocmask(SIG_BLOCK, &mask_one, &prev_one)
因为maskone我们之前赋了SIGCHLD值,所以这句会直接让当前进程屏蔽SIGCHLD信号,并用prevone保存当前信号集,这样之后就刻意用sigprocmask(SIG_SETMASK, &prev_one, NULL)
来取消之前的阻塞。
由此,我们可以将lab所给的函数完善一下:
1 | void eval(char *cmdline) |
这一步其实工作量还是挺大的,需要仔细看下书本的内容(听听b站的网课)最后运行一下:
1 | stardust@LAPTOP-4IJ6JI22:/mnt/e/Program/ipads/training/ics-lab$ make test05 |
06
这个就是实现处理sigint信号的handler函数,同时要修改一下sigchld_handler
函数:
关于
int kill(pid_t pid, int sig)
函数,pid可能的选择有以下四种:
- pid大于零时,pid是信号欲送往的进程的标识
- pid等于零时,信号将送往所有与调用kill()的那个进程属同一个使用组的进程
- pid等于-1时,信号将送往所有调用进程有权给其发送信号的进程,除了进程1(init)
- pid小于-1时,信号将送往以-pid为组标识的进程
1 | void sigint_handler(int sig) |
然后来让我们运行一下:
1 | stardust@LAPTOP-4IJ6JI22:/mnt/e/Program/ipads/training/ics-lab$ make test06 |
08
这个和上面差不多,完善一下处理SIGTSTP信号的函数,补充一下对应的SIGCHLD部分:
1 | void sigtstp_handler(int sig) |
09
完成 bg 和 fg 对应的函数:
bg命令是先kill一个SIGCONT指令(SIGCONT用于继续进程),让暂停的进程继续,然后将job的state变成BG,再打印出来,然后就任由其在后台执行了。
fg命令是先kill一个SIGCONT让暂停的进程继续,然后将job的state变成FG再waitfg去等待这个前台执行完成。
1 | void do_bgfg(char **argv) |
运行一下试试:
1 | stardust@LAPTOP-4IJ6JI22:/mnt/e/Program/ipads/training/ics-lab$ make test09 |
主要功能实现就是跟着test01~test10,这样就可以完成所有函数的补充了,确实对信号机制理解深很多。
Malloc
前置知识
三种适配方式:
- fitst fit:最为直接的办法。扫描所有的块,只要当前块的大小满足要求就使用,速度较快。但容易导致空闲列表中前面的块被不断地细分,而后面的一些块却一直迟迟得不到利用。
- next fit:扫描的时候,每次从上一次扫描的下一个块开始,这样可以使得整个列表的块都可以被使用,这使得效率更高。然而,实际应用中,作用也很有限,容易产生很大的空间浪费,造成大量碎片。
- best fit:这种方式最大的好处是可以充分地利用空间。找到所有满足要求的块中最小的那一个,这样可以很大程度上避免浪费。当然,这也使得时间成本较高,尤其是如果空间链表的组织方式不太恰当的话,容易导致每次都要遍历一整个列表。
四种空闲列表的组织方式
- implicit free list:这种方式最为简单,直接将所有的块(不管是否有分配)串在一起,然后遍历。这种方式可也使得块最小可以达到8 bytes。当然,这种方式效率很低,尤其是当块的数量较多的时候。
- explicit free list:在每一个free 块中保存两个指针,将所有空闲的块组成一个双向链表。和隐式相比,这种方式最大的好处在于我们不需要遍历已经分配的块,速度上快了很多,当然,由于需要保存指针,所以每一个块最小为16 bytes。
- segregated free list:这种方式的特点在于,根据块的不同大小,分成k组,组织成k条双向链表。分类的方式有很多,比如可以采用2的倍数的分类方式,{1},{2},{3-4},{5-8}……大小为6的块放在第四条链中,大小为3的块则放在第三条链中等等。
- Red-Black Tree:按大小排序的平衡树,在每个空闲块中使用一个带指针的平衡树,并使用长度作为权值。
两种合并的方式
- immediate coalescing:每次释放后立即合并,当内存块被释放后,立即与相邻的空闲内存块合并,以获得一个更大的空闲块,插入到链表的相应位置。这样可以减少碎片化。
- deferred coalescing: 尝试通过延迟合并,即直到需要才合并来提高释放的性能,例如:
分配器是如何实现合并的?如果要合并(内存中)下一个空闲块,只需要当前free掉的块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块即可在常数时间内被合并。
但如果要合并前面的块呢?给定一个带头部的隐式空闲链表,唯一的选择将是搜索整个链表,记住前面块的位置,直到我们到达当前块。使用隐式空闲链表,这意味着每次调用 free 需要的时间都与堆的大小成线性关系。即使用更复杂精细的空闲链表组织,搜索时间也不会是常数。
由此引入了边界标记(boundary tag)允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾处添加一个脚部(footer,边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检査它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用的。然而,它也存在一个潜在的缺陷。它要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。例如,如果一个图形应用通过反复调用 malloc 和 free 来动态地创建和销毁图形节点,并且每个图形节点都只要求两个内存字,那么头部和脚部将占用每个已分配块的一半的空间。
幸运的是,有一种非常聪明的边界标记的优化方法,能够使得在已分配块中不再需要脚部。回想一下,当我们试图在内存中合并当前块以及前面的块和后面的块时,只有在前面的块是空闲时,才会需要用到它的脚部。如果我们把前面块的已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不需要脚部了,这样我们就可以将这个多出来的空间用作有效载荷了。不过请注意,空闲块仍然需要脚部。
两种内存碎片
- internal fragmentation:内部碎片,即是已经被分配出去(能明确指出属于哪个进程)却不能被利用的内存空间。比如当前我需要44 bytes的内存,然后malloc的时候分配到了一个48 bytes的块,这样的话,剩下的4 bytes的内存尽管我不需要用到,但是其他程序也无法使用,这就是内部碎片。
- external fragmentation:外部碎片,即还没有被分配出去(不属于任何进程),但由于太小了无法分配给申请内存空间的新进程的内存空闲区域。
实验开始之前
一般来说,做这个实验都会遇到首次make时报错的问题:
1 | “fatal error: bits/libc-header-start.h: No such file or directory” while compiling |
这个只需要使用如下命令安装32位的库(不知道为什么我wsl和debian虚拟机都各种报错,直到ubuntu才成功
1 | apt-get install gcc-multilib |
在make完之后,在执行./mdriver -V
时会出现找不到文件路径的问题:
1 | Testing mm malloc |
这个只需要在config.h中设置TRACEDIR为自己traces文件夹的路径再重新make即可,例如:
1 | #define TRACEDIR "/home/os/Desktop/training/ics-lab/traces/" |
隐式空闲链表
首先是用最简单的隐式空闲链表+首次适配+realloc方式,采用隐式空闲链表组织空闲块,并且在头部和尾部添加了序言块和结尾块,方便添加和释放块。在空闲块适配上,采用最简单的首次适配策略,由头到尾遍历的第一个符合大小的空闲块会被采用。这种方式显然不是最佳,会在头部形成大量碎片,最后跑分很低(划掉)
init
首先是init函数,mm_init 函数初始化分配器,如果成功就返回 0,否则就返回 -1。
第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block),这是一个 8 字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后紧跟的是零个或者多个由 malloc 或者 free 调用创建的普通块。堆总是以一个特殊的结尾块(epilogue block)来结束,这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个单独的私有(static)全局变量(heap_listp),它总是指向序言块。(作为一个小优化,我们可以让它指向下一个块,而不是这个序言块)
编写代码时还需要定义一些基本的宏:字的大小(WSIZE)双字的大小(DSIZE)初始空闲块的大小和扩展堆时的默认大小(CHUNKSIZE);PACK 宏将大小和已分配位结合起来并返回一个值,可以把它存放在头部或者脚部中。GET 宏读取和返回参数 p 引用的字。这里强制类型转换是至关重要的,参数 P 典型地是一个(viod*) 指针,不可以直接进行间接引用。同理,PUT 宏将 val 存放在参数 p 指向的字中。
1 |
|
GET_SIZE 和 GET_ALLOC 宏从地址 p 处的头部或者脚部分别返回大小和已分配位。剩下的宏是对块指针(block pointer 用bp
表示)的操作,块指针指向第一个有效载荷字节。给定一个块指针 bp,HDRP 和 FTRP 宏分别返回指向这个块的头部和脚部的指针。NEXT_BLKP 和 PREV_BLKP 宏分别返回指向后面的块和前面的块的块指针。
1 | /* Given block ptr bp, compute address of its header and footer */ |
extend_heap 函数会在两种不同的环境中被调用:1)当堆被初始化时;2)当 loc 不能找到一个合适的匹配块时。为了保持对齐,extend_heap 将请求大小向上舍入为最接近的 2 字(8 字节)的倍数,然后向内存系统请求额外的堆空间。
extend_heap 函数的剩余部分有点儿微妙。堆开始于一个双字对齐的边界,并且每次对 extend_heap 的调用都返回一个块,该块的大小是双字的整数倍。因此,对 mem_sbrk 的每次调用都返回一个双字对齐的内存片,紧跟在结尾块的头部后面。这个头部变成了新的空闲块的头部,并且这个片的最后一个字变成了新的结尾块的头部。最后,在很可能出现的前一个堆以一个空闲块结束的情况中,调用 coalesce 函数来合并两个空闲块,并返回指向合并后的块的块指针。
free
通过调用 mm_free 函数来释放一个以前分配的块,这个函数释放所请求的块(bp),然后使用边界标记合并技术将之与邻接的空闲块合并起来。
1 | void mm_free(void *bp) |
情况 1:前面的和后面块都已分配。情况 2:前面块已分配,后面块空闲。
情况 3:前面块空闲,后面块已分配。情况 4:后面块和前面块都空闲。
case1 ,两个邻接的块都是已分配的,不可能合并。所以当前块的状态只是简单地从已分配变成空闲。
case2,当前块与后面的块合并。用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部。
case3,前面的块和当前块合并。用两个块大小的和来更新前面块的头部和当前块的脚部。
case4,要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每种情况中,合并都是在常数时间内完成的。
malloc
一个应用通过调用 mm_malloc 函数来向内存请求大小为 size 字节的块。在检査完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。代码强制了最小块大小是 16 字节:8 字节用来满足对齐要求,而另外 8 个用来放头部和脚部。对于超过 8 字节的请求一般的规则是加上开销字节,然后向上舍入到最接近的 8 的整数倍。
一旦分配器调整了请求的大小,它就会搜索空闲链表,寻找一个合适的空闲块。如果有合适的,那么分配器就放置这个请求块,并可选地分割出多余的部分,然后返回新分配块的地址。如果分配器不能够发现一个匹配的块,那么就用一个新的空闲块来扩展堆,把请求块放置在这个新的空闲块里,可选地分割这个块,然后返回一个指针,指向这个新分配的块。
1 | void *mm_malloc(size_t size) |
realloc
1 | void *mm_realloc(void *ptr, size_t size) |
显示空闲链表
隐式空闲链表看上去比较简单容易理解,但因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数量预先就知道是很小的特殊的分配器来说还是可以)一种更好的方法是将空闲块组织为某种形式的显式数据结构,例如双向空闲链表,在每个空闲块中,都包含一个 pred 前驱和 succ 后继指针。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间取决于我们所选择的空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比 LIFO 排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
init
1 | int mm_init(void) |
创建初始堆的大小由原先的 4 WSIZE 扩充成了 6 WSIZE ,在开始处新增了前后两个指针。
1 |
|
向内存系统请求words大小的堆空间,因为堆是向上连续申请的,新申请的在结尾块后面。这里把之前的结尾块当做新申请的头部,申请最后字节当做尾部,最后再尝试合并bp前的块。
free
1 | void mm_free(void *ptr) |
malloc
1 | void *mm_malloc(size_t size) |
realloc
1 | void *mm_realloc(void *ptr, size_t size) |
最后跑分结果其实也不是不能看哈(捂脸
1 | Results for mm malloc: |
分离空闲链表
一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。而我们可以用分离存储(segregated storage)来减少分配时间,即维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class)。有很多种方式来定义大小类,例如可以根据 2 的幕来划分块大小:{1},{2},{3,4},{5∼8},⋯ ,{1025∼2048},{4097∼∞}
分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为 n 的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。
有关动态内存分配的文献描述了几十种分离存储方法,主要的区别在于它们如何定义大小类,何时进行合并,何时向操作系统请求额外的堆内存,是否允许分割,等等。基本的方法有简单分离存储(simple segregated storage)和分离适配(segregated fit)以下用分离适配作为举例。
分离适配方法分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。有许多种不同的分离适配分配器。
为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,査找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
分离适配方法是一种常见的选择,C 标准库中提供的 GNU malloc 包就是釆用的这种方法,因为这种方法既快速,对内存的使用也很有效率。搜索时间减少了,因为搜索被限制在堆的某个部分,而不是整个堆。内存利用率得到了改善,因为有一个有趣的事实:对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内存利用率。
init
1 | int mm_init(void) |
free
1 | void mm_free(void *ptr) |
malloc
1 | void *mm_malloc(size_t size) |
realloc
1 | void *mm_realloc(void *ptr, size_t size) |