hook系统底层和socket相关的API,socket IO相关的API,以及sleep系列的API。hook的开启控制是线程粒度的,可以自由选择。通过hook模块,可以使一些不具异步功能的API,展现出异步的性能,如MySQL。
hook实际上就是对系统调用API进行一次封装,将其封装成一个与原始的系统调用API同名的接口,应用在调用这个接口时,会先执行封装中的操作,再执行原始的系统调用API。
hook技术可以使应用程序在执行系统调用之前进行一些隐藏的操作,比如可以对系统提供malloc()和free()进行hook,在真正进行内存分配和释放之前,统计内存的引用计数,以排查内存泄露问题。
还可以用C++的子类重载来理解hook。在C++中,子类在重载父类的同名方法时,一种常见的实现方式是子类先完成自己的操作,再调用父类的操作,如下:
class Base { public: void Print() { cout << "This is Base" << endl; } }; class Child : public Base { public: /// 子类重载时先实现自己的操作,再调用父类的操作 void Print() { cout << "This is Child" << endl; Base::Print(); } }; |
在上面的代码实现中,调用子类的Print方法,会先执行子类的语句,然后再调用父类的Print方法,这就相当于子类hook了父类的Print方法。
由于hook之后的系统调用与原始的系统系统调用同名,所以对于程序开发者来说也很方便,不需要重新学习新的接口,只需要按老的接口调用惯例直接写代码就行了。
hook的目的是在不重新编写代码的情况下,把老代码中的socket IO相关的API都转成异步,以提高性能。hook和IO协程调度是密切相关的,如果不使用IO协程调度器,那hook没有任何意义,考虑IOManager要在一个线程上按顺序调度以下协程:
在未hook的情况下,IOManager要调度上面的协程,流程是下面这样的:
上面的调度流程最终总结起来就是,协程只能按顺序调度,一旦有一个协程阻塞住了,那整个调度线程也就阻塞住了,其他的协程都无法在当前线程上执行。像这种一条路走到黑的方式其实并不是完全不可避免,以sleep为例,调度器完全可以在检测到协程sleep后,将协程yield以让出执行权,同时设置一个定时器,2秒后再将协程重新resume。这样,调度器就可以在这2秒期间调度其他的任务,同时还可以顺利的实现sleep 2秒后再继续执行协程的效果,send/recv与此类似。在完全实现hook后,IOManager的执行流程将变成下面的方式:
上面的4、5、6步都是异步的,调度线程并不会阻塞,IOManager仍然可以调度其他的任务,只在相关的事件发生后,再继续执行对应的任务即可。并且,由于hook的函数签名与原函数一样,所以对调用方也很方便,只需要以同步的方式编写代码,实现的效果却是异步执行的,效率很高。
总而言之,在IO协程调度中对相关系统调用进行hook,可以让调度线程尽可能得把时间片都花在有意义的操作上,而不是浪费在阻塞等待中。
hook的重点是在替换API的底层实现的同时完全模拟其原本的行为,因为调用方是不知道hook的细节的,在调用被hook的API时,如果其行为与原本的行为不一致,就会给调用方造成困惑。比如,所有的socket fd在进行IO调度时都会被设置成NONBLOCK模式,如果用户未显式地对fd设置NONBLOCK,那就要处理好fcntl,不要对用户暴露fd已经是NONBLOCK的事实,这点也说明,除了IO相关的函数要进行hook外,对fcntl, setsockopt之类的功能函数也要进行hook,才能保证API的一致性。
这里只讲解动态链接中的hook实现,静态链接以及基于内核模块的hook不在本章讨论范围。
在学习hook之前需要对Linux的动态链接有一定的了解,建议阅读《程序员的自我修养 —— 链接、装载与库》第7章。本站 关于链接与装载的几个测试代码 提供了一些示例,有助于理解动态链接的具体行为。
hook要实现的目地非常简单,就是用自定义的接口来替换掉原始的系统调用接口,比如用自定义的write函数来替换掉系统提供的write函数来进行数据发送。
基于动态链接的hook有两种方式,第一种是外挂式hook,也称为非侵入式hook,通过优先加载动态库来实现对后加载的动态库进行hook,这种hook方式不需要重新编译代码,考虑以下例子:
#include <unistd.h> #include <string.h> int main() { write(STDOUT_FILENO, "hello world\n", strlen("hello world\n")); // 调用系统调用write写标准输出文件描述符 return 0; } |
在这个例子中,可执行程序调用write向标准输出文件描述符写数据。对这个程序进行编译和执行,预期效果如下:
# gcc main.c # ./a.out hello world |
下面在不重新编译代码的情况下,用自定义的write函数来替换掉可执行程序a.out中的write实现,新建hook.c,内容如下:
#include <unistd.h> #include <sys/syscall.h> #include <string.h> ssize_t write(int fd, const void *buf, size_t count) { syscall(SYS_write, STDOUT_FILENO, "12345\n", strlen("12345\n")); } |
将hook.c编译成动态库:
gcc -fPIC -shared hook.c -o libhook.so |
通过设置 LD_PRELOAD
环境变量,将libhoook.so设置成优先加载,则lib
进行hookPRE_LOAD=“a.so” ./mainreadelf -d a.out
侵入式hook,需要改造代码
内核式hook,插入一个内核模块,在模块中对系统调用进行hook常用命令,readelf, nm, ldd静态链接的hook:在编译链接阶段就得完成,运行时没法通过替换库的方式做到链接阶段存在符号冲突问题,同名的强符号不能共存用-Wl,--whold-archive方式加载全部的符号,实现hook,参考libgoman 8 ld.so 查找LD_PRELOAD/lib/x86_64-linux-gnu/libc.so.6 64位C运行库,软链接,指向真正的C运行库,也就是同目录下的libc-2.27.so/lib/i386-linux-gnu/libc.so.6 32位C运行库/usr/lib/x86_64-linux-gnu/libc.so 链接脚本,指向64位C运行库libc.so.5也存在过,但它不是指glibc,而是指Linux libc,这个版本的C运行库相当于glibc的一个试图替代者,但现在不用了,参考man libchttps://stackoverflow.com/questions/426230/what-is-the-ld-preload-tricklibc的链接顺序确实是在所有用户指定的链接库后面,也就是说用户可以先链自己的库,然后hook掉libc里的接口。
sylar对以下函数进行了hook,并且只对socket fd进行了hook,如果操作的不是socket fd,那会直接调用系统原本的API,而不是hook之后的API:sleepusleepnanosleepsocketconnectacceptreadreadvrecvrecvfromrecvmsgwritewritevsendsendtosendmsgclosefcntlioctlgetsockoptsetsockopt
关于宏的骚操作
#define HOOK_FUN(XX)
XX(sleep) \
XX(usleep) \
#define XX(name)
HOOK_FUN(XX)
#undef XX
#define XX(name)
HOOK_FUN(XX)
#undef XX