调试器
如果把调试器看成一个产品的时候,首先他应该具体的功能有哪些?
1. 断点
2. 代码与当前断点映射(例如查看源码)
3. 断点过后继续运行(继续执行,单步,单出)
4. 打印变量
附属的功能:
1. 打印调用栈
2. 打印堆栈
3. 函数调用
4. 条件断点
5. 反汇编
...
调试状态
linux
会有trace进程的状态,对被trace
的进程
可以进行控制
基本功能
断点
断点通常分为两种,硬件断点和软件断点
先说硬件断点,这个我们(普通的后台开发)平时不太常用,对于x86系统来说, 是依赖寄存器DR0~DR7和2个MSR实现的
说直白一点,就是可以把内存地址放到寄存器内,当cpu进行 读、写、修改该地方内存时,就会触发trap而对于软件断点,依赖CPU的指令
对于x86架构CPU来说,int 3
指令就是暂停的系统中断指令,0XCC
(实际上还有一种软断点编码[]byte{0xcd, 0x03})
当CPU指令执行0xCC
,task
(CPU的工作基本单位)就会陷入暂停状态(trap或者中断),等待新的命令
代码和当前断点映射
这个需要编译器和链接器支持,编译参数 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
附属功能
打印调用栈
主要指函数调用,通常我们有函数帧(栈),栈中包含了返回地址,通过返回地址来保证读取到函数栈
(图片好像有点小错误,但是我忘记了哪个寄存器搞错了)
函数调用
需要语言本身提供支持(原理上来讲不支持也可以注入的方式,但是异常复杂),因为涉及到内存(堆栈)和寄存器的变动
"debugCall"
"runtime.debugCall"
"runtime.debugCallV1"
条件断点
条件断点是一件很慢的事情,普通的10000次循环,只check一个变量,延迟会达到10s
go-delve/delve 1549
反汇编
指令翻译 符号对应关系提前从二进制文件中解析出来
go特殊的部分
协程
- cgo的协程是要做切换栈的,我们对于切换的协程是如何做到
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()
}
(流程上操作为主)
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
寄存器的
amd64 linux fs -8 (fs地址偏移量直接-8)
i386 linux gs -4 (gs是ldt,需要找到对应gdt地址-4)
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