BCC和libbpf的转换

本文讲述如何将基于BCC的BPF应用转换为libbpf + BPF CO-RE。BPF CO-RE可以参见上一篇博文

为什么是libbpf和BPF CO-RE?

历史上,当需要开发一个BPF应用时可以选择BCC 框架,在实现各种用于Tracepoints的BPF程序时需要将BPF程序加载到内核中。BCC提供了内置的Clang编译器,可以在运行时编译BPF代码,并将其定制为符合特定主机内核的程序。这是在不断变化的内核内部下开发可维护的BPF应用程序的唯一方法。在BPF的可移植性和CO-RE一文中详细介绍了为什么会这样,以及为什么BCC是之前唯一的可行方式,此外还解释了为什么 libbpf是目前比较好的选择。去年,Libbpf的功能和复杂性得到了重大提升,消除了与BCC之间的很多差异(特别是对Tracepoints应用来说),并增加了很多BCC不支持的新的且强大的特性(如全局变量和BPF skeletons)。

诚然,BCC会竭尽全力简化BPF开发人员的工作,但有时在获取便利性的同时也增加了问题定位和修复的困难度。用户必须记住其命名规范以及自动生成的用于Tracepoints的结构体,且必须依赖这些代码的重写来读取内核数据和获取kprobe参数。当使用BPF map时,需要编写一个半面向对象的C代码,这与内核中发生的情况并不完全匹配。除此之外,BCC使得用户在用户空间编写了大量样板代码,且需要手动配置最琐碎的部分。

如上所述,BCC依赖运行时编译,且本身嵌入了庞大的LLVM/Clang库,由于这些原因,BCC与理想的使用有一定差距:

  • 编译时的高资源利用率(内存和CPU),在繁忙的服务器上时有可能干扰主流程。
  • 依赖内核头文件包,不得不在每台目标主机上进行安装。即使这样,如果需要某些没有通过公共头文件暴露的内核内容时,需要将类型定义拷贝黏贴到BPF代码中,通过这种方式达成目的。
  • 即使是很小的编译时错误也只能在运行时被检测到,之后不得不重新编译并重启用户层的应用;这大大影响了开发的迭代时间(并增加了挫败感…)

Libbpf + BPF CO-RE (Compile Once – Run Everywhere) 选择了一个不同的方式,其思想在于将BPF程序视为一个普通的用户空间的程序:仅需要将其编译成一些小的二进制,然后不用经过修改就可以部署到目的主机上。libbpf扮演了BPF程序的加载器,负责配置工作(重定位,加载和校验BPF程序,创建BPF maps,附加到BPF钩子上等),开发者仅需要关注BPF程序的正确性和性能即可。这种方式使得开销降到了最低,消除了大量依赖,提升了整体开发者的开发体验。

在API和代码约定方面,libbpf坚持”最少意外”的哲学,即大部分内容都需要明确地阐述:不会隐含任何头文件,也不会重写代码。仅使用简单的C代码和适当的辅助宏即可消除大部分单调的环节。 此外,用户编写的是需要执行的内容,BPF应用程序的结构是一对一的,最终由内核验证并执行。

本指南用于简单快速地将BCC转换为libbpf+BPF CO-RE。本文解释了多种预配置步骤,并概述了常见的模式,以及可能会碰到的不同点,困难和陷阱。

一开始将BCC转换为普通的BPF CO-RE时,可能会感到不适和困惑,但很快就会掌握它,并在下次遇到编译或验证问题时欣赏libbpf的明确性和直接性。

此外,注意BPF CO-RE用到的很多Clang特性都比较新,需要用到Clang 10或更新的版本

可以参照官方文档升级Clang:

  1. git clone https://github.com/llvm/llvm-project.git
  2. Build LLVM and Clang:
    1. cd llvm-project
    2. mkdir build (in-tree build is not supported)
    3. cd build
    4. cmake -DLLVM_ENABLE_PROJECTS=clang -G "Unix Makefiles" ../llvm
    5. make

