【服务器学习】hook模块
hook模块
以下是从sylar服务器中学的,对其的复习;
参考资料
hook系统底层和socket相关的API,socket IO相关的API,以及sleep系列的API。hook的开启控制是线程粒度的,可以自由选择。通过hook模块,可以使一些不具异步功能的API,展现出异步的性能,如MySQL。
hook概述
hook实际上就是对系统调用API进行一次封装,将其封装成一个与原始的系统调用API同名的接口,应用在调用这个接口时,会先执行封装中的操作,再执行原始的系统调用API。
hook技术可以使应用程序在执行系统调用之前进行一些隐藏的操作,比如可以对系统提供malloc()和free()进行hook,在真正进行内存分配和释放之前,统计内存的引用计数,以排查内存泄露问题。
- hook功能
hook的目的是在不重新编写代码的情况下,把老代码中的socket IO相关的API都转成异步,以提高性能。
- hook实现
hook的实现机制非常简单,就是通过动态库的全局符号介入功能,用自定义的接口来替换掉同名的系统调用接口。由于系统调用接口基本上是由C标准函数库libc提供的,所以这里要做的事情就是用自定义的动态库来覆盖掉libc中的同名符号。
基于动态链接的hook有两种方式,第一种是外挂式hook,也称为非侵入式hook,通过优先加自定义载动态库来实现对后加载的动态库进行hook,这种hook方式不需要重新编译代码
下面在不重新编译代码的情况下,用自定义的动态库来替换掉可执行程序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"));
}
这里实现了一个write函数,这个函数的签名和libc提供的write函数完全一样,函数内容是用syscall的方式直接调用编号为SYS_write的系统调用,实现的效果也是往标准输出写内容,只不过这里我们将输出内容替换成了其他值。将hook.c编译成动态库:
gcc -fPIC -shared hook.c -o libhook.so
通过设置 LD_PRELOAD环境变量,将libhoook.so设置成优先加载,从面覆盖掉libc中的write函数
# LD_PRELOAD="./libhook.so" ./a.out
12345
这里我们并没有重新编译可执行程序a.out,但是可以看到,write的实现已经替换成了我们自己的实现。究其原因,就是LD_PRELOAD环境变量,它指明了在运行a.out之前,系统会优先把libhook.so加载到了程序的进程空间,使得在a.out运行之前,其全局符号表中就已经有了一个write符号,这样在后续加载libc共享库时,由于全局符号介入机制,libc中的write符号不会再被加入全局符号表,所以全局符号表中的write就变成了我们自己的实现。
第二种方式的hook是侵入式的,需要改造代码或是重新编译一次以指定动态库加载顺序。如果是以改造代码的方式来实现hook,那么可以像下面这样直接将write函数的实现放在main.c里,那么编译时全局符号表里先出现的必然是main.c中的write符号:
#include <unistd.h>
#include <string.h>
#include <sys/syscall.h>
ssize_t write(int fd, const void *buf, size_t count) {
syscall(SYS_write, STDOUT_FILENO, "12345\n", strlen("12345\n"));
}
int main() {
write(STDOUT_FILENO, "hello world\n", strlen("hello world\n")); // 这里调用的是上面的write实现
return 0;
}
关于如何找回已经被全局符号介入机制覆盖的系统调用接口,因为大部分情况下,系统调用提供的功能都是无可替代的,我们虽然可以用hook的方式将其替换成自己的实现,但是最终要实现的功能,还是得由原始的系统调用接口来完成。在Linux中,这个方法就是
dslym
sylar hook模块设计
sylar的hook功能以线程为单位,可自由设置当前线程是否使用hook。默认情况下,协程调度器的调度线程会开启hook,而其他线程则不会开启。
sylar对以下函数进行了hook,并且只对socket fd进行了hook,如果操作的不是socket fd,那会直接调用系统原本的API,而不是hook之后的API
sleep
usleep
nanosleep
socket
connect
accept
read
readv
recv
recvfrom
recvmsg
write
writev
send
sendto
sendmsg
close
fcntl
ioctl
getsockopt
setsockopt
除此外,sylar还增加了一个 connect_with_timeout 接口用于实现带超时的connect。
为了管理所有的socket fd,sylar设计了一个FdManager类来记录所有分配过的fd的上下文,这是一个单例类,每个socket fd上下文记录了当前fd的读写超时,是否设置非阻塞等信息。
关于hook模块和IO协程调度的整合。一共有三类接口需要hook,如下:
-
sleep延时系列接口,包括sleep/usleep/nanosleep。对于这些接口的hook,只需要给IO协程调度器注册一个定时事件,在定时事件触发后再继续执行当前协程即可。当前协程在注册完定时事件后即可yield让出执行权。
-
socket IO系列接口,包括read/write/recv/send…等,connect及accept也可以归到这类接口中。这类接口的hook首先需要判断操作的fd是否是socket fd,以及用户是否显式地对该fd设置过非阻塞模式,如果不是socket fd或是用户显式设置过非阻塞模式,那么就不需要hook了,直接调用操作系统的IO接口即可。如果需要hook,那么首先在IO协程调度器上注册对应的读写事件,等事件发生后再继续执行当前协程。当前协程在注册完IO事件即可yield让出执行权。
-
socket/fcntl/ioctl/close等接口,这类接口主要处理的是边缘情况,比如分配fd上下文,处理超时及用户显式设置非阻塞问题。
socket fd上下文和FdManager的实现,这两个类用于记录fd上下文和保存全部的fd上下文,它们的关键实现如下
/**
* @brief 文件句柄上下文类
* @details 管理文件句柄类型(是否socket)
* 是否阻塞,是否关闭,读/写超时时间
*/
class FdCtx : public std::enable_shared_from_this<FdCtx> {
public:
typedef std::shared_ptr<FdCtx> ptr;
/**
* @brief 通过文件句柄构造FdCtx
*/
FdCtx(int fd);
/**
* @brief 析构函数
*/
~FdCtx();
....
private:
/// 是否初始化
bool m_isInit: 1;
/// 是否socket
bool m_isSocket: 1;
/// 是否hook非阻塞
bool m_sysNonblock: 1;
/// 是否用户主动设置非阻塞
bool m_userNonblock: 1;
/// 是否关闭
bool m_isClosed: 1;
/// 文件句柄
int m_fd;
/// 读超时时间毫秒
uint64_t m_recvTimeout;
/// 写超时时间毫秒
uint64_t m_sendTimeout;
};
/**
* @brief 文件句柄管理类
*/
class FdManager {
public:
typedef RWMutex RWMutexType;
/**
* @brief 无参构造函数
*/
FdManager();
/**
* @brief 获取/创建文件句柄类FdCtx
* @param[in] fd 文件句柄
* @param[in] auto_create 是否自动创建
* @return 返回对应文件句柄类FdCtx::ptr
*/
FdCtx::ptr get(int fd, bool auto_create = false);
/**
* @brief 删除文件句柄类
* @param[in] fd 文件句柄
*/
void del(int fd);
private:
/// 读写锁
RWMutexType m_mutex;
/// 文件句柄集合
std::vector<FdCtx::ptr> m_datas;
};
/// 文件句柄单例
typedef Singleton<FdManager> FdMgr;
FdCtx类在用户态记录了fd的读写超时和非阻塞信息,其中非阻塞包括用户显式设置的非阻塞和hook内部设置的非阻塞,区分这两种非阻塞可以有效应对用户对fd设置/获取NONBLOCK模式的情形。
另外注意一点,FdManager类对FdCtx的寻址采用了和IOManager中对FdContext的寻址一样的寻址方式,直接用fd作为数组下标进行寻址。
接下来是hook的整体实现。首先定义线程局部变量t_hook_enable,用于表示当前线程是否启用hook,使用线程局部变量表示hook模块是线程粒度的,各个线程可单独启用或关闭hook。然后是获取各个被hook的接口的原始地址, 这里要借助dlsym来获取。
#define HOOK_FUN(XX) \
XX(sleep) \
XX(usleep) \
XX(nanosleep) \
XX(socket) \
XX(connect) \
XX(accept) \
XX(read) \
XX(readv) \
XX(recv) \
XX(recvfrom) \
XX(recvmsg) \
XX(write) \
XX(writev) \
XX(send) \
XX(sendto) \
XX(sendmsg) \
XX(close) \
XX(fcntl) \
XX(ioctl) \
XX(getsockopt) \
XX(setsockopt)
extern "C" {
#define XX(name) name ## _fun name ## _f = nullptr;
HOOK_FUN(XX);
#undef XX
}
void hook_init() {
static bool is_inited = false;
if(is_inited) {
return;
}
#define XX(name) name ## _f = (name ## _fun)dlsym(RTLD_NEXT, #name);
HOOK_FUN(XX);
#undef XX
}
上面的宏展开之后的效果如下:
extern "C" {
sleep_fun sleep_f = nullptr; \
usleep_fun usleep_f = nullptr; \
....
setsocketopt_fun setsocket_f = nullptr;
};
hook_init() {
...
sleep_f = (sleep_fun)dlsym(RTLD_NEXT, "sleep"); \
usleep_f = (usleep_fun)dlsym(RTLD_NEXT, "usleep"); \
...
setsocketopt_f = (setsocketopt_fun)dlsym(RTLD_NEXT, "setsocketopt");
}
hook_init() 放在一个静态对象的构造函数中调用,这表示在main函数运行之前就会获取各个符号的地址并保存在全局变量中。
最后是各个接口的hook实现,这部分和上面的全局变量定义要放在extern "C"中,以防止C++编译器对符号名称添加修饰。由于被hook的接口要完全模拟原接口的行为,所以这里要小心处理好各种边界情况以及返回值和errno问题。
- sleep/usleep/nanosleep的hook实现,它们的实现思路完全一样,即先添加定时器再yield
- socket接口的hook实现,socket用于创建套接字,需要在拿到fd后将其添加到FdManager中
- connect和connect_with_timeout的实现,由于connect有默认的超时,所以这里只需要实现connect_with_timeout即可