汇编之路-栈操作与栈帧

结构化程序的一个最基本的单元就是“函数”或者叫“过程”。在汇编这一层自然也相应的有支持这些概念的指令操作,如栈操作和栈帧的概念。

首先这里要为“打开汇编之门”那篇blog补充一点的是:汇编语言是与机器相关,这里的一切都是基于IA-32机器平台的。

1、寻址方式
我们已经知道在操作数表示中有一种是用来指示内存地址的内容的,在GNU Assembly中指示内存地址有多种方式,这些方式被统称“寻址方式”。通用的寻址格式为:“Imm(Eb, Ei, s)”[1]。解释一下:该表达式的计算方式为Imm + R[Eb] + R[Ei] * s,这一串的结果是什么呢?是一个存储器的地址,操作指令通过该操作数表达式计算出来的内存地址来访问内存。

由通用形式演化几种常见特殊形式如下:
1) Imm – 注意与$Imm区别,后者为立即数,而前者是以立即数形式承载的一个内存地址,这种方式叫绝对寻址;
2) (Ex) – 注意与Ex区别,后者为寄存器内容,而前者是以寄存器内容形式承载的一个内存地址,这种方式叫间接寻址;
3) Imm(Eb) – 其表示结果是内存地址为Imm + R[Eb];
4) (Eb, Ei) – 其表示结果是内存地址为R[Eb] + R[Ei];
5) Imm((Eb, Ei) – 其表示结果是内存地址为Imm + R[Eb] + R[Ei]。

2、寄存器使用
在“打开汇编之门”中曾经提过虽然寄存器的专用性已经降低,但是某些寄存器还是有其专用场合的。GNU为我们制定了一个寄存器使用规则,规则规定:“%eax、%ecx和%edx是由调用者负责存储的,而%ebx、%ebi和%esi则由被调用者保护,而%esp和%ebp都是栈操作专用的”。

3、栈操作
栈,实际上是一块儿专用的内存区域,每个进程地址空间都有其专有的栈区。地球人都知道关于栈有两种操作:Push和Pop。相应的GNU Assembly分别定义了“pushl S”和“popl D”分别来完成压栈和出栈操作。每个操作都包含两个步骤:移动栈顶指针和数据传送。
pushl S R[%esp] <– R[%esp] – 4 ;M[R[%esp]]<– S
popl D   D <– M[R[%esp]];R[%esp] <– R[%esp] + 4

4、栈帧的形成
提到函数或者过程调用就不能离开栈操作。而每个函数或者过程调用也都离不开一个叫“栈帧”的概念。栈是用来传递参数、保存返回结果等作用的,而栈帧则是1对1映射到某个过程调用的。栈帧由%ebp来标识。我们来看看一个例子,通过该例子看看栈帧里到底有些什么东西?
void callee(int x, int y) {
 x = 1;
 y = 2;
}

void caller(int m, int n) {
 callee(m, n);
}

翻译为汇编代码为:
_callee:
 pushl %ebp   //保存调用者的栈帧地址
 movl %esp, %ebp  //初始化callee栈帧地址
 movl $1, 8(%ebp)  //获取参数x信息
 movl $2, 12(%ebp)  //获取参数y信息
 popl %ebp
 ret
… …
… …
_caller:
 pushl %ebp   //保存调用者的栈帧地址
 movl %esp, %ebp  //初始化caller栈帧地址
 subl $8, %esp  
 movl 12(%ebp), %eax  
 movl %eax, 4(%esp)
 movl 8(%ebp), %eax
 movl %eax, (%esp)
 call _callee
 leave
 ret
看看callee的汇编码:进入callee后首先保存其调用者caller的栈帧地址,然后读取其调用者caller栈帧中的参数信息进行计算。可以看出一个过程的栈帧中起码包括其上一个栈帧的起始地址,然后是一些参数信息,按照CS.APP说法,栈帧在存储参数信息之前还有可能保存一些本地变量或临时变量等。在每个过程的栈帧的结尾处都记录着过程返回地址,这个返回地址是由call执行时自动加入的。callee都是通过%ebp +/- 偏移量来获取参数信息的。用下面的图可以小结一下栈帧的模样(起始:%ebp所指的字节–> 终止:返回地址所在字节):

+              +
 |               |
+———-+
| old %ebp | <— %ebp
+———-+
| 本地变量 |
+———-+
|   参数n  |
+———-+
|   参数…|
+———-+
|   参数1  |
+———-+
| 返回地址 |
+———-+
|     …        |
|                |<– %esp

[注1]
这里采用了CSAPP中的表示方法,Eb表示基址寄存器,Ei表示变址寄存器,s为伸缩因子。我们使用R来表示引用某个寄存器的值,使用M来表示引用某内存地址。

打开汇编之门

工作这么长时间,一直在C语言这一层面上钻研和打拼,日积月累,很多关于C的疑惑在书本和资料中都难以找到答案。程序员是追求完美的一个种群,其头脑中哪怕是存在一点点的思维黑洞都会让其坐卧不宁。不久前在itput论坛上偶得《Computer Systems A Programmer's Perspective》(以下称CSAPP)这本经典好书,遂连夜拜读以求解惑。虽说书中没有能正面的回答我的一些疑惑,但是它却为我指明了一条通向“无惑”之路 — 这就是打开汇编之门。

汇编语言是一门非常接近机器语言的语言,其语句与机器指令之间的对应关系更加简单和清晰。打开汇编之门不仅仅能解除高级语言给你带来的疑惑,它更能让你更加的理解现代计算机的运行体系,还有一点更加重要的是它给你带来的是一种自信的感觉,减少了你在高处摇摇欲坠的恐惧,响应了侯捷老师的“勿在浮沙筑高台”的号召。现在学习汇编的目的已与以前大大不同了。正如CS.APP中所说那样“程序员学习汇编的需求随着时间的推移也发生了变化,开始时是要求程序员能直接用汇编编写程序,现在则是要求能够阅读和理解优化编译器产生的代码”。能阅读和理解,这也恰恰是我的需求和目标。

在大学时接触过汇编,主要是Microsoft MASM宏汇编,不过那时的认识高度不够加上态度不端正,错失了一个很好的学习机会。现在绝大部分时间是使用GCC在Unix系列平台上工作,选择汇编语言当然是GNU汇编了,恰好CS.APP中使用的也是GNU的汇编语法。由于学习汇编的主要目的还是“解惑”,所以形式上多是以C代码和汇编代码的比较。

1、汇编让你看到更多
随着你使用的语言的层次的提高,你眼中的计算机将会越来越模糊,你的关注点也越来越远离语言本身而靠近另一端“问题域”,比如通过JAVA,你更多看到的是其虚拟机,而看不到真实的计算机;通过C,你看到的也仅仅是内存一层;到了汇编语言,你就可以深入到寄存器一层自由发挥了。汇编程序员眼里的“独特风景”包括:
a) “程序计数器(%eip)” — 一个特殊寄存器,其中永远存储下一条将要执行的指令的地址;
b) 整数寄存器 — 共8个,分别是%eax、%ebx、%ecx、%edx、%esi、%ebi、%esp和%ebp,它们可以存整数数据,可以存地址,也可以记录程序状态等。早期每个寄存器都有其特殊的用途,现在由于像linux这样的平台多采用“平面寻址[1]”,寄存器的特殊性已经不那么明显了。
c) 条件标志寄存器 — 保存最近执行的算术指令的状态信息,用来实现控制流中的条件变化。
d) 浮点数寄存器 — 顾名思义,用来存放浮点数。
虽说寄存器的特殊性程度已经弱化,但是实际上每个编译器在使用这些寄存器时还是遵循一定的规则的,以后再说。

