命令行界面

GUI 与 CLI

大部分的操作系统都是默认以图形界面(Graphical User Interface, GUI)启动的,从而使得用户可以用显示屏看到五颜六色的窗口,用键盘和鼠标来进行大量的操作。但是在早期计算机并不发达的年代,使用者只能在一个由纯文本组成的界面中,使用键盘向其中键入“命令”,然后得到计算机显示的结果。大概的样子是长成这样:

实际上这个过程并不陌生,截止至目前为止我们演示的所有 C++ 程序都是基于命令行界面的:std::cin 读取键盘的输入,然后 std::cout 把结果输出到某个地方——只不过,这些输入输出大多是通过 GUI 显示的。那么想要退回去使用比较古早的 CLI,则需要用到终端模拟器(Terminal Emulator)。在 Windows 上,可以通过从开始菜单启动“命令提示符”程序来启动终端模拟器,而在 macOS 上则是“终端”应用程序:

壳层程序

终端模拟器中,你必须使用命令行界面来操作。基本上,你只能做两件事:通过键盘键入内容,通过终端模拟器上的文字来阅读内容。一般地,终端模拟器启动时会打开一个称为“壳层”(Shell)的程序来帮助你操作。这个程序可以源源不断地接受键盘的输入(被称作“命令”),然后分析这个输入是在做什么,然后做一些事情,这样不断地重复。比如你在其中键入了 echo Hello 这个命令,然后壳层程序就开始分析这个命令的含义:得知其含义是“输出 Hello 到屏幕上”,然后就确切地去做这件事情:将 Hello 输出。下面演示了主流操作系统下的命令行界面在接受 echo Hello 命令时的行为:

在 Windows 上,这个壳层程序叫做 cmd.exe。当你向其中输入 echo Hello 并按下回车时,壳层程序会输出 Hello,然后再次等待你的下一个命令:

在 GNU/Linux 上,这个壳层程序一般是 bash。当你向其中输入 echo Hello 并按下回车时,壳层程序会输出 Hello,然后再次等待你的下一个命令:

在 macOS 上,这个壳层程序则是 zsh。当你向其中输入 echo Hello 并按下回车时,壳层程序会输出 Hello,然后再次等待你的下一个命令:

程序执行和命令行参数

我现在来解释一下 echo Hello 这个命令对于壳层的意义。简单来说,每一个命令都是这样形式的:

程序名 参数1 参数2 ...

每一个命令按空格分成若干段,最重要的是第一段,称作“程序名”。程序名指定了一个程序:壳层会在 CLI 中启动这个程序,然后将输入输出的控制权交给它。也就是说,系统中存在一个名为 echo 的程序,当执行 echo Hello 时,壳层会启动这个 echo 程序。

上面的例子在 Windows 下是错误的,对于一些终端如 zsh 等也是错误的。echo 在这些情形下是作为壳层的内置命令而非外部程序运行的。

除命令的第一段外,剩下的部分称为命令的“参数”。这些参数将在程序启动时通过一种特别的方式传入程序,而程序可以通过一些方法获得这些参数。比如,echo 程序需要输出这个“参数”,从而让 echo Hello 启动 echo 程序后可以输出 Hello。为了说明如何做到这一点,我将写一个超级简化版的 echo 程序的源码:

// echo.cpp 超级简化版 echo 源码
#include <iostream>
using namespace std;
int main(int argc, char** argv) {
    if (argc == 1) {
        cout << endl;
    } else {
        cout << argv[1] << endl;
    }
}

喏,就这么简单。但是,它其中有特别之处:main 函数的声明和我们之前见到的不一样。它接受两个参数 argcargv。这种形式的 main 函数表明程序需要用到命令的“参数”。

壳层在启动程序时,会特别地分配一个块内存空间,用来存放被空格分隔的命令。此外,还有一个 arg 数组,其元素都是 char* 类型的,分别指向这些被空格分隔的命令片段。比如当以 echo Hello 启动 echo 程序时——

