本文是系列文章“Ylang:适用于 eBPF、Stap+、GDB 等的通用语言”的第三集。还可以参阅第一集第二集第四集

Y 语言的语法(接上文)

字符串

Ylang 支持 C 语言的字符串字面量语法,即双引号括起来的字符串。

但是需注意每个 Ylang 程序中的两个内存空间。默认情况下,字符串字面量作为 Ylang 内置字符串存在于跟踪器空间中。而当我们尝试获取这些字符串字面量的地址时,Ylang 将自动在追踪对象的内存空间(即目标进程)中找到确切的字面量字符串,并返回一个在追踪对象空间中的地址。例如:

const char *s = "hello, world\n";

在后台,Ylang 会尽力扫描目标进程的 .rodata 节,并返回匹配字符串的虚拟内存地址(只有第一个匹配项有效)。如果没有匹配项,Ylang 编译器将出现编译时错误。通常 .rodata 节的数据由 OpenResty XRay 的数据库包索引,这样 Ylang 编译器就不需要自己每次分析目标可执行文件了。

内置的正则表达式支持

Ylang 中,支持原生的 Perl 兼容正则表达式(或 regexes)。许多 Perl 正则表达式语法的标准特性都得到支持。Ylang 使用 OpenResty 正则表达式优化编译器为用户的正则表达式生成高效的代码。我们专有的自动机算法保证了与输入字符串长度成线性匹配时间,并且无论输入字符串的内容和大小,内存使用量始终保持恒定。

下面是一个示例:

_probe _oneshot {
    _str a = "hello, world";
    if (a !~~ rx/^([a-z]+), ([a-z]+)$/) {
        _error("not matched");
    }
    _print("0: ", $0, ", 1: ", $1, ", 2: ", $2, "\n");
}

运行这个 Ylang 程序时,我们会得到以下输出:

$ run-y test.y
Start tracing...
0: hello, world, 1: hello, 2: world

我们使用特殊变量 $0 来捕获与整个正则表达式匹配的子字符串,$1 $2 用于捕获子匹配组。

OpenResty 正则表达式编译器可以构建一个最小的确定有限自动机(DFA),其形式如下:

Min DFA for a Regex

完整控制流支持

Ylang 支持所有的 C 控制流语句,如 forwhiledo whileifelseswitch/casebreakcontinue 等。甚至 C 的 goto 语句在 Ylang 的所有后端(Stap+、eBPF、ODB 和 GDB)中都可以运行。除此之外还支持递归函数调用。下面是一个来自 LuaJIT 源代码树的 C 代码片段,也是一个有效的 Ylang 代码。

