Programming from the Ground Up notes
本文是 Jonathan Bartlett 撰写的 Programming from the Ground Up 书的笔记
Chapter 1 介绍
工具
本书讨论的是x86的汇编语言和 GNU/Linux 操作系统,所以我们会使用标准的 GCC 工具集。
Tips:本书使用AT&T语体
Chapter 2 计算机架构
现代计算机架构基于冯诺依曼架构,包括两个部分——CPU和内存。
计算机内存的结构
存储在计算机的内存里面的不止是数据,还有控制计算机操作的程序。除了它们被使用的方式不同,它们都以相同的方式储存在内存中并以相同的方式访问。
中央处理器(CPU)
CPU从内存中读取指令并执行它们。这被称为 fetch-excute cycle
为了实现这个,CPU包含:
- 程序计数器(Program Counter)
- 指令解码器(Instruction Decoder)
- 数据总线(Data bus)
- 通用寄存器(General-purpose registers)
- 算术和逻辑单元(Arithmetic and logic unit)
程序计数器告诉计算机该从哪里获取下一条指令。程序计数器保存着下一个该被执行的指令的内存地址。CPU通过程序计数器中储存的内存地址,将对应地址的指令传递给指令解码器。指令解码器将解码指令并指明该条指令有关的内存地址。计算机指令一般由实际的指令和一些内存地址组成。
然后计算机用数据总线来获取待处理的内存位置。数据总线用来连接CPU和内存的物理连线,就像主板上的线那样。
除了处理器外面的内存,处理器自己还有一些特殊的高速的内存位置,称作寄存器(registers)。有两种寄存器——通用寄存器(General-purpose registers) 和 专用寄存器(Special-purpose registers)。
现在CPU已经获取到了它需要的所有数据,它将数据和已解码的指令传递给算术和逻辑单元来进行进一步处理。指令实际上就是在这里被执行的。在计算完成之后,结果被放在数据总线上并被储存到合适的内存位置或寄存器中。
这是一个极度简化了的过程,事实上,现代计算机已经进步了很多, 以上的过程并未考虑缓存层次结构、超标量处理器、流水线、分支预测、乱序执行、微码转换、协处理器和其他优化。
一些术语
计算机的内存是固定大小的有序的存储空间。每个存储位置对应的编号叫做它的 地址(address) 单个存储位置的大小叫做 字节(byte) 。在x86处理器上,一字节就是一个0到255的数。
计算机可以使用ASCII来处理文字。
如果我们需要表示比255大的数字怎么办?我们可以吧多个字节整合在一起,比如两个字节最大表示65535,四个字节最大可以表示4294967295。幸运的是,我们不必手动把字节捏在一起,事实上,计算机会帮我们处理细节。我们默认处理四字节长的数据
你可以把寄存器想象为你的桌子,上面保存着你要处理的数据。
在计算机上,一个典型的寄存器的大小叫做一个电脑的字长(word size)。x86处理器的字长为四字节。这意味着四字节是在x86上处理的最自然的。地址也是四字节长的,所以它能塞进一个寄存器内,
储存在内存中的地址也叫做指针(pointers)。它们指向一个不同的内存位置。
正如我们前面提到的,计算机指令和数据以一致的方式储存着。区别他俩的唯一方式就是通过一个专用寄存器叫做指令指针,它指向内存中的指令。如果指令指针指向一个内存字,那么它就会被解释为指令。
解读内存
计算机十分精确,所以你对你在操作什么东西必须十分明白。
数据访问方法
处理器有很多访问数据的方法,称作寻址模式(addressing modes) 。
最简单的寻址模式是立即模式(immediate mode),数据直接内嵌在处理器中,比如我们要载入数字0,那么就可以指定立即模式直接写入0。
寄存器寻址模式(register addressing mode),指令包含一个要访问的寄存器,而不是一个内存地址。剩余的模式会处理内存地址。
直接寻址模式(direct addressing mode) ,指令包含着要访问的内存地址。计算机会直接从指定的内存地址获取数据。
索引寻址模式(indexed addressing mode),指令包含内存地址,并且还指定了一个 索引寄存器(index register) 来对那个地址进行 偏移(offset) 。比方说你指定了地址114510,偏移寄存器储存为4,那么你实际访问的地址就是114514。在x86处理器上,你还可以给索引寄存器指定一个 倍数(multiplier) 以便一次访问若干个字节的内存。
间接寻址模式(indirect addressing mode) ,指令包含一个寄存器,里面储存着一个指针指向我们要访问的地方。比如%eax
寄存器中储存着值4,那么在间接寻址模式下,我们就去内存地址为4的位置加载数据,而在直接寻址模式,我们直接载入4。
最后,是 基指针寻址模式(base pointer addressing mode) 。和间接寻址模式相似,但是你还要加入偏移来向寄存器加上偏移值再访问数据。
还有其他的寻址模式,在这里我们只介绍重要的几个。
Chapter 3 你的第一个程序
输入一个程序
1 |
|
使用 as exit.s -o exit.o
和 ld exit.o -o exit
来汇编并链接。
汇编语言小知识
中断就是中断正常的指令流,把控制权交给内核来完成 系统调用(system call) ,系统调用完成后再将控制权交回程序。
系统调用快速回顾: 简而言之,系统的功能可以通过系统调用来访问。通过特别地设置寄存器并执行int 0x80
指令,Linux 内核通过 %eax
寄存器的值知道我们要访问哪个系统调用,在上面的例子中,系统调用序号1就是exit
调用,需要把状态码放在 %ebx
寄存器中。
在x86处理器上,有几个通用寄存器(都可以使用 movl
指令),包括:
%eax
%ebx
%ecx
%edx
%edi
%esi
还有专用寄存器,包括:
%ebp
%esp
%eip
%eflags
像 %eip
和 %eflags
这种只能通过特殊的指令访问。其他的寄存器可以像通用寄存器那样用普通的指令访问,但是他们有特殊的意义和用途。
找到最大值
1 |
|
通过 echo $?
来获得运行结果
第八行的 .long
表示保留的内存位置的类型,有:
.byte
:占据一个存储位置,0-255.int
:占据两个存储位置,0-65535.long
:占据四个存储位置,0-4294967295.ascii
:在内存中储存字符串,如.ascii "Hello there\0"
就会以ascii码的形式在内存中储存12个字节,其中\0
表示字符串的终止,不会显示在屏幕上。
第十六行的 movl data_items(,%edi,4)
表示从data_items开始,以 %edi
为索引,一次获取四个字节的数据并将其储存在 %eax
寄存器中。索引寻址模式更一般的形式是:movl 开始的地址(,%索引寄存器,字长)
下一个 cmpl
指令是比较两个值,影响到 %eflags
寄存器,也称作状态寄存器。紧接着的是流控制指令 je
,它使用状态寄存器来获取上一次比较的结果,还有很多跳转指令:
je
如果相等就跳转jg
如果第二个比较的值比第一个大就跳转jge
如果第二个比较的值大于等于第一个就跳转jl
如果第二个比较的值小于第一个就跳转jle
如果第二个比较的值小于等于第一个就跳jmp
无论情况如何,直接跳转
incl %edi
表示将 %edi
加1
寻址模式
通用的内存地址引用是:地址或偏移(%基准或偏移,%索引,倍数)
最终地址 = 地址或偏移 + %基准或偏移 + 倍数 * %索引
直接寻址模式
只使用地址或偏移部分,例如 movl ADDRESS, %eax
将在ADDRESS内存地址的值载入 %eax
寄存器
索引寻址模式
例如 movl string_start(,%ecx,1), %eax
从 string_start
开始,再加上 1*%ecx
,将最终的值载入%eax
间接寻址模式
间接寻址模式将一个寄存器中保存的内存地址对应的值载入,例如 movl (%eax), %ebx
将 %eax
储存的内存地址的值载入%ebx
基指针寻址模式
基指针寻址与间接寻址模式相似,除了它向寄存器中储存的内存地址加上一个常量。例如movl 4(%eax), %ebx
除此之外,我们还能操作不同大小的数据,例如movb
可以一次移动一个字节的数据,而且我们可以使用部分寄存器。
拿 %eax
举个例子,如果你想一次使用两个字节,那么 %ax
就是 %eax
的后半部分, %ax
还能再分为两个一字节大小的寄存器,前半部分叫 %ah
,后半部分叫 %al
。
使用部分的寄存器会损坏之前的数据,请小心。
Chapter 4 函数
函数是如何工作的
函数由以下几部分组成:
- 函数名(function name)
- 函数参数(function parameters)
- 局部变量(local variables)
- 静态变量(static variables)
- 全局变量(global variables)
- 返回地址(return address)
- 返回值(return value)
不同的语言的变量储存、参数传递和返回地址传递的方式不同。这种区别就是不同的 调用约定(calling convention)。
汇编语言允许你使用任何语言的调用约定,你甚至可以自己实现一个调用约定,但为了与其他语言兼容,最好采用对应的方式。
这里我们介绍C语言的调用约定。
使用C调用约定的汇编语言函数
栈
我们先要了解栈(stack)是什么。你的电脑有栈,它在内存地址的顶部。你可以用 pushl
向栈的顶部压入东西,也可以用 popl
弹出东西。虽然我们说“栈的顶部”,但是栈在内存地址的顶部,所以栈是向下增长的,新数据在栈内存地址的下面。我们可以一直往栈里压入东西,栈顶部会移动来容纳我们的数据,直到栈往下增长碰到我们的代码或其他数据。
所以我们怎么知道栈的顶部在哪?%esp
(栈寄存器)寄存器保存着指向栈顶部的指针。每次我们用 pushl
压入栈, %esp
就会减4来指向栈顶部,用 popl
弹出栈,它就会加4。
可以使用各种寻址模式来访问栈中各种位置的数据。
C语言函数调用约定
C语言首先将参数反向地压入栈中,然后执行 call + 函数名
指令调用函数, call
会将返回地址压入栈顶,并修改 %eip
寄存器(指令指针)使其指向函数开始的指令的地址。函数刚调用时栈看起来像这样:
1 |
|
调用函数后,第一件事就是将原来的基指针寄存器( %ebp
)压住栈中保存,并将 %ebp
移动到 %esp
的位置( movl %esp, %ebp
),这样无论怎么压入弹出栈,你总能通过位于函数开始位置的 %ebp
轻松地访问栈中各个位置。
这样, %esp
就能自由移动,将其减去相应的字节数来创建更多的栈的空间供函数使用(比如储存局部变量)。
当函数干完活了,它按下面的流程滚蛋:
1.在 %eax
中储存返回值。
2.将栈指针恢复到调用前的样子。
3.使用 ret
指令,从栈顶获得返回地址并将 %eip
设定到那里。将控制权交回原程序。
执行下面的指令:
1
2
3movl %ebp, %esp
popl %ebp
ret
所有的局部变量都已销毁,现在可以在 %eax
找到返回值,若不需要调用参数,还需要把 %esp
加4*参数数量( addl
)。
注意:调用函数的时候除了 %ebp
会保留原来的内容,你应该假设其它寄存器都被清空了。
这是一个简化了的版本,具体细节可以在这里查询 System V Application Binary Interface- Intel386 Architecture
Processor Supplement
一个使用函数的例子
1 |
|
递归函数
1 |
|
Chapter Final(?)
如你所见,这篇笔记到末尾了。这里面肯定还有错误,希望广大读者能指出,鄙人不才,献丑了。
汇编是一种看起来简单但使用起来极其复杂的底层语言,在高级语言流行的时代,我们不能粗暴的忽视汇编,相反,接触一些汇编知识会让我们对计算机架构和高级语言的指针有更深刻的理解,也为逆向和信息安全领域打下一些基础。
Programming from the Ground Up 这本书还有内容,由于笔者精力和个人安排,笔记就记录到这,后面可能会更新一点第五章的内容。
E.O.F.
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!