图中也可看到,arg 数组的长度称作 argc,指向数组首元素的指针称作 argvargcargv 同时也是 main 函数可选的两个参数。最终借助 argcargv,程序得到了命令行的“参数”。下面的 echo 源码详细地解释了其工作原理:

// echo.cpp 超级简化版 echo 源码
#include <iostream>
using namespace std;
int main(int argc, char** argv) {
    if (argc == 1) {
        // argc 等于 1,也就是说命令被空格分隔后只有一个元素
        // 也就是说命令只能长成 "echo" 的形式,不带任何额外的参数
        // 此时只需输出一个换行即可
        cout << endl;
    } else {
        // 否则的话,命令带有额外的参数。比如:
        // "echo Hello"
        // 由于 argv 是 arg 数组首元素,可以得到
        // argv[0] 指向 "echo",argv[1] 指向 "Hello"
        // echo 程序需要输出参数,所以这里 cout argv[1]
        cout << argv[1] << endl;
    }
}

工作路径和 PATH

壳层需要在文件系统的一个特定的路径中工作。这个路径称为工作路径(Working Directory)。工作路径一般作为壳层的输入提示符(Input prompt)来呈现:

上图中框起的部分就是当前壳层所处的工作路径。壳层的工作路径一定程度上决定了壳层怎样通过命令中的“程序名”来找到它该启动的程序。首先最基本的,你可以通过 cd 路径名 来更换壳层的工作路径:

cd 路径名 并不是常规的命令格式。因为 cd 不是一个程序名,而是壳层的内置命令:它并不启动任何程序,而是告诉壳层更换其工作路径。

Windows 下的 cmd.exe 壳层出于历史原因,无法更换工作路径到其它磁盘。你需要输入 盘符: 命令来切换到其它磁盘。比如,将工作路径从 C:\Windows\system32 切换到 D:\MyFiles,需要执行 D:cd D:\MyFiles 两条命令。

可以从输入提示符中看到,执行 cd 命令后壳层的工作路径发生了改变。此外,有两个特殊的记号 ... 需要记忆:

  • . 总是代表当前路径;
  • .. 代表上层路径(对于根目录则是自身)。

你可以试一下 cd .cd .. 的效果,想一想为什么。

这个 . 的作用不可小觑。. 提供了方便地运行工作路径下程序的方法。比如你的壳层目前在 /home/user/ 路径下工作,而就在刚刚你用 echo.cpp 编译出了 /home/user/echo 可执行文件。则你可以直接这样执行这个 /home/user/echo 程序:

显然,./echo 就代表了 /home/user/echo;因为 . 就是工作路径 /home/user 嘛。当然如果不嫌麻烦的话,你也可以执行 /home/user/echo Hello 这样的命令。

操作系统提供一些常用的程序。GNU/Linux 中,它们大多位于 /bin 路径下,而 Windows 中,它们大多位于 C:\Windows\system32 下。这些程序很常用,以至于我不希望在调用它们的时候总是带着长长的路径名:

因此操作系统为这些路径提供了快捷方式:PATH 环境变量。你可以将 PATH 环境变量想象为一个字符串数组,其中是大量常用程序所在的路径。比如

  • Windows 的默认 PATH 环境变量一般是:
    • C:\Windows\system32
    • C:\Windows
    • C:\Windows\System32\Wbem
    • C:\Windows\System32\WindowsPowerShell\v1.0\
  • GNU/Linux 的默认 PATH 环境变量一般是:
    • /usr/local/sbin
    • /usr/local/bin
    • /usr/sbin
    • /usr/bin
    • /sbin
    • /bin
    • /usr/games

PATH 环境变量中的程序在执行时只需给出程序名,而不用给出其路径。所以,你在 Windows 下执行 C:\Windows\system32\ipconfig.exe 程序并不需要输入这样长长一串路径,只需输入 ipconfig.exe 就够了;在 GNU/Linux 下执行 /usr/bin/date 程序也只需输入 date 就够了。

图?

