动态链接、系统调用与 C 的 inline

本文最后更新于 2024-05-27

为什么不该在你的 C 程序里写一个看似人畜无害的 inline int read() 快读?

实际上并不只是 read(),是因为 read() 这个名字太常见了。以 read() 为例,我们来探讨一下 C 的 inline 与链接,以及在什么时候你会遇到由链接导致的足够 debug 三天三夜的问题。

TL;DR

在 Linux / macOS 下,你的 inline read() 的定义被抛弃掉了,而实际调用的 read() 来自 libc.so。是的,是动态链接。

你说的对,但我用 Windows。

出门左转,爬。

接下来,没有特殊说明的话,我们假定所有的操作都在 Linux / macOS 上完成。


1
2
3
4
5
6
7
8
9
10
11
12
inline int read() {
int x = 0, f = 1;
char ch = getchar();
while (ch < '0' || ch > '9') {
if (ch == '-')
f = -1;
ch = getchar();
}
while (ch >= '0' && ch <= '9')
x = x * 10 + ch - '0', ch = getchar();
return x * f;
}

哦对了,虽然这是快读,但它可以不是快读。其实我们唯一关心的事情是 read() 这个函数名。方便起见,我们接下来看这个:

1
inline int read() { return 42; }

虽然但是,你在说什么?

朋友们好啊。我是 C 语言。刚才有个群友问我:为什么我写的快读永远返回 -1?
我说怎么会是?给我发来两张截图。我一看,噢,原来是昨天,有两个年轻函数,一个叫 main,一个叫 read
它们说,唉…有一个说是,在 Windows 写的代码,在 Unix 上写坏了。C 语言,你能不能教教我 Unix 的程序,诶…帮我治疗一下,我的链接性。
我说可以,我说你在 Windows 写代码,不好用。它不服气。我说小朋友,你两个函数来折我一个 syscall,它折不动。它说你这个没用。
我说我这个有用。这是化劲,传统链接是讲化劲的,四两拨千斤。几万行代码的 C++ STL 都挝不动我这一个 libc。啊…哈!它说要和我试试。我说可以。
诶…我一说,它啪就开始写了,很快啊!然后上来就是一个 read(),一个 inline,一个 static inline。我全部防出去了啊。
防出去以后自然是传统链接以点到为止,函数声明放在了 unistd.h 里,没放在 stdio.h 里。
我笑一下,准备收手。因为这时间,按传统链接的点到为止它已经输了。如果放在 stdio.h 里,一放就给它编译错误了,放在 unistd.h 里没有动它。
它也承认我 unistd.h 先出手,它不知道我函数实现放在 libc 里。它承认我 unistd.h 先出手,啊!我关终端的时间不打了,它突然袭击 -O2 优化来打我。啊,我大意了啊,没有闪。
诶…它的 -O2 给我符号,符号表蹭了一下。但没关系啊,它也说啊,重新编译以后,当时优化掉了,捂着符号表,我说停停。然后重新编译以后,重新编译以后诶好了。
我说我说小伙子你不讲武德你不懂,他忙说 C 语言对不对不起,我不懂规矩,啊…他说他是乱打的。
他可不是乱打的啊,inline read() -O2 训练有素。后来它说它打过三四年 OI,啊,看来是有备而来。
这两个年轻函数,不讲武德,来,骗!来,偷袭!我五十岁的老语言。这好吗?这不好。
我劝这个年轻函数,耗子尾汁,好好反思。以后不要再犯这样的聪明,小聪明,啊,嗯…编程语言要以和为贵,要讲武德,不要搞窝里斗。
谢谢朋友们!

总之,大概就像这样:

a.c
1
2
3
4
5
6
7
#include <stdio.h>

inline int read() { return 42; }

int main() {
printf("imma read an integer: %d\n", read());
}

那么,聪明的,你告诉我,这个程序输出什么呢?你说,这还不简单?这不就是 42 吗。

不是哦:

1
2
3
4
$ clang a.c -o a

$ ./a
imma read an integer: -1

怎么会是?暂且先按下不表,来看这个好康的。

编译器与符号表

回忆一下程序从源代码到可执行文件的四个步骤:

  • 预处理(preprocess)
  • 编译(compile)
  • 汇编(assemble)
  • 链接(link)

现在,打开你最喜欢的编辑器,写这么一份程序:

