Redis命令执行成功,断点就一定命中了吗?一次真实的SET源码调试
我把断点放在setCommand,发送SET,客户端返回OK,GET也能读到hello。然后故意把断点换成msetCommand,仍然只发送SET:客户端照样返回OK,GET照样得到hello。
请求执行结果相同,第二次却没有命中我要观察的函数。“Redis能正常用”和“我正在调试这条请求”是两件事。
第一次读源码,容易把目标定成“安装好IDE,配好远程调试”。但配置填完不等于验收完成。更小的目标是:让一条具体SET真的经过断点,并同时看见本次参数、调用栈和服务进程身份。
这次先不引入QEMU和跨架构调试。实验在WSL的Arch Linux、x86_64上完成,使用GCC 16.1.1和GDB 17.2。源码固定为Redis 7.0.15,提交为f35f36a265403c07b119830aa4bb3b7d71653ec9。这是学习用的历史版本,不是“当前最新版”或生产部署建议。
1. 先让源码、程序和调试信息对得上
下面命令在Linux/WSL终端执行,工作目录选在空间充足的盘上。需要Git、make、C编译器、GDB、Python 3,以及提供file和readelf的工具。不要直接往已经编译过其他版本的目录里覆盖源码。
git -c core.autocrlf=false clone --depth 1 --branch 7.0.15 https://github.com/redis/redis.git redis-debug-source cd redis-debug-source git rev-parse HEAD make -j2 MALLOC=libc OPTIMIZATION=-O0 CFLAGS="-g3 -fno-omit-frame-pointer" src/redis-server --version file src/redis-server readelf -S src/redis-server | grep debug_info各检查的作用不同:
| 检查 | 排除什么问题 |
|---|---|
| 提交号 | 看着一份源码,运行另一版本 |
| redis-server版本 | 不小心调用系统安装的Redis |
| file | 目标架构不对、二进制被剥离 |
| debug_info | 没有足够的源码调试信息 |
| 实际命中断点 | 配置看似正确,请求却没经过目标函数 |
MALLOC=libc让这次教学构建不依赖默认jemalloc配置;OPTIMIZATION=-O0明确关闭编译优化,便于观察源码位置;-g3保留调试信息。这些选项服务于调试,不是性能优化建议。Redis会缓存构建选项,换选项后需要按该版本官方构建说明处理清理;这里使用全新的检出目录,没有执行make install。
我实际遇到的第一个错误不是缺头文件
第一份源码通过Windows Git检出,脚本被转换成CRLF。构建最早的错误是:
sh: ./mkreleasehdr.sh: /bin/sh^M: bad interpreter: No such file or directory后面才出现:
release.c:37:10: fatal error: release.h: No such file or directoryrelease.h是构建过程中生成的文件。生成脚本没有运行,文件自然不存在。只盯着最后一条错误,容易开始手工补一个头文件,把真正的原因藏起来。
我的处理是另建一个保持LF的新检出,再构建成功。上面的-c core.autocrlf=false只作用于这次Git命令,不修改全局设置;它也不会自动修复一个已经存在的CRLF工作树。
2. 断在哪里,才能看见这一次SET?
固定版本的src/t_string.c中,setCommand(client *c)是SET命令处理入口。先用它,不必在事件循环里一路单步几十次。
这次实际命中的参数为:
SET_BREAKPOINT_HIT argc=3 COMMAND=SET KEY=blog:key VALUE=hello前面的参数与下面的栈都是实测日志节选。这里直接保留可复制文本,不把日志缩成一张手机上难读的图片。调用栈包含:
setCommand t_string.c:292 call server.c:3386 processCommand server.c:4020 processCommandAndResetClient networking.c:2473可以把它理解为:请求处理进入命令执行路径,再调用具体的SET处理函数。这里只展示栈顶四层,不是完整的网络收包路径。行号仅适用于这个固定提交。
GDB里的c->argc是本次命令参数数量;c->argv保存参数对象。后面的脚本把这个简单SET请求的字符串参数打印出来。不要把(char *)object->ptr推广为“所有Redis对象都能这样查看”:对象类型和编码不同,解释方式也不同。
3. 为什么还要故意设错一次断点?
我另外启动了一次独立的Redis,把断点从setCommand改成msetCommand,仍然只发送SET。
| 检查项 | 正确断点 | 错误断点 |
|---|---|---|
| SET返回 | OK | OK |
| GET返回 | hello | hello |
| SET断点标记 | 出现 | 不出现 |
| 调试器正常结束 | 是 | 是 |
命令执行结果完全一样,但调试证据不一样。这就是为什么不能把“客户端返回OK”当成调试环境验收标准。
正常实验要求参数值和调用栈同时对上;对照实验要求相同请求不触发错误断点。后者不是Redis故障,而是验证程序需要识别的负例。
4. OK却没有停住,先确认请求发给谁
程序不连接你已有的Redis,也不用默认6379端口。它分别启动两次自己的Redis,使用唯一Unix socket,关闭TCP监听、RDB保存和AOF,仅对当前用户开放socket。
步骤是:启动GDB及被调试程序 → 等待PING可用 → 发送SET → 读取GET → SHUTDOWN NOSAVE → 检查日志与socket清理。设置等待上限,避免断点或启动失败后一直挂着。
GDB断点命令打印证据,随后禁用这一个断点并继续运行。因此客户端可以收到回复;如果手动停在断点上不继续,客户端等待是正常的,不代表网络失效。
验证包含三层断言:
- 环境层:固定提交、版本和调试信息存在;从这条Unix socket连接查询服务PID,核对/proc/PID/exe指向本次源码目录的redis-server。
- 行为层:SET为OK,GET为hello。
- 调试层:argc、参数和调用栈一致,错误断点不命中。
Unix socket在这里不只是避免端口冲突。客户端每次明确使用本实验的唯一socket,INFO server返回这条连接实际访问的PID,再检查该进程执行文件。这样不会把“系统中另一份Redis成功处理了请求”误算成本次调试成功。
遇到OK但没有停住,先分三问:请求是否发给这次启动的实例?目标文件是否具备匹配的调试信息?这条命令是否真的经过所设函数?先确认是哪一层缺证据,再去改配置。
正常路径主动关闭Redis。异常路径仅终止本实验创建的进程组,不扫描并杀掉其他Redis进程。本次两个实验都正常退出并移除了socket。
这是针对SET路径的冒烟验证,没有执行Redis全量测试,也没有验证RISC-V、CLion远程调试或生产持久化。启动日志有内存overcommit提示,这次未更改系统参数,也不由这个小实验推导内存压力下的表现。
5. 回到那个看起来成功的SET
OK与GET结果说明请求完成,却没有说明这次调试器看见了它。参数和调用栈回答“执行到了哪里”,连接对应的PID与执行文件回答“正在观察谁”。把这两件事分别确认,才不必在没有证据时反复改IDE配置。
完整编排放在后面;下一步若研究远程调试,还要另行解决目标架构、符号文件与安全边界,不能把本地实验成功当成远程配置也正确。
附录一:如果下一步换成远程调试
先检查调试器是否支持目标架构,再确认本地符号文件是本地可读取、与目标程序同一次构建匹配的ELF,最后处理源码路径映射。仅比较version.h不能证明源码内容一致。
CLion官方远程调试说明区分符号文件和源码路径映射;GDB官方gdbserver说明也说明了调试服务的安全边界。不要为了让IDE连上就把无认证调试端口开放到公网。这篇没有开启远程调试服务。
附录二:完整验证源码
保存下面两个文件到同一目录。源码按第一节构建好后,在Linux终端执行:
python3 verify_debug.py /你的绝对路径/redis-debug-source成功会输出GDB_SET_CHECKS_PASSED和结果JSON路径。日志保留在源码目录的同级临时实验目录中,便于复查;不会自动删除你的源码或现有数据。脚本固定校验7.0.15提交,换版本需要重新核对断点与断言,不能仅把这个校验删掉就宣称兼容。
breakpoint.gdb
set pagination off set confirm off set breakpoint pending off break setCommand commands silent printf "SET_BREAKPOINT_HIT argc=%d\n", c->argc printf "COMMAND=%s\n", (char *)c->argv[0]->ptr printf "KEY=%s\n", (char *)c->argv[1]->ptr printf "VALUE=%s\n", (char *)c->argv[2]->ptr bt 4 disable 1 continue end runverify_debug.py
import json import os from pathlib import Path import subprocess import sys import tempfile import time base = Path(__file__).resolve().parent source = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else base / "source-lf" server = source / "src/redis-server" cli = source / "src/redis-cli" run = Path(tempfile.mkdtemp(prefix="redis-debug-", dir=source.parent)) record = {"run": str(run), "architecture": os.uname().machine} def command(args, cwd=source): r = subprocess.run(args, cwd=cwd, text=True, capture_output=True, timeout=30) if r.returncode: raise RuntimeError(r.stderr) return r.stdout.strip() record["commit"] = command(["git", "rev-parse", "HEAD"]) record["redisVersion"] = command([str(server), "--version"]) record["gdbVersion"] = command(["gdb", "--version"]).splitlines()[0] record["gccVersion"] = command(["gcc", "--version"]).splitlines()[0] record["file"] = command(["file", str(server)]) record["sections"] = command(["readelf", "-S", str(server)]) if record["commit"] != "f35f36a265403c07b119830aa4bb3b7d71653ec9": raise AssertionError("unexpected source commit") if ".debug_info" not in record["sections"] or "v=7.0.15" not in record["redisVersion"]: raise AssertionError("version or debug sections mismatch") script = (base / "breakpoint.gdb").read_text() def experiment(name, commands): work = run / name work.mkdir() gdbfile = work / "commands.gdb" gdbfile.write_text(commands) sock = Path("/dev/shm") / (run.name + "-" + name + ".sock") log = work / "gdb.log" with log.open("w") as output: child = subprocess.Popen(["gdb", "-q", "--batch", "-x", str(gdbfile), "--args", str(server), "--port", "0", "--unixsocket", str(sock), "--unixsocketperm", "700", "--save", "", "--appendonly", "no", "--daemonize", "no", "--dir", str(work)], cwd=source, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) try: for _ in range(200): if child.poll() is not None: raise RuntimeError(log.read_text()) ping = subprocess.run([str(cli), "--raw", "-s", str(sock), "PING"], capture_output=True, text=True, timeout=2) if ping.returncode == 0 and ping.stdout.strip() == "PONG": break time.sleep(0.05) else: raise RuntimeError("Redis readiness timeout") info = command([str(cli), "--raw", "-s", str(sock), "INFO", "server"]) fields = dict(line.split(":", 1) for line in info.splitlines() if ":" in line and not line.startswith("#")) server_pid = int(fields["process_id"]) server_exe = Path(f"/proc/{server_pid}/exe").resolve(strict=True) if server_exe != server.resolve(strict=True): raise AssertionError("connected server executable mismatch") set_result = command([str(cli), "--raw", "-s", str(sock), "SET", "blog:key", "hello"]) get_result = command([str(cli), "--raw", "-s", str(sock), "GET", "blog:key"]) command([str(cli), "--raw", "-s", str(sock), "SHUTDOWN", "NOSAVE"]) child.wait(timeout=10) if set_result != "OK" or get_result != "hello" or child.returncode != 0: raise AssertionError("command output or debugger exit mismatch") finally: if child.poll() is None: # Stop only the debugger/inferior process group created for this experiment. os.killpg(child.pid, 15) try: child.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(child.pid, 9) child.wait(timeout=5) return {"log": log.read_text(), "debuggerStopped": child.poll() is not None, "socketRemoved": not sock.exists(), "setOutput": set_result, "getOutput": get_result, "serverPid": server_pid, "serverExecutable": str(server_exe), "endpoint": str(sock)} record["good"] = experiment("good", script) for text in ["SET_BREAKPOINT_HIT argc=3", "COMMAND=SET", "KEY=blog:key", "VALUE=hello", "setCommand", "processCommand"]: if text not in record["good"]["log"]: raise AssertionError("missing debugger evidence: " + text) if not record["good"]["socketRemoved"]: raise AssertionError("normal Redis shutdown did not remove socket") mutant = script.replace("break setCommand", "break msetCommand") if mutant == script: raise AssertionError("mutation not applied") record["wrongBreakpoint"] = experiment("wrong", mutant) if "SET_BREAKPOINT_HIT" in record["wrongBreakpoint"]["log"]: raise AssertionError("incorrect breakpoint unexpectedly hit") if not record["wrongBreakpoint"]["socketRemoved"]: raise AssertionError("mutation shutdown cleanup failed") record["passed"] = True (run / "results.json").write_text(json.dumps(record, indent=2)) print("GDB_SET_CHECKS_PASSED") print("RESULTS=" + str(run / "results.json"))