汇编语言入门三:是时候上内存了

时间:2021-10-09 13:01:40

上回说到了寄存器和指令,这回说下内存访问。开始之前,先来复习一下。

回顾

寄存器

  • 寄存器是在CPU里面
  • 寄存器的存储空间很小
  • 寄存器存放的是CPU马上要处理的数据或者刚处理出的结果(还是热乎的)

指令

  • 传送数据用的指令mov
  • 做加法用的指令add
  • 做减法用的指令sub
  • 函数调用后返回的指令ret

指针和内存

高能预警

高能预警,后面会涉及到一些高难度动作,请提前做好以下准备:

  • 精通2进制和16进制加减法
  • 精通2进制表示与16进制表示之间的关系
  • 精通8位、16位、32位、64位二进制数的16进制表示

举个例子,一个16进制数0BC71820,其二进制表示为:

00001011 11000111 00011000 00100000

你能快速地找到它们之间的对应关系吗?不会的话快去复习吧。

寄存器宽度

现在,为了简便,我们只讨论32位宽的寄存器。也就是说,目前我们讨论的寄存器,它的宽度都是32位的,也就是里面存放了一个32位长的2进制数。

通常,一个字节为8个二进制比特位,那么一个32位长的二进制数,那么它的大小就应该是4个字节。也就是把32位长的寄存器写入到内存里,会覆盖掉四个字节的存储空间。

内存

想必内存大家心里都比较有数,就是暂时存放CPU计算所需的指令和数据的地方。

诶?那前面说好的寄存器呢?寄存器也是类似的功能啊。对的,寄存器有类似功能,理论上一个最小的计算系统只需要寄存器和CPU的计算部件(ALU)就够了。不过,实际情况更加复杂一些,还是拿计算题举例,这次更复杂了:

(这里的例子只够说明寄存器和内存的角色区别,而非出现内存和寄存器这样角色的根本原因)

( 847623785 * 12874873 + 274632 ) / 999 =

好了,这个题目就不像前面的那么简单了,首先你肯定没法直接在脑子里三两下就算出来,还是得需要一个草稿纸了。

计算过程中,你还是会把草稿纸上正在计算的几个数字记在脑子里,然后快速地算完并记下来,然后往草稿纸上写。

最后,在草稿纸上演算完毕后,你会把最终结果写到试卷上。

好了,这里的草稿纸就相当于是内存了。它也充当一个临时记录数据的作用,不过它的容量就比自己的脑子要大得多了,而且一旦你把东西写下来,也就不那么担心忘记了。

诶?我不能多做点寄存器,就不需要单独的内存了呀?是的,理论上是这样,然而,实际上如果多做一点寄存器的话,CPU就要卖$9999999一片了,贵啊(具体原因可以了解SRAM与DRAM)。

也就是说,在计算机系统里,寄存器和内存都充当临时存储用,但是寄存器太小也太少了,内存就能帮个大忙了。

指针

在C语言里面,有个神奇的东西叫做指针,它是初学者的噩梦,也是高手的天堂。

这里不打算给不明白指针的人讲个明白,直接进入正题。首先,内存是一个比较大的存储器,里面可以存放非常非常多的字节。

好了,现在我们来为整个内存的所有字节编号,为了方便,咱们首先考虑按照字节为单位连续编号:

  0  1  2  3  4  5  6  7              ...
