运维知识
悠悠
2025年12月3日

我就输了个ls,Linux底层居然背着我干了这么多事?

大家好,我是悠悠。

昨晚熬大夜,盯着黑漆漆的屏幕发呆。手里捧着那杯续命的冰美式,眼神空洞地看着终端里那个闪烁的光标。不知道你们有没有过这种时刻,脑子里突然蹦出一个极其无聊但又细思极恐的问题:

我特么就在键盘上敲了个 l,又敲了个 s,然后回车,这屏幕上怎么就出来文件列表了?

这事儿看着简单得令人发指,对吧?连刚才路过的保洁阿姨可能都知道 ls 是干啥的。但是,兄弟们,咱们干技术的不能只看表面。这就好比你按了一下冲水马桶,水哗啦流走了,你觉得挺自然,但地底下那复杂的管道系统、化粪池处理逻辑、市政管网压力平衡,那可是一套庞大的工程。

Linux 也是一样。

这一个简简单单的 ls,背后涉及了从硬件中断、内核调度、进程复制、文件系统读取到显卡渲染等一系列骚操作。那一瞬间,CPU 跑过的指令数可能比你这辈子吃过的米粒还多。

今天反正也没啥故障,咱们就闲下来,哪怕是钻牛角尖也好,把这层窗户纸捅破,看看当我们敲下 ls 的时候,Linux 到底在底下偷偷摸摸干了啥。

一、 那个并不简单的“连接”

咱先别急着敲命令,故事得从你连上服务器那一刻说起。

你坐在工位上,用着 SecureCRT 或者 Xshell,或者像我一样装X用 iTerm2,连上了一台几千公里外的 CentOS 服务器。

这时候,服务器上的 sshd 守护进程(daemon)正百无聊赖地监听着 22 端口。突然,你的连接请求像个愣头青一样撞了进来。sshd 一看有客到,立马精神了,它不会亲自接待你,毕竟它还得在门口站岗接客。于是,它通过 fork() 系统调用,生了个孩子(子进程)。

这个子进程就是专门为你服务的。接下来的加密协商、密钥交换这堆烂事儿我就不展开了,那是密码学的范畴。

重点来了,认证通过后,这个 sshd 子进程还得干个体力活:它得给你准备环境。

它会去读 /etc/passwd,看看你这小子到底是用 bash 还是 zsh,还是哪个反人类的 sh。通常咱们都是 /bin/bash 对吧?

于是,sshd 子进程再次变身,调用 execve(),把自己脑子里的代码替换成了 /bin/bash 的代码。这时候,你屏幕上那个黑框框的背后,其实跑的就是一个 Bash 进程了。

这个 Bash 刚醒过来,起床气还挺大,它得穿衣服洗漱。它会疯狂地去读一堆配置文件:/etc/profile~/.bash_profile~/.bashrc/etc/bashrc……

咱们平时给服务器配环境变量,搞得乱七八糟的,就是这时候生效的。如果这时候哪个文件里写了个 sleep 10,你登录的时候就得卡在那儿怀疑人生。

最后,Bash 准备好了,它调用 write() 系统调用,向你的屏幕输出了一个提示符:

[root@localhost ~]#

然后,它调用 read(),阻塞在那里,静静地等着你临幸。

二、 指尖的舞蹈:从键盘到内核

现在,你的手指悬在键盘上方,准备敲下 l

当你按下 'l' 键的那一微秒,键盘内部的电路闭合,产生了一个扫描码(Scan Code)。键盘控制器一看,哟,来活了,立马向 CPU 发送一个硬件中断

CPU 正忙着算别的进程的圆周率呢,被这中断一搞,不得不停下手里的活,去执行键盘中断处理程序。操作系统内核(Kernel)接管了现场,它从端口读取扫描码,把它转换成 ASCII 码的 'l'。

但这还没完。这个 'l' 现在还在内核的输入缓冲区里。

你的 SSH 客户端(比如 Xshell)通过网络把这个字符发给了服务器。服务器网卡收到包,又是一顿中断处理,内核把数据包拆开,扔给了伪终端主设备(PTY Master)。

