[C.C++] C语言从入门到精通【超详细】:基础语法、数组、指针、内存、结构体、文件与项目实战

83 0
Honkers 昨天 19:38 来自手机 | 显示全部楼层 |阅读模式

定位:这不是一篇只列语法的“知识点清单”,而是一份可以从头学习、也可以以后回来查阅的 C 语言长篇教程。文章重点放在 代码、内存模型、指针、常见错误和工程实践 上。

如果你已经会 Java / Python,也可以直接跳过非常基础的部分,把重心放到 数组、字符串、指针、内存管理、多文件工程和文件 I/O

目录
一、先建立完整学习地图
二、C语言到底是什么
三、开发环境与编译运行
四、C程序从源码到可执行文件
五、基础语法与第一个程序
六、变量、常量、数据类型与 sizeof
七、输入输出:printf、scanf 与常见坑
八、运算符与表达式
九、选择结构:if 与 switch
十、循环结构:for、while、do-while
十一、函数:参数、返回值、声明与作用域
十二、递归:函数调用自己到底发生了什么
十三、一维数组与二维数组
十四、字符串:字符数组、\0 与常用函数
十五、指针入门:地址、& 与 *
十六、指针与数组:arr 为什么等价于 *(arr+i)
十七、指针进阶:二级指针、指针数组、数组指针、函数指针
十八、内存模型:栈、堆、全局区与生命周期
十九、动态内存:malloc、calloc、realloc、free
二十、结构体、typedef、enum、union 与内存对齐
二十一、预处理、宏、static、extern 与多文件工程
二十二、文件操作:文本文件与二进制文件
二十三、调试与 C 语言高频错误
二十四、数据结构入门:手写单链表
二十五、综合项目:学生信息管理系统
二十六、C语言高频易错点与面试题
二十七、练习路线:从语法题到项目题
二十八、C语言核心速查表

一、先建立完整学习地图
很多人学 C 语言学到一半会觉得“知识越来越散”:变量、数组、函数、指针、malloc、结构体看起来像很多独立内容。其实它们可以串成一条很清晰的主线:
变量解决“单个数据放在哪里”;
数组解决“同类型数据连续存放”;
函数解决“代码如何拆分和复用”;
指针解决“如何直接使用地址访问对象”;
动态内存解决“运行时需要多少空间”;
结构体解决“把多种字段组合成一个对象”;
文件解决“程序退出后数据如何保留”;
多文件工程解决“代码规模变大后如何组织”。

学习时最值得优先攻克的 5 个难点
数组为什么从 0 开始;
C 字符串为什么必须有 \0;
指针为什么必须区分 int *、char *;
malloc 返回的到底是什么;
函数传参为什么修改不了外面的普通变量,却能通过指针修改。

建议:每看懂一段代码,都尝试回答“数据在哪里、地址是什么、生命周期到什么时候”。这是学 C 最有效的思路之一。
返回目录



二、C语言到底是什么
C 语言是一门编译型、过程式、接近底层但仍具备高级语言表达能力的编程语言。它允许你控制内存布局、地址和资源生命周期,因此在系统软件、嵌入式、数据库、编译器、网络库等领域长期存在。
C 与 Java / Python 最大的思维差异
对比项 C Java Python
内存管理 经常需要手动考虑 GC 为主 GC + 引用计数为主
指针 直接支持 不暴露裸指针 不暴露裸指针
编译方式 编译为机器相关程序 编译为字节码运行于 JVM 解释/字节码执行
字符串 字符数组 + \0 String 对象 str 对象
越界保护 很弱,需要程序员负责 有运行时检查 有运行时检查
C 最值得学习的不是“语法”,而是这些能力
明白值、地址和对象生命周期;
理解数组和内存连续性的关系;
理解函数调用和栈;
理解动态内存为什么会泄漏;
理解很多高级语言封装背后做了什么。
返回目录


三、开发环境与编译运行
学习 C 至少需要两个东西:编辑器和编译器。
Windows 推荐组合
Visual Studio 2022:适合初学者、调试体验好;
VS Code + GCC/MinGW:更轻量,也更方便理解编译命令;
CLion:工程体验好,但不是必须。
Linux / macOS 常见方式
终端直接使用 GCC 或 Clang 即可。
检查 GCC

  1. gcc --version
复制代码

编译并运行

  1. gcc hello.c -o hello
  2. ./hello
复制代码

Windows PowerShell 中通常运行:

  1. .\hello.exe
复制代码

建议从一开始就打开警告

  1. gcc -std=c17 -Wall -Wextra -pedantic hello.c -o hello
复制代码

-Wall -Wextra 能帮助你尽早发现未使用变量、可疑转换、格式符不匹配等问题。不要把编译警告当成“可以先不管”。
返回目录


四、C程序从源码到可执行文件
很多人会写 gcc main.c -o main,但不知道 GCC 背后做了什么。理解这一节后,你会更容易读懂“头文件错误”“重复定义”“undefined reference”等问题。

  1. 预处理
    处理 #include、#define、条件编译等。
  1. gcc -E hello.c -o hello.i
复制代码
  1. 编译
    把预处理后的 C 代码翻译成汇编代码。
  1. gcc -S hello.i -o hello.s
复制代码
  1. 汇编
    把汇编代码生成目标文件。
  1. gcc -c hello.s -o hello.o
复制代码
  1. 链接
    把目标文件和需要的库组合成可执行程序。
  1. gcc hello.o -o hello
复制代码

一个特别常见的错误:声明了函数但没有参与链接

  1. int add(int a, int b);
  2. int main(void) {
  3. return add(1, 2);
  4. }
