在刚刚上面的例子中,我们发现了一个简单的 HelloWorld 程序,也是需要和操作系统发生关系的。这种关系在编译过程里,主要就是通过“链接”操作系统的“库”来体现。那么下一个需要了解的问题就是:编译器软件是通过什么规则来把“库”和你的程序“链接”起来的呢?
可能很多人对于代码里的那行 #include <stdio.h> 印象深刻,觉得“链接”这个过程和这行代码一定有极大的关系。而事实上,确实有一点点关系,但关系不大。我们可以来做一个试验,把我们的 HelloWorld 文件 hello.c 改成下面这样:
int printf(const char *format, ...);
int main()
{
printf("Hello, World!\n");
}
可以看到这样的代码,已经没有那行“神奇”的 include 代码了。我们可以尝试编译一下:
clang hello.c
你会发现,这样照样可以生成 a.exe,并且运行结果还是一样的正常!这就说明了,stdio.h 里面并没有什么神奇的东西,对于我们这个程序有用的,也就是一行普通的“函数声明”而已!
int printf(const char *format, ...);
所谓 函数声明 :一行用来说明某个函数的调用方式的代码,编译器会根据这行代码,来判断后面的代码,对于此函数的使用格式是否正确。
编译器并不会纠结当前要编译的这个文件里,是否包含每个用到的函数的完整代码(所谓函数的“定义”),只要是能和“函数声明”对的上,就可以编译成功。关于要调用的函数到底在哪里,如何处理,那会留给“链接”步骤去处理。
在“链接”阶段,编译器会从 hello.o (中间产物,目标文件)中搜索得到 printf() 这个函数没有被“满足”,便会自己能找到的“库”里面搜索,如果找到了能满足 printf() 这个声明的“函数定义”,便会把这个库的 printf() 函数“链接”到最终产物 a.exe 文件里面去。因此最后 a.exe 就是一个可以运行的正常程序了。
编译器在默认的情况下,会自己去搜索一系列的“标准库”和“系统库”。而 printf() 正是属于这些“标准库”的其中一个函数。而保存了这个函数的代码,就是在 kernel32.dll 这个操作系统附带的文件中。需要注意的是,同样这个 printf 函数的代码,在 Linux 和 MacOS 上,是在不同名字的库文件里的。
不管是 Windows 还是 Linux,都以 C 语言库的形式,提供了很多功能,以便开发者可以通过编程来使用操作系统的功能。从这个角度来说,操作系统的用户还包含了程序开发者。
从上面的例子中,我们可以感觉到一个事实:C 语言中的“函数”,是基本的程序模块单元。
设想一下,如果我们需要用 C 语言来开发一个大型的程序。这个工作需要很多人参与,并且可能持续一个比较长的开发时间。我们的程序代码应该如何进行划分呢?显然让所有人都把代码加到一个 .c 文件里面,是不可行的。最自然的想法,是能让程序分成很多不同的“模块”,各自单独开发和测试,然后再通过某种机制“组装”在一起。对于 C 语言来说,就是可以把不同的模块,定义为不同的“函数”,写入到不同的 .c 文件中,最后我们可以把这些 .c 文件编译成不同的“库”文件,用链接的方式,生成最终的可执行程序。
很多现代编程语言,都已经把“模块管理”放到语言本身里面的设计了。如 Java 和 Go 都有 import 关键字,C# 有 using 关键字。唯独 C 语言是缺乏这方面的设计的,所以我们需要额外了解这一套特别的机制。
首先,我们新建一个源文件 add.c,里面定义一个我们自己写的函数 add(),这个函数功能就是做一个简单的加法。
int add(int a, int b)
{
return a + b;
}
C 语言的函数和数学的函数有点像,都有输入参数和返回值:
一个函数的“返回值、名字、参数列表”这一行,就是这个函数的“声明”。如果其他的 .c 文件想调用这个函数,就需要把函数的声明部分写到调用方的源文件里。因此我们在 hello.c 里面写上 add() 的声明,并且进行调用:
int printf(const char *format, ...); // 声明
int add(int a, int b); // 声明
int main()
{
int c = add(1, 2);
printf("Hello, World! %d\n", c);
}
在 main() 中,我们以 int c = add(1,2); 这句调用了 add() 函数,然后把返回值放在变量 c 里面。并且在下面的 printf() 函数中,以 %d 这个定位符,把变量 c 的内容以十进制的格式显示出来。
现在,我们可以尝试编译 hello.c,不出意外,编译器找不到 add() 函数 在哪里,所以没法链接成一个可执行程序。 (编译器也把函数和变量这些名字统称为“符号”)
clang hello.c -o hello.exe
hello-834397.o : error LNK2019: 无法解析的外部符号 add,函数 main 中引用了该符号
hello.exe : fatal error LNK1120: 1 个无法解析的外部命令
clang: error: linker command failed with exit code 1120 (use -v to see invocation)
为了让编译器能把 add() 函数链接成功,我们需要把 a.c 文件编译成一个“库”。多个包含了机器指令”的目标文件,可以放入一个“库”文件中,以便一起提供给调用者。我们这次需要先准备好装入“库”文件的唯一一个目标文件,把 add.c 编译成 add.o:
clang add.c -c -o add.o
然后我们用命令 llvm-lib 把 add.o 文件放入库文件 add.lib 中:
llvm-lib add.o /out:add.lib
在 Linux 下使用 ar 命令来生成(静态)库,库文件一般是 libadd.a
clang hello.c -o hello.exe -ladd
注意这里我们的链接命令中,增加了一个 -ladd 的参数,意思就是:链接一个叫 add 的库。这个参数会让编译器,在当前目录寻找 add.lib 这个库文件,然后尝试链接所有未能找到定义的函数。(当然默认的标准库也会一起尝试进行链接)
当我们运行 hello.exe 之后,我们可以看到屏幕上显示了 Hello, World! 3 这行字,表示确实调用了 add() 这个函数。
我们这里生成的 add.lib 文件,被称为“静态库”。对于静态库来说的链接,称为“静态链接”,这种链接方式,并不会需要生成出来的 hello.exe 还依赖 add.lib 文件;而是会把 add() 函数的程序代码,直接复制到 hello.exe 的内部。——这和我们对于 printf() 的链接是不一样的,printf() 的代码并没有复制到 hello.exe 中,而是继续呆在 kernel32.dll 里面,在运行 hello.exe 再去调用 kernel32.dll 的内容。prinf() 这种链接方式,我们称为“动态链接”,而动态链接库文件,一般都是用 .dll 的名字来命名。
我们下面可以尝试把 add() 这个函数,放入一个动态链接库中,让 hello.c 来进行链接,看看有什么不一样。
在 windows 上,为了让我们的函数可以被动态链接,我们需要遵守 windows 的规则,修改一下 add.c 的代码,主要就是加上个标记 __declspec(dllexport) :
__declspec(dllexport) int add(int a, int b)
{
return a + b;
}
在 Linux 下,是不需要加这个标记的。
改了 add.c 后,我们的 hello.c 对应的 add() 函数声明也得加上这个标记:
int printf(const char *format, ...);
__declspec(dllexport) int add(int a, int b);
int main()
{
int c = add(1, 2);
printf("Hello, World! %d\n", c);
}
clang add.c -c -o add.o
然后就可以编译动态库了,注意这次用的是 clang 而不是 llvm-lib 来生成:
clang add.o -shared -o add.dll
正在创建库 add.lib 和对象 add.exp
clang hello.c -o hello.exe -ladd
在 Windows 下,链接 add.dll 的时候,必须要有 add.lib;在 Linux 下则只需要动态库文件本身这一个文件(一般文件名会是 libadd.so)就可以了,不像 windows 需要有两个文件。
运行了 hello.exe 后,结果和原来链接的静态库效果是一样的。但是我们可以用工具来看到确实是动态链接了。
&"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.38.33130\bin\Hostx64\x64\dumpbin.exe" /dependents .\hello.exe
动态链接和静态链接不同之处,就在于运行程序时,dll 文件必须存在。
> .\hello.exe
> Hello, World! 3
如果我们删除了 add.dll,然后运行 hello.exe,你就会发现之前显示的 "Hello, World! 3" 消失了!
> mv add.dll add.d
> .\hello.exe
>
动态链接库更加有用的地方,在于如果我们修改了 dll 文件的内容,只要替换文件,exe 文件不需要修改,就能使用修改过之后的功能。譬如我们可以把 add() 函数的功能稍微修改一下,让计算的结果加上 100:
__declspec(dllexport) int add(int a, int b)
{
return a + b + 100;
}
clang add.c -c -o add.o
clang add.o -shared -o add.dll
我们无需重新编译 hello.exe,直接运行,就能发现显示的内容已经不同了:从显示“3”到显示“103”。
> .\hello.exe
> Hello, World! 103
动态链接库的 dll 文件,作为一种可以拷贝就能用的程序模块,可以被多个不同的 exe 程序所共用,因此操作系统的很多功能,都是通过 dll 来提供的。另外,我们的程序如果需要更新某些功能,也可以让用户下载并覆盖那些有修改的 dll 文件,就能拥有新的功能。
大家在电脑使用中,是不是也有碰到过 dll 文件找不到的错误呢?其实这就是有些软件开发者,认为使用者的电脑中“应该”有,而实际上使用者并没有这些库文件而导致的。以前我最常见的是 DirectX 组件找不到的问题,譬如 DDraw.dll 找不到 。DirectX 是游戏开发者常用的库,因此想要运行使用了这些库的游戏,就必须要要有这批 dll 文件才行。而且很多不同的游戏可能会用到同一套 DirectX 库文件,所以没必要每个游戏都复制一份这些 dll,让玩家去下载安装一次就好了。
在上面的例子中,我们需要在 hello.c 里面,写上 add.c 里面定义的 add() 函数的声明,才能编译通过。写 hello.c 的人,在没有 add.c 的源代码的情况下,其实是可以链接 add.dll/add.lib 的。但是实际上,add() 函数和 hello.c 往往是不同的人开发的。hello.c 的开发者,在没有 add.c 源码,要怎么知道 add() 函数该如何写声明呢?
所以一般来说,编写 add() 函数和库的作者,除了编译好 add.dll/add.lib 以外,还会编写一份名字为 add.h 的“头文件”,附带在库文件 add.dll/add.lib 上,一起提供给使用者。而使用者只需要在 hello.c 里面,写上 #include "add.h" 就可以代替具体的 add() 函数声明了。
#ifndef ADD_H_
#define ADD_H_
__declspec(dllexport) int add(int a, int b);
#endif /* ADD_H_ */
这样的头文件,相当于函数库的“使用手册”,帮使用者对库里面的函数进行调用。我们在最早的 HelloWorld 程序中,看到的 #include <stdio.h> 中的 stdio.h,其实也是“标准库”提供给我们的头文件,方便我们能使用操作系统的标准库的。
#include <stdio.h>
#include "add.h"
int main()
{
int c = add(1, 2);
printf("Hello, World! %d\n", c);
}
在 add.h 的内容中,除了函数声明,还有 3 行其他的代码: #ifndef #define #endif 。它们并不属于严格意义上的 C 语言代码,而是“预处理”的命令,作用是防止 .h 文件被多次 #include 后发生“重复声明”的编译错误。这种特点体现了 C 语言,在模块管理的设计上确实考虑的不多,需要开发者配合编译器,通过“额外补丁”的方法来解决问题。在后续的大多数语言中,都不需要这些东西了。现在的 C++20 方案中,就加入了模块管理的功能,也是可以告别预处理指令了。
C 语言的函数,不仅仅是编程语言角度上一个可以复用的代码块,而且是最基本的链接单元。操作系统和编译器,使用函数的声明,作为不同的库文件进行链接的接口格式。正是因为大量的操作系统功能,都以 C 函数库的规则,来供开发者链接,所以直接写 C 语言代码来使用操作系统,反而是最方便的做法。网上很多开发者宣称 C 语言适合做“底层开发”,这种和操作系统的适应性,就是其中的理由之一。
评论区
共 18 条评论热门最新