1
0
wiki/docs/开发/C/多文件项目.md

238 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
id: 多文件项目
title: 多文件项目
sidebar_position: 20
data: 2022年3月30日
---
## 简介
一个软件项目往往包含多个源码文件,编译时需要将这些文件一起编译,生成一个可执行文件。
假定一个项目有两个源码文件`foo.c`和`bar.c`,其中`foo.c`是主文件,`bar.c`是库文件。所谓“主文件”,就是包含了`main()`函数的项目入口文件,里面会引用库文件定义的各种函数。
```c
// File foo.c
#include <stdio.h>
int main(void) {
printf("%d\n", add(2, 3)); // 5!
}
```
上面代码中,主文件`foo.c`调用了函数`add()`,这个函数是在库文件`bar.c`里面定义的。
```c
// File bar.c
int add(int x, int y) {
return x + y;
}
```
现在,将这两个文件一起编译。
```bash
$ gcc -o foo foo.c bar.c
# 更省事的写法
$ gcc -o foo *.c
```
上面命令中gcc 的`-o`参数指定生成的二进制可执行文件的文件名,本例是`foo`。
这个命令运行后,编译器会发出警告,原因是在编译`foo.c`的过程中,编译器发现一个不认识的函数`add()``foo.c`里面没有这个函数的原型或者定义。因此,最好修改一下`foo.c`,在文件头部加入`add()`的原型。
```c
// File foo.c
#include <stdio.h>
int add(int, int);
int main(void) {
printf("%d\n", add(2, 3)); // 5!
}
```
现在再编译就没有警告了。
你可能马上就会想到,如果有多个文件都使用这个函数`add()`,那么每个文件都需要加入函数原型。一旦需要修改函数`add()`(比如改变参数的数量),就会非常麻烦,需要每个文件逐一改动。所以,通常的做法是新建一个专门的头文件`bar.h`,放置所有在`bar.c`里面定义的函数的原型。
```c
// File bar.h
int add(int, int);
```
然后使用`include`命令,在用到这个函数的源码文件里面加载这个头文件`bar.h`。
```c
// File foo.c
#include <stdio.h>
#include "bar.h"
int main(void) {
printf("%d\n", add(2, 3)); // 5!
}
```
上面代码中,`#include "bar.h"`表示加入头文件`bar.h`。这个文件没有放在尖括号里面,表示它是用户提供的;它没有写路径,就表示与当前源码文件在同一个目录。
然后,最好在`bar.c`里面也加载这个头文件,这样可以让编译器验证,函数原型与函数定义是否一致。
```c
// File bar.c
#include "bar.h"
int add(int a, int b) {
return a + b;
}
```
现在重新编译,就可以顺利得到二进制可执行文件。
```bash
$ gcc -o foo foo.c bar.c
```
## 重复加载
头文件里面还可以加载其他头文件,因此有可能产生重复加载。比如,`a.h`和`b.h`都加载了`c.h`,然后`foo.c`同时加载了`a.h`和`b.h`,这意味着`foo.c`会编译两次`c.h`。
最好避免这种重复加载,虽然多次定义同一个函数原型并不会报错,但是有些语句重复使用会报错,比如多次重复定义同一个 Struct 数据结构。解决重复加载的常见方法是,在头文件里面设置一个专门的宏,加载时一旦发现这个宏存在,就不再继续加载当前文件了。
```c
// File bar.h
#ifndef BAR_H
#define BAR_H
int add(int, int);
#endif
```
上面示例中,头文件`bar.h`使用`#ifndef`和`#endif`设置了一个条件判断。每当加载这个头文件时,就会执行这个判断,查看有没有设置过宏`BAR_H`。如果设置过了,表明这个头文件已经加载过了,就不再重复加载了,反之就先设置一下这个宏,然后加载函数原型。
## extern 说明符
当前文件还可以使用其他文件定义的变量,这时要使用`extern`说明符,在当前文件中声明,这个变量是其他文件定义的。
```c
extern int myVar;
```
上面示例中,`extern`说明符告诉编译器,变量`myvar`是其他脚本文件声明的,不需要在这里为它分配内存空间。
由于不需要分配内存空间,所以`extern`声明数组时,不需要给出数组长度。
```c
extern int a[];
```
这种共享变量的声明,可以直接写在源码文件里面,也可以放在头文件中,通过`#include`指令加载。
## static 说明符
正常情况下,当前文件内部的全局变量,可以被其他文件使用。有时候,不希望发生这种情况,而是希望某个变量只局限在当前文件内部使用,不要被其他文件引用。
这时可以在声明变量的时候,使用`static`关键字,使得该变量变成当前文件的私有变量。
```c
static int foo = 3;
```
上面示例中,变量`foo`只能在当前文件里面使用,其他文件不能引用。
## 编译策略
多个源码文件的项目,编译时需要所有文件一起编译。哪怕只是修改了一行,也需要从头编译,非常耗费时间。
为了节省时间,通常的做法是将编译拆分成两个步骤。第一步,使用 GCC 的`-c`参数将每个源码文件单独编译为对象文件object file。第二步将所有对象文件链接在一起合并生成一个二进制可执行文件。
```bash
$ gcc -c foo.c # 生成 foo.o
$ gcc -c bar.c # 生成 bar.o
# 更省事的写法
$ gcc -c *.c
```
上面命令为源码文件`foo.c`和`bar.c`,分别生成对象文件`foo.o`和`bar.o`。
对象文件不是可执行文件,只是编译过程中的一个阶段性产物,文件名与源码文件相同,但是后缀名变成了`.o`。
得到所有的对象文件以后,再次使用`gcc`命令,将它们通过链接,合并生成一个可执行文件。
```bash
$ gcc -o foo foo.o bar.o
# 更省事的写法
$ gcc -o foo *.o
```
以后,修改了哪一个源文件,就将这个文件重新编译成对象文件,其他文件不用重新编译,可以继续使用原来的对象文件,最后再将所有对象文件重新链接一次就可以了。由于链接的耗时大大短于编译,这样做就节省了大量时间。
## make 命令
大型项目的编译,如果全部手动完成,是非常麻烦的,容易出错。一般会使用专门的自动化编译工具,比如 make。
make 是一个命令行工具,使用时会自动在当前目录下搜索配置文件 makefile也可以写成 Makefile。该文件定义了所有的编译规则每个编译规则对应一个编译产物。为了得到这个编译产物它需要知道两件事。
- 依赖项(生成该编译产物,需要用到哪些文件)
- 生成命令(生成该编译产物的命令)
比如,对象文件`foo.o`是一个编译产物,它的依赖项是`foo.c`,生成命令是`gcc -c foo.c`。对应的编译规则如下:
```c
foo.o: foo.c
gcc -c foo.c
```
上面示例中,编译规则由两行组成。第一行首先是编译产物,冒号后面是它的依赖项,第二行则是生成命令。
注意,第二行的缩进必须使用 Tab 键,如果使用空格键会报错。
完整的配置文件 makefile 由多个编译规则组成,可能是下面的样子。
```c
foo: foo.o bar.o
gcc -o foo foo.o bar.o
foo.o: bar.h foo.c
gcc -c foo.c
bar.o: bar.h bar.c
gcc -c bar.c
```
上面是 makefile 的一个示例文件。它包含三个编译规则,对应三个编译产物(`foo.o`、`bar.o`和`foo`),每个编译规则之间使用空行分隔。
有了 makefile编译时只要在 make 命令后面指定编译目标(编译产物的名字),就会自动调用对应的编译规则。
```bash
$ make foo.o
# or
$ make bar.o
# or
$ make foo
```
上面示例中make 命令会根据不同的命令,生成不同的编译产物。
如果省略了编译目标,`make`命令会执行第一条编译规则,构建相应的产物。
```bash
$ make
```
上面示例中,`make`后面没有编译目标,所以会执行 makefile 的第一条编译规则,本例是`make foo`。由于用户期望执行`make`后得到最终的可执行文件,所以建议总是把最终可执行文件的编译规则,放在 makefile 文件的第一条。makefile 本身对编译规则没有顺序要求。
make 命令的强大之处在于,它不是每次执行命令,都会进行编译,而是会检查是否有必要重新编译。具体方法是,通过检查每个源码文件的时间戳,确定在上次编译之后,哪些文件发生过变动。然后,重新编译那些受到影响的编译产物(即编译产物直接或间接依赖于那些发生变动的源码文件),不受影响的编译产物,就不会重新编译。
举例来说,上次编译之后,修改了`foo.c`,没有修改`bar.c`和`bar.h`。于是,重新运行`make foo`命令时Make 就会发现`bar.c`和`bar.h`没有变动过,因此不用重新编译`bar.o`,只需要重新编译`foo.o`。有了新的`foo.o`以后,再跟`bar.o`一起,重新编译成新的可执行文件`foo`。
Make 这样设计的最大好处,就是自动处理编译过程,只重新编译变动过的文件,因此大大节省了时间。