这时候,Bash 进程之前不是在 read() 那儿傻等吗?内核一看,有数据来了,立马把 Bash 叫醒:“别睡了,起来干活!”

Bash 醒过来,读到了这个 'l'。

注意,这时候屏幕上还没显示 'l' 呢!

Bash 拿到 'l' 后,它得决定怎么办。通常情况下,它会把这个字符“回显”给你。也就是说,Bash 又调用 write() 把 'l' 发回给你的 SSH 客户端,你的屏幕上这才出现了一个字母 'l'。

这就是为什么有时候网络卡顿,你敲了半天键盘,屏幕上没反应。因为那个字符得去服务器溜达一圈再回来,你才能看见。

同理,你敲下 's',也是这么个流程。

三、 回车那一刻:解析与查找

当你按下回车键(Enter)的时候,那才是高潮的开始。

Bash 读到了换行符 \n,它知道,这哥们儿输入完了,该我干活了。

首先,Bash 会把你输入的字符串 ls 拿来进行切割和分析。这叫词法分析。它得搞清楚,哪部分是命令,哪部分是参数,哪部分是选项。

对于 ls 这种简单命令,没啥好切的。但 Bash 也是个心机boy,它得先查查户口:

  1. 是不是绝对路径? 你输的不是 /bin/ls,所以它得接着找。
  2. 是不是别名(Alias)? 这一点很关键!

在大部分发行版里,你输的 ls 其实根本不是 ls。不信你现在去终端敲一下 alias ls,十有八九会告诉你:alias ls='ls --color=auto'

原来是替身攻击!Bash 默默地把 ls 替换成了 ls --color=auto。这就是为什么你列出来的文件是五颜六色的,而不是灰头土脸的白字。

  1. 是不是内置命令(Builtin)?cdechoalias 这种是 Bash 自带的,不需要去磁盘找。但 ls 显然不是,它是个独立的可执行文件。
  2. 哈希表(Hash)里有没有? Bash 为了快,会把以前执行过的命令位置记在内存里。如果以前敲过,直接去老地方找。
  3. 查 PATH 变量。 如果上面都没找到,Bash 就得去翻箱倒柜了。它会读取 $PATH 环境变量,通常是 /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin 这一长串。

它会按照顺序,先去 /usr/local/bin 看看有没有个叫 ls 的文件?没有?再去 /usr/bin 看看。哎,找到了!

一旦找到,Bash 就算心里有底了:目标锁定 /usr/bin/ls

四、 细胞分裂:Fork 的魔法

找到了程序文件,怎么运行它?

这里就是 Linux 最反直觉也最迷人的地方了。在 Windows 里,可能有个 API 叫 CreateProcess 之类的直接搞定。但在 Linux 里,创造新生命的方式是细胞分裂

Bash 会调用 fork() 系统调用。

那一瞬间,操作系统会把当前的 Bash 进程完完整整地复制一份。包括内存里的变量、打开的文件描述符、环境变量……简直就是克隆人。

  • 父进程(原来的 Bash):它的 ID(PID)不变。
  • 子进程(新的 Bash):它获得了一个新的 PID。

此时此刻,系统里有两个一模一样的 Bash 在跑。

父进程 Bash 现在的任务完成了,它会调用 wait() 或者 waitpid(),进入睡眠状态,就像老父亲看着儿子出门闯荡,自己在门口抽烟等着儿子回来汇报工作。

而那个子进程 Bash,虽然长得和它爹一样,但它肩负着特殊的使命。它紧接着会调用 execve() 系统调用。

execve 是个狠人,它会把子进程脑子里原有的 Bash 代码全部清空,把刚才找到的 /usr/bin/ls 的代码加载进内存,替换掉原来的脑子。

这一刻,子进程不再是 Bash,它正式变身为 ls 进程。

五、 动态链接的狂欢

变身成功了,ls 就能直接跑了吗?太天真了。

