libco源码解析(8) hook机制探究

libco源码解析(1) 协程运行与基本结构
libco源码解析(2) 创建协程,co_create
libco源码解析(3) 协程执行,co_resume
libco源码解析(4) 协程切换,coctx_make与coctx_swap
libco源码解析(5) poll
libco源码解析(6) co_eventloop
libco源码解析(7) read,write与条件变量
libco源码解析(8) hook机制探究
libco源码解析(9) closure实现

引言

在探究这个机制之前我们先来看看libco为什么被腾讯的工程师们创造出来。

如今微信已经是一个月活近12亿的现象级软件,不可否认其背后的技术架构一定是首屈一指的。但是罗马不是一日建成的。实际在微信运行之初其并发能力并不是像现在一样。事实上当时大部分模块都采用了半同步半异步模型。接入层为异步模型,业务逻辑层则是同步的多进程或多线程模型,业务逻辑的并发能力只有几十到几百。显然微信需要一次技术上的革命。

有两种方案被提出来:

  1. A 线程异步化:把所有服务改造成异步模型,相当于把整个微信的框架重新实现一遍,从技术上来说是合理的,但是风险和代价都太大。
  2. B 协程异步化:对业务逻辑非侵入的异步化改造,即使用某种技术使得能够在最小化侵入代码的情况下完成异步改造。

最终腾讯的工程师选择了后者,libco就是这个方案的最终结果。

以上我们可以看出libco除了异步以外最重要的功能就是要做到对于用户代码的侵入性小,即最小化修改用户代码,hook机制就是实现这一步最为重要的一点。

静态链接与动态链接在这里插入图片描述

首先我们要复习下链接相关的知识,可在阅读下文前先行阅读这篇博客:Linux下的静态链接与动态链接

我们知道在链接的过程中并不是只有我们的代码而已,在绝大多数情况下要用到安全稳定的标准库(大多数情况),那我们就要把库的代码拿到我们的的代码中来,我们知道在链接阶段已经得到了一张符号表,所以我们要做的就是把引用与定义相连接,但一个标准库中文件显然是很多的,如果每次引用都会把库中的代码都拷贝了显然是有些笨重的。

所以我们的静态库采用了把库中每一个函数都变成一个独立的可重定位文件(点击开头浅析链接),这样我们在链接阶段就可以把我们需要的部分拷贝,其他的则不同理会,这样极大的节省了我们的宝贵的内存。

如此看来静态链接已经很好的解决了问题,为什么还有动态链接呢?

第一点是因为静态库虽然已经很好的节省了内存,但在很多情况下还是不够优秀,比如常用的标准IO库,我们几乎每个程序都需要,难到要给每一个进程都拷贝一份吗,答案是否定的,共享库的策略是每一个文件系统系统只有一份库的数据,每一个库的代码部分可被不同进程共享,也就是说每一个进程的虚拟地址经过翻译后得到的物理地址相同,共用一份库,这样就优化了我们的内存占用。

第二点就是静态库因为是在编译的时候执行的,所以就算静态库有一点点小小的改变都需要重新编译程序,然后发布出去(对于玩家来说,可能是一个很小的改动,却导致整个程序重新下载,全量更新)。所以动态库其实并不是在编译时载入而是在运行时载入的,这样就解决了静态库对程序的更新、部署和发布页会带来麻烦,用户只需要更新动态库即可。

具体的实现取决于一个重要的结构,即GOT(Global Offset Table),即全局偏移量表,因为链接阶段可能不知道一些符号的的具体位置,所以我们需要一个数据结构来存储经过动态加载后找到的符号的真实地址,这就是GOT。

在这里插入图片描述

当然动态链接也分为加载时链接运行时链接

  1. 加载时链接就是在加载时重定位GOT中的条目
  2. 运行时链接就是对于每一条GOT条目在库中进行匹配

