☰
C语言基础精讲:从变量初始化到数据类型陷阱
2026/10/23 17:22:40 网站建设 项目流程

简介:面向C语言初学者的系统入门讲义,以PDF文档集中整理。C语言是操作系统、嵌入式系统、底层驱动等领域广泛使用的工业语言,入门时需先打通基本语法与编程思维。内容从计算机与编程语言的关系讲起,对比编译与解释两种执行方式,并梳理C语言从K&R到ANSI C、C95、C99的标准化历程;接着围绕变量初始化、const常量、scanf函数的返回值判断、表达式与运算符优先级、赋值及递增递减运算符等细节展开,通过计算最大公约数、时间差和英制单位转换等经典示例,帮助读者掌握顺序程序设计、输入输出及常见计算写法。资源共1个PDF文件,压缩包大小1.57MB,轻量易保存,适合配合大学计算机课程、MOOC或自学笔记使用。目前已有13511人学习下载,零基础或刚接触C语言的同学可借此快速建立知识框架,为后续学习判断、循环、数组、函数与指针等内容打好基础。

1. C语言基础这份PDF资料,先把语法拆成能落地的代码例子

如果你已经看完几节C语言网课,却发现自己还是写不出一个完整的小程序,那这份C语言基础PDF资料值得用一周时间逐章过一遍。它不是一本塞满语法定义的厚手册,而是从最大公约数、猜数字、正序分解整数这些经典小算法切入,把变量、表达式、分支、循环、数据类型按章节串起来,每个知识点后面都配了能直接编译运行的代码片段,是一份典型的“边看边敲”型基础文档。最值钱的部分不是Hello World,而是scanf返回值的三种情况、C89与C99在变量定义上的差异、浮点数为什么不能用==比较这些容易被IDE掩盖的细节。适合刚学到指针之前、想靠练习把语法钉牢的初学者,也适合带新人时当练习题库用。

2. 变量、表达式与运算符优先级:先弄懂内存怎么走

学C语言遇到的第一个认知坎不是语法,而是“变量到底是什么”。这份PDF先不讲类型表,而是用英制身高换算和时间差计算这两个例子,让你先把变量用起来,再把类型细节放到后面章节展开。我建议你顺着它的顺序走,先会写、再深挖,比一上来背char/int/float取值范围高效得多。

2.1 变量定义与初始化:C89和C99的关键差异

PDF里反复强调一句话:所有变量在第一次被使用之前应该有过初始化。这不是洁癖,而是C语言不会帮你把变量清零。如果你只写int count;然后直接count++,count的初值取决于栈上残留的数据,程序可能每次跑结果都不一样。

最常见的初始化写法是定义时就给初值,比如最大公约数这段:

#include <stdio.h> int main() { int u = 32; int v = 26; while (v != 0) { int temp = u % v; u = v; v = temp; } printf("gcd=%d\n", u); return 0; }

这里u和v在进入while之前已经初始化,第一次判断v != 0是安全的。temp在循环体内定义,每轮循环都重新创建和赋值,不需要在外部预先初始化。逻辑上,while循环每轮都在把u、v向前滚动,直到v变成0,此时u保存的就是最大公约数。参数说明:%d是int的输出占位符,u和v分别是被除数和除数。

关于变量定义位置,这里藏着C语言版本差异。早期的ANSI C标准要求变量在代码块开头定义,C99则允许使用前定义。现在主流编译器默认支持C99及以上,所以int i = 0;后面再int j = 0;这种写法完全合法。但如果你遇到老项目里一堆变量堆在函数开头,不用奇怪,那是C89时代留下的风格。

const常量也值得单独说。C99才支持const,命名习惯是全大写:

#include <stdio.h> int main() { const int PRICE = 3; int total = PRICE * 12; printf("total=%d\n", total); // PRICE = 4; // 编译报错,const变量不能赋值 return 0; }

const的作用是让编译器帮你检查“不该被修改的量”。金币兑换、税率、屏幕宽度这类固定参数,用const表达意图比裸写数字好得多。