现在的程序很少有静态编译的(Golang 写的那种除外)。ls 这种 C 语言写的老古董,严重依赖动态链接库。

你可以用 ldd /bin/ls 看看,它拖家带口的依赖了一堆库:libselinux.solibc.solibpcre.so 等等。

image-20251203223227798

lsmain() 函数执行之前,Linux 的动态链接器(通常是 /lib64/ld-linux-x86-64.so.2)得先出场。它负责把 ls 需要的所有共享库文件加载到内存里,并且把代码里的那些“坑”填上。

比如 ls 里面调用了 printf,在代码里那只是个符号,链接器得把它指向 libc.so 里真正的 printf 函数地址。

这步工作如果做不好,你就会看到那个著名的报错:error while loading shared libraries。这种错我估计你们也没少见,通常都是刚装完某个软件环境变量没配好。

等到所有库都加载完毕,链接器功成身退,控制权终于交到了 ls 程序的入口点(Entry Point)。

六、 真正的干活:系统调用的暴力美学

现在,ls 终于开始执行它的业务逻辑了。

它的核心任务很简单:列出目录里的东西。

root@debian:~# strace ls
execve("/usr/bin/ls", ["ls"], 0x7ffca85f2e60 /* 19 vars */) = 0
brk(NULL)                               = 0x55d90544d000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f9820b9f000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=58767, ...}) = 0
mmap(NULL, 58767, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f9820b90000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0644, st_size=202984, ...}) = 0
mmap(NULL, 210640, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f9820b5c000
mmap(0x7f9820b63000, 131072, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x7000) = 0x7f9820b63000
mmap(0x7f9820b83000, 36864, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x27000) = 0x7f9820b83000
mmap(0x7f9820b8c000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x30000) = 0x7f9820b8c000
mmap(0x7f9820b8e000, 5840, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f9820b8e000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libcap.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360y\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0644, st_size=47288, ...}) = 0
mmap(NULL, 45128, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f9820b50000
mmap(0x7f9820b53000, 20480, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x3000) = 0x7f9820b53000
mmap(0x7f9820b58000, 8192, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x8000) = 0x7f9820b58000
mmap(0x7f9820b5a000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0xa000) = 0x7f9820b5a000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\236\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840
fstat(3, {st_mode=S_IFREG|0755, st_size=2003408, ...}) = 0
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840
mmap(NULL, 2055800, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f982095a000
mmap(0x7f9820982000, 1462272, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7f9820982000
mmap(0x7f9820ae7000, 352256, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x18d000) = 0x7f9820ae7000
mmap(0x7f9820b3d000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e2000) = 0x7f9820b3d000
mmap(0x7f9820b43000, 52856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f9820b43000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpcre2-8.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0644, st_size=711216, ...}) = 0
mmap(NULL, 713544, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f98208ab000
mmap(0x7f98208ae000, 503808, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x3000) = 0x7f98208ae000
mmap(0x7f9820929000, 192512, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x7e000) = 0x7f9820929000
mmap(0x7f9820958000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0xac000) = 0x7f9820958000
close(3)                                = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f98208a9000
arch_prctl(ARCH_SET_FS, 0x7f98208aa240) = 0
set_tid_address(0x7f98208aa510)         = 73938
set_robust_list(0x7f98208aa520, 24)     = 0
rseq(0x7f98208aa0a0, 0x20, 0, 0x53053053) = 0
mprotect(0x7f9820b3d000, 16384, PROT_READ) = 0
mprotect(0x7f9820958000, 4096, PROT_READ) = 0
mprotect(0x7f9820b5a000, 4096, PROT_READ) = 0
mprotect(0x7f9820b8c000, 4096, PROT_READ) = 0
mprotect(0x55d8f8f52000, 8192, PROT_READ) = 0
mprotect(0x7f9820bdb000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x7f9820b90000, 58767)           = 0
prctl(PR_CAPBSET_READ, CAP_MAC_OVERRIDE) = 1
prctl(PR_CAPBSET_READ, 0x30 /* CAP_??? */) = -1 EINVAL (Invalid argument)
prctl(PR_CAPBSET_READ, CAP_CHECKPOINT_RESTORE) = 1
prctl(PR_CAPBSET_READ, 0x2c /* CAP_??? */) = -1 EINVAL (Invalid argument)
prctl(PR_CAPBSET_READ, 0x2a /* CAP_??? */) = -1 EINVAL (Invalid argument)
prctl(PR_CAPBSET_READ, 0x29 /* CAP_??? */) = -1 EINVAL (Invalid argument)
statfs("/sys/fs/selinux", 0x7fffce9f78e0) = -1 ENOENT (No such file or directory)
statfs("/selinux", 0x7fffce9f78e0)      = -1 ENOENT (No such file or directory)
getrandom("\xe6\xef\xc7\x83\xae\x77\x10\x65", 8, GRND_NONBLOCK) = 8
brk(NULL)                               = 0x55d90544d000
brk(0x55d90546e000)                     = 0x55d90546e000
openat(AT_FDCWD, "/proc/filesystems", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0444, st_size=0, ...}) = 0
read(3, "nodev\tsysfs\nnodev\ttmpfs\nnodev\tbd"..., 1024) = 387
read(3, "", 1024)                       = 0
close(3)                                = 0
access("/etc/selinux/config", F_OK)     = -1 ENOENT (No such file or directory)
ioctl(1, TCGETS, {c_iflag=ICRNL|IXON, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=61, ws_col=143, ws_xpixel=0, ws_ypixel=0}) = 0
openat(AT_FDCWD, ".", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
fstat(3, {st_mode=S_IFDIR|0700, st_size=4096, ...}) = 0
getdents64(3, 0x55d905452c40 /* 28 entries */, 32768) = 960
getdents64(3, 0x55d905452c40 /* 0 entries */, 32768) = 0
close(3)                                = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}) = 0
write(1, " Python-3.14.0a5       awx_env\t "..., 98 Python-3.14.0a5       awx_env             kimchi-3.0.0-0.noarch.deb   sudo                  wok-3.0.0-modified.deb
) = 98
write(1, " Python-3.14.0a5.tgz   daemon.js"..., 94 Python-3.14.0a5.tgz   daemon.json    kimchi-venv              'udo ss -tulpn | grep 9090'    wok_extract
) = 94
write(1, " awx\t\t       kickstart.sh   ngsh"..., 77 awx                       kickstart.sh   ngshow                      wok-3.0.0-0.debian.noarch.deb   xuexi
) = 77
close(1)                                = 0
close(2)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++
root@debian:~# 

但应用程序是不能直接读硬盘的,那是内核的禁脔。ls 必须通过系统调用(System Call)来求内核办事。

咱们可以用 strace ls 来偷窥一下它到底干了啥。这命令特好用,建议没事多跑跑,比看源码来得直观。

  1. openat()
    ls 首先得打开当前目录。它会调用 openat(),传入 .(当前目录)。内核检查一下权限:这小子是 root 吗?或者对这目录有读权限吗?如果有,内核返回一个文件描述符(File Descriptor,简称 FD),比如是 3
  2. getdents64()
    这是最核心的一步。ls 调用 getdents64(3, ...)。这个系统调用的意思是“Get Directory Entries”,获取目录项。
    内核接到这个请求,会去文件系统(Ext4, XFS等)读取目录的元数据。注意,它读的不是文件内容,是目录结构。它会拿到一堆文件名、inode 号、文件类型(是文件还是目录)。
    如果目录文件特别多,这个 getdents64 可能要读好几次。
  3. lstat() / stat()
    如果你只是敲 ls,可能到上面就差不多了。但别忘了,我们默认可能有 alias 或者你敲了 ls -l
    如果要显示详细信息(权限、大小、时间),或者要根据文件类型显示颜色,ls 必须知道每个文件的详细信息。
    于是,ls 会对着刚才拿到的每一个文件名,疯狂调用 lstat()
    重点来了! 如果你这个目录下有几万个文件,或者有个挂载的 NFS 网络盘卡住了,ls 就会在这里卡死!
    因为每一个 lstat 都要去问文件系统:“哎,这个文件多大?谁的?啥时候改的?”
    这就是为啥有时候 ls 一下,终端直接僵死半天不动的原因。IO 炸了。
  4. ioctl()
    ls 还没忘了一件事,它得知道你的终端窗口有多宽。它调用 ioctl(1, TIOCGWINSZ, ...)
    这玩意儿就是问内核:“现在的终端窗口是几行几列啊?”
    如果你的窗口很宽,它就把文件名横着排;如果很窄,它就竖着排。很贴心是吧?

七、 格式化与输出:为了让你看懂

拿到了所有文件的元数据,ls 开始在用户态内存里拼凑字符串了。

它会根据配置(比如 /etc/DIR_COLORS),给不同类型的文件加上 ANSI 转义码。
比如,目录是蓝色的,它就在目录名字前面加个 \033[01;34m,后面加个 \033[0m

这些乱七八糟的字符拼好之后,ls 再次调用 write(),把这一大坨数据写入到文件描述符 1(标准输出 stdout)。

八、 尘埃落定

数据写入 stdout 后,又是熟悉的流程:

内核 TTY 驱动接收数据 -> SSHD 进程读取 -> 加密 -> 通过 TCP/IP 发送 -> 你的网卡接收 -> OS 解包 -> SSH 客户端解密 -> 终端模拟器解析颜色代码 -> 显卡渲染字体。

最终,你的视网膜上接收到了这些光子,大脑皮层处理了一下:

“哦,有个 config.yaml 文件。”

任务完成后,ls 进程调用 exit_group(0),宣布自杀。

内核回收它的内存,关闭它打开的文件描述符,向它的父进程(那个等着抽烟的 Bash)发送一个 SIGCHLD 信号:“儿砸完事儿了,你可以收尸了。”

Bash 收到信号,wait() 返回,清理掉僵尸进程(Zombie Process),重新打印出提示符:

[root@localhost ~]#

整个过程,可能也就几毫秒。

九、 这一点也不“简单”

写到这,我不禁有点感慨。

咱们平时敲个命令,觉得是天经地义的事。要是稍微慢了一点,还要骂一句“这破服务器真垃圾”。

但你看,为了这简简单单的两个字母,Linux 内核、文件系统、内存管理、进程调度、网络协议栈,像一支训练有素的军队,进行了一次精密至极的协同作战。这里面任何一个环节出了岔子,比如内存耗尽(OOM)、磁盘坏道、网络丢包、权限配置错误,都会导致你看到的不是文件列表,而是一行冰冷的报错,甚至是一个永远转不完的圈圈。

这也是为什么我总是跟刚入行的小兄弟说,别光背命令,要懂原理。

当你明白了 ls 背后发生了什么,下次遇到服务器卡顿、命令 hang 住、Permission denied 的时候,你脑海里浮现的就不是焦躁,而是清晰的数据流图。你会知道是用 strace 去看卡在哪个 syscall,还是用 dmesg 去看是不是硬件报错,或者是去查查那个该死的 alias 是不是被谁改了。

这就是“运维”和“操作员”的区别。

技术这东西,越往深了挖,越觉得有意思。它不只是冷冰冰的代码,它是逻辑的艺术,是人类智慧的结晶……哎呀我去,扯远了,有点矫情了。

反正吧,下次当你再敲下回车的时候,记得在心里给这套伟大的系统点个赞。它真的很努力了。

总结一下:
一个 ls,从硬件中断到 Shell 解析,从 Fork/Exec 到动态链接,再到系统调用读取文件系统,最后渲染输出。每一层都是前人踩过无数坑总结出来的最佳实践。

咱们做技术的,就是要这种刨根问底的劲儿。你看完这篇文章,要是觉得哪怕有一点点收获,或者觉得“卧槽,原来是这样”,那我就没白写。


公众号:运维躬行录
个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码