漏洞狩猎与利用 计算机体系结构 1001 x86-64 汇编课程

Architecture 1001: x86-64 Assembly

课程 https://p.ost2.fyi/courses/course-v1:OpenSecurityTraining2+Arch1001_x86-64_Asm+2021_v1/about
要求:必须熟悉 C 语言编程
推荐C 语言编程课程:https://www.learn-c.org/ 与 https://www.edx.org/certificates/professional-certificate/dartmouth-imtx-c-programming-with-linux
课程路线图:https://ost2.fyi/OST2_LP_Vulns_Exploits.pdf
英语阅读速成:http://www.yinwang.org/blog-cn/2018/11/23/grammar
英语阅读速成对应视频:https://www.bilibili.com/video/BV1JS7D6XEKY/?spm_id_from=333.1387.homepage.video_card.click&vd_source=b1acc63fa6d7d73e53111f9e1153f990
什么免费的GPT配合使用(kimi,gemini,豆包,文心一言等),其他GPT:https://github.com/topics/free-gpt?o=asc&s=forks
kimi 月之暗面:https://www.kimi.com/

1.环境准备

创建 Broadcom 帐户并登录,下载vmware虚拟机
https://www.vmware.com/products/desktop-hypervisor/workstation-and-fusion

选择 Windows 10专业版,而非其他版本
https://drive.google.com/file/d/1oSyhape3oPsBj07OYxAPtsT7KvDw8WCn/view

建议您仅使用 Windows 10 系统上课。如果您使用 Windows 11,则需要自行调试遇到的任何问题,因为目前该配置不受支持。

Windows VM on Windows Host OS 环境准备教学:https://youtu.be/ZPE9Yva_tys?si=S04VvgmSC4BR9fkN

解压缩文件,将其移动到桌面,然后打开 Visual Studio 解决方案文件 (Arch1001_x86-64_asm.sln)

下载课程幻灯片:https://gitlab.com/opensecuritytraining/arch1001_x86-64_asm_slides_and_subtitles

2.介绍

2.1 为什么要学习汇编语言?

课程视频:https://youtu.be/jAxtQ2k4Naw?si=1FPNL7UwzjyRjimR

2.2 关于这门课程

课程视频:https://youtu.be/EALp2iziAIQ?si=uhmd7PmQTtIsLO_n

可选教程:"Introduction to 64 Bit Assembly Programming for Linux and OS X: Third Edition" by Ray Seyfarth

https://www.amazon.com/gp/product/1484921909/ref=as_li_tl?ie=UTF8&camp=1789&creative=390957&creativeASIN=1484921909&linkCode=as2&tag=opensecuinfo-20&linkId=EPDXM3AQYTVSJEET

z-library 搜索(ISBN-13):978-1484921906
https://github.com/z-libraryopp/z-libraryopp.github.io

《统计结构:恶意软件指纹识别与分类分析》:据统计,仅 14 条汇编指令就占了代码的 90%
http://www.blackhat.com/presentations/bh-usa-06/BH-US-06-Bilar.pdf

x86-64 指令频率 pi 图:
https://web.archive.org/web/20221006192057/https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.407.5071&rep=rep1&type=pdf

英特尔最初在迈向 64 位时,曾希望彻底抛弃 x86 架构。这就是 IA64(英特尔 64 位架构),也被称为 Itanium(安腾)。然而,AMD 决定自己将 x86 扩展到 64 位,从而诞生了 AMD64 架构。随着 Itanium 的市场普及极其缓慢,英特尔最终决定咬紧牙关,向 AMD 购买了 64 位扩展技术的授权。
在英特尔的手册中,你会看到 64 位扩展技术被称为 IA32e、EM64T 或 Intel 64(但绝不会叫 IA64——再次强调,IA64 指的是 Itanium,那是一套完全不同的架构)。
在英特尔的手册中,这项技术拥有许多不同的称呼,比如 IA32e、EM64T 以及 Intel 64。

你有时可能会看到微软或某些 Linux 发行版将它称为 amd64 或 x64。
而在本门课程中,我们将统一采用 x86-64 这个称呼。

