提示

本文章适用于有 C 语言基础的学者阅读,旨在加深对 C 语言处理逻辑的理解,便于与其他语言互相操作

1. 为什么要重新理解 C?

引言

对于大部分开发者来说,C 语言并不陌生。我们可能在大学甚至之前就已经了解或学习过 C 语言,也编写过一些简单的工程。但是当我们在实际场景中真正用到 C 语言的时候,我们发现: “自己会写 C,但是并不了解 C” 这种落差并非源于语法问题,而是对 C 语言的认知层级的问题。

1.1 会 C 与理解 C 的区别

我们先来看一个简单的问题

#include <stdio.h>
int main(){
	printf("hello world");
}

这段代码看似简单,但是如果深入追问:

  • #include到底做了一些什么?
  • printf()是在哪里实现的?
  • 编译器是如何找到它们的?
  • 程序是如何被拼接成一个可执行文件的? 大多数情况下,我们都是能用,但是解释不清楚。这正是问题的根源所在

1.2 C 不是一门现代语言

很多误解,源于我们使用现代语言的思维来了解 C

在 Java / Kotlin / Python 中,我们习惯于:

  • 有清晰的模块系统
  • 有运行时(runtime / VM)
  • 有自动内存管理(GC)
  • 有统一的构建工具

但是在 C 中:

  • 语言本身没有统一的构建系统,只有预处理层面的#include
  • 没有像 JVM / VM 那样的托管运行时,标准库主要以函数集合的形式提供
  • 没有自动内存管理机制,需要手动管理内存
  • 没有由语言标准规定的统一构建方式,CMake / Makefile 属于工程约定

换句话说:C 不是一个“封装好的系统”,而是一套“直接操作机器的规则”

1.3 C 更接近“构建规则”,而非“语言工具”

一个更准确的理解方式

C 语言 = 语法 + 编译模型 + 内存模型 + ABI 约定 它并不像高级语言那样提供“完整开发体验”,而是暴露了程序构建的核心过程:

  • 代码如何被展开?(预处理)
  • 代码如何被翻译?(编译)
  • 程序如何被拼接?(链接)
  • 数据如何存在?(内存布局)
  • 函数如何调用?(ABI) 这些内容在高级语言中被隐藏,但是在 C 中是显式存在的

1.4 为什么在跨语言场景下必须理解这些?

需要理解问题的根源

当用户开始做跨语言调用时,无论是哪种语言:

  • Kotlin/Native
  • Rust FFI
  • Python C Extension
  • Go cgo

都会遇到类似的问题:

  • 为什么找不到函数?
  • 为什么参数传递错误?
  • 为什么会崩溃?
  • 为什么结构体数据不对?

上述问题的共同点是:它们都不是语言问题,而是“C 的底层规则问题” 因为在跨语言调用中,C 经常成为中间接口语言,因此所有语言都要了解:

  • C 的内存布局
  • C 的调用约定
  • C 的链接规则

1.5 关键认知:C 是“接口语言”

在现代的软件系统中,C 的角色很特殊

它不是最常用的开发语言,却是最通用的接口语言 原因在于:

  • 同一平台约定下 C ABI 相对稳定
  • 运行时依赖少
  • 调用成本低
  • 几乎所有语言都能和 C 互调用

这也是为什么:

  • 操作系统的接口是 C(POSIX)
  • 大量基础库是 C
  • 跨语言绑定基本用 C

1.6 本篇文章的目的

总结

本文不会聚焦于基本语法,而是聚焦于三个核心问题:

  • 程序是如何被构建的? ⇒ 编译、链接、头文件
  • 数据是如何存在的? ⇒ 指针、内存模型、结构体布局
  • 不同语言是如何对接的? ⇒ ABI、函数调用、二进制接口

本章的核心观点在于:理解 C 语言,不是掌握基本语法,而是理解程序在机器上运行的基本方式


2. 详解编译链接全过程

本章概述

上一章中我们了解到,C 的核心不只是语法,而是程序如何被构建。 在本章,我们就来详解一个关键问题:一段 C 代码,是如何成为一个可执行程序的

2.1 从源码到程序

从一个最简单的程序开始

#include <stdio.h>
int main(){
	printf("Hello\n");
}

如果此时运行,会得到一个可执行程序,但这个过程并不是一步完成的。 一个 C 语言程序的构建,通常可分为四个阶段: 源代码 → 预处理 → 编译 → 汇编 → 链接 → 可执行文件 接下来我们逐步拆解

预处理

概述

预处理是一个纯文本处理阶段,主要处理以#开头的指令:

  • #include
  • #define
  • #ifdef / #ifndef 例如:
#include <stdio.h>

在预处理阶段,可以粗略理解为把stdio.h中的声明、宏和条件编译内容展开到当前位置 也就是说,C 没有“导入模块”,只有“文本拼接”

一个重要结论

头文件不是库,而是声明的集合。这也是为什么:

  • 用户可以#include但仍然链接失败
  • 因为“声明存在” ≠ “实现存在”

编译

编译全过程

预处理之后,编译器会将代码转换为目标文件(.o)

gcc -c main.c

生成

main.o

这个阶段会完成:

  • 语法分析
  • 类型检查
  • 生成机器码(或中间表示)

关键点:什么是“符号”?

在main.o中,会记录两类信息:

  • 已定义符号(Defined Symbols)
int main(){...}

main 被定义

  • 未定义符号(Undefined Symbols)
printf("Hello");

printf 仅被声明,未被定义

可以使用工具查看:

nm main.o

会看到类似:

U printf
T main

其中,T表示已定义,U表示未定义(需要在链接阶段解决)

汇编

汇编过程

这一步通常由编译器自动完成:

  • 将中间表示转换为汇编
  • 再转换为机器码 也可以手动查看:
gcc -S main.c

生成:

main.s

对于整体流程来说,这一步不是重点

链接

链接过程

这是最重要但是也是最容易被忽略的一步!

链接的作用是:把多个目标文件 + 库 拼接成一个完整的程序

链接解决什么问题?

回忆一下,在main.o中,printf还没有实现,链接器的作用就是在其他目标文件 / 库中找到printf的实现并“拼接”上去

标准库是如何被链接的?

当用户执行:

gcc main.c -o main

实际上,编译器驱动程序会帮你完成很多隐含步骤,例如链接启动文件、默认库和 C 标准库。可以把它粗略理解为:

gcc main.o -lc -o main

-lc表示链接 C 标准库。真实命令会因平台和工具链不同而更复杂,但核心思想是:printf 的实现来自库,而不是来自你的源文件。 当用户忘记链接某个库:

undefined reference to 'xxx'

本质原因是符号声明存在,但是链接时找不到实现

2.2 多文件编译:程序是如何“拆开再拼起来”的?

用一个简单的示例来讲解

源码

file1.c

int add(int a, int b){
	return a + b;
}

file2.c

int add(int a, int b);
int main(){
	return add(1,2);
}

编译

gcc -c file1.c
gcc -c file2.c

生成:

file1.o
file2.o

链接

gcc file1.o file2.o -o main

链接器会:

  • 在file2.o中发现:
U add
  • 在file1.o中发现:
T add

将二者链接

关键结论

C 的多文件组织,本质是通过链接器拼接符号,而不是

  • import
  • module
  • package

2.3 静态库与动态库

概述

在实际工程中,我们不会每次都直接链接.o文件,而是使用“库”

静态库(.a)

ar rcs libmath.a file1.o

特点:

  • 链接时把需要的目标代码合入最终程序
  • 不依赖外部文件

动态库(.so / .dylib)

gcc -fPIC -c file1.c -o file1.o
gcc -shared -o libmath.so file1.o

特点:

  • 运行时加载
  • 最终可执行文件通常更小
  • 依赖外部文件

跨语言的关键区别

  • 静态库更适合把依赖合入最终产物
  • 动态库更适合插件机制、系统库和需要独立升级的场景
  • 跨语言绑定可以使用静态库,也可以使用动态库,关键在于构建、发布和加载方式是否一致

2.4 本章小结

总结

为什么需要了解这一切?

因为这些“隐藏步骤”,在跨语言调用时全部都会暴露出来 例如.def文件中的

headers = xxx.h
linkerOpts = -L... -lxxx

本质就是指定预处理输入,指定链接阶段的参数 当遇到下列问题时,对应地思考:

  • 找不到函数 → 链接问题
  • 编译报错 → 编译阶段问题
  • 宏失效 → 预处理问题

在进入下一章前,思考一个问题

如果#include仅仅是文本替换,那么:

  • C 是如何避免重复定义的?
  • 头文件为什么只写声明?

3. 头文件与模块机制

引言

在上一章中,我们知道了 C 程序不是靠“模块系统”组织代码,而是靠:

  • 预处理阶段的文本展开
  • 编译阶段的符号生成
  • 链接阶段的符号拼接

这就引出一个很自然的问题:既然 C 没有现代意义上的模块系统,那么它又是怎么组织多文件程序的? 答案就是:头文件 + 源文件 + 链接器 不过,很多人虽然长期在写 xxx.h 和 xxx.c,却并没有真正理解这种组织方式的底层逻辑。尤其是在自己设计头文件时,往往会遇到这些问题:

  • 头文件里应该有什么?
  • 为什么有的内容可以在.h文件中,而有些不行?
  • 为什么会出现重复定义?
  • 为什么有时明明包含了头文件,链接还是失败?
  • 宏、声明、定义、extern 之间到底是什么关系?

在本章,我们将讲透上述问题

3.1 C 的“模块”究竟是什么?

“模块”非模块

先说最重要的一点

C 没有现代意义上的模块系统。 在很多现代语言中,模块通常意味着:

  • 有明确的导入语义
  • 编译器知道依赖关系
  • 不会因为多次导入而被重复展开
  • 命名空间和可见性规则比较清晰

但 C 不是这样。 在 C 中

#include "math_utils.h"

它的本质不是“导入一个模块”,而是把这个头文件中的内容,原样插入到当前位置。也就是说,#include本质仍然是文本替换 这一点非常关键,因为它决定了 C 的“模块机制”从一开始就有天然限制:

  • 头文件可能会被重复展开
  • 同一份声明可能出现在不同的编译单元中
  • 一旦头文件中存在不应该存在的定义,那么就可能造成重复定义
  • 编译器并不知道“你想表达一个模块”,它只看展开后的文本

所以从严格意义上来说,C 不是通过“模块”组织代码,而是通过“约定”组织代码 这套约定通常表现为:

  • .h文件负责暴露接口
  • .c文件负责提供实现
  • 链接器负责将它们拼接起来

3.2 理解头文件

一个最基本的分工:.h 和 .c

一个最经典的结构:

// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
 
int add(int a, int b);
int sub(int a, int b);
 
#endif 
// math_utils.c
#include "math_utils.h"
int add(int a, int b){
	return a + b;
}
int sub(int a, int b){
	return a - b;
}
// main.c
#include <stdio.h>
#include "math_utils.h"
int main(){
	printf("%d\n", add(3,4));
	return 0;
}

在这里:

  • math_utils.h:提供“别人可以这么用我”
  • math_utils.c:提供“这些东西具体怎么实现”
  • main.c:通过包含头文件来获得声明,再在链接阶段找到真正实现

理解头文件第一关:声明与定义

声明 VS 定义

想要编写好头文件,就必须彻底分清两个概念:

  • 声明(declaration)
  • 定义(definition) 它们常被混淆,但是在 C 中区别非常重要

声明

声明的作用是告诉编译器:这个名字存在,它的类型是这样的,你后续会用到它

// example
int add(int a, int b);

这是一个函数声明,它告诉编译器:

  • 有一个函数叫做add
  • 接受两个int
  • 返回一个int 但是它没有给出函数体,所以还没有真正“实现”这个函数 变量的声明也一样
// example
extern int global_count;

这是一个变量声明,它表示:

  • 有一个全局变量叫做global_count
  • 类型是int
  • 它定义在别的地方

定义

定义的作用是:“真正创建这个实体”

// example
int add(int a, int b){
	return a + b;
}

这不仅告诉编译器有这个函数,还真正给出了函数体,所以这是一个函数定义

// example
int global_count = 0;