a.c
1
2
3
4
5
6
7
8
#include <stdio.h>

int ret0() { return 0; }

int main() {
puts("hello motherf*cker");
return ret0();
}

这个程序的结果小学二年级学生都能指出来。(不要把这个程序给小学生看,至少把字符串内容换一下。)

打开你最喜欢的终端,但是先别急,我并不是要让你编译这个程序。

1
$ clang a.c -E -o a.i

(用 gcc 也无所谓,随便你。但是建议不要使用 gcc,用 clang 会让你的生命开阔许多。)

现在打开 a.i,你会看见一大堆内容:

a.i
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1 "a.c"
# 1 "<built-in>" 1
# 1 "<built-in>" 3

/* ... 省略 755 行,YMMV */

# 2 "a.c" 2

int ret0() { return 0; }

int main() {
puts("hello motherf*cker");
return ret0();
}

聪明如你一定已经猜到这多出来的这些就是 stdio.h 预处理后的内容。实际上我们刚才的 -E 选项就是指定编译器只进行预处理。你会发现 a.i 中除了这种奇怪的以 # 开头的语句没有其他的预处理语句了,我们在这里也不继续讨论它们。总之重点是你已经看到,预处理后你会得到这样的东西。稍微搜索一下,你会发现这样一条:

a.i
640
extern int puts (const char *__s);

没错,这就是 stdio.hputs() 的声明。现在可以编译这个东西了,但是你先别急。

3
$ clang a.i -c -o a.o

出现了!目标文件 a.o!这一行走了两步,编译和汇编。实际上汇编这个阶段我们不关心,在这里就直接认为这俩是捆绑在一起的好了。

来康康 a.o 的内容吧。但我们要看的不是汇编(经典双关),而是去看 a.o 中所拥有的符号

5
6
7
8
$ nm a.o
0000000000000008 T main
U puts
0000000000000000 T ret0

去查 nm 的 manpage 可以得到更多的信息。总之在这里我们看到:

  • main 符号位于 0x8,类型 T 代表它处于代码段,且拥有外部链接。
  • puts 符号,不知道地址,因为类型 U 代表它是未定义的,它不存在于 a.o 里。
  • ret0 符号位于 0x0,其他和 main 一样。

如果你在 macOS 上,也许会发现这三个符号变成了 _main _puts_read。有没有下划线不重要,只要自始至终你都在同一个平台上就行。

这也合理,因为 puts() 不是你写的而是系统提供的,你只需要拿来用就可以了。至于什么是代码段,什么是外部链接,我们在这里也不会涉及。

那么最后,链接,快点端上来罢!

10
$ clang a.o -o a

编译的结果 a 已经可以运行了。再用 nm a 看看这个文件的符号列表,你会发现还多出了一堆其他玩意。puts 仍然是未定义,但稍微有点不一样。随便抓几个符号:

31
32
33
34
                 U __libc_start_main@GLIBC_2.34
00000000000007dc T main
U puts@GLIBC_2.17
00000000000007d4 T ret0

出现了!神秘的东西 GLIBC!符号所在的地址也变了。至于 glibc 和 libc 的区别是什么我们也不会在此涉及。Search the f*cking web.

至此,这个程序已经索然无味。ほらほら,我们稍微改点东西:

1
2
3
4
5
6
7
8
#include <stdio.h>

inline int ret0() { return 0; }

int main() {
puts("hello motherf*cker");
return ret0();
}

现在编译看看,你应当得到这样的结果:(别纠结了,用 gcc 也救不了你)

12
13
14
15
$ clang a.c -o a
/usr/bin/ld: /tmp/a-a275c9.o: in function `main':
a.c:(.text+0x1c): undefined reference to `ret0'
clang: error: linker command failed with exit code 1 (use -v to see invocation)

噫!我的 ret0() 呢?生成一个目标文件看看:

17
18
19
20
21
22
$ clang a.c -c -o a.o

$ nm a.o
0000000000000000 T main
U puts
U ret0

噫!ret0() 变成未定义了!如果你对 C++ 略知一二的话,会知道一样的程序在 C++ 里是完全没有问题的。怎么会是呢?

剧透一下,C++ 的结果像这样:

1
2
3
0000000000000000 T main
U puts
0000000000000000 W _Z4ret0v

至此,我们先打住。来看看另一个方面。

系统调用与 libc