那么,x86-64 究竟应用在哪些领域呢?你大概已经知道,它主要用于需要高计算能力的场景,例如个人电脑(PC)、服务器,甚至超级计算机。极大概率,你现在用来观看本课程的这台电脑就是一台 x86-64 机器。不过,基于 ARM 架构的系统——比如微软的 Surface,或是苹果搭载 M1 芯片的 Mac(我曾参与过该芯片 SecureBoot 安全启动系统的设计)——正在 PC 领域异军突起,给英特尔带来巨大的竞争压力。

英特尔确实有一些更专注于功耗受限设备(如嵌入式系统)的芯片,但这些只占英特尔芯片出货量极小的一部分。

同一套C语言源代码在所有反编译软件中的总体脉络是差不多了,至于部分汇编代码目前为止还不清楚它们是干嘛的,属于实际情况。

我认为,只要掌握大约 20 到 30 条核心指令(不算变体),就足够让你在大部分时候都极少需要去查阅参考手册了

2.3 可选复习:二进制到十六进制到十进制

十进制:00
二进制:0000b
十六进制(Hex):0x00
其他用工具转换即可

2.4 可选复习:二进制补码负数

C 语言:“有符号”类型可以表示正值或负值,“无符号”类型只能表示正值
例如:unsigned char与signed char(用谷歌的gemini解释一下即可)
负值用其正值的“二进制补码”表示。二进制补码的计算方法是将所有位翻转,然后加 1

2.5 复习:C 数据类型大小

取自 2020 年 11 月 Intel 手册 ,Vol 1 Sec 4.1 - page 4-1(PDF每页下方有该标志):显示了基本 C 数据类型的大小
https://ost2images.s3.amazonaws.com/PDFs/325462-sdm-vol-1-2abcd-3abcd.pdf
图片

“word”(字)这个术语最初指的是当 x86 还是 16 位架构时,英特尔原生的 16 位数据大小。因此,当它扩展到 32 位时,该大小就被称为“double word”(双字)。这种“双字”的称谓随后在 Windows 编程中被采纳为“DWORD”数据类型名称。同样地,对于 64 位,“QWORD”(四字)也经常被用于 Windows 系统中。

2.6 背景知识:字节序

https://youtu.be/tPJgoVxqlnU?si=_ymoQUdaJ6zic5XH

视频对应的幻灯片:https://gitlab.com/opensecuritytraining/arch100x_slides_and_subtitles

书写或显示值:0x1234567887654321
书写或显示值在内存中存放为,内存小端序:现在内存地址最小的在左上角,值为0x21,读取书写或显示出来就是最右侧;而最大的内存地址的值0x12,读取书写或显示出来就是最左侧
书写或显示值在寄存器中存放为,大端序:0x1234567887654321

注意Intel 手册 Vol. 1 4-1:https://ost2images.s3.amazonaws.com/PDFs/325462-sdm-vol-1-2abcd-3abcd.pdf
字节序适用于内存,不适用于寄存器;字节序适用于字节,不是位
图片

图片

如果改变字节的显示大小,这个也很好理解
书写或显示值:0x1234567887654321(小端序放入内存4个字节为一个整体即0x87654321)
如果你改成4个字节为整体来显示,它首先要把值放到内存里面然后再显示出来,0x87654321为一个整体放到最小内存地址的地方,0x12345678继续放
而寄存器为大端序(与书写或显示值保持一致):即0x1234567887654321
图片

改成4个字节为整体来显示,源代码中的变量的值即为书写或显示值
图片

基于以上理解,对于所有的反汇编软件而已,只需要关注书写或显示值是什么,几个字节为整体来显示,即可区分它们不同的大小端风格。

比如GDB,你要求它已2,4,8字节为整体,它就表现出了大端序的风格:你把0x55,0x48从内存取出来,放到书写或显示值中为0x4855(因为从内存读取到显示值为小端序),然后再对比2,4,8字节为整体的显示风格(所谓的大小端风格就出来了)
图片

3.计算机寄存器

3.1 内存层次结构

