通过构建简单项目来理解 C/C++ 构建系统
为了使教程能够按预期工作,包括“memory_api.h”应该是#include“free_memory_api.h”,我迫不及待地想看第二部分;)
C/C++ 是当今许多流行编程语言的鼻祖,我们今天使用的所有高级编程语言,如 Python、JavaScript,都是使用 C/C++ 构建的。例如,标准的 Python 解释器CPython
是使用 C 构建的,而最流行的 JavaScript 实现V8
是使用 C/C++ 构建的,C/C++ 还为 Node.js 使用的大多数底层库提供支持,换句话说,C/C++ 为人类编写的大多数开源软件提供支持。我们更喜欢 Python 等高级语言的主要原因之一是它们提供了强大的包管理工具,我们不必再担心管理依赖项,它们会pip
自动为我们管理。JavaScript 也是如此。这些语言还具有强大的构建系统,使我们能够更轻松地构建和发布软件。
C/C++ 也有一些流行的构建系统,例如cmake
和,bazel
它们可以自动管理依赖项,但在本文中,我们将在不使用这些工具的情况下编译一个 C/C++ 项目,以便了解其内部工作原理。
我们将构建一个简单的系统记录器,每 5 秒记录一次系统的总可用 RAM 内存。
首先!让我们创建一个项目结构:
项目结构必须易于理解,并应尽可能隔离不同的功能,以避免混淆。没有人会阻止我们使用自己的项目结构,但大多数使用 C/C++ 构建的开源项目都使用这种结构:
project_root
- include
- src
- module-1
- module-2
- module-n
- main.c/main.cc (depends on the project)
- Makefile
- README
- LICENSE
- misc files
让我们看看每个文件/目录的含义:
include
- 这是我们所有头文件所在的地方。src
- 包含所有源代码的目录。src 目录下可以包含多个子目录/模块。此外,还可以包含一个主函数文件src
。Makefile
:Makefile 由命令使用make
,我们将使用它make
来构建我们的项目。
对于我们的项目,我们将具有以下结构:
memlogger
- bin - will explain the need for this
- include
- free_memory_api.h
- file_writer.h
- src
- free_memory_api
- free_memory_api.c
- file_writer
- file_writer.c
- main.c
- Makefile
让我们编码
完成项目结构设置后,我们就可以开始编写代码了。为了避免篇幅过长,我不会深入解释代码,而是会更多地关注概念。
什么是头文件?
头文件是我们实际 C 代码的蓝图。对于我们编写的每个 C 模块,导出头文件是一个好习惯。编译器使用这些头文件来了解模块导出的所有函数。编译完成后,头文件就不会再被使用。当我们的项目/模块被其他项目用作模块时,头文件的实际用途就显现出来了,其他程序员只需包含我们的头文件即可使用我们导出的函数声明。
让我们free_memory_api.h
按照结构创建:
#ifndef __FREE_MEMORY_API
#define __FREE_MEMORY_API
//this is our API function which returns free memory in bytes
unsigned long long get_free_system_memory();
#endif
让我们创建file_writer.h
声明文件写入 API
#ifndef __FILE_WRITER_API
#define __FILE_WRITER_API
#include <stdio.h>
//opens the log file for writing
FILE * open_log_file(char * path);
// we will use this function to write contents to the log file
void write_log_to_file(FILE * file, unsigned long long free_memory);
//closes the log file
void close_log_file(FILE * file);
#endif
让我们定义这些 API:
我们声明了所有需要的 API,但尚未编写这些 API 的底层代码。我们将编写文件日志记录和从系统获取可用内存的代码。在编写代码之前,我们必须导入之前声明的蓝图。file_writer.c
#include "file_writer.h"
// Open the log-file in append mode and return it
FILE * open_log_file(char * file_path) {
FILE * fp = fopen(file_path, "a");
return fp;
}
// Close the file
void close_log_file(FILE * fp) {
if(fp) {
fclose(fp);
}
}
//write log entry into the file
void write_log_to_file(FILE * fp, unsigned long long free_memory) {
if(fp) {
fprintf(fp, "free_memory=%llu\n", free_memory);
}
}
现在让我们定义免费内存 API,即free_memory_api.c
:
#include <sys/sysinfo.h>
#include "free_memory_api.h"
unsigned long long get_free_system_memory() {
struct sysinfo info;
if (sysinfo(&info) < 0) {
return 0;
}
return info.freeram;;
}
最后,main.c
#include "file_writer.h"
#include "free_memory_api.h"
#include <unistd.h>
int main(int argc, char **argv) {
if (argc < 2) {
printf("Provide log file name\n");
return 0;
}
unsigned long long free_memory = 0;
while(1) {
free_memory = get_free_system_memory();
FILE * log = open_log_file(argv[1]);
write_log_to_file(log, free_memory);
close_log_file(log);
sleep(5);
}
}
让我们开始构建项目
代码编写完毕后,我们就可以编译项目了。现在我们的项目中有多个模块。这些模块可以链接在一起构建一个独立的可执行文件,也可以将各个模块单独构建为共享库,然后在运行时将它们链接在一起。
构建静态单片可执行文件
在本节中,我们将构建一个可以交付的二进制文件。构建 C/C++ 项目的方法有很多种。在本文中,我们将仅构建一个独立的可执行文件,这是构建 C 项目最简单的方法。
我们将使用make
一个可以自动执行任何任务的 Linux 命令行实用程序,它是一系列 Shell 命令,可以分组并标记为特定任务。我们可以使用 方便地编写多个这样的任务Makefile
。现在让我们看看我们的 Makefile。
COMPILER=gcc
file_writer:
@$(COMPILER) -c src/file_writer/*.c -Iinclude/ -o bin/file_writer.o
@echo "Built file_writer.o"
free_memory_api:
@$(COMPILER) -c src/free_memory_api/*.c -Iinclude/ -o bin/free_memory_api.o
@echo "Built free_memory_api.o"
project:
$(COMPILER) -c src/main.c -Iinclude/ -o bin/main.o
@$(COMPILER) bin/free_memory_api.o bin/file_writer.o bin/main.o -o memlogger
@echo "Finished building memlogger"
尽管我们可以用一个命令构建整个项目,但我将其分为三个阶段。
file_writer
:此make
规则将file_writer.o
在下生成目标文件./bin
。free_memory_api
:此规则free_memory_api.o
在下生成./bin
。project
:这将构建整个项目,它生成main.o
并链接main.o
其他两个目标文件以创建一个名为的独立可执行文件memlogger
。
让我们用 make 执行这些命令:
步骤 1。file_writer.o
make file_writer
步骤2。free_memory_api.o
make free_memory_api
步骤3. 最终二进制文件:
make project
执行二进制文件:
我们可以像运行普通 Linux 可执行文件一样运行该二进制文件:
memlogger logs.txt
15 秒后,我们在日志文件中看到 3 个条目:
free_memory=8322523136
free_memory=8330776576
free_memory=8335728640
这大约相当于8GB
释放了16GB
RAM,而且是正确的。好极了!我们创建了一个小型系统记录器。
引擎盖下发生了什么?
理解我们这里使用的构建过程非常重要。为了理解这一点,我们需要了解目标文件的概念。
我们在 Makefile 中做了什么?
我们定义了三条规则,每条规则构建一个模块,第三条规则进一步链接所有三个模块。
我们使用了gcc
编译器(g++
适用于 C++ 项目)。使用的选项:
-c
:这告诉编译器只编译而不执行链接,因为我们在步骤 3 中明确链接目标文件。-I
:由于我们定义了自己的标头,因此我们必须在编译时将其提供给编译器,默认情况下,编译器会在标准位置搜索这些标头,我们还可以告诉编译器包含我们的自定义位置以使用来解析标头-I
。-o
:输出文件名。
什么是目标文件?
目标文件是ELF - Executable and linkable format
由编译器生成的 Linux 二进制文件。ELF 格式遵循 POSIX 标准,所有 Linux 发行版都能理解什么是目标文件。通俗地说,目标文件包含一个映射表和符号定义。
mapping-table or Symbol table
:映射表包含由目标文件定义的一组符号和定义符号实际代码的文本段中的偏移量。Symbol definitions
:此段包含所有函数的机器码。因此,为了获取函数/符号的机器码,我们需要执行以下两个步骤:首先,查找目标文件的符号表,获取其在文本段中的偏移量。然后,进入文本段并获取其代码。
这些只是外行术语,ELF 有标准定义,mapping-table
对于Segment definitions
初学者来说可能比较困惑。
让我们看一下目标文件的内容file_writer.o
,我们使用一个名为的工具,readelf
它是所有 Linux 系统中的默认工具。
readelf -h bin/file_writer.o
输出:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 1168 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12
这些是 ELF 头文件。现在我们来看一下符号表(或者用我们的话来说就是映射表)。
readelf --syms bin/file_writer.o
输出:
Symbol table '.symtab' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS file_writer.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 41 FUNC GLOBAL DEFAULT 1 open_log_file
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND fopen
12: 0000000000000029 34 FUNC GLOBAL DEFAULT 1 close_log_file
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND fclose
14: 000000000000004b 54 FUNC GLOBAL DEFAULT 1 write_log_to_file
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND fprintf
如表所示,表中有open_log_file
、的条目close_log_file
,write_log_to_file
它们是我们定义的 API 函数。太好了!我们的目标文件正确无误。此外,如果仔细观察,我们会看到fclose
、fopen
和的存在,fprintf
并且它们带有前缀,UND
这意味着这些符号地址尚不清楚,但 C/C++ 运行时会在链接过程中(即步骤 3)解析它们,它会在运行时静态或动态地链接到这些函数。我们将在下一部分中了解共享库的概念。
类似地,我们可以运行相同的命令来free_memory_api.o
查看它的符号表。我们得到如下输出:
Symbol table '.symtab' contains 14 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS free_memory_api.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 89 FUNC GLOBAL DEFAULT 1 get_free_system_memory
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sysinfo
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __stack_chk_fail
我们可以将get_free_system_memory
aFUNC
看作sysinfo
是未定义的。
我们最后一步做了什么?
在前两步中,我们编译了模块并生成了目标文件,但它们无法执行,因为它们没有main
函数定义,而函数定义是任何 C/C++ 程序的入口点。我们在规则(步骤 3)中有两个命令Makefile
,project
第一个命令只将 main.c 文件编译成main.o
,让我们尝试运行它,它应该可以运行,因为它有main
函数。
./main.o
输出:
bash: ./bin/main.o: cannot execute binary file: Exec format error
我们无法运行它,因为它不是可执行文件,而是一个目标文件。最后一步是链接所有三个目标文件并生成最终的可执行二进制文件。
在此之前,我们将尝试仅链接main.o
并丢弃剩下的两个模块,让我们看看会发生什么:
gcc bin/main.o -o memlogger
输出:
bin/main.o: In function `main':
main.c:(.text+0x36): undefined reference to `get_free_system_memory'
main.c:(.text+0x4d): undefined reference to `open_log_file'
main.c:(.text+0x64): undefined reference to `write_log_to_file'
main.c:(.text+0x70): undefined reference to `close_log_file'
collect2: error: ld returned 1 exit status
这正是我们预期会发生的情况,可执行文件需要以下函数,但不知道它们在哪里。所以我们需要将它与剩下的两个目标文件链接起来。
gcc bin/free_memory_api.o bin/file_writer.o bin/main.o -o memlogger
现在,编译器会检查file_writer.o
和的符号表free_memory_api.o
,以解析上一个命令中未定义的函数。由于这两个目标文件的符号表定义了这些符号/函数,因此链接成功,并生成了最终的可执行文件。
让我们看看二进制文件的映射表或符号表memlogger
:
readelf --syms memlogger
输出:
26: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
27: 0000000000000780 0 FUNC LOCAL DEFAULT 14 deregister_tm_clones
28: 00000000000007c0 0 FUNC LOCAL DEFAULT 14 register_tm_clones
29: 0000000000000810 0 FUNC LOCAL DEFAULT 14 __do_global_dtors_aux
30: 0000000000201010 1 OBJECT LOCAL DEFAULT 24 completed.7698
31: 0000000000200d88 0 OBJECT LOCAL DEFAULT 20 __do_global_dtors_aux_fin
32: 0000000000000850 0 FUNC LOCAL DEFAULT 14 frame_dummy
33: 0000000000200d80 0 OBJECT LOCAL DEFAULT 19 __frame_dummy_init_array_
34: 0000000000000000 0 FILE LOCAL DEFAULT ABS free_memory_api.c
35: 0000000000000000 0 FILE LOCAL DEFAULT ABS file_writer.c
36: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
37: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
38: 0000000000000c5c 0 OBJECT LOCAL DEFAULT 18 __FRAME_END__
39: 0000000000000000 0 FILE LOCAL DEFAULT ABS
40: 0000000000200d88 0 NOTYPE LOCAL DEFAULT 19 __init_array_end
41: 0000000000200d90 0 OBJECT LOCAL DEFAULT 21 _DYNAMIC
42: 0000000000200d80 0 NOTYPE LOCAL DEFAULT 19 __init_array_start
43: 0000000000000a78 0 NOTYPE LOCAL DEFAULT 17 __GNU_EH_FRAME_HDR
44: 0000000000200f80 0 OBJECT LOCAL DEFAULT 22 _GLOBAL_OFFSET_TABLE_
45: 0000000000000a30 2 FUNC GLOBAL DEFAULT 14 __libc_csu_fini
46: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
47: 0000000000201000 0 NOTYPE WEAK DEFAULT 23 data_start
48: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@@GLIBC_2.2.5
49: 0000000000201010 0 NOTYPE GLOBAL DEFAULT 23 _edata
50: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fclose@@GLIBC_2.2.5
51: 0000000000000a34 0 FUNC GLOBAL DEFAULT 15 _fini
52: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@@GLIBC_2
53: 00000000000008dc 34 FUNC GLOBAL DEFAULT 14 close_log_file
54: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@@GLIBC_2.2.5
55: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@@GLIBC_
56: 0000000000201000 0 NOTYPE GLOBAL DEFAULT 23 __data_start
57: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fprintf@@GLIBC_2.2.5
58: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
59: 0000000000201008 0 OBJECT GLOBAL HIDDEN 23 __dso_handle
60: 0000000000000a40 4 OBJECT GLOBAL DEFAULT 16 _IO_stdin_used
61: 000000000000085a 89 FUNC GLOBAL DEFAULT 14 get_free_system_memory
62: 00000000000009c0 101 FUNC GLOBAL DEFAULT 14 __libc_csu_init
63: 0000000000201018 0 NOTYPE GLOBAL DEFAULT 24 _end
64: 0000000000000750 43 FUNC GLOBAL DEFAULT 14 _start
65: 0000000000201010 0 NOTYPE GLOBAL DEFAULT 24 __bss_start
66: 0000000000000934 130 FUNC GLOBAL DEFAULT 14 main
67: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fopen@@GLIBC_2.2.5
68: 00000000000008fe 54 FUNC GLOBAL DEFAULT 14 write_log_to_file
69: 0000000000000000 0 FUNC GLOBAL DEFAULT UND sysinfo@@GLIBC_2.2.5
70: 0000000000201010 0 OBJECT GLOBAL HIDDEN 23 __TMC_END__
71: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
72: 0000000000000000 0 FUNC GLOBAL DEFAULT UND sleep@@GLIBC_2.2.5
73: 00000000000008b3 41 FUNC GLOBAL DEFAULT 14 open_log_file
74: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@@GLIBC_2.2
75: 0000000000000698 0 FUNC GLOBAL DEFAULT 11 _init
由于符号表很大,我从第 26 条开始粘贴条目。正如你所见,我们最终的可执行文件包含了所有三个模块的定义,而且没有UND
符号,这些符号现在被替换成了类似 的内容fopen@@GLIBC_2.2.5
。这意味着,这些函数的代码不会被复制到我们的二进制文件中,而是必须在运行时解析,Linux 加载器负责ld.so
在运行时动态链接这些符号。
就这样!这篇文章的第一部分已经完成了。如果你正在读这行字,我由衷地感谢你读完整篇文章,即使你跳过了所有内容直接跳到这里,也请保留我的赞美。
谢谢:)祝您玩得愉快。
文章来源:https://dev.to/narasimha1997/understanding-cc-build-system-by-building-a-simple-project-part-1-4fff