注意:在2.3步执行cmake时,可能会因为Host GCC version must be at least 5.1,这样的错误,需要升级GCC,升级之后删除build再重新编译即可。但有时即便GCC升级成功,且清除build中的缓存,再次编译时还是会出现上述错误,可以手动指定GCC路径来解决该问题:

 CC=$HOME/toolchains/bin/gcc cmake -DLLVM_ENABLE_PROJECTS=clang -G "Unix Makefiles" ../llvm

另外就是在执行make命令时会执行lib库的编译和链接,在链接过程中会占用大量内存,建议在执行该命令时打开(或扩大)系统的swap功能,防止内存不足导致系统出问题。

配置用户空间

生成必要的内容

构建基于libbpf的BPF应用需要使用BPF CO-RE包含的几个步骤:

  • 生成带所有内核类型的头文件vmlinux.h
  • 使用Clang(版本10或更新版本)将BPF程序的源代码编译为.o对象文件
  • 从编译好的BPF对象文件中生成BPF skeleton 头文件 (BPF skeleton 头文件内容来自上一步生成的.o文件,可以参考libbpf-tools的Makefile文件,可以看到 skeleton 头文件其实是通过bpftool gen命令生成的)
  • 在用户空间代码中包含生成的BPF skeleton 头文件
  • 最后,编译用户空间代码,这样会嵌入BPF对象代码,后续就不用发布单独的文件

具体步骤依赖用户指定的配置和构建系统,此处不一一列出。一种方式是参考BCC’s libbpf-tools,它给出了一个通用的Makefile文件,可以通过该文件来检查环境配置。

当编译BPF代码并生成BPF skeleton后,需要在用户空间代码中包含libbpf和skeleton头文件:

#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "path/to/your/skeleton.skel.h"

Locked内存的限制

BPF的BPF maps以及其他内容使用了locked类型的内存。默认的限制非常低,因此,除非增加该值,否则有可能连一个很小的BPF程序都无法加载。BCC会无条件地将限制设置为无限大,但libbpf不会自动进行设置。

生产环境中可能会有更好的方式来设置locked内存的限制。但为了快速实验或在没有更好的办法时,可以通过setrlimit(2)系统调用进行设置(在程序开始前调用)。

    #include <sys/resource.h>

    rlimit rlim = {
        .rlim_cur = 512UL << 20, /* 512 MBs */
        .rlim_max = 512UL << 20, /* 512 MBs */
    };

    err = setrlimit(RLIMIT_MEMLOCK, &rlim);
    if (err)
        /* handle error */

Libbpf 日志

如果程序运行不正常,最好的方式是检查libbpf的日志输出。libbpf会以多种级别输出大量有用的日志。默认会输出error级别的日志。建议安装一个自定义的日志回调,这样就可以配置日志的输出级别:

int print_libbpf_log(enum libbpf_print_level lvl, const char *fmt, va_list args) {
    if (!FLAGS_bpf_libbpf_debug && lvl >= LIBBPF_DEBUG)
        return 0;
    return vfprintf(stderr, fmt, args);
}

/* ... */

libbpf_set_print(print_libbpf_log); /* set custom log handler */

BPF skeleton 和 BPF app 生命周期

对BPF skeleton(以及libbpf API)的详细介绍和使用超出了本文档的范畴,内核selftests以及BCC提供的libbpf-tools 例子可以帮助熟悉这部分内容。查看runqslower 示例,它是一个使用skeleton的简单却真实的工具。

尽管如此,了解主要的libbpf概念和每个BPF应用经过的阶段是很有用的。BPF应用包含一组BPF程序(合作或完全独立),以及在所有的BPF程序间共享的BPF maps和全局变量(允许操作共同的数据)。BPF 也可以在用户空间(我们将用户空间中的程序称为”控制app”)中访问maps和全局变量,允许控制app获取或设置必要的额外数据。BPF应用通常会经过如下阶段:

  • 打开阶段:BPF对象文件的解析:发现但尚未创建的BPF maps,BPF程序和全局变量。在BPF app打开后,可以在所有的表项创建并加载前进行任何额外的调整(设置BPF类型;预设值全局变量的初始值等);
  • 加载阶段:创建BPF maps并解决了符号重定位之后,BPF程序会被加载到内核进行校验。此时,BPF程序所有的部分都是有效且存在于内核中的,但此时的BPF并没有被执行。在加载阶段之后,可以配置BPF map状态的初始值,此时不会导致BPF程序代码竞争性地执行;
  • 附加阶段:此阶段中,BPF程序会附加到各种BPF钩子上(如Tracepoints,kprobes,cgroup钩子,网络报文处理流水线等)。此时,BPF会开始执行有用的工作,并读取/更新BPF maps和全局变量;
  • 清理阶段:分离并从内核卸载BPFBPF程序。销毁BPF maps,并释放所有的BPF使用的资源。