教学视频:https://youtu.be/Zph6GkevMzo?si=_IHWYgY5urjjTZ7h

幻灯片:https://gitlab.com/opensecuritytraining/arch100x_slides_and_subtitles

3.2 x86-64 通用寄存器

教学视频:https://youtu.be/T9FUYHrnLUM?si=G3Ripj1rufABhRdH

x86-64 寄存器速查表:https://ost2images.s3.amazonaws.com/Arch101_x86-64_Asm/CheatSheet_x86-64_Registers.pdf

3.3 英特尔推荐的寄存器约定

教学视频:https://youtu.be/_HOfsOFhFxw?si=LGN6CyjfPafTG-XR

Intel Arch v1 Section 3.4.1 - General-Purpose Registers, page 3-11

微软编译器寄存器约定: https://docs.microsoft.com/en-us/cpp/build/x64-software-conventions?view=msvc-160

这些是英特尔给编译器开发者(以及汇编手写代码者)的建议。寄存器并不强制要求必须这样使用,但如果你看到它们被这样用了,就会明白其中的缘由。不过我对部分描述做了简化
RAX - 存储函数的返回值
RBX - 指向数据段的基址指针
RCX - 用于字符串和循环操作的计数器
RDX - I/O(输入/输出)指针
RSI - 字符串操作的源索引指针(Source Index)
RDI - 字符串操作的目的索引指针(Destination Index)
RSP - 栈顶指针(Stack Pointer)
RBP - 栈帧基址指针(Base Pointer)
RIP - 指向下一条待执行指令的指针(即“指令指针”,Instruction Pointer)

4.你的第一条指令:空操作 (NOP)

教学视频:https://youtu.be/pURECiExMCM?si=yP3Bl6c0E2TRgEuT

NOP — 无操作(No-Operation)指令!

不涉及任何寄存器,不改变任何数值,什么都不干!
它的存在纯粹是为了填充/对齐字节,或者是用来延时。
攻击者常用它来提升简单漏洞利用(Exploit)的稳定性。不过,那就是另一门课的内容啦 😉

“引用这段有趣的冷知识,绝对会让懂 x86 的人对你刮目相看:
‘单字节的 NOP 指令,本质上就是 XCHG (E)AX, (E)AX 指令的别名助记符。’
看起来我之前显然也没去翻过 NOP 的官方手册 😃
XCHG 指令虽然并不在咱们这门课的官方教学大纲里,但如果我刚才没直接告诉你自己看,我敢打赌你肯定也能猜出它是干嘛的。”

0x90

5 堆栈

5.1 概览

教学视频:https://youtu.be/7ofOVc5Mf1M?si=u0B2RobEzH7R9fRn

栈(Stack)是一种“后进先出”(LIFO)的数据结构,数据从栈顶被“压入”(Push),也从栈顶被“弹出”(Pop)。

栈是主内存(RAM)中的一块逻辑区域,在程序启动时由操作系统负责指定。
不同的操作系统出于各自的规范习惯,会将其划定在不同的内存地址;而如果开启了地址空间布局随机化(ASLR),其地址则会随机分配。

按照惯例,栈是向低内存地址方向增长的。因此,向栈中添加数据(入栈)意味着此时的栈顶将位于更低的内存地址处。
图片

RSP(栈指针寄存器)始终指向栈顶——即目前正在使用的最低内存地址。
虽然栈顶地址之外(更低地址处)可能依然保留着数据,但这些数据都被视为未定义的(无效/不可靠的)。

在栈(Stack)上你能找到些什么?

  • “返回地址”(Return Addresses):以便被调用的函数(Callee)能够精准返回到调用它的函数(Caller)中继续执行

  • 局部变量

  • 函数参数:有时用于在函数之间传递参数

  • 寄存器保存区:供函数间共享寄存器使用,避免相互破坏/覆写彼此的值

  • 寄存器溢出保存区:当编译器在一个函数里需要同时协调(Juggle)处理太多变量/寄存器时,用栈来提供额外的暂存空间

  • 动态分配的内存:通过 alloca() 函数分配的空间

