前言
在上一篇《前端魔法堂——異常不僅僅是try/catch》中我們描述出一副異常及如何捕獲異常的畫像,但僅僅如此而已。試想一下,我們窮盡一切捕獲異常實例,然后僅僅為告訴用戶,運維和開發人員頁面報了一個哪個哪個類型的錯誤嗎?答案是否定的。我們的目的是收集剛剛足夠的現場證據,好讓我們能馬上重現問題,快速修復,提供更優質的用戶體驗。那么問題就落在“收集足夠的現場證據”,那么我們又需要哪些現場證據呢?那就是異常信息,調用棧和棧幀局部狀態。(異常信息我們已經獲取了)
本文將圍繞上調用棧和棧幀局部狀態敘述,準開開車^_^
概要
本篇將敘述如下內容:
一.什么是調用棧?
既然我們要獲取調用棧信息,那么起碼要弄清楚什么是調用棧吧!下面我們分別從兩個層次來理解~
印象派
倘若主要工作內容為應用開發,那么我們對調用棧的印象如下就差不多了:
function funcA (a, b){
return a + b
}
function funcB (a){
let b = 3
return funcA(a, b)
}
function main(){
let a = 5
funcB(a)
}
main()
那么每次調用函數時就會生成一個棧幀,并壓入調用棧,棧幀中存儲對應函數的局部變量;當該函數執行完成后,其對應的棧幀就會彈出調用棧。
因此調用main()
時,調用棧如下
----------------<--棧頂
|function: main|
|let a = 5 |
|return void(0)|
----------------<--棧底
調用funcB()
時,調用棧如下
----------------<--棧頂
|function:funcB|
|let b = 3 |
|return funcA()|
----------------
|function: main|
|let a = 5 |
|return void(0)|
----------------<--棧底
調用funcA()
時,調用棧如下
----------------<--棧頂
|function:funcA|
|return a + b |
----------------
|function:funcB|
|let b = 3 |
|return funcA()|
----------------
|function: main|
|let a = 5 |
|return void(0)|
----------------<--棧底
funcA()
執行完成后,調用棧如下
----------------<--棧頂
|function:funcB|
|let b = 3 |
|return funcA()|
----------------
|function: main|
|let a = 5 |
|return void(0)|
----------------<--棧底
funcB()
執行完成后,調用棧如下
----------------<--棧頂
|function: main|
|let a = 5 |
|return void(0)|
----------------<--棧底
main()
執行完成后,調用棧如下
----------------<--棧頂
----------------<--棧底
現在我們對調用棧有了大概的印象了,但大家有沒有留意上面記錄"棧幀中存儲對應函數的局部變量",棧幀中僅僅存儲對應函數的局部變量,那么入參呢?難道會作為局部變量嗎?這個我們要從理論的層面才能得到解答呢。
理論派
這里我們要引入一個簡單的C程序,透過其對應的匯編指令來講解了。我會盡我所能用通俗易懂的語言描述這一切的,若有錯誤請各位指正!!
前提知識
- Intel X86架構中調用棧的棧底位于高位地址,而棧頂位于低位地址。(和印象派中示意圖的方向剛好相反)
調用棧涉及的寄存器有
ESP/RSP, 暫存棧頂地址 EBP/RBP, 暫存棧幀起始地址 EIP, 暫存下一個CPU指令的內存地址,當CPU執行完當前指令后,從EIP讀取下一條指令的內存地址,然后繼續執行
操作指令
PUSH <OPRD>,將ESP向低位地址移動操作數所需的空間,然后將操作數壓入調用棧中 POP <OPRD>,從調用棧中讀取數據暫存到操作數指定的寄存器或內存空間中,然后向高位地址移動操作數對應的空間字節數 MOV <SRC>,<DST>,數據傳送指令。用于將一個數據從源地址傳送到目標地址,且不破壞源地址的內容 ADD <OPRD1>,<OPRD2>,兩數相加不帶進位,然后將結果保存到目標地址上 RET,相當于POP EIP。就是從堆棧中出棧,然后將值保存到EIP寄存器中 LEAVE,相當于MOV EBP ESP,然后再POP EBP。就是將棧頂指向當前棧幀地址,然后將調用者的棧幀地址暫存到EBP中
每個函數調用前匯編器都會加入以下前言(Prolog),用于保存棧幀和返回地址
push %rbp ;將調用者的棧幀指針壓入調用棧 mov %rsp,%rbp ;現在棧頂指向剛入棧的RBP內容,要將其設置為棧幀的起始位置
現在們結合實例來理解吧!
C語言
#include <stdio.h>
int add(int a, int b){
return a + b;
}
int add2(int a){
int sum = add(0, a);
return sum + 2;
}
void main(){
add2(2);
}
然后執行以下命令編譯帶調試信息的可執行文件,和dump文件
$ gcc -g -o main main.c
$ objdump -d main > main.dump
下面我們截取main、add2和add對應的匯編指令來講解
main函數對應的匯編指令
0x40050f <main> push %rbp
0x400510 <main+1> mov %rsp,%rbp
;將2暫存到寄存器EDI中
0x400513 <main+4> mov $0x2,%edi
;執行call指令前,EIP寄存器已經存儲下一條指令的地址0x40051d了
;首先將EIP寄存器的值入棧,當函數返回時用于恢復之前的執行序列
;然后才是執行JUMP指令跳轉到add2函數中開始執行其第一條指令
0x400518 <main+9> callq 0x4004ea <add2>
;什么都不做
0x40051d <main+14> nop
;設置RBP為指向main函數調用方的棧幀地址
0x40051e <main+15> pop %rbp
;設置EIP指向main函數返回后將要執行的指令的地址
0x40051f <main+16> retq
下面是執行add2函數第一條指令前的調用棧快照
+++++++++++++++++ 高位地址
99 | 110 | -- 存放main函數調用方的棧幀地址 <-- EBP
+++++++++++++++++
98 | 0x40051d | -- EIP的值,存放add2返回后將執行的指令的地址 <-- ESP
+++++++++++++++++ 低位地址
add2函數對應的匯編指令
0x4004ea <add2> push %rbp
0x4004eb <add2+1> mov %rsp,%rbp
0x4004ee <add2+4> sub $0x18,%rsp ;棧頂向低位移動24個字節,為后續操作預留堆棧空間
0x4004f2 <add2+8> mov %edi,-0x14(%rbp);從EDI寄存器中讀取參數,并存放到堆棧空間中
0x4004f5 <add2+11> mov -0x14(%rbp),%eax;從堆棧空間中讀取參數,放進EAX寄存器中
0x4004f8 <add2+14> mov %eax,%esi ;從EAX寄存器中讀取參數,存放到ESI寄存器中
0x4004fa <add2+16> mov $0x0,%edi ;將0存放到EDI寄存器中
;執行call指令前,EIP寄存器已經存儲下一條指令的地址0x400504了
;首先將EIP寄存器的值入棧,當函數返回時用于恢復之前的執行序列
;然后才是執行JUMP指令跳轉到add函數中開始執行其第一條指令
0x4004ff <add2+21> callq 0x4004d6 <add>
0x400504 <add2+26> mov %eax,-0x4(%rbp) ;讀取add的返回值(存儲在EAX寄存器中),存放到堆棧空間中
0x400507 <add2+29> mov -0x4(%rbp),%eax ;又將add的返回值存放到EAX寄存器中(這是有多無聊啊~~)
0x40050a <add2+32> add $0x2,%eax ;讀取EAX寄存器的值與2相加,結果存放到EAX寄存器中
0x40050d <add2+35> leaveq ;讓棧頂指針指向main函數的棧幀地址,然后讓EBP指向main函數的棧幀地址
0x40050e <add2+36> retq ;讓EIP指向add2返回后將執行的指令的地址
下面是執行完add2函數中mov %rsp,%rbp
的調用棧快照
+++++++++++++++++ 高位地址
99 | 110 | -- 存放main函數調用方的棧幀地址
+++++++++++++++++
98 | 0x40051d | -- 存放EIP的值,add2返回后將執行的指令的地址
+++++++++++++++++
97 | 99 | -- 存放add2函數調用方(即main函數)的棧幀地址<-- ESP,EBP
+++++++++++++++++ 低位地址
下面是執行add函數第一條指令前的調用棧快照
+++++++++++++++++ 高位地址
99 | 110 | -- 存放main函數調用方的棧幀地址
+++++++++++++++++
98 | 0x40051d | -- 存放EIP的值,add2返回后將執行的指令的地址
+++++++++++++++++
97 | 99 | -- 存放add2函數調用方(即main函數)的棧幀地址<-- EBP
+++++++++++++++++
96 | 0xXX |
+++++++++++++++++
.................
76 | 0x02 | -- 這是`mov %edi,-0x14(%rbp)`的執行結果
+++++++++++++++++
.................
+++++++++++++++++
73 | 0xXX |
+++++++++++++++++
72 | 0x400504 | -- EIP的值,存放add返回后將執行的指令的地址 <-- ESP
+++++++++++++++++ 低位地址
add函數對應的匯編指令
0x4004d6 <add> push %rbp
0x4004d7 <add+1> mov %rsp,%rbp
0x4004da <add+4> mov %edi,-0x4(%rbp)
0x4004dd <add+7> mov %esi,-0x8(%rbp)
0x4004e0 <add+10> mov -0x4(%rbp),%edx
0x4004e3 <add+13> mov -0x8(%rbp),%eax
0x4004e6 <add+16> add %edx,%eax
0x4004e8 <add+18> pop %rbp
0x4004e9 <add+19> retq
下面是add函數執行完mov %rsp,%rbp
的調用棧快照
+++++++++++++++++ 高位地址
99 | 110 | -- 存放main函數調用方的棧幀地址
+++++++++++++++++
98 | 0x40051d | -- 存放EIP的值,add2返回后將執行的指令的地址
+++++++++++++++++
97 | 99 | -- 存放add2函數調用方(即main函數)的棧幀地址
+++++++++++++++++
96 | 0xXX |
+++++++++++++++++
.................
76 | 0x02 | -- 這是`mov %edi,-0x14(%rbp)`的執行結果
+++++++++++++++++
.................
+++++++++++++++++
73 | 0xXX |
+++++++++++++++++
72 | 0x400504 | -- EIP的值,存放add返回后將執行的指令的地址
+++++++++++++++++
71 | 97 | -- 存放add函數調用方(即add函數)的棧幀地址<-- EBP,ESP
+++++++++++++++++ 低位地址
下面就是一系列彈出棧幀的過程了
當add函數執行完retq
的調用棧快照
+++++++++++++++++ 高位地址
99 | 110 | -- 存放main函數調用方的棧幀地址
+++++++++++++++++
98 | 0x40051d | -- 存放EIP的值,add2返回后將執行的指令的地址
+++++++++++++++++
97 | 99 | -- 存放add2函數調用方(即main函數)的棧幀地址 <-- EBP
+++++++++++++++++
96 | 0xXX |
+++++++++++++++++
.................
76 | 0x02 | -- 這是`mov %edi,-0x14(%rbp)`的執行結果
+++++++++++++++++
.................
+++++++++++++++++
73 | 0xXX | <-- ESP
+++++++++++++++++ 低位地址
然后就不斷彈出棧幀了~~~
從上面看到函數入參是先存儲到寄存器中,然后在函數體內讀取到棧幀所在空間中(局部變量、臨時變量)。那么從調用棧中我們能獲取函數的調用流和入參信息,從而恢復案發現場^_^
插播:函數的調用方式
其實函數入參的傳遞方式不止上述這種,還有以下3種
- cdecl調用約定
調用方從右到左的順序將參數壓入棧中,在被調用方執行完成后,由調用方負責清理棧中的參數(也稱為棧平衡)。 - stdcall調用約定
巨硬自稱的一種調用約定,并不是實際上的標準調用約定。調用方從右到左的順序將參數壓入棧中,在被調用方執行完成后,由被調用方負責清理棧中的參數(也稱為棧平衡)。 - fastcall調用約定
是stdcall的變體,調用方從右到左的順序將參數壓入棧中,最右邊的兩個參數則不壓入棧中,而是分別存儲在ECX和EDX寄存器中,在被調用方執行完成后,由被調用方負責清理棧中的參數(也稱為棧平衡)。
但不管哪種,最終還是會在函數體內讀取到當前棧幀空間中。
二. 如何獲取調用棧?
上面寫的這么多,可是我們現在寫的是JavaScript哦,那到底怎么才能讀取調用棧的信息呢?
拋個異常看看
IE10+的Error實例中包含一個stack
屬性
示例
function add(a, b){
let sum = a + b
throw Error("Capture Call Stack!")
return sum
}
function add2(a){
return 2 + add(0, a)
}
function main(){
add2(2)
}
try{
main()
} catch (e){
console.log(e.stack)
}
Chrome回顯
Error: Capture Call Stack!
at add (index.html:16)
at add2 (index.html:21)
at main (index.html:25)
at index.html:29
FireFox回顯
add@file:///home/john/index.html:16:9
add2@file:///home/john/index.html:21:14
main@file:///home/john/index.html:25:3
@file:///home/john/index.html:29:3
V8的Error.captureStackTrace
函數
V8引擎向JavaScript提供了其Stack Trace API中的captureStackTrace
函數,用于獲取調用Error.captureStackTrace
時的調用棧快照。函數簽名如下
@static
@method captureStackTrace(targetObject, constructorOpt)
@param {Object} targetObject - 為targetObject添加.stack屬性,該屬性保存調用Error.captureStackTrace時的調用棧快照
@param {Function} constructorOpt= - 調用棧快照不斷作出棧操作,直到constructorOpt所指向的函數剛好出棧為止,然后保存到targetObject的stack屬性中
@return {undefined}
示例
function add(a, b){
let sum = a + b
let targetObj = {}
Error.captureStackTrace(targetObj)
console.log(targetObj.stack)
Error.captureStackTrace(targetObj, add)
console.log(targetObj.stack)
return sum
}
function add2(a){
return 2 + add(0, a)
}
function main(){
add2(2)
}
main()
Chrome回顯
Error
at add (index.html:18)
at add2 (index.html:28)
at main (index.html:32)
at index.html:35
Error
at add2 (index.html:28)
at main (index.html:32)
at index.html:35
控制臺的console.trace
函數
還有最后一招console.trace
,不過實際用處不大
示例
function add(a, b){
let sum = a + b
console.trace()
return sum
}
function add2(a){
return 2 + add(0, a)
}
function main(){
add2(2)
}
main()
Chrome回顯
add @ index.html:16
add2 @ index.html:22
main @ index.html:26
(anonymous) @ index.html:29
上述三種方式(實際就兩種可用啦)都只能獲取函數調用流,函數入參、局部變量等信息全都灰飛煙滅了?上面不是說好這些信息調用棧都有嘛,干嘛不給我呢?其實想想都知道調用棧中有這么多信息,其實我們只需一小部分,全盤托出并不是什么好設計。其實我們只要再獲取棧幀局部狀態就好了。
三. 什么是棧幀局部狀態?又如何獲取呢?
所謂棧幀局部狀態其實就是函數入參和局部變量,試想如果我們得到add
函數調用時的入參是a=0
、b=2
和sum=2
,那么不就得到完整案發現場了嗎?那問題就是如何獲得了。要不我們做個Monkey Patch
- 自定義一個異常類來承載棧幀局部狀態
function StackTraceError(e, env){
if (this instanceof StackTraceError);else return new StackTraceError(e, env)
this.e = e
this.env = env
}
let proto = StackTraceError.prototype = Object.create(Error.prototype)
proto.name = "StackTraceError"
proto.message = "Internal error."
proto.constructor = StackTraceError
proto.valueOf = proto.toString = function(){
let curr = this, q = [], files = []
do {
if (curr.stack){
let stack = String(curr.stack)
let segs = stack.split('\n').map(seg => seg.trim())
files = segs.filter(seg => seg != "Error")
}
else{
q.unshift({name: curr.name,
msg: curr.message,
env: curr.env})
}
} while (curr = curr.e)
let frames = []
let c = files.length, i = 0
while (i < c){
let file = files[i]
let e = q[i]
let frame = {
name: e && e.name,
msg: e && e.msg,
env: e && e.env,
file: file
}
frames.push(JSON.stringify(frame))
i += 1
}
return frames.join("\n")
}
- 每個函數定義都通過
try/catch
捕獲棧幀局部狀態
function add(a, b){
try{
var sum = a + b
throw Error()
}
catch(e){
throw StackTraceError(e, ["a:", a, "b", b, "sum", sum].join("::"))
}
return sum
}
function add2(a){
try{
return 2 + add(0, a)
}
catch(e){
throw StackTraceError(e, ["a", a].join("::"))
}
}
function main(){
try{
add2(2)
}
catch(e){
throw StackTraceError(e, "")
}
}
try{
main()
} catch(e){
console.log(e+'')
}
chrome下
{"name":"StackTraceError","msg":"Internal error.","env":"a::0::b::2::sum::2","file":"at add (file:///home/john/index.html:57:11)"}
{"name":"StackTraceError","msg":"Internal error.","env":"a:;2","file":"at add2 (file:///home/john/index.html:67:16)"}
{"name":"StackTraceError","msg":"Internal error.","env":"","file":"at main (file:///home/john/index.html:76:5)"}
{"file":"at file:///home/john/index.html:84:3"}
上面這種做法有三個問題
- V8引擎不會對包含
try/catch
的函數進行優化,如果每個函數都包含try/catch
那會嚴重影響執行效率。 - 這種方式顯然不能讓每個開發人員手寫,必須通過預編譯器來靜態織入,開發難度有點大哦。
- 像
sum
這種臨時變量其實并不用記錄,因為它可以被運算出來,只要記錄a
和b
即可。
假如我們寫的全是純函數(就是相同入參必定得到相同的返回值,函數內部不依賴外部狀態,如加法一樣,1+1永遠等于2),那么我們只需捕獲入口/公用函數的入參即可恢復整個案發現場了。
function add(a, b){
var sum = a + b
throw Error()
return sum
}
function add2(a){
try{
return 2 + add(0, a)
}
catch(e){
throw {error:e, env:["a:", a].join("::")})
}
}
function main(){
add2(2)
}
try{
main()
} catch(e){
console.log(e+'')
}
然后我們就可以拿著報錯信息從add2
逐步調試到add
中了。假如用ClojureScript我們還可以定義個macro簡化一下
;; 私有函數
(defn- add [a b]
(let [sum (+ a b)]
(throw (Error.))
sum))
;; 入口/公用函數
(defn-pub add2 [a]
(+ 2 (add 0 a)))
(defn main []
(add2 2))
(try
(main)
(catch e
(println e)))
defn-pub macro的定義
(defmacro defn-pub [name args & body]
(let [e (gensym)
arg-names (mapv str args)]
`(def ~name
(fn ~args
(try ~@body
(catch js/Object ~e
(throw (clj->js {:e ~e, :env (zipmap ~arg-names ~args)}))))))))
總結
寫到這里其實也沒有一個很好的方式去捕獲案發現場證據,在入口/公用函數中加入try/catch
是我現階段能想到比較可行的方式,請各位多多指點。
尊重原創,轉載請注明轉自:http://www.cnblogs.com/fsjohnhuang/p/7729527.html ^_^肥仔John
參考
http://www.cnblogs.com/exiahan/p/4310010.html
http://blog.csdn.net/qiu265843468/article/details/17844419
http://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html
http://blog.shaochuancs.com/about-error-capturestacktrace/
https://github.com/v8/v8/wiki/Stack-Trace-API
文章列表