读懂操作系统(x64)之堆栈帧(过程调用)
前言
上一节内容我们对在32位操作系统下堆栈帧进行了详细的分析,本节我们继续来看看在64位操作系统下对于过程调用在处理机制上是否会有所不同呢?
堆栈帧
我们给出如下示例代码方便对照汇编代码看,和上一节有所不同的是函数调用多了几个参数。
#include <stdio.h>
int main()
{
int a = ,b = , c = , d = , e = ,f = , g = ,h = ;
int func(int a, int b,int c,int d,int e,int f ,int g,int h);
func(a,b,c,d,e,f,g,h);
} int func(int a, int b,int c,int d,int e,int f ,int g,int h)
{
int i = ;
return a + b + c + d + e + f + g + h + i;
}
接下来我们将上述代码转换为intel语法汇编代码,如下:
gcc -S -masm=intel -m64 .c
x86仅提供8个通用寄存器(eax,ebx,ecx,edx,ebp,esp,esi,edi),而x64将它们扩展到64位(前缀为“r”而不是“e”),并添加了另外8个(r8,r9,r10,r11,r12,r13,r14,r15)。由于x86的某些寄存器具有特殊的隐含含义,并且并未真正用作通用寄存器(最著名的是ebp和esp),因此有效的增加甚至更大。根据《深入理解计算机系统》这本书介绍,函数的前6个整数或指针参数在寄存器中传递,第一个放在rdi中,第二个放在rsi中,第三个放在rdx中,然后是rcx,r8和r9寄存器中,仅第7个参数及后续参数在堆栈上传递(如下图所示)

关于以上代码就不一一进行图解了,这里我用一张图解进行最终解释,如下:

