• 前端魔法堂——调用栈,异常实例中的宝藏


    前言

     在上一篇《前端魔法堂——异常不仅仅是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读取下一条指令的内存地址,然后继续执行
    
    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('
    ').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("
    ")
    }
    
    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

  • 相关阅读:
    Leetcode 349. Intersection of Two Arrays
    hdu 1016 Prime Ring Problem
    map 树木品种
    油田合并
    函数学习
    Leetcode 103. Binary Tree Zigzag Level Order Traversal
    Leetcode 102. Binary Tree Level Order Traversal
    Leetcode 101. Symmetric Tree
    poj 2524 Ubiquitous Religions(宗教信仰)
    pat 1009. 说反话 (20)
  • 原文地址:https://www.cnblogs.com/fsjohnhuang/p/7729527.html
Copyright © 2020-2023  润新知