前言
初学 C 语言时,「声明」「定义」「调用」这三个词经常被混着用。有人把函数头加一个分号就叫定义,有人把头文件里的原型当成实现,还有人写了函数却忘了在调用处之前声明,靠编译器「猜」出一个返回int的版本。这些混淆在单个.c文件里往往能蒙混过关,一旦拆成多个文件、或者换一个更严格的编译器,就会变成编译错误或链接错误。
需要先明确一件事:C 语言从 C99 起就不再允许隐式函数声明,调用一个此前没有声明过的函数属于约束违反,编译器必须给出诊断;C23 进一步收紧,隐式声明和旧式(K&R)函数定义都不再是标准的一部分。所以「不声明也能用」不是语言特性,而是旧编译器留下的宽松行为。
本文按「声明—定义—使用」的顺序讲清三者的区别:声明描述接口,定义提供实体,使用则是调用方按接口发起调用。然后给出一个头文件加两个源文件的完整组织方式,最后列出初学者最常踩的坑。
一、声明:只描述接口,不产生实体
函数声明(function declaration)告诉编译器:这个名字是一个函数,返回什么类型,接受几个什么类型的参数。它不生成任何代码,也不分配存储。带参数类型的声明叫函数原型(function prototype),是 C 语言里唯一推荐的写法。
/* 适用于 C17 */
int add(int a, int b); /* 原型:两个 int 参数,返回 int */
double average(const int *data, size_t count);
void print_banner(void); /* 明确表示不接受参数 */原型的作用是让编译器在调用点做类型检查。实参个数不对、类型无法隐式转换,编译期就能报出来。如果没有原型,编译器只能按默认实参提升规则推测,错误就留到运行期。
这里有一个经典区别:void f(void)和void f()在声明位置的含义不同。前者明确表示不接受参数;后者是旧式声明,表示「参数未知」,编译器不会对调用的实参做检查。只有在定义位置,空括号才等价于无参数。所以头文件里一律写(void),不要留空。
声明可以出现多次,只要彼此一致。同一翻译单元里重复写同一个原型是合法的;类型不一致则违反约束。声明通常放在头文件里,被多个源文件包含,这样所有调用方看到的接口是同一份。
函数指针的声明需要括号:
/* 适用于 C17 */
int (*compare)(const void *, const void *); /* 指向函数的指针 */
int *(*factory)(void); /* 返回 int* 的函数指针 */int *compare(...)是返回指针的函数,int (*compare)(...)是指向函数的指针,两者差别只在一对括号,含义完全不同。读声明时可以按「右左法则」:从名字出发,先看右边,再看左边。
二、定义:提供函数体,全程序唯一
函数定义(function definition)包含函数体,是编译器真正生成代码的地方。一个函数在整个程序中只能有一个外部定义(inline 的情况稍后再谈)。多个源文件各自写一份同名函数的完整定义,链接阶段会报重复定义(multiple definition)。
定义与声明的区别可以用一句话概括:声明可以有很多份,定义只能有一份。定义本身也是声明,它同时提供了接口信息和实现。
链接属性由static和extern控制:
| 写法 | 链接属性 | 可见范围 | 典型用途 |
|---|
int f(void) { ... } | 外部链接 | 整个程序 | 对外提供的接口 |
static int f(void) { ... } | 内部链接 | 仅当前翻译单元 | 文件私有的辅助函数 |
extern int f(void); | 外部链接 | 当前翻译单元可见 | 在头文件或源文件里引用别处的定义 |
inline(C99 语义) | 特殊 | 见下文 | 头文件中的小函数 |
static用在函数上表示内部链接,别的翻译单元链接不到它。这是隐藏实现细节的常用手段:辅助函数一律加static,既避免命名冲突,也给编译器更多内联机会。
C99 的inline语义和 C++ 不同,容易搞错。在 C 里,一个inline定义本身不提供外部定义;如果所有使用处都被内联,链接器不需要实体;一旦有调用没有被内联,就需要在某一个源文件里提供外部定义。常见做法是在头文件里写static inline(每个翻译单元各自一份内部链接的副本),简单且不易出错;或者在头文件里放inline定义,并在一个.c文件里写extern声明来生成实体。两种做法各有取舍,初学阶段用static inline最省心。
还有一类容易忽略的定义:文件作用域的int counter;是暂定定义(tentative definition)。同一翻译单元里可以出现多次,编译器把它们合并成一个定义并零初始化;如果整个翻译单元都没有带初始化的定义,最后就按一个零初始化的定义处理。这也是为什么头文件里写变量定义会导致多个源文件各自拥有一份、链接时冲突——正确做法是头文件里写extern,在一个源文件里定义。
三、使用:头文件负责声明,源文件负责实现
多文件项目的常规组织是:
- 头文件(
.h)放函数原型、类型定义、宏、extern变量声明,并用包含卫士(include guard)防止重复包含。 - 源文件(
.c)包含对应的头文件,写出函数定义。 - 调用方包含头文件,按原型调用。
包含卫士有两种写法,#ifndef宏卫士可移植性最好,#pragma once被主流编译器支持但不属于 C 标准:
/* 适用于 C17 */
#ifndef MATHUTIL_H
#define MATHUTIL_H
#include <stddef.h> /* size_t */
int add(int a, int b);
double average(const int *data, size_t count);
#endif /* MATHUTIL_H */源文件包含自己的头文件有一个额外好处:让编译器核对定义与声明是否一致。如果定义写错了参数类型,包含头文件后立刻就能发现,而不是等到调用方链接时才出现难以理解的错误。
调用时的规则很简单:实参个数与类型要能匹配形参,返回值可以忽略(除非函数声明了[[nodiscard]],那是 C23 的属性,C17 里没有)。数组作为参数会退化为指向首元素的指针,所以函数内部拿不到数组长度,必须额外传一个长度参数——这是初学者最常见的困惑之一。
main函数的标准形式只有两种:int main(void)和int main(int argc, char *argv[])。void main()从来不是标准写法,虽然某些编译器容忍它。main的返回值会作为进程退出码传给操作系统,省略return时 C99 起等价于return 0。
四、完整示例:三个文件的最小工程
下面的例子把接口和实现分开。头文件只放声明,mathutil.c放定义,main.c负责调用。
/* mathutil.h —— 适用于 C17 */
#ifndef MATHUTIL_H
#define MATHUTIL_H
#include <stddef.h>
int add(int a, int b);
double average(const int *data, size_t count);
void print_banner(void);
#endif /* MATHUTIL_H *//* mathutil.c —— 适用于 C17 */
#include "mathutil.h"
#include <stdio.h>
/* 外部链接:其他翻译单元可以调用 */
int add(int a, int b) {
return a + b;
}
double average(const int *data, size_t count) {
if (data == NULL || count == 0) {
return 0.0;
}
long sum = 0;
for (size_t i = 0; i < count; ++i) {
sum += data[i];
}
return (double)sum / (double)count;
}
void print_banner(void) {
puts("mathutil demo");
}
/* 内部链接:只在本文件可见 */
static int clamp_to_zero(int value) {
return value < 0 ? 0 : value;
}/* main.c —— 适用于 C17 */
#include "mathutil.h"
#include <stdio.h>
int main(void) {
int values[] = {3, 1, 4, 1, 5, 9, 2, 6};
size_t count = sizeof(values) / sizeof(values[0]);
print_banner();
printf("add(2, 3) = %d\n", add(2, 3));
printf("average = %.3f\n", average(values, count));
/* 传数组名等价于传首元素地址,长度必须另传 */
printf("first = %d\n", values[0]);
return 0;
}构建时把两个.c一起交给编译器,例如cc -std=c17 -Wall -Wextra main.c mathutil.c -o demo。这里只给出命令形式,不声称任何执行结果。
clamp_to_zero加了static,即使别的文件里也有同名函数也不会冲突。average用long累加而不是int,是为了降低求和溢出的风险;即便如此,元素数量极大时仍可能溢出,工程上要按数据范围选择更宽的累加类型。sizeof(values) / sizeof(values[0])只在数组仍以数组类型存在的作用域里有效,传进函数后数组已经退化为指针,这个比值会变成指针大小除以元素大小,结果没有意义。
常见坑点
1. 调用处没有可见的声明
❌ 在main里直接调用后面才定义的函数,依赖旧编译器的隐式声明。
✅ 在头文件里写原型,调用方包含头文件;或者在同一文件里把定义或原型放在调用之前。
2.void f()当成无参数
❌ 头文件里写void f();,调用f(1, 2)编译器不报错。
✅ 明确写void f(void);,让编译器检查实参。
3. 在头文件里写函数定义(非static inline)
❌int add(int a, int b) { return a + b; }放在.h里,被两个.c包含后链接报重复定义。
✅ 头文件只放原型;需要头文件内实现时用static inline,或者按 C99 的inline规则在一个源文件里提供外部定义。
4. 在头文件里定义变量
❌int g_count = 0;放在头文件里,每个包含它的源文件都有一份。
✅ 头文件写extern int g_count;,在一个.c文件里写int g_count = 0;。
5. 数组参数里用sizeof求长度
❌void f(int arr[]) { size_t n = sizeof(arr) / sizeof(arr[0]); },得到的是指针大小之比。
✅ 增加一个size_t count参数,由调用方传入真实长度。
6. 返回局部数组的指针
❌char *make(void) { char buf[16]; return buf; },函数返回后buf已销毁。
✅ 由调用方提供缓冲区,或使用malloc分配并在文档中写明由谁释放。
7. 忘记返回值的路径
❌ 非void函数在某个分支没有return,调用方读到不确定的值。
✅ 打开-Wall -Wextra,保证每条路径都有返回值;main除外,C99 起省略等价于返回 0。
8. 用void main()
❌void main()作为入口,返回值类型不符合标准。
✅ 写int main(void)或int main(int argc, char *argv[]),并返回 0 或EXIT_SUCCESS。
总结
| 概念 | 作用 | 出现次数 | 放在哪里 |
|---|
| 声明(原型) | 描述返回类型与参数,供编译器检查调用 | 可多次 | 头文件 |
| 定义 | 提供函数体,生成代码 | 全程序一次 | 源文件 |
| 调用 | 按原型传参并接收返回值 | 任意次 | 调用方源文件 |
static函数 | 内部链接,仅本翻译单元可见 | 每单元一次 | 源文件 |
extern变量声明 | 引用别处的定义 | 可多次 | 头文件 |
static inline | 头文件内实现,每单元一份副本 | 每单元一次 | 头文件 |
把「声明是接口、定义是实现、头文件管接口、源文件管实现」这条线立住,多文件工程就不会乱。真正需要记牢的细节只有三个:(void)不能省、数组参数会退化、头文件里不要放非 inline 的定义。