这段时间在研究 Linux 中用户登录和 Shell 执行程序的原理。
我们知道,Shell 命令分为内部命令和外部命令。内部命令有诸如 cd、history、exit、echo 等,常见的外部命令有 ls、ping、netstat 等。通过 type 命令可以查看一个命令是内部命令还是外部命令。
当执行的命令是内部命令时,Shell 可以直接执行。那么,Shell 是如何执行一个外部命令的呢?Shell 本身也是一个程序,程序之间调用的方式有很多,例如 popen。但是,Shell 要正确地执行一个外部命令,需要满足几个条件:
- 首先是 Shell 能够找到并调用这个程序,这是最基本的前提。
- Shell 需要等待外部程序执行完成之后再继续。
- 外部程序执行时,需要继承 Shell 的一些运行状态,例如当前工作目录、控制终端、根目录等。
实际上,在 Linux 中,Shell 执行外部命令通常是通过创建子进程的方式完成的。通过 pstree 可以看到 Linux 的进程树,例如:
[root@home ~]# pstree
systemd-+-2*[agetty]
|-avahi-daemon---avahi-daemon
|-avahi-dnsconfd
|-crond
|-dbus-daemon
|-haveged
|-ifplugd
|-master---pickup
|-python
|-rngd
|-sshd-+-3*[sshd]
| `-sshd---bash---pstree
|-systemd---(sd-pam)
|-systemd-journal
|-systemd-logind
|-systemd-udevd
`-wpa_supplicant
可以看到,这里执行的 pstree 就是 bash 的子进程。那么,Shell 执行外部命令的原理是什么呢?当 Shell 执行一个外部命令时,第一步通常是 fork 出一个子进程。Shell 获取需要执行的程序以及相关参数之后,子进程再调用 exec 系列函数执行另一个程序。这里需要注意:
exec 并不会创建新的进程,而是直接用新的程序替换当前进程。
因此,执行 exec 前后的进程 ID 并不会发生变化。整个过程可以简单理解成:
Shell
↓
fork()
↓
子进程
↓
exec()
↓
新的程序
下面的代码可以简单模拟 Shell 执行外部程序的过程:
#!/usr/bin/env python
import os
import sys
if sys.argv[1:]:
pid = os.fork()
else:
sys.exit(0)
if not pid:
filename = sys.argv[1]
argv1 = filename
argvs = ",".join(sys.argv[2:])
os.execlp(
filename,
argv1,
argvs
)
os.waitpid(pid, 0)
把这个文件保存为 ba,增加执行权限,然后运行:
./ba ping www.hitoy.org
程序可以正常执行。
再运行:
pstree
可以看到 Python 进程产生了一个 ping 子进程。
Linux 中,一个程序调用另一个程序时,经常会采用这种:
fork + exec
的方式。
exec是什么?
再来看一下 exec。
exec 并不是一个单独的函数,而是一组用于执行新程序的函数。Linux 中常见的有 6 个:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,
char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[],
char *const envp[]);
这几个函数虽然名字比较多,但实际上是按照几个不同的功能组合出来的。
其中需要注意的是,带有 p 的函数,例如:
execlp()execvp()
当传入的文件名不是完整路径时,会根据 PATH 环境变量去查找对应的程序。
而不带 p 的函数,参数中的 path 一般需要指定程序的路径。
另外一个需要特别注意的地方是参数的传递。
例如:
execlp()
execvp()
这里的 arg 并不是简单地理解成“传给程序的第一个参数”。
按照程序实际接收到的参数列表来看,通常需要从第 0 个参数开始传递,也就是把程序本身的名称也作为第一个参数传进去。
例如:
argv[0] = 程序名称
argv[1] = 第一个参数
argv[2] = 第二个参数
...
所以,在使用 exec 系列函数的时候,需要特别注意参数的组织方式。通过这几个函数,可以看到 Linux 中“启动另外一个程序”的一个基本过程:
先通过 fork() 创建子进程,再通过 exec() 让这个子进程执行新的程序。
这也是理解 Linux Shell、进程以及程序调用关系时一个非常重要的基础。
评论0
欢迎分享你的看法,也欢迎补充不同的实践经验。