定位:这不是一篇只列语法的“知识点清单”,而是一份可以从头学习、也可以以后回来查阅的 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
编译并运行
- gcc hello.c -o hello
- ./hello
复制代码Windows PowerShell 中通常运行:
建议从一开始就打开警告
- gcc -std=c17 -Wall -Wextra -pedantic hello.c -o hello
复制代码-Wall -Wextra 能帮助你尽早发现未使用变量、可疑转换、格式符不匹配等问题。不要把编译警告当成“可以先不管”。
返回目录
四、C程序从源码到可执行文件
很多人会写 gcc main.c -o main,但不知道 GCC 背后做了什么。理解这一节后,你会更容易读懂“头文件错误”“重复定义”“undefined reference”等问题。
- 预处理
处理 #include、#define、条件编译等。
- gcc -E hello.c -o hello.i
复制代码- 编译
把预处理后的 C 代码翻译成汇编代码。
- gcc -S hello.i -o hello.s
复制代码- 汇编
把汇编代码生成目标文件。
- gcc -c hello.s -o hello.o
复制代码- 链接
把目标文件和需要的库组合成可执行程序。
一个特别常见的错误:声明了函数但没有参与链接
- int add(int a, int b);
- int main(void) {
- return add(1, 2);
- }
复制代码如果 add 的实现位于 math.c,只编译 main.c 就可能链接失败。应该:
返回目录
五、基础语法与第一个程序
- #include <stdio.h>
- int main(void) {
- printf("Hello, C!\n");
- return 0;
- }
复制代码#include
告诉编译器后续代码会使用标准输入输出库中声明的接口,例如 printf。
int main(void)
main 是程序入口。void 明确表示该版本不接收参数。
return 0
通常表示程序正常结束。
注释
语句以分号结束
- int age = 21;
- printf("%d\n", age);
复制代码代码块使用 {}
- if (age >= 18) {
- printf("adult\n");
- }
复制代码写 C 代码时建议从一开始保持统一格式
- if (condition) {
- statement1();
- statement2();
- }
复制代码良好的缩进不影响机器执行,但会极大影响你自己发现逻辑错误的速度。
返回目录
六、变量、常量、数据类型与 sizeof
变量可以理解为:一块有类型、有地址、当前保存某个值的存储区域。
常见基础类型
- char c = 'A';
- short s = 100;
- int age = 21;
- long count = 100000L;
- float price = 9.9f;
- double pi = 3.1415926;
复制代码不要死记每个平台的字节数
C 标准对很多类型只规定最小范围和相对关系,因此应使用 sizeof 查看当前平台实际大小。
- #include <stdio.h>
- int main(void) {
- printf("char : %zu\n", sizeof(char));
- printf("short : %zu\n", sizeof(short));
- printf("int : %zu\n", sizeof(int));
- printf("long : %zu\n", sizeof(long));
- printf("float : %zu\n", sizeof(float));
- printf("double : %zu\n", sizeof(double));
- return 0;
- }
复制代码signed 与 unsigned
- unsigned int count = 100U;
- signed int temperature = -10;
复制代码unsigned 适合明确不需要负数的场景,但不要把它当作“更大范围的普通 int”随便混用。有符号和无符号混合比较很容易制造 bug。
常量
- const double PI = 3.1415926;
复制代码const 的核心价值不是“神秘的常量语法”,而是告诉编译器和读代码的人:这里不应该被修改。
类型转换
- int a = 5;
- int b = 2;
- double result = (double)a / b;
复制代码如果不做转换:
a / b 会先进行整数除法得到 2,再转成 2.0。
易错点:整数溢出
- int x = 100000;
- int y = 100000;
- 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
- #include <stdio.h>
- int main(void) {
- int age = 21;
- double score = 95.5;
- printf("age=%d, score=%.1f\n", age, score);
- return 0;
- }
复制代码scanf 为什么经常要写 &
- int age;
- scanf("%d", &age);
复制代码scanf 要修改调用者的变量,因此需要拿到这个变量的地址。
字符数组是例外之一
- char name[32];
- scanf("%31s", name);
复制代码数组名在这里会转换为首元素地址,所以不再写 &name。
一定要检查返回值
- int age;
- if (scanf("%d", &age) != 1) {
- printf("输入无效\n");
- return 1;
- }
复制代码为什么 %31s 比 %s 更好
- char name[32];
- scanf("%31s", name);
复制代码最多读取 31 个非空白字符,留下 1 个位置给结尾 \0,可以降低缓冲区溢出的风险。
读取一整行更推荐 fgets
- #include <stdio.h>
- int main(void) {
- char line[128];
- if (fgets(line, sizeof line, stdin) != NULL) {
- printf("%s", line);
- }
- return 0;
- }
复制代码返回目录
八、运算符与表达式
算术运算
- 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);
复制代码关系运算
- if (a >= b) {
- printf("a >= b\n");
- }
复制代码逻辑运算
- if (age >= 18 && score >= 60) {
- printf("条件成立\n");
- }
复制代码短路求值非常重要
- if (p != NULL && *p > 0) {
- printf("valid\n");
- }
复制代码如果 p == NULL,右侧 *p > 0 不会执行,因此避免了空指针解引用。
++i 与 i++
单独作为语句时:
通常最终效果一样。区别主要体现在表达式求值时。
此时 a 得到旧值 5,之后 i 变 6。
不要写过度复杂、依赖求值细节的表达式
可读性比“炫技”重要。复杂逻辑拆成多行,往往更安全。
位运算
- unsigned int flags = 0;
- flags |= 1u << 2;
- flags &= ~(1u << 2);
复制代码位运算常用于标志位、协议、硬件寄存器和压缩表示。
返回目录
九、选择结构:if 与 switch
if
- if (score >= 60) {
- printf("pass\n");
- }
复制代码if ... else
- if (score >= 60) {
- printf("pass\n");
- } else {
- printf("fail\n");
- }
复制代码多分支
- if (score >= 90) {
- printf("A\n");
- } else if (score >= 80) {
- printf("B\n");
- } else if (score >= 60) {
- printf("C\n");
- } else {
- printf("D\n");
- }
复制代码switch
- switch (choice) {
- case 1:
- printf("add\n");
- break;
- case 2:
- printf("delete\n");
- break;
- default:
- printf("unknown\n");
- break;
- }
复制代码忘记 break 会发生什么
- switch (x) {
- case 1:
- printf("one\n");
- case 2:
- printf("two\n");
- break;
- }
复制代码当 x == 1 时,执行完 case 1 后还会继续落入 case 2。这种行为叫 fall-through。有时它是故意设计的,但初学阶段大多数情况是 bug。
返回目录
十、循环结构:for、while、do-while
for
适合循环次数相对明确的场景。
- for (int i = 0; i < 5; ++i) {
- printf("%d\n", i);
- }
复制代码while
适合“条件成立就继续”的场景。
- int n = 5;
- while (n > 0) {
- printf("%d\n", n);
- --n;
- }
复制代码do-while
至少执行一次。
- int choice;
- do {
- printf("1. add\n0. exit\n");
- scanf("%d", &choice);
- } while (choice != 0);
复制代码三种循环怎么选
需求 推荐
明确遍历 0 到 n-1 for
不确定执行次数,只看条件 while
菜单至少显示一次 do-while
break
立刻结束当前循环。
- for (int i = 0; i < 100; ++i) {
- if (i == 10) {
- break;
- }
- }
复制代码continue
跳过本轮剩余语句,进入下一轮。
- for (int i = 0; i < 10; ++i) {
- if (i % 2 == 0) {
- continue;
- }
- printf("%d\n", i);
- }
复制代码经典练习:九九乘法表
- #include <stdio.h>
- int main(void) {
- for (int i = 1; i <= 9; ++i) {
- for (int j = 1; j <= i; ++j) {
- printf("%d*%d=%-2d ", j, i, i * j);
- }
- printf("\n");
- }
- return 0;
- }
复制代码返回目录
十一、函数:参数、返回值、声明与作用域
函数让代码从“所有逻辑挤在 main”变成可拆分、可复用、可测试的模块。
最基本的函数
- int add(int a, int b) {
- return a + b;
- }
复制代码函数声明
声明告诉编译器函数的名字、参数和返回类型,实现可以放在后面甚至另一个 .c 文件。
C 参数传递的核心:值传递
- #include <stdio.h>
- void change(int x) {
- x = 100;
- }
- int main(void) {
- int a = 10;
- change(a);
- printf("%d\n", a);
- return 0;
- }
复制代码输出仍然是 10。因为 change 拿到的是 a 的值的副本。
想修改外部变量怎么办
- #include <stdio.h>
- void change(int *p) {
- *p = 100;
- }
- int main(void) {
- int a = 10;
- change(&a);
- printf("%d\n", a);
- return 0;
- }
复制代码现在函数虽然仍然是“值传递”,但传进去的值变成了地址。通过这个地址,就能修改原对象。
局部变量与全局变量
- int global_count = 0;
- void test(void) {
- int local_count = 10;
- }
复制代码局部变量只在对应作用域内可见。全局变量可见范围更大,但也更容易造成模块耦合,实际工程不要滥用。
static 局部变量
- #include <stdio.h>
- void counter(void) {
- static int count = 0;
- ++count;
- printf("%d\n", count);
- }
- int main(void) {
- counter();
- counter();
- counter();
- return 0;
- }
复制代码它的作用域仍在函数内部,但生命周期贯穿整个程序运行期。
返回目录
十二、递归:函数调用自己到底发生了什么
递归就是函数直接或间接调用自身。
阶乘
- long long factorial(int n) {
- if (n <= 1) {
- return 1;
- }
- return (long long)n * factorial(n - 1);
- }
复制代码递归必须考虑两个东西:
终止条件;
每次调用是否更接近终止条件。
递归调用栈
计算 factorial(4):
- factorial(4)
- factorial(3)
- factorial(2)
- factorial(1)
复制代码返回时逐层展开:
递归不是越多越高级
深度过大会占用大量栈空间,甚至导致栈溢出。能用清晰循环解决的问题,不必强行递归。
斐波那契的低效递归
- long long fib(int n) {
- if (n <= 1) {
- return n;
- }
- return fib(n - 1) + fib(n - 2);
- }
复制代码这个写法会重复计算大量相同子问题,更适合教学,不适合大规模实际计算。
返回目录
十三、一维数组与二维数组
数组是一段连续存放相同类型元素的内存区域。
一维数组
- int scores[5] = {90, 88, 76, 95, 82};
复制代码访问:
- printf("%d\n", scores[0]);
复制代码为什么下标从 0 开始
数组访问本质上与“首地址 + 偏移”相关。
可以理解为第 i 个元素距离首元素偏移 i * sizeof(int) 字节。
遍历数组
- #include <stdio.h>
- int main(void) {
- int arr[] = {10, 20, 30, 40, 50};
- size_t n = sizeof arr / sizeof arr[0];
- for (size_t i = 0; i < n; ++i) {
- printf("%d ", arr[i]);
- }
- printf("\n");
- return 0;
- }
复制代码sizeof arr / sizeof arr[0] 什么时候能用
只能在 arr 仍然是真正数组对象的作用域里可靠使用。
- void print_array(int arr[]) {
- printf("%zu\n", sizeof arr);
- }
复制代码函数参数里的 int arr[] 实际会调整为 int *arr,所以这里得到的是指针大小,不是整个数组大小。
二维数组
- int matrix[2][3] = {
- {1, 2, 3},
- {4, 5, 6}
- };
复制代码遍历:
- for (int i = 0; i < 2; ++i) {
- for (int j = 0; j < 3; ++j) {
- printf("%d ", matrix[i][j]);
- }
- printf("\n");
- }
复制代码二维数组在内存中仍然是连续布局,只是我们把它按“行”来解释。
返回目录
十四、字符串:字符数组、\0 与常用函数
C 语言没有像 Java String 那样的内建字符串对象。最常见的字符串表示方式是:以 \0 结尾的字符序列。
定义字符串
实际需要 6 个字符位置:
strlen 与 sizeof 完全不是一回事
- #include <stdio.h>
- #include <string.h>
- int main(void) {
- char s[] = "hello";
- printf("strlen = %zu\n", strlen(s));
- printf("sizeof = %zu\n", sizeof s);
- return 0;
- }
复制代码典型结果:
strlen 数的是 \0 前的字符数;sizeof 看的是整个数组对象占多少字节。
常见字符串函数
- strlen(s);
- strcmp(a, b);
- strcpy(dest, src);
- strcat(dest, src);
复制代码strcpy 的危险点
- char dest[4];
- strcpy(dest, "hello");
复制代码目标空间装不下,会造成越界写入。C 不会自动扩容。
更推荐明确控制容量
如果允许,优先使用能显式限制输入长度的接口,并始终保证目标缓冲区有空间容纳结尾 \0。
字符串字面量不要当作可写数组
- char *p = "hello";
- p[0] = 'H';
复制代码修改字符串字面量属于未定义行为。需要修改时:
- char p[] = "hello";
- p[0] = 'H';
复制代码返回目录
十五、指针入门:地址、& 与 *
指针是 C 语言最重要的主题之一。它并不是“神秘高级语法”,本质只有一句话:指针变量用于保存地址。
最基本例子
- #include <stdio.h>
- int main(void) {
- int a = 10;
- int *p = &a;
- printf("a = %d\n", a);
- printf("&a = %p\n", (void *)&a);
- printf("p = %p\n", (void *)p);
- printf("*p = %d\n", *p);
- return 0;
- }
复制代码四个表达式一定要分清
写法 含义
a 变量当前的值
&a 变量 a 的地址
p 指针变量中保存的地址
*p 访问 p 所指向的对象
通过指针修改对象
- int a = 10;
- int *p = &a;
- *p = 99;
复制代码此时 a 也变成 99,因为 *p 指的就是 a 这个对象。
指针类型为什么重要
- int *pi;
- double *pd;
- char *pc;
复制代码不同类型决定了解引用时如何解释内存,也影响指针加减时一次跨过多少字节。
空指针
NULL 表示当前没有指向有效对象。绝对不要解引用 NULL。
- if (p != NULL) {
- printf("%d\n", *p);
- }
复制代码野指针
p 没有初始化就被解引用,这是高危错误。
返回目录
十六、指针与数组:arr 为什么等价于 *(arr+i)
在大多数表达式中,数组名会转换为指向首元素的指针。
- int arr[3] = {10, 20, 30};
- int *p = arr;
复制代码两种访问形式
两者访问的是同一个元素。
指针移动不是简单“地址 + 1 字节”
p 会移动到下一个 int 元素,而不是只加一个字节。
用指针遍历数组
- #include <stdio.h>
- int main(void) {
- int arr[] = {1, 2, 3, 4, 5};
- int *p = arr;
- size_t n = sizeof arr / sizeof arr[0];
- for (size_t i = 0; i < n; ++i) {
- printf("%d ", *(p + i));
- }
- printf("\n");
- return 0;
- }
复制代码数组不是指针
这是特别重要的结论。
- int arr[10];
- int *p = arr;
复制代码arr 是数组对象,p 是指针对象。它们在很多表达式中表现相似,但 sizeof、可赋值性等方面完全不同。
通常不会相等。
数组传参
- void print_array(const int arr[], size_t n) {
- for (size_t i = 0; i < n; ++i) {
- printf("%d ", arr[i]);
- }
- }
复制代码必须额外传入长度,因为函数无法从这个参数重新推导出原始数组总长度。
返回目录
十七、指针进阶:二级指针、指针数组、数组指针、函数指针
- 二级指针
- int a = 10;
- int *p = &a;
- int **pp = &p;
- printf("%d\n", **pp);
复制代码二级指针最常见的使用场景之一,是函数需要修改调用者的指针变量本身。
- #include <stdlib.h>
- int create_array(int **out, size_t n) {
- *out = malloc(n * sizeof **out);
- return *out != NULL;
- }
复制代码调用:
- int *arr = NULL;
- if (create_array(&arr, 10)) {
- free(arr);
- arr = NULL;
- }
复制代码- 指针数组
p 先和 [] 结合,所以它是数组,数组的每个元素都是 int *。
3. 数组指针
括号让 *p 先结合,因此 p 是指针,它指向“包含 3 个 int 的数组”。
- 函数指针
- int add(int a, int b) {
- return a + b;
- }
- int main(void) {
- int (*operation)(int, int) = add;
- int result = operation(3, 4);
- return result == 7 ? 0 : 1;
- }
复制代码函数指针常见于:
回调函数;
策略切换;
排序比较函数;
驱动/协议接口表。
5. const 与指针
不能通过 p 修改所指向的 int。
p 自身不能再指向别处。
两者都限制。
返回目录
十八、内存模型:栈、堆、全局区与生命周期
学指针如果不理解生命周期,很容易出现“明明地址还在,为什么不能用”的问题。
栈上的典型对象
- void test(void) {
- int x = 10;
- }
复制代码x 的生命周期到函数执行结束为止。
不能返回局部变量地址
错误示例:
- int *bad(void) {
- int x = 10;
- return &x;
- }
复制代码函数结束后 x 生命周期已经结束,这个地址不能继续作为有效对象使用。
静态存储期
静态对象的生命周期贯穿整个程序运行期。
堆内存
- int *p = malloc(sizeof *p);
复制代码堆对象不会因为当前函数返回就自动释放。它会一直存在,直到你 free,或者进程结束由操作系统整体回收。
一个重要思维模型
看到指针时不要只问:
“这个地址是多少?”
更应该问:
“这个地址对应的对象现在还活着吗?”
这就是 C 中大量悬空指针问题的根源。
返回目录
十九、动态内存:malloc、calloc、realloc、free
动态内存用于运行时才能确定需要多少空间的场景。
malloc
- #include <stdlib.h>
- int *arr = malloc(10 * sizeof *arr);
- if (arr == NULL) {
- return 1;
- }
复制代码推荐 sizeof *arr,这样以后即使修改 arr 的类型,也更不容易漏改。
在 C 中通常不需要强制转换 malloc 返回值
- int *arr = malloc(n * sizeof *arr);
复制代码void * 可以自动转换为对象指针类型。额外强制转换反而可能掩盖忘记包含 的问题。
calloc
- int *arr = calloc(n, sizeof *arr);
复制代码它会为 n 个元素分配空间,并把分配到的字节初始化为全 0。
realloc
不要直接覆盖原指针:
- int *temp = realloc(arr, new_size * sizeof *arr);
- if (temp != NULL) {
- arr = temp;
- }
复制代码如果写成:
- arr = realloc(arr, new_size * sizeof *arr);
复制代码一旦失败返回 NULL,你可能丢失旧地址,造成内存泄漏。
free
free 之后原来的堆对象已经不存在,再访问就是 use-after-free。
错误:重复释放
属于未定义行为。
错误:释放栈变量
free 只能用于兼容的动态分配结果,不能随便释放任意地址。
一个完整动态数组示例
- #include <stdio.h>
- #include <stdlib.h>
- int main(void) {
- size_t n;
- printf("元素数量:");
- if (scanf("%zu", &n) != 1 || n == 0) {
- return 1;
- }
- int *arr = malloc(n * sizeof *arr);
- if (arr == NULL) {
- return 1;
- }
- for (size_t i = 0; i < n; ++i) {
- arr[i] = (int)(i + 1);
- }
- for (size_t i = 0; i < n; ++i) {
- printf("%d ", arr[i]);
- }
- printf("\n");
- free(arr);
- arr = NULL;
- return 0;
- }
复制代码返回目录
二十、结构体、typedef、enum、union 与内存对齐
结构体:把多个字段组合成一个对象
- typedef struct {
- int id;
- char name[32];
- double score;
- } Student;
复制代码使用
- Student s = {1001, "Alice", 95.5};
- printf("%d %s %.1f\n", s.id, s.name, s.score);
复制代码结构体指针与 ->
- Student *p = &s;
- printf("%s\n", p->name);
复制代码p->name 等价于:
typedef
- typedef unsigned long ulong;
复制代码它创建的是类型别名,不是新建一个完全独立的底层类型。
enum
- typedef enum {
- STATUS_OK,
- STATUS_ERROR,
- STATUS_NOT_FOUND
- } Status;
复制代码枚举比散落在代码里的“魔法数字”更有可读性。
union
- union Value {
- int i;
- double d;
- char c;
- };
复制代码联合体成员共享同一段存储空间,同一时刻通常只把其中一种解释当作有效值。
结构体内存对齐
- struct Demo {
- char c;
- int i;
- short s;
- };
复制代码结构体大小不一定等于 1 + 4 + 2。为了满足成员对齐要求,编译器可能插入 padding。
不要只凭示意图死记“永远是多少字节”,实际结果由平台 ABI、编译器和成员类型共同决定。使用 sizeof 验证当前环境。
返回目录
二十一、预处理、宏、static、extern 与多文件工程
宏定义
宏是预处理阶段的文本替换,不是变量。
带参数宏最常见的坑:缺括号
错误:
调用:
可能展开成:
更安全:
- #define SQUARE(x) ((x) * (x))
复制代码但宏仍可能对带副作用的参数产生问题,所以不要把所有小函数都改成宏。
头文件保护
- #ifndef STUDENT_H
- #define STUDENT_H
- /* declarations */
- #endif
复制代码static 在文件作用域
- static int helper(void) {
- return 42;
- }
复制代码这样可以限制符号只在当前源文件内部使用,降低模块之间的名字冲突。
extern
表示该对象的定义位于其他位置。实际工程中比起到处共享全局变量,更推荐通过函数接口管理模块状态。
多文件工程结构
- project/
- ├── main.c
- ├── student.h
- ├── student.c
- └── storage.c
复制代码编译:
- gcc -std=c17 -Wall -Wextra -pedantic main.c student.c storage.c -o app
复制代码.h 和 .c 的职责建议
文件 主要职责
.h 类型、宏、函数声明、对外接口
.c 函数实现、模块内部细节
main.c 程序入口与高层流程
返回目录
二十二、文件操作:文本文件与二进制文件
程序退出后,内存中的普通数据会消失。文件 I/O 让数据可以持久化。
打开文件
- FILE *fp = fopen("data.txt", "r");
- if (fp == NULL) {
- return 1;
- }
复制代码常见模式
模式 含义
r 读,文件必须存在
w 写,通常截断原内容
a 追加
rb 二进制读
wb 二进制写
文本写入
- #include <stdio.h>
- int main(void) {
- FILE *fp = fopen("score.txt", "w");
- if (fp == NULL) {
- return 1;
- }
- fprintf(fp, "%d %s %.2f\n", 1001, "Alice", 95.5);
- fclose(fp);
- return 0;
- }
复制代码文本读取
- char line[128];
- while (fgets(line, sizeof line, fp) != NULL) {
- printf("%s", line);
- }
复制代码二进制写入结构体数组
- size_t written = fwrite(students, sizeof(Student), count, fp);
复制代码二进制读取
- size_t count = fread(students, sizeof(Student), capacity, fp);
复制代码注意:直接保存结构体二进制不是通用跨平台文件格式
原因包括:
字节序;
对齐与 padding;
类型大小;
结构体版本变化。
对于课程项目和同平台简单程序,这样做很方便;真正跨平台长期存储,应设计明确的序列化格式。
返回目录
二十三、调试与 C 语言高频错误
C 最可怕的不是“报错”,而是有些错误不一定立刻报错。
- 数组越界
- int arr[3] = {1, 2, 3};
- arr[3] = 99;
复制代码有效下标只有 0、1、2。
2. NULL 解引用
- int *p = NULL;
- printf("%d\n", *p);
复制代码- 野指针
- 悬空指针
- int *p = malloc(sizeof *p);
- free(p);
- printf("%d\n", *p);
复制代码- 内存泄漏
- void test(void) {
- int *p = malloc(100 * sizeof *p);
- if (p == NULL) {
- return;
- }
- }
复制代码函数离开后地址丢失,再也无法 free。
6. = 和 ==
这是赋值,不是比较。编译器警告很重要。
7. 格式符不匹配
- double x = 3.14;
- printf("%d\n", x);
复制代码格式符与实参类型不匹配会导致未定义行为。
8. 返回局部变量地址
- int *bad(void) {
- int x = 10;
- return &x;
- }
复制代码- 忘记检查库函数结果
malloc、fopen、scanf、fread 等都可能失败或只完成部分操作。 - 编译器警告和 Sanitizer
GCC / Clang 调试阶段推荐:
- gcc -std=c17 -Wall -Wextra -Wpedantic -fsanitize=address,undefined -g main.c -o app
复制代码AddressSanitizer / UndefinedBehaviorSanitizer 对越界、use-after-free、部分未定义行为非常有帮助。
返回目录
二十四、数据结构入门:手写单链表
数组的容量和内存布局比较固定,而链表通过节点和指针把数据连接起来。
节点定义
- typedef struct Node {
- int value;
- struct Node *next;
- } Node;
复制代码创建节点
- #include <stdlib.h>
- Node *create_node(int value) {
- Node *node = malloc(sizeof *node);
- if (node == NULL) {
- return NULL;
- }
- node->value = value;
- node->next = NULL;
- return node;
- }
复制代码头插法
- int push_front(Node **head, int value) {
- Node *node = create_node(value);
- if (node == NULL) {
- return 0;
- }
- node->next = *head;
- *head = node;
- return 1;
- }
复制代码为什么是 Node **head?因为函数需要修改调用者保存的“头指针”本身。
遍历
- void print_list(const Node *head) {
- for (const Node *p = head; p != NULL; p = p->next) {
- printf("%d ", p->value);
- }
- printf("\n");
- }
复制代码释放整个链表
- void destroy_list(Node **head) {
- Node *current = *head;
- while (current != NULL) {
- Node *next = current->next;
- free(current);
- current = next;
- }
- *head = NULL;
- }
复制代码这一小节把前面大量知识真正串起来:
结构体;
结构体指针;
动态内存;
二级指针;
生命周期;
循环。
返回目录
二十五、综合项目:学生信息管理系统
最后用一个完整项目把核心知识串起来。
项目功能
添加学生;
删除学生;
按学号查询;
显示全部学生;
按成绩排序;
计算平均分;
启动时加载文件;
退出时保存文件。
工程结构
- student_system/
- ├── main.c
- ├── student.h
- ├── student.c
- ├── storage.c
- └── students.dat
复制代码student.h
- #ifndef STUDENT_H
- #define STUDENT_H
- #include <stddef.h>
- #define NAME_LEN 32
- typedef struct {
- int id;
- char name[NAME_LEN];
- double score;
- } Student;
- int add_student(Student students[], size_t capacity, size_t *count);
- int delete_student(Student students[], size_t *count, int id);
- Student *find_student(Student students[], size_t count, int id);
- void print_students(const Student students[], size_t count);
- void sort_by_score(Student students[], size_t count);
- double average_score(const Student students[], size_t count);
- int save_students(const char *filename, const Student students[], size_t count);
- int load_students(const char *filename, Student students[], size_t capacity, size_t *count);
- #endif
复制代码student.c 核心查询函数
- Student *find_student(Student students[], size_t count, int id) {
- for (size_t i = 0; i < count; ++i) {
- if (students[i].id == id) {
- return &students[i];
- }
- }
- return NULL;
- }
复制代码删除学生
- int delete_student(Student students[], size_t *count, int id) {
- for (size_t i = 0; i < *count; ++i) {
- if (students[i].id == id) {
- for (size_t j = i + 1; j < *count; ++j) {
- students[j - 1] = students[j];
- }
- --(*count);
- return 1;
- }
- }
- return 0;
- }
复制代码排序
- void sort_by_score(Student students[], size_t count) {
- for (size_t i = 0; i + 1 < count; ++i) {
- for (size_t j = 0; j + 1 < count - i; ++j) {
- if (students[j].score < students[j + 1].score) {
- Student temp = students[j];
- students[j] = students[j + 1];
- students[j + 1] = temp;
- }
- }
- }
- }
复制代码文件保存
- int save_students(const char *filename, const Student students[], size_t count) {
- FILE *fp = fopen(filename, "wb");
- if (fp == NULL) {
- return 0;
- }
- size_t written = fwrite(students, sizeof(Student), count, fp);
- fclose(fp);
- return written == count;
- }
复制代码编译整个工程
- gcc -std=c17 -Wall -Wextra -pedantic main.c student.c storage.c -o student_system
复制代码完整源码已经放在配套源码包的 src/student_system/ 中。
这个项目真正训练了什么
知识点 在项目中的应用
结构体 Student 数据模型
数组 保存多名学生
指针 修改 count、返回查询结果
函数 拆分增删改查
文件 保存与恢复数据
头文件 暴露模块接口
多文件 分离 UI、业务与存储
返回目录
二十六、C语言高频易错点与面试题
- 数组和指针一样吗?
不一样。数组是数组对象,指针是保存地址的对象。数组名只是在很多表达式中会转换为首元素指针。 - sizeof 和 strlen 有什么区别?
sizeof:操作类型或对象大小;
strlen:扫描 C 字符串直到 \0,返回字符数。 - malloc 返回的是什么?
返回指向所分配内存块起始位置的 void *,失败返回 NULL。 - 为什么 free 后常把指针设为 NULL?
free 只释放对象,不会自动修改你的指针变量。原指针会成为悬空指针。置空不能解决所有别名问题,但能减少再次误用该变量的风险。 - 为什么函数修改不了普通参数?
因为参数是值传递。函数修改的是自己的形参副本。 - 为什么传指针后能修改?
仍然是值传递,只是传入的“值”是地址。函数通过这个地址访问原对象。 - char *s = "abc" 与 char s[] = "abc" 有什么不同?
前者 s 是指针,通常指向字符串字面量,不应该修改内容;后者创建一个可修改字符数组。 - 什么是未定义行为?
标准不规定程序必须产生什么结果。例如数组越界、使用已释放对象、修改字符串字面量等。它不是“保证报错”,可能表现为崩溃、错误结果,甚至看起来暂时正常。 - 什么是内存泄漏?
动态分配的内存失去所有可用于释放它的有效引用,程序无法再 free 这块内存。 - static 有哪些常见含义?
函数内部:静态存储期,值跨调用保留;
文件作用域:限制名称的链接属性,使它只在当前翻译单元内部使用。
返回目录
二十七、练习路线:从语法题到项目题
第一阶段:基础控制
判断奇偶数;
判断闰年;
计算 1 到 n 的和;
输出九九乘法表;
求三个数最大值;
猜数字小游戏。
第二阶段:数组与字符串
求数组最大值和最小值;
数组逆序;
冒泡排序;
统计字符串中字母、数字、空格数量;
手写字符串长度函数;
判断回文字符串。
第三阶段:函数与指针
用函数交换两个变量;
用指针求数组和;
手写 my_strlen;
手写 my_strcpy;
使用二级指针动态创建数组。
第四阶段:结构体与文件
学生成绩排序;
通讯录;
图书管理;
保存结构体到文件;
启动程序时恢复数据。
第五阶段:工程化
把一个单文件项目拆成:
- main.c
- module.h
- module.c
- storage.c
复制代码然后启用完整警告并尝试 AddressSanitizer。
返回目录
二十八、C语言核心速查表
基本程序
- #include <stdio.h>
- int main(void) {
- return 0;
- }
复制代码输入输出
- int x;
- scanf("%d", &x);
- printf("%d\n", x);
复制代码数组长度
- size_t n = sizeof arr / sizeof arr[0];
复制代码指针
- int a = 10;
- int *p = &a;
- printf("%d\n", *p);
复制代码交换两个变量
- void swap(int *a, int *b) {
- int temp = *a;
- *a = *b;
- *b = temp;
- }
复制代码动态内存
- int *arr = malloc(n * sizeof *arr);
- if (arr == NULL) {
- return 1;
- }
- free(arr);
- arr = NULL;
复制代码结构体
- typedef struct {
- int id;
- char name[32];
- } Student;
复制代码文件
- FILE *fp = fopen("data.txt", "r");
- if (fp != NULL) {
- fclose(fp);
- }
复制代码推荐编译参数
- gcc -std=c17 -Wall -Wextra -Wpedantic -g main.c -o app
复制代码调试阶段加入 Sanitizer
- gcc -std=c17 -Wall -Wextra -fsanitize=address,undefined -g main.c -o app
复制代码返回目录
二十九、从入门走向真正“精通”的路线
所谓“精通 C”,绝不是把每一个库函数都背下来。更重要的是形成稳定的底层判断能力。
第一层:会写
熟悉语法;
会写循环和函数;
会用数组、字符串;
能完成简单项目。
第二层:会解释
看到代码能解释:
变量存在哪里;
指针保存什么;
生命周期多久;
哪些访问可能越界;
谁负责释放资源。
第三层:会设计
模块接口清晰;
少依赖全局状态;
失败路径有处理;
动态内存所有权明确;
能把项目拆成多个源文件。
第四层:会排错
真正拉开差距的是调试能力:
看编译警告;
用调试器单步;
查调用栈;
用 Sanitizer;
从“随机崩溃”反推边界和生命周期问题。
推荐后续路线
- C 语法
- ↓
- 数据结构与算法
- ↓
- Linux C / 系统编程
- ↓
- 进程、线程、文件、网络
- ↓
- 操作系统 / 编译原理 / 嵌入式 / 高性能方向
复制代码最后一句
C 语言真正难的不是 if、for,甚至也不只是“指针”。真正难的是你要开始对内存、边界、生命周期和资源所有权负责。
一旦这套思维建立起来,你学到的不只是一门 C 语言,而是更接近计算机真实运行方式的一套编程基本功。
返回目录