跳转到内容

Shell

来自ACM Class Wiki


初识 Shell

(本文中 shell 代指命令行终端环境。)

Shell 是一个通过命令行来操作计算机的方式,在 UNIX/Linux 环境下较为常见。与常见的图形化操作不同,shell 通过纯文本的方式提交指令。很多情况下我们需要用到 shell 来进行一些工作 (比如一些工具只能从命令行跑起来,很多服务器上都没有提供图形化操作界面)。

在 WSL (Windows Subsystem for Linux) 下,我们可以用 Windows Terminal 打开一个 shell,也可以通过在 Windows CMD 中输入 wslbash 打开。

默认的 shell 界面大概是这样的:

acm@acm-box:~$

它的意思是:

acm@acm-box:~$
^^^            当前登录用户是 acm

acm@acm-box:~$
    ^^^^^^^    当前计算机名是 acm-box

acm@acm-box:~$
            ^  当前工作目录是 ~

acm@acm-box:~$
             ^ 请输入命令

在下文的叙述中,我们会把你需要输入的部分用粗体标示出来;为了简便起见,通常我们会省略 $ 前的部分。例如,当示例里出现

$ ls

你的 shell 里实际显示的可能是:

acm@acm-box:~$ ls

基本操作

目录操作

每个 shell 都有一个 工作目录 (Working Directory) 的概念 (也叫 当前目录 Current Working Directory 简称 cwd),很多命令的参数都是相对于工作目录的。我们可以通过 pwd (Print Working Directory) 命令来查看当前工作目录:

$ pwd
/home/acm/

/home/用户名/ 是一个特殊的目录,我们把它称作 家目录 (Home),类似于 Windows 上的主目录 (C:\Users\用户名\)。习惯上,Linux 用户会把自己用的文件 (例如文档、图片、代码等等) 存放在这个目录里。

我们可以用 ls (LiSt) 来列出当前目录下的所有文件:

$ ls
Code
Desktop
Documents
Downloads
(此处省略更多内容)

可以通过 ls-l (long) 选项来查看文件的更多细节:

$ ls -l
total 13150952
drwxr-xr-x 29 acm users      69632 Oct 13 22:56  Documents
drwxr-xr-x 15 acm users      57344 Nov  4 20:11  Downloads
-rw-r--r--  1 acm users   19242084 Aug 30  2021  MicrosoftYaHeiUI-02.ttf
(此处省略更多内容)

如果再加上 -h (human) 选项,输出中的文件大小会用 K/M/G 等单位表示:

$ ls -l -h
total 13G
drwxr-xr-x 29 acm users  68K Oct 13 22:56  Documents
drwxr-xr-x 15 acm users  56K Nov  4 20:11  Downloads
-rw-r--r--  1 acm users  19M Aug 30  2021  MicrosoftYaHeiUI-02.ttf
(此处省略更多内容)

我们一般把文件名以 . (点) 开头的文件视作隐藏文件,它们不会出现在 ls 的输出里。可以通过 -a (all) 选项来让 ls 显示这些文件:

$ ls -a
.
..
.Xauthority
.Xilinx
.aMule
.acme.sh
(此处省略更多内容)

... 是两个特殊的文件:. 代表当前目录本身,.. 代表上一级目录。例如在 /home/acm/ 里,. 就是 /home/acm/../home/

同时使用不同的单字母选项时,这些选项可以直接拼到一起,例如 -l -a -h 可以用 -lah 代替:

$ ls -lah
total 13G
drwx------  96 acm  users  12K Nov  5 10:15  .
drwxr-xr-x   4 root root  4.0K Aug  7  2021  ..
-rw-------   1 acm  users  546 Nov  2 21:10  .Xauthority
drwxr-xr-x   5 acm  users 4.0K Oct 29 19:05  .Xilinx
drwxr-xr-x   4 acm  users 4.0K Mar 13  2022  .aMule
(此处省略更多内容)

如果你忘记了上面这些选项,可以用 --help 查看帮助。很多程序都可以用 --help 查看帮助信息。