生成的BPF skeleton 使用如下函数触发相应的阶段:

  • <name>__open() – 创建并打开 BPF 应用(例如的runqslowerrunqslower_bpf__open()函数);
  • <name>__load() – 初始化,加载和校验BPF 应用部分;
  • <name>__attach() – 附加所有可以自动附加的BPF程序 (可选,可以直接使用libbpf API作更多控制);
  • <name>__destroy() – 分离所有的 BPF 程序并使用其使用的所有资源.

BPF 代码转换

本章节会检查常用的转换流,并概述BCC和libbpf/BPF CO-RE之间存在的典型的不匹配。通过本章节,希望可以使你的BPF代码能够同时兼容BCC和BPF CO-RE。

检测BCC与libbpf模式

在需要同时支持BCC和libbpf模式的场景下,需要检测BPF程序代码能够编译为哪种模式。最简单的方式是依赖BCC中的宏BCC_SEC

#ifdef BCC_SEC
#define __BCC__
#endif

之后,在整个BPF代码中,可以执行以下操作:

#ifdef __BCC__
/* BCC-specific code */
#else
/* libbpf-specific code */
#endif

这样就可以拥有通用的BPF源代码,并且只有必要的逻辑代码段才是BCC或libbpf特定的。

头文件包含

使用 libbpf/BPF CO-RE时,不需要包含内核头文件(如#include <linux/whatever.h>),仅需要包含一个vmlinux.h和少量libbpf辅助功能的头文件:

#ifdef __BCC__
/* linux headers needed for BCC only */
#else /* __BCC__ */
#include "vmlinux.h"   /* all kernel types */
#include <bpf/bpf_helpers.h>  /* most used helpers: SEC, __always_inline, etc */
#include <bpf/bpf_core_read.h>  /* for BPF CO-RE helpers */
#include <bpf/bpf_tracing.h>    /* for getting kprobe arguments */
#endif /* __BCC__ */

vmlinux.h可能不包含某些有用的内核#define定义的常量,此时需要重新声明这些变量。但bpf_helpers.h中提供了大部分常用的变量。

字段访问

BCC会默默地重写你的BPF代码,并将诸如tsk-> parent-> pid之类的字段访问转换为一系列的bpf_probe_read()调用。Libbpf/BPF CO-RE没有此项功能,但bpf_core_read.h提供了一系列普通C代码编写的辅助函数来完成类似的工作。上述的tsk->parent->pid会变成BPF_CORE_READ(tsk, parent, pid)。从Linux 5.5开始使用tp_btffentry/fexit BPF程序类型,使用的也是C语法。但对于老版本的内核以及其他BPF程序类型(如Tracepoints和kprobe),最好将其转换为BPF_CORE_READ

BPF_CORE_READ宏也可以工作在BCC模式下,因此为了避免在#ifdef __BCC__/#else/#endif中重复使用,可以将所有字段的读取转换为BPF_CORE_READ,这样就可以同时给BCC和libbpf模式使用。使用BCC时,需要确保包含 bpf_core_read.h头文件。

BPF maps

BCC 和libbpf对BPF maps的声明是不同的,但转换方式很直接,下面是一些例子:

/* Array */
#ifdef __BCC__
BPF_ARRAY(my_array_map, struct my_value, 128);
#else
struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 128);
    __type(key, u32);
    __type(value, struct my_value);
} my_array_map SEC(".maps");
#endif

/* Hashmap */
#ifdef __BCC__
BPF_HASH(my_hash_map, u32, struct my_value);
#else
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);
    __type(value, struct my_value);
} my_hash_map SEC(".maps")
#endif

/* Per-CPU array */
#ifdef __BCC__
BPF_PERCPU_ARRAY(heap, struct my_value, 1);
#else
struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, struct my_value);
} heap SEC(".maps");
#endif

