4615 字
23 分钟
Bk-2 计算机组成原理初步

推荐阅读:《深入理解计算机系统》(CSAPP)

01 - The Parts of Computer you’ll need to know#

总所周知,一台简单的PC(Personal Computer)一般由以下关键组件构成:

  • CPU 中央处理器:CPU是电脑的大脑,负责执行指令和处理数据。它包含算术逻辑单元(ALU)、控制单元(CU)和寄存器等部分。
  • 主板:主板是所有硬件组件的连接中心,上面有很多插槽和接口,用于连接CPU、内存、硬盘、显卡等设备。
  • RAM 内存:内存是电脑用来存储数据和程序的地方,它的速度比硬盘快得多。内存的大小对电脑的性能有很大影响。
  • 硬盘:硬盘是用来永久存储数据的设备,包括机械硬盘(HDD)和固态硬盘(SSD)两种。机械硬盘通过旋转盘片和磁头读写数据,而固态硬盘通过闪存芯片实现。
  • 显卡:显卡负责处理电脑的图形显示,它包括图形处理器(GPU)和显存。对于需要进行图形处理的应用程序,如游戏和图像编辑软件,显卡的性能至关重要。
  • 输入输出设备:电脑的输入设备包括键盘、鼠标、触摸屏等,用于输入数据和指令。输出设备包括显示器、打印机、音箱等,用于显示结果和输出数据。

以上包括其他硬件设施,都属于计算机的硬件组成,除开这些,我们还需要了解软件组成:

  • 操作系统(OS):操作系统是电脑的核心软件,它管理硬件资源、提供用户接口和运行应用程序。常见的操作系统有Windows、MacOS和Linux等。
  • 应用软件:应用软件是由程序员开发的,用于完成特定任务的软件。例如,办公软件、图像处理软件、视频编辑软件等。
  • 开发工具:开发工具是用于编写、调试和测试代码的软件,包括集成开发环境(IDE)和编译器等。常见的开发工具有Visual Studio、Eclips和Xcode等。
  • 驱动程序:驱动程序是用于控制硬件设备的软件,它们与操作系统紧密配合,使硬件能够正常工作。
  • 网络协议:网络协议是用于在计算机网络中传输数据的规则和标准。常见的网络协议有TCP/IP、HTTP、FTP等。

在CTF-PWN中,我们主要关注应用软件的底层运作,分析其漏洞并实现利用方法,最终得到Flag。而接下来的入门,我们需要从Linux操作系统的用户态开始进行。

02- x86 Register#

接下来我们了解一下CPU的储存元件——寄存器

寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。其实寄存器就是一种常用的时序逻辑电路,但这种时序逻辑电路只包含存储电路。寄存器的存储电路是由锁存器或触发器构成的,因为一个锁存器或触发器能存储1位二进制数,所以由N个锁存器或触发器可以构成N位寄存器。寄存器是中央处理器内的组成部分。寄存器是有限存储容量的高速存储部件,它们可用来暂存指令、数据和位址。

你可能已经在Bk-1-02-C Programming中了解过C语言的编译过程了,其中会产生一个低级的代码文件——汇编语言。在汇编语言中,寄存器充当着类似变量的角色。在Intel&AMD的CPU中使用的h就是我们现在要介绍的x86/x64架构。

x86架构CPU走的是复杂指令集(CISC) 路线,提供了丰富的指令来实现强大的功能,与此同时也提供了大量寄存器来辅助功能实现。

通用寄存器#

首先要介绍通用寄存器,这些的寄存器是程序执行代码最最常用,也最最基础的寄存器,程序执行过程中,绝大部分时间都是在操作这些寄存器来实现指令功能。

  • eax:通常用来执行加法,函数调用的返回值一般也放在这里面。
  • ebx:数据存取
  • ecx:通常作为计数器,例如在for循环的汇编实现
  • edx:读写IO端口,该寄存器存放端口号
  • esp:存放指向栈顶的指针
  • ebp:存放指向栈底的指针,通常用ebp + offset的形式来定位栈中的局部变量
  • esi:字符串操作时,用于存放数据源的地址
  • edi:符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作

在x64架构中,这些寄存器都拓展为了64位版本 rax rbx rcx rdx rsp rbp rsi rdi 同时引入了r8-r15的寄存器

通用寄存器在x64架构中被用来传递参数。

标志寄存器#