这里不再是单纯地说“有这个变量”,而是真正分配了存储空间,因此这是一个变量的定义

为什么头文件通常只放声明?

因为一个头文件可能会被多个.c文件包含 假如把函数实现直接写到头文件中:

// header.h
int add(int a, int b){
	return a + b;
}

然后多个源文件同时包含它,那么预处理后,相当于每个源文件中都会有一份add的定义,那么这样在链接阶段就会出现multiple definition of add,因为链接器发现同名符号被定义了多次 所以最基本的规则就是:头文件主要放声明,不放普通定义

头文件应该放什么?

这是最实用的问题

一个头文件中应该放以下内容

  • 函数声明
int add(int a, int b);
void print_message(const char* msg);
  • 类型声明,包括
    • struct
    • union
    • enum
    • typedef
  • 宏定义
#define MAX_SIZE 1024
  • 常量声明
extern const int DEFAULT_PORT;
  • 必要的内联函数 这个属于例外情况,后续会专门讲

不适合放在头文件中的内容

  • 普通变量的全局定义
// 错误示例
int global_count = 0;
  • 普通函数实现
// 错误示例
int add(int a, int b){
	return a + b;
}
  • 不必要的#include 很多初学者喜欢把大量头文件全部塞进一个.h中这是很不好的习惯。因为头文件被包含后会传播到所有使用者,导致:
  • 编译变慢
  • 依赖关系混乱
  • 耦合过高
  • 更容易产生命名冲突或循环包含 这部分内容后续会展开讲

3.3 如何写好头文件?

头文件的基本写法:从模板开始

#ifndef MYLIB_MATH_UTILS_H
#define MYLIB_MATH_UTILS_H
 
#ifdef __cplusplus
extern "C" {
#endif
 
typedef struct{
	int x;
	int y;
} Point;
 
int add(int a, int b);
int sub(int a, int b);
 
extern const int DEFAULT_PORT;
 
#ifdef __cplusplus
}
#endif
 
#endif

这个模板中浓缩了很多经验,下面进行详解

写头文件的精髓

include guard:防止头文件重复展开

我们现在已经知道,#include本质是文本插入,那么如果同一个头文件被间接包含多次,就会出问题。例如:

// a.h
#include "common.h"
// b.h
#include "common.h"
// main.c
#include "a.h"
#include "b.h"

如果common.h没有保护措施,那么它就会被展开两次,这可能导致:

  • 类型重复定义
  • 宏重复定义
  • 结构体重复声明报错 因此,头文件几乎都要写:
// example
#ifndef COMMON_H
#define COMMON_H
 
/* 头文件内容 */
 
#endif

这就叫做include guard,工作方式是:

  1. 第一次包含时,COMMON_H还没定义,于是进入内容区
  2. 同时定义COMMON_H
  3. 后续再次包含时,发现已经定义,就直接跳过

宏名字怎么起?

最优的方式是加上项目名称或路径信息:

#ifndef MYLIB_NET_SOCKET_H
#define MYLIB_NET_SOCKET_H

这样更加稳妥

pragma once能不能用?

也可以,很多编译器都支持,它的作用和 include guard 类似,而且更简洁。但是要注意两点:

  • 它不是最早的 C 标准机制
  • 某些极端构建环境或老工具链下可移植性不如 include guard

头文件编写的核心思路:接口优先,而非实现优先

这是编写头文件的重要思维方式。 在编写头文件时,不要问自己:“我在.c文件里面写了什么,哪些也需要放进.h?”,而要问:“别人如果也想要使用我的模块,他们最少需要知道什么?”这才是接口设计思维 假如你需要写一个栈模块,你不应该上来直接把内部结构暴露给用户,而应该先思考:

  • 用户需要创建栈
  • 用户需要压栈
  • 用户需要弹栈
  • 用户需要销毁栈 于是头文件可能是:
#ifndef MYLIB_STACK_H
#define MYLIB_STACK_H
 
typedef struct Stack Stack;
Stack* stack_create(void);
void stack_push(Stack* s, int val);
int stack_pop(Stack* s, int* out_value);
void stack_destroy(Stack* s);
 
#endif

这里有个关键设计:

typedef struct Stack Stack;

这叫做不透明类型声明,它告诉用户:

  • 有一个叫做 Stack 的结构体类型
  • 但是我不告诉你它里面长什么样 真正的定义放在.c文件中:
struct Stack{
	int* data;
	int size;
	int capacity;
};

这样做的好处是:

  • 用户只能通过你提供的函数操作这个类型
  • 内部可以实现自由修改,不影响外部代码
  • 更像真正的“模块接口” 这一点在跨语言调用中同样重要,因为很多 C 库就是这么设计 API 的

extern是做什么的?

很多人写头文件时对extern很模糊,只知道“好像写全局变量时要加”。下面我们彻底讲清楚其本质。 extern表示:“这个名字在别处定义,这里只是声明”

// xxx.h
extern int global_count;

这表示:

  • global_count是一个全局变量
  • 这个头文件仅告诉别人它存在
  • 真正的定义要在某个.c文件中
// xxx.c
int global_count = 0;

为什么声明变量常常需要extern,而函数声明通常不写?

因为函数声明默认就有“外部链接”的语义,也就是说:

int add(int a, int b);

本身就已经在声明一个外部函数了。虽然可以加上extern,但是通常没必要。 而变量不一样:

int global_count;

在文件作用域下,这是一个 tentative definition,不适合放在头文件中。为了明确表达“这里只是引用别处某个变量”,通常会写成:

extern int global_count;

头文件中全局变量的正确写法

// config.h
#ifndef CONFIG_H
#define CONFIG_H
extern int global_count;
#endif
// config.c
#include "config.h"
int global_count = 0;

这样无论多少个.c文件中包含了config.h,都不会被重复定义

const是一个容易被忽略的坑

很多人写:

const int BUFFER_SIZE = 1024;

然后放进头文件中,觉得“这是一个常量,应该没事”,但这样往往会出问题,因为它仍然可能形成定义,而且不同编译器和语言标准细节会让行为更复杂。 更稳妥的做法是:

// 头文件
extern const int BUFFER_SIZE;
// 源文件
const int BUFFER_SIZE = 1024;

或者直接使用宏:

#define BUFFER_SIZE 1024

不过宏没有类型信息,所以二者适用场景有所不同

宏:预处理器最大也最危险的功能

头文件里除了声明,最常见的就是宏,如:

#define P 3.1415926
#define MAX(a, b) ((a) > (b) ? (a) : (b))

宏的本质仍然是预处理阶段的文本替换,这意味着它不是函数,不做类型检查,也不理解语义

宏的优点

  • 简单直接
  • 没有运行时开销
  • 可用于条件编译
  • 可用于平台适配

宏的风险

这是头文件中最容易埋雷的地方:

#define SQUARE(x) x * x

你认为SQUARE(1 + 2)是(1 + 2) * (1 + 2),实际上展开后是:1 + 2 * 1 + 2,结果完全变了。所以宏参数和整体表达式都应该加括号:

#define SQUARE(x) ((x) * (x))

宏的副作用问题

再看:

MAX(i++, j++)

展开后,条件表达式会先计算一次参数,被选中的那个参数还会再计算一次,因此可能产生副作用。所以,带副作用的表达式不要随便传递给函数式宏。这也是为什么在现代 C 中,很多场景会优先考虑:

  • 常量:const
  • 小函数:static inline
  • 类型:typedef 而不是全部靠宏硬写

头文件中宏的合理用途

比较推荐放在头文件中的宏包括:

  • 编译期开关
#define ENABLE_LOG 1
  • 平台兼容控制
#ifdef _WIN32
#define PATH_SEPARATOR '\\'
#else
#define PATH_SEPARATOR '/'
#endif
  • 简单常量
#define DEFAULT_TIMEOUT 30
  • 头文件保护宏
#ifndef MY_HEADER_H
#define MY_HEADER_H

而复杂逻辑宏需要谨慎

条件编译:头文件如何适配不同的环境?

头文件不仅是声明载体,还是平台适配的重要入口:

// example
#ifdef _WIN32
typedef __int64 my_int64;
#else
typedef long long my_int64;
#endif

这说明头文件不仅在“告诉别人接口”,也在“根据环境调整接口细节” 常见用途包括:

  • 区分操作系统
  • 区分编译器
  • 打开或关闭调试功能
  • 做 C / C++ 兼容 一个容易忽略的点:条件编译会让头文件接口随环境变化,这意味着同一个头文件,在不同平台上可能展开成不同的内容。所以遇到“某个平台能编译,另一个平台不行”时,不要只怀疑源码,头文件的条件编译常常才是问题的根源

typedef:让头文件更像接口,而不是底层细节泄漏

typedef在头文件里很常见,它的意义不是“创建新类型”,而是“为已有类型提供更稳定更易读的名字”

// example
typedef unsigned long usize;
// example
typedef struct Point{
	int x;
	int y;
} Point;

它的价值有两个:

  • 让接口更清晰
  • 降低底层实现细节对调用者的暴露程度 尤其是适合不透明结构体:
typedef struct Stack Stack;

这几乎是 C API 设计中的经典手法

前向声明:减少依赖,避免循环包含

这是写头文件时非常容易忽略,但很重要的内容。 假设两个结构体互相引用:

struct A{
	struct B b;
};
struct B{
	struct A a;
};

这样会出问题,因为编译器需要完整知道类型大小,而双方都未完成定义。 这时通常会改为指针,并使用前向声明:

struct B;
typedef struct A{
	struct B* b;
} A;
// 然后再定义
typedef struct B{
	struct A* a;
} B;

这里的思路是:

  • 如果只需要“知道有这么个类型”,用前向声明就够了
  • 如果需要知道其大小和字段,才必须 include 完整定义

这也是减少头文件依赖的关键技巧

实用原则

在头文件中:

  • 能前向声明,就尽量别include
  • 只有确实需要完整类型时,才include对应头文件 这会明显降低耦合度

static、inline与头文件实现:哪些例外是被允许的?

前面我们说头文件通常不放定义,但是这里确实有几个例外需要讲明

static函数

如果在头文件中编写:

static int add(int a, int b){
	return a + b;
}

它不会像普通函数那样在链接阶段冲突,因为static让这个函数有内部链接:

  • 每个包含该头文件的.c文件,都会得到自己私有的一份add
  • 链接器看不到跨文件的同名冲突

这在某些小工具函数中是可行的,但也要谨慎,因为:

  • 每个翻译单元都会复制一份实现
  • 可能增加代码体积
  • 不适合复杂逻辑

static inline

这是头文件里更常见也更合理的一种形式:

static inline int add(int a, int b){
	return a + b;
}

适用于:

  • 很短的小函数
  • 希望调用开销低
  • 同时不想在.c中单独实现
// example
static inline int is_even(int x){
	return (x % 2) == 0;
}

这类函数可以安全地放在头文件中。

不要随便写只有inline的函数

inline在 C 中的语义比很多人想象的复杂,不同标准和编译器细节也会影响行为。对初学者而言,最稳定的经验是:头文件中的可实现小函数,优先使用static inline。这样最不容易出链接问题。

头文件的编写步骤

  1. 先写“别人需要知道什么”
  • 提供哪些函数?
  • 暴露哪些类型?
  • 是否需要公开常量?
  1. 把接口写进.h
  2. 再去.c中实现
  3. 检查头文件是否泄漏的不必要的细节
  • 用户真的需要看到这个结构体的内部字段吗?
  • 这个宏是接口的一部分,还是实现细节?
  • 这个include是否是必须的?
  • 这个变量真的应该暴露吗?
  1. 检查头文件是否“自洽”
  • 别人单独include它,也能顺利通过编译

C 与 C++兼容:一个容易忽略但是很实用的写法

如果头文件未来会被 C++ 使用,通常会写:

#ifdef __cplusplus
extern "C" {
#endif
/* C declarations */
#ifdef __cplusplus
}
#endif

原因是 C++ 会对函数名进行 name mangling,而 C 不会 加上extern "C"后,可以告诉 C++: 按照 C 的方式导出这些符号 这对跨语言、跨编译器、跨库协作都很常见

一份相对规范的头文件示例