你有没有思考过,当你调用 scanf() 时程序是怎么获取你的输入的?也许你知道,C 里有 stdinstdout,也许如果知道多一些的话还有个 stderr。比如这样:

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
char buf[12];
fread(buf, 1, 12, stdin);
fwrite(buf, 1, 12, stderr);
}

读入最多 12 个字节,再原封不动打到 stderr

现在我们换个写法:

1
2
3
4
5
6
7
#include <unistd.h>

int main() {
char buf[12];
read(0, buf, 12);
write(2, buf, 12);
}

(有语言洁癖的人会说应该用 STDIN_FILENOSTDERR_FILENO。无所谓。)
这两个程序还是有点差别的。试试看!

也许你会说,等一下,这个 read()write() 是哪来的?我怎么从来没听说过?
那么,欢迎来到系统调用(syscall)的世界。你说得对,但是 read() 是由 libc 提供的一款符合 POSIX 规范的系统调用函数。write() 也是。还有一堆。

正如你所熟知的 fread() 等一族函数通过 FILE * 来操作文件一样,在 POSIX 的世界中(或者叫 UNIX,随你喜欢;我们在这里不会特意去区分它们)文件是通过另一套机制来操作的:文件描述符(file descriptor),简称 fd,或者 fildes,或者你想怎么叫都可以。希望这能让你想起一句著名的论述:

In UNIX, everything is a file.

“这个名字很炫,”你说,“有一种与其他一万种计算机科学家们搞出来的让人摸不着头脑的专有名词一样看不懂的美感。”实际上 FILE * 也只是 fd 的皮套罢了,stdin stdout stderr 就分别对应着 fd 0~2。它们通过 fileno()fdopen() 也可以互相转换,但总之记住在 UNIX 的世界里 fd 是更加底层的那一个。

