1 前言
在我的上一篇文章中,有講到掌握匯編知識的重要性,關(guān)鍵時刻可能還會拯救你于泥潭之中。
那么,本篇文章,我將再介紹一個使用匯編知識排查疑難問題的方法,希望對大家有所幫助。
2 問題描述
問題是這樣的,前一段時間我們項(xiàng)目組在進(jìn)行一項(xiàng)自測試中,偶然發(fā)現(xiàn)我們的代碼好像掛了一樣:現(xiàn)象就是命令行輸入不了,但是沒有看到復(fù)位信息輸出。
當(dāng)時,我們一個小伙伴說:“好像我們的系統(tǒng)掛了?”當(dāng)我了解到這個現(xiàn)象之后,根據(jù)我之前的排查經(jīng)驗(yàn),我當(dāng)即得出了一個結(jié)論:“可能是我們的代碼跑進(jìn)死循環(huán)了,好好檢查下”!
于是,我們開始debug代碼,加了一些必要的調(diào)試信息,最終發(fā)現(xiàn)有一個計(jì)算校驗(yàn)的函數(shù),調(diào)進(jìn)去了但是沒有退出來,而這個校驗(yàn)的函數(shù)非常之簡單,它就長這樣:
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;
}
我想當(dāng)你看到這段函數(shù)時,肯定也是:“臥槽,這TM不就是算累加校驗(yàn)和嗎?怎么可能會死循環(huán)?”
沒錯,當(dāng)時我們的爭論的場景也的確如此!
3 簡單分析
這個checksum函數(shù)真的是非常簡單,入?yún)⒑唵巍?shí)現(xiàn)也簡單、返回值也簡單,根本不存在難點(diǎn)。
一步步來分析:
既然代碼沒有崩潰,證明data指針肯定非NULL的,不會有問題;
倒是這個len有些可疑,len的類型是uint8_t無符號的,它的范圍是0-255;但是如果外面?zhèn)魅氲氖?1呢?
如果傳入-1,強(qiáng)制轉(zhuǎn)換為uint8_t,其值也是255,那么下面的for循環(huán),依然只會跑256次,它必須得退出呀?
有沒有可能for循環(huán)的過程中,棧的值被修改了,然后i的值和len的值都變了,進(jìn)而for的次數(shù)改變了?
于是我們開始打印i和len的值,發(fā)現(xiàn)他們兩個的值,都是正常變化的,并不是剛剛想的那樣。
這就很奇怪了?。?!
如果說這個for循環(huán)要“無限”循環(huán)下去,造成“死循環(huán)”,必須滿足的條件是len很大很大,但是len不是uint8_t類型嘛?最大也就255呀?
printf大法再來一遍:結(jié)果出乎我們的意料,請看:
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
。。。還在不停的打印
看到這里似乎有點(diǎn)眉目了?len的值為4294967295?
這個值不是0xFFFFFFFF嗎?
我們再使用%d打印了一下len,發(fā)現(xiàn)值為-1。
回過頭來看下checksum的調(diào)用之處:
uint16_t res = checksum(&data[0], len - 1);
看似真相了,當(dāng)len為0的時候,傳入的值不就是-1嗎?
好像是這么回事,但是-1進(jìn)去,它是uint8_t的呀,頂多就是255???怎么變成了4294967295? 到底是誰干的???
同時也發(fā)現(xiàn)關(guān)鍵問題了,這里并不是真正意義的“死循環(huán)”,而是for循環(huán)執(zhí)行太久了,導(dǎo)致長時間無法結(jié)束,因?yàn)槲覀兊闹黝l才160MHZ,CPU就是猛跑,從1加到0xFFFFFFFF,也需要好長一段時間呢!
4 場景再現(xiàn)
為了充分說明這個問題,我盡可能地還原下當(dāng)時我們的代碼場景:
/*
一個結(jié)構(gòu)體定義數(shù)據(jù)
不要急于吐槽它的定義,這代碼是開源的,冤有頭。。。
還有不要懷疑是字節(jié)對齊不對齊的問題,曾經(jīng)我也懷疑過,最后知道真相的時候,我被打臉了!
*/
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 調(diào)用checksum的C文件 */
/* 定義全局的數(shù)據(jù) */
static data_t g_data;
/* 設(shè)置全局的數(shù)據(jù) */
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函數(shù)的工具類 */
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;
}
在我的第一次認(rèn)知里,還是len=-1=255的情況,由于g_data.data只有127字節(jié),但它最后是可以訪問到255下標(biāo)的,其實(shí)這本身就有數(shù)據(jù)非法訪問的問題;但是經(jīng)過仔細(xì)論證,得出的結(jié)論是,這并不會導(dǎo)致死循環(huán),或者說并不會改變len的值;因?yàn)閏hecksum里面知識讀取data指針的值,并沒改變它的值,即便越界了,頂多訪問了別人,并不會出啥異常(至少在我們的處理器平臺是這樣)。
這個問題對我們來說,真的是百思不得其解,為了規(guī)避掉這個問題,我們在調(diào)用checksum的時候做了判斷,但len為0的時候直接不調(diào)用,也就繞過了這個問題。
但是作為一個深挖底層邏輯的攻城獅來說,我們不應(yīng)該放過這樣的細(xì)節(jié),或許還有什么我們未發(fā)現(xiàn)的潛在風(fēng)險(xiǎn)呢?
這個問題一直困擾著我,時不時有空的時候,我就會想想,到底還有什么情況還會導(dǎo)致這個現(xiàn)象?
5 柳暗花明
偶然有一天,我正瀏覽到一篇關(guān)于編譯器做代碼優(yōu)化的文章,它是在知乎上發(fā)出來的,我看到其中一個重要線索:
突然我腦子里,閃過一個疑問:“會不會那段for循環(huán)的checksum函數(shù),正是因?yàn)檎{(diào)用方?jīng)]有申明checksum函數(shù),也就是說沒有include對應(yīng)的頭文件導(dǎo)致編譯器做了默認(rèn)處理呢?”?
我們都知道,在使用gcc編譯器編譯C代碼時,如果一個函數(shù)未申明就調(diào)用,是會報(bào)一個警告的:“warning: implicit declaration of function ‘checksum’ [-Wimplicit-function-declaration]”!
同時,尤其編譯器不知道被調(diào)用函數(shù)的原型,那么它只能依靠你的調(diào)用代碼結(jié)合一些默認(rèn)值做假設(shè):
比如我們的調(diào)用代碼是:
uint16_t res = checksum(&g_data.data[0], g_data.len - 0);
這里,我猜測編譯器的行為就是,你有一個叫checksum的函數(shù),但我找不到它的原型,那么我就按“返回值是uint16_t類型,第一個參數(shù)是int型,第二個參數(shù)也是int型”來吧!
為何,gcc默認(rèn)參數(shù)列表都是int類型?這是我的假想猜測,下面我們再論證,究竟是不是這樣?
有了這個假設(shè)之后,我們回到ARM匯編在函數(shù)調(diào)用時的參數(shù),這時R0應(yīng)該等于&g_data.data[0],R1應(yīng)該等于-1。
由于R0/R1都是32位的寄存器,在存儲數(shù)據(jù)的時候,無所謂有符號和無符號一說,且本問題R0沒有出現(xiàn)問題,我們僅討論R1。
這個時候R1的寄存器值,應(yīng)該是“-1 = 0xFFFFFFFF”,這個假設(shè)很關(guān)鍵,如果分析地很順利,那么這個for循環(huán)不停地循環(huán)下去,才可以有理論進(jìn)行下去。
6 找到證據(jù)
既然上面我們發(fā)現(xiàn)了端倪,那么我們應(yīng)該進(jìn)一步找到相關(guān)的證據(jù),證明我們的想法;同時,如果這個問題根源在于include頭文件,那么當(dāng)我們添加了頭文件之后,這個問題應(yīng)該不會再復(fù)現(xiàn)。我們來看下,究竟是不是這樣?
6.1 究竟是不是警告
由于我們的代碼實(shí)在太多警告了,就屬于那種 0 error N warnings 那種,屬于你需要找一個警告往往好費(fèi)好大勁!
經(jīng)過好一番檢索,果不其然,還真的報(bào)警告了,的確是“warning: implicit declaration of function ‘checksum’ [-Wimplicit-function-declaration]”!
6.2 盤根問底
看編譯器的行為,我們肯定是要看其對應(yīng)的匯編文件,這里有兩個地方需要看,一個是checksum函數(shù)的匯編,還有一個調(diào)用checksum函數(shù)附近的匯編。
我們一起看看:
/* checksum 函數(shù)的匯編代碼 */
.section .text.checksum,"ax",%progbits
.align 1
.global checksum
.code 16
.thumb_func
.type checksum, %function
checksum:
.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循環(huán)里面的關(guān)鍵判斷,即 i < len
beq .L28 // 退出for循環(huán)
.loc 1 131 0 discriminator 3 //下面就是for循環(huán)的循環(huán)執(zhí)行體
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循環(huán)執(zhí)行多少次,關(guān)鍵在于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, %function
verify_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存儲結(jié)構(gòu)體g_data的地址
ldrb r1, [r4] //將g_data的第一個字節(jié),即g_data.len賦值為r1
adds r0, r0, #34 //r0的地址偏移34個字節(jié),即偏移到g_data.data的位置;
subs r1, r1, #1 //關(guān)鍵的一步:r1 = r1 - 1 由于我們復(fù)現(xiàn)問題的時候,g_data.len是為0的,所以此時r1的值就是0xFFFFFFFF
bl checksum //調(diào)用checksum函數(shù),第1-2個入?yún)ⅲ謩e是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
了解匯編知識的,看到上面的匯編代碼,結(jié)合checksum函數(shù)的匯編代碼,就應(yīng)該明白,我前面的假設(shè)成立了,但len傳入到checksum函數(shù)時,它的值真的是0xFFFFFFFF,而使用%u打印出來,就是4294967295。
到此,罪魁禍?zhǔn)灼鋵?shí)已經(jīng)找到了,與其說是編譯器的無故優(yōu)化,倒不如說是程序猿寫代碼不嚴(yán)謹(jǐn),沒有正確處理掉這個編譯警告。
6.3 解除風(fēng)險(xiǎn)
既然找到了問題根源,那么我們嘗試下解除這個風(fēng)險(xiǎn)。
方法其實(shí)也很簡單,直接需要在調(diào)用checksum函數(shù)的1.c中,include一下checksum函數(shù)所在的頭文件即可。
添加之后,我們看下發(fā)生的變化,很顯然,checksum函數(shù)的匯編代碼肯定是沒有任何不變的,應(yīng)該它壓根沒有改;
而調(diào)用checksum的匯編就發(fā)生了些許的變化,同時編譯輸出的地方,那個編譯警告也都消失了。
* 添加頭文件之后的匯編代碼 */
.section .text.verify_checksum,"ax",%progbits
.align 1
.global verify_checksum
.code 16
.thumb_func
.type verify_checksum, %function
verify_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 //關(guān)鍵改變!??!r1 = r1 * (2的24次冪),也就是算術(shù)左移24位
lsrs r1, r1, #24 //關(guān)鍵改變!??!r1 = r1 / (2的24次冪),也就是算術(shù)右移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,參考這里。
一個是算術(shù)左移24位,一個是算術(shù)右移24位,倒來倒去,無非就是把高24位給情況,這樣-1的值傳入checksum的時候,就只有0x000000FF了,而不是0xFFFFFFFF。
這樣就把uint8_t len拉回正常的邏輯了,自然也就不會出現(xiàn)之前的for循環(huán)一直退不出來了。
7 擴(kuò)展延伸
上面我提及的場景對應(yīng)的是ARM平臺的,由于我們的代碼是跨平臺的,支持RISC-V架構(gòu),X86架構(gòu)等等。
7.1 RISC-V架構(gòu)
所以我們來對比看下RISC-V架構(gòu)下的情況:
這么看,RISC-V的處理也是夠粗暴的,一個addi指令,把高24位去掉就完事了?。?!
7.2 80x86架構(gòu)
我push了一個簡易的工程代碼到github,以便于重現(xiàn)此問題,感興趣的可以看這里。
很遺憾的是,在80x86上竟然沒有復(fù)現(xiàn)此問題。
代碼的核心差別就是是否include 2.h:
匯編代碼確實(shí)有差異:
但是跑出來的效果確實(shí)一樣的:
總結(jié)下沒有復(fù)現(xiàn)問題的原因,可能是:
編譯選項(xiàng)沒有使用正確?
80x86編譯器更懂事?更能知道如何合理編譯代碼?
還有未知的編譯特性未了解到?
7.3 其他架構(gòu)
感興趣的可以在其他平臺上驗(yàn)證下,是否有類似的問題,歡迎討論。
8 經(jīng)驗(yàn)總結(jié)
請?zhí)嵘愕拇a編譯嚴(yán)謹(jǐn)性,如果是gcc編譯器,-Wall -Werror -Os是最低要求;
談優(yōu)化代碼前,請close掉你的代碼編譯異常,先達(dá)到 0 error 0 warning 再說;
請重視warning: implicit declaration of function這個編譯警告;
如果使用gcc編譯器,不提示任何編譯警告和錯誤,并不代表編譯器沒有告訴你,也許是你使用-w選項(xiàng)編譯了輸出,你僅僅是在自欺欺人而已;
老老實(shí)實(shí)在調(diào)用函數(shù)前申明你的函數(shù),或者包含其對應(yīng)的頭文件,有時候編譯器的默認(rèn)行文不見得就可靠;
代碼細(xì)節(jié)很重要,真的是細(xì)節(jié)決定成??;
不放過一絲可能性,作為一個攻城獅,這點(diǎn)專研精神需要時刻掛在心里;
大膽假設(shè),小心求證,亙古不變的方法論。
審核編輯:湯梓紅
-
匯編
+關(guān)注
關(guān)注
2文章
214瀏覽量
25887 -
代碼
+關(guān)注
關(guān)注
30文章
4722瀏覽量
68231
原文標(biāo)題:【匯編實(shí)戰(zhàn)開發(fā)筆記】從匯編代碼中找出一段普通的for循環(huán)變成“死循環(huán)”的根本原因
文章出處:【微信號:RTThread,微信公眾號:RTThread物聯(lián)網(wǎng)操作系統(tǒng)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論