$ ls --help
用法:ls [选项]... [文件]...
列出 <文件>(默认为当前目录)的信息。
如果既没有指定 -cftuvSUX 中任何一个,也没有指定 --sort,则按字母排序项目。

长选项的必选参数对于短选项也是必选的。
  -a, --all                  不要隐藏以 . 开头的项目
  -A, --almost-all           列出除 . 及 .. 以外的所有项目
      --author               与 -l 同时使用时,列出每个文件的作者
  -b, --escape               以 C 风格的转义序列表示不可打印的字符
(此处省略更多内容)

你可以用 cd (Change Directory) 来进入其他目录:

$ pwd
/home/acm
$ cd Desktop
$ pwd
/home/acm/Desktop
$ cd ..
$ pwd
/home/acm

你也可以直接跳进多级子目录:

$ pwd
/home/acm
$ cd Code/cpp
$ pwd
/home/acm/Code/cpp
$ cd ../..
$ pwd
/home/acm

当传给 cd 的目录名称以 / 开头的时候,它会认为这是一个绝对的路径 (而不是相对与当前目录而言的):

$ pwd
/home/acm
$ cd /home/acm/Code/cpp
$ pwd
/home/acm/Code/cpp
$ cd /home/acm/
$ pwd
/home/acm

你可以用 ~ 来指代你的家目录:

$ pwd
/home/acm/Desktop
$ cd ~/Code/cpp
$ pwd
/home/acm/Code/cpp

在命令打到一半的时候,可以用 ↹ Tab 键来让 shell 自动补全剩下的部分。

创建目录用 mkdir (MaKe DIRectory),删除空目录用 rmdir (ReMove DIRectory):

$ ls
hello
$ mkdir world
$ ls
hello
world
$ rmdir world
$ ls
hello

访问和修改文件

输出文件内容的命令叫 cat (Concatenate,意为拼接):

$ ls
hello.cpp
$ cat hello.cpp
#include <iostream>
int main () {
  std::cout << "Hello World!" << std::endl;
  return 0;
}

echo 命令可以把从命令行输入的参数直接输出出来:

$ echo hello world
hello world

这看起来没什么用,但是结合后面的输出重定向,就可以向文件里写内容:

$ echo hello world > f
$ cat f
hello world

你可以用文本编辑器编辑文件。常见的命令行文本编辑器有 nano、vim 和 emacs。其中后两者使用比较复杂,初学者推荐使用 nano。

进入 nano 后你将会看到一个类似于这样的界面:

$ nano hello.cpp
  GNU nano 6.4                        hello.cpp                                 
#include <iostream>
int main () {
  std::cout << "Hello World!" << std::endl;
  return 0;
}
 

                                [ 已读取 5 行 ]
^G 帮助      ^O 写入      ^W 搜索      ^K 剪切      ^T 执行命令  ^C 位置
^X 离开      ^R 读档      ^\ 替换      ^U 粘贴      ^J 对齐      ^/ 跳行

你可以用键盘上的上下左右键移动光标。修改完成之后,用 Ctrl+X 退出,然后根据提示先后按下 Y⏎ Enter。(一般来说,显示 ^X 的意思就是 Ctrl+X,有时候也写作 C-x。Alt+X 则写作 M-x。)

附:如何退出 vim

先点击若干次 Esc,保证没有类似于 -- 插入 ---- INSERT -- 的标示,然后输入 :wq 保存并退出,或者 :q! 不保存并退出。

你可以用 cp (CoPy) 命令复制单个文件。要复制一个目录,需要用 -r (Recursive) 选项。这里递归的意思是说,如果你要复制一个目录,那么你要递归地进入目录和目录的子目录和目录的子目录的子目录……来复制所有的文件。

$ echo hello > f
$ ls
f
$ cp f g
$ ls
f
g
$ cat g
hello

类似地,可以用 mv (MoVe) 命令移动或者重命名文件或目录 (但是不需要加 -R)。

