x87 FPU对数指令详解:从FYL2X到完整汇编实现
如果你在x86 64位汇编里要算一个对数第一反应多半是call logPLT。这当然能出结果但如果你只想算一次标量对数、不想背glibc那一堆重符号或者你在逆向一段老代码时撞见了FYL2X、FYL2XP1这两个指令那FPU这套x87指令就不能装作看不见了。今天把FPU对数函数这条线完整过一遍指令语义、换底公式、完整例程、精度坑还有调试技巧争取让你看完就能自己在Linux下写出一段真正能跑、结果正确的对数计算汇编代码。FPU对数运算的核心就两条指令和一组常量加载指令。这两条指令是硬件直接实现的对数运算不需要你手动算多项式也不需要调用外部数学库。理解它们的关键是搞清楚x87那套“栈式寄存器”的操作顺序。很多人第一次用FYL2X卡了半天结果算出个0十有八九就是st0、st1顺序搞反了。1. 为什么x86-64时代还要看x87 FPU的对数指令1.1 x87 FPU并没有“过时”只是换了个身份进入x86-64之后浮点运算的主流工具变成了SSE2的XMM寄存器。系统V的调用约定里浮点参数用xmm0传、浮点结果也用xmm0返回。于是很多人形成一种印象x87是古董是32位时代留下的包袱。但实际情况是x87指令集在64位模式下被完整保留Linux下照样能执行。而且x87有SSE完全没有的硬件超越函数指令比如算对数的FYL2X、算指数的F2XM1、算三角函数的FSIN/FCOS/FPTAN。SSE是向量化的主力但它没有对应的硬件对数指令。所以如果你需要的只是一个标量对数计算用x87反而是指令数最少的路径。我在实际中遇到x87对数指令主要有三类场景第一是读别人的反汇编代码。老的程序、早期编译器生成的目标文件、手写汇编教程里的例程经常混着x87指令。你不认识FYL2X就很难快速还原这段代码在算什么。第二是写一个不依赖libm的小工具。比如自己写引导代码、写极简运行时或者只是想在汇编实验里快速验证一个数学想法用一条硬件指令比链接整个数学库干净得多。第三是做高精度中间计算。x87内部用80位扩展精度如果你把整个对数计算链路都留在FPU栈里最后再舍入到double中间误差会比直接用SSE算double要小。这一点放到后面精度小节细说。1.2 对数函数的“一条指令”诱惑调用C数学库的log内部是一套精心优化过的多项式求值通常有十几条甚至几十条指令。而FYL2X一条指令就完成了核心的以2为底的对数计算再配一条FLDLN2之类的常量加载指令换底公式也就一两条乘法。这个指令密度对比很直观。当然别把“一条指令”神话。一条硬件微码指令在CPU内部执行的周期数并不少x87指令在现代CPU上的延迟也不算低。它真正的优势是编码简单、行为确定、不依赖外部函数。在性能上它不一定比glibc快但在“只想写个能跑的标量对数”这个需求上它是真的省心。1.3 一个最常见的误区x87是“过时”所以不用学我见过不少搞汇编的人一看到x87就跳过觉得能跑SSE就够了。但反过来想如果你连一条FYL2X都看不懂逆向一个老程序时就会卡住。而且x87这套栈式寄存器设计和普通寄存器模型的思维方式差别很大不亲自写几次、踩几次顺序坑很难真正建立感觉。下面我开始拆指令然后直接上完整代码。2. 对数运算的数学公式与FPU指令对应2.1 换底公式是唯一要记的数学任意底数b的对数都可以统一换算成以2为底log_b(x) log2(x) / log2(b)为什么是以2为底因为FPU的硬件对数指令FYL2X算的就是以2为底的对数。其他底数比如自然对数ln(x)、常用对数log10(x)都是通过这个公式换出来的。具体换算ln(x) log2(x) * ln(2)log10(x) log2(x) * log10(2)log_b(x) log2(x) / log2(b)所以你只需要掌握一个核心计算怎么用FYL2X得到log2(x)。剩下全是乘除法。2.2 FYL2X与FYL2XP1的语义和区别先看指令文档上的定义FYL2XST(1) ST(1) * log2(ST(0))然后弹出两个寄存器把结果压入栈顶。FYL2XP1ST(1) ST(1) * log2(ST(0) 1.0)然后弹出两个寄存器把结果压入栈顶。这两条指令都接受两个操作数乘数在ST(1)真数在ST(0)。算完以后原来的两个值都被弹出栈顶变成结果。区别在于FYL2XP1多了一个对真数加1的操作。它专门用来计算log2(1 x)这种形式尤其是当x很小、1x接近1的时候精度会比先用FYL2X算log2(1x)更好。指令数学含义适用场景弹出行为FYL2Xst1 * log2(st0)通用对数计算弹出两个操作数压入结果FYL2XP1st1 * log2(st0 1)x接近0时计算log2(1x)弹出两个操作数压入结果这里需要特别强调指令名里的“L2”表示log2也就是以2为底。你要算ln(x)得乘上ln(2)你要算log10(x)得乘上log10(2)。FPU正好提供了这几个常数加载指令等会儿就到。2.3 配套的常量加载指令FPU内置了几个扩展精度常数可以直接压入栈顶。这比在.data段写一个double再用fld加载更精准因为内置常数是80位扩展精度版本没有经过64位double舍入。FLD1压入1.0用于给FYL2X提供一个“乘数1”从而直接得到log2(x)FLDL2E压入log2(e)FLDL2T压入log2(10)FLDLG2压入log10(2)FLDLN2压入ln(2)FLDPI顺带一提压入圆周率π对应关系要记清楚。很多人把FLDLN2和FLDLG2搞混一个是ln(2)约0.693147一个是log10(2)约0.301030。算自然对数就找“LN”算常用对数就找“LG”。2.4 操作数顺序是最大的坑FYL2X的公式是st1 * log2(st0)。这意味着你要让“乘数”在st1“真数”在st0。由于FPU是栈结构后压入的值会出现在栈顶st0所以正确的压栈顺序是先压入乘数比如1.0或ln2再压入真数x如果搞反了先压真数x再压乘数1.0那么执行FYL2X算的就是x * log2(1.0)等于x乘以0结果为0。我第一次写这个代码时就犯了这个错花了不少时间排查才发现栈序反了。下面完整演示计算ln(x)的代码。代码用NASM在Linux x86-64下编译运行调用printf输出结果方便验证。3. 从零实现一个对数计算例程3.1 环境准备NASM gcc先准备环境。示例代码要调用printf所以用gcc来链接nasm -f elf64 log_demo.asm -o log_demo.o gcc -no-pie log_demo.o -o log_demo ./log_demo如果不调用printf只想写纯汇编syscall输出那工作量主要在浮点数转字符串上和主题关系不大。用printf做验证窗口最直观也能顺便演示x86-64下调用可变参数函数时几个容易踩的细节。3.2 计算ln(x)的完整代码; log_demo.asm ; 计算 ln(120.0)并输出结果 global main extern printf section .data x dq 120.0 fmt_ln db ln(%f) %f, 10, 0 section .text main: push rbp mov rbp, rsp ; 计算 ln(x) fld1 ; st0 1.0 fld qword [x] ; st0 120.0, st1 1.0 fyl2x ; st0 1.0 * log2(120.0) log2(120.0) fldln2 ; st0 ln2, st1 log2(120.0) fmulp ; st0 log2(120.0) * ln2 ln(120.0) ; 把结果存储到内存 sub rsp, 8 fstp qword [rsp] ; 弹出结果到临时内存 ; 调用 printf两个double参数 movsd xmm0, qword [x] movsd xmm1, qword [rsp] lea rdi, [fmt_ln] mov eax, 2 ; AL表示XMM寄存器传参数量 call printf add rsp, 8 xor eax, eax pop rbp ret编译运行后输出ln(120.000000) 4.787492用Python或计算器验证一下math.log(120)的值约4.787491742782046结果一致。3.3 代码与FPU栈状态逐行拆解这段代码的关键在计算部分我用栈状态变化来走一遍fld1 ; 栈: [1.0] fld qword [x] ; 栈: [120.0, 1.0] fyl2x ; 栈: [log2(120.0)]这里正好体现2.4节说的顺序。先压乘数1.0再压真数120.0FYL2X才算得对。接着fldln2 ; 栈: [ln2, log2(120.0)] fmulp ; 栈: [log2(120.0) * ln2]fmulp是“浮点乘法后弹出”它把st0和st1相乘结果放在st1然后弹掉st0。最终栈顶就是ln(120)。这里有一个细节fyl2x把两个操作数都弹掉了压入唯一的结果所以后续压入ln2时原来的log2(120)自然退到st1。这就是x87栈式设计配合指令弹出规则的结果。你在纸上画一遍栈就不会迷路。3.4 计算log10(x)与任意底数计算log10(x)只改一处把FLDLN2换成FLDLG2。fld1 fld qword [x] fyl2x ; st0 log2(x) fldlg2 ; st0 log10(2), st1 log2(x) fmulp ; st0 log2(x) * log10(2) log10(x)输出格式串改成log10(%f) %f即可。计算任意底数b的对数就按换底公式来log_b(x) log2(x) / log2(b)。需要计算两个log2然后做一次浮点除法; 计算 log_b(x)b和x都是内存中的double fld1 fld qword [x] fyl2x ; st0 log2(x) fld1 fld qword [b] fyl2x ; st0 log2(b), st1 log2(x) fdivp ; st0 st1 / st0 log2(x) / log2(b)fdivp是“浮点除法后弹出”它把st1除以st0结果放在st1然后弹掉st0。所以最后栈顶就是任意底数的对数。这里要注意除数与被除数的位置。fdivp默认计算的是st1 / st0所以要先让被除数log2(x)在st1再让除数log2(b)在st0。正好和入栈顺序相反先算log2(x)后算log2(b)那么后者自然压在前者之上满足条件。3.5 写成函数返回给C调用时的返回值约定如果你想把对数计算封装成一个汇编函数给C调用要注意x86-64 System V ABI里的一个关键约定float类型返回值放在xmm0double也是xmm0但long double类型返回值放在st0。所以如果你写一个返回double的汇编函数不能直接让x87结果留在st0就ret。你需要把它转存到xmm0global my_ln section .text ; double my_ln(double x) my_ln: sub rsp, 8 movsd [rsp], xmm0 ; 存入入参x fld1 fld qword [rsp] fyl2x fldln2 fmulp fstp qword [rsp] ; 结果存入临时内存 movsd xmm0, [rsp] ; 放入返回寄存器 add rsp, 8 ret这里sub rsp, 8同时起到了两个作用提供临时存储空间以及让call下一条指令前栈保持对齐。因为call指令会把返回地址压栈在函数入口处rsp % 16 8减去8之后变成0对齐。如果C函数声明成long double my_ln(long double x)那传入参数时会使用st0返回值也约定在st0。这种写法对调用者更省事不过long double在printf里格式化比较麻烦日常调试还是建议先用double版本。4. 精度问题与高精度入场4.1 内部80位扩展精度的价值x87 FPU内部所有寄存器都是80位扩展精度所有运算也以这个精度执行。这意味着如果整个计算链路都保持在FPU栈中中间结果不会因为存储到64位内存而提前舍入。比如算ln(x)从FYL2X到乘ln2全程在栈里完成最后的误差只有一次最终舍入。如果用SSE2和math库虽然库内部也做了精度补偿但那条路径是为向量化和通用性设计的。FPU这条链路在“连续标量运算”的场景下确实有精度优势。我做过一个实验用x87栈内连续算ln(ln(x))这种嵌套最后输出double和先用double算完ln(x)、再把结果转成double算第二次ln相比两者的差值有时能在最后一位看到。对数函数本身对输入误差比较敏感中间多保留几位扩展精度最终舍入的积累误差会小一点。当然这个差异通常很小日常计算完全可以忽略。但如果你想理解x87为什么到今天还有人用这就是原因之一它内部不是double而是比double更宽的扩展精度。4.2 什么时候必须用FYL2XP1FYL2XP1的存在价值在于处理“接近1的真数”。假设你要算ln(1 t)其中t非常小。如果先把1t算成一个double再传给FYL2X那么1t这个数本身的存储就已经引入了舍入误差。比如t 1e-12double能表示1.000000000001但这个表示的相对误差大约在1e-16量级对应到对数值可能就不够精确了。更好的做法是直接传t给FYL2XP1让它算log2(1 t)。这样输入是精确的t不是被1t舍入过的值。代码; 计算 ln(1 t)t 是内存里的double且绝对值很小 fldln2 ; st0 ln2 fld qword [t] ; st0 t, st1 ln2 fyl2xp1 ; st0 ln2 * log2(1 t) ln(1 t)注意压栈顺序先压乘数ln2再压增量t。这和FYL2X的“先乘数后真数”原则一致只是这里的真数被FYL2XP1内部自动加1。如果你的程序入参就是一个很接近1的数比如用户传的是1.0000001那么到底用FYL2X还是FYL2XP1我的经验是如果这个数是以“1.0000001”这种形式直接存在于内存的那用FYL2X问题也不大如果这个数是从两个测量值相减得到的增量或者算法里天然就有“1 delta”的结构那优先用FYL2XP1让delta以原始精度直接参与计算避免构造1delta时丢失低位信息。4.3 常量加载指令与手动定义常量的差别很多人会图省事在.data段写ln2 dq 0.6931471805599453然后用fld qword [ln2]加载。这当然能算但有个容易被忽略的问题这个十进制数先被转换成64位double精度已经被限制一次了。而fldln2加载的是FPU内置的80位扩展精度常量数值更接近ln(2)的真实值。单次乘法里这个差别微乎其微但如果你写的是一个需要稳定复现结果的双精度数学函数或者在做一致性对比尽量用指令内置常量。这既是精度习惯也是代码风格问题——看到FLDLN2读者立刻知道你算的是自然对数意图更清晰。5. 调试FPU对数代码的实战记录5.1 用gdb直接看FPU栈FPU代码最容易出问题的就是栈状态。我的调试习惯是在可疑指令前打断点然后用gdb查看浮点寄存器栈break log_demo.asm:16 run info floatinfo float会列出st0到st7当前的值以及每个寄存器的tag状态。一眼就能看出来栈里是[120.0, 1.0]还是[1.0, 120.0]。很多栈序问题靠肉眼盯代码容易漏看寄存器栈马上真相大白。我在调试FYL2X时最常见的情形就是栈里出现0或NaN。st0是0基本可以断定是乘数/真数压栈顺序反了st0是NaN往往是操作数里混入了非规格化数或者栈深超过8导致异常。5.2 FPU异常为什么会“静默”FPU的异常默认大多是屏蔽的。也就是说栈上溢出、下溢、除零这类错误发生时CPU不会像整数除零那样触发信号而是设置状态字里的异常位并且给结果填一个NaN或Inf。程序继续往下跑你看到的是一个大得离谱的数或一堆nan没有任何报错。检查FPU状态字可以用fstsw指令fstsw ax test ax, 0x3800 jnz fpu_error0x3800对应的是几个异常汇总位。但在大多数普通应用中你不一定要写这么完整的检查。真正要养成的是习惯FPU写出NaN或Inf时先回头查栈状态和控制字而不是急着怀疑printf。5.3 我踩过的三个典型问题第一个就是栈序颠倒。我自己第一次写FYL2X算ln时错误地先压了真数再压1.0然后FYL2X算出了0。排查过程还挺曲折最后是info float救了我栈顶st0是0而st1是120.0说明FYL2X执行的是120.0 * log2(1.0)。把这个教训记住FYL2X永远是st1 * log2(st0)所以“先压乘数、后压真数”。第二个是printf的AL寄存器。x86-64下可变参数函数中浮点参数通过XMM寄存器传递AL的值表示“使用了多少个向量寄存器”。调用printf传两个double时要mov eax, 2。我试过只设置rdi和xmm0、xmm1但不碰eax结果某些glibc版本下输出正常某个环境下突然输出垃圾值。这种“时灵时不灵”最坑后来一律按ABI要求设AL再也没出过问题。第三个问题是函数返回前FPU栈没有清空。在汇编函数里计算完对数把结果存走以后一定要确认栈是空的。残留的FPU寄存器值不会影响普通整数代码但会影响后续任何使用x87的代码甚至在某些极端情况下栈深超过8会被x87的tag机制标记为invalid。写工具函数时入口和出口的栈状态保持一致这是最基本的卫生习惯。5.4 验证数值的正确姿势FPU算出来的对数对不对肉眼不好估。我一般直接开个终端python3 -c import math; print(math.log(120), math.log10(120))然后用printf输出结果对照。注意不要只比前几位小数多比几位至少要到printf默认的6位小数以上。如果你输出的结果最后一位有偏差不一定是FPU错也可能是printf的四舍五入策略和Python不同。重点看前6位是否一致对数计算精度在这个量级上不该有问题。6. 从x87之外再看一眼SSE与多项式6.1 现代C数学库怎么算log现代glibc的log实现用的是精心设计的多项式逼近配合SSE2/AVX向量化。它没有硬件log指令可用所以选择了软件算法。这样做的原因很现实现代计算里对数往往是批量处理的SSE一次能算4个甚至8个double吞吐量远超标量x87而且软件算法可以严格保证IEEE double精度舍入内部还可以做补偿修正。所以如果你追求的是大规模数据处理别抱着FYL2X不放用编译器自动向量化或者手写SSE多项式是更主流的路。6.2 什么时候仍然值得用x87对数指令我总结出三个还值得用的场景你只需要计算一次标量对数不想链接庞大的数学库。你在解析反汇编代码看到FYL2X需要能立刻反应出它在算什么这本身就是技能价值。你需要完整利用80位扩展精度做中间计算x87栈内运算天然是扩展精度这条路径SSE反而给不了。性能上我也做过简单测试。现代CPU上FYL2X的延迟大概是几十个周期和glibc的log差得不算离谱。但x87版本通常难以向量化所以它只有在标量路径上才有竞争力。写性能敏感代码前最好先用自己的环境实测不要凭印象选型。6.3 给新手的一个操作习惯如果你刚接触FPU我强烈建议在写代码之前先在纸上把每一步的栈状态画出来。一个对数计算往往只有五六条指令但栈序的微小错误就能让结果变成0或NaN。画一遍栈比debug半小时划算得多。我自己的习惯是每一对压栈指令旁边写一行注释标明当前st0、st1是什么遇到FYL2X这种弹出两个寄存器再压入一个结果的指令单独画一个箭头。写完以后照着注释复查一遍基本不会错。这个内容后面其实还可以继续扩展。比如FYL2X配合F2XM1可以组合出完整的pow函数先算log2(x)再乘上指数最后用F2XM1和scale指令还原结果。如果你在逆向程序时看到FYL2X后面跟着F2XM1那大概率是在手写浮点乘方。想深挖的话建议自己动手写一个x^y的汇编例程把对数-指数组合打通你会对整个浮点指令家族有更完整的认识。