来自 《解决Linux内核问题实用技巧之 - Crash工具结合/dev/mem任意修改内存》
Linux内核程序员几乎每天都在和各种问题互相对峙:
- 内核崩溃了,需要排查原因。
- 系统参数不合适,需要更改,却没有接口。
- 改一个变量或一条if语句,就要重新编译内核。
- 想稍微调整下逻辑却没有源码,没法编译。
- …
解决每一类问题都需要消耗大量的时间,特别是重新编译内核这种事情。于是,每一个Linux内核程序员或多或少都会掌握一些Hack技巧,以节省时间提高工作效率。
然而,自己Hack内核非常容易出错,稍不留意就会伤及无辜(panic,踩内存…),然后你会陷入没完没了的细节,比如查找页表就够折腾。
俗话说工欲善其事,必先利其器,临渊羡鱼,不如退而结网。
但是如果你使用现成的工具,就会发现有时候工具很难扩展。自己需要的边缘小众功能往往并不提供,你依然需要自己动手但却又无从下手。
怎么办?为何不把二者结合呢?
本文将通过几个简单的小例子,描述如何综合systemtap,crash & gdb,/dev/mem,内核模块等技术排查以及解决现实中的Linux问题。
关于前置知识
本文不想花太多笔墨在前置知识上,本文默认读者已经了解systemtap,crash & gdb等工具的基本用法。作为Linux内核开发者,这些工具的熟练使用是必须的。
如果需要这些知识,自行百度或者Google(有条件的话),会得到更好的答案。其中每一个细节都可以单独写一篇文章甚至一本书。
但还是要说一点关于 /dev/mem 的话题。
/dev/mem 几乎总是被宣称为作为整个物理内存映像可以被mmap到进程地址空间,很多人确实将/dev/mem设备文件mmap到了自己的程序,然而却几乎一无所得。这不是程序员的错,毕竟作为一个平坦的内存地址空间,/dev/mem的内容看起来没有任何结构,一般DIY的程序根本就无力解析它。
/dev/mem 是个宝藏,它暴露了整个内存,但是只有你拥有强大的分析能力时,它才是宝藏,否则它只是一块平坦的空间,充满着0或1。所有的内核实时数据均在 /dev/mem 中,找到它们才有意义,但找到它们并不容易。
crash & gdb工具会把这件事情做得很好。本文后面将侧重于crash工具,gdb与此类似。
crash不光可以用来分析调试已经死掉的Linux尸体的vmcore内存映像,还可以用来分析调试活着的Linux Live内存映像,比如/dev/mem和/proc/kcore。同样都是内存映像,调试活着的内存映像显得更加有趣些。本文的例子将无一例外地描述这个方面的操作步骤和细节。
现在让我们开始。
使/dev/mem可写
小贴士:
这个步骤非常重要!建议始终作为hack /dev/mem的第一步!
这个例子是第一步,也是继续下面所有例子的前提。
首先,我们执行crash命令,调试/dev/mem内存映像:
[root@localhost ~]# crash /usr/lib/debug/usr/lib/modules/3.10.0-15.327.x86_64/vmlinux /dev/mem
大多数情况下,当我们尝试使用crash工具的wr命令写一个变量或者内存地址的时候,会收获一个报错提示:
crash> wr jiffies 123456
wr: cannot write to /proc/kcore
这是因为我们运行的Linux内核大多数情况下都开启了以下的编译选项:
CONFIG_STRICT_DEVMEM=y
这意味着,当我们尝试写 /dev/mem 的时候,会受到内核函数 devmem_is_allowed 的约束。所以,为了我们使用crash wr命令修改内存成为可能,我们必须要绕开这一约束,即:
- 让 devmem_is_allowed 函数恒返回1。
这一点通过systemtap很容易做到:
[root@localhost mod]# stap -g -e 'probe kernel.function("devmem_is_allowed").return { $return = 1 }'
在上述stap命令保持的情况下,退出crash并再次运行,此时我们便将可以完全读写 /dev/mem 了,如果说依然发生内存不可写的情况,那便是受到了页表项的约束,这个我们后面会谈。
我们并不想让那个stap命令一直运行在那里,我们不希望通过crash写内存这个操作依赖一个不能退出的stap命令,所以第一步,我们将直接修改 devmem_is_allowed 函数本身!
我们先反汇编它:
crash> dis -s devmem_is_allowed
FILE: arch/x86/mm/init.c
LINE: 583
578 * contains bios code and data regions used by X and dosemu and similar apps.
579 * Access has to be given to non-kernel-ram areas as well, these contain the PCI
580 * mmio resources as well as potential bios/acpi data regions.
581 */
582 int devmem_is_allowed(unsigned long pagenr)
* 583 {
584 if (pagenr < 256)
585 return 1;
586 if (iomem_is_exclusive(pagenr << PAGE_SHIFT))
587 return 0;
588 if (!page_is_ram(pagenr))
589 return 1;
590 return 0;
591 }
crash>
非常简单的逻辑,我想我们可以很快完成该函数的二进制修改。
让我们看一下它的汇编码,并且注意到下图红色框里的细节:
我们只要将 ja xxx 处的指令改成nop序列即可绕开这个跳转,即修改 0xffffffff8105e649 地址处的2个字节的值:
crash> wr -16 0xffffffff8105e649 0x9090
当然,比这个更直接的方法是直接重写这个函数,仅仅执行两个指令, mov $0x1,%eax 和 retq 。但是很遗憾,使用crash命令完成这个修改难度极大,我们仔细缕一下:
无论先替换nop还是先替换ret,均会破坏栈帧,造成返回地址错误从而panic:
除非同时原子替换二者(这在crash工具中几乎不可能)。更安全的替换方案是在crash外部去替换,比如写一个内核模块。先将crash查询到的地址记录下来:
随后编写模块,修改两个地址的值:
#include <linux/module.h>
static int __init hook_init(void)
{
char *to_nop = 0xffffffff8105e635;
char *to_ret = 0xffffffff8105e642;
to_nop[0] = 0x90;// 替换push为nop
to_ret[0] = 0xc3;// 替换mov为ret
// 不是真的加载。
return -1;
}
static void __exit hook_exit(void)
{
}
module_init(hook_init);
module_exit(hook_exit);
MODULE_LICENSE("GPL");
模块加载命令执行后,我们再次crash反汇编devmem_is_allowed,看看效果:
代码还是很简洁的,最终也成功了,但是挺迂回的,没有第一种方法修改ja指令更简单。
OK,接着我实际选择的 “修改ja指令为两个nop” 继续讲。现在让我们杀掉stap命令,并且重新打开crash,再次看 devmem_is_allowed 函数的反汇编:
很明显,条件跳转已经被改成了nop序列,至此,我们已经解除了对stap的依赖。没有stap的情况下,我们可以试试看修改一些无关紧要的东西:
crash> rd panic_on_oops
ffffffff81977890: 0000000000000001 ........
crash> wr panic_on_oops 0
crash> rd panic_on_oops
ffffffff81977890: 0000000000000000 ........
crash>
现在,我们可以使用crash来修改内存了。当然,如果你照着上面的步骤一步一步挨着做也没有成功,比如在写内存时收获了下面的错误:
crash> wr -16 0xffffffffa8c6fad4 0x9090
wr: write error: kernel virtual address: ffffffffa8c6fad4 type: "write memory"
crash>
不要着急,后面我们会专门分析这种情况下怎么应对。
现在,让我们继续更多的例子。
修改TCP初始拥塞窗口
很多搞TCP调优的同行曾经吐槽, “为什么不能修改系统的TCP初始拥塞窗口啊?!”
目前Linux的TCP实现中初始拥塞窗口时10个mss,该值是Google内网经验和全球经验折中的结果。
该值不能修改的原因之一我觉得是为了保证TCP的公平性。如果这个值能被随意配置修改,那岂不是把该值配置越来对自己越有利吗?这会破坏公平性。用户态的Quic就有这样的问题。
修改TCP初始拥塞窗口的方法很多,比如将该值导出成sysctl配置并重新编译内核,比如用iproute2配置携带 init_cwnd 参数的的路有项(同样有最大值限制)等等,但是都不直接也并不简单,我们要做的只是将下面的宏改成别的值即可:
/* TCP initial congestion window as per draft-hkchu-tcpm-initcwnd-01 */
#define TCP_INIT_CWND 10
然而宏并非变量,宏是在编译期就被替换的。
为了确认这宏定义值,我们编写一个简单的packetdrill脚本:
// test.pkt
// Establish a connection and send 20 MSS.
0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
0.000 bind(3, ..., ...) = 0
0.000 listen(3, 1) = 0
0.000 < S 0:0(0) win 32792 <mss 1000,sackOK,nop,nop,nop,wscale 7>
0.0 > S. 0:0(0) ack 1 <...>
0.0 < . 1:1(0) ack 1 win 1024
0.0 accept(3, ..., ...) = 4
0.0 %{ print tcpi_snd_cwnd }%
0.0 write(4, ..., 20000) = 20000
5.0 < . 1:1(0) ack 1 win 257 <sack 1001:3001,nop,nop>
运行它:
[root@localhost sack]# pdrill ./test.pkt --tolerance_usecs=10000
10
OK,很明显是10个mss。现在让我们用crash来修改它。
TCP连接初始化拥塞窗口的函数是 tcp_init_cwnd,我们用crash的dis命令看看它是怎么实现的:
改法很简单,我们看 0xffffffff815790e7 处的内容:
crash> rd 0xffffffff815790e7
ffffffff815790e7: e083480000000aba .....H..
crash>
将 0000000a 改成 00000005,这意味着初始拥塞窗口变成了5个mss:
crash> wr 0xffffffff815790e7 0xe0834800000005ba
crash>
我们用packetdrill脚本确认这个修改:
[root@localhost sack]# pdrill ./test.pkt --tolerance_usecs=10000
5
通过利用crash工具,修改TCP初始拥塞窗口非常简单。
修改TCP Time wait时间
很多人遭遇过TCP Time wait过多的问题,一个主动断开的连接要维持60秒的Time wait状态(Linux系统),这在现代高速网络环境下已经不再必要。我们想把这个值调小,但遗憾的是,这个值在Linux内核中同样是是以宏定义存在的,无法调整。
和修改TCP初始拥塞窗口方法一致,不同的是 tcp_time_wait 函数的复杂度要远高于 tcp_init_cwnd 函数,不过大同小异,TCP_TIMEWAIT_LEN 和 TCP_INIT_CWND 在同一个地方被定义:
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT
* state, about 60 seconds */
在HZ被定义为1000的情况下,只需要注意即时数60*1000,即0xea60即可:
注意到该值,我们需要将其改为我们需要的更小的值。
对于复杂的函数,在我们使用crash dis命令时,可以配合-l -s等参数将源代码和汇编指令做对应关系,更有效率地定位到我们需要修改的地方。
修改页表项之后…
每一个运行的Linux内核都可能来自不同的编译选项,从而导致了crash命令运行时的不同表现行为。
比如,即便是用stap hook住了devmem_is_allowed的返回值,让它恒为1,也依然无法修改内存:
crash> wr -16 0xffffffffa8c6fad4 0x9090
wr: write error: kernel virtual address: ffffffffa8c6fad4 type: "write memory"
这是为什么呢?
在现代操作系统中,地址空间的内存操作全部针对虚拟地址进行,而决定该虚拟内存对应的物理内存是否可写是由页表项决定的。
所以,首先我们要确认页表项的权限,这在crash中用vtop即可:
crash>
crash> vtop 0xffffffffa8c6fad4
wr: current context no longer exists -- restoring "crash" context:
PID: 3262
COMMAND: "crash"
TASK: ffff9e6bfdbacf10 [THREAD_INFO: ffff9e6bf67e4000]
CPU: 2
STATE: TASK_RUNNING (ACTIVE)
VIRTUAL PHYSICAL
ffffffffa8c6fad4 3b86fad4
PML4 DIRECTORY: ffffffffa980e000
PAGE DIRECTORY: 3c412067
PUD: 3c412ff0 => 3c413063
PMD: 3c413a30 => 3b8000e1
PAGE: 3b800000 (2MB)
PTE PHYSICAL FLAGS
3b8000e1 3b800000 (PRESENT|ACCESSED|DIRTY|PSE) # 无可写标志!!
PAGE PHYSICAL MAPPING INDEX CNT FLAGS
ffffe06f80ee1bc0 3b86f000 0 0 1 1fffff00000400 reserved
crash>
注意到, 3b8000e1 表明该页表项指向的页面是不可写的!
我们需要修改页表项,而这个很容易,注意以下的关系:
我们只需要写物理内存 0x3c413a30 即可:
crash> rd -p 3c413a30
3c413a30: 000000003b8000e1 ...;....
crash> wr -p 3c413a30 000000003b8000e3
crash>
此时,再次确认之前写失败的vtop结果:
PTE PHYSICAL FLAGS
3b8000e3 3b800000 (PRESENT|RW|ACCESSED|DIRTY|PSE) # 有了可写标志
OK,有了可写标志,现在写入它:
wr: write error: kernel virtual address: ffffffffa8c6fad4 type: "write memory"
很不幸,又失败了。这是为什么呢?
写一个单独的内核模块,刷新一下TLB。刷TLB很容易,就是重新载入RC4寄存器并重新使能分页机制即可,此时系统会将所有的TLB失效。
有个未导出的 flush_tlb_all 现成的函数却不可以直接调用,我们需要在 /proc/kallsyms 里找到它的地址来调用。代码如下:
#include <linux/module.h>
void (*pf)(void);
static int __init flushtlb_init(void)
{
// 从/proc/kallsyms获取flush_tlb_all的地址并调用.
pf = 0xffffffffa8c7a040;
pf();
// 不要真正加载
return -1;
}
static void __exit flushtlb_exit(void)
{
}
module_init(flushtlb_init);
module_exit(flushtlb_exit);
MODULE_LICENSE("GPL");
我们发现,只要刷一遍TLB,crash中读取的页表项PTE就会重新还原为不可写。
出现该问题的内核编译时有两个选项CONFIG_PHYSICAL_START和CONFIG_PHYSICAL_START,crash的manual中有关于此的描述:
–reloc size
When analyzing live x86 kernels that were configured with a CONFIG_PHYSICAL_START value that is larger than its CONFIG_PHYSICAL_ALIGN value, then it will be necessary to enter a
relocation size equal to the difference between the two values.
我们确认下当前写失败的内核的config文件配置:
CONFIG_PHYSICAL_START=0x1000000
CONFIG_PHYSICAL_ALIGN=0x200000
与此同时,该内核的flush_tlb_all并非简单操作RC4寄存器这么简单。
我们不再指望使用crash直接修改内存,退一步,让crash作为我们的信息查询工具起作用,剩余的内存写操作让我们自己写内核模块来做。
依然以修改TCP初始拥塞窗口为例,我们要改两个地方:
- 改页表项,让 tcp_init_cwnd 函数指令可写。
- 改tcp_init_cwnd函数硬编码的指令操作数。
分两步走,先找到页表项并通过ptov命令得到它的虚拟地址(所有OS级别的写内存都基于虚拟地址进行):
再找需要修改的指令地址和值:
我们依据这些值编写内核模块:
#include <linux/module.h>
void (*pf)(void);
static int __init modcwnd_init(void)
{
char *ppte = 0xffff9e6bfc413a48; // 需要修改的PTE地址
long *pvalue = 0xffffffffa924eb67; // 需要修改的窗口值地址
pf = 0xffffffffa8c7a040;
ppte[0] = 0xe3;
pf();
// 我们将其改成一个奇怪的值:6
pvalue[0] = 0xe0834800000006ba;
return -1;
}
static void __exit modcwnd_exit(void)
{
}
module_init(modcwnd_init);
module_exit(modcwnd_exit);
MODULE_LICENSE("GPL");
尝试加载模块之后,执行我们上面的packetdrill脚本,这次让我们用抓包来确认:
正好6个数据段。
这种情况下,crash工具成了辅助,而真正起作用的是我们自己编写的内核模块,而这些背后,需要我们对操作系统整体的内存管理机制拥有清晰的认知。编写这种内核模块也是Linux内核程序员必备的技能。
修改用户进程内存
来点轻松的,我们来用crash修改一个用户态程序的内存,先看程序代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
unsigned long a = 0x1122334455667788;
printf("%lx %p\n", a, &a);
getchar();
printf("%lx\n", a);
getchar();
}
和《Linux内核如何私闯进程地址空间并修改进程内存》这篇文章里做的事情差不多,不同的是,本文我们用一种更加优雅的方式来进行内存篡改。
首先,运行它:
[root@localhost mod]# ./a.out
1122334455667788 0x7fff45d5d4d8
然后在crash中找到它:
crash> ps |grep a.out
7166 1582 0 ffff9e6bf66f8000 IN 0.0 4324 516 a.out
crash> set 7166
PID: 7166
COMMAND: "a.out"
TASK: ffff9e6bf66f8000 [THREAD_INFO: ffff9e6bcb0ec000]
CPU: 0
STATE: TASK_INTERRUPTIBLE
我们的目标是修改变量a的值,因此我们要定位该进程的用户态堆栈。
注意,此时getchar已经在内核空间等待了,所以bt命令只是内核栈,用户占还需要我们自己来找,我们从进程的task_struct结构体里寻找用户堆栈的蛛丝马迹:
crash> task_struct ffff9e6bf66f8000
struct task_struct {
state = 1,
stack = 0xffff9e6bcb0ec000,
usage = {
counter = 2
},
flags = 4202496,
...
sp0 = 18446636784538288128,
sp = 18446636784538287176,
usersp = 140734365029480,
...
我们就从 usersp = 140734365029480 开始找吧:
现在修改它:
crash> wr -u -64 0x7fff45d5d4d8 0xaabbccdd99887700
然后在a.out运行的终端敲回车:
[root@localhost mod]# ./a.out
1122334455667788 0x7fff45d5d4d8
aabbccdd99887700
可以看到,变量a的值发生了改变。
拯救D进程
我们经常在系统中发现D状态的进程,大多数情况下我们对其无能为力。
D进程处在 等待资源不能满足却又不能离开 的两难境地。然而这并不意味着D进程不可拯救,我们只需要解除它对资源的依赖,然后让它退出即可,即调用 do_exit 。
以下三篇文章描述了一种拯救D进程的方法:
Linux如何终止D状态的进程 :https://blog.csdn.net/dog250/article/details/53043445
Linux x86内核终止D状态的进程 :https://blog.csdn.net/dog250/article/details/53071973
Linux x86_64内核终止D状态的进程 :https://blog.csdn.net/dog250/article/details/53072028
下面将介绍另一种方法。为了举个例子,首先我们要先自己造一个D进程。我们先写一个内核模块
// main4.c
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/wait.h>
int condition = 1234;
module_param(condition, uint, 0644);
wait_queue_head_t waitq;
static int __init Ds1_init(void)
{
long magic = 0x22334455667788;
condition = 0x1234;
printk("condition:%lu magic:%lu %p\n", condition, magic, &waitq);
init_waitqueue_head (&waitq);
// 此处没有任何人会将condition设置为123,因此insmod会一直等待,进而D住
wait_event(waitq, condition == 123);
return 0;
}
static void __exit Ds1_exit(void)
{
}
module_init(Ds1_init);
module_exit(Ds1_exit);
MODULE_LICENSE("GPL");
然后我们试着加载这个模块。很不幸,卡住了,即便是 kill -9 也无法杀掉它。很显然,它D住了:
现在,我们如何将其从D状态激活呢?下面我们用crash工具试试看。我们的目标有3步:
- 找到insmod进程。
- 修改内核模块里的condition变量的值为希望的123。
- 唤醒insmod睡眠在的wait队列。
我们一步一步来。先找到insmod进程:
crash> ps |grep insmod
9074 1408 0 ffff88003a3e3de0 UN 0.1 13252 800 insmod
crash> set 9074
PID: 9074
COMMAND: "insmod"
TASK: ffff88003a3e3de0 [THREAD_INFO: ffff880022a2c000]
CPU: 0
STATE: TASK_UNINTERRUPTIBLE # D状态
crash>
接下来我们要找到condition变量的位置,这个需要些技巧,千人千法。我这里只介绍我采用的方法。先打印出最后的stack:
crash> bt
PID: 9074 TASK: ffff88003a3e3de0 CPU: 0 COMMAND: "insmod"
#0 [ffff880022a2fca8] __schedule at ffffffff81639b5d
#1 [ffff880022a2fd10] schedule at ffffffff8163a199
#2 [ffff880022a2fd20] init_module at ffffffffa00370bb [main4]
#3 [ffff880022a2fd60] do_one_initcall at ffffffff810020e8
#4 [ffff880022a2fd90] load_module at ffffffff810e9f3e
#5 [ffff880022a2fee8] sys_finit_module at ffffffff810ea8f6
#6 [ffff880022a2ff80] system_call_fastpath at ffffffff81645189
RIP: 00007f313239a1c9 RSP: 00007ffdbeea5578 RFLAGS: 00010216
... # 不care寄存器,因为这种case用不到
R13: 00000000007671c0 R14: 0000000000000000 R15: 00000000007671f0
ORIG_RAX: 0000000000000139 CS: 0033 SS: 002b
crash>
我们希望可以在main4模块的函数 内部 找到为condition变量赋值的语句或者找到 “condition == 123” 。我们注意到以下的行:
#2 [ffff880022a2fd20] init_module at ffffffffa00370bb [main4]
我们就在地址 0xffffffffa00370bb 前面某个地方找找看。之所以在前面找是因为condition变量的操作语句在执行流调用下一个函数之前,所以还在上一个栈帧上:
当然,更直接的,还可以直接反汇编Ds1_init函数,很容易从内核栈上获取它的位置:
现在很明确,方法有两个:
- 修改cmpl语句,将123,即0x7b改成0x1234。
- 修改condition变量位置的值,改成0x7b,即123。
我选择方法2(宁改数据不改指令,万一碰到指令不可写又要改页表项):
crash> rd -32 0xffffffffa0146000
ffffffffa0146000: 00001234 4...
crash> wr -32 0xffffffffa0146000 0x0000007b
crash>
crash> waitq 0xffffffffa0370260
PID: 9074 TASK: ffff88003a3e3de0 CPU: 1 COMMAND: "insmod"
crash>
现在condition的值已经是123了,接下来最后一步,唤醒insmod的睡眠队列wait。再看上面的反汇编:
注意到地址 0xffffffffa0146260 ,作为函数 prepare_to_wait 的参数,它就是等待队列waitq。我们确认一下:
crash> wait_queue_head_t
typedef struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
} wait_queue_head_t;
SIZE: 24
crash> wait_queue_head_t 0xffffffffa0146260
struct wait_queue_head_t {
lock = {
{
rlock = {
raw_lock = {
{
head_tail = 131074,
tickets = {
head = 2,
tail = 2
}
}
}
}
}
},
task_list = {
next = 0xffff880022a2fd40,
prev = 0xffff880022a2fd40
}
}
0xffff880022a2fd40 作为两枚list_head指针,链入的正是wait_queue_t,我们可以再次确认:
其中确认wait_queue_t对象时将list减去3*8这个偏移是可以用crash工具的 struct wait_queue_t.task_list -o 计算出来的。
现在,是时候写一个内核模块来唤醒D进程了:
#include <linux/module.h>
#include <linux/sched.h>
static int __init wake_init(void)
{
wait_queue_head_t *wait = 0xffff880022a2fd28;
wake_up(wait);
// 并不加载,wakeup后即退出。
return -1;
}
static void __exit wake_exit(void)
{
}
module_init(wake_init);
module_exit(wake_exit);
MODULE_LICENSE("GPL");
编译加载,加载,效果如下:
[root@localhost mod]# insmod ./wake.ko
insmod: ERROR: could not insert module ./wake.ko: Operation not permitted
[root@localhost mod]# ps -elf|grep [i]nsmod
[root@localhost mod]#
这就完成了我们拯救D进程的演示,但是注意,拯救D进程没有通用的方法,即便是成功将其从D状态救出,也依然要确认资源依赖,解除资源依赖后尽快调用 do_exit 。
结语
以上只是抛砖引玉般结合 crash,stap以及内核模块分析了几个简单的实例,如果继续下去,还会有非常多类似的例子以及更为复杂更为有趣的案例供我们去分析或者把玩,这将给我们带来无穷无尽的快乐。
但是限于篇幅,本文只能在此点到为止。本文的宗旨在于,通过这些简单有趣的实例,让我们理解工具的使用方法以及使用这些工具的重要性。
与此同时,在我们日常分析解决Linux内核问题时,如何使用工具并不是核心,工具始终只是一个让你的工作效率更高的锦上之花,真正干货的背后永远都是对操作系统理论以及对Linux内核本身的理解和掌握,否则,工具掌握得再熟练也只能是个熟练工。
浙江温州皮鞋湿,下雨进水不会胖。