HOOK 模块
1️⃣ 什么是 HOOK
HOOK ,中文简称:钩子 ,是对 函数/系统调用 API 进行一次封装。主要是将其封装成一个与原始的 函数/系统调用 API 同名的接口。
调用这个接口的时候,会 先进行封装的操作,再执行 原始 的 函数/系统调用API
sylar中的HOOK,就是为了在不重写代码的情况下,把原有代码中的 同步socket api都转换为 异步 操作,以提升性能。
还可以用 C++ 的子类重载来理解 HOOK。在 C++ 中,子类在重载父类的同名方法时,一种常见的实现方式是子类先完成自己的操作,再调用父类的操作,如下:
1 |
|
在上面的代码实现中,调用子类的 Print 方法,会先执行子类的语句,然后再调用父类的 Print 方法,这就相当于子类 hook 了父类的 Print 方法。
由于 hook 之后的系统调用与原始的系统系统调用同名,所以对于程序开发者来说也很方便,不需要重新学习新的接口,只需要按老的接口调用惯例直接写代码就行了。
2️⃣ 基于动态链接的 HOOK
动态链接场景下,很多 Hook 本质都是利用 ELF 的全局符号介入(symbol interposition):
当进程解析一个符号(如 write、sleep)时,先命中的定义会遮蔽后面库里的同名符号。因此可以 “先放一个同名函数”,达到替换效果。
非侵入式 HOOK
特点:不需要重新编译目标程序。
将预先编译好的动态库 .so 提前注入到进程地址空间,让 动态加载器 优先加载指定库,从而让 同名符号 先进入 ,使得后面 libc.so 里的同名符号就 “进不来了”。
相关案例
1 |
|
将上述代码编译运行后,得到的结果是 hello world
1 | gcc main.c |
此时,可以通过 ldd 查看该可执行程序 a.out 所依赖的共享库,如下所示:
1 | ldd ./a.out |
可以看到其依赖 libc 共享库,write 系统调用就是由 libc 提供的。
接下来将会讲解:如何在在 不重新编译代码 的情况下,用 自定义动态库 来替换掉可执行程序 a.out 中的 write 实现。
创建
hook.cc1
2
3
4
5
6
7
ssize_t write(int fd, const void *buf, size_t count) {
syscall(SYS_write, STDOUT_FILENO, "12345\n", strlen("12345\n"));
}将
hook.cc编译成动态库libhook.so1
gcc -fPIC -shared hook.cc -o libhook.so
通过设置
LD_PRELOAD环境变量,将libhoook.so设置成优先加载,从面覆盖掉libc中的write函数1
2LD_PRELOAD="./libhook.so" ./a.out
12345通过
LD_PRELOAD环境变量,它指明了在运行a.out之前,系统会优先把libhook.so加载到了程序的进程空间,使得在a.out运行之前,其全局符号表中就已经有了一个write符号,这样在后续加载libc共享库时,由于全局符号介入机制,libc中的write符号不会再被加入全局符号表,所以全局符号表中的write就变成了自己的实现。
侵入式 HOOK
特点:需要改造代码或重新编译一次以指定动态库加载顺序。
把 hook 代码编进目标程序或其依赖库里,并确保 hook 后的符号比 libc 的同名符号更早被解析到。
sylar对系统调用API即采用了这种方法
常用做法:直接覆盖系统调用接口
- 在程序运行时,通常 可执行文件的符号优先级更高,这会直接命中程序员自己生成的实现。
- 只要这个库在运行时进入 “全局符号可见范围”,并且在查找链上排在 libc 之前,就会命中你们的实现。
问题:覆盖系统调用后,如何调用原版系统调用呢?
侵入式 hook 必须配套 dlsym(RTLD_NEXT, "sleep")
RTLD_NEXT:从当前库之后继续找同名符号sleep:寻找的符号名称
dlsym 的含义:使用 dlsym 找回被覆盖的符号,第一个参数固定为 RTLD_NEXT,第二个参数是符号名称。
dlsym 在 libdl 里,链接时要把 -ldl 带上,否则会报未定义引用。
3️⃣ HOOK 在 sylar 中的运用
sylar 的 Hook 不是为了 “偷梁换柱” 做恶作剧,而是服务协程框架的核心目标:
把可能 阻塞线程 的系统调用,改造成 “只阻塞当前协程(Fiber)”,线程可以继续跑其它协程。
1️⃣ sylar 为什么需要 HOOK
在没有 HOOK 的情况下
sleep()会阻塞线程;connect()/accept()/read()/write()会在阻塞IO上卡住线程;
协程优势 在于 “一个线程跑很多 fiber” ,这会被直接打回原形:一个阻塞调用就把整个线程封死。
所以 sylar 选择用 Hook 把这些 API 变成:
- 遇到阻塞时:把
fd事件挂到epoll(IOManager) - 同时可选:加一个 定时器 做 超时控制
- 当前协程
yield()让出执行权 - 事件就绪/超时:协程被
schedule()唤醒继续执行
2️⃣ sylar 的 Hook 属于哪一类?
sylar 的 HOOK 模块实现属于 基于动态链接符号介入 的侵入式 Hook:
侵入式: 需要把
Hook代码编进工程,重新编译并参与链接动态链接符号介入: 通过定义同名函数覆盖
libc的符号调用
dlsym: 找回原始实现,避免递归并支持 “关闭 hook 时走原逻辑”
3️⃣ 侵入式 Hook 如何在 sylar 中怎么落地?
声明原函数指针
1
2using sleep_fun = unsigned int (*)(unsigned int seconds);
extern sleep_fun sleep_f;- 定义一个类型
sleep_fun,它完全匹配libc的sleep签名 - 声明一个全局变量
sleep_f- 全局变量将来会存放 “
libc原版sleep的地址” hook版sleep()如果想 “退回原逻辑”,就调用sleep_f(seconds),而不是调用sleep()。
- 全局变量将来会存放 “
- 定义一个类型
原函数指针的定义与初始化
首先将原函数
sleep指针定义出来1
2
3
HOOK_FUN(XX);- 展开:
sleep_fun sleep_f = nullptr; - 防止:链接会报错
undefined reference to sleep_f
- 展开:
在
hook_init()找回原函数地址1
2
3
HOOK_FUN(XX);- 展开:
sleep_f = (sleep_fun)dlsym(RTLD_NEXT, "sleep");
- 展开:
确保
hook_init()先于main执行1
2
3
4
5
6
7
8
9
10struct _HookIniter
{
_HookIniter()
{
hook_init();
. ..
}
};
static _HookIniter s_hook_initer;1
2
3
4
5
6
7
8
9
10
11
12void hook_init()
{
static bool is_inited = false;
if (is_inited)
{
return;
}
// 使用 dlsym 来获取函数指针并赋值
HOOK_FUN(XX);
}C++全局静态对象 会在main()之前构造,这使得在 全局静态对象 把sleep_f/read_f/connect_f等初始化。使得第一次调用sleep(),sleep_f就已经是原版函数sleep了
实现同名函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19unsigned int sleep(unsigned int seconds)
{
// 判断当前线程是否被hook
if (!sylar::t_hook_enable)
{
return sleep_f(seconds);
}
// 这说明当前线程被hook
SYLAR_LOG_DEBUG(SYLAR_LOG_ROOT()) << "hook start";
sylar::Fiber::ptr fiber = sylar::Fiber::GetThis(); // 获得当前线程正在执行的协程
sylar::IOManager* iom = sylar::IOManager::GetThis(); // 获得当前线程所属的IO协程调度器
//
iom->addTimer(seconds * 1000, false, [iom, fiber](){
SYLAR_LOG_DEBUG(SYLAR_LOG_ROOT()) << "Timer triggered, scheduling fiber.";
iom->schedule(fiber);
});
sylar::Fiber::GetThis()->yield(); // 因为要睡眠,所以要让出执行权
return 0;
}- 当业务代码调用
sleep(3),链接器生成对符号sleep的引用。运行时,动态链接器解析sleep,会优先使用我们自己的版本。 - 每个线程都拥有 静态线程局部变量
t_hook_enable来决定是否走 协程化。1
static thread_local bool t_hook_enable = false;
- 当业务代码调用
4️⃣ 阻塞 IO 变成 协程等待
对 read/recv/write/send/accept 等这类调用来说,真正会把线程卡死的情况通常是:
fd是阻塞的- 当前没有数据可读/不可写
- 系统调用会让线程睡在内核里,直到就绪
do_io 的做法是:
先尝试调用一次原始系统调用 → 如果发现 “现在会阻塞” → 就不让线程在内核里睡,而是把
fd的READ/WRITE事件交给epoll,然后当前Fiber yield(),等事件就绪再恢复Fiber,回来重新启动系统调用
1 | template<typename OriginFun, typename... Args> |