删除文件则用 rm (ReMove),删除目录还要加上 -r。你可能在一些地方看到这段代码:(请务必不要执行这段命令!)

请务必不要执行这段命令!
$ su​do r​m -r​f /​*

这段命令的意思是,用最高管理员权限递归删除计算机上的 所有文件 并忽略警告。(简单来说:删库跑路)

编译代码

使用 g++ 命令可以编译 C++ 代码。(编译 C 代码则用 gcc。) 例如:

$ ls
hello.cpp
$ cat hello.cpp
#include <iostream>
int main () {
  std::cout << "Hello World!" << std::endl;
  return 0;
}
$ g++ hello.cpp
$ ls
a.out
hello.cpp
$ ./a.out
Hello World!

g++gcc 编译出的可执行文件默认放在当前目录下的 a.out 这个文件里。由于 a.out 在当前目录下,而不在系统的程序目录下,所以运行的时候不能直接输入 a.out,而是要加上 ./。可以用 -o 指定输出文件的文件名:

$ g++ hello.cpp -o hello
$ ls
hello
hello.cpp
$ hello
Hello World!

可以用一系列的 -O 选项来启动优化。其中优化运行时间的选项有 -O-O2-O3-Ofast。我们的 Online Judge 上使用的是 -O2。当你的代码并不完全符合 C++ 规范的时候,开 -O2 和不开优化有时候会使程序的行为不同。

默认情况下编译器会对一些行为报 warning,但是有一些 warning 需要手动启用。常用的 warning 选项是 -Wall -Wextra。我们建议在编译的时候加上这些选项并认真阅读编译器输出的 warning 信息,这对 debug 会有很大的帮助。

大家都知道在 C++ 里有一些未定义行为,比如访问越界、整数溢出等等。默认情况下,程序可能会表现出一些奇怪的现象。我们可以让编译器在生成的代码里插入一些指令来检测这些行为有没有发生,编译参数是 -fsanitize=undefined (检测未定义行为) 和 -fsanitize=address (检测非法内存访问)。一起用则是 -fsanitize=undefined,address。例如:

$ cat hello.cpp
int main () {
  int bad = 2147483647;
  ++bad;
  return 0;
}
$ g++ hello.cpp -fsanitize=undefined
$ ls
a.out
hello.cpp
$ ./a.out
hello.cpp:3:3: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

你可以用 Ctrl+C 来中止一个程序:

$ cat hello.cpp
int main () {
  while (true) continue;
}
$ g++ hello.cpp
$ ./a.out
按下 Ctrl-C
$

输入输出重定向

在 Linux 系统里,一个程序默认有三条输入输出的通道:

  • 通道 0 表示标准输入 (stdin / std::cin)
  • 通道 1 表示标准输出 (stdout / std::cout)
  • 通道 2 表示标准错误 (stderr / std::cerr)

在 shell 里,我们可以把这三个通道重定向到文件 (类似于 freopen)。例如,如果我们要从 1.in 里读数据,输出到 1.out 里,并把标准错误写到 1.err 里,可以这样:

$ ./a.out <1.in >1.out 2>1.err
$ ls
1.err
1.in
1.out
a.out
hello.cpp

务必注意:如果一个被输出文件在执行命令前已经存在了,那么它会在程序执行前先被 清空。如果你不想让它被清空,而是想让程序输出到文件末尾,可以用 >> 代替 >

有时候我们想要把 stdout 和 stderr 的内容都输出到同一个文件里,这时候可以用 2>&1

$ ./a.out >1.out 2>&1
$ ls
1.out
a.out
hello.cpp

比较文件内容

比较两个文件的命令是 diff

$ cat 1.out
abcdefg
fghij
1949
10
1
$ cat 1.ans
abcdefg
fffff
1949
10
1
$ diff 1.out 1.ans
2c2
< fghij
---
> fffff

在评测时,我们使用 -ZB 来忽略行末空格和空行的差异,这样行末有没有空格、文末有没有换行不会影响答案的正确与否。在调试的时候,我们建议使用 -uZB 来做 diff:(1.{out,ans}1.out 1.ans 的缩写)

