如果你在x86 64位汇编里要算一个对数,第一反应多半是call log@PLT。这当然能出结果,但如果你只想算一次标量对数、不想背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的语义和区别
先看指令文档上的定义:
- FYL2X:ST(1) = ST(1) * log2(ST(0)),然后弹出两个寄存器,把结果压入栈顶。
- FYL2XP1:ST(1) = ST(1) * log2(ST(0) + 1.0),然后弹出两个寄存器,把结果压入栈顶。
这两条指令都接受两个操作数,乘数在ST(1),真数在ST(0)。算完以后,原来的两个值都被弹出,栈顶变成结果。
区别在于:FYL2XP1多了一个对真数加1的操作。它专门用来计算log2(1 + x)这种形式,尤其是当x很小、1+x接近1的时候,精度会比先用FYL2X算log2(1+x)更好。
| 指令 | 数学含义 | 适用场景 | 弹出行为 |
|---|---|---|---|
| FYL2X | st1 * log2(st0) | 通用对数计算 | 弹出两个操作数,压入结果 |
| FYL2XP1 | st1 * log2(st0 + 1) | x接近0时计算log2(1+x) | 弹出两个操作数,压入结果 |
这里需要特别强调:指令名里的“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.0,FYL2X才算得对。接着:
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类型返回值放在xmm0,double也是xmm0,但long double类型返回值放在st0。
所以如果你写一个返回double的汇编函数,不能直接让x87结果留在st0就ret。你需要把它转存到xmm0:
global 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 什么时候必须用FYL2XP1
FYL2XP1的存在价值,在于处理“接近1的真数”。
假设你要算ln(1 + t),其中t非常小。如果先把1+t算成一个double,再传给FYL2X,那么1+t这个数本身的存储就已经引入了舍入误差。比如t = 1e-12,double能表示1.000000000001,但这个表示的相对误差大约在1e-16量级,对应到对数值可能就不够精确了。
更好的做法是直接传t给FYL2XP1,让它算log2(1 + t)。这样输入是精确的t,不是被1+t舍入过的值。代码:
; 计算 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以原始精度直接参与计算,避免构造1+delta时丢失低位信息。
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的汇编例程,把对数-指数组合打通,你会对整个浮点指令家族有更完整的认识。