1. 这十个C语言入门项目,为什么能真正帮你跨过“学不会”的坎?
我带过不下两百个零基础转行的学员,也给高校实训课做过三年助教,最常听到的一句话是:“书看了三遍,代码敲了十遍,一写项目还是空——不知道从哪下手,更不知道写出来对不对。”这不是态度问题,是学习路径断层了。C语言不是靠背语法点就能掌握的,它是一门需要在内存里“搭积木”的语言:变量在哪块内存、数组怎么连续排布、指针怎么跳转、函数调用时栈帧怎么压入弹出……这些看不见的过程,恰恰决定你写的程序是稳定运行还是随机崩溃。而这十个练手项目,就是专为补上这层“空间感”设计的——它们不追求炫技,每个都锚定一个核心机制:数组的线性布局与索引边界、字符串在内存中的终止符逻辑、文件I/O的缓冲区行为、结构体对齐带来的内存浪费、指针与数组名的本质区别……比如“学生成绩管理系统”,表面是增删改查,实则逼你反复处理二维数组的行列索引、用结构体封装数据并理解.operator的内存偏移、用动态内存模拟可变长度记录;再比如“通讯录管理”,必须亲手实现字符串比较、复制、拼接,才能真正明白strcmp为什么返回-1/0/1,strcpy为什么比strncpy危险,strcat在什么情况下会越界写入。这些项目不是让你“完成任务”,而是制造“认知摩擦”:当你第一次因为忘记初始化数组而得到一堆随机数,当你第一次因指针未判空导致段错误,当你第一次发现sizeof(arr)/sizeof(arr[0])在函数参数里失效——这些“踩坑瞬间”,才是C语言开始长进骨头里的起点。适合谁?刚学完语法但不敢写完整程序的新手;学过Python/Java想补底层功底的转行者;准备计算机二级或考研C语言科目的学生;还有那些被面试官一句“说说数组和指针的区别”问懵的求职者。别急着抄代码,先想清楚:这个项目,到底在训练你对哪一块内存的掌控力。
2. 项目整体设计逻辑:为什么是这十个?它们如何构成能力进阶闭环?
这十个项目的排序不是随意堆砌,而是按“内存可见性→数据组织→外部交互→系统协作”四层递进构建的,每层解决一类典型认知盲区,形成闭环训练。
2.1 第一层:建立内存空间直觉(项目1-3)
新手最大的障碍是“看不见内存”。C语言里一切操作最终都映射到地址和字节,但教材很少带你画内存图。这前三项全部聚焦“让内存变得可触摸”:
- 项目1:数字反转与回文判断——强制你用
%10和/10拆解整数,把数字当字符序列处理。关键不在算法,而在让你亲手验证:int num = 123; int arr[10];时,arr[0]存的是3还是1?为什么arr[i++] = num % 10后要倒序输出?这直接关联到大端小端和数值存储本质。 - 项目2:冒泡排序可视化——要求每次交换后打印整个数组状态。你必须盯着控制台看
{5,2,8,1}怎么一步步变成{1,2,5,8},观察arr[j]和arr[j+1]的地址差是否恒为4(int大小),理解为什么j < n-1-i中的i代表已确定的最大值个数。 - 项目3:矩阵转置(非方阵)——打破“数组必须方阵”的思维定式。当你用
int a[3][4]转置成b[4][3],必须手动计算b[j][i] = a[i][j],此时&a[0][0]和&b[0][0]的地址差是多少?为什么不能用memcpy直接拷贝?这逼你理解二维数组在内存中是“行优先连续存储”的铁律。
提示:这一层所有项目必须关闭编译器警告(如
-Wall),但要强制自己用printf("addr: %p\n", (void*)&arr[i]);打印关键变量地址。眼见为实,比任何文字描述都管用。
2.2 第二层:驾驭复杂数据结构(项目4-6)
当内存布局清晰后,下一步是组织数据。C语言没有内置List/Map,一切靠结构体+指针+数组组合。这三项训练你“用简单积木搭复杂房子”:
- 项目4:学生成绩管理系统(结构体+数组)——定义
struct Student { char name[20]; int scores[5]; float avg; };后,用Student stu[100]存储。重点不是功能,而是理解:stu[0].scores[0]的地址 =&stu[0] + offsetof(Student, scores) + 0*sizeof(int);当avg字段加在scores后面,为什么sizeof(Student)可能是44而非40?(考虑内存对齐) - 项目5:通讯录(结构体+动态内存)——把数组换成
Student *stu = malloc(n * sizeof(Student))。必须亲手写realloc扩容逻辑,并在每次free(stu)前打印stu地址,观察同一变量不同次运行地址是否变化。这是理解堆内存碎片化的第一课。 - 项目6:简易计算器(函数指针数组)——用
double (*ops[4])(double, double) = {add, sub, mul, div};实现运算符映射。关键在体会:ops[0]存的是函数入口地址,ops[0](a,b)等价于(*ops[0])(a,b),而ops本身是数组名,不可赋值。这直接打通指针数组与函数指针的认知壁垒。
2.3 第三层:连接外部世界(项目7-8)
程序不能只在内存里跑,必须读写文件、解析输入。这两项专治“输入输出玄学”:
- 项目7:学生成绩文件读写——用
fopen("score.txt", "r")读取文本,再用fprintf(fp, "%s %d %d %d\n", s.name, s.scores[0], ...)写入。必须对比fgets和fscanf的行为差异:fgets读到换行符就停,fscanf会跳过空白符;当文件末尾无换行符时,fgets可能少读一行。这让你明白“行缓冲”和“全缓冲”的实际影响。 - 项目8:单词统计(文件+字符串处理)——读取英文文本,统计每个单词出现次数。难点在
strtok的陷阱:它修改原字符串插入\0,且第二次调用必须传NULL。你会被迫重写my_strtok,用isalpha()逐字判断分词边界,从而真正吃透ASCII码表和字符分类函数。
2.4 第四层:逼近真实系统(项目9-10)
最后两项模拟小型系统级任务,整合所有能力:
- 项目9:简易Shell(进程创建+I/O重定向)——用
fork()创建子进程,execvp()执行命令,dup2()重定向stdin/stdout。当你实现ls > out.txt,必须理解:父进程fork后,子进程继承文件描述符表副本,dup2(fd, STDOUT_FILENO)是把fd指向的文件对象绑定到标准输出槽位。这直接关联操作系统进程模型。 - 项目10:内存池模拟(malloc/free底层简化)——用大块
char pool[1024*1024]模拟堆,手动维护空闲链表。分配时遍历链表找足够空间,分割后更新指针;释放时合并相邻空闲块。虽然远不如glibc malloc,但写完你就懂了brk/sbrk系统调用、内存碎片、以及为什么free(NULL)是安全的。
这十个项目像十个齿轮,咬合传动:从看清单个字节,到组织百条记录,再到调度进程、管理内存——每一步都踩在C语言最硬核的关节上。跳过任何一层,后续都会卡住。
3. 核心细节与实操要点:每个项目必须死磕的3个技术锚点
光列项目没用,关键是要知道每个项目里哪些细节是“必须亲手验证否则等于白做”的技术锚点。以下是每个项目最值得深挖的三个实操要点,附带我的调试笔记。
3.1 项目1:数字反转与回文判断
锚点1:整数拆解的边界处理
新手常写while(num) { digit = num % 10; num /= 10; },但遇到num=0时循环不执行。正确写法必须包含do-while或初始判断。我在教学中让学生故意输入0,观察输出是否为空——这就是调试意识的起点。
锚点2:数组索引与内存布局的对应
要求学生用int digits[10]存各位数字后,打印&digits[0],&digits[1],&digits[2]。三次运行结果地址差应恒为4(32位int)。若出现差8,说明编译器做了填充,需检查结构体对齐设置(#pragma pack(1)可验证)。
锚点3:回文判断的指针写法
禁用下标for(i=0,j=len-1; i<j; i++,j--),强制用指针:int *left = digits, *right = digits + len - 1; while(left < right) { if(*left != *right) break; left++; right--; }。这样写才能体会指针算术的本质:right--等价于right = right - 1,地址减4。
3.2 项目2:冒泡排序可视化
锚点1:双重循环的物理意义
外层i不是“轮数”,而是“已就位元素个数”。内层j的上限n-1-i必须手动画图验证:当i=0,j从0到n-2,比较arr[0]~arr[1]直到arr[n-2]~arr[n-1],最大值沉底;当i=1,j上限变为n-2,不再比较最后一位——这就是“冒泡”的物理过程。
锚点2:交换操作的原子性
必须用临时变量temp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = temp;,禁用异或交换(arr[j] ^= arr[j+1]; ...)。因为异或交换在arr[j] == arr[j+1]时失效(结果全0),这是经典陷阱。
锚点3:优化标志位的实际效果
添加int swapped = 0;,内层交换时置1。外层循环前检查if(!swapped) break;。让学生用{1,2,3,4,5}测试,观察循环次数从O(n²)降到O(n),理解“提前退出”对性能的真实影响。
3.3 项目3:矩阵转置(非方阵)
锚点1:二维数组的内存连续性证明
声明int a[2][3] = {{1,2,3},{4,5,6}};后,打印&a[0][0],&a[0][1],&a[0][2],&a[1][0]。地址应严格递增4字节,证明a[0][3]即a[1][0]。这是理解int *p = &a[0][0]; p[3]为何等于4的关键。
锚点2:转置算法的索引映射b[j][i] = a[i][j]中,i范围0~m-1,j范围0~n-1。当a是3×4,b必为4×3。让学生手动计算a[2][3](值6)应存入b[3][2],再验证&b[3][2]是否等于&a[2][3]——地址相同才证明转置未复制数据,只是重新解释。
锚点3:memcpy失效的根本原因
尝试memcpy(b, a, sizeof(a))后打印b,发现结果错乱。因为memcpy按字节拷贝,而a和b内存布局不同:a是3行×4列连续,b是4行×3列连续。必须用嵌套循环逐元素赋值,这让你明白“内存布局即数据结构”。
3.4 项目4:学生成绩管理系统(结构体+数组)
锚点1:结构体内存对齐的量化计算struct Student { char name[20]; int scores[5]; float avg; };
name[20]: 20字节scores[5]: 5×4=20字节avg: 4字节
理论大小44,但sizeof(Student)实测为48。因为avg前需4字节对齐,编译器在scores后插入4字节填充。用offsetof宏验证:offsetof(Student, avg)应为44而非40。
锚点2:数组名作为参数的陷阱
写函数void print_all(Student stu[], int n),在函数内sizeof(stu)返回8(64位指针大小),而非sizeof(Student)*n。必须强调:数组名传参即指针传递,stu等价于Student *stu。这是C语言最易混淆的概念。
锚点3:字符串输入的安全写法
禁用scanf("%s", stu[i].name),强制用fgets(stu[i].name, sizeof(stu[i].name), stdin)。fgets会读入换行符,需手动替换为\0:stu[i].name[strcspn(stu[i].name, "\n")] = '\0';。这教会你处理输入缓冲区残留。
3.5 项目5:通讯录(结构体+动态内存)
锚点1:malloc失败的防御性编程Student *stu = malloc(n * sizeof(Student));后必须检查if(!stu) { perror("malloc failed"); exit(1); }。在Linux下用ulimit -v 10000限制虚拟内存,触发malloc失败,观察程序是否优雅退出。
锚点2:realloc的异常安全
错误写法:stu = realloc(stu, new_size);若realloc失败返回NULL,原指针丢失。正确写法:void *tmp = realloc(stu, new_size); if(tmp) stu = tmp; else { /* 处理失败 */ }。这是C语言资源管理的核心原则。
锚点3:动态数组的生命周期管理
要求学生在main函数结束前free(stu),并在free后立即置stu = NULL。用valgrind ./a.out检测内存泄漏,观察free(NULL)是否报错(不报错,证明安全)。
3.6 项目6:简易计算器(函数指针数组)
锚点1:函数指针声明的语法糖double (*ops[4])(double, double)解析:ops[4]是数组,(*ops[4])表示数组元素是指针,(double, double)是参数,double是返回值。等价于typedef double (*op_func)(double, double); op_func ops[4];。必须手写三遍声明,强化语法直觉。
锚点2:函数指针与普通指针的地址差异
打印printf("func addr: %p\n", (void*)add);和printf("var addr: %p\n", (void*)&x);,观察两者都是地址,但函数地址不可*解引用。这打破“指针万能”的误解。
锚点3:回调函数的上下文传递
扩展项目:支持带精度参数的四舍五入。需改造为double round_to(double x, int precision),但ops数组只接受双参数。解决方案:用union包装参数,或引入全局精度变量——这引出闭包概念的C语言实现局限。
3.7 项目7:学生成绩文件读写
锚点1:文本模式与二进制模式的区别
用fopen("score.txt", "w")写入{10,20,30},用fopen("score.txt", "rb")读取,发现读到的是ASCII码'1','0','\n','2','0','\n'...而非二进制0x0A,0x14,0x1E。必须用"wb"和fwrite(&num, sizeof(int), 1, fp)才能写二进制。
锚点2:fscanf的格式化陷阱fscanf(fp, "%s %d %d %d", name, &s1, &s2, &s3)中,%s遇到空格停止,但若文件中name含空格(如“Zhang San”),%s只读“Zhang”,“San”残留缓冲区,导致后续%d读错。必须用fgets读整行再sscanf解析。
锚点3:文件结束的可靠判断while(!feof(fp))是经典反模式。正确写法:while(fscanf(fp, "%s %d %d %d", ...) == 4),以fscanf返回值(成功转换项数)为循环条件。feof只在尝试读取失败后置位,此时已晚。
3.8 项目8:单词统计(文件+字符串处理)
锚点1:strtok的隐式状态strtok(str, " ")第一次调用后,内部保存str地址;第二次调用strtok(NULL, " ")才继续分割。若中间调用其他strtok,状态被覆盖。必须用strtok_r(带char **saveptr)实现可重入版本。
锚点2:大小写统一的健壮处理tolower()只能处理单字节字符。对于UTF-8文本,需用mbtowc转换宽字符再towlower。但本项目限定ASCII,所以for(i=0; str[i]; i++) str[i] = tolower((unsigned char)str[i]);是安全的。
锚点3:哈希表的简易实现
不用<uthash.h>,手写链地址法:struct WordNode { char word[50]; int count; struct WordNode *next; }; struct WordNode *hash[26];。插入时hash[tolower(word[0])-'a']为头指针。这让你理解哈希冲突的实际处理。
3.9 项目9:简易Shell(进程创建+I/O重定向)
锚点1:fork后文件描述符的继承fork()后子进程获得父进程文件描述符表副本,fd 0,1,2指向同一内核文件表项。dup2(fd, STDOUT_FILENO)是让fd指向的文件表项绑定到STDOUT_FILENO槽位,不影响原fd。
锚点2:重定向的执行顺序
实现cmd > file时,必须在fork后、execvp前执行dup2(fd, STDOUT_FILENO),且close(fd)。若在execvp后调用,子进程已替换,dup2无效。
锚点3:waitpid的错误处理pid_t pid = waitpid(-1, &status, WNOHANG);中,-1等待任意子进程,WNOHANG非阻塞。必须检查pid == 0(无子进程退出)、pid == -1(错误)、pid > 0(正常退出)三种情况。
3.10 项目10:内存池模拟(malloc/free底层简化)
锚点1:内存块头部的元数据设计
每个分配块前放struct Header { size_t size; int is_free; struct Header *next; };。malloc时返回header + 1,free时通过ptr - sizeof(Header)找回头部。这模拟了glibc malloc的chunk结构。
锚点2:首次适配与最佳适配的选择
遍历空闲链表时,“首次适配”找到第一个够大的块,“最佳适配”找最接近的块。前者快但易碎片,后者慢但内存利用率高。本项目用首次适配,因代码简洁且教学目的明确。
锚点3:合并相邻空闲块的条件free时检查prev->next == header且header->next存在,若header->next->is_free为真,则prev->next = header->next->next; header->next->size += header->size + sizeof(Header);。这重现了内存合并的核心逻辑。
4. 实操过程与核心环节实现:从零开始搭建“学生成绩管理系统”
现在我们以项目4“学生成绩管理系统”为例,完整走一遍从需求分析、结构设计、编码实现到调试验证的全流程。这不是代码搬运,而是展示一个资深开发者如何把模糊需求拆解为可执行步骤。
4.1 需求拆解:把“管理系统”翻译成C语言能理解的动作
用户说“管理系统”,在C语言里意味着:
- 数据存储:至少100个学生,每个学生有姓名(≤20字符)、5门课成绩(0-100整数)、平均分(浮点数)
- 核心操作:添加、查询(按姓名/学号)、修改(单科成绩)、删除、显示全部
- 持久化:退出时保存到文件,启动时从文件加载
- 交互方式:命令行菜单,输入数字选择功能
关键约束:
- 姓名用
char name[20],必须预留\0,所以最多19字符 - 成绩用
int scores[5],避免浮点精度问题 - 平均分用
float avg,计算时强制转float:(float)(s1+s2+s3+s4+s5)/5.0f
4.2 结构体设计:内存对齐与字段顺序的实战权衡
#pragma pack(1) // 强制1字节对齐,节省空间(教学用) struct Student { char id[10]; // 学号,字符串形式便于扩展 char name[20]; int scores[5]; float avg; }; #pragma pack() // 恢复默认对齐为什么id放前面?因为id和name都是字符串,放一起减少填充。若avg放最前,float需4字节对齐,id[10]后需2字节填充才能对齐avg,浪费空间。#pragma pack(1)在教学中可忽略对齐,专注逻辑。
4.3 数组与内存管理:静态数组的边界与安全
#define MAX_STUDENTS 100 struct Student students[MAX_STUDENTS]; int student_count = 0; // 当前有效学生数student_count是关键!不能用sizeof(students)/sizeof(students[0])获取数量,那是数组总容量。必须维护独立计数器。- 添加学生时:
if(student_count >= MAX_STUDENTS) { printf("已达上限!\n"); return; } - 删除学生时:用
memmove移动后续元素,保持数组连续:memmove(&students[pos], &students[pos+1], (student_count - pos - 1) * sizeof(struct Student));
4.4 字符串安全输入:fgets的完整处理链
void input_string(char *buf, int size, const char *prompt) { printf("%s", prompt); if(fgets(buf, size, stdin) == NULL) { printf("输入错误!\n"); exit(1); } // 移除换行符 int len = strlen(buf); if(len > 0 && buf[len-1] == '\n') { buf[len-1] = '\0'; } else { // 输入过长,清空缓冲区 int c; while((c = getchar()) != '\n' && c != EOF); } }这段代码处理了fgets三大陷阱:
fgets返回NULL(EOF或错误)- 换行符
\n必须手动替换为\0 - 输入超长时,剩余字符留在
stdin,必须用getchar清空,否则污染下次输入
4.5 平均分计算与显示:浮点精度与格式化
void calculate_avg(struct Student *s) { int sum = 0; for(int i = 0; i < 5; i++) { sum += s->scores[i]; } s->avg = (float)sum / 5.0f; // 强制用float除法 } void print_student(const struct Student *s) { printf("学号:%s 姓名:%s ", s->id, s->name); printf("成绩:"); for(int i = 0; i < 5; i++) { printf("%d ", s->scores[i]); } printf("平均:%.1f\n", s->avg); // %.1f 保留1位小数 }注意:5.0f中的f后缀确保是float而非double,避免隐式转换开销。%.1f格式化防止输出85.000000这种冗余小数。
4.6 文件读写:文本格式的健壮解析
保存到文件(text mode):
FILE *fp = fopen("students.txt", "w"); if(!fp) { perror("保存失败"); return; } for(int i = 0; i < student_count; i++) { fprintf(fp, "%s %s %d %d %d %d %d\n", students[i].id, students[i].name, students[i].scores[0], students[i].scores[1], students[i].scores[2], students[i].scores[3], students[i].scores[4]); } fclose(fp);从文件加载:
FILE *fp = fopen("students.txt", "r"); if(!fp) { printf("无历史数据\n"); return; } char line[256]; while(fgets(line, sizeof(line), fp) && student_count < MAX_STUDENTS) { struct Student *s = &students[student_count]; if(sscanf(line, "%9s %19s %d %d %d %d %d", s->id, s->name, &s->scores[0], &s->scores[1], &s->scores[2], &s->scores[3], &s->scores[4]) == 7) { calculate_avg(s); student_count++; } } fclose(fp);关键点:
sscanf用%9s限制学号长度,防溢出- 返回值检查
==7确保所有字段解析成功,避免部分解析导致垃圾数据 fgets读整行再sscanf,比直接fscanf更容错(跳过格式错误行)
4.7 调试验证:用地址打印和valgrind定位真实问题
在main函数开头添加:
printf("students数组起始地址: %p\n", (void*)students); printf("students[0]地址: %p\n", (void*)&students[0]); printf("students[0].id地址: %p\n", (void*)students[0].id); printf("students[0].name地址: %p\n", (void*)students[0].name); printf("students[0].scores[0]地址: %p\n", (void*)&students[0].scores[0]); printf("students[0].avg地址: %p\n", (void*)&students[0].avg);运行输出:
students数组起始地址: 0x7ffeedb8a9a0 students[0]地址: 0x7ffeedb8a9a0 students[0].id地址: 0x7ffeedb8a9a0 students[0].name地址: 0x7ffeedb8a9aa students[0].scores[0]地址: 0x7ffeedb8a9be students[0].avg地址: 0x7ffeedb8a9ce计算:name起始 -id起始 = 10字节(id[10]),scores[0]-name起始 = 20字节(name[20]),avg-scores[0]= 20字节(scores[5]×4),完全符合预期。若avg地址差为24,说明有4字节填充,验证了对齐规则。
最后用valgrind --leak-check=full ./a.out运行,确认无内存泄漏、无越界访问。这才是专业级验证。
5. 常见问题与排查技巧实录:那些年我们踩过的坑
在十年带教中,这十个项目的报错率TOP5问题高度集中。我把它们整理成速查表,并附上独家排查技巧——这些技巧不会出现在任何教材里,全是血泪经验。
| 问题现象 | 根本原因 | 排查技巧 | 我的实操心得 |
|---|---|---|---|
| 程序运行一闪而退,看不到输出 | main函数末尾缺少getchar()或system("pause"),控制台窗口关闭 | 在return 0;前加printf("按任意键退出..."); getchar(); | 别用system("pause")!它依赖Windows命令,跨平台失效。getchar()是标准C,且能捕获回车符。 |
| 输入姓名后,下一行成绩输入直接跳过 | scanf("%d", &score)前有scanf("%s", name),%s不读取换行符,%d遇到换行符直接失败 | 在scanf后加getchar()清空缓冲区,或统一用fgets+sscanf | 这是最高频问题!我让学生在每次scanf后立刻printf("buffer: '%c'\n", getchar());,亲眼看到残留的\n。 |
数组初始化后仍有随机值(如int arr[10] = {0};仍报错) | 局部数组未显式初始化,内容为栈上随机垃圾;{0}只初始化首元素,其余未定义 | 全局数组自动清零,局部数组必须int arr[10] = {0};({0}会将所有元素设0)或memset(arr, 0, sizeof(arr)) | int arr[10] = {0};是C标准保证的全零初始化,比memset更高效。别信“只初始化第一个”的谣言。 |
strcmp返回值不是0/-1/1,而是-123或89 | strcmp返回的是两字符串ASCII码差值,非布尔值;新手误用if(strcmp(a,b) == 1) | 永远用if(strcmp(a,b) == 0)判断相等,if(strcmp(a,b) < 0)判断小于 | 我让学生打印strcmp("abc","abd"),结果是-1('c'-'d'),strcmp("abd","abc")是1,彻底破除“只返回0/-1/1”的迷思。 |
malloc后程序崩溃,valgrind报Invalid write | 忘记为字符串分配末尾\0空间,如char *p = malloc(10); strcpy(p, "hello");——"hello"需6字节,malloc(10)够用,但若strcpy(p, "hello world")就溢出 | 用strncpy(p, src, size-1); p[size-1] = '\0';,或直接snprintf(p, size, "%s", src) | snprintf是终极方案:snprintf(p, size, "%s", src)自动截断并保\0,且返回值告诉你是否截断。 |
5.1 独家调试技巧:三步定位段错误(Segmentation Fault)
段错误是C语言新手噩梦,但其实有固定套路:
第一步:用gdb抓现场
gcc -g -o score score.c # 编译时加-g gdb ./score (gdb) run # 程序崩溃后 (gdb) bt # 查看调用栈 (gdb) info registers # 查看寄存器,看哪个地址非法 (gdb) x/10xw $rax # 查看rax寄存器指向的10个字(w=word)第二步:检查指针合法性
崩溃行附近,对每个指针变量执行:
printf("ptr=%p, *ptr=%d\n", (void*)ptr, ptr ? *ptr : -1); // 加?判空若ptr为NULL或野指针(如0xdeadbeef),立即定位。
第三步:用valgrind查内存违规
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all ./score重点关注:
Invalid read/write:越界访问Use of uninitialised value:使用未初始化变量