restart:
    lname = debug_varname(pt, proto_bcpos(pt, ip), slot);
    if (lname != NULL) { @name[0] = lname; return "local"; }
    while (--ip > proto_bc(pt)) {
        BCIns ins = *ip;
        BCOp op = bc_op(ins);
        BCReg ra = bc_a(ins);
        if (bcmode_a(op) == BCMbase) {
            if (slot >= ra && (op != BC_KNIL || slot <= bc_d(ins)))
                return NULL;
        } else if (bcmode_a(op) == BCMdst && ra == slot) {
            switch (bc_op(ins)) {
                case BC_MOV:
                    if (ra == slot) { slot = bc_d(ins); goto restart; }
                    break;
                default:
                    return NULL;
            }
        }

Ylang 中的所有循环都是有限的,包括通用的 goto 语句。每个探测处理程序只允许运行有限数量的 Ylang 语句,不过这个限制是可配置的。此外,用户可调的阈值限制了递归函数调用深度。Ylang 编译器确保 Ylang 程序能快速终止,并使用有限的堆栈大小。实质上,我们拥有一个非常高效的运行时沙盒,用于 Y 编译器生成的工具。

值得注意的是,eBPF 具有最小的循环结构,其中循环必须在编译时展开。开源的 eBPF C 语言禁止一般的循环,特别是后向跳转。这不仅使目标应用程序中代码重用变得非常困难,而且重写经过验证的重要 C 代码控制流还非常容易出错。在 动态追踪 工具本身排除程序故障是很有挑战性的,标准的 eBPF 验证器在预估执行的 eBPF 指令总数方面也表现不佳。例如,一个大的 switch 语句可能会超过其 100 万条指令的限制,尽管实际上只有几条指令用于单个 case 语句的运行。OpenResty XRayeBPF 实现没有这些限制,而且即使 Ylang 程序本身有错误,也仍能确保所有的 Ylang 程序在生产系统中始终安全运行。

DTrace 的 D 语言没有任何循环结构,DTrace 用户必须自己展开循环。

尽管开源的 SystemTap 具有非常灵活的控制流语句,但它仍然缺少 goto 语句。而 OpenResty XRay 的 Stap+ 工具链就没有这种限制。

浮点数支持

Ylang 的所有后端都支持浮点数。floatdouble 这两种 C 数据类型在追踪者空间和被追踪者空间都可以使用。

下面是一个用于在追踪者空间进行浮点数运算的示例:

double a = 3.1234512345123451234;
double b = 1.8123451234512345123;

_probe _oneshot(void) {
  printf("a + b: %.15f\n", a + b);
  printf("b + a: %.15f\n", b + a);
  printf("a - b: %.15f\n", a - b);
  printf("b - a: %.15f\n", b - a);
}

我们用 run-y 工具来运行它:

$ run-y test.y
Start tracing...
a + b: 4.935796357963580
b + a: 4.935796357963580
a - b: 1.311106111061111
b - a: -1.311106111061111

对于被追踪者空间,我们也可以从目标进程的内存中读取浮点数。假设目标 C 程序有一个如下所示的结构类型的变量:

typedef struct foo {
    double d;
    float f;
} foo;

foo obj = { 3.14, -0.01 };

int main(void) {
    return 0;
}

它既有 double 类型的字段,也有一个 float 类型的字段。我们用以下的 Ylang 代码片段来读取这些字段:

_target static foo obj;

_probe main() {
    printf(".d: %f\n", obj.d);
    printf(".f: %f\n", obj.f);
}

这个 Ylang 程序的输出如下:

$ run-y -c ./a.out test.y
Start tracing...
.d: 3.140000
.f: -0.010000

这里的 ./a.out 是从上面的目标 C 代码编译出来的可执行文件。

与开源工具链的比较

大多数基于内核的开源工具链都不支持浮点数,除了 Solaris 上的 DTraceSystemTap 是。而 SystemTap 中的浮点数语法也非常繁琐,用 tapset 函数来处理涉及浮点数的所有内容(如 fp_ltfp_to_longfp_add)特别麻烦。

清晰的调试符号方式

现代优化编译器可以生成调试符号(或调试信息),让在不牺牲运行时性能的情况下调试二进制程序成为可能。它本质是调试器在冷冰的二进制世界中的导航地图。这些调试符号让我们可以在 Ylang 程序中直接通过名称引用任何数据类型、字段名、函数和全局/静态变量。Ylang 将这些名称映射到为 Stap+、eBPFGDB 等生成的分析器中的数字。1

调试符号:无运行期系统开销

C/C++ 编译器通常支持 -g(或类似的选项,如 -g3-ggdb),它会在特殊的 ELF 文件节(如 .debug_info.debug_line)中生成调试符号。调试符号可能以 DWARF 格式存储。有时编译器可能会使用其他调试数据格式,如 CTFBTF。当操作系统加载可执行文件时,这些调试符号不会映射到内存中,因此不会产生运行时开销。这些调试节还可以被剥离成单独的调试文件,并且在生产系统中可能不存在。例如,基于RPM的系统通常将独立的调试符号收集到专用的 *-debuginfo RPM 软件包中,而基于 APT 的系统则提供专用的 *-dbgsym*-dbg DEB 软件包。

集中的软件包数据库

我们的爬虫不断从主流 Linux 发行版中获取所有的公共二进制软件包,比如 Ubuntu、Debian、CentOS、Rocky、RHEL、Fedora、Oracle、OpenSUSE、Amazon、Alpine 等,以及许多热门开源软件的软件包仓库(如 MySQL 和 PHP),并补充给 OpenResty XRay 的软件包数据库。此外,如果用户的系统具有带有嵌入或单独调试符号的自定义可执行二进制文件,OpenResty XRay 将自动收集调试符号。然后,它在软件包数据库的特定于租户的数据库中索引这些数据。出于安全和隐私考虑,不同的租户无法看到其他人的私有调试符号。由于集中的软件包数据库不断增长,用户不需要在每台拥有相同程序二进制文件的机器上安装调试符号。只需要为 OpenResty XRay 查看特定可执行二进制文件的调试符号一次即可。尽管当前系统中没有可用的调试符号,它也可以自动将相同的二进制文件映射到已经索引的正确版本的调试符号。

软件包数据库还会处理调试符号数据,并为 Ylang 编译器构建高效的索引。处理复杂的调试数据格式(如 DWARF)非常昂贵,因此最好只进行一次格式解析。它还会调用我们针对 Linux 内核的模糊测试(fuzz testing)系统,以确保新的内核软件包(包括用户的自定义内核)与我们的动态追踪工具链没有问题。

软件包数据库非常庞大!截至本文撰写时,它已经占用了数百 TB 的空间,并且在不断增长。因此,OpenResty XRay 的本地版本仍然需要通过(加密的)互联网连接访问我们的集中只读数据库包以获取公共软件包。但是,对于本地版本,软件包数据库的租户部分驻留在用户的计算机上。

截至本文撰写时,OpenResty XRay 仅支持 DWARF 格式。未来我们计划将添加对 CTFBTF 的支持。

模糊匹配调试符号

有时人们为了减小内存,故意在构建或打包过程中剥离程序二进制文件,比如在编译程序时不添加调试符号2,或者找不到调试符号包3。在这种情况下,我们仍然可以通过模糊匹配现有的调试符号在 OpenResty XRay 中自动构建大部分调试符号,用于类似但不完全相同的可执行二进制文件。这需要先进的机器学习和逆向工程技术。尽管还没有在 OpenResty XRay 中应用,但我们已经在这方面做了很多工作。对于用户的自定义程序,在开源世界中没有“相似”的二进制文件的情况下,唯一的方法是使用 -g 编译器选项重新编译这些二进制文件,使其可调试。

未完待续

以上就是我想在第三集中介绍的内容,我会在第四集继续介绍 Ylang 的更多功能和优点。

关于作者

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

我们的微信公众号

翻译

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


  1. 由于 Ylang 编译器自己处理调试符号,所以生成的工具不依赖于运行时的调试信息解释,这让最终的工具运行得更快。具有内置 DWARF 支持的后端(如 Stap+ 和 GDB),完全不使用其 DWARF 功能。 ↩︎

  2. 例如,用户可能在编译 C/C++ 程序时没有使用 -g 编译器选项。 ↩︎

  3. 某些 Linux 发行版不提供调试信息包,如 Arch Linux 和 Slackware。 ↩︎