
前言初学 C 语言时「声明」「定义」「调用」这三个词经常被混着用。有人把函数头加一个分号就叫定义有人把头文件里的原型当成实现还有人写了函数却忘了在调用处之前声明靠编译器「猜」出一个返回int的版本。这些混淆在单个.c文件里往往能蒙混过关一旦拆成多个文件、或者换一个更严格的编译器就会变成编译错误或链接错误。需要先明确一件事C 语言从 C99 起就不再允许隐式函数声明调用一个此前没有声明过的函数属于约束违反编译器必须给出诊断C23 进一步收紧隐式声明和旧式KR函数定义都不再是标准的一部分。所以「不声明也能用」不是语言特性而是旧编译器留下的宽松行为。本文按「声明—定义—使用」的顺序讲清三者的区别声明描述接口定义提供实体使用则是调用方按接口发起调用。然后给出一个头文件加两个源文件的完整组织方式最后列出初学者最常踩的坑。一、声明只描述接口不产生实体函数声明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);外部链接当前翻译单元可见在头文件或源文件里引用别处的定义inlineC99 语义特殊见下文头文件中的小函数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.hint 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.hint 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 -stdc17 -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 的定义。