// example.h
#ifndef MYLIB_VECTOR_H
#define MYLIB_VECTOR_H
 
#include <stddef.h>
 
#ifdef __cplusplus
extern "C" {
#endif
 
typedef struct Vector Vector;
 
Vector* vector_create(void);
void vector_destroy(Vector* v);
 
size_t vector_size(const Vector* v);
int vector_push(Vector* v, int val);
int vector_get(const Vector* v, size_t index, int* out_value);
 
#define VECTOR_OK 0
#define VECTOR_ERR_OUT_OF_RANGE -1
#define VECTOR_ERR_NO_MEMORY -2
 
#ifdef __cplusplus
}
#endif
 
#endif

这个头文件的特点是:

  • 有 include guard
  • 自己包含了所需的stddef.h
  • 用不透明类型隐藏实现
  • 只暴露必要接口
  • 常量用宏定义表达状态码
  • 兼容 C++

本章小结与回顾

小结

用一句话总结本章节,那就是: 头文件的本质不是存放代码,而是定义边界 它告诉外部:

  • 你可以使用什么?
  • 你应该如何使用?
  • 哪些细节对你不可见?
  • 哪些名字在链接阶段被解析? 所以,写头文件时最核心的思路不是“复制实现”,而是以最小暴露原则,给出清晰稳定自洽的接口

要点回顾

本章可以压缩成几个关键结论:

  • #include的本质是文本替换,不是模块导入
  • 头文件主要放声明,不放普通定义
  • 全局变量如果需要暴露,通常在头文件中使用extern声明
  • 头文件要用 include guard 防止重复展开
  • 能向前声明,就尽量减少 include,降低耦合度
  • 头文件应该尽量自包含,不依赖调用者的 include 顺序
  • static inline 是头文件中少数适合放实现的形式
  • 头文件的职责是描述接口边界,而不是堆放实现细节

4. 指针与内存模型

引言

如果说编译和链接解释了程序是如何被“构建”出来的,那么指针和内存模型解释的就是程序运行时,数据到底“存在于哪里”,又是如何被访问的 对于很多初学者来说,指针之所以难,不是因为它的语法复杂,而是因为它要求你放弃某些高级语言带来的“抽象保护”,直接面对程序最原始的运行现实:

  • 变量不是“名字”,而是内存中的一段区域
  • 指针不是“魔法引用”,而是地址
  • 数据不是容器,而是一段连续内存
  • 结构体不是对象,而是按规则排布的字节块 所以在 C 中理解内存,不是附加技能,而是主体能力

4.1 基本观念:程序运行时,操作的是内存

简单的例子

int a = 10;
int b = a + 5;

从语法层面来看,这只是变量赋值。但从运行层面来看,它至少意味着:

  • 程序需要为 a 找到一段能够存放 int 的内存
  • 程序需要为 b 找到另一段能够存放 int 的内存
  • CPU 从 a 对应地读出值 10
  • CPU 计算 10 + 5
  • 再把结果写入 b 对应的地址 也就是说,程序运行的本质,是读写内存。C 之所以特殊,就在于它允许你非常直接地参与这个过程

4.2 理解指针

什么是指针?

一句最朴素的话就能够概括:指针就是地址

int a = 10;
int* p = &a;

这里:

  • a 是一个 int
  • &a 表示 a 的地址
  • p 是一个“指向 int 的指针”
  • p 中存放的内容,就是 a 所在的内存地址 可以理解为:
a -> 某个内存位置,里面存放着10
p -> 另一个内存位置,里面存放着a的地址

所以,指针变量本身也占用内存,而不是简单的引用关系。在 C 中,它是一个实实在在的值,只不过这个值恰好是“地址”

*和&究竟是什么意思?

& VS *

这是最容易混乱的地方之一:

  • &:取地址
int a = 10;
int* p = &a;

&a表示取出变量 a 的地址

  • *:解引用
int a = 10;
int* p = &a;
int x = *p;

这里*p的意思是:去 p 存放的那个地址上,取出那里的值。这里 p 存放的是 a 的地址,那么*p取到的就是 a 的值

区分两种*

在 C 里,*有两种常见的角色:

  1. 声明中,表示“这是一个指针”
  2. 表达式中表示“解引用” 前者是在说类型,后者是在进行内存访问

指针为什么强大?

因为指针打通了两个世界:

  • 语言中的变量
  • 机器中的地址

有了指针,就可以:

  • 间接修改外部变量
  • 操作数组和缓冲区
  • 管理动态分配的内存
  • 构建链表、树、哈希表等数据结构
  • 与操作系统、库、其他语言进行底层交互 所以很多人说:C 真正的能力,不在于语法,而在于它把地址作为一等公民暴露给了你

4.3 详解内存

内存不是一整块混沌空间

通过一个模型理解内存

为了理解 C 的内存管理,我们先建立一个常用的简化视图。一个运行中的程序,通常可以粗略地想象为包含这些区域:

  • 代码区
  • 全局 / 静态区
  • 栈(stack)
  • 堆(heap) 这不是全部细节,但是足够形成一个有用的认知框架

栈:函数调用时自动管理的内存

栈

先看一个例子:

void foo(){
	int x = 10;
	int y = 20;
}

这里的 x 和 y 通常会分配在栈上 栈的特点是:

  • 随函数调用进入而分配
  • 随函数返回自动释放
  • 分配和回收速度更快
  • 生命周期严格受作用域和调用栈的控制 可以理解为:栈适合存放短命、局部、结构清晰的数据

栈为什么高效?

因为它通常只需要移动一个“栈顶指针” 函数进入时,栈顶向下或向上移动一段空间 函数退出时,直接恢复栈顶 不需要像堆那样维护复杂的分配表,所以局部变量天然高效

栈变量的最大限制:不能越过生命周期

// example
int* bad(){
	int x = 10;
	return &x;
}

这段代码非常危险: 因为 x 是栈变量,函数返回后它的生命周期已经结束,对应的栈空间也不再属于它。此时返回它的地址,相当于返回了一个悬空指针。后续再访问这个地址,就是未定义行为。 这是 C 中最经典的错误之一,因此用户必须牢记:不要返回普通局部变量的内存地址。static 局部变量虽然生命周期足够长,但它会引入共享状态,仍然需要谨慎使用

堆:程序员显式管理的动态内存

堆

再看:

int* p = malloc(sizeof *p);
if(p != NULL){
	*p = 10;
	free(p);
}

这里使用了malloc,意味着这块内存来自堆 堆的特点是:

  • 生命周期不受函数作用域的限制
  • 分配和释放由程序员手动控制
  • 适合大小在运行时才确定的数据
  • 适合需要跨函数,跨模块长期存在的数据

堆和栈的核心区别

最本质的区别不在于“位置”,而是谁负责管理生命周期:

  • 栈:编译器 / 调用约定自动管理
  • 堆:程序员手动管理 所以一旦使用堆,就意味着你接管了生命周期的责任

全局区与静态存储区

除了堆栈,还有一类常见内存:全局变量和静态变量

// example
int global_count = 0;
void foo(){
	static int once = 0;
}

它们的共同点是:

  • 生命周期贯穿整个程序运行时
  • 不会随着函数调用结束而释放

区别是:

  • global_count在全局作用域可见
  • once的作用域仅在于函数内部,但生命周期同样贯穿整个程序 所以“作用域”和“生命周期”是两件不同的事,这是一个必须理清的点

4.4 重新认识数组、结构体

数组与指针

数组与指针像,但不完全一样

// example
int arr[3] = {1,2,3};
int* p = arr;

很多人因此得出结论:“数组就是指针” 这并不准确。更准确的讲,应该是:数组在很多表达式中会退化为指向首元素的指针 这里:

  • arr 本身是一个数组对象,类型是int[3]
  • 在表达式中,它常常转换为int*,指向arr[0] 所以p = arr成立,但他们不是一个东西。
sizeof(arr) // 得到的是整个数组的大小,也就是3 * sizeof(int)
sizeof(p) // 得到的是一个指针的大小

这说明数组和指针只是在某些场景下表现相似,而不是本体相同

指针运算:为什么 p + 1 不是简单地“加一”?

看例子:

int arr[3] = {1,2,3};
int* p = arr;

如果执行p + 1,它不是地址值单纯地加1个字节,而是向后移动一个 int 的大小。也就是地址 + sizeof(int)。这说明指针运算本质上是类型相关地址的运算。 所以,这也是为什么指针必须带类型信息。没有类型,地址移动就没有意义

char*的特殊地位:按字节观察内存

在 C 中,char*很特殊,因为它常被用来按字节访问内存 例如:

int x = 0x12345678;
unsigned char* p = (unsigned char*)&x;

此时可以逐字节查看 x 的内存布局,这类操作在:

  • 序列化
  • 网络协议
  • 底层调试
  • 二进制文件处理 中都很常见 但也正因为它足够底层,所以要格外小心平台差异,例如:
  • 大端 / 小端
  • 对齐要求
  • 类型别名规则 这里先建立直觉,后面讲结构体布局时会更加清晰

结构体

结构体就是按规则排列的一段内存

看一个结构体:

struct Point{
	int x;
	int y;
};

它不是“对象系统”中的对象,而是一段连续的内存,其中前一部分解释为 x,后一部分解释为 y 例如:

struct Point p = {1,2};

这意味着程序分配了一块足以容纳两个 int 的内存,并按照约定布局。所以访问p.x、p.y本质上是按照偏移量去访问内存中的不同区域

对齐与填充:结构体大小为什么常常不是字段大小之和?

// example
struct A{
	char a;
	int b;
};

很多人的直觉会觉得它的大小会是1 + 4 = 5,但在很多平台上,实际可能是 8。原因是对齐和填充

为什么需要对齐?

因为很多处理器访问某些类型时,更希望它们出现在“合适边界”上 例如,一个 int 往往更适合放在 4 字节对齐的位置,于是布局可能是:

a:1字节
padding:3字节
b:4字节

总共 8 字节 为什么这很重要? 因为这直接影响:

  • sizeof(struct A)
  • 二进制数据格式
  • 跨语言结构体映射
  • 网络协议、文件协议解析 所以不能想当然地把结构体当成字段的简单拼接。 更准确的说法是:结构体是按照 ABI 和对齐规则布局的一段内存

指针与结构体: .和->的本质

例如:

struct Point p = {1,2};
struct Point* pp = &p;

访问字段时:

p.x
pp -> x

这两者只是形式不同:

  • 前者直接访问对象字段
  • 后者先解引用指针,再访问字段 也就是说,pp -> x等价于(*pp).x 这里的括号不能省略,因为.(成员运算符)的优先级高于*

4.5 动态内存分配

为什么需要 malloc?

有些数据大小在编译时并不知道,只能在运行时确定。 例如:

int n;
scanf("%d", &n);
int* arr = malloc((size_t)n * sizeof *arr);

如果你想根据输入动态分配一段数组,就需要用到堆内存。 使用 malloc 需要包含 <stdlib.h>。它的作用是:在堆上申请一段指定大小的未初始化的内存,并返回起始地址。如果分配失败,就会返回 NULL。 所以一个更稳妥的方法是:

if(n > 0){
	int* arr = malloc((size_t)n * sizeof *arr);
	if(arr == NULL){
		// 处理分配失败
	}
}

真实工程中还应该检查 scanf 是否成功、n 是否为正,以及 n * sizeof *arr 是否可能溢出。

malloc / calloc / realloc / free

这些是 C 中最基本的内存管理工具

  • malloc(size):申请 size 字节内存,不初始化
  • calloc(count,size):申请 count * size 字节内存,并把所有字节置为 0
  • realloc(ptr, new_size):调整已有内存块大小,可能搬迁内存位置
  • free(ptr):释放之前分配的内存,free(NULL) 是安全的空操作 一个非常重要的点:free 之后指针不会自动成为空指针,例如:
int* p = malloc(sizeof *p);
free(p);

此时 p 仍然保存着原来的地址,只不过那块内存已经不属于你了。 这时 p 变成了悬空指针,因此更安全的习惯是手动赋值为空:

free(p);
p = NULL;

4.6 典型内存错误/误区

概述

