bash中命令的种类

命令的种类

Bash支持的命令包括以下三类:

  • shell函数:按照shell编程的语法构造的可多次调用的代码块, 与其他语言不同的是, shell中的函数没有形参列表, 但可以在调用函数时传递任意数量的参数, 函数内部通过$N的方式获取指定位置的参数. 我们可以用typeset -f命令查看当前shell中定义的所有函数函数, 通过下列命令可以直接显示函数名.
> typeset -f | awk '$2~/\(\)/ { print $1 }'
> typeset -f | sed -En "s/(.*) \(\)/\1/ p"
> typeset -f | grep "()"
  • 内置命令:由shell在源代码层面提供的命令,而不是存在于文件系统中的某个可执行文件。例如,用于进入或者切换目录的cd命令并不是某个外部文件,在执行内置命令时不需要fork子进程,也不需要使用exec加载外部可执行文件,因此不会触发磁盘I/O,执行内置命令相当于执行当前shell源代码中的一个函数。我们可以用type判断一个命令是否为内置命令:
> type cd alias
cd is a shell builtin
alias is a shell builtin
> compgen -b  # 显示所有内置命令
  • 外部可执行文件:一般为编译好的二进制文件, bash会在当前路径和PATH环境变量中的路径下寻找可执行文件, bash使用哈希表(内存中的数据存储区)记住可执行文件的完整路径名, 用于避免多次重复的全局搜索.
> man
What manual page do you want?
> type man
man is hashed (/usr/bin/man)  # 再次访问就已经被哈希表缓存了
> which man  # 查看外部命令的路径
/usr/bin/man

在进行命令查询时, 优先级为shell 函数 > 内置命令 > 外部可执行文件, 例如Ubuntu系统中/usr/bin/路径下自带GNU标准echo可执行文件, 而bash中也存在内置命令echo, 按照命令查询顺序, 在bash中使用echo时会优先执行内置命令. 在bash中还可以使用alias为常用的命令添加别名, 别名的优先级要高于以上三种常规命令.

