前端魔法堂——調用棧,異常實例中的寶藏

前言

 在上一篇《前端魔法堂——異常不只僅是try/catch》中咱們描述出一副異常及如何捕獲異常的畫像,但僅僅如此而已。試想一下,咱們窮盡一切捕獲異常實例,而後僅僅爲告訴用戶,運維和開發人員頁面報了一個哪一個哪一個類型的錯誤嗎?答案是否認的。咱們的目的是收集剛剛足夠的現場證據,好讓咱們能立刻重現問題,快速修復,提供更優質的用戶體驗。那麼問題就落在「收集足夠的現場證據」,那麼咱們又須要哪些現場證據呢?那就是異常信息調用棧棧幀局部狀態。(異常信息咱們已經獲取了)
 本文將圍繞上調用棧棧幀局部狀態敘述,準開開車^_^html

概要

 本篇將敘述以下內容:前端

  1. 什麼是調用棧?
  2. 如何獲取調用棧?
  3. 什麼是棧幀局部狀態?又如何獲取呢?

一.什麼是調用棧?

 既然咱們要獲取調用棧信息,那麼起碼要弄清楚什麼是調用棧吧!下面咱們分別從兩個層次來理解~git

印象派

 假若主要工做內容爲應用開發,那麼咱們對調用棧的印象以下就差很少了:github

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()時,調用棧以下chrome

----------------<--棧頂
|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()執行完成後,調用棧以下this

----------------<--棧頂
----------------<--棧底

 如今咱們對調用棧有了大概的印象了,但你們有沒有留意上面記錄"棧幀中存儲對應函數的局部變量",棧幀中僅僅存儲對應函數的局部變量,那麼入參呢?難道會做爲局部變量嗎?這個咱們要從理論的層面才能獲得解答呢。

理論派

 這裏咱們要引入一個簡單的C程序,透過其對應的彙編指令來說解了。我會盡我所能用通俗易懂的語言描述這一切的,如有錯誤請各位指正!!

前提知識

  1. Intel X86架構中調用棧的棧底位於高位地址,而棧頂位於低位地址。(和印象派中示意圖的方向恰好相反)
  2. 調用棧涉及的寄存器有
ESP/RSP, 暫存棧頂地址
EBP/RBP, 暫存棧幀起始地址
EIP, 暫存下一個CPU指令的內存地址,當CPU執行完當前指令後,從EIP讀取下一條指令的內存地址,而後繼續執行
  1. 操做指令
PUSH <OPRD>,將ESP向低位地址移動操做數所需的空間,而後將操做數壓入調用棧中
POP <OPRD>,從調用棧中讀取數據暫存到操做數指定的寄存器或內存空間中,而後向高位地址移動操做數對應的空間字節數
MOV <SRC>,<DST>,數據傳送指令。用於將一個數據從源地址傳送到目標地址,且不破壞源地址的內容
ADD <OPRD1>,<OPRD2>,兩數相加不帶進位,而後將結果保存到目標地址上
RET,至關於POP EIP。就是從堆棧中出棧,而後將值保存到EIP寄存器中
LEAVE,至關於MOV EBP ESP,而後再POP EBP。就是將棧頂指向當前棧幀地址,而後將調用者的棧幀地址暫存到EBP中
  1. 每一個函數調用前彙編器都會加入如下前言(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種

  1. cdecl調用約定
     調用方從右到左的順序將參數壓入棧中,在被調用方執行完成後,由調用方負責清理棧中的參數(也稱爲棧平衡)。
  2. stdcall調用約定
     巨硬自稱的一種調用約定,並非實際上的標準調用約定。調用方從右到左的順序將參數壓入棧中,在被調用方執行完成後,由被調用方負責清理棧中的參數(也稱爲棧平衡)。
  3. 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=0b=2sum=2,那麼不就獲得完整案發現場了嗎?那問題就是如何得到了。要不咱們作個Monkey Patch

  1. 自定義一個異常類來承載棧幀局部狀態
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")
}
  1. 每一個函數定義都經過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"}

 上面這種作法有三個問題

  1. V8引擎不會對包含try/catch的函數進行優化,若是每一個函數都包含try/catch那會嚴重影響執行效率。
  2. 這種方式顯然不能讓每一個開發人員手寫,必須經過預編譯器來靜態織入,開發難度有點大哦。
  3. sum這種臨時變量其實並不用記錄,由於它能夠被運算出來,只要記錄ab便可。

 假如咱們寫的全是純函數(就是相同入參一定獲得相同的返回值,函數內部不依賴外部狀態,如加法同樣,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

相關文章
相關標籤/搜索