调试器与GO

调试器

如果把调试器看成一个产品的时候,首先他应该具体的功能有哪些?
1. 断点
2. 代码与当前断点映射(例如查看源码)
3. 断点过后继续运行(继续执行,单步,单出)
4. 打印变量

附属的功能:
1. 打印调用栈
2. 打印堆栈
3. 函数调用
4. 条件断点
5. 反汇编
...

调试状态

linux会有trace进程的状态,对被trace进程可以进行控制

基本功能

断点

断点通常分为两种,硬件断点和软件断点

  1. 先说硬件断点,这个我们(普通的后台开发)平时不太常用,对于x86系统来说, 是依赖寄存器DR0~DR7和2个MSR实现的
    说直白一点,就是可以把内存地址放到寄存器内,当cpu进行 读、写、修改该地方内存时,就会触发trap

  2. 而对于软件断点,依赖CPU的指令
    对于x86架构CPU来说,int 3指令就是暂停的系统中断指令,0XCC (实际上还有一种软断点编码[]byte{0xcd, 0x03}
    当CPU指令执行 0xCCtask(CPU的工作基本单位)就会陷入暂停状态(trap或者中断),等待新的命令

go-delve-debugger

debugger3.svg

代码和当前断点映射

这个需要编译器和链接器支持,编译参数 go build -gcflags="-N -l" -o main main.go
保证禁用编译器优化和内联优化 ctx objdump --dwarf main > log.txt 可以看到 行号 状态机

 The Directory Table is empty. 

 The File Name Table (offset 0x279ad):
  Entry Dir Time    Size    Name   
  1 0   0   0   /home/chainhelen/main.go

 Line Number Statements:
  [0x000279ca]  Extended opcode 2: set Address to 0x48cf30
  [0x000279d5]  Set File Name to entry 1 in the File Name Table
  [0x000279d7]  Advance Line by 1 to 2 
  [0x000279d9]  Special opcode 9: advance Address by 0 to 0x48cf30 and Line by 5 to 7 
  [0x000279da]  Set prologue_end to true
  [0x000279db]  Special opcode 154: advance Address by 15 to 0x48cf3f and Line by 0 to 7 
  [0x000279dc]  Special opcode 145: advance Address by 14 to 0x48cf4d and Line by 1 to 8 
  [0x000279dd]  Set is_stmt to 0
  [0x000279de]  Special opcode 34: advance Address by 3 to 0x48cf50 and Line by 0 to 8 
  [0x000279df]  Set is_stmt to 1
  [0x000279e0]  Advance PC by 66 to 0x48cf92
  [0x000279e2]  Special opcode 244: advance Address by 24 to 0x48cfaa and Line by 0 to 8 
  [0x000279e3]  Special opcode 55: advance Address by 5 to 0x48cfaf and Line by 1 to 9 
  [0x000279e4]  Special opcode 102: advance Address by 10 to 0x48cfb9 and Line by -2 to 7
  [0x000279e5]  Extended opcode 1: End of Sequence
objdump --dwarf ./main | grep -C2 "DW_TAG_compile_unit\|DW_AT_producer"  

dwarf 知识简介
golang行号状态机的小bug
PrologueEnd 断点

断点过后继续运行(继续执行,单步,单出)

主要有两个系统调用可以让进程从trap恢复出来, PTRACE_CONTPTRACE_SINGLESTEP
而对于 PTRACE_CONT 是让进程恢复正常的trace状态并且一直运行下去
PTRACE_SINGLESTEP 是执行一条汇编语句

两个问题
1. 如果断点断住了,那么breakpoint的位置是 int 3PTRACE_CONT是不能正常运行的,举个例子

(dlv) disassemble
TEXT main.main(SB) /home/chainhelen/main.go  
    ...
    main.go:7   0x4aa3b8    488d6c2460      lea rbp, ptr [rsp+0x60]
=>    main.go:8   0x4aa3bd*   0f57c0          xorps xmm0, xmm0
    main.go:8   0x4aa3c0    0f11442438      movups xmmword ptr [rsp+0x38], xmm0
    main.go:8   0x4aa3c5    488d442438      lea rax, ptr [rsp+0x38]
    ...
    main.go:7   0x4aa429    e8b2e9faff      call $runtime.morestack_noctxt
    main.go:1   0x4aa42e    e96dffffff      jmp $main.main

实际上的状态是0XCC

(dlv) disassemble
TEXT main.main(SB) /home/chainhelen/main.go  
    ...
    main.go:7   0x4aa3b8    488d6c2460      lea rbp, ptr [rsp+0x60]
=>    main.go:8   0x4aa3bd*   cc57c0          xorps xmm0, xmm0
    main.go:8   0x4aa3c0    0f11442438      movups xmmword ptr [rsp+0x38], xmm0
    main.go:8   0x4aa3c5    488d442438      lea rax, ptr [rsp+0x38]
    ...
    main.go:7   0x4aa429    e8b2e9faff      call $runtime.morestack_noctxt
    main.go:1   0x4aa42e    e96dffffff      jmp $main.main


如果你从这个地方直接运行 PTRACE_CONT,那么通常结果是出错(或者CPU跳过错误指令继续前向)

需要的是让指令继续前行(PC寄存器,IP寄存器,RIP寄存器,或者EIP寄存器)
2. 正常运行了,如何实现source code的单步调试
而是源码级别的单步调试,那么需要对每一行的源码都下断点,还有注意返回帧(函数栈或者叫调用栈)

打印变量

对于打印变量,对于全局变量(包括global或者package导出的)主要是符号表
是否符号表就够了?不够!

main.c  
nm main

main.go  
nm main  

局部变量怎么办,跟package无关,也就是跟导出符号无关,也就是不在符号表内
并且对于编译器来说,所有的非static局部变量,最终都在栈上面(还有逃逸的部分在堆上),利用压栈弹栈方式
在编译链接过程中,局部变量都已经转化成栈底(或者栈顶)偏移量

这个时候就需要dwarf记录各个变量的信息,通常非优化编译,都是可以保留的

// main.go
package main

import (  
    "fmt"
)

func main() {  
    hello := "world"
    fmt.Println(hello)
}

// go build
go build -gcflags="-l -N" -o main main.go 

// objdump
objdump --dwarf main > log.txt  


274873  <2><76ed1>: Abbrev Number: 10 (DW_TAG_variable)  
274874     <76ed2>   DW_AT_name        : hello  
274875     <76ed8>   DW_AT_decl_line   : 8  
274876     <76ed9>   DW_AT_type        : <0x37482>  
274877     <76edd>   DW_AT_location    : 3 byte block: 91 b8 7f    (DW_OP_fbreg: -72)  

这种做法是对的吗???哪种场景的变量不正确 Stack unwinding

附属功能

打印调用栈

主要指函数调用,通常我们有函数帧(栈),栈中包含了返回地址,通过返回地址来保证读取到函数栈

test_defer3.svg

(图片好像有点小错误,但是我忘记了哪个寄存器搞错了)

函数调用

需要语言本身提供支持(原理上来讲不支持也可以注入的方式,但是异常复杂),因为涉及到内存(堆栈)和寄存器的变动

"debugCall"
"runtime.debugCall"
"runtime.debugCallV1"

debugcall

条件断点

条件断点是一件很慢的事情,普通的10000次循环,只check一个变量,延迟会达到10s
go-delve/delve 1549

反汇编

指令翻译 符号对应关系提前从二进制文件中解析出来

go特殊的部分

协程

  1. cgo的协程是要做切换栈的,我们对于切换的协程是如何做到

debugger.svg

asm_amd64.s 文件
go-delve/delve 935 cgo better

2.切换用户无关,那么trace权限如何控制?
lockOSThread trace协程与线程绑定

3.系统线程和协程绑定关系保存在当前协程的tls中(见下文)
所有线程,Linux 下在/proc中可以获取到task
所有协程,依赖底下几个内存变量,官方runtime的代码dlv 解析代码官方runtime使用代码

runtime.allglen  
runtime.allgs  
runtime.allg(旧版本)  

单步

序章

关于协程调度的地方,序章需要跳过做一些识别

// main.go 

package main

import (  
    "fmt"
)

func A() {  
    fmt.Println("hello world")  // set breakpoint
}

func main() {  
    A()
}

TEXT main.A(SB) /home/chainhelen/main.go  
    main.go:7   0x4aa3a0    64488b0c25f8ffffff  mov rcx, qword ptr fs:[0xfffffff8]
    main.go:7   0x4aa3a9    483b6110        cmp rsp, qword ptr [rcx+0x10]
    main.go:7   0x4aa3ad    767a            jbe 0x4aa429
=>    main.go:7   0x4aa3af*   4883ec68        sub rsp, 0x68
    main.go:7   0x4aa3b3    48896c2460      mov qword ptr [rsp+0x60], rbp
    main.go:7   0x4aa3b8    488d6c2460      lea rbp, ptr [rsp+0x60]
    main.go:8   0x4aa3bd    0f57c0          xorps xmm0, xmm0
    main.go:8   0x4aa3c0    0f11442438      movups xmmword ptr [rsp+0x38], xmm0
    main.go:8   0x4aa3c5    488d442438      lea rax, ptr [rsp+0x38]

可能这个地方不够明显,断点在0x4aa3a0 或者 0x4aa3af 差别不是很大

栈分裂

再来看一下栈分裂(或者翻译成分裂栈?)情况(写屏障类似)

package main

import (  
    "fmt"
)

func A() {  
    s := make([]string, 0, 0)
    s = append(s, "hello", "world") // set breakpoint
    fmt.Println(s)
}

func main() {  
    A()
}


(流程上操作为主)

go1.15引入的一个分裂栈问题解决

interface

对于interface的实现,也需要跳过

package main

import (  
    "fmt"
)

type Iface interface {  
    Blah() string
}

type A struct {  
    a int
}

func (A) Blah() string {  
    return "blah"
}

func main() {  
    var iface Iface = A{a: 1}
    s := iface.Blah()    // line 21
    fmt.Printf("%s\n", s)
}

(流程上操作为主)

第二种interface的实现,组合方式

package main

import "fmt"

type A struct {  
    a int
}

type B struct {  
    *A
}

type Iface interface {  
    PtrReceiver() string
    NonPtrReceiver() string
}

func (*A) PtrReceiver() string {  
    return "blah"
}

func (A) NonPtrReceiver() string {  
    return "blah"
}

func main() {  
    var iface Iface = &B{&A{1}}
    s := iface.PtrReceiver()
    s = iface.NonPtrReceiver()
    fmt.Printf("%s\n", s)
}

(流程上操作为主)

tls

并不是指tls双向验证,而是thread local storage.
(linux语境) M 代表的是 task,每一个task都需要一块内存来存放数据,例如 G结构

而且 go 汇编中是有一个名为 TLS寄存器的

debugger2.svg

amd64 linux fs -8 (fs地址偏移量直接-8)
i386 linux gs -4 (gs是ldt,需要找到对应gdt地址-4)

golang tls
dlv中对于这部分的注释

pie

地址无关,尤其是动态库,.text段被加载到虚拟内存任意位置都是可以利用当前指令的相对位置(动态重定位)

// main.go
package main

import (  
    "fmt"
)

func main() {  
    fmt.Println("hello world\n")
}


go build -buildmode=pie -gcflags="-N -l" -o main.pie ~/main.go  
objdump -s -S main.pie > main.pie.log  

go build -gcflags="-N -l" -o main.nopie ~/main.go  
objdump -s -S main.nopie > main.nopie.log  

分别在64bit和32bit上面执行,拿到的文件做一下对比

export CGO_ENABLED="0"  
go build -buildmode=pie -gcflags="-N -l" -o main.pie ~/main.go 

linux x86 32位 编译不过  
linux x86 64位 能编译过  
32位  
    world.go:17     0x86ef64    e80729f7ff      call $__x86.get_pc_thunk.dx
    world.go:17     0x86ef69    8d9277b20200        lea edx, ptr [edx+0x2b277]
    world.go:17     0x86ef6f    89542430        mov dword ptr [esp+0x30], edx
    world.go:17     0x86ef73    894c2434        mov dword ptr [esp+0x34], ecx
    world.go:17     0x86ef77    8400            test byte ptr [eax], al
    world.go:17     0x86ef79    eb00            jmp 0x86ef7b
    world.go:17     0x86ef7b    89442444        mov dword ptr [esp+0x44], eax
    world.go:17     0x86ef7f    c744244801000000    mov dword ptr [esp+0x48], 0x1
    world.go:17     0x86ef87    c744244c01000000    mov dword ptr [esp+0x4c], 0x1
    world.go:17     0x86ef8f    890424          mov dword ptr [esp], eax
    world.go:17     0x86ef92    c744240401000000    mov dword ptr [esp+0x4], 0x1
    world.go:17     0x86ef9a    c744240801000000    mov dword ptr [esp+0x8], 0x1
    world.go:17     0x86efa2    e8d9a2ffff      call $fmt.Println

一些get_pc_thunk介绍,dlv没有直接解析反汇编指令跳过,对于get_pc_thunk.dx 需要跳过 get_pc_thunk

参考
一个古老又广泛的寻址技术:段寄存器
ptrace.2