【读书笔记】汇编语言程序设计

零.阅读目的
开发基于C++的游戏服务器时难以完全避免偶尔出现的宕机问题,在排查故障时通常会进行堆栈调试(使用dwarf框架)。然而由于这些方面的限制以及64位调试工具的复杂性,《深入理解计算机系统》一书的目标不是要求全面深入地掌握所有底层细节
一.汇编基础
1.基础指令汇总
- mov //传送指令
- cmov //条件传送指令
- xchg //交换指令
- push //压栈
- pop //出栈
- pusha/popa //压入/弹出所有16位通用寄存器
- pushad/popad //压入/弹出所有32位通用寄存器
- add //加法
- sub //减法
- inc //自增
- dec //递减
- mul //无符号乘
- imul //带符号乘
- div //无符号除
- idiv //带符号除
- sal //向左移位,右边补0
- shr //无符号,向右移位,左边补0
- sar //带符号,向右移位,左边补1
- lea //赋值地址
- xor //异或,可用来清零 ^
- or //或者 ||
- not //非 !
- and //并且 &&
- nop //空指令
- test //位判断
- je //j开头的均为条件跳转指令,带n的为反义
- call //调用函数
- enter //替代函数操作esp,pushl %ebp movl %esp, %ebp
- leave //替代函数操作esp,movl %ebp, %esp popl %ebp
- ret //函数返回指令
- jmp //跳转到某个地址
- int //中断
- rep //重复执行某个操作,知道ecx为0
- loop //循环直到ecx寄存器为0
以下罗列了大量常见的汇编指令,在程序调试阶段(phase),这些指令无处不在地出现,并且是所有程序员必须掌握的基础命令。
2.数据类型
- AT&T语法
使用L,W,B来表示数据大小,分别代表四位long,两位word,一位byte;
Intel汇编规则中定义了多种数据类型:其中"byte"表示一个字节、"word"表示一个字、"dword"表示两个字、"qword"表示四个字、而"Tbyte"则代表五个字。这些术语通常位于变量声明前缀位置上。
二.通用寄存器
1.32位寄存器
EAX 用于累加操作数与结果数据的寄存器
EBX 指示数据内存段中数据位置的指针
ECX 用于字符串操作中计数、比较及循环控制的寄存器
EDX 表示I/O 指针
EDI 用于字符串操作中目标数据及字符计数的寄存器
ESI 用于字符串操作中源字符及长度计数的寄存器
ESP 表示堆栈顶部地址的位置
EBP 表示堆栈中当前元素起始地址的位置
2.64位寄存器
- RAX
- RBX
- RCX
- RDX
- RDI
- RSI
- RSP
- RBP
3.系统寄存器
- EIP 系统寄存器,用来记录CPU要执行的指令地址
4.寄存器的特定使用
在Linux程序中,默认情况下程序退出时的状态码会被存储在%ebx寄存器中。
通过执行movl 8, %ebx 指令即可将该状态码加载到内存中的位置008字节处。
通过执行echo ?指令可查看前一个进程的日志信息(该指令也会输出到标准输出)。
对于Linux平台来说,默认情况下可以通过执行echo ?来获取当前进程的返回状态(该指令的结果也会被显示在标准输出上)。如果需要进一步操作这些信息,则可以在汇编语言层面将返回值加载到ebx寄存器中。
5.8位、16位、32位寄存器
| 位数 | 寄存器 | 寄存器 | 寄存器 | 寄存器 |
|---|---|---|---|---|
| 32位 | EAX | EBX | ECX | EDX |
| 16位 | AX | BX | CX | DX |
| 8位 | AH/AL | BH/BL | CH/CL | DH/DL |
三.开发工具
1.汇编器
MASM 微软开发的 http://www.masm32.com/
NASM
GAS GNU系列,另外有gcc、g++
HLA
2.连接器
ld:把汇编语言目标代码和其他库连接在一起,生成操作系统可执行文件
3.调试器
gdb:停止程序、检查修改数据
4.编译器
as:把高级语言转换为处理器能够执行的指令码
5.目标代码反汇编器
objdump:将可执行文件或者目标代码文件转换成汇编语言
6.简档器
gprof:记录所有函数在运行过程中使用时消耗了多少处理器时间
7.一些需要用到的工具
gdb 是一个强大的调试工具。
kdbg 是一种基于图形界面的调试工具。
通过 objdump 可以查看反汇编信息。
gprof 是一个性能分析工具,在分析时可查看函数被调用次数及所需时间。
使用 gcc 命令编译并生成可执行文件:gcc -o demo demo.c -pg。
运行程序:./demo。
运行 prof 分析并将结果保存为 txt 文件:gprof demo > gprof.txt。
gcc过程:
使用gcc编译器将ctest.c转换为ctest.s。
通过as编译器将ctest.s编译为目标文件ctest.o。
在链接时若涉及调用C库函数,则需附加相应参数。
四.操作码语法(Intel和AT &T的语法不同)
DEC和Intel汇编语法显而易见的是操作数的排列顺序存在显著差异。具体来说,则包括以下几个方面:
| 编号 | Intel | AT&T | AT&T说明 |
|---|---|---|---|
| 1 | 4 | $4 | AT&T使用$表示立即操作数 |
| 2 | eax | %eax | AT&T在寄存器名称前面加上前缀% |
| 3 | mov eax, 4 | movl $4, %eax | 处理源和目标使用相反的顺序 |
| 4 | mov eax, dword ptr test | movl $test, %eax | AT&T不用指定数据长度,但mov后面要指定L,W,B |
| 5 | jmp section:offset | ljmp section, offset | 长调用和跳转使用不同语法定义段和偏移值 |
| 6 | -4(%ebp) | [ebp-4] | 间接寻址 |
| 7 | foo(,%eax,4) | [foo + eax*4] | 间接寻址 |
这里只是列举了几个常见并且比较基本的区别,太复杂的语法没有深究。
五.汇编程序
1.基本模板
#注释
.section .data
.section .bss
.section .text
.globl _start
_start:
movl $0, %eax
代码解读
2.编译
as cpuid.s -o cpuid.o (-gstabs 添加调试信息)
ld cpuid.o -o cpuid
代码解读
3.调试(几个gdb常用调试命令)
以下是对原文的改写
*n表示字段属性数目: 字段数量。
*y代表输出数据格式: c编码类型对应字符集, d表示十进制数值, x则为十六进制标识。
*z表示显示字段长度: 使用b表示字节大小, h代表半字节单位, w用于32位内存块。
*例子:使用x/42cb指令并带有&output参数查看变量42位内容。
4.测试程序
#cpuid2.s
.section .data
output:
.asciz "The processor Vendor ID is '%s'\n"
.section .bss
.lcomm buffer, 12
.section .text
.globl _start
_start:
movl $0, %eax
cpuid
movl $buffer, %edi
movl %ebx, (%edi)
movl %edx, 4(%edi)
movl %ecx, 8(%edi)
pushl $buffer
pushl $output
call printf
addl $8, %esp
pushl $0
call exit
代码解读
编译运行
gzshun@gzshun-vm:~/c$ as cpuid2.s -o cpuid2.o
gzshun@gzshun-vm:~/c$ ld cpuid2.o -o cpuid2 -lc --dynamic-linker /lib/ld-linux.so.2
gzshun@gzshun-vm:~/c$ ./cpuid2
The processor Vendor ID is 'GenuineIntel'
代码解读
最初当我通读这本书时
遵循AT&T语法规范时,在寄存器前应正确使用百分号%,然而我却误用了美元符号($),导致解析失败。
gzshun@gzshun-vm:~/c$ ld cpuid2.o -o cpuid2 -lc --dynamic-linker /lib/ld-linux.so.2
cpuid2.o: In function `_start':
(.text+0xe): undefined reference to `ebx'
代码解读
最初我发现该标记可能被错误地赋值为%edi。
因此,在使用过程中遇到了问题。
于是尝试将程序应用于多个操作系统:centos、ubuntu以及redhat等主流系统。
然而,在这些操作系统的环境下均出现了相同的问题:segmentation fault(核心被挂起)。
之后便不再深入探究这个问题。
但在仔细阅读相关书籍后,
了解到这种情况通常是由于gdb调试时未正确设置符号引用,
导致执行出现异常。
最终发现,
原始代码中的引用缺少必要的括号,
从而引发了数据加载错误。
原来作者意图是对内存地址进行修改,
而非直接破坏指针指向的数据。
gzshun@gzshun-vm:~/c$ as cpuid2.s -o cpuid2.o -gstabs
gzshun@gzshun-vm:~/c$ ld cpuid2.o -o cpuid2 -lc --dynamic-linker /lib/ld-linux.so.2
gzshun@gzshun-vm:~/c$ gdb cpuid2
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1
(gdb) b 13
Breakpoint 1 at 0x80481cc: file cpuid2.s, line 13.
(gdb) r
Starting program: /home/gzshun/c/cpuid2
Breakpoint 1, _start () at cpuid2.s:13
13 movl %ebx, %edi
(gdb) x/12c buffer
0xb7fbd5d4 <buffer>: 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000'
0xb7fbd5dc <buffer+8>: 0 '\000' 0 '\000' 0 '\000' 0 '\000'
(gdb) x/12c $edi
0x80492c8 <buffer>: 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000'
0x80492d0 <buffer+8>: 0 '\000' 0 '\000' 0 '\000' 0 '\000'
(gdb) s
14 movl %edx, 4(%edi)
(gdb) x/12c $edi
0x756e6547: Cannot access memory at address 0x756e6547
(gdb)
代码解读
通过调试工具查看打印信息时会发现,在该指令执行后movl \, \%ebx, \%edi之后,可以看到edi寄存器中的数值已经被修改了。
5.数据类型
汇编语言的数据类型用于声明程序中的变量,跟C语言的类型差不多。
| 命令 | 数据类型 |
|---|---|
| .ascii | 字符串 |
| .asciz | 空字符结尾的字符串 |
| .byte | 字节值 |
| .double | 双精度浮点数 |
| .float | 单精度浮点数 |
| .int | 32位整数 |
| .long | 同.int |
| .octa | 16字节整数 |
| .quad | 8字节整数,也就是64位 |
| .short | 16位整数 |
| .single | 同.float |
6.数组
-
声明
sizes:
.int 100,150,200,250,300 -
获取
将 sizes加载到% edi。 将 2加载到% edi。
将values数组中的第零个元素加载到% edi,并加载其后四个字的数据到% eax中。
// 因此对应于$ sizes[2]的位置。
// eax寄存器的值被错误赋值为-198(即-198),这相当于在内存中读取了错误的内容
7.bss段
| 命令 | 描述 |
|---|---|
| .comm | 声明未初始化的数据的通用内存区域 |
| .lcomm | 声明未初始化的数据的本地通用内存区域,会占用程序空间 |
在汇编程序中,在.lcomm标签处声明一个相当大的数组时(或:当在汇编程序中,在.lcomm标签处设置了一个相当大的数组时),生成的对象文件会包含该数组的数据量(或:该对象文件会包含该数组所占有的内存空间)。
六.指令集语法
1.传送数据
- 向数据传输
将值从eax寄存器中加载到变量value中。
从ecx寄存器加载数值到变量value中。
通过变址机制实现内存位置的访问(即对数组元素进行读写操作)
通过寄存器实现间接寻址的方式如下:
- 将$values内存地址赋值于edi寄存器;
- 将ebx寄存器的数值赋值至edi指针指向的位置;
- 将edx寄存器的内容赋值于edi指针所指位置后方连续四个字节;
- 将edx寄存器的内容赋值于edi指针所指位置前方四个字节。
- 条件传输指令
cmovx 源操作数, 目标操作数
比较结果存储于 EFLAGS 寄存器中, 包括带符号与无符号的比较:
以下表格列出无符号条件传送指令:
指令对 描述 EFLAGS状态 CMOVA/CMOVNBE 大于/不小于或者等于 (CF或ZF)=0 CMOVAE/CMOVNB 大于或者等于/不小于 CF=0 CMOVNC 无进位 CF=0 CMOVC 进位 CF=1 CMOVB/CMOVNAE 小于/不大于或者等于 CF=1 CMOVBE/CMOVNA 小于或者等于/不大于 (CF或ZF)=1 CMOVE/CMOVZ 等于/零 ZF=1 CMOVNE/COMVNZ 不等于/不为零 ZF=0 CMOVP/CMOVPE 奇偶校验/偶校验 PF=1 CMOVNP/CMOVPO 非奇偶校验/奇校验 PF=0 从上表可以看出,无符号条件传送指令依靠进位、零和奇偶校验标志确定两个操作数之间的区别。
上面的指令有些是用 / 符号隔开指令对,这两个指令具有相同的含义。比如,一个值大于另外一个值,也可以说是不小于或者等于另外一个值。这两个条件是等同的,但是二者具有个字的传送指令,如 CMOVA 和 CMOVNBE .
如果操作数是带符号值的,就必须使用不同的条件传送指令集,如下表所示:
指令对 描述 EFLAGS状态 CMOVGE/CMOVNL 大于或者等于/不小于 (SF异或OF)=0 CMOVL/CMOVNGE 小于/不大于或者等于 (SF异或OF)=1 CMOVLE/CMOVNG 小于或者等于/不大于 ((SF异或OF) 或 ZF)=1 CMOVO 溢出 OF=1 CMOVNO 未溢出 OF=0 CMOVS 带符号(负) SF=1 CMOVNS 无符号(非负) SF=0
2.交换数据
| 指令 | 描述 |
|---|---|
| XCHG | xchg src, dst // src和dst交换 |
| BSWAP | 0x12345678,转换后变成0x78563412 |
| XADD | xadd src,dst // dst = src + dst |
| CMPXCHG | cmpxchg src, dst //dst与eax比较,若相等则src传送到dst |
| CMPXCHG8B | cmpxchg8b dst //将dst和edx:eax比较,若相等则将ecx:ebx传送到dst,高位:低位 |
3.堆栈
堆栈地址随压栈操作不断下降; esp寄存器用于存储当前栈顶的位置信息; 每当执行push操作压入新数据时; esp寄存器值随之递减。
指令
- press to source //按住源操作码
- press from source
- pop from destination //从栈弹出操作码
- pop to destination
- load into registers / save registers //将所有16位通用寄存器进行加载或保存
- save and restore registers / save and restore all 32-bit general purpose registers
- access the EFLAGS register file / access the lower 16 bits of EFLAGS register file
- access the entire EFLAGS register file
栈顺序(从高地址向低地址增长)
该处理器的x86系列系统进程中运行着两个独立的方向性存储区:一个朝下延伸(stack),另一个朝上延伸(heap)。从INTEL的角度来看,在其早期的产品如^{[4]} ^{[5]} ^{[6]} 以及最新的^{[7]} 中,默认使用了高地址方向的增长模式;然而,在x-series(^{[2]})中,默认转为低地址方向的增长模式。对于其他品牌如ARM等,则提供了选择权:除了采用ARM架构设计的企业外(或公司),大多数设备均未提供这种选择权;而多数则是采用了低地址方向的增长模式。
历史遗留
历史上,在没有MMU的情况下,人们最大限度地利用内存空间时会采用独特的设计理念——即让堆与栈从两端相向发展。那么哪个方向向上呢?人们通常习惯将数据存储在较高地址的位置上。例如,在堆中创建一个数组时(如new array),人们倾向于将较低位置的数据存储在较低的内存地址处,并且较高的位置存放较大的索引值或对象引用信息。因此,在这种情况下,默认使用堆来管理内存更为合理。相比之下,栈的操作不受方向限制(如push和pop操作),因此将它们放置在同一片内存区域不会导致混乱或溢出问题。这样一来,在未引入MMU之前,默认策略就是必须区分方向并分别管理这两部分内存区域:即将堆分配至内存低端区域,并将栈分配至高端区域。引入MMU后,默认策略不再是必须区分方向的问题了。

