ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言第21天复盘:指针、scanf缓冲区与gdb调试实战

C语言第21天复盘:指针、scanf缓冲区与gdb调试实战 第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 123。这道题本身只需要枚举和取余但我一开始把“因子”和“质因数”搞混了导致判断条件写错输出自然全是错的。1.3 从卡题倒推出来的缺口清单三道题卡完我把症状和背后缺的知识拉了一张表卡住的现象背后缺的东西我当时的解法scanf读进字符串总是少一半不知道%s遇空格就停改用fgets读整行输出字符串末尾多一个换行缓冲区残留和\n的处理手动去掉结尾的\n霍格沃茨找零钱三列借位算不对进制换算思维全换成最小单位纳特再相减数组越界或指针乱指导致段错误对内存地址没有概念引入gdb看地址和变量字符函数调用总是传参报错字符串和指针的关系不清回头重看数组名和函数参数这张表让我意识到我在第15天到第19天之间跳过了最核心的一层C语言的运行模型。变量不是写在数学考卷上的“未知数”而是内存某个地址上的数据函数名、数组名、指针也不是语法装饰而是代码和内存之间的通道。于是第20天到第21天我停下来做了一件事把指针这个地基重新挖开。2. 指针到底怎么想的地址、自增和数组名的坑2.1 为什么指针是“分水岭”很多人学C语言把指针当成“高级语法”实际上指针就是C语言的地基。学循环、数组的时候还能靠直觉混过去但只要你开始写字符串函数、写文件、写链表指针就躲不掉了。我自己的感受是第21天之前我用数组名的时候根本不清楚它是什么第21天之后我才明白数组名、函数名、指针变量本质上都在描述同一件事——内存地址。用一个生活化的类比内存就像一栋宿舍楼每个房间都有门牌号。普通变量是“先在某个房间放一个值再用变量名给这个房间起个别名”指针变量则是“用一个房间来记录另一个房间的门牌号”。int *p定义了一个pp里面存的不是整数本身而是另一个int变量所在房间的门牌号。这就是为什么scanf(%d, n)必须要写n——scanf要往n的门牌号对应的房间里写数据不给地址它就不知道该敲哪扇门。2.2 “ab”和指针自增前缀后缀必须一次分清我搜过一个热搜词是“c语言 a b解释”。这个知识点看起来基础但到了指针这里会放大成四个完全不同语义的表达式int b 5; int a b; // b先自增成6再把6赋给aa6, b6 int c b; // 先把b当前的值6赋给c然后b自增成7c6, b7如果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 pp是const的int*p本身不能改动但可以改p指向的值const int *pp是指向const int的指针p可以改但不能通过p改它指向的值int const *p同const int *p同上const int *const pp是const的指向const int的指针指针和指向的值都不能改其实日常工作里写代码不一定天天用const修饰指针但理解这个读法能让你看别人代码时不慌。我给自己的检查方法很简单问“到底谁不能变”答案是“const靠近谁谁就不能变”。3. 字符串函数、逆序输出和函数指针指针最常见的三个战场3.1 字符串函数为什么都在“偷偷”用指针学到这里我才明白字符串在C语言里就是“以\0结尾的一串char”而字符串函数接收的所谓“字符串”本质上是字符数组的首地址。比如strlen(s)它不知道该数组的长度所以函数内部必须靠\0来判断结束。我手动写了一遍自己的strlensize_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语言高手但它让我知道了接下来该往哪里走这就够了。
返回列表