bpf 跟踪功能
bpf 提供了非常强大的工具能够让我们在程序运行的时候收集需要的数据,它几乎可以访问 Linux 内核和应用程序的任何信息,同时对系统性能和延迟造成的开销最小,它能够实现类似 dtrace、SystemTap 的动态探针功能,不需要修改程序就能够收集数据。
本文中的 BPF 程序都是通过 BCC 来编写的,程序源码部分摘自《Linux内核观测技术BPF》这本书的配套 github 项目中。
探针
跟踪探针是探索程序,旨在传递程序执行时环境的相关信息。
内核探针
内核探针几乎可以在任何内核指令上设置动态标志或中断,并且系统损耗最小。当内核执行到这些标志时,附加到探针的代码将被执行,之后内核将恢复正常模式。
kprobes 探针
kprobes 探针在内核函数入口被调用,示例程序如下:
from bcc import BPF
bpf_source = """
#include <uapi/linux/ptrace.h>
int do_sys_execve(struct pt_regs *ctx) {
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_trace_printk("executing program: %s\\n", comm);
return 0;
}
"""
bpf = BPF(text=bpf_source)
execve_function = bpf.get_syscall_fnname("execve")
bpf.attach_kprobe(event=execve_function, fn_name="do_sys_execve")
bpf.trace_print()
执行示例如下:
[longyu@debian-10:17:12:19] kprobes $ sudo python example.py
[sudo] longyu 的密码:
guake-21900 [003] .... 19913.271490: 0: executing program: guake
guake-21901 [001] .... 19913.273462: 0: executing program: guake
bash-21902 [000] .... 19913.274109: 0: executing program: bash
bash-21903 [005] .... 19913.277194: 0: executing program: bash
bash-21906 [002] .... 19913.279022: 0: executing
kretprobes
kretprobes 探测点在内核函数返回的时候被调用,示例程序如下:
from bcc import BPF
bpf_source = """
#include <uapi/linux/ptrace.h>
int ret_sys_execve(struct pt_regs *ctx) {
int return_value;
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
return_value = PT_REGS_RC(ctx);
bpf_trace_printk("program: %s, return: %d\\n", comm, return_value);
return 0;
}
"""
bpf = BPF(text=bpf_source)
execve_function = bpf.get_syscall_fnname("execve")
bpf.attach_kretprobe(event=execve_function, fn_name="ret_sys_execve")
bpf.trace_print()
执行示例如下:
[longyu@debian-10:17:15:35] kretprobes $ sudo python example.py
bash-22471 [007] d... 20099.730870: 0: program: bash, return: 0
id-22472 [005] d... 20099.732045: 0: program: id, return: 0
utempter-22473 [004] d... 20099.732546: 0: program: utempter, return: 0
dircolors-22474 [005] d... 20099.733757: 0: program: dircolors, return: 0
dircolors-22475 [005] d... 20099.735221: 0: program: dircolors, return: 0
lua-22476 [005] d... 20099.736689: 0: program: lua, return: 0
tracepoints
tracepoints 是内核代码的静态标记,可用于将代码附加到运行的内核中,它要比动态探针更稳定。
查看 /sys/kernel/debug/tracing/events 目录下的内容可以获取系统中所有可用的跟踪点。
《Linux内核观测技术BPF》中对 bpf 自身 tracepoint 的观测脚本执行的时候会有如下报错信息:
open(/sys/kernel/debug/tracing/events/bpf/bpf_prog_load/id): No such file or directory
Traceback (most recent call last):
File "./example.py", line 14, in <module>
bpf.attach_tracepoint(tp = "bpf:bpf_prog_load", fn_name = "trace_bpf_prog_load")
File "/usr/lib/python2.7/dist-packages/bcc/__init__.py", line 797, in attach_tracepoint
(fn_name, tp))
Exception: Failed to attach BPF program trace_bpf_prog_load to tracepoint bpf:bpf_prog_load
看来是 5.0 内核的 bpf 并没有实现相关的静态探针,或者是其它问题,暂时跳过。
用户空间探针
与内核的探针类似,用户空间也有对应的探针,同样也有函数入口探针、函数返回探针、静态探针这几种类型。
uprobes 探针
uprobes 是用户空间函数入口探针。注意在运行 uprobes/example.py 程序前需要修改 example.py 中的程序路径为绝对路径!
一个示例 patch 内容如下:
diff --git a/code/chapter-4/uprobes/example.py b/code/chapter-4/uprobes/example.py
old mode 100644
new mode 100755
index 4f2c76f..c0a93de
--- a/code/chapter-4/uprobes/example.py
+++ b/code/chapter-4/uprobes/example.py
@@ -9,5 +9,5 @@ int trace_go_main(struct pt_regs *ctx) {
"""
bpf = BPF(text = bpf_source)
-bpf.attach_uprobe(name = "hello-bpf", sym = "main.main", fn_name = "trace_go_main")
+bpf.attach_uprobe(name = "/home/longyu/linux-observability-with-bpf/code/chapter-4/uprobes/hello-bpf", sym = "main.main", fn_name = "trace_go_main")
bpf.trace_print()
同时需要注意书中以一个 go 语言程序作为 demo,在编译 go 代码之前需要安装 go 的编译器。
直接访问 https://golang.org/dl/ 下载 release tar 包,然后解压并配置环境变量即可。
更详细的介绍请访问如下链接:
How to install go on debian 10
执行示例如下:
[longyu@debian-10:17:52:59] uprobes $ sudo python ./example.py
hello-bpf-25911 [005] .... 22341.336969: 0: New hello-bpf process running with PID: 25911
hello-bpf-25927 [001] .... 22354.651541: 0: New hello-bpf process running with PID: 25927
在 example.py 执行后,在其它终端执行 hello-pbf 程序,然后返回到 example.py 执行的终端,可以看到探针生效。
uretprobes
uretprobes 是用户态程序函数返回的探针,ebpf 程序能够通过此探针获取到函数的返回值。
原书中的示例将 uprobes 与 uretprobes 结合,在 uprobes 中计算时间并填充到一个映射中,然后在 uretprobes 中获取新的时间并计算消耗的时间并打印。
执行示例如下:
[longyu@debian-10:18:01:03] uretprobes $ sudo python example.py
hello-bpf-27290 [007] .... 22831.146477: 0: Function call duration: 21201
hello-bpf-27297 [005] .... 22832.079298: 0: Function call duration: 21484
hello-bpf-27304 [001] .... 22832.646157: 0: Function call duration: 22084
hello-bpf-27311 [005] .... 22833.132329: 0: Function call duration: 22265
同样需要在 example.py 执行后在另外一个终端运行 hello-bpf 程序,可以看到它打印出了函数执行的时间,基本稳定在 22832 左右。
我多次运行 hello-bpf 程序的时候遇到了如下问题:
[longyu@debian-10:18:01:22] uprobes $ ./hello-bpf
runtime: unexpected return pc for main.main called from 0x7fffffffe000
stack: frame={sp:0xc0000a6f38, fp:0xc0000a6f98} stack=[0xc0000a6000,0xc0000a7000)
000000c0000a6e38: 0000000000486ebd <fmt.glob..func1+45> 00000000004af4c0
000000c0000a6e48: 000000c0000a0000 000000c0000a6ec0
000000c0000a6e58: 0000000000486d52 <fmt.(*pp).doPrintln+178> 000000c0000a0000
000000c0000a6e68: 00000000004983e0 00000000004cf7c0
000000c0000a6e78: 0000000000000076 0000000000000000
000000c0000a6e88: 000000c00009e100 000000c00009e128
000000c0000a6e98: 000000c0000486c0 0000000000000000
000000c0000a6ea8: 0000000000551340 00000000004b24a0
000000c0000a6eb8: 000000c0000a6f78 000000c0000a6f28
000000c0000a6ec8: 0000000000480b78 <fmt.Fprintln+88> 000000c0000a0000
000000c0000a6ed8: 000000c0000a6f78 0000000000000001
000000c0000a6ee8: 0000000000000001 000000000040b0a8 <runtime.newobject+56>
000000c0000a6ef8: 0000000000000010 00000000004a2120
000000c0000a6f08: 0000000000000001 000000c00009a020
000000c0000a6f18: 000000c0000a0000 0000000000457e6d <errors.New+45>
000000c0000a6f28: 000000c0000a6f88 00000000004871e6 <main.main+118>
000000c0000a6f38: <00000000004d0cc0 000000c000094008
000000c0000a6f48: 000000c000048778 0000000000000001
000000c0000a6f58: 0000000000000001 0000000000000000
000000c0000a6f68: 0000000000000000 0000000000487028 <fmt.init+104>
000000c0000a6f78: 00000000004983e0 00000000004cf7c0
000000c0000a6f88: 000000c000048790 !00007fffffffe000
000000c0000a6f98: >000000c0000160c0 0000000000000000
000000c0000a6fa8: 000000c0000160c0 0000000000000000
000000c0000a6fb8: 0000000000000000 0000000000000000
000000c0000a6fc8: 000000c000000180 0000000000000000
000000c0000a6fd8: 00000000004510f1 <runtime.goexit+1> 0000000000000000
000000c0000a6fe8: 0000000000000000 0000000000000000
000000c0000a6ff8: 0000000000000000
fatal error: unknown caller pc
runtime stack:
runtime.throw(0x4ba83d, 0x11)
/usr/local/go/src/runtime/panic.go:617 +0x72
runtime.gentraceback(0xffffffffffffffff, 0xffffffffffffffff, 0x0, 0xc000000180, 0x0, 0x0, 0x7fffffff, 0x4bfee8, 0x7ffd7666e558, 0x0, ...)
/usr/local/go/src/runtime/traceback.go:275 +0x1cd1
runtime.copystack(0xc000000180, 0x1000, 0x7f5197eec001)
/usr/local/go/src/runtime/stack.go:881 +0x25b
runtime.newstack()
/usr/local/go/src/runtime/stack.go:1050 +0x2fd
runtime.morestack()
/usr/local/go/src/runtime/asm_amd64.s:429 +0x8f
goroutine 1 [copystack]:
runtime.(*mspan).nextFreeIndex(0x557e60, 0x411bda)
/usr/local/go/src/runtime/mbitmap.go:195 +0x17a fp=0xc0000a6b78 sp=0xc0000a6b70 pc=0x41086a
runtime.(*mcache).nextFree(0x7f519a15dd98, 0x7f519814bf05, 0x400, 0x7f5197f51100, 0x20300000000000)
/usr/local/go/src/runtime/malloc.go:779 +0x4d fp=0xc0000a6bb0 sp=0xc0000a6b78 pc=0x40a34d
runtime.mallocgc(0x10, 0x0, 0xc000048400, 0x41416e)
/usr/local/go/src/runtime/malloc.go:939 +0x76e fp=0xc0000a6c50 sp=0xc0000a6bb0 pc=0x40ac9e
runtime.growslice(0x498520, 0x0, 0x0, 0x0, 0xa, 0x8, 0x2000, 0x8)
/usr/local/go/src/runtime/slice.go:175 +0x151 fp=0xc0000a6cb8 sp=0xc0000a6c50 pc=0x43b6c1
fmt.(*buffer).WriteString(...)
/usr/local/go/src/fmt/print.go:83
fmt.(*fmt).padString(0xc0000a0040, 0x4b9768, 0xa)
/usr/local/go/src/fmt/format.go:110 +0xfa fp=0xc0000a6d40 sp=0xc0000a6cb8 pc=0x47e65a
fmt.(*fmt).fmtS(0xc0000a0040, 0x4b9768, 0xa)
/usr/local/go/src/fmt/format.go:347 +0x61 fp=0xc0000a6d78 sp=0xc0000a6d40 pc=0x47f3d1
fmt.(*pp).fmtString(0xc0000a0000, 0x4b9768, 0xa, 0xc000000076)
/usr/local/go/src/fmt/print.go:448 +0x132 fp=0xc0000a6dc8 sp=0xc0000a6d78 pc=0x481fb2
fmt.(*pp).printArg(0xc0000a0000, 0x4983e0, 0x4cf7c0, 0x76)
/usr/local/go/src/fmt/print.go:684 +0x880 fp=0xc0000a6e60 sp=0xc0000a6dc8 pc=0x4842d0
fmt.(*pp).doPrintln(0xc0000a0000, 0xc0000a6f78, 0x1, 0x1)
/usr/local/go/src/fmt/print.go:1159 +0xb2 fp=0xc0000a6ed0 sp=0xc0000a6e60 pc=0x486d52
fmt.Fprintln(0x4d0cc0, 0xc000094008, 0xc000048778, 0x1, 0x1, 0x0, 0x0, 0x487028)
/usr/local/go/src/fmt/print.go:265 +0x58 fp=0xc0000a6f38 sp=0xc0000a6ed0 pc=0x480b78
runtime: unexpected return pc for main.main called from 0x7fffffffe000
stack: frame={sp:0xc0000a6f38, fp:0xc0000a6f98} stack=[0xc0000a6000,0xc0000a7000)
000000c0000a6e38: 0000000000486ebd <fmt.glob..func1+45> 00000000004af4c0
000000c0000a6e48: 000000c0000a0000 000000c0000a6ec0
000000c0000a6e58: 0000000000486d52 <fmt.(*pp).doPrintln+178> 000000c0000a0000
000000c0000a6e68: 00000000004983e0 00000000004cf7c0
000000c0000a6e78: 0000000000000076 0000000000000000
000000c0000a6e88: 000000c00009e100 000000c00009e128
000000c0000a6e98: 000000c0000486c0 0000000000000000
000000c0000a6ea8: 0000000000551340 00000000004b24a0
000000c0000a6eb8: 000000c0000a6f78 000000c0000a6f28
000000c0000a6ec8: 0000000000480b78 <fmt.Fprintln+88> 000000c0000a0000
000000c0000a6ed8: 000000c0000a6f78 0000000000000001
000000c0000a6ee8: 0000000000000001 000000000040b0a8 <runtime.newobject+56>
000000c0000a6ef8: 0000000000000010 00000000004a2120
000000c0000a6f08: 0000000000000001 000000c00009a020
000000c0000a6f18: 000000c0000a0000 0000000000457e6d <errors.New+45>
000000c0000a6f28: 000000c0000a6f88 00000000004871e6 <main.main+118>
000000c0000a6f38: <00000000004d0cc0 000000c000094008
000000c0000a6f48: 000000c000048778 0000000000000001
000000c0000a6f58: 0000000000000001 0000000000000000
000000c0000a6f68: 0000000000000000 0000000000487028 <fmt.init+104>
000000c0000a6f78: 00000000004983e0 00000000004cf7c0
000000c0000a6f88: 000000c000048790 !00007fffffffe000
000000c0000a6f98: >000000c0000160c0 0000000000000000
000000c0000a6fa8: 000000c0000160c0 0000000000000000
000000c0000a6fb8: 0000000000000000 0000000000000000
000000c0000a6fc8: 000000c000000180 0000000000000000
000000c0000a6fd8: 00000000004510f1 <runtime.goexit+1> 0000000000000000
000000c0000a6fe8: 0000000000000000 0000000000000000
000000c0000a6ff8: 0000000000000000
fmt.Println(...)
/usr/local/go/src/fmt/print.go:275
main.main()
/home/longyu/linux-observability-with-bpf/code/chapter-4/uprobes/main.go:6 +0x76 fp=0xc0000a6f98 sp=0xc0000a6f38 pc=0x4871e6
看这个报错信息说明我们添加的 uretprobes 探针影响到了函数的正常返回,问题并不是必现的,可能 uretprobes 探针还是存在一些不稳定性!
USDT
USDT 是用户态程序静态定义的跟踪点,类似于内核中的 tracepoint,它需要在程序的源代码中添加代码。
示例 demo 源码如下:
#include <sys/sdt.h>
int main(int argc, char const *argv[]) {
DTRACE_PROBE("hello-usdt", "probe-main");
return 0;
}
直接编译,发现会报 sys/sdt.h 头文件不存在的问题。可以通过执行如下命令来解决:
sudo apt-get install systemtap-sdt-dev
编译通过后执行 example.py 脚本会报错,参考如下链接来解决:
getting bpf programs working with usdt probes probes working with usdt probes dtrace in linux
修改 example.py 后执行有如下信息:
[longyu@debian-10:18:36:53] usdt $ sudo python example.py
/virtual/main.c:7:1: warning: control reaches end of non-void function [-Wreturn-type]
}
^
1 warning generated.
同样在其它终端中执行 hello_usdt 程序,但是发现 example.py 完全没有输出,执行了三四次后仍旧没有任何输出。
参考下面这些链接中的描述搞了下,仍旧没有任何输出信息。
https://github.com/scylladb/seastar/issues/223
https://sourceware.org/gdb/current/onlinedocs/gdb/Static-Probe-Points.html
https://stackoverflow.com/questions/62641551/getting-bpf-programs-working-with-usdt-probes-dtrace-in-linux
http://www.brendangregg.com/ebpf.html
搞的过程中,我大致理解了 usdt 的工作原理,下面具体描述一下。
USDT 探针的工作原理
1. 在代码中添加探测点,使用 DTRACE_PROBEXX 这种宏来声明探测点
2. 编译器编译
编译器会将 DTRACE_PROBEXXX 这种宏替换为一个 nop 空指令,这个指令啥也不干,只负责占位,同时编译器会在程序的 .note.stapsdt 中添加必要的信息。
objdump hello_usdt 的 main 函数得到了如下信息:
0000000000001125 <main>:
1125: 55 push %rbp
1126: 48 89 e5 mov %rsp,%rbp
1129: 89 7d fc mov %edi,-0x4(%rbp)
112c: 48 89 75 f0 mov %rsi,-0x10(%rbp)
1130: 90 nop
1131: b8 00 00 00 00 mov $0x0,%eax
1136: 5d pop %rbp
1137: c3 retq
1138: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
113f: 00
偏移 1130 处的 nop 指令就是探测点被替换后生成的空指令。
.note.stapsdt 中的程序信息通过 readelf -n 来获取,示例如下:
[root@debian-10:08:50:59] usdt # readelf -n hello_usdt
Displaying notes found in: .note.ABI-tag
所有者 Data size Description
GNU 0x00000010 NT_GNU_ABI_TAG (ABI version tag)
OS: Linux, ABI: 3.2.0
Displaying notes found in: .note.gnu.build-id
所有者 Data size Description
GNU 0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring)
Build ID: 74f76f6be327b6d300f2d9459393553b8f87a87b
Displaying notes found in: .note.stapsdt
所有者 Data size Description
stapsdt 0x00000033 NT_STAPSDT (SystemTap probe descriptors)
Provider: "hello-usdt"
Name: "probe-main"
Location: 0x0000000000001130, Base: 0x0000000000002004, Semaphore: 0x0000000000000000
Arguments:
其中 Provider 标识程序名,Name 标识探测点的名称,Location 选项记录了探测点的位置,其它的内容暂时用不到,不进行说明。
3. 绑定探测函数并使能探测功能
这一步首先使用 bcc 编写 py 脚本,在脚本中就用到了可执行程序的路径与探测点的名称这两个字段,在运行待探测的程序前需要运行这个程序以绑定探测函数并使能探测点。
4. 运行待探测的程序,内核会在程序运行到探测点的时候修改程序指令,将 nop 指令替换为 int3 指令
这一步可以使用 gdb 程序来观察,当我使用 gdb 运行的时候,我发现一执行 start 然后就异常了,估计应该是内核修改了进程的虚拟内存空间带来的副作用。
为此我将 hello_usdt.c 修改为如下内容:
#include <sys/sdt.h>
#include <unistd.h>
#include <stdio.h>
int add(int a, int b) {
DTRACE_PROBE(hello-usdt, probe-main);
return a + b;
}
int main(int argc, char const *argv[]) {
while (1) {
printf("result is %d\n", add(1, 2));
sleep(1);
}
return 0;
}
重复上面的步骤,这次我在 hello_usdt 程序执行后使用 gdb -p 连接上去,然后反汇编 add 函数查看。
(gdb) disas add
Dump of assembler code for function add:
0x0000556570231145 <+0>: push %rbp
0x0000556570231146 <+1>: mov %rsp,%rbp
0x0000556570231149 <+4>: mov %edi,-0x4(%rbp)
0x000055657023114c <+7>: mov %esi,-0x8(%rbp)
0x000055657023114f <+10>: int3
0x0000556570231150 <+11>: mov -0x4(%rbp),%edx
0x0000556570231153 <+14>: mov -0x8(%rbp),%eax
0x0000556570231156 <+17>: add %edx,%eax
0x0000556570231158 <+19>: pop %rbp
0x0000556570231159 <+20>: retq
End of assembler dump.
可以看到 add 函数偏移量 10 处变为了一个 int3 指令,这就是被内核修改过的 nop 指令的位置。
等了一会发现竟然有打印了,相关的信息记录到下面:
[root@debian-10:09:18:16] usdt # ./hello_usdt
result is 3
result is 3
result is 3
result is 3
result is 3
result is 3
result is 3
result is 3
result is 3
result is 3
result is 3
hello_usdt-27832 [006] .... 21724.335110: 0: New hello_usdt process running with PID: 27832 hello_usdt-27832 [006] .... 21726.335364: 0: New hello_usdt process running with PID: 27832 hello_usdt-27832 [006] .... 21727.335511: 0: New hello_usdt process running with PID: 27832 hello_usdt-27832 [006] .... 21728.335644: 0: New hello_usdt process running with PID: 27832 hello_usdt-27832 [006] .... 21871.118820: 0: New hello_usdt process running with PID: 27832 hello_usdt-28132 [005] .... 21907.429008: 0: New hello_usdt process running with PID: 28132 hello_usdt-28132 [005] .... 21909.429279: 0: New hello_usdt process running with PID: 28132 hello_usdt-28132 [006] .... 21912.429602: 0: New hello_usdt process running with PID: 28132 hello_usdt-28132 [006] .... 21914.429775: 0: New hello_usdt process running with PID: 28132 hello_usdt-28132 [006] .... 21915.429858: 0: New hello_usdt process running with PID: 28132 hello_usdt-28132 [006] .... 21917.430100: 0: New
这里由于我将 example.py 后台运行了,所以输出与 hello_usdt 程序混合到一起了。
有上面的输出信息,仔细想想应该是 USDT 的探测点的记录要达到某个阀值才会打印!
为了验证这点,我还原代码,使用原来的程序(解决上面提到过的 example.py 运行失败的问题),进行如下测试:
[root@debian-10:09:27:09] usdt # python example.py &
[2] 29031
[root@debian-10:09:27:17] usdt # /virtual/main.c:7:1: warning: control reaches end of non-void function [-Wreturn-type]
}
^
1 warning generated.
[root@debian-10:09:27:19] usdt # while true; do ./hello_usdt; done
hello_usdt-29034 [006] .... 22423.199045: 0: New hello_usdt process running with PID: 29034 hello_usdt-29035 [006] .... 22423.199604: 0: New hello_usdt process running with PID: 29035 hello_usdt-29037 [006] .... 22423.200529: 0: New hello_usdt process running with PID: 29037
hello_usdt-29042 [006] .... 22423.202862: 0: New hello_usdt process running with PID: 29042 hello_usdt-29043 [006] .... 22423.203310: 0: New hello_usdt process running with PID: 29043 hello_usdt-29044 [006] .... 22423.203711: 0: New hello_usdt process running with PID: 29044 hello_usdt-29045 [006] .... 22423.204155: 0: New
直接整了个循环让它不断的执行,果然有输出了!开心!!!
5. 程序执行 int3 指令再次进入内核,执行相关的探测函数并返回结果
最后的最后,附上修改后的代码:
example.py:
from bcc import BPF, USDT
bpf_source = """
#include <uapi/linux/ptrace.h>
int trace_binary_exec(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
bpf_trace_printk("New hello_usdt process running with PID: %d", pid);
}
"""
usdt = USDT(path = "./hello_usdt")
usdt.enable_probe(probe = "probe-main", fn_name = "trace_binary_exec")
bpf = BPF(text = bpf_source, usdt_contexts = [usdt])
bpf.trace_print()
hello_usdt.c:
#include <sys/sdt.h>
int main(int argc, char const *argv[]) {
DTRACE_PROBE(hello-usdt, probe-main);
return 0;
}
DTRACE_PROBE 宏的两个参数都去掉了双引号,这个双引号也被算作是字符的一部分,这是个坑。
总结
本文描述了我对 bpf 追踪功能的学习过程,重点描述了 USDT 这个用户态程序静态跟踪点的学习过程。
其中也遇到了一些问题,这些问题也没有在网上找到相关的资料,只能自己搞一搞,最终上手了 USDT,这一点值得肯定。
不过我也想说,我在做事的时候需要多点耐心!也许多执行一次就有输出了呢?也许再尝试一次就成功了呢?实在不能成功那就写篇博客总结一下,指不定写的过程中又有新的发现呢!
为什么不能写学习 xxx 失败的主题呢?

988

被折叠的 条评论
为什么被折叠?



