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的目的。

參考:

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