首页 / 资讯中心 / 文章详情

C语言第二课:表达式、scanf缓冲区、循环与数组调试实战

C语言第二课:表达式、scanf缓冲区、循环与数组调试实战 ★ FEATURED ARTICLE
如果你昨天刚配好环境、敲出第一个 hello world今天多半正卡在一种微妙的焦虑里printf 会用变量也认识但真要写一个有点实际价值的程序脑子里还是空的。这就是 day2 要解决的问题。这篇笔记按我自己备课、带学生踩坑的节奏来写相当于《C语言》系列的第 2 课把表达式、scanf 和缓冲区、while / do-while、数组这几块真正吃透同时把 VSCode 配合 gdb 的调试习惯拉起来。今天所有代码都值得手敲一遍我建议不要复制粘贴亲自跑过之后印象才会深。说实话day2 的内容一点都不难但它恰恰是整个 C 语言学习里翻车率最高的一课。1. 表达式与运算符被 ab 拦住的第三天1.1 先搞懂“一个表达式是有值的”很多新手学运算符只记“先算谁后算谁”但 C 语言里几乎所有的运算符都有一个共同特征表达式本身会产生一个值。这个认知比背一百条优先级规则都重要。比如a 5是一个赋值表达式它做的事情是把 5 存进 a同时这个表达式的值也是 5。正因为它有值你才能写出b (a 5)这种把赋值结果继续赋给另一个变量的代码。C 语言的这种设计源自 C 语言前身 B 语言的简洁哲学能省一步就省一步一个表达式能完成的事情绝不用多条语句。但简洁的另一面是难读这也是无数面试题喜欢拿表达式做文章的原因。赋值运算符的结合性是从右往左的。a b c 5实际执行顺序是先将 5 赋给 c再把 c 的值赋给 b最后赋给 a。这一点和数学里“等号是相等关系”的理解完全不同初学者往往要踩几次坑才习惯。1.2 自增自减的两种姿势与“先加后赋”的真相自增自减和--是 C 语言里最有代表性的运算符也是“表达式有值”这个特性最典型的体现。区分方式其实一句话就够了b是先让 b 加 1然后整个表达式的值是“加完之后”的新值b是整个表达式的值是“加之前”的旧值然后 b 再偷偷加 1。写成代码验证一下#include stdio.h int main(void) { int a, b 10; a b; // b 先变成 11再把 11 赋给 a printf(a %d, b %d\n, a, b); // 输出 a 11, b 11 a b; // 把 b 的旧值 11 赋给 a然后 b 变成 12 printf(a %d, b %d\n, a, b); // 输出 a 11, b 12 return 0; }把这四行输出结果亲手跑出来比背十遍“前置先加后赋值后置先赋值后加”都管用。注意第二段的 a 和 b 可别推错了第一段结束后 b 是 11所以第二段a b里的旧值是 11a 拿到 11b 才变 12。还有一个特别容易翻车的地方同一个 printf 里写多个自增自减。比如printf(%d %d\n, b, b);C 标准并没有规定函数参数的求值顺序不同编译器可能先算左边也可能先算右边结果五花八门。这种代码在考试题里很常见但实际工程中属于典型的未定义行为谁写谁挨骂。你在练习时看到一个表达式里出现多个自增自减最佳策略是拆成多行写清楚而不是硬着头皮猜。1.3 优先级别背表记住一句口诀就够C 语言运算符优先级表密密麻麻有十几行新手背下来也记不住记住了也容易在“整型提升”“类型转换”这些隐藏规则上栽跟头。我个人带学生时的建议是括号永远最高拿不准就加括号单目运算符、--、!其次算术运算符按“先乘除、后加减”这一条小学数学规则记关系运算符再往后逻辑运算符更往后赋值运算符基本上是最低的一档。int x 2 3 * 4; // 结果是 14不是 20 int y (2 3) * 4; // 想表达 20就乖乖加括号这背后的逻辑和人类阅读习惯有关单目运算符靠近变量关系最紧密乘法是对数字的加工优先级也高赋值是把结果“存起来”的动作天然排在最后。理解这个层级规律比背表更容易内化。日常写代码时我几乎只用三条原则四则运算按数学直觉、比较运算前加括号避免歧义、赋值表达式不做组合。我见过太多新手喜欢写if ((a b) 0)这种把赋值嵌在条件里的代码语法没错但同事和未来的你都能读懂吗不能。把一句话拆成两行收益远大于那一点“简洁”。2. scanf 与缓冲区吃回车的坑早晚要踩一次2.1 scanf 到底在读什么输入流与缓冲区很多教材讲解 scanf 只会说“格式化输入”然后列出%d、%c、%s的对应关系。但如果你不理解背后那层缓冲区就一定会栽在“scanf 吃回车”这个问题上。程序从键盘读数据并不是按一下键就立刻把字符送给程序。键盘输入先进入一块内存区域这块区域叫输入缓冲区。你在终端里敲下字符、直到按下回车这一整行内容才会被一次性送入程序程序里的 scanf 再从缓冲区里按格式一个个地取。回车键产生的一个\n字符也会留在缓冲区里。用排队做类比缓冲区就像一条滚动的输送带按下回车是把货物一次性放上传送带scanf 像工人按照订单从带上取零件。%d这类格式会跳过零件之间的空白包括空格、Tab、换行但%c不会——它只取当前位置的一个字符哪怕那个字符是回车。2.2 翻车现场%d 之后紧跟 %c这是最容易复现、也最能说明缓冲区问题的场景。看这段代码#include stdio.h int main(void) { int n; char ch; printf(输入一个整数); scanf(%d, n); printf(输入一个字符); scanf(%c, ch); printf(n %d, ch [%c], ch 的 ASCII %d\n, n, ch, ch); return 0; }你运行后会发现第二个 scanf 根本没有等你输入直接结束了ch往往变成了 10也就是\n的 ASCII 码。原因就是第一个scanf(%d, n)读走了数字但把用户按下回车产生的\n留在了缓冲区里。紧接着的scanf(%c)发现缓冲区里有货直接取走了这个换行符。如果用输入描述你在键盘上敲入5和回车缓冲区里的内容是5和\n。%d读走5并转换为整数 5缓冲区剩下\n。%c读到\n赋值给 ch。程序看起来就像“跳过了输入”其实是缓冲区在捣鬼。2.3 三种靠谱的清缓冲姿势既然明白了原因解决办法无非两条思路要么把残留的\n主动读走要么让%c主动跳过空白。第一种在第一个 scanf 之后加一个getchar()scanf(%d, n); getchar(); // 吃掉缓冲区里的换行符 scanf(%c, ch);第二种在%c的格式串前加一个空格。这个空格告诉 scanf“先跳过所有空白字符再读一个字符”scanf( %c, ch);第三种如果你希望把缓冲区里当前残留的一整段脏数据都清掉可以用循环读到换行为止while (getchar() ! \n) ;这个空循环体会一直读取字符直到读到\n。它的含义是“把这一行剩下的内容全部消费掉”。注意分号是循环体不能漏——新手常常在这里少写一个分号导致整个逻辑错乱。推荐优先级如果是单字符输入用方式二格式串加空格最简洁如果是想彻底清空输入行用方式三方式一适合只需要吃掉一个回车的简单场景。务必记住不要用fflush(stdin)这个函数在标准 C 里的行为未定义在部分编译器上甚至不起作用属于教科书点名的反面典型。2.4 把 scanf 的返回值用起来scanf 还有一个绝大多数初学者忽略的价值它的返回值是成功匹配并赋值的参数个数。如果用户输入的不是数字比如scanf(%d, n)时输入了abc返回值是 0n 里的值还是原来那个垃圾值。利用这个特性可以写出非常可靠的多组数据处理代码。比如处理多组输入直到文件末尾#include stdio.h int main(void) { int n; int sum 0; while (scanf(%d, n) ! EOF) { sum n; } printf(sum %d\n, sum); return 0; }这段代码会不停读整数并累加直到读到文件结束符。Windows 上手动结束输入通常按CtrlZ再回车Linux 和 macOS 上按CtrlD。这种写法在在线判题系统里极其常见也是很多初学者看不懂网上题解的第一个障碍。理解了“scanf 的返回值代表匹配了几项”这类题解就能读通了。3. while 与 do-while先问再做还是先做再问3.1 两句代码讲清区别while 和 do-while 的语法差别很小很多人以为只是“多此一举的另一种写法”直到遇到必须至少执行一次的场景才恍然大悟。while 是先判断条件再执行循环体条件一开始就不成立循环体一次都不会执行。do-while 是先执行一次循环体再判断条件循环体至少执行一次。代码差异就在关键字位置// while先问后做 while (条件) { // 循环体 } // do-while先做后问 do { // 循环体 } while (条件); // 注意这里末尾有分号do-while 末尾分号这个点特别容易被漏。漏掉分号时后面的代码就可能被解析成一条奇怪的语句编译报错也很隐蔽稍微复杂一点的代码里会让人找半天。3.2 什么时候该用 do-while猜数字和菜单最典型的 do-while 场景是“不管条件满不满足总要先进一次交互”。比如猜数字游戏用户怎么也得先输入一次再判断对错#include stdio.h int main(void) { int target 42; int guess; do { printf(请猜一个数); scanf(%d, guess); if (guess target) { printf(大了\n); } else if (guess target) { printf(小了\n); } } while (guess ! target); printf(猜中了\n); return 0; }这里如果把 do-while 改成 while就得在循环外先重复写一遍输入代码代码立刻丑一倍。类似的还有菜单循环不管用户选什么菜单总得先显示一次登录重试也是同理。我实际写代码时有个朴素判断标准这个动作是不是“无论如何都要先做一次”如果是就用 do-while如果不是一律用 while。这个标准在真实项目中基本不会出错。3.3 死循环高发区分号、i 位置、continue新手写循环最容易翻车的三个地方值得单独拿出来说。第一个循环体后面多写一个分号。int i 0; while (i 10); { printf(%d\n, i); i; }分号让循环体变成空语句程序只会反复判断i 10i 永远不变于是死循环。编译器通常不会报错因为空语句在语法上合法。肉眼排查时看到 while 或者 for 后面紧跟分号基本就是这个坑。第二个循环体里忘了改变循环变量。int i 0; while (i 10) { printf(%d\n, i); // 漏掉 i; }同样的死循环。这类错误在复杂代码里很难一眼看出因为编译器无法帮你识别“逻辑上应该自增但你没写”。第三个continue 跳过了 i。这是最隐蔽的。int i 0; while (i 10) { if (i % 2 0) { continue; // i 是偶数时直接跳回条件判断i 永远不会执行 } printf(%d\n, i); i; }这段代码在 i 等于 0 时直接 continuei 永远停在 0死循环。这就是为什么很多老手在 while 循环里写 continue 时格外小心。解决办法是把 i 放在 continue 之前或者更常见的做法这种场景干脆用 for 循环。for 循环把“初始化、条件、步进”集中在头部continue 之后步进语句依然会执行。3.4 用 while 写两个马上用得上的练习这里插入两个适合 day2 的经典练习都能用 while 独立完成。第一个是九九乘法表用来理解循环嵌套#include stdio.h int main(void) { int i 1; while (i 9) { int j 1; while (j i) { printf(%d*%d%-2d , j, i, i * j); j; } printf(\n); i; } return 0; }%-2d里的-表示左对齐2 表示至少占两个字符宽度这样输出表格会比较整齐。内层循环的变量 j 在外层循环体内每次重新初始化为 1这个细节决定乘法表每行从“1 乘当前行数”开始打印而不是一路涨下去。第二个是“完数”判断。完数指一个数恰好等于它的所有真因子不包括自身之和比如 6 的真因子是 1、2、3123628 的真因子是 1、2、4、7、14加起来也是 28。这类题是循环加条件判断的绝佳组合#include stdio.h int main(void) { int n; int i 1; int sum 0; printf(请输入一个正整数); scanf(%d, n); if (n 1) { printf(输入不合法\n); return 1; } while (i n) { if (n % i 0) { sum i; } i; } if (sum n) { printf(%d 是完全数\n, n); } else { printf(%d 不是完全数\n, n); } return 0; }这段代码有三个细节值得提一是 sum 一定要在循环前清零否则会带进上一次计算的结果二是 i 从 1 开始到 n-1 结束完全不用包含 n 本身三是if (n % i 0)判断 i 是 n 的因子取余为 0 即整除。这个练习虽然简单但把循环、条件、取余、累加全串起来了是 day2 阶段最有性价比的练手题之一。4. 数组同一个类型的变量排成队一起管4.1 为什么需要数组成绩管理的例子假设要给 5 个学生打印成绩。如果没有数组代码会是int s1 85, s2 92, s3 78, s4 90, s5 88; printf(%d\n, s1); printf(%d\n, s2); printf(%d\n, s3); printf(%d\n, s4); printf(%d\n, s5);5 个学生尚且能忍50 个呢500 个呢更别说还要求和、平均、找最高分——每个操作都要把变量名写一遍谁写谁崩溃。数组的意义就在于把“同一类型、逻辑相关”的多个数据放进一块连续的内存空间用一个名字统一管理靠下标访问。声明方式int scores[5] {85, 92, 78, 90, 88};这里 scores 是数组名5 是元素个数后面的花括号是初始化列表。声明后5 个 int 会占一排连续的内存区scores[0]到scores[4]分别对应第 1 到第 5 个元素。注意下标从 0 开始这是 C 语言新手最容易弄混的地方。为什么从 0 开始因为数组名其实表示首元素的内存地址scores[i]在底层被翻译成“从数组起点往后数 i 个元素的位置”第 1 个元素自然就是“往后数 0 个”所以下标从 0 开始。遍历数组是 while 的经典应用#include stdio.h int main(void) { int scores[5] {85, 92, 78, 90, 88}; int i 0; int n sizeof(scores) / sizeof(scores[0]); while (i n) { printf(第%d个成绩%d\n, i 1, scores[i]); i; } return 0; }sizeof(scores)是整个数组占用的字节数sizeof(scores[0])是单个元素占用的字节数两者相除就是元素个数。这个写法比硬编码 5 更健壮就算以后把数组改成 100 个元素只要这里用的是 sizeof代码依然正确。4.2 下标、sizeof 和数组名不能自增数组有两个底层规律day2 最好就形成一个直观认识。第一个是数组名代表首元素地址且数组名是地址常量不能自增。写scores编译会直接报错因为数组名不是可修改的变量。这里不展开指针的概念那是后面几天的内容但你可以先记住数组名在表达式里使用时会“退化”成指向首元素的指针。这也是为什么下面讲字符串输入时scanf 不需要加。第二个是 C 语言不会检查数组下标是否越界。scores[5]语法上合法但访问的是数组后面的内存那里存的是别的变量、别的数据内容不可预期。对数组越界写入轻则数据被破坏重则程序崩溃甚至可能被利用做恶意攻击。很多初学者在网上看到“C 语言不检查数组越界”把它当成自由的象征实则这是 C 语言最容易制造隐患的地方之一。考试和实际项目中边界条件是最值得反复检查的位置。4.3 字符数组就是 C 语言里最朴素的“字符串”C 语言没有内置的 string 类型处理文字用的是字符数组并以\0空字符表示字符串结束。声明char str[] hello;这行代码实际开辟了 6 个 char 的空间而不是 5 个。因为字符串字面量hello在内存中末尾还带了一个\0。\0是输入/输出函数识别“字符串到此为止”的标记。这个设计造成了一个经典陷阱新手用strlen和sizeof时总搞混。#include stdio.h #include string.h int main(void) { char str[] hello; printf(sizeof(str) %d\n, sizeof(str)); // 6包含 \0 printf(strlen(str) %d\n, strlen(str)); // 5不含 \0 return 0; }sizeof是求“数组本身占多少字节”strlen是求“从起点数到第一个\0有多少个字符”。前者是编译时就能确定的后者要运行时扫描。记住这个区别字符串处理就不容易乱了。输入字符串也有个关键差异。声明char name[20];之后scanf(%s, name); // 不需要 符号因为数组名 name 已经是指向首元素的地址再写name反而会得到“整个数组的地址”类型都不一样。%s还会自动跳过开头的空白字符并在读到空格、Tab、换行时停止所以输入hello world只能存下hello这一段。如果要读带空格的整行文本应改用fgets。曾经教材爱用的gets因为无法限制长度在新标准中已被移除后续练习请自觉绕开它。4.4 字符串逆序一次手写双指针体验字符串逆序是道很好的练习题它不需要知道什么高深算法但强迫你同时用上数组、循环、下标和字符数组是 day2 阶段性价比很高的一道题。#include stdio.h #include string.h int main(void) { char str[100]; int left, right; char tmp; printf(请输入一个字符串); scanf(%s, str); left 0; right (int)strlen(str) - 1; while (left right) { // 交换左右两个字符 tmp str[left]; str[left] str[right]; str[right] tmp; left; right--; } printf(逆序后%s\n, str); return 0; }这段代码的核心思想叫“首尾交换”left 从最左边出发right 从最后一个有效字符出发一个往右走一个往左走当 left 小于 right 时就交换两边的字符。中间那个字符比如长度为奇数的正中间一位不需要动所以循环条件是left right而不是left right。其中right (int)strlen(str) - 1可能有读者不理解假设字符串是abcstrlen返回 3但下标最大到 2所以要减 1。写成right strlen(str) - 1也可以这里用了(int)强转是提醒你strlen的返回类型是size_t无符号整数在某些编译器上无符号数和有符号数比较时容易出警告。养成把这个减法结果转成 int 的习惯可以少碰一次类型警告的钉子。5. 调试与运行VSCode 配好环境后gdb 这四板斧够用5.1 环境自查编译运行出问题的三个常见点day2 除了学语法还应该把“写好代码 → 编译 → 运行 → 找 bug”这一整条工作流理顺。很多人 day1 就在折腾环境day2 又遇到新问题这里把最常见的三个排查方向列出来。第一确认编译器真的装好了并且能在终端里直接调用。打开终端输入gcc --version如果打印出版本信息就说明编译器可用如果提示“不是内部或外部命令”说明编译器没装或者没有加入系统环境变量。检查环境变量是 Windows 上最常踩的坑安装 MinGW 之后要把安装目录下的 bin 文件夹路径追加到 PATH 里。第二VSCode 里写完了 C 源文件文件名后缀必须是.c而不是.txt或者其他。这个问题虽然幼稚但确实经常出现新建文件时没注意写成demo.c.txtVSCode 根本不会按 C 语言语法高亮。第三tasks.json 的配置核心思路是指定“用哪个编译器、编译哪个文件、输出到哪里”。一个最简配置大致长这样{ version: 2.0.0, tasks: [ { label: build, type: shell, command: gcc, args: [-g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe], group: { kind: build, isDefault: true } } ] }其中-g是为了生成调试信息调试时必需${file}指当前打开的源文件${fileDirname}/${fileBasenameNoExtension}.exe意思是“在源文件同一目录下生成一个和源文件同名、后缀为 .exe 的可执行文件”。如果你用的是 Linux 或 macOS把末尾的.exe去掉即可。5.2 gdb 断点调试的四板斧环境配好后调试工具 gdb 就是你的眼睛。很多新手写代码出错后只会盯着代码看看半天也看不出问题更好的办法是让程序停在某一行亲眼看看变量值到底是几。先说怎么编译。gcc -g test.c -o test注意必须有-g选项否则 gdb 无法对应到源代码行号。Windows 下生成的是test.exeLinux / macOS 下是test调用方式一致gdb test进入 gdb 交互界面后记住四个高频命令即可命令作用简单说明break main在 main 函数入口断点程序运行到这一行会暂停run开始运行程序到断点处停下等待指令next单步执行下一行会把函数调用整体一步带过print 变量名查看变量的当前值也可以写成p acontinue继续运行到下一断点简写cquit退出 gdb简写q一个典型的调试流程是break main设断点run运行程序停在 main 第一行后用next一行一行走走几步就print i看看循环变量变化发现问题后quit退出回编辑器改代码。这套流程熟练之后你排查逻辑错误的效率至少翻一倍。如果要查看循环里的变量变化还可以直接在next之后反复print i。如果变量多了用info locals可以一次性列出当前函数里所有局部变量的值。这个命令在循环嵌套时特别好用。对了gdb 里按方向键上可以翻出之前输入过的命令不用每次重敲。这个小心得能省不少时间。5.3 今天开始养成的三个调试习惯调试习惯比命令本身更重要。我观察过很多新手也踩过同样的坑以下三条是我认为 day2 阶段最值得建立的。第一条编译报错先看第一个错误。编译器的错误信息可能刷了满满一屏幕新手容易盯着最下面的错误使劲看。实际上很多后续报错都是“连锁反应”——第一个错误导致编译器理解错位才产生一连串后续问题。先读最上面那个错误修一个再看下一个效率更高。第二条scanf 忘取地址是最常见的崩溃原因。scanf(%d, n)里的不能少。如果你看到程序“一输入就崩溃”或者运行到一半位置停了下来优先检查所有 scanf 的第二个参数是不是都带了取地址符。除了数组名本身是地址几乎都要加。第三条printf 大法依然是最低成本的调试手段。别觉得 gdb 高端就轻视 printf。遇到“这段循环怎么没进去”这样的问题在循环前、循环内、循环后各加一行 printf把关键变量打出来经常 30 秒就定位了。gdb 适合需要逐步观察的场景printf 适合快速确认“到底有没有执行到这里”。两者配合使用比单用任何一个都顺手。还有一个很实用的小习惯写代码时随手画一画内存草图。数组是什么布局、字符串结尾的\0占在哪、两个变量在内存中谁在前谁在后画几笔比盯屏幕更容易想明白。学到后面指针和结构体时这个习惯会帮助你避开大部分鬼打墙式的困惑。自己在 day2 阶段最深刻的体会是比起收藏一堆“C 语言基础代码大全”不如把今天这几个坑亲手踩一遍。缓冲区残留、循环死锁、数组越界这些错误几乎每个 C 语言学习者都会遇到。你今天花四十分钟把它们挨个跑通、改明白明天再学指针、学函数、学文件操作时会顺畅得多。下一篇笔记可以接着讲 for 循环到底比 while 强在哪里以及如何用数组做查找和排序——这些都是建立在这篇文章基础之上的自然延伸。
阅读完成 · 觉得有帮助?
咨询建站