2.2 表达式与复合赋值:读代码前先看优先级

C语言的一条语句本质上是一个表达式,由运算符和算子组成。PDF里给出了完整的运算符优先级表,其中最容易让初学者翻车的是复合赋值与四则运算混在一起的情况。

看这个例子:

#include <stdio.h> int main() { int total = 100; total /= 12 + 6; printf("total=%d\n", total); // 输出5,不是14 return 0; }

这个输出可能出乎意料:不是100 / 12 + 6 = 14,而是100 / (12 + 6) = 5。原因是复合赋值运算符的右侧整体是一个表达式,先算12+6,再赋值。如果你按数学直觉拆成total = total / 12 + 6,结果就完全错了。这个细节在改别人代码时特别容易踩,因为视觉上total /= 12 + 6比total = total / (12 + 6)隐蔽得多。

赋值也是运算,它有返回值。a = b = 6之所以能连续赋值,是因为赋值表达式的结果就是被赋予的值,结合方向从右到左,等价于a = (b = 6)。这也正是if (a = b)能编译通过的原因:它把b赋给a,再判断a是否为真。新手经常把==写成=,编译器往往只给警告不报错,最后查半天发现是“赋值代替了判断”。

递增递减运算符是C语言的历史遗产。当年在特定的老机器上有专门的INC、DEC指令,++和--能直接映射成那两条指令,如今编译器已经能自动优化a = a + 1,++和--继续存在更多是习惯问题。但它们的前缀后缀差异仍然重要:

表达式效果表达式的值
count++count加1count原来的值
++countcount加1count加1以后的值
count--count减1count原来的值
--countcount减1count减1以后的值

2.3 scanf的返回值:输入出错时程序不会告诉你

PDF里有一块内容含金量很高:scanf函数是有返回值的,而且返回值分三种情况。大多数入门资料只教你scanf("%d", &a)怎么写,不告诉你它返回什么。

#include <stdio.h> int main() { int a = 0; int b = 0; int ret = scanf("%d %d", &a, &b); printf("ret=%d, a=%d, b=%d\n", ret, a, b); return 0; }

你可以亲手验证三组输入:

  1. 输入“3 4”,ret=2,a=3,b=4,两个变量都正确读入
  2. 输入“3,4”,ret=1,a=3,b保持0,第二个参数匹配失败
  3. 输入“,3 4”,ret=0,a和b都保持0,第一个参数就匹配失败

关键点是:scanf匹配失败时不会修改变量的值。所以变量必须预先初始化,否则“读入失败”和“读入成功但值是垃圾”会混在一起,极难排查。EOF是第三种情况,在stdio.h中定义,通常是-1,表示输入流结束。Windows下按Ctrl+Z再回车,Linux下按Ctrl+D。

scanf返回值含义典型场景
正整数成功读取的参数个数scanf("%d %d")输入“3 4”返回2
0用户的输入不匹配,无法正确读取任何值输入“,3 4”返回0
EOF输入流已经结束Windows下Ctrl+Z,Linux下Ctrl+D

实际写代码时,我一般会把scanf的返回值作为判断条件,而不是假设用户一定会按格式输入。后面猜数字程序里,这个知识点会直接决定程序是正常提示还是死循环。

3. 分支与循环:从看懂if到选对循环结构

第三章是这份PDF篇幅最重的部分,也是初学者从“看得懂代码”到“写得出代码”的分水岭。分支和循环本身不难,难的是在不同场景里选对写法:什么时候用if-else if,什么时候用switch;什么时候用for,什么时候用do-while。

3.1 分支写法:从多出口改成单一出口

if语句的基本形态是:if后面跟一个括号里的逻辑表达式,如果结果非零就执行后面大括号里的语句。注意if这一行结束时没有分号,这是和普通语句最大的视觉差异。

PDF里有一个容易被忽略的编程习惯建议:更支持单一出口方式。对比下面两种写法:

// 多出口写法:每个分支里都直接printf if (x < 0) { printf("%d", -1); } else if (x == 0) { printf("%d", 0); } else { printf("%d", 2 * x); } // 单一出口写法:先算结果,最后统一输出 int f; if (x < 0) { f = -1; } else if (x == 0) { f = 0; } else { f = 2 * x; } printf("%d", f);

单一出口的好处是逻辑集中:f的计算过程全在if-else里,输出只有最后一行。调试时你只需要在printf那行打断点,就能看到所有分支的最终结果。多出口写法一旦分支变多,每个分支里都夹着输出语句,排查时要在好几个printf之间跳来跳去。

switch-case和if-else是另一对需要选型的组合。switch的控制表达式只能是整数型结果,case后面跟的是常量,可以是常数也可以是const常量表达式。PDF里那个穿透例子很典型:

switch (type) { case 1: case 2: printf("你好\n"); break; case 3: printf("晚上好\n"); case 4: printf("再见\n"); break; default: printf("啊,什么啊?\n"); break; }

case 1和case 2之间没有break,所以type=1时会一路穿透到case 2的printf。执行完case 3的printf后没有break,会继续执行case 4的printf,输出“晚上好再见”两行。这就是switch的穿透特性:case只是路标,不是边界。

如果遇到分段函数,我建议尽量用if-else而不是switch。switch(x<0)虽然能编译,case 1和case 0也能跑,但可读性很差。switch更适合“值等于什么就做什么”的离散场景,区间判断天生属于if。

3.2 三种循环的选型:固定次数、至少一次和未知次数

C语言有三种循环:while、do-while、for。PDF给出的选择规则很实用:如果有固定次数,用for;如果必须执行一次,用do-while;其他情况用while。

for循环像计数循环,包含初始化、条件和每轮动作三个部分,而且每个表达式都可以省略。for (; 条件;)等价于while (条件)。一个容易忽略的细节是for循环里定义的变量只在循环内有效:

#include <stdio.h> int main() { int n; scanf("%d", &n); int fact = 1; for (int i = 1; i <= n; i++) { fact *= i; } // printf("%d\n", i); // 编译报错,i在循环外不可见 printf("%d!=%d\n", n, fact); return 0; }

这个特性来自C99标准,老标准里for的循环变量定义必须在外部完成。如果循环结束后你还需要访问计数变量,就老老实实把变量定义在for外面,改用while。这是内存作用域带来的约束,不是编译器故意为难你。

do-while的适用场景是“至少执行一次”。猜数字游戏就是典型:用户至少要猜一次,不管猜得对不对。而求平均值读到-1结束,循环次数取决于输入,用while最自然。

还有一个经典陷阱必须记:for (i = 0; i < 5; i++); 分号让循环体变成空语句,程序会空转5次什么都不做。如果你在分号后面跟了printf,它会在循环结束后只执行一次,初学者常被这个坑困扰。

3.3 综合练习:猜数字与求平均数里的输入校验

把前面scanf返回值的知识用起来的时机到了。猜数字程序如果只写scanf("%d", &a)而不检查返回值,用户输入一个字母时,a保持旧值,程序会陷入“永远猜不对”的死循环。

#include <stdio.h> #include <stdlib.h> #include <time.h> int main() { srand(time(0)); int number = rand() % 100 + 1; int count = 0; int a = 0; printf("我已经想好了一个1到100之间的数。\n"); do { printf("请猜测:"); int ret = scanf("%d", &a); if (ret != 1) { while (getchar() != '\n'); // 清空输入缓冲 printf("输入无效,请输入整数。\n"); continue; } count++; if (a > number) { printf("你猜的数大了。\n"); } else if (a < number) { printf("你猜的数小了。\n"); } } while (a != number); printf("太好了!你用了%d次猜到了答案。\n", count); return 0; }

这里srand(time(0))用当前时间做随机种子,rand() % 100 + 1生成1到100之间的数。如果把+1去掉,范围会变成0到99,这是常见偏差。ret != 1判断输入失败,while (getchar() != '\n')清空这一行的残留字符,continue跳过统计次数,直接进入下一轮猜测。

