GDB 断点停住了,为什么还是找不到源码?用一个小程序分清符号和路径
GDB 断点停住了,为什么还是找不到源码?
调试 Redis 这样的项目,配置里经常要填几处路径:可执行文件、源码目录,有时还要选远端程序。
这几处都像是在告诉调试器“我的程序在哪”,很容易混在一起。尤其遇到这种情况:连接没报错,断点也停了,参数都能看见,编辑器却说找不到源码。
那到底是没连上,还是路径写错了?
先不启动整个 Redis。我们用一个十几行的 C 程序,把这个现象单独做出来。这里在同一个 Linux 环境中运行 GDB 和 gdbserver,通过管道使用远程调试协议,不需要开端口,也不需要虚拟机、SSH 或 IDE。它用来解释配置里的两类路径,不是完整的跨机器配置教程。
先停在这个函数里
程序保存为probe.c,后面附有完整代码。核心只有这几行:
int double_value(int value) { return value * 2; }main调用double_value(21),最后应该打印result=42。
正常用gcc -g编译时,除了机器指令,文件里还会有调试信息。比如这个函数的参数叫value,某段指令对应probe.c的哪一行。先不用记住它的格式,我们马上看这些信息能做什么。
为了稳定复现“源码不在原位置”,编译时加一个参数:
-fdebug-prefix-map=实际源码目录=/__blog_build_not_present__/src它把调试信息中的目录前缀换成一个故意不存在的目录。源文件没有搬走,程序计算也没有改变,但 GDB 读到的路径变了。
完整的手动编译步骤如下。在只有probe.c的实验目录里执行,路径不要带空格;自动化脚本不受这个手动操作约定限制。
root="$PWD" work=$(mktemp -d "$root/gdb-source-XXXXXX") gcc -g -O0 -fno-inline -fno-omit-frame-pointer -fno-pie -no-pie \ "-fdebug-prefix-map=$root=/__blog_build_not_present__/src" \ "$root/probe.c" -o "$work/probe" cd "$work" gdb -q ./probe这里特意进入新建的工作目录,里面只有二进制,没有probe.c。否则 GDB 可能从当前目录找到同名源码,故障就被绕过去了。root留在外层 shell 中,后面还要用它找到真正的源码目录。
在 GDB 里输入:
set debuginfod enabled off target remote | gdbserver --once - ./probe 2>server.log break double_value continue print value list double_valuetarget remote | ...让 GDB 用管道与 gdbserver 通信;这里的-表示标准输入输出,并不是一个 TCP 地址。
本次实验在断点处出现的关键输出是:
Breakpoint 1, double_value (value=21) at /__blog_build_not_present__/src/probe.c:5 ... No such file or directory注意这两件事同时发生了:参数是21,源码文件却找不到。
如果调试器什么都没读到,它怎么知道参数名?又怎么知道第 5 行?所以先不用把整套远程配置推倒重来。至少在这个例子里,GDB 已经得到了一部分有效信息。
它知道第几行,不代表它有这一行的文字
我们顺着刚才的输出往下想。
GDB 知道“当前指令对应某个文件的第 5 行”,这是一个位置关系。要把那一行显示在窗口里,还要打开对应的源文件。文件打不开,位置关系本身却不一定丢失。
把这一次的查找画出来:
停在 double_value,value = 21 | v 调试信息:probe.c,第 5 行 记录目录:不存在的 /__blog.../src | v 去旧目录打开 probe.c | v 失败:那里没有这个文件图里缩短了目录名;真实命令仍使用前面的完整前缀。
那么我们缺的不是另一个端口,而是一条“旧目录现在对应哪里”的说明。
在 GDB 中输入,第二个目录替换成前面root的实际值:
set substitute-path /__blog_build_not_present__/src /实际源码目录 list double_value再看这一步发生了什么:
原来:旧目录 + probe.c -> 打不开 加映射:旧目录换成实际源码目录 | v 实际源码目录/probe.c | v 显示 return value * 2;这次输出里就能看到:
3 int double_value(int value) 4 { 5 return value * 2; 6 }这一轮不需要重新编译,也没有重新连接,更没有改程序。我们只是让 GDB 去正确的目录找源码。
set substitute-path做的是目录前缀替换,匹配还要满足目录边界,不能理解成任意字符串替换。更详细的规则见 GDB 的源码路径说明。
那只要配好源码目录,就都能调了吗?
再换一个条件。保持 gdbserver 运行的仍是刚才那个程序,但给 GDB 读取的文件去掉调试信息:
objcopy --strip-debug ./probe ./probe-no-debug先在前一轮 GDB 中continue,让程序正常结束,再quit。然后执行上面的命令,用gdb -q ./probe-no-debug开始新一轮。连接仍然是:
target remote | gdbserver --once - ./probe 2>server.log注意两个文件名不同:运行的是probe,GDB 这边读取的是probe-no-debug。这是特意安排的对照,不是推荐实际工程混用构建产物。
这一次,我们仍设置正确的源码映射,仍然break double_value、continue。在本次构建中,断点居然还可以停住!
但print value和list double_value失败了。
为什么还认得函数?--strip-debug去掉的是调试信息,并不等于删掉所有符号。这一份文件还保留了可供定位函数入口的普通符号;参数信息、源代码行的关系却不在了。因此“按函数名能打断点”也不能单独证明调试信息完整。
现在即使把probe.c放到 GDB 面前,它也不会自动从 C 代码反推出这些缺失信息。源码路径映射没有替代调试信息的能力。
把三轮结果放一起,区别就比较清楚了:
| GDB 读取的文件 | 源码映射 | 本次结果 |
|---|---|---|
带调试信息的probe | 没有 | 断点停住、参数为 21,源码打不开 |
同一个probe | 正确 | 参数为 21,源码第 5 行可见 |
probe-no-debug | 正确 | 函数断点仍可停,参数和源码行不可用 |
三轮都继续执行到result=42,并正常退出。第三轮的变化在 GDB 读取的信息,不在程序的计算结果。
回到 Redis、IDE 和另一台机器
现在再看配置里的路径,可以先问两个具体问题:
- GDB 读到的是不是与实际运行程序相匹配、含有所需调试信息的构建文件?
- 调试信息记录的源码目录,在 GDB 所在环境里是否存在?如果不存在,现在对应哪个目录?
比如程序在构建机器的/build/redis/src下编译,源码在你这里放到了/home/me/redis/src。路径映射解决的是这两处目录的对应关系。它不负责把另一版本的 Redis 变成这次编译使用的版本。即使窗口能显示源码,拿错版本仍可能让你看着错误的行解释程序。
如果连目标都连接不上,先检查连接与进程;如果连接和停止都正常,再按已有输出检查符号与源码。这两种现象不该只因为“都叫调试失败”就使用同一套修复操作。共享库和跨架构调试还有各自的配置,本例没有覆盖它们。
另一个配置不能照着字面猜:不要以为gdbserver 127.0.0.1:端口必然只监听回环。GNU 文档明确说明,当前 TCP 地址参数的主机部分会被忽略,且 gdbserver 没有内置安全保护。本例因此使用标准输入输出管道,不开放调试端口。跨机器可以进一步研究文档中的 SSH 管道方式,不能直接把无保护的调试端口暴露出去。见 gdbserver 官方说明。
附:程序和验证脚本怎么安排
实验环境为 WSL 中的 Arch Linux、x86_64,GCC 16.1.1、GDB/gdbserver 17.2。下面两个文件放在同一目录,执行python3 verify_remote.py;需要系统已有 GCC、binutils、带 Python 支持的 GDB 和 gdbserver。无需 root 权限,不修改系统配置。
先是完整的probe.c:
#include <stdio.h> int double_value(int value) { return value * 2; } int main(void) { int result = double_value(21); printf("result=%d\n", result); return result == 42 ? 0 : 1; }验证脚本不会只判断进程有没有退出。它通过 GDB 内置 Python 读取value,取得list的输出,再检查三组结果是否符合刚才的区别。第二轮仍使用第一轮的二进制,只改变映射,避免用重新编译偶然修好问题来解释结果。
第三轮从同一文件剥离调试信息,作为“只有源码路径正确仍然不够”的对照。各轮独立启动调试会话;日志和临时产物留在脚本所在目录下的新子目录,不覆盖你的项目文件。
还有两个小细节:本实验为便于对照保留函数入口位置,使用-O0、禁用内联,并生成非 PIE 的小程序;这不是要求 Redis 或日常项目关闭 PIE。另一个是缺源码时,GDB 不一定让list抛出异常,也可能返回一行文件位置,同时打印警告。因此测试既检查源码正文没出现,也检查实际的缺文件警告,而不是只认某一种报错形式。
verify_remote.py:
import hashlib import json import os from pathlib import Path import re import signal import shlex import subprocess import tempfile base = Path(__file__).resolve().parent run = Path(tempfile.mkdtemp(prefix="gdb-source-", dir=base)) virtual = "/__blog_build_not_present__/src" source = base / "probe.c" binary = run / "probe" stripped = run / "probe-no-debug" def command(args, cwd=run): result = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=30) if result.returncode: raise RuntimeError(result.stdout + result.stderr) return result.stdout.strip() command(["gcc", "-g", "-O0", "-fno-inline", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", f"-fdebug-prefix-map={base}={virtual}", str(source), "-o", str(binary)], base) command(["objcopy", "--strip-debug", str(binary), str(stripped)]) def experiment(name, symbols, map_source): folder = run / name folder.mkdir() log = folder / "server.log" observed = folder / "observed.json" script = folder / "commands.gdb" mapping = f"set substitute-path {virtual} {base}\n" if map_source else "" script.write_text( "set pagination off\nset confirm off\nset debuginfod enabled off\n" f"file {symbols}\ntarget remote | gdbserver --once - {shlex.quote(str(binary))} 2>{shlex.quote(str(log))}\n" + mapping + "break double_value\ncontinue\npython\n" "import gdb, json\n" "data = {'symbolFound': gdb.lookup_global_symbol('double_value') is not None}\n" "try:\n data['argument'] = int(gdb.parse_and_eval('value'))\n" "except gdb.error as error:\n data['argumentError'] = str(error)\n" "try:\n data['listing'] = gdb.execute('list double_value', to_string=True)\n" "except gdb.error as error:\n data['listingError'] = str(error)\n" f"with open({str(observed)!r}, 'w') as out: json.dump(data, out, indent=2)\n" "end\ncontinue\n", encoding="utf-8") with (folder / "client.log").open("w+") as output: server = subprocess.Popen(["gdb", "-q", "--batch", "-x", str(script)], cwd=folder, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) try: server.wait(timeout=30) output.seek(0) client_log = output.read() server_log = log.read_text() if server.returncode: raise RuntimeError(client_log + server_log) if "result=42" not in server_log + client_log or "exited normally" not in client_log: raise AssertionError("Inferior did not finish with expected result") data = json.loads(observed.read_text()) data.update({"clientExit": server.returncode, "serverLog": server_log, "clientLog": client_log, "commands": str(script), "transport": "stdio pipe; no TCP listener"}) return data finally: if server.poll() is None: os.killpg(server.pid, signal.SIGTERM) try: server.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(server.pid, signal.SIGKILL) server.wait(timeout=5) match = re.search(r"created; pid = (\d+)", log.read_text()) if match: pid = int(match.group(1)) try: executable = Path(f"/proc/{pid}/exe").resolve(strict=True) except FileNotFoundError: executable = None if executable == binary.resolve(): os.kill(pid, signal.SIGKILL) missing = experiment("missing-source", binary, False) fixed = experiment("mapped-source", binary, True) no_debug = experiment("no-debug-symbols", stripped, True) if not (missing["symbolFound"] and missing.get("argument") == 21 and "return value * 2;" not in missing.get("listing", "") and "No such file or directory" in missing["clientLog"]): raise AssertionError(missing) if not (fixed["symbolFound"] and fixed.get("argument") == 21 and "return value * 2;" in fixed.get("listing", "")): raise AssertionError(fixed) if no_debug["symbolFound"] or "argumentError" not in no_debug or "listingError" not in no_debug: raise AssertionError(no_debug) result = {"passed": True, "architecture": os.uname().machine, "gcc": command(["gcc", "--version"]).splitlines()[0], "gdb": command(["gdb", "--version"]).splitlines()[0], "gdbserver": command(["gdbserver", "--version"]).splitlines()[0], "binarySha256": hashlib.sha256(binary.read_bytes()).hexdigest(), "sourceSha256": hashlib.sha256(source.read_bytes()).hexdigest(), "virtualSourcePrefix": virtual, "run": str(run), "missing": missing, "fixed": fixed, "noDebug": no_debug} (run / "results.json").write_text(json.dumps(result, indent=2), encoding="utf-8") print(json.dumps({"passed": True, "results": str(run / 'results.json')}, indent=2))运行成功会打印results.json的位置,里面保留命令、日志、工具版本和文件摘要。真正需要核对的不是一句passed:第一轮有没有读到参数,第二轮是否只改路径便显示了源码,第三轮是否在映射正确时仍缺少参数信息。这三个现象,才是这份实验要验证的内容。