汇编语言程序设计读书笔记(4)- 程序设计基础之一

程序设计基础部分主要内容包含数据定义,数据传输,寻址方式,汇编指令等等。涉及的内容较多,用多篇文章才可叙述完。

一、数据定义

汇编程序可以定义赋予了初始值的数据,且该数据在程序代码中是可改变值的,类似于变量,也可以是不可改变值的,类似常量,还可以是程序使用的缓冲区,类似于函数中的无初值的局部变量。下面具体描述。

1、变量数据定义

前面的文章提过,.data段用于存放有初始值的数据,因此定义有初始值的变量数据就在.data段中定义。定义的模板如下:

.section .data
label:
type digit1, digit2, ..., digitn模板

 

上面的语句在.data定义了n个数据digit1到digitn,首地址用标签label表示,表示在label地址开始的内存空间定义了n个数据,这n个数据类型都是type表示的类型(type具体可能是int或short之类的,后面详述),类型决定了每个数据所占的内存空间是多少字节。而且对于IA-32的处理器而言,这n个数据是按小端对齐的方式存放在内存中的。比如以下的例子:

.section .data
arr:
    .int 0x345678, 0xa6, 0x1298 

定义了三个整型数据,起始地址为标签arr,这三个数据按照从小端对齐的方式存储于内存中,单个数据所占的存储空间是整型的大小,即32位,或说是4字节。内存图示如下:

image

程序中要引用数据,就必须使用标签,如上图,arr是首地址,那么arr+1的地址就表示下一个字节的地址,因为整数大小为4字节,因此下一个整数的起始地址就是arr+4,第三个整数的起始地址为arr+8,后面寻址方式那节会说到直接寻址,通过对内存地址的直接寻址,可以获得该内存地址所存储的数据;这个例子中,数据是按照.int定义的,然而并非必须按照int的长度来访问这些数据,事实上,访问的长度是由指令来决定,比如,用访问一个字节的指令在arr地址访问,那访问到的数据就是0x78,按照两个字节来访问就是0x5678,下面的var_test1.s程序演示了如何在程序中使用这些定义的数据。

var_test1.s

 

编译,连接,最后运行结果如下:

image

在var_test.s程序中的前三小段,通过32位的指令直接寻址arr,arr+4,arr+8内存地址的内容,并入栈作为printf的参数,打印出定义的三个整数;程序最后两小段,在arr+1的地址处直接寻址,分别通过16位(movw)和8位(movb)的指令来访问内存,对照上面所画的内存图,很容易理解,arr+1起始位置的内存数据字节为0x56,字则是0x3456,获取到数据后入栈作为printf的参数,打印出来。因而得到如上的运行结果。

回到最开始定义的模版,类型type代表具体的一些类,包括整型,浮点型和字符串,具体可列表如下。

星星 整型数据类型:

type

类型说明

.byte

字节值(1字节)

.short

16位整数(2字节)

.int

32位整数(4字节)

.long

32位整数(4字节)

.quad

64位整数(8字节)

.octa

128位整数(16字节)

 

星星 浮点型数据类型:

type

类型说明

.float

单精度浮点型

.single

单精度浮点型

.double

双精度浮点型

 

星星 字符串类型:

type

类型说明

.ascii

文本字符串(结尾不会自动加0)

.asciz

0结尾的字符串

2、常量数据定义

类似于C语言中用#define宏定义来定义常量,比如:

#define WEIGHT   60

定义WEIGHT为常量符号,代表60。汇编中也有类似的定义,定义模板为:

.section .data
.equ const_data, value

.equ是指令(英文单词equal的缩写,等于的意思),const_data是常量符号,用户可以任意定义名称,只要满足变量命名规则则可,value就是const_data符号所代表的常量值,不可以在程序中被改动,使用时,要按照立即数的方式$const_data来使用常量符号,比如movl $const_data, %eax,就相当于movl $value, %eax。

3、缓冲区定义

缓冲区是一段内存,内存中的数据没有被初始化,以前文章说过,.bss段用于存储未初始化或者全零的数据,因此缓冲区在.bss段中定义,指令.comm和.lcomm用于定义缓冲区,两者的区别在于.lcomm定义仅供本地使用的缓冲区,前面的字母l正是单词local的首字母,表示本地,局部的意思。定义模板为:

.section .bss
    .comm buffer, length

或者

.section .bss
    .lcomm buffer, length

buffer表示缓冲区首地址,length表示缓冲区字节长度。比如:

.section .bss
    .comm buf, 10000

定义了10000个字节的缓冲区,首地址为buf。

缓冲区不会包含在可执行程序中,而.data中定义的变量数据会包含在可执行程序中,比较以下两段代码,buff_test.s和data_test.s,两者都定义了10000个字节的长度,所不同的是buff_test.s在.bss定义,data_test.s在.data定义。最后观察两者的可执行文件的长度,data_test文件长度很长,超过了10000个字节,而buff_test文件才几百字节,可见在.data定义的10000字节包含在可执行文件中了,而.bss定义的10000字节则没有包含到可执行文件中。

代码如下:

buff_test.s

 

data_test.s


说明:.fill 10000表示用0填充10000个字节长度的数据。

汇编连接后,可见一个长度为574字节,另一个为10575字节,如下图。

image

二、寻址方式

上面叙述了数据的定义,数据定义后,需要在程序中使用它,即在程序中如何访问它,比如需要读取它,或者把其它的数值写给它,就涉及到要通过什么样的方式去访问这些数据,一般分为立即数寻址,寄存器寻址,直接寻址,寄存器间接寻址,寄存器相对间接,变址寻址,下面分别详述。

1、立即数寻址

立即数是指数据在指令码语句中直接指定的,而且在运行时不能改动的,用$符号开始的数据,比如int $0x80中的$0x80就是立即数,以及指令中类似$1,$0之类以美元符号开头的数据都是立即数。

立即数寻址的格式为: $imm

2、寄存器寻址

是指数据存在于寄存器中,比如数据在寄存器eax中,那么读取寄存器eax的内容就得到了寄存器eax中的数据,像这样通过读取寄存器来获取寄存器内的数据的方式就叫寄存器寻址。比如movl %eax, %ebx语句,指令中的%eax和%ebx都是寄存器寻址,即从寄存器eax中读取源数据,写到寄存器%ebx中;又比如movl $1, %eax指令中的%eax也是寄存器寻址,$1是立即数寻址,即把立即数1写到寄存器中。

寄存器寻址的格式为: %reg

3、直接寻址

是指通过内存的地址直接去访问内存中的数据,直接寻址和立即数寻址要区分开,比如定义了标签label,那么label就表示一个内存的地址,地址里面存放数据digit,因此指令中通过label这个内存地址来访问数据就是直接寻址的方式,而如果使用$label就是把地址当成立即数来使用,述立即数寻址,$digit也是把内存中的数值当立即数使用,也属立即数寻址。举个完整程序的例子:

  1: .section .data
  2: label:
  3:     .int 0x123456
  4: .section .text
  5: .globl _start
  6: _start:
  7:     movl $label, %ebx
  8:     movl label, %edx
  9: 
 10:     movl $1, %eax
 11:     movl $0, %ebx
 12:     int $0x80

第7行中的$label表示立即数寻址,ebx中得到的是label的地址值,第8行中的label表示直接寻址,edx中得到的是label地址所对应内存中的数据,即0x123456。

直接寻址的格式为: address

4、寄存器间接寻址

寄存器间接寻址是指寄存器中存储的是内存地址,通过寄存器间接的访问到地址中所对应的内存数据的这样一种寻址方式,比如寄存器eax存储的是数据在内存中的地址addr,通过(%eax)的间接的方式(小括号栝着寄存器),可以访问到地址addr所对应的内存中的数据,比如如下图内存地址0x80490a0的位置存储了数据0x99,0xef,0xcd,0xab。

image

那么如果eax=0x80490a0,那么通过寄存器eax间接寻址(%eax),可以访问到整数数据0xabcdef99。

寄存器间接寻址要和寄存器寻址区别开来,如上图,如果是寄存器寻址,那么通过eax获取到的是寄存器里的数值,即地址0x80490a0,即下面两个指令结果是不一样的:

  1: movl %eax, %ebx        # 寄存器寻址,ebx=0x80490a0
  2: movl (%eax), %ebx      # 寄存器间接寻址,ebx=0xabcdef99

寄存器间接寻址的格式为: (%reg)

5、寄存器相对寻址