说了这么多,到底什么是 fd?实际上 fd 就是个一般通过 int,用一个非负整数值来标识一个文件(或者更广泛的说法,一份资源)。回去看我们之前的那个程序,就是用 0 和 2 这两个 fd 替代了 stdinstderr 这两个 FILE *,但是也要注意它们的行为并不完全相同。再举个例子,查查 open() 函数的 manpage,它就返回一个 fd,标识着刚刚打开的资源;或者如果没有成功打开的话,返回 -1:(也许你需要这么查:man 2 open,术语上我们称之为 open(2)

open(2) - System Calls Manual
1
2
3
4
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

这些函数实际上都只是系统调用套了一层薄薄的皮方便你在 C 中调用它们。用法如下:

1
2
3
4
5
6
7
8
int main() {
char buf[9] = {};
int fd = open("/etc/passwd", O_RDONLY);
printf("fd = %d\n", fd);
read(fd, buf, 8);
printf("%s\n", buf);
close(fd);
}

麻雀虽小五脏俱全,用于演示目的足够了。仔细读读这个程序。它的输出大概这样:

1
2
3
$ ./a
fd = 3
root::0:

这和你的预期应当是相符的。新的 fd 是 3 是合理的,因为你已经知道 0 1 2 是什么了。以及顺便说一句,不要去混着用 FILE * 和它对应的 fd,除非你是受虐狂masochistic。(原话如此——来自 stdin(3)。)

那么现在你已经知道了系统调用和这一系列的东西,是不是有种说不上来的违和感?没有也无所谓。绕了这么大一圈,我其实只是想说,不要在 C 里面写快读了,或者说不要盲目地写快读了,它不是灵丹妙药,有时候(像这种时候)倒会反咬一口。可以写,但是别给它起名 read() 了,你起个 fastread() 都行啊。但是也别起 fread()

我们接下来的话题是,为什么偏偏是 read()?如果你尝试写一个 fread() 的话,编译器会狠狠地拷打你。也不只是 read()write() open() 这些都一样。这种函数名实在是太常见了,谁敢说自己写了这么多年程序没写过一个函数叫 read()?问题是,为什么是它们?为什么 C++ 可以 C 不行?其实这俩问题我们之前都提了一嘴,接下来我们准备深入讨论一下。

内联

让我们把思路重新跳回内联这件事情上。

inline 是 C99 引入的关键字,从 C++ 那边吸收过来的(参考 cppreference.com)。吸收了但没有完全吸收,它的语义很奇怪,inlineextern inlinestatic inline 是三种不同的意思。

1
2
3
inline int inl() { return 0; }
extern inline int extinl() { return 0; }
static inline int stainl() { return 0; }

像这样的一份程序,nm 的结果可能像下面这样。如果缺符号了,就在编译时加上 -fno-inline

1
2
3
0000000000000000 T extinl
U inl
0000000000000014 t stainl

在 C++ 中,inlineextern inline 是等同的。用 C++ 编译相同的代码,但记得手动包一层 extern "C"。结果可能像这样:

1
2
3
0000000000000000 W extinl
0000000000000000 W inl
0000000000000044 t _ZL6stainlv

W 是 weak,即弱符号。t 的意思是在代码段,拥有局部链接。如果你疑惑为什么 stainl() 变成了这个样子,我们等下也会解释。

说到底,inline 是一个很半吊子的关键词。ISO/IEC 9899:1999 是这样描述的:

§ 6.7.4 Function specifiers

  1. Any function with internal linkage can be an inline function. For a function with external linkage, the following restrictions apply: If a function is declared with an inline function specifier, then it shall also be defined in the same translation unit. If all of the file scope declarations for a function in a translation unit include the inline function specifier without extern, then the definition in that translation unit is an inline definition. An inline definition does not provide an external definition for the function, and does not forbid an external definition in another translation unit. An inline definition provides an alternative to an external definition, which a translator may use to implement any call to the function in the same translation unit. It is unspecified whether a call to the function uses the inline definition or the external definition.

说人话就是:

  1. 一个非 externinline 函数不会产生一个外部可见的符号。如果它刚好也不是 static 的,则这个函数的定义成为内联定义,它不阻止另一个外部定义存在。
  2. 内联定义只是为编译器提供一个选择,调用时编译器可以选择内联定义也可以选择外部定义。实际选择哪个是未指定的。(未指定的意思是说,编译器一定会在这两者里选一个,但它没有义务告诉你它选择了哪个,你也不能假设它一定选了其中一个。)

(关于 undefined behavior、unspecified 和 implementation-defined 的区别,这篇 C 答疑帖子这个 StackOverflow 问题都讲得很清楚。)

虽然第 2 条话是这么说,但是实际上几乎所有现代的 C 语言编译器的行为都与我们实践的结果是一样的:直接抛弃内联定义,可以去 Compiler Explorer 在线试试。(在这里我们特指支持 -std=c99 选项的 gcc 和 clang。MSVC 更偏向 C++ 编译器所以我们不考虑它。)说到底在源文件里单走一个 inline 确实毫无意义。它只有和链接性搭配起来才有的说,具体可以去看 cppreference 上的例子。总结一下大概就像这样:

a.h
1
inline int inl() { return 42; }
a.c
1
2
3
4
#include "a.h"
extern inline int inl(); // 仅此一处 extern
// 有没有 inline 无所谓
int main() { return inl(); }
b.c
1
2
#include "a.h"
int b_fn_not_special() { return inl(); }

然后把 a.cb.c 编译到一起。在这里 b.c 只是单纯用来演示对 inl() 的调用不会爆掉链接器。如果 a.c 中写成 static inline,链接器就会跟你爆,你就必须把 a.hinl() 的定义复制到 a.cb.c 中去,总之更混乱了,留作练习 ;)

动态链接

说了这么多,我们离想要的答案还差了一半呢。我们之前痛苦的源泉在于 inl(),或者再往前点的 ret0(),编译后类型变成了 U,即被抛弃了,导致链接的时候链接器跟我们爆 undefined reference。是这样的……吧?

……且慢!如果你还残存一点前文的模糊记忆,你会拍案而起:

1
2
3
0000000000000008 T main
U puts
0000000000000000 T ret0

那在这里 puts 也是 U 呀!怎么链接器就没爆呢?我们现在就来解释这个问题。

重新编译那一份程序,但这次我们要用到另一个妙妙工具:

1
2
3
4
$ ldd ./a
linux-vdso.so.1 (0x0000ffffbb936000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffbb700000)
/lib/ld-linux-aarch64.so.1 (0x0000ffffbb8f9000)

出现了!libc.so.6!(其他两个是什么,我们不关心。)

如果你在 macOS 上:

1
2
3
$ otool -L ./a
./a:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.100.3)

.dylib 是 macOS 下的动态库,libSystem.B.dylib 这个路径不存在于 /usr/lib 也不打紧,因为 macOS 知道它在哪。对你而言,经典 find 即可。

