[调试][实现][原理]用Golang实现建议断点调试器

发布于:2025-08-29 ⋅ 阅读:(14) ⋅ 点赞:(0)

1 目的

深入理解调试的原理

2 实现

golang 版本:1.24.2

系统:Rocky Linux 9.5

2.1 被调试程序

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("start demo")
    // NOTE:手速快点 30秒内启动断点调试程序
    time.Sleep(30 * time.Second)
    i := 0
    fmt.Println(i)
    time.Sleep(3 * time.Second)
    i++
    fmt.Println(i)
    time.Sleep(3 * time.Second)
    i++
    fmt.Println(i)
    fmt.Println("stop demo")
}

编译时 一定要带上调试信息,默认时带调试信息的

go build -o demo demo.go

2.2 简易断点调试器

2.2.1 原理

需求:给程序指定行打断点

实现:程序运行之前代码已经被编译成一条一条的指令,运行时这些指令会被加载到内存,然后再到寄存器,CPU读取寄存器的指令来执行。打断点的原理是给运行的程序增加中断指令(INT),从而实现打断点的目的。

(1)调试程序如何与被调试程序建立联系

调试程序使用ptrace系统调用来操作被调试程序

链接:ptrace(2) - Linux manual page

ptrace系统调用:Linux/Unix 下的系统调用,全称是 Process Trace,它是实现调试器(如 gdb, strace)的核心机制。ptrace 允许一个进程(通常是调试器)去 观察和控制另一个进程(被调试进程),包括:读取 / 修改寄存器、内存;捕获系统调用(syscall);设置断点 / 单步执行;捕获信号。换句话说,调试器就是靠 ptrace 来 “附加 attach” 到目标程序,然后拦截它的执行。

使用的相关操作:

  • PTRACE_ATTACH:调试器 attach 到某个正在运行的进程。
  • PTRACE_DETACH:调试器脱离。脱离之前要先attach 否则会报错!
  • PTRACE_PEEKDATA / PEEKUSER:读目标进程的内存/寄存器。
  • PTRACE_POKEDATA / POKEUSER:写目标进程的内存/寄存器(比如往代码里写入 INT 3 (0xCC) 来设置断点)。
  • PTRACE_CONT:让目标进程继续运行。

(2)如何获取需要打断点的地址

即:如何获取指定行的地址?每一行代码被编译的时候会编译成一条或多条指令,这些汇编指令的地址在编译的时候已经就确定了。可以通过读取可执行文件的dwarf信息来获取指定行编译出来的第一个指令的地址。

ELF(Executable and Linkable Format,可执行和可链接格式)是一种在 Linux 和类Unix系统中使用的标准二进制文件格式,用于表示可执行文件、目标代码(编译生成的目标文件)、共享库和核心转储文件。它包含了除机器码本身之外的额外元数据,如程序的入口点、符号表、段信息等,这些元数据使得操作系统能够正确地加载和运行程序。

