自上而下,逐步揭开PHP解析大整数的面纱

遇到的问题

最近遇到一个PHP大整数的问题,问题代码是这样的

$shopId = 17978812896666957068;
var_dump($shopId);

上面的代码输出,会把$shopId转换成float类型,且使用了科学计数法来表示,输出如下:

float(1.7978812896667E+19)

但在程序里需要的是完整的数字作为查找数据的参数,所以需要用的是完整的数字,当时以为只是因为数据被转换成科学计数法了,于是想到的解决方案是强制让它不使用科学计数法表示:

$shopId= number_format(17978812896666957068);
var_dump($shopId);

这时候奇怪的事情出现了,输出的是:

17978812896666957824

当时没有仔细看,对比了前十位就没有继续往下看,所以认为问题解决了,等到真正根据ID去找数据的时候才发现数据查不出来,这时候才发现是数据转换错误了。

这里使用number_format失败的原因在后面会讲到,当时就想到将原来的数据转成字符串的,但是使用了以下方法仍然不行

$shopId= strval(17978812896666957068);
var_dump($shopId);

$shopId = 17978812896666957068 . '';
var_dump($shopId);

输出的结果都是

float(1.7978812896667E+19)

最后只有下面这种方案是可行的:

$shopId = '17978812896666957068';
var_dump($shopId);

// 输出
// string(20) "17978812896666957068"

众所周知,PHP是一门解释型语言,所以当时就大胆地猜测PHP是在编译期间就将数字的字面量常量转换成float类型,并用科学计数法表示。但仅仅猜测不能满足自己的好奇心,想要看到真正实现代码才愿意相信。于是就逐步分析、探索,直到找到背后的实现。

刚开始根据这个问题直接上网搜“PHP大整数解析过程”,并没有搜到答案,因此只能自己去追查。一开始对PHP的执行过程不熟悉,出发点就只能是一步一步地调试,然后

示例代码:

// test.php
$var = 17978812896666957068;
var_dump($var);

追查过程

1、查看opcode
通过vld查看PHP执行代码的opcode,可以看到,赋值的是一个ASSIGN的opcode操作


php整数01

接下来就想看看ASSIGN是在哪里执行的。

2、gdb调试
2-1、用list查看有什么地方可以进行断点

php整数02

2-2、暂时没有头绪,在1186断点试试


php整数03

结果程序走到sapi/cli/php_cli.c文件的1200行了,按n不断下一步执行,一直到这里就走到了程序输出结果了:


php整数04

2-4、于是可以猜测,ASSIGN操作是在do_cli函数里面进行的,因此对do_cli函数做断点:break do_cli。
输入n,不断回车,在sapi/cli/php_cli.c文件的993行之后就走到程序输出结果了:


php整数05

2-5、再对php_execute_script函数做断点:break php_execute_script
不断逐步执行,发现在main/main.c文件的2537行就走到程序输出结果了:


php整数06

2-6、继续断点的步骤:break zend_execute_scripts
重复之前的步骤,发现在zend/Zend.c文件的1476行走到了程序输出结果的步骤:


php整数07

看到这里的时候,第1475行里有一个op_array,就猜测会不会是在op_array的时候就已经有值了,于是开始打印op_array的值:


php整数08

打印之后并没有看到有用的信息,但是其实这里包含有很大的信息量,比如opcode的handler: ZEND_ASSIGN_SPEC_CV_RETVAL_CV_CONST_RETVAL_UNUSED_HANDLER,但是当时没注意到,因为就想着看看op_array是怎么被赋值的,相关步骤做了什么,忽略了这个重要的信息,之后还是会回到这个handler。

2-7、重新从2-5的断点开始,让程序逐步执行,看到op_array的赋值如下:


php整数09

将zend_compile_file函数运行的结果赋值给op_array了,于是break zend_compile_file,被告知zend_compile_file未定义,通过源码工具追踪到zend_compile_file指向的是compile_file,于是break zend_compile

发现是在Zend/zend_language_scanner.l 文件断点了,逐步执行,看到这行pass_two(op_array),猜测可能会在这里就有值,所以打印看看:


php整数10

结果发现还是跟之前的一样,但是此时看到有一个opcodes的值,打印看看


php整数11
php整数12

看到opcode = 38,网上查到38代表赋值

php整数13

2-8、于是可以知道,在这一步之前就已得到了ASSIGN的opcode,因此,不断的往前找,从op_array开始初始化时就开始,逐步打印op_array->opcodes的值,一直都是null:


php整数14

直到执行了CG(zend_lineno) = last_lineno;才得到opcode = 38 的值:


php整数15

因为这一句:CG(zend_lineno) = last_lineno;是一个宏,所以也没头绪,接近放弃状态。。。

于是先去了解opcode的数据结构,在深入理解PHP内核书里找到opcode处理函数查找这一章,给了我一些继续下去的思路。

引用里面的内容:
在PHP内部有一个函数用来快速的返回特定opcode对应的opcode处理函数指针:zend_vm_get_opcode_handler()函数:


php整数16

知道其实opcode处理函数的命名是有以下规律的

ZEND_[opcode]_SPEC_(变量类型1)_(变量类型2)_HANDLER

根据之前调试打印出来的内容,在2-6的时候就看到了一个handler的值:


php整数17


ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER

找出函数的定义如下:


php整数18

可以看到,opcode操作的时候,值是从EX_CONSTANT获取的,根据定义展开这个宏,那就是

opline->op2->execute_data->literals

