這篇文章是“Y 語言:適用於 eBPF、Stap+、GDB 等的通用語言”系列的第二集。其他集詳見第一集第三集第四集

在第二集,我們將繼續這個系列在第一集的討論,介紹更多 Y 語言語法對 C 語言各種拓展的細節。

語言語法(接上文)

宏拓展

Y 語言支援所有 C 前處理器的指令,來處理 C 語言中的宏,甚至支援 GNU 擴充套件。包括 #if#elif#define#ifdef#ifndef,甚至可變引數宏(variadic macros)都可以立即處理。Y 語言 編譯器直接呼叫 GCC 的前處理器來處理 Y 語言 原始碼中的任意宏指令,以確保其與 GCC 100% 相容。

此外,Y 語言 支援很多自己的前處理器指令,在動態追蹤時進行程式碼複用。例如:

##ifdeftype SBufExt
#include "new.y"
##else
#include "old.y"
##endif

這裡的 ##ifdeftype 指令是 Y 語言 自己的前處理器指令,它用於檢查指令引數所指定的 C/C++ 資料型別,是否存在於目標程序或應用程式中。在這個例子中,如果目標(或“被追蹤者”)中存在 C/C++ 資料型別 SBufExt,那麼 Y 語言 原始檔 new.y 將會被包含進來。通常,OpenResty XRay 會從其集中式軟體包資料庫中提取型別資訊,並透明地提供給 Y 語言 編譯器。軟體包資料庫中儲存的除錯資訊,是提前從像 DWARF 這樣格式的除錯符號中收集來的。

您可能已經注意到,Y 語言 自身的指令名稱以 ## 開頭而不是單個 # 。這是為了避免和 C 前處理器的指令發生任何衝突。對於 Y 語言 自身的命令比如 ##ifdeftype,使用者需要使用相應的 ##elif##else 和 ##endif 指令,而不是在 C 語言中對應的指令。

Y 語言 本身還採用了其他與符號相關的指令,而且它們大多都十分直觀:

##ifdeffield my_type my_field
##ifdeffunc do_something
##ifdeftype struct bar_s
##ifdefenum error_codes
##ifdefvar my_var

追蹤者與被追蹤者空間

Y 語言 中管理的記憶體有兩個不同的空間,“追蹤者空間”(tracer space)和“被追蹤者空間”(tracee space)。追蹤者空間記憶體常駐在動態追蹤工具或分析器本身中,且是可寫的。另一方面,被追蹤者空間記憶體常駐在我們追蹤的目標程序(或核心)中,且是嚴格只讀的。被追蹤者空間有時也稱為“目標空間”。只讀的被追蹤者空間記憶體確保 Y 語言 工具和分析器,不會改變目標程序中的任何狀態,哪怕只是一個位元位。不僅如此,即使在被追蹤者空間(或追蹤者空間)中讀取無效地址也 永遠不會 導致目標程序崩潰。這種非法讀取只會在追蹤者空間中導致錯誤,或是返回垃圾資料。

預設情況下,Y 語言 中所有的變數宣告或變數定義都在追蹤者空間中,如:

int a;
long b[3];
double *d;
struct Foo foo;

在被追蹤者空間,您需要用 _target 關鍵字來 宣告 變數,如:

_target int a[5];

這行程式碼在被追蹤者空間中,宣告瞭一個名為 a 的變數是一個陣列型別變數。因為被追蹤者空間是隻讀的,我們只能宣告已存在於被追蹤者或目標中的變數。Y 語言 編譯器會自動從 OpenResty XRay 的軟體包資料庫中查詢有關被追蹤變數符號的資訊。

Y 語言 中使用的資料型別預設來自被追蹤者空間,不需要 _target 關鍵字(實際上,如果您使用了,會從 Y 語言 編譯器收到語法錯誤)。

追蹤者空間複合型別

可以在 Y 語言追蹤者空間 中,宣告覆合型別的變數,例如:

void foo(void) {
    struct foo a;
    a.name = "John";
    a.age = 32;
    printf("name: %s, age: %d", a.name, a.age);
}

這裡的 struct foo 型別來自被追蹤者空間,Y 語言 會在 OpenResty Xray 軟體包資料庫的幫助下,嘗試自動解析它。

很多開源的動態追蹤框架如 SystemTapGDBDTrace 等,必須透過骯髒、血腥的侵入,來定義這樣的複合追蹤者空間(tracer-land)變數。Y 語言 則沒有這個限制。

探針

在任何動態追蹤語言中,探針定義都是最關鍵的語言結構之一。Y 語言 支援許多在使用者態和核心空間的探針位置,且數目還在不斷增長。

使用者態探針