标志寄存器,里面有众多标记位,记录了CPU执行指令过程中的一系列状态,这些标志大都由CPU自动设置和修改

  • CF 进位标志
  • PF 奇偶标志
  • ZF 0标志
  • SF 符号标志
  • OF 补码溢出标志
  • TF 跟踪标志
  • IF 中断标志

指令寄存器#

eip: 指令寄存器可以说是CPU中最最重要的寄存器了,它指向了下一条要执行的指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复,这就是CPU工作的基本日常。

在CTF挑战中,我们主要通过修改IP寄存器的内容控制程序执行流。

段寄存器#

段寄存器与CPU的内存寻址技术紧密相关。

  • cs:代码段
  • ds:数据段
  • ss:栈段
  • es:拓展段
  • fs:数据段
  • gs:数据段

03 -Assembly Language#

要说到学习PWN,绕不开的一个话题就是汇编语言。什么是汇编语言?

汇编语言(Assembly Language)是任何一种用于电子计算机、微处理器、微控制器或其他可编程器件的低级语言,亦称为符号语言。 在汇编语言中,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址。 在不同的设备中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令

实际上,汇编语言实际上是一条条指令,指挥计算机去操作和运算。譬如:

mov eax, 8

这条指令表示将数值8赋值给eax寄存器。如果将eax看作是一个变量value那么这条指令等价于

int value = 8;

汇编语言可以无缝转换成计算机能读懂的二进制码。在接近底层研究方向的PWN,我们就是和汇编语言打交道。

在学习本节内容之前,你应该保证自己熟记了Bk-2-02,寄存器的相关知识。

基础汇编指令#

汇编语言是由一条条指令组成的,于是我们有以下类型的指令。

数据传输指令#

  • MOV 传送指令 MOV dest, src :将数据从src移动到dest
  • PUSH压栈指令 PUSH eax :将eax寄存器里的数据放入栈顶。%% 关于栈的相关知识请看Bk-2-04 %%
  • POP弹栈指令 POP ebx :将栈顶的数据放入ebx的寄存器。
  • LEA计算有效地址 LEA rdi, LABEL :将LABEL的地址赋予rdi

算术运算指令#

  • ADD加法指令 ADD eax, ebx :将eax里的数据与ebx里的数据相加,结果放入前一个中。
  • SUB 减法指令 SUB ecx, edxecx中的数据的基础上减去edx中的数据。
  • INC +1指令 INC edxedx寄存器中的数据自增1
  • DEC -1指令 DEX edxedx寄存器中的数据自减1

逻辑运算指令#

  • NOT取反指令 NOT eax :将eax中的数据的二进制按位取反%%比如数据为2,二进制表示为10,取反就是01%%
  • AND与运算 AND eax, ebx :将eax中的数据与ebx中的数据做与运算后放回前一个。
  • OR 或运算 OR eax, ebx :将eax中的数据与ebx中的数据做或运算后放回前一个。
  • XOR 异或运算 XOR eax, ebx :将eax中的数据与ebx中的数据做异或运算后放回前一个。

循环控制指令#

  • LOOP 计数循环指令 LOOP Label :使ecx的值减1,当ecx的值不为0的时候跳转至label,否则执行LOOP之后的语句。

转移指令#

  • JMP 无条件跳转指令 JMP Label :无条件跳转到label的位置
  • CALL 过程调用指令 CALL Label :等价于PUSH ip;JMP Label
  • JE & JNE :条件跳转指令 JE Label:考察前一条指令的执行结果,JE为真跳转,JNE为假跳转

汇编程序分析#

大致了解以上指令后,我们用一个简单易懂的例子来深入理解汇编语言。

%%Linux_x86_64 环境%%

一般地,我们有一个c程序

#include <stdio.h>
int main{
	printf("Hello World!");
	return 0;
}

学习了c语言,我们可以了解到c语言在编译执行时会生成一个.s文件,这就是c语言程序转换成的 汇编语言文件。

.LC0:
    .string "Hello World!"
main:
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    mov eax, 0
    ret

当然,实际的.s不会如此简洁,这里我们只保留了需要的部分。

  • .LC0可以看作一个储存着字符串常量的指针。
  • lea rdi, .LC0[rip]:将.LC0的指针地址赋予rdi,此时rdi装着一个指针,实际指向.LC0
  • mov eax, 0:将0赋予eax
  • call printf@plt :调用.plt表中记载的printf(),打印字符串。
  • ret :返回上一个函数。