复制代码

如果 add 的实现位于 math.c,只编译 main.c 就可能链接失败。应该:

  1. gcc main.c math.c -o app
复制代码

返回目录


五、基础语法与第一个程序

  1. #include <stdio.h>
  2. int main(void) {
  3. printf("Hello, C!\n");
  4. return 0;
  5. }
复制代码

#include
告诉编译器后续代码会使用标准输入输出库中声明的接口,例如 printf。
int main(void)
main 是程序入口。void 明确表示该版本不接收参数。
return 0
通常表示程序正常结束。
注释

  1. // 单行注释
  2. /*
  3. 多行注释
  4. */
复制代码

语句以分号结束

  1. int age = 21;
  2. printf("%d\n", age);
复制代码

代码块使用 {}

  1. if (age >= 18) {
  2. printf("adult\n");
  3. }
复制代码

写 C 代码时建议从一开始保持统一格式

  1. if (condition) {
  2. statement1();
  3. statement2();
  4. }
复制代码

良好的缩进不影响机器执行,但会极大影响你自己发现逻辑错误的速度。
返回目录


六、变量、常量、数据类型与 sizeof
变量可以理解为:一块有类型、有地址、当前保存某个值的存储区域。

常见基础类型

  1. char c = 'A';
  2. short s = 100;
  3. int age = 21;
  4. long count = 100000L;
  5. float price = 9.9f;
  6. double pi = 3.1415926;
复制代码

不要死记每个平台的字节数
C 标准对很多类型只规定最小范围和相对关系,因此应使用 sizeof 查看当前平台实际大小。

  1. #include <stdio.h>
  2. int main(void) {
  3. printf("char : %zu\n", sizeof(char));
  4. printf("short : %zu\n", sizeof(short));
  5. printf("int : %zu\n", sizeof(int));
  6. printf("long : %zu\n", sizeof(long));
  7. printf("float : %zu\n", sizeof(float));
  8. printf("double : %zu\n", sizeof(double));
  9. return 0;
  10. }
复制代码

signed 与 unsigned

  1. unsigned int count = 100U;
  2. signed int temperature = -10;
复制代码

unsigned 适合明确不需要负数的场景,但不要把它当作“更大范围的普通 int”随便混用。有符号和无符号混合比较很容易制造 bug。
常量

  1. const double PI = 3.1415926;
复制代码

const 的核心价值不是“神秘的常量语法”,而是告诉编译器和读代码的人:这里不应该被修改。
类型转换

  1. int a = 5;
  2. int b = 2;
  3. double result = (double)a / b;
复制代码

如果不做转换:

  1. double result = a / b;
复制代码

a / b 会先进行整数除法得到 2,再转成 2.0。
易错点:整数溢出

  1. int x = 100000;
  2. int y = 100000;
  3. long long result = (long long)x * y;
复制代码

如果先在 int 中完成乘法再转成 long long,可能已经溢出了。
返回目录


七、输入输出:printf、scanf 与常见坑
printf 常见格式符
格式符 常见用途
%d int
%u unsigned int
%ld long
%lld long long
%f printf 中输出浮点数
%c 字符
%s C 字符串
%p 地址,通常配 (void *)
%zu size_t

  1. #include <stdio.h>
  2. int main(void) {
  3. int age = 21;
  4. double score = 95.5;
  5. printf("age=%d, score=%.1f\n", age, score);
  6. return 0;
  7. }
复制代码

scanf 为什么经常要写 &

  1. int age;
  2. scanf("%d", &age);
复制代码

scanf 要修改调用者的变量,因此需要拿到这个变量的地址。
字符数组是例外之一

  1. char name[32];
  2. scanf("%31s", name);
复制代码

数组名在这里会转换为首元素地址,所以不再写 &name。
一定要检查返回值

  1. int age;
  2. if (scanf("%d", &age) != 1) {
  3. printf("输入无效\n");
  4. return 1;
  5. }
复制代码

为什么 %31s 比 %s 更好

  1. char name[32];
  2. scanf("%31s", name);
复制代码

最多读取 31 个非空白字符,留下 1 个位置给结尾 \0,可以降低缓冲区溢出的风险。
读取一整行更推荐 fgets

  1. #include <stdio.h>
  2. int main(void) {
  3. char line[128];
  4. if (fgets(line, sizeof line, stdin) != NULL) {
  5. printf("%s", line);
  6. }
  7. return 0;
  8. }
复制代码

返回目录


八、运算符与表达式
算术运算

  1. int a = 10;
  2. int b = 3;
  3. printf("%d\n", a + b);
  4. printf("%d\n", a - b);
  5. printf("%d\n", a * b);
  6. printf("%d\n", a / b);
  7. printf("%d\n", a % b);
复制代码

关系运算

  1. if (a >= b) {
  2. printf("a >= b\n");
  3. }
复制代码

逻辑运算

  1. if (age >= 18 && score >= 60) {
  2. printf("条件成立\n");
  3. }
复制代码

短路求值非常重要

  1. if (p != NULL && *p > 0) {
  2. printf("valid\n");
  3. }
复制代码

如果 p == NULL,右侧 *p > 0 不会执行,因此避免了空指针解引用。
++i 与 i++
单独作为语句时:

  1. ++i;
  2. i++;
复制代码

通常最终效果一样。区别主要体现在表达式求值时。

  1. int i = 5;
  2. int a = i++;
复制代码

此时 a 得到旧值 5,之后 i 变 6。
不要写过度复杂、依赖求值细节的表达式
可读性比“炫技”重要。复杂逻辑拆成多行,往往更安全。
位运算

  1. unsigned int flags = 0;
  2. flags |= 1u << 2;
  3. flags &= ~(1u << 2);
复制代码

位运算常用于标志位、协议、硬件寄存器和压缩表示。
返回目录


九、选择结构:if 与 switch

if

  1. if (score >= 60) {
  2. printf("pass\n");
  3. }
复制代码

if ... else

  1. if (score >= 60) {
  2. printf("pass\n");
  3. } else {
  4. printf("fail\n");
  5. }
复制代码

多分支

  1. if (score >= 90) {
  2. printf("A\n");
  3. } else if (score >= 80) {
  4. printf("B\n");
  5. } else if (score >= 60) {
  6. printf("C\n");
  7. } else {
  8. printf("D\n");
  9. }
复制代码

switch

  1. switch (choice) {
  2. case 1:
  3. printf("add\n");
  4. break;
  5. case 2:
  6. printf("delete\n");
  7. break;
  8. default:
  9. printf("unknown\n");
  10. break;
  11. }
复制代码

忘记 break 会发生什么

  1. switch (x) {
  2. case 1:
  3. printf("one\n");
  4. case 2:
  5. printf("two\n");
  6. break;
  7. }
复制代码

当 x == 1 时,执行完 case 1 后还会继续落入 case 2。这种行为叫 fall-through。有时它是故意设计的,但初学阶段大多数情况是 bug。
返回目录


十、循环结构:for、while、do-while
for
适合循环次数相对明确的场景。

  1. for (int i = 0; i < 5; ++i) {
  2. printf("%d\n", i);
  3. }
复制代码

while
适合“条件成立就继续”的场景。

  1. int n = 5;
  2. while (n > 0) {
  3. printf("%d\n", n);
  4. --n;
  5. }
复制代码

do-while
至少执行一次。

  1. int choice;
  2. do {
  3. printf("1. add\n0. exit\n");
  4. scanf("%d", &choice);
  5. } while (choice != 0);
复制代码

三种循环怎么选
需求 推荐
明确遍历 0 到 n-1 for
不确定执行次数,只看条件 while
菜单至少显示一次 do-while
break
立刻结束当前循环。

  1. for (int i = 0; i < 100; ++i) {
  2. if (i == 10) {
  3. break;
  4. }
  5. }
复制代码

continue
跳过本轮剩余语句,进入下一轮。

  1. for (int i = 0; i < 10; ++i) {
  2. if (i % 2 == 0) {
  3. continue;
  4. }
  5. printf("%d\n", i);
  6. }
复制代码

经典练习:九九乘法表

  1. #include <stdio.h>
  2. int main(void) {
  3. for (int i = 1; i <= 9; ++i) {
  4. for (int j = 1; j <= i; ++j) {
  5. printf("%d*%d=%-2d ", j, i, i * j);
  6. }
  7. printf("\n");
  8. }
  9. return 0;
  10. }
复制代码

返回目录


十一、函数:参数、返回值、声明与作用域
函数让代码从“所有逻辑挤在 main”变成可拆分、可复用、可测试的模块。

最基本的函数

  1. int add(int a, int b) {
  2. return a + b;
  3. }
复制代码

函数声明

  1. int add(int a, int b);
复制代码

声明告诉编译器函数的名字、参数和返回类型,实现可以放在后面甚至另一个 .c 文件。
C 参数传递的核心:值传递

  1. #include <stdio.h>
  2. void change(int x) {
  3. x = 100;
  4. }
  5. int main(void) {
  6. int a = 10;
  7. change(a);
  8. printf("%d\n", a);
  9. return 0;
  10. }
复制代码

输出仍然是 10。因为 change 拿到的是 a 的值的副本。
想修改外部变量怎么办

  1. #include <stdio.h>
  2. void change(int *p) {
  3. *p = 100;
  4. }
  5. int main(void) {
  6. int a = 10;
  7. change(&a);
  8. printf("%d\n", a);
  9. return 0;
  10. }
复制代码

现在函数虽然仍然是“值传递”,但传进去的值变成了地址。通过这个地址,就能修改原对象。
局部变量与全局变量

  1. int global_count = 0;
  2. void test(void) {
  3. int local_count = 10;
  4. }
复制代码

局部变量只在对应作用域内可见。全局变量可见范围更大,但也更容易造成模块耦合,实际工程不要滥用。
static 局部变量

  1. #include <stdio.h>
  2. void counter(void) {
  3. static int count = 0;
  4. ++count;
  5. printf("%d\n", count);
  6. }
  7. int main(void) {
  8. counter();
  9. counter();
  10. counter();
  11. return 0;
  12. }
复制代码

它的作用域仍在函数内部,但生命周期贯穿整个程序运行期。
返回目录


十二、递归:函数调用自己到底发生了什么
递归就是函数直接或间接调用自身。
阶乘

  1. long long factorial(int n) {
  2. if (n <= 1) {
  3. return 1;
  4. }
  5. return (long long)n * factorial(n - 1);
  6. }
复制代码

递归必须考虑两个东西:
终止条件;
每次调用是否更接近终止条件。
递归调用栈
计算 factorial(4):

  1. factorial(4)
  2. factorial(3)
  3. factorial(2)
  4. factorial(1)
复制代码

返回时逐层展开:

  1. 1 → 2 → 6 → 24
复制代码

递归不是越多越高级
深度过大会占用大量栈空间,甚至导致栈溢出。能用清晰循环解决的问题,不必强行递归。
斐波那契的低效递归

  1. long long fib(int n) {
  2. if (n <= 1) {
  3. return n;
  4. }
  5. return fib(n - 1) + fib(n - 2);
  6. }
复制代码

这个写法会重复计算大量相同子问题,更适合教学,不适合大规模实际计算。
返回目录


十三、一维数组与二维数组
数组是一段连续存放相同类型元素的内存区域。
一维数组

  1. int scores[5] = {90, 88, 76, 95, 82};
复制代码

访问:

  1. printf("%d\n", scores[0]);
复制代码

为什么下标从 0 开始
数组访问本质上与“首地址 + 偏移”相关。

  1. scores[i]
复制代码

可以理解为第 i 个元素距离首元素偏移 i * sizeof(int) 字节。
遍历数组

  1. #include <stdio.h>
  2. int main(void) {
  3. int arr[] = {10, 20, 30, 40, 50};
  4. size_t n = sizeof arr / sizeof arr[0];
  5. for (size_t i = 0; i < n; ++i) {
  6. printf("%d ", arr[i]);
  7. }
  8. printf("\n");
  9. return 0;
  10. }
复制代码

sizeof arr / sizeof arr[0] 什么时候能用
只能在 arr 仍然是真正数组对象的作用域里可靠使用。

  1. void print_array(int arr[]) {
  2. printf("%zu\n", sizeof arr);
  3. }
复制代码

函数参数里的 int arr[] 实际会调整为 int *arr,所以这里得到的是指针大小,不是整个数组大小。
二维数组

  1. int matrix[2][3] = {
  2. {1, 2, 3},
  3. {4, 5, 6}
  4. };
复制代码

遍历:

  1. for (int i = 0; i < 2; ++i) {
  2. for (int j = 0; j < 3; ++j) {
  3. printf("%d ", matrix[i][j]);
  4. }
  5. printf("\n");
  6. }
复制代码

二维数组在内存中仍然是连续布局,只是我们把它按“行”来解释。
返回目录


十四、字符串:字符数组、\0 与常用函数
C 语言没有像 Java String 那样的内建字符串对象。最常见的字符串表示方式是:以 \0 结尾的字符序列。

定义字符串

  1. char name[] = "hello";
复制代码

实际需要 6 个字符位置:

  1. h e l l o \0
复制代码

strlen 与 sizeof 完全不是一回事

  1. #include <stdio.h>
  2. #include <string.h>
  3. int main(void) {
  4. char s[] = "hello";
  5. printf("strlen = %zu\n", strlen(s));
  6. printf("sizeof = %zu\n", sizeof s);
  7. return 0;
  8. }
复制代码

典型结果:

  1. strlen = 5
  2. sizeof = 6
复制代码

strlen 数的是 \0 前的字符数;sizeof 看的是整个数组对象占多少字节。
常见字符串函数

  1. strlen(s);
  2. strcmp(a, b);
  3. strcpy(dest, src);
  4. strcat(dest, src);
复制代码

strcpy 的危险点

  1. char dest[4];
  2. strcpy(dest, "hello");
复制代码

目标空间装不下,会造成越界写入。C 不会自动扩容。
更推荐明确控制容量
如果允许,优先使用能显式限制输入长度的接口,并始终保证目标缓冲区有空间容纳结尾 \0。
字符串字面量不要当作可写数组

  1. char *p = "hello";
  2. p[0] = 'H';
复制代码

修改字符串字面量属于未定义行为。需要修改时:

  1. char p[] = "hello";
  2. p[0] = 'H';
复制代码

返回目录


十五、指针入门:地址、& 与 *
指针是 C 语言最重要的主题之一。它并不是“神秘高级语法”,本质只有一句话:指针变量用于保存地址。

最基本例子

  1. #include <stdio.h>
  2. int main(void) {
  3. int a = 10;
  4. int *p = &a;
  5. printf("a = %d\n", a);
  6. printf("&a = %p\n", (void *)&a);
  7. printf("p = %p\n", (void *)p);
  8. printf("*p = %d\n", *p);
  9. return 0;
  10. }
复制代码

四个表达式一定要分清
写法 含义
a 变量当前的值
&a 变量 a 的地址
p 指针变量中保存的地址
*p 访问 p 所指向的对象
通过指针修改对象

  1. int a = 10;
  2. int *p = &a;
  3. *p = 99;
复制代码

此时 a 也变成 99,因为 *p 指的就是 a 这个对象。
指针类型为什么重要

  1. int *pi;
  2. double *pd;
  3. char *pc;
复制代码

不同类型决定了解引用时如何解释内存,也影响指针加减时一次跨过多少字节。
空指针

  1. int *p = NULL;
复制代码

NULL 表示当前没有指向有效对象。绝对不要解引用 NULL。

  1. if (p != NULL) {
  2. printf("%d\n", *p);
  3. }
复制代码

野指针

  1. int *p;
  2. *p = 10;
复制代码

p 没有初始化就被解引用,这是高危错误。
返回目录


十六、指针与数组:arr 为什么等价于 *(arr+i)
在大多数表达式中,数组名会转换为指向首元素的指针。

  1. int arr[3] = {10, 20, 30};
  2. int *p = arr;
复制代码

两种访问形式

  1. arr[1]
  2. *(arr + 1)
复制代码

两者访问的是同一个元素。
指针移动不是简单“地址 + 1 字节”

  1. int *p = arr;
  2. ++p;
复制代码

p 会移动到下一个 int 元素,而不是只加一个字节。
用指针遍历数组

  1. #include <stdio.h>
  2. int main(void) {
  3. int arr[] = {1, 2, 3, 4, 5};
  4. int *p = arr;
  5. size_t n = sizeof arr / sizeof arr[0];
  6. for (size_t i = 0; i < n; ++i) {
  7. printf("%d ", *(p + i));
  8. }
  9. printf("\n");
  10. return 0;
  11. }
复制代码

数组不是指针
这是特别重要的结论。

  1. int arr[10];
  2. int *p = arr;
复制代码

arr 是数组对象,p 是指针对象。它们在很多表达式中表现相似,但 sizeof、可赋值性等方面完全不同。

  1. sizeof arr
  2. sizeof p
复制代码

通常不会相等。
数组传参

  1. void print_array(const int arr[], size_t n) {
  2. for (size_t i = 0; i < n; ++i) {
  3. printf("%d ", arr[i]);
  4. }
  5. }
复制代码

必须额外传入长度,因为函数无法从这个参数重新推导出原始数组总长度。
返回目录


十七、指针进阶:二级指针、指针数组、数组指针、函数指针

  1. 二级指针
  1. int a = 10;
  2. int *p = &a;
  3. int **pp = &p;
  4. printf("%d\n", **pp);
复制代码

二级指针最常见的使用场景之一,是函数需要修改调用者的指针变量本身。

  1. #include <stdlib.h>
  2. int create_array(int **out, size_t n) {
  3. *out = malloc(n * sizeof **out);
  4. return *out != NULL;
  5. }
复制代码

调用:

  1. int *arr = NULL;
  2. if (create_array(&arr, 10)) {
  3. free(arr);
  4. arr = NULL;
  5. }
复制代码
  1. 指针数组
  1. int *p[3];
复制代码

p 先和 [] 结合,所以它是数组,数组的每个元素都是 int *。
3. 数组指针

  1. int (*p)[3];
复制代码

括号让 *p 先结合,因此 p 是指针,它指向“包含 3 个 int 的数组”。

  1. 函数指针
  1. int add(int a, int b) {
  2. return a + b;
  3. }
  4. int main(void) {
  5. int (*operation)(int, int) = add;
  6. int result = operation(3, 4);
  7. return result == 7 ? 0 : 1;
  8. }
复制代码

函数指针常见于:
回调函数;
策略切换;
排序比较函数;
驱动/协议接口表。
5. const 与指针

  1. const int *p;
复制代码

不能通过 p 修改所指向的 int。

  1. int *const p = &a;
复制代码

p 自身不能再指向别处。

  1. const int *const p = &a;
复制代码

两者都限制。
返回目录


十八、内存模型:栈、堆、全局区与生命周期
学指针如果不理解生命周期,很容易出现“明明地址还在,为什么不能用”的问题。

栈上的典型对象

  1. void test(void) {
  2. int x = 10;
  3. }
复制代码

x 的生命周期到函数执行结束为止。
不能返回局部变量地址
错误示例:

  1. int *bad(void) {
  2. int x = 10;
  3. return &x;
  4. }
复制代码

函数结束后 x 生命周期已经结束,这个地址不能继续作为有效对象使用。
静态存储期

  1. static int count = 0;
复制代码

静态对象的生命周期贯穿整个程序运行期。
堆内存

  1. int *p = malloc(sizeof *p);
复制代码

堆对象不会因为当前函数返回就自动释放。它会一直存在,直到你 free,或者进程结束由操作系统整体回收。
一个重要思维模型
看到指针时不要只问:

“这个地址是多少?”
更应该问:
“这个地址对应的对象现在还活着吗?”
这就是 C 中大量悬空指针问题的根源。
返回目录



十九、动态内存:malloc、calloc、realloc、free
动态内存用于运行时才能确定需要多少空间的场景。

malloc

  1. #include <stdlib.h>
  2. int *arr = malloc(10 * sizeof *arr);
  3. if (arr == NULL) {
  4. return 1;
  5. }
复制代码

推荐 sizeof *arr,这样以后即使修改 arr 的类型,也更不容易漏改。
在 C 中通常不需要强制转换 malloc 返回值

  1. int *arr = malloc(n * sizeof *arr);
复制代码

void * 可以自动转换为对象指针类型。额外强制转换反而可能掩盖忘记包含 的问题。
calloc

  1. int *arr = calloc(n, sizeof *arr);
复制代码

它会为 n 个元素分配空间,并把分配到的字节初始化为全 0。
realloc
不要直接覆盖原指针:

  1. int *temp = realloc(arr, new_size * sizeof *arr);
  2. if (temp != NULL) {
  3. arr = temp;
  4. }
复制代码

如果写成:

  1. arr = realloc(arr, new_size * sizeof *arr);
复制代码

一旦失败返回 NULL,你可能丢失旧地址,造成内存泄漏。
free

  1. free(arr);
  2. arr = NULL;
复制代码

free 之后原来的堆对象已经不存在,再访问就是 use-after-free。
错误:重复释放

  1. free(p);
  2. free(p);
复制代码

属于未定义行为。
错误:释放栈变量

  1. int x = 10;
  2. free(&x);
复制代码

free 只能用于兼容的动态分配结果,不能随便释放任意地址。
一个完整动态数组示例

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. int main(void) {
  4. size_t n;
  5. printf("元素数量:");
  6. if (scanf("%zu", &n) != 1 || n == 0) {
  7. return 1;
  8. }
  9. int *arr = malloc(n * sizeof *arr);
  10. if (arr == NULL) {
  11. return 1;
  12. }
  13. for (size_t i = 0; i < n; ++i) {
  14. arr[i] = (int)(i + 1);
  15. }
  16. for (size_t i = 0; i < n; ++i) {
  17. printf("%d ", arr[i]);
  18. }
  19. printf("\n");
  20. free(arr);
  21. arr = NULL;
  22. return 0;
  23. }
复制代码

返回目录


二十、结构体、typedef、enum、union 与内存对齐
结构体:把多个字段组合成一个对象

  1. typedef struct {
  2. int id;
  3. char name[32];
  4. double score;
  5. } Student;
复制代码

使用

  1. Student s = {1001, "Alice", 95.5};
  2. printf("%d %s %.1f\n", s.id, s.name, s.score);
复制代码

结构体指针与 ->

  1. Student *p = &s;
  2. printf("%s\n", p->name);
复制代码

p->name 等价于:

  1. (*p).name
复制代码

typedef

  1. typedef unsigned long ulong;
复制代码

它创建的是类型别名,不是新建一个完全独立的底层类型。
enum

  1. typedef enum {
  2. STATUS_OK,
  3. STATUS_ERROR,
  4. STATUS_NOT_FOUND
  5. } Status;
复制代码

枚举比散落在代码里的“魔法数字”更有可读性。
union

  1. union Value {
  2. int i;
  3. double d;
  4. char c;
  5. };
复制代码

联合体成员共享同一段存储空间,同一时刻通常只把其中一种解释当作有效值。
结构体内存对齐

  1. struct Demo {
  2. char c;
  3. int i;
  4. short s;
  5. };
复制代码

结构体大小不一定等于 1 + 4 + 2。为了满足成员对齐要求,编译器可能插入 padding。

不要只凭示意图死记“永远是多少字节”,实际结果由平台 ABI、编译器和成员类型共同决定。使用 sizeof 验证当前环境。
返回目录



二十一、预处理、宏、static、extern 与多文件工程
宏定义

  1. #define MAX_STUDENTS 100
复制代码

宏是预处理阶段的文本替换,不是变量。
带参数宏最常见的坑:缺括号
错误:

  1. #define SQUARE(x) x * x
复制代码

调用:

  1. SQUARE(1 + 2)
复制代码

可能展开成:

  1. 1 + 2 * 1 + 2
复制代码

更安全:

  1. #define SQUARE(x) ((x) * (x))
复制代码

但宏仍可能对带副作用的参数产生问题,所以不要把所有小函数都改成宏。
头文件保护

  1. #ifndef STUDENT_H
  2. #define STUDENT_H
  3. /* declarations */
  4. #endif
复制代码

static 在文件作用域

  1. static int helper(void) {
  2. return 42;
  3. }
复制代码

这样可以限制符号只在当前源文件内部使用,降低模块之间的名字冲突。
extern

  1. extern int global_count;
复制代码

表示该对象的定义位于其他位置。实际工程中比起到处共享全局变量,更推荐通过函数接口管理模块状态。
多文件工程结构

  1. project/
  2. ├── main.c
  3. ├── student.h
  4. ├── student.c
  5. └── storage.c
复制代码

编译:

  1. gcc -std=c17 -Wall -Wextra -pedantic main.c student.c storage.c -o app
复制代码

.h 和 .c 的职责建议
文件 主要职责
.h 类型、宏、函数声明、对外接口
.c 函数实现、模块内部细节
main.c 程序入口与高层流程
返回目录


二十二、文件操作:文本文件与二进制文件
程序退出后,内存中的普通数据会消失。文件 I/O 让数据可以持久化。

打开文件

  1. FILE *fp = fopen("data.txt", "r");
  2. if (fp == NULL) {
  3. return 1;
  4. }
复制代码

常见模式
模式 含义
r 读,文件必须存在
w 写,通常截断原内容
a 追加
rb 二进制读
wb 二进制写
文本写入

  1. #include <stdio.h>
  2. int main(void) {
  3. FILE *fp = fopen("score.txt", "w");
  4. if (fp == NULL) {
  5. return 1;
  6. }
  7. fprintf(fp, "%d %s %.2f\n", 1001, "Alice", 95.5);
  8. fclose(fp);
  9. return 0;
  10. }
复制代码

文本读取

  1. char line[128];
  2. while (fgets(line, sizeof line, fp) != NULL) {
  3. printf("%s", line);
  4. }
复制代码

二进制写入结构体数组

  1. size_t written = fwrite(students, sizeof(Student), count, fp);
复制代码

二进制读取

  1. size_t count = fread(students, sizeof(Student), capacity, fp);
复制代码

注意:直接保存结构体二进制不是通用跨平台文件格式
原因包括:
字节序;
对齐与 padding;
类型大小;
结构体版本变化。
对于课程项目和同平台简单程序,这样做很方便;真正跨平台长期存储,应设计明确的序列化格式。
返回目录


二十三、调试与 C 语言高频错误
C 最可怕的不是“报错”,而是有些错误不一定立刻报错。

  1. 数组越界
  1. int arr[3] = {1, 2, 3};
  2. arr[3] = 99;
复制代码

有效下标只有 0、1、2。
2. NULL 解引用

  1. int *p = NULL;
  2. printf("%d\n", *p);
复制代码
  1. 野指针
  1. int *p;
  2. *p = 10;
复制代码
  1. 悬空指针
  1. int *p = malloc(sizeof *p);
  2. free(p);
  3. printf("%d\n", *p);
复制代码
  1. 内存泄漏
  1. void test(void) {
  2. int *p = malloc(100 * sizeof *p);
  3. if (p == NULL) {
  4. return;
  5. }
  6. }
复制代码

函数离开后地址丢失,再也无法 free。
6. = 和 ==

  1. if (x = 0) {
  2. }
复制代码

这是赋值,不是比较。编译器警告很重要。
7. 格式符不匹配

  1. double x = 3.14;
  2. printf("%d\n", x);
复制代码

格式符与实参类型不匹配会导致未定义行为。
8. 返回局部变量地址

  1. int *bad(void) {
  2. int x = 10;
  3. return &x;
  4. }
复制代码
  1. 忘记检查库函数结果
    malloc、fopen、scanf、fread 等都可能失败或只完成部分操作。
  2. 编译器警告和 Sanitizer
    GCC / Clang 调试阶段推荐:
  1. gcc -std=c17 -Wall -Wextra -Wpedantic -fsanitize=address,undefined -g main.c -o app
复制代码

AddressSanitizer / UndefinedBehaviorSanitizer 对越界、use-after-free、部分未定义行为非常有帮助。
返回目录


二十四、数据结构入门:手写单链表
数组的容量和内存布局比较固定,而链表通过节点和指针把数据连接起来。
节点定义

  1. typedef struct Node {
  2. int value;
  3. struct Node *next;
  4. } Node;
复制代码

创建节点

  1. #include <stdlib.h>
  2. Node *create_node(int value) {
  3. Node *node = malloc(sizeof *node);
  4. if (node == NULL) {
  5. return NULL;
  6. }
  7. node->value = value;
  8. node->next = NULL;
  9. return node;
  10. }
复制代码

头插法

  1. int push_front(Node **head, int value) {
  2. Node *node = create_node(value);
  3. if (node == NULL) {
  4. return 0;
  5. }
  6. node->next = *head;
  7. *head = node;
  8. return 1;
  9. }
复制代码

为什么是 Node **head?因为函数需要修改调用者保存的“头指针”本身。
遍历

  1. void print_list(const Node *head) {
  2. for (const Node *p = head; p != NULL; p = p->next) {
  3. printf("%d ", p->value);
  4. }
  5. printf("\n");
  6. }
复制代码

释放整个链表

  1. void destroy_list(Node **head) {
  2. Node *current = *head;
  3. while (current != NULL) {
  4. Node *next = current->next;
  5. free(current);
  6. current = next;
  7. }
  8. *head = NULL;
  9. }
复制代码

这一小节把前面大量知识真正串起来:
结构体;
结构体指针;
动态内存;
二级指针;
生命周期;
循环。
返回目录


二十五、综合项目:学生信息管理系统
最后用一个完整项目把核心知识串起来。
项目功能
添加学生;
删除学生;
按学号查询;
显示全部学生;
按成绩排序;
计算平均分;
启动时加载文件;
退出时保存文件。

工程结构

  1. student_system/
  2. ├── main.c
  3. ├── student.h
  4. ├── student.c
  5. ├── storage.c
  6. └── students.dat
复制代码

student.h

  1. #ifndef STUDENT_H
  2. #define STUDENT_H
  3. #include <stddef.h>
  4. #define NAME_LEN 32
  5. typedef struct {
  6. int id;
  7. char name[NAME_LEN];
  8. double score;
  9. } Student;
  10. int add_student(Student students[], size_t capacity, size_t *count);
  11. int delete_student(Student students[], size_t *count, int id);
  12. Student *find_student(Student students[], size_t count, int id);
  13. void print_students(const Student students[], size_t count);
  14. void sort_by_score(Student students[], size_t count);
  15. double average_score(const Student students[], size_t count);
  16. int save_students(const char *filename, const Student students[], size_t count);
  17. int load_students(const char *filename, Student students[], size_t capacity, size_t *count);
  18. #endif
复制代码

student.c 核心查询函数

  1. Student *find_student(Student students[], size_t count, int id) {
  2. for (size_t i = 0; i < count; ++i) {
  3. if (students[i].id == id) {
  4. return &students[i];
  5. }
  6. }
  7. return NULL;
  8. }
复制代码

删除学生

  1. int delete_student(Student students[], size_t *count, int id) {
  2. for (size_t i = 0; i < *count; ++i) {
  3. if (students[i].id == id) {
  4. for (size_t j = i + 1; j < *count; ++j) {
  5. students[j - 1] = students[j];
  6. }
  7. --(*count);
  8. return 1;
  9. }
  10. }
  11. return 0;
  12. }
复制代码

排序

  1. void sort_by_score(Student students[], size_t count) {
  2. for (size_t i = 0; i + 1 < count; ++i) {
  3. for (size_t j = 0; j + 1 < count - i; ++j) {
  4. if (students[j].score < students[j + 1].score) {
  5. Student temp = students[j];
  6. students[j] = students[j + 1];
  7. students[j + 1] = temp;
  8. }
  9. }
  10. }
  11. }
复制代码

文件保存

  1. int save_students(const char *filename, const Student students[], size_t count) {
  2. FILE *fp = fopen(filename, "wb");
  3. if (fp == NULL) {
  4. return 0;
  5. }
  6. size_t written = fwrite(students, sizeof(Student), count, fp);
  7. fclose(fp);
  8. return written == count;
  9. }
复制代码

编译整个工程

  1. gcc -std=c17 -Wall -Wextra -pedantic main.c student.c storage.c -o student_system
复制代码

完整源码已经放在配套源码包的 src/student_system/ 中。
这个项目真正训练了什么
知识点 在项目中的应用
结构体 Student 数据模型
数组 保存多名学生
指针 修改 count、返回查询结果
函数 拆分增删改查
文件 保存与恢复数据
头文件 暴露模块接口
多文件 分离 UI、业务与存储
返回目录


二十六、C语言高频易错点与面试题

  1. 数组和指针一样吗?
    不一样。数组是数组对象,指针是保存地址的对象。数组名只是在很多表达式中会转换为首元素指针。
  2. sizeof 和 strlen 有什么区别?
    sizeof:操作类型或对象大小;
    strlen:扫描 C 字符串直到 \0,返回字符数。
  3. malloc 返回的是什么?
    返回指向所分配内存块起始位置的 void *,失败返回 NULL。
  4. 为什么 free 后常把指针设为 NULL?
    free 只释放对象,不会自动修改你的指针变量。原指针会成为悬空指针。置空不能解决所有别名问题,但能减少再次误用该变量的风险。
  5. 为什么函数修改不了普通参数?
    因为参数是值传递。函数修改的是自己的形参副本。
  6. 为什么传指针后能修改?
    仍然是值传递,只是传入的“值”是地址。函数通过这个地址访问原对象。
  7. char *s = "abc" 与 char s[] = "abc" 有什么不同?
    前者 s 是指针,通常指向字符串字面量,不应该修改内容;后者创建一个可修改字符数组。
  8. 什么是未定义行为?
    标准不规定程序必须产生什么结果。例如数组越界、使用已释放对象、修改字符串字面量等。它不是“保证报错”,可能表现为崩溃、错误结果,甚至看起来暂时正常。
  9. 什么是内存泄漏?
    动态分配的内存失去所有可用于释放它的有效引用,程序无法再 free 这块内存。
  10. static 有哪些常见含义?
    函数内部:静态存储期,值跨调用保留;
    文件作用域:限制名称的链接属性,使它只在当前翻译单元内部使用。
    返回目录


二十七、练习路线:从语法题到项目题
第一阶段:基础控制
判断奇偶数;
判断闰年;
计算 1 到 n 的和;
输出九九乘法表;
求三个数最大值;
猜数字小游戏。
第二阶段:数组与字符串
求数组最大值和最小值;
数组逆序;
冒泡排序;
统计字符串中字母、数字、空格数量;
手写字符串长度函数;
判断回文字符串。
第三阶段:函数与指针
用函数交换两个变量;
用指针求数组和;
手写 my_strlen;
手写 my_strcpy;
使用二级指针动态创建数组。
第四阶段:结构体与文件
学生成绩排序;
通讯录;
图书管理;
保存结构体到文件;
启动程序时恢复数据。
第五阶段:工程化
把一个单文件项目拆成:

  1. main.c
  2. module.h
  3. module.c
  4. storage.c
复制代码

然后启用完整警告并尝试 AddressSanitizer。
返回目录


二十八、C语言核心速查表
基本程序

  1. #include <stdio.h>
  2. int main(void) {
  3. return 0;
  4. }
复制代码

输入输出

  1. int x;
  2. scanf("%d", &x);
  3. printf("%d\n", x);
复制代码

数组长度

  1. size_t n = sizeof arr / sizeof arr[0];
复制代码

指针

  1. int a = 10;
  2. int *p = &a;
  3. printf("%d\n", *p);
复制代码

交换两个变量

  1. void swap(int *a, int *b) {
  2. int temp = *a;
  3. *a = *b;
  4. *b = temp;
  5. }
复制代码

动态内存

  1. int *arr = malloc(n * sizeof *arr);
  2. if (arr == NULL) {
  3. return 1;
  4. }
  5. free(arr);
  6. arr = NULL;
复制代码

结构体

  1. typedef struct {
  2. int id;
  3. char name[32];
  4. } Student;
复制代码

文件

  1. FILE *fp = fopen("data.txt", "r");
  2. if (fp != NULL) {
  3. fclose(fp);
  4. }
复制代码

推荐编译参数

  1. gcc -std=c17 -Wall -Wextra -Wpedantic -g main.c -o app
复制代码

调试阶段加入 Sanitizer

  1. gcc -std=c17 -Wall -Wextra -fsanitize=address,undefined -g main.c -o app
复制代码

返回目录


二十九、从入门走向真正“精通”的路线
所谓“精通 C”,绝不是把每一个库函数都背下来。更重要的是形成稳定的底层判断能力。
第一层:会写
熟悉语法;
会写循环和函数;
会用数组、字符串;
能完成简单项目。
第二层:会解释
看到代码能解释:
变量存在哪里;
指针保存什么;
生命周期多久;
哪些访问可能越界;
谁负责释放资源。
第三层:会设计
模块接口清晰;
少依赖全局状态;
失败路径有处理;
动态内存所有权明确;
能把项目拆成多个源文件。
第四层:会排错
真正拉开差距的是调试能力:
看编译警告;
用调试器单步;
查调用栈;
用 Sanitizer;
从“随机崩溃”反推边界和生命周期问题。
推荐后续路线

  1. C 语法
  2. 数据结构与算法
  3. Linux C / 系统编程
  4. 进程、线程、文件、网络
  5. 操作系统 / 编译原理 / 嵌入式 / 高性能方向
复制代码

最后一句
C 语言真正难的不是 if、for,甚至也不只是“指针”。真正难的是你要开始对内存、边界、生命周期和资源所有权负责。
一旦这套思维建立起来,你学到的不只是一门 C 语言,而是更接近计算机真实运行方式的一套编程基本功。
返回目录

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

中国红客联盟公众号

联系站长QQ:5520533

admin@chnhonker.com
Copyright © 2001-2026 Discuz Team. Powered by Discuz! X3.5 ( 粤ICP备13060014号 )|天天打卡 本站已运行