请注意BCC中maps的默认大小,通常为10240。使用libbpf时必须明确指定大小。

PERF_EVENT_ARRAY, STACK_TRACE和其他特殊的maps(DEVMAP, CPUMAP, etc) 尚不支持键/值类型的BTF类型,因此需要直接指定key_size/value_size:

/* Perf event array (for use with perf_buffer API) */
#ifdef __BCC__
BPF_PERF_OUTPUT(events);
#else
struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");
#endif

访问BPF代码中的BPF maps

BCC使用伪C++语言处理maps,在幕后将其重写为实际的BPF辅助调用,通常使用如下模式:

some_map.operation(some, args)

将其重写为如下格式:

bpf_map_operation_elem(&some_map, some, args);

下面是一些例子:

#ifdef __BCC__
    struct event *data = heap.lookup(&zero);
#else
    struct event *data = bpf_map_lookup_elem(&heap, &zero);
#endif

#ifdef __BCC__
    my_hash_map.update(&id, my_val);
#else
    bpf_map_update_elem(&my_hash_map, &id, &my_val, 0 /* flags */);
#endif

#ifdef __BCC__
    events.perf_submit(args, data, data_len);
#else
    bpf_perf_event_output(args, &events, BPF_F_CURRENT_CPU, data, data_len);
#endif

BPF程序

所有BPF程序提供的功能都需要通过SEC()(来自bpf_helpers.h)宏来自定义section名称,如:

#if !defined(__BCC__)
SEC("tracepoint/sched/sched_process_exec")
#endif
int tracepoint__sched__sched_process_exec(
#ifdef __BCC__
    struct tracepoint__sched__sched_process_exec *args
#else
    struct trace_event_raw_sched_process_exec *args
#endif
) {
/* ... */
}

这只是一个约定,但如果遵循libbpf的section名称,会有更好的开发体验。期望的名称可以参见此处(原文中给出的代码行可能不准,参见section_defs的定义即可),通常的用法为:

  • tp/<category>/<name> 用于Tracepoints;
  • kprobe/<func_name> 用于kprobe ,kretprobe/<func_name> 用于kretprobe;
  • raw_tp/<name> 用于原始Tracepoint;
  • cgroup_skb/ingress, cgroup_skb/egress,以及整个cgroup/<subtype> 程序家族。

Tracepoints

从上面的例子中可以看到,Tracepoint上下文的类型名称略有不同。BCC允许Tracepoint使用tracepoint__<category>__<name>命名模式。BCC会在编译时自动生成相应的类型。libbpf没有此功能,但幸运的是,内核已经提供了所有Tracepoint数据的类似类型,一般命名为trace_event_raw_<name>,但有时内核中的少量Tracepoints会重用常用的类型,因此如果上述模式不起作用,则需要在内核源码(或 vmlinux.h)中查找具体的类型名称。如必须使用struct trace_event_raw_sched_process_template来代替struct trace_event_raw_sched_process_exit

在大多数情况下,用于访问tracepoint 上下文数据的代码完全相同,但特殊的可变长度字符串字段除外。对于此类情况,其转换也很直接:data_loc_<some_field>变为__data_loc_<some_field>(注意双下划线)即可。

Kprobes

BCC有很多种方式声明kprobe。实践中,这类BPF程序会接收一个指向struct pt_regs的指针作为上下文参数,但BCC允许像使用内核函数参数一样给BPF程序传参。使用libbpf的BPF_KPROBE宏可以获得类似的效果,目前其存在于内核selftest的bpf_trace_helpers.h头文件中,但后续应该会作为libbpf的一部分(已经是了):

#ifdef __BCC__
int kprobe__acct_collect(struct pt_regs *ctx, long exit_code, int group_dead)
#else
SEC("kprobe/acct_collect")
int BPF_KPROBE(kprobe__acct_collect, long exit_code, int group_dead)
#endif
{
    /* BPF code accessing exit_code and group_dead here */
}

对于有返回值的kprobe,也有对应的宏BPF_KRETPROBE