如果说高级语言常见的错误是“逻辑错”,那么 C 常见的错误往往在于“内存错” 这类错误往往更危险,因为它们可能:

  • 立即崩溃
  • 偶尔崩溃
  • 长时间潜伏
  • 在完全无关的位置表现出来 下面我们介绍几类常见的内存错误

野指针、空指针和悬空指针

野指针

野指针通常指未初始化或指向无效区域的指针 例如:

int* p;
*p = 10;

这里 p 根本没有被初始化,里面是随机值,这时对它解引用就是灾难

空指针

空指针是明确不指向任何有效对象的指针,通常用 NULL 表示:

int* p = NULL;
*p = 10;

对空指针解引用同样是未定义行为。空指针的价值在于它可以作为“没有对象”的明确状态,但在访问前必须检查。

悬空指针

例如:

int* p = malloc(sizeof *p);
free(p);
*p = 10;

或者:

int* bad(){
	int x = 10;
	return &x;
}

前者是释放后继续访问,后者是访问局部变量的地址。二者本质一样:指针指向的对象都已失效

内存泄漏

内存泄漏

例如:

int* p = malloc(sizeof *p);
p = malloc(sizeof *p);

第一次分配的地址丢失了,就再也没法free,这就导致了内存泄漏 内存泄漏不一定马上崩溃,但是会不断地吞噬资源

重复释放

重复释放

例如:

int* p = malloc(sizeof *p);
free(p);
free(p);

这会造成严重错误,因为同一块内存只能释放一次

越界访问

越界访问

例如:

int arr[3] = {1,2,3};
arr[5] = 10;

或者:

int* p = malloc(3 * sizeof *p);
p[3] = 10;

这类错误非常常见而且很难排查,因为越界不一定会立刻触发崩溃 C 不会自动检查边界,责任完全在于程序员

4.7 内存管理策略

内存管理不是“会用 malloc / free就够了”

很多人把内存理解成:

  • 需要的时候malloc
  • 用完之后free

这只是最表层。 真正成熟的内存管理能力关键在于:用户是否能够清晰地定义“谁拥有这块内容,谁负责释放它” 这就是“所有权”的问题。虽然 C 本身没有官方的 ownership 机制,但是工程实践中必须有 ownership 思维。

一个核心策略:谁申请、谁释放、或者明确转移所有权

来看一个常见错误的源头:

情况A:函数内部申请,调用者释放

char* make_message(void){
	char* s = malloc(128);
	if(s != NULL){
		// 写入内容
	}
	return s;
}

这时接口语义应该明确:

  • 返回值由调用者拥有
  • 调用者必须负责free 否则就极其容易泄漏

情况B:调用者提供缓冲区

void make_message(char* buffer, size_t size);

这时内存由调用者管理,函数只负责写入 这个设计通常更清晰,特别适合 C API

情况C:模块内部拥有,外部只借用

例如某个对象内部维护字符串:

  • 外部拿到的是只读指针
  • 不应该释放
// example
const char* user_name(const User* u);

返回的是借用视图,而不是所有权转移

关键结论

在 C 中,内存管理策略本质上是接口设计问题 你必须通过函数命名、注释、文档甚至参数形式,明确表达所有权,否则调用者几乎必踩坑

面向工程的几条内存管理原则

下面的几个原则非常适合于面向实践的场景

原则1:初始化所有指针

不要让指针处于未定义状态

int* p = NULL;

这是最基本的防御习惯

原则2:谁分配谁负责,如有例外,必须显式说明

不要让释放责任模糊不清

原则3:释放后立即置空

虽然不是万能的,但是能减少一类错误

// example
free(p);
p = NULL;

原则4:避免隐藏分配

如果一个函数偷偷分配了大量内存,但接口完全看不出来,使用者很难正确管理资源

原则5:优先使用栈,必要时才使用堆

栈更简单、更安全、成本更低 只有在这些情况下才会优先考虑堆:

  • 需要跨作用域存活
  • 大小随运行时决定
  • 数据体积较大
  • 需要构建复杂的动态结构

原则6:让资源释放路径尽量单一

复杂函数中容易出现多个return,导致一部分路径忘记释放。 可以采用“统一清理出口”的方式减少错误:

int func(){
	char* buf = NULL;
	FILE* fp = NULL;
	int result = -1;
	
	buf = malloc(1024);
	if(buf == NULL) goto cleanup;
	
	fp = fopen("data.txt", "r");
	if(fp == NULL) goto cleanup;
	
	result = 0;
	
cleanup:
	if(fp != NULL) fclose(fp);
	if(buf != NULL) free(buf);
	return result;
}

这种写法虽然不“优雅”,但在 C 中很实用

指针参数:为什么很多函数需要传二级指针?

例如

void set_value(int* p){
	*p = 42;
}

这是通过一级指针修改外部变量。

但是如果你想在函数里“修改调用者手里的指针本身”,那就需要二级指针

// example
void alloc_buffer(char** out){
	*out = malloc(128);
}

调用方式:

char* buf = NULL;
alloc_buffer(&buf);

这里:

  • buf是一个指针变量
  • &buf是指向这个指针变量的地址
  • 函数内部通过*out修改buf本身 这在工厂函数、输出参数、链表操作中非常常见

const 与内存访问约束:谁可改谁不可改

内存管理不只是“活多久”,还包括“谁可以修改”

例如:

void print_message(const char* msg);

这里的const char*表示:

  • 函数可以读取 msg 指向的内容
  • 但不应该修改它

这有两个价值:

  • 给调用者清晰的承诺
  • 让编译器帮助检查误修改

注意区分:

const char* p1;
char* const p2;
  • const char* p1;:指向的内容不可改
  • char* const p2;:指针本身不可改 前者更常见,也更适合接口设计

C 语言中“对象”的本质

C 语言中的“对象”本质上是一段生命周期受控的内存

这是一个非常重要的抽象。 在很多面向对象的语言里,我们习惯把对象理解为:

  • 有字段(属性)
  • 有方法
  • 有封装

而在 C 中,一个“对象”更本质的理解是:一段被赋予了类型解释、作用域和生命周期的内存区域 例如:

struct User user;

它本质上不是“类实例”,而是一块按照struct User规则解释的内存。 所以我们说“管理对象的生命周期”时,在 C 中实际上是在说:

  • 这块内存什么时候被创建
  • 谁能访问它
  • 什么时候销毁
  • 销毁后是否还有悬空指针 这会帮助读者把“面向对象直觉”重新映射到底层模型上

一个常被忽略的问题:不是所有内存错误都会立刻暴露

这恰恰是 C 最让人头疼的地方

例如:

  • 你越界写了一个字节,程序可能暂时正常
  • 你释放后访问,可能只有某些机器崩
  • 你忘了free,可能只在长时间运行后出问题 所以 C 的内存问题往往不是“立即报错”,而是:未定义行为可能在任一时刻,以任意形式表现出来。这也是为什么“我试了,没问题”在 C 里没有太大说服力的原因

内存管理策略,最终会影响跨语言的互操作

概述

虽然本章不展开讲具体的跨语言调用,但是必须指出这一点: 当跨语言调用时,内存问题会变得更加敏感。 因为这时你不仅要考虑

  • 这块内存什么时候释放

还要考虑:

  • 是 C 侧释放,还是外部语言释放
  • 生命周期如何跨语言边界保持一致
  • 返回的指针是否仍然有效
  • 结构体布局是否一致 所以一旦 C 侧内存模型没有理清,跨语言调用几乎一定会出问题。也正因如此,C 往往不是“难在语法”,而是“难在你必须对对象的生死负责”

4.8 本章小结

C 的能力,本质上来自对内存的直接控制

这一章的内容用一句话来总结:C 的强大,不在于它能做很多高级事情,而在于它几乎不替你隐藏内存。 你看到的每一个变量、每一个数组、每一个结构体、每一个指针,本质上都在对应着某种内存状态:

  • 它在哪块区域?
  • 它由谁创建?
  • 它什么时候失效?
  • 它是否还能被访问?
  • 它的字节布局是什么? 而所谓“会用 C”,在很大程度上就是能够持续、清醒地回答这些问题

5. ABI与函数调用

前言

在前面的章节中,我们已经知道:

  • C 程序会经过预处理、编译、汇编和链接,最终变成可执行代码
  • 变量、数组、结构体和指针,本质上都是内存中的一段布局
  • 程序运行时的核心行为,就是按照这些规则读写这些内存 但是仅有“代码”和“内存”还不够 因为一个程序并不总是只由一个文件、一个编译器、一个语言环境构成。现实中的系统几乎总是由多个边界拼接而成:
  • 一个模块调用另一个模块
  • 一个动态库被主程序加载
  • 一门语言去调用 C 库
  • 编译器 A 生成的目标文件被编译器 B 生成的代码使用 这时就会出现一个关键问题:双方凭什么相信彼此对“函数调用”和“数据解释”的理解是一致的? 答案就是:ABI

5.1 ABI 到底是什么

ABI

ABI 是 Application Binary Interface,通常译为“应用程序二进制接口” 如果只看字面,我们会觉得它很抽象,实际上可以把它理解成:ABI 是二进制层面的合作协议 它规定的不是“源代码应该怎么写”,而是:编译后的代码,在机器层面必须如何相互配合 也就是说,ABI 关心的不是你写了什么样的语法,而是编译完成之后:

  • 函数参数放在哪里
  • 返回值放在哪里
  • 栈如何维护
  • 结构体如何对齐
  • 符号名字如何导出
  • 不同模块能否识别彼此的数据和函数

所以 ABI 是“运行和链接层面的约定”,而不是“语言语法层面的约定”

从更工程化的角度看,ABI 回答的是这样一组问题:

  • 调用者如何把参数交给被调用者?
  • 被调用者如何把结果交回去?
  • 哪些寄存器可以随便改,哪些寄存器必须恢复?
  • 栈在函数调用前后应该保持什么状态?
  • 一个结构体在内存中每个字段的偏移是多少?
  • 一个函数在目标文件或动态库中的符号名是什么?
  • 动态库升级后,旧程序还能不能直接运行?

因此,ABI 不是某一个头文件,也不是某一个函数声明,而是编译器、链接器、操作系统、CPU 架构共同遵守的一套二进制规则。

一个很重要的前提

C 语言标准本身并没有规定完整 ABI。 不同平台会有不同 ABI,例如:

  • Linux x86-64 常见的是 System V AMD64 ABI
  • Windows x64 有自己的调用约定
  • ARM64 / AArch64 又有对应的平台 ABI

所以我们常说“C ABI 稳定”,不是说全世界只有一种 C ABI,而是说:在同一平台和工具链约定下,C 的二进制接口相对简单、清晰、容易被其他语言实现。

5.2 API vs ABI

概念面向对象层级
API程序员源码层
ABI编译器 / 机器二进制层

示例:

int add(int a, int b);

这行声明对于程序员来说是 API:

  • 函数名是 add
  • 接收两个 int
  • 返回一个 int

但是对于 ABI 来说,它关心的是另一套问题:

  • 第一个参数放在哪个寄存器或栈位置?
  • 第二个参数放在哪个寄存器或栈位置?
  • 返回值放在哪个寄存器?
  • 符号表里这个函数叫什么?
  • 调用完成后栈由谁恢复?

API 可以理解为“源码层面的使用说明”,ABI 则是“二进制层面的执行说明”

这也是为什么有些改动看起来只是改了一点点源码,但实际上可能破坏 ABI。

API 还在,但 ABI 已经变了

假设一个动态库原来暴露:

int add(int a, int b);

后来改成:

long add(long a, long b);

如果所有调用方都重新编译,那么它们会按照新的头文件生成新的调用代码。 但如果某个旧程序没有重新编译,只是替换了动态库,那么它仍然会按照旧 ABI 去传参和取返回值。 这就可能导致结果错误,甚至直接崩溃。

关键结论

API 兼容不等于 ABI 兼容。 源码能重新编译通过,只能说明 API 可能还能用;旧二进制文件不重新编译还能运行,才说明 ABI 没有被破坏。

常见的 ABI 破坏包括:

  • 修改公开函数的参数类型或返回值类型
  • 修改公开结构体字段顺序
  • 在公开结构体中间插入字段
  • 改变编译选项导致结构体对齐规则变化
  • 改变符号名或隐藏了原本导出的符号
  • 改变内存分配和释放约定
  • C++ 接口没有使用 extern "C",导致符号名变化

