找回密码
 注册会员
更新自动建库工具PCB Footprint Expert 2024.04 Pro / Library Expert 破解版

x86汇编教程3

[复制链接]
admin 发表于 2012-9-3 00:43:26 | 显示全部楼层 |阅读模式

本文包含原理图、PCB、源代码、封装库、中英文PDF等资源

您需要 登录 才可以下载或查看,没有账号?注册会员

×
第三章 操作内存
在前面的章节中,我们已经了解了寄存器的基本使用方法。而正如结尾提到的那样,仅仅使用寄存器做一

点运算是没有什么太大意义的,毕竟它们不能保存太多的数据,因此,对编程人员而言,他肯定迫切地希

望访问内存,以保存更多的数据。
我将分别介绍如何在保护模式和实模式操作内存,然而在此之前,我们先熟悉一下这两种模式中内存的结

构。
3.1 实模式
事实上,在实模式中,内存比保护模式中的结构更令人困惑。内存被分割成段,并且,操作内存时,需要

指定段和偏移量。不过,理解这些概念是非常容易的事情。请看下面的图:


|-------| |--------| |--------|
|内存 | | | | |
| | |--------| |--------|段
| | | 段 | | |
| | | | | <------|----偏移地址
| | |--------| |--------|
| | | | | |
--------- |--------| |--------|

段-寄存器这种格局是早期硬件电路限制留下的一个伤疤。地址总线在当时有20-bit。
然而20-bit的地址不能放到16-bit的寄存器里,这意味着有4-bit必须放到别的地方。因此,为了访问所有

的内存,必须使用两个16-bit寄存器。

这一设计上的折衷方案导致了今天的段-偏移量格局。最初的设计中,其中一个寄存器只有4-bit有效,然

而为了简化程序,两个寄存器都是16-bit有效,并在执行时求出加权和来标识20-bit地址。
偏移量是16-bit的,因此,一个段是64KB。下面的图可以帮助你理解20-bit地址是如何形成的:

|-------16bit---------|
|----|----|-----|-----|-----|
| - | - | - | -| 0000| 段
|----|----|-----|-----|-----|

|-------16bit---------|
|----|----|-----|-----|-----|
|0000| - | - | -| - | 偏移地址
|----|----|-----|-----|-----|

|-----------20位地址--------|
|----|----|-----|-----|-----|
| - | - | - | -| - |物理地址
|----|----|-----|-----|-----|

段-偏移量标识的地址通常记做 段:偏移量 的形式。
由于这样的结构,一个内存有多个对应的地址。例如,0000:0010和0001:0000指的是同一内存地址。又如


0000:1234 = 0123:0004 = 0120:0034 = 0100:0234
0001:1234 = 0124:0004 = 0120:0044 = 0100:0244
作为负面影响之一,在段上加1相当于在偏移量上加16,而不是一个“全新”的段。反之,在偏移量上加16

也和在段上加1等价。某些时候,据此认为段的“粒度”是16字节。

我们现在可以写一个真正的程序了。
经典程序:Hello, world

; 应该得到一个29字节的.com文件

.MODEL TINY ;.COM文件的内存模型是‘TINY'
.CODE ; 代码段开始
CR equ 13 ;回车
LF equ 10 ;换行
TERMINATOR equ '$' ;DOS字符串结束符
ORG 100h ;代码起始地址为CS:0100h

Main PROC ;
mov dx,offset sMessage ;令DS:DX指向Message,21h中断显示dx开始地址的字符串
mov ah,9
int 21h ; int 21h(DOS中断)功能9
mov ax,4c00h
int 21h ;终止程序并返回
Main ENDP

sMessage:
DB 'Hello, World!'
DB CR,LF,TERMINATOR
END Main ;程序结束的同时指定入口点为Main


那么,我们需要解释很多东西。
首先,作为汇编语言的抽象,C语言拥有“指针”这个数据类型。在汇编语言中,几乎所有对内存的操作都

是由对给定地址的内存进行访问来完成的。这样,在汇编语言中,绝大多数操作都要和指针产生或多或少