1
$ find / -name 'libSystem.B.dylib' 2>/dev/null

libSystem.B.dylib 还引用了 /usr/lib/system/libsystem_c.dylib,这就相当于 macOS 的 libc。

libc.so.6 就是 Linux 下的 libc。ldd 就是用来查询一个可执行文件的动态库依赖的工具。所以这就是动态链接:不是由你提供的函数(例如 puts()),你不需要给出它的定义;链接器只需要标记上库依赖,直到程序真正运行的时候再去对应的动态库(对于 puts() 来说是 libc.so)中寻找 puts() 并执行之。

说这么多,也许你还是没有什么实感。那么我们就来实操一下吧!新建一个 ans.c,写上这样的内容:

ans.c
1
2
3
int the_answer_to_everything() {
return 42;
}

然后接下来就是把它编译成 libans.so!编译时得这么写:

1
$ clang -shared -fPIC ans.c -o libans.so

-shared 指定你想要创建一个共享库(shared object),这个名词和动态库是一样的,虽然其实“动态库”更多的是 Windows 下的说法。-fPIC 的意思是生成位置无关代码(Position Independent Code),因为作为一个共享库,你想要它是可以被任意程序加载的。(鼓励自主用 nm 再去查看它所拥有的符号,我们在这里就不再演示了。)

a.c
1
2
3
4
5
6
7
#include <stdio.h>

int the_answer_to_everything();

int main() {
printf("The answer is %d\n", the_answer_to_everything());
}

直接编译 a.c 当然也不行,我们想要让 a.c 去动态链接 libans.so。我们得这样:

3
$ clang a.c -o a -lans -L.
  • -lans 的意思是让 clang 去链接 libans 这个库,也就是如果有没有定义的符号,就多去那里看一眼。如果不加这个选项的话,链接器就会因为找不到 the_answer_to_everything() 而报错。
  • -L. 的意思是将当前目录(.)加入搜索库时该找的路径。如果不加这个选项的话,链接器就会因为找不到 libans.so 而报错。

好了,现在用 ldd 看看新鲜编译的 a

5
6
7
8
9
$ ldd ./a
linux-vdso.so.1 (0x0000ffffa2979000)
libans.so => not found
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffa2740000)
/lib/ld-linux-aarch64.so.1 (0x0000ffffa293c000)

出现了 libans.so!但是,啊?not found?

这就又不得不提 Linux 的动态链接机制了。你已经知道 ld 是链接器,在程序运行的时候还是由 ld 负责去在共享库里翻找程序想要的符号。那么 ld 怎么知道去哪里找共享库?是这样的,它有一些默认的路径会去找,但是 . 显然不在此列。于是它需要一个叫做 LD_LIBRARY_PATH 的环境变量来指定一些要额外查找的路径。所以我们这样写就可以了:

11
12
13
14
15
$ LD_LIBRARY_PATH=. ldd ./a
linux-vdso.so.1 (0x0000ffff8cec1000)
libans.so => ./libans.so (0x0000ffff8ce30000)
libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff8cc60000)
/lib/ld-linux-aarch64.so.1 (0x0000ffff8ce84000)

如果你尝试运行 ./a 的话,也是差不多的情况:

17
18
19
20
21
$ ./a
./a: error while loading shared libraries: libans.so: cannot open shared object file: No such file or directory

$ LD_LIBRARY_PATH=. ./a
The answer is 42

因为只有有了 LD_LIBRARY_PATH Linux 才知道该去哪找 libans.so。方便起见,你可以跑个这个,这样就不用敲每行命令都在前面写上 LD_LIBRARY_PATH=. 了。

23
$ export LD_LIBRARY_PATH=.

所以说到现在你应当已经知道动态链接大体上是怎么一回事了。与之相对的即是静态链接。虽然我很想继续展开说说静态链接,但我扯的废话已经太多了。大概的意思就是,在编译时加上 -static 选项,然后去用各种妙妙工具检查一下编译得到的可执行文件,例如 nm ldd 以及 file,也注意一下文件大小的不同。

对了,如果你在 macOS 上,也是没有 -static 选项给你用的。你会得到这样的报错:

1
2
3
$ clang a.c -o a -static
ld: library not found for -lcrt0.o
clang: error: linker command failed with exit code 1 (use -v to see invocation)

原因可以参考这个 StackOverflow 问题

