GNU 调试器中的 Python 解释器

摘要

GNU 调试器 (GDB) 是开发者用于排查代码错误的最强大的调试工具。但是,对于初学者来说,GDB 难以学习,这就是许多程序员更喜欢插入 print 来检查运行时状态的原因。幸运的是,GDB 文本用户界面 (TUI) 为开发者提供了一种同时查看源代码和调试的方法。更令人兴奋的是,在 GDB 7 中,Python 解释器被内置到 GDB 中。此功能通过 Python 库提供了更简单的方法来自定义 GDB 打印机和命令。本文通过讨论示例,试图探索通过 Python 使用高级调试技术来开发 GDB 工具包。

引言

排查软件错误对于开发者来说是一个巨大的挑战。虽然 GDB 提供了许多“调试命令”来检查程序的运行时状态,但其不直观的用法阻碍了程序员使用它来解决问题。的确,掌握 GDB 是一个长期的过程。但是,快速入门并不复杂;你必须像尤达大师一样忘掉你所学过的知识。为了更好地理解如何在 GDB 中使用 Python,本文将重点讨论 GDB 中的 Python 解释器。

定义命令

GDB 支持使用 define 来自定义命令。它可以同时运行一批命令来进行排查。例如,开发者可以通过定义 sf 命令来显示当前帧信息。

# define in .gdbinit
define sf
  where        # find out where the program is
  info args    # show arguments
  info locals  # show local variables
end

但是,由于 API 有限,编写用户定义的命令可能不方便。幸运的是,通过与 GDB 中的 Python 解释器交互,开发者可以利用 Python 库轻松地建立他们的调试工具包。以下部分展示了如何使用 Python 简化调试过程。

内存转储

检查进程的内存信息是排查内存问题的有效方法。开发者可以通过 info proc mappingsdump memory 获取内存内容。为了简化这些步骤,定义一个自定义命令很有用。但是,使用纯 GDB 语法实现并不简单。即使 GDB 支持条件,处理输出也不直观。为了解决这个问题,使用 GDB 中的 Python API 会很有帮助,因为 Python 包含许多用于处理字符串的有用操作。

# mem.py
import gdb
import time
import re

class DumpMemory(gdb.Command):
    """Dump memory info into a file."""

    def __init__(self):
        super().__init__("dm", gdb.COMMAND_USER)

    def get_addr(self, p, tty):
        """Get memory addresses."""
        cmd = "info proc mappings"
        out = gdb.execute(cmd, tty, True)
        addrs = []
        for l in out.split("\n"):
            if re.match(f".*{p}*", l):
                s, e, *_ = l.split()
                addrs.append((s, e))
        return addrs

    def dump(self, addrs):
        """Dump memory result."""
        if not addrs:
            return

        for s, e in addrs:
            f = int(time.time() * 1000)
            gdb.execute(f"dump memory {f}.bin {s} {e}")

    def invoke(self, args, tty):
        try:
            # cat /proc/self/maps
            addrs = self.get_addr(args, tty)
            # dump memory
            self.dump(addrs)
        except Exception as e:
            print("Usage: dm [pattern]")

DumpMemory()

运行 dm 命令将调用 DumpMemory.invoke。通过在 .gdbinit 中加载或实现 Python 脚本,开发者可以使用用户定义的命令在程序运行时跟踪错误。例如,以下步骤展示了如何在 GDB 中调用 DumpMemory

(gdb) start
...
(gdb) source mem.py  # source commands
(gdb) dm stack       # dump stack to ${timestamp}.bin
(gdb) shell ls       # ls current dir
1577283091687.bin  a.cpp  a.out  mem.py

JSON 转储

当开发者在正在运行的程序中检查 JSON 字符串时,解析 JSON 会很有帮助。GDB 可以通过 gdb.parse_and_eval 解析 std::string 并将其作为 gdb.Value 返回。通过处理 gdb.Value,开发者可以将 JSON 字符串传递到 Python json API 中,并以漂亮的格式打印出来。

# dj.py
import gdb
import re
import json

class DumpJson(gdb.Command):
    """Dump std::string as a styled JSON."""

    def __init__(self):
        super().__init__("dj", gdb.COMMAND_USER)

    def get_json(self, args):
        """Parse std::string to JSON string."""
        ret = gdb.parse_and_eval(args)
        typ = str(ret.type)
        if re.match("^std::.*::string", typ):
            return json.loads(str(ret))
        return None

    def invoke(self, args, tty):
        try:
            # string to json string
            s = self.get_json(args)
            # json string to object
            o = json.loads(s)
            print(json.dumps(o, indent=2))
        except Exception as e:
            print(f"Parse json error! {args}")