由如上可知,前6个参数通过寄存器传递,而最后最后2个参数也就是g和h通过堆栈传递,但是除此和x86区别之外,还有个酒红色的区域,该空间不得由信号或中断处理程序修改,因此,函数可以使用此区域存储跨函数调用不需要的临时数据。尤其是,子函数可以在整个堆栈框架中使用此区域,而不是在序言和结语中调整堆栈指针,该区域称为红色区域(简而言之,保留该区域是一种优化)。比如在上述函数中调用子函数并将对应参数传递到子函数中去,此时会将子函数中的局部变量存储在该保留区域,这样一来就无需通过rsp减去堆栈地址为局部变量分配空间,从而达到优化目的。以上对于x86-64的堆栈帧调用约定遵循AMD64 ABI(Application Binary Interface:应用程序二进制接口),但是针对Windows x64位(ABI)定义了x86-64软件调用约定,称为fastcall。接下来我们结合基于Windows x64汇编代码讲讲和上述区别在哪里?我们知道首先为主函数分配一个堆栈帧,然后将对应参数压入栈,如上述a~h参数,对应汇编代码如下:
push rbp
mov rbp, rsp sub rsp, call __main //将立即数1写入【rbp-4】
mov DWORD PTR -[rbp], //将立即数2写入【rbp-8】
mov DWORD PTR -[rbp], //将立即数3写入【rbp-12】
mov DWORD PTR -[rbp], //将立即数4写入【rbp-16】
mov DWORD PTR -[rbp], //将立即数5写入【rbp-20】
mov DWORD PTR -[rbp], //将立即数6写入【rbp-24】
mov DWORD PTR -[rbp], //将立即数7写入【rbp-28】
mov DWORD PTR -[rbp], //将立即数8写入【rbp-32】
mov DWORD PTR -[rbp],
我们知道接下来会调用函数,并将a~h参数进行传入,所以此时会将上述8个参数通过寄存器传递多对应堆栈上,这是x86操作系统上的做法,在windows x64也会是如此吗?如下:
//将【rbp-16】值(即4)写入寄存器r9d
mov r9d, DWORD PTR -[rbp] //将【rbp-12】值(即3)写入寄存器r8d
mov r8d, DWORD PTR -[rbp] //将【rbp-8】值(即2)写入寄存器edx
mov edx, DWORD PTR -[rbp] //将【rbp-4】值(即1)写入寄存器eax
mov eax, DWORD PTR -[rbp]
在windows x64上会将前4个参数存入对应寄存器(我们发现上述却是32位寄存器,这里可能和gcc编译器优化有关,windows x64会将edi和esi进行保留,所以最终参数顺序对应上述表edx、ecx、r8d、r9d,但是我们会发现表中根本就没有eax寄存器,请继续往下看),而剩余的参数则放到堆栈上,如下:
//将【rbp-32】值写入寄存器ecx
mov ecx, DWORD PTR -[rbp]
//将寄存器ecx中的值(即8)写入【rsp+56】
mov DWORD PTR [rsp], ecx //将【rbp-28】值写入寄存器ecx
mov ecx, DWORD PTR -[rbp]
//将寄存器ecx中的值(即7)写入【rsp+48】
mov DWORD PTR [rsp], ecx //将【rbp-24】值写入寄存器ecx
mov ecx, DWORD PTR -[rbp]
//将寄存器ecx中的值(即6)写入【rsp+40】
mov DWORD PTR [rsp], ecx //将【rbp-20】值写入寄存器ecx
mov ecx, DWORD PTR -[rbp]
//将寄存器ecx中的值(即5)写入【rsp+32】
mov DWORD PTR [rsp], ecx
此时理应进入函数调用,因为上述将立即数1存入的是eax寄存器,所以这里会将eax寄存器的数据传送到ecx(我有点疑惑,对照上述表的话,在windows x64会将esi和edi寄存器保留,第一个参数对应的寄存器应是edx,但是这里却是ecx寄存器,不明白edx和ecx寄存器存储参数的顺序为何颠倒了,若有明白的童鞋,还望指点一二),如下:
//将寄存器eax的数据【rbp-4】送入寄存器ecx
mov ecx, eax
接下来开始调用函数,首先将返回地址压入栈,通过call指令如下:
call func
进入函数堆栈帧,首先设置当前函数堆栈帧,接下来则是分配局部变量空间,然后将局部变量入栈,并获取寄存器和堆栈上存储的数据进行计算,整个逻辑如下:
push rbp
mov rbp, rsp sub rsp, //将寄存器ecx中的值(即1)写入【rbp+16】
mov DWORD PTR [rbp], ecx //将寄存器edx中的值(即2)写入【rbp+24】
mov DWORD PTR [rbp], edx //将寄存器edx中的值(即3)写入【rbp+32】
mov DWORD PTR [rbp], r8d //将寄存器edx中的值(即4)写入【rbp+40】
mov DWORD PTR [rbp], r9d //将立即数写入【rbp-4】
mov DWORD PTR -[rbp], //将【rbp+16】值(即)写入寄存器edx
mov edx, DWORD PTR [rbp] //将【rbp+24】值(即2)写入寄存器edx
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为3
add edx, eax //将【rbp+32】值(即3)写入寄存器eax
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为6
add edx, eax //将【rbp+40】值(即4)写入寄存器edx
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为10
add edx, eax //将【rbp+48】值(即5)写入寄存器edx
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为15
add edx, eax //将【rbp+56】值(即6)写入寄存器edx
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为21
add edx, eax //将【rbp+64】值(即7)写入寄存器edx
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为28
add edx, eax //将【rbp+72】值(即8)写入寄存器edx
mov eax, DWORD PTR [rbp] //edx寄存器存储结果为36
add edx, eax mov eax, DWORD PTR -[rbp] //eax寄存器存储结果为66
add eax, edx
计算完毕后,则是释放局部变量内存空间,并返回(注:释放局部变量内存空间和x86有所不同),如下:
//清理堆栈帧,释放局部变量空间
add rsp, //弹出当前堆栈帧
pop rbp //弹出返回地址
ret
到这里关于函数堆栈帧已经执行完毕,这里稍微注意下,我们在主函数中调用函数时并未将结果返回,所以在汇编代码中会将已存储结果的寄存器数据置为0,然后同样也是释放主函数局部变量内存空间,如下:
//将eax寄存器中已存储的数据置为0
mov eax, add rsp,
pop rbp ret
这里呢,我再一次将整个汇编代码逻辑通过图方式来进行详细解释,如下:

如上为调用函数之前主函数堆栈帧,此时前4个参数在对应寄存器上,而剩余4个参数则是在堆栈上,接下来进入调用函数堆栈帧,如下:

堆栈帧解惑
大多数数据结构将按照其自然对齐方式对齐,这意味着,如果数据结构需要与特定边界对齐,则编译器将根据需要插入填充(加速cpu访问,以空间换时间),针对x64调用约定虽然windows x64有所区别,但是都必须满足相同的堆栈对齐策略,也就是说栈必须与16字节边界完全对齐,如果内存地址可以被16整除,或者最后一位为0(用十六进制表示),换言之通过rsp分配的堆栈必须是16的倍数,比如上述主函数的96个字节,函数调用的16个字节(经查资料,gcc上的32位也是16个字节边界对齐),仔细观察上述图发现,当我们调用函数时(即call指令),此时会将8个字节的返回地址压入栈,这其实是windows x64中的做法,因此,在分配堆栈空间时,所有函数调用必须将堆栈调整为16n + 8形式,所以针对堆栈帧的偏移都为8。
在释放堆栈帧上内存空间时,我们发现是直接通过堆栈针rsp加上在分配时减去的字节数(比如主函数的add rsp,96),在x64处理器模式下,如上述极少情况下会通过rsp来调整参数而是通过rbp来进行偏移,同时x64会分配足够大的堆栈空间来调用最大目标函数(按参数方式使用),而x86模式下,esp的值会随着添加和从堆栈中清除参数而发生变化。
总结
x64处理器模式下需要满足16个字节边界对齐策略,它和x86处理器模式主要有两大区别,一个是x64处理器模式下的参数可通过寄存器来传递参数(这是一大优化,将参数压入堆栈必将导致内存访问),而x86处理器模式下的参数都是存储在堆栈上,另外一个是x64直接使用堆栈针来释放内存空间(即rsp),而x86使用堆栈帧释放空间(即ebp)。AMD x64 ABI和Windows x64 ABI也有几点区别,比如参数传递方式,AMD x64是前6个参数通过寄存器传递,而剩余参数放在堆栈上,而Windows x64则是前4个参数通过寄存器传递,而剩余参数放在堆栈上,AMD x64留有红色的暂存区域,而Windows x64认为该区域是不安全的,所以不存在,同时Windows x64在调用函数时会将8个字节的返回地址压入栈,所以对于参数的访问则需再移动8个字节以满足16个字节边界对齐调用约定,理论上不管是x86还是x64都应该有调用方清理堆栈应而不是被调用方,但是Windows x64模式则是被调用方清理堆栈,还有其他比如对浮点数的存储和处理等等。x64体系结构起源于AMD,被称为AMD64,后来由Intel实施,被称之为IA-32e,然后是EM64T,最后是Intel64。它也被称为x86-64,这两个版本之间有些不兼容,但是大多数代码在两个版本上都可以正常工作,我们更多的称之为x64或x86-64。
读懂操作系统(x64)之堆栈帧(过程调用)的更多相关文章
- 读懂操作系统(x86)之堆栈帧(过程调用)
		前言 为进行基础回炉,接下来一段时间我将持续更新汇编和操作系统相关知识,希望通过屏蔽底层细节能让大家明白每节所阐述内容.当我们写下如下C代码时背后究竟发生了什么呢? #include <stdi ... 
- 读懂操作系统之缓存原理(cache)(三)
		前言 本节内容计划是讲解TLB与高速缓存的关系,但是在涉及高速缓的前提是我们必须要了解操作系统缓存原理,所以提前先详细了解下缓存原理,我们依然是采取循序渐进的方式来解答缓存原理,若有叙述不当之处,还请 ... 
- 读懂操作系统之快表(TLB)原理(七)
		前言 前不久.我们详细分析了TLB基本原理,本节我们通过一个简单的示例再次叙述TLB的算法和原理,希望借此示例能加深我们对TLB(又称之为快表,深入理解计算机系统(第三版)又称之为翻译后备缓冲区)的理 ... 
- 读懂操作系统之虚拟内存TLB与缓存(cache)关系篇(四)
		前言 前面我们讲到通过TLB缓存页表加快地址翻译,通过上一节缓存原理的讲解为本节做铺垫引入TLB和缓存的关系,同时我们来完整梳理下从CPU产生虚拟地址最终映射为物理地址获取数据的整个过程是怎样的,若有 ... 
- 一次CMS GC问题排查过程(理解原理+读懂GC日志)
		这个是之前处理过的一个线上问题,处理过程断断续续,经历了两周多的时间,中间各种尝试,总结如下.这篇文章分三部分: 1.问题的场景和处理过程:2.GC的一些理论东西:3.看懂GC的日志 先说一下问题吧 ... 
- [转]一次CMS GC问题排查过程(理解原理+读懂GC日志)
		这个是之前处理过的一个线上问题,处理过程断断续续,经历了两周多的时间,中间各种尝试,总结如下.这篇文章分三部分: 1.问题的场景和处理过程:2.GC的一些理论东西:3.看懂GC的日志 先说一下问题吧 ... 
- 一篇文章教你读懂Makefile
		makefile很重要 什么是makefile?或许很多Winodws的程序员都不知道这个东西,因为那些Windows的IDE都为你做了这个工作,但我觉得要作一个好的和professiona ... 
- 一文读懂HTTP/2及HTTP/3特性
		摘要: 学习 HTTP/2 与 HTTP/3. 前言 HTTP/2 相比于 HTTP/1,可以说是大幅度提高了网页的性能,只需要升级到该协议就可以减少很多之前需要做的性能优化工作,当然兼容问题以及如何 ... 
- 读懂IL代码就这么简单(二)
		一 前言 IL系列 第一篇写完后 得到高人指点,及时更正了文章中的错误,也使得我写这篇文章时更加谨慎,自己在了解相关知识点时,也更为细致.个人觉得既然做为文章写出来,就一定要保证比较高的质量,和正确率 ... 
随机推荐
- 易学又实用的新特性:for...of
			今天带来的知识点既简单又使用,是不是感觉非常的棒啊,OK,不多说了,咱们开始往下看. for...of 是什么 for...of 一种用于遍历数据结构的方法.它可遍历的对象包括数组,对象,字符串,se ... 
- 【mybatis annotation】数据层框架应用--Mybatis(二) 基于注解实现数据的CRUD
			使用MyBatis框架进行持久层开发 MyBatis是支持普通SQL查询,存储过程和高级映射的优秀持久层框架. MyBatis消除了几乎所有的JDBC代码和参数的手工设置以及对结果集的检索. MyBa ... 
- php +go关键字实现协程
			来源: https://studygolang.com/articles/17631?fr=sidebar 今天在知乎浏览时忽然发现了一个有趣的东西,php竟然可以实现协程的实现,而且还是通过go关键 ... 
- 20199310《Linux内核原理与分析》第十二周作业
			1.问题描述 2014年9月24日,Bash中发现了一个严重漏洞shellshock,该漏洞可用于许多系统,并且既可以远程也可以在本地触发.在本实验中,通过学习重现攻击该漏洞,加深对于ShellSho ... 
- SQLI-LABS学习笔记(四)
			第十六关 和之前的关卡一样,修改闭合,无意义的关卡 ")闭合即可 第十七关 这题从源码上看发现 这里进行了两次查询 先查询了用户名是否存在 再查询密码是否匹配 ... 
- 终止过久没有返回的 Windows API 函数 ---- “CancelSynchronousIo”
			Marks pending synchronous I/O operations that are issued by the specified thread as canceled. BOOL W ... 
- 怎么break java8 stream的foreach
			目录 简介 使用Spliterator 自定义forEach方法 总结 怎么break java8 stream的foreach 简介 我们通常需要在java stream中遍历处理里面的数据,其中f ... 
- 李宏毅机器学习--PM2.5预测
			一.说明 给定训练集train.csv,要求根据前9个小时的空气监测情况预测第10个小时的PM2.5含量. 训练集介绍: (1).CSV文件,包含台湾丰原地区240天的气象观测资料(取每个月前20天的 ... 
- 搭建vsftpd文件服务器并创建虚拟用户
			一.安装 1. 查看是否安装vsftpd rpm -qa | grep vsftpd 2. 安装 yum -y install vsftpd ... 
- Android FrameWork学习(二)Android系统源码调试
			通过上一篇 Android FrameWork学习(一)Android 7.0系统源码下载\编译 我们了解了如何进行系统源码的下载和编译工作. 为了更进一步地学习跟研究 Android 系统源码,今天 ... 
