?
1 前言
在我的上一篇文章中,有講到掌握匯編知識的重要性,關鍵時刻可能還會拯救你于泥潭之中。
那么,本篇文章,我將再介紹一個使用匯編知識排查疑難問題的方法,希望對大家有所幫助。
2 問題描述
問題是這樣的,前一段時間我們項目組在進行一項自測試中,偶然發現我們的代碼好像掛了一樣:現象就是命令行輸入不了,但是沒有看到復位信息輸出。
當時,我們一個小伙伴說:“好像我們的系統掛了?”當我了解到這個現象之后,根據我之前的排查經驗,我當即得出了一個結論:“可能是我們的代碼跑進死循環了,好好檢查下”!
于是,我們開始debug代碼,加了一些必要的調試信息,最終發現有一個計算校驗的函數,調進去了但是沒有退出來,而這個校驗的函數非常之簡單,它就長這樣:
?
uint16_t checksum(uint8_t *data, uint8_t len){ uint8_t i; uint16_t sum = 0, res; for (i = 0; i < len; i++) { sum += data[i]; } res = sum ; return res;}
?
我想當你看到這段函數時,肯定也是:“臥槽,這TM不就是算累加校驗和嗎?怎么可能會死循環?”
沒錯,當時我們的爭論的場景也的確如此!
3 簡單分析
這個checksum函數真的是非常簡單,入參簡單、實現也簡單、返回值也簡單,根本不存在難點。
一步步來分析:
既然代碼沒有崩潰,證明data指針肯定非NULL的,不會有問題;
倒是這個len有些可疑,len的類型是uint8_t無符號的,它的范圍是0-255;但是如果外面傳入的是-1呢?
如果傳入-1,強制轉換為uint8_t,其值也是255,那么下面的for循環,依然只會跑256次,它必須得退出呀?
有沒有可能for循環的過程中,棧的值被修改了,然后i的值和len的值都變了,進而for的次數改變了?
于是我們開始打印i和len的值,發現他們兩個的值,都是正常變化的,并不是剛剛想的那樣。
這就很奇怪了!!!
如果說這個for循環要“無限”循環下去,造成“死循環”,必須滿足的條件是len很大很大,但是len不是uint8_t類型嘛?最大也就255呀?
printf大法再來一遍:結果出乎我們的意料,請看:
log輸出:
?
[12-21 1938]checksum 128 len: 4294967295[12-21 1938]0 4294967295[12-21 1938]1 4294967295[12-21 1938]2 4294967295[12-21 1938]3 4294967295[12-21 1938]4 4294967295[12-21 1938]5 4294967295[12-21 1938]6 4294967295[12-21 1938]7 4294967295[12-21 1938]8 4294967295[12-21 1938]9 4294967295[12-21 1938]10 4294967295 。。。省略很多[12-21 1938]250 4294967295[12-21 1938]251 4294967295[12-21 1938]252 4294967295[12-21 1938]253 4294967295[12-21 1938]254 4294967295[12-21 1938]255 4294967295[12-21 1938]256 4294967295[12-21 1938]257 4294967295[12-21 1938]258 4294967295[12-21 1938]259 4294967295[12-21 1938]260 4294967295
?
? ? 。。。還在不停的打印
看到這里似乎有點眉目了?len的值為4294967295?
這個值不是0xFFFFFFFF嗎?
我們再使用%d打印了一下len,發現值為-1。
回過頭來看下checksum的調用之處:
?
uint16_t res = checksum(&data[0], len - 1);
?
看似真相了,當len為0的時候,傳入的值不就是-1嗎?
好像是這么回事,但是-1進去,它是uint8_t的呀,頂多就是255啊?怎么變成了4294967295? 到底是誰干的啊?
同時也發現關鍵問題了,這里并不是真正意義的“死循環”,而是for循環執行太久了,導致長時間無法結束,因為我們的主頻才160MHZ,CPU就是猛跑,從1加到0xFFFFFFFF,也需要好長一段時間呢!
4 場景再現
為了充分說明這個問題,我盡可能地還原下當時我們的代碼場景:
?
/*一個結構體定義數據不要急于吐槽它的定義,這代碼是開源的,冤有頭。。。還有不要懷疑是字節對齊不對齊的問題,曾經我也懷疑過,最后知道真相的時候,我被打臉了!*/typedef struct _data_t { /* result, final result */ uint8_t len; uint8_t flag; uint8_t passwd_len; uint8_t *passwd; uint8_t ssid_len; uint8_t *ssid; uint8_t token_len; uint8_t *token; uint8_t bssid_type_len; uint8_t *bssid; uint8_t ssid_is_gbk; uint8_t ssid_auto_complete_disable; uint8_t data[127]; uint8_t checksum;} data_t;
?
?
/* 1.c 調用checksum的C文件 *//* 定義全局的數據 */static data_t g_data;/* 設置全局的數據 */void set_global_data(void){ g_data.len = 0;}void handle_global_data(void){ uint16_t res = checksum(&g_data.data[0], g_data.len - 0); //sometimes no return form checksum}void test_func_entry(void){ set_global_data(); handle_global_data();}
?
?
/* 2.c 定義checksum函數的工具類 */uint16_t checksum(uint8_t *data, uint8_t len){ uint8_t i; uint16_t sum = 0, res; for (i = 0; i < len; i++) { sum += data[i]; } res = sum ; return res;}
?
在我的第一次認知里,還是len=-1=255的情況,由于g_data.data只有127字節,但它最后是可以訪問到255下標的,其實這本身就有數據非法訪問的問題;但是經過仔細論證,得出的結論是,這并不會導致死循環,或者說并不會改變len的值;因為checksum里面知識讀取data指針的值,并沒改變它的值,即便越界了,頂多訪問了別人,并不會出啥異常(至少在我們的處理器平臺是這樣)。
這個問題對我們來說,真的是百思不得其解,為了規避掉這個問題,我們在調用checksum的時候做了判斷,但len為0的時候直接不調用,也就繞過了這個問題。
但是作為一個深挖底層邏輯的攻城獅來說,我們不應該放過這樣的細節,或許還有什么我們未發現的潛在風險呢?
這個問題一直困擾著我,時不時有空的時候,我就會想想,到底還有什么情況還會導致這個現象?
5 柳暗花明
偶然有一天,我正瀏覽到一篇關于編譯器做代碼優化的文章,它是在知乎上發出來的,我看到其中一個重要線索:
突然我腦子里,閃過一個疑問:“會不會那段for循環的checksum函數,正是因為調用方沒有申明checksum函數,也就是說沒有include對應的頭文件導致編譯器做了默認處理呢?”?
我們都知道,在使用gcc編譯器編譯C代碼時,如果一個函數未申明就調用,是會報一個警告的:“warning: implicit declaration of function ‘checksum’ [-Wimplicit-function-declaration]”!
同時,尤其編譯器不知道被調用函數的原型,那么它只能依靠你的調用代碼結合一些默認值做假設:
比如我們的調用代碼是:
?
uint16_t res = checksum(&g_data.data[0], g_data.len - 0);
?
這里,我猜測編譯器的行為就是,你有一個叫checksum的函數,但我找不到它的原型,那么我就按“返回值是uint16_t類型,第一個參數是int型,第二個參數也是int型”來吧!
為何,gcc默認參數列表都是int類型?這是我的假想猜測,下面我們再論證,究竟是不是這樣?
有了這個假設之后,我們回到ARM匯編在函數調用時的參數,這時R0應該等于&g_data.data[0],R1應該等于-1。
由于R0/R1都是32位的寄存器,在存儲數據的時候,無所謂有符號和無符號一說,且本問題R0沒有出現問題,我們僅討論R1。
這個時候R1的寄存器值,應該是“-1 = 0xFFFFFFFF”,這個假設很關鍵,如果分析地很順利,那么這個for循環不停地循環下去,才可以有理論進行下去。
6 找到證據
既然上面我們發現了端倪,那么我們應該進一步找到相關的證據,證明我們的想法;同時,如果這個問題根源在于include頭文件,那么當我們添加了頭文件之后,這個問題應該不會再復現。我們來看下,究竟是不是這樣?
6.1 究竟是不是警告
由于我們的代碼實在太多警告了,就屬于那種 0 error N warnings 那種,屬于你需要找一個警告往往好費好大勁!
經過好一番檢索,果不其然,還真的報警告了,的確是“warning: implicit declaration of function ‘checksum’ [-Wimplicit-function-declaration]”!
6.2 盤根問底
看編譯器的行為,我們肯定是要看其對應的匯編文件,這里有兩個地方需要看,一個是checksum函數的匯編,還有一個調用checksum函數附近的匯編。
我們一起看看:
?
/* checksum 函數的匯編代碼 */ .section .text.checksum,"ax",%progbits .align 1 .global checksum .code 16 .thumb_func .type checksum, %functionchecksum:.LFB4: .loc 1 125 0 .cfi_startproc @ args = 0, pretend = 0, frame = 0 @ frame_needed = 0, uses_anonymous_args = 0.LVL27: push {r4, r5, r6, lr} .cfi_def_cfa_offset 16 .cfi_offset 4, -16 .cfi_offset 5, -12 .cfi_offset 6, -8 .cfi_offset 14, -4 .loc 1 125 0 movs r4, r0 movs r5, r1 // r1 -> r5 ,即 len的值存在r5中 .loc 1 129 0 movs r2, r1 ldr r0, .L29.LVL28: bl printf //打印len的值.LVL29: movs r3, r4 .loc 1 127 0 movs r0, #0 adds r5, r4, r5.LVL30:.L26: .loc 1 130 0 discriminator 1 cmp r3, r5 //for循環里面的關鍵判斷,即 i < len beq .L28 // 退出for循環 .loc 1 131 0 discriminator 3 //下面就是for循環的循環執行體 ldrb r2, [r3] adds r3, r3, #1.LVL31: adds r0, r0, r2.LVL32: lsls r0, r0, #16 lsrs r0, r0, #16.LVL33: b .L26.LVL34:.L28: .loc 1 136 0 @ sp needed.LVL35: pop {r4, r5, r6, pc}.L30: .align 2.L29: .word .LC12 .cfi_endproc.LFE4: .size checksum, .-checksum
?
由它的匯編代碼可知,for循環執行多少次,關鍵在于r5寄存器的值,也就是len的值。
注意在匯編代碼這里,是看不到r5是uint8_t還是uint32_t的,它僅僅是一個32位的寄存器。
?
.section .text.verify_checksum,"ax",%progbits .align 1 .global verify_checksum .code 16 .thumb_func .type verify_checksum, %functionverify_checksum:.LFB5: .loc 1 81 0 .cfi_startproc @ args = 0, pretend = 0, frame = 0 @ frame_needed = 0, uses_anonymous_args = 0.LVL17: push {r4, lr} .cfi_def_cfa_offset 8 .cfi_offset 4, -8 .cfi_offset 14, -4 .loc 1 83 0 ldr r4, .L20 .loc 1 91 0 @ sp needed .loc 1 83 0 movs r0, r4 //r0存儲結構體g_data的地址 ldrb r1, [r4] //將g_data的第一個字節,即g_data.len賦值為r1 adds r0, r0, #34 //r0的地址偏移34個字節,即偏移到g_data.data的位置; subs r1, r1, #1 //關鍵的一步:r1 = r1 - 1 由于我們復現問題的時候,g_data.len是為0的,所以此時r1的值就是0xFFFFFFFF bl checksum //調用checksum函數,第1-2個入參,分別是r0和r1.LVL18: .loc 1 84 0 adds r4, r4, #160 .loc 1 89 0 ldrb r3, [r4] lsls r0, r0, #24.LVL19: lsrs r0, r0, #24 subs r0, r0, r3 .loc 1 91 0 pop {r4, pc}.L21: .align 2.L20: .word .LANCHOR4 .cfi_endproc.LFE5: .size verify_checksum, .-verify_checksum
?
了解匯編知識的,看到上面的匯編代碼,結合checksum函數的匯編代碼,就應該明白,我前面的假設成立了,但len傳入到checksum函數時,它的值真的是0xFFFFFFFF,而使用%u打印出來,就是4294967295。
到此,罪魁禍首其實已經找到了,與其說是編譯器的無故優化,倒不如說是程序猿寫代碼不嚴謹,沒有正確處理掉這個編譯警告。
6.3 解除風險
既然找到了問題根源,那么我們嘗試下解除這個風險。
方法其實也很簡單,直接需要在調用checksum函數的1.c中,include一下checksum函數所在的頭文件即可。
添加之后,我們看下發生的變化,很顯然,checksum函數的匯編代碼肯定是沒有任何不變的,應該它壓根沒有改;
而調用checksum的匯編就發生了些許的變化,同時編譯輸出的地方,那個編譯警告也都消失了。
?
* 添加頭文件之后的匯編代碼 */ .section .text.verify_checksum,"ax",%progbits .align 1 .global verify_checksum .code 16 .thumb_func .type verify_checksum, %functionverify_checksum:.LFB5: .loc 1 81 0 .cfi_startproc @ args = 0, pretend = 0, frame = 0 @ frame_needed = 0, uses_anonymous_args = 0.LVL17: push {r4, lr} .cfi_def_cfa_offset 8 .cfi_offset 4, -8 .cfi_offset 14, -4 .loc 1 83 0 ldr r4, .L20 .loc 1 91 0 @ sp needed .loc 1 83 0 movs r0, r4 ldrb r1, [r4] adds r0, r0, #34 subs r1, r1, #1 //r1寄存器的一樣的操作 r1 = r1 - 1 lsls r1, r1, #24 //關鍵改變!!!r1 = r1 * (2的24次冪),也就是算術左移24位 lsrs r1, r1, #24 //關鍵改變!!!r1 = r1 / (2的24次冪),也就是算術右移24位 bl checksum.LVL18: .loc 1 84 0 adds r4, r4, #160 .loc 1 89 0 ldrb r3, [r4] lsls r0, r0, #24.LVL19: lsrs r0, r0, #24 subs r0, r0, r3 .loc 1 91 0 pop {r4, pc}.L21: .align 2.L20: .word .LANCHOR4 .cfi_endproc.LFE5: .size verify_checksum, .-verify_checksum
?
為了好對比,我直接使用對比工具貼圖上來看下:
查了下多出來的這兩條指令:lsls和lsrs,參考這里。
一個是算術左移24位,一個是算術右移24位,倒來倒去,無非就是把高24位給情況,這樣-1的值傳入checksum的時候,就只有0x000000FF了,而不是0xFFFFFFFF。
這樣就把uint8_t len拉回正常的邏輯了,自然也就不會出現之前的for循環一直退不出來了。
7 擴展延伸
上面我提及的場景對應的是ARM平臺的,由于我們的代碼是跨平臺的,支持RISC-V架構,X86架構等等。
7.1 RISC-V架構
所以我們來對比看下RISC-V架構下的情況:
這么看,RISC-V的處理也是夠粗暴的,一個addi指令,把高24位去掉就完事了!!!
7.2 80x86架構
我push了一個簡易的工程代碼到github,以便于重現此問題,感興趣的可以看這里。
很遺憾的是,在80x86上竟然沒有復現此問題。
代碼的核心差別就是是否include 2.h:
匯編代碼確實有差異:
但是跑出來的效果確實一樣的:
總結下沒有復現問題的原因,可能是:
編譯選項沒有使用正確?
80x86編譯器更懂事?更能知道如何合理編譯代碼?
還有未知的編譯特性未了解到?
7.3 其他架構
感興趣的可以在其他平臺上驗證下,是否有類似的問題,歡迎討論。
8 經驗總結
請提升你的代碼編譯嚴謹性,如果是gcc編譯器,-Wall -Werror -Os是最低要求;
談優化代碼前,請close掉你的代碼編譯異常,先達到 0 error 0 warning 再說;
請重視warning: implicit declaration of function這個編譯警告;
如果使用gcc編譯器,不提示任何編譯警告和錯誤,并不代表編譯器沒有告訴你,也許是你使用-w選項編譯了輸出,你僅僅是在自欺欺人而已;
老老實實在調用函數前申明你的函數,或者包含其對應的頭文件,有時候編譯器的默認行文不見得就可靠;
代碼細節很重要,真的是細節決定成敗;
不放過一絲可能性,作為一個攻城獅,這點專研精神需要時刻掛在心里;
大膽假設,小心求證,亙古不變的方法論。
評論
查看更多