第21天,周六。我把控制台里的“Hello, World”关掉,屏幕上只剩两行指针代码——这是我学C语言的第21天,也是我第一次决定不急着抄答案,而是把学过的东西翻出来“返工”的一天。前20天我跟着视频走完了变量、循环、数组、函数这些基础路线,也刷了几道PTA的入门题,但说实话,指针、scanf的返回值、字符串函数这些词在我脑子里一直是绕成一团的耳机线。这篇文章不是新手第一天的保姆教程,也不打算把C语言语法重新抄一遍,它是我在“第21天”这个节点上的完整复盘:哪些题卡住了我、我怎么用gdb把程序拆开看、指针和数组名到底有什么关系、scanf的“脏数据”是怎么混进来的。如果你也刚好卡在这一带,希望这篇能帮你少绕几次弯路。
1. 第21天的“地基体检”:二十天路线、三处卡点和一张缺口清单
1.1 前20天我到底走了多远
先交代背景。我不是计算机科班出身,之前写过一点脚本,但对C语言这种“离内存很近”的语言基本是零基础。第1天到第7天,我把环境装好、把变量和运算符过了一遍,包括数据变量定义分类(局部变量、全局变量、static修饰符这几个概念当时就是死记硬背),然后是printf和scanf的入门用法,if/switch的分支逻辑。第8天到第14天进入循环和数组:while和do-while的区别是我第一次被卡住的地方——while是先判断再执行,do-while是至少执行一次再判断,看起来简单,但写菜单程序时一旦搞反,逻辑就完全乱掉;这个阶段我还写了九九乘法表和冒泡排序,算是把循环和数组下标练熟了。第15天到第18天开始碰字符串函数和函数传参,从这时起,代码开始变得不那么“听话”了。
这里有个经验:零基础学C语言,前两周的目标不应该是“会做题”,而应该是“能把一段代码的每一步在脑子里模拟跑完”。我前两周最大的问题就是只求代码能运行,不求为什么,结果一到稍复杂的题就露馅。
1.2 让我卡到怀疑人生的三道题
成体系的进度走到第19天,我开始刷PTA上的基础题,然后被三道题狠狠按在地上摩擦。
第一道是PTA(乙级)1037 在霍格沃茨找零钱。题目把货币体系换成加隆、西可、纳特,进制关系是1加隆=17西可、1西可=29纳特,输入应付和实付,要求输出找零。我当时第一反应是直接对三列做借位减法,结果发现加隆、西可、纳特三列的“借位倍数”不一样,手推都推不对。折在这道题上的根本原因是:我没有想到“把不同进制的金额统一换算成最小单位”,先去减再除回去。
第二道是字符串逆序。PTA上有好几道字符串逆序的题,我一开始用scanf("%s", str)输入,结果输入“hello world”只读进“hello”;后来改成读一行,又发现数组末尾多了个换行符,输出时总会多一个空行。这个坑现在回头看非常经典,但当时我完全不知道“输入缓冲区还有残留字符”这个概念,debug了整整一晚上。
第三道是完数。我第一次看到“完数c语言什么意思”这个搜索词时,甚至不知道“完数”就是“完全数”——所有真因子之和等于它本身的数,比如6 = 1+2+3。这道题本身只需要枚举和取余,但我一开始把“因子”和“质因数”搞混了,导致判断条件写错,输出自然全是错的。
1.3 从卡题倒推出来的缺口清单
三道题卡完,我把症状和背后缺的知识拉了一张表:
| 卡住的现象 | 背后缺的东西 | 我当时的解法 |
|---|---|---|
| scanf读进字符串总是少一半 | 不知道%s遇空格就停 | 改用fgets读整行 |
| 输出字符串末尾多一个换行 | 缓冲区残留和\n的处理 | 手动去掉结尾的\n |
| 霍格沃茨找零钱三列借位算不对 | 进制换算思维 | 全换成最小单位纳特再相减 |
| 数组越界或指针乱指导致段错误 | 对内存地址没有概念 | 引入gdb看地址和变量 |
| 字符函数调用总是传参报错 | 字符串和指针的关系不清 | 回头重看数组名和函数参数 |
这张表让我意识到,我在第15天到第19天之间跳过了最核心的一层:C语言的运行模型。变量不是写在数学考卷上的“未知数”,而是内存某个地址上的数据;函数名、数组名、指针也不是语法装饰,而是代码和内存之间的通道。于是第20天到第21天,我停下来做了一件事:把指针这个地基重新挖开。
2. 指针到底怎么想的:地址、自增和数组名的坑
2.1 为什么指针是“分水岭”
很多人学C语言把指针当成“高级语法”,实际上指针就是C语言的地基。学循环、数组的时候还能靠直觉混过去,但只要你开始写字符串函数、写文件、写链表,指针就躲不掉了。我自己的感受是:第21天之前,我用数组名的时候根本不清楚它是什么;第21天之后,我才明白数组名、函数名、指针变量本质上都在描述同一件事——内存地址。
用一个生活化的类比:内存就像一栋宿舍楼,每个房间都有门牌号。普通变量是“先在某个房间放一个值,再用变量名给这个房间起个别名”;指针变量则是“用一个房间来记录另一个房间的门牌号”。int *p定义了一个p,p里面存的不是整数本身,而是另一个int变量所在房间的门牌号。这就是为什么scanf("%d", &n)必须要写&n——scanf要往n的门牌号对应的房间里写数据,不给地址它就不知道该敲哪扇门。
2.2 “a=++b”和指针自增:前缀后缀必须一次分清
我搜过一个热搜词是“c语言 a= ++b解释”。这个知识点看起来基础,但到了指针这里会放大成四个完全不同语义的表达式:
int b = 5; int a = ++b; // b先自增成6,再把6赋给a,a=6, b=6 int c = b++; // 先把b当前的值(6)赋给c,然后b自增成7,c=6, b=7如果b是一个指针p,代码就更迷惑了:
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; *p++; // 等价于 *(p++):先取出p指向的值10,再把p移动到下一个元素 (*p)++; // 先取出p指向的值,然后把这个值加1(位置不动) ++*p; // 等价于 ++(*p):把p指向的值先加1再用我当时的笨办法是给这四种表达式各写一个最小测试程序,用printf把每一步前后的p和*p都打出来,跑一遍就记住了。这里有个关键认知:指针自增并不是地址单纯加1,而是加“一个指针所指类型的字节数”。int* p做p++,地址偏移4个字节(在常见平台上),char* p做p++,地址只偏移1个字节。这是搜索引擎里“c语言 数组 指针 移动 指定位输出 字符”这类问题的答案来源。
2.3 数组名不是“数组的地址”这么简单
我第一次听到“数组名就是首元素地址”这句话时,以为完全懂了,结果做二维数组时又蒙了。准确说法是:在绝大多数表达式中,数组名会“退化”成指向首元素的指针。比如:
int arr[5] = {10, 20, 30, 40, 50}; printf("%d\n", *(arr + 2)); // 等价于 arr[2],输出30arr + 2算的是“从首地址往后挪2个int大小”的地址,不是往后挪2个字节。所以用指针遍历数组时,类型的意义就体现出来了:编译器必须知道你挪的“一步”是多宽。我自己动手写过一个最基础的字符输出:
char s[] = "hello"; char *p = s; while (*p != '\0') { putchar(*p); p++; }如果要“指定位输出字符”,比如从第3个字符开始输出,直接printf("%s", s + 2);就能得到“llo”,原理就是s + 2这个指针运算。二维数组也同理:a[i][j]本质上就是*(*(a + i) + j),先把行指针挪到第i行,再解引用得到这一行的首地址,然后偏移j个元素。这个式子我从前背不下来,现在能自己推出来了。
2.4 一页纸读懂“指针常量”和“常量指针”
第21天必须把几个容易混淆的定义一次性分清,我用的是“从右往左读名字”的口诀:
| 定义 | 读法 | 含义 |
|---|---|---|
int *const p | p是const的int* | p本身不能改动,但可以改p指向的值 |
const int *p | p是指向const int的指针 | p可以改,但不能通过p改它指向的值 |
int const *p | 同const int *p | 同上 |
const int *const p | p是const的指向const int的指针 | 指针和指向的值都不能改 |
其实日常工作里写代码不一定天天用const修饰指针,但理解这个读法能让你看别人代码时不慌。我给自己的检查方法很简单:问“到底谁不能变”,答案是“const靠近谁,谁就不能变”。
3. 字符串函数、逆序输出和函数指针:指针最常见的三个战场
3.1 字符串函数为什么都在“偷偷”用指针
学到这里我才明白,字符串在C语言里就是“以\0结尾的一串char”,而字符串函数接收的所谓“字符串”,本质上是字符数组的首地址。比如strlen(s),它不知道该数组的长度,所以函数内部必须靠\0来判断结束。
我手动写了一遍自己的strlen:
size_t my_strlen(const char *s) { size_t n = 0; while (*s != '\0') { n++; s++; } return n; }别看它简单,这十几行让我想通了很多事:函数参数传数组时,数组名退化成指针;函数内部对const char *的遍历不会影响外面的数组;返回值是size_t(无符号整型)而不是int。以后再看strcpy、strcat、strcmp,原理都能猜到个大概——无非是拿着指针一个字符一个字符地搬、接、比较。
3.2 字符串逆序的两版实现
为了彻底搞懂“字符串逆序c语言pta”这类题,我写了两个版本。第一版是下标法,第二版是指针法:
#include <stdio.h> #include <string.h> int main() { char s[100]; if (fgets(s, sizeof(s), stdin) == NULL) return 1; size_t n = strlen(s); if (n > 0 && s[n - 1] == '\n') { s[n - 1] = '\0'; // 去掉fgets带入的换行 n--; } char *left = s; char *right = s + n - 1; while (left < right) { char t = *left; *left = *right; *right = t; left++; right--; } puts(s); return 0; }这里有个必须强调的细节:为什么不用scanf,而用fgets?因为scanf("%s", s)遇到空格就停止,输入“hello world”只会读到“hello”。fgets能读一整行,但它会把换行符也读进数组,所以要去掉结尾的\n。这个问题我在第19天卡了一晚上,现在写出来不觉得难,但当时完全没意识到“输入不只包含可见字符,还包括换行符号”。搜索词里的“文件缓冲区 c语言程序”也源自这里:我们看键盘输入是“这一行字符串”,程序看到的却是“字符流+换行符+缓冲区里的剩余字符”。
3.3 函数指针:先混个脸熟
函数指针对第21天的我来说还有点超前,但热搜里大量出现“c语言库函数大全”“qsort回调”,让我意识到函数指针是绕不开的。它的逻辑其实很简单:函数本身也有地址,这个地址存在代码段里,函数指针就是一个“记录函数地址”的变量。
#include <stdio.h> int add(int a, int b) { return a + b; } int main() { int (*fp)(int, int) = add; // fp是指向函数的指针 printf("%d\n", fp(3, 4)); // 等价于 add(3, 4),输出7 return 0; }int (*fp)(int, int)的读法依旧是“从右往左”:fp是一个指针,指向一个“接收两个int、返回int”的函数。为什么会有这种东西?因为它可以当作参数传给另一个函数,由对方在“合适的时机”回调,这就是“回调函数”的基本形态。第21天不需要自己设计复杂的回调机制,但至少看到这种定义时别再当成“函数声明记错了”。
4. scanf与缓冲区:输入是怎样“干干净净”流进变量的
4.1 scanf的格式串:你输入的到底是什么
有个搜索词是“c语言里scanf一定要输入abc吗 而不是可以h1 m1”,我猜提问者可能是把变量名“abc”和格式化字符串混在一起了。scanf的第一参数是“格式串”,比如scanf("%d", &n),它表示“请读取一个十进制整数”,和变量名是什么无关。真正影响读取内容的是%d、%f、%c、%s这些格式符。格式串里出现非格式符的普通字符时,scanf会要求输入流里也必须出现相同字符才能匹配,这就是为什么有时候格式串里写了个逗号,输入却不能少那个逗号。
另一个第21天必须知道的知识是scanf的返回值。它返回“成功匹配并赋值了的项数”,比如:
int n; if (scanf("%d", &n) == 1) { // 确实读到了一个整数 }这个返回值在刷题里非常常用,尤其是“读到文件末尾就停止”的场景。我之前根本不用返回值,导致循环里不知道输入是否成功,白白写错一堆代码。
4.2 缓冲区残留:让你的程序“看到幽灵字符”
这是我踩过最典型的坑。看这段代码:
int n; char c; scanf("%d", &n); scanf("%c", &c);第二次scanf很可能没有等待用户输入,而是直接把上一次留在缓冲区里的换行符读给了c。原因在于:%d在读取时会跳过空白字符,但它不会把换行符“吃掉”,换行符还留在输入缓冲区里;而%c不会跳过空白字符,所以直接读到残留的\n。
解决方式一般有两种:一是在格式串里加一个空格,写成scanf(" %c", &c),让scanf先跳过空白;二是在两次scanf之间显式清空缓冲区。我建议初学者养成“字符输入前先加空格”的习惯,同时记住:数字读取会跳过空白,字符读取不会。这个规则能解释一大半“scanf读错”的怪现象。
4.3 从键盘输入到文件缓冲区:一条完整的数据流
搜索引擎热词里多次出现“文件缓冲区 c语言程序”,这和第21天正好接得上。程序从键盘读数据时,数据不是直接进变量的,而是先由操作系统放进内存的一块缓冲区,scanf再从缓冲区里取;同理,printf输出也不是立刻到屏幕,而是先进缓冲区,再刷新到终端。这个“中间排队”的设计是为了提高效率——内核和硬件打交道太慢,攒一批再处理更划算。
文件读写也是同一套逻辑。fopen打开一个文件后,fscanf、fprintf和scanf/printf行为很像,只是多了一个FILE*参数。这里有个极其容易被忽略的教训:fclose不只是关闭文件,还会把缓冲区里没写完的数据刷到磁盘。如果我fopen后写数据,但不fclose就直接退出,程序看起来正常,数据却可能丢。我在测试文件输出时遇到过:printf在终端能看到,但fprintf的内容必须fclose后才在文件里出现,当时差点以为写文件写错了。
4.4 一张表看清三种输入方式
到第21天,我已经不再用gets了,因为gets不知道缓冲区大小,越界风险极高。下面这张表建议存下来:
| 函数 | 能否读带空格的整行 | 是否保留末尾换行 | 安全程度 |
|---|---|---|---|
scanf("%s", s) | 不能,遇空白停止 | 不读入换行 | 中,容易缓冲区溢出 |
gets(s) | 能 | 不保留,它会去掉换行 | 极不安全,已被标准库废弃 |
fgets(s, n, stdin) | 能 | 保留换行,需要手动去掉 | 安全,需指定最大长度 |
我现在的输入习惯是:能读数字就用scanf并检查返回值;能读整行就首选fgets,然后手动去掉结尾的\n。
5. gdb实战:把看不清的代码变量拉出来看一遍
5.1 为什么我决定不再只靠printf调试
前20天我调试只有一招:往代码里塞printf,打印各种中间变量。这个方法在小程序里够用,但它有几个致命弱点:一是要反复改代码加删打印语句,很费时间;二是段错误时printf根本来不及输出,程序就崩了;三是我只能看到“我预判要看的变量”,看不到程序真正的运行轨迹。
第21天,我决定认真把gdb用起来。在Ubuntu虚拟机里配置C语言环境时,我本来只装了gcc,后来发现gdb很容易装:sudo apt install gdb,编译时加-g选项保留调试信息,比如gcc -g -o demo demo.c。这一步之后,我等于给程序装了一台“X光机”。
5.2 最常用的几条gdb命令
对于第21天阶段的初学者,gdb不需要学全套,先掌握这张表就够用:
| 命令 | 作用 |
|---|---|
break 行号或break 函数名 | 设置断点 |
run | 开始运行,停在第一个断点 |
next | 单步执行,不进入函数 |
step | 单步执行,进入函数 |
print 变量名或print *p | 查看变量或指针指向的值 |
info locals | 查看当前函数所有局部变量 |
bt | 查看函数调用栈 |
watch 变量名 | 变量值变化时停下 |
continue | 继续运行到下一个断点 |
我第一次用gdb最震撼的一刻是:一个未初始化的指针,用print p打出来,地址不是连续的随机数,就是0x0;我试过print *p,然后gdb告诉我“Cannot access memory at address 0x0”之类的警告。那一刻我终于理解“野指针”不是概念,而是真实会发生的崩溃。
5.3 一次段错误的完整排查复盘
假设我写了这么一段有问题的代码:
#include <stdio.h> int main() { int *p; *p = 5; printf("%d\n", *p); return 0; }我先用gcc -g -o bad bad.c编译,运行后终端会提示“Segmentation fault”。如果不开gdb,我可能只会到处猜是不是printf的问题。开gdb后流程是:gdb ./bad,输入run,程序停在崩溃位置,输入bt查看调用栈,发现崩溃就在main函数。再print p,看到p的值是个无效地址。这样我就能确定问题不是后面代码造成的,而是p根本没有指向合法内存。
这就是调试的本质:把“整个程序为什么会崩”缩小成“到底是哪一行、哪个变量出了事”。我后来遇到段错误时不再焦虑,因为我知道gdb总能帮我定位到具体行号。
5.4 顺带认识内存管理这件事
第21天我还不需要精通malloc/free,但“c语言内存管理”“虚拟存储器管理c语言”这些概念已经开始进入视野。malloc分配的内存来自堆区,用完必须free,否则就是内存泄漏。以后写链表时内存泄漏会非常常见,所以我在Ubuntu里提前装了一个工具:valgrind。它能在程序跑完后报告“多少字节还被占用没有释放”。第21天的我只需要知道有这个东西,等到动态内存那一步再系统学也完全来得及。
6. 结构体、动态内存和“霍格沃茨找零钱”:第22天起的10天计划
6.1 为什么下一阶段的重点是结构体和动态内存
到了第21天晚上,我开始为后面做计划。指针有了基础,数组名、字符串、scanf缓冲区的坑都踩过一轮之后,C语言最值得学的就剩三块:结构体、动态内存、文件操作。结构体解决的是“怎么把一组相关数据绑成一个整体”的问题,比如一个学生的学号、姓名、成绩;动态内存解决的是“程序运行时才知道需要多少空间”的问题;文件操作解决的是“数据如何在程序结束后继续存在”的问题。这三块正好也是后续项目——比如网吧计费管理、弹球游戏——绕不开的地基。
6.2 用“第21天重做”的方式,把1037题彻底打通
在列计划之前,我先把之前卡住的PTA 1037重做了一遍。题目里1加隆=17西可,1西可=29纳特,也就是1加隆=493纳特。要找零钱,就用“全部换算成最小单位,算完再除回去”的思路:
#include <stdio.h> int main() { int ag, as, ak; // 应付 int pg, ps, pk; // 实付 scanf("%d.%d.%d %d.%d.%d", &ag, &as, &ak, &pg, &ps, &pk); long long due = (long long)ag * 17 * 29 + as * 29 + ak; long long paid = (long long)pg * 17 * 29 + ps * 29 + pk; long long diff = paid - due; printf("%lld.%lld.%lld\n", diff / (17 * 29), // 加隆 (diff / 29) % 17, // 西可 diff % 29); // 纳特 return 0; }这道题重做一遍,我有几个收获:第一,不同的“单位”聚集在一起时,不要试图用人的大脑做三列借位,机器擅长统一换算;第二,int可能溢出,金额大时要转long long;第三,进制换算本质上就是“除法取商、取模取余”的组合。第21天的时候我连“为什么找零要除以493”都想不通,现在再看,它和“1元=10角,要求输出几元几角”没有任何区别。
6.3 给接下来10天的任务清单
我的计划是这么排的:
- 第22-24天:结构体定义、初始化、结构体数组、typedef,把学生的成绩管理写成一个小练习;
- 第25-27天:动态内存,malloc/free,配合valgrind检查泄漏,写一个能动态扩容的整数数组;
- 第28-30天:单链表的基本操作:创建、插入、删除、遍历;
- 第31-33天:文件读写,fopen/fclose/fscanf/fprintf,把链表数据保存到磁盘再读回来;
- 之后两周:做一个小项目,优先选“网吧计费管理”或“弹球游戏”这类能把文件、链表、指针全用起来的题目。
这样安排的核心思路是:每个新概念都要马上落到一个能跑的程序里,不能只停留在“看懂”。我前20天最大的教训就是看视频多、动手少,导致自己在指针上返工;后面绝不能再犯。
6.4 给同样走到第21天的人几句真心话
如果你也正处在类似阶段,我能分享的只有几条笨办法。第一,遇到看不懂的代码,别急着问人,先试着把它抄下来、改成小例子、加printf、上gdb,通常跑两遍就懂了大半。第二,数组名、指针、scanf这种概念,不要靠背定义,要靠“观察地址变化”来理解——用%p打印指针地址,打印数组名地址,打印数组名+1的地址,你会比任何人讲一百遍都更清楚。第三,题目卡住了不可怕,可怕的是卡住之后直接放弃;我卡在霍格沃茨找零钱时差点关电脑,但转换思维之后,它反而成了我最喜欢的一道题。第21天不会让我变成C语言高手,但它让我知道了接下来该往哪里走,这就够了。