调试器与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指令执行 0xCC,task(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_CONT、PTRACE_SINGLESTEP
而对于 PTRACE_CONT 是让进程恢复正常的trace状态并且一直运行下去
PTRACE_SINGLESTEP 是执行一条汇编语句

两个问题

  1. 如果断点断住了,那么breakpoint的位置是 int 3,PTRACE_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