65.9K
CodeProject 正在变化。 阅读更多。
Home

创建用于编译 C 代码的基本 Makefile

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (2投票s)

2014年7月9日

CPOL

12分钟阅读

viewsIcon

67008

编译 C 代码和使用 Makefiles 的基础知识。

binary-blanket-240

我最近开始上哈佛大学的计算机科学课程。听起来有点装模作样,但实际上并没有那么糟糕。我正在免费在线学习哈佛 CS50,以期 拓展我的知识并加深我对编程的热爱

该课程使用 Linux 和 C 作为两个主要的学习工具/环境。虽然我基本能够使用 Linux,并且 C 的语法也很熟悉,但 C 编译、链接和 makefile 的机制对我来说都是全新的。

Imagebyquimby  |  保留部分权利

如果您是经验丰富的 C 开发者,或者您之前大量使用过 Make,那么这里可能没有什么新内容。但是,如果您想告诉我我何时传播了错误信息,请务必告知!本文(希望)对刚开始编译 C 程序和/或使用 GNU Make 工具的人会有所帮助。

在本文中,我们讨论了一些特定于课程练习的内容。但是,所讨论的概念和介绍的示例足够通用,因此本文应能超越课程的特定内容而有所用处。

该课程提供了一个“虚拟机”(基本上是一个预先配置好的虚拟机,其中包含所有必需的软件和文件),我认为任何参加免费在线课程的人都可以下载。但是,由于我已经知道如何设置/配置 Linux 系统(但总能从中获得更多实践),我认为通过“硬”方式做事,手动完成课程要求的所有内容,可以提升我的学习效果。

无论好坏,这迫使我学习 Make 和 makefile(这是好的,我只是这样写了这句话,为了听起来好听)。

Github 上的 Make 文件示例

在本文中,我们将组合几个示例 Make 文件。您也可以在我的 Github 仓库中找到它们的源代码。

编译和链接 - 简单的

这绝不是一篇经过深入考虑的关于编译和链接源代码的资源。但是,为了理解 make 如何工作,我们需要在某个层面上理解编译和链接。

让我们考虑一下用 C 语言编写的经典 "Hello World!" 程序。假设我们有以下源代码文件,名为 hello.c。

C 语言的基本 "Hello World" 实现
#include <stdio.h> 
  
int main(void)
{
    printf("Hello, World!\n");
}

在上文中,第一行指示编译器包含 C 标准 IO 库,通过引用 stdio.h 头文件。接着是我们应用程序的代码,它会令人印象深刻地将字符串 "Hello World!" 打印到终端窗口。

要运行上面的程序,我们首先需要编译。由于哈佛课程使用的是 Clang 编译器,我们在终端中可能输入的最高级别的编译命令是

用于 "Hello World" 的基本编译命令
$ clang hello.c -o hello

当我们在终端窗口中输入上述命令时,我们实际上是在告诉 Clang 编译器编译源代码文件 hello.c,而 -o 标志告诉它将输出的二进制文件命名为 hello

对于像编译 "Hello World" 这样简单的任务,这已经足够了。C 标准库的文件会被自动链接,我们使用的唯一编译器标志是 -o 来命名输出文件(如果我们不这样做,输出文件将被命名为 a.out,这是默认输出文件名)。

编译和链接 - 稍微复杂一点

当我在上面的标题中说“复杂”时,这都是相对的。我们将通过添加外部库和使用一些重要的附加编译器标志来扩展我们简单的 "Hello World" 示例。

哈佛 CS50 课程团队创建了一个 cs50 库供学生在课程中使用。该库包含一些函数,可以帮助人们轻松地开始使用 C 语言。除此之外,团队还添加了许多旨在从用户那里检索终端输入的函数。例如,cs50 库定义了一个GetString()函数,该函数将接受来自终端窗口的文本用户输入。

除了GetString()函数外,cs50 库还定义了一个字符串数据类型(它不是 C 的原生数据类型!)。

我们可以按照 cs50 网站的说明将 cs50 库添加到我们的机器上。在此过程中,库将被编译,输出将被放置在 /usr/local/lib/ 目录中,所有重要的头文件将被添加到我们的 usr/local/include/ 目录中。

注意:在这里您不需要特别关注使用这个特定于课程的库 - 这只是一个向编译过程添加外部引用的示例。

添加完成后,其中定义的各种函数和类型将可供我们使用。

通过引用 cs50 库,并使用GetString()函数和新的字符串type

修改版的 "Hello World" 示例
// Add include for cs50 library:
#include <cs50.h>
#include <stdio.h>
  
int main(void)
{
    printf("What is your name?");
    // Get text input from user:
    string name = GetString();
  
    // Use user input in output striing:
    printf("Hello, %s\n", name);
}

现在,如果我们尝试使用相同的终端命令来编译这个版本,我们会看到一些问题。

运行原始编译命令
$ clang hello.c -o hello