好了,到现在,我们之前提出的最后两个关键问题还原封不动呢:

  1. 为什么 C++ 可以而 C 不行?
  2. 为什么偏偏是 read() 它们而不是 puts() 它们?

虽然这两个问题的答案都很简单,但我还是决定展开一堆废话。

函数重载与函数原型

这一节之所以叫做“函数重载与函数原型”而不是直接叫做“函数重载与原型”也是有原因的,因为我们接下来要讨论的两件事情是分开的:C++ 中的函数重载与 C 中的函数原型。当然我们的重点不是 C++。

1
2
int read();
ssize_t read(int fd, void *buf, size_t count);

在 C 下,这两个声明有区别吗?有不了一点,在目标文件里两者会冲突,因为编译过后其符号都是 read。C++ 就不一样了,前者的符号是 _Z4readv 而后者是 _Z4readiPvm。C++ 的函数重载就是这么实现的,这项技术叫 name mangling。我们不会讨论这些个符号是按照什么规则生成的,建议上手实操一下。nm 自不必说,对于 C++ 的编译产物,还有一个妙妙工具叫 c++filt 可以应用。

所以这就是 C++ 的函数重载:如果两个函数有相同的函数名,但其参数有差异,它们就被认为是不同的两个函数,根据调用者传递的参数来区分到底该调用哪个。经过 name mangling,它们的符号也是不同的。
注:参数有差异指的是实际的类型,typedef 的当然还是相同的东西。

C++ 的事情到这里就打住,我们扯 C++ 的原因是是为了强调 C 不仅没有函数重载,而且 C 的原型也许也和你的直觉相左(如果你之前不知道的话)。现在是,C 的时间!

在 C 语言中,你所熟知的声明和定义函数的方式叫做新式函数声明,或者叫做 ANSI C 式。还有一种历史遗留方式叫做救世旧式函数声明,或称 K&R C 式。虽然自 C23 标准起 K&R C 式声明被踢出 C 标准了,但 C23 太新了,接下来的十年你都不会接触到它的。你所熟知的 ANSI C 是像这样的:

1
2
3
4
int sum(int, int);      // 芝士声明
int sum(int a, int b) { // 芝士与它相配的定义
return a + b;
}

太美丽了 ANSI C。但在 K&R C 中,是像这样的奇行种:

1
2
3
4
5
6
7
int sum();    // 芝士声明
int sum(a, b) // 芝士与它相配的定义
int a;
int b;
{
return a + b;
}

所以说

1
2
int sum();     // 不知道接受多少个参数,传多少个都可以
int sum(void); // 一个参数都不接受

这两者是不同的。关于更具体的区别,RTFM。最后我们把 cppreference 上的例子抄一个放在这里。

1
2
3
4
5
6
7
8
9
10
int f(void); // 声明:不接收参数
int g(); // 声明:接收未知参数

int main(void) {
f(1); // 编译时错误
g(2); // 未定义行为
}

int f(void) { return 1; ) // 实际定义
int g(a,b,c,d) int a,b,c,d; { return 2; } // 实际定义

之前我们说过,stdio.h 里有这样的东西:

stdio.h
661
extern int puts (const char *__s);

经过预处理,再抽象一下,实际上我们想讨论的就是下面这样。
在这里我们严格区分编译和链接两个阶段。

1
2
3
4
5
6
7
8
9
10
11
12
void abort(void); // 为了匹配 stdlib.h 中的定义
// 我们希望通过不引入任何头文件来说明一些东西
int puts(const char *); // 我们要研究的主角

// 上手实操时,在下面四行中请只选择一行!
// 就算你知道前两行是声明后两行是定义也是如此
/* 1 */ int puts();
/* 2 */ int puts(void);
/* 3 */ int puts() { return 42; }
/* 4 */ void puts(const char *p) { abort(); }

int main() { return puts("0721"); }

挨个试一遍,1 和 3 是可以正常通过编译的,而 2 和 4 是无法通过编译的。给你 24 秒思考一下原因。

答案是:

  1. 一个“接收未知参数”的函数原型,与 stdio.h 的原型不冲突
  2. 一个“不接收参数”的函数原型,与 stdio.h 中的原型冲突
  3. 一个“接收未知参数”的函数定义,与 stdio.h 的原型不冲突;调用时实际调用你的定义。
  4. 一个接受一个 const char * 的函数定义,与 stdio.h 中的原型冲突,因为返回类型不同!