这里可以得到两个信息:
1、参数的转换在opcode执行前就做好了
2、赋值过程取值时是在op2->execute_data->literals,如果猜想没错的话,op2->execute_data->literals此时保存的就是格式转换后的值,可以打印出来验证一下

打印结果如下:


php整数19

猜想验证正确,但是没有看到真正做转换的地方,还是不死心,继续找PHP的Zend底层做编译的逻辑代码。

参考开源的GitHub项目,PHP编译阶段如下图:

php整数20

猜测最有可能的是在zendparse、zend_compile_top_stmt这两个阶段完成转换,因为这个两个阶段做的事情就是将PHP代码转换成opcode数组。

上网搜索了PHP语法分析相关的文章,有一篇里面讲到了解析整数的过程,因此找到了PHP真正将大整数做转换的地方:

<ST_IN_SCRIPTING>{LNUM} {
    char *end;
if (yyleng < MAX_LENGTH_OF_LONG - 1) { /* Won't overflow */
    errno = 0;
    ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));
    /* This isn't an assert, we need to ensure 019 isn't valid octal
    * Because the lexing itself doesn't do that for us
    */
    if (end != yytext + yyleng) {
        zend_throw_exception(zend_ce_parse_error, "Invalid numeric literal", 0);
        ZVAL_UNDEF(zendlval);
        RETURN_TOKEN(T_LNUMBER);
    }
} else {
    errno = 0;
    ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 0));
    if (errno == ERANGE) { /* Overflow */
        errno = 0;
        if (yytext[0] == '0') { /* octal overflow */
            ZVAL_DOUBLE(zendlval, zend_oct_strtod(yytext, (const char **)&end));
        } else {
            ZVAL_DOUBLE(zendlval, zend_strtod(yytext, (const char **)&end));
        }
        /* Also not an assert for the same reason */
        if (end != yytext + yyleng) {
            zend_throw_exception(zend_ce_parse_error,
            "Invalid numeric literal", 0);
            ZVAL_UNDEF(zendlval);
            RETURN_TOKEN(T_DNUMBER);
        }
        RETURN_TOKEN(T_DNUMBER);
    }    
    /* Also not an assert for the same reason */
    if (end != yytext + yyleng) {
        zend_throw_exception(zend_ce_parse_error, "Invalid numeric literal", 0);
        ZVAL_UNDEF(zendlval);
        RETURN_TOKEN(T_DNUMBER);
    }
}
ZEND_ASSERT(!errno);
RETURN_TOKEN(T_LNUMBER);
}

可以看到,zend引擎在对PHP代码在对纯数字的表达式做词法分析的时候,先判断数字是否有可能会溢出,如果有可能溢出,先尝试将其用LONG类型保存,如果溢出,先用zend_strtod将其转换为double类型,然后用double类型的zval结构体保存之。

number_format失败的原因

通过gdb调试,追查到number_format函数,在PHP底层最终会调用php_conv_fp函数对数字进行转换:


php整数21

函数原型如下:

PHPAPI char * php_conv_fp(register char format, register double num, boolean_e add_dp, int precision, char dec_point, bool_int * is_negative, char *buf, size_t *len);

这里接收的参数num是一个double类型,因此,如果传入的是字符串类型数字的话,number_format函数也会将其转成double类型传入到php_conf_fp函数里。而这个double类型的num最终之所以输出为17978812896666957824,是因为进行科学计数法之后的精度丢失了,重新转成double时就恢复不了原来的值。在C语言下验证:

double local_dval = 1.7978812896666958E+19;
printf("%f\n", local_dval);

输出的结果就是

17978812896666957824.000000

所以,这不是PHP的bug,它就是这样的。

此类问题解决方案

对于存储,超过PHP最大表示范围的纯整数,在MySQL中可以使用bigint/varchar保存,MySQL在查询出来的时候会将其使用string类型保存的。
对于赋值,在PHP里,如果遇到有大整数需要赋值的话,不要尝试用整型类型去赋值,比如,不要用以下这种:

$var = 17978812896666957068;

而用这种:

$var = '17978812896666957068';

而对于number_format,在64位操作系统下,它能解析的精度不会丢失的数,建议的最大值是这个:9007199254740991。参考鸟哥博客:关于PHP浮点数你应该知道的

总结

这个问题的原因看起来不太重要,虽然学这个对于实际上的业务开发也没什么用,不会让你的开发能力“duang”地一下上去几个level,但是了解了PHP对于大整数的处理,也是自己知识框架的一个小小积累,知道了为什么之后,在日常开发中就会多加注意,比如从存储以及使用赋值的角度。了解这个细节还是很有好处的。

回想整个解决问题的过程,个人感觉有点长,总共大约花了4个小时去定位这个问题。因为对PHP的内核只是一知半解,没有系统的把整个流程梳理下来,所以一开始也不知道从哪里开始下手,就开始根据自己的猜测来调试。现在回想起来,应该先学习PHP的编译、执行流程,然后再去猜测具体的步骤。

原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容

  • PHP执行原理** php是一门应用非常简单,开发效率极高的一门语言,其弱类型的变量能省去程序员大量的定义变量、类...
    人在码途阅读 1,449评论 1 7
  • 喜欢在热闹的世界中,找一个安静的角落,呆呆得想你。你知或者不知,都没有关系。喜欢躲在你张牙舞爪的身后,痴痴得看你,...
    圈儿里人阅读 310评论 0 2
  • 文\落雪 紫霞推浪荡回声 轻鸥未起目如鹰 遥转清风吹两袖 ...
    落雪有晴空阅读 317评论 11 9