5.2 Push 与 Pop 指令(或 压栈与弹出指令)

教学视频:https://youtu.be/0PqMJ1e-iQ4?si=LCW9-Wl4zetTN_UX

在 macOS 上编译并反汇编的 Hello World 示例能看到 PUSH 和 POP,在 Linux 上编译并反汇编相同的代码,我们也能看到 PUSH 和 POP。

虽然还不清楚为什么 GCC 或 Clang 编译的 Hello World 包含 PUSH 和 POP,而 Visual Studio 却没有。

  • PUSH 指令

第一条指令是 PUSH,它将一个四字(Quad Word,即 8 个字节)的数据压入栈中。

执行 PUSH 时,它会自动将栈指针 RSP 的值减去 8。因为你刚刚向栈中压入了 8 字节的数据,而栈是向低地址方向增长的,所以 RSP 的地址数值需要向下减去 8

在 64 位执行模式下,PUSH 有几种不同的形式:
1.直接压入 64 位寄存器中的值;
2.压入内存中的 64 位数值(这里的内存寻址采用了特殊的 r/mX 形式,稍后会细讲)。

注:实际上还有一种可以压入 32 位立即数(即常数)的形式,但那涉及一些复杂的例外情况。为了简化概念,我们先直接忽略这种形式,就当只有上面提到的两种。等大家以后学会翻阅手册时,再回过头来看那些细节。

  • r/mX 寻址形式

r/mX 这个词是我为了方便讲解自己造的,用来指代 Intel 手册中的 r/m8、r/m16、r/m32 或 r/m64。

在 Intel 官方手册里(它是决定什么是可行、什么是不可选的权威来源),PUSH 包含 r/m16、r/m32 和 r/m64 等形式(其中某些形式如 32 位在 64 位模式下是无效的)。这门课后面会教大家怎么查阅手册,现在只要记住:当我讨论汇编指令时,如果说它支持 r/mX 形式,就意味着它支持某种特定格式。

  • 什么是 r/mX 形式?

它是一种用来指定寄存器或内存值(长度可以是 8、16、32 或 64 位)的语法规则。

在 Intel 汇编语法中,方括号 [] 代表解引用(Dereference):即将方括号内部的值视为内存地址,并从该地址中提取数据。
简单形式:
可以是一个纯寄存器,比如 RBX。
也可以是带方括号的形式,比如 [RBX]——代表取 RBX 里的值,将其视为内存地址,通过方括号解引用,提取该地址处的数据。此时我们使用的不是 RBX 本身的值,而是 RBX 所指向的内存中的值。
复杂形式:
[基址寄存器 + 索引寄存器 * 比例因子]:比例因子只能是 1、2、4 或 8。你可以把它理解为基址(Base)、索引(Index)和缩放比例(Scale)。
完整形式:[基址 + 索引 * 比例因子 + 偏移量](即 [Base + Index * Scale + Displacement])。
这里的偏移量(Displacement)在机器码中有两种编码方式:单字节编码(范围 0~255)或 4 字节编码(范围 0~40亿)。

这种 基址 + 索引 * 比例因子 + 偏移量 的寻址方式非常复杂,但它能够一次性精准定位 4GB 巨大内存空间中的任意位置。你可以想象,这种设计天然适用于多维数组或结构体数组等数据结构。

课程约定:后续课程中,只要我提到某条指令“支持内存访问”,意思就是其内存寻址被编码为了 r/mX 形式——它既可以简单到就是一个寄存器 r,也可以复杂到包含基址、索引、比例因子和偏移量。

示例:
push rax(直接压入寄存器)
push [rax](读取 rax 指向的内存数据并压栈)

  • 讲义中的数值表示法
在我的这门课里,当我书写 64 位十六进制数时,会使用反引号 ` 进行分隔(例如:0x12345678`12345678)。

这种写法借鉴自 WinDbg(虽然咱们课程不用这个调试器)。因为 64 位十六进制数位数太多,加个反引号能极大地帮助我们目测和核对位数是否正确。

  • PUSH 执行过程详解

