这篇文章是“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. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文)。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!