命令的终端输出

/tmp/hello-E2TvwD.o: In function `main':
hello.c:(.text+0x22): undefined reference to `GetString'
clang: error: linker command failed with exit code 1 
    (use -v to see invocation)

从终端输出中,我们可以看到编译器找不到GetString()方法,并且链接器出现了问题。

事实证明,我们可以向 clang 命令添加一些额外的参数来告诉 Clang 需要链接哪些文件。

$ clang hello.c -o hello -lcs50

通过添加 -l 标志,后跟我们需要包含的库的名称,我们告诉 Clang 链接到 cs50 库。

处理错误和警告

当然,上面使用 Clang 编译器 和参数的示例仍然代表一个非常基本的情况。通常,我们可能希望指示编译器添加编译器警告,和/或在输出文件中包含调试信息。使用我们的上述示例,从终端执行此操作的简单方法如下:

向 clang 终端命令添加额外的编译器标志
clang hello.c -g -Wall -o hello -lcs50

在这里,我们使用了-g标志,它告诉编译器在输出文件中包含调试信息,以及-Wall标志。-Wall启用 Clang 中大多数各种编译器警告(警告不会阻止编译和输出,但会警告潜在问题)。

快速浏览 Clang 文档 会发现存在大量潜在的编译器标志和其他参数。

正如我们所见,即使是仍然很简单的 hello 应用程序的编译,我们的终端输入也变得很麻烦。现在想象一个更大的应用程序,它有多个源文件,引用了多个外部库。

什么是 Make?

由于源代码可以包含在多个文件中,并且还可以引用其他文件和库,因此我们需要一种方法来告诉编译器要编译哪些文件,以什么顺序编译它们,以及如何链接我们源代码所依赖的外部文件和库。随着为了让我们的应用程序运行而需要添加各种编译器选项,再加上开发过程中可能频繁使用的编译/运行周期,很容易看出手动输入编译命令会迅速变得麻烦。

这时,Make 工具就派上用场了。

GNU Make 最初由 Richard M. Stallman ("RMS") 和 Roland McGrath 创建。来自 GNU 手册

"make 工具会自动确定一个大型程序需要重新编译的哪些部分,并发出命令来重新编译它们。"

当我们用 C、C++ 或其他编译语言编写源代码时,创建源代码只是第一步。人类可读的源代码必须被编译成二进制文件,以便机器能够运行该应用程序。

本质上,Make 工具利用 makefile 中包含的结构化信息来正确编译和链接程序。Make 文件被命名为 Makefile 或 makefile,并放置在项目的源代码目录中。

用于 "Hello World" 的示例 Makefile

我们最后使用clang命令在终端中的示例包含了一些编译器标志,并引用了一个外部库。手动执行该命令仍然可行,但使用 make,我们可以让工作变得更轻松。

在简单形式下,一个 make 文件可以设置为执行上面的终端命令。基本结构如下:

Makefile 基本结构
# Compile an executable named yourProgram from yourProgram.c
all: yourProgram.c
<TAB>gcc -g -Wall -o yourProgram yourProgram.c

在 makefile 中,以井号开头的行是注释,将被工具忽略。在上面的结构中,第三行中的 <TAB> 实际上是一个制表符至关重要。使用 Make,所有实际命令都必须以制表符开头。

例如,我们可以为我们的 hello 程序创建一个 Makefile,如下所示:

用于 Hello 程序的 Makefile
# compile the hello program with compiler warnings, 

# 调试信息,并包含 cs50 库 all: hello.c clang -g -Wall -o hello hello.c -lcs50

这个 Make 文件,(恰当地)命名为 makefile 并保存在我们的 hello.c 源文件所在的目录中,将执行与我们之前检查的最后一个终端命令完全相同的操作。为了使用上面的 makefile 编译我们的 hello.c 程序,我们只需要在终端中输入以下命令:

使用 Make 编译 Hello
$ make

当然,我们需要位于 make 文件和 hello.c 源文件所在的目录中。

更通用的 Makefile 模板

当然,编译我们的 "Hello World" 应用程序仍然代表了编译过程的一个相当简化的视图。我们可能希望利用 Make 工具的优势,并创建一个更通用的模板供我们创建 make 文件。

Make 工具允许我们将 makefile 结构化,以便将编译 *目标*(要编译的源)与命令以及编译器标志/参数(在 make 文件中称为 *规则*)分开。我们甚至可以使用相当于是变量来存储这些值。

例如,我们可以如下修改当前的 makefile:

通用 Makefile 模板
# the compiler to use
CC = clang

# compiler flags:
#  -g    adds debugging information to the executable file
#  -Wall turns on most, but not all, compiler warnings
CFLAGS  = -g -Wall
  
#files to link:
LFLAGS = -lcs50
  
# the name to use for both the target source file, and the output file:
TARGET = hello
  
all: $(TARGET)
  