5.3 为什么 C 是跨语言接口标准

C 的特殊位置

在跨语言开发中,C 经常不是业务语言,而是“接口层语言”。 很多语言的 FFI(Foreign Function Interface)最终都会落到 C ABI 上,例如:

  • Rust 调用 C:extern "C"
  • Go 调用 C:cgo
  • Python 调用 C:CPython Extension / ctypes / cffi
  • Kotlin/Native 调用 C:cinterop
  • Swift 调用 C:Clang Importer

C 之所以适合作为跨语言接口标准,主要有几个原因。

第一,C 的函数模型足够简单:

int add(int a, int b);

它没有默认的对象模型,没有方法表,没有泛型实例化,也没有语言级异常机制。一个函数就是一个符号,一个参数列表,一个返回值。

第二,C 的运行时依赖很少。

很多高级语言都有自己的运行时,例如:

  • Java 有 JVM
  • Python 有解释器和对象系统
  • Go 有 goroutine 调度器和 GC
  • Kotlin/JVM 依赖 JVM,Kotlin/Native 也有自己的运行时约定

但是 C 函数在二进制层面更接近“普通机器代码”。只要调用约定一致,其他语言就可以按照 C ABI 组织参数并跳转执行。

第三,C 的数据结构比较容易描述。

typedef struct {
	int x;
	int y;
} Point;

只要结构体布局、字段类型、对齐规则一致,其他语言就可以构造一块相同布局的内存,再交给 C 函数使用。

关键理解

C 成为跨语言接口标准,并不是因为 C 最强大,而是因为 C 足够朴素。 它暴露的概念更接近机器层:

  • 函数
  • 指针
  • 数值
  • 结构体
  • 连续内存

这些概念几乎所有语言都能模拟或对接。

5.4 函数调用的本质

从一行代码开始

int x = add(3, 4);

从源码层面看,这只是一次函数调用。但在机器层面,它大致会经历以下过程:

  1. 调用者按照 ABI 规则准备参数
  2. CPU 跳转到 add 函数对应的地址
  3. 被调用函数建立自己的临时运行环境
  4. 函数体执行计算
  5. 返回值被放到 ABI 规定的位置
  6. CPU 跳回调用点继续执行
  7. 调用者从约定位置读取返回值

也就是说,函数调用不是“语法跳转”,而是一套精确的二进制协作流程。

可以用一个非常简化的模型理解:

调用者 caller
  |
  | 准备参数
  | 跳转到函数地址
  v
被调用者 callee
  |
  | 建立栈帧
  | 执行逻辑
  | 放置返回值
  v
调用者继续执行

这也是跨语言调用容易出问题的根源

如果调用者以为参数在 A 位置,而被调用者从 B 位置读取参数,那么语法再正确也没有意义。 这类问题在源码层面不一定能看出来,因为它本质上是 ABI 层面的不一致。

举个更直观的例子:

// lib.c
int add(int a, int b){
	return a + b;
}

调用方必须知道:

  • add 这个符号在库里叫什么
  • 传入的两个 int 应该如何放置
  • 返回的 int 应该从哪里读取

如果其中任何一个规则不一致,结果都可能是错误的。

5.5 调用约定(Calling Convention)

调用约定是什么

调用约定是 ABI 中非常核心的一部分,它规定一次函数调用中,调用者和被调用者分别要承担什么责任。 它通常包括:

  • 参数传递方式
  • 返回值传递方式
  • 栈由谁清理
  • 哪些寄存器由调用者保存
  • 哪些寄存器由被调用者保存
  • 栈对齐要求

例如,同样是:

int x = add(3, 4);

在某些 ABI 中,前几个整数参数会通过寄存器传递;在另一些 ABI 中,参数可能更多地依赖栈。即使都是 x86-64,不同平台的寄存器分配也可能不同。

只看概念,不要求死记

在 Linux x86-64 的常见 ABI 中,前几个整数或指针参数通常会优先放入寄存器。 在 Windows x64 中,前几个参数也会优先放入寄存器,但使用的寄存器集合和细节并不完全相同。

所以跨平台时不能凭感觉假设“参数一定在某个地方”,而应该交给编译器和正确的函数声明来处理。

调用约定不一致的后果通常很严重:

  • 函数读到错误参数
  • 返回值解释错误
  • 栈被错误恢复
  • 后续函数调用连锁出错
  • 程序在看似无关的位置崩溃

跨语言场景中的典型错误

C 端声明是:

int add(int a, int b);

其他语言绑定时却写成:

add(long, long) -> long

这不是简单的“类型写宽一点更安全”,而是可能改变参数和返回值解释方式。 在 FFI 中,类型必须严格对应,而不是大致相似。

5.6 参数传递

C 中参数传递的基本规则

C 的函数参数默认都是“值传递”。 也就是说,函数拿到的是实参值的一份拷贝。

例如:

void change(int x){
	x = 20;
}
 
int main(){
	int a = 10;
	change(a);
	// a 仍然是 10
}

这里 change 修改的是参数副本,不是 main 中的 a。

如果想让函数修改外部变量,就必须传递地址:

void change(int* p){
	*p = 20;
}
 
int main(){
	int a = 10;
	change(&a);
	// a 变成 20
}

注意这里仍然是值传递。只不过传递的值是一个地址。函数拿到的是调用者传入地址的一份拷贝,所以它可以通过这份地址访问同一块内存,但不能直接改掉调用者手里的那个指针变量。

指针参数的本质

传指针不是“引用传递”,而是“把地址作为值传进去”。 这就是为什么:

  • int* 可以让函数修改一个 int
  • char* 可以让函数修改一段字符缓冲区
  • T** 可以让函数修改一个指针变量本身

数组参数也是一个常见误区。

void print_array(int arr[]){
	// 这里的 arr 实际上是 int*
}

在函数参数中,int arr[] 会退化为 int* arr。这意味着函数无法仅通过 arr 知道数组长度,所以长度必须单独传入:

void print_array(const int* arr, size_t len){
	for(size_t i = 0; i < len; i++){
		printf("%d\n", arr[i]);
	}
}

参数设计的基本原则

  • 输入数据使用 const T*
  • 输出数据使用 T* 或 T**
  • 缓冲区必须同时传递指针和长度
  • 不要让调用方猜测数组长度
  • 跨语言接口中尽量避免可变参数函数

可变参数函数例如:

printf("%d %s\n", 10, "hello");

这类函数依赖额外的 ABI 规则和格式字符串约定,在跨语言调用中更容易出错。设计 FFI 接口时,通常不建议把可变参数函数作为公开边界。

5.7 返回值

返回值也是 ABI 的一部分

函数返回值并不是“自动回到调用者手里”,而是被放在 ABI 规定的位置。 常见规则可以粗略理解为:

  • 小整数、指针、浮点数通常通过寄存器返回
  • 较大的结构体可能通过调用者提供的隐藏内存返回
  • 有些复杂返回值会被 ABI 拆分或特殊处理

例如:

int add(int a, int b){
	return a + b;
}

返回的 int 通常可以直接放入寄存器。

但是:

typedef struct {
	char data[1024];
} Big;
 
Big make_big(void){
	Big b = {0};
	return b;
}

这么大的结构体不太可能简单地塞进一个寄存器。具体怎么返回,就要看平台 ABI 的规定。很多情况下,调用者会提供一块空间,被调用者把结果写进去。

不要返回已经失效的地址

int* bad(void){
	int x = 10;
	return &x;
}

这里返回的是局部变量地址。函数返回后,x 的生命周期结束,这个地址不再属于有效对象。 这不是“偶尔不安全”,而是未定义行为。

工程中常见的返回方式有三类:

直接返回值

适合小对象、状态码、简单结果:

int add(int a, int b);

通过输出参数返回

适合需要返回多个值或大对象:

int parse_int(const char* text, int* out_value);

这里返回值可以表示成功或失败,真正的数据通过 out_value 写出。

返回堆对象指针

适合生命周期需要跨越函数调用的对象:

Buffer* buffer_create(size_t capacity);
void buffer_destroy(Buffer* buffer);

这种方式必须明确:谁负责释放。

跨语言接口中更推荐“状态码 + 输出参数”

因为它能清楚表达:

  • 函数是否成功
  • 输出数据写到哪里
  • 内存由谁提供
  • 调用方如何处理失败

5.8 栈帧(Stack Frame)

什么是栈帧

每次函数调用,通常都会在栈上形成一块临时区域,用来保存这次调用所需的信息。 这块区域可以粗略理解为栈帧。

栈帧中可能包含:

  • 返回地址
  • 局部变量
  • 临时值
  • 保存的寄存器
  • 参数溢出到栈上的部分
  • 栈对齐产生的空间

例如:

void foo(int a){
	int x = a + 1;
	int y = x * 2;
}

x、y 这类局部变量可能会位于栈帧中。但现代编译器会优化,变量也可能直接放在寄存器里,甚至整个函数被内联后根本没有独立栈帧。

不要把栈帧模型理解成绝对布局

“函数调用有栈帧”是一个有用的理解模型,但具体每个变量在哪个地址、栈帧长什么样,并不是 C 语言标准保证的。 编译器优化、目标平台、调试模式都会改变实际结果。

栈帧最重要的意义在于理解生命周期。

char* make_message(void){
	char buf[64] = "hello";
	return buf;
}

这里 buf 属于当前函数调用的栈帧。函数返回后,栈帧被回收,buf 也就失效。因此返回 buf 是错误的。

正确的方式通常是让调用者提供缓冲区:

int make_message(char* buffer, size_t capacity){
	if(buffer == NULL || capacity < 6){
		return -1;
	}
	snprintf(buffer, capacity, "hello");
	return 0;
}

或者明确从堆上分配,并提供释放函数:

char* make_message(void);
void free_message(char* message);

5.9 符号与名称修饰

链接器看到的是符号

源码里的函数名、变量名,在目标文件和动态库中会以“符号”的形式存在。 链接阶段就是把未定义符号和已定义符号匹配起来。

例如:

// math.c
int add(int a, int b){
	return a + b;
}

编译后可以使用:

nm math.o

可能会看到类似:

0000000000000000 T add

这里的 add 就是导出的函数符号。

C 的符号通常比较直接,但 C++ 不一样。C++ 支持函数重载、命名空间、类成员函数等特性,所以编译器必须把更多类型信息编码到符号名中,这就是名称修饰(name mangling)。

int add(int a, int b);
double add(double a, double b);

这两个函数在源码中都叫 add,但二进制符号不能完全一样,否则链接器无法区分。因此 C++ 编译器会生成经过修饰的符号名。

如果希望 C++ 提供一个能被 C 或其他语言稳定调用的函数,就需要:

extern "C" {
	int add(int a, int b);
}

extern "C" 的作用

它不是把 C++ 代码变成 C 代码,也不是关闭 C++ 编译器。 它主要是在告诉编译器:这部分接口使用 C 语言链接规则,符号名按照 C 的方式导出。

在头文件中通常这样写:

#ifdef __cplusplus
extern "C" {
#endif
 
int add(int a, int b);
 
#ifdef __cplusplus
}
#endif

这样同一份头文件既能被 C 编译器使用,也能被 C++ 编译器使用。

还有一个和符号相关的重要概念:可见性。

static int helper(void){
	return 1;
}

文件作用域的 static 函数具有内部链接,只在当前编译单元可见,不会作为外部符号暴露出去。

符号层面的几个原则

  • 公开函数必须有稳定的符号名
  • 内部函数尽量使用 static 限制可见性
  • C++ 对外暴露 C 接口时使用 extern "C"
  • 动态库只导出真正需要公开的符号
  • 链接错误时优先用 nm 检查符号是否存在

5.10 结构体与 ABI

结构体不是字段列表,而是二进制布局

当结构体跨越模块或语言边界时,它就不只是“方便组织数据的语法”,而是一份二进制协议。

例如:

typedef struct {
	char tag;
	int value;
} Item;

很多人会以为它的大小是:

1 + 4 = 5

