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. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:
翻译
我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!