在处理函数参数时会涉及堆栈知识,在文章中详细讨论了C样式函数的实现过程
4.控制执行流程
函数调用
- jmp location //跳转指令
跳转指令用于将程序执行流程从当前位置转移到指定的位置。 - call address //调用函数
目标地址用于指定调用函数或子程序的位置。 - enter == push %ebp mov %esp,%ebp
在进入时会先压入EBP寄存器以保护栈基地址ESP。 - leave == mov %ebp,%esp pop %ebp
在退出时会将EBP寄存器的值移动到ESP寄存器中并弹出EBP寄存器。
跳转指令
jxx address,跳转指令非常多,都是j开头的指令:
以下是对原文内容的同义改写
这些指令有两种相同作用(即ja/jg和jb/jl),但它们的用途却有所不同:ja/jb用于无符号数值判断(上/下),而jg/jl则用于带符号数值判断(较大/较小)。
比较指令
cmp a, b //内部处理:b - a
jge address //此时如果调用jge,在b > a的情况下才会跳转
循环指令
重复指令//当 \texttt{ecx} 寄存器等于零时执行循环
若 \texttt{ecx} 为零会引致 loop 问题, 因此采用 \texttt{jcxz} 或 \texttt{ecxz} 指令, 在 \texttt{ecx} 等于零时跳跃转移
5.数字
整数长度
byte word doubleword quadword 与C语言类似
字节顺序
请注意:内存数据采用了little-endian编码,而寄存器采用了big-endian编码。通过调用gdb的x/4b &data方法能够获取各字节的具体排列顺序。
传送不同数据大小的数字
当需要将16位数字传输至32位寄存器时
MMX整数
movq source, dest //将数据传送到MMX寄存器中,比如%mm0,%mm1
SSE整数
movdqa source, dest //将数据传送到XMM寄存器中,比如%xmm0, %xmm1
其他
- 原码:数据本身
- 反码:原码的取反
- 补码:反码+1
浮点数
-
科学计数法
0.159 * 10^0 值这样算:0 + (1/10) + (5/100) + (9/1000) //这是日常人类看得懂的数字 -
二进制浮点数
1.0101 * 2^2 是 101.01,值这样算:5 + (0/2) + (1/4) = 5.25 //这是计算机看得懂的 -
例子
二进制 十进制分数 十进制值 0.1 1/2 0.5 0.01 1/4 0.25 0.001 1/8 0.125
- 二进制浮点格式
浮点类型 符号位 指数 系数(有效数字) float 31 23~30 0~22 double 63 52~62 0~51 float的有效数字为23位,则计算其对应的十进制数量为2^{23}=8,\!388,\!608个单位,在十进制中表现为7位整数部分的小数表示形式。
同理可知double的有效数字为52位,则2^{52}=4.5\times 10^{15}个单位,在十进制中表现为16位整数部分的小数表示形式。
浮点数指令
F开头的指令基本上是浮点数的操作指令,大概了解一下就行。
- FLD source:会将浮点数压入FPU堆栈,并使用寄存器st0和st1存放操作结果
- FLDS:表示单精度浮点运算
- FLDL:表示双精度浮点运算
- FLD1:用于加载数值1
- FLDL2T和FLDL2E都是与对数值相关的指令
- FLDPI:用于压入圆周率π的值
- FLDLG2:用于计算以二为底的对数值
- FLDLN2:用于计算自然对数值(基于e)
- FLDZ:用于压入零值
SSE浮点
掌握多种技术细节对于深入学习内存保护策略等主题通常不太必要
6.基本数学功能
-
加法(b、w、l):
add source, dest -
双字加法(b、w、l):会将进位标志带入高位计算
adc source, dest -
减法(b、w、l):
sub source, dest -
双字减法(b、w、l):无符号的减法,考虑溢出和进位标志位
sbb source, dest -
递增/递减:无符号,不影响进位标志
inc dest
dec dest -
乘法(无符号):
mul source //无符号,目标数隐含,因为有以下情况
源操作数长度 目标操作数 目标位置 8bit AL AX 16bit AX DX:AX 32bit EAX EDX:AX
-
signed multiplication
执行带有符号的操作时,请确保检查结果是否溢出,并使用jo指令。
乘以指定操作数。
计算结果存储在dest变量中。- 除法(无符号)
div divisor //divisor是除数
- 除法(无符号)
被除数 被除数长度 商 余数 AX 16bit AL AH DX:AX 32bit AX DX EDX:EAX 64bit EAX EDX
- 除法(带符号)
idiv divisor //带符号
移位乘法:通过左边添加零来实现乘以2的操作。
使用算术移位(sal)和逻辑移位(shl)的区别在于前者保留符号位而后者不保留。
sal dest // 左移1位
使用寄存器cl指定移动的位数:sal %cl, dest
使用指定的数值进行移动:sal val, dest
这些指令分别用于不同情况下的移位操作。
-
移位除法
shr dest //无符号,左边补0
sar dest //带符号,左边补1 -
循环移位:overflow bits are appended to the other end of the value
cyclically left shift destination register
cyclically right shift destination register
cyclically left shift destination register with carry flag
cyclically right shift destination register with carry flag -
未分组BCD运算
AAA 位于 add 之后
AAS 位于 sub 之后
AAM 位于 mul 后面
AAD 位于 div 前面 -
编码运算:其中字节低4位编码为BCD低四位数值 字节高四位编码为BCD高位四位数值 注 这些指令用于执行加法 ADC 和减法 SBB 操作
-
布尔逻辑:
按位与运算应用于源与目标
对目标执行按位非并将其结果存于目标
对目标执行按位或
对目标执行按位异或并将其结果存于目标;此功能可被用来清除高阶bit(如)
对目标执行特定bit状态的检测- 清空进位标志:
clc
- 清空进位标志:
7.高级数学功能(FPU寄存器)
浮点运算单元(FPU)中的寄存区域定义为R0至R7,并用于处理浮点数运算。其中一些操作指令与基本算术运算相似,在执行时均在前面加上一个字母F以区别于常规操作。
-
FPU寄存器堆栈
R0 –> ST7
R1 –> ST6
…
R7 –> ST0 -
常用的浮点计算指令
fadd
fdiv
fdivr
fmul
fsub
fsubr -
三角函数:
fcos
fsin
fptan
三角函数、对数、平方、绝对值等等
fpu指令暂时没用到,不再深究。
8.处理字符串
-
传输字符串
movs(b,w,l):
默认的源寄存器为esi,默认的目标寄存器为edi。
因此完整的指令形式如下:
movs %esi, %edi //但实际编程中通常省略这一部分
例子:
将输入数据加载至源寄存器 esi 中;
将输出数据加载至目标寄存器 edi 中;
执行 movsl 指令完成传输操作。- 地址传送指令
lea output, %edi //该指令经常用来将内存地址赋值给dest
- 地址传送指令
-
DF位
在每次执行movs指令时, esi和edi会变化.若DF位为0,则增加;若DF位为1,则减少.
cld 将DF清零
std 设置为1
特别注意的是,在向后处理字符串时,moves指令仍然是向前获取内存. -
REP前缀(repeat)作为替代指令
按照 ecx 的值执行处理直至其变为零
动态地根据 movsq、movsw 和 movsl 的长度进行调整
超出边界时
用于判断 ecx 和 ZF 标志的状态
完成字符串的加载与存储操作。其中lods指令隐式使用esi寄存器获取操作数,并将单个字符加载至AL寄存器;而lodsb指令则将两个字符依次加载至AX寄存器中。类似的stos指令及其变体(如stobs、stosw等)分别负责将字符或字节复制到目标寄存器中。通过rep前缀修饰的lods与stos指令组合能够完成较大规模字符串的数据复制任务,并具有类似于memset指令的功能特性。
- 比较字符串:
cmps(q,w,l)
隐含的参数是esi和edi,也可配合rep使用
扫描字符串:
scas(b,w,l)
对比AL, AX, EAX以及隐含edi寄存器的操作数字段,并且还可以与rep指令配合使用。
七.函数
1.创建函数
固定格式如下:
.type fun1, @function
area:
ret
代码解读
当调用 call 指令时,在其后续立即进行的操作就是执行 ret 指令。该 ret 指令的作用是引导整个程序流程回到其主调函数之前的部分。
2.参数和返回结果
- 参数:寄存器、全局变量、堆栈
- 返回结果:寄存器、全局变量
3.调用函数
- 指令
call function
参数
调用call之前应将输入值置于适当位置。函数内部可能修改寄存器值以便在返回时能恢复其原始状态。可以通过pusha和popa操作来实现;或者仅对特定的寄存器进行操作。
通过pusha指令可同时保存所有寄存器
通过popa指令可同时恢复所有寄存器
4.C样式传递数据值(堆栈)
C样式函数传参采用堆栈技术,在调用过程中将参数依次压入栈顶
-
返回值
-
将32位数值存储于eax寄存器中
-
将64位数值存储至edx:eax寄存器中
-
通过FPU中的ST0字段来保存浮点数值
- 程序执行过程中,在函数入口处及尾部通常会保留esp值
function:
pushl %ebp
movl %esp, %ebp
...
movl %ebp, %esp
pop %ebp
ret
代码解读
也可以这样写:
function:
enter
...
leave
ret
代码解读
在函数执行的最开始阶段(通常是函数入口处),系统会将 esp 寄存器设置为一个初始值以实现为程序堆栈分配了一段内存空间的目标。当函数完成其内部操作并返回时(即 function call 完成后),系统会再次对 esp 进行调整以清除堆栈中的残留数据。为了实现这一功能,在处理堆栈数据时通常会采用以下方式:通过间接寻址机制使用 %ebp 寄存器来访问堆栈中的数据。例如,在某些情况下(如本例),-4(%ebp) 的位置可能被用作存储第一个局部变量的内存地址。
function:
enter
subl $12, %esp
movl $1, -4(%ebp) #这里就有3个4字节的栈可以用
leave
ret
call fun1
addl $12, %esp #将刚才开辟的12个字节栈空间清掉
代码解读
5.函数的堆栈空间
实验
以一个实例阐述局部变量在栈中的顺序以及函数占用内存的空间,在程序运行初期,在程序开始之前需预先确定ESP寄存器偏移值所对应的参数排列。以下表格展示了函数调用时的堆栈占用情况,请注意表中从上至下的排列对应于高地址到低地址依次递减的趋势。
| 地址 | 变量 | ebp偏移 |
|---|---|---|
| 0xbfffefd4 | 函数参数3 | 16(%ebp) |
| 0xbfffefd0 | 函数参数2 | 12(%ebp) |
| 0xbfffefcc | 函数参数1 | 8(%ebp) |
| 0xbfffefc8 | 返回地址 | 4(%ebp) |
| 0xbfffefc4 | 旧的EBP值 | (%ebp) |
| 0xbfffefc0 | 局部变量3 | -4(%ebp) |
| 0xbfffefbc | 局部变量2 | -8(%ebp) |
| 0xbfffefb8 | 局部变量1 | -12(%ebp) |
我阅读了这本书的例子,还包括有网上一些堆栈空间的说明,对局部变量的栈顺序都有不同的理解,比如上面这个表格,局部变量1到底是对应-4(%ebp),还是对应-12(%ebp)呢?因为这个对于调试有时是有帮助的,有时想通过esp偏移量得到某个局部变量的大小或者内存,所以了解这个顺序是很有必要的。
于是使用以下程序进行验证:
开发环境:Ubuntu 16.04.1 LTS 32位
编译器:gcc version 5.4.0 20160609
#include <stdio.h>
int fun(int a, int b, int c)
{
int va = a;
int vb = b;
int vc = c;
return va;
}
int main()
{
int result = fun(1, 2, 3);
return 0;
}
代码解读
通过gcc进行项目构建,并利用objdump执行反向工程以分析目标main函数及辅助功能fun的具体汇编指令
080483db <fun>:
80483db: 55 push %ebp
80483dc: 89 e5 mov %esp,%ebp
80483de: 83 ec 10 sub $0x10,%esp
80483e1: 8b 45 08 mov 0x8(%ebp),%eax
80483e4: 89 45 f4 mov %eax,-0xc(%ebp)
80483e7: 8b 45 0c mov 0xc(%ebp),%eax
80483ea: 89 45 f8 mov %eax,-0x8(%ebp)
80483ed: 8b 45 10 mov 0x10(%ebp),%eax
80483f0: 89 45 fc mov %eax,-0x4(%ebp)
80483f3: 8b 45 f4 mov -0xc(%ebp),%eax
80483f6: c9 leave
80483f7: c3 ret
080483f8 <main>:
80483f8: 55 push %ebp
80483f9: 89 e5 mov %esp,%ebp
80483fb: 83 ec 10 sub $0x10,%esp
80483fe: 6a 03 push $0x3
8048400: 6a 02 push $0x2
8048402: 6a 01 push $0x1
8048404: e8 d2 ff ff ff call 80483db <fun>
8048409: 83 c4 0c add $0xc,%esp
804840c: 89 45 fc mov %eax,-0x4(%ebp)
804840f: b8 00 00 00 00 mov $0x0,%eax
8048414: c9 leave
8048415: c3 ret
8048416: 66 90 xchg %ax,%ax
8048418: 66 90 xchg %ax,%ax
804841a: 66 90 xchg %ax,%ax
804841c: 66 90 xchg %ax,%ax
804841e: 66 90 xchg %ax,%ax
代码解读
先看fun函数的汇编代码,其中有两行代码如下:
mov 0x8(%ebp),%eax
mov %eax,-0xc(%ebp)
代码解读
(%ebp)中的0x8指示函数参数1的位置。
即为a形参。
将a值暂存在eax寄存器中。
随后将该值复制至-0xc(%ebp)处。
此时查看C语言代码,在C语言中该变量被赋值给va变量。
从而得出结论:-0xc(%ebp)位置指向局部变量va。
表明随着局部变量声明顺序的不同,在堆栈中的位置也会发生变化。
根据上述分析可得如下表格:
| 类型 | 地址偏移量 | 地址含义 |
|---|---|---|
| ebp | 8 | 参数1 |
| ebp-12 | -12 | 参数2 |
| ebp-16 | va |
| 地址 | 变量 | ebp偏移 |
|---|---|---|
| 0xbfffefd4 | c | 16(%ebp) |
| 0xbfffefd0 | b | 12(%ebp) |
| 0xbfffefcc | a | 8(%ebp) |
| 0xbfffefc8 | 返回地址 | 4(%ebp) |
| 0xbfffefc4 | 旧的EBP值 | (%ebp) |
| 0xbfffefc0 | vc | -4(%ebp) |
| 0xbfffefbc | vb | -8(%ebp) |
| 0xbfffefb8 | va | -12(%ebp) |
示意图:

疑问
对此问题值得商榷。那么 vc 变量所在的内存地址为何位于 ebp 下方的位置(即 -4(%ebp) 处)?值得注意的是,在函数内部环境中,在处理栈变量时需要特别关注其声明顺序对内存布局的影响。具体而言,在函数内部环境中,在函数体内声明越晚出现的变量通常会被安排在其后延的位置上,并且其对应的内存位置逐渐升高。因此位于较高的位置就意味着该内存块靠近 ebp 寄存器这一特性在此情况下得到了体现。为了验证这一现象的存在与规律性 我特意编写了一个小型程序 并通过实际运行程序获取了 va 和 vb 的具体内存偏移量 显示结果表明 va 的偏移量小于 vb 的偏移量 这一现象恰能佐证上述理论推导过程中的关键假设条件。综合以上观察结果与现有知识库中的相关信息 由此可知 在函数调用层中的栈组织结构遵循如下顺序: ebp 寄存器 → vc → vb → va
原贴地址
原贴地址
6.独立的函数文件
汇编程序中的globl标签通常被声明为_start;然而,在独立函数文件中,则应将其明确声明为其相应的函数名称。
.section .text
.type area, @function
.globl area
area:
代码解读
处理的方式与C语言处理相同,在这个过程中各自会生成. o 文件,并通过ld工具整合所有. o 文件最终生成可执行程序。
其中一些.s 汇编文件需要单独进行逐步调试操作;而无需调试的则只需将目标汇编文件带选项-gstabs进行处理即可。
7.命令行参数
在Linux系统中,默认情况下新进程会将执行代码段(E)和数据段(D)存储于内存区域`16:3FFA:2B:9C开始处(对应十进制数值&H16773152),而栈区通常占据内存区域$16:BF:C3:C7`(对应十进制数值&H16773591)。该程序在Linux系统中的虚拟内存地址范围是从十六进制数值$22:4B5E:9B:C2开始延伸至十六进制数值`22:FFFF:F5:F3结束。因此,在调试过程中观察到内存地址以十六进制数值$22:4B5E:9B:C2`作为前缀较为常见,并且此现象通常是由于代码段与数据段通常存储于内存区域$22:4B5E:9B:C2的原因所导致的。从而使ESIP寄存器指示位置指向十六进制数值`22:FFFF:F5:F3`处。