注意:在4.17 内核中,Syscall 函数发生了重命名。从4.17 版本开始,用于Syscall krpobe调用的sys_kill对应当前的__x64_sys_kill(在x64系统上,不同的架构具有不同的前缀)。在附加一个kprobe/kretprobe时应该注意这一点。但如果可能的话,尽可能遵循tracepoints。

如果要开发一个新的,带tracepoint/kprobe/kretprobe的BPF程序,查看新的raw_tp/fentry/fexit 探针,它们提供了更好的性能和易用性(内核5.5开始提供此功能)。

在BCC中处理编译时的#if

在BCC模式中大量使用了预处理#ifdef 和 #if 条件。大部分是因为支持不同的内核版本或启用/禁用可选择的逻辑(依赖应用配置)。此外,BCC允许在用户空间侧提供自定义的#define,在BPF代码编译期间的运行时阶段进行替换。通常用于自定义各种参数。

不能使用libbpf + BPF CO-RE做类似的事情(通过编译时(compile-time)逻辑),原因是BPF程序遵循一次编译就可以在所有可能的内核以及应用配置上运行。

为了处理不同的内核版本,BPF CO-RE支持两种补充机制:Kconfig externsstruct “flavors”(在上一篇博客中有涉及)。通过声明外部变量,BPF代码可以知道处理的内核版本:

#define KERNEL_VERSION(a, b, c) (((a) << 16) + ((b) << 8) + (c))

extern int LINUX_KERNEL_VERSION __kconfig;

if (LINUX_KERNEL_VERSION < KERNEL_VERSION(5, 2, 0)) {
  /* deal with older kernels */
} else {
  /* 5.2 or newer */
}

类似地,可以通过从Kconfig(位于内核的.config文件中)中抽取类似CONFIG_xxx的变量来获取内核版本:

extern int CONFIG_HZ __kconfig;

/* now you can use CONFIG_HZ in calculations */

通常,如果重命名了一个字段,或将其移入一个子结构体中时,可以通过检查目标内核是否存在该字段来判断是否发生了这种情况。可以通过bpf_core_field_exists(<field>)实现,如果返回1,则表示目标字段位于目标内核中;返回0则表示不存在内核中。配合struct flavors,可以处理内核结构布局的发生重大变动的情况。下面是一个简短的例子,展示了如何适应 struct kernfs_iattrs在不同内核版本中的变化:

/* struct kernfs_iattrs will come from vmlinux.h */

struct kernfs_iattrs___old {
    struct iattr ia_iattr;
};

if (bpf_core_field_exists(root_kernfs->iattr->ia_mtime)) {
    data->cgroup_root_mtime = BPF_CORE_READ(root_kernfs, iattr, ia_mtime.tv_nsec);
} else {
    struct kernfs_iattrs___old *root_iattr = (void *)BPF_CORE_READ(root_kernfs, iattr);
    data->cgroup_root_mtime = BPF_CORE_READ(root_iattr, ia_iattr.ia_mtime.tv_nsec);
}

应用配置

BPF CO-RE的办法是使用全局变量自定义程序的行为。全局变量允许用户空间app在BPF程序加载和校验前预配置必要的参数和标志。全局变量可以是可变的或恒定的。常量(只读)最常用于指定一个BPF程序的一次性配置(在程序加载和校验前)。可变的量在BPF程序加载并运行后,可用于BPF程序与其用户空间副本之间的双向数据交换。

在BPF代码侧,可以使用一个const volatile全局变量(当用于可变的量时,只需丢弃const volatile修饰符)声明只读的全局变量。

const volatile struct {
    bool feature_enabled;
    int pid_to_filter;
} my_cfg = {};

有如下几点需要重点关注:

  • 必须指定const volatile来防止不合时宜的编译器优化(编译器可能并且会错误地采用零值并将其内联到代码中);
  • 如果定义了一个可变的(非const)量时,确保不会被标记为static:非静态全局变量最好与编译器配合。这种情况下通常不需要volatile
  • 变量需要被初始化,否则libbpf会拒绝加载BPF应用。初始值可以为0或其他任意值。这类值作为变量的默认值,除非在控制应用程序中覆盖。

使用BPF代码中的全局变量很简单:

if (my_cfg.feature_enabled) {
    /* … */
}

