2. 命令行与目录操作
设想这样一个常见的场景:同一个数据分析程序需要依次处理十个不同的文本输入文件,并且要把每一组处理结果分别持久化保存下来。如果局限在图形界面的 IDE 中操作,开发者往往需要反复修改十次运行配置、连续点击十次运行按钮,再逐一把控制台输出拷贝粘贴到各个目标文件中;但在命令行环境中,通过 Shell 的批量通配与重定向,这一整套流程只需敲下一行简洁的指令即可完成。下面我们以系统自带的行数统计工具 wc 来模拟这一处理过程:
$ wc -l inputs/*.txt
9 inputs/01.txt
16 inputs/02.txt
23 inputs/03.txt
7 inputs/04.txt
14 inputs/05.txt
21 inputs/06.txt
5 inputs/07.txt
12 inputs/08.txt
19 inputs/09.txt
3 inputs/10.txt
129 total
$ wc -l inputs/*.txt > lines.txt
$ cat lines.txt
9 inputs/01.txt
16 inputs/02.txt
23 inputs/03.txt
7 inputs/04.txt
14 inputs/05.txt
21 inputs/06.txt
5 inputs/07.txt
12 inputs/08.txt
19 inputs/09.txt
3 inputs/10.txt
129 total2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
此处的 inputs/ 对应教程代码仓库中 code/开发与工程基础/2_命令行与目录操作/inputs/ 下的十个文本样本,各文件的文本长度不一,wc 打印的最后一行则是全部样本的累积行数。只要将终端的工作目录切换至 code/开发与工程基础/2_命令行与目录操作/,便能完全复现上述终端交互。
在真实的软件工程中,这种基础操作贯穿始终:自由游走于层级目录之间、归拢管理各类源文件与中间产物、利用管道把多个独立工具编织成流水线、以及精确捕获前序任务的退出状态以决断后续流程。不论是手写编译选项、触发自动化单元测试,还是跟踪内存分析,终端交互都是最直接高效的控制台。熟练掌握这套操作手感,是后续深入工程构建与系统开发的基石。
正文中的命令均以工业界最为通用的 Bash(Bourne Again Shell)规范呈现。在 Linux 与 macOS 环境下,系统终端默认搭载的 Bash 或 Zsh 在绝大多数语法上彼此兼容;Windows 平台读者在依照第 1 章装好 MSYS2 之后,在「MSYS2 UCRT64」终端里即可无缝实验。文中所列出的终端执行截获均以 Debian 13 环境下的 Bash 5.2 实测为基准,涉及到 Windows 平台特有的差异点均有对应的提示说明。
1. 终端、shell 与命令
终端(terminal)本质上是宿主操作系统中负责字符展示与键盘输入事件分发的视窗界面;而 Shell 则是常驻在此视窗之后、负责读取用户键盘输入指令并进行语法解析与分发调度的核心进程。在日常使用中,这两者紧密结合在同一个界面内,容易被初学者混为一谈。一旦关闭终端窗口,内部宿主的 Shell 进程会随之接收信号并退出;而在光标闪烁处键入的每一串字符,都必须交由 Shell 完成拆解与调度。
本教程以经典的 Bash 为讲解主干。一条标准命令通常由三项要素构成:要调用的可执行命令名、微调其运行行为的选项(option)、以及指定其操作目标的数据参数(argument)。以基础的文件查看命令 ls 为例,输入 ls --help 能够打印出其标准的参数规范:
$ ls --help | head -n 8
Usage: ls [OPTION]... [FILE]...
List information about the FILEs (the current directory by default).
Sort entries alphabetically if none of -cftuvSUX nor --sort is specified.
Mandatory arguments to long options are mandatory for short options too.
-a, --all do not ignore entries starting with .
-A, --almost-all do not list implied . and ..
--author with -l, print the author of each file2
3
4
5
6
7
8
9
在帮助文档的格式描述中,方括号包裹的 [OPTION] 与 [FILE] 表明该项属于可选项,若执行时不予指定,程序便会退回其内置的默认行为。选项的声明方式通常包含短选项与长选项两种风格:短选项以单横线引领单个字母,长选项则以双横线引领完整的语义单词,例如 -a 与 --all 在行为上完全对等。多个单字母短选项通常支持合并书写,因此 ls -l -a 与紧凑的 ls -la 是等价的。当某个长选项需要传递具体取值时,既可以采用等号连接(--color=auto),也可以采用空格将其分隔(--color auto)。
长选项 --help 是 Unix 体系中大多数标准命令行工具都遵循的快速查阅入口。在系统完整安装了手册页(Manual Pages)的前提下,执行 man ls 会拉起一个功能更全面的格式化手册阅览器,在其中可以上下翻页检索,按 q 键退出。而在一些轻量化的容器镜像或极简部署环境中,man 命令可能并未默认安装,此时 --help 便成了最直接的求助渠道。
对于 Shell 内建命令,其帮助信息的查询途径稍有不同,通常通过 help 内部命令名 来调用,例如 help cd。
终端窗口行首出现的 $ 符号是系统提示符(prompt),代表当前的 Shell 已经就绪并正在静候用户输入,实际敲击命令时无需键入它。在后文所有的交互演示中,均以行首的 $ 标明用户输入的具体指令。需要谨记的是,在 Unix 哲学下命令名称是严格区分大小写的,敲击 LS 绝不会被识别为 ls。
2. 工作目录与路径
2.1 当前目录、绝对路径与相对路径
在 Shell 会话中,任何时刻都存在一个工作目录(working directory)作为当前上下文锚点。所有未加前缀修饰的裸文件名或路径,均默认相对于当前工作目录进行解析。调用 pwd(Print Working Directory)可以随时打印出当前所处的完整路径。
文件系统中的路径表示法主要分为两类:凡是以文件系统根节点 / 起始的路径统称为绝对路径(absolute path),它在整台机器的文件树中拥有全局唯一的寻址语义;而未从根节点写起的路径则统称为相对路径(relative path),其最终指向取决于当前终端停留在哪一个工作目录。在路径书写中,. 明确代表当前工作目录自身,.. 代表当前目录的直接父级目录(上一层),而波浪号 ~ 则由 Shell 自动展开为当前登录用户的主工作目录(Home 目录)。
本节后续的所有路径与命令演示,均依托于配套仓库中的练习目录拓扑:src 用于归档 C 语言源程序,include 用于存放头文件,build 预留为编译产物的输出路径,根目录下还放置了 text.txt 与 notes.txt 两个辅助文本样本。将 code/开发与工程基础/2_命令行与目录操作/ 复制到本地工作区,便能获得完全一致的目录环境:
$ cd /home/minsecrus/tutorial/work/p071/practice
$ pwd
/home/minsecrus/tutorial/work/p071/practice
$ ls
build include notes.txt src text.txt
$ ls -a
. .. build include notes.txt src text.txt
$ ls -l
total 20
drwxrwxr-x 2 minsecrus minsecrus 4096 Oct 21 02:22 build
drwxrwxr-x 2 minsecrus minsecrus 4096 Oct 21 02:22 include
-rw-r--r-- 1 minsecrus minsecrus 98 Oct 21 02:22 notes.txt
drwxrwxr-x 2 minsecrus minsecrus 4096 Oct 21 02:22 src
-rw-r--r-- 1 minsecrus minsecrus 315 Oct 21 02:22 text.txt
$ ls src
args.c bad.c hello.c mix.c upper.c
$ cd src
$ pwd
/home/minsecrus/tutorial/work/p071/practice/src
$ cd ../include
$ pwd
/home/minsecrus/tutorial/work/p071/practice/include
$ cd ..
$ pwd
/home/minsecrus/tutorial/work/p071/practice
$ cd -
/home/minsecrus/tutorial/work/p071/practice/include
$ cd ~
$ pwd
/home/minsecrus2
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
ls 默认会忽略所有以句点 . 开头的隐藏项(在 Unix 惯例中称为点文件或 dotfile);附带 -a(--all)选项则会指示其列出目录下的一切条目,因此当前目录 . 与父目录 .. 也会显现出来。当使用详细列表选项 -l 时,输出中每一行的首字符标明了文件节点类型:d 代表目录(directory),短横线 - 代表常规普通文件;紧随其后的九个字符代表 POSIX 读写与执行权限位(在系统编程部分第 3 章中有专门推演);其后依次排列着硬链接计数、所有者用户名、所属用户组、字节大小、最后修改时间戳以及文件名称。
目录切换命令 cd 拥有几处十分方便的捷径:若不带任何参数直接回车,它会自动切换回用户主目录(与 cd ~ 效果一致);而 cd - 则会快速跳回上一次所停留的工作目录,并在终端中回显该目录路径,这在两处深层目录之间来回往返调试时极其高效。当尝试切换到一个并不存在的路径时,cd 命令会直接报错中断,并返回非 0 的退出状态码(通常为 1),我们在第 5 节将深入剖析退出状态码的机制。
相对路径中的 .. 允许连续叠加组合,例如 ../../include 意为向上退回两级父目录后再步入 include 子目录。至于表示当前目录的 .,在工程中最常见的用途是显式消除歧义,例如执行当前目录下的二进制程序必须写作 ./程序名,这背后的安全机制我们在第 4 节会展开。
2.2 含空格的路径与 Windows 盘符
当路径名或文件名中不可避免地出现空格字符时,必须使用单引号或双引号将整段路径包裹起来。如果不加引号保护,Shell 的词法分析器就会默认将空格视为命令行参数的天然断句标志,从而把单个完整路径强行切分为两个彼此独立的参数片段。下面演示创建一个含有空格的子目录:
$ mkdir "my notes"
$ cd "my notes"
$ pwd
/home/minsecrus/tutorial/work/p071/practice/my notes
$ cd ..
$ ls
build include 'my notes' notes.txt src text.txt
$ rmdir "my notes"2
3
4
5
6
7
8
在上述 ls 的输出中,该目录项外围被自动包裹上了单引号 'my notes'。这属于现代 coreutils 工具为了提示终端用户「该文件名内部包含特殊字符或空白」而采取的防护性显示修饰,文件系统里实际登记的文件名并没有包含那些引号字符。
在 Windows 生态下,磁盘盘符(如 C:\、D:\)在 MSYS2 与 Git Bash 等 POSIX 模拟运行环境中,会被映射为以根斜杠开头的单层目录,即 /c 与 /d。例如绝对路径 C:\Users\alice\proj 在类 Unix 终端中对应写作 /c/Users/alice/proj。在编写跨平台环境脚本时,可以借助 MSYS2 附带的 cygpath 工具在两套体系之间快速转换:
$ cygpath -w /c/Users/alice/proj
C:\Users\alice\proj
$ cygpath -u 'C:\Users\alice\proj'
/c/Users/alice/proj
$ cygpath -m /c/Users/alice/proj
C:/Users/alice/proj2
3
4
5
6
参数 -w 输出纯粹的 Windows 风格路径;-u 转换为 POSIX 风格的 Unix 虚拟路径;而 -m 则输出保留 Windows 盘符但将反斜杠替换为正斜杠的混合模式。当需要把路径转交由仅识别纯 Windows API 的外部工具时,通常选用 -w;而在编写 Makefile 这类可能同时被原生工具与 Unix 工具交叉读取的脚本时,使用 -m 能最大程度规避语法歧义。需要特别提防的是,在 Bash 语法中反斜杠 \ 充当转义字符,直接输入 echo C:\Users 时反斜杠会被 Shell 自身吞掉而打印成 C:Users。因此,在命令行中输入 Windows 路径时,务必使用单引号包裹,或者统一采用正斜杠替代。
工作目录是 /home/u/proj/src,执行 cd ../build 之后,pwd 打印什么?
3. 创建、复制、移动和删除
在文件系统的日常维护与工程构建中,文件的基础流转主要依赖五条核心指令:mkdir 用于创建新目录;touch 用于快速生成零字节的空文件或更新已有文件的时间戳标记;cp 负责文件或目录的拷贝;mv 负责移动文件或就地重命名;rm 则负责从文件系统中彻底移除条目。在下面的演练中,我们使用 ls -1(每行固定仅展示一个条目)来配合查看目录树的变化。其中,mkdir -p(--parents)是一项高频使用的工程选项,它能够沿着指定路径一次性把中间缺失的所有多级父目录递归补齐,并且如果目标目录已经存在,它也会保持静默容错而不抛出错误:
$ cd /home/minsecrus/tutorial/work/p071/practice
$ mkdir notes
$ touch notes/todo.txt
$ cp notes/todo.txt notes/todo.bak
$ mv notes/todo.bak notes/done.txt
$ ls -1 notes
done.txt
todo.txt
$ mkdir -p notes/2026/10
$ mv notes/todo.txt notes/2026/10/
$ ls -R notes
notes:
2026 done.txt
notes/2026:
10
notes/2026/10:
todo.txt
$ cp -r src src_copy
$ ls
build include notes notes.txt src src_copy text.txt
$ rm -r src_copy
$ ls
build include notes notes.txt src text.txt
$ rmdir build
$ ls
include notes notes.txt src text.txt
$ mkdir build
$ rm -r notes
$ ls
build include notes.txt src text.txt2
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
在底层机制上,当源路径与目标路径位于同一个文件系统分区时,mv 仅修改目录项(directory entry)的元数据指针,而完全无需对底层数据块进行任何实际拷贝,因此即便是移动数以吉字节计的大型归档,在同分区下也是即时完成的。若目标位置仍在同级目录,mv 旧名 新名 就纯粹起到了文件重命名的作用。 使用 cp 复制整个目录时,必须显式附加 -r(--recursive)递归参数;若遗漏该参数,cp 会直接拒绝执行并报警提示 -r not specified。 在目录清理方面,rmdir 严格受限于仅能清除内部没有任何内容的空目录,若目录内尚存文件,命令会立即中断并报错 Directory not empty;而当需要将某个非空目录连同其内部的所有子层级完整抹除时,必须调用 rm -r 进行递归销毁。对于直接使用 rm 尝试删除目录的操作,系统会反馈 Is a directory 错误以示拦截。
rm 指令不设回收站缓冲
通过终端 rm 移除的文件,是由操作系统直接解除文件系统硬链接并回收数据块指针,它不会被转移到操作系统的桌面「回收站」中,具有不可逆性。在工程实践中,尤其是在使用通配符进行批量删除(如 rm -rf *.o)之前,一个稳健的防错习惯是先用相同的通配模式执行一次 ls 命令,亲眼确认匹配到的目标清单完全符合预期,随后再将命令替换为 rm 执行清除。
快速检视文本内容时,cat(concatenate)是最轻巧的选择,它会将文件数据完整倾泻到终端屏幕上;当面对数百上千行的长日志或源文件时,推荐使用交互式的分页阅览器 less,它允许使用方向键、PageUp/PageDown 上下滚动检索,输入 q 即可退出视窗;若仅需快速核对文件首部或尾部的一小段片段,head 与 tail 能够精准截取对应行数;配合 wc -l(Word Count - Lines),则可以快速获取目标文件的行数统计:
$ cat notes.txt
实验记录
第一行:建立目录树
第二行:复制与移动
第三行:删除与清理
$ head -n 2 notes.txt
实验记录
$ tail -n 2 notes.txt
第二行:复制与移动
第三行:删除与清理
$ wc -l notes.txt
5 notes.txt2
3
4
5
6
7
8
9
10
11
12
13
14
若调用 head 或 tail 时未显式指定 -n 行数 选项,它们默认均截取前 10 行或末 10 行文本。cat 能够同时接收多个文件路径作为参数,并按照命令行给出的顺序顺次拼接输出,在第 6 节中我们将看到它如何与管道命令结合发挥威力。
在类 Unix 的命令行设计中,通配符(Wildcards,亦称 Globbing)包括星号 *、问号 ? 以及字符集合 [...],其匹配展开工作是由 Shell 自身在启动目标程序之前提前完成的。目标程序在接收命令行参数时,拿到的已经是经过 Shell 展开后的具体文件名列表。其中,*.c 能够匹配当前目录下所有以 .c 结尾的文件;?.c 仅匹配主文件名恰好由单个任意字符构成的 C 源文件;而 [ah]*.c 则匹配所有以字符 a 或 h 起始的 C 文件:
$ echo *.txt
notes.txt text.txt
$ echo "*.txt"
*.txt
$ ls -1 src/[ah]*.c
src/args.c
src/hello.c2
3
4
5
6
7
在上述示例中,被双引号包裹的 "*.txt" 之所以未被展开而是按字面打印,是因为引号机制明确指示 Shell 抑制通配符求值。理解这一阶段性展开机制至关重要,我们在第 5 节将专门用 C 程序来实测验证程序究竟看到了什么。
4. shell 怎样找到命令
日常在终端中敲入的各种命令,其内部来源并不完全相同。像 ls、gcc、git 这些工具,属于磁盘文件系统中独立编译存放的二进制可执行程序或外部脚本;而诸如 cd、echo、pwd 等操作,则是常驻于 Shell 进程内部的内建命令(builtin)。利用 type 指令,我们可以清晰探查任何一个命令的底层属性:
$ type pwd
pwd is a shell builtin
$ type ls
ls is /usr/bin/ls
$ type cd
cd is a shell builtin
$ type gcc
gcc is /usr/bin/gcc2
3
4
5
6
7
8
对于 Shell 内建命令,Shell 会直接在当前进程内部通过函数分发执行,无需向操作系统申请创建新的子进程;而遇到外部可执行程序时,操作系统与 Shell 则必须依据用户键入的命令名在磁盘目录中展开寻址。这一寻址搜索的范围,由操作系统环境变量(environment variable)PATH 严格约束。PATH 本质上是一个使用冒号 :(在 Windows 原生环境中为分号 ;)串联而成的目录列表。当用户输入一个不带斜杠的纯命令名时,Shell 会顺着该列表从前往后逐个检查各个目录中是否存在匹配且具备执行权限的文件,首个命中的实体即成为最终执行的目标:
$ echo "$PATH" | tr ':' '\n'
/usr/local/bin
/usr/bin
/bin
/usr/games2
3
4
5
在上面的管道演示中,tr ':' '\n' 仅用于将紧凑的冒号替换为换行符以便排版查阅。不同机器与系统配置下的 PATH 条目数量与顺序各异,但有一个共同的严密设计:为了系统的安全与确定性,Unix 系统的 PATH 环境变量中通常绝不包含当前目录 .。正因如此,即使我们在当前工作目录下刚刚编译出了可执行文件 hello,若直接在终端输入 hello,系统依然会报错:
$ gcc -std=c17 -Wall -Wextra -o build/hello src/hello.c
$ hello
bash: hello: command not found
$ echo "rc=$?"
rc=127
$ ./build/hello
Hello, C!
$ command -v hello
$ command -v gcc
/usr/bin/gcc
$ command -v ./build/hello
./build/hello2
3
4
5
6
7
8
9
10
11
12
终端返回的 bash: hello: command not found 以及 127 状态码明确表明:Shell 遍历了 PATH 中的每一个目录,均未能找到名为 hello 的可执行项。 command -v 是测试 PATH 检索结果的标准工具:当目标命中时它打印解析出的完整绝对路径;若检索落空则没有任何输出,并返回状态码 1。与之相对,形如 ./build/hello 这种带有显式斜杠 / 的调用形式属于路径调用,Shell 会跳过 PATH 遍历机制,直接在指定路径加载并执行二进制,因此 command -v ./build/hello 会直接将其原样回显。在 Windows 环境下运行构建出的程序时,通常可以省略末尾的 .exe 后缀,MSYS2 的 Bash 会自动尝试补齐并启动。
向当前环境的 PATH 追加新搜索目录时,使用 export 命令。需要注意,这种注入仅在当前 Shell 会话以及由它随后派生的子进程空间内生效:
$ export PATH="$PATH:$PWD/build"
$ command -v hello
/home/minsecrus/tutorial/work/p071/practice/build/hello
$ hello
Hello, C!2
3
4
5
这里的 $PWD 是 Shell 自动维护的只读环境标记,时刻反映当前工作目录的绝对路径。在构造新的 PATH 字符串时,是将新目录追加在 $PATH 末尾还是插入在最前端,会直接决定同名命令的解析优先级:当多个不同目录中并存同名程序时,优先排在前面的目录实体将拥有绝对的执行权:
$ mkdir -p p1 p2
$ printf '#!/bin/sh\necho one\n' > p1/whichone
$ printf '#!/bin/sh\necho two\n' > p2/whichone
$ chmod +x p1/whichone p2/whichone
$ PATH="$PWD/p1:$PWD/p2:$PATH" whichone
one
$ PATH="$PWD/p2:$PWD/p1:$PATH" whichone
two2
3
4
5
6
7
8
PATH 是按顺序搜索的目录列表
Shell 在执行不包含任何斜杠修饰的纯粹命令名时,会顺着环境变量 PATH 所记录的目录路径列表展开线性扫描。从左至右首个命中的可执行文件将被调遣执行;若整个列表遍历殆尽仍未命中,Shell 即汇报 command not found 并伴随 127 退出状态。标准环境的 PATH 列表中通常不包含当前工作目录,因此运行本地二进制时必须显式标注形如 ./程序名 的相对路径。
环境变量的基本赋值语法遵循 名字=值 的格式。若仅仅采用直接赋值,该对象仅局限在当前 Shell 进程内部,其后续派生的任何子进程(包括执行外部脚本或编译命令)均无法读取;唯有使用 export 显式导出后,子进程才能在其继承的环境块中访问该值。此外,写在具体命令首部的临时前置赋值,其有效范围被严格限定在该单条命令的生命周期之内:
$ GREETING=hey
$ sh -c 'echo "[$GREETING]"'
[]
$ GREETING=hey sh -c 'echo "[$GREETING]"'
[hey]
$ export GREETING=hey
$ sh -c 'echo "[$GREETING]"'
[hey]
$ GREETING=bye sh -c 'echo "[$GREETING]"'
[bye]
$ echo "$GREETING"
hey
$ unset GREETING
$ echo "[$GREETING]"
[]2
3
4
5
6
7
8
9
10
11
12
13
14
15
在上述对比中,最初的 GREETING=hey 只是 Shell 自身的本地对象,派生出的子进程 sh 自然一无所获;而在命令前置添加 GREETING=hey 时,子进程的环境块中被临时注入了该对象;经过 export GREETING=hey 导出后,后续所有派生子进程均可稳定读取;随后的 GREETING=bye 临时覆盖仅对当前单次调用有效,其后的 echo 依然输出原有的 hey。所有这些在终端会话中的动态赋值都属于临时状态,一旦终端窗口关闭即化为乌有;若要在每次登录时持久生效,需将对应的 export 语句写入个人配置文件(如 ~/.bashrc)。
p1 和 p2 里各有一个可执行的 whichone,当前 PATH 为 "$PWD/p1:$PWD/p2:$PATH",运行 whichone 执行哪一个?
5. 命令行参数与退出状态
5.1 程序内部感知的参数边界
当我们在终端输入命令并按下回车时,Shell 会先行完成整行的词法解析,将程序名称与后续的各个参数切分为一组独立的以空字符结尾的字符串,随后通过操作系统的进程加载机制注入新创建的进程空间。在 C 语言程序中,主函数 main 通过标准的参数列表 int argc, char *argv[] 接收这组数据:整型对象 argc 记录参数字符串的总项数,指针数组中的 argv[0] 指向调用该程序时所使用的命令路径或名称,而自 argv[1] 开始至 argv[argc - 1] 则依次存放用户在命令行附加的各项具体参数。
为了直观观察不同引用与通配写法在进入 C 程序后所呈现的边界形态,我们编写了一个专门将各参数用方括号包裹打印的诊断程序:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = [%s]\n", i, argv[i]);
}
if (argc > 1 && strcmp(argv[1], "--fail") == 0) {
return 3;
}
return EXIT_SUCCESS;
}2
3
4
5
6
7
8
9
10
11
12
13
14
在上述代码中,当传入的第一个参数为 --fail 时,程序特意通过返回整数 3 退出,以便在下一小节中检视退出状态码的传递。该源文件保存在配套仓库的 code/开发与工程基础/2_命令行与目录操作/args.c。编译后,我们为其传入不同的引号与通配组合:
$ cd /home/minsecrus/tutorial/work/p071/practice
$ gcc -std=c17 -Wall -Wextra -o build/args src/args.c
$ ./build/args a b
argc = 3
argv[0] = [./build/args]
argv[1] = [a]
argv[2] = [b]
$ ./build/args "a b"
argc = 2
argv[0] = [./build/args]
argv[1] = [a b]
$ ./build/args src/*.c
argc = 6
argv[0] = [./build/args]
argv[1] = [src/args.c]
argv[2] = [src/bad.c]
argv[3] = [src/hello.c]
argv[4] = [src/mix.c]
argv[5] = [src/upper.c]
$ ./build/args 'src/*.c'
argc = 2
argv[0] = [./build/args]
argv[1] = [src/*.c]
$ ./build/args $HOME '$HOME'
argc = 3
argv[0] = [./build/args]
argv[1] = [/home/minsecrus]
argv[2] = [$HOME]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
从这组实测回显中可以清楚梳理出参数传递的核心规律:键入 a b 时空格触发了 Shell 的切词规则,从而形成两个独立的参数;当写为 "a b" 时,双引号成功阻止了切词,程序只收到一个内含空格的参数。不加引号的 src/*.c 在命令启动前就被 Shell 自动展开为了 5 个具体的源文件路径,导致 argc 膨胀到了 6;而用单引号包裹的 'src/*.c' 则禁止了通配符求值,字符串被原汁原味地交给了程序。同理,环境变量 $HOME 在没有引号保护时会被展开为具体的绝对路径,而在强引用的单引号包裹下则保持了字面符号。通配展开、参数切割与环境变量求值完全是在 Shell 层面先行完成的,C 程序自身接到的已经是处理完毕的确定性字符串。
Windows 上引号对 *.c 的抑制边界
在 Windows 平台上,由 MinGW-w64 GCC 构建出来的原生二进制程序在启动时,其附带的 C 运行时启动代码(CRT startup code)会针对命令行再次主动执行一轮通配符展开(Globbing)。因此,即使在 Bash 中使用了单引号 '*.c' 阻止了 Shell 的展开,Windows 下的可执行文件在接收参数时仍有可能再次被运行时展开为文件列表。在 Windows 与跨平台构建中处理文件名模式时,需留意这一平台特有的运行时行为。
5.2 退出状态
当一个进程在操作系统中运行终止时,无论是因为从 main 函数正常 return,还是由于显式调用了 exit(),它都必须向父进程归还一个整数形式的退出状态(exit status,亦称退出码)。在终端会话中,Shell 会将上一条刚刚结束的命令的退出状态自动保存在特殊变量 $? 中。在系统工程领域,通行的通用准则是:状态码 0 代表程序圆满成功完成任务,任何非 0 状态码均代表出现了某种程度的失败或异常。关于 return 与 exit 的标准生命周期语义,在14.3.1 终止程序中有过严谨推演;关于进程环境块的读取,也在14.3.2 环境访问中有过剖析。
$ ./build/args --fail
argc = 2
argv[0] = [./build/args]
argv[1] = [--fail]
$ echo $?
3
$ ./build/args x && echo "succeeded"
argc = 2
argv[0] = [./build/args]
argv[1] = [x]
succeeded
$ ./build/args --fail && echo "not printed"
argc = 2
argv[0] = [./build/args]
argv[1] = [--fail]
$ ./build/args --fail || echo "failed"
argc = 2
argv[0] = [./build/args]
argv[1] = [--fail]
failed
$ true && false || echo "third runs"
third runs2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
退出状态码构成了 Shell 脚本中逻辑控制的核心条件:使用逻辑与操作符 && 连接的两条命令,唯有在左侧命令返回退出码 0(成功)时,右侧命令才会被触发;与之相反,逻辑或操作符 || 则仅在左侧命令返回非 0(失败)时才会执行右侧分支。多组条件操作符支持自左向右串联结合,例如 true && false || echo "third runs" 会首先评估 true && false,因其整体最终产生非 0 失败,随后的 || 分支便顺理成章地被执行。
需要注意的是,echo $? 所读取的始终是距离其最近的那一条命令的状态。如果在一个故障命令之后无意中穿插了其他命令,$? 就会立刻被新命令的退出状态所覆盖。另外,Shell 本身也保留了一批约定的状态码:当用户输入了一个根本无法在磁盘中寻址定位的命令名时,Shell 自身会返回 127;而当目标文件存在却缺乏可执行权限(Permission denied)时,Shell 则会返回 126。这些特定的状态码是由解释器层面派发的,并非来自被调用的应用程序内部。
退出状态是程序报告成败的标准渠道
退出状态码是应用程序与外部宿主环境、上层编排系统进行成败沟通的通用语言。0 代表成功,非 0 代表失败。工业界的构建引擎、自动化测试框架以及 CI/CD 持续集成流水线,无一例外依赖这一机制来裁决后续流程走向:当 Make 调度编译器驱动编译某一个模块时,若编译器返回了非 0 退出码,Make 会立即勒马刹车,中断整条构建流水线;自动化测试框架 CTest 依据被测测试用例的退出状态来判定断言是否通过;云端流水线同样依据最终脚本的退出状态来判断构建任务是标记为绿色还是红色。在 C 程序设计中,通过在 main 中返回 EXIT_FAILURE 或调用 exit(1),正是在向这些上层监管工具明确递交失败报告。
6. 重定向与管道
6.1 三个标准 I/O 流
在 POSIX 与 C 语言的运行时体系中,任何一个新程序在启动伊始,操作系统都会默认并为其建立三个标准的字符流管道:标准输入(standard input)、标准输出(standard output)与标准错误(standard error)。在 C 语言程序内部,它们直接映射为标准库提供的全局指针对象 stdin、stdout 与 stderr(其在语言层面的缓冲机制与格式化用法在13.1 I/O 流中已有过详尽剖析)。在操作系统内核与文件描述符(file descriptor)体系中,这三者按约定固定被赋予编号 0、1 和 2。
在没有附加任何特殊修饰的常规交互下,这三个流默认均绑定在当前宿主终端上:标准输入监听键盘敲击输入,而标准输出与标准错误则双双将字符倾泻在终端视窗之中。所谓的I/O 重定向(redirection),就是让 Shell 在派发程序之前动态修改这三个流的挂接目标,使其重定向到普通磁盘文件或虚拟设备中;而管道(pipe)机制则更进一步,直接将前序程序的标准输出端口与后序程序的标准输入端口咬合拼接,形成无缝衔接的链式处理流。
重定向的核心符号体系主要包括:
>:将标准输出(文件描述符 1)截获并覆写至目标文件;若目标文件已存在,则在写入前将其原有内容完整清空(截断)。>>:同样用于捕获标准输出,但采用追加(append)写入模式,在保留原有内容的基础上将新字符附着在文件末尾。<:将目标磁盘文件反向挂接到程序的标准输入(文件描述符 0),使程序读取时宛若在读取用户手工输入。2>:将标准错误(文件描述符 2)独立剥离并写入目标文件。2>&1:指示 Shell 将文件描述符 2(标准错误)的当前流向与文件描述符 1(标准输出)进行对齐合并。
6.2 重定向
为了演示数据流向的操控,我们编写了小巧的过滤器程序 upper.c:它从标准输入逐个字符吞入,将其转写为大写字符后倾泻至标准输出:
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int c;
while ((c = getchar()) != EOF) {
if (putchar(toupper((unsigned char)c)) == EOF) {
perror("upper");
return EXIT_FAILURE;
}
}
if (ferror(stdin)) {
perror("upper");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
该源文件存放在 code/开发与工程基础/2_命令行与目录操作/upper.c。其中,toupper 的实参被显式转换为 unsigned char,这是遵循 C 标准的安全准则,因为字符分类函数要求传入值必须能以 unsigned char 表达或是特殊的负数常量 EOF。将其编译为二进制后,我们可以通过不同的重定向手段操控数据走向:
$ gcc -std=c17 -Wall -Wextra -o build/upper src/upper.c
$ ./build/upper < text.txt | wc -l
15
$ ./build/upper < text.txt > build/out.txt
$ head -n 3 build/out.txt
THE CAT SAT ON THE MAT.
THE CAT SAT ON THE MAT.
THE CAT SAT ON THE MAT.
$ ./build/upper < text.txt >> build/out.txt
$ wc -l build/out.txt
30 build/out.txt
$ wc -l < text.txt
15
$ wc -l text.txt
15 text.txt2
3
4
5
6
7
8
9
10
11
12
13
14
15
在第一条命令中,< text.txt 将源文本文件接入标准输入,处理后的全大写输出经由管道无缝交由 wc -l 统计,得到 15 行的计数。第二条命令使用 > build/out.txt 将全部转换结果截流写入目标文件。第三条命令使用 >> 再次追加一次写入,使目标文件的总行数翻倍至 30 行。最后两条对比展示了重定向传参与命令行显式传参在行为上的细微差别:wc -l < text.txt 由于仅仅从标准输入吸收流式数据,程序本身并不知道底层来源的文件名,因此仅打印裸数字;而 wc -l text.txt 则是把文件名作为命令行参数提供给程序,程序会在统计数字之后附加对应的文件名打印。
切忌将输入与输出指向同一个目标文件
若敲下形如 ./upper < same.txt > same.txt 的指令,Shell 在启动 ./upper 进程之前,就会先行解析重定向标记 > 并以截断模式(O_TRUNC)打开 same.txt,瞬间把文件清空为 0 字节。紧随其后启动的应用程序在从标准输入读取时,便只能读到一个空荡荡的空文件。若需要就地更新某个文本,稳妥的工程模式是先将结果定向写入一个临时文件中,待处理成功后再用 mv 进行覆盖重命名。
6.3 诊断信息与 2>&1 的解析次序
现代 C 编译器在构建代码时,遵循将正常的编译产物(或标准日志)与警告/报错诊断信息解耦输出的原则:所有的错误与警告信息均定向派发至标准错误(stderr)。如果仅仅使用普通的 >,被截获重定向的只是标准输出,编译诊断依然会顽固地打印在屏幕上:
$ gcc -std=c17 -Wall -Wextra -o build/bad src/bad.c 2> build.log
$ grep -c error build.log
2
$ gcc -std=c17 -Wall -Wextra -o build/bad src/bad.c > stdout.log
src/bad.c: In function ‘main’:
src/bad.c:5:17: error: ‘count’ undeclared (first use in this function)
5 | int total = count + 1;
| ^~~~~
src/bad.c:5:17: note: each undeclared identifier is reported only once for each function it appears in
src/bad.c:6:26: error: expected ‘;’ before ‘return’
6 | printf("%d\n", total)
| ^
| ;
7 | return 0;
| ~~~~~~
$ wc -c stdout.log
0 stdout.log2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在上述实测中,首次使用 2> build.log 成功将标准错误导向日志文件,随后的 grep -c error 准确数出了两处标记为 error 的编译报错。第二次误用 > 尝试重定向时,屏幕上依然滚出了完整的编译诊断,而 stdout.log 则是一个 0 字节的空文件。示例中故意包含语法缺陷的源文件位于 code/开发与工程基础/2_命令行与目录操作/bad.c。
在自动化构建与持续集成脚本中,经常需要将标准输出与标准错误全部汇流记录到同一个日志归档中。要达成这一目的,必须严格遵循语法次序:把 2>&1 放置在 > 之后。Shell 在处理一行命令的重定向配置时,严格依据从左往右的时序执行描述符重绑定。通过下面这个能够先后向两个流输出明确标记的程序,可以清晰透视这一时序差异:
#include <stdio.h>
int main(void) {
printf("stdout line\n");
fflush(stdout);
fprintf(stderr, "stderr line\n");
return 0;
}2
3
4
5
6
7
8
该测试程序位于 code/开发与工程基础/2_命令行与目录操作/mix.c。使用 gcc -std=c17 -Wall -Wextra -o build/mix src/mix.c 编译后,我们对比两种书写顺序:
$ ./build/mix 2>&1 > build/only.txt
stderr line
$ cat build/only.txt
stdout line2
3
4
$ ./build/mix > build/both.txt 2>&1
$ cat build/both.txt
stdout line
stderr line2
3
4
在左侧的错误写法中,Shell 先执行 2>&1,由于当时文件描述符 1 尚指向控制台终端,因此文件描述符 2 也被复制绑定到了终端;紧随其后执行的 > build/only.txt 仅修改了文件描述符 1 的朝向,将其转引至文件,而文件描述符 2 依然驻留在终端上。这导致标准错误的内容留在屏幕上,只有标准输出写入了文件。而在右侧的标准写法中,> build/both.txt 先将描述符 1 引向文件,随后的 2>&1 紧接着将描述符 2 复制对齐至描述符 1 当下的流向(即该文件),两者最终顺理成章地双双落盘。若不落地磁盘而是对接下游管道,2>&1 | cat 则常用于先将标准错误并入标准输出,再整体作为流推入下游命令。
类 Unix 系统还提供了一个极具工程价值的虚拟特殊设备 /dev/null(常被称为空设备或黑洞)。写入该设备的所有字节流都会被内核静默丢弃;从该设备读取则会立即收到文件结束标记(EOF)。利用这一特性,我们可以精确屏蔽冗余的干扰信息:./mix > /dev/null 仅保留标准错误;./mix 2> /dev/null 仅保留标准输出;而 ./mix > /dev/null 2>&1 则让程序在完全静默的状态下运行。需要牢记的是,重定向仅改变字符流的去向,绝不会干涉进程自身的退出状态:在 ./args --fail > /dev/null 2>&1 静默执行后,读取 $? 依然能够稳定拿到程序返回的退出码 3。
6.4 管道
管道操作符竖线 | 是 Unix 哲学的核心基石之一:它在内核中建立单向的字节数据通道,将左侧程序的标准输出端直接对接到右侧程序的标准输入端。通过管道,开发者可以把多个专注于单一任务的小型工具链式组合,构建出功能高度灵活的数据处理管线。下面这条复合管道命令完整展示了统计 text.txt 中出现频次最高的前 5 个整行文本的流式过程:
$ cat text.txt | ./build/upper | sort | uniq -c | sort -rn | head -n 5
5 THE CAT SAT ON THE MAT.
4 THE DOG SAT ON THE LOG.
3 A FOX RUNS FAST.
2 A BIRD FLIES HIGH.
1 BEES HUM.2
3
4
5
6
我们沿流水线逐段拆解其内部流动:
cat text.txt负责读取源文件并向标准输出发射文本流;./build/upper拦截前者的输出,将英文字母统一转译为全大写,抹平大小写差异;sort接收清洗后的文本流并进行字典序排序,使内容相同的文本行紧密相邻;uniq -c遍历排好序的连续数据流,将相邻的重复行压缩折叠,并在每行首部追加该行出现的频次计数;sort -rn(-r代表倒序,-n代表按数值解析)依照行首的频次数值自高到低逆向排序;head -n 5截断数据流,仅放行前 5 项高频结果打印到终端。
如果从管道中移除 ./build/upper 这一环节,大小写相异的行就会被判定为不同文本,统计结果随即产生分歧:
$ sort text.txt | uniq -c | sort -rn | head -n 5
4 The dog sat on the log.
4 The cat sat on the mat.
3 A fox runs fast.
2 A bird flies high.
1 the cat sat on the mat.2
3
4
5
6
在默认机制下,一条由多个命令构成的管道,其最终向 Shell 汇报的退出状态取决于管道中最后一个命令的退出码;哪怕处于管道前序位置的某个命令执行失败,也不会自发影响整条管道最后的 $? 裁决:
$ false | true
$ echo $?
0
$ true | false
$ echo $?
12
3
4
5
6
在构建健壮的自动化工程脚本时,若需要对整条管道的安全负责,Bash 提供了内置选项 set -o pipefail。开启该选项后,管道内部只要有任何一段产生非 0 失败,整条管道的最终退出码就会取自最后一个失败命令的状态。这在编写配合 set -eu 的严格构建脚本时属于关键防线。
执行 gcc bad.c > out.txt 之后,编译诊断仍然出现在屏幕上,out.txt 是空文件。原因是什么?
7. 把命令编排为自动化脚本
当某一系列命令需要被反复执行、或是作为团队协同的标准构建步骤时,将它们按序固化到一个纯文本文件中交给 Shell 统一批处理执行,便构成了Shell 脚本(Shell Script)。在脚本首行通常以特殊标记 #!/bin/sh(Shebang)起始,它向操作系统内核声明由哪一个解释器来装载并执行该文本;紧随其后的 set -e 则是编写严谨工程脚本的关键防线——它指示解释器一旦遭遇任何一条返回非 0 状态码的命令,便立即终止整个脚本的推进,杜绝带着前序错误盲目执行后续指令:
#!/bin/sh
# build.sh:编译本目录下的两个程序并试运行
set -e
mkdir -p build
gcc -std=c17 -Wall -Wextra -o build/hello src/hello.c
gcc -std=c17 -Wall -Wextra -o build/args src/args.c
./build/hello
./build/args first "second argument"2
3
4
5
6
7
8
#!/bin/sh
set -e
gcc -std=c17 -Wall -Wextra -o build/bad src/bad.c
echo "编译成功"2
3
4
上述两份脚本均保存在仓库的 code/开发与工程基础/2_命令行与目录操作/ 路径下。由于 build.sh 内部使用相对路径定位 src 与 build,它必须在工程练习目录下被唤起。在 Unix 权限模型中,新建的文件默认不具备执行权限(Execute 标志),直接输入 ./build.sh 会被系统拒绝并抛出 Permission denied,伴随状态码 126;如果显式调用 sh build.sh,则是将脚本文件作为参数提供给外部的 Shell 解释器读取,此时无需执行权限即可运行:
$ cat build.sh
#!/bin/sh
# build.sh:编译本目录下的两个程序并试运行
set -e
mkdir -p build
gcc -std=c17 -Wall -Wextra -o build/hello src/hello.c
gcc -std=c17 -Wall -Wextra -o build/args src/args.c
./build/hello
./build/args first "second argument"
$ ./build.sh
bash: ./build.sh: Permission denied
$ echo "rc=$?"
rc=126
$ sh build.sh
Hello, C!
argc = 3
argv[0] = [./build/args]
argv[1] = [first]
argv[2] = [second argument]
$ chmod +x build.sh
$ ./build.sh
Hello, C!
argc = 3
argv[0] = [./build/args]
argv[1] = [first]
argv[2] = [second argument]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
通过 chmod +x build.sh 为其赋予可执行权限后,./build.sh 即可像原生独立程序一样被直接调用,首行的 #!/bin/sh 也随之正式接管解释工作。 在故意留下语法错误的 check.sh 中,set -e 的保护机制展现得淋漓尽致:编译器一旦在 bad.c 处报错中断,整个脚本立即终止,随后的 echo "编译成功" 被安全跳过,脚本整体以状态码 1 退出:
$ sh check.sh
src/bad.c: In function ‘main’:
src/bad.c:5:17: error: ‘count’ undeclared (first use in this function)
5 | int total = count + 1;
| ^~~~~
src/bad.c:5:17: note: each undeclared identifier is reported only once for each function it appears in
src/bad.c:6:26: error: expected ‘;’ before ‘return’
6 | printf("%d\n", total)
| ^
| ;
7 | return 0;
| ~~~~~~
$ echo "rc=$?"
rc=12
3
4
5
6
7
8
9
10
11
12
13
14
如果将 check.sh 中的 set -e 移除后再跑一次,情形就会变得危险:编译器虽然同样报错,但解释器会毫不犹豫地越过失败指令继续推进,紧随其后的 echo 依然向标准输出打印出「编译成功」,脚本最终整体以状态码 0 退出。这会导致任何调用该脚本的外部自动化系统错误地判定构建无误。因此在编写编译、测试以及自动化部署脚本时,养成在开头声明 set -e 是至关重要的工程准则。
当然,纯文本 Shell 脚本的局限在于:每次启动它都会把所有编译命令从头到尾盲目执行一遍,即使绝大多数源文件根本未发生任何改动。在大型工程项目中,这项增量更新的统筹工作会交给专门的构建系统来负责,第 8 章的 Make 工具会依照文件最后修改时间戳精确计算出哪些目标需要重新编译,从而节省构建耗时。
Windows 平台编辑脚本请确保使用 LF 换行
在 Windows 编辑器中若不慎将带有 #!/bin/sh 的脚本保存为 Windows 风格的 CRLF 换行格式,将该脚本拷贝到 Linux 终端中直接执行时,内核往往会将回车符 \r 误判为解释器路径的一部分,从而汇报类似于 cannot execute: required file not found 的诡异错误。在第 1 章中通过 .editorconfig 将 end_of_line = lf 固化下来正是为了规避此类跨平台换行故障。
8. PowerShell 语法对照
在 Windows 原生环境中,系统默认配备的主流命令行环境是 PowerShell。它的指令体系、选项规则与底层的对象传递模型与传统的 Bash 存在显著差异,但所承载的核心系统操作是一一对应的。下表是在 64 位 Windows 系统下基于 PowerShell 7.6 逐项核对过的映射关系:
| 用途 | bash | PowerShell |
|---|---|---|
| 打印当前目录 | pwd | Get-Location(系统别名为 pwd) |
| 列出目录内容 | ls、ls -a、ls -l | Get-ChildItem(系统别名为 ls)、配合 -Force 查看隐藏项、配合 Format-List 展开属性 |
| 切换目录 | cd 目录 | Set-Location 目录(系统别名为 cd) |
| 创建多级目录 | mkdir -p a/b | New-Item -ItemType Directory -Path a\b(附加 -Force 可跳过已存在目录报警) |
| 创建空文件 | touch f | New-Item -ItemType File -Path f |
| 复制、移动与删除 | cp、mv、rm -r | Copy-Item、Move-Item、Remove-Item -Recurse |
| 检视文件内容 | cat、head -n、tail -n | Get-Content、配合 -TotalCount 截首、配合 -Tail 截尾 |
| 读取前序退出状态 | $? | $LASTEXITCODE(PowerShell 的 $? 仅为布尔值 True/False) |
| 环境变量操作 | $PATH、export NAME=值 | $env:PATH、$env:NAME = "值" |
| 执行当前目录二进制 | ./hello | .\hello.exe(PowerShell 同样默认不搜索当前目录) |
| 标准重定向 | > f、2> f、2>&1 | >、2>、2>&1,基本语法规则一致 |
需要提醒的是,PowerShell 虽然为了方便用户迁移而将 mkdir、cp、mv、rm、cat、ls、cd、pwd 等经典 Unix 命令映射为了内部别名或包装函数,但其底层接受的参数开关遵循 PowerShell 的 Cmdlet 规范。例如在 PowerShell 中输入 ls -a 会抛出未识别参数错误,查看隐藏条目必须写为 ls -Force。
在退出状态的提取机制上两者的差异尤为显著:Bash 中的 $? 直接返回具体的整型状态码;而在 PowerShell 中,$? 仅反映上一个操作是否成功完成(仅回显 True 或 False),外部原生可执行文件(.exe)所返回的真实进程退出代码被独立存放在专用自动化变量 $LASTEXITCODE 中:
PS> .\args.exe --fail
argc = 2
argv[0] = [D:\demo\args.exe]
argv[1] = [--fail]
PS> $?
False
PS> $LASTEXITCODE
32
3
4
5
6
7
8
此外,两者的管道底层模型也截然不同:Bash 管道在各环节之间传递的是无结构的原始字节流(Byte Stream),而 PowerShell 管道传递的则是 .NET 强类型对象。在 PowerShell 中统计分组或排序时,需要调用 Group-Object、Sort-Object 以及 Select-Object -First 等 Cmdlet。在后续章节中,教程的工程演练均以标准的 Bash 规范为主,建议 Windows 读者在 MSYS2 UCRT64 终端中实践这些操作。
9. 习题
9.1 巩固
只用 mkdir -p、touch、mv、cp、rm 建出下面的目录树:
proj/
├── src/
│ └── main.c
├── include/
├── build/
└── notes/
└── 2026-10.txt2
3
4
5
6
7
main.c 和 2026-10.txt 用 touch 建空文件即可。然后新建 proj/docs/,把 notes 整个移进去,删除 build 目录,最后用 ls -R 输出整棵树。验收:ls -R 的结果里只剩 src、include、docs,且 docs/notes/2026-10.txt 存在。
用 code/开发与工程基础/2_命令行与目录操作/args.c 编译出 args,依次运行 ./args a b、./args "a b"、./args *.c、./args '*.c'、./args $HOME '$HOME',把每次的 argc 和各参数记录下来,逐个说明差别是由 shell 还是程序造成的。然后修改程序,在原有输出后面加一行 total = 后跟所有参数字符数之和,重新编译并确认这行数字和你手算的一致。在 Windows 上(MSYS2 终端或 Git Bash)再做一次:编译并运行带引号的 '*.c',记录结果并与 Linux 对照;本页第 5 节有一条相关提示。
用 --help 和 help 查清下面三件事,把命令和关键输出记下来:ls 的 -h、-t、-r 各是什么含义,组合成 ls -lhtr 之后按什么顺序排列;cd 的 -P 与 -L 有什么区别;type 对 cd、ls、gcc、echo 分别报告什么。最后说明哪些是 shell 内建命令,为什么内建命令查不到单独的 PATH 路径。
9.2 编程
写 judge.c:命令行参数恰好是一个十进制整数时返回 0;参数不是整数(或个数不对)时向标准错误打印一条错误信息并返回 2。要求用 strtol 判断整个参数是否都是数字。
再用它验证三件事:用 &&、|| 写一条命令,成功时打印 ok、失败时打印 bad;用 echo $? 确认两种情况的退出状态;把标准输出和标准错误分别写到两个文件,检查错误信息只出现在标准错误的文件里。
把 code/开发与工程基础/2_命令行与目录操作/upper.c 编译成 upper,再取同目录的 text.txt,写一条管道找出出现次数最多的五个整行。要求输出中每一行前面有次数,按次数从大到小排列。
然后回答:管道里每个程序各做什么、sort 为什么要出现两次、去掉 upper 之后哪几行会被分开统计。验收:不改 text.txt 时,第一名是 THE CAT SAT ON THE MAT.,出现 5 次。
自己写一个 hello.c(打印一句问候即可),建一个 bin/ 目录,把编译好的 hello 放进去。分别试验三种运行方式:./bin/hello、hello、以及 export PATH="$PATH:$PWD/bin" 之后的 hello,每次用 command -v hello 记录查找结果,并说明第三种方式为什么不需要写路径。最后在一个新的终端窗口里确认 PATH 已经恢复原样,并解释原因。
9.3 综合与挑战
写 check.sh:编译 src/bad.c(用仓库里的 bad.c),把诊断写入 build.log;编译失败时打印 build.log 中 error 的行数并以非 0 状态退出,成功时打印 PASS 并返回 0。脚本开头写 set -e。
(1) [2.2] 先用最直接的写法(gcc ... 2> build.log 之后接统计)运行一次,记录脚本的退出状态,说明为什么 set -e 让统计那一步根本没执行。
(2) [2.2] 去掉 set -e 再运行一次,记录输出和退出状态,说明这次为什么不可靠。
(3) [3.3*] 改成既保留 set -e、又能在编译失败时打印行数并返回非 0 的版本,在 bad.c 和 hello.c 上各跑一次,确认两种情况都符合要求。这一问要用到脚本的条件语句,本页没有展开,暂不会写的读者可以跳过。
(4) [2.2] 写出 (1) 的脚本在 PowerShell 中的等价命令(编译、重定向诊断、统计行数),指出两边的退出状态分别从哪里读。
10. 参考资料
- MIT《The Missing Semester》2026 课程《Shell》与《Command-line Environment》:https://missing.csail.mit.edu/2026/course-shell/、https://missing.csail.mit.edu/2026/command-line-environment/
- GNU Bash Reference Manual:命令查找与执行(3.7.2)、退出状态(3.7.5)、重定向(3.6)、引号(3.1.2)、文件名展开(3.5.8):https://www.gnu.org/software/bash/manual/bash.html
- William Shotts《The Linux Command Line》,命令行基础与文件操作:https://linuxcommand.org/tlcl.php
- MSYS2 文档《Filesystem Paths》,
cygpath与 Unix 路径转换:https://www.msys2.org/docs/filesystem-paths/ - POSIX《Shell Command Language》,命令语言与重定向的标准化描述:https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html
- Microsoft Learn《about_Redirection》,PowerShell 的重定向与
2>&1:https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_redirection - Microsoft Learn《about_Automatic_Variables》,
$?与$LASTEXITCODE的区别:https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables - GNU Coreutils 手册,
ls、cp、mv、rm、head、tail、sort、uniq、wc的完整说明:https://www.gnu.org/software/coreutils/manual/coreutils.html