的联系。
这里我想强调的是,由于这一特性,汇编语言中同样会出现C程序中常见的缓冲区溢出问题。如果你正在设

计一个与安全有关的系统,那么最好是仔细检查你用到的每一个串,例如,它们是否一定能够以你预期的

方式结束,以及(如果使用的话)你的缓冲区是否能保证实际可能输入的数据不被写入到它以外的地方。

作为一个汇编语言程序员,你有义务检查每一行代码的可用性。
程序中的equ伪指令是宏汇编特有的,它的意思接近于C或Pascal中的const(常量)。多数情况下,equ伪

指令并不为符号分配空间。

此外,汇编程序执行一项操作是非常繁琐的,通常,在对与效率要求不高的地方,我们习惯使用系统提供

的中断服务来完成任务。例如本例中的中断21h,它是DOS时代的中断服务,在Windows中,它也被认为是

Windows API的一部分(这一点可以在Microsoft的文档中查到)。中断可以被理解为高级语言中的子程序

,但又不完全一样——中断使用系统栈来保存当前的机器状态,可以由硬件发起,通过修改机器状态字来

反馈信息,等等。

那么,最后一段通过DB存放的数据到底保存在哪里了呢?答案是紧挨着代码存放。在汇编语言中,DB和普

通的指令的地位是相同的。如果你的汇编程序并不知道新的助记符(例如,新的处理器上的CPUID指令),

而你很清楚,那么可以用DB 机器码的方式强行写下指令。这意味着,你可以超越汇编器的能力撰写汇编程

序,然而,直接用机器码编程是几乎肯定是一件费力不讨好的事——汇编器厂商会经常更新它所支持的指

令集以适应市场需要,而且,你可以期待你的汇编其能够产生正确的代码,因为机器查表是不会出错的。

既然机器能够帮我们做将程序转换为代码这件事情,那么为什么不让它来做呢?

细心的读者不难发现,在程序中我们没有对DS进行赋值。那么,这是否意味着程序的结果将是不可预测的

呢?答案是否定的。DOS(或Windows中的MS-DOS VM)在加载.com文件的时候,会对寄存器进行很多初始化

。.com文件被限制为小于64KB,这样,它的代码段、数据段都被装入同样的数值(即,初始状态下DS=CS)


也许会有人说,“嘿,这听起来不太好,一个64KB的程序能做得了什么呢?还有,你吹得天花乱坠的堆栈

段在什么地方?”那么,我们来看看下面这个新的Hello world程序,它是一个EXE文件,在DOS实模式下运

行。

应该得到一个561 字节的EXE文件

.MODEL SMALL
.STACK 200h ; 堆栈段

CR equ 13
LF equ 10
TERMINATOR equ '$'

.DATA
Message DB 'Hello, World !'
DB CR,LF,TERMINATOR

.CODE
Main PROC
mov ax, DGROUP
mov ds, ax
mov dx, offset Message
mov ah, 9
int 21h
mov ax, 4c00h
int 21h
Main ENDP
END main

561字节?实现相同功能的程序大了这么多!为什么呢?我们看到,程序拥有了完整的堆栈段、数据段、代

码段,其中堆栈段足足占掉了512字节,其余的基本上没什么变化。

分成多个段有什么好处呢?首先,它让程序显得更加清晰——你肯定更愿意看一个结构清楚的程序,代码

中hard-coded的字符串、数据让人觉得费解。比如,mov dx, 0152h肯定不如mov dx, offset Message来的

亲切。此外,通过分段你可以使用更多的内存,比如,代码段腾出的空间可以做更多的事情。exe文件另一

个吸引人的地方是它能够实现“重定位”。现在你不需要指定程序入口点的地址了,因为系统会找到你的

程序入口点,而不是死板的100h。

程序中的符号也会在系统加载的时候重新赋予新的地址。exe程序能够保证你的设计容易地被实现,不需要

考虑太多的细节。
当然,我们的主要目的是将汇编语言作为高级语言的一个有用的补充。如我在开始提到的那样,真正完全

用汇编语言实现的程序不一定就好,因为它不便于维护,而且,由于结构的原因,你也不太容易确保它是