$ diff -uZB 1.{out,ans}
--- 1.out       2022-11-05 11:45:10.335901602 +0800
+++ 1.ans       2022-11-05 11:45:35.044658842 +0800
@@ -1,5 +1,5 @@
 abcdefg
-fghij
+fffff
 1949
 10
 1

如果你需要将 diff -u ... 产生的输出当成补丁 (patch) 发送给别人,或是需要关注两个文件顺序对输出的影响,文件的顺序应当为先旧后新 (diff base new)。

man

Linux 有一个自带的查文档机制,称作 man pages (man for MANual)。大多数命令都可以用 man 查到对应的比较详细的文档。Linux 的系统调用、配置文件等等也能在 man 里查到。

$ man 1 cat
CAT(1)                           User Commands                          CAT(1)

NAME
       cat - concatenate files and print on the standard output

SYNOPSIS
       cat [OPTION]... [FILE]...

DESCRIPTION
       Concatenate FILE(s) to standard output.
(此处省略更多内容)

进阶操作

glob

有时候我们可能要批量操作一些文件名相似的文件。例如,我们跑测试程序生成了 1.out 2.out … 20.out,现在想把他们全都删掉。这时候可以直接用

$ rm *.out

来删除这些所有文件,就不用手打所有 20 个文件名了。同样,如果我们有 test1/1.out test1/2.out test1/3.out test2/1.out test2/2.out test2/3.out …,那么我们可以用 test*/*.out 来匹配这些所有文件。

管道和分页

在我们调试的时候,输出文件经常长达几千到几十万行,这时候如果用 cat 输出,那么输出就会刷屏,看不到你想看的内容。这时候额可以用 less 代替 catless 会输出文件的第一页,你可以用键盘的上下键来切换到上下的行,也可以用空格来跳到下一页。

如果程序的输出很长,我们也可以把程序的输出直接用 管道 (pipe) 传到 less 里:

$ ./a.out | less

这就相当于把 ./a.out 的标准输出接到 less 的标准输入上。

管道也可以用来连接其他命令。比如,可以这样来比较 ./a.out 的输出和 1.ans

$ ./a.out | diff - 1.ans

其中 - 表示让 diff 从标准输入里读入。

权限管理

在 Linux 里,很多东西都属于一个用户。比如,每个程序都是一个用户运行的,每个文件都是一个用户拥有的。在 Linux 里,同样有 用户组 (user group),正常来说,每个用户都属于至少一个用户组,每个程序有一个所属的用户组,每个文件也都是一个用户组所拥有的。在 ls -l 的输出里:

$ ls -l
total 13150952
-rw-r--r--  1 acm users   19242084 Aug 30  2021  MicrosoftYaHeiUI-02.ttf

acm 就是这个文件的所属用户,users 就是这个文件的所属用户组。

Linux 里基础的文件权限分为三个部分:用户权限、组权限和其他用户权限。每组权限有三个权限位 rwx,分别代表读 Read、写 Write、执行 eXecute。例如上面的 -rw-r--r--,就代表用户权限可读可写不可执行,组权限可读不可写不可执行,其他用户权限可读不可写不可执行。我们经常用一个八进制数表示一个文件的权限,例如上面的文件权限是 0644。其他常见的权限有 0600、0640、0755、0750、0700。(目录权限里的可执行位表示可不可以用类似 ls 的命令列出目录内容,所以一般来说目录的权限都有可执行位。)

拥有文件的用户可以用 chmod 命令更改一个文件的权限位:

$ mkdir hello
$ ls -l
total 0
drwxr-xr-x 2 acm users 40 Nov  5 19:44 hello
$ chmod 0700 hello
$ ls -l
total 0
drwx------ 2 acm users 40 Nov  5 19:44 hello

在 Linux 系统里,大部分系统文件的所有者都是一个特殊的用户 root,所以试图直接修改会被拒绝:

$ cd /
$ ls -ld
drwxr-xr-x 19 root root 4096 Jun  9 15:05 .
$ touch fish
touch: 无法 touch 'fish': 权限不够

如果你确定你想修改,可以使用 sudo 命令来用 root 身份来执行一条命令:

$ cd /
$ sudo touch fish
[sudo] acm 的密码:输入你的密码然后打回车,注意这里的输入不会显示出来,并不是你没打进去
$ ls -l fish
-rw-r--r-- 1 root root 0 Nov  5 19:50 fish
$ rm fish
rm: 无法删除 'fish': 权限不够
$ sudo rm fish

包管理

与 Windows 上安装软件「下载 .exe 并执行、下一步、同意、下一步、下一步、下一步、完成」的习惯不同,Linux 上安装软件经常借助操作系统自带的包管理器。不同的 Linux 发行版 (distribution) 有不同的包管理命令。Ubuntu 的包管理系统是 apt,它的基本命令有以下这些:

  • apt update:更新软件包索引。一般的包管理器都会在本地维护一个所有软件包的清单,这个命令会从网络上同步这个清单。
  • apt install 软件包名称:安装对应的软件包。
  • apt autoremove 软件包名称:卸载对应的软件包。
  • apt upgrade:升级系统上的所有软件包(就相当于更新系统)。

注意,安装或者卸载软件包就相当于改变了操作系统,所以需要 sudo 才能执行。

如果你在 apt install 的时候遇到了 404 错误,很可能是你本地的软件包清单里,你要装的软件还停留在一个旧版本,而这个版本已经下不到了。此时 apt update 就要解决。

后台运行

有时候我们想在同时运行多个命令,又不想开多个终端;这时候就可以用后台运行模式启动命令。后台运行的方式是在想运行的命令后面加一个 &

$ ./a.out &
[1] 64661
$

注意此时会立即返回 shell,即使程序还在跑。

也有的时候,我们开着一个程序,但是想在同一个终端里切到另一个程序,这时候可以用 Ctrl+Z 把程序暂停运行。一般来说按下之后就会立即返回到 shell 界面。当你想回到原来的程序的时候,可以使用 fg 命令:

$ python3
Python 3.10.6 (main, Aug  3 2022, 17:39:45) [GCC 12.1.1 20220730] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> Ctrl+Z
[1]+  已停止               python3
$ cat data
123 4566 7788
$ fg
python3
>>> 123 + 4566 + 7788
12477
>>> 

一行里执行多条命令

在 shell 里打完一条指令是不需要以分号结尾的,只需要回车即可;但是如果你想在一行里打多条指令,就需要分号了:

$ ./a.out > 1.out; cat 1.out

有时候(比如程序 RE 的时候),我们会想让后一条指令只在前一条指令成功运行的时候才运行,这时候可以用 && 代替 ;

$ ./a.out > 1.out && cat 1.out

当然,你也可以让后一条指令只在前一条指令运行失败(即返回值非 0)的时候才运行,这时候可以用 || 代替 ;

环境变量

有时候,给程序传入的参数不是在命令行里显式写出的,而是用一个被称作 环境变量 (environment variable) 的机制传入的。大家都知道 C/C++ 里的 main 函数可以这样写:

int main (int argc, char **argv);

实际上,main 还有一个三参数版本:

int main (int argc, char **argv, char **envp);

其中 envp 就是传入的环境变量。(其实,即使是在用双参数版本的 main 时,环境变量也会正常传入,这个行为甚至导致了一些意料之外的严重漏洞,有兴趣的同学可以参考阅读 Pwnkit。)

你可以用 env 命令查看当前定义的所有环境变量,或者用 $变量名查看单个变量:

$ env
COLORFGBG=15;0
COLORTERM=yes
CUDA_PATH=/opt/cuda
(此处省略更多内容)
$ echo $CUDA_PATH
/opt/cuda

要新增定义一个环境变量,可以执行 export 变量名=变量值 (需要注意的是,等号两边不可以有空格)。这个环境变量会对这个终端里以后执行的所有命令生效,但是如果切换或者重启了终端,或者通过 sudo 切换了用户,这个变量就不再有效了。你也可以通过在命令前面加声明环境变量的方式来创建一个作用域仅限这条命令的环境变量:变量名=变量值 命令

$ A=1 env
A=1
COLORFGBG=15;0
(此处省略更多内容)
$ env
COLORFGBG=15;0
(此处省略更多内容)
$ export A=1
$ env
A=1
COLORFGBG=15;0
(此处省略更多内容)

如果要让这个环境变量在每次打开终端的时候都会自动设上,可以把 export 语句写到一个叫 ~/.bashrc 的特殊文件里,这个文件里的命令会在 bash 打开的时候自动运行。(zsh 会执行的文件叫 ~/.zshrc,其他 shell 也有类似的文件。)

之前我们说过,执行 cat 等系统命令的时候不用打绝对路径,而执行 a.out 这种自己编译的程序的时候就需要打 ./;这个行为其实是由一个叫 PATH 的环境变量控制的:

$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
$ ls
a.out
$ a.out
a.out: command not found
$ ./a.out
Hello, World!
$ export PATH=$PATH:.
$ a.out
Hello, World!

一些工具命令

  • seq 起始 终止:打印出起始到终止的所有整数(闭区间)。
  • sort:对输入以行为单位排序。
  • sed 's/查找内容/替换内容/g':将输入里的所有查找内容替换为替换内容。
  • grep 查找内容:在输入里查找查找内容。
  • which 命令:输出命令的绝对路径。whereis 也有类似的功能。
  • ps:输出这个终端里正在执行的程序(包含 shell 和 ps 本身)。
  • kill 进程编号killall 程序名称:发出信号让对应进程终止。
  • kill -9 进程编号killall 程序名称:发出信号让对应进程强行立即终止。

Shell 脚本

有些时候,我们会想写一个简单的脚本来自动化一些任务。比如说,编译程序并依次运行所有测试点,然后将输出和答案对比。这种情况下写一个 C++ 程序未免有点麻烦,就可以直接写一个 shell script 解决需求。

一个 shell script 就是一个特殊的文本文件,包含一系列 shell 命令。需要注意的是,这个文件需要有可执行权限(需要 chmod +x)。比如上面的需求就可以这样写:

$ cat judge
#!/bin/bash
g++ code.cpp -o code
./code < 1.in > 1.out
diff -qZB 1.out 1.ans
$ ./judge

第一行是一个特殊指令,用来告诉系统这是一个 bash 脚本。后面的指令就会被 bash 依次执行。bash 里也可以设置变量,方法和环境变量类似:

#!/bin/bash
PROBLEM=1024
g++ $PROBLEM.cpp -o $PROBLEM
./$PROBLEM < $PROBLEM.in > $PROBLEM.out
diff -qZB $PROBLEM.{out,ans}

这就相当于:

#!/bin/bash
g++ 1024.cpp -o 1024
./1024 < 1024.in > 1024.out
diff -qZB 1024.{out,ans}

如果你有一系列测试点要跑,可以用 for 循环:

#!/bin/bash
for i in $(seq 1 10); do
  ./code < $i.in > $i.out
  diff -qZB $i.{out,ans}
done

shell 也有 if 语句:

#!/bin/bash
if ./code < $i.in > $i.out; then
  diff -qZB $i.{out,ans}
else
  echo "RE for testpoint $i"
fi

shell 里还有类似的很多语法,这里就不一一介绍了。

Subshell

在 shell 里我们可以把程序的输出作为其他程序的命令行参数传入:

$ cat file
hello world
$ echo $(cat file)
hello world

这种 subshell 是可以嵌套的:

$ echo $(echo $(echo $(cat file)))
hello world

需要注意的是,在上面的情况下,helloworld 是作为两个参数传入的;如果要作为一个参数 hello world 就需要加引号:

$ echo "$(cat file)"
hello world

补充阅读

中科大 LUG 编写的 Linux 101