C 语言实战项目
学了 16 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——学生成绩管理系统、单链表、简易 shell,每个都给出完整代码、运行结果和要点讲解。三个项目分别覆盖 C 的三大核心能力:struct + 文件 + 动态内存(数据组织)、指针串联(动态数据结构)、进程与系统调用(操作系统交互)。建议你把代码复制到本地亲手编译运行,再试着改一改需求。
实战 1:学生成绩管理系统(struct + 文件 I/O + 动态数组)
需求:做一个学生成绩管理程序,支持增加学生、列出全部、按学号查询、保存到文件、从文件加载。底层用动态数组(malloc/realloc)自动扩容。
涉及知识:结构体 struct、typedef、动态内存 malloc/realloc/free、文件 I/O fopen/fprintf/fscanf、字符串拷贝 strncpy。
/* student.c — 学生成绩管理系统
* 功能:增加学生、列出全部、按学号查询、保存到文件、从文件加载
* 编译:gcc student.c -o student
* 知识点:struct、文件 I/O、动态数组(malloc/realloc)、字符串处理
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define NAME_LEN 32
typedef struct {
int id; /* 学号 */
char name[NAME_LEN]; /* 姓名 */
float score; /* 成绩 */
} Student;
/* 动态数组:用 malloc 申请,realloc 扩容 */
typedef struct {
Student *data;
int len; /* 当前元素数 */
int cap; /* 容量 */
} StudentList;
void list_init(StudentList *L) {
L->cap = 4;
L->len = 0;
L->data = malloc(L->cap * sizeof(Student));
if (!L->data) { perror("malloc"); exit(1); }
}
void list_free(StudentList *L) {
free(L->data);
L->data = NULL;
L->len = L->cap = 0;
}
/* 在末尾添加一个学生;满了就扩容 */
void list_add(StudentList *L, int id, const char *name, float score) {
if (L->len == L->cap) { /* 容量不够,翻倍扩容 */
L->cap *= 2;
L->data = realloc(L->data, L->cap * sizeof(Student));
if (!L->data) { perror("realloc"); exit(1); }
}
Student *s = &L->data[L->len++];
s->id = id;
s->score = score;
strncpy(s->name, name, NAME_LEN - 1);
s->name[NAME_LEN - 1] = '\0';
}
void list_show(const StudentList *L) {
printf("学号\t姓名\t成绩\n");
printf("----\t----\t----\n");
for (int i = 0; i < L->len; i++) {
printf("%d\t%s\t%.1f\n", L->data[i].id, L->data[i].name, L->data[i].score);
}
printf("共 %d 名学生\n", L->len);
}
/* 按学号查询,返回下标,找不到返回 -1 */
int list_find(const StudentList *L, int id) {
for (int i = 0; i < L->len; i++) {
if (L->data[i].id == id) return i;
}
return -1;
}
/* 保存到文件:第一行写数量,后面每行一个学生 */
void list_save(const StudentList *L, const char *path) {
FILE *fp = fopen(path, "w");
if (!fp) { perror("fopen"); return; }
fprintf(fp, "%d\n", L->len);
for (int i = 0; i < L->len; i++) {
fprintf(fp, "%d %s %.1f\n", L->data[i].id, L->data[i].name, L->data[i].score);
}
fclose(fp);
printf("已保存 %d 条到 %s\n", L->len, path);
}
/* 从文件加载:先读数量,再逐条读 */
void list_load(StudentList *L, const char *path) {
FILE *fp = fopen(path, "r");
if (!fp) { perror("fopen"); return; }
int n;
if (fscanf(fp, "%d", &n) != 1) { fclose(fp); return; }
for (int i = 0; i < n; i++) {
int id; char name[NAME_LEN]; float score;
if (fscanf(fp, "%d %s %f", &id, name, &score) == 3) {
list_add(L, id, name, score);
}
}
fclose(fp);
printf("已从 %s 加载 %d 条\n", path, L->len);
}
int main(void) {
StudentList L;
list_init(&L);
list_add(&L, 1001, "张三", 88.5);
list_add(&L, 1002, "李四", 92.0);
list_add(&L, 1003, "王五", 76.5);
list_add(&L, 1004, "赵六", 95.0);
list_show(&L);
/* 输出:
* 学号 姓名 成绩
* ---- ---- ----
* 1001 张三 88.5
* 1002 李四 92.0
* 1003 王五 76.5
* 1004 赵六 95.0
* 共 4 名学生
*/
int idx = list_find(&L, 1003);
if (idx >= 0) {
printf("\n查询 1003:%s 成绩 %.1f\n", L.data[idx].name, L.data[idx].score);
}
/* 输出: 查询 1003:王五 成绩 76.5 */
list_save(&L, "students.txt");
/* 输出: 已保存 4 条到 students.txt */
/* 模拟重新启动:新建空列表,从文件加载 */
StudentList L2;
list_init(&L2);
list_load(&L2, "students.txt");
/* 输出: 已从 students.txt 加载 4 条 */
list_free(&L);
list_free(&L2);
return 0;
}要点讲解:
typedef struct:把struct Student起个短名Student,后面写Student s;而不是struct Student s;。- 动态数组:
malloc申请初始容量,realloc在满时翻倍扩容——这是 C 里"可增长数组"的标准实现(C++ 的vector、Python 的list底层都是这套)。 - 防止内存泄漏:每个
malloc/realloc必须对应一个free;list_free就是干这个的。 - 字符串安全:
strncpy限制长度防溢出,最后手动'\0'收尾,这是 C 字符串的常见防坑写法。 - 文件 I/O:
fopen("w")写、fopen("r")读;第一行写数量,后面每行一条记录,是文本存储的常见格式。 - 错误处理:每次
malloc都检查返回值,C 没有异常,失败必须自己判断。
实战 2:单链表实现(struct pointer + 增删查遍历)
需求:实现一个单链表,支持头插、尾插、按值查找、按值删除、遍历打印、整体释放。
涉及知识:结构体指针 struct Node *、自引用结构、malloc/free、指针串联、遍历循环。
/* linked_list.c — 单链表的增删查遍历
* 编译:gcc linked_list.c -o linked_list
* 知识点:struct 指针、动态内存、指针串联
* 链表 vs 数组:链表插入删除 O(1),数组随机访问 O(1)
*/
#include <stdio.h>
#include <stdlib.h>
/* 节点:数据 + 指向下一个节点的指针 */
typedef struct Node {
int value;
struct Node *next; /* 注意:这里要用 struct Node 而非 Node */
} Node;
/* 头指针:整个链表的入口。空链表时 head = NULL */
typedef struct {
Node *head;
int len;
} LinkedList;
void list_init(LinkedList *L) {
L->head = NULL;
L->len = 0;
}
/* 头插法:O(1),新节点变成新的头 */
void list_push_front(LinkedList *L, int value) {
Node *node = malloc(sizeof(Node));
if (!node) { perror("malloc"); exit(1); }
node->value = value;
node->next = L->head; /* 新节点指向原来的头 */
L->head = node; /* 头指针更新为新节点 */
L->len++;
}
/* 尾插法:O(n),需要遍历到末尾 */
void list_push_back(LinkedList *L, int value) {
Node *node = malloc(sizeof(Node));
if (!node) { perror("malloc"); exit(1); }
node->value = value;
node->next = NULL;
if (!L->head) { /* 空链表,直接当头 */
L->head = node;
} else {
Node *cur = L->head;
while (cur->next) { /* 走到最后一个节点 */
cur = cur->next;
}
cur->next = node;
}
L->len++;
}
/* 查找:返回第一个等于 value 的节点指针,找不到返回 NULL */
Node *list_find(const LinkedList *L, int value) {
for (Node *cur = L->head; cur; cur = cur->next) {
if (cur->value == value) return cur;
}
return NULL;
}
/* 删除:删掉第一个等于 value 的节点。需要记住前驱才能"重新接线" */
int list_remove(LinkedList *L, int value) {
Node *prev = NULL;
Node *cur = L->head;
while (cur) {
if (cur->value == value) {
if (prev) prev->next = cur->next; /* 跳过 cur */
else L->head = cur->next; /* 删的是头 */
free(cur);
L->len--;
return 1; /* 删除成功 */
}
prev = cur;
cur = cur->next;
}
return 0; /* 没找到 */
}
/* 遍历:从头沿着 next 一路走,直到 NULL */
void list_print(const LinkedList *L) {
printf("链表(len=%d): ", L->len);
for (Node *cur = L->head; cur; cur = cur->next) {
printf("%d", cur->value);
if (cur->next) printf(" -> ");
}
printf(" -> NULL\n");
}
/* 释放整个链表:必须一个一个 free,否则内存泄漏 */
void list_free(LinkedList *L) {
Node *cur = L->head;
while (cur) {
Node *next = cur->next; /* 先记住下一个,free 完就找不到了 */
free(cur);
cur = next;
}
L->head = NULL;
L->len = 0;
}
int main(void) {
LinkedList L;
list_init(&L);
list_push_back(&L, 10);
list_push_back(&L, 20);
list_push_back(&L, 30);
list_push_front(&L, 5); /* 头插 */
list_print(&L);
/* 输出: 链表(len=4): 5 -> 10 -> 20 -> 30 -> NULL */
Node *found = list_find(&L, 20);
if (found) printf("找到节点: %d\n", found->value);
/* 输出: 找到节点: 20 */
list_remove(&L, 10); /* 删中间 */
list_print(&L);
/* 输出: 链表(len=3): 5 -> 20 -> 30 -> NULL */
list_remove(&L, 5); /* 删头 */
list_print(&L);
/* 输出: 链表(len=2): 20 -> 30 -> NULL */
list_free(&L);
printf("已释放, len=%d\n", L.len);
/* 输出: 已释放, len=0 */
return 0;
}要点讲解:
- 自引用结构:节点里
struct Node *next必须用struct Node全名,因为此时Node这个 typedef 还没定义完。 - 头插 vs 尾插:头插 O(1) 但顺序倒过来;尾插保持顺序但 O(n)。这是链表 vs 数组的核心权衡之一。
- 删除要记前驱:删一个节点必须把它前驱的
next指向它的后继——所以遍历时要用prev+cur双指针。 - 释放顺序:
list_free必须先记next再free(cur),否则free完就找不到下一个了——这是链表释放的经典坑。 - 链表 vs 数组:链表插入删除 O(1)(已知位置)、随机访问 O(n);数组正好相反。CPU 缓存对数组更友好,所以小规模数据数组往往更快。
实战 3:简易 shell(fork/exec/wait + 管道)
需求:写一个迷你 shell,能跑普通命令(如 ls -l),也能跑带单个管道的命令(如 ls -l | wc -l),输入 exit 退出。
涉及知识:进程创建 fork、进程替换 execvp、等待 waitpid、管道 pipe、文件描述符重定向 dup2。这是 Linux/Unix 系统编程的核心。
/* mysh.c — 简易 shell:支持普通命令与单管道 "ls -l | wc -l"
* 编译:gcc mysh.c -o mysh
* 运行:./mysh,然后输入命令,输入 exit 退出
* 知识点:fork 创建子进程、exec 替换进程映像、wait 回收、pipe 管道
*
* 核心思路:
* 1. fork() 复制当前进程,得到父 + 子两个进程
* 2. 子进程调 execvp() 把自己替换成 ls / wc / grep 等命令
* 3. 父进程调 wait() 等子进程结束,避免产生僵尸进程
* 4. 管道命令 "A | B":再 fork 两个子进程,中间用 pipe() 连通
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_LINE 256
#define MAX_ARGS 64
/* 把一行命令按空格切成参数数组,供 execvp 使用
* 例:"ls -l /tmp" -> ["ls", "-l", "/tmp", NULL]
*/
void parse(char *line, char **argv, int *argc) {
*argc = 0;
char *tok = strtok(line, " \t\n");
while (tok && *argc < MAX_ARGS - 1) {
argv[(*argc)++] = tok;
tok = strtok(NULL, " \t\n");
}
argv[*argc] = NULL;
}
/* 运行一条普通命令(无管道)*/
void run_simple(char **argv) {
pid_t pid = fork(); /* 复制进程 */
if (pid < 0) { perror("fork"); return; }
if (pid == 0) { /* 子进程 */
execvp(argv[0], argv); /* 替换映像;成功不会返回 */
perror("execvp"); /* 走到这里说明出错(如命令不存在)*/
_exit(127);
} else { /* 父进程 */
int status;
waitpid(pid, &status, 0); /* 等子进程结束 */
}
}
/* 运行带一个管道的命令:left | right */
void run_pipe(char **left, char **right) {
int fd[2]; /* fd[0]=读端, fd[1]=写端 */
if (pipe(fd) < 0) { perror("pipe"); return; }
pid_t p1 = fork(); /* 第一个子进程跑 left */
if (p1 == 0) {
dup2(fd[1], STDOUT_FILENO); /* 把 stdout 重定向到管道写端 */
close(fd[0]); close(fd[1]);
execvp(left[0], left);
perror("execvp left");
_exit(127);
}
pid_t p2 = fork(); /* 第二个子进程跑 right */
if (p2 == 0) {
dup2(fd[0], STDIN_FILENO); /* 把 stdin 重定向到管道读端 */
close(fd[0]); close(fd[1]);
execvp(right[0], right);
perror("execvp right");
_exit(127);
}
close(fd[0]); close(fd[1]); /* 父进程关闭管道两端 */
waitpid(p1, NULL, 0); /* 等两个子进程都结束 */
waitpid(p2, NULL, 0);
}
int main(void) {
char line[MAX_LINE];
printf("mysh> ");
while (fgets(line, sizeof(line), stdin)) {
/* 找管道符 | —— 简化版只支持单管道 */
char *pipe_pos = strchr(line, '|');
if (pipe_pos) {
*pipe_pos = '\0'; /* 拆成两段 */
char *left_line = line;
char *right_line = pipe_pos + 1;
char *left[MAX_ARGS], *right[MAX_ARGS];
int la, ra;
parse(left_line, left, &la);
parse(right_line, right, &ra);
if (la > 0 && ra > 0) run_pipe(left, right);
} else {
char *argv[MAX_ARGS]; int argc;
parse(line, argv, &argc);
if (argc == 0) { printf("mysh> "); continue; }
if (strcmp(argv[0], "exit") == 0) {
printf("bye\n");
break;
}
run_simple(argv);
}
printf("mysh> ");
}
return 0;
}
/* === 运行示例 ===
* $ ./mysh
* mysh> ls -l
* (列出当前目录)
* mysh> ls -l | wc -l
* 12
* mysh> echo hello
* hello
* mysh> exit
* bye
*
* 要点:
* - fork() 一次返回两次:父进程拿到子 pid,子进程拿到 0
* - execvp() 不会返回,除非出错;这就是命令找不到的处理点
* - waitpid() 不调用会产生"僵尸进程"(zombie),占着 PID 不释放
* - pipe() 创建一对文件描述符,中间用 dup2 重定向 stdin/stdout
*/要点讲解:
fork一次返回两次:父进程拿到子进程 PID(>0),子进程拿到 0——这是 Unix 进程模型的精髓,bash、zsh跑命令都是这么来的。execvp不返回:它把当前进程的代码段、数据段全部替换成新程序;只有失败才返回。所以必须先fork,否则原 shell 就没了。waitpid防僵尸:子进程结束后会留一个"僵尸"状态等父进程收尸,不wait就会占着 PID,长期运行的服务会耗尽 PID 表。- 管道
pipe:创建一对文件描述符,fd[1]写的数据能从fd[0]读出来——这就是 shell|的实现原理。 dup2重定向:把管道一端"接到"标准输入/输出上,子进程跑起来时根本不知道自己连的是管道而非终端——这就是 Unix 的"一切皆文件"哲学。- 可扩展方向:支持重定向
>/<、后台运行&、多级管道、内建命令cd、命令历史——这些加起来就是真正的 bash。
小结
三个项目分别对应 C 的三大核心场景:数据组织(struct + 文件 + 动态内存)、动态数据结构(指针 + 链表)、系统编程(进程 + 管道)。它们都用到了前面学的基础——指针、struct、内存管理、文件 I/O——只是组合起来解决真实问题。建议你至少把其中一个改造成自己的版本(给学生管理加"删除/修改"、给链表加"反转/合并"、给 shell 加"重定向"),做过一遍比读十遍更扎实。
恭喜!到这里 C 系列就真正完结了。接下来可以挑一个方向深入——Linux 系统编程(APUE)、网络编程(socket)、开源项目贡献(Redis / SQLite 的 C 代码片段)、嵌入式(STM32 / Arduino)——C 的世界非常广阔。
← 上一篇 C 标准库速览
返回 C 语言教程目录