正确的;汇编语言是一种非结构化的语言,调试一个精心设计的汇编语言程序,即使对于一个老手来说也

不啻是一场恶梦,因为你很可能掉到别人预设的“陷阱”中——这些技巧确实提高了代码性能,然而你很

可能不理解它,于是你把它改掉,接着就发现程序彻底败掉了。使用汇编语言加强高级语言程序时,你要

做的通常只是使用汇编指令,而不必搭建完整的汇编程序。绝大多数(也是目前我遇到的全部)C/C++编译

器都支持内嵌汇编,即在程序中使用汇编语言,而不必撰写单独的汇编语言程序——这可以节省你的不少

精力,因为前面讲述的那些伪指令,如equ等,都可以用你熟悉的高级语言方式来编写,编译器会把它转换

为适当的形式。
需要说明的是,在高级语言中一定要注意编译结果。编译器会对你的汇编程序做一些修改,这不一定符合

你的要求(附带说一句,有时编译器会很聪明地调整指令顺序来提高性能,这种情况下最好测试一下哪种

写法的效果更好),此时需要做一些更深入的修改,或者用db来强制编码。


3.2 保护模式
实模式的东西说得太多了,尽管我已经删掉了许多东西,并把一些原则性的问题拿到了这一节讨论。这样

做不是没有理由的——保护模式才是现在的程序(除了操作系统的底层启动代码)最常用的CPU模式。保护

模式提供了很多令人耳目一新的功能,包括内存保护(这是保护模式这个名字的来源)、进程支持、更大

的内存支持,等等。

对于一个编程人员来说,能“偷懒”是一件令人愉快的事情。这里“偷懒”是说把“应该”由系统做的事

情做的事情全都交给系统。为什么呢?这出自一个基本思想——人总有犯错误的时候,然而规则不会,正

确地了解规则之后,你可以期待它像你所了解的那样执行。对于C程序来说,你自己用C语言写的实现相同

功能的函数通常没有系统提供的函数性能好(除非你用了比函数库好很多的算法),因为系统的函数往往

使用了更好的优化,甚至可能不是用C语言直接编写的。

当然,“偷懒”的意思是说,把那些应该让机器做的事情交给计算机来做,因为它做得更好。我们应该把

精力集中到设计算法,而不是编写源代码本身上,编译器几乎只能做等价优化,而实现相同功能,但使用

更好算法的程序实现,则几乎只能由人自己完成。

举个例子,这样一个函数:
int fun()
{
int a=0;
register int i;
for(i=0; i<1000; i++)
a+=i;
return a;
}

在某种编译模式[DEBUG]下被编译为
push ebp
mov ebp,esp
sub esp,48h
push ebx
push esi
push edi
lea edi,[ebp-48h]
mov ecx,12h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
mov dword ptr [ebp-4],0
mov dword ptr [ebp-8],0
jmp
 楼主| admin 发表于 2012-9-3 00:43:30 | 显示全部楼层
fun+31h
mov eax,dword ptr [ebp-8]
add eax,1
mov dword ptr [ebp-8],eax
cmp dword ptr [ebp-8],3E8h
jge fun+45h
mov ecx,dword ptr [ebp-4]
add ecx,dword ptr [ebp-8]
mov dword ptr [ebp-4],ecx
jmp fun+28h
mov eax,dword ptr [ebp-4]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret

而在另一种模式[RELEASE/MINSIZE]下却被编译为

xor eax,eax ;a=0
xor ecx,ecx ;i=0
add eax,ecx ;a=a+i
inc ecx ;i++
cmp ecx,3E8h ;比较i和1000
jl fun+4 ; 小于就转向fun+4,既inc ecx处.
ret

如果让我来写,多半会写成
mov eax, 079f2ch
ret ; return 499500

为什么这样写呢?我们看到,i是一个外界不能影响、也无法获知的内部状态量。作为这段程序来说,对它

的计算对于结果并没有直接的影响——它的存在不过是方便算法描述而已。并且我们看到的,这段程序实