DumpJson()

命令 dj 在 GDB 中显示更易读的 JSON 格式。此命令有助于在 JSON 字符串较大的情况下提高视觉识别能力。此外,通过使用此命令,可以检测或监视 std::string 是否为 JSON。

(gdb) start
(gdb) list
1       #include <string>
2
3       int main(int argc, char *argv[])
4       {
5           std::string json = R"({"foo": "FOO","bar": "BAR"})";
6           return 0;
7       }
...
(gdb) ptype json
type = std::string
(gdb) p json
$1 = "{\"foo\": \"FOO\",\"bar\": \"BAR\"}"
(gdb) source dj.py
(gdb) dj json
{
  "foo": "FOO",
  "bar": "BAR"
}

语法高亮

语法高亮对于开发者跟踪源代码或排查问题很有用。通过使用 Pygments,可以轻松地为源代码应用颜色,而无需手动定义 ANSI 转义代码。以下示例展示了如何为 list 命令输出应用颜色。

import gdb

from pygments import highlight
from pygments.lexers import CLexer
from pygments.formatters import TerminalFormatter

class PrettyList(gdb.Command):
    """Print source code with color."""

    def __init__(self):
        super().__init__("pl", gdb.COMMAND_USER)
        self.lex = CLexer()
        self.fmt = TerminalFormatter()

    def invoke(self, args, tty):
        try:
            out = gdb.execute(f"l {args}", tty, True)
            print(highlight(out, self.lex, self.fmt))
        except Exception as e:
            print(e)

PrettyList()

跟踪点

尽管开发者可以插入 printfstd::coutsyslog 来检查函数,但当项目庞大时,打印消息并不是一种有效的调试方法。开发者可能会浪费时间构建源代码,并且可能获得很少的信息。更糟糕的是,输出可能变得太多,难以检测问题。实际上,检查函数或变量不需要在代码中嵌入打印函数。通过使用 GDB API 编写 Python 脚本,开发者可以自定义观察点以在运行时动态地跟踪问题。例如,通过实现 gdb.Breakpointgdb.Command,对于开发者获取必要信息(如参数、调用栈或内存使用情况)很有用。

# tp.py
import gdb

tp = {}

class Tracepoint(gdb.Breakpoint):
    def __init__(self, *args):
        super().__init__(*args)
        self.silent = True
        self.count = 0

    def stop(self):
        self.count += 1
        frame = gdb.newest_frame()
        block = frame.block()
        sym_and_line = frame.find_sal()
        framename = frame.name()
        filename = sym_and_line.symtab.filename
        line = sym_and_line.line
        # show tracepoint info
        print(f"{framename} @ {filename}:{line}")
        # show args and vars
        for s in block:
            if not s.is_argument and not s.is_variable:
                continue
            typ = s.type
            val = s.value(frame)
            size = typ.sizeof
            name = s.name
            print(f"\t{name}({typ}: {val}) [{size}]")
        # do not stop at tracepoint
        return False

class SetTracepoint(gdb.Command):
    def __init__(self):
        super().__init__("tp", gdb.COMMAND_USER)

    def invoke(self, args, tty):
        try:
            global tp
            tp[args] = Tracepoint(args)
        except Exception as e:
            print(e)

def finish(event):
    for t, p in tp.items():
        c = p.count
        print(f"Tracepoint '{t}' Count: {c}")

gdb.events.exited.connect(finish)
SetTracepoint()

与其在函数开头插入 std::cout,不如在函数的入口点设置跟踪点,以便提供有用的信息来检查参数、变量和栈。例如,通过在 fib 上设置跟踪点,可以帮助检查内存使用情况、栈和调用次数。

int fib(int n)
{
    if (n < 2) {
        return 1;
    }
    return fib(n-1) + fib(n-2);
}

int main(int argc, char *argv[])
{
    fib(3);
    return 0;
}

以下输出显示了检查函数 fib 的结果。在这种情况下,跟踪点显示了开发者需要的所有信息,包括参数的值、递归流程和变量的大小。与 std::cout 相比,使用跟踪点可以获取更有用的信息。

(gdb) source tp.py
(gdb) tp main
Breakpoint 1 at 0x647: file a.cpp, line 12.
(gdb) tp fib
Breakpoint 2 at 0x606: file a.cpp, line 3.
(gdb) r
Starting program: /root/a.out
main @ a.cpp:12
        argc(int: 1) [4]
        argv(char **: 0x7fffffffe788) [8]
fib @ a.cpp:3
        n(int: 3) [4]
fib @ a.cpp:3
        n(int: 2) [4]
