1️⃣ 什么是 HOOK

HOOK ,中文简称:钩子 ,是对 函数/系统调用 API 进行一次封装。主要是将其封装成一个与原始的 函数/系统调用 API 同名的接口。

调用这个接口的时候,会 先进行封装的操作,再执行 原始函数/系统调用API

sylar 中的 HOOK,就是为了在不重写代码的情况下,把原有代码中的 同步 socket api 都转换为 异步 操作,以提升性能。

还可以用 C++ 的子类重载来理解 HOOK。在 C++ 中,子类在重载父类的同名方法时,一种常见的实现方式是子类先完成自己的操作,再调用父类的操作,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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 之后的系统调用与原始的系统系统调用同名,所以对于程序开发者来说也很方便,不需要重新学习新的接口,只需要按老的接口调用惯例直接写代码就行了。

2️⃣ 基于动态链接的 HOOK

动态链接场景下,很多 Hook 本质都是利用 ELF 的全局符号介入(symbol interposition):
  当进程解析一个符号(如 writesleep)时,先命中的定义会遮蔽后面库里的同名符号。因此可以 “先放一个同名函数”,达到替换效果。

非侵入式 HOOK

特点:不需要重新编译目标程序。
  将预先编译好的动态库 .so 提前注入到进程地址空间,让 动态加载器 优先加载指定库,从而让 同名符号 先进入 ,使得后面 libc.so 里的同名符号就 “进不来了”

相关案例

1
2
3
4
5
6
7
8
#include <unistd.h>
#include <string.h>

int main(){
// 调用系统调用write写标准输出文件描述符
write(STDOUT_FILENO, "hello world\n", strlen("hello world\n"));
return 0;
}

  将上述代码编译运行后,得到的结果是 hello world

1
2
3
# gcc main.c
# ./a.out
hello world

  此时,可以通过 ldd 查看该可执行程序 a.out 所依赖的共享库,如下所示:

1
2
3
4
# ldd ./a.out 
linux-vdso.so.1 (0x00007ffde42a4000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f80ec76e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f80ecd61000)

   可以看到其依赖 libc 共享库,write 系统调用就是由 libc 提供的。

接下来将会讲解:如何在在 不重新编译代码 的情况下,用 自定义动态库 来替换掉可执行程序 a.out 中的 write 实现。

  • 创建 hook.cc

    1
    2
    3
    4
    5
    6
    7
    #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.cc 编译成动态库 libhook.so

    1
    gcc -fPIC -shared hook.cc -o libhook.so	
  • 通过设置 LD_PRELOAD 环境变量,将 libhoook.so 设置成优先加载,从面覆盖掉 libc 中的 write 函数

    1
    2
    # LD_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️⃣ HOOKsylar 中的运用

sylarHook 不是为了 “偷梁换柱” 做恶作剧,而是服务协程框架的核心目标:

把可能 阻塞线程 的系统调用,改造成 “只阻塞当前协程(Fiber)”,线程可以继续跑其它协程。

1️⃣ sylar 为什么需要 HOOK

在没有 HOOK 的情况下

  • sleep() 会阻塞线程;
  • connect()/accept()/read()/write() 会在阻塞 IO 上卡住线程;

协程优势 在于 “一个线程跑很多 fiber ,这会被直接打回原形:一个阻塞调用就把整个线程封死

所以 sylar 选择用 Hook 把这些 API 变成:

  • 遇到阻塞时:把 fd 事件挂到 epollIOManager
  • 同时可选:加一个 定时器超时控制
  • 当前协程 yield() 让出执行权
  • 事件就绪/超时:协程被 schedule() 唤醒继续执行

2️⃣ sylarHook 属于哪一类?

sylarHOOK 模块实现属于 基于动态链接符号介入 的侵入式 Hook

  • 侵入式: 需要把 Hook 代码编进工程,重新编译并参与链接

  • 动态链接符号介入: 通过定义同名函数覆盖 libc 的符号

  • 调用 dlsym 找回原始实现,避免递归并支持 “关闭 hook 时走原逻辑”