看到这里,你应该对我们所说的内容有了一些懵懂的理解,在接下来的学习中。我们将从底层剖析函数调用流程参数传递规范,以及对基本数据结构——栈的学习。

04 -Stack#

栈基本介绍#

在学习函数调用流程和参数传递规范之前,我们先要了解一下一个基本的数据结构:栈(Stack)。它与堆(Heap)一样,是一种运行时数据结构。在我们运行一个程序时,首先它会被加载进入内存,按照特定的顺序将不同的内容存放到内存的各个部分。栈就是内存中的一个部分。

栈是一种典型的先进后出(First in Last out-FILO)数据结构,对于栈的操作主要有压栈(PUSH)和弹栈(POP)(出栈)。两种操作都对栈顶进行。栈在虚拟地址空间中用于保存函数调用信息和储存局部变量 %%栈示意图%%

C语言中的函数调用栈#

程序的执行可以看作连续的函数调用过程。当一个函数调用完毕,需要回到原来的函数继续执行。函数的调用通常使用堆栈来实现,编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器的值。

函数的调用通常是嵌套的。在同一时刻,栈中通常会有不同函数的信息,每个未完成的函数占用一个独立的连续的虚拟内存空间,称作栈帧Stack Frame。

%%栈帧示意图%%

栈帧的边界由寄存器BPSP界定。在x86_64架构中,RSP寄存器指向当前栈帧的栈顶RBP寄存器指向当前栈帧的栈底

在x86架构中

  • i386(32位)中,函数的参数在栈上传递
  • x86_64(64位)中,函数s的参数小于等于6时,依次由RDI,RSI,RDX,RCX,R8,R9传递,大于6个时,第7个开始的参数在栈上传递

函数调用约定#

我们有一个实验来具体学习函数调用约定。我们给定以下源码。

Source Code

#include <stdio.h>  
  
int add(int a,int b){  
 return a + b;  
}  
  
int main(){  
 int x = 1;  
 int y = 2;  
 int z;  
 z = add(x, y);  
 printf("%d", z);  
 return 0;  
}

用以下命令编译

$ gcc -m32 code.c -o code
  • -m32表示指定编译为32位程序。

同样的,我们可以查看在编译过程中会生成的.s文件。

$ gcc -m32 -S code.c -o code.s -masm=intel
  • -S表示输出汇编文件
  • -masm=intel表示用intel的汇编格式

剔除掉不重要的部分后,我们可以得到汇编代码

main:
push    ebp
mov     ebp, esp
push    ebx
push    ecx
sub     esp, 10h
call    __x86_get_pc_thunk_bx
add     ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
mov     [ebp+var_14], 1
mov     [ebp+var_10], 2
push    [ebp+var_10]
push    [ebp+var_14]
call    add
add     esp, 8
mov     [ebp+var_C], eax
sub     esp, 8
push    [ebp+var_C]
lea     eax, (aD - 3FF4h)[ebx] ; "%d"
push    eax             ; format
call    _printf
add     esp, 10h
mov     eax, 0
lea     esp, [ebp-8]
pop     ecx
pop     ebx
pop     ebp
lea     esp, [ecx-4]
retn
add:
push    ebp
mov     ebp, esp
call    __x86_get_pc_thunk_ax
add     eax, (offset _GLOBAL_OFFSET_TABLE_ - $)
mov     edx, [ebp+arg_0]
mov     eax, [ebp+arg_4]
add     eax, edx
pop     ebp
retn

我们知道main()是程序的入口。实际上main()也会被相应程序调用。我们主要关注main()的执行流程。在进入main()时,程序的栈帧如图。

此时进入main()我们需要开辟一个新的栈帧

push ebp
mov ebp, esp
  • push ebp指令表示将当前ebp的值压入栈中,实际上就是将_libc_start_main的栈底保存到栈中。_libc_start_main()函数会调用main()。这一条指令的意义就是保存当前函数的栈帧。注意这里push了之后esp会抬高一个内存单元
  • mov ebp, esp指令表示将当前esp的值赋给ebpesp是指向栈顶的指针,那么esp中的值就是当前栈顶的地址ebp的值变成了esp的值,ebp是指向栈底的指针,那么实际上ebp现在与esp重合了,将栈底抬高至栈顶

内存地址和地址储存的值是不同的,这一点注意区分。可以近似理解为指针。

push ebx
push ecx
sub esp, 10h
  • push ebxebx的值压入栈中,esp抬高一个内存单元
  • push ecxecx的值压入栈中,esp抬高一个内存单元