fib @ a.cpp:3
        n(int: 1) [4]
fib @ a.cpp:3
        n(int: 0) [4]
fib @ a.cpp:3
        n(int: 1) [4]
[Inferior 1 (process 5409) exited normally]
Tracepoint 'main' Count: 1
Tracepoint 'fib' Count: 5

性能分析

无需插入时间戳,仍然可以通过跟踪点进行性能分析。通过在 gdb.Breakpoint 之后使用 gdb.FinishBreakpoint,GDB 会在帧的返回地址处设置一个临时断点,以便开发者获取当前时间戳并计算时间差。请注意,通过 GDB 进行性能分析并不精确。其他工具(如 Linux perfValgrind)提供了更有用、更准确的信息来跟踪性能问题。

import gdb
import time

class EndPoint(gdb.FinishBreakpoint):
    def __init__(self, breakpoint, *a, **kw):
        super().__init__(*a, **kw)
        self.silent = True
        self.breakpoint = breakpoint

    def stop(self):
        # normal finish
        end = time.time()
        start, out = self.breakpoint.stack.pop()
        diff = end - start
        print(out.strip())
        print(f"\tCost: {diff}")
        return False

class StartPoint(gdb.Breakpoint):
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        self.silent = True
        self.stack = []

    def stop(self):
        start = time.time()
        # start, end, diff
        frame = gdb.newest_frame()
        sym_and_line = frame.find_sal()
        func = frame.function().name
        filename = sym_and_line.symtab.filename
        line = sym_and_line.line
        block = frame.block()

        args = []
        for s in block:
            if not s.is_argument:
                continue
            name = s.name
            typ = s.type
            val = s.value(frame)
            args.append(f"{name}: {val} [{typ}]")

        # format
        out = ""
        out += f"{func} @ {filename}:{line}\n"
        for a in args:
            out += f"\t{a}\n"

        # append current status to a breakpoint stack
        self.stack.append((start, out))
        EndPoint(self, internal=True)
        return False

class Profile(gdb.Command):
    def __init__(self):
        super().__init__("prof", gdb.COMMAND_USER)

    def invoke(self, args, tty):
        try:
            StartPoint(args)
        except Exception as e:
            print(e)

Profile()

以下输出显示了在函数 fib 上设置跟踪点后的性能分析结果。可以方便地同时检查函数的性能和栈。

(gdb) source prof.py
(gdb) prof fib
Breakpoint 1 at 0x606: file a.cpp, line 3.
(gdb) r
Starting program: /root/a.out
fib(int) @ a.cpp:3
        n: 1 [int]
        Cost: 0.0007786750793457031
fib(int) @ a.cpp:3
        n: 0 [int]
        Cost: 0.002572298049926758
fib(int) @ a.cpp:3
        n: 2 [int]
        Cost: 0.008517265319824219
fib(int) @ a.cpp:3
        n: 1 [int]
        Cost: 0.0014069080352783203
fib(int) @ a.cpp:3
        n: 3 [int]
        Cost: 0.01870584487915039

漂亮打印

虽然 GDB 中的 set print pretty on 提供了一种更好的格式来检查变量,但开发者可能需要解析变量的值以提高可读性。以系统调用 stat 为例。虽然它提供了有用的信息来检查文件属性,但输出值(如权限)可能对于调试来说不可读。通过实现用户定义的漂亮打印,开发者可以解析 struct stat 并以易读的格式输出信息。

import gdb
import pwd
import grp
import stat
import time

from datetime import datetime