> which ls
/usr/bin/ls  # 是外部命令
> type ls
ls is aliased to `ls --color=auto'   # 别名 ls 会自动显示颜色

命令的结构

实现 shell 函数命令

bash中定义函数时, 关键字function可以省略, 函数内部可能包括内置命令, 外部命令, 变量, 数组, 关键字等, 其本质就是代码块, 不过有一些类似其他语言中函数的特性. 函数的其他语法细节可以参考GNU Bash 手册.

function pskill()
{
    # 这个函数的功能类似于 pkill
    # 判断参数数量是否正确
    if [ $# -lt 1 ]; then
        echo "Usage: pskill <proc-name>"
        return -127
    fi

    local pid  # 设置作用域为本地, 也就是代码块内部
    # 不这么写就默认是全局变量

    pid=$(ps -ax | grep $1 | grep -v grep | awk '{ print $1 }')
    kill -9 $pid &  # 提交作业
    echo -n "killing $1 (process $pid)..."
    wait  # 等待作业完成
    echo "slaughtered."
}

相比其他语言, bash中的函数没有固定的形参列表, 好处是其调用形式更像普通的命令, 例如:函数名 <参数1> <参数2> ..., 但坏处是如果不在函数内部对参数的数量以及合法性进行判断, 很容易产生严重的运行时错误.

> pskill
Usage: pskill <proc-name>

> xclock &
[1] 3003
> pskill xclock
[2] 3043
killing xclock (process 3003)...[1]-  Killed                  xclock
[2]+  Done                    kill -9 $pid
slaughtered.

实现内置命令

本节使用5.0版本的GUN bash源代码进行讲解, 在修改源代码之前, 建议先确认你的环境是否能够成功配置并编译bash:

> git clone https://git.savannah.gnu.org/git/bash.git
> ./configure
> make  # 成功则会在当前目录下生成可执行文件

现在我们开始实现一个名为scarlet的内置命令, 其功能和eval相同. 我们令源代码根目录为$(topdir), 首先我们要在$(topdir)/builtins/路径下建立scarlet.def文件并填充如下内容:

$PRODUCES scarlet.c

$BUILTIN scarlet
$FUNCTION scarlet_builtin
$SHORT_DOC scarlet [arg ...]
Execute arguments as a shell command.

Combine ARGs into a single string, use the result as input to the shell,
and execute the resulting commands.

Exit Status:
Returns exit status of command or success if command is null.
$END

*.def格式的文件是bash内置命令的预定义文件, 在make过程中会先将$(topdir)/builtins/mkbuiltins.c文件编译为mkbuiltins, 然后使用这个工具将预定义文件转换为*.c格式, 再通过gcc将其编译为*.o文件, 并最终成为bash可执行文件的一部分. 使用mkbuiltins可以显著提升内置命令的编写效率, 因为这个工具帮你自动生成了大部分重复的代码, 你只需要配置以下几个变量:

  • $PRODUCES: 用于表示转换源代码目标文件的名字, 变量的命名注意不要与其他文件冲突, 之后我们还要在Makefile.in中进行配置.
  • $BUILTIN: 最终可调用的内置命令的名字, 命名规则与函数相同.
  • $FUNCTION: 命令的入口函数名, 命名规则与函数相同, 这个函数的实现也需要在这个文件中.
  • $SHORT_DOC: 命令的简要帮助文档, 以$END作为结束标志, 这部分内容在运行时可以通过命令 --help的方式查看.

接下来在我们需要在scarlet.def文件中实现scarlet_builtin函数:

#include <config.h>
#if defined (HAVE_UNISTD_H)
#  ifdef _MINIX
#    include <sys/types.h>
#  endif
#  include <unistd.h>
#endif

#include "../shell.h"
#include "bashgetopt.h"
#include "common.h"

int
scarlet_builtin (list)
     WORD_LIST *list;
{
  if (no_options (list))
    return (EX_USAGE);
  list = loptend;

  return (list ? evalstring (string_list (list), "scarlet", SEVAL_NOHIST) : EXECUTION_SUCCESS);
}

我们首先引用了必要的头文件用于处理输入, 主要是为了使用WORD_LIST这个数据结构, 它存储了bash对当前命令的分词结果token, 然后通过evalstring解析并执行这些token. 可以看出, 我们在编写这个内置命令时只需要处理核心业务逻辑, 其他部分都可以通过合理配置预定义变量让mkbuiltins自动生成. 接下来需要在Makefile.in文件中对编译生成的中间文件和引用的头文件进行配置:

# DEFSRC 变量中添加
$(srcdir)/scarlet.def

# OFILES 变量中添加
scarlet.o

# dependencies 区域中添加
scarlet.o: scarlet.def

# def files 区域中添加
scarlet.o: $(topdir)/command.h ../config.h $(BASHINCDIR)/memalloc.h
scarlet.o: $(topdir)/error.h $(topdir)/general.h $(topdir)/xmalloc.h
scarlet.o: $(topdir)/quit.h $(topdir)/dispose_cmd.h $(topdir)/make_cmd.h
scarlet.o: $(topdir)/subst.h $(topdir)/externs.h  $(topdir)/sig.h
scarlet.o: $(topdir)/shell.h $(topdir)/syntax.h $(topdir)/unwind_prot.h $(topdir)/variables.h $(topdir)/conftypes.h
scarlet.o: $(BASHINCDIR)/maxpath.h ../pathnames.h

处理完所有的预定义文件后, mkbuiltins还会生成用于存放所有内置命令接口的builtins.cbuiltext.h文件, 我们现在可以重新配置并编译bash来测试我们实现的内置命令scarlet的效果:

# 注意要进到 $(topdir)/ 路径下
> make clean; ./configure; make -j6
> ./bash
> scarlet --help
scarlet: scarlet [arg ...]
    Execute arguments as a shell command.
    
    Combine ARGs into a single string, use the result as input to the shell,
    and execute the resulting commands.
    
    Exit Status:
    Returns exit status of command or success if command is null.
> VAR=1; POINT+VAR
> echo \$$POINT
$VAR  # bash 默认只解释一次变量名
> scarlet echo \$$POINT
1  # 自定义内置变量实现了 eval 多次解释的功能

实现外部命令

大部分外部命令都是事先编译好的二进制可执行文件, 在bash中会通过fork-exec模式处理每个外部命令, 也就是说外部命令都运行在子进程中. 在本节将通过实现ls命令的最基础功能来讲解外部命令的实现过程:

#include "apue.h"  // 这个头文件需要自己去 APUE 网站下载
#include <dirent.h>  // 路径处理相关头文件

int main(int argc, char const *argv[])
{
    DIR *dp;  // 路径数据结构
    struct dirent *dirp;  // 子路径数据结构

    // 判断参数数量是否正确
    if (argc != 2)
        err_quit("usage: myls directory_name");

    // 判断传入的目录是否能打开
    if ((dp = opendir(argv[1])) == NULL)
        err_sys("can't open %s", argv[1]);

    // 遍历指定目录
    while ((dirp = readdir(dp)) != NULL)
    {
        printf("%s\n", dirp->d_name);
    }
    
    return 0;
}

这个例子来自于《Unix环境高级编程》第一章, 实现一个命令至少需要完成输出参数处理, 核心业务逻辑, 结果输出三个部分. 而且建议在参数输入错误时给出用法提示, 在运行出现问题时应该给出适当的退出状态码. 现在我们编译这个命令并将其放到PATH包含的目录中, 然后测试这个命令是否能正确输出.

> sudo gcc 1/ls.c -o /usr/bin/myls
> myls  # 用法错误
usage: myls directory_name  # 给出提示
> myls .  # 遍历当前路径
snap
peko
pekosh
R
.ssh

脚本的结构

bash中, 脚本就是命令和控制逻辑的组合, 我们先看一个普通脚本的例子:

#!/bin/bash

clear
echo "This is information provided by mysystem.sh.  Program starts now."

printf "Hello, $USER\n\n"

printf "Today's date is `date`, this is week `date +"%V"`.\n\n"

echo "These users are currently connected:"
w | cut -d " " -f 1 - | grep -v USER | sort -u
echo

printf "This is `uname -s` running on a `uname -m` processor.\n\n"

echo "This is the uptime information:"
uptime
echo

echo "That's all folks!"
  • #!/bin/bashshabang, 用于指定这个脚本的解释器。
  • /usr/bin/clear是一个外部命令,用于清除当前shell中的输出信息。
  • echoprintf都是bash的内置命令,不同的是echo始终以0状态码退出(退出状态永远是成功),并且仅在标准输出上打印参数,然后打印行尾字符,而printf允许定义格式字符串,并在失败时给出非零的退出状态代码。
  • USER是一个变量,用于存储当前用户的名字,需要使用$取出变量的值。

脚本有两种执行方式:

  • source.: 这种方式是在当前shell中执行脚本, 等价于用{}包裹的代码块, 但要注意这种执行方法可能会污染当前shell中的变量.
  • ./脚本名bash 脚本名: 这种方式本质上是执行外部变量bash并将脚本名作为参数传递给命令, 这种方法启动的脚本会运行在child shell中, 不可以访问父shell的全局变量, 只能访问环境变量. 需要注意的是使用./脚本名方法执行脚本需要在脚本第一行配置shabang.

我们再来看一个初始化脚本upon-sound的例子:

#!/bin/bash

case "$1" in  # 脚本的控制逻辑,根据输入选择要执行的分支
'start')  # 服务启动时执行
  cat /usr/share/audio/at_your_service.au > /dev/audio
  ;;
'stop')  # 服务停止时执行
  cat /usr/share/audio/oh_no_not_again.au > /dev/audio
  ;;
esac
exit 0

初始化脚本(启动脚本)存储在/etc/rc.d/init.d/etc/init.d目录下,用于启动系统服务,例如:系统日志服务,电源管理服务,名称和邮件服务。PID=1的初始化进程init读取其配置文件,并决定在每个运行级别中启动或停止哪些服务。

> mv upon-sound /etc/init.d/upon-sound
> ln -s /etc/init.d/upon-sound /etc/rc3.d/S99upon-sound  # 运行级别为3 启动时调用 start
> ln -s /etc/init.d/upon-soudecanshund /etc/rc0.d/K01upon-sound  # 运行级别为0 关机时调用 stop

参考内容

Something you didn't know about functions in bash
Adding builtins for bash

阅读 336

推荐阅读
Bash高级编程
用户专栏

结合源码与示例深度剖析bash中的各种复杂机制, 需要读者掌握C语言和操作系统的相关知识.

2 人关注
9 篇文章
专栏主页