我們可以對目標程序中任意 C 語言的函式定義的入口,放置動態探針。每當探針被觸發時,下面的程式碼塊都會執行。要定義一個函式入口探針,我們可以使用 _probe 關鍵字並指定目標函式的名稱,如:

_probe main() {
    printf("main func is called!\n");
}

這裡我們在目標程序的函式 main() 入口定義了一個探針:

在被追蹤者空間中,我們也可以宣告和引用任何引數,例如:

_probe ngx_http_finalize_request(ngx_http_request *r, ngx_int_t rc) {
    printf("uri: %.*s, status code: %d\n", (int) r->uri.len, r->uri.data, rc);
}

在這裡我們從在目標程序中定義的 C 函式 ngx_http_finalize_request 的引數 r 和 rc 中,輸出了資料。

函式返回探針

我們也可以在任意的使用者態函式上,定義返回探針,例如:

_probe foo() -> int {
    printf("foo() func returned!\n");
}

也可以引用任何返回值,只需像這樣宣告一個返回變數:

_probe foo() -> int a {
    printf("foo is returning %d\n", a);
}

當然,目標程序中的行內函數和尾遞迴最佳化不會觸發這些返回探針,因為它們 從不 返回,至少不是一般意義上的返回。

其他動態追蹤框架

值得注意的是,許多其他動態追蹤框架都缺乏函式返回探針支援,或者實現上存在缺陷。例如,GDB 就缺乏函式返回探針的內建支援(或其術語稱為斷點),使用者必須自己在目標函式的每個返回位置,手動設定斷點。而 eBPFSystemTapBpftrace 等依賴核心的 uretprobes 機制,有一個天生的設計失誤,會修改和弄亂目標程序的執行時棧(stack),還會破壞很多功能如 stack unwinding。

幸運的是,即使目標是此類後端,或以 OpenResty XRay 提供的目標的增強版本為目標(如 Stap+ 和 OpenResty XRay 自己的 eBPF 實現),Y 語言 也可以自動規避這類限制。

核心空間探針

Y 語言也支援許多核心空間中的探針,分析使用者態的目標程序。

程序排程器探針

探測作業系統的程序排程器的 “CPU-on” 和 “CPU-off” 事件:

_probe _scheduler.cpu_on {
    int tid = _tid();
    printf("thread %d is on a CPU.\n", tid);
}

_probe _scheduler.cpu_off {
    int tid = _tid();
    printf("thread %d is off any CPUs.\n", tid);
}

這些探針位置在作業系統核心的程序/執行緒排程器內,因此也在核心空間中。例如,OpenResty XRay 中的 off-CPU 火焰圖分析器就用了和 Y 語言 相同的探針。

效能分析器探針

對 CPU 熱度效能分析,我們可以使用 _timer.profile 探針位置,如下所示:

_probe _timer.profile {
    _str bt = _ubt();  /* user-land backtrace string */

    /* do aggregates on the bt string value here... */
}

這裡我們使用 Y 語言 的內建函式 _ubt() ,將當前使用者態的回溯資訊,提取為一個字串(Y 語言 內建型別為 _str)。OpenResty XRay 中的標準分析器使用相同的 Y 語言 探測位置,生成各種型別的 on- CPU 火焰圖。

對於純使用者態的追蹤後端如 GDB 和 ODB ,使用任何核心空間探針都會導致編譯時的錯誤。這些後端設計上就無法接入核心空間。

定時器探針

能在指定時間發射探針事件很方便,比如像一次性定時器或週期性定時器那樣。Y 語言 透過 _timer.s 和 _timer.ms  探針名稱,來支援這樣的探針位置。以下是一些例子:

_probe _timer.s(3) {
    printf("firing every 3 seconds.\n");
}

_probe _timer.ms(100) {
    printf("firing after 100ms");
    _exit();  // quit so that this timer is a one-off.
}

請注意,標準的 eBPF 工具鏈不支援這種定時器探針,但是 Y 語言 可以在使用者態產生程式碼並正確地模擬它們。

系統呼叫探針

我們也可以探測系統呼叫,如:

_probe _syscall.open {
    // ...
}

這裡探測了 open 系統呼叫(或者簡稱為 syscall)。

程序啟動退出探針

Y 語言也可以在使用者態程序的啟動和退出位置放置探針,如:

_probe _process.begin {
  // ...
}

_probe _process.end {
  // ...
}

Y 語言 分析器或工具開始執行,而目標程序已經在執行時,那麼即使這些程序處於休眠狀態,_process.begin 探針處理程式也會對這些程序執行一次,且僅此一次。

不依賴 DWARF 的分析

