多文件程序构建基础

介绍C/C++语言构建可执行文件的基本原理和术语

编译(构建)过程

计算机并不能直接执行C或者C++语言指令,因此C/C++程序必须被翻译(translate)成计算机(CPU)认识的指令(机器语言指令),并按照特定的格式保存在可执行文件(Executable)中之后,才能被计算机载入执行。在C++11(ISO/IEC 14882:2011)标准中,将从C/C++源程序文件到可执行文件的过程称为"translation"。在很多C/C++教材中,也将这个过程称为**“Compilation”(编译)**。

由于compilation/compile在不同的情境下有不同的含义,为了便于的区分,很多工具软件将这个过程称为build(构建)

下图展示了C/C++编译(构建)程序的一般过程:

graph TD
  subgraph 构建
  Source1{{源文件}} -- 预处理 --> TranslationUnit1[编译单元]
  Header1[[头文件]] -.-> TranslationUnit1[编译单元]
  Source2{{源文件}} -- 预处理 --> TranslationUnit2[编译单元]
  Header2[[头文件]] -.-> TranslationUnit2[编译单元]
  Source3{{源文件}} -- 预处理 --> TranslationUnit3[编译单元]
  Header3[[头文件]] -.-> TranslationUnit3[编译单元]
  TranslationUnit1 -- 编译 --> TranslatedUnit1([目标文件])
  TranslationUnit2 -- 编译 --> TranslatedUnit2([目标文件])
  TranslationUnit3 -- 编译 --> TranslatedUnit3([目标文件])
  end
  subgraph 链接
  TranslatedUnit1 & TranslatedUnit2 & TranslatedUnit3 -- 链接 --> Executable[/可执行文件/]
  end

预处理和编译单元

编译的第一步是对源文件(source file)进行预处理(preprocess)。预处理包括转码、标记切分(tokenize)、宏替换等很多工作,但其中最重要的工作是把源文件中#include语句包含的头文件(header)的内容和源文件合并在一起成为一个整体,也就是所谓的"编译单元"(translation unit)。

每一个源文件经过预处理后都会得到一个"编译单元"。

在gcc编译时加入"-E"参数,就可以将源文件预处理后得到的编译单元保存到指定的文件中。

编译和目标文件

编译过程的下一步是将编译单元转换为机器代码,这个步骤一般叫做"编译"(compilation),编译得到的机器代码被称为"translated unit"(“被编译单元”)。 当被编译单元保存到文件中时,我们会将该文件称为"目标文件"(object file),通常以.o或者.obj作为目标文件的后缀名。

因为"被翻译单元"这个说法在中文中比较别扭,所以我们一般用"目标文件"来代替它,即使"目标文件"并没有被实际存到文件里。

说明:很不幸,编译这个词被在不同的地方表示了不同的含义。不过根据上下文,我们一般都能够理解"编译"到底是指从源文件到目标文件,还是从编译单元到目标文件。

链接

链接是整个编译过程中的最后一步,将一个或者多个目标文件整合成一个可执行文件。这是多文件编译时最容易出问题的步骤。

下图是一个典型的链接错误提示(首先,错误提示显示的文件名是个目标文件"xxxx.o";其次,ld(gcc的链接程序)程序错误退出"ld returned 1 exit status"):

常见的错误问题有:

  • 多个目标文件中存在同名的标识符。(在多个源文件中定义了同一个全局变量或者函数)
  • 找不到某个函数的定义。(某个源文件中调用了某个函数,但是在所有的目标文件中都找不到这个函数的定义)

库文件

在链接时我们可能会需要用到.a或者.lib结尾的库文件(library file),这些文件实际上是由多个预先生成的目标文件组成的包。链接某个库文件实际上就是链接包里面的目标文件。

在gcc编译时,指定-lxxx参数,实际是让gcc链接libxxx.a库文件。

使用gcc工具集里的ar命令可以管理和查看.a文件中的内容。下图显示了libstdc++.a库文件中包含的目标文件:

在编译C/C++程序时,缺省gcc会自动链接标准库的库文件。因此,我们不需要告诉gcc怎么找到printf等标准库函数的定义,gcc就能够正确编译和链接普通的程序。

Make与Makefile

gcc一次运行只能进行单个文件的编译,单个文件的编译链接,或者多个文件的链接;无法完成多个文件的编译和链接。因此,需要按照一定的顺序多次运行gcc,才能完成一次完整的多文件编译链接。

此外,我们在维护和修改维护软件时,往往需要在只更新了一两个源文件的情况下重新生成可执行文件。显然,此时只需要对那些修改过的源文件重新编译得到更新的目标文件;没有修改过的源文件没有必要重新编译一遍。

为了方便这个过程,大家使用make工具来管理和执行这个过程。它的核心功能就是检查相关的源文件和头文件和对应的目标文件相比是否有更新,重新编译那些受到变化影响的源文件得到更新的目标文件,然后重新链接所有的目标文件最终得到新的可执行文件。

一般我们会用一个配置文件用来告诉make工具,生成可执行性文件需要哪些目标文件,每个目标文件又对应哪个源文件以及涉及哪些头文件,应该如何编译等等信息。这个配置文件被称为makefile,一般使用Makefile或者Makefile.win作为它的文件名。

小熊猫C++在编译单文件时,会直接调用gcc;在多文件编译(编译项目)时,会根据项目信息,自动生成makefile,然后调用make程序来完成编译。

下面是小熊猫C++自动生成的一个项目makefile:

# Project: 项目23
# Makefile created by Red Panda C++ 1.0.0

CPP      = g++.exe
CC       = gcc.exe
WINDRES  = windres.exe
RES      = 项目23_private.res
OBJ      = main.o glmatrix.o $(RES)
LINKOBJ  = main.o glmatrix.o $(RES)
CLEANOBJ  =  main.o glmatrix.o $(RES)
LIBS     =  -lwinmm -mwindows -lm -lfreeglut.dll -lopengl32 -lwinmm -lgdi32  
INCS     =  
CXXINCS  =  
BIN      = 项目23.exe
CXXFLAGS = $(CXXINCS)  -Wall -Wextra -g3 -pipe -D__DEBUG__
CFLAGS   = $(INCS)  -Wall -Wextra -g3 -pipe -D__DEBUG__
RM       = del /q /f

.PHONY: all all-before all-after clean clean-custom

all: all-before $(BIN) all-after

clean: clean-custom
	${RM} $(CLEANOBJ) $(BIN) > NUL 2>&1

$(BIN): $(OBJ)
	$(CPP) $(LINKOBJ) -o $(BIN) $(LIBS)

main.o: main.c glmatrix.h
	$(CPP) -c C:/Users/royqh/Documents/projects/项目23/main.c -o main.o $(CXXFLAGS) 

glmatrix.o: glmatrix.c glmatrix.h
	$(CPP) -c C:/Users/royqh/Documents/projects/项目23/glmatrix.c -o glmatrix.o $(CXXFLAGS) 

项目23_private.res: 项目23_private.rc 
	$(WINDRES) -i 项目23_private.rc --input-format=rc -o 项目23_private.res -O coff 

从中我们可以看到,"$(BIN)"(也就是"项目23.exe")依赖于"$(OBJ)"(main.o、glmatrix.o等目标文件);而main.o依赖于main.c和glmatrix.h。make程序会根据这些信息来自动判断应该执行哪些操作来生成项目23.exe文件。

最后修改 April 20, 2022: update (965cb8b6)