汇编编程中的浮点运算与栈初始状态
常见x86 - 64指令
在汇编编程里,有不少常见的x86 - 64指令,下面为大家介绍一些指令及其含义:
| 指令 | 含义 |
| — | — |
| pdivX SRC, DST | 对DST与SRC进行并行除法运算,并将结果存储在DST中(仅适用于整数值) |
| cvtXX2YY SRC, DST | 将SRC的低阶字节从XX指定的类型转换为YY指定类型的DST低阶字节,并非所有类型和组合都适用 |
| lock | 这是一个前缀,可与面向内存的指令结合使用,确保对该地址的缓存具有独占访问权 |
| nop | 执行“无操作”操作 |
浮点运算
计算机在处理数字时,此前我们接触的大多是整数,也就是没有小数点的数。而对于有小数点的十进制数,计算机处理起来存在普遍问题,因为计算机只能存储固定大小、有限的值,可十进制数的长度是任意的,甚至可能是无限长,像1/3的结果就是无限循环小数。
计算机处理小数的方式是按固定精度(有效位数)存储。计算机将十进制数分为三部分存储:符号位、指数和尾数。尾数包含实际要使用的数字,指数表示数字的大小。例如,12345.2可表示为1.23452 * 10^4,这里尾数是1.23452,指数是4,底数为10。但计算机采用的底数是2,所有数字都以X.XXXXX * 2^XXXX的形式存储,比如数字1存储为1.00000 * 2^0。这种存储数字的方式被称为浮点表示法,因为有效数字相对于小数点的位置会随指数变化。
不过,尾数和指数的长度是有限的,这就引发了一些有趣的问题。例如,计算机存储整数时,给它加1,结果就大1;但对于浮点数,情况并非如此。如果数字足够大,加1可能在尾数中根本体现不出来(要知道,尾数和指数部分的长度是有限的)。这会影响很多方面,尤其是运算顺序。给一个浮点数加1.0,如果这个数足够大,可能根本不会对其产生影响。在x86平台上,一个4字节的浮点数,虽然能表示很大的数,但超过16777216.0后,加1.0就不再有意义,数字不会改变。所以,在进行两个数先相加再相乘的运算时,先做加法和先分配乘法可能会得到不同结果。比如,1000.0 * (16777216.0 + 1.0)和1000.0 * 16777216.0 + 1000.0 * 1.0的结果就不同,前者为16777216000.0,后者为16777217024.0(后者也并非完全准确)。
浮点数在内存中的存储格式是IEEE 754。一般来说,双精度(64位)值中,1位用于符号位,11位用于指数,52位用于尾数;单精度(32位)值中,1位用于符号位,8位用于指数,23位用于尾数。需要注意的是,大多数计算机进行浮点运算比整数运算耗时长得多。所以,对速度要求高的程序大多使用整数。
浮点运算单元的发展历程
x86架构的浮点运算单元经历了多次迭代:
1.
最初阶段
:最初的8086芯片不支持浮点运算,一切都得手动完成。
2.
协处理器阶段
:后来通过协处理器8087添加了浮点支持,处理器会将相关指令交给协处理器处理。8087有一个80位的浮点值,有八个寄存器按栈的形式排列,称为FPU(浮点运算单元)栈,操作大多围绕栈的“顶部”值进行。
3.
集成阶段
:到了80486,浮点运算单元被集成到芯片中。
4.
扩展阶段
:为了扩展浮点支持并添加并行操作,Intel和AMD分别添加了额外指令。Intel的是“MMX”,添加了八个特殊的浮点寄存器,名为%mm0到%mm7,这些寄存器可直接访问;AMD的是“3DNow!”。
5.
SSE系列
:Pentium III时,Intel添加了类似于3DNow!的一组指令,即SSE(流式SIMD扩展),使用一组称为XMM寄存器(%xmm0到%xmm7)的八个寄存器,这些寄存器宽128位,但只能同时存储四个32位的值。后续的SSE2在Pentium IV上推出,将寄存器数量扩展到16个(%xmm0到%xmm15),并且可以以多种方式访问(作为不同大小的整数或浮点值),x86 - 64指令集要求必须具备SSE2。
SSE2寄存器的使用
SSE2寄存器长128位,但不使用128位的值,它可以当作两个64位值、四个32位值、八个16位值或十六个8位值来处理。对这些寄存器的操作,除非另有说明,否则会同时对所有值进行。如果只关注一个值,就考虑最低阶的位(虽然指令会对所有值操作,但可以忽略其他值)。
可以使用标准的movq指令将四字(quadword)移入或移出XMM寄存器。若目标寄存器是XMM寄存器,高阶四字会被清零,这在一次只处理一个值时通常没问题,是在内存和通用寄存器之间来回移动数据的简便方法。
加载值有两种方式:
1.
编码为浮点值
:在数据段,就像有用于64位整数的.quad指令一样,有用于64位浮点值的.double指令。可以对其进行编码并正常加载,示例代码如下:
myval:
.double 57.2
.section .text
_start:
movq myval, %xmm0
需要注意的是,没有用于加载值的立即模式指令。
2.
整数转换
:若从整数开始并想将其转换为浮点数,可以使用cvtsi2sd指令,意思是将一个整数值转换为标量双精度浮点值。它需要一个源寄存器(不能是XMM寄存器)和一个目标寄存器(必须是XMM寄存器)。例如,要将数字5作为浮点数加载到%xmm0中,代码如下:
movq $5, %rax # 将整数5放入%rax
cvtsi2sd %rax, %xmm0 # 将其转换为双精度浮点数存入%xmm0
移动整个寄存器
要移动整个128位,有多种指令可供选择,主要分为两类:对齐指令和未对齐指令。未对齐指令速度较慢,但可在任何寄存器或内存位置使用;对齐指令速度快,但如果使用的值未按16字节对齐,会触发异常。而且在某些处理器上,这些指令处理整数或浮点值时速度会有细微差别。具体指令如下:
| 指令 | 适用情况 |
| — | — |
| movdqu | 用于针对整数优化的未对齐移动 |
| movdqa | 用于针对整数优化的对齐移动 |
| movups | 用于针对浮点值优化的未对齐移动 |
| movaps | 用于针对整数优化的对齐移动 |
如果使用寄存器或知道要加载的内存是对齐的,就使用对齐版本的指令;否则,使用未对齐版本。
浮点运算与函数调用
进行使用浮点数的函数调用时,有几点需要注意:
-
参数传递
:对于浮点值参数,使用%xmm0到%xmm7寄存器传递。如果函数同时接收浮点和非浮点值,非浮点值按常规方式传递(使用%rdi、%rsi、%rdx、%r8和%r9),浮点值使用XMM寄存器传递。
-
可变参数函数
:对于像fprintf这样的可变参数函数,要将使用的XMM寄存器数量放入%rax,这样被调用的函数就能知道需要保存多少个向量寄存器。
-
返回值
:如果返回值是浮点数,会在%xmm0中返回。
例如,对于函数签名为double myfunc(int a, double b, int c, double d)的函数,a通过%rdi传递,b通过%xmm0传递,c通过%rsi传递,d通过%xmm1传递,返回值在%xmm0中。如果这是一个可变参数函数,需要将%rax设置为2,表示只有两个XMM寄存器有需要关注的内容。
浮点算术运算
值存入寄存器后,可使用稍有不同的指令进行标准运算。在算术指令末尾添加sd后缀,可使其对标量(单值)双精度值进行操作。例如,若%xmm0和%xmm1都加载了双精度值,可使用addsd %xmm0, %xmm1将它们相加,并将结果存储在%xmm1中。
乘法(mulsd)和除法(divsd)需要源操作数和目标操作数。除法中,第一个操作数是除数,第二个操作数是被除数,也是结果商的存储位置。
完成浮点运算后,有以下几种选择:
- 使用cvtsd2si将其转换回整数。
- 使用movq将其移回%xmm0,作为函数的返回值。
- 使用movq将其移到寄存器作为参数传递。
下面是一个将1除以3并使用fprintf输出结果的程序示例:
fpdiv.s
.globl main
output:
.ascii "The result is %f\n\0"
.section .text
main:
enter $0, $0
movq $1, %rax
movq $3, %rbx
cvtsi2sd %rax, %xmm0
cvtsi2sd %rbx, %xmm1
divsd %xmm1, %xmm0
movq stdout, %rdi
movq $output, %rsi
call fprintf
leave
ret
所有这些操作也可使用单精度(32位)浮点值进行,只需将sd后缀改为ss即可,会得到addss、subss、mulss、divss、cvtss2si和cvtsi2ss等指令。但将单精度浮点值传递给需要双精度值的函数(如fprintf)时,需要改变值的“大小”来匹配。可以使用cvtss2sd将单精度浮点值转换为双精度浮点值,也可使用cvtsd2ss进行反向转换。
向量操作
SSE2不仅能处理单个值,还能同时处理多个值。XMM寄存器长128位,可用于存储两个双精度值或四个单精度值,这种存储多个值的方式称为“打包”值。
要将值移动到寄存器的不同部分,可以直接加载到寄存器的低阶字节,然后将寄存器左移为下一个值腾出空间。不过,典型的movq指令在移动64位值时会将高阶字节清零,所以要用movsd指令,它能移动四字并保持寄存器的高阶字节不变。
要将XMM寄存器左移,需使用SSE特定的指令pslldq,它能将XMM寄存器左移指定的字节数。例如,pslldq $8, %xmm5会将%xmm5寄存器的内容左移8个字节,以便插入另一个四字。
下面是将内存中的双精度值复制到XMM寄存器两个四字的代码片段:
mydata:
.double 1.5
.section .text
# ... 之前的代码 ...
movq mydata, %xmm6 # movq会将高阶字节清零
pslldq $8, %xmm6 # 将该值移到高阶字节
movsd mydata, %xmm6 # 将值加载到低阶字节,不影响高阶字节
提取值时,可使用普通的movq将数据从寄存器中移出,然后使用psrldq将源寄存器右移以获取下一个值。也可以用同样的技术加载较小的值,但需要使用movss指令移动4个字节(而不是8个)且不影响其他值。虽然SSE也能处理字大小和字节大小的值,但最好一次加载4或8个字节。
以这种方式加载值可以并行执行操作。对于浮点值,指令看起来和普通算术指令类似,但会添加p(表示打包),然后是d表示双精度,s表示单精度。例如,mulpd %xmm0, %xmm1会将%xmm0和%xmm1的低阶双精度值相乘,并将结果存储在%xmm1的低阶双精度位置,同时也会对高阶双精度值进行相同操作,即使用一条指令进行两次并行乘法。如果是单精度浮点运算,一条指令可以进行四次并行乘法。
下面是一个将2.1分别与5.0、6.0、7.0和8.0同时相乘,然后使用fprintf显示结果的程序:
vectormultiply.s
.globl main
.balign 16
starting_value:
.single 2.1, 2.1, 2.1, 2.1
multiply_by:
.single 5.0, 6.0, 7.0, 8.0
output:
.ascii "Results: %f, %f, %f, %f\n\0"
.section .text
main:
enter $0, $0
# 一次加载整个128位的值
movaps starting_value, %xmm4
movaps multiply_by, %xmm5
# 乘法运算
mulps %xmm4, %xmm5
# 提取值作为fprintf的参数
movss %xmm5, %xmm0 # 提取第一个值到%xmm0
cvtss2sd %xmm0, %xmm0 # 从单精度升级为双精度
psrldq $4, %xmm5 # 移动下一个值到合适位置
movss %xmm5, %xmm1 # 提取下一个值到%xmm1
cvtss2sd %xmm1, %xmm1 # 从单精度升级为双精度
psrldq $4, %xmm5 # 移动下一个值到合适位置
movss %xmm5, %xmm2 # 提取下一个值到%xmm2
cvtss2sd %xmm2, %xmm2 # 从单精度升级为双精度
psrldq $4, %xmm5 # 移动下一个值到合适位置
movss %xmm5, %xmm3 # 提取下一个值到%xmm3
cvtss2sd %xmm3, %xmm3 # 从单精度升级为双精度
movq $4, %rax # 保护4个XMM寄存器
# 调用函数
movq stdout, %rdi
movq $output, %rsi
call fprintf
leave
ret
可以看到,虽然将值整理成参数花了些功夫,但实际乘法只用了一条指令。如果有大量数学运算,这是一种快速完成的方法。SSE也能对整数进行向量操作,相关指令以p(表示打包)为前缀,后面跟着操作名称和操作数的大小(b表示字节,w表示字,d表示双字,q表示四字),例如paddw %xmm0, %xmm1会将%xmm0的每个字节与%xmm1相加。
栈的初始状态
栈初始时已经有以下值(从高内存到低内存):
- 空指针
- 第n个环境变量的指针(如果存在)
- 第二个环境变量的指针(如果存在)
- 第一个环境变量的指针(如果存在)
- 空指针
- 第n个程序参数的指针(如果存在)
- 第二个程序参数的指针(如果存在)
- 第一个程序参数的指针(如果存在)
- 程序文件名的指针
- 命令行参数计数(包括命令本身)
因此,在_start入口点,可以通过栈偏移量将这些信息加载到程序中。环境变量以单个字符串形式存储,如MYVAR = MYVAL,如果需要,要将其拆分为键(MYVAR)和值(MYVAL)。
下面的代码将参数计数加载到%rax,将文件名的指针加载到%rbx:
_start:
movq 0(%rsp), %rax
movq 8(%rsp), %rbx
如果使用C库(从main开始而不是_start),C库会帮我们完成这些操作。main的第一个参数是命令行参数计数,会在%rdi中可用;第二个参数是指向命令行参数字符串指针数组的指针,该指针会在%rsi中。还可以通过调用getenv函数获取单个环境变量,或调用environ函数获取所有环境变量。
需要注意的是,栈的起始位置(在内存中向下增长)接近0x00007fffffffffff,但Linux会对其进行一些随机化处理,防止黑客猜出栈在内存中的实际位置。
汇编编程中的浮点运算与栈初始状态
指令总结与对比
为了更清晰地理解各种指令的用途和特点,我们将前面介绍的一些关键指令进行总结和对比:
| 指令类型 | 指令示例 | 功能描述 | 适用场景 |
| — | — | — | — |
| 基本运算 | pdivX SRC, DST | 对DST与SRC进行并行除法运算,结果存于DST(仅整数) | 整数并行除法场景 |
| 类型转换 | cvtXX2YY SRC, DST | 将SRC低阶字节从XX类型转换为YY类型的DST低阶字节 | 不同类型数据转换 |
| 内存操作 | lock | 作为前缀与面向内存指令结合,确保对地址缓存的独占访问 | 多线程或并发环境下内存操作 |
| 无操作 | nop | 执行“无操作”操作 | 调试或填充指令序列 |
| 浮点加载 | cvtsi2sd | 将整数转换为双精度浮点数 | 从整数转换为浮点数场景 |
| 浮点运算 | addsd, mulsd, divsd | 对双精度浮点数进行加、乘、除运算 | 双精度浮点运算场景 |
| 寄存器移动 | movdqu, movdqa, movups, movaps | 移动128位数据,有对齐和未对齐之分,针对整数或浮点优化 | 不同对齐要求和数据类型的寄存器移动 |
| 向量运算 | mulpd, mulps | 对打包的双精度或单精度浮点数进行并行乘法运算 | 多数据并行运算场景 |
通过这个表格,我们可以更直观地看到不同指令在功能和适用场景上的差异,方便在实际编程中根据需求选择合适的指令。
浮点运算的注意事项
在进行浮点运算时,除了前面提到的精度和速度问题,还有一些其他的注意事项:
-
精度损失
:由于浮点数的尾数和指数长度有限,在进行复杂运算时,可能会累积精度损失。例如,多次进行加法和乘法运算后,结果可能与理论值有较大偏差。在对精度要求较高的场景中,需要谨慎处理。
-
异常处理
:浮点运算可能会产生一些异常情况,如除零错误、溢出等。在编程时,需要考虑如何处理这些异常,以确保程序的稳定性。可以通过检查状态寄存器或使用特定的异常处理机制来实现。
-
代码优化
:虽然浮点运算相对较慢,但可以通过合理的代码优化来提高性能。例如,尽量减少不必要的浮点运算,合理安排运算顺序,利用向量运算的并行性等。
栈操作的实际应用
栈的初始状态和操作在实际编程中有很多应用场景,下面我们通过一个简单的示例来展示如何利用栈的信息:
; 示例代码:打印命令行参数和环境变量
.globl _start
.section .text
_start:
; 加载参数计数到 %rax
movq 0(%rsp), %rax
; 加载文件名指针到 %rbx
movq 8(%rsp), %rbx
; 打印文件名
movq %rbx, %rdi
call print_string
; 打印参数计数
movq %rax, %rdi
call print_number
; 遍历环境变量
movq %rsp, %r10
addq $16, %r10 ; 跳过参数计数和文件名
loop_env:
movq (%r10), %r11
cmpq $0, %r11
je end_loop_env
movq %r11, %rdi
call print_string
addq $8, %r10
jmp loop_env
end_loop_env:
; 退出程序
movq $60, %rax
xorq %rdi, %rdi
syscall
; 打印字符串函数
print_string:
movq %rdi, %rsi
movq $1, %rax
movq $1, %rdi
call strlen
movq %rax, %rdx
syscall
ret
; 打印数字函数
print_number:
; 这里可以实现将数字转换为字符串并打印的逻辑
ret
; 计算字符串长度函数
strlen:
xorq %rax, %rax
loop_strlen:
cmpb $0, (%rsi, %rax, 1)
je end_strlen
incq %rax
jmp loop_strlen
end_strlen:
ret
这个示例代码展示了如何从栈中获取命令行参数计数、文件名和环境变量,并将它们打印出来。通过栈偏移量,我们可以方便地访问这些信息,为程序的进一步处理提供基础。
总结与展望
汇编编程中的浮点运算和栈操作是非常重要的内容,它们涉及到计算机底层的数值处理和内存管理。浮点运算虽然存在精度和速度等问题,但通过合理的使用和优化,可以在科学计算、图形处理等领域发挥重要作用。栈操作则为程序的参数传递、环境变量管理等提供了基础支持。
在未来的编程中,随着计算机硬件的不断发展,浮点运算和向量操作的性能将不断提升,同时也会有更多的指令和技术出现。我们需要不断学习和掌握这些新的知识,以更好地应对各种复杂的编程需求。同时,对于汇编编程这样的底层技术,深入理解其原理和机制,将有助于我们编写更高效、更稳定的程序。
总之,汇编编程中的浮点运算和栈操作是一个广阔而深入的领域,值得我们不断探索和研究。希望通过本文的介绍,能帮助大家更好地理解和应用这些知识。
以上就是关于汇编编程中浮点运算和栈操作的相关内容,希望对大家有所帮助。在实际编程中,要根据具体需求灵活运用这些知识,不断积累经验,提高编程能力。
超级会员免费看
77

被折叠的 条评论
为什么被折叠?