if (my_cfg.pid_to_filter && pid == my_cfg.pid_to_filter) {
    /* … */
}

全局变量提供了更好的用户体验,并避免了BPF map查询造成的开销。此外,对于不变的量,它们的值是对BPF验证器来说是透明的(众所周知的),并在程序验证期间将其视为常量。这种方式可以允许BPF校验器精确且高效地消除无用代码分支。

控制app可以使用BPF skeleton方便地提供这类变量:

struct <name> *skel = <name>__open();
if (!skel)
    /* handle errors */

skel->rodata->my_cfg.feature_enabled = true;
skel->rodata->my_cfg.pid_to_filter = 123;

if (<name>__load(skel))
    /* handle errors */

只读变量可以在BPF skeleton加载前在用户空间进行设置和修改。一旦加载了BPF程序,则无法在用户空间进行设置和修改。这保证BPF校验器在校验期间将这类变量视为常数,以便更好地移除无效代码。而非常量则可以在BPF skeleton加载之后的整个生命周期中(从BPF和用户空间)进行修改,这些变量可以用于交换可变的配置,状态等等。

常见的问题

在运行BPF程序时可能会遇到各种问题。有时只是一个误解,有时是因为BCC和libbpf实现上的差异导致的。下面给出了一些典型的场景,可以帮助更好地进行BCC到BPF CO-RE的转换。

全局变量

BPF全局变量看起来就像一个用户空间的变量:它们可以在表达式中使用,也可以更新(非const表达式),甚至可以使用它们的地址并传递到辅助函数中。但这是在BPF代码侧有效。在用户空间侧,只能通过BPF skeletob进行读取和更新。

  • skel->rodata 用于只读变量;
  • skel->bss 用于初始值为0的可变量;
  • skel->data 用于初始值非0的可变量。

可以在用户空间进行读取/更新,这些更新会立即反映到BPF侧。但在用户空间侧,这些变量并不是全局的,它们只是BPF skeleton的rodatabss、或data的成员,在skeleton 加载期间进行了初始化。因此意味着在BPF代码和用户空间代码中声明完全相同的全局变量将视为完全独立的变量,在任何情况下都不会出现交集。

循环展开

除非目标内核为5.3以上的版本,否则BPF代码中的所有循环都必须使用#pragma unroll标识,强制Clang进行循环展开,并消除所有可能的循环控制流:

#pragma unroll
for (i = 0; i < 10; i++) { ... }

如果没有循环展开,或循环没有在固定迭代之后结束,那么会返回一个”back-edge from insn X to Y”的校验器错误,即BPF校验器检测到了一个无限循环(或无法在有限次数的迭代之后结束的循环)。

辅助子程序

如果使用静态辅助函数,则必须将其标记为static __always_inline(由于当前libbpf的处理限制):

static __always_inline unsigned long
probe_read_lim(void *dst, void *src, unsigned long len, unsigned long max)
{
    ...
}

从5.5内核开始支持非内联的全局函数,但它们具有与静态函数不同的语义和校验限制,这种情况下,最好也使用内核标记!

bpf_printk 调试

BPF程序没有常规调试器可以用于设置断点,检查变量和BPF maps,以及代码的单步调试等。使用这类工具通常无法确定BPF代码的问题所在。

这种情况下,使用日志输出是最好的选择。使用bpf_printk(fmt, args...)打印输出额外的信息来理解发生的事情。该函数接受printf类的格式,最大支持3个参数。它的使用非常简单,但开销也比较大,不适合用于生产环境,因此仅适用于临时调试:

char comm[16];
u64 ts = bpf_ktime_get_ns();
u32 pid = bpf_get_current_pid_tgid();

bpf_get_current_comm(&comm, sizeof(comm));
bpf_printk("ts: %lu, comm: %s, pid: %d\n", ts, comm, pid);

日志信息可以从一个特殊的/sys/kernel/debug/tracing/trace_pipe文件中读取:

$ sudo cat /sys/kernel/debug/tracing/trace_pipe
...
      [...] ts: 342697952554659, comm: runqslower, pid: 378
      [...] ts: 342697952587289, comm: kworker/3:0, pid: 320
...

版权声明:本文为charlieroro原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/charlieroro/p/14244276.html