3️⃣ 侵入式 Hook 如何在 sylar 中怎么落地?

  • 声明原函数指针

    1
    2
    using sleep_fun = unsigned int (*)(unsigned int seconds);
    extern sleep_fun sleep_f;
    • 定义一个类型 sleep_fun,它完全匹配 libcsleep 签名
    • 声明一个全局变量 sleep_f
      • 全局变量将来会存放 libc 原版 sleep 的地址”
      • hooksleep() 如果想 “退回原逻辑”,就调用 sleep_f(seconds),而不是调用 sleep()
  • 原函数指针的定义与初始化

    • 首先将原函数 sleep指针定义出来

      1
      2
      3
      #define XX(name) name ## _fun name ## _f = nullptr;
      HOOK_FUN(XX);
      #undef XX
      • 展开:sleep_fun sleep_f = nullptr;
      • 防止:链接会报错 undefined reference to sleep_f
    • hook_init() 找回原函数地址

      1
      2
      3
      #define XX(name) name##_f = (name##_fun)dlsym(RTLD_NEXT, #name);
      HOOK_FUN(XX);
      #undef XX
      • 展开:sleep_f = (sleep_fun)dlsym(RTLD_NEXT, "sleep");
  • 确保 hook_init() 先于 main 执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct _HookIniter
    {
    _HookIniter()
    {
    hook_init();
    . ..
    }
    };

    static _HookIniter s_hook_initer;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void hook_init()
    {
    static bool is_inited = false;
    if (is_inited)
    {
    return;
    }
    // 使用 dlsym 来获取函数指针并赋值
    #define XX(name) name ## _f = (name ## _fun)dlsym(RTLD_NEXT, #name);
    HOOK_FUN(XX);
    #undef 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
    19
    unsigned 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 的做法是:

先尝试调用一次原始系统调用 → 如果发现 “现在会阻塞” → 就不让线程在内核里睡,而是把 fdREAD/WRITE 事件交给 epoll,然后当前 Fiber yield(),等事件就绪再恢复 Fiber,回来重新启动系统调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
template<typename OriginFun, typename... Args>
static ssize_t do_io(int fd, OriginFun fun, const char* hook_fun_name, uint32_t event, int timeout_so, Args&&... args)
{
// 这是判断是否启用hook
if (!sylar::t_hook_enable)
{
// 如果没有启用 hook,直接调用原始系统调用函数
return fun(fd, std::forward<Args>(args)...);
}

// 这是获得 fd 的 上下文(是否 socket、是否设置非阻塞、超时设置等信息)
sylar::FdCtx::ptr ctx = sylar::FdMgr::GetInstance()->get(fd);
if (!ctx)
{
// 如果失败获取 fdCtx,直接调用原始系统调用函数
return fun(fd, std::forward<Args>(args)...);
}

// 判断该fd是否已经关闭
if (ctx->isClosed())
{
// 如果fd已关闭,设置错误码errno
errno = EBADF;
return -1;
}

// 如果不是 socket 或是用户自己设置了非阻塞模式,不 hook
if (!ctx->isSocket() || ctx->getUserNonblock())
{
// 直接返回系统调用
return fun(fd, std::forward<Args>(args)...);
}

// 获取当前 fd 的超时时间
uint64_t to = ctx->getTimeout(timeout_so);
// 构建一个 timer_info(协程阻塞等待的超时信息)用于后续协程恢复判断
std::shared_ptr<timer_info> tinfo(new timer_info);
retry:
// 尝试执行系统调用函数
ssize_t n = fun(fd, std::forward<Args>(args)...);
// n == -1 表示执行失败,并且 errno == EINTR 表示程序被中断,重新执行
while (n == -1 && errno == EINTR)
{
n = fun(fd, std::forward<Args>(args)...);
}
// errno == EAGAIN 这表示当前不可以进行读写,处于阻塞状态,所以需要当前协程让出执行权
if (n == -1 && errno == EAGAIN)
{
// 获得当前IO协程调度器
sylar::IOManager* iom = sylar::IOManager::GetThis();
// 设置一个定时器
sylar::Timer::ptr timer;
std::weak_ptr<timer_info> winfo(tinfo);
// 判断是否需要设置超时计时器
if (to != (uint64_t)-1)
{
// 给IO协程调度器添加一个条件定时器
timer = iom->addTimerCondition(to, false,[winfo, fd, iom, event](){
auto t = winfo.lock();
if (!t || t->cancelled)
{
return;
}
t->cancelled = ETIMEDOUT;
iom->cancelEvent(fd, static_cast<sylar::IOManager::Event>(event));
}, winfo);
}
// 将 fd 对应的事件 event 添加到 epoll 内核事件表
int rt = iom->addEvent(fd, static_cast<sylar::IOManager::Event>(event));
// 这表示往epoll添加事件失败
if (SYLAR_UNLIKELY(rt))
{
SYLAR_LOG_ERROR(g_logger) << hook_fun_name << " addEvent("
<< fd << ", " << event << ")";
if(timer)
{
timer->cancel();
}
return -1;
}
// 这表示往epoll添加事件成功
else
{
// 当前协程让出执行权
sylar::Fiber::GetThis()->yield();
if (timer)
{
timer->cancel();
}
if (tinfo->cancelled)
{
errno = tinfo->cancelled;
return -1;
}
goto retry;
}
}


return n;
}

参考文章