0%

汇编基础

指令的汇编基础知识,从代码到可执行文件的流程

Overview

指令(instruction)的执行依托于计算机体系结构的通力合作。在第1节先介绍指令是如何在计算机体系结构下运行的。第2节对汇编进行简单介绍。第3节对汇编、链接、加载的工具链进行介绍。

这些知识是为了对现代程序的执行过程有一个更加底层的理解,且由于这些知识对细节的要求比较高,所以能对系统优化有更清晰的认识。

1. 计算机体系结构

image-20240531220434969

现代计算机中包含的主要组件如图,CPU是计算的核心,RAM为**主存(Primary Storage)**,

我们熟知的固态硬盘、机械硬盘等为**二级存储(Secondary Storage)**。

CPU内部又包含多级**缓存(Cache Memory)**和寄存器(Register)。

这些看起来略显复杂的架构其实只是为了让CPU更快地处理数据,本质上来说,以上所述元件都是把数据的流转速度进行多次加速给CPU进行处理。

1.1 数据存储方式(X86_64)

不同指令集架构(ISA,Instruction-Set Architecture)中数据存储的方式是不同的,在X86_64架构中,数据按照以下大小进行存储:

image-20240602200653337

不同的语言中又使用了各自封装的存储类型,但底层存储依然是以上几种存储方式。例如,在C/C++中:

image-20240602200922057

1.2 CPU

CPU(Central Process Unit),直接翻译为核心处理装置,我们通常翻译为中央处理器,是计算机体系结构的核心,它负责处理一条条指令。

寄存器(Register)与缓存(Cache Memory)是CPU的组成部分。

在对指令学习的过程中,对寄存器的理解是前置知识中最重要的部分。

1.2.1 寄存器

寄存器是CPU临时存放处理数据的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
⭐提出问题:
ISA与CPU中寄存器的设计是一一对应的吗?X86-64指令集架构因为历史原因有多个名字(X86-64/X86_64/x64/AMD64/Intel 64),它们之间有什么细微的差别吗?

解决思路:
可能需要详细理解一些知识之后才能完全解答,简单的解答可以先去维基百科了解。

20240601更新:
不同的CPU有不同的微架构,微架构指的是CPU厂商设计的CPU架构,如AMD的ZEN、Intel的Lake,它们都支持X86_64指令集,但是物理上的设计不同。高效的微架构能够提升指令执行速度并提高能效。
寄存器的设计和所支持的ISA息息相关,但是并不是严格对应,不同的微架构的设计中可能不同。

X86-64的历史比较复杂。
总体来说,目前市场上最流行的的64位CISC从源头上看是由AMD设计的基于IA-32的扩展ISA。
微软使用x64,Linux系统多数使用AMD64来指代该指令集。
它们之间细微的差别目前来看应该是存在的,但是也许要更加深入的学习才能完全解答这个问题。

在使用X86_64指令集的CPU中,有以下几种主要的**通用寄存器(GPRs, General Purpose Registers)**:

image-20240602202736796

Processor register - Wikipedia

其中rax/rbx/rcx/rdx允许低位部分直接作为单独的寄存器存放数据:

image-20240602202851307

这其中的一些寄存器的作用如下:

RSP堆栈寄存器、RBP基本指针寄存器、RIP指令指针寄存器。这些寄存器都是64位的。

此外XMM寄存器是一个128bit的寄存器,主要用于支持浮点运算和SMID(Single Instruction Multiple Data),关于SMID这里不做详细展开。该寄存器推出于SSE指令集。后来Intel推出AVX/AVX2等指令集中,引入了256bit的YMM寄存器作为XMM的扩展,并与XMM兼容。

此处仅对寄存器有一个大概的概念即可,深入学习需要投入专门的时间与精力。我把它放入下一个阶段进行学习。

1.2.2 高速缓存(Cache Memory)

指CPU中的高速缓冲存储器,在2024年的今天,指的是CPU架构中的三级缓存。访问内存时,会通过总线提取数据到CPU中的Cache Memory中,随后提交给CPU核心进行处理。Cache Memory中的数据访问速度比对内存的访问快得多。

1.3 主存(Main Memory)

即平时所说的内存。它是一系列连续的字节存储位,可以通过一个个字节进行寻址。

内存地址是反向的(little-endian),指物理地址的最后一位编号最小,第一位编号最大。

如十进制的17000000,翻译为16进制为0x01036640,实际存储在内存中时是40660301,占用4个字节,第一个字节为最低位40,第二个字节为66,第三个03,第四个为最高位01。

1.4 程序在内存中的布局

一个运行中的程序所需要的数据都要放入内存中供CPU存取,而操作系统会给每个程序都分配一个独立的内存空间,在这个内存空间中的程序通常以以下的方式进行布局:

image-20240604152141609

**BSS(Block Started by Symbol)**是未初始化的数据存放的位置。

Data是全局变量存放的位置。

text是存放指令的区域,通常为只读。

stack/heap是大名鼎鼎的内存堆栈区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
⭐提出问题:
1.内存堆栈为什么要高栈低堆并且相向生长?
2.内存布局为什么要这样设计,是约定俗成还是有这样做比较好的原因?假如我交换data和text区域会有问题吗?
3.堆栈是动态分配的,那在一开始OS分配内存的时候如何确保有足够的空间不发生StackOverflow?

解决思路:
这里可以找到一个比较好的答案,但是看完仍然有疑问。
https://stackoverflow.com/questions/79923/what-and-where-are-the-stack-and-heap
问题3在把问题表达出来的时候就已经想到答案了。
对于栈来说有虚拟内存技术的存在,对于堆来说malloc是一开始就写入代码的。
MMU存在的意义就是对于这种情况的预防。


20240601更新:
堆和栈有各自存在的必要性,栈先进后出的特性对于函数调用来说非常方便,堆是程序员手动申请的内存空间,它们都需要敏捷地确定当前地址的位置,使用相向生长的方式可以不必严格地设定各自的地址限制。
对于栈来说,有专门的寄存器RSP来查找位置,速度非常快,对于堆来说略难管理一些。但是高栈低堆而不是高堆低栈主要是历史沿袭的原因。
内存布局顺序的规划也是历史沿袭的原因。

2. 汇编入门

此部分需要提前了解一些编码知识,如原码反码补码等。还需要一些进制转换的知识。

2.1 注释

英文分号(;)注释

1
2
;注释
const1 equ 100 ;这一行是汇编代码,后面也可以用分号接注释

2.2 数值的写法

1
2
3
4
5
6
7
8
;默认10进制
const1 equ 100

;16进制表示的100,16进制需要在前面加上0x
const2 equ 0x64

;8进制表示的100,8进制需要在后面加上q
const3 equ 144q

2.3 定义一个常数

上面的关键字equ就是等于的意思
<常数名> equ <常数值>

常数没有类型的概念,常数就是一个数值。

编译器会选择适合的大小对常数进行存储。比如10000可以存储到word或double-word大小的空间中,但是byte由于是8bits,最大只能表示到128所以不会把10000存入到byte大小的空间中。

2.4 定义一个变量(data)

<变量名> <类型> <初始值>

1
2
3
4
5
6
7
8
9
section	.data						; 表示以下存储在data区域
bVar db 10 ; byte variable
cVar db "H" ; single character
strng db "Hello World" ; string
wVar dw 5000 ; 16-bit variable
dVar dd 50000 ; 32-bit variable
arr dd 100, 200, 300 ; 3 element array
flt1 dd 3.14159 ; 32-bit float
qVar dq 1000000000 ; 64-bit variable

需要注意,db/dw/dd/dq分别代表了byte/word/double-word/Quadword大小的变量,并不是像高级编程语言中的类型一样的概念,这里关注的是大小。而第3行中的Hello World是一个字符串,其实是一个char列表,每一个字符各自存储于8bit的空间中。第6行中也是100,200,300三个元素存储于三个dd的空间中作为列表而存在。

2.5 声明一个未初始化的变量(BSS)

在上节对BSS(Block Started by Symbol)有简单介绍,该位置存储未初始化的变量,用以下方式声明:

<变量名> <类型> <数量>

1
2
3
4
5
section	.bss		; 表示以下存储在bss区域
bArr resb 10 ; 10 element byte array
wArr resw 50 ; 50 element word array
dArr resd 100 ; 100 element double array
qArr resq 200 ; 200 element quad array

2.6 汇编代码(text)

1
2
3
4
5
6
section	.text		; 表示以下存储在text区域

;使用standard system linker时以下两行表示程序的入口
global _start
_start:
mov al, byte [bVar1] ; 一条简单汇编的指令

由于指令集的复杂性,汇编代码中其他部分的将放在后面学习指令集的时候继续进行。

3. 工具链

把代码编译成可执行程序的过程中所需要的工具称为工具链。

对于汇编语言,工具链有多种选择,通常包括汇编器Assembler、链接器Linker、加载器Loader、调试器Debugger

  • 汇编器Assembler将人类可读的源文件转换为对象文件。
  • 这些对象文件通常由链接器Linker转换成可执行文件。
  • 加载器Loader将可执行文件加载到内存中。
  • 调试器Debugger用于调试程序。

下图展示了Assemble->Link->Load的过程

image-20240605223307189

3.1 汇编器

以一条真实的汇编器在Ubuntu下执行的命令为例:

1
yasm -g dwarf2 -f elf64 example.asm -l example.lst

以下解释命令的含义

  • yasm:一个开源汇编器,支持x86_64指令集的汇编,更多介绍参见官方网站。本文使用当前最新版1.3.0

    image-20240605223911671

  • -g dwarf2:表示选择dwarf2作为debug格式

  • -f elf64:表示输出对象格式为elf64

  • example.asm:指已经写好的汇编代码文件,yasm对它进行汇编

  • -l example.lst:指创建一个名为example.lst的列表文件方便后期调试,列表文件在上图中也出现了,它存储了汇编程序列表数据以方便查找指令,文件内容示例如下图,可以很方便地理解它是什么。

    image-20240605225853037

汇编的过程通常包括两步

  • 第一步:

    • 创建符号表

      符号表是程序中的每一个变量名、标签和符号的列表,它还包括相对地址,上图即为符号表。

    • 解析宏(Macro)

      宏可以理解为对一系列指令的封装

    • 解析仅含常量的表达式(Constant Expression)

      例如mov rax, buff+5这条指令,如果buff是一个已经定义好的常量,在第一步中可以直接解析完成

  • 第二步:

    • 生成最终的代码
    • 创建列表文件(如果需要生成的话)
    • 创建对象文件(即最终的.o文件)
    1
    2
    3
    4
    5
    6
    7
    8
    ⭐提出问题:
    资料显示这里其实还有一个叫做
    直接汇编指令(Assembler Directives)
    的指令,这个指令被汇编器执行而不是转化为CPU指令。
    那么,不转化为CPU指令是如何被汇编器执行的?

    解决思路:
    暂时搁置,写完这篇之后再补充学习。

3.2 链接器

同样的方式:

1
ld -g -o example example.o
  • ld:指GNU Linker,使用率很高的一个链接器,古神,GNU的LD手册居然于1998年更新。

  • -g:表示输出调试信息

  • -o example:创建一个叫做example的可执行文件

  • example.o:汇编器汇编好的对象文件,可以有多个,比如

    ld -g -o example example.o a.o b.o

外部调用问题

在汇编生成的object file对应的列表文件中,有些调用了外部的变量或函数无法确认需要调用的地址,就会使用一个R字符来标记。

链接器会在链接的过程中对有R标记的指令进行解析,确定目标的地址。

在C/C++中,R标记类似于extern关键字,在当前文件中没有这个函数或变量,就使用extern关键字声明它在外部已经定义好了,可以通过编译。

动态链接问题

即某些符号的解析推迟到程序执行时。 实际的指令不在可执行文件中,而是在运行时根据需要进行解析和访问。即模块化了程序,可以有些通用的库拿出来等运行时再访问,这样对库的实现的优化也可以不必重新链接。缺点是库升级时如果不兼容之前的接口就会破坏程序运行,对于有些严格测试性能的程序可能很不友好。

windows中,这些库的后缀一般是dll,linux中一般为so。

3.3 加载器

加载器其实是OS的一部分,指把可执行文件从Secondary Storage(硬盘)调入Main Memory(内存)并创建新的进程、标记可执行。然后OS来决定对进程的调度。

3.4 调试器(GDB)

GDB(GNU Debugger)是一个调试工具,可以调试二进制文件、core文件、running progress,可以用DDD(一个GDB可视化前端)来熟悉GDB的使用。

GDB官方文档

工具的使用是一个熟练度的问题,在ubuntu中使用man gdb有一条很有用的学习指南,即最常使用的命令推荐:

Here are some of the most frequently needed GDB commands:

break [file:][function|line]
Set a breakpoint at function or line (in file).

run [arglist]
Start your program (with arglist, if specified).

bt Backtrace: display the program stack.

print expr
Display the value of an expression.

c Continue running your program (after stopping, e.g. at a breakpoint).

next
Execute next program line (after stopping); step over any function calls in the line.

edit [file:]function
look at the program line where it is presently stopped.

list [file:]function
type the text of the program in the vicinity of where it is presently stopped.

step
Execute next program line (after stopping); step into any function calls in the line.

help [name]
Show information about GDB command name, or general information about using GDB.

quit
exit

1
2
3
4
5
6
7
心得20240602:
在计算机科学中最重要的好像是对概念的理解。
有一些很常见,但是由于太过于常见,实际上并不懂的概念。
比如堆栈是内存中给程序运行用的一片区域,但是稍微深入一点就不是两句话能说清的。
比如汇编是一种低级编程语言,但是assembly这个词也像是一个把程序分区组装到内存中的一个过程。
作为一门工科,计算机好像尤其需要“探索语言之美”。
也许是我没有接触到Science而是Engineering。