我们在写 C++ 程序之前,首先要做的就是配置 C++ 编程环境。而配置编程环境中常需要做的一件事情就是把编译器添加到 PATH 环境变量中。编译器可能会安装到任何地方,但如果想要快速地在壳层中启动编译器,则需要将它的位置放在 PATH 环境变量中。以 GNU/Linux 举例,其常用的 C++ 编译器经常安装在 /usr/bin/g++ 中,但日常使用时只需输入 g++ 命令就够了。

在 CLI 中使用编译器

假设我们的 C++ 编译器名字叫 g++。事实上,它是 GNU/Linux 操作系统的最常用编译器,也是 Windows 下广泛使用的 MinGW 编译环境所提供的编译器。在 macOS 上,g++ 也一般会设置为其常用编译器——AppleClang 的别名。

常见的唯一例外是 Microsoft Visual C++(MSVC,即 Visual Studio),其编译器名叫 cl.exe,且安装 Visual Studio 时也一般不将它添加到 PATH 环境变量中。

几乎所有的编译器都是以命令行界面来操作的。(换而言之 IDE 只是用漂亮的图形元素包装了这些命令行界面。)对于 g++ 来说,最常见的命令形式长成这样:

g++ 源文件路径 -o 编译得到的可执行文件路径

为了演示,仍然假设我们的壳层于 /home/user 路径下工作。这里存放了一个名为 helloworld.cpp 的文件:

// /home/user/helloworld.cpp
#include <iostream>
int main() {
    std::cout << "Hello, world!" << std::endl;
}

然后尝试用 g++ 编译器编译它。在壳层中执行如下命令:

g++ ./helloworld.cpp -o ./helloworld.exe↵

如果没有语法错误的话,g++ 编译器将什么都不会输出,直接默默地成功执行了。你可以检查一下,/home/user 路径下应当多了一个名为 helloworld.exe 的文件,这就是 g++ 编译的结果。接下来,尝试通过刚才学到的方法来运行这个 helloworld.exe 程序。在壳层中执行:

./helloworld.exe↵

应当能够看到输出 Hello, world!。这样,我们成功地做到了在 CLI 下编译并运行 C++ 程序。

“一个程序成功执行了它该做的事情时,不输出任何东西;仅当程序出现错误时才会输出错误提示。”这种行为有时被称为 UNIX 哲学:因为曾经及其著名的元祖级别的操作系统 UNIX 的内置程序采用了这种风格的行为。

多文件编译

这里,我将使用在多文件编译中的三文件例子。现在,/home/user 路径下存在 main.cpp hello.hhello.cpp 三个文件。

这里有两个编译单元 main.cpphello.cpp。将它们一起编译的方法非常简单,只需执行如下命令即可:

g++ ./main.cpp ./hello.cpp -o ./main.exe↵

在这条命令中,结尾的 -o ./main.exe 仍然用于指示可执行文件名;但不同于之前,这次使用 ./main.cpp./hello.cpp 两个参数来告诉编译器将哪些源文件作为翻译单元。当指定多个翻译单元时,编译器会分别编译这些翻译单元,并在编译完成后自动调用链接器来链接它们,最终将链接的结果转换为可执行文件保存到 ./main.exe 中。

在本文靠后的部分中,我将继续阐述一些有关多文件编译的细节问题。不过在此之前,容我先插入有关命令行选项的介绍。

命令行选项

大量 UNIX 风格的软件使用了名为 getopt 的库来处理命令行参数。g++ 编译器也不例外。getopt 库所能处理的命令行参数有其特别的风格,而这种风格及其广泛地影响了大量的程序,因此值得一提。

首先,getopt 将命令行参数分为“选项”和“非选项”两种类别。选项是指一系列由 - 减号开头的若干个参数。比如:

g++ -V↵
g++ --version↵

中,-V--version 这两个参数就是选项。这种简单的减号开头的选项称为“开关”或“布尔开关”。它仿佛给程序传入了一个布尔类型变量——好比说,如果参数中带有 --version,程序中的某个布尔变量 showVersion 就设置为 true;如果不带 --version,则设置为 false