class StatPrint:
    def __init__(self, val):
        self.val = val

    def get_filetype(self, st_mode):
        if stat.S_ISDIR(st_mode):
            return "directory"
        if stat.S_ISCHR(st_mode):
            return "character device"
        if stat.S_ISBLK(st_mode):
            return "block device"
        if stat.S_ISREG:
            return "regular file"
        if stat.S_ISFIFO(st_mode):
            return "FIFO"
        if stat.S_ISLNK(st_mode):
            return "symbolic link"
        if stat.S_ISSOCK(st_mode):
            return "socket"
        return "unknown"

    def get_access(self, st_mode):
        out = "-"
        info = ("r", "w", "x")
        perm = [
            (stat.S_IRUSR, stat.S_IWUSR, stat.S_IXUSR),
            (stat.S_IRGRP, stat.S_IRWXG, stat.S_IXGRP),
            (stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH),
        ]
        for pm in perm:
            for c, p in zip(pm, info):
                out += p if st_mode & c else "-"
        return out

    def get_time(self, st_time):
        tv_sec = int(st_time["tv_sec"])
        return datetime.fromtimestamp(tv_sec).isoformat()

    def to_string(self):
        st = self.val
        st_ino = int(st["st_ino"])
        st_mode = int(st["st_mode"])
        st_uid = int(st["st_uid"])
        st_gid = int(st["st_gid"])
        st_size = int(st["st_size"])
        st_blksize = int(st["st_blksize"])
        st_blocks = int(st["st_blocks"])
        st_atim = st["st_atim"]
        st_mtim = st["st_mtim"]
        st_ctim = st["st_ctim"]

        out = "{\n"
        out += f"Size: {st_size}\n"
        out += f"Blocks: {st_blocks}\n"
        out += f"IO Block: {st_blksize}\n"
        out += f"Inode: {st_ino}\n"
        out += f"Access: {self.get_access(st_mode)}\n"
        out += f"File Type: {self.get_filetype(st_mode)}\n"
        out += f"Uid: ({st_uid}/{pwd.getpwuid(st_uid).pw_name})\n"
        out += f"Gid: ({st_gid}/{grp.getgrgid(st_gid).gr_name})\n"
        out += f"Access: {self.get_time(st_atim)}\n"
        out += f"Modify: {self.get_time(st_mtim)}\n"
        out += f"Change: {self.get_time(st_ctim)}\n"
        out += "}"
        return out

p = gdb.printing.RegexpCollectionPrettyPrinter("sp")
p.add_printer("stat", "^stat$", StatPrint)

o = gdb.current_objfile()
gdb.printing.register_pretty_printer(o, p)

通过加载前面的 Python 脚本,PrettyPrinter 可以识别 struct stat 并输出易读的格式,以便开发者检查文件属性。无需插入函数来解析和打印 struct stat,这是从 Python API 获取更好输出的一种更便捷的方式。

(gdb) list 15
10          struct stat st;
11
12          if ((rc = stat("./a.cpp", &st)) < 0) {
13              perror("stat failed.");
14              goto end;
15          }
16
17          rc = 0;
18       end:
19          return rc;
(gdb) source st.py
(gdb) b 17
Breakpoint 1 at 0x762: file a.cpp, line 17.
(gdb) r
Starting program: /root/a.out

Breakpoint 1, main (argc=1, argv=0x7fffffffe788) at a.cpp:17
17          rc = 0;
(gdb) p st
$1 = {
Size: 298
Blocks: 8
IO Block: 4096
Inode: 1322071
Access: -rw-rw-r--
File Type: regular file
Uid: (0/root)
Gid: (0/root)
Access: 2019-12-28T15:53:17
Modify: 2019-12-28T15:53:01
Change: 2019-12-28T15:53:01
}

请注意,开发者可以通过 disable 命令禁用用户定义的漂亮打印。例如,前面的 Python 脚本在全局漂亮打印机下注册了一个漂亮打印机。通过调用 disable pretty-print,打印机 sp 将被禁用。

(gdb) disable pretty-print global sp
1 printer disabled
1 of 2 printers enabled
(gdb) i pretty-print
global pretty-printers:
  builtin
    mpx_bound128
  sp [disabled]
    stat

此外,如果不再需要,开发者可以在当前 GDB 调试会话中排除打印机。以下代码片段展示了如何通过 gdb.pretty_printers.remove 删除 sp 打印机。

(gdb) python
>import gdb
>for p in gdb.pretty_printers:
>    if p.name == "sp":
>        gdb.pretty_printers.remove(p)
>end
(gdb) i pretty-print
global pretty-printers:
  builtin
    mpx_bound128

结论

将 Python 解释器集成到 GDB 中提供了许多灵活的方法来排查问题。虽然许多集成开发环境 (IDE) 可能会嵌入 GDB 以进行可视化调试,但 GDB 允许开发者在运行时实现自己的命令并解析变量的输出。通过使用调试脚本,开发者可以在不修改代码的情况下监视和记录必要的信息。老实说,插入或启用调试代码块可能会改变程序的行为,开发者应该改掉这个坏习惯。此外,当问题重现时,GDB 可以附加该进程并检查其状态,而无需停止它。显然,如果出现具有挑战性的问题,则无法避免通过 GDB 进行调试。感谢将 Python 集成到 GDB 中,开发调试脚本变得更容易,这使得开发者能够以多种方式建立自己的调试方法。

参考

  1. 使用 Python 扩展 GDB

  2. gcc/gcc/gdbhooks.py

  3. gdbinit/Gdbinit

  4. cyrus-and/gdb-dashboard

  5. hugsy/gef

  6. sharkdp/stack-inspector

  7. gdb 调试完整示例(教程)