这当然合理,如果符号在原地就有定义,去用它;如果没有定义再去动态库寻找。这没什么好说的。

如果你尝试去静态链接这个程序,去尝试覆盖 libc 中的函数的话,有的函数的是可以的,而有的函数链接器会爆 multiple definition。这就是弱符号与强符号:组装一个可执行文件时,可以有很多弱符号,但最多只能有一个强符号。如果有一个以上的强符号,链接器就会爆。如果都是弱符号,那链接器选择哪个弱符号是未指定的,但是在实践中最好避免这么写。

Recap

那么现在,结合上我们之前所讲的全部内容,重新审视这个梦开始的地方:

1
2
3
4
5
6
7
#include <stdio.h>

inline int read() { return 42; }

int main() {
printf("imma read an integer: %d\n", read());
}

至此,希望这个 read() 返回 -1 的原因对你已经很显然了:

  • 预处理阶段,#include 语句发生替换,在 stdio.h 文件中并没有 read() 的声明,所以不存在原型冲突;
  • 编译阶段,由于我们定义 read() 为非 externstaticinline,这个定义成为内联定义,之后转头被编译器抛弃,目标文件中 read 符号是未定义;
  • 链接阶段,目标文件与 libc 进行链接,目标文件中未定义的 read 符号链接到 libc 中的 read()
  • 运行阶段,实际调用了 libc 中的 read(),而它就是 read syscall 套皮。由于以错误的参数调用了它,其返回 -1。

在 C++ 中可以这么写,是因为 C++ 中 inline 的结果是 weak。当然,如果你随便打开一个优化选项(-O1 足矣),那么上面说的所有问题都不复存在了,因为内联定义真的被内联了。至于还有 __attribute__((always_inline)) 这种,是另一个话题了。

错误的参数?

有时候,也许,可能你会发现你的 read() 并没有按照我们上面所说的直接返回 -1,而是完全没有输出,程序就像卡住了一样——直到你试着按了下回车:

1
2
3
$ ./a

imma read an integer: 1

好吧,这里应该澄清一下“错误的参数”这个说法。正确的说法是,因为以与原型不匹配的参数调用函数是未定义行为 a.k.a. 任何事情都可能发生

read(2) - System Calls Manual
1
ssize_t read(int fd, void *buf, size_t count);

(以防你不知道,read syscall 的返回值是它实际读到的字节数,或 -1 表明出错,并且在这种情况下全局变量 errno 会被设置。)

有时候,也许,在汇编层面,恰巧在 call read 之时,rdi 或者 r0 或者 a0 或者不管什么用来传参的方式,使得对 read() 来说 fd 的值是 0,这种情况下它去等待键盘输入就也并非不能理解。

但不要依赖于这个行为。任何未定义行为都是不可靠的,不可预测的。也许这一秒是这样的,下一秒就不一定了。更进一步,因为它是未定义行为,所以就算你的 ./a 帮你点了个疯狂星期四也不能说它做错了。(并且,就算 fd 是 0,buf 的值是什么我们也永远无曾知晓,那么得到一个 SIGSEGV 也是很正常的。)

It’s perilous to think that you can tolerate undefined behavior in a program, imagining that its undefinedness can’t hurt; the undefined behavior can be more undefined than you think it can.

comp.lang.c FAQ list · Question 11.33

后记与参考链接

完结撒花~

这个问题实际上在 2023-05-21 在某群提出,当时查完一堆链接就想总结一下,结果拖了两个月(好鸽),连着 push 自己写了四五天才写完,越写越多。虽然在这个过程中也学到了很多。

肯定还存在一堆错误什么什么的,大佬轻喷 orz

好累,奖励自己一顿萨莉亚(躺


所有引用自 cppreference.com 的材料均遵循 CC BY-SA 3.0 协议。

  1. StackOverflow - C99 referring to inline function: undefined reference to XXX and why should I put it to header?
  2. StackOverflow - Is “inline” without “static” or “extern” ever useful in C99?
  3. StackOverflow - undefined reference when calling inline function
  4. cppreference.com - C - inline 函数说明符
  5. cppreference.com - C - 函数声明

动态链接、系统调用与 C 的 inline
https://heap.45gfg9.net/t/def7b3875299/
作者
45gfg9
发布于
2023-07-18
许可协议