7,463
社区成员
发帖
与我相关
我的任务
分享定位:这不是一篇只列语法的“知识点清单”,而是一份可以从头学习、也可以以后回来查阅的 C 语言长篇教程。文章重点放在 代码、内存模型、指针、常见错误和工程实践 上。
如果你已经会 Java / Python,也可以直接跳过非常基础的部分,把重心放到 数组、字符串、指针、内存管理、多文件工程和文件 I/O。
学习时最值得优先攻克的 5 个难点
数组为什么从 0 开始;
C 字符串为什么必须有 \0;
指针为什么必须区分 int *、char *;malloc 返回的到底是什么;
函数传参为什么修改不了外面的普通变量,却能通过指针修改。
建议:每看懂一段代码,都尝试回答“数据在哪里、地址是什么、生命周期到什么时候”。这是学 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 --version
编译并运行
gcc hello.c -o hello
./hello
Windows PowerShell 中通常运行:
.\hello.exe
建议从一开始就打开警告
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
链接
把目标文件和需要的库组合成可执行程序。
gcc hello.o -o hello
一个特别常见的错误:声明了函数但没有参与链接
int add(int a, int b);
int main(void) {
return add(1, 2);
}
如果 add 的实现位于 math.c,只编译 main.c 就可能链接失败。应该:
gcc main.c math.c -o app
五、基础语法与第一个程序
#include <stdio.h>
int main(void) {
printf("Hello, C!\n");
return 0;
}
#include <stdio.h>
告诉编译器后续代码会使用标准输入输出库中声明的接口,例如 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;
如果不做转换:
double result = 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++
单独作为语句时:
++i;
i++;
通常最终效果一样。区别主要体现在表达式求值时。
int i = 5;
int a = 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-whilefor
适合循环次数相对明确的场景。
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-whilebreak
立刻结束当前循环。
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;
}
函数声明
int add(int a, int 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)
返回时逐层展开:
1 → 2 → 6 → 24
递归不是越多越高级
深度过大会占用大量栈空间,甚至导致栈溢出。能用清晰循环解决的问题,不必强行递归。
斐波那契的低效递归
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 开始
数组访问本质上与“首地址 + 偏移”相关。
scores[i]
可以理解为第 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 结尾的字符序列。

定义字符串
char name[] = "hello";
实际需要 6 个字符位置:
h e l l o \0
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 = 5
sizeof = 6
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;
不同类型决定了解引用时如何解释内存,也影响指针加减时一次跨过多少字节。
空指针
int *p = NULL;
NULL 表示当前没有指向有效对象。绝对不要解引用 NULL。
if (p != NULL) {
printf("%d\n", *p);
}
野指针
int *p;
*p = 10;
p 没有初始化就被解引用,这是高危错误。返回目录
十六、指针与数组:arr[i] 为什么等价于 *(arr+i)
在大多数表达式中,数组名会转换为指向首元素的指针。
int arr[3] = {10, 20, 30};
int *p = arr;
两种访问形式
arr[1]
*(arr + 1)
两者访问的是同一个元素。
指针移动不是简单“地址 + 1 字节”
int *p = arr;
++p;
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、可赋值性等方面完全不同。
sizeof arr
sizeof p
通常不会相等。
数组传参
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;
}
指针数组
int *p[3];
p 先和 [] 结合,所以它是数组,数组的每个元素都是 int *。
3. 数组指针
int (*p)[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 与指针
const int *p;
不能通过 p 修改所指向的 int。
int *const p = &a;
p 自身不能再指向别处。
const int *const p = &a;
返回目录
十八、内存模型:栈、堆、全局区与生命周期
学指针如果不理解生命周期,很容易出现“明明地址还在,为什么不能用”的问题。

栈上的典型对象
void test(void) {
int x = 10;
}
x 的生命周期到函数执行结束为止。
不能返回局部变量地址
错误示例:
int *bad(void) {
int x = 10;
return &x;
}
函数结束后 x 生命周期已经结束,这个地址不能继续作为有效对象使用。
静态存储期
static int count = 0;
静态对象的生命周期贯穿整个程序运行期。
堆内存
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 * 可以自动转换为对象指针类型。额外强制转换反而可能掩盖忘记包含 <stdlib.h> 的问题。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(arr);
arr = NULL;
free 之后原来的堆对象已经不存在,再访问就是 use-after-free。
错误:重复释放
free(p);
free(p);
属于未定义行为。
错误:释放栈变量
int x = 10;
free(&x);
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 等价于:
(*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 MAX_STUDENTS 100
宏是预处理阶段的文本替换,不是变量。
带参数宏最常见的坑:缺括号
错误:
#define SQUARE(x) x * x
调用:
SQUARE(1 + 2)
可能展开成:
1 + 2 * 1 + 2
更安全:
#define SQUARE(x) ((x) * (x))
但宏仍可能对带副作用的参数产生问题,所以不要把所有小函数都改成宏。
头文件保护
#ifndef STUDENT_H
#define STUDENT_H
/* declarations */
#endif
static 在文件作用域
static int helper(void) {
return 42;
}
这样可以限制符号只在当前源文件内部使用,降低模块之间的名字冲突。extern
extern int global_count;
表示该对象的定义位于其他位置。实际工程中比起到处共享全局变量,更推荐通过函数接口管理模块状态。
多文件工程结构

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;
*p = 10;
悬空指针
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. = 和 ==
if (x = 0) {
}
这是赋值,不是比较。编译器警告很重要。
7. 格式符不匹配
double x = 3.14;
printf("%d\n", x);
格式符与实参类型不匹配会导致未定义行为。
8. 返回局部变量地址
int *bad(void) {
int x = 10;
return &x;
}
忘记检查库函数结果malloc、fopen、scanf、fread 等都可能失败或只完成部分操作。
gcc -std=c17 -Wall -Wextra -Wpedantic -fsanitize=address,undefined -g main.c -o app
返回目录
二十四、数据结构入门:手写单链表
数组的容量和内存布局比较固定,而链表通过节点和指针把数据连接起来。
节点定义
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
返回目录
二十八、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 语言,而是更接近计算机真实运行方式的一套编程基本功。
返回目录
文章来源: https://blog.csdn.net/2401_86617027/article/details/163975566
版权声明: 本文为博主原创文章,遵循CC 4.0 BY-SA 知识共享协议,转载请附上原文出处链接和本声明。