2、初窥汇编
下面是一个简单的C函数:
void dummy() {
 int a = 1234;
 int b = a;
}
我们使用gcc加-S选项将之转换成汇编代码如下(省略部分内容):
 movl $1234, -4(%ebp)
 movl -4(%ebp), %eax
 movl %eax, -8(%ebp)
看了一眼又一眼,还是看不懂,只是发现些熟悉的内容,因为上面提过如%ebp、%eax等。这只是个引子,让我们感性的认识一下汇编的“容貌”。我们一点点地来看。咋看一眼汇编代码长得似乎很相似,没错,汇编代码就是一条一条的“指令+操作数”的语句的集合。汇编指令是固定的,每条指令都有其固定的用途,而操作数表示则有多种类型。

1) 操作数表示
大部分汇编指令都有一个或多个操作数,包括指令操作中的源和目的。一条标准的指令格式大致是这样的:“指令 + 源操作数 + 目的操作数”,其中源操作数可以是立即数、从寄存器中读出的数或从内存中读出的数;而目的操作数则可以是寄存器或内存。按这么一分类,操作数就大致有三种:
a) 立即数表示法 — 如“movl $1234, -4(%ebp)”中的“$1234”,就是一个立即数作为操作数,按照GNU汇编语法,立即数表示为“$+整数”。立即数常用来表示代码中的一些常数,如上例中的“$1234”。注意一点的是立即数不能作为目的操作数。
b) 寄存器表示法 — 这种比较简单,它就是表示寄存器之内容。如上面的“movl -4(%ebp), %eax”中的%eax就是使用寄存器表示法作源操作数,而“movl %eax, -8(%ebp)”中的%eax则是使用寄存器表示法作目的操作数。
c) 内存引用表示法 — 计算出的该操作数的值表示的是相应的内存地址。汇编指令根据这个内存地址访问相应的内存位置。如上例“movl -4(%ebp), %eax”中的“-4(%ebp)”,其表示的内存地址为(%ebp寄存器中的内容-4)得到的值。