际上无论执行多少次,其结果都不会发生变化,因此,直接返回计算结果就可以了,计算是多余的(如果

说一定要算,那么应该是编译器在编译过程中完成它)。

更进一步,我们甚至希望编译器能够直接把这个函数变成一个符号常量,这样连操作堆栈的过程也省掉了



第三种结果属于“等效”代码,而不是“等价”代码。作为用户,很多时候是希望编译器这样做的,然而

由于目前的技术尚不成熟,有时这种做法会造成一些问题(gcc和g++的顶级优化可以造成编译出的FreeBSD

内核行为异常,这是我在FreeBSD上遇到的唯一一次软件原因的kernel panic),因此,并不是所有的编译

器都这样做(另一方面的原因是,如果编译器在这方面做的太过火,例如自动求解全部“固定”问题,那

么如果你的程序是解决固定的问题“很大”,如求解迷宫,那么在编译过程中你就会找锤子来砸计算机了

)。然而,作为编译器制造商,为了提高自己的产品的竞争力,往往会使用第三种代码来做函数库。正如

前面所提到的那样,这种优化往往不是编译器本身的作用,尽管现代编译程序拥有编译执行、循环代码外

提、无用代码去除等诸多优化功能,但它都不能保证程序最优。最后一种代码恐怕很少有编译器能够做到

,不信你可以用自己常用的编译器加上各种优化选项试试:)

发现什么了吗?三种代码中,对于内存的访问一个比一个少。这样做的理由是,尽可能地利用寄存器并减

少对内存的访问,可以提高代码性能。在某些情况下,使代码既小又快是可能的。
书归正传,我们来说说保护模式的内存模型。保护模式的内存和实模式有很多共同之处。


内存 GDT或LDT
|-------| |------|
|-------|<------| | 1 |<------选择器(放ds,cs,es,fs或gs中的数)
| | |------|------| 每个项目是一个描述符,描述一部分内存
| | | | 2 |
|-------|<------| |------|
| | | 3 |
| | |------|
| | | 4 |
| | |-.----|
--------| |-.----|
保存在内存的某个地方


毫无疑问,以'protected mode'(保护模式), 'global descriptor table'(全局描述符表), 'local

descriptor table'(本地描述符表)和'selector'(选择器)搜索,你会得到完整介绍它们的大量信息。
保护模式与实模式的内存类似,然而,它们之间最大的区别就是保护模式的内存是“线性”的。

新的计算机上,32-bit的寄存器已经不是什么新鲜事(如果你哪天听说你的CPU的寄存器不是32-bit的,那

么它——简直可以肯定地说——的字长要比32-bit还要多。新的个人机上已经开始逐步采用64-bit的CPU了

),换言之,实际上段/偏移量这一格局已经不再需要了。尽管如此,在继续看保护模式内存结构时,仍请

记住段/偏移量的概念。不妨把段寄存器看作对于保护模式中的选择器的一个模拟。选择器是全局描述符表

(Global Descriptor Table, GDT)或本地描述符表(Local Descriptor Table, LDT)的一个指针。

如图所示,GDT和LDT的每一个项目都描述一块内存。例如,一个项目中包含了某块被描述的内存的物理的

基地址、长度,以及其他一些相关信息。
保护模式是一个非常重要的概念,同时也是目前撰写应用程序时,最常用的CPU模式(运行在新的计算机上

的操作系统很少有在实模式下运行的)。
为什么叫保护模式呢?它“保护”了什么?答案是进程的内存。保护模式的主要目的在于允许多个进程同

时运行,并保护它们的内存不受其他进程的侵犯。这有点类似于C++中的机制,然而它的强制力要大得多。

如果你的进程在保护模式下以不恰当的方式访问了内存(例如,写了“只读”内存,或读了不可读的内存
*滑块验证:
您需要登录后才可以回帖 登录 | 注册会员

本版积分规则

QQ|手机版|MCU资讯论坛 ( 京ICP备18035221号-2 )|网站地图

GMT+8, 2024-11-29 17:48 , Processed in 0.055628 second(s), 12 queries , Redis On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表