假设我们执行 push rax:
执行前:假设 RAX 寄存器的值为 3,栈顶指针 RSP 指向 14FE08。
执行后:
RSP 的值自动减 8,从 14FE08 变为 14FE00;
RSP 依然指向最新的栈顶,而该位置上存放的就是刚刚压入的数值 3。

  • POP 指令

与 PUSH 相对的就是 POP 指令,它从栈顶弹出一个数值。
在 64 位模式下,它可以将数据弹出到 64 位寄存器 或 r/mX 格式指定的内存地址 中。
假设我们执行 pop rax:
执行前:RAX 为旧值,RSP 为 14FE00(栈顶存着 3)。
执行后:
栈顶的数值 3 被弹出并写入 RAX 寄存器,覆盖其原有的值;
作为副作用,RSP 的值自动加 8,从 14FE00 恢复为 14FE08;
原先存放 3 的那个内存位置,现在被视为未定义(Undefined)。

注意:虽然逻辑上该区域变为了未定义,但实际上物理内存里的 3 依然保存在那里。编译器和硬件不会专门去清零这块内存,只是合规的编译器绝不应该去访问超过栈顶以外的内存。

  • 补充:32 位与 16 位模式

虽然本课程聚焦于 64 位汇编,但了解一下 32 位模式也很重要:
32 位模式:PUSH 和 POP 每次操作 4 个字节(32 位),因此 ESP 每次加减 4。
16 位模式:Intel 为了保持向下兼容,至今开机仍以 16 位模式启动。在 16 位模式下,每次操作 2 个字节。

  • 总结

太棒了!现在我们又掌握了两条全新的汇编指令:
加上之前的 NOP,我们已经学了 NOP、PUSH 和 POP 三条指令,进度已经完成超过 20% 了,离精通汇编又近了一步!

6 调用函数

6.1 新学指令:call, ret, mov, add, sub

教学视频:https://youtu.be/DQnAeoDQtZg?si=_7qMDOHQwOpmjC8h

  • 从简单 C 代码到汇编指令

首先看一段极其简单的 C 语言程序:main 函数调用了一个子函数,该子函数仅返回一个硬编码(Hard-coded)常数;而 main 函数拿到返回值后未作任何额外处理,直接返回另一个硬编码常数。 面对这段代码,编译器生成的汇编指令中包含了 5 个全新的 x86-64 汇编指令。下面我们将逐一拆解其底层工作原理。
图片

  • 控制流转移指令:call 与 ret

1.call 指令(函数调用)call 指令的核心职责是转移程序控制流至目标函数,同时确保在目标函数执行完毕后,控制流能够准确返回到原先中断的位置继续执行。

入栈返回地址:call 指令会自动将“下一条指令的内存地址”压入栈中(Push)。

影响栈指针 RSP:由于执行了隐式的入栈操作,栈指针寄存器 rsp 的值会自动减小 8 字节(即 rsp = rsp - 8)。压入栈中的这个地址被称为返回地址(Return Address),供后续 ret 指令使用。

修改指令指针 RIP:完成返回地址入栈后,call 会修改 64 位指令指针寄存器 rip,将其设置为指令中给出的目标函数入口地址,从而跳转至目标函数。

目标地址编码方式:目标地址可以以绝对地址或相对地址(如相对于 call 指令结尾的偏移量,或相对于某个寄存器)的形式指定。在编写汇编或进行逆向工程分析时,理解这些编码机制对于手动硬编码或重定向控制流至关重要。

2.ret 指令(函数返回)与 call 形成完美对应,ret 指令用于恢复控制流。它主要有两种形式:
标准形式(无参数):弹出当前栈顶的值(Pop),将其写入指令指针寄存器 rip 中。同时,由于执行了弹栈操作,栈指针 rsp 会自动增加 8 字节(即 rsp = rsp + 8)。简单来说就是:“取出栈顶地址赋给 rip,并恢复栈空间”。
ret

带立即数参数形式:在完成标准弹栈操作(将返回地址写入 rip)之后,还会额外向 rsp 加上指定的字节数(例如额外加上 8 字节或 20 字节)。这种形式常用于由被调用方清理栈参数的调用约定(如部分 Windows API 所采用的约定)。
ret 0x8 或 ret 0x20 等

  • 汇编语法体系对比:Intel 格式 vs AT&T 格式