.........................           ......................
|12|b7|33|e8|66|4c|87|3c|    ...    |cc|cc|cc|cc|cc|cd|cd|
`````````````````````````           ``````````````````````

大概意思一下,你可以想象每一个格子就是一个字节,每个格子都有编号,相邻的格子的编号也是相邻的。这个编号,你就可以理解为所谓的指针或者地址(这里不严格区分指针与地址)。那么当我需要获取某个位置的数据时,那么我们只需要一个编号(也就是地址)就知道在哪些格子里获取数据了,当然,写入数据也是一样的道理。

到这里,我们大概清楚了访问内存的时候需要一些什么东西:

  • 首先得有内存
  • 要访问内存的哪个位置(编号,地址)

那,我哪知道地址是多少呢?别介,这不是重点,你不需要知道地址具体是多少,你只需要知道它是个地址,按照正确的方式去思考和使用就行了。继续。

mov指令还没完

前面说到,寄存器可以临时存储计算所需数据和结果,那么,问题来了,寄存器也就那么几个,用完了咋办?你能发现这个问题,说明你有成为大佬的潜质。接下来,说正事。

前面说到了mov指令,可以将数据送入寄存器,也可以将一个寄存器的数据送到另一个寄存器,像这样:

mov eax, 1
mov ebx, eax

好了,这还没完,mov指令可谓是x86中花样比较多的指令了,前面的两种情形都还是比较简单的情形,今天我们来扯一下更复杂的。

寄存器不够用了

现在,某个很复杂的运算让你感觉寄存器不够用了,怎么办?按照前面说的意思,要把寄存器的东西放到内存里去,把寄存器的空间腾出来,就可以了。

好的思路有了,可是,怎么把寄存器的数据丢到内存里去呢?还是使用mov指令,只是写法不同了:

mov [0x5566], eax

好了,现在,请全神贯注。这条指令就是将寄存器的数据丢到内存里去。再多看几眼,免得看得不够顺眼:

mov [0x0699], eax
mov [0x0998], ebx
mov [0x1299], ecx
mov [0x1499], edx
mov [0x1999], esi

好了,应该已经脸熟了。

现在,我告诉你,最前面那个指令mov [0x5566], eax的作用:

将eax寄存器的值,保存到编号为0x5566对应的内存里去,按照前面的说法,一个eax需要4个字节的空间才装得下,所以编号为0x5566 0x5567 0x5568 0x5569这四个字节都会被eax的某一部分覆盖掉。

好了,我们已经了解了如何将一个寄存器的值保存到内存里去,那么我怎么把它取出来呢?

mov eax, [0x0699]
mov ebx, [0x0998]
mov ecx, [0x1299]
mov edx, [0x1499]
mov esi, [0x1999]

反过来写就是了,比如mov eax, [0x0699]就表示把0x0699这个地址对应那片内存区域中的后4个字节取出来放到eax里面去

到此

到这,我们已经学会了如何把寄存器的数据临时保存到内存里,也知道怎么把内存里的数据重新放回寄存器了。

动手编程

接下来,该动手操练了。先来一个题目:

假设我们现在有一个比较蛋疼的要求,就是把1和2相加,然后把结果放到内存里面,最后再把内存里的结果取出来。(好无聊的题目)

那么按理说,我们就应该这么写代码:

global main

main:
    mov ebx, 1
    mov ecx, 2
    add ebx, ecx
    
    mov [0x233], ebx
    mov eax, [0x233]
    
    ret

好了,编译运行,假如程序是danteng,那么运行结果应该是这样:

$ ./danteng ; echo $?
3

实际上,并不能行。程序挂了,没有输出我们想要的结果。

这是在逗我呢?别急,按理说,前面说的都是没问题的,只是这里有另外一个问题,那就是“我们的程序运行在一个受管控的环境下,是不能随便读写内存的”。这里需要特殊处理一下,至于具体为何,后面有机会再慢慢叙述,这不是当下的重点,先照抄就是了。

程序应该改成这样才行:

global main

main:
    mov ebx, 1
    mov ecx, 2
    add ebx, ecx
    
    mov [sui_bian_xie], ebx
    mov eax, [sui_bian_xie]
    
    ret

section .data
sui_bian_xie   dw    0

好了这下运行,我们得到了结果:

$ ./danteng ; echo $?
3

好了,有了程序,咱们来梳理一下每一条语句的功能:

mov ebx, 1                   ; 将ebx赋值为1
mov ecx, 2                   ; 将ecx赋值为2
add ebx, ecx                 ; ebx = ebx + ecx
    
mov [sui_bian_xie], ebx      ; 将ebx的值保存起来
mov eax, [sui_bian_xie]      ; 将刚才保存的值重新读取出来,放到eax中
    
ret                          ; 返回,整个程序最后的返回值,就是eax中的值

好了,到这里想必你基本也明白是怎么一回事了,有几点需要专门注意的:

  • 程序返回时eax寄存器的值,便是整个程序退出后的返回值,这是当下我们使用的这个环境里的一个约定,我们遵守便是

与前面那个崩溃的程序相比,后者有一些微小的变化,还多了两行代码

section .data
sui_bian_xie   dw    0

第一行先不管是表示接下来的内容经过编译后,会放到可执行文件的数据区域,同时也会随着程序启动的时候,分配对应的内存。

第二行就是描述真实的数据的关键所在里,这一行的意思是开辟一块4字节的空间,并且里面用0填充。这里的dw(double word)就表示4个字节,前面那个sui_bian_xie的意思就是这里可以随便写,也就是起个名字而已,方便自己写代码的时候区分,这个sui_bian_xie会在编译时被编译器处理成一个具体的地址,我们无需理会地址具体时多少,反正知道前后的sui_bian_xie指代的是同一个东西就行了。

疯狂的写代码

好了,有了这一个程序作铺垫,我们继续。趁热打铁,继续写代码,分析代码:

global main

main:
    mov ebx, [number_1]
    mov ecx, [number_2]
    add ebx, ecx
    
    mov [result], ebx
    mov eax, [result]
    
    ret

section .data
number_1      dw        10
number_2      dw        20
result        dw        0

好了,自己琢磨着写代码,运行程序,然后分析程序每一条指令都在干什么。还有,这个程序本身还可以精简,如果你已经发现了,那说明你老T*棒了。

global main

main:
    mov eax, [number_1]
    mov ebx, [number_2]
    add eax, ebx
    
    ret

section .data
number_1      dw        10
number_2      dw        20

好了,好好分析比较上面的几个程序,基本这一块就了解得差不多了。随着了解的逐渐深入,我们后续还会介绍更多更复杂,更全面的内容。

反汇编

这里插播一段反汇编的讲解。引入调试器和反汇编工具,我们后续将有更多机会对程序进行深入的分析,现阶段,我们先找一个简单的程序上手,熟悉一下操作和工具。

先安装gdb:

$ sudo apt-get install gdb -y

然后,我们把这个程序,保存为test.asm:

global main

main:
    mov eax, 1
    mov ebx, 2
    add eax, ebx
    ret

然后编译:

$ nasm -f elf test.asm -o test.o ; gcc -m32 test.o -o test

运行:

$ ./test ; echo $?
3

OK,到这里,程序是对的了。开始动刀子,使用gdb:

$ gdb ./test

启动之后,你会看到终端编程变成这样了:

(gdb) 

OK,说明你成功了,接下来输入,并回车:

(gdb) set disassembly-flavor intel

这一步是把反汇编的格式调整称为intel的格式,稍后完事儿后你可以尝试不用这个设置,看看是什么效果。好了,继续,反汇编,输入命令并回车:

(gdb) disas main
Dump of assembler code for function main:
   0x080483f0 <+0>: mov    eax,0x1
   0x080483f5 <+5>: mov    ebx,0x2
   0x080483fa <+10>:    add    eax,ebx
   0x080483fc <+12>:    ret    
   0x080483fd <+13>:    xchg   ax,ax
   0x080483ff <+15>:    nop
End of assembler dump.
(gdb) 

好了,整个程序就在这里被反汇编出来了,请你先仔细看一看,是不是和我们写的源代码差不多?(后面多了两行汇编,你把它们当成路人甲看待就行了,不用理它)。

动态调试

后面将继续介绍动态调试,帮助更加深入地理解汇编中的一些概念。现在先提示一些概念:

  • 断点:程序在运行过程中,当它执行到“断点”对应的这条语句的时候,就会被强行叫停,等着我们把它看个精光,然后再把它放走
  • 注意看反汇编代码,每一行代码的前面都有一串奇怪的数字,这串奇怪的数字指它右边的那条指令在程序运行时的内存中的位置(地址)。注意,指令也是在内存里面的,也有相应的地址。

好了,我们开始尝试一下调试功能,首先是设置一个断点,让程序执行到某一个地方就停下来,给我们足够的时间观察。在gdb的命令行中输入:

(gdb) break *0x080483f5 

后面那串奇怪的数字在不同的环境下可能不一样,你可以结合这里的代码,对照着自己的实际情况修改。(使用反汇编中<+5>所在的那一行前面的数字)

然后我们执行程序:

(gdb) run Starting program: /home/vagrant/code/asm/03/test Breakpoint 1, 0x080483f5 in main () (gdb) 

看到了吧,这下程序就被停在了我们设置的断点那个地方,对比着反汇编和你的汇编代码,找一找现在程序是停在哪个位置的吧。run后面提示的内容里,那一串奇怪的数字又出现了,其实这就是我们前面设置断点的那个地址。

好了,到这里,我们就把程序看个精光吧,先看一下eax寄存器的值:

(gdb) info register eax eax 0x1 1 

刚好就是1啊,在我们设置断点的那个地方,它的前面一个指令是mov eax, 1,这时候eax的内容就真的变成1了,同样,你还可以看一下ebx:

info register ebx
ebx            0xf7fce000   -134422528

ebx的值并不是2,这是因为mov ebx, 2这个语句还没有执行,所以暂时你看不到。那我们现在让它执行一下吧:

(gdb) stepi 0x080483fa in main () 

好了,输入stepi之后,到这里,程序在我们的控制之下,向后运行了一条指令,也就是刚刚执行了mov ebx, 2,这时候看下ebx:

(gdb) info register ebx
ebx            0x2  2

看到了吧,ebx已经变成2了。继续,输入stepi,然后看执行了add指令后的各个寄存器的值:

(gdb) stepi
0x080483fc in main ()
(gdb) info register eax
eax            0x3  3

执行完add指令之后,eax跟我们想的一样,变成了3。如果我不知道程序现在停在哪里了,怎么办?很简单,输入disas之后,又能看到反汇编了,同时gdb还会标记出当前断点所在的位置:

(gdb) disas
Dump of assembler code for function main:
   0x080483f0 <+0>: mov    eax,0x1
   0x080483f5 <+5>: mov    ebx,0x2
   0x080483fa <+10>:    add    eax,ebx
=> 0x080483fc <+12>:    ret    
   0x080483fd <+13>:    xchg   ax,ax
   0x080483ff <+15>:    nop
End of assembler dump.

现在刚好就在add执行过后的ret那个地方。这时候,如果你不想玩了,可以输入continue,让程序*地飞翔起来,直到GG

(gdb) continue
Continuing.
[Inferior 1 (process 1283) exited with code 03]

看到了吧,程序已经GG了,而且返回了一个数字03。这刚好就是那个eax寄存器的值嘛。

总结

好了,这次就到这里结束,内容有点多,没关系可以慢慢来,没事的时候就翻出来,把目前学的汇编语言和gdb都好好玩一下,最好是能玩出花来,这样才能有更多的收获。清点一下今天的内容:

  • 通过mov指令可以把内存的数据放到寄存器中,也可以把寄存器的数据放回到内存
  • 在操作系统的保护下,程序是不能随便到处访问内存的,乱搞的话会GG
  • gdb的功能很牛逼

懒得配置环境的可以戳这里,在线写汇编:asm.0x233.com

若读者对文中部分内容有疑惑或是有表达不当或是有疏漏,欢迎指正。