跳转至

Linux 上的编程

本文已完稿并通过审阅,是正式版本。

导言

作为一个成熟而实用的系统,我们该如何在 Linux 上进行日常的编程开发呢? 这一章将解答以下几个问题:

  • Linux 上的 C/C++ 开发
  • Linux 上的 Python 开发
  • Linux 上编程语言开发的范式与共性

C 语言开发

C 语言是大学编程语言教学中几乎必定讲解的一门编程语言。 考虑到 Linux 内核即是用 C 语言编写的,在 Linux 上 C 语言拥有近乎系统级的支持。 在 Linux 上开发 C 语言(以及 C++)是一件非常轻松、方便的事。

从单文件开始

现在假设我们有一份源码文件 main.c,内容如下:

// main.c
#include <stdio.h>

int main() {
  printf("Hello World!\n");
  return 0;
}

这是一个简单的 Hello World 程序。我们如何使它变为一份二进制可执行文件呢?

在 Windows 或 macOS 这样带 GUI 的系统上,通过安装 IDE,我们可以使用 IDE 中的编译功能来编译出目标。 实际上,这些带有图形界面的 IDE 的编译往往是封装了各种提供命令行接口的编译器。 自然,在众多无 GUI 的 Linux 上,我们同样可以调用这些提供命令行接口的编译器进行编译。

各平台常见编译器

Linux 上常用的编译器是 gcc 和 clang。 其中 gcc 是由 GNU 组织维护的,而 clang 是由 LLVM 组织维护的。

Windows 上常见的编译器则是 cl.exe,由微软维护。著名的 Visual C++ (MSVC) 即使用了 cl.exe。

macOS 本身由 BSD 发展而来,也以 gcc 和 clang 为主。 值得一提的是,macOS 上自带的 gcc 其实是 clang 的别名,在 Terminal 输入 gcc -v 即可发现。

这里我们使用 gcc 对这个文件进行编译,生成二进制文件:

$ gcc main.c -o main
$ ./main
Hello World!

这里用 -o 指定了输出的二进制文件的文件名 main

应当注意到 gcc main.c -o main 这条指令没有打印出任何内容。 这是因为整个编译过程是成功的,gcc 没有需要报告的内容,因此保持沉默。 这是 Unix 哲学的一部分:Rule of Silence: When a program has nothing surprising to say, it should say nothing.1

多文件的状况

只在一个文件中编写代码,对于稍微大的开发都是不够的: 对于个人维护的小项目尚可,但当你面临的是一个多人开发、模块复杂、功能繁多的大项目时(无论是在公司工程还是在实验室科研中,这都是普遍的情况), 拆分代码到多个文件才是一个明智(或者说可行)的做法。

C 语言的多文件实现

我们假设你对于 C 语言的多文件实现有着基本的认知: 即能够在之前的系统中的 IDE 内完成 C 语言的多文件开发。

如果你不会,别急,这里将做一个简单的介绍:

假设你拥有以下两个文件:

// main.c
#include "print.h"

int main() {
  print();
  return 0;
}
// print.c
#include "print.h"

#include <stdio.h>

void print() {
  printf("Hello World!\n");
}

那为了在 main.c 中调用 void print() 这个函数,你需要做以下几件事:

  • 在当前目录下新建一个头文件 print.h;
  • 在 print.h 中填入以下内容:
// print.h
#ifndef PRINT
#define PRINT

void print();

#endif  // PRINT

这里的 #ifndef ... #define ... #endif 是头文件保护,防止同一头文件被 #include 两次造成重复声明的错误, 如果你不理解这部分也没关系,只需保证 void print(); 这一行声明存在即可。

  • 在 main.c 和 print.c 中同时 #include "print.h"

这样,程序就可以被编译运行了。

假设我们有以下三个文件:

// main.c
#include "print.h"

int main() {
  print();
  return 0;
}
// print.c
#include <stdio.h>

void print() {
  printf("Hello World!\n");
}
// print.h
#ifndef PRINT
#define PRINT

void print();

#endif  // PRINT

我们将依次编译链接,生成目标的二进制可执行程序。让我们看一下命令:

$ gcc main.c -c  # 生成 main.o
$ gcc print.c -c  # 生成 print.o
$ gcc main.o print.o -o main
$ ./main
Hello World!

这里我们使用了 gcc -c-c 会将源文件编译为对象文件(Object file,.o 这一后缀就源自单词 object 的首字母)。 对象文件是二进制文件,不过它不可执行,因为其中需要引用外部代码的地方,是用占位数替代的,无法真正调用函数。

注意到我们没有添加 -o 选项,因为 -c 存在时 gcc 总会生成相同文件名(这里特指 basename,main.c 中的 main 部分)的 .o 对象文件。

