Ylang:適用於 eBPF、Stap+、GDB 等框架的通用語言(第二集,全四集)
這篇文章是“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 軟體包資料庫的幫助下,嘗試自動解析它。
很多開源的動態追蹤框架如 SystemTap、GDB、DTrace 等,必須透過骯髒、血腥的侵入,來定義這樣的複合追蹤者空間(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 就缺乏函式返回探針的內建支援(或其術語稱為斷點),使用者必須自己在目標函式的每個返回位置,手動設定斷點。而 eBPF、SystemTap、Bpftrace 等依賴核心的 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 的統計資料型別非常相似。其他追蹤框架如 DTrace 和 Bpftrace 也提供類似的資料型別。聚合提供了一種非常高效的記憶體和 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 也可以將這樣的文字圖自動渲染成漂亮的網頁圖表,如下所示:
要清除聚合中記錄的全部資料,我們可以用 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
)的陣列。我們可以定義任意元素型別,如 int
、double
、指標型別,甚至複合型別。
您也可以從內建陣列的頭部或尾部移除元素,例如:
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"};
}
我們建議在不確定時,先測試鍵的存在性。這是因為
- 一些 Y 語言 的後端如 GDB Python 在鍵不存在時,可能會丟擲執行時異常。
- 一些其他後端像 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、LuaJIT、GDB、SystemTap、LLVM、Perl 等,並編寫過 60 多個開源軟體庫。
關注我們
如果您喜歡本文,歡迎關注我們 OpenResty Inc. 公司的部落格網站 。也歡迎掃碼關注我們的微信公眾號:
翻譯
我們提供了英文版原文和中譯版(本文)。我們也歡迎讀者提供其他語言的翻譯版本,只要是全文翻譯不帶省略,我們都將會考慮採用,非常感謝!