图片

Intel 语法类似于 C 语言赋值语句(如 a = b,右值赋给左值);而 AT&T 语法更贴近日常算术逻辑(如 1 + 2 = 3,从左向右推导)。本课程将优先采用 Intel 语法 进行讲解,但作为逆向人员,熟练掌握并无缝阅读两种语法是必不可少的技能。

  • 数据传送与基本算术指令

1.mov 指令(数据传送)
mov 指令用于在寄存器、内存以及立即数之间传送数据。其支持的形式包括:
寄存器 --> 寄存器
内存 --> 寄存器
寄存器 --> 内存
立即数 --> 寄存器
立即数 --> 内存

重要限制:mov 指令不支持内存到内存的直接传输。另外,Intel 架构不支持将 64 位立即数直接移动到内存地址中,但支持将 64 位立即数装载到寄存器中。

图片

2.add 与 sub 指令(加法与减法)
add 与 sub 指令用于执行基础的算术加减运算:
操作数规则:目的操作数可以是寄存器或内存寻址表达式(RMX),源操作数可以是寄存器、内存或立即数。但源与目的不能同时为内存地址(即同样不支持内存到内存的算术操作)。 Intel 格式示例:
add rsp, 8:计算 rsp + 8 并将结果存回 rsp(更新栈指针)。
sub rax, [rbx * 2]:计算 rbx * 2 对应的内存地址,读取该地址处的值,将其从 rax 中减去,并将最终差值更新回 rax 寄存器中。

6.2 栈帧单步调试的幻灯片演示

教学视频:https://youtu.be/9ub5Uu3fB1w?si=QuG6125k1oh34c9E

现在,为了完成这第一个示例,我想通过“幻灯片式虚拟调试(Slideware Virtual Debugging)”的方式,带大家单步执行(Step through)这段汇编代码,观察在这个过程中所有内容的变化。

我们将逐行分析这段代码,观察栈(Stack)和寄存器(Registers)分别发生了哪些改变。

在接下来的几张幻灯片中,我使用了一套统一的标注标识:
蓝色(蓝色文本 / 带有蓝色 X): 表示刚刚执行完毕的汇编指令(目前我们还没有执行任何指令)。
红色: 表示因执行某条汇编指令而发生改变(被修改)的值。
绿色: 表示在所有指令执行前,系统初始的起始值。

  • 初始状态

我们假设当前指令指针 RIP 指向 0x140001010(即 main 函数的起始入口地址),此时尚未执行任何汇编指令。
我们先看一下这些寄存器的初始值(这些数值取自调试器,以便与实际调试环境完全对应):
RAX 的初始值为 1。
RSP(栈指针寄存器)被设置为 0x14FE08,指向栈顶的某个位置。
此时 RSP 所指向的内存中存储着一个特定值——这实际上是返回地址(Return Address)。因为我们虽然停在 main 函数的第一行,但尚未执行它,这意味着在进入 main 之前,必定有其他代码调用了 main;当调用发生时,调用者就会将返回地址压入栈中,以便 main 执行完毕后能返回到调用它的代码位置。

  • 单步执行过程

1.执行 sub rsp, 0x28
该指令将 RSP 当前的值减去 0x28(即 40 字节),并将结果写回 RSP。
栈指针 RSP 从原来的高地址位置向低地址方向移动了 0x28 字节,我们可以看到 RSP 寄存器中的值也相应发生了改变。

2.执行 call func(目标函数地址为 0x140001000)
根据之前讲解的 CALL 指令机制,它的副作用是将下一条指令的地址作为返回地址压入栈中。
这里的返回地址是 0x140001019,即 call 指令紧接着的下一条汇编指令的地址。
当 func 函数执行完毕返回时,它不能跳回 call 本身(否则会造成死循环),而是必须跳回 call 下方的下一条指令继续执行。
由于将 8 字节的返回地址压入了栈,RSP 的值再次向下移动了 8 个字节,RSP 寄存器的数值相应更新。