求平均值程序则展示了另一种输入终止方式:

#include <stdio.h> int main() { int number; int sum = 0; int count = 0; while (scanf("%d", &number) != EOF) { if (number == -1) { break; } sum += number; count++; } printf("%f\n", 1.0 * sum / count); return 0; }

1.0 * sum把整数提升为浮点数,避免整数除法直接砍掉小数。如果你写成sum / count,两个int相除结果还是int,9除以2会得到4而不是4.5。

4. 循环控制与整数分解算法:边界条件比算法本身更磨人

循环写多了你会发现,真正让你调一晚上的往往不是算法思路,而是边界条件:循环里t > 9还是t >= 9,for里i < min还是i <= min。这一章把这些边界和两个经典整数算法放在一起讲透。

4.1 整数正序分解:mask的构造与循环边界

逆序分解整数很简单,模10取个位、除10去掉个位。但正序分解麻烦:你得先知道这个数最高位是多少。PDF给的做法是构造一个mask,也就是10的“位数-1”次方。

#include <stdio.h> int main() { int x; scanf("%d", &x); int mask = 1; int t = x; while (t > 9) { t /= 10; mask *= 10; } // 此时mask是和x位数匹配的最高位权重 do { int d = x / mask; printf("%d", d); if (mask > 9) { printf(" "); } x %= mask; mask /= 10; } while (mask > 0); printf("\n"); return 0; }

边界条件是这一段的关键。while (t > 9)的意思是:只要t还有两位及以上,就除以10并让mask乘10。以x=700为例,t从700变70再变7,循环时mask从1变10再变100,最终mask=100。do循环第一轮d = 700 / 100 = 7,打印7,x变成0,mask变成10;第二轮d = 0 / 10 = 0,打印0,x还是0,mask变成1;第三轮d = 0 / 1 = 0,打印0。所以700的输出是“7 0 0”。

测试x=0会怎样?while条件不成立,mask保持1,do循环执行一次打印0,效果正确。负数的情况则需要注意:比如x=-123,d = -123 / 100 = -1,输出带负号且位数分解会乱,通常正序分解器只处理非负整数。调试时可以加一行printf("x=%d, mask=%d\n", x, mask);,每轮循环都能看到mask的收缩过程。

4.2 最大公约数两种算法:枚举与辗转相除的取舍

PDF在第一章和第五章两次提到最大公约数,第一次用来讲算法思想,第二次给出完整代码。枚举法贴合定义,但有个边界坑:

#include <stdio.h> int main() { int a, b; int min; scanf("%d %d", &a, &b); if (a < b) { min = a; } else { min = b; } int ret = 0; for (int i = 1; i < min; i++) { if (a % i == 0 && b % i == 0) { ret = i; } } printf("%d\n", ret); return 0; }

注意for循环条件是i < min,这意味着i根本不会取到min本身。当a=4、b=4时,min=4,循环跑到i=3就停了,ret只能记到2,输出2而不是4。这个bug在讲义代码里真实存在,照抄会出错。把条件改成i <= min才严谨。枚举法的缺点是循环次数接近min,而且ret从0开始累加,逻辑上不如辗转相除干净。

辗转相除法才是工程上该用的:

#include <stdio.h> int main() { int a, b; int t; scanf("%d %d", &a, &b); while (b != 0) { t = a % b; a = b; b = t; printf("a=%d, b=%d, t=%d\n", a, b, t); } printf("gcd=%d\n", a); return 0; }

循环结束时b=0,a就是最大公约数。同样的a=32、b=26,枚举可能要循环二十几次,辗转相除只要几次。调试时那行printf能清楚看到每次迭代中a、b、t三个值如何滚动:a变成原来的b,b变成余数,直到余数为0。

4.3 接力break与goto:三重循环怎么跳出

换零钱问题是经典的“在多重循环里提前退出”场景。假设要凑出x元的零钱,分别用1角、2角、5角的硬币,三重循环找出第一组解就停:

#include <stdio.h> int main() { int x; scanf("%d", &x); int exit = 0; for (int one = 1; one < x * 10; one++) { for (int two = 1; two < x * 10 / 2; two++) { for (int five = 1; five < x * 10 / 5; five++) { if (one + two * 2 + five * 5 == x * 10) { printf("可以用%d个1角加%d个2角加%d个5角得到%d元\n", one, two, five, x); exit = 1; break; } } if (exit == 1) break; } if (exit == 1) break; } return 0; }

one、two、five分别表示1角、2角、5角硬币的个数,x * 10把元换算成角。三层循环每层都检查exit标记,一旦内层找到解,就一层一层break出来。这种方式可读性好,但代码里要写三个一模一样的if (exit == 1) break,很啰嗦。

讲义里还给了goto版本:

for (int one = 1; one < x * 10; one++) { for (int two = 1; two < x * 10 / 2; two++) { for (int five = 1; five < x * 10 / 5; five++) { if (one + two * 2 + five * 5 == x * 10) { printf("可以用%d个1角加%d个2角加%d个5角得到%d元\n", one, two, five, x); goto out; } } } } out: return 0;

goto能直接跳出所有循环,代码最短。代价是标号out让控制流不再线性,别人读代码时要跳来跳去找标号,团队规范里经常禁用它。我的习惯是先写接力break,只在一次性脚本或者三层以上的嵌套里才考虑goto。另外这段讲义代码有一个真实排版错误:if条件末尾多了一个右括号,写成==x*10)),照抄会编译失败,需要自己修掉。

5. 数据类型陷阱排查:整数溢出、浮点比较和字符输入的四类翻车现场

语法和循环掌握之后,真正的考验是数据类型边界。这一章是排查经验合集,每一条都是能直接在代码里复现的翻车现场,建议对照着在自己的机器上跑一遍。

5.1 整数范围与溢出:char做不了大循环变量

现象:把循环变量定义成char,循环条件i < 200,程序陷入死循环。

#include <stdio.h> int main() { char i = 0; while (i < 200) { printf("%d\n", i); i = i + 1; } return 0; }

原因:有符号char只有1字节,取值范围-128到127。i从127再加1时发生溢出,结果变成-128,于是i永远小于200,循环无法退出。现代CPU字长普遍是32位或64位,用更短的整数类型不但不会更快,反而可能因为内存对齐而更慢。

解决:循环变量用int。除非你在做底层协议解析,需要严格按字节处理,否则int是第一选择。

补码概念在这里要真正理解。-1的补码是11111111,与1相加会溢出得到0,这就是补码能把负数的减法变成加法运算的原因。unsigned类型不是为了扩大正数范围,而是为了做纯二进制运算,尤其是移位。

5.2 浮点比较:0.1加0.2不等于0.3

现象:float a = 1.345f; float b = 1.123f; a + b的结果不等于2.468,甚至两个浮点数比较f1 == f1都可能返回假。

#include <stdio.h> #include <math.h> int main() { double a = 0.1; double b = 0.2; double c = a + b; printf("c=%.17f\n", c); if (fabs(c - 0.3) < 1e-12) { printf("相等\n"); } else { printf("不相等\n"); } return 0; }

原因:二进制浮点数无法精确表示0.1这类十进制小数,0.1和0.2在机器里都是近似值,相加后误差累积,与0.3的比较自然失败。printf用%.17f打印double,能看到小数点后第17位附近的误差。

解决:不要用==判断浮点相等,用fabs(c - 0.3) < 1e-12这种绝对值误差判断。fabs是绝对值函数,需要#include <math.h>,并且在Linux下用gcc编译时要加-lm参数,否则链接报错。如果你在算钱,直接用整数以分为单位,别让浮点误差掺和进金额计算。PDF里那句“计算器不用浮点计算”说的就是这事。

5.3 字符输入与EOF:C的字符是整数,输入流判断要看返回值