但实际通常不是。因为 int 往往需要按 4 字节对齐,所以 tag 后面可能会插入填充字节:

offset 0: tag
offset 1: padding
offset 2: padding
offset 3: padding
offset 4: value

因此 sizeof(Item) 常见结果可能是 8。

结构体 ABI 主要受这些因素影响:

  • 字段顺序
  • 字段类型大小
  • 对齐规则
  • padding
  • 编译器选项
  • 目标平台
  • 是否使用特殊 packing 属性

不要轻易使用 packing 解决问题

#pragma pack 或 __attribute__((packed)) 可以改变结构体对齐,但它不是万能修复手段。 过度 packing 可能导致:

  • 未对齐访问
  • 性能下降
  • 某些平台上直接触发异常
  • 与默认 ABI 约定不一致

跨语言接口中设计结构体时,应尽量遵守以下原则:

  • 使用固定宽度整数类型,例如 int32_t、uint64_t
  • 避免使用平台大小不稳定的类型暴露 ABI,例如直接暴露 long
  • 不在公开结构体中暴露内部指针,除非清楚说明所有权
  • 不把 C++ 类、STL 容器、引用类型放进 C ABI
  • 不直接把结构体二进制内容当成跨平台文件格式

更稳妥的公开结构体

#include <stdint.h>
#include <stddef.h>
 
typedef struct {
	uint32_t version;
	uint32_t size;
	int32_t x;
	int32_t y;
} PointOptions;

version 和 size 可以帮助库在未来扩展时判断调用方传入的是哪个版本的结构体。

更推荐的方式是隐藏结构体内部布局,只暴露不透明指针:

typedef struct Database Database;
 
Database* database_open(const char* path);
void database_close(Database* db);

调用方知道有一个 Database 类型,但不知道它内部字段。这让库作者可以修改内部结构,而不破坏外部 ABI。

5.11 跨语言接口设计原则

跨语言接口不是把所有 C 函数暴露出去

真正稳定的 FFI 接口需要刻意设计。 目标不是“能被调用”,而是“长期可维护、可诊断、可升级”

一个比较可靠的 C 接口通常长这样:

#ifndef MYLIB_H
#define MYLIB_H
 
#include <stddef.h>
#include <stdint.h>
 
#ifdef __cplusplus
extern "C" {
#endif
 
typedef struct MyContext MyContext;
 
typedef enum {
	MY_OK = 0,
	MY_ERR_INVALID_ARGUMENT = 1,
	MY_ERR_OUT_OF_MEMORY = 2,
	MY_ERR_INTERNAL = 3
} MyStatus;
 
MyStatus my_context_create(MyContext** out_ctx);
void my_context_destroy(MyContext* ctx);
 
MyStatus my_process(
	MyContext* ctx,
	const uint8_t* input,
	size_t input_len,
	uint8_t* output,
	size_t output_capacity,
	size_t* output_len
);
 
const char* my_status_message(MyStatus status);
 
#ifdef __cplusplus
}
#endif
 
#endif

这份接口体现了几个重要原则:

  • MyContext 是不透明类型,内部布局不暴露
  • 创建函数通过 MyContext** 输出对象
  • 销毁函数与创建函数成对出现
  • 输入缓冲区使用 const uint8_t* + size_t
  • 输出缓冲区由调用者提供,并明确容量
  • 函数返回状态码,而不是让错误隐藏在崩溃里
  • 字符串消息由库内部管理,调用者只读不释放

跨语言接口设计清单

  • 使用 C ABI,而不是直接暴露 C++ ABI
  • 使用基础数值类型、指针和简单结构体
  • 明确每个指针能不能为 NULL
  • 明确每块内存由谁申请、谁释放
  • 缓冲区永远同时传递地址和长度
  • 避免可变参数函数
  • 避免让异常跨越 C ABI 边界
  • 回调函数最好带 void* userdata
  • 动态库边界两侧不要混用不同分配器释放内存

回调接口也要注意上下文:

typedef void (*LogCallback)(int level, const char* message, void* userdata);
 
void my_set_logger(LogCallback callback, void* userdata);

userdata 的作用是让调用方把自己的上下文传回来。否则回调函数很容易依赖全局变量,导致多实例、多线程场景下难以维护。

5.12 小结

本章总结

ABI 是理解 C 跨语言能力的核心。 如果说头文件解决的是“编译器如何理解声明”,链接解决的是“符号如何拼接”,那么 ABI 解决的就是“二进制代码如何彼此协作”

本章需要记住的重点:

  • API 面向源码,ABI 面向二进制
  • 函数调用本质上是调用约定
  • 参数、返回值、栈、寄存器都受 ABI 约束
  • 结构体一旦跨边界,就是二进制布局协议
  • C 适合作为跨语言接口,是因为它足够简单、稳定、贴近机器
  • 好的跨语言接口必须明确生命周期、所有权和错误处理

6. 常见坑与总结

引言

前面几章分别讲了编译链接、头文件、内存模型和 ABI。 这些内容看似分散,但在排查 C 程序问题时,它们会合在一起。 很多 C 错误之所以难,不是因为错误信息看不懂,而是因为我们把错误放错了层级。

例如:

  • undefined reference 不是头文件问题,而是链接问题
  • segmentation fault 不是编译问题,而是运行时内存访问问题
  • 结构体字段读错不一定是指针问题,也可能是 ABI 布局问题
  • 跨语言调用崩溃不一定是库坏了,也可能是调用约定不一致

所以本章的重点不是罗列错误,而是建立一套排错方法论。

6.1 常见误解

错误认知一: #include 等于导入模块

#include 只是文本展开。 它只能让编译器看到声明,不能保证实现已经参与链接。

#include "math_utils.h"
 
int main(){
	return add(1, 2);
}

如果只编译 main.c,没有把 math_utils.c 或对应库链接进来,仍然会出现链接错误。

错误认知二:指针等于高级语言引用

指针只是地址值。 它不会自动保证对象有效,也不会自动延长生命周期。

int* p = malloc(sizeof *p);
free(p);
*p = 10;   // 错误:p 指向的内存已经释放

错误认知三:声明等于定义

声明只是告诉编译器“有这个东西”,定义才是真正创建函数或对象。

extern int count;   // 声明
int count = 0;      // 定义

错误认知四:能编译就说明程序正确

C 编译器只能检查一部分问题。 很多错误只有在链接、加载、运行时才会出现,甚至可能在运行很久之后才暴露。

6.2 错误分层模型

先判断错误发生在哪一层

排查 C 问题时,不要第一时间改代码,而是先判断它属于哪个阶段。

层级常见问题典型表现
预处理头文件找不到、宏展开错误、条件编译错误No such file or directory、宏替换异常
编译语法错误、类型错误、声明不可见expected ...、unknown type name
链接符号找不到、重复定义、库顺序错误undefined reference、multiple definition
加载动态库找不到、版本不匹配cannot open shared object file、Library not loaded
运行越界、空指针、释放后使用segmentation fault、数据错乱
ABI调用约定不一致、结构体布局不一致跨语言崩溃、参数错位、返回值异常

这个分层非常重要,因为不同层级的解决方向完全不同。

例如:

  • 编译错误通常看声明、头文件和类型
  • 链接错误通常看目标文件、库和符号
  • 运行崩溃通常看内存生命周期和越界访问
  • ABI 错误通常看函数签名、结构体布局和调用约定

排错第一步

先问:这是预处理问题、编译问题、链接问题、加载问题、运行问题,还是 ABI 问题?

6.3 典型链接错误

undefined reference

含义

undefined reference 的意思是:编译器已经接受了你的代码,但链接器找不到某个符号的实现。

例如:

// math_utils.h
int add(int a, int b);
// main.c
#include "math_utils.h"
 
int main(){
	return add(1, 2);
}

如果只执行:

gcc main.c -o main

就可能得到:

undefined reference to `add'

因为 main.c 只看到了声明,但 add 的定义并没有参与链接。

正确方式之一是:

gcc main.c math_utils.c -o main

或者先编译成目标文件再链接:

gcc -c main.c
gcc -c math_utils.c
gcc main.o math_utils.o -o main

常见原因包括:

  • 忘记把某个 .c 文件加入编译
  • 忘记链接静态库或动态库
  • -L 路径错误
  • -l 库名错误
  • 链接顺序错误
  • C++ 符号被 name mangling,但 C 端按 C 符号查找
  • 函数被声明为 static,导致外部不可见

使用 nm 检查符号

nm main.o
nm math_utils.o

如果在 main.o 中看到 U add,表示它需要 add。 如果在 math_utils.o 中看到 T add,表示这里提供了 add。

multiple definition

含义

multiple definition 的意思是:链接器发现同一个外部符号被定义了多次。

最常见的错误是把变量定义放进头文件:

// config.h
int global_count = 0;

如果多个 .c 文件都包含 config.h,预处理后每个编译单元里都会有一份 global_count 定义。链接器就会发现多个同名符号。

正确写法是:

// config.h
extern int global_count;
// config.c
int global_count = 0;

函数也是一样。普通函数实现通常不要放在头文件中,除非你非常明确地使用 static inline 等方式控制链接行为。

include guard 不能解决多编译单元重复定义

#ifndef CONFIG_H 只能防止同一个编译单元里重复展开同一个头文件。 但如果 a.c 和 b.c 分别包含了这个头文件,它们仍然会各自得到一份定义。

链接库顺序错误

在一些链接器中,静态库顺序会影响符号解析。

假设 main.o 依赖 libmath_utils.a,常见写法应该是:

gcc main.o -L. -lmath_utils -o main

而不是:

gcc -L. -lmath_utils main.o -o main

因为链接器通常从左到右处理输入。库应该出现在依赖它的目标文件之后。

6.4 典型运行错误

segmentation fault

含义

segmentation fault 通常表示程序访问了不该访问的内存。 它不是一种具体原因,而是一类结果。

常见原因包括:

  • 解引用空指针
  • 使用野指针
  • 访问已经释放的内存
  • 数组越界
  • 栈变量地址逃逸
  • 字符串没有 \0 结尾
  • 函数指针类型不匹配
  • FFI 函数签名写错

例如:

int* p = NULL;
*p = 10;

或者:

int arr[3] = {1, 2, 3};
arr[10] = 100;

这类错误最麻烦的地方在于:错误发生的位置不一定是崩溃的位置。一次越界写可能破坏了旁边的数据,真正崩溃可能发生在很久之后。

实用排查工具

编译时可以开启 AddressSanitizer:

gcc -g -fsanitize=address main.c -o main

它能帮助定位越界、释放后使用、重复释放等问题。

也可以使用调试器:

gdb ./main

或在 macOS 上使用:

lldb ./main

崩溃后查看调用栈,通常比直接盯着代码猜更可靠。

bus error

bus error 在某些平台上常见于未对齐访问或非法内存映射访问。

例如在对齐要求严格的架构上,把一个未对齐地址强行解释为 int* 并解引用,就可能出问题。

char buffer[8];
int* p = (int*)(buffer + 1);
*p = 10;

在一些平台上这可能工作,在另一些平台上可能直接崩溃。这正是 C 中“未定义行为”的危险之处。

6.5 生命周期问题

错误示例

int* bad(void){
	int x = 10;
	return &x;
}

x 是局部变量,属于当前函数调用的栈帧。 函数返回后,x 生命周期结束,返回它的地址就是悬空指针。

类似的问题还包括返回局部数组:

char* message(void){
	char buf[32] = "hello";
	return buf;
}

正确方式通常有三种。

调用者提供缓冲区

int message(char* buffer, size_t capacity){
	if(buffer == NULL || capacity < 6){
		return -1;
	}
	snprintf(buffer, capacity, "hello");
	return 0;
}

函数内部堆分配,调用者释放

char* message_create(void);
void message_destroy(char* message);

返回静态存储期对象

const char* message(void){
	return "hello";
}

这种方式适合只读字符串字面量,但不适合需要返回可变内容的场景。

生命周期问题的本质

指针本身不会告诉你它指向的对象还活着没有。 所以每个返回指针、保存指针、传递指针的接口,都必须说明对象的生命周期。

6.6 内存所有权问题

所有权要回答的问题

  • 这块内存是谁申请的?
  • 谁可以读?
  • 谁可以写?
  • 谁负责释放?
  • 什么时候释放?
  • 能不能跨线程使用?

一个常见错误是跨模块释放内存:

// library
char* lib_make_string(void);
 
// caller
char* s = lib_make_string();
free(s);

这看起来很自然,但不一定安全。尤其在 Windows 或跨语言场景中,库内部可能使用了不同的分配器。更稳妥的接口是成对提供释放函数:

char* lib_make_string(void);
void lib_free_string(char* s);

这样谁申请,谁释放,边界更清晰。

所有权规则

  • 谁分配,最好由谁提供释放方式
  • 如果调用者负责释放,必须明确使用哪个函数释放
  • 如果调用者不负责释放,返回值应该是 const 或文档明确只读
  • 函数保存调用方传入的指针时,必须说明是否会复制数据

例如:

void set_name(User* user, const char* name);

这个函数到底是复制 name 的内容,还是只保存 name 指针?如果不说明,调用方就不知道传入临时字符串是否安全。

更清晰的做法是通过命名和文档表达:

int user_set_name_copy(User* user, const char* name);
int user_set_name_ref(User* user, const char* name);

实际项目中不一定要用这个命名,但接口必须表达清楚语义。

6.7 字符串与缓冲区误区

C 字符串不是对象,而是以 \0 结尾的字符数组

char* 本身只是一段内存的地址。 它不携带长度,也不保证可写,更不保证一定以 \0 结尾。

常见错误:

char buf[5];
strcpy(buf, "hello");

"hello" 实际需要 6 个字节:

h e l l o \0

所以 buf[5] 不够。

更稳妥的写法:

char buf[6];
snprintf(buf, sizeof(buf), "hello");

跨语言场景中还要注意编码问题。C 的 char* 只是字节序列,具体是 UTF-8、GBK 还是其他编码,不由 C 类型本身决定。

缓冲区接口设计原则

  • 指针和长度一起传
  • 输出缓冲区传容量
  • 函数写出实际长度
  • 明确是否包含结尾的 \0
  • 明确字符串编码

例如:

int get_name(char* buffer, size_t capacity, size_t* written);

比下面这种接口更清晰:

char* get_name(void);

因为前者明确了内存由谁提供、最多写多少、实际写了多少。

6.8 结构体误区

错误理解

结构体大小等于所有字段大小相加。

实际情况是:结构体还受到对齐和 padding 的影响。

typedef struct {
	char a;
	int b;
} S;

sizeof(S) 很可能不是 5,而是 8。

更隐蔽的问题是把结构体直接写入文件或网络:

fwrite(&s, sizeof(S), 1, file);

这在同一编译器、同一平台、同一版本下可能能用,但它不是可靠的跨平台序列化方案。原因包括:

  • padding 字节内容不稳定
  • 大小端不同
  • 类型大小不同
  • 对齐规则不同
  • 结构体未来版本可能变化

结构体跨边界要特别谨慎

如果结构体只在单个 .c 文件内部使用,怎么组织主要是代码风格问题。 如果结构体出现在公开头文件中,它就成为 ABI 的一部分。

更稳妥的做法是:

  • 对内使用完整结构体
  • 对外暴露不透明指针
  • 如果必须暴露结构体,使用固定宽度类型
  • 预留 size 或 version 字段
  • 不承诺结构体二进制内容就是文件格式

6.9 头文件与宏误区

头文件要自包含

一个好的头文件应该能被单独包含并通过编译。

例如:

// user.h
typedef struct {
	size_t len;
} User;

如果没有包含 <stddef.h>,那么使用者单独包含 user.h 时可能不知道 size_t 是什么。

正确写法:

#ifndef USER_H
#define USER_H
 
#include <stddef.h>
 
typedef struct {
	size_t len;
} User;
 
#endif

宏也很容易制造隐藏问题。

#define SQUARE(x) x * x

调用:

int y = SQUARE(1 + 2);

展开后是:

int y = 1 + 2 * 1 + 2;

结果不是 9。

更好的宏写法:

#define SQUARE(x) ((x) * (x))

但即便如此,宏仍然会重复求值:

int y = SQUARE(i++);

i++ 会被执行两次。

宏使用原则

  • 简单常量宏可以接受
  • 复杂逻辑优先考虑函数或 static inline
  • 宏参数要加括号
  • 注意重复求值
  • 公开头文件中的宏要避免污染命名空间

6.10 跨语言调用常见坑

FFI 错误通常不是某一门语言的问题

它们多数来自双方对 C ABI 的理解不一致。

常见问题包括:

类型不匹配

C 端:

int add(int a, int b);

绑定层写成:

long add(long a, long b)

这可能导致参数或返回值解释错误。

bool、long、size_t 大小差异

不同平台上 long 的大小并不总是一致。size_t 也会随 32 位和 64 位平台变化。

跨语言绑定时,应该明确对应关系,而不是凭名称猜测。

内存分配器不一致

C 库分配的内存,最好由 C 库提供的释放函数释放。

回调生命周期不清晰

如果 C 库保存了其他语言传入的回调函数,必须保证回调在 C 库使用期间仍然有效。

异常跨越 C 边界

C 没有异常机制。C++ 异常、Rust panic、其他语言异常都不应该直接穿过 C ABI 边界。

线程模型不一致

C 库可能在内部线程调用回调,而上层语言运行时可能要求回调必须发生在特定线程。这类约束必须在接口中说明。

FFI 排查问题时优先检查

  • 函数名是否正确导出
  • 函数签名是否逐项匹配
  • 结构体字段顺序和对齐是否一致
  • 字符串编码和结尾规则是否一致
  • 内存由谁释放是否一致
  • 回调是否仍然有效
  • 动态库是否加载到了预期版本

6.11 方法论

遇到 C 问题时,始终问这些问题

  • 这个错误发生在哪个阶段?
  • 这个名字是声明问题,还是符号问题?
  • 这块内存在哪里?
  • 它的生命周期到什么时候?
  • 谁拥有它,谁释放它?
  • 这个结构体布局是否被双方一致理解?
  • 这个函数调用是否符合相同 ABI?

一套比较实用的排查流程:

  1. 先看完整错误信息,不只看最后一行
  2. 判断错误层级:编译、链接、加载、运行、ABI
  3. 构造最小复现,减少无关代码
  4. 用 gcc -E 查看预处理结果
  5. 用 nm 查看目标文件和库里的符号
  6. 用 ldd、otool -L 或平台工具检查动态库依赖
  7. 用 AddressSanitizer 检查内存错误
  8. 用调试器查看崩溃调用栈
  9. 跨语言问题逐项核对函数签名和结构体布局

一个很实用的习惯

不要一开始就猜“是不是指针错了”。 先把问题放回 C 程序的生命周期中: 预处理 → 编译 → 链接 → 加载 → 运行

大部分问题只要层级判断对了,排查路径就会清晰很多。

6.12 小结

本章总结

C 的常见坑,本质上来自几个核心事实:

  • #include 只是文本展开
  • 声明不等于定义
  • 链接器只认符号
  • 指针只是地址,不携带生命周期
  • 结构体是二进制布局,不只是字段集合
  • ABI 不一致时,源码看起来正确也可能崩溃

真正有效的 C 排错方式不是背错误信息,而是建立分层模型。 先判断错误在哪一层,再选择对应工具和思路。


7. 跨语言构建与工程实践

引言

前面几章讲的是规则,本章讲的是如何把这些规则落实到工程中。 因为在真实项目里,我们面对的不是一个 main.c,而是:

  • 多个源文件
  • 多个头文件
  • 静态库和动态库
  • Debug / Release 构建
  • 不同平台和编译器
  • 对外导出的 C ABI
  • 其他语言通过 FFI 调用

构建系统的意义,就是把这些规则稳定、可重复地组织起来。

7.1 为什么需要构建系统

只用一条 gcc 命令不行吗?

对于单文件程序可以:

gcc main.c -o main

但真实项目很快会变成:

gcc -Iinclude src/a.c src/b.c src/c.c app/main.c -Llib -lxxx -o app

再加上不同平台、不同构建模式、不同依赖库后,手写命令会变得不可维护。

构建系统至少要解决这些问题:

  • 哪些源文件需要编译?
  • 头文件搜索路径是什么?
  • 每个目标依赖哪些库?
  • 编译选项如何区分 Debug 和 Release?
  • 静态库还是动态库?
  • 构建产物输出到哪里?
  • 公开头文件如何安装或导出?
  • 跨平台差异如何处理?
  • 修改某个文件后,哪些目标需要重新构建?

构建系统的核心价值

构建系统不是为了“少写几条命令”,而是为了让构建过程可重复、可维护、可移植。

7.2 构建系统本质

一个 C 项目的构建,本质上仍然是前面讲过的流程:

源码文件 -> 编译成目标文件 -> 链接成可执行文件或库

对于一个简单项目:

project/
  include/
    math_utils.h
  src/
    math_utils.c
  app/
    main.c

手工构建可能是:

gcc -Iinclude -c src/math_utils.c -o math_utils.o
gcc -Iinclude -c app/main.c -o main.o
gcc main.o math_utils.o -o main

如果要构建静态库:

ar rcs libmath_utils.a math_utils.o
gcc main.o -L. -lmath_utils -o main

如果要构建动态库:

gcc -Iinclude -fPIC -c src/math_utils.c -o math_utils.o
gcc -shared math_utils.o -o libmath_utils.so
gcc -Iinclude app/main.c -L. -lmath_utils -o main

构建系统做的事情

Makefile、CMake、Meson 等工具本质上都是在描述:

  • 编译哪些文件
  • 用什么参数编译
  • 生成什么目标
  • 目标之间如何依赖
  • 最终如何链接

也就是说,构建系统没有改变 C 的构建模型,只是把构建模型自动化、结构化了。

7.3 Makefile

Makefile 的核心思想

Makefile 描述的是“目标、依赖和命令”。 当依赖比目标更新时,就重新执行命令。

最简单的 Makefile:

main: main.o math_utils.o
	gcc main.o math_utils.o -o main
 
main.o: main.c math_utils.h
	gcc -c main.c -o main.o
 
math_utils.o: math_utils.c math_utils.h
	gcc -c math_utils.c -o math_utils.o

每一条规则的结构是:

目标: 依赖
	命令

注意命令前面通常是 Tab,不是普通空格。

更工程化一点可以写成:

CC := gcc
CFLAGS := -Wall -Wextra -g -Iinclude
 
TARGET := app
OBJS := app/main.o src/math_utils.o
 
$(TARGET): $(OBJS)
	$(CC) $(OBJS) -o $@
 
app/%.o: app/%.c
	$(CC) $(CFLAGS) -c $< -o $@
 
src/%.o: src/%.c
	$(CC) $(CFLAGS) -c $< -o $@
 
.PHONY: clean
clean:
	rm -f $(TARGET) $(OBJS)

这里有几个常用自动变量:

  • $@:当前目标
  • $<:第一个依赖
  • $^:所有依赖

Makefile 适合什么场景

Makefile 简单、直接、透明,适合小型 C 项目或需要精确控制命令的场景。 但当项目需要跨平台、安装导出、复杂依赖管理时,手写 Makefile 会迅速变复杂。

7.4 CMake

CMake 的核心思想

CMake 不是编译器,也不是链接器。 它是一个“生成构建系统的工具”,可以生成 Makefile、Ninja 文件、Xcode 工程、Visual Studio 工程等。

现代 CMake 更推荐以 target 为中心组织项目,而不是全局堆参数。

一个简单示例:

cmake_minimum_required(VERSION 3.16)
project(MyProject C)
 
add_library(math_utils
	src/math_utils.c
)
 
target_include_directories(math_utils
	PUBLIC
		${CMAKE_CURRENT_SOURCE_DIR}/include
)
 
add_executable(app
	app/main.c
)
 
target_link_libraries(app
	PRIVATE
		math_utils
)

这里最重要的是三个 target:

  • math_utils:一个库目标
  • app:一个可执行文件目标
  • app 通过 target_link_libraries 依赖 math_utils

PUBLIC 和 PRIVATE 很关键。

target_include_directories(math_utils
	PUBLIC include
)

表示 math_utils 自己需要这个头文件路径,链接 math_utils 的目标也需要这个路径。

如果写成:

target_include_directories(math_utils
	PRIVATE include
)

则表示只有 math_utils 自己编译时需要,使用者不自动继承。

现代 CMake 的核心习惯

  • 使用 target_include_directories
  • 使用 target_compile_options
  • 使用 target_compile_definitions
  • 使用 target_link_libraries
  • 少用全局 include_directories
  • 少用全局 link_directories

因为 C 项目一旦变大,全局参数会污染所有目标,让依赖关系变得难以判断。

7.5 构建库

静态库

静态库是什么

静态库本质上是一组目标文件的归档。 链接时,库中的代码会被合并进最终可执行文件。

手工构建:

gcc -c src/math_utils.c -o math_utils.o
ar rcs libmath_utils.a math_utils.o

CMake 中:

add_library(math_utils STATIC
	src/math_utils.c
)

静态库特点:

  • 部署简单,最终程序不依赖外部 .so / .dylib / .dll
  • 可执行文件体积可能变大
  • 更新库后通常需要重新链接程序
  • 多个程序使用同一库时,每个程序都有自己的代码副本

动态库

动态库是什么

动态库在运行时被加载,多个程序可以共享同一份库文件。

Linux 上常见:

gcc -fPIC -c src/math_utils.c -o math_utils.o
gcc -shared math_utils.o -o libmath_utils.so

macOS 上通常是:

gcc -dynamiclib math_utils.o -o libmath_utils.dylib

CMake 中:

add_library(math_utils SHARED
	src/math_utils.c
)

动态库特点:

  • 可以独立升级库文件
  • 多程序共享同一份库
  • 运行时需要能找到动态库
  • ABI 稳定性变得非常重要

动态库升级的风险

如果只是修复内部实现,通常比较安全。 如果改了公开函数签名、公开结构体布局或符号导出,就可能破坏旧程序。

7.6 头文件导出

公开头文件就是 API 边界

库对外提供的不只是 .a、.so、.dylib 或 .dll,还必须提供对应头文件。 调用方通过头文件知道怎么编译,通过库文件完成链接和运行。

一个比较清晰的项目结构:

mylib/
  include/
    mylib/
      mylib.h
  src/
    mylib.c
    internal.h
  CMakeLists.txt

其中:

  • include/mylib/mylib.h 是公开头文件
  • src/internal.h 是内部头文件
  • 使用者只能依赖公开头文件

CMake 中:

target_include_directories(mylib
	PUBLIC
		$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
		$<INSTALL_INTERFACE:include>
	PRIVATE
		${CMAKE_CURRENT_SOURCE_DIR}/src
)

这样可以区分构建阶段和安装后的头文件路径。

公开头文件的要求

  • 自包含,单独 #include 能编译
  • 不暴露内部头文件路径
  • 尽量少包含不必要依赖
  • 对 C++ 使用者提供 extern "C"
  • 公开类型和函数命名要有项目前缀
  • 明确内存所有权和线程约束

7.7 链接参数

C 编译链接中常见参数:

参数作用示例
-I添加头文件搜索路径-Iinclude
-L添加库搜索路径-L/usr/local/lib
-l链接某个库-lmylib
-D定义宏-DDEBUG
-fPIC生成位置无关代码构建动态库常用
-shared生成动态库gcc -shared ...

-l 的库名规则

-lmath 通常会查找:

  • libmath.so
  • libmath.a

所以链接 libmylib.so 时写的是:

-lmylib

而不是:

-llibmylib

链接顺序也要注意:

gcc main.o -L. -lmylib -o main

通常比下面这种更合理:

gcc -L. -lmylib main.o -o main

因为库应该放在依赖它的目标文件后面。

头文件路径和库路径是两回事

-I 只影响编译器找头文件。 -L 和 -l 只影响链接器找库。

能 #include 成功,不代表链接一定成功。

7.8 动态库运行时查找

编译链接成功,为什么运行时还找不到库?

因为链接时找到库,和运行时加载器找到库,是两件事。

Linux 上可能看到:

error while loading shared libraries: libmylib.so: cannot open shared object file

macOS 上可能看到:

Library not loaded: libmylib.dylib

临时解决方式:

export LD_LIBRARY_PATH=.

macOS:

export DYLD_LIBRARY_PATH=.

这类环境变量更适合开发和临时调试。在真实项目中,更常用的是设置 rpath。

Linux 示例:

gcc main.o -L. -lmylib -Wl,-rpath,'$ORIGIN' -o main

$ORIGIN 表示可执行文件所在目录。

macOS 常见概念包括:

  • @rpath
  • @loader_path
  • @executable_path

例如:

gcc main.o -L. -lmylib -Wl,-rpath,@loader_path -o main

动态库问题要分两步看

  • 链接时:链接器能不能找到库?
  • 运行时:动态加载器能不能找到库?

这两个阶段使用的搜索路径并不完全相同。

检查动态库依赖可以用:

ldd ./main

macOS:

otool -L ./main

7.9 跨平台差异

平台静态库动态库常见编译器
Linux.a.soGCC / Clang
macOS.a.dylibClang
Windows.lib.dll,通常配套导入库 .libMSVC / MinGW

除了文件扩展名,还有很多差异:

  • 默认调用约定
  • 符号导出方式
  • 路径分隔符
  • long 的大小
  • 动态库搜索路径
  • 线程和运行时库
  • 编译器参数风格

Windows 动态库通常需要显式导出符号。常见写法是定义导出宏:

#if defined(_WIN32)
	#if defined(MYLIB_BUILD)
		#define MYLIB_API __declspec(dllexport)
	#else
		#define MYLIB_API __declspec(dllimport)
	#endif
#else
	#define MYLIB_API __attribute__((visibility("default")))
#endif
 
MYLIB_API int mylib_add(int a, int b);

构建库时定义 MYLIB_BUILD,使用库时不定义。

跨平台不是到处写 #ifdef

更好的方式是把平台差异集中封装在少量头文件或构建脚本中,业务代码尽量依赖统一接口。

7.10 C vs C++

C++ 可以调用 C,但 C ABI 不等于 C++ ABI

C++ 有函数重载、类、虚函数、异常、模板、命名空间等特性。 这些特性会让 C++ ABI 复杂得多,而且不同编译器之间不一定稳定。

如果一个 C++ 库想对外提供跨语言接口,通常会包一层 C API。

// engine.hpp
class Engine {
public:
	int run(int value);
};

不要直接把这个类暴露给 C 调用方,而是提供:

// engine_c.h
#ifdef __cplusplus
extern "C" {
#endif
 
typedef struct EngineHandle EngineHandle;
 
EngineHandle* engine_create(void);
void engine_destroy(EngineHandle* engine);
int engine_run(EngineHandle* engine, int value);
 
#ifdef __cplusplus
}
#endif

实现中再转发到 C++:

// engine_c.cpp
#include "engine_c.h"
#include "engine.hpp"
 
struct EngineHandle {
	Engine engine;
};
 
EngineHandle* engine_create(void){
	return new EngineHandle();
}
 
void engine_destroy(EngineHandle* engine){
	delete engine;
}
 
int engine_run(EngineHandle* engine, int value){
	return engine->engine.run(value);
}

不要让 C++ 异常穿过 C ABI

如果内部 C++ 代码可能抛异常,C API 实现层应该捕获异常并转换成错误码。

例如:

int engine_run_safe(EngineHandle* engine, int value, int* out_result){
	try {
		*out_result = engine->engine.run(value);
		return 0;
	} catch (...) {
		return -1;
	}
}

7.11 版本与 ABI 兼容

动态库一旦被外部使用,就要考虑版本兼容

如果用户已经基于某个头文件和动态库编译了程序,那么后续替换动态库时,最好不要破坏旧二进制。

通常比较安全的改动:

  • 修复函数内部 bug
  • 增加新的函数
  • 增加新的不透明类型接口
  • 在不影响公开布局的情况下优化内部实现

高风险改动:

  • 删除公开函数
  • 修改函数参数或返回值
  • 修改公开结构体字段顺序
  • 改变公开结构体大小
  • 改变错误码含义
  • 改变内存所有权约定

可以提供版本查询函数:

int mylib_version_major(void);
int mylib_version_minor(void);
int mylib_version_patch(void);

或者:

const char* mylib_version_string(void);

对于可扩展配置结构体,可以使用 size 字段:

typedef struct {
	size_t size;
	int option_a;
	int option_b;
} MyOptions;

调用方初始化:

MyOptions options = {0};
options.size = sizeof(options);

库内部可以根据 size 判断调用方使用的是哪个版本的结构体。

7.12 常见构建问题

头文件找不到

典型表现:

fatal error: mylib.h: No such file or directory

排查方向:

  • #include 路径是否正确
  • 是否添加了 -I
  • CMake 是否正确设置 target_include_directories
  • 公开头文件是否安装到了预期位置

链接时找不到符号

典型表现:

undefined reference to `mylib_add'

