自己动手写Shell(二)——Python实现

Shell的基本生命周期

无论使用什么语言实现,Shell 的生命周期都是一样的,主要做三件事:

  • 初始化:Shell 会在初始化时读入和执行配置文件。这会改变 Shell 接下来各方面的行为。
  • 解释:Shell 在解释阶段(也就是等待用户输入的阶段)读入标准输入的命令并解释执行。
  • 终结:在用户输入 shutdown 命令后,Shell 会释放掉占用的内存并终结自己。

使用Python实现的 Shell 主函数如下:

1
2
3
4
5
6
7
def main():
# 在执行 shell_loop 函数进行循环监听之前,首先进行初始化
# 即建立命令与函数映射关系表
init()

# 预处理命令的主程序
shell_loop()

和 C 中的主函数相比,多了一个init() 函数,该函数是用来注册函数关系映射表的,在 Shell 正式启动前将一些内置的命令先加载好,各个内置的命令写在主目录下的 func 文件夹下,每一个命令都对应一个.py文件。这个功能在 C 中的 Shell也有实现,只不过是通过字符串数组和函数指针数组实现的,且全部写在 main.c 文件里。在 Python 中这样实现的目的是为了降低代码的耦合度,使得增添命令变得更加灵活。如果想为我们的 Shell 增加或减少一个命令,只需要在 func 文件下增加 或减少一个.py文件即可,不需要频繁改动主函数。

init() 函数的实现如下(暂时只实现了这四个命令):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def register_command(name, func):
"""
注册命令,使命令与相应的处理函数建立映射关系
@Param name:命令名
@param func:函数名
"""
built_in_cmds[name] = func

def init():
"""
注册所有命令
"""
register_command("cd", cd)
register_command("exit", exit)
register_command("getenv", getenv)
register_command("history", history)

Shell的基本循环

Shell 的基本循环也是通用的以下三点:

  • 读入:从标准输入中读入命令
  • 解析:将执行的命令和其参数解析出来输入程序执行
  • 执行:根据命令和参数执行程序

用 Python 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def shell_loop():
status = SHELL_STATUS_RUN
while status == SHELL_STATUS_RUN:
#打印命令提示符,如'[<user>@<hostname> <base_dir>]$'
display_cmd_prompt()

#忽略 Ctrl-Z 或Ctrl-C 信号
ignore_signals()

try:
#读取命令
cmd = sys.stdin.readline()

#解析命令
#将命令进行拆分,返回一个列表
cmd_tokens = tokenize(cmd)

#预处理函数
#将命令中的环境变量用真实值进行替换
#比如讲$HOME这样的变量替换为实际值
cmd_tokens = preprocess(cmd_tokens)

#执行命令,并返回shell的状态
status = execute(cmd_tokens)
except:
#sys.exc_info函数返回一个包含三个值的远足(type,value,traceback)
#这三个值产生于最近一次被处理的异常
#而我们只需要获取中间的值
_, err, _ = sys.exc_info()
print(err)

虽然没有 C 中实现的那么简洁清楚,但我增加了更多的功能。比如:打印命令提示符,在 C 中只是打印一个’>’;忽略 Ctrl-z 和 Ctrl-c 终止信号;在循环内加入了异常检测语句。这样的 Shell 比起 C 里的 Shell 看起来就显得高大上多了。

显示命令提示符的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#展示命令提示符,行如'[<user>@<hostname> <base_dir>]$'
def display_cmd_prompt():
# getpass.getuser 用于获取当前用户名
user = getpass.getuser()

# socket.gethostname() 返回当前运行 python 程序的机器的主机名
hostname = socket.gethostname()

# 获取当前工作路径
cwd = os.getcwd()

# 获取路径 cwd 的最低一级目录
base_dir = os.path.basename(cwd)

# 如果用户当前位于用户的根目录之下,使用'~'代替目录名
home_dir = os.path.expanduser('~')
if cwd == home_dir:
base_dir = '~'

# 输出命令提示符
if platform.system() != "Windows":
sys.stdout.write("[\033[1;33m%s\033[0;0m@%s \033[1;36m%s\033[0;0m] $"%(user, hostname, base_dir))
else:
sys.stdout.write("[%s@%s %s]$ " % (user, hostname, base_dir))
sys.stdout.flush()

分别通过 Python 标准库中的函数获取了当前用户名、主机名和工作路径。并判断如果是用户目录则用’~’替代。对于非 Windows 系统输出带颜色的代码。

忽略终止信号的代码如下:

1
2
3
4
5
6
7
8