感兴趣的师傅可以了解一下这些寄存器的在这里的作用这里不做赘述

  • sub esp, 10hesp的值减去0x10,注意栈是高地址向低地址生长,在i386架构中,0x8bit为一个内存单元,实际上就是抬高栈顶两个内存单元。
mov     [ebp+var_14], 1
mov     [ebp+var_10], 2
push    [ebp+var_10]
push    [ebp+var_14]
  • mov [ebp+var_14], 1将1放入[ebp+var_14]的位置,此处[]表示解引用。我们知道ebp是一个指向栈底的指针,ebp+var_14表示ebp的值加上var_14的偏移量,就是指向ebp+var_14的指针,解引用表示向这个地址中的内存写入值。
  • mov [ebp+var_10], 2将2放入[ebp+var_10]的位置。
  • push [ebp+var10]&push [ebp+var_14]这两条指令将我们刚刚赋给的值push到了栈上,实际上,这一步就是参数传递,因为我们下一步准备调用add()函数了,在32位程序中,参数依靠栈来传递。
call add

这里调用了add函数,关键点。我们知道每一个函数都有一个独立的栈帧,这里我们将为add()开辟一个新的栈帧,然后,我们详细解释一下call指令

实际上,call指令是两条指令push eip;jmp label

  • push eip将当前eip的值压入栈中。我们知道eip储存当前程序下一条指令的地址,称为返回地址,那么我们这个时候将eip压入栈中实际上是压入了call指令的下一条指令的地址,保存到栈中。当我们执行完call的函数后,将原来push的值popeip,就回到上一个函数中继续执行了。
  • jmp label跳转到调用函数执行

好,现在我们执行流来到了add()

push    ebp
mov     ebp, esp

同样的,开辟栈帧。

注意此处push ebp目的是保存main()函数的栈底,在执行完当前调用函数后,我们将会销毁这个栈帧,回到main()函数的栈帧

mov     edx, [ebp+arg_0]
mov     eax, [ebp+arg_4]
add     eax, edx
  • mov edx, [ebp+arg_0][ebp+arg_0]的值放入edx中,此处对应刚刚push进到栈上的var_10
  • mov eax, [ebp+arg_4][ebp+arg_4]的值放入eax中,此处对应刚刚push进栈上的var_14
  • add eax, edx此处将edx的值与eax的值相加,返回值放入eax中。

执行完add()现在我们要返回了。

pop ebp

将栈顶的值弹入ebp

可以看到栈顶的值就是我们刚刚push入栈中的main()的栈底。

现在,栈帧归位了,我们要将执行流改到main(),就会用到ret,即pop eip,将栈顶的main return address弹入eip中。

现在我们回到main()继续执行,刚刚执行add()的结果保存在了eax中。

ax寄存器通常是保存返回值的寄存器

add     esp, 8
mov     [ebp+var_C], eax
sub     esp, 8
push    [ebp+var_C]
  • add esp, 8清理栈帧,降栈一个内存单元。
  • mov [ebp+var_C], eaxeax中的返回值放入[ebp+var_C]
  • push [ebp+var_C]保存参数,准备调用printf
lea     eax, (aD - 3FF4h)[ebx] ; "%d"
push    eax             ; format
call    _printf
  • lea eax, (aD - 3FF4h)[ebx] 这里将%d格式化字符串的指针赋给eax
  • push eaxeax的值即%d的地址压入栈中作为printf的参数。
  • call _printf调用printf外部函数。
add     esp, 10h
mov     eax, 0
lea     esp, [ebp-8]
  • add esp, 10h,降栈2个内存单元
  • mov eax, 0清空eax
  • lea esp, [ebp-8],将当前esp 的位置赋给esp
pop     ecx
pop     ebx
pop     ebp
retn
  • pop ecx&pop ebx将先前保存的寄存器的值弹回对应寄存器
  • pop ebp回到_libc_start_main的栈帧
  • retn会到_libc_start_main的执行流。

至此,程序执行完毕。

你可以通过gdb调试实时查看栈情况,通过复现此实验,彻底理解函数调用约定和参数传递流程。以上实验演示在i386架构下的函数调用。你可以通过以下指令来编译x86_64架构下的程序,学习了解其参数传递流程。

$ gcc code.c -o code
Bk-2 计算机组成原理初步
https://k4per-blog.xyz/posts/bk-2-计算机组成原理初步/
作者
K4per
发布于
2025-03-30
许可协议
CC BY-NC-SA 4.0