现象一:程序里用scanf("%c", &c)读字符,前面刚执行过scanf("%d", &n),结果字符根本没读进去。

原因:前一个scanf("%d")把数字读走后,输入缓冲区里还残留着换行符,scanf("%c")把这个换行符当成目标字符读走了。

解决:在格式串里加一个空格,写成scanf(" %c", &c)。前导空格会让scanf跳过空白字符。

现象二:Windows下按Ctrl+Z程序没结束,要按两次回车;Linux下不知道该怎么结束输入。

原因:scanf遇到EOF时返回EOF常量,在stdio.h里定义,通常为-1。Windows输入Ctrl+Z后需要再回车一次让输入流刷新,Linux下用Ctrl+D。如果你只判断变量值不判断返回值,程序可能把EOF当成普通数处理。

解决:循环用返回值收尾:

#include <stdio.h> int main() { int num; while (scanf("%d", &num) != EOF) { // 处理num } return 0; }

另一个相关认知:char是整数,字符'1'和数字1不是一回事。'1'的ASCII编码是49,所以char c = '1'时,printf("%d", c)输出49,printf("%c", c)输出字符1。大小写字母转换利用ASCII表顺序:c + 'a' - 'A'可以把大写变成小写。

5.4 强制类型转换优先级:转换符放错位置就是灾难

现象:想算a除以b的浮点结果,写成int i = (int)(a / b),得到的是0;写成double d = (double)(a / b),得到的还是0.0。

#include <stdio.h> int main() { int a = 5; int b = 6; double d1 = (double)(a / b); // 先整数除得0,再转double,输出0.000000 double d2 = (double)a / b; // 先把a转double,再除法,输出0.833333 printf("d1=%f d2=%f\n", d1, d2); return 0; }

原因:强制类型转换的优先级高于四则运算。如果转换成int,负数的截断方向是向零取整;如果转换整个除法表达式,运算顺序是先整数相除再转double,精度早已丢失。

解决:正确做法是只转换其中一个操作数:(double)a / b,这样整数除法被提升为浮点除法。还要注意转换本身不改变原变量:double x = 10.6; int y = (int)x;执行后x仍然是10.6,y是10,只是从x那里算出了一个新值。如果你想修改原变量,必须显式赋值回去。

6. 把这份讲义变成你的调试习惯:printf断点、编译三步和回归清单

6.1 用printf当临时断点

调试循环边界最有效的方式不是单步跟踪,而是在关键位置加printf。正序分解整数程序里加一行printf("x=%d, mask=%d\n", x, mask);,每轮循环的mask收缩过程清清楚楚。猜数字程序里加printf("debug: number=%d\n", number);,你就能看到程序“想”的数字和你猜的关系。发现问题后这行代码删掉即可,printf是最便宜的调试工具。

6.2 编译三步与回归清单

拿到一份PDF资料后,别只读代码,按三步走:

gcc -Wall -o guess guess.c && ./guess

第一步,用gcc加-Wall编译,让编译器把所有可疑警告亮出来。第二步,设计三组输入:一组正常值、一组边界值、一组非法值。猜数字试1、100和非数字字符;scanf试“3 4”和“3,4”;正序分解试700和0。第三步,改完一个条件后把旧代码注释掉而不是直接删掉,重新编译跑一遍,对比行为变化,确认新改动没有破坏边界情况。

我最初学C语言时习惯“人脑编译”,盯着代码觉得逻辑没问题就跳过运行。后来帮一位同学调猜数字程序,他把scanf("%d", &a)写成了scanf("%f", &a),printf里却依然用%d,编译器只给警告不报错,运行结果全乱,他查了一晚上才发现输入格式和输出格式不匹配。从那以后,我每拿到一份这样的基础资料,都会强制自己把核心代码从头到尾敲一遍,再改掉至少一个边界条件,用命令行重新编译跑一次,才敢说真的看懂了。这份PDF也一样,适合下载到本地当手边速查手册,从第二章开始逐块对照敲代码,把每个坑亲手踩一遍再跨过去。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询