使用 getopt 库的程序会规定一些可用的选项。如果你使用了这些规定之外的选项作为参数,那么你会得到一个 getopt 错误。

g++ --abcde↵
g++.exe: error: unrecognized command-line option '-abcde'

这个例子中,我是用了未定义的选项 --abcde。(因为这个参数是减号开头的,所以它被识别为选项。)然而,g++ 并不认识 --abcde 这个选项,所以它给出“未识别的命令行选项”错误。

几乎所有的程序都会规定 --help--version 两个布尔开关。当启用 --help 开关时,程序一般会输出帮助信息——关于如何使用此程序的帮助;当启用 --version 开关时,程序一般会输出版本信息。

g++ --help↵
Usage: g++.exe [options] file...
Options:
  -pass-exit-codes         Exit with highest error code from a phase.
  --help                   Display this information.
[...] 非常长的 g++ 帮助信息

布尔开关如同给程序传入了一个布尔变量,但如果要传入其它类型的变量就比较费力气了。所以,getopt 引入“带值的”选项的概念。比如:

g++ -x=c++↵
g++ -xc++↵
g++ -x c++↵

上面这三行的意思都是,-x 选项带有一个额外的字符串值 c++。换而言之,这里如同将程序中的某个字符串类型变量设置为 "c++"getopt 支持三种类型的“带值的”选项,即用 = 等号分隔名字和值、用 空格分隔名字和值,以及不加任何分隔符。显然,最后一种要求选项只能是单个字母形式的(如 -x -L)而不能是多个字母(如 --help -specs)。在 g++ 中,有些选项只支持 = 分隔的值(比如 -std),有些选项只支持后两者;具体情况需要查看帮助文档。

除了布尔开关、带值的选项,剩余的所有参数都是“非选项”参数。在 g++ 中,所有非选项参数都视为源文件——即翻译单元。

g++ abcd↵

这里,abcd 因为不是减号开头的,所以不会被识别为布尔开关或者选项。所有它是非选项参数。所以 g++ 会尝试在壳层的当前工作路径中寻找名为 abcd 的源文件,并将其作为翻译单元进行编译。如果找不到这个文件,则给出错误。

最后回过头来看一看最初的这条命令:

g++ ./main.cpp ./hello.cpp -o ./main.exe↵

这里,-o ./main.exe 就是带值的选项,而 ./main.cpp./hello.cpp 则是非选项参数。因此,g++ 将后两者认定为翻译单元。在 g++ 中,-o 选项可选地带有一个值,指定了编译的结果存放在哪里。所以 -o ./main.exe 就是将可执行文件存放在 ./main.exe 这个文件中。

常用的 g++ 选项除了 -o 外,还有:

选项名说明
-std= 标准指定语言标准,如 -std=c++20 std=c++98
-O 等级指定优化等级,如 -O1 -O2 -Ofast
-Wall给出比一般情况下更多的警告
-Werror视警告为错误,即出现警告则编译失败

g++ 中的翻译阶段

我们已经知道的是 C++ 翻译分为预处理、编译和链接三个大阶段。而编译则可更细划分出“汇编”一个阶段。也就是说,完整的翻译阶段分为如下几步:

其实,g++ 可以完成图中从任意一个阶段到另外一个阶段的流程。平时,我们总是直接从 .cpp 源文件一下子到 .exe 可执行文件,但通过调整 g++ 的命令行选项,可以指定其只完成其中部分阶段。

首先,g++ 通过文件后缀名来判断其流程的起点。

