天天看點

自上而下,逐漸揭開PHP解析大整數的面紗

浮點數精度丢失是一個長久的問題,PHP中精度丢失并不是PHP的bug,是計算機表示範圍導緻的問題。精度,這個問題的原因看起來不太重要,雖然學這個對于實際上的業務開發也沒什麼用,不會讓你的開發能力“duang"地一下上去幾個level,但是了解了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);
           

輸出的結果都是

最後隻有下面這種方案是可行的:

$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解析大整數的面紗

接下來就想看看ASSIGN是在哪裡執行的。

2、gdb調試

2-1、用list檢視有什麼地方可以進行斷點

自上而下,逐漸揭開PHP解析大整數的面紗

2-2、暫時沒有頭緒,在1186斷點試試

自上而下,逐漸揭開PHP解析大整數的面紗

結果程式走到sapi/cli/php_cli.c檔案的1200行了,按n不斷下一步執行,一直到這裡就走到了程式輸出結果了:

自上而下,逐漸揭開PHP解析大整數的面紗

2-4、于是猜測,ASSIGN操作是在do_cli函數裡面進行的,是以對do_cli函數做斷點:break do_cli。

輸入n,不斷回車,在sapi/cli/php_cli.c檔案的993行之後就走到程式輸出結果了:

自上而下,逐漸揭開PHP解析大整數的面紗

2-5、再對php_execute_script函數做斷點:break php_execute_script,不斷逐漸執行,發現在main/main.c檔案的2537行就走到程式輸出結果了:

自上而下,逐漸揭開PHP解析大整數的面紗

2-6、繼續斷點的步驟:break zend_execute_scripts,重複之前的步驟,發現在zend/Zend.c檔案的1476行走到了程式輸出結果的步驟:

自上而下,逐漸揭開PHP解析大整數的面紗

看到這裡的時候,第1475行裡有一個op_array,就猜測會不會是在op_array的時候就已經有值了,于是開始列印op_array的值:

自上而下,逐漸揭開PHP解析大整數的面紗

列印之後并沒有看到有用的資訊,但是其實這裡包含有很大的資訊量,比如opcode的handler: ZEND_ASSIGN_SPEC_CV_RETVAL_CV_CONST_RETVAL_UNUSED_HANDLER,但是當時沒注意到,是以就想着看看op_array是怎麼被指派的,相關步驟做了什麼。

2-7、重新從2-5的斷點開始,讓程式逐漸執行,看到op_array的指派如下:

自上而下,逐漸揭開PHP解析大整數的面紗

看到第1470行将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解析大整數的面紗

結果發現還是跟之前的一樣,但是此時看到有一個opcodes的值,再列印看看

自上而下,逐漸揭開PHP解析大整數的面紗
自上而下,逐漸揭開PHP解析大整數的面紗

看到opcode = 38,網上查到38代表指派

自上而下,逐漸揭開PHP解析大整數的面紗

2-8、于是可以知道,在這一步之前就已得到了ASSIGN的opcode,是以,不斷地往前找,從op_array開始初始化時就開始,逐漸列印op_array->opcodes的值,一直都是null,

自上而下,逐漸揭開PHP解析大整數的面紗

直到執行了

CG(zend_lineno) = last_lineno;

才得到opcode = 38 的值:

自上而下,逐漸揭開PHP解析大整數的面紗

因為這一句:CG(zend_lineno) = last_lineno;是一個宏,是以也沒頭緒,接近放棄狀态。。。

于是先去了解opcode的資料結構,在深入了解PHP核心書裡找到opcode處理函數查找這一章,給了我一些繼續下去的思路。

引用裡面的内容:

在PHP内部有一個函數用來快速的傳回特定opcode對應的opcode處理函數指針:zend_vm_get_opcode_handler()函數:

自上而下,逐漸揭開PHP解析大整數的面紗

知道其實opcode處理函數的命名是有以下規律的

ZEND_[opcode]_SPEC_(變量類型1)_(變量類型2)_HANDLER
           

根據之前調試列印出來的内容,在2-6的時候就看到了一個handler的值:

自上而下,逐漸揭開PHP解析大整數的面紗

ZEND_ASSIGN_SPEC_CV_CONST_RETVAL_UNUSED_HANDLER,

找出函數的定義如下:

自上而下,逐漸揭開PHP解析大整數的面紗

可以看到,opcode操作的時候,值是從EX_CONSTANT擷取的,根據定義展開這個宏,那就是

opline->op2->execute_data->literals
           

這裡可以得到兩個資訊:

1、參數的轉換在opcode執行前就做好了

2、指派過程取值時是在op2->execute_data->literals,如果猜想沒錯的話,op2->execute_data->literals此時儲存的就是格式轉換後的值,可以列印出來驗證一下

列印結果如下:

自上而下,逐漸揭開PHP解析大整數的面紗

猜想驗證正确,但是沒有看到真正做轉換的地方,還是不死心,繼續找PHP的Zend底層做編譯的邏輯代碼。

參考開源的GitHub項目,PHP編譯階段如下圖:

自上而下,逐漸揭開PHP解析大整數的面紗

猜測最有可能的是在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解析大整數的面紗

函數原型如下:

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。參考鳥哥部落格:http://www.laruence.com/2011/12/19/2399.html

總結

這個問題的原因看起來不太重要,雖然學這個對于實際上的業務開發也沒什麼用,不會讓你的開發能力“duang"地一下上去幾個level,但是了解了PHP對于大整數的處理,也是自己知識架構的一個小小積累,知道了為什麼之後,在日常開發中就會多加注意,比如從存儲以及使用指派的角度。了解這個細節還是很有好處的。

回想整個解決問題的過程,個人感覺有點長,總共大約花了4個小時去定位這個問題。因為對PHP的核心隻是一知半解,沒有系統的把整個流程梳理下來,是以一開始也不知道從哪裡開始下手,就開始根據自己的猜測來調試。現在回想起來,應該先學習PHP的編譯、執行流程,然後再去猜測具體的步驟。

原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

如果本文對你有幫助,請點下推薦吧,謝謝_

更多精彩内容,請關注個人公衆号。