我平时排查Linux程序问题有时候会遇上一类很尴尬的需求程序是别人编译好的没有源码、又不能改代码重编但我想看看它内部到底调用了哪些库函数、某个malloc到底是谁发起的或者暂时把某个时间函数的结果改一改。多数人第一反应是strace、gdb这些动态调试工具但如果是想“替换掉库函数本身”或者“给已有函数加一层观察逻辑”strace就力不从心了。这时候LD_PRELOAD才是真正的解法它能让我们在不修改目标程序任何字节的前提下介入程序的函数调用过程。这篇文章我会把LD_PRELOAD的原理、用法、坑以及几个可以直接复制的实战示例一次讲清楚。适合三类人看一是做Linux运维和故障排查的二是写C/C程序想给旧代码加监控或者临时改行为的三是学底层原理想弄明白动态链接器到底在做什么的。我不打算写教科书式的概念解释而是按照“为什么会生效、怎么动手写、真实场景怎么用、坑在哪里”这条线来讲。1. 从ld.so的“寻路逻辑”看LD_PRELOAD的插入点想理解LD_PRELOAD首先得搞清楚一个常被忽略的事实一个Linux程序从启动到进入main函数中间并不是“操作系统直接把代码加载进内存”这么简单。在ELF格式的可执行文件被内核映射进内存之后真正接手控制权的其实是一个叫动态链接器的程序在glibc环境下它就是ld.so大家更熟悉的名字可能是ld-linux-x86-64.so.2。这个链接器的工作是在main执行之前把所有依赖的共享库找出来、加载进内存、解析好符号然后才把你的main跑起来。1.1 先从ldd看到的那三行说起你随便拿一个动态链接的程序跑ldd看一下比如ldd /bin/ls输出大概长这样$ ldd /bin/ls linux-vdso.so.1 (0x00007ffd3d5ba000) libselinux.so.1 /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f...) libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...) /lib64/ld-linux-x86-64.so.2 (0x00007f...)这里面其实藏着三层信息。第一层是程序编译时写在ELF文件里头的DT_NEEDED字段也就是它明确依赖的共享库清单ldd第一列就是这些库的名字。第二层是ld.so根据这些名字去搜索真实文件搜索顺序有讲究先找DT_RPATH和DT_RUNPATH编译时嵌入程序里的搜索路径再找环境变量LD_LIBRARY_PATH指定的路径最后才是系统默认路径比如/lib、/usr/lib这也是为什么你能用LD_LIBRARY_PATH让程序优先加载某个库。第三层是在符号解析时各库之间不是按加载顺序随便排的而是有一套优先级规则。这三层信息合在一起构成了“程序执行时到底用哪个库里的哪个函数”的最终答案。大多数时候我们不需要关心这个过程因为编译器已经安排好了。但一旦你想干预这个流程你盯住的就是这个“加载并解析符号”的时机而LD_PRELOAD正好就插在这个时机的入口处。1.2 符号优先级LD_PRELOAD的插队位置ld.so加载库的顺序决定了符号查找的顺序。默认情况下它会先扫描可执行程序本身的符号表再按依赖关系逐层加载共享库。共享库之间的符号解析有一个基本原则先加载的库优先解析符号。这里的“先加载”指的就是地址空间里谁先被映射进去谁就占先。LD_PRELOAD环境变量的作用就是让ld.so在加载任何其他共享库之前先把你指定的那个.so文件加载进来。你可以把它理解成“插队”哪怕程序明确写了依赖libc.so.6ld.so也会先加载你给的这个库。由于符号查找按加载顺序进行你的库里如果定义了和libc同名同签名的函数比如malloc、time、open那么程序以及所有后续库里的这些调用都会先落到你定义的这个版本上。大家常说的“拦截函数”“hook函数”本质上用的就是这个顺序机制。还有一个很重要的细节这个优先级不只影响目标程序本身的调用也影响它依赖的所有动态库内部的调用。比如libc内部有些函数会调用malloc如果你的LD_PRELOAD库里定义了malloc连libc自己调用的malloc也会被换成你的版本。这一点后面写例子的时候会体现出来也是很多人在使用中意料不到的。2. 原理拆解符号解析顺序、PLT/GOT与dlsym(RTLD_NEXT)理解了LD_PRELOAD在加载顺序上“插队”之后第二个问题就来了我写了同名函数覆盖了原来的函数那我怎么在覆盖函数里继续调用“真正的”那个函数总不能把原来库里的逻辑抄一遍吧。这就引出了LD_PRELOAD实现里最核心的两个机制PLT/GOT重定位以及dlsym(RTLD_NEXT)。2.1 程序内部的函数调用多数是“间接跳转”现代动态链接的ELF程序内部对共享库函数的调用通常不是直接call 绝对地址而是先跳到一张叫PLTProcedure Linkage Table过程链接表的跳板表再从PLT跳到GOTGlobal Offset Table全局偏移表里记录的最终地址。第一次调用时GOT里存的还是ld.so里一个解析函数的地址程序真正跑起来之后ld.so会把目标函数的真实地址回填到GOT里。之后再调用同一个函数就是一次“间接跳转”。这个设计的妙处在于所有外部函数调用的最终落点其实是可以被动态改写的。因为代码里并没有写死“调用libc的malloc”而是写死“跳到GOT条目看它指向哪”。LD_PRELOAD之所以能生效本质上就是在GOT条目被填充之前把自己的同名函数地址挤了进去于是后续所有跳转都到了你的函数。这也顺带解释了为什么“全局符号插桩”要求符号必须暴露在动态符号表中并且函数不能是static的。如果程序把某个函数编译成了静态内联或者只在一个编译单元内部使用、没有走PLT那LD_PRELOAD就管不到它。这一点在第5部分细说。2.2 dlsym(RTLD_NEXT)找到被自己遮住的那个函数覆盖同名函数之后你想调用原来那个“正主”常规做法是在库里通过dlopen/dlsym按名字找到它。但问题是你现在处于一个“同名函数被自己覆盖”的状态dlsym(handle, time)如果直接用默认句柄找到的很可能还是你自己的版本这就死循环了。解决方法是RTLD_NEXT。这是GNU扩展的一个特殊句柄含义是“除当前正在执行的库之外从下一个库开始搜索这个符号”。在LD_PRELOAD库里写dlsym(RTLD_NEXT, time)就能找到真正由libc导出的那个time。这是所有LD_PRELOAD拦截库的统一写法你可以把这一行当成模板背下来#define _GNU_SOURCE #include dlfcn.h static time_t (*real_time)(time_t *t) NULL; if (!real_time) real_time (time_t (*)(time_t *))dlsym(RTLD_NEXT, time);这里有几个细节需要注意。首先头文件之前一定要定义_GNU_SOURCE否则RTLD_NEXT根本不会暴露出来。其次用静态指针保存real_time一方面避免每次都查一遍符号表另一方面也是为了防止递归查找时反复初始化。第三dlsym返回的是void *转成函数指针在C里是允许的虽然C标准严格说起来有点灰色但在Linux/GNU环境下这是通行写法不用担心。2.3 覆盖函数自身要有“出口意识”很多人第一次写LD_PRELOAD库容易忽略一个事实你自己写的这个覆盖函数也可能成为其他代码调用的目标。比如你的malloc包装里如果调用了fprintf往stderr打印日志而fprintf内部又申请了堆内存它申请的malloc又会被你的包装函数拦住于是你的包装函数里的打印逻辑还没执行完又调用了自己的malloc再打印、再调用、再打印……很快就栈溢出了。所以写LD_PRELOAD库时要时刻区分“我想观察的目标”和“我用来输出的工具”。输出工具本身不能依赖被观察的目标。这也是为什么后面示例里我写日志时优先使用write(2, ...)这种系统调用级别的函数而不是fprintf。用系统调用能避开它内部对libc函数的再次依赖减少递归风险。这一条是实战中最容易踩的坑我先在这里打了预防针。3. 一个能跑的LD_PRELOAD库从编译到运行的手把手流程原理说得再多不如亲手跑一遍。这一节我会给一个完整的“最小可运行”流程先写一个普通的小程序作为靶子再写一个LD_PRELOAD库去拦截它的malloc从编译到运行一步步来。3.1 准备一个目标程序先造一个简单的C程序里面反复调用malloc假装是一个有内存分配行为的业务程序// app.c #include stdio.h #include stdlib.h #include string.h int main(void) { char *p malloc(1024); strcpy(p, hello ld_preload); printf(%s\n, p); free(p); int *arr malloc(10 * sizeof(int)); arr[3] 42; printf(arr[3] %d\n, arr[3]); free(arr); return 0; }编译命令很简单gcc -o app app.c正常跑一遍输出就是两行文本。现在我们对这个程序没有任何源码改动的前提下想统计它到底调用了多少次malloc、每次请求多大LD_PRELOAD就能派上用场。3.2 编写并编译拦截库写一个malloc_track.c// malloc_track.c #define _GNU_SOURCE #include dlfcn.h #include stdio.h #include stdlib.h #include unistd.h static unsigned long malloc_count 0; static char log_buf[256]; void *malloc(size_t size) { static void *(*real_malloc)(size_t) NULL; if (!real_malloc) real_malloc (void *(*)(size_t))dlsym(RTLD_NEXT, malloc); malloc_count; int len snprintf(log_buf, sizeof(log_buf), [preload] malloc(%zu) called, count%lu\n, size, malloc_count); write(2, log_buf, len); return real_malloc(size); } __attribute__((destructor)) static void dump_total(void) { int len snprintf(log_buf, sizeof(log_buf), [preload] total malloc calls: %lu\n, malloc_count); write(2, log_buf, len); }编译成共享库gcc -shared -fPIC -o malloc_track.so malloc_track.c -ldl注意两个参数-shared表示生成共享库-fPIC生成位置无关代码-ldl链接libdl这样dlsym才能用。有人会问为什么-fPIC不能省因为共享库要被映射到任意进程的地址空间没有PIC的话加载时的重定位会非常麻烦而且可能在部分系统上直接报错。3.3 运行并观察效果执行LD_PRELOAD./malloc_track.so ./app输出会变成类似这样[preload] malloc(1024) called, count1 [preload] malloc(1) called, count2 hello ld_preload [preload] malloc(40) called, count3 arr[3] 42 [preload] total malloc calls: 3这段输出里藏着很多信息。注意malloc(1)是strcpy或者printf内部搞出来的malloc(40)是创建int *arr malloc(10 * sizeof(int))时产生的40字节在64位系统上正好是10个int。从这里你能直观感受到LD_PRELOAD不只拦截了你写代码时显式调用的malloc还会拦截依赖库内部的malloc调用。这也是为什么我们打印日志用write而不是fprintf——因为在这个库里任何再入式的动态内存分配都会回到我们自己的malloc。在我给的这个示例里日志输出在stderr上所以能和程序自己的stdout输出混在一起看起来有点乱。实际做监控时你完全可以把日志写到文件里甚至利用__attribute__((constructor))在进程启动时打开文件描述符后续所有日志都写到那个fd里这样就不污染终端了。4. 三个看得见效果的使用示例上面那个malloc计数只是热身真正让LD_PRELOAD显得“值钱”的是几个能解决实际问题的场景。我这里挑了三个我实际用过的方向修改时间函数、观察环境变量读取、重定向文件打开路径。它们都不需要改目标程序只是往环境变量里加一个库路径而已。4.1 模拟时间偏移给time函数“注入”一个常数很多测试场景需要把程序感知到的时间往后拨比如验证日志时间戳、测试证书过期逻辑。改系统时间动静太大还可能影响其他服务。用LD_PRELOAD可以只让目标进程“觉得时间变了”。// time_shift.c #define _GNU_SOURCE #include dlfcn.h #include time.h time_t time(time_t *t) { static time_t (*real_time)(time_t *) NULL; if (!real_time) real_time (time_t (*)(time_t *))dlsym(RTLD_NEXT, time); time_t now real_time(NULL); time_t shifted now 7 * 86400; // 往后拨7天 if (t) *t shifted; return shifted; }编译gcc -shared -fPIC -o time_shift.so time_shift.c -ldl拿一个打印当前时间的程序跑LD_PRELOAD./time_shift.so date你看到的日期会比真实日期多7天。这里有个关键点date命令查询时间通常用的是clock_gettime(CLOCK_REALTIME)而不是time所以在某些glibc版本上这个示例能不能生效取决于date内部到底调用了哪个符号。我选择time作为示例因为它足够简单直观实战中如果是date这类工具可能需要同时覆盖clock_gettime和gettimeofday。这个“符号是否真的被调用”的问题后面第5部分专门讨论。4.2 记录程序读取了哪些环境变量getenv观察器排查问题时经常想知道某程序到底依赖了哪些环境变量。与其一个个试不如直接在getenv外面套一层马甲把它读取的所有键和返回值都打印出来。// getenv_log.c #define _GNU_SOURCE #include dlfcn.h #include stdio.h #include stdlib.h #include string.h #include unistd.h char *getenv(const char *name) { static char *(*real_getenv)(const char *) NULL; if (!real_getenv) real_getenv (char *(*)(const char *))dlsym(RTLD_NEXT, getenv); char *val real_getenv(name); int len snprintf(NULL, 0, [getenv] %s %s\n, name, val ? val : (null)); char *buf malloc(len 1); snprintf(buf, len 1, [getenv] %s %s\n, name, val ? val : (null)); write(2, buf, len); free(buf); return val; }注意这里我在日志输出里用了malloc和free但它们不会死循环因为我没有拦截malloc拦截的是getenvmalloc在getenv日志函数内部调用时会正常走libc的版本不会递归。这说明一个道理拦截哪个函数就要警惕在当前覆盖函数里调用哪个函数不同包之间是互相独立的。snprintf(NULL, 0, ...)这招可以先用一次计算需要的长度再申请空间避免日志行被截断。如果你的环境限制了malloc行为也可以换成直接复用一块固定大小缓冲区但那样日志行就得精简些。4.3 不重编译就换文件路径把open的请求改道还有一种很实用的场景程序写死了打开某个配置文件路径你想临时让它读另一个路径但不想改源码、也不方便改文件权限。通过LD_PRELOAD拦截open/open64对特定路径做字符串匹配然后替换成目标路径。// open_redirect.c #define _GNU_SOURCE #include dlfcn.h #include fcntl.h #include string.h #include stdarg.h static const char *TARGET /etc/myapp.conf; static const char *REPLACE /tmp/myapp-test.conf; int open(const char *pathname, int flags, ...) { static int (*real_open)(const char *, int, ...) NULL; if (!real_open) real_open (int (*)(const char *, int, ...))dlsym(RTLD_NEXT, open); const char *effective pathname; if (strcmp(pathname, TARGET) 0) effective REPLACE; mode_t mode 0; if (flags O_CREAT) { va_list ap; va_start(ap, flags); mode va_arg(ap, mode_t); va_end(ap); } return real_open(effective, flags, mode); }编译方式都一样运行时LD_PRELOAD./open_redirect.so ./the_app这个示例有两个细节相当重要。第一open是变参函数只有flags里有O_CREAT时才需要第三个参数mode所以覆盖版本必须用va_list把参数正确取出来否则传给real_open的参数是错的轻则创建出的文件权限不对重则整个调用崩溃。第二字符串比较用strcmp没问题但真实场景里更稳妥的做法是做路径前缀匹配或正则匹配比如只替换以/etc/myapp.conf开头的路径还可以顺带处理openat、stat、fopen等多个入口避免程序走别的调用路径绕开了你。5. 实战里那些防不胜防的坑这一节我要把踩过的坑集中倒出来。每一笔都对应真实问题看完至少能帮你少折腾一晚上。5.1 被系统信任机制直接忽略setuid程序不认LD_PRELOAD这里有个安全边界必须说清楚LD_PRELOAD这个变量本身非常强大强大到如果随便谁都能给一个特权程序注入代码那系统早就乱套了。所以glibc在加载动态链接库时有一个AT_SECURE机制凡是可执行文件的真实用户ID和有效用户ID不一致或者文件带有setuid/setgid属性加载器都会忽略LD_PRELOAD和其他几个相关环境变量。这意味着什么你拿LD_PRELOAD去追踪/usr/bin/passwd这种带setuid位的程序会发现没有任何效果。这不是你的库写得有问题而是系统设计上就堵死了这条路。理解这一点能避免大量不必要的调试时间。同时这也是好事它保证了LD_PRELOAD这种手法只能作用于普通程序不至于变成任意提权的通用入口。5.2 符号被绑定死为什么有时候覆盖不生效前面说过LD_PRELOAD主要靠动态符号解析“抢”调用但有一类调用它抢不到如果目标程序在编译时使用了-Bstatic、-Bsymbolic或者库自身在编译时开启了-Bsymbolic那库内部对同名函数的引用可能会被直接绑定到自身实现不经过PLT/GOT。你拦截那个库内部自用的函数外部符号表里虽然也有这个名字但内部调用早就“短路”了你的版本进不去。还有一种常见情况是malloc在glibc内部的某些路径会用到__libc_malloc、__malloc_*一类的内部符号它们不是每次都会走公共的malloc入口。所以拦截malloc计数时统计到的数字未必等于进程实际的总分配次数只能作为一个接近值。类似的printf家族在不同glibc版本里底层可能走vfprintf、__printf等不同符号跨版本行为会有差异。5.3 日志输出本身的递归危机前面3.2的例子已经演示过用write规避递归的做法这里再补充一个隐藏点即使你不用fprintf只要你拦截的是free你的库结构体里如果用了静态缓冲区也要小心释放顺序问题。我在一次实践里拦截过free打算记录每次释放的指针值结果发现很多指针来自日志库内部的临时缓冲区而不是业务代码。这倒不会崩溃但统计数据里的“噪音”非常大光靠过滤指针范围才能看出真正的业务分配模式。如果你的拦截函数确实需要在内部使用动态内存一个惯用技巧是在构造函数里先分配一块专用缓冲区并保存指针运行中只用这块缓冲区不要再次调用malloc或者通过pthread_key把缓冲区做成线程私有。总之让你的日志系统“自洽”不依赖你正在观察的那个函数。5.4 环境变量格式和多库的加载顺序LD_PRELOAD的值可以用空格或冒号分隔多个库路径ld.so会按顺序加载它们。这里有个隐蔽问题加载顺序不只决定符号优先级还会影响构造函数的执行顺序。先加载的库它的__attribute__((constructor))先执行。如果你的库之间还有依赖关系顺序没排好构造函数里调用别的库函数时可能符号还没就绪就会出现“明明函数存在却报undefined symbol”的怪问题。另外LD_PRELOAD里的路径如果写的是相对路径ld.so在解析时以当前工作目录为基准。服务进程往往由systemd启动工作目录可能是/你的相对路径.so就找不到了。这个坑我遇到过不止一次。稳妥做法是要么写绝对路径要么在调用时把库放在/etc/ld.so.preload全局配置所有进程生效里。注意/etc/ld.so.preload和LD_PRELOAD又有区别前者是全局的影响所有动态链接程序后者只影响当前环境下的进程优先级更高。5.5 调试时最容易忽略的先看ldd和nm如果某个拦截函数没生效别上来就怀疑是不是LD_PRELOAD失效。先做两个检查第一运行ldd看目标程序是不是动态链接的有没有可能程序根本没有依赖libc动态库静态编译程序不会理会LD_PRELOAD第二用nm -D查看目标程序导出的动态符号确认它调用的函数是不是通过动态符号解析的。static编译没有任何动态符号只会让一切操作失效。这两个检查能排除一半的“为什么没效果”问题。我见过有人花了一下午追一个“LD_PRELOAD拦截printf不生效”的case最后发现那个程序是编译时加了-static的静态二进制。方向错了再努力也没用。6. 和它常搭边的几个机制LD_LIBRARY_PATH、/etc/ld.so.preload、工具链对比最后说几个和LD_PRELOAD容易混淆或者经常一起出现的机制。LD_LIBRARY_PATH解决的问题是“库文件在哪”它的作用是把搜索路径插入到默认路径之前LD_PRELOAD解决的问题是“谁的符号优先”它不管你从哪个路径加载库只负责让加载器优先加载你指定的文件。两者不矛盾但别搞混LD_LIBRARY_PATH设错了顶多是“找不到库”LD_PRELOAD设错了则可能是“加载了不该加载的库”影响更隐蔽。/etc/ld.so.preload是一个全局白名单文件里面每行列一个库路径所有动态链接程序启动时都会先加载这些库。它和LD_PRELOAD的使用场景差别很大如果你希望所有进程都生效用这个文件如果你只想临时影响某个进程用环境变量就够。运维时往/etc/ld.so.preload里写内容要格外慎重因为它一旦写错路径可能导致系统里几乎所有命令都无法启动连ls都会报动态链接错误那你只能进修复模式处理。还有一类常被拿出来和LD_PRELOAD对比的工具比如LD_AUDIT、ltrace、strace。它们各有侧重LD_AUDIT是glibc提供的审计接口可以拿到比LD_PRELOAD更干净的调用事件但写起来复杂ltrace专门跟踪库函数调用交互性强但性能开销大、对静态编译程序无效strace走的是系统调用层面看不到纯用户态的库函数调用。我的经验是想看系统调用strace优先想看用户态函数调用LD_PRELOAD或ltrace想做精细的运行时行为修改基本只有LD_PRELOAD能胜任。从我个人这几年的使用体会来说LD_PRELOAD称不上一个“常用命令”但它是一把非常好用的瑞士军刀。遇到那些源码丢失、重新编译成本高的旧程序它就是最后的介入窗口。只要记住几个原则——符号走PLT才能拦、日志别递归、setuid程序会被忽略、路径尽量绝对化——大多数场景都能顺利落地。真正用熟了以后你还能把它和dlopen、构造/析构函数、thread local storage这些机制组合起来做出更灵活的工具比如给老服务临时加一层流量开关、把特定函数的耗时记录到rpc链路里。这篇文章给的是地基怎么盖楼就看你的实际需求了。