DWARF(Debugging With Attributed Record Formats)是一种通用的标准调试信息格式(https://dwarfstd.org/doc/dwarf-2.0.0.pdf),用于在编译后的可执行文件和原始源代码之间建立映射关系,从而实现源代码级别的调试。它以树状结构存储信息,通过调试信息条目(DIE)描述源代码中的变量、函数、类型、以及它们与机器码的对应关系。通过Dwarf,调试器可以显示当前代码的行号、局部变量、调用堆栈等信息,便于开发者进行程序调试和崩溃信息解析。

golang也有工具来获取汇编指令的地址

go tool objdump demo | grep demo.go | more

(3)断点怎么打

INT 是x86 架构中CPU的中断指令,INT 3 就是调用 中断向量 3,它被保留专门作为 断点异常(Breakpoint Exception)。当 CPU 执行到 INT 3:一是:产生 #BP 异常(Breakpoint Exception)。二是:控制权交给中断向量表中的处理程序(如调试程序),如果没有处理程序,程序将崩溃退出。

2.2.2 代码实现

package main

import (
    "debug/dwarf"
    "debug/elf"
    "fmt"
    "syscall"
)

// getLineAddrByNumber 获取指定代码行的地址
func getLineAddrByNumber(execPath, fileName string, lineNum int) (uintptr, error) {
    execFile, err := elf.Open(execPath)
    if err != nil {
        return 0, err
    }
    defer execFile.Close()

    dwarfData, err := execFile.DWARF()
    if err != nil {
        return 0, err
    }

    reader := dwarfData.Reader()
    // 从头开始读
    reader.Seek(0)

    for {
        entry, err := reader.Next()
        if err != nil {
            return 0, err
        }

        if entry == nil {
            break
        }

        if entry.Tag == dwarf.TagCompileUnit {
            lineReader, err := dwarfData.LineReader(entry)
            if err != nil {
                return 0, fmt.Errorf("error get line reader: %v", err)
            }
            if lineReader == nil {
                continue
            }
            for {
                entry := dwarf.LineEntry{}
                err = lineReader.Next(&entry)
                if err != nil {
                    break
                }
                if entry.File.Name == fileName && entry.Line == lineNum {
                    return uintptr(entry.Address), nil
                }
            }
        }
    }

    return 0, fmt.Errorf("not find line address")
}

// insertBreakpoint 插入断点指令并返回原始指令
func insertBreakpoint(pid int, addr uintptr) (byte, error) {
    // 读取原始指令
    var origIns [1]byte
    _, err := syscall.PtracePeekText(pid, addr, origIns[:])
    if err != nil {
        return 0, fmt.Errorf("error read original instruction: %v", err)
    }

    // 插入断点指令 (0xCC 是 INT 3 指令)
    _, err = syscall.PtracePokeText(pid, addr, []byte{0xCC})
    if err != nil {
        return 0, err
    }
    fmt.Printf("Breakpoint set at address %x\n", addr)

    // 返回原始指令,以便之后恢复
    return origIns[0], nil
}

func breakpointDemo(pid int, execPath string) error {
    // 调用ptrace系统调用的PTRACE_ATTACH操作,附加到指定的进程
    // attach到程序后 pid对应的程序会Stopped,会停止 不是退出
    if err := syscall.PtraceAttach(pid); err != nil {
        return fmt.Errorf("error attacting to process: %v", err)
    }
    fmt.Println("Attached to process", pid)

    // 调用wait4系统调用等待进程停止
    fmt.Println("wait process stop")
    var pStatus syscall.WaitStatus
    if _, err := syscall.Wait4(pid, &pStatus, 0, nil); err != nil {
        return fmt.Errorf("error wait process stop: %v", err)
    }
    // Stopped: true, Signaled: false, ExitStatus: -1, StopSignal: 19
    fmt.Printf("Status: %v Stopped: %v Signaled: %v ExitStatus: %d StopSignal: %d\n",
        pStatus, pStatus.Stopped(), pStatus.Signaled(), pStatus.ExitStatus(),
        pStatus.StopSignal())

    // 获取断点地址
    addr1, err := getLineAddrByNumber(execPath, "/code/local/goscripts/demo.go", 22)
    if err != nil {
        return fmt.Errorf("error get 1 line addr: %v", err)
    }

    addr2, err := getLineAddrByNumber(execPath, "/code/local/goscripts/demo.go", 25)
    if err != nil {
        return fmt.Errorf("error get 2 line addr: %v", err)
    }

    // 插入断点指令
    orgIns1, err := insertBreakpoint(pid, addr1)
    if err != nil {
        return fmt.Errorf("error inserting breakpoint 1: %v", err)
    }
    orgIns2, err := insertBreakpoint(pid, addr2)
    if err != nil {
        return fmt.Errorf("error inserting breakpoint 2: %v", err)
    }

    // 继续执行子进程
    if err := syscall.PtraceCont(pid, 0); err != nil {
        return fmt.Errorf("error continuing process: %v", err)
    }

    // 等待第一个断点触发
    _, err = syscall.Wait4(pid, &pStatus, 0, nil)
    if err != nil {
        return fmt.Errorf("error waiting for process: %v", err)
    }
    fmt.Printf("Status: %v Stopped: %v Signaled: %v ExitStatus: %d StopSignal: %d\n",
        pStatus, pStatus.Stopped(), pStatus.Signaled(), pStatus.ExitStatus(), pStatus.StopSignal())

    // 检查是否由于断点停止
    if pStatus.Stopped() && pStatus.StopSignal() == syscall.SIGTRAP {
        fmt.Println("Process hit a breakpoint 1")

        // 恢复原始指令
        _, err = syscall.PtracePokeText(pid, addr1, []byte{orgIns1})
        if err != nil {
            return fmt.Errorf("Error restoring original instruction: %v", err)
        }
        fmt.Printf("Restored original instruction at address %x\n", addr1)
    }

    // 继续执行子进程
    if err := syscall.PtraceCont(pid, 0); err != nil {
        return fmt.Errorf("error continuing process: %v", err)
    }

    // 等待第二个断点触发
    _, err = syscall.Wait4(pid, &pStatus, 0, nil)
    if err != nil {
        return fmt.Errorf("Error waiting for process: %v", err)
    }
    fmt.Printf("Status: %v Stopped: %v Signaled: %v ExitStatus: %d StopSignal: %d\n",
        pStatus, pStatus.Stopped(), pStatus.Signaled(), pStatus.ExitStatus(), pStatus.StopSignal())

    // 检查是否由于断点停止
    if pStatus.Stopped() && pStatus.StopSignal() == syscall.SIGTRAP {
        fmt.Println("Process hit a breakpoint 2")

        // 恢复原始指令
        _, err = syscall.PtracePokeText(pid, addr2, []byte{orgIns2})
        if err != nil {
            return fmt.Errorf("Error restoring original instruction: %v", err)
        }
        fmt.Printf("Restored original instruction at address %x\n", addr2)
    }

    // 调用ptrace系统调用的PTRACE_DETACH操作
    // PTRACE_DETACH 之后pid程序会自动开始运行,如果前面调用的PtraceCont这里会detach失败
    if err := syscall.PtraceDetach(pid); err != nil {
        return fmt.Errorf("Error detaching from process: %v", err)
    }

    fmt.Println("Detached from process", pid)
    return nil
}

func main() {
    breakpointDemo(1628245, "/code/local/goscripts/demo")
}

2.2.3 运行结果

被调试程序输出:

调试程序输出:


网站公告

今日签到

点亮在社区的每一天
去签到