2) 数据传送指令
汇编语言中最最常用的指令 — 数据传送指令,也是我们接触的第一种类别的汇编指令。其指令的格式为:“mov 源操作数, 目的操作数”。
mov系列支持从最小一个字节到最大双字的访问与传送。其中movb用来传送一字节信息,movw用来传送二字节,即一个字的信息,movl用来传送双字信息。这些不详说了。除此以外mov系列还提供两个带位扩展的指令movsbl和movzbl,我们举个例子来说明一下这两个特殊指令的作用何在:

a) movzbl指令
void dummy1() {
 unsigned char c = 'a';
 unsigned int a = c;
}
其对应的GNU汇编为(省略部分内容):
 movb $97, -1(%ebp)   //'a'的ASCII码为97
 movzbl -1(%ebp), %eax
 movl %eax, -8(%ebp)
说明:在dummy1函数中“unsigned int a = c”语句完成的是一个从unsigned char到unsigned int的赋值操作,由于int的类型长度大于char类型长度,所以实际是将一个字节的内容拷贝到一个可以容纳4个字节的地方,这样的话需要对源数据进行一下扩展,即填充高位的3个字节。

如何填充呢?由于变量a和c都为无符号整型,所以只需要填充0即可。而movzbl就是干这个活的。movzbl指令负责拷贝一个字节,并用0填充其目的操作数中的其余各位,这种扩展方式叫“零扩展”。

b) movsbl指令
void dummy2() {
 signed char c = 'a';
 unsigned int a = c;
}

其对应的GNU汇编为(省略部分内容):
 movb $97, -1(%ebp)   //'a'的ASCII码为97
 movsbl -1(%ebp), %eax
 movl %eax, -8(%ebp)
说明:在dummy2函数中“unsigned int a = c”语句完成的是一个从signed char到unsigned int的赋值操作,由于int的类型长度大于char类型长度,所以实际是将一个字节的内容拷贝到一个可以容纳4个字节的地方,这样的话需要对源数据进行一下扩展,即填充高位的3个字节。如何填充呢?GNU汇编告诉我们它使用了变量c的最高位来填充其余的3个字节。movsbl指令负责拷贝一个字节,并用源操作数的最高位填充其目的操作数中的其余各位,这种扩展方式叫“符号扩展”。实际上dummy2中变量a还是保留了变量c的符号位的,起码GCC是这么做的。

c) 在CS.APP中pushl和popl也别归入“数据传送指令”类别,但对于刚入门选手这两个指令还是稍显复杂,在以后谈到“procedure”时再细说。

3、小结
已经迈出了踏入汇编之门的第一步,汇编的确让我眼前敞亮了许多,看得多了,知道得多了,疑惑也就少了。

4、参考资料
1) 《Computer Systems A Programmer's Perspective》

[注1]
平面寻址:简单的将存储器看成一个大的、按照字节寻址的数组。不区分类型、符号、地址还是整数。注意汇编程序员看到也是进程空间的虚拟地址。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats