这是我个人的 C 预处理器参考手册文档。
C 预处理器,简称 cpp,是一个宏处理器语言和工具。
cpp 包提供了 cc1 命令,gcc 调用它来执行预处理(gcc -E),单独的 cpp 程序是为了兼容性。
- /usr/libexec/gcc/aarch64-linux-gnu/13/cc1
在编译链接时,可以使用 gcc 的 -v 和 -Wp,-v 参数来打印 gcc 调用 cc1 的参数详情。
alizj@ubuntu:/Users/alizj/docs/lang/c$ gcc -g -v -Wp,-v -Wa,-v -Wl,-v array.c
...
# cc1 是 cpp 包提供的预处理器
COLLECT_GCC_OPTIONS='-g' '-v' '-mlittle-endian' '-mabi=lp64' '-dumpdir' 'a-'
/usr/libexec/gcc/aarch64-linux-gnu/13/cc1 -quiet -v -imultiarch aarch64-linux-gnu -v array.c -quiet -dumpdir a- -dumpbase array.c -dumpbase-ext .c -mlittle-endian -mabi=lp64 -g -version -fasynchronous-unwind-tables -fstack-protector-strong -Wformat -Wformat-security -fstack-clash-protection -o /tmp/ccvkgXuK.s
GNU C17 (Ubuntu 13.3.0-6ubuntu2~24.04) version 13.3.0 (aarch64-linux-gnu)
compiled by GNU C version 13.3.0, GMP version 6.3.0, MPFR version 4.2.1, MPC version 1.3.1, isl version isl-0.26-GMP
## cc1 内置编译预处理搜索头文件目录
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
ignoring nonexistent directory "/usr/local/include/aarch64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/include-fixed/aarch64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/../../../../aarch64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/aarch64-linux-gnu/13/include
/usr/local/include
/usr/include/aarch64-linux-gnu
/usr/include
End of search list.
头文件内容 #
头文件一般包含声明,如:
- extern 类型的变量和常量;
- 函数签名声明;
- struct/union/enum 类型的前向声明;
- 只在本文件有效的宏定义(常量宏、函数宏);
- typedef 类型别名定义;
C 不允许整体范围的重复定义:
- 变量定义
- 常量定义
- 函数定义
- struct/union/enum 类型定义
因为头文件会被不同的源文件包含,可能导致重复定义,所以这些定义一般在 C 源文件而非头文件中定义。如果在定义前使用它们:
- struct/union/enum:使用前向声明方式来使用;
- 变量、常量、函数: 通过 extern 声明的方式来使用;
宏定义 #
使用 #define
指令定义常量宏或函数宏。可以重复定义,但如果定义不一致会告警。
#define
宏的作用域从其定义处开始,直到本文件末尾或遇到 #undef
指令为止,即只在本文件有效 。
如果要在多个源文件中共享,需要在一个头文件中定义,然后被多个源文件 #include
,这样可以保证重复包含后定义一致。
编译器参数 -D
定义的宏对所有源文件生效,如: gcc -DBUFFER_SIZE=1024 file1.c file2.c -o myprogram
// 宏定义是编译预处理指令,所以不以分号结尾
#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b))
// 宏定义也可以不包含值
#define EXTRA_HAPPY
#ifdef EXTRA_HAPPY
//...
#endif
宏定义比较特殊,它是 cpp 而非编译器处理的,而且是文件级别有效。头文件的 #ifndef 和 #ifdef 定义的宏只在包含它的源文件中生效,这是因为 C 预处理器在编译每个源文件之前独立地运行,处理在该文件中定义的所有预处理指令。
typedef 类型别名定义 #
使用 typedef
指令定义新的类型名称,可以重复定义,但是定义必须完全一致,否则编译时报错。
- 文件级别(全局作用域):如果 typedef 定义在任何函数之外,则只在定义它的文件有效 。所以如果要在多个源文件中使用,则可以在头文件中定义。
- 块级别(局部作用域):如果 typedef 定义在函数内部,它的作用域仅限于该函数内部。
// typedef 是编译器识别的语句,故以分号结尾。
typedef unsigned int uint;
typedef struct {
int x;
int y;
} Point;
函数原型声明 #
声明函数原型,可选的加 extern 前缀,可以重复声明,但是需要确保函数签名完全一致:
void printHello(void);
int add(int a, int b);
全局变量声明 #
使用 extern 关键字声明在其它文件中定义的全局变量、常量,可以重复声明,但是类型必须要一致:
extern int globalVar;
内联函数 #
inline 函数是文件作用域(一般和 static 连用, 确保只是单文件内有效。),通过在头文件中直接定义实现,可以在多个源文件中复用。
inline static int square(int x) {
return x * x;
}
条件编译指令 #
如 #if/#ifdef/#ifndef/#else/#elifdev/#endif
等:
#ifndef MY_HEADER_H
// MY_HEADER_H 只在当前文件有效,可以确保当前文件只会 include 该头文件一次内容
#define MY_HEADER_H
#endif
#ifdef EXTRA_HAPPY
printf("I'm extra happy!\n");
#endif
#ifndef EXTRA_HAPPY
printf("I'm just regular\n");
#endif
#ifdef EXTRA_HAPPY
printf("I'm extra happy!\n");
#else
printf("I'm just regular\n");
#endif
// #elifdef, #elifndef 是 C23 标准提供的指令
#ifdef MODE_1
printf("This is mode 1\n");
#elifdef MODE_2
printf("This is mode 2\n");
#elifdef MODE_3
printf("This is mode 3\n");
#else
printf("This is some other mode\n");
#endif
#include <stdio.h>
#define EXTRA_HAPPY
int main(void)
{
#ifdef EXTRA_HAPPY
printf("I'm extra happy!\n");
#endif
printf("OK!\n");
#if HAPPY_FACTOR == 0
printf("I'm not happy!\n");
#elif HAPPY_FACTOR == 1
printf("I'm just regular\n");
#else
printf("I'm extra happy!\n");
#endif
}
struct/union/enum 前向声明 #
struct/union/enum 类型全局不能重复定义, 所以它们一般在 C 源文件而非头文件中定义,源文件对应的头文件中只做类型的前向声明。对于使用这些类型的其它头文件(如函数参数类型, struct 字段类型等), 需要在头文件前部 #include 包含它们前向声明的头文件,或者直接前向声明这些类型。
前向声明可以重复声明,用来消除头文件中的循环依赖。
// 前向声明,该类型实际在其它文件中定义。
struct Rect;
// 函数原型声明,使用前向声明的类型 struct Rect。
bool is_in_rect(My_Point point, struct Rect rect);
访问前向声明的 struct/union/enum 的成员时,需要确保编译器可以找到(可以在后续其它文件或 obj 中)它们的类型定义,否则编译失败:
struct MyStruct; // 前向声明
void myFunction() {
struct MyStruct s;
s.member = 10; // 错误:不能访问前向声明的成员
}
不能在头文件重复定义的情况 #
一般在 C 源文件定义它们,在头文件中通过 extern 变量声明或 struct/enum/union 前向声明来使用它们:
-
变量和函数的重复定义:变量和函数的定义在同一个作用域内不能重复(如全局作用于)。例如:
int a; // 定义 int a = 10; // 错误:重复定义 void func(void) { // 实现 } void func(void) { // 错误:重复定义 }
-
结构体、联合体、枚举的重复定义:在同一个作用域内,不能重复定义同名的结构体、联合体、枚举。
- 但是允许用 typedef 重复定义 struct/union/enum 类型,需要确保重复定义完全一致,否则编译报错。
- C 允许可以重复声明的 struct/union/enum 前向声明,可以在函数签名或类型定义中使用它们。
struct MyStruct { int a; }; struct MyStruct { int a; }; // 错误:重复定义,即使结构完全相同也不行
Include Guard #
为了防止头文件重复包含,使用 include guard 或 #pragma once
。
使用 include gurad 可以消除同一个源文件多次 include 同一个头文件的问题,但是它不能消除不同源文件之间的重复包含同一个头文件(因为 guard 只在当前源文件有效)。
Guard 宏名称惯例:对于用户头文件,一般使用大写的头文件全称,对于系统库文件,一般是以 __
开头的宏名。
#ifndef MY_HEADER_H
#define MY_HEADER_H
// 内容
#endif
// 或者
#pragma once
// 内容
头文件使用 #pragma once
来确保不会重复包含:
// https://github.com/me-no-dev/OpenAI-ESP32/blob/master/src/OpenAI.h
#pragma once
#include "Arduino.h"
#include "cJSON.h"
class OpenAI_Completion;
class OpenAI_ChatCompletion;
class OpenAI_Edit;
class OpenAI_ImageGeneration;
编译预处理指令 #
#include #
支持相对路径:
#include "mydir/myheader.h"
#include "../someheader.py"
#include 和 #define
都是预处理指令,不以分号结尾:
#define HELLO "Hello, world"
#define PI 3.14159
#define PI (3.14159) // 建议
// 在定义常量宏时可以不带 value,或者 gcc -DEXTRA_HAPPY
#define EXTRA_HAPPY
#define SQR(x) (x) * (x) // Better... but still not quite good enough!
#define SQR(x) ((x) * (x)) // Good!
条件编译 #
#if/#elif
支持常量表达式(不支持函数):
#ifndef MYHEADER_H
#define MYHEADER_H
int x = 12;
#endif // Last line of myheader.h
#ifdef EXTRA_HAPPY
printf("I'm extra happy!\n");
#else
printf("I'm just regular\n");
#endif
#ifdef FOO
#if defined FOO
#if defined(FOO) // Parentheses optional
#ifndef FOO
#if !defined FOO
#if !defined(FOO) // Parentheses optional
#if __STDC_VERSION__ >= 1999901L
#include <stdio.h>
#define HAPPY_FACTOR 1
int main(void)
{
#if HAPPY_FACTOR == 0 // 条件表达式(常量)
printf("I'm not happy!\n");
#elif HAPPY_FACTOR == 1
printf("I'm just regular\n");
#else
printf("I'm extra happy!\n");
#endif
printf("OK!\n");
}
可以使用 #if 0
来注释一段代码块(代码块中可以包含注释):
#if 0
printf("All this code"); /* is effectively */
printf("commented out"); // by the #if 0
#endif
其他:
#ifdef FOO
#if defined FOO
#if defined(FOO) // Parentheses optional
#ifndef FOO
#if !defined FOO
#if !defined(FOO) // Parentheses optional
取消宏定义 #undef #
// #undef
#include <stdio.h>
int main(void)
{
#define GOATS
#ifdef GOATS
printf("Goats detected!\n"); // prints
#endif
#undef GOATS // Make GOATS no longer defined
#ifdef GOATS
printf("Goats detected, again!\n"); // doesn't print
#endif
}
编译器内置的调试宏常量 #
__DATE__/__TIME__/__FILE__/__LINE__/__func__/__STDC__/__STDC_HOSTED__/__STDC_VERSION__
#include <stdio.h>
int main(void)
{
printf("This function: %s\n", __func__);
printf("This file: %s\n", __FILE__);
printf("This line: %d\n", __LINE__);
printf("Compiled on: %s %s\n", __DATE__, __TIME__);
printf("C Version: %ld\n", __STDC_VERSION__);
}
#if __STDC_VERSION__ >= 1999901L
宏函数 #define #
#define SQR(x) ((x) * (x)) // Good!
#define TRIANGLE_AREA(w, h) (0.5 * (w) * (h))
宏函数之间可以嵌套调用 #
#include <stdio.h>
#include <math.h> // For sqrt()
#define QUADP(a, b, c) ((-(b) + sqrt((b) * (b) - 4 * (a) * (c))) / (2 * (a)))
#define QUADM(a, b, c) ((-(b) - sqrt((b) * (b) - 4 * (a) * (c))) / (2 * (a)))
#define QUAD(a, b, c) QUADP(a, b, c), QUADM(a, b, c)
int main(void)
{
printf("2*x^2 + 10*x + 5 = 0\n");
printf("x = %f or x = %f\n", QUAD(2, 10, 5));
}
可变参数 #
#include <stdio.h>
// Combine the first two arguments to a single number, then have a commalist of the rest of them:
#define X(a, b, ...) (10*(a) + 20*(b)), __VA_ARGS__
// 加前缀 # 表示替换为字符串
#define Y(...) #__VA_ARGS__
int main(void)
{
printf("%d %f %s %d\n", X(5, 4, 3.14, "Hi!", 12));
printf("%s\n", Y(1,2,3)); // Prints "1, 2, 3"
}
GNU 扩展: ##VA_ARGS 会消耗(删除)最后一个逗号。
字符串化 #
#include <stdio.h>
#define STR(x) #x
#define PRINT_INT_VAL(x) printf("%s = %d\n", #x, x)
int main(void)
{
int a = 5;
PRINT_INT_VAL(a); // prints "a = 5"
printf("%s\n", STR(3.14159));
// printf("%s\n", "3.14159");
}
参数连接 a ## b: #
#define CAT(a, b) a ## b // 中间需要有空格
printf("%f\n", CAT(3.14, 1592)); // 3.141592
多条语句的函数宏 #
- 每条语句都以反斜杠结尾;
- 使用
do {} while(0)
包裹所有语句; - GNU C 扩展支持复合语句表达式, 可以用来替换/简化 do {} while(0);
#include <stdio.h>
#define PRINT_NUMS_TO_PRODUCT(a, b) do { \
int product = (a) * (b); \
for (int i = 0; i < product; i++) { \
printf("%d\n", i); \
} \
} while(0)
int main(void)
{
PRINT_NUMS_TO_PRODUCT(2, 4); // Outputs numbers from 0 to 7
}
#define maxint(a,b) \
({int _a = (a), _b = (b); _a > _b ? _a : _b; })
#define SEARCH(value, array, target) \
do { \
__label__ found; \
typeof (target) _SEARCH_target = (target); \
typeof (*(array)) *_SEARCH_array = (array); \
int i, j; \
int value; \
for (i = 0; i < max; i++) \
for (j = 0; j < max; j++) \
if (_SEARCH_array[i][j] == _SEARCH_target) \
{ (value) = i; goto found; } \
(value) = -1; \
found:; \
} while (0)
// 用 statement expression 重写:
#define SEARCH(array, target) \
({ \
__label__ found; \
typeof (target) _SEARCH_target = (target); \
typeof (*(array)) *_SEARCH_array = (array); \
int i, j; \
int value; \
for (i = 0; i < max; i++) \
for (j = 0; j < max; j++) \
if (_SEARCH_array[i][j] == _SEARCH_target) \
{ value = i; goto found; } \
value = -1; \
found: \
value; \
})
ASSERT 宏 #
#include <stdio.h>
#include <stdlib.h>
#define ASSERT_ENABLED 1
#if ASSERT_ENABLED
#define ASSERT(c, m) \
do { \
if (!(c)) { \
fprintf(stderr, __FILE__ ":%d: assertion %s failed: %s\n", \
__LINE__, #c, m); \
exit(1); \
} \
} while(0)
#else
#define ASSERT(c, m) // Empty macro if not enabled
#endif
int main(void)
{
int x = 30;
ASSERT(x < 20, "x must be under 20");
}
#error/#warning 指令 #
#ifndef __STDC_IEC_559__
#error I really need IEEE-754 floating point to compile. Sorry!
#endif
#pragma 指令 #
- 编译器遇到不认识的 #pragma 指令会直接忽略;
- 头文件中使用
#pragma once
来确保不会重复包含; - https://gcc.gnu.org/onlinedocs/gcc/Pragmas.html
#pragma omp parallel for
for (int i = 0; i < 10; i++) { ... }
// 头文件中使用 once 来确保不会重复包含
#pragma once
重置 __LINE__
编号
#
后续从指定的编号开始计数:
#line 300
// To override the line number and the filename:
#line 300 "newfilename"
Null 指令 #
#ifdef FOO
#
#else
printf("Something");
#endif
#embed (C23 新增) #
The gist of this is that you can include bytes of a file as #integer constants as if you’d typed them in.
int a[] = {
#embed "foo.bin"
};
// 等效于:
int a[] = {11,22,33,44};
头文件搜索路径 #
#include "xx.h"
: 优先搜索源文件所在目录,然后是自定义目录和系统标准目录;
- 优先搜索 include 该头文件的源文件所在目录;
- -Idir1 -Idir2 指定的目录;
- -iquote dir1 -iquote dir2 指定的目录;
- 系统标准目录;
- -idirafter dir1 -idirafter dir2 指定的目录;
#include<file.h>
:不搜索源文件所在的目录;
- -Idir1 -Idir2 指定的目录;
- -isystem dir1 -isystem dir2 指定的目录;
- 系统标准目录;
- -idirafter dir1 -idirafter dir2 指定的目录;
注:-iquote 和 -isystem 目录是各自专用的,而 -I 是共用的而且优先级高。
系统标准目录:包括 gcc 头文件目录和 glibc 标准库目录,它们都是架构相关的,需要和交叉编译工具链匹配使用。
具体参考:20250124-gcc-cross-compiling-toolchain.md
使用 cpp -v
命令查看搜索路径:
$ cpp -v /dev/null -o /dev/null
...
ignoring nonexistent directory "/usr/local/include/aarch64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/include-fixed/aarch64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/../../../../aarch64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/aarch64-linux-gnu/13/include
/usr/local/include
/usr/include/aarch64-linux-gnu
/usr/include
End of search list.
...
使用命令 echo 'main(){}' | gcc -E -v -
或 gcc -v -xc /dev/null -fsyntax-only
查看头文件搜索路径(gcc -v 显示详细执行命令):
alizj@ubuntu:/Users/alizj/docs/lang/c$ echo 'main(){}' | gcc -E -v -
...
ignoring nonexistent directory "/usr/local/include/aarch64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/include-fixed/aarch64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/aarch64-linux-gnu/13/../../../../aarch64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/aarch64-linux-gnu/13/include
/usr/local/include
/usr/include/aarch64-linux-gnu
/usr/include
End of search list.
...
clang 头文件搜索路径 #
MacOS 默认的系统头文件路径是 /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/
, 使用命令可以查看 xcrun --show-sdk-path
clang 按照 -isysem/-idirafter 指定的顺序自左向右来依次搜索系统头文件目录,以找到的第一个为准。使用 clang -v -xc /dev/null -fsyntax-only
命令可以查看 SYSTEM include search path:
$ xcrun --show-sdk-path
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk
$ clang -v -xc /dev/null -fsyntax-only -isystem /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/ -idirafter /Users/zhangjun/codes/ubuntu-5.15.0-75-headers
...
clang -cc1 version 16.0.4 based upon LLVM 16.0.4 default target x86_64-apple-darwin22.4.0
ignoring nonexistent directory "/Library/Developer/CommandLineTools/SDKs/MacOSX13.sdk/usr/local/include"
ignoring nonexistent directory "/Library/Developer/CommandLineTools/SDKs/MacOSX13.sdk/Library/Frameworks"
ignoring duplicate directory "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include"
#include "..." search starts here:
#include <...> search starts here:
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include
/usr/local/opt/llvm/bin/../include/c++/v1
/usr/local/Cellar/llvm/16.0.4/lib/clang/16/include
/Library/Developer/CommandLineTools/SDKs/MacOSX13.sdk/System/Library/Frameworks (framework directory)
/Users/zhangjun/codes/ubuntu-5.15.0-75-headers
End of search list.
可以使用环境变量 SDKROOT
修改 MacOS 的系统头文件路径, 这样所有搜索路径都以它为前缀:
$ export SDKROOT=/Users/zhangjun/codes/ubuntu-5.15.0-75-headers
$ xcrun --show-sdk-path
/Users/zhangjun/codes/ubuntu-5.15.0-75-headers
$ clang -v -xc++ /dev/null -fsyntax-only
...
clang -cc1 version 16.0.4 based upon LLVM 16.0.4 default target x86_64-apple-darwin22.4.0
ignoring nonexistent directory "/Users/zhangjun/codes/ubuntu-5.15.0-75-headers/usr/local/include"
ignoring nonexistent directory "/Users/zhangjun/codes/ubuntu-5.15.0-75-headers/usr/include"
ignoring nonexistent directory "/Users/zhangjun/codes/ubuntu-5.15.0-75-headers/System/Library/Frameworks"
ignoring nonexistent directory "/Users/zhangjun/codes/ubuntu-5.15.0-75-headers/Library/Frameworks"
#include "..." search starts here:
#include <...> search starts here:
/usr/local/opt/llvm/bin/../include/c++/v1
/usr/local/Cellar/llvm/16.0.4/lib/clang/16/include
End of search list.
头文件包含顺序 #
https://google.github.io/styleguide/cppguide.html
google C++ 编程风格对头文件的包含顺序作出如下指示:为了加强可读性和避免隐含依赖,应使用下面的顺序:
- C 标准库
- C++ 标准库
- 其它库的头文件
- 自己工程的头文件
不过这里最先包含的是 首选的头文件
,即 a.cpp 文件应该优先包含 a.h。
先首选的头文件是为了发现隐藏依赖,同时确保头文件和实现文件是匹配的。
头文件 A.h 应该 #include 它依赖的所有其它头文件,如 B.h C.h,这样 C 源文件 A.c 中只需要感知 #include "A.h"
即可,从而避免显式的包含头文件 B.h/C.h。
假如你有一个源文件 google-awesome-project/src/foo/internal/fooserver.cc,那么它所包含的头文件的顺序如下:
// 首选头文件放在第一位,且应该从根目录开始。
#include "foo/public/fooserver.h"
// 标准头文件
#include <sys/types.h>
#include <unistd.h>
#include <hash_map>
#include <vector>
// 工程头文件放到标准库头文件之后,这是因为这些头文件一般依赖于标准库头文件中定义的函数或声明。
#include "base/basictypes.h"
#include "base/commandlineflags.h"
#include "foo/public/bar.h"
隐含依赖又叫作隐藏依赖,即一个头文件依赖其它头文件,例如:
// A.h
struct BS bs;
...
// B.h
struct BS{
....
};
//在 A.c 中,这样会报错
#include A.h
#include B.h
//先包含 B.h 就可以
#include B.h
#include A.h
这样就叫隐藏依赖,如果先包含 A.h 就可以发现隐藏依赖,所以各种规范都要求源文件自身的头文件(也称首选头文件)放在第一个。
对隐藏依赖的解决办法是:在 A.h 中包含 B.h,而不是在 A.c 中再包含,这样通过 A.h 屏蔽了它的依赖 B.h,在开发 A.c 时就不需要显式包含 B.h。
在包含头文件时应该加上头文件所在工程的文件夹名,即假如你有这样一个工程 base,里面有一个 logging.h,那么外部包含这个头文件应该这样写: #include "base/logging.h"
,而不是 #include "logging.h"
。
链接时的强弱符号 #
参考:https://www.zhaixue.cc/c-arm/c-arm-weak-attribute.html
GNU C 通过 __atttribute__
声明 weak 属性,可以将一个强符号转换为弱符号。
void __attribute__((weak)) func(void);
int num __attribte__((weak);
编译器在编译源程序时,无论你是变量名、函数名,在它眼里,都是一个符号而已,用来表征一个地址。编译器会将这些符号集中,存放到一个叫符号表的 section 中。
编译链接的基本过程其实很简单,主要分为三个阶段。
- 编译阶段:编译器以源文件为单位,将每一个源文件编译为一个 .o 后缀的目标文件。每一个目标文件由代码段、数据段、符号表等组成。
- 链接阶段:链接器将各个目标文件组装成一个大目标文件。链接器将各个目标文件中的代码段组装在一起,组成一个大的代码段;各个数据段组装在一起,组成一个大的数据段;各个符号表也会集中在一起,组成一个大的符号表。最后再将合并后的代码段、数据段、符号表等组合成一个大的目标文件。
- 重定位:因为各个目标文件重新组装,各个目标文件中的变量、函数的地址都发生了变化,所以要重新修正这些函数、变量的地址,这个过程称为重定位。
重定位结束后,就生成了可以在机器上运行的可执行程序。
上面举例的工程项目,在编译过程中的链接阶段,可能就会出现问题:A.c 和 B.c 文件中都定义了一个同名变量 num,那链接器到底该用哪一个呢?
这个时候,就需要引入强符号和弱符号的概念了。
9.2 强符号和弱符号
在一个程序中,无论是变量名,还是函数名,在编译器的眼里,就是一个符号而已。符号可以分为强符号和弱符号。
- 强符号:函数名、初始化的全局变量名;
- 弱符号:未初始化的全局变量名。
强符号和弱符号在解决程序编译链接过程中,出现的多个同名变量、函数的冲突问题非常有用。
在一个项目中, 不能同时存在两个强符号
,比如你在一个多文件的工程中定义两个同名的函数,或初始化的全局变量,那么链接器在链接时就会报重定义的错误。
但一个工程中 允许强符号和弱符号同时存在
。比如你可以同时定义一个初始化的全局变量和一个未初始化的全局变量,这种写法在编译时是可以编译通过的。编译器对于这种同名符号冲突,在作符号决议时,一般会选用强符号,丢掉弱符号。
还有一种情况就是,一个工程中,同名的符号都是弱符号,那编译器该选择哪个呢?谁的体积大,即谁在内存中存储空间大,就选谁。
我们接下来写一个简单的程序,来验证上面的理论。定义两个源文件:main.c 和 func.c。
一般来讲,不建议在一个工程中定义多个不同类型的弱符号,编译的时候可能会出现各种各样的问题,这里就不举例了。在一个工程中,也不能同时定义两个同名的强符号,即初始化的全局变量或函数,否则就会报重定义错误。但是我们可以使用 GNU C 扩展的 weak 属性,将一个强符号转换为弱符号。
//func.c
int a __attribute__((weak)) = 1;
void func(void)
{
printf("func:a = %d\n", a);
}
//main.c
int a = 4;
void func(void);
int main(void)
{
printf("main:a = %d\n", a);
func();
return 0;
}
编译程序,可以看到程序运行结果。
$ gcc -o a.out main.c func.c
main: a = 4
func: a = 4
我们通过 weak 属性声明,将 func.c 中的全局变量 a,转换为一个弱符号,然后在 main.c 里同样定义一个全局变量 a,并初始化 a 为4。链接器在链接时会选择 main.c 中的这个强符号,所以在两个文件中,打印变量 a 的值都是4。
9.3 函数的强符号和弱符号
链接器对于同名变量冲突的处理遵循上面的强弱规则,对于函数同名冲突,同样也遵循相同的规则。函数名本身就是一个强符号,在一个工程中定义两个同名的函数,编译时肯定会报重定义错误。但我们可以通过 weak 属性声明, 将其中一个函数转换为弱符号
。
//func.c
int a __attribute__((weak)) = 1;
void __attribute__((weak)) func(void)
{
printf("func:a = %d\n", a);
}
//main.c
int a = 4;
void func(void)
{
printf("I am a strong symbol!\n");
}
int main(void)
{
printf("main:a = %d\n", a);
func();
return 0;
}
编译程序,可以看到程序运行结果。
$ gcc -o a.out main.c func.c
main: a = 4
func: I am a strong symbol!
在这个程序示例中,我们在 main.c 中重新定义了一个同名的 func 函数,然后将 func.c 文件中的 func() 函数,通过 weak 属性声明转换为一个弱符号。链接器在链接时会选择 main.c 中的强符号,所以我们在 main 函数中调用 func() 时,实际上调用的是 main.c 文件里的 func() 函数。
9.4 弱符号的用途
在一个源文件中引用一个变量或函数,当我们只声明,而没有定义时,一般编译是可以通过的。这是因为编译是以文件为单位的,编译器会将一个个源文件首先编译为 .o 目标文件。编译器只要能看到函数或变量的声明,会认为这个变量或函数的定义可能会在其它的文件中,所以不会报错。甚至如果你没有包含头文件,连个声明也没有,编译器也不会报错,顶多就是给你一个警告信息。但链接阶段是要报错的,链接器在各个目标文件、库中都找不到这个变量或函数的定义,一般就会报未定义错误。
当函数被声明为一个弱符号时,会有一个奇特的地方: 当链接器找不到这个函数的定义时,也不会报错
。编译器会将这个函数名,即弱符号,设置为0或一个特殊的值。只有当程序运行时,调用到这个函数,跳转到0地址或一个特殊的地址才会报错。
//func.c
int a __attribute__((weak)) = 1;
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
printf("main:a = %d\n", a);
func();
return 0;
}
编译程序,可以看到程序运行结果。
$ gcc -o a.out main.c func.c
main: a = 4
Segmentation fault (core dumped)
在这个示例程序中,我们没有定义 func() 函数,仅仅是在 main.c 里作了一个声明,并将其声明为一个弱符号。编译这个工程, 你会发现是可以编译通过的,只是到了程序运行时才会出错
。
为了防止函数运行出错,我们可以在运行这个函数之前,先做一个判断,即看这个函数名的地址是不是0,然后再决定是否调用、运行。这样就可以避免段错误了,示例代码如下。
//func.c
int a __attribute__((weak)) = 1;
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
printf("main:a = %d\n", a);
if (func)
func();
return 0;
}
编译程序,可以看到程序运行结果。
$ gcc -o a.out main.c func.c
main: a = 4
函数名的本质就是一个地址,在调用 func 之前,我们先判断其是否为0,为0的话就不调用了,直接跳过。你会发现,通过这样的设计,即使这个 func() 函数没有定义,我们整个工程也能正常的编译、链接和运行!
弱符号的这个特性,在库函数中应用很广泛
。比如你在开发一个库,基础的功能已经实现,有些高级的功能还没实现,那你可以将这些函数通过 weak 属性声明,转换为一个弱符号。通过这样设置,即使函数还没有定义,我们在应用程序中只要做一个非0的判断就可以了, 并不影响我们程序的运行
。等以后你发布新的库版本,实现了这些高级功能,应用程序也不需要任何修改,直接运行就可以调用这些高级功能。
弱符号还有一个好处,如果我们对库函数的实现不满意,我们可以自定义与库函数同名的函数,实现更好的功能。比如我们 C 标准库中定义的 gets() 函数,就存在漏洞,常常成为黑客堆栈溢出攻击的靶子。
int main(void)
{
char a[10];
gets(a);
puts(a);
return 0;
}
C 标准定义的库函数 gets() 主要用于输入字符串,它的一个 Bug 就是使用回车符来判断用户输入结束标志。这样的设计很容易造成堆栈溢出。比如上面的程序,我们定义一个长度为10的字符数组用来存储用户输入的字符串,当我们输入一个长度大于10的字符串时,就会发生内存错误。
接着我们定义一个跟 gets() 相同类型的同名函数,并在 main 函数中直接调用,代码如下。
#include <stdio.h>
char * gets (char * str)
{
printf("hello world!\n");
return (char *)0;
}
int main(void)
{
char a[10];
gets(a);
return 0;
}
程序运行结果如下。
hello world!
通过运行结果,我们可以看到,虽然我们定义了跟 C 标准库函数同名的 gets() 函数,但编译是可以通过的。程序运行时调用 gets() 函数时,就会跳转到我们自定义的 gets() 函数中运行。
9.5 属性声明:alias
GNU C 扩展了一个 alias 属性,这个属性很简单,主要用来给函数定义一个别名。
void __f(void)
{
printf("__f\n");
}
void f() __attribute__((alias("__f")));
int main(void)
{
f();
return 0;
}
程序运行结果如下。
__f
通过 alias 属性声明,我们就可以给 f() 函数定义一个别名 f(),以后我们想调用 f() 函数,可以直接通过 f() 调用即可。
在 Linux 内核中,你会发现 alias 有时会和 weak 属性一起使用。比如有些函数随着内核版本升级,函数接口发生了变化,我们可以通过 alias 属性给这个旧接口名字做下封装,起一个新接口的名字。
//f.c
void __f(void)
{
printf("__f()\n");
}
void f() __attribute__((weak,alias("__f")));
//main.c
void __attribute__((weak)) f(void);
void f(void)
{
printf("f()\n");
}
int main(void)
{
f();
return 0;
}
当我们在 main.c 中新定义了 f() 函数时,在 main 函数中调用 f() 函数,会直接调用 main.c 中新定义的函数;当 f() 函数没有新定义时,就会调用 __f() 函数。