C語言的反人類函數(shù):setjmp和longjmp的詳細剖析
我希望看這篇文章的你對C++的傳統(tǒng)異常處理,即try...catch...throw有了解(不是Windows SEH),這樣才能方便你最深入的理解這2個C語言的反人類函數(shù)。
當然如果不了解就先看下面的“C++式的異常處理”,如果感覺自己了解了,可以直接skip看到“C語言中的模擬”。
【C++式的異常處理】
首先,我們寫一個類,請不要想這個類有什么特別的地方,其只是為了打印出來構(gòu)造和析構(gòu)。
class?CFoo { public: ????CFoo() ????{ ????????printf("Create?CFoo.n"); ????} ????~CFoo() ????{ ????????printf("~Destroy?CFoo.n"); ????} };
然后我們寫一個函數(shù),這個函數(shù)foo是為了根據(jù)情況拋出異常:
void?foo(int?exp) { ????if?(exp?==?'a') ????????throw?std::exception("a"); ????printf("foo?ok?%d.n",exp); }
我們來寫第一個main:
int?main() { ????int?val?=?getchar(); ????foo(val); ????return?0; }
此時我們輸入b,其輸出的肯定是:
foo ok 98.
這里98是b的ascii值。
而我們輸入a,則會出情況了:
因為foo拋出了一個異常,但是沒例程去處理他,所以程序崩潰。
所以我們現(xiàn)在在main上加上處理foo異常的代碼:
int?main() { ????int?val?=?getchar(); ????try{ ????????foo(val); ????}catch?(std::exception&?ex) ????{ ????????printf("skip?ex:%s.n",ex.what()); ????} ????return?0; }
好了,我們再次輸入a,則會出現(xiàn):
skip ex:a.
foo在throw下正常的printf則不會執(zhí)行,流程被改變。
所以我們可以簡單理解為throw是一個“帶有異常信息的”return,當然實際情況比這個復雜的多,我這樣說只是為了讓你有一種C語言的感覺。
還記得上面那個CFoo嘛,我一直沒使用它,現(xiàn)在我們把foo函數(shù)改一下:
void?foo(int?exp) { ????CFoo?cfoo; ????if?(exp?==?'a') ????????throw?std::exception("a"); ????printf("foo?ok?%d.n",exp); }
可以看到我只加了一行代碼,在堆棧上開了一個cfoo的實例,我們main不動,輸入一個p試試:
Create CFoo.
foo ok 112.
~Destroy CFoo.
可以看到,其輸出了CFoo的構(gòu)造和析構(gòu),這個是正常的情況,因為我們看到printf執(zhí)行了。
那我們輸入a呢,我們來嘗試:
Create CFoo.
~Destroy CFoo.
skip ex:a.
我們可以看到,雖然throw下面的printf沒有被執(zhí)行,但是CFoo被構(gòu)造和析構(gòu)了,這就是C++異常會遵循C++的棧上展開的特點,也就是即便發(fā)生異常了,throw前的棧上對象,都需要被析構(gòu),如果他們有“真正的”析構(gòu)代碼的話。
在執(zhí)行析構(gòu)的時候情況也是十分復雜,這里不扯那么多,因為這文章不是介紹C++異常處理的。。。
不過為了讓你看得更清除點,我們再來把CFoo函數(shù)改一下,也是一行代碼:
void?foo(int?exp) { ????CFoo?cfoo; ????if?(exp?==?'a') ????????throw?std::exception("a"); ????CFoo?cfoo2; ????printf("foo?ok?%d.n",exp); }
我們再次輸入p:
Create CFoo.
Create CFoo.
foo ok 112.
~Destroy CFoo.
~Destroy CFoo.
可以看到這是輸出,好,我們輸入a:
Create CFoo.
~Destroy CFoo.
skip ex:a.
可以很明顯的看到,因為cfoo2構(gòu)造在throw下面,所以它在異常導致foo進行return的時候,并不需要被析構(gòu),因為它并沒有生成一個真正的實例。
好了到這里你就算不懂C++異常處理可能也可以入門了(如果你有興趣的話)。
【C語言中的模擬】
這里我們開始正式說一下setjmp和longjmp。
如果上面那個foo函數(shù):
void?foo(int?exp) { ????if?(exp?==?'a') ????????throw?std::exception("a"); ????printf("foo?ok?%d.n",exp); }
因為在C語言中沒有C++異常,foo一般使用一個返回值來交出結(jié)果判斷失敗,然后調(diào)用者根據(jù)返回值進行流程控制,比如foo我們可以寫成:
bool?foo(int?exp) { ????if?(exp?==?'a') ????????return?false; ????printf("foo?ok?%d.n",exp); ????return?true; }
我們用bool來給出返回值,當然更多是使用int,char。
如果我們有特殊的情懷,或者我們有一些批量的任務,希望用一個統(tǒng)一的例程處理他們的錯誤。。。
我們想在C語言中,使用C++類似的東西,在foo中拋出一個異常,在main中catch呢?
這里需要用到setjmp和longjmp,我先給你一些概念:
setjmp=try;
longjmp=throw。
可以看到try和throw都有了,那catch在哪里?
要知道C語言是流程式的語言,那catch在C語言中肯定得遵循某一個流程表達式,沒錯。。。就是if。。。
所以你可以看到:
setjmp=try,longjmp=throw,if=catch。
好像所有條件都具備了,到底怎么玩?來我們繼續(xù)。
我們還是上面那個foo函數(shù):
(首先我們使用setjmp和longjmp需要include setjmp.h)
void?foo(int?exp,jmp_buf&?jb) { ????if?(exp?==?'a') ????????longjmp(jb,'a');?//throw?std::exception("a"); ????printf("foo?ok?%d.n",exp); }
然后我們寫main:
int?main() { ????jmp_buf?jb; ????int?jmp_ret?=?setjmp(jb); ????if?(jmp_ret?==?0)?//try ????{ ????????int?val?=?getchar(); ????????foo(val,jb); ????}else{?//catch ????????printf("skip?ex:%d.n",jmp_ret); ????} ????return?0; }
按照上面的路子來,我們輸入b:
foo ok 98.
其輸入也是一樣的,那我們輸入a呢:
skip ex:97.
這里97是a的ascii碼,也就是其是跟上面的異常流程處理是一樣的,是不是感覺很奇葩。
你肯定在想,為什么,按照理論上來說,setjmp后==0,foo才會執(zhí)行,按照我們的傳統(tǒng)流程,既然foo被執(zhí)行了,那else應該永遠得不到執(zhí)行,那longjmp又是如何從foo里面跑回去了main?
我們來設想一下,else要如何才能被執(zhí)行?
對了,肯定是jmp_ref != 0嘛,沒錯,longjmp做的就是這個工作。
我們先不要在意jmp_buf,我們先看下longjmp的第二個參數(shù),他是一個值類型,這個參數(shù)我指定的是'a',也就是97,你看到了,我在prntf里面打印了jmp_ret的值,也就是,我們在longjmp時指定某一個值后,longjmp會把當前函數(shù)的流程做一個大轉(zhuǎn)彎,直接跳回到這里:
if (jmp_ret == 0) //try
而此時,jmp_ret已經(jīng)是我們指定的值,就是97了,那if的==不會被成立,則去執(zhí)行else了。
此時可能你想,如果我這樣:
longjmp(jb,0);
那不是jmp_ret還是==0,還又去執(zhí)行foo,又被longjmp,不是死循環(huán)了么?
這個情況在CRT已經(jīng)考慮過了,如果你給longjmp使用0值,其會自動修改為1,也就是0值是永遠不會被出現(xiàn)的。
好,我們來總結(jié):
1、首先setjmp需要==0才執(zhí)行foo。
2、foo發(fā)現(xiàn)錯誤,把setjmp的==0給改了。
3、if表達式的else被執(zhí)行。
可能你現(xiàn)在頭還有點暈,不過我們先說這個到這里,我們來看setjmp的第一個參數(shù):jmp_buf。
這個jmp_buf是什么呢,首先我們來再寫一個main:
int?main() { ????char?sz[128]?=?"hello.n"; ????jmp_buf?jb; ????int?jmp_ret?=?setjmp(jb); ????if?(jmp_ret?==?0)?//try ????{ ????????int?val?=?getchar(); ????????foo(val,jb); ????}else{?//catch ????????printf("skip?ex:%d.n",jmp_ret); ????????printf(sz); ????} ????return?0; }
輸入a,則會輸出:
skip ex:97.
hello.
你肯定想這是當然的,因為sz變量在main范圍內(nèi)嘛。
但是別忘了,我們訪問sz可是在else里,也就是我們訪問的時候,是被longjmp跳過去的。。。
要知道,執(zhí)行foo的時候,可能整個堆棧環(huán)境已經(jīng)變得離譜了,如果你知曉匯編,肯定知道,執(zhí)行foo的時候,main使用堆棧指針EBP(當然也可以直接ESP,不過這里做一個比方)會被保存起來,要等foo進行return的時候,才會恢復EBP,然后main的局部變量才能通過EBP訪問到,但是我們的foo可是直接longjmp的,我們沒有任何代碼用于恢復EBP的值,那如何保證飛過去else的時候,訪問sz變量的地址是正確的?
對了,在setjmp的時候,CRT會把EBP等變量的值保存在jmp_buf里面,然后在longjmp里面,把EBP的值從jmp_buf里面取出來,進行恢復。
這樣在執(zhí)行l(wèi)ongjmp的時候,EBP會被恢復到setjmp時的情況,也就保證了sz變量的地址在執(zhí)行else的時候也是正確的。
如果你只會C語言,那看到這里,你應該大概理解了,如果你還了解過匯編,那可以繼續(xù)看下去,我會為你揭示setjmp、longjmp背后的一些東西。
【深入探索】
我們把剛才那個exe進行動態(tài)反匯編,以便我們整體的了解setjmp和longjmp的所有情況。
首先在調(diào)試器里面,main是這樣的:
可以看到,關(guān)鍵就是在TEST EAX,EAX這里有一個JNZ跳,如果不是0則跳到下面的catch。
我們來看setjmp的匯編:
可以看到其保存了幾個windows關(guān)鍵的寄存器。
注意,在win32下,eax、edx、ecx被定義為易失寄存器,比如我們調(diào)用foo的時候,如果foo需要用到ebx,esi,它也需要保存,退出時恢復,但是使用edx則不需要保存。
setjmp也是遵循這個原則。
可以看到setjmp的返回是XOR EAX,EAX,就是返回0。
好我們來看longjmp的反匯編:
可以看到其檢測了一下jmp_buf的正確性,然后就進行寄存器的恢復,最終把call自身的堆棧平衡了后,就使用JMP指令直接JMP到setjmp后的那個指令地址,而此時其把EAX改成了longjmp的第二個參數(shù):
那接下來的TEST EAX,EAX肯定不會成功,就會跑去執(zhí)行catch了。
【與C++的結(jié)合】
文章寫到這里,應該快結(jié)束了,可還有一個點,可能你沒注意到,我們還是回到我們第一個代碼——CFoo這個類來。
在上面的C++異常里面,我們看到了這樣的代碼:
void?foo(int?exp) { ????CFoo?cfoo; ????if?(exp?==?'a') ????????throw?std::exception("a"); ????printf("foo?ok?%d.n",exp); }
按照C++的規(guī)范,異常發(fā)生的時候,cfoo也會被析構(gòu),如果我們使用longjmp呢,就像下面:
void?foo(int?exp,jmp_buf&?jb) { ????CFoo?cfoo; ????if?(exp?==?'a') ????????longjmp(jb,'a');?//throw?std::exception("a"); ????printf("foo?ok?%d.n",exp); }
你肯定會想,cfoo應該只會被構(gòu)造,而不會被析構(gòu),因為longjmp可是CRT的函數(shù)。
其實原來我也是這樣想的,但是我不懂是不是VC spec,我在跟蹤longjmp的時候發(fā)現(xiàn)了堆棧展開的代碼。。。
也就是,其實cfoo在longjmp的時候,也是會被析構(gòu)的:
Create CFoo.
~Destroy CFoo.
skip ex:97.
hello.
這個要注意一下。
如果你想看匯編,在下面。
這個是foo函數(shù)的匯編:
SEH處理器在這里:
然后會展開到析構(gòu)函數(shù):
【完結(jié)】
為這2個狗血的東西寫了那么多,也說的差不多了。
其實這2個東西,因為其反人類的特性,在項目開發(fā)中,不應該被使用上,在這里只是告訴大家,如果遇到有setjmp、longjmp的情況的時候,可以判斷出來代碼的執(zhí)行流程。