python3 shell_用Python写一个简单的Linux Shell(3)

声明

本人小白,欢迎在评论区提出意见建议。文章未经允许不得转载。

文章首发于我的博客。

实现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增加重定向和管道的功能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值