$(TARGET): $(TARGET).c
	$(CC) $(CFLAGS) -o $(TARGET) $(TARGET).c $(LFLAGS)

如上所示,我们可以为每个大写变量赋值,然后它们用于形成命令(请注意,在突出显示的行中,实际命令再次以制表符开头)。虽然此 Make 文件仍为我们的 "Hello World" 应用程序设置,但我们可以轻松更改 TARGET 变量的赋值,以及为不同的应用程序添加或删除编译器标志和/或链接文件。

同样,我们只需键入以下命令即可告诉 make 编译我们的 hello 应用程序:

使用修改后的 Makefile 编译 Hello.c
$ make

关于编辑器中 Tab 与空格的说明

如果您也遵循“唯一真理编码约定”,即

"应使用空格,而不是制表符,进行缩进"

那么您在创建 make 文件时会遇到问题。如果您的编辑器设置为将制表符转换为空格,Make 将无法识别命令前面的至关重要的 Tab 字符,因为,嗯,它不在那里。

幸运的是,有一个变通方法。如果您的源代码文件中没有制表符,您可以改用分号来分隔编译目标和命令。有了这个修复,我们的 Make 文件可能看起来像这样:

没有 Tab 字符的 Makefile
# Compile an executable named yourProgram from yourProgram.c
  
all: yourProgram.c
<TAB>gcc -g -Wall -o yourProgram yourProgram.c

# compile the hello program with spaces instead of Tabs
  
# the compiler to use
CC = clang
  
# compiler flags:
#  -g    adds debugging information to the executable file
#  -Wall turns on most, but not all, compiler warnings
CFLAGS  = -g -Wall
  
#files to link:
LFLAGS = -lcs50
  
# require that an argument be provided at the command line for the target name:
TARGET = hello
  
all: $(TARGET)
$(TARGET): $(TARGET).c ; $(CC) $(CFLAGS) -o $(TARGET) $(TARGET).c $(LFLAGS)

在上文中,我们在依赖项定义(目标的依赖项定义)和命令语句结构(请参阅最后一行)之间插入了一个分号。

将编译目标名称作为命令行参数传递给 Make

大多数时候,在开发应用程序时,您最有可能需要一个特定于应用程序的 Makefile。至少,任何实质性的应用程序,它包含一个以上的源文件和/或外部引用。

但是,对于简单的玩弄,或者在我看来,对于拼凑各种一次性的示例片段(这些片段构成了哈佛 cs50 课程大部分的问题集),能够将编译目标的名称作为命令行参数传递可能会很方便。哈佛的示例大部分都包含程序人员创建的 cs50 库(至少在早期练习中),但除此之外,大部分都需要相同的参数集。

例如,假设我们在同一个目录中有另一个代码文件 goodby.c

我们可以这样简单地传递目标名称:

将编译目标名称作为命令行参数传递
make TARGET=goodbye.c

正如我们所见,我们在调用 Make 时将目标名称分配给 TARGET 变量。在这种情况下,如果我们不传递目标名称,而是像以前那样只键入 make,Make 将编译硬编码到 Makefile 中的程序 - 在这种情况下是 hello。尽管我们有其他意图,但错误的程序将被编译。

我们可以对 Makefile 再做一个修改,如果我们想要求必须将目标指定为命令行参数:

要求编译目标名称的命令行参数
# compile the hello program with spaces instead of Tabs
# the compiler to use
CC = clang
  
# compiler flags:
#  -g    adds debugging information to the executable file
#  -Wall turns on most, but not all, compiler warnings
  
CFLAGS  = -g -Wall
  
#files to link:
LFLAGS = -lcs50
  
# require that an argument be provided at the command line for the target name:
TARGET = $(target)
  
all: $(TARGET)
$(TARGET): $(TARGET).c ; $(CC) $(CFLAGS) -o $(TARGET) $(TARGET).c $(LFLAGS)

有了这个更改,我们现在可以这样运行一个简单的单文件程序的 make:

调用带有必需目标名称的 Make
$ make target=hello

当然,如果我们忘记包含目标名称,或者如果我们忘记在命令行中显式调用 Make 来进行赋值,事情就会变得有点混乱。

仅仅是开始

这篇帖子主要是为了我自己的参考。随着我越来越熟悉 C、编译和 Make,我预计我的用法可能会发生变化。但目前,上述内容代表了我在尝试处理在线哈佛课程中的示例时所理解的内容。

如果您发现我在上面做了任何愚蠢的事情,或者有任何建议,我洗耳恭听!请在下方评论,或者通过本页顶部“关于作者”的介绍中描述的电子邮件与我联系。

更新:与上面段落一致,关于此主题在 Reddit 上进行了一些 非常有趣的讨论。一些经验丰富的开发者对我的文章内容提出了他们的意见。我强烈建议您查看 Reddit 上的讨论以获取更多信息。

其他资源和感兴趣的项目

© . All rights reserved.