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

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

回顾

寄存器

  • 寄存器是在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,说明你成功了,接下来输入,并回车:

这里要先run一次,才能开辟真正的内存空间,只要不退出本次gdb,不论run多少次,都还是占用这些内存地址

(gdb) run
Starting program: /home/vagrant/code/asm/03/test
[Inferior 1 (process 408757) exited with code 03]
(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后一定要先run一次,这样才会开辟真正的内存空间,之后再看内存分配和设置断点才好用

koala@koala:~/桌面/DesktopHelper/汇编语言入门/03demo$ gdb ./test
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2
Copyright (C) 2020 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-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 ./test...
(No debugging symbols found in ./test)
(gdb) run
Starting program: /media/koala/data/WinUserHome/DesktopFile/DesktopHelper/汇编语言入门/03demo/test 
[Inferior 1 (process 408757) exited with code 03]
(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
   0x565561a0 <+0>:     mov    eax,0x1
   0x565561a5 <+5>:     mov    ebx,0x2
   0x565561aa <+10>:    add    eax,ebx
   0x565561ac <+12>:    ret    
   0x565561ad <+13>:    xchg   ax,ax
   0x565561af <+15>:    nop
End of assembler dump.
(gdb) break *0x565561a5
Breakpoint 1 at 0x565561a5
(gdb) run
Starting program: /media/koala/data/WinUserHome/DesktopFile/DesktopHelper/汇编语言入门/03demo/test 

Breakpoint 1, 0x565561a5 in main ()
(gdb) info register eax
eax            0x1                 1
(gdb) info register ebx
ebx            0x0                 0
(gdb) stepi
0x565561aa in main ()
(gdb) info register ebx
ebx            0x2                 2
(gdb) stepi
0x565561ac in main ()
(gdb) info register eax
eax            0x3                 3
(gdb) disas
Dump of assembler code for function main:
   0x565561a0 <+0>:     mov    eax,0x1
   0x565561a5 <+5>:     mov    ebx,0x2
   0x565561aa <+10>:    add    eax,ebx
=> 0x565561ac <+12>:    ret    
   0x565561ad <+13>:    xchg   ax,ax
   0x565561af <+15>:    nop
End of assembler dump.
(gdb) continue
Continuing.
[Inferior 1 (process 409281) exited with code 03]

总结

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

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

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