C 语言是一门接近底层、强调性能和控制能力的编程语言。操作系统、嵌入式设备、数据库、编译器、网络服务和高性能计算等领域,都能看到 C 语言的身影。
本文从开发环境和基础语法开始,逐步讲解数组、字符串、指针、结构体、动态内存、文件操作、多文件工程、调试工具以及常见安全问题,并在最后提供一个可以直接编译运行的通讯录项目。
1. C 语言是什么
C 语言诞生于 20 世纪 70 年代,最初主要用于开发 UNIX 操作系统。它既能编写接近硬件的底层程序,也能组织较为复杂的通用应用程序。
C 语言的主要特点包括:
- 语法相对精简;
- 编译后的程序性能较高;
- 可以通过指针直接操作内存;
- 具有良好的跨平台能力;
- 运行时依赖较少;
- 能够与汇编语言和其他编程语言协作;
- 给程序员较大的控制权,同时也要求程序员自己管理资源。
1.1 C 语言的主要应用场景
C 语言常见于以下领域:
- 操作系统内核;
- 嵌入式系统和单片机;
- 驱动程序;
- 数据库和存储引擎;
- 编译器与解释器;
- 网络协议栈;
- 音视频编解码;
- 游戏引擎底层模块;
- 高性能计算;
- Python、Java、Lua 等语言的原生扩展。
1.2 C 语言的优势
- 性能高,额外运行时开销少;
- 能精确控制内存布局和生命周期;
- 可以访问硬件寄存器、内存地址和系统接口;
- 编译器、开发工具和第三方库非常丰富;
- 大量操作系统接口都以 C API 的形式提供。
1.3 C 语言的不足
- 没有自动垃圾回收;
- 数组本身通常不记录长度;
- 字符串没有内置边界检查;
- 指针使用错误容易导致崩溃或安全漏洞;
- 标准库相对精简;
- 部分行为依赖编译器、平台和操作系统;
- 编译通过不代表程序一定安全。
学习 C 语言的关键,不只是记住语法,而是理解以下几个核心概念:
- 数据存放在哪里;
- 数据的类型和大小是什么;
- 一个对象能存活多久;
- 指针指向哪个有效对象;
- 谁负责申请和释放资源;
- 当前操作是否会越过内存边界。
2. C 语言标准与编译器
C 语言不是由某一个编译器定义的,而是由 ISO C 标准定义。编译器负责实现相应的语言标准。
2.1 常见 C 语言标准
| 标准 | 发布时间 | 主要变化 |
|---|---|---|
| C89/C90 | 1989/1990 | 第一个广泛使用的标准化版本 |
| C95 | 1995 | 对 C90 的补充 |
| C99 | 1999 | // 注释、变长数组、stdint.h、复合字面量等 |
| C11 | 2011 | 原子操作、线程支持、静态断言等 |
| C17 | 2018 | 主要修复 C11 中的问题 |
| C23 | 2024 | 新关键字、属性和多项现代化改进 |
WG14 官方信息显示,当前已经发布的 C 语言标准是 C23,对应 ISO/IEC 9899:2024。
为了兼顾编译器支持和代码可移植性,本文示例默认按照 C17 编写。使用支持 C23 的新版本 GCC 或 Clang 时,也可以切换到 C23 模式。
2.2 常见 C 编译器
常见编译器包括:
- GCC;
- Clang;
- Microsoft Visual C,也就是 MSVC;
- Intel oneAPI 编译器;
- 面向嵌入式开发的 ARM、IAR、Keil 等编译器。
编译器可能在标准之外提供扩展功能。为了获得更好的可移植性,建议明确指定语言标准,并开启警告。
GCC 示例:
bash
gcc -std=c17 -Wall -Wextra -Wpedantic main.c -o app
Clang 示例:
bash
clang -std=c17 -Wall -Wextra -Wpedantic main.c -o app
如果编译器已经支持 C23,可以使用:
bash
gcc -std=c23 -Wall -Wextra -Wpedantic main.c -o app
不要依赖编译器的默认语言模式,因为不同编译器、不同版本的默认值可能不同。
3. 搭建 C 语言开发环境
学习 C 语言至少需要以下工具:
- 文本编辑器或集成开发环境;
- C 编译器;
- 调试器;
- 可选的构建工具,如 GNU Make 或 CMake。
3.1 Windows 环境
Windows 下可以选择以下方案。
方案一:Visual Studio
安装 Visual Studio 时选择"使用 C++ 的桌面开发"工作负载。虽然工作负载名称包含 C++,但其中的 MSVC 同样可以编译 C 程序。
在"Developer PowerShell for Visual Studio"或"Developer Command Prompt for Visual Studio"中执行:
powershell
cl /W4 main.c
MSVC 会把 .c 文件按照 C 语言源文件处理。
生成指定名称的程序:
powershell
cl /W4 main.c /link /out:app.exe
指定 C17 模式:
powershell
cl /std:c17 /W4 main.c /link /out:app.exe
方案二:GCC 或 Clang
也可以通过 MSYS2、MinGW-w64、LLVM 等方式安装 GCC 或 Clang。
安装后检查版本:
powershell
gcc --version
或者:
powershell
clang --version
3.2 Linux 环境
确认 GCC 是否存在:
bash
gcc --version
确认 Clang 是否存在:
bash
clang --version
确认 GDB 是否存在:
bash
gdb --version
不同 Linux 发行版的软件安装方式不同,应使用当前发行版的官方包管理器安装编译器和调试器。
3.3 macOS 环境
macOS 通常使用 Apple Clang。安装 Command Line Tools 后检查:
bash
clang --version
3.4 编辑器选择
可以使用:
- Visual Studio Code;
- Visual Studio;
- CLion;
- Vim;
- Neovim;
- Emacs;
- Code::Blocks;
- 任意能够保存纯文本文件的编辑器。
编辑器本身不是编译器。点击编辑器中的"运行"按钮时,背后仍然需要调用 GCC、Clang 或 MSVC 等编译器。
4. 第一个 C 语言程序
创建 hello.c:
c
#include <stdio.h>
int main(void)
{
printf("Hello, C!\n");
return 0;
}
使用 GCC 编译:
bash
gcc -std=c17 -Wall -Wextra -Wpedantic hello.c -o hello
Linux 或 macOS 运行:
bash
./hello
Windows PowerShell 运行:
powershell
.\hello.exe
输出:
text
Hello, C!
4.1 代码解释
c
#include <stdio.h>
#include 是预处理指令,表示引入标准输入输出头文件。printf 函数的声明位于 stdio.h 中。
c
int main(void)
main 是托管环境中程序的入口函数。
常见的标准形式有:
c
int main(void)
以及:
c
int main(int argc, char *argv[])
第二种形式可以接收命令行参数。
c
printf("Hello, C!\n");
printf 用于格式化输出,\n 表示换行。
c
return 0;
向操作系统返回状态码 0,通常表示程序正常结束。
4.2 注释
单行注释:
c
// 这是单行注释
多行注释:
c
/*
* 这是多行注释
*/
C 语言的多行注释不能直接嵌套。
5. C 程序的编译过程
一个 C 程序从源代码变成可执行文件,通常会经过以下阶段:
- 预处理;
- 编译;
- 汇编;
- 链接。
5.1 预处理
处理以下内容:
#include;#define;- 条件编译;
- 宏展开。
查看预处理结果:
bash
gcc -E main.c -o main.i
5.2 编译
编译器检查语法和类型,并把预处理后的 C 代码转换为汇编代码:
bash
gcc -S main.c -o main.s
5.3 汇编
汇编器把汇编代码转换为目标文件:
bash
gcc -c main.c -o main.o
目标文件中已经包含机器指令,但通常还不能独立运行。
5.4 链接
链接器把目标文件和依赖库组合成最终程序:
bash
gcc main.o -o app
多文件项目可以分别编译:
bash
gcc -c main.c -o main.o
gcc -c calculator.c -o calculator.o
gcc main.o calculator.o -o calculator
分别编译的好处是:只修改一个源文件时,通常不需要重新编译整个项目。
6. 变量、常量与基本数据类型
6.1 变量
变量是带有类型和名称的对象。
c
int age = 18;
double price = 19.99;
char grade = 'A';
声明但不显式初始化的局部自动变量,其值是不确定的,不能直接读取:
c
int value;
printf("%d\n", value); /* 错误:读取未初始化变量 */
正确做法:
c
int value = 0;
6.2 标识符规则
变量名、函数名等标识符通常需要满足:
- 由字母、数字和下划线组成;
- 不能以数字开头;
- 区分大小写;
- 不能使用关键字;
- 不要使用标准库保留名称。
例如:
c
int user_age;
int userAge;
int _temporary;
以下写法错误:
c
int 2users;
int return;
在全局作用域中以下划线开头的名称,以及包含双下划线的名称,通常属于实现保留范围,业务代码应尽量避免。
6.3 基本数据类型
| 类型 | 说明 | 常用输出格式 |
|---|---|---|
char |
字符或小整数 | %c |
signed char |
有符号字符类型 | %hhd |
unsigned char |
无符号字符类型或原始字节 | %hhu |
short |
短整型 | %hd |
unsigned short |
无符号短整型 | %hu |
int |
整型 | %d |
unsigned int |
无符号整型 | %u |
long |
长整型 | %ld |
unsigned long |
无符号长整型 | %lu |
long long |
长长整型 | %lld |
float |
单精度浮点数 | printf 中使用 %f |
double |
双精度浮点数 | %f |
long double |
扩展精度浮点数 | %Lf |
_Bool/bool |
布尔值 | 通常转换为 int 后用 %d |
void |
无具体值或通用指针基础类型 | 无 |
不同平台上的类型大小可能不同,不要想当然地认为 int 永远是 32 位、long 永远是 64 位。
可以使用 sizeof 查看大小:
c
#include <stdio.h>
int main(void)
{
printf("char: %zu bytes\n", sizeof(char));
printf("short: %zu bytes\n", sizeof(short));
printf("int: %zu bytes\n", sizeof(int));
printf("long: %zu bytes\n", sizeof(long));
printf("long long: %zu bytes\n", sizeof(long long));
printf("float: %zu bytes\n", sizeof(float));
printf("double: %zu bytes\n", sizeof(double));
return 0;
}
sizeof 的结果类型是 size_t,输出时应使用 %zu。
6.4 固定宽度整数
需要明确位数时,可以使用 stdint.h:
c
#include <stdint.h>
int8_t small;
int32_t number;
uint32_t flags;
int64_t large;
精确宽度类型只有在目标平台存在对应类型时才会被定义,不过现代常用平台通常都提供这些类型。
输出固定宽度整数时,推荐使用 inttypes.h 中的格式宏:
c
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
int main(void)
{
int64_t value = INT64_C(9000000000);
printf("value = %" PRId64 "\n", value);
return 0;
}
6.5 布尔类型
在 C17 中使用:
c
#include <stdbool.h>
bool enabled = true;
if (enabled) {
/* 执行操作 */
}
6.6 常量
使用 const 表示通过当前标识符不能修改对象:
c
const double pi = 3.141592653589793;
const 并不一定意味着编译期常量,也不等同于预处理宏。
枚举常量适合定义整数常量:
c
enum {
MAX_RETRY = 3,
BUFFER_SIZE = 1024
};
6.7 整数范围
使用 limits.h:
c
#include <limits.h>
#include <stdio.h>
int main(void)
{
printf("INT_MIN = %d\n", INT_MIN);
printf("INT_MAX = %d\n", INT_MAX);
printf("UINT_MAX = %u\n", UINT_MAX);
return 0;
}
6.8 类型转换
隐式转换:
c
int count = 10;
double result = count;
显式转换:
c
double total = 10.0;
int value = (int)total;
整数除法需要特别注意:
c
int a = 5;
int b = 2;
printf("%d\n", a / b); /* 2 */
printf("%.2f\n", (double)a / b); /* 2.50 */
不要用强制类型转换掩盖编译器警告。转换前应确认数值范围、符号和目标类型是否匹配。
7. 输入与输出
7.1 printf 格式化输出
c
#include <stdio.h>
int main(void)
{
int age = 20;
double score = 95.5;
char grade = 'A';
const char *name = "Alice";
printf("name = %s\n", name);
printf("age = %d\n", age);
printf("score = %.2f\n", score);
printf("grade = %c\n", grade);
return 0;
}
常见格式:
| 格式 | 含义 |
|---|---|
%d |
int |
%u |
unsigned int |
%ld |
long |
%lld |
long long |
%f |
printf 中的 double |
%e |
科学计数法 |
%c |
字符 |
%s |
以 \0 结尾的字符串 |
%p |
指针 |
%zu |
size_t |
%% |
百分号 |
打印指针时应转换为 void *:
c
int value = 10;
int *pointer = &value;
printf("%p\n", (void *)pointer);
格式说明符与参数类型不匹配可能产生未定义行为。
7.2 scanf 的基本使用
c
#include <stdio.h>
int main(void)
{
int age;
printf("请输入年龄:");
if (scanf("%d", &age) == 1) {
printf("年龄:%d\n", age);
} else {
printf("输入无效\n");
}
return 0;
}
scanf 需要接收变量地址,因此 age 前面必须写 &。
但是,scanf 在处理错误输入、残留换行符和字符串边界时比较容易出错。业务程序通常更适合先使用 fgets 读取整行,再解析内容。
7.3 使用 fgets 读取一行
c
#include <stdio.h>
#include <string.h>
int main(void)
{
char name[64];
printf("请输入姓名:");
if (fgets(name, sizeof name, stdin) == NULL) {
fprintf(stderr, "读取失败\n");
return 1;
}
name[strcspn(name, "\r\n")] = '\0';
printf("你好,%s!\n", name);
return 0;
}
fgets 最多读取"数组容量减一"个字符,并在末尾添加 \0。
7.4 使用 strtol 解析整数
相比 atoi,strtol 能够检查错误和溢出:
c
#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
static bool parse_int(const char *text, int *result)
{
char *end = NULL;
errno = 0;
long value = strtol(text, &end, 10);
if (text == end || errno == ERANGE ||
value < INT_MIN || value > INT_MAX) {
return false;
}
while (isspace((unsigned char)*end)) {
++end;
}
if (*end != '\0') {
return false;
}
*result = (int)value;
return true;
}
int main(void)
{
char input[64];
int value;
printf("请输入整数:");
if (fgets(input, sizeof input, stdin) == NULL) {
return 1;
}
if (!parse_int(input, &value)) {
fprintf(stderr, "输入不是有效整数\n");
return 1;
}
printf("value = %d\n", value);
return 0;
}
8. 运算符与表达式
8.1 算术运算符
c
+ - * / %
示例:
c
int a = 10;
int b = 3;
printf("%d\n", a + b);
printf("%d\n", a - b);
printf("%d\n", a * b);
printf("%d\n", a / b);
printf("%d\n", a % b);
% 只能用于整数类型。
8.2 关系运算符
c
== != > < >= <=
比较结果为真时得到 1,为假时得到 0。
不要把比较写成赋值:
c
if (value = 10) {
/* value 被赋值为 10,条件通常为真 */
}
正确写法:
c
if (value == 10) {
/* value 等于 10 */
}
8.3 逻辑运算符
c
&& || !
&& 和 || 具有短路特性:
c
if (pointer != NULL && *pointer > 0) {
/* 只有 pointer 非空时才会解引用 */
}
8.4 自增和自减
c
++value;
value++;
--value;
value--;
单独作为一条语句时,前置和后置自增通常效果相同:
c
value++;
++value;
在复杂表达式中不要反复修改同一个变量:
c
/* 不要这样写 */
value = value++ + 1;
这种代码涉及未定义行为。
8.5 复合赋值
c
value += 10;
value -= 5;
value *= 2;
value /= 2;
value %= 3;
8.6 三元运算符
c
条件 ? 表达式1 : 表达式2
示例:
c
int max = a > b ? a : b;
复杂逻辑不应过度嵌套三元运算符,否则会降低可读性。
8.7 浮点数比较
浮点数存在精度误差,不应总是直接使用 ==:
c
#include <math.h>
#include <stdbool.h>
static bool nearly_equal(double a, double b, double epsilon)
{
return fabs(a - b) <= epsilon;
}
对于数值跨度较大的场景,需要结合绝对误差和相对误差设计比较规则。
9. 条件判断与循环
9.1 if 语句
c
if (score >= 90) {
printf("优秀\n");
} else if (score >= 60) {
printf("及格\n");
} else {
printf("不及格\n");
}
即使代码块只有一行,也建议保留大括号。
9.2 switch 语句
c
switch (command) {
case 1:
printf("新增\n");
break;
case 2:
printf("删除\n");
break;
case 3:
printf("查询\n");
break;
default:
printf("未知命令\n");
break;
}
忘记 break 会继续执行后续分支,这称为贯穿。如果确实需要贯穿,应添加清晰注释。
9.3 for 循环
c
for (int i = 0; i < 10; ++i) {
printf("%d\n", i);
}
遍历数组时更适合使用 size_t:
c
int numbers[] = {10, 20, 30, 40};
size_t length = sizeof numbers / sizeof numbers[0];
for (size_t i = 0; i < length; ++i) {
printf("%d\n", numbers[i]);
}
9.4 while 循环
c
int value = 0;
while (value < 5) {
printf("%d\n", value);
++value;
}
9.5 do-while 循环
c
int command;
do {
printf("请输入命令,输入 0 退出:");
scanf("%d", &command);
} while (command != 0);
do-while 至少执行一次循环体。
9.6 break 与 continue
c
for (int i = 0; i < 10; ++i) {
if (i == 3) {
continue;
}
if (i == 8) {
break;
}
printf("%d\n", i);
}
continue:跳过当前一轮;break:结束当前循环或switch。
10. 函数、作用域与存储期
10.1 定义函数
c
int add(int a, int b)
{
return a + b;
}
调用:
c
int result = add(10, 20);
10.2 函数声明
函数在调用前需要有可见声明:
c
int add(int a, int b);
int main(void)
{
int result = add(10, 20);
return result == 30 ? 0 : 1;
}
int add(int a, int b)
{
return a + b;
}
在现代 C 代码中,不接收参数的函数应明确写成:
c
int get_value(void);
10.3 C 语言按值传递
C 语言的函数参数都是按值传递:
c
static void change(int value)
{
value = 100;
}
int main(void)
{
int number = 10;
change(number);
/* number 仍然是 10 */
return 0;
}
如果希望修改调用者的数据,需要传递地址:
c
static void change(int *value)
{
if (value != NULL) {
*value = 100;
}
}
int main(void)
{
int number = 10;
change(&number);
/* number 现在是 100 */
return 0;
}
"传递指针"本质上仍是按值传递,只不过复制的值是一个地址。
10.4 作用域
常见作用域包括:
- 块作用域;
- 文件作用域;
- 函数作用域;
- 函数原型作用域。
示例:
c
int global_value = 10;
int main(void)
{
int local_value = 20;
if (local_value > 0) {
int block_value = 30;
printf("%d\n", block_value);
}
return 0;
}
block_value 只在 if 的代码块中可见。
10.5 static 的不同作用
文件作用域中的 static 表示内部链接,只在当前源文件中可见:
c
static int internal_counter;
局部变量上的 static 表示对象具有静态存储期,函数结束后其值仍会保留:
c
static int next_id(void)
{
static int id = 0;
return ++id;
}
10.6 extern
extern 通常用于声明定义在其他源文件中的对象:
c
extern int global_count;
全局变量应谨慎使用。相比共享可变全局状态,通过函数参数和返回值传递数据通常更容易测试和维护。
10.7 生命周期与存储期
C 语言标准关注的是对象的存储期,常见类型包括:
- 自动存储期;
- 静态存储期;
- 线程存储期;
- 分配存储期。
平时所说的"栈"和"堆"是常见实现方式,但不是所有平台都必须按照完全相同的方式实现。
当对象生命周期结束后,指向它的指针就不能再被解引用。
错误示例:
c
static int *wrong(void)
{
int local = 10;
return &local;
}
local 在函数结束后失效,返回它的地址会形成悬空指针。
11. 数组与字符串
11.1 一维数组
c
int numbers[5] = {10, 20, 30, 40, 50};
下标从 0 开始:
c
printf("%d\n", numbers[0]);
printf("%d\n", numbers[4]);
访问 numbers[5] 会越界,属于未定义行为。
11.2 计算数组长度
在数组仍然是数组的作用域中,可以使用:
c
size_t length = sizeof numbers / sizeof numbers[0];
常见宏:
c
#define ARRAY_LENGTH(array) (sizeof(array) / sizeof((array)[0]))
这个宏只能用于真正的数组,不能用于指针。
11.3 数组作为函数参数
c
static int sum(const int numbers[], size_t length)
{
int result = 0;
for (size_t i = 0; i < length; ++i) {
result += numbers[i];
}
return result;
}
在函数参数中,const int numbers[] 会调整为指针类型。函数无法仅通过该参数知道数组长度,因此需要额外传递 length。
11.4 二维数组
c
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
遍历:
c
for (size_t row = 0; row < 2; ++row) {
for (size_t column = 0; column < 3; ++column) {
printf("%d ", matrix[row][column]);
}
putchar('\n');
}
11.5 C 字符串
C 语言字符串本质上是以空字符 \0 结尾的字符序列:
c
char name[] = "Alice";
在内存中相当于:
c
char name[] = {'A', 'l', 'i', 'c', 'e', '\0'};
数组需要为结尾的 \0 预留空间。
错误示例:
c
char name[5] = "Alice";
这个数组没有空间保存结尾的空字符,因此不能安全地作为普通 C 字符串使用。
11.6 常见字符串函数
需要包含:
c
#include <string.h>
常见函数:
| 函数 | 作用 |
|---|---|
strlen |
计算字符串长度,不包含 \0 |
strcmp |
比较字符串 |
strchr |
查找字符 |
strstr |
查找子串 |
memcpy |
复制不重叠内存 |
memmove |
复制可能重叠的内存 |
memset |
按字节设置内存 |
memcmp |
比较内存 |
比较字符串不能使用 ==:
c
if (strcmp(username, "admin") == 0) {
printf("用户名匹配\n");
}
11.7 安全复制字符串
strcpy 不检查目标容量,使用不当容易越界。
可以使用 snprintf:
c
char destination[32];
const char *source = "hello";
int written = snprintf(destination, sizeof destination, "%s", source);
if (written < 0) {
fprintf(stderr, "格式化失败\n");
} else if ((size_t)written >= sizeof destination) {
fprintf(stderr, "字符串被截断\n");
}
strncpy 也不是简单、安全的 strcpy 替代品:当源字符串过长时,它可能不会自动添加 \0。
12. 指针详解
指针是 C 语言最重要的知识点之一。
12.1 指针是什么
指针变量保存某个对象的地址:
c
int value = 10;
int *pointer = &value;
&value:取得value的地址;pointer:保存地址;*pointer:访问该地址上的对象。
c
printf("%d\n", *pointer);
*pointer = 20;
printf("%d\n", value);
12.2 空指针
c
int *pointer = NULL;
解引用空指针属于未定义行为:
c
/* 错误 */
printf("%d\n", *pointer);
应先检查:
c
if (pointer != NULL) {
printf("%d\n", *pointer);
}
12.3 指针与数组
数组在大多数表达式中会转换为指向首元素的指针:
c
int numbers[] = {10, 20, 30};
int *pointer = numbers;
printf("%d\n", *pointer);
printf("%d\n", *(pointer + 1));
printf("%d\n", pointer[2]);
下面两种写法含义相同:
c
numbers[index]
*(numbers + index)
指针运算以所指向类型的元素大小为单位,而不是简单增加一个字节。
12.4 指针运算边界
对数组元素的指针可以在数组范围内移动,也可以形成"尾后指针":
c
int numbers[] = {10, 20, 30};
int *begin = numbers;
int *end = numbers + 3;
for (int *current = begin; current != end; ++current) {
printf("%d\n", *current);
}
尾后指针可以用于比较,但不能解引用。
不要让指针在无关对象之间随意运算。
12.5 const 与指针
指向常量的指针:
c
const int *pointer;
可以修改 pointer 指向哪里,但不能通过它修改目标对象。
常量指针:
c
int *const pointer = &value;
可以修改目标对象,但不能修改 pointer 本身保存的地址。
指向常量的常量指针:
c
const int *const pointer = &value;
既不能通过它修改目标对象,也不能修改它保存的地址。
可以这样记忆:
text
const 在 * 左边:不能通过指针修改目标值
const 在 * 右边:不能修改指针本身
12.6 void 指针
void * 是通用对象指针:
c
int value = 10;
void *generic = &value;
int *integer_pointer = generic;
在标准 C 中,对象指针和 void * 之间可以隐式转换。
void * 不能直接解引用,因为编译器不知道目标对象类型。
12.7 二级指针
二级指针保存一级指针的地址:
c
int value = 10;
int *pointer = &value;
int **pointer_to_pointer = &pointer;
printf("%d\n", **pointer_to_pointer);
二级指针常用于:
- 修改调用者的指针;
- 动态二维结构;
- 字符串数组;
- 链表和树结构;
- 输出参数。
示例:
c
#include <stdlib.h>
static int create_number(int **output)
{
if (output == NULL) {
return 0;
}
int *value = malloc(sizeof *value);
if (value == NULL) {
return 0;
}
*value = 100;
*output = value;
return 1;
}
12.8 指针与字符串字面量
c
const char *text = "hello";
字符串字面量不能被修改:
c
/* 错误:未定义行为 */
char *text = "hello";
text[0] = 'H';
如果需要修改,应使用数组:
c
char text[] = "hello";
text[0] = 'H';
13. 结构体、联合体、枚举与 typedef
13.1 结构体
结构体可以组合多个不同类型的成员:
c
struct User {
int id;
char name[64];
double balance;
};
使用:
c
struct User user = {
.id = 1,
.name = "Alice",
.balance = 100.50
};
printf("%d\n", user.id);
printf("%s\n", user.name);
printf("%.2f\n", user.balance);
推荐使用指定成员初始化,减少成员顺序变化带来的问题。
13.2 结构体指针
c
struct User *pointer = &user;
printf("%s\n", pointer->name);
以下两种写法等价:
c
pointer->name
(*pointer).name
13.3 结构体复制
相同类型的结构体可以直接赋值:
c
struct User copy = user;
这是按值复制成员。若结构体中包含指针,只会复制指针值,不会自动复制指针所指向的数据。
这类复制称为浅复制。
13.4 结构体内存对齐
结构体成员之间可能存在填充字节:
c
struct Example {
char flag;
int value;
};
它的大小不一定等于 sizeof(char) + sizeof(int)。
不要把结构体的内存布局想当然地当作稳定的磁盘格式或网络协议格式,因为它可能受到以下因素影响:
- 编译器;
- 处理器架构;
- 对齐规则;
- 字节序;
- 编译选项。
13.5 typedef
typedef 可以为类型创建别名:
c
typedef struct {
int id;
char name[64];
} User;
之后可以直接写:
c
User user;
typedef 只是类型别名,不会创建运行时对象。
13.6 枚举
c
typedef enum {
STATUS_PENDING,
STATUS_RUNNING,
STATUS_SUCCESS,
STATUS_FAILED
} Status;
使用:
c
Status status = STATUS_RUNNING;
枚举比散落的魔法数字更容易阅读。
不要默认枚举对象在所有平台上都具有固定字节数。如果需要持久化或传输,应明确设计编码格式。
13.7 联合体
联合体的所有成员共享同一块内存:
c
union Value {
int integer;
double decimal;
};
同一时间通常只应读取最后写入的有效成员。
实际开发中可以使用"标签加联合体":
c
typedef enum {
VALUE_INTEGER,
VALUE_DECIMAL
} ValueType;
typedef struct {
ValueType type;
union {
int integer;
double decimal;
} data;
} Value;
读取前根据 type 判断:
c
if (value.type == VALUE_INTEGER) {
printf("%d\n", value.data.integer);
}
14. 动态内存管理
14.1 malloc
c
#include <stdlib.h>
int *numbers = malloc(10 * sizeof *numbers);
if (numbers == NULL) {
/* 内存申请失败 */
}
在 C 语言中,不需要对 malloc 返回值进行强制类型转换:
c
/* 推荐 */
int *numbers = malloc(10 * sizeof *numbers);
不推荐:
c
int *numbers = (int *)malloc(10 * sizeof(int));
使用 sizeof *numbers 可以降低修改类型后忘记同步调整分配表达式的风险。
14.2 calloc
c
int *numbers = calloc(10, sizeof *numbers);
calloc 会把分配区域的所有字节初始化为零。
需要注意,"所有字节为零"不应在高度可移植的代码中被想当然地解释为所有类型都具有某个语义值。
14.3 free
c
free(numbers);
numbers = NULL;
free(NULL) 是安全的。
同一块内存不能释放两次:
c
free(numbers);
free(numbers); /* 错误 */
释放后也不能继续访问:
c
free(numbers);
printf("%d\n", numbers[0]); /* 错误 */
把当前指针设置为 NULL 能降低误用风险,但不会自动清除指向同一内存的其他别名指针。
14.4 realloc
realloc 用于调整已分配区域的大小:
c
size_t new_count = 20;
if (new_count > SIZE_MAX / sizeof *numbers) {
/* 乘法会溢出 */
}
int *temporary = realloc(numbers, new_count * sizeof *numbers);
if (temporary == NULL) {
/* 原来的 numbers 仍然有效 */
} else {
numbers = temporary;
}
不要直接写:
c
numbers = realloc(numbers, new_size);
如果分配失败,返回的 NULL 会覆盖原指针,导致原内存无法释放。
14.5 防止分配大小溢出
c
#include <stdint.h>
#include <stdlib.h>
static int *allocate_numbers(size_t count)
{
if (count > SIZE_MAX / sizeof(int)) {
return NULL;
}
return malloc(count * sizeof(int));
}
14.6 常见内存问题
| 问题 | 说明 |
|---|---|
| 内存泄漏 | 申请后失去地址,没有释放 |
| 越界访问 | 访问分配区域之外的位置 |
| 释放后使用 | free 后继续读写 |
| 重复释放 | 同一内存释放多次 |
| 空指针解引用 | 对 NULL 使用 * 或 -> |
| 悬空指针 | 指针指向生命周期已经结束的对象 |
| 分配大小溢出 | 元素数量乘元素大小时溢出 |
14.7 所有权约定
C 语言没有自动所有权系统,项目应明确约定:
- 谁申请内存;
- 谁负责释放;
- 函数是借用指针还是接管指针;
- 返回值是否需要调用者释放;
- 指针是否允许为空;
- 数组长度由哪个参数表示。
例如:
c
/*
* 返回新分配的字符串。
* 调用者负责调用 free。
* 失败时返回 NULL。
*/
char *create_message(const char *name);
清晰的接口契约比仅仅写出能够编译的函数更重要。
15. 预处理器与宏
15.1 对象宏
c
#define BUFFER_SIZE 1024
#define APP_NAME "c-demo"
预处理器只是进行文本级处理,宏本身没有普通变量那样的类型。
15.2 函数式宏
错误示例:
c
#define SQUARE(x) x * x
调用:
c
SQUARE(1 + 2)
展开后变成:
c
1 + 2 * 1 + 2
正确添加括号:
c
#define SQUARE(x) ((x) * (x))
但下面仍然有问题:
c
int value = 2;
int result = SQUARE(value++);
因为参数可能被计算多次。
可以优先使用内联函数:
c
static inline int square_int(int value)
{
return value * value;
}
15.3 多语句宏
c
#define LOG_ERROR(message) \
do { \
fprintf(stderr, \
"error: %s\n", \
(message)); \
} while (0)
do { ... } while (0) 能让多语句宏在 if-else 等场景中表现得更像普通语句。
15.4 条件编译
c
#ifdef DEBUG
printf("debug information\n");
#endif
根据平台编译:
c
#ifdef _WIN32
/* Windows 代码 */
#else
/* 非 Windows 代码 */
#endif
条件编译应集中管理。大量平台判断散落在业务代码中会降低可维护性。
15.5 头文件保护
c
#ifndef CALCULATOR_H
#define CALCULATOR_H
int add(int a, int b);
int subtract(int a, int b);
#endif
这可以防止同一个头文件被重复包含。
#pragma once 得到了许多编译器支持,但它不是传统 ISO C 头文件保护机制。需要最大可移植性时,可以使用 include guard。
16. 文件操作
16.1 打开文件
c
FILE *file = fopen("data.txt", "r");
if (file == NULL) {
perror("fopen");
return 1;
}
常见模式:
| 模式 | 含义 |
|---|---|
"r" |
只读,文件必须存在 |
"w" |
写入,不存在则创建,存在则截断 |
"a" |
追加,不存在则创建 |
"r+" |
读写,文件必须存在 |
"w+" |
读写,存在则截断 |
"a+" |
读取和追加 |
"rb" |
二进制只读 |
"wb" |
二进制写入 |
16.2 写入文本文件
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
FILE *file = fopen("output.txt", "w");
if (file == NULL) {
perror("fopen");
return EXIT_FAILURE;
}
if (fprintf(file, "name=%s\nage=%d\n", "Alice", 20) < 0) {
fprintf(stderr, "写入失败\n");
fclose(file);
return EXIT_FAILURE;
}
if (fclose(file) != 0) {
perror("fclose");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
关闭文件时也可能发现缓冲区写入失败,因此重要程序应检查 fclose 返回值。
16.3 按行读取
c
char line[256];
while (fgets(line, sizeof line, file) != NULL) {
printf("%s", line);
}
if (ferror(file)) {
fprintf(stderr, "读取文件失败\n");
}
不要使用以下模式:
c
while (!feof(file)) {
fgets(line, sizeof line, file);
}
feof 只有在读取操作尝试越过文件末尾后才会被设置。应以具体读取函数的返回值作为循环条件。
16.4 二进制读写
c
int numbers[] = {10, 20, 30};
size_t written = fwrite(
numbers,
sizeof numbers[0],
sizeof numbers / sizeof numbers[0],
file
);
读取:
c
int numbers[3];
size_t read_count = fread(
numbers,
sizeof numbers[0],
sizeof numbers / sizeof numbers[0],
file
);
直接把结构体内存写入文件通常不适合作为长期、跨平台格式,因为可能受到以下因素影响:
- 字节序;
- 类型宽度;
- 对齐和填充;
- 浮点格式;
- 编译器 ABI;
- 程序版本变化。
需要稳定格式时,应明确设计字段编码,或者使用经过设计的文本、二进制序列化协议。
16.5 标准流
C 程序通常具有三个标准流:
| 标准流 | 含义 |
|---|---|
stdin |
标准输入 |
stdout |
标准输出 |
stderr |
标准错误 |
错误信息通常输出到 stderr:
c
fprintf(stderr, "配置文件读取失败\n");
不要使用 fflush(stdin) 清理输入缓冲区。在 ISO C 中,这不是可移植的输入清理方式。
17. 函数指针与回调函数
函数也有地址,可以通过函数指针保存和调用。
17.1 基本语法
c
static int add(int a, int b)
{
return a + b;
}
int main(void)
{
int (*operation)(int, int) = add;
int result = operation(10, 20);
return result == 30 ? 0 : 1;
}
可以使用 typedef 简化:
c
typedef int (*BinaryOperation)(int, int);
17.2 回调函数
c
#include <stdio.h>
typedef void (*ResultHandler)(int result);
static void print_result(int result)
{
printf("result = %d\n", result);
}
static void calculate(
int a,
int b,
int (*operation)(int, int),
ResultHandler handler)
{
if (operation == NULL || handler == NULL) {
return;
}
handler(operation(a, b));
}
函数指针常用于:
- 排序比较器;
- 事件处理;
- 状态机;
- 驱动接口;
- 插件系统;
- 异步任务回调。
17.3 qsort 排序
c
#include <stdio.h>
#include <stdlib.h>
static int compare_int(const void *left, const void *right)
{
int a = *(const int *)left;
int b = *(const int *)right;
return (a > b) - (a < b);
}
int main(void)
{
int numbers[] = {5, 2, 9, 1, 3};
size_t length = sizeof numbers / sizeof numbers[0];
qsort(
numbers,
length,
sizeof numbers[0],
compare_int
);
for (size_t i = 0; i < length; ++i) {
printf("%d ", numbers[i]);
}
putchar('\n');
return 0;
}
不要简单写成:
c
return a - b;
如果数值差超出 int 范围,减法可能发生有符号整数溢出。
18. 位运算
位运算通常用于权限、状态标记、协议字段和嵌入式寄存器。
常见运算符:
| 运算符 | 含义 |
|---|---|
& |
按位与 |
| ` | ` |
^ |
按位异或 |
~ |
按位取反 |
<< |
左移 |
>> |
右移 |
建议对位运算使用无符号类型:
c
#include <stdbool.h>
#include <stdint.h>
enum {
PERMISSION_READ = 1u << 0,
PERMISSION_WRITE = 1u << 1,
PERMISSION_EXEC = 1u << 2
};
int main(void)
{
uint32_t permissions = 0;
/* 设置 */
permissions |= PERMISSION_READ;
permissions |= PERMISSION_WRITE;
/* 检查 */
bool can_read = (permissions & PERMISSION_READ) != 0;
/* 清除 */
permissions &= ~PERMISSION_WRITE;
/* 切换 */
permissions ^= PERMISSION_EXEC;
return can_read ? 0 : 1;
}
移位时需要保证:
- 移位数量不是负数;
- 移位数量小于类型位数;
- 不依赖有符号负数右移的具体行为;
- 不让有符号左移产生无法表示的结果。
19. 多文件项目与 Makefile
19.1 推荐目录结构
text
c-demo/
├── main.c
├── statistics.c
├── statistics.h
└── Makefile
19.2 头文件
statistics.h:
c
#ifndef STATISTICS_H
#define STATISTICS_H
#include <stdbool.h>
#include <stddef.h>
int statistics_sum(const int values[], size_t length);
bool statistics_average(
const int values[],
size_t length,
double *result
);
#endif
19.3 实现文件
statistics.c:
c
#include "statistics.h"
int statistics_sum(const int values[], size_t length)
{
int total = 0;
for (size_t i = 0; i < length; ++i) {
total += values[i];
}
return total;
}
bool statistics_average(
const int values[],
size_t length,
double *result)
{
if (values == NULL || result == NULL || length == 0) {
return false;
}
*result = (double)statistics_sum(values, length) / length;
return true;
}
19.4 主程序
main.c:
c
#include "statistics.h"
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int values[] = {10, 20, 30, 40};
size_t length = sizeof values / sizeof values[0];
double average;
if (!statistics_average(values, length, &average)) {
fprintf(stderr, "计算失败\n");
return EXIT_FAILURE;
}
printf("sum = %d\n", statistics_sum(values, length));
printf("average = %.2f\n", average);
return EXIT_SUCCESS;
}
19.5 手动编译
bash
gcc -std=c17 -Wall -Wextra -Wpedantic \
main.c statistics.c -o statistics-app
19.6 GNU Makefile
Makefile:
makefile
CC := gcc
CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -O2
LDFLAGS :=
LDLIBS :=
TARGET := statistics-app
OBJECTS := main.o statistics.o
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJECTS)
$(CC) $(LDFLAGS) $(OBJECTS) $(LDLIBS) -o $@
main.o: main.c statistics.h
$(CC) $(CFLAGS) -c main.c -o main.o
statistics.o: statistics.c statistics.h
$(CC) $(CFLAGS) -c statistics.c -o statistics.o
clean:
$(RM) $(OBJECTS) $(TARGET) $(TARGET).exe
Makefile 中命令前面的缩进必须是制表符 Tab,而不是普通空格。
执行:
bash
make
清理:
bash
make clean
19.7 头文件设计原则
- 头文件只暴露调用者需要的接口;
- 头文件应能够独立被包含;
- 使用 include guard;
- 不要在普通头文件中定义可变全局对象;
- 内部函数在
.c文件中使用static; - 公共接口明确空指针、长度、所有权和错误返回规则;
- 头文件包含自己真正需要的依赖,不依赖隐式包含顺序。
20. 错误处理与资源清理
C 语言没有统一异常机制,常见错误处理方式包括:
- 返回状态码;
- 返回空指针;
- 通过输出参数返回结果;
- 设置
errno; - 记录错误日志;
- 使用统一清理出口。
20.1 检查返回值
c
FILE *file = fopen("config.txt", "r");
if (file == NULL) {
perror("fopen");
return EXIT_FAILURE;
}
以下函数的返回值通常不应随意忽略:
malloc;realloc;fopen;fread;fwrite;fclose;scanf;fgets;snprintf;- 系统调用和网络接口。
20.2 errno
c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
FILE *file = fopen("missing.txt", "r");
if (file == NULL) {
perror("无法打开文件");
return EXIT_FAILURE;
}
fclose(file);
return EXIT_SUCCESS;
}
只有在被调用函数明确说明会设置 errno,并且函数返回失败时,errno 才有解释价值。
20.3 使用 goto 统一清理
在 C 语言中,goto 可以合理地用于集中清理资源:
c
#include <stdio.h>
#include <stdlib.h>
static int process_file(const char *path)
{
int result = EXIT_FAILURE;
FILE *file = NULL;
char *buffer = NULL;
file = fopen(path, "r");
if (file == NULL) {
perror("fopen");
goto cleanup;
}
buffer = malloc(1024);
if (buffer == NULL) {
fprintf(stderr, "内存分配失败\n");
goto cleanup;
}
if (fgets(buffer, 1024, file) == NULL && ferror(file)) {
fprintf(stderr, "读取失败\n");
goto cleanup;
}
printf("%s", buffer);
result = EXIT_SUCCESS;
cleanup:
free(buffer);
if (file != NULL && fclose(file) != 0) {
perror("fclose");
result = EXIT_FAILURE;
}
return result;
}
相比在每个错误分支中重复释放资源,统一清理出口更容易检查是否存在遗漏。
21. 未定义行为与常见安全问题
未定义行为意味着 C 标准不再约束程序结果。程序可能:
- 看似正常运行;
- 输出错误结果;
- 在另一种优化级别下失败;
- 在另一台机器上崩溃;
- 被编译器优化成意想不到的代码;
- 形成安全漏洞。
21.1 常见未定义行为
数组越界
c
int values[3] = {1, 2, 3};
values[3] = 4;
读取未初始化变量
c
int value;
printf("%d\n", value);
空指针解引用
c
int *pointer = NULL;
*pointer = 10;
释放后使用
c
int *pointer = malloc(sizeof *pointer);
free(pointer);
*pointer = 10;
重复释放
c
free(pointer);
free(pointer);
返回局部变量地址
c
int *wrong(void)
{
int value = 10;
return &value;
}
修改字符串字面量
c
char *text = "hello";
text[0] = 'H';
有符号整数溢出
c
int value = INT_MAX;
value += 1;
无符号整数按照其类型范围进行模运算,但这并不代表所有无符号回绕都是业务上正确的。
错误的 printf 格式
c
long value = 10;
printf("%d\n", value);
正确写法:
c
printf("%ld\n", value);
21.2 缓冲区溢出
危险代码:
c
char name[8];
scanf("%s", name);
输入过长会越界。
即使添加宽度,也要为 \0 预留空间:
c
char name[8];
scanf("%7s", name);
需要读取包含空格的一整行时,优先使用:
c
fgets(name, sizeof name, stdin);
21.3 格式化字符串漏洞
错误:
c
printf(user_input);
如果输入中包含 %s、%n 等格式符,可能导致错误访问甚至安全问题。
正确:
c
printf("%s", user_input);
21.4 不要使用 gets
gets 无法获知目标数组大小,已经从现代 C 标准中删除。任何新代码都不应使用它。
21.5 strcpy、strcat 与 sprintf
这些函数本身不接收目标容量,使用前必须能够严格证明空间充足。
在多数业务场景下,可以优先考虑:
fgets;snprintf;- 明确长度检查;
- 经过统一封装的字符串工具;
- 动态容量管理。
需要注意,部分带 _s 后缀的接口并非在所有编译器和标准库中都可用,不能把它们当作完全通用的跨平台方案。
21.6 memcpy 与 memmove
源区域和目标区域可能重叠时,不能使用 memcpy:
c
memmove(destination, source, count);
memmove 能处理重叠区域。
21.7 volatile 不是线程同步工具
volatile 主要用于告诉编译器:对象的值可能通过普通程序流之外的方式变化,例如某些内存映射硬件寄存器。
它不能代替:
- 互斥锁;
- 原子变量;
- 内存屏障;
- 正确的线程同步协议。
多线程共享数据应使用平台线程库、C 原子操作或其他明确的同步机制。
22. 调试、内存检查与测试
22.1 编译器警告
开发阶段建议开启警告:
bash
gcc -std=c17 \
-Wall \
-Wextra \
-Wpedantic \
-Wconversion \
-Wshadow \
-g \
main.c -o app
警告不一定都是错误,但每条警告都应该被理解,而不是随意关闭。
MSVC 可以使用:
powershell
cl /std:c17 /W4 /Zi main.c
22.2 使用 GDB
带调试信息编译:
bash
gcc -std=c17 -Wall -Wextra -g -O0 main.c -o app
启动 GDB:
bash
gdb ./app
常用命令:
text
break main
run
next
step
print variable
backtrace
continue
quit
22.3 AddressSanitizer
在支持的 GCC 或 Clang 环境中:
bash
gcc -std=c17 \
-Wall \
-Wextra \
-g \
-O1 \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
main.c -o app
运行程序:
bash
./app
Sanitizer 可以帮助发现:
- 越界访问;
- 释放后使用;
- 部分内存泄漏;
- 双重释放;
- 多类未定义行为。
具体可用功能受编译器、运行库和目标平台影响。
22.4 Valgrind
在支持 Valgrind 的环境中,先带调试信息编译:
bash
gcc -std=c17 -Wall -Wextra -g -O0 main.c -o app
检查:
bash
valgrind --leak-check=yes ./app
Valgrind Memcheck 可以帮助定位内存泄漏、越界访问和未初始化数据等问题。Windows 用户可以在合适的 Linux 或 WSL 环境中使用。
22.5 assert
c
#include <assert.h>
static int absolute_value(int value)
{
assert(value != INT_MIN);
return value < 0 ? -value : value;
}
assert 适合检查程序内部不变量,不适合代替用户输入校验。
定义 NDEBUG 后,断言可能被移除,因此断言表达式中不应包含必要的业务副作用。
22.6 简单单元测试
c
#include <assert.h>
static int add(int a, int b)
{
return a + b;
}
static void test_add(void)
{
assert(add(1, 2) == 3);
assert(add(-1, 1) == 0);
assert(add(0, 0) == 0);
}
int main(void)
{
test_add();
return 0;
}
正式项目还应测试:
- 正常路径;
- 空输入;
- 边界值;
- 最大长度;
- 内存申请失败;
- 文件不存在;
- 文件内容损坏;
- 重复调用;
- 资源释放;
- 不同编译器和不同优化级别。
23. 综合项目:命令行通讯录
下面实现一个简单的命令行通讯录,综合使用:
- 结构体;
- 动态数组;
- 指针;
- 字符串;
- 文件读写;
- 输入校验;
- 错误处理;
- 资源释放。
创建 contacts.c:
c
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define NAME_SIZE 64
#define PHONE_SIZE 32
#define INITIAL_CAPACITY 8
#define DATA_FILE "contacts.txt"
typedef struct {
char name[NAME_SIZE];
char phone[PHONE_SIZE];
} Contact;
typedef struct {
Contact *items;
size_t size;
size_t capacity;
} ContactBook;
static void book_init(ContactBook *book)
{
book->items = NULL;
book->size = 0;
book->capacity = 0;
}
static void book_destroy(ContactBook *book)
{
free(book->items);
book->items = NULL;
book->size = 0;
book->capacity = 0;
}
static bool book_reserve(ContactBook *book, size_t minimum)
{
if (book->capacity >= minimum) {
return true;
}
size_t new_capacity = book->capacity == 0
? INITIAL_CAPACITY
: book->capacity;
while (new_capacity < minimum) {
if (new_capacity > SIZE_MAX / 2) {
return false;
}
new_capacity *= 2;
}
if (new_capacity > SIZE_MAX / sizeof *book->items) {
return false;
}
Contact *new_items = realloc(
book->items,
new_capacity * sizeof *book->items
);
if (new_items == NULL) {
return false;
}
book->items = new_items;
book->capacity = new_capacity;
return true;
}
static bool contains_invalid_separator(const char *text)
{
return strchr(text, '\t') != NULL ||
strchr(text, '\n') != NULL ||
strchr(text, '\r') != NULL;
}
static bool book_add(
ContactBook *book,
const char *name,
const char *phone)
{
if (book == NULL || name == NULL || phone == NULL) {
return false;
}
if (name[0] == '\0' || phone[0] == '\0') {
return false;
}
if (strlen(name) >= NAME_SIZE ||
strlen(phone) >= PHONE_SIZE ||
contains_invalid_separator(name) ||
contains_invalid_separator(phone)) {
return false;
}
if (book->size == SIZE_MAX) {
return false;
}
if (!book_reserve(book, book->size + 1)) {
return false;
}
Contact *contact = &book->items[book->size];
snprintf(contact->name, sizeof contact->name, "%s", name);
snprintf(contact->phone, sizeof contact->phone, "%s", phone);
++book->size;
return true;
}
static void book_list(const ContactBook *book)
{
if (book->size == 0) {
printf("通讯录为空。\n");
return;
}
printf("\n%-6s %-20s %-20s\n", "序号", "姓名", "电话");
printf("------------------------------------------------\n");
for (size_t i = 0; i < book->size; ++i) {
printf(
"%-6zu %-20s %-20s\n",
i + 1,
book->items[i].name,
book->items[i].phone
);
}
}
static bool book_save(
const ContactBook *book,
const char *path)
{
FILE *file = fopen(path, "w");
if (file == NULL) {
return false;
}
bool success = true;
for (size_t i = 0; i < book->size; ++i) {
if (fprintf(
file,
"%s\t%s\n",
book->items[i].name,
book->items[i].phone
) < 0) {
success = false;
break;
}
}
if (fclose(file) != 0) {
success = false;
}
return success;
}
static bool book_load(ContactBook *book, const char *path)
{
FILE *file = fopen(path, "r");
if (file == NULL) {
return false;
}
ContactBook temporary;
book_init(&temporary);
char line[NAME_SIZE + PHONE_SIZE + 8];
bool success = true;
while (fgets(line, sizeof line, file) != NULL) {
line[strcspn(line, "\r\n")] = '\0';
char *separator = strchr(line, '\t');
if (separator == NULL) {
success = false;
break;
}
*separator = '\0';
const char *name = line;
const char *phone = separator + 1;
if (!book_add(&temporary, name, phone)) {
success = false;
break;
}
}
if (ferror(file)) {
success = false;
}
if (fclose(file) != 0) {
success = false;
}
if (!success) {
book_destroy(&temporary);
return false;
}
book_destroy(book);
*book = temporary;
return true;
}
static bool read_line(
const char *prompt,
char *buffer,
size_t capacity)
{
if (prompt != NULL) {
fputs(prompt, stdout);
}
if (fgets(buffer, capacity, stdin) == NULL) {
return false;
}
char *newline = strchr(buffer, '\n');
if (newline != NULL) {
*newline = '\0';
size_t length = strlen(buffer);
if (length > 0 && buffer[length - 1] == '\r') {
buffer[length - 1] = '\0';
}
return true;
}
/*
* 当前输入超过缓冲区容量时,丢弃本行剩余内容,
* 避免它影响下一次读取。
*/
int character;
while ((character = getchar()) != '\n' &&
character != EOF) {
/* 丢弃 */
}
return true;
}
static void add_contact_interactively(ContactBook *book)
{
char name[NAME_SIZE];
char phone[PHONE_SIZE];
if (!read_line("请输入姓名:", name, sizeof name)) {
printf("读取姓名失败。\n");
return;
}
if (!read_line("请输入电话:", phone, sizeof phone)) {
printf("读取电话失败。\n");
return;
}
if (book_add(book, name, phone)) {
printf("联系人添加成功。\n");
} else {
printf("添加失败,请检查输入长度或可用内存。\n");
}
}
static void print_menu(void)
{
printf("\n");
printf("========== 通讯录 ==========\n");
printf("1. 添加联系人\n");
printf("2. 查看联系人\n");
printf("3. 保存到文件\n");
printf("4. 从文件加载\n");
printf("0. 退出\n");
printf("============================\n");
}
int main(void)
{
ContactBook book;
book_init(&book);
char command[16];
bool running = true;
while (running) {
print_menu();
if (!read_line("请选择操作:", command, sizeof command)) {
putchar('\n');
break;
}
if (strcmp(command, "1") == 0) {
add_contact_interactively(&book);
} else if (strcmp(command, "2") == 0) {
book_list(&book);
} else if (strcmp(command, "3") == 0) {
if (book_save(&book, DATA_FILE)) {
printf("已保存到 %s。\n", DATA_FILE);
} else {
perror("保存失败");
}
} else if (strcmp(command, "4") == 0) {
if (book_load(&book, DATA_FILE)) {
printf(
"加载成功,共 %zu 个联系人。\n",
book.size
);
} else {
perror("加载失败");
}
} else if (strcmp(command, "0") == 0) {
running = false;
} else {
printf("未知命令,请重新输入。\n");
}
}
book_destroy(&book);
return EXIT_SUCCESS;
}
23.1 编译项目
GCC:
bash
gcc -std=c17 \
-Wall \
-Wextra \
-Wpedantic \
-Wconversion \
-g \
contacts.c -o contacts
Windows PowerShell 可以写成一行:
powershell
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -g contacts.c -o contacts.exe
MSVC:
powershell
cl /std:c17 /W4 /Zi contacts.c /link /out:contacts.exe
23.2 运行
Linux 或 macOS:
bash
./contacts
Windows PowerShell:
powershell
.\contacts.exe
23.3 项目中的关键设计
动态扩容
通讯录初始没有分配内存,第一次添加联系人时申请空间。容量不足后按照两倍扩容。
扩容前同时检查:
- 容量翻倍是否溢出;
- 元素数量乘元素大小是否溢出;
realloc是否成功。
临时指针接收 realloc
c
Contact *new_items = realloc(...);
只有 realloc 成功后,才更新原来的指针。
加载文件时使用临时通讯录
程序先把文件内容加载到临时对象。只有整个加载过程成功后,才替换当前通讯录。
这样可以避免文件内容损坏时把原有数据清空一半。
明确释放资源
程序退出前调用:
c
book_destroy(&book);
文件操作结束后调用:
c
fclose(file);
文件格式
当前项目使用:
text
姓名<Tab>电话
这是为了演示文本文件读写,并不适合所有生产场景。
真实项目还需要进一步考虑:
- 字段转义;
- 编码;
- 文件锁;
- 临时文件替换;
- 写入中断;
- 数据版本;
- 权限控制;
- 敏感信息保护。
24. C 语言学习路线
第一阶段:基础语法
学习内容:
- 程序结构;
- 变量与数据类型;
- 运算符;
if、switch;for、while;- 函数;
- 数组;
- 字符串。
练习项目:
- 计算器;
- 猜数字;
- 成绩统计;
- 九九乘法表;
- 文本菜单程序。
第二阶段:指针与内存
学习内容:
- 地址与解引用;
- 指针和数组;
- 指针和字符串;
- 二级指针;
const;malloc、calloc、realloc、free;- 生命周期与所有权。
练习项目:
- 动态数组;
- 字符串复制函数;
- 单向链表;
- 动态通讯录;
- 简单内存池。
第三阶段:数据结构
学习内容:
- 链表;
- 栈;
- 队列;
- 哈希表;
- 二叉树;
- 图;
- 排序和查找。
练习项目:
- 表达式计算器;
- LRU 缓存;
- 单词统计器;
- 文件索引工具。
第四阶段:工程化
学习内容:
- 头文件;
- 多文件编译;
- Makefile;
- 静态库和动态库;
- 调试器;
- Sanitizer;
- 单元测试;
- Git;
- 编码规范。
练习项目:
- 可复用数据结构库;
- 命令行参数解析器;
- 日志库;
- 配置文件解析器。
第五阶段:系统编程
学习内容:
- 进程和线程;
- 文件描述符;
- Socket;
- 信号;
- 内存映射;
- 同步机制;
- 操作系统 API;
- 网络协议。
练习项目:
- TCP 聊天程序;
- HTTP 服务器;
- 文件传输工具;
- 多线程任务队列;
- 简单 Shell。
推荐学习原则
- 每个示例都亲自编译;
- 始终开启编译器警告;
- 不忽略警告背后的原因;
- 主动测试空值和边界值;
- 经常使用调试器;
- 使用 Sanitizer 检查内存;
- 阅读优秀开源项目;
- 先保证正确,再讨论优化;
- 不要依赖"碰巧可以运行"的代码;
- 为资源所有权和错误返回编写清晰约定。
25. 常见面试题
25.1 数组和指针完全相同吗
不完全相同。
数组是包含固定数量元素的对象,指针是保存地址的对象。数组在大多数表达式中会转换为首元素指针,但仍存在明显区别:
c
int values[10];
int *pointer = values;
sizeof values; /* 整个数组大小 */
sizeof pointer; /* 指针本身大小 */
数组不能像普通指针变量一样被重新赋值。
25.2 malloc 和 calloc 有什么区别
malloc:
c
malloc(count * size);
申请指定字节数,内存内容未初始化。
calloc:
c
calloc(count, size);
接收元素数量和元素大小,并把分配区域按字节清零。
无论使用哪个函数,都需要检查返回值,并在不再使用时调用 free。
25.3 指针和引用有什么区别
标准 C 语言没有 C++ 那样的引用类型。C 语言通过指针间接访问和修改对象。
25.4 static 有什么作用
文件作用域:
c
static int value;
使名称具有内部链接,只在当前源文件中可见。
函数内部:
c
static int counter;
使局部变量具有静态存储期,函数调用结束后仍然保留值。
函数定义:
c
static void helper(void);
使函数只在当前源文件中可见。
25.5 const int *p 与 int *const p 有什么区别
c
const int *p;
不能通过 p 修改目标值,但可以让 p 指向其他对象。
c
int *const p = &value;
可以通过 p 修改目标值,但不能让 p 指向其他对象。
25.6 什么是野指针和悬空指针
野指针通常指没有初始化为有效地址的指针:
c
int *pointer;
悬空指针指曾经指向有效对象,但目标对象生命周期已经结束:
c
int *pointer = malloc(sizeof *pointer);
free(pointer);
25.7 为什么 free 后建议设置为 NULL
可以降低当前指针被再次解引用或重复释放的风险:
c
free(pointer);
pointer = NULL;
但如果还有其他指针指向同一块内存,它们仍然是悬空指针。
25.8 什么是内存泄漏
程序申请动态内存后,在不再使用时没有释放,并且失去了能够释放它的有效指针。
长期运行的程序不断泄漏会造成内存占用持续增长。
25.9 sizeof 和 strlen 有什么区别
c
char text[] = "hello";
此时:
c
sizeof text; /* 6,包括结尾的 \0 */
strlen(text); /* 5,不包括结尾的 \0 */
sizeof 是运算符,计算对象或类型所占字节数。
strlen 是标准库函数,会从传入地址开始查找第一个 \0。
25.10 memcpy 和 memmove 有什么区别
memcpy要求源区域和目标区域不重叠;memmove可以处理重叠区域。
无法证明不重叠时,应使用 memmove。
25.11 结构体为什么存在填充字节
处理器通常对不同类型有对齐要求。编译器可能在成员之间或结构体末尾插入填充字节,以满足对齐要求并提升访问效率。
25.12 C 是 C++ 的子集吗
不能简单地说标准 C 是标准 C++ 的严格子集。两种语言存在语法、类型规则、关键字和标准库方面的差异。
例如,C 中 void * 可以隐式转换为其他对象指针,而 C++ 通常要求显式转换。
C 源文件应使用 C 编译模式编译,不要因为某段 C 代码能被 C++ 编译器接受,就认为它一定符合标准 C。
25.13 为什么有些程序不开优化正常,开启优化后出错
常见原因是程序中存在未定义行为,例如:
- 越界访问;
- 未初始化读取;
- 有符号整数溢出;
- 违反别名规则;
- 释放后使用;
- 对同一变量进行未排序修改。
优化器可以假设程序没有未定义行为,因此原本"碰巧可用"的代码可能在优化后表现完全不同。
26. 总结
C 语言的语法并不算庞大,真正需要深入理解的是它背后的运行机制:
- 类型决定数据如何解释;
- 数组访问必须遵守边界;
- 字符串必须正确终止;
- 指针只能指向仍然有效的对象;
- 动态内存必须明确所有权;
- 每次资源申请都要考虑失败和清理;
- 文件、内存和系统调用都要检查返回值;
- 编译器警告不能随意忽略;
- 编译成功不代表不存在未定义行为;
- 性能优化必须建立在正确性和测量结果之上。
如果只记住一条原则,可以记住:
写 C 程序时,要始终知道数据在哪里、长度是多少、能够存活多久,以及由谁负责释放。
掌握这些概念后,再学习数据结构、操作系统、网络编程和嵌入式开发,会更加容易理解程序在计算机内部是如何运行的。