Y 語言使用 OpenResty XRay 的軟體包資料庫查詢變數、複合型別的欄位偏移、任意目標函式的入口和返回位置,的記憶體地址和偏移量。這些查詢通常是透過符號名稱完成的。所以它不需要使用者深入血腥的二進位制世界。它也不需要目標系統(通常是生產系統)在其二進位制檔案或獨立的 .debug 檔案中,攜帶除錯符號或符號表。透過這種方式,我們也可以使用如 CTF、BTF 這樣其他的除錯資訊格式,甚至是機器學習演算法從二進位制可執行檔案中自動派生的資訊。

拓展變數型別

為了方便,Y 語言 用在 Perl 和 Python 這樣的動態語言中,常見的變數型別來擴充套件 C 語言。事實上我們很快就會發現,Y 語言 從 Perl 6 語言和 SystemTap 的指令碼語言裡借鑑了一些符號。

內建字串

Y 語言對字串有內建支援,即使是傳統的 C 語言字串也支援。內建字串很方便,並且 Y 語言 的許多內建函式適用於內建字串,而不是 C 語言字串(儘管也可以將 C 語言字串轉換為內建字串,甚至是從被追蹤者空間,有時甚至是隱式轉換)。

內建的字串型別名是 _str,與 C 語言字串不同,它顯式地記錄了字串長度,且允許空值字元 (\0) 在 playload 中間。儘管如此,字串資料總是會在末尾包含一個無值字元用以確保安全性(不計入字串長度)。

當然,內建字串只能在追蹤者空間分配,因為它是 Y 語言 自己的資料型別。

您可以在使用者自定義函式中使用 _str 型別作為引數型別或返回型別,也可以在追蹤者空間的全域性和自動變數中使用。下面是一個例子:

_str foo(_str a) {
    _str b = a + ", world";
    printf("b = %s (len: %d)", b, _len(b));
    return b;
}

請注意,我們可以使用過載 + 運算子來串接兩個內建字串。

要將被追蹤空間的 C 語言字串轉換為追蹤空間的內建字串,我們可以使用 Y 語言 內建函式 _tostr() ,如:

_target char *p;
...
_str s = _tostr(p);

但被追蹤者空間(tracee land)從設計上就是隻讀的,因此不能反向轉換。

在追蹤者空間使用內建字串是進行字串處理的最佳方式。Y 語言 提供了許多用於操作內建字串的內建函式,如字首/字尾/正規表示式匹配,子字串提取等。

Y 語言 的使用者自定義函式使用內建字串作為引數時,他們的函式呼叫都是透過 引用 傳遞字串,不涉及任何值複製。

內建聚合

聚合資料型別與 SystemTap 的統計資料型別非常相似。其他追蹤框架如 DTraceBpftrace 也提供類似的資料型別。聚合提供了一種非常高效的記憶體和 CPU 的利用方式,來計算線上的資料聚合和統計,如計算最小值、最大值、平均值、總和、計數、方差等。它們還可以計算和輸出直方圖,用於視覺化資料的值分佈。下面是一個簡單的例子:

_agg my_agg;

_probe foo() -> int retval {
    my_agg <<< retval;
}

_probe _timer.s(3) {
    long cnt = _count(my_agg);
    if (cnt == 0) {
        printf("no samples found.\n");
        _exit();
    }
    printf("min/avg/max: %ld/%ld/%ld\n", _min(my_agg), _avg(my_agg), _max(my_agg));
    _print(_hist_log(my_agg));  // print out the logrithmic histogram from my_agg
}

一個簡單的對數直方圖如下:

value |-------------------------------------------------- count
 1024 |                                                   0
 2048 |                                                   0
 4096 |@@@@@@@                                            7
 8192 |@                                                  1
16384 |@@@                                                3
32768 |                                                   0
65536 |                                                   0
65536 |                                                   0

OpenResty XRay 也可以將這樣的文字圖自動渲染成漂亮的網頁圖表,如下所示:

Web Chart Sample for a Histogram Generated by <a href="https://doc.openresty.com.cn/en/xray/ylang/">Ylang</a>

要清除聚合中記錄的全部資料,我們可以用 Y 語言 引入 _del 字首運算子:

_del my_agg;

內建聚合也可以透過引數,傳遞給 Y 語言 的使用者自定義函式(但不作為返回值),例如:

void foo(_agg a) {
    // ...
}

函式呼叫總是透過 引用 傳遞聚合型別的引數。

內建陣列

Y 語言為變數提供了一個內建的陣列型別。我們都知道 C 語言中的陣列用起來很痛苦。內建陣列變數像 Perl 6 語言一樣都帶有一個@的符文。下面是一個簡單的例子:

void foo(void) {
    _str @a;  // define a tracer-land array with the element type _str
    _push(@a, "world");  // append a new _str typed elem to @a
    _unshift(@a, "hello");  // prepend an elem to @a
    printf("array len: %d\n", _elems(@a));  // # of elems in array @a
    printf("a[0]: %s, a[1]: %s", @a[0], @a[1]);  // output the 1st and 2nd elems
}

