声明
本人小白,欢迎在评论区提出意见建议。文章未经允许不得转载。
文章首发于我的博客。
实现shell工作目录切换
大家都应该是试过在shell中输入cd new_directory来切换shell的当前工作目录(current working directory,cwd)。但是,大家可能不太熟悉的是,在绝大多数情况下,cd其实是一个shell的内置命令,而不是一个程序。
POSIX API中有专门的函数,可以获取程序当前运行目录,以及对程序当前运行的目录进行修改。在Python中分别是os.getcwd以及os.chdir。
先上代码,再来逐条解释。
class PyShell(cmd.Cmd()):
@staticmethod
def home_abbr_to_full(abbr_path: str) -> str:
if abbr_path.startswith('~'):
abbr_path = abbr_path.replace('~', os.environ['HOME'], 1)
return abbr_path
@staticmethod
def full_to_home_abbr(full_path: str) -> str:
if full_path.startswith(os.environ['HOME']):
full_path = full_path.replace(os.environ['HOME'], '~', 1)
return full_path
def do_cd(self, arg_str: str) -> None:
"""usage: cd target_path"""
args = shlex.split(arg_str)
if len(args) > 1:
print('cd: too many arguments', file=sys.stderr)
return
try:
if len(args) == 0 or args[0] == '':
os.chdir(os.environ['HOME'])
else:
os.chdir(PyShell.home_abbr_to_full(args[0]))
except FileNotFoundError:
print('cd: invalid path', file=sys.stderr)
except NotADirectoryError:
print('cd: not a directory', file=sys.stderr)
else:
self.prompt = f'{getpass.getuser()}@{socket.gethostname()}:{PyShell.full_to_home_abbr(os.getcwd())}$ '
首先我们判断参数的个数,我们只需要实现cd命令的最简单的用法,也就是cd new_directory。所以参数个数大于1直接丢弃(同样使用shlex.split来处理双引号"")。此外cd指令若不给目标目录,默认是进入HOME目录。否则的话,则进入指定的目录。在切换完目录之后,记得更新命令提示中的当前工作目录(self.prompt是父类cmd.Cmd中定义的属性(attribute),内容会显示为用户每次输入命令前的提示)。
此处的两个home_abbr_to_full和full_to_home_abbr是两个工具函数,负责转换完整的路径和简略了HOME目录的路径(一般以~符号代表HOME路径,举个例子,如果你的HOME路径是/home/foobar,那么cd ~/Desktop相当于cd /home/foobar/Desktop)。
实现shell执行其它程序
为了实现在shell中执行程序,我们需要以下两个函数,os.fork和os.execvp。
通过os.fork复制一份shell
os.fork是Python对POSIX API中的fork函数进行的包装。os.fork负责将当前的进程(也就是我们自己的shell)复制出相同的一份出来。如果我们运行下面的代码(假设保存在test.py文件里)
import os
input('准备调用os.fork();按任意键继续')
os.fork()
print('这条消息会显示两次')
while True:
pass
我们会看到代码中的print函数被执行了两次。
此外,如果我们在程序调用os.fork前(也就是input函数等待输入的时候),打开另一个终端窗口,输入命令ps all | grep -i python(ps all命令来查看当前所有的进程(相当于Windows中的任务管理器),然后通过管道操作符|,将ps all命令产生的输出,作为grep -i python命令的输入,传给grep命令;grep -i python则会显示ps all命令中,所有包含python的行(忽略大小写))。此时会有如下显示(可能会略有出入,但是应该会只有一个python的解释器在运行)。
0 1000 28179 28127 20 0 21264 5644 - S tty1 0:00 python3 test.py
0 1000 28199 28181 20 0 11320 496 - R tty2 0:00 grep --color=auto -i python
在我们输入任意字符并确认之后,os.fork会执行,然后程序会因为最后的while进入一个循环。此时我们在另一个终端窗口中输入同样的指令,我们会发现,进程列表里比之前多了一个python在运行。
0 1000 28179 28127 20 0 21264 5652 - R tty1 0:02 python3 test.py
0 1000 28200 28179 20 0 21264 1344 - R tty1 0:02 python3 test.py
0 1000 28202 28181 20 0 13084 1120 - S tty2 0:00 grep --color=auto -i python
此外,如果我们回到程序所在的那个终端窗口,并按下^C,我们会发现屏幕上显示了两次关于KeyboardInterrupt的提示。
总而言之,os.fork会将当前的进程原封不动地复制一份。新的进程会继承原来的进程的所有状态。那么问题来了,既然新的进程(我们称之为子进程(child process))是原来的进程,父进程(parent process)的拷贝,那我们在代码中如何区分,这段代码到底是运行在子进程中,还是父进程中呢?
实际上,os.fork会返回一个数字,这个数字就是进程ID(process ID,pid)。如果os.fork返回的是0,那么说明当前代码是运行在子进程里面。如果os.fork返回的数字大于0,说明当前代码运行在父进程里头,并且这个大于0的数字就是我们新的子进程的进程ID(有了进程ID,父进程就可以对子进程进行管理了)。
如果os.fork函数失败了,没办法创建子进程(比如说当前系统运行了太多进程),那么OSError异常会被抛出(在POSIX的C语言API中,fork函数在出错时会返回小于0的值(一般是-1,并且会将errno变量修改为对应的值))。
通过os.execvp执行程序
经过os.fork之后,我们的操作系统中同时运行了两份一模一样的shell。因为是一模一样的shell,所以我们的子进程中同样会有用户输入的指令。此时,我们只需要通过os.execvp,把现在这个shell,变成我们需要执行的程序。如此一来,我们就实现了在shell中执行程序的功能。
首先,我们简单介绍一下os.execvp的原理。os.execvp会将当前进程(我们的子进程)的内存,给全部覆盖为我们想要运行的程序的数据和代码。如果用把一个进程看作一个西瓜,那么os.execvp就是把西瓜里头的红色瓜瓤(原进程的代码)给换成草莓酱(其他程序的代码),而不改变进程的进程ID(瓜皮还是同一个)。顺便一提,os.execvp也是Python对POSIX的C语言API中的execvp函数进行的封装。
如果我们运行下面的代码(假设保存在test.py文件里)
import os
import shlex
command_to_run = shlex.split(input('输入想要执行的程序:'))
input('按下回车键后会执行os.fork')
child_pid = os.fork()
if child_pid == 0: # 如果返回值是0,代表此时在子进程中
print('[子进程] 这个print在子进程里头')
print(f'[子进程] 这是想要运行的程序:{command_to_run}')
os.execvp(command_to_run[0], command_to_run)
print('[子进程] 我们看不到这个print')
# 我们看不到这个print,因为执行os.execvp时,
# 当前进程的代码和数据,会被替换为想要执行的程序的代码和数据
else:
print('[父进程] 这个print在父进程里')
print('[父进程] 即将进入while循环')
while True:
pass
我们会得到这样的输出(顺序可能有所不同)
输入想要执行的程序:echo "我是一个程序,名字叫echo"
按下回车键后会执行os.fork
[父进程] 这个print在父进程里
[子进程] 这个print在子进程里头
[父进程] 即将进入while循环
[子进程] 这是想要运行的程序:['echo', '我是一个程序,名字叫echo']
我是一个程序,名字叫echo
在调用os.execvp时,第一个参数是要执行的程序的路径,第二个参数则会直接传给目标程序,作为它的启动参数。而因为启动参数的第一个一定是目标程序的路径,所以我们一般直接将启动参数的第0个直接传给os.execvp作为路径。
我们可以发现,子进程中在os.execvp之后的代码并没有被执行,原因就是os.execvp已经将子进程的代码和数据全部替换成了echo这个程序的代码和数据。如果os.execvp失败了(比如说找不到指定的文件,或者给的文件不是可执行文件等),OSError异常会被抛出。
总而言之,在shell中执行新程序的方法总共两步。第一,os.fork复制一份一模一样的shell,第二,通过os.execvp替换子进程的内存数据,从而实现执行程序。
让父进程等待子进程
从前面的示例代码可能已经有读者发现了,在os.fork结束之后,父进程会直接继续运行,而不是等待子进程执行完毕之后再继续运行。而在实际使用shell的时候,我们一般是输入一条指令,等子进程结束之后,父进程才会提示我们要输入下一条指令。
为了让父进程能够等待子进程,我们需要用到os.waitpid这个函数。从函数名就能看出来,这个函数可以让当前的父进程等待某一个子进程,在子进程退出后再继续执行。对前面的代码稍作修改
import os
import shlex
command_to_run = shlex.split(input('输入想要执行的程序:'))
input('按下回车键后会执行os.fork')
child_pid = os.fork()
if child_pid == 0: # 如果返回值是0,代表此时在子进程中
print('[子进程] 这个print在子进程里头')
print(f'[子进程] 这是想要运行的程序:{command_to_run}')
os.execvp(command_to_run[0], command_to_run)
print('[子进程] 我们看不到这个print')
# 我们看不到这个print,因为执行os.execvp时,
# 当前进程的代码和数据,会被替换为想要执行的程序的代码和数据
else:
print('[父进程] 这个print在父进程里')
print('[父进程] 即将调用os.waitpid(child_pid, 0)')
os.waitpid(child_pid, 0) # 调用了os.waitpid
print('[父进程] 子进程退出了,现在轮到我退出了')
我们会得到这样的输出
输入想要执行的程序:python3
按下回车键后会执行os.fork
[父进程] 这个print在父进程里
[父进程] 即将调用os.waitpid(child_pid, 0)
[子进程] 这个print在子进程里头
[子进程] 这是想要运行的程序:['python3']
Python 3.7.3 (v3.7.3:ef4ec6ed12, Mar 25 2019, 16:52:21)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> quit()
[父进程] 子进程退出了,现在轮到我退出了
值得注意的是,在Python解释器之前的输出,可能顺序会跟我不一样(比如说有可能你的输出会是先是两个子进程,然后两个父进程,或者一个子进程一个父进程交替等)。但是最后这个“[父进程] 子进程退出了,现在轮到我退出了”,一定是在Python解释器退出之后,才会显示。原因正是我们在父进程中,人为设定了,父进程要在子进程结束后才继续运行,所以父进程中的最后一个print,会在子进程(Python解释器)退出后,才会被执行。
为什么os.waitpid最后要加一个0呢?其实这是一个参数,通过修改这个参数,可以让父进程“以其他方式等待”子进程的完成,不过这里我们就不展开讲了。
小结
有了os.fork,os.execvp,和os.waitpid,我们的shell终于可以执行程序,可以使用了。
下面是整合之后的代码
import os
import shlex
if __name__ == '__main__':
while True:
command_to_run = shlex.split(input('输入想要执行的程序:'))
if command_to_run[0] == 'exit':
exit(0)
child_pid = os.fork()
if child_pid == 0:
os.execvp(command_to_run[0], command_to_run)
else:
os.waitpid(child_pid, 0) # 调用了os.waitpid
这段简单的代码,展示了shell的最基础的工作流程。不过因为我们使用了Python自带的cmd.Cmd类,不需要我们自己写循环,因此,我们需要在我们自己的PyShell类中对default这个方法进行重写。
class PyShell(cmd.Cmd()):
# 此处省略PyShell类的其他方法
def default(self, line: str) -> None:
"""handler for undocumented inputs"""
command = shlex.split(line)
child_pid = os.fork()
if child_pid == 0:
os.execvp(command[0], command)
else:
os.waitpid(child_pid, 0)
至此,一个可以执行程序的shell便完成了。在后面的文章里,我们要给我们的shell增加重定向和管道的功能。