def ignore_signals():
if platform.system() != "Window":
# 忽略 Ctrl-Z 信号
signal.signal(signal.SIGTSTP, signal.SIG_IGN)
#忽略 Ctrl-C 信号

signal.signal(signal.SIGINT, signal.SIG_IGN)

在读入命令时直接使用了 Python 中的标准读入,所以就没有单独为读入写一个函数了。

解析一行

首先将读到的行进行分词,我直接使用了 Python 库 shlex 中的 split() 方法,该方法按 Shell 的语法规则对字符串进行分割。

1
2
3
4
5
6
7
def tokenize(string):
# 将 string 按 shell 的语法规则进行分割
# 返回 string 的分割列表
# 其实就是按空格符将命令与参数分开
# 比如, 'ls -l /home/yifeng' 划分之后就是
# ['ls', '-l', '/home/yifeng']
return shlex.split(string)

相较于 C 中的 Shell 分词之后还增加了一个预处理函数,将输入的环境变量进行替换,这样就可以输入变量了。

预处理函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
def preprocess(tokens):
# 用于存储处理之后的 token
processed_token = []
for token in tokens:
if token.startswith('$'):
# os.getenv() 用于获取环境变量的值,比如 'HOME'
# 变量不存在则返回空
processed_token.append(os.getenv(token[1:]))
else:
processed_token.append(token)

return processed_token

解释执行

在读取了预处理后的命令行参数后,便可以进行解释执行了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def execute(cmd_tokens):
# 'a' 模式表示以添加的方式打开指定文件
# 这个模式下文件对象的 write 操作不会覆盖文件原有的信息,而是添加到文件原有信息之后
with open(HISTORY_PATH, 'a') as history_file:
history_file.write(' '.join(cmd_tokens) + os.linesep)

if cmd_tokens:
# 获取命令
cmd_name = cmd_tokens[0]
# 获取命令参数
cmd_args = cmd_tokens[1:]

# 如果当前命令在命令表中
# 则传入参数,调用相应的函数进行执行
if cmd_name in built_in_cmds:
return built_in_cmds[cmd_name](cmd_args)

# 监听 Ctrl-C 信号
signal.signal(signal.SIGINT, handler_kill)

# 如果当前系统不是 Windows
# 则创建子进程
if platform.system() != "Windows":
# Unix 平台
# 调用子进程执行命令
p = subprocess.Popen(cmd_tokens)

#父进程从子进程读取数据,直到读取到EOF
# 这里主要用来等待子进程终止运行
p.communicate()
else:
# Windows 平台
command = ""
command = ' '.join(cmd_tokens)
# 执行 command
os.system(command)
# 返回状态
return SHELL_STATUS_RUN

由于实现了 history 命令,所以在每一次执行命令时都会将该命令写入保存历史命令的文件 history_file 中,方便调用 history() 时读取。接下来判断输入的命令是否是内置实现的命令,如果是,则直接调用。不是的话则会判断当前操作系统类型,并根据操作系统类型调用不同的执行命令的函数。

总结

至此,用 Python 实现的 Shell 就全部介绍完毕了。相较于 C 中的实现,我做了以下几点优化:

  1. 优化了命令提示符功能,看起来更专业
  2. 低耦合的设计,可以动态地添加删除命令
  3. 根据不同的操作系统类型进行了适配(C中的实现仅支持 Linux)
  4. 可以在输入中使用变量

总之,Shell 就是这么一个读取用户命令,并将其解释执行的程序。通过实现这么一个简单的 Shell 让我对平时编程最常打交道的程序有了更清楚的认识,十分有趣。强烈建议大家自己动手实现一遍。

最后是各个命令的具体实现代码:

cd:

1
2
3
4
5
6
7
8
from .constants import *

def cd(args):
if len(args) > 0:
os.chdir(args[0])
else:
os.chdir(os.getenv('HOME'))
return SHELL_STATUS_RUN

exit:

1
2
3
4
from .constants import *

def exit(args):
return SHELL_STATUS_STOP

getenv:

1
2
3
4
5
6
from .constants import *

def getenv(args):
if len(args) > 0:
print(os.getenv(args[0]))
return SHELL_STATUS_RUN

history:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
from .constants import *

def history(args):
with open(HISTORY_PATH, 'r') as history_file:
lines = history_file.readlines()
limit = len(lines)
if len(args) > 0:
limit = int(args[0])
start = len(lines) - limit
for line_num, line in enumerate(lines):
if line_num >= start:
sys.stdout.write('%d %s' %(line_num + 1, line))

sys.stdout.flush()

return SHELL_STATUS_RUN
# Linux

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×