排查方向:

  • 对应 .c 是否参与编译
  • 静态库或动态库是否参与链接
  • -L 和 -l 是否正确
  • 链接顺序是否正确
  • 符号是否被 static 隐藏
  • C++ 是否缺少 extern "C"

运行时找不到动态库

典型表现:

cannot open shared object file

排查方向:

  • 动态库是否在系统搜索路径中
  • 是否设置了 LD_LIBRARY_PATH
  • 是否设置了 rpath
  • macOS 下 install name 是否正确
  • Windows 下 DLL 是否在可执行文件同目录或 PATH 中

Debug 能运行,Release 崩溃

常见原因:

  • 未初始化变量
  • 越界访问
  • 依赖未定义行为
  • 栈内存生命周期错误
  • 优化后时序或布局变化暴露问题

处理这类问题

不要假设是编译器优化错了。 优先检查代码是否依赖了未定义行为。

7.13 工程实践建议

一个可靠 C 库的基本结构

mylib/
  include/
    mylib/
      mylib.h
  src/
    mylib.c
    internal.h
  tests/
    test_mylib.c
  CMakeLists.txt
  README.md

建议遵守这些原则:

  • 公开头文件放在 include/
  • 内部头文件放在 src/
  • 公开符号统一加项目前缀
  • 对外使用 C ABI
  • 内部实现可以自由组织
  • 创建和销毁函数成对出现
  • 错误统一使用错误码或明确的错误对象
  • 使用 Sanitizer 和测试覆盖内存边界
  • CI 中至少构建 Debug 和 Release
  • 动态库发布时关注 ABI 兼容

一个最小 CMake 测试目标:

enable_testing()
 
add_executable(test_mylib
	tests/test_mylib.c
)
 
target_link_libraries(test_mylib
	PRIVATE
		mylib
)
 
add_test(NAME test_mylib COMMAND test_mylib)

测试 C 接口时,不只测功能

还应该测试:

  • NULL 参数
  • 缓冲区太小
  • 重复创建销毁
  • 错误输入
  • 多次调用
  • 跨动态库边界的释放

这些测试能尽早发现接口边界设计问题。

7.14 本质总结

本章总结

构建系统的本质,是把 C 的编译模型、链接模型和 ABI 边界变成可重复的工程流程。

需要记住的核心点:

  • #include 解决编译期声明可见性
  • .o、.a、.so / .dylib / .dll 解决二进制组织
  • -I 是头文件路径,-L 和 -l 是库链接路径
  • 链接成功不代表运行时一定能找到动态库
  • 公开头文件就是 API 边界
  • 动态库升级必须考虑 ABI 兼容
  • 跨语言接口要以 C ABI 为中心设计

总结

全文回顾

如果把 C 只理解为语法,那么很容易停留在“会写但解释不清”的阶段。 真正理解 C,需要抓住三条主线:

  • 构建模型:源码如何经过预处理、编译、汇编、链接变成程序
  • 内存模型:变量、数组、指针、结构体如何在内存中存在
  • ABI 协议:函数、参数、返回值、结构体和符号如何在二进制层面协作

掌握这三点,才能真正理解 C 为什么适合作为系统接口语言,也才能在跨语言开发中判断问题到底出在头文件、链接、内存、动态库还是 ABI 上。