所以所谓的hook其实就是在这张表上把原本对应的地址改变,举个例子,在加载时把我们自己的read函数地址放到表中,并使用dlsym族函数获取hook前函数的地址,这样就可以在自己实现的read中回调原函数,并加上一些额外的逻辑,并且在运行是会调用我们的版本了。

hook机制

系统提供给我们的dlopen、dlsym族函数可以用来操作动态链接库,这也为hook机制提供了技术上的保证。

我们来举一个简单的例子,我们要实现的功能是在调用系统给我们提供的read时打印一句“hello world”,并回调系统的read。

第一种方法是使用LD_PRELOAD环境变量。

LD_PRELOAD是Linux系统的一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库。这个功能主要就是用来有选择性的载入不同动态链接库中的相同函数。通过这个环境变量,我们可以再主程序和其动态链接库的中间加载别的动态链接库,甚至覆盖正常的函数库。一方面,我们可以以此功能来使用自己的或是更好的函数(无需别人的源码),而另一方面,我们也可以向别人的程序注入程序,从而达到特定的目的.

系统一般会去LD_LIBRARY_PATH下寻找,但如果使用了这个变量,系统会优先去这个路径下寻找,如果找到了就返回,不在往下找了,顺便提下,动态库的加载顺序为LD_PRELOAD>LD_LIBRARY_PATH>/etc/ld.so.cache>/lib>/usr/lib。

我们来看一个小小的例子:

//hookread.cpp
#include <dlfcn.h>
#include <unistd.h>

#include <iostream>

typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);

static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");

ssize_t read( int fd, void *buf, size_t nbyte ){
    std::cout << "进入 hook read\n";
    return g_sys_read_func(fd, buf, nbyte);
}

void co_enable_hook_sys(){
    std::cout << "可 hook\n";
}
#include <bits/stdc++.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

using namespace std;

int main(){
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    char buffer[10000];
    
    int res = read(fd, buffer ,10000);
    return 0;
}

然后执行以下命令:

g++ -o main main.cpp
g++ -o hookread.so -fPIC -shared -D_GNU_SOURCE hookread.cpp -ldl
LD_PRELOAD=./hookread.so ./main

我们可以看到输出为:

进入 hook read

hook成功!

libco如何做

但是libco并不是这样做的,整个libco中你都看不到LD_PRELOAD,libco使用了一种特殊的方法,秘密都在于co_enable_hook_sys函数,通过在用户代码中包含这个函数,可以把整个co_hook_sys_call.cpp中的符号表导入我们的项目中,这样也可以做到使用我们自己的库去替换系统的库。我们来看看如何做到吧:

//hookread.cpp
#include <dlfcn.h>
#include <unistd.h>

#include <iostream>

#include "hookread.h"

typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);

static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");

ssize_t read( int fd, void *buf, size_t nbyte ){
    std::cout << "进入 hook read\n";
    return g_sys_read_func(fd, buf, nbyte);
}

void co_enable_hook_sys(){
    std::cout << "可 hook\n";
}
// hookread.h
void co_enable_hook_sys();
// main.cpp
#include <bits/stdc++.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "hookread.h"
#include <unistd.h>

using namespace std;

int main(){
    co_enable_hook_sys();
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    char buffer[10000];
    
    int res = read(fd, buffer ,10000);
    return 0;
}

执行以下命令:

g++ hookread.cpp -o hookread.i -E
g++ hookread.i -o hookread.s -S
g++ hookread.s -o hookread.o -c
g++ main.cpp -ldl hookread.o
./a.out

我们可以看到输出为:

可 hook
进入 hook read

我们可以看到这样也可以达到hook的目的,虽然相比于上一种方法,会对用户代码造成侵入,但是好处是不需要用户自己去配置环境变量降低使用难度。

总结

hook机制的神秘面纱就这样被我们揭开了,我们可以看到其实并不难,重点都是动态链接相关的知识点,只有懂了动态链接,这些都不是神秘什么问题啦。也就是通过hook机制,libco可以达到用户无感的情况下把同步的代码替换为异步,这也是腾讯工程师写出libco的目的。

参考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章