Redis命令执行成功,断点就一定命中了吗?一次真实的SET源码调试
Redis命令执行成功断点就一定命中了吗一次真实的SET源码调试我把断点放在setCommand发送SET客户端返回OKGET也能读到hello。然后故意把断点换成msetCommand仍然只发送SET客户端照样返回OKGET照样得到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.autocrlffalse 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 MALLOClibc 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版本不小心调用系统安装的Redisfile目标架构不对、二进制被剥离debug_info没有足够的源码调试信息实际命中断点配置看似正确请求却没经过目标函数MALLOClibc让这次教学构建不依赖默认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.autocrlffalse只作用于这次Git命令不修改全局设置它也不会自动修复一个已经存在的CRLF工作树。2. 断在哪里才能看见这一次SET固定版本的src/t_string.c中setCommand(client *c)是SET命令处理入口。先用它不必在事件循环里一路单步几十次。这次实际命中的参数为SET_BREAKPOINT_HIT argc3 COMMANDSET KEYblog:key VALUEhello前面的参数与下面的栈都是实测日志节选。这里直接保留可复制文本不把日志缩成一张手机上难读的图片。调用栈包含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返回OKOKGET返回hellohelloSET断点标记出现不出现调试器正常结束是是命令执行结果完全一样但调试证据不一样。这就是为什么不能把“客户端返回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为OKGET为hello。调试层argc、参数和调用栈一致错误断点不命中。Unix socket在这里不只是避免端口冲突。客户端每次明确使用本实验的唯一socketINFO server返回这条连接实际访问的PID再检查该进程执行文件。这样不会把“系统中另一份Redis成功处理了请求”误算成本次调试成功。遇到OK但没有停住先分三问请求是否发给这次启动的实例目标文件是否具备匹配的调试信息这条命令是否真的经过所设函数先确认是哪一层缺证据再去改配置。正常路径主动关闭Redis。异常路径仅终止本实验创建的进程组不扫描并杀掉其他Redis进程。本次两个实验都正常退出并移除了socket。这是针对SET路径的冒烟验证没有执行Redis全量测试也没有验证RISC-V、CLion远程调试或生产持久化。启动日志有内存overcommit提示这次未更改系统参数也不由这个小实验推导内存压力下的表现。5. 回到那个看起来成功的SETOK与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.gdbset 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.pyimport 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(prefixredis-debug-, dirsource.parent)) record {run: str(run), architecture: os.uname().machine} def command(args, cwdsource): r subprocess.run(args, cwdcwd, textTrue, capture_outputTrue, timeout30) 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 v7.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)], cwdsource, stdoutoutput, stderrsubprocess.STDOUT, start_new_sessionTrue) 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_outputTrue, textTrue, timeout2) 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(strictTrue) if server_exe ! server.resolve(strictTrue): 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(timeout10) 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(timeout5) except subprocess.TimeoutExpired: os.killpg(child.pid, 9) child.wait(timeout5) 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 argc3, COMMANDSET, KEYblog:key, VALUEhello, 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, indent2)) print(GDB_SET_CHECKS_PASSED) print(RESULTS str(run / results.json))