這個例子定義了一個,元素為內建字串型別 (_str)的陣列。我們可以定義任意元素型別,如 intdouble、指標型別,甚至複合型別。

您也可以從內建陣列的頭部或尾部移除元素,例如:

int @a;
_push(@a, 32);
_push(@a, 64);
_push(@a, -7);
int v = _pop(@a);  // v gets -7
v = _shift(@a);  // v now gets 32

要迭代內建陣列,我們可以像這樣使用經典的 C 語言 for 迴圈語句:

_str @arr;
// ...
int len = _elems(@arr);  // cache the array len in a local var
for (int i = 0; i < len; i++) {
    printf("arr[%d]: %s\n", @arr[i]);
}

我們也可以透過使用者自定義函式的引數來傳遞內建陣列,例如:

void foo(int @a) {
    // ...
}

函式呼叫都是透過 引用 來傳遞內建陣列型別的引數。

要清空陣列中的所有元素並將長度重置為 0 ,我們可以使用 Y 語言 的字首運算子 _del ,例如:

_del @arr;

內建雜湊表

Y 語言也提供了一個內建雜湊表型別。和內建陣列一樣,內建雜湊變數也帶著一個符文,但是不同的是,它用的是 % (就像 Perl 6 )。要宣告一個帶有內建字串鍵和整數值的雜湊表,我們可以這樣寫:

int %ages{_str};

雜湊的鍵和值可以是任何資料型別。

要插入一個新的鍵值對時,我們可以這樣寫:

%ages{"Tom"} = 32;

要查詢一個已經存在的鍵的值,我們這樣寫:

int age = %ages{"Bob"};

但如果我們不確定一個鍵是否存在,我們應該先使用 Y 語言 的字首運算子 _exists 來做測試,像這樣:

if (_exists %ages{"Zoe"}) {
    int age = %ages{"Zoe"};
}

我們建議在不確定時,先測試鍵的存在性。這是因為

  1. 一些 Y 語言 的後端如 GDB Python 在鍵不存在時,可能會丟擲執行時異常。
  2. 一些其他後端像 Stap+(或 SystemTap)在這種情況下可能會默默返回整數 0 或浮點數值。

我們也可以透過使用者自定義函式的引數,來傳遞內建的雜湊表,例如:

void foo(int %a{_str}) {
    // ...
}

函式呼叫總是以 引用 的方式,傳遞內建雜湊表型別的引數。

要從一個雜湊表中刪除一個鍵,我們可以使用 Y 語言 的 _del 運算子,如:

_del %my_hash{my_key};

或者在不指定鍵的部分的情況下,清空整個雜湊表:

_del %my_hash;

為了遍歷內建雜湊表,我們可以使用特殊的 _foreach 迴圈語句,如:

_foreach %my_hash -> _str name, int age {
    printf("%s: %d\n", name, age);
}

Perl 6 使用者應該會覺得這個迴圈結構很熟悉。在這裡我們借鑑了它的語法。

未完待續

這就是我在第二集想要介紹的全部內容。我不得不在這裡暫停一下。從第三集開始,我們將繼續介紹 Y 語言 的更多特性和優勢。

關於作者

章亦春是開源 OpenResty® 專案創始人兼 OpenResty Inc. 公司 CEO 和創始人。

章亦春(Github ID: agentzh),生於中國江蘇,現定居美國灣區。他是中國早期開源技術和文化的倡導者和領軍人物,曾供職於多家國際知名的高科技企業,如 Cloudflare、雅虎、阿里巴巴, 是 “邊緣計算“、”動態追蹤 “和 “機器程式設計 “的先驅,擁有超過 22 年的程式設計及 16 年的開源經驗。作為擁有超過 4000 萬全球域名使用者的開源專案的領導者。他基於其 OpenResty® 開源專案打造的高科技企業 OpenResty Inc. 位於美國矽谷中心。其主打的兩個產品 OpenResty XRay(利用動態追蹤技術的非侵入式的故障剖析和排除工具)和 OpenResty Edge(最適合微服務和分散式流量的全能型閘道器軟體),廣受全球眾多上市及大型企業青睞。在 OpenResty 以外,章亦春為多個開源專案貢獻了累計超過百萬行程式碼,其中包括,Linux 核心、Nginx、LuaJITGDBSystemTapLLVM、Perl 等,並編寫過 60 多個開源軟體庫。

關注我們

如果您喜歡本文,歡迎關注我們 OpenResty Inc. 公司的部落格網站 。也歡迎掃碼關注我們的微信公眾號:

我們的微信公眾號

翻譯

我們提供了英文版原文和中譯版(本文)。我們也歡迎讀者提供其他語言的翻譯版本,只要是全文翻譯不帶省略,我們都將會考慮採用,非常感謝!