寄存器相对寻址是在寄存器间接寻址的基础上,加上一个相对当前地址的偏移来寻址。比如eax存储了地址addr,(%eax) 是间接寻址,如果往内存的高地址的方向偏移4个字节,即要访问地址addr+4的数据,那么可以通过4(%eax)这样的相对寻址的方式,来访问addr+4这个地址所对应的内存的数据,同样的,-4(%eax也是相对寻址,访问的是 addr-4这个地址所对应的内存的数据。

比如,eax=0x80490a0,那么4(%eax)访问的就是地址为0x80490a4的内存位置的数据。在指令: movl 4(%eax), %ebx 中,4(%eax)这样的方式就表示寄存器相对寻址。

相对寻址的格式为: offset(%reg)

6、变址寻址

要说清楚变址寻址,要从它的格式说起。

变址寻址的格式为: base(offset, index, size)

base指的是内存的基址,offset指的是偏移地址,index指的是数据的序号,size指的是单个数据的字节长度,可以拿C语言中的数组的访问来比喻,比如整型数组a[10],那么base可以认为是数组的首地址a,offset可以认为是0(因为数组的下标从0开始),index是索引,相当于数组的下标,即从0到9的索引值,size则是数组中单个数据的字节长度,整数字节长度就是4,那么对数组a[i]的访问,就使用a(0, index, 4)这样的形式。举个具体的例子来说明:

.section .data
arr:
    .int 1, 7, 9, 46, 68, 13, 93, 88, 2, 555

arr开始的地址定义了10个整数,如果基址base=arr,如果偏移为0,即offset=0,整数字节长度为4字节,那么arr(0, 0, 4)就可以访问第一个数据1,arr(0, 3, 4)就可以访问第4个数据46。这里只是为了便于说明而采用了伪代码arr(0, 3, 4)的方式,实际上格式中的offset和index必须采用寄存器的方式,比如eax=0,edi=3,那么指令中要采用arr(%eax, %edi, 4)的方式,格式中,如果有哪个值为0值,可以不写,但逗号不可以省略,比如写成arr(, %edi, 4)的方式,省略了offset,因为它是0。

实际上,变址寻址访问的数据地址位于base + offset + index * size的位置。

下面的程序为变址寻址的范例,程序作用是打印数组的值。

test3.s

 

文章第一次出现(. – arr) >> 2这样的语句,其中的.表示的是当前的地址,减去arr得到的差值就是arr到当前地址这一段内存空间的字节大小,右移2位表示除以4,得到的是arr到当前地址这一段内存空间的整数个数(因为一个整数占4字节,所以字节数除以4),总言之,可认为是为了求出数组arr的整数的个数。建议采用这样的方式来获取数据数目或者字符串长度,虽然这个例子中是10个整数很好数,如果是成千上万甚至更多的话,那怎么数?

程序编译链接后执行如下图所示,数据被正确访问并打印。

image

三、数据传送和MOV指令

1、数据传送规则

传数据最常用的指令是mov指令。学过单片机汇编或者对8086汇编有所了解的人都知道,传送数据是有一定规则的,比如一般而言,不能从内存到内存,即两个内存之间直接进行传送,而要通过寄存器。部件之间传送数据的关系可以用下图表示:

image

图示说明除了立即数以外,其它部件的和通用寄存器之间都可以相互传数据。

灯泡 有一个要特别说明的,就是movs指令可以在内存之间传送字符串。后续说到字符串处理的内容时会详述。

2、mov指令

mov指令的格式是: movx src, dst

这里x代表的是字母q,l,w,b其中之一,分别表示传送的是64位,32位,16位,8位的数据。linux中采用的是at&t的指令格式,指令最后的字母q,l,w,b就表示传送的数据的长度。

src表示源操作数,dst表示目的操作数,它们的规则看上面传送关系的图示。比如可以是从立即数到寄存器,那么src就是立即数,dst就是寄存器。

无论src是内存,或是dst是内存时,内存的寻址都可以采用寻址方式一段说述的寻址方式,下面例子演示了mov指令的用法:

  1: movl $123, %eax            # 立即数到寄存器
  2: movl $123, arr             # 立即数到内存,内存用直接寻址
  3: movl $123, (%eax)          # 立即数到内存,内存用间接寻址
  4: movl $123, 4(%eax)         # 立即数到内存,内存用相对寻址
  5: movl $123, arr(, %edi, 4)  # 立即数到内存,内存用变址寻址
  6: movl %eax, %ebx            # 寄存器到寄存器
  7: movl %eax, arr             # 寄存器到内存,内存用直接寻址
  8: movl %eax, (%ebx)          # 寄存器到内存,内存用间接寻址
  9: movl %eax, 4(%ebx)         # 寄存器到内存,内存用相对寻址
 10: movl %eax, arr(, %edi, 4)  # 寄存器到内存,内存用变址寻址
 11: movl arr, %eax             # 内存到寄存器,内存用直接寻址
 12: movl (%ebx), %eax          # 内存到寄存器,内存用间接寻址
 13: movl 4(%ebx), %eax         # 内存到寄存器,内存用相对寻址
 14: movl arr(, %edi, 4), %eax  # 内存到寄存器,内存用变址寻址

四、条件传送数据cmov指令

条件传送和上面所述的数据传送在传送上是一样的,所不同的是要满足某个或某些条件时才执行传送数据。

条件传送传送数据的格式为: cmovx src, dst

前面的字母c为英文condition(意思是条件)的首字母,后面的x表示条件,不同的条件有不同的表示,通常是一到三个字母,比如无符号数大于这个条件,用字母a表示(above的第一个字母),也就是指令码为cmova;后面的src和dst和数据传送中论述的一样,不再详述。

比如在C语言中经常见到类似如下的语句:

if(a > b)
    max = a;

那么在汇编语言中,如果eax=a,ebx=b,max在内存中,上面的语句就是:如果eax大于ebx这个条件满足,那么就把数据从eax寄存器传送到内存max中,这就要用到条件传送,此时用汇编的语句为:

cmpl %ebx, %eax
cmova %eax, max

cmpl是比较指令,以后的文章会详述,这里只要理解是比较eax和ebx,判断是否满足eax大于ebx条件的,cmova是表示如果满足大于(字母a)的这个条件(字母c),就把eax中的数据传送到max地址所对应的内存中。那么电脑是怎么通过cmpl的指令结果来判断是否满足大于这个条件的呢?对IA-32的cpu而言,是通过一个叫EFLAGS的标志寄存器中的状态位来判断的,指令中的算数运算的结果会反映到状态标志中,而条件传送语句则判断EFLAGS中的状态标志是否满足条件来决定是否传送数据。这个关系可以用如下的图示来描述:

image

下面对EFLAGS寄存器的状态标志做进一步的叙述。

1、状态标志位

eflags是32位的寄存器,常用于条件判断的状态标志位有6个,用于指示算数运算结果的,列表如下:

状态标志

位号

名称

描述

CF

bit0

进位标志

(Carry Flag)

算数运算的结果在最高有效位产生了进位或者借位,该标志置1,否则清0。
或者说该位用于指示无符号数是否溢出,比如n位(n为8,16或者32)无符号数范围是[0, 2的n次方-1]的闭合区间,如果对应位数的指令(指令中最后的字母为b,w,l分别指示是8位,16位,32位)运算的结果超出了这个范围,那么该标志为1。否则为0。如果结果考虑了进位或者借位,那结果还是正确的,可以用于多字(节)的运算。

PF

bit2

奇偶标志

(Parity Flag)

运算结果的最低字节有偶数个1,该位置1,否则该位清0。即结果的最低字节和该位所包含的位中1的数目一定是奇数个。

AF

bit4

辅助进位标志

(Auxiliary Carry flag)

如果算数运算中bit3这一位产生了进位或者借位,那么该位置1,否则清0。

ZF

bit6

零标志

(Zero flag)

如果结果为0,那么该位置1,否则清0。

SF

bit7

符号标志

(Sign flag)

等于有符号数的符号位(有符号数用最高位表示数值的正负符号,1表示负数,0表示正数),结果为负,该位置1,否则清0。

OF

bit11

溢出标志

(Overflow flag)

该位用于指示有符号数是否溢出,比如n位(n为8,16或者32)无符号数范围是[-(2的n-1次方), 2的n-1次方-1]的闭合区间,如果对应位数的指令(指令中最后的字母为b,w,l分别指示是8位,16位,32位)运算的结果超出了这个范围,那么结果溢出,该标志为1。否则为0。如果结果溢出,那么结果不正确了。

以上标志只有CF标志可以用指令直接置位或者清零,其它标志只能通过算数运算的指令来影响。

其实上面的cmpl %ebx, %eax指令就是执行eax-ebx这样的算数运算,结果会反映到状态标志中。如果作为无符号数的形式,eax大于ebx,那么就会把CF清零,如果等于就会把ZF置1,如果小于,就会引起CF置1;同样作为有符号数则会影响到SF,ZF,OF标志。cmpl这样的比较指令以后会说到。

2、cmov指令

如上所述,指令码cmov之后接一个字母到三个字母来表示条件,先复习一下英文单词,再理解指令码中的条件为什么用这样的字母表示,这样更容易理解,无符号数用above(高)和below(低)表示大于和小于,有符号数用greater(更大)和less(更少)表示大于和小于,equal表示等于,zero表示零,sign表示符号(上面叙述了符号位为1表示为负数),carry表示进位,parity表示奇偶校验,even表示偶数,odd表示奇数,overflow表示溢出,no表示非,不。通过这些单词的第一个字母的组合可以产生指令码中的条件,比如na,表示no above,即无符号数的不大于,也就是小于或等于,所以和below or equal是等价的,即na和be等价;同样parity even简写为pe,表示偶校验,等等。

基于这样的字母组合,得到一系列的条件罗列如下表格,等价的条件罗列在一块:

条件

描述

状态标志

a/nbe

无符号数大于(above)/不小于等于(no below equal)

CF=0 且 ZF=0

ae/nb

无符号数大于等于(above equal)/不小于(no below)

CF=0

b/nae

无符号数小于(below)/不大于等于(no above equal)

CF=1

be/na

无符号数小于等于(below equal)/不大于 (no above)

CF=1 或 ZF=1

c

有进位(carry)

CF=1

nc

无进位(no carry)

CF=0

g/nle

有符号数大于(greater)/不小于等于(no less equal)

SF=OF 且 ZF=0

ge/nl

有符号数大于等于(greater equal)/不小于(no less)

SF=OF

l/nge

有符号数小于(less)/不大于等于(no greater equal)

SF=^OF

le/ng

有符号数小于等于(less equal)/不大于(no greater)

SF=^OF 或 ZF=1

o

溢出(overflow)

OF=1

no

未溢出(no overflow)

OF=0

s

带符号(sign),即负数

SF=1

ns

无符号(no sign),即非负数

SF=0

e/z

相等(equal)/为零(zero)

ZF=1

ne/nz

不等于(no equal)/不为零(no zero)

ZF=0

p/pe

奇偶校验位置一(parity)/偶校验(parity even)

PF=1

np/po

奇偶校验位清零(no parity)/偶校验(parity odd)

PF=0

cmov接着上面的条件字母就得到了所有的条件传送数据的指令码,比如cmova,cmovnbe,等等,不一一列举。

下面通过打印有符号整数数组中最大值和最小值为例,说明条件传输的用法。程序如下:

cmov_test.s

 

汇编连接后执行如下图所示:

image

五、交换数据

如果要交换两个位置的数据,如果仅使用mov指令的话,就需要一个临时的缓冲区,先把位置1的数据传输给临时缓冲区,再把位置2的数据传送到位置1,最后把临时缓冲区的数据传给位置2。相当于分了3步。汇编语言提供了解决这个问题的指令,一个指令就可以实现数据交换的操作。详述如下。

1、xchg指令

xchg格式为: xchg op1, op2

op1和op2表示操作数1和操作数2,两者可以是寄存器或者内存,但不可以同时都是内存,而且两个操作数必须位数一样,即同是8位,16位,或32位的操作数。

当操作数之一为内存时,处理器会lock住这个内存位置,防止交换过程中被其它处理器访问,lock处理很耗时间,可能对性能会有不良影响。

下面以对一个数组进行倒序来演示这个指令的用法,代码如下,主要是执行第一个数据和最后一个数据交换,第二个和倒数第二个交换,…,如此,直到把所有的数据都交换了,数组顺序就倒过来了。

reverse.s


汇编连接执行结果如下图,数组反转了。

image

2、bswap指令

bswap指令格式为: bswap reg32 

reg32指的是32位的寄存器。如果reg32的四个字节从高到低记为byte3-byte0,那么这个指令的作用是把byte3和byte0交换,byte2和byte1交换,即实现了大小端的数据转换。

以下例子示范这个指令的使用,程序为把num地址中的数据0x12345678转为大端的排列方式,即把0x12345678转为0x78563412,源代码为:

bswap_test.s

 

汇编连接后执行结果如下图:

image

3、xadd指令

该指令格式为:xadd src, dst

src必须是寄存器,dst可以是寄存器或者内存,该指令是交换src和dst的数据,然后把两个数据的和存到dst中,即执行后,src=dst,dst=src+dst。

例子程序如下:

xadd_test.s


汇编连接后运行,如下图所示:

image

4、cmpxchg指令

该指令格式为: cmpxchg src, dst

src必须是寄存器,dst可以是寄存器或者内存,而且src位数要和dst一致,如果dst是32(16/8)位的,那么dst和eax(ax/al)比较,相等的话就把src传送到dst,如果不等就把dst传送到eax(ax/al)。

示例如下:

cmpxchg_test.s

 

用kdbg看调试的过程如下图:

image

开始赋给初值:eax=0x12,ebx=0x34,ecx=0x12,edx=0x56

执行cmpxchg %ebx, %ecx后,由于ecx等于eax,那么就会把ebx传到ecx中,即ecx变成了0x34,下图的调试结果证实如此:

image

再执行cmpxchg %ebx, %edx,由于edx不等于eax,因此,会把edx传送到eax,即eax变成了0x56,下图的调试结果也证实了如此:

image

5、cmpxchg8b指令

指令格式为: cmpxchg8b dst

功能和上述的cmpxchg类似,只是处理8字节位数据而已,指令格式中的dst是8字节的内存,和cmpxchg指令中的dst类似,指令中没有src,是因为src默认是ecx:ebx组合的8字节寄存器对,比较的是edx:eax组合的8字节寄存器对。即dst和edx:eax比较,相等的话,就把ecx:ebx(即src)传送到dst,不等的话就把dst传送给edx:eax。有了cmpxchg的实例,这个很容易理解,不需再举例。

六、堆栈

1、堆栈简介

堆(heap)和栈(stack)本来是两种不同的数据结构,而我们所说的堆栈,其实就是栈(stack),是一种串行形式的数据结构,通过在内存中指定一段存储空间来存放这样的数据结构:该结构如下面图示所示,只能从该串行结构的一端存入或者取出数据。

image

如图示,上面开口的容器就是栈,最下面的叫栈底,数据进出的那一端叫栈顶,数据从栈顶存入堆栈,如果数据一直存入而不取出,就会装满容器,造成栈溢出,反之如果只取不存入,就会造成栈空。故通常,堆栈在使用上是有存有取的,而且压入栈和弹出栈只能按顺序从栈顶存取,即不能存到容器的中间或者底部(该说法仅针对push的压栈和pop的弹出方式,手动操作堆栈的方式不受这个限制),取数据时也一样,显而易见,最后存入的数据会被最先取走,这个叫后进先出(Last In First Out,简称LIFO)。

大多数CPU都有用作堆栈指针(stack pointer)的寄存器,简称SP,比如原来8086的16位CPU结构上叫SP寄存器,IA-32结构CPU扩展到32位,叫ESP(extended stack pointer)寄存器,它的低16位还是SP寄存器。sp寄存器指向堆栈活动的那一端,即栈顶。这里要特别说明,不同的CPU结构,SP操作堆栈的方式有些区别,根据这些区别,堆栈有满堆栈和空堆栈的区别,有递增型和递减型的区别。

递增型堆栈:堆栈由内存低地址向高地址方向生长,也叫向上生长型堆栈,即上面的图示中的栈底在内存的低地址,栈顶在内存的高地址。

递减型堆栈:堆栈由内存高地址向低地址方向生长,也叫向下生长型堆栈,即图示中的栈底在内存的高地址,栈顶在内存的低地址。

满堆栈: 如果SP指针指向的是栈顶的最后一个数据的位置,就叫满堆栈。满堆栈入栈时,sp要先指向下一个空位置(对递增型堆栈而言,该空位置就是下一个高地址,对递减型则是下一个低地址),然后数据再入栈,出栈时,数据先出栈,然后sp再指向新的栈顶的数据。即入栈和出栈后,sp最终都指向栈顶的最后一个数据的位置。如下图示:

image

空堆栈: 如果SP指向的是栈顶的空位置,就叫空堆栈。空堆栈入栈时,数据先入栈,然后sp指向下一个新的空位置(对递增型堆栈而言,该空位置就是下一个高地址,对递减型则是下一个低地址),出栈时,sp要先指向栈顶的最后一个数据,然后数据再出栈,出栈后,sp所指的位置就成了栈顶的新的空位置。即入栈和出栈后,sp最终都指向栈顶的空位置,如下图示:

image

IA-32结构的CPU采用的是递减型满堆栈,举例来说,比如起始时ESP指向地址0xffffd2c0的位置,如果整数(4字节)0x12345678要入栈,那么ESP先变成0xffffd2bc,然后4字节的数据再进入到地址0xffffd2bf-0xffffd2bc的4字节的堆栈中,此时ESP指向的是最后入栈的整数0x12345678,出栈时数据先出栈,然后ESP变成0xffffd2c0。

堆栈的操作通常有两种方式,一种是手动操作ESP寄存器或者EBP寄存器,通过寄存器的间接寻址或者相对寻址的方式来操作堆栈,比如下面的代码手动操作堆栈,把数据0x12345678入栈:

subl $4, %esp
movl $0x12345678, (%esp)

而另一种方式是使用汇编语言提供的push指令入栈,pop指令出栈,比如下面的代码也是把0x12345678入栈:

 pushl $0x12345678

堆栈是怎么来的?程序中直接使用push和pop来操作堆栈,完全没有看到有建立堆栈的相关代码。如果做过单片机或者像arm之类的程序的话,应该知道,裸机程序最先运行的是一个汇编启动程序,或者说加载操作系统前是先跑bootloader程序,通常这样的程序在跳转到main函数执行前会先初始化堆栈。但是这里汇编程序设计是在IA-32结构中,是在linux系统中跑的,可以理解为inux下的汇编程序所使用的堆栈是linux操作系统建立的,毕竟这里的汇编程序不是在裸机上跑的程序,而是在系统中的一个应用程序,因此不需要像裸机程序一样,程序开始要先初始化一段内存区作为堆栈。

堆栈用途广泛,比如函数中的局部变量,32位系统的参数传递,异常或者中断以及任务切换的现场保护,或者上下文保护,用的都是堆栈。

2、入栈指令push

push是指把数据压入堆栈。

指令格式为: pushx src

x可以表示l或者w,分别代表压入32位或者16位的数据,位数必须和后面的src匹配,src可以是寄存器,内存,立即数。

例如以下的指令都是push的合法指令:

  1: pushl $0x12345678               # 32-bits immediate 
  2: pushw $0xabcd                   # 16-bits immediate   
  3: pushl %eax                      # reg32
  4: pushw %ax                       # reg16
  5: pushl lab1                      # mem32
  6: pushw lab2                      # mem16

3、出栈指令pop

和push类似,pop的指令格式为: popx dst

x可以表示l或者w,分别代表压入32位或者16位的数据,dst表示接收数据的目的位置,位数要和指令码对应,可以是寄存器或者内存,不可以是立即数。

例如下面的指令都是pop的合法指令:

  1: popl %eax
  2: popw %ax
  3: popl mem32
  4: popw mem16

4、所有寄存器入栈出栈

有4种指令格式,列表如下:

指令

描述

pusha/popa

压入/弹出所有16位的寄存器,入栈顺序为:DI,SI,BP,BX,DX,CX,AX;出栈顺序则反过来。

pushad/popad

压入/弹出所有32位的寄存器,入栈顺序为:EDI,ESI,EBP,EBX,EDX,ECX,EAX;出栈顺序则反过来。

pushf/popf

压入/弹出flags寄存器

pushfd/popfd

压入/弹出eflags寄存器

以上指令中的a,是all(全部)的缩写;d可能是double,双倍的意思,即16位变成了32位;f表示flags寄存器,fd表示后面加double后为32位,表示eflags寄存器。

灯泡 注意: 

根据处理器的操作模式,POPF/POPFD 指令对 EFLAGS 寄存器的影响略有差异。处理器在保护模式中操作时,如果特权级别为 0(或是在实地址模式中操作,它相当于特权级别 0),则 EFLAGS 寄存器中除 VIP、VIF 以及 VM 标志之外的所有非保留位都可以修改。VIP 与 VIF 标志被清除,VM 标志不受影响。

在保护模式中操作时,如果特权级别大于 0 但小于或等于 IOPL,则除 IOPL 字段与 VIP、VIF 以及 VM 标志之外的所有标志都可以修改。这里,IOPL 标志不受影响,VIP 与 VIF 标志被清除,VM 标志不受影响。只有在至少与 IOPL 相等的特权级别下执行时,才可以更改中断标志 (IF)。如果在特权不够高的情况下执行 POPF/POPFD 指令,则不会发生异常,但特权位也不会改变。

在虚 8086 模式中操作时,I/O 特权级别 (IOPL) 必须等于 3 才能使用 POPF/POPFD 指令,此时 VM、RF、IOPL、VIP 以及 VIF 标志不受影响。如果 IOPL 小于 3,POPF/POPFD 指令将导致一般保护性异常 (#GP)。

具体可以参考intel的手册。

5、堆栈的另一种用法

即上面堆栈简介中提到的手动操作堆栈的方法,通常在函数中,会安排一段堆栈空间给函数使用,包括存储函数的局部变量或者临时空间,一般使用ESP作为函数中堆栈栈顶的指针,EBP作为栈底的指针,通过EBP的间接寻址或者相对寻址来操作堆栈,存储临时变量或者局部变量,此时ebp是不变的,存储的是要恢复的esp的值,调用子函数则通过ESP入栈,比如类似以下的代码的用法,仔细阅读注释应该不难理解。

  1: func:                        # func函数
  2:     pushl %ebp               # ebp保存到堆栈
  3:     movl %esp, %ebp          # ebp为func函数所用堆栈的栈底
  4:     subl $24, %esp           # esp为func函数所用堆栈的栈顶,即开辟了24个字节给func函数使用 
  5:     ...                      # func处理
  6:     movl local_var, -4(%ebp) # 通过ebp相对寻址来操作func的堆栈底部来保存局部或临时变量,此时ebp是不变的,
  7:     movl temp_var, -8(%ebp)  # ebp保持着func函数所用堆栈底的值,即是函数返回后需恢复的堆栈指针值
  8:     ...                      # func处理
  9:     pushl param              # 参数入栈,使用的是func堆栈的顶部
 10:     call other_func          # 调用别的函数
 11:     ...                      # func处理
 12: 
 13:     movl %ebp, %esp          # 恢复esp堆栈指针
 14:     popl %ebp                # 恢复ebp寄存器
 15:     ret                      # 函数返回

七、优化内存访问

内存的访问比寄存器慢很多,往往造成CPU执行慢。因此,要优化程序的处理速度,按以下几点:

  • 尽量使用寄存器,而不用内存,
  • 如果需要使用内存,把使用频繁的数据存放到连续的内存块,因为CPU访问内存时,会把一块内存读到缓存中,缓存比内存快多了,这么做可以提高缓存的命中率,也就提高了速度
  • 内存对齐,因为CPU读取内存是按照一定的粒度去读取的,比如先读取地址0-3的4个字节,下一次可能读取地址4-7的4字节,因此,如果数据在内存中跨边界,比如用了地址2-5,则要读取两次才可取到数据,如果对齐边界存放,则能一次读回来,因此建议是n字节的数据对齐到n的倍数的基址上存放,比如32位数据(4字节)对齐到4的倍数的基址上。汇编语言提供了.align指令用于对齐
  • 避免小数据传输,比如要复制内存的数据到另一块内存,对IA-32而言,一次复制4字节肯定比按字节去复制要快
  • 避免在堆栈中使用大的数据长度(比如80位和128位的浮点数)

灯泡注意:

gas中,指令.align n对不同的cpu结构意义不一样,对于a29k , HPPA , M68K , m88k , W65 ,SPARC , Xtensa和瑞萨/的SuperH SH ,以及i386的ELF格式这样的结构而言,表示对齐到n的倍数的基址上,比如.align 4表示对齐到地址为4的倍数的基址上;而对于其他系统,包括使用a.out格式的i386 ,ARM和StrongARM的结构,表示对齐到2的n次方的倍数的基址上,比如.align 3表示对齐到8的倍数的基址上。

posted on 2013-11-23 13:47  kenzhang1031  阅读(2427)  评论(1编辑  收藏  举报