生成了对象文件后,我们来进行链接,在相应函数调用的位置填上函数真正的地址,从而生成二进制可执行文件。 gcc 这一指令会根据输入文件的类型调用相应的程序完成整个编译流程。 在这里,虽然同样是 gcc 指令,但是由于输入的为 .o 文件,gcc 将调用链接器进行链接,从而生成最终的可执行文件。

同样是这个原因,实际上使用 gcc main.c print.c -o main 是可以一步到位,但在接下来的内容里,你会看到另一个方案。

gcc 的四个部分,编译的过程

gcc 的编译其实是四个过程的集合,分别是预处理(preprocessing)、编译(compilation)、汇编(assembly)、链接(linking), 分别由 cpp、cc1、as、ld 这四个程序完成,gcc 是它们的封装。

这四个过程分别完成:处理 # 开头的预编译指令、将源码编译为汇编代码、将汇编代码编译为二进制代码、组合众多二进制代码生成可执行文件, 也可分别调用 gcc -Egcc -Sgcc -cgcc 来完成。

在这一过程中,文件经历了如下变化:main.cmain.imain.smain.omain

使用构建工具(Build tools)

上述方法在源文件较少时是比较方便的,但当我们面对的是数以千计万计的源文件(同样的,在工作或科研中这也是常见状况),我们将面临以下困难:

  • 手动地一一编译实在太麻烦,太浪费精力;
  • 这些源文件的编译有顺序要求,为了满足此依赖关系需要设计一个流程;
  • 编译整个项目需要难以忍受的大量时间,应当考虑到一部分未更改的源文件不需要重新编译。

为了让机器帮助程序员解决这些困难,构建工具应运而生。 同样的,由于需求巨大,构建工具在 Linux 上亦获得了强力支持。

Makefile

Makefile 是中小型项目常用的构建工具。 让我们考虑以下例子:

假设前述 3 份源文件已存在在当前目录下。 创建以下内容的文件,并命名为 Makefile

main.o: main.c print.h
print.o: print.c print.h
main: main.o print.o

然后在当前目录下执行:

$ make main
$ ./main
Hello World!

为了解释这一过程,我们来分析一下 Makefile 的内容。其中:

main.o: main.c print.h

这一行,通过冒号分割,指定了一个名为 main.o 的目标,其依赖为 main.cprint.h。 由于整个文件中没有名为 main.c 的目标,所以 Makefile 会认为对应的 main.c 文件为一个依赖,print.h 同理。

在指定了目标和依赖后,紧接着的下一行如果用 Tab 缩进,则可以指定利用依赖获得目标的指令。 例如:

main.o: main.c print.h
    gcc main.c -c  # 一定要用 Tab 缩进而不是 4 个 / 2 个空格——这是历史遗留问题。

以上内容表示如果要获得 main.o 这个目标,则会执行 gcc main.c -c 这个指令。 如果没有指定命令,Makefile 会尝试从文件后缀等处获取信息,推测你需要的指令。 例如此处即使不显式写出指令,Makefile 也知道用 gcc 来完成编译。

最终我们在 shell 中执行 make main,正是指定了一个最终目标。 如果不提供这个目标,Makefile 则会选择 Makefile 文件中第一目标。 为了获得最终目标,Makefile 会递归地获取依赖、执行指令。

Makefile 的亮点在于引入了文件间的依赖关系。 在使用它进行构建时,Makefile 可以根据文件间的依赖关系和文件更新时间,找出需要重新编译的文件。 在项目较大时这能明显节省构建所需的时间,同时也能解决一些由于编译链接顺序造成的问题。 相较与输入一大串指令,单个的 make [target] 甚至是仅仅 make,也更加优雅和方便。

小知识

在 Makefile 中有一些隐含规则。即使我们的 Makefile 中没有显式书写这样的规则,make 也会按照这些隐含规则来运行。 例如,上文提到的自动将 .c 文件编译成 .o 就是一种隐含规则。

除此之外,Makefile 中还有如下隐含规则:

  • filename.o 的依赖会自动推导为 filename.c
  • filename 的依赖会自动推导为 filename.o

利用这两条隐含规则,我们的 Makefile 还可进一步化简成:

main.o: print.h
print.o: print.h
main: print.o

其他的构建工具:CMake,ninja……

一个更大的工程可能有上万、上十万份源文件,如果一一写进 Makefile,那依然会异常痛苦,且几乎不可能维护。

为了更好的构建程序,大家想出了“套娃”的办法:用一个程序来生成构建所需的配置,CMake 则在这一想法下诞生。

CMake 在默认情况下,可以通过 cmake 命令生成 Makefile,再进一步进行 make