8.系统调用
Linux系统的系统调用函数对应特定的编号信息,请参阅universe.h中的具体内容如下:
#define __NR_exit 1 //注释:exit函数对应的编号并不固定
-
向系统发送中断请求
movl $1, %eax //将系统调用编号写入eax寄存器
int 0x80 //使用硬件中断处理程序- 系统调用的参数
eax用于存放函数编号
顺序:ebx(第1个参数) -> ecx -> edx -> esi -> edi
- 系统调用的参数
-
计算字符串的长度
输出结果:
.asci ‘helloworld’
asci_len:
.equ len; output_len = output_len - output; -
涉及较为复杂的系统交互
例如,在传递参数时采用结构体指针的形式
调用完成后可以通过该指针获取所需数据
便于在内存中进行连续分配和管理
每个标签对应一个独立的变量
使得内存布局保持连续性 -
监控程序调用系统函数
该命令的主要作用是记录程序所调用的各种系统函数及其相关信息
其输出包括被调用函数名称、返回值、执行时间和进程信息等详细数据
strace -p pid //动态附加
strace -c 程序 //运行时间
八.内联汇编
1.使用
在C/C++程序中,使用关键之asm,ANSI C使用asm 包含的汇编程序
2.语法
asm("movl $1, %eax\n\t"
"movl $0, %ebx\n\t"
"int $0x80")
代码解读
在编程中,默认情况下制表符并非必须;然而,在这种特定的语法规范中,则仅允许使用全局变量。
经过编译后生成的汇编代码通常会包含#APP和#NO_APP这两个标志位。
asm volatile ("")注释的作用是禁止编译器进行优化处理。
#include <stdio.h>
int a = 2;
int b = 3;
int result = 0;
int main()
{
asm volatile ("pusha\n\t"
"movl a, %eax\n\t"
"movl b, %ebx\n\t"
"imull %ebx, %eax\n\t"
"movl %eax, result\n\t"
"popa");
printf("The result is %d\n", result);
return 0;
}
代码解读
3.扩展asm格式
ASM("汇编代码":输出位置:输入操作数:修改的寄存器);
ASM(汇编指令:输出位置:输入操作数:修改的寄存器);
扩展汇编指定寄存器通常会使用一张约束表,例如:
a则代表EAX、AX或AL这三个通用寄生器中的一个。
b对应的EBX registers are used for b.
cECX registers are assigned to c.
dFor d, we utilize EDX registers.
SThe source register is designated by esi.
DThe destination register is edi.
r r can be any general-purpose register, serving as a placeholder。
m m stands for memory addresses or specific locations in memory。
其他
- 输出修饰符:
+ -> 读写
= -> 只写
%
&
通过asm扩展能够实现利用局部变量的功能,并且也支持占位符的使用;其中寄存器的赋值采用两个百分号的方式进行操作;例如,在实际应用中可以通过这种方式来实现特定功能。
int main()
{
int data1 = 10;
int data2 = 20;
int result;
asm("imull %%edx, %%ecx\n\t"
"movl %%ecx, %%eax"
: "=a"(result)
: "d"(data1), "c"(data2));
printf("The result is %d\n", result);
return 0;
}
代码解读
占位符例子1:
asm("assembly code"
: "=r"(result)
: "r"(data1), "r"(data2));
代码解读
汇编语句直接用数字访问,%0表示result,%1表示data1,%2表示data2
占位符例子2:
asm("assembly code"
: "=r"(result)
: "r"(data1), "0"(data2));
代码解读
汇编指令通过数字进行操作,在data2前缀的0标识为与零号项相对应的情况下,则表明result共享同一个变量空间
占位符例子3:
asm("assembly code"
: [value2] "=r"(result)
: [value1] "r"(data1), "0"(data2));
代码解读
汇编语句使用%[value1]访问
4.内联汇编宏函数
跟C语言一样,可以把asm定义成宏,方便使用。
九.结束
在深入学习调试的过程中发现无法理解汇编语言成为阻碍前进的关键因素于是我着手阅读了这本教材其目标明确旨在彻底弄懂而无需操心后续编码工作因此整本书的重点在于系统整理流程将常见的操作点进行归纳总结并针对模糊环节编写测试代码经过学习后这种积累的经验能够显著提升效率尽管该书遵循Linux GNU AT&T汇编语法规范但不同品牌的汇编语言仍具相似性
汇编指令速查
汇编指令速查
作者:gzshun. 原创作品,转载请标明出处!
来源:<>