后缀名g++ 的行为
.cpp .c++ .cc .cxx视为源文件,将预处理作为第一步
.ii视为预处理后的源文件,将编译作为第一步
.s .S视为汇编文件,将汇编作为第一步
其它(一般为 .o .obj视为对象文件,将链接作为第一步

比如

g++ ./hello.cpp↵

中,g++./hello.cpp 视为源文件,从而执行预处理、编译、汇编、链接这四个流程,得到可执行文件。又比如

g++ ./hello.s↵

中,g++./hello.s 视为汇编文件,故只会执行汇编、链接两个流程,得到可执行文件。

其次,g++ 通过若干个选项来控制器流程的终点。

选项g++ 的行为
-E将预处理作为最后一步,得到预处理后的源文件
-S将编译作为最后一步,得到汇编文件
-c将汇编作为最后一步,得到对象文件
不含以上选项将链接作为最后一步,得到可执行文件

比如

g++ ./hello.cpp↵

中,由于没有指定 -E -S-c 选项,所以 g++ 会走到最后一步生成可执行文件。又比如

g++ ./hello.cpp -c↵

中,由于指定了 -c 选项,所以 g++ 只会执行预处理、编译、汇编三个流程,随后就终止了。最终只会生成名字类似 hello.o 的对象文件,而不是可执行文件。

此时,-o 不仅指定可执行文件的名字,还执行每次流程结束后得到结果的名字。比如

g++ ./hello.cpp -c -o abc.o↵

就会生成名为 abc.o 的对象文件。当不指定 -o 时,-E 会将预处理后的文件输出到屏幕上;-S 会以 .s 后缀名保存结果,-c 会以 .o 后缀名保存结果;可执行文件名则为 a.outa.exe

如果你想详细查看每一阶段 g++ 都做了什么,你可以将这四个阶段分开执行。下面的命令分别执行了其中单个阶段,并在最后运行这个可执行文件。(注:在大多数壳层程序中,# 代表单行注释,其后内容仅用于注解,不会被壳层处理。)

g++ ./hello.cpp -E >  ./hello.ii↵   # 预处理
g++ ./hello.ii  -S -o ./hello.s↵    # 编译
g++ ./hello.s   -c -o ./hello.o↵    # 汇编
g++ ./hello.o      -o ./hello.exe↵  # 链接
./hello.exe↵                        # 运行

-E 并不通过 -o 选项来控制输出位置,它直接将预处理结果输出到屏幕(stdout)上。所以这里使用了壳层的重定向运算符 > 将其输出保存到我们想要的位置 ./hello.ii 中。

注意:如果你在 Windows 下使用 PowerShell 来执行上述命令,你可能需要改变重定向的默认编码,比如提前执行 $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' 命令。

GNU Make

当初引入多文件编译的情景是这样的: f.cpp g.cpp main.cpp 分别各自编译出 f.o g.o main.o,随后再链接为 main.exe。当 f.cpp 有改动时,只需重新编译 f.o 并链接,而不需编译 g.omain.o。这种做法可以大大减少整个项目的编译时间。

但是,我们改动 f.cpp g.cpp 可能是很随意的事情——我可能不记得“f.cpp 改过了,待会需要重新生成 f.o”这种事情。如果翻译单元多起来,在一次编译中“哪些需要重新编译?哪些不需要?”就变成了很头疼的问题。因此,这里需要一个称作 GNU Make 的工具来解决它。

GNU Make(简称 Make)是一个经典的构建工具。所谓“构建”(Build),就是在一个项目中安排编译的顺序、时机,最终得到你想要的结果(称为“目标”(Target))。它的原理非常简单,就是一件事情:目标如何得到?

仍然是之前 f g main 这个情景。我们的目标是生成 main.exe。那么想一想如何生成 main.exe?它应该是由 f.o g.o main.o 三个对象文件链接得到的。而且,这三个文件一旦有一个发生更改,那么 main.exe 就应当重新编译。换而言之,main.exe 依赖于 f.o g.o main.o 三个文件。

Make 使用一种称为 Makefile 的文件来指明这些构建方法。Makefile 就是文件名为 Makefile 的文件,不带后缀名。Makefile 使用这样的语法来表示 main.exe 如何得到:

目标文件: 依赖文件1 依赖文件2 ...
	生成命令

注意 生成命令 前面的空白是一个 Tab 字符,而不是若干个空格。聪明的编辑器在编辑 Makefile 时会处理好这一点。

带入刚才的 main.exe 的生成方法,则得到这样的 Makefile 片段:

main.exe: f.o g.o main.o
	g++ f.o g.o main.o -o main.exe

这表示,要想生成 main.exe,需要在有 f.o g.o main.o 三个文件的情形下,执行 g++ f.o g.o main.o -o main.exe 命令来得到。有了这样的 Makefile,Make 就能知道如何生成 main.exe 目标。那么继续追问,既然得到 main.exe 需要 f.o,那么 f.o 如何得到?显然,它是由 g++-c 参数,从 f.cpp 生成的。于是又有了下面三条 Makefile 片段:

f.o: f.cpp
	g++ f.cpp -c -o f.o<br>
g.o: g.cpp
	g++ g.cpp -c -o g.o<br>
main.o: main.cpp
	g++ main.cpp -c -o main.o

Makefile 中保存了这样四个片段后,就可以开始构建了。在壳层中执行 make main.exe 命令:

make main.exe↵

在 MinGW 中,使用 mingw32-make 来代替 make

此命令会以 main.exe 为参数启动 Make 程序。首先,Make 程序会读取当前工作路径下的 Makefile 来了解构建规则。然后,它根据命令行参数得知我们想要得到 main.exe 这个目标。于是 Make 就会根据这些构建规则来生成它。Make 可以通过每个文件的最后改动时间来确定一个目标是否需要重新生成,而不用我们再手动判断。比如若 main.exe 的最后改动时间晚于 f.cpp g.cpp main.cpp,那么 Make 就不会做任何事——因为它知道所有的源代码都没更新,不需要重新编译。又或者 f.cpp 的最后改动时间晚于 f.o,那么 Make 就知道 f.o 需要更新,然后推导出 main.exe 需要更新。类似这样,Make 通过我们在 Makefile 中指定的构建规则来自动地管理编译顺序和时机。

CMake

可以看出,通过 Make 来管理构建方法是一种非常原始的策略。我现在有三个翻译单元,我就需要写至少三个生成规则;翻译单元越多,编写 Makefile 的难度就越大。所以,CMake 工具横空出世。

简单说,CMake 可以通过一个名为 CMakeLists.txt 的脚本来生成 Makefile。但 CMakeLists.txt 的语法比 Makefile 复杂得多,我只能在这里简要介绍。仍然用老例子,它的 CMakeLists.txt 长成这样:

cmake_minimum_required(VERSION 3.18.0)
project(HelloWorld)
add_executable(main main.cpp f.cpp g.cpp)

第一行规定了最低 CMake 版本,没啥意思。第二行指定了项目名称。第三行是重点: add_executable 告诉 CMake,我最终要生成一个可执行文件。可执行文件的名字叫 main,它需要从 main.cpp f.cpp g.cpp 三个文件编译链接得到。

安装 CMake 后,在源代码位置新建一个 build 文件夹,然后把壳层程序 cd 到这个 build 文件夹。接着,输入:

cmake ..↵

MinGW 中,请在上述命令中添加 -G "MinGW Makefiles" 选项。

就可以运行 CMake。.. 代表了 CMakeLists.txt 的位置;这里因为是在 build 文件夹中执行,所以 .. 就代表 build 文件夹的上层文件夹——也就是 CMakeLists.txt 所处的文件夹。CMake 阅读了 CMakeLists.txt,然后在 build 文件夹中生成了一系列文件——其中就包括 Makefile。

既然有 Makefile 了,接下来就可以输入 make main 命令来生成 main 可执行文件。这样,我们不用手动编写 Makefile 也能获得良好的多文件编译体验。许多 IDE(如 CLion、VS Code)将 CMake 的操作包装了起来,使得其使用更加方便:这也是我在正文中所介绍的。

在 CMake 诞生之前,大多数程序使用 GNU Autotools 来生成 Makefile。但目前 CMake 已是主流的、成熟的构建生成器,仅有一些 GNU 项目仍在使用 Autotools。虽然话这么说,CMake 仍然出于历史包袱原因饱受批评,也有部分新生项目使用 Xmake、SCons 等构建生成器。

提示

[TODO]

最近更新:
代码未运行