文章出處

前言

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

概要

 本篇將敘述如下內容:

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

一.什么是調用棧?

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

印象派

 倘若主要工作內容為應用開發,那么我們對調用棧的印象如下就差不多了:

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程序,透過其對應的匯編指令來講解了。我會盡我所能用通俗易懂的語言描述這一切的,若有錯誤請各位指正!!

前提知識

  1. Intel X86架構中調用棧的棧底位于高位地址,而棧頂位于低位地址。(和印象派中示意圖的方向剛好相反)
  2. 調用棧涉及的寄存器有

    ESP/RSP, 暫存棧頂地址
    EBP/RBP, 暫存棧幀起始地址
    EIP, 暫存下一個CPU指令的內存地址,當CPU執行完當前指令后,從EIP讀取下一條指令的內存地址,然后繼續執行
  3. 操作指令

    PUSH <OPRD>,將ESP向低位地址移動操作數所需的空間,然后將操作數壓入調用棧中
    POP <OPRD>,從調用棧中讀取數據暫存到操作數指定的寄存器或內存空間中,然后向高位地址移動操作數對應的空間字節數
    MOV <SRC>,<DST>,數據傳送指令。用于將一個數據從源地址傳送到目標地址,且不破壞源地址的內容
    ADD <OPRD1>,<OPRD2>,兩數相加不帶進位,然后將結果保存到目標地址上
    RET,相當于POP EIP。就是從堆棧中出棧,然后將值保存到EIP寄存器中
    LEAVE,相當于MOV EBP ESP,然后再POP EBP。就是將棧頂指向當前棧幀地址,然后將調用者的棧幀地址暫存到EBP中
  4. 每個函數調用前匯編器都會加入以下前言(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


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()