本文共 7.9k 字,预计阅读时间 33 分钟。
本文主要结合 IO Visor Project 的英文文档进行翻译,从零开始入门 ebpf,并使用 bcc 编写自定义的探针 (Probe,某些地方叫做 Agent)。
本文中会有一些推荐阅读的参考资料和一些英文原文文档。如果忽略任何这些链接,都不会对理解本文造成问题,但是他们可能对一些技术历史和技术架构的了解有所帮助(或许只是谈资)
0x00 ebpf 是什么
历史渊源
要说清楚这个还是要说点历史的。推荐阅读:eBPF 简史
BPF 的全称是 Berkeley Packet Filter,顾名思义,这是一个用于过滤(filter)网络报文(packet)的架构。
BPF 采用的报文过滤设计的全称是 CFG(Computation Flow Graph),顾名思义是将过滤器构筑于一套基于 if-else 的控制流(flow graph)之上
BPF 被引入 Linux 之后,除了一些小的性能方面的调整意外,很长一段时间都没有什么动静。直到 3.0 才首次迎来了比较大的革新:在一些特定硬件平台上,BPF 开始有了用于提速的 JIT(Just-In-Time) Compiler。
自 3.15 伊始,一个套源于 BPF 的全新设计开始逐渐进入人们的视野,并最终(3.17)被添置到了 kernel/bpf 下。这一全新设计最终被命名为了 extended BPF(eBPF):顾名思义,有全面扩充既有 BPF 功能之意;而相对应的,为了后向兼容,传统的 BPF 仍被保留了下来,并被重命名为 classical BPF(cBPF)。
相对于 cBPF,eBPF 带来的改变可谓是革命性的:一方面,它已经为内核追踪(Kernel Tracing)、应用性能调优/监控、流控(Traffic Control)等领域带来了激动人心的变革;另一方面,在接口的设计以及易用性上,eBPF 也有了较大的改进。
定位和定义
所以 ebpf 实际上是类 Unix 系统上数据链路层的一种原始接口,他可以提供原始链路层包的收发和过滤。
再说简单一点,他就是一个带有过滤功能的数据包收发接口,当然也是可以 dump 数据的。
另外,ebpf 只能读取系统调用的参数和返回值,而不能修改任何寄存器(用户态或者内核态),因此它本身并不具有拦截功能。这一限制实际是其内部 verifier.c
模块的一个部分,真正的目的是为了保证内核本身的运行安全。因此在编写 BPF C 程序时,要非常小心的处理指针操作,避免一切的未定义行为(第5课的实验中会强调);如果出现了未被 verifier.c
检测出的异常行为,也会在执行时被其他模块中断(如第13课的实验中的报错)。
0x01 安装和试用 bcc
好了,刚说完 ebpf 还有 bcc,但是这是本文最后一个概念了:
bcc 是一个为了方便的创建高效内核跟踪和操作程序的工具包,包括一些开箱即用的工具和示例。 它基于 eBPF 开发(需要 Linux 3.15 及更高版本)。 bcc 使用的大部分内容都需要 Linux 4.1 及更高版本。
bcc 使得 bpf 程序更容易被书写,bcc 使用 Python 和 Lua,虽然核心依旧是一部分 C 语言代码(BPF C 代码)。但是我们很快就可以体验了,这比手动安装 C 语言依赖、编译、插入内核要方便的多。
这里是 bcc 的安装文档,安装完就可以体验了。Debian 系编译安装可以参考这里,其中依赖包安装部分的 clang-format-3.8
需要改为 clang-format-6.0
。 运行前可能需要 pip/pip3 install bcc
。
有几点注意事项:
hello_world.py
在 bcc 根目录下example
内- 需要 root 权限运行,记得加 sudo
- 如果程序没有输出,请新开一个会话(ssh 或新终端)随便写一点命令
- 推荐 python2
这是一个典型输出:
1 | cyrus@debian:~/bcc$ cd examples/ |
我们看一下这个 hello world 的源码,非常简单:
1 | from bcc import BPF |
原来 python 中关机的语句只有一句 C 语言代码。这段代码实际上是 BPF C 代码。官方文档有以下两个部分:
- 英文原文文档:bcc Python Developer Tutorial 主要为 BPF C 和 bcc Python 的使用案例
- 英文原文文档:bcc Reference Guide 主要为 BPF C 和 bcc Python 的文档
除此之外还有这个英文原文文档:The bpftrace One-Liner Tutorial ,bpftrace 也是一个很棒的、高于 BPF C 语言层面的开发工具,他更多的是打印一些很棒的可视化信息。作为工具开发和对数据的挖掘和统计,我们还是以 bcc 和 BPF C 为主。如果有机会我会翻译一些 bpftrace 文档。
我的水平有限,这肯定不是最好的一篇中文 ebpf & bcc 文档,但是据我所知这是第一篇完全完整的中文版本(逃)。不过这里依旧致敬一下前辈:云栖社区、CSDN 等社区的一些文章。笔者之前使用过 DPDK,想要了解关于 ebpf 作为包过滤器在 xdp 的应用,推荐阅读这些文章。
这是在工作过程中我找到的另一篇不错的文章,推荐阅读。
0x02 Python 开发教程(译)
这是一个关于开发 bcc 工具和程序的 Python 接口教程。主要有两个部分:内核探测和流量探测(其中流量探测部分还没有编写)。代码片段取自库中 bcc 样例,请自行参考。
内核探测
内核探测部分包括 17 个课时(实际上每个都很简短)和 46 个知识点。
第1课 Hello World
我们在之前已经体验过 examples/hello_world.py
了,在别的会话中运行如 ls
等指令时,由于有新进程被创建,程序会打印出“Hello, World!”。如果失败了,可能需要重新去安装配置一下 bcc 环境,这里是安装文档。
源码:
1 | from bcc import BPF |
有6个知识点:
text='...'
这里定义了一个内联的、C 语言写的 BPF 程序。kprobe__sys_clone()
这是一个通过内核探针(kprobe)进行内核动态跟踪的快捷方式。如果一个 C 函数名开头为kprobe__
,则后面的部分实际为设备的内核函数名,这里是sys_clone()
。void *ctx
这里的ctx
实际上有一些参数,不过这里我们用不到,暂时转为void *
吧。bpf_trace_printk()
这是一个简单地内核设施,用于 printf() 到 trace_pipe(译者注:可以理解为 BPF C 代码中的printf()
)。它一般来快速调试一些东西,不过有一些限制:最多有三个参数,一个%s
,并且 trace_pipe 是全局共享的,所以会导致并发程序的输出冲突,因而BPF_PERF_OUTPUT()
是一个更棒的方案,我们后面会提到。return 0
这是一个必须的部分(为什么必须请参见 这个issue)。.trace_print()
一个 bcc 实例会通过这个读取 trace_pipe 并打印出来。
第2课 sys_sync()
尝试练习一下探测 sys_sync
吧,检测到 sync
时打印出“sys_sync() called”。hello_worpd.py 有一切你需要用的知识。你可以通过在另外的会话里运行 sync
来测试。
你还可以在程序开始时打印“Tracing sys_sync()… Ctrl-C to end.”,不过这只是只靠 Python 实现的而已。
(译者注:sync
为 UNIX 操作系统的标准系统调用,功能为将内核文件系统缓冲区的所有数据写入存储介质。)
第3课 hello_fields.py
本课代码在 examples/tracing/hello_fields.py 中,典型输出(需要在新会话运行命令)应该是:
1 | # ./examples/tracing/hello_fields.py |
代码:
1 | from bcc import BPF |
这和 hello_world.py 很接近,也是通过 sys_clone()
跟踪了一些新进程的创建,但是我们需要学一些新知识点:
prog =
这次我们通过变量声明了一个 C 程序源码,之后引用它。这对于需要通过命令行参数为 C 程序增加不同的指令很棒。hello()
现在我们声明了一个 C 语言函数,而不是使用kprobe__
开头的快捷方式。我们稍后调用这个函数。BPF 程序中的任何 C 函数都需要在一个探针上执行,因而我们必须将pt_reg* ctx
这样的 ctx 变量放在第一个参数。如果你需要声明一些不在探针上执行的辅助函数,则需要定义成static inline
以便编译器内联编译。有时候你可能需要添加_always_inline
函数属性。b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")
这里建立了一个内核探针,以便内核系统出现 clone 操作时执行 hello() 这个函数。你可以多次调用attch_kprobe()
,这样就可以用你的 C 语言函数跟踪多个内核函数。b.trace_fields()
这里从 trace_pipe 返回一个混合数据,这对于黑客测试很方便,但是实际工具开发中需要使用BPF_PERF_OUTPUT()
。
第4课 sync_timing.py
(译者注:这段话直接 Google 翻译的)还记得系统管理员在 reboot
之前在慢速控制台上键入三次 sync
的日子,以便完成第一次异步同步时间吗? 然后有人认为 sync;sync;sync
是聪明的,在一条线上运行它们,尽管打败了原来的目的,这已成为行业惯例! 然后 sync
变成了 synchronous
,所以更多的原因是愚蠢的。 无论如何。
下面的这个例子是,如果 1 秒之内多次使用了 sync
,它会告诉我们到底 do_sync
花了多少时间。也就是说如果使用了 sync;sync;sync
,后面两次 sync
会被打印出来。
他的代码在 examples/tracing/sync_timing.py:
1 | from __future__ import print_function |
要学的知识点:
bpf_ktime_get_ns()
以纳秒为单位返回当前时间。BPF_HASH(last)
创建一个名为 last 的 BPF hash 映射。我们使用了默认参数,所以它使用了默认的 u64 作为 key 和 value 的类型。(译者注:这里可以理解为一个储存数据用的全局变量,因为一些原因可能在 BPF 中只能使用 hash 映射这种形式作为全局变量,以便通信。)key = 0
我们只储存一个键值对,每次存在 key 为 0 的位置即可。所以 key 固定为 0。last.lookup(&key)
在 hash 映射中寻找一个 key 对应的 value。不存在会返回空。我们将 key 作为地址指针传入函数。last.delete(&key)
顾名思义,移除一个键值对。至于为什么要删除,参见这个内核 bug。last.update(&key, &ts)
顾名思义,传入两个参数,覆盖更新原有的键值对。ts
是时间戳。
第5课 sync_count.py
修改上一课的 sync_timing.py 程序,以存储所有内核中调用 sync
的计数(快速和慢速),并打印出来。 通过向现有的 hash 映射添加新的键值对,可以在 BPF 程序中记录此计数。
这是译者给出的一个参考代码。译者这里列举一些注意事项:
bpf_trace_printk
的输出实际是一个字符串,也就是说,有多组数据希望从 trace_pipe 传递到 python 时应该使用分隔符,并且在 python 中自行 split 取出。这一点我们会在下一课提到。- bpf 程序有自己的检查器(这可能是出于内核的安全考虑),所以关于指针的操作应该非常小心。
第6课 disksnoop.py
看一看 examples/tracing/disksnoop.py 的代码你会有一些新的发现,这是一个典型的输出:
1 | # ./disksnoop.py |
我们看一下代码片段:
1 | [...] |
要学的知识点:
REQ_WRITE
我们在 Python 中定义了一个常数,我们稍后会用到它。如果我们在 bpf 程序中直接使用它(而不重新定义),可能需要一些适当的#include
。trace_start(struct pt_regs *ctx, struct request *req)
这个函数烧毁会被附加到内核探针上。内核探针使用的参数是struct pt_regs *ctx
,用于寄存和 BPF 上下文;之后是被附加的函数的实际参数。我们把它附加到blk_start_request()
,他的第一个参数是struct request *
。start.update(&req, &ts)
我们把请求结构体的指针作为 hash 映射中的 key,这在探测中很常见。指向结构体的指针是很好的 key,因为这是唯一的:两个结构体不会具有相同的指针地址(只需要注意它被释放和重新使用的情况)。所以我们真正要做的就是,我们用我们自己的时间戳,把它与包含磁盘 I/O 描述的请求结构体对应起来,从而使得我们可以计时。提示:有两个用于储存时间戳的常用 key:指向结构体的指针,线程的 ID。req->__data_len
我们解引用struct request
的成员(译者注:实际可以理解成从指针对应的值中取出各个成员),我们可以在内核源代码中看到request
的成员定义。bcc 实际上将这些表达式重写为一系列的bpf_probe_read()
调用。有时候 bcc 可能无法处理十分复杂的解引用,这时候就需要直接调用bpf_probe_read()
。
这是一个非常有趣的程序,如果你能理解所有的代码,你就会理解许多重要的基础知识。 我们仍在使用 bpf_trace_printk()
来输出,所以我们接下来解决这个问题。
译者注:这个程序实现的实际是 Linux 的 Block 层 I/O 操作的输出,关于 struct request
的结构,可以在这里找到定义。
第7课 hello_perf_output.py
终于,我们可以不再使用 bpf_trace_printk()
而使用更推荐的 BPF_PERF_OUTPUT()
接口了。这也意味着我们不再继续简单地通过 trace_field()
来方便的获取 PID 和时间戳,我们需要调用一些函数直接获取他们。
这是一个在另一个会话运行时的典型输出:
1 | # ./hello_perf_output.py |
代码在 examples/tracing/hello_perf_output.py:
1 | from bcc import BPF |
译者注:译者对这部分代码中的部分变量换了名字,这是为了避免造成一些不必要的误解。
要学的知识点:
struct data_t
一个简单的 C 语言结构体,用于从内核态向用户态传输数据。BPF_PERF_OUTPUT(result)
表明内核传出的数据将会打印到 “result” 这个通道内(译者注:实际上这就是 bpf 对象的一个 key,可以通过bpf_object["result"]
的方式读取。)struct data_t data = {};
创建一个空的 data_t 结构体,之后在填充。bpf_get_current_pid_tgid()
返回以下内容:位于低 32 位的进程 ID(内核态中的 PID,用户态中实际为线程 ID),位于高 32 位的线程组 ID(用户态中实际为 PID)。我们通常将结果通过 u32 取出,直接丢弃最高的 32 位。我们优先选择了 PID(← 指内核态的)而不是 TGID(← 内核态线程组 ID),是因为多线程应用程序的 TGID 是相通的,因此需要 PID 来区分他们。通常这也是我们代码的用户所关心的。bpf_get_current_comm(&data.comm, sizeof(data.comm));
将当前参数名填充到指定位置。reault.perf_submit()
通过 perf 缓冲区环将结果提交到用户态。def print_event()
定义一个函数从result
流中读取 event。(译者注:这里的 cpu, data, size 是默认的传入内容,连接到流上的函数必须要有这些参数)。b["events"].event(data)
通过 Python 从result
中获取 event。b["events"].open_perf_buffer(print_event)
将print_event
函数连接在result
流上、while 1: b.perf_buffer_poll()
阻塞的循环获取结果。
这些未来的 bcc 版本中可能有所改进。 例如,Python 数据结构可以从 C 代码自动生成。
第8课 sync_perf_output.py
使用 BPF_PERF_OUTPUT
重新编写 sync_timing.py。
这是译者给出的一个参考代码。
第9课 bitehist.py
以下工具记录磁盘不同大小 I/O 情况的直方图。这是一个典型输出:
1 | # ./bitehist.py |
代码在 examples/tracing/bitehist.py:
1 | from __future__ import print_function |
我们首先回顾一下早期课程:
kprobe__
前缀表示后面的部分实际为设备的内核函数名,这是一个通过内核探针(kprobe)进行内核动态跟踪的快捷方式。struct pt_regs *ctx, struct request *req
是内核探针的参数。这里的ctx
是寄存器和 BPF 的上下文,req
是探针对应的内核函数blk_account_io_completion()
的第一个参数。req->__data_len
是从req
的结构体中解引用。
本节课要学的知识点有:
BPF_HISTOGRAM(hist)
定义一个直方图形式的 BPF 映射(译者注:区别于之前的 hash 映射),名为 hist。dist.increment()
第一个参数的运算结果作为直方图索引的递增量,默认为 1。自定义的递增量可以作为第二个参数提供。bpf_log2l()
返回参数以 2 为底的对数值。 这将作为我们直方图的索引,因此我们正在构建 2 的幂直方图。b["dist"].print_log2_hist("kbytes")
将直方图 dist 的索引打印为 2 的幂,列标题为 “kbytes”。从内核态传到用户态的唯一数据是这一系列索引项的总计数,从而提高了效率。(译者注:因为一个 2 的幂实际上对应多个索引,比如第 3 行包含了 2^2 至 2^3-1 的多个索引的计数之和。)
第10课 disklatency.py
编写一个对磁盘 I/O 进行计时的程序,并打印出延迟的直方图。 磁盘 I/O工具和时序可以在第 6 课的 disksnoop.py
程序中找到,直方图代码可以在第 9 课的 bitehist.py
中找到。
这是译者给出的一个参考代码。
第11课 vfsreadlat.py
这是一个将 Python 和 C 文件分离的例子。典型输出如下:
1 | # ./vfsreadlat.py 1 |
你可以在 examples/tracing/vfsreadlat.py 和 examples/tracing/vfsreadlat.c 看到源码。
要学的知识点有:
b = BPF(src_file = "vfsreadlat.c")
从一个独立的源文件中读取 BPF C 代码。b.attach_kretprobe(event="vfs_read", fn_name="do_return")
将 BPF C 函数do_return()
附加到内核函数vfs_read()
的返回结果上。这里是一个返回型内核探针(kretprobe):他探测的对象是内核函数的返回值或输出值,而非它的输入值。["dist"].clear()
清理直方图。
第12课 urandomread.py
在命令 dd if=/dev/urandom of=/dev/null bs=8k count=5
运行时探测:
1 | # ./urandomread.py |
emmm,这里意外地抓住了 smtp。代码在 examples/tracing/urandomread.py:
1 | from __future__ import print_function |
-
TRACEPOINT_PROBE(random, urandom_read)
探测内核断点random:urandom_read
。对于这些有稳定功能的 API,在条件允许时,我们不建议使用kprobes
。你可以使用perf list
命令来获取内核断点的清单。要想将 BPF 程序附加到内核断点需要 Linux 版本 >=4.7。译者注:
perf list
命令有一相关依赖,这里给出一个样例的安装命令,其中 4.15.0-43-generic 可以通过uname -a
查询,如果有必要请自行替换:1
2
3
4
5
6sudo apt install \
linux-tools-common \
linux-tools-4.15.0-43-generic \
linux-cloud-tools-4.15.0-43-generic \
linux-tools-generic \
linux-cloud-tools-generic -
args->got_bits
这里的args
是一个具有自动填充功能的参数,他会和断点的参数进行匹配,返回一系列断点相关的成员。上方的注释表明了这些格式可以在那里查到(译者注:查询时可能需要 root 权限)。1
2
3
4
5
6
7
8
9
10
11
12
13
14$ sudo cat /sys/kernel/debug/tracing/events/random/urandom_read/format
name: urandom_read
ID: 1122
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int got_bits; offset:8; size:4; signed:1;
field:int pool_left; offset:12; size:4; signed:1;
field:int input_left; offset:16; size:4; signed:1;
print fmt: "got_bits %d nonblocking_pool_entropy_left %d input_entropy_left %d", REC->got_bits, REC->pool_left, REC->input_left在这个例子中,我们打印了
got_bits
成员。
第13课 重构 disksnoop.py
将第 6 课中的 diaksnoop.py
转换成使用 block:block_rq_issue
和 block:block_rq_complete
断点形式的程序。
这是译者给出的一个参考代码。
这段代码的书写过程异常诡异,感谢还二进制大手子 plusls 的帮助。主要有这些问题:
-
这是一个玄学问题:在没有 warning 的情况下,程序运行时有一个
R3 type=ctx expected=fp
的报错,然而ctx
和fp
没有在任何地方出现过。经过研究他们是这里 的变量。在对一个 BPF hash 映射进行操作时,要求 key 和 value 都必须在栈上,参见这里。下面是一组正确和错误的示范:1
2
3
4
5
6
7
8
9
10// correct:
u32 pid = bpf_get_current_pid_tgid();
u64 tsp = bpf_ktime_get_ns();
time.update(&pid, &tsp);
int blocksize = args->bytes;
size.update(&pid, &blocksize);
// wrong:
time.update(&bpf_get_current_pid_tgid(), &tspbpf_ktime_get_ns()
size.update(&bpf_get_current_pid_tgid(), &args->bytes); -
在处理完这个之后,我们会发现有很多读写过程没有输出。这个相对简单:将
if (tsp0 != 0 && blocksize != 0)
增加 else 处理一下会发现很多 hash 映射的查表结果为空,因为我们按照上方代码中的方式获取的 tgid 实际上是用户态的 pid 、是内核态的进程组 id。我们查看block/block_rq_issue/format
等相关文件,将这个 id 换成args->dev
就可以很好地解决这个问题。注意这个类型是 u64。
第14课 strlen_count.py
这是一个用于探测用户态中函数调用的例子,我们检测了 strlen()
库函数以及使用次数的计数。典型输出如下:
1 | # ./strlen_count.py |
这些是探测调用这个库函数处理的字符串,以及他们调用处理次数。如 strlen()
在 LC_ALL
中被调用了 12 次。
代码在 examples/tracing/strlen_count.py:
1 | from __future__ import print_function |
要学的知识点:
PT_REGS_PARM1(ctx)
这是取回所探测的目标函数strlen()
的第一个参数,也就是处理的目标字符串。b.attach_uprobe(name="c", sym="strlen", fn_name="count")
将探针附加到 C library(如果这是主函数,需要使用绝对路径),探测的目标函数是strlen()
,探测到执行时调用 BPF C 程序中的count()
。
第15课 nodejs_http_server.py
该程序用于探测用户定义的静态跟踪(USDT)探针,该探针是内核断点的用户级版本。 典型输出如下:
1 | # ./nodejs_http_server.py 24728 |
相关代码在 examples/tracing/nodejs_http_server.py:
1 | from __future__ import print_function |
要学的知识点:
bpf_usdt_readarg(6, ctx, &addr)
从 USDT 探针中读取第六个参数到addr
。bpf_probe_read(&path, sizeof(path), (void *)addr)
将addr
指向path
变量。u = USDT(pid=int(pid))
针对给定的 PID 初始化 USDT。u.enable_probe(probe="http__server__request", fn_name="do_trace")
将我们编写的do_trace()
BPF C 代码附加到 Node.js 的 USDT 探针http__server__request
上。b = BPF(text=bpf_text, usdt_contexts=[u])
BPF 对象创建时需要传入我们的 USDt 对象u
。
译者注:这里调试时需要使用以下命令安装 dtrace:
1 | sudo apt install systemtap-sdt-dev |
随后启用 dtrace 选项编译 node:
1 | git clone --depth=1 https://github.com/nodejs/node |
第16课 task_switch.c
到这里你已经学完了全部新知识了,本课作为一个彩蛋加在了这里,它是一个旧版本教程的内容。你可以用它复习和抽象概括你之前学到的知识。以下是旧版本的叙述:
这是一个比 Hello World 更加复杂的探测:内核中每个任务切换时,这个程序会被调用,并在 BPF 映射中记录新旧 PID。
下面 C 程序包含了两个概念:
- 第一个概念是宏
BPF_TABLE
。这里定义了一个类型为 hash 的映射(可以理解为一个 dict),key 和 value 的类型为 key_t 和 u64(这是一个独立的计数器)。映射名为stats
,最多包含 1024 个条目。可以在表中使用lookup
,lookup_or_init
,update
和delete
操作条目。 - 第二个概念是呈递参数。这个参数在 BCC 的 Python 部分中被特别对待,以便从内核探针架构的上下文中获取数据,并呈递到 Python 中来访问。从位置 1 开始的参数应该与内核探针所需的参数相匹配(译者注:位置 0 为内核探针架构的上下文,即
ctx
,前面课程有说到)。这样就可以使得程序无缝访问任何函数参数。
1 | #include <uapi/linux/ptrace.h> |
用户态的组件(即 Python 部分)加载上面的文件,并将其附加到内核函数 finish_task_switch
上。BPF 对象的 []
运算符提供对程序中每个 BPf_TABLE 的访问,并允许对保存在内核中的值进行呈递和访问:就像使用任何一个 Python dict 对象一样,可以读取、更新和删除。
1 | from bcc import BPF |
这个例子已经被 merge 了,代码位于 examples/tracing/task_switch.py。
第17课 进阶学习
更深入的学习可以参考 Sasha Goldshtein 的 linux-tracing-workshop,其中包含更多实验。在 bcc/tools
目录也有很多可以学习的工具。
流量探测
译者注:这一部分原文还没有填坑,相关代码在 bcc/examples/networking
目录。如果之后有空我会将这里尽可能细致的讲解。
0x03 bcc 参考手册(译)
主要是一些偏向于手册的内容,如函数列表、成员、错误处理和环境变量等,我在近期数周内会在这里更新。
如果你有兴趣,也可以通过 fcs98#sina,com
或 WeChat Cyru1s
或在下方评论留言,我们可以一起完成。