3.跳转至 func 并执行 mov eax, 0xbeef
此时程序控制流跳转,RIP 更新为 0x140001000(func 函数的第一条指令)。
这条指令不涉及栈操作,因此栈空间和 RSP 没有任何变化。
它将 EAX(或 RAX 的低 32 位)的值修改为 0xBEEF。
重要细节:零扩展(Zero-extension)
虽然这里写入的目标是 32 位寄存器 EAX 而非 64 位寄存器 RAX,但在 x86-64 架构下,当对 32 位通用寄存器进行写入时,硬件会自动将高 32 位清零(零扩展)。
需要特别注意:这种零扩展机制仅适用于写入寄存器,不适用于写入内存(写入内存 32 位数据时,只会改变内存中的那 32 个比特)。

  • 此时的栈帧(Stack Frame)状态

在执行完 mov eax, 0xbeef 后,我们暂停并观察一下当前的栈状态:
栈的顶部/基部保留着调用 main 函数之前的上层函数的返回地址,这属于上层函数的栈帧。
接着是 main 函数通过 sub rsp, 0x28 预分配的 0x28 字节栈空间。
当 main 调用 func 时,压入的返回地址被视为调用者(即 main 函数)栈帧的一部分。
由于 func 非常简单(仅仅是将返回值写入 EAX 寄存器然后直接返回),它甚至没有任何属于自己的栈帧。

  • 函数返回与清除栈帧

4.执行 ret(从 func 返回)
ret 指令从当前栈顶弹出(Pop)保存的返回地址(即 0x140001019)并写入 RIP,使控制流恢复到 main 函数中 call 的下一条指令处。
弹栈操作会自动将 RSP 加 8,栈指针回退。

5.执行 mov eax, 0xf00d
控制流回到 main 函数的 0x140001019 位置。
可以看到,代码根本不在乎 func 刚刚在 RAX/EAX 中返回了什么,而是直接用 0xF00D 覆盖了它(func 实际上成了无意义的死代码 / Dead Code)。

该指令不影响栈状态。

6.执行 add rsp, 0x28
此处用于平衡一开始的 sub rsp, 0x28。
编译器在这里没有使用频繁的 push/pop,而是直接通过对栈指针进行减法和加法来一次性分配与释放栈空间。

7.执行 ret(从 main 返回)
弹出 main 函数的返回地址(0x140001349)写入 RIP,同时 RSP 自动加 8。
程序控制流成功返回至调用 main 的函数(如 invoke_main)。

  • 课程总结与思考

优化开关(Optimization): 在实际生产环境中,如果开启编译器优化,编译器发现 func 只返回一个固定的常量值且后续未被使用,会直接将这些死代码内联或直接优化抹去。课程中特意关闭了编译器优化,是为了展示 C 语言与汇编代码之间最直接的对应关系。

神秘的预留空间: 为什么 main 函数需要执行 sub rsp, 0x28 和 add rsp, 0x28?看似它并没有用到这块栈空间。我们将这个疑问暂留为“未解之谜”,等后续章节深入讲解函数调用约定(Calling Conventions)时再揭晓。

调用栈结构总览:
最外层:调用 main 的函数(如 invoke_main)将返回地址压栈。
main 函数建立自己的栈帧逻辑。
main 调用 foo,将返回地址压栈。
foo 调用 bar,将返回地址压栈……
每一个被调用函数的返回地址,都嵌套在调用者(Caller)的栈帧结构中。

随着这 5 个新指令(call、ret、mov、add、sub)的加入,我们已经掌握了真实浏览器或常用软件汇编代码中超过 50% 以上的核心指令!

6.3 Visual Studio 中栈帧单步执行的实操演练

教学视频:https://youtu.be/7u4_fRw1HV4?si=Hcr3K72AWQyjH_E3

调试代码

int func(){
    return 0xbeef;
}
int main(){
    func();
    return 0xf00d;
}
posted @ 2026-07-25 19:09  sec875  阅读(51)  评论(0)    收藏  举报