对于 CMake 的讲解已经超出了本课程的讲解范围。 CMake 作为一个足够成熟、也足够陈旧的工具,既有历史遗留问题,也有新时代下的新思路。 正如 C++ 和 Modern C++,CMake 也有 Modern CMake,更有像微软 vcpkg 那样新的辅助工具和解决方案。 如果你想了解 CMake 的一些知识,附录将会有简单的介绍,亦可以考虑看一些较新的、关于 Modern CMake 的博客,以及官方的最新文档。

另一个值得一提的是 ninja。ninja 和 Makefile、autoconf 较类似,是构建工具,所属抽象层次低于 CMake。 此外,Meson 也是近年来非常流行的现代构建系统,它通常配合 ninja 使用,语法比 CMake 更加简洁。

至于 C++

C++ 的工具链与 C 的是相似的。

实际上,只需将上面内容中的 gcc 指令改为 g++,你就能同样地完成 C++ 的开发。 gcc 这一编译器本身即支持多种编程语言,包括了 C、C++、Objective C 等。 其他编译器如 clang 也会提供 clang++ 这样的指令完成 C++ 的编译。 Makefile、CMake 这样的构建工具亦可以用于多种编程语言。

总结

在 Linux 下,大多编程语言都会提供一套适合命令行的、简单便捷的工具链。 善于运用这些工具,能够极大地提升你的开发效率,支持你完成自己的项目。

Python 语言开发

Python 作为一门年长但恰逢新春的解释型语言,亦被业界广泛使用。 相较于 Windows,在 Linux 上开发 Python 要更加简单。

针对 Python 的介绍,我们将不会着力于具体代码,而是分析其一些外围架构,从而引出总结。

解释器 python

一般的 Python(CPython)程序的运行,依靠的是 Python 解释器(Interpreter)。 在 Python 解释器中,Python 代码首先被处理成一种字节码(Bytecode,与 JVM 运行的字节码不是一个东西,但有相似之处), 然后再交由 PVM(Python virtual machine)进行执行,从而实现跨平台和动态等特性。

由于使用过于广泛,几乎每一份 Linux 都带有 Python 解释器,以命令 python3 调用。

Note

部分更早的发行版中会包含 Python 2,且默认将 python 命令指向 Python 2。

对于现代的 Linux 发行版,我们建议安装 python-is-python3 包,使得 python 命令指向 python3

$ sudo apt install python-is-python3

但我们仍建议在脚本和 shebang (#! 开头的行) 中显式使用 python3,以避免上述歧义。

#!/usr/bin/env python3

# rest of the code

包管理器 pip

为使用外部的第三方包,Python 提供了一个包管理器:pip。

pip 和 apt 之类的包管理器有相似之处:完成包的安装和管理,完成依赖的分析,等等。 不过 pip 管理的是 Python 包,可以在 Python 代码中使用这些包。让我们看下面的例子:

# 安装 Python 3  Python 3  pip。
$ sudo apt install python3 python3-pip

# 测试一下看看,是否能够正常使用它们。
$ python3 -V
$ pip3 -V

# 暂时忽略以下两条指令,我们会在之后讲解。
$ python3 -m venv venv
$ source venv/bin/activate
(venv)$ ls
venv

# 安装一个 Python  a、b,以及 a、b 依赖的 Python 包。
(venv)$ pip3 install a b

# 卸载一个 Python  b。注意:这不会删除之前一起安装的包 b 的依赖。
(venv)$ pip3 uninstall b

安装了 a 之后,我们就能在代码中使用 a 这个包了。

# main.py
import a

print(a)
(venv)$ python3 main.py
<module 'a' from '...'>

这样,我们就完成了对外部 Python 包的安装和引用。

Python 依赖管理

一个软件一般含有众多依赖,尤其是对于追求易用、外部库众多的 Python 而言,使用外部库作为依赖是常事。

此处我们将尝试给出各种使用较多的 Python 依赖管理方案。

requirements.txt

在一些项目下,你可能会发现一个名为 requirements.txt 的文件,里面是一行行的 Python 包名和一些对于软件版本的限制。

# requirements.txt
django
pytest>=3.0.0
pytest-cov==1.0.0

为了安装这些 Python 包,使用以下指令:

$ pip3 install -r requirements.txt

这将从 requirements.txt 文件中逐行读取包名和版本限制,并由 pip 完成安装。

此方案简单明了,易于使用,但对于依赖的处理能力不足。

setuptools: setup.py

在 PyPI,即 pip 获取 Python 包的来源中,使用 setuptools 是主流选择。 常见状况是目录下会有一个名为 setup.py 的文件。要安装依赖,只需执行 pip3 install .

其他的:pip-tools、pipenv、uv……

Python 有非常多的依赖管理方案。其中 uv 是近年来备受瞩目的新工具,它用 Rust 编写,集成了包管理、虚拟环境管理和 Python 版本管理,速度极快,被认为是 Python 工具链的未来。

Virtualenv 与 venv

让我们考虑以下情况:

Python 通过包管理器如 apt 安装的包,默认安装在系统目录 /usr/lib/python[version] 下, 而通过 pip 安装的包,默认安装目录在 /usr/local/lib/python[version] 下, 当通过 pip 安装时显式传入了一个 --user 选项时,则会安装在用户目录 ~/.local/lib/python[version] 下, 另外,在普通用户下直接执行 pip install 而没有传入 --user 选项时,也会因为用户没有系统目录的写权限而安装到用户目录。 当普通地运行 Python 解释器时,这几个目录下的包均可见。

现在假设用户目录下已有一个包 a,版本为 1.0.0。 现在我们需要开发一个程序,也需要包 a,但要求版本大于 2.0.0

由于 pip 不允许同时安装不同版本的同一个包,当你运行 pip3 install a>=2.0.0 时,pip 会更新 a2.0.0, 那原先依赖于 a==1.0.0 的软件就无法正常运行了。

注意 >=

在一些 Shell(如 zsh)中,>= 有特殊含义。 此时上述命令应用引号包裹 >= 部分,如 pip3 install 'a>=2.0.0'

为了解决这一问题,允许不同软件使用不同版本的包,Python 有下面的虚拟环境工具:

  • venv:这是 Python 3.3 之后内置的标准模块,通过 python3 -m venv venv 即可创建,是目前的官方推荐方案。
  • virtualenv:一个历史更悠久的第三方工具,在 venv 成为标准之前它是事实上的选择,目前在一些复杂场景或旧版本支持中仍在使用。

常见的做法是使用 Python 的模块运行来完成在 Shell 中的执行:

$ python3 -m venv venv

在一般的 shell 环境下,我们将使用 source venv/bin/activate 来启用这个 venv。启用后,你使用 pip3 install 安装的包将被隔离在当前文件夹中。通过 deactivate 可以退出虚拟环境。

Python 的版本

Python 2 已在 2020 年初正式宣告停止维护,由于已淘汰多年,所以本教程不再做更多介绍。

现在的 Python,最新的版本已到 3.14(截至 2025 年 9 月)。实际上还在使用中的 Python 版本,主要在 3.9 以上。

我应该选择哪个版本的 Python?

Python 3.x 已经迭代到一个相对稳定的阶段,如果你没有特殊需求,请使用 Python 3.x 的最新版本。

截止到 2025 年 9 月,我们推荐 Python 3.14。(或者使用系统自带的版本)

你可以在 Status of Python versions 查看 Python 各个版本的状态。

Python 的生命支持周期

Python 的每个主版本分支都遵循一个固定的生命支持周期,以确保语言的稳定性和持续发展。

根据 Python 官方的规定,自 Python 3.9 开始,从每个 Python 3.X.0 版本发布起,Python 3.X 系列享有为期 5 年的支持。 这 5 年的生命支持周期分为以下几个阶段:

  1. 错误修复支持 / 完整支持 (Bugfix / Full Support): 从正式发布开始,该版本将获得全面的支持,包括接收错误修复和安全补丁。这个阶段通常持续 2 年。 在此期间,大约每隔一个月会发布新的二进制文件。

  2. 安全修复支持 (Security Support): 在错误修复支持阶段结束后,该版本将进入为期 3 年 的安全修复阶段。 在此期间,将不再发布任何二进制文件,仅提供源代码形式的安全补丁。

  3. 生命周期结束 (End-of-Life, EOL): 在发布 5 年后,该版本的支持将正式结束。 届时,该版本将不再接收任何更改,包括安全修复,使用这些版本的项目将面临安全风险。

你可以在 PEP 602 查看详细的有关 Python 的生命支持周期的规定。

Python 的其他实现

除了官方的 CPython,Python 还有其他实现:

  • PyPy:实现了 JIT(just in time)编译器,性能有极大提升;
  • Cython:引入了额外的语法和严密的类型系统;
  • Numba:将 Python 编译到机器码,适合科学计算。

总结

外部包引用和依赖管理是程序开发中必不可少的部分。如果官方有成熟的方案,跟随他们是明智的选择。

思考题

试试 Rust

Rust 是一门新兴编译型编程语言。 尝试查询 Rust 的文档,了解 Rust 的编译器(rustc)、依赖管理程序(cargo), 介绍一下如何将 Rust 源码变为可执行程序,并思考为什么 Rust 的包管理体验通常被认为优于 C++?

引用来源