语言是很多人的第一门编程语言,但也是劝退率最高的一门。语法本身并不复杂,变量、运算符、分支循环、函数,真正要死记的东西比想象中少;难的是它太贴近硬件,内存、指针、类型这些概念一步跟不上,运行结果就会当场翻脸。这篇笔记聚焦C语法基础,写给刚入门的初学者,也写给已经写过一些代码、但总被编译报错打断思路的人。不讲虚的,直接聊怎么把代码写出来、编译通过、跑出正确结果。
1. 动手之前,先把编译环境这几件事搞定
1.1 工具的取舍:本地编译器还是在线IDE
C是编译型语言,写好的源码不能像脚本语言那样直接运行,必须经过编译器转换成机器码,生成可执行文件后才能执行。所以第一步不是打开某个编辑器就开始敲代码,而是先把编译器准备好。
常见的选择有三条路。
第一条,Windows上装MinGW-w64,这是一套把GCC移植到Windows的开源工具集。安装完之后,在命令行里就能使用gcc命令。优点是干净、贴近真实开发流程,缺点是配置稍微麻烦一点,要把bin目录加进系统环境变量。
第二条,直接用IDE。集成开发环境自带编译器,比如Code::Blocks或者Dev-C++。Dev-C++以前是很多高校机房的首选,界面简单,装完就能跑,但功能比较简陋,调试和代码补全都很弱。Visual Studio的功能很全,但完整版体积大、对新手来说也是杀鸡用牛刀。
第三条,在线编译环境。网页打开就能写代码,比如一些在线编译器站点。用来验证一个几行的语法片段完全没问题,适合在地铁上刷个函数试试手。但线上环境毕竟没有本地的调试器,也看不到内存和变量的实时变化,想认真学的话,我不建议长期依赖在线方式。
我自己比较推荐的是本地编译器加一个轻量编辑器。编辑器用VS Code或vim都行, VS Code装好C/C++插件后,语法高亮、代码补全、断点调试都能用。编辑器只是个壳,核心是把编译命令掌握住。
安装完成后可以运行一个最简单的验证:
gcc --version如果能看到版本号,说明工具链已经就绪。如果没有,多半是环境变量没有配置到位,这是新手遇到的第一个坑,不丢人。
1.2 一个最小程序背后的执行逻辑
准备好环境之后,随便新建一个hello.c文件,写下面这段代码:
#include <stdio.h> int main(void) { printf("Hello, world!\n"); return 0; }然后执行:
gcc hello.c -o hello ./helloWindows下生成的可执行文件名是hello.exe,在命令行里直接输入hello.exe就能运行。
这段代码虽然只有几行,但每个部分都值得拆开看。#include <stdio.h>是预处理指令,作用是引入标准输入输出头文件,如果不加,printf就无法声明,编译器会报出“隐式声明”的警告甚至错误。int main(void)是程序入口,程序永远从main函数开始执行。printf("Hello, world!\n");是打印文本,\n表示换行。return 0;是向操作系统返回退出码,一般约定0表示正常结束。
从源码到可执行文件,中间经历了预处理、编译、汇编、链接四个阶段。#include的替换发生在预处理阶段,代码翻译成汇编在编译阶段,汇编器生成目标文件,链接器把标准库函数printf的实现绑定到你的程序里。很多教程直接说“编译器把代码变成了可执行文件”,其实是一个简化的说法。理解了这四个阶段,后面遇到链接错误时就不会发懵。
2. 变量与数据类型:程序和内存打交道的起点
2.1 为什么C语言要分数据类型
概念上,内存可以理解成一排带编号的储物格,每个格子都是8位(1字节)。变量是你给某个格子起的名字,而数据类型则是你决定占用多少个格子、以及怎么解释格子里的数据。
定义变量时,C语言要求必须指定类型:
int count = 42; double price = 3.14; char grade = 'A';int通常占4字节,double占8字节,char占1字节。同一个地址上的1字节,如果解释成整数它可能就是65,解释成字符就是A(ASCII码)。类型之所以存在,是因为同一片内存可以有不同的解读方式,这既是C语言灵活的地方,也是新手容易踩坑的地方。
常见的基础类型可以列表整理一下:
| 类型 | 典型大小 | 大致范围 | 格式化占位符 |
|---|---|---|---|
| char | 1字节 | -128 ~ 127 | %c、%hhd |
| int | 4字节 | ±2.1e9左右 | %d |
| unsigned int | 4字节 | 0 ~ 4.2e9左右 | %u |
| long | 4/8字节 | 取决于平台 | %ld |
| float | 4字节 | 约±3.4e38 | %f |
| double | 8字节 | 约±1.7e308 | %lf |
| long double | 8/16字节 | 取决于平台 | %Lf |
注意“典型大小”和“取决于平台”这种表述。C语言标准只规定了最小尺寸限制,比如int至少16位,实际在现代PC上几乎都是32位。但在一些嵌入式平台上,int就可能是16位。所以写跨平台代码时,最好用<stdint.h>里定义的类型,比如int32_t、uint8_t,保证在任何平台上宽度一致。
新手最容易踩的第一个类型坑是整数溢出。比如:
int a = 2147483647; a = a + 1; printf("%d\n", a);结果是-2147483648,这就是有符号整数溢出。计算机里的整数不是数学里的整数,它描述了“一个封闭的环”,超出最大值后会绕回最小值。很多时候程序出bug,不是逻辑错了,而是数据的取值范围没考虑进去。
2.2 初始化与生命周期的坑
C语言和Java、Python有个显著区别:局部变量默认不会自动初始化为零。如果定义一个变量不赋值,直接打印,运行结果是不确定的,可能是0,也可能是一个巨大的垃圾值。这在大多数场景下不会报错,所以特别隐蔽。
int total; printf("%d\n", total); // 结果是一个不确定的值我见过不少初学者在这里摔跟头,跑一次程序结果变一次,还以为电脑坏了。变量分不清的“脏值”,是之前某个变量留在那块内存里的残留数据。C语言没有义务帮你清理,它只是把内存原样交给你。
所以现在写代码时,我会严格要求自己:定义变量的同时就给初始值。如果暂时不知道初始值,也要先赋值成0或NULL。这不只是一种好习惯,更是一种防御性编程。等到找工作面试的时候,面试官问“局部变量不初始化会怎样”,实际经验早就把这个答案刻进习惯了。
3. 输入输出与格式化:printf/scanf没那么简单
3.1 printf的格式控制
printf是大多数人的第一个函数,但很多人会用却不会用。格式化占位符不只是%d和%f这么简单,它还支持很多修饰符。
printf("%5d\n", 42); // 宽度5,右对齐,结果: 42 printf("%-5d|\n", 42); // 宽度5,左对齐,结果:42 | printf("%05d\n", 42); // 宽度5,前面补0,结果:00042 printf("%.2f\n", 3.14159); // 保留两位小数,结果:3.14 printf("%10.2f\n", 3.14159); // 宽度10,保留两位小数这些控制在实际开发中很常用。比如打印一个对齐美观的表格:商品名称、数量、单价、总价,用%-20s、%8.2f这样的格式就能轻易做到列对齐。
还有一个容易踩的坑:float和double在printf里的占位符。标准规定,printf中float会被自动提升为double,所以打印float用%f没问题;但传入的是double时,严格来说也要用%f(经典C写法),如果用%lf在某些旧的实现下也可能出问题。现代编译器多数都能兼容,但严谨起见,printf里打印double用%f,scanf里读取double必须用%lf,这两者经常被搞混。
转义字符也值得列一下:\n换行、\t制表符、\\输出反斜杠、\"输出双引号、\r回车。需要注意,printf("path is C:\\new\\test")如果写成单反斜杠,结果可能直接换行。这类错误在Windows路径输出时特别常见。
3.2 scanf与缓冲区:新手第一课
scanf比printf坑更多,它的第一个特点就是:修改变量必须取地址。
int num; scanf("%d", &num);很多人会写scanf("%d", num),漏掉&,编译时还会收到一个警告,运行时往往程序直接崩溃。原因是scanf需要知道变量的地址才能写入数据,它拿到的是值的副本,没法改变原变量。
第二个经典坑是输入缓冲区的残留问题。看下面这个例子:
int num; char ch; scanf("%d", &num); scanf("%c", &ch);你输入42并回车后,屏幕显示会直接跳过第二个scanf。原因不是程序坏了,而是输入缓冲区里除了42,还残留了一个换行符\n。第一次scanf只取走了整数,换行符还在缓冲区里等待着,第二次scanf("%c")立刻就把换行符读走了。
解决办法是主动吃掉那个残留字符:
scanf("%d", &num); while (getchar() != '\n'); // 把剩余字符全部读走 scanf("%c", &ch);或者使用scanf(" %c", &ch);,在格式串前加一个空格,让scanf跳过空白字符。
第三个坑:输入类型与格式串不匹配。如果要求输入整数,用户偏输入了字母,scanf不会清空错误状态,后面的读取会持续失败。正式开发中,我基本不用scanf做交互式输入,而是用fgets读整行再解析,但作为学习语法,搞清楚scanf的脾性很有价值,因为在很多考试和入门练习里它仍然是主角。
4. 运算符、表达式与流程控制
4.1 优先级和副作用
C语言的运算符优先级表很长,我没有背全,记住几个关键层级就够了:括号最高,然后是单目运算符、算术运算符、关系运算符、逻辑运算符、赋值最低。
一条最基础的原则:不确定优先级时,就加括号。写代码给机器看,更给人看,括号不会让人觉得你不懂C,反而会让代码意图清晰。
需要专门警惕的是&&、||的短路求值。比如:
int a = 0; if (a != 0 && 10 / a > 2) { // 不会进入,因为a != 0为假,后面的除法根本不执行 }如果C语言不在运算符左侧为假时短路,那么10 / a就会造成除零错误。利用短路求值,可以安全地先判断指针不为空,再解引用。这是C程序员常用的防守技巧。
自增自减也是一个重灾区。
int i = 5; int a = i++; // a得到5,i变成6 int b = ++i; // i变成7,b得到7i++和++i作为独立语句没有任何区别,但一旦混进表达式里,就产生了副作用。新手最容易在printf("%d %d", i++, i++)这类代码上翻车,因为同一表达式中对同一变量的多次修改是未定义行为,不同编译器的结果可能完全不一样。我的建议很简单:别写这种代码,一行一个操作,清晰又安全。
4.2 三种循环的选择标准
C语言提供while、do-while、for三种循环,选择标准其实很清楚:
- 循环次数事先已知,用
for。 - 循环次数未知,靠条件决定,用
while。 - 至少要执行一次,才用
do-while。
do-while用得最少,但也不是没用。比如菜单程序:你希望先显示菜单,再根据用户选择判断是否退出,天然就是“至少执行一次”的场景。
还有一个老生常谈的问题:break和continue的区别。break是跳出整个循环,continue是跳过本次循环的剩余语句,进入下一轮循环。注意continue在while循环里可能造成死循环:
int i = 0; while (i < 10) { continue; i++; }这段代码里i++永远执行不到,程序死循环。很多人写for循环形成习惯了,转用while时没注意到continue会跳过迭代语句,这是经典陷阱。
判断语句if和else的配对也要注意。C语言的else与最近的if配对,而不是与缩进配对。下面这种写法有歧义:
if (a > 0) if (b > 0) printf("positive\n"); else printf("negative\n");缩进看起来else匹配外层if,但编译器会把它匹配给内层if,结果跟你看到的完全不是一回事。好习惯是:所有分支都加大括号,既防止歧义,也方便以后往里加代码。
5. 函数:拆出来的可复用逻辑
5.1 声明顺序与原型
C语言是按顺序编译的,如果先调用add、后定义add,第一条语句处编译器还不知道add长什么样,就会报错。两种解决方案:要么把函数完整定义放在调用之前,要么在文件开头放一个函数原型声明。
#include <stdio.h> int add(int a, int b); // 函数原型 int main(void) { printf("%d\n", add(2, 3)); return 0; } int add(int a, int b) { return a + b; }函数原型的语法就是在函数定义的基础上,把函数体换成;。原型告诉编译器三件事:函数名称、参数类型、返回类型。编译器只需要这些信息就能帮你检查调用是否合法。
int main(void)和int main()是有区别的。int main(void)表示main不接受任何参数,int main()是旧式写法,表示参数未指定。现代代码推荐用int main(void)。有些人用void main,这在C标准里是不正确的,虽然有的编译器默认放行,但严格来说程序入口应返回int。
5.2 传参:值传递和指针的首次相遇
C语言函数传参默认是值传递。传进去的值会被复制一份,函数内部修改形参,不会影响实参。
void swap(int a, int b) { int temp = a; a = b; b = temp; } int main(void) { int x = 10, y = 20; swap(x, y); printf("x=%d, y=%d\n", x, y); // 输出 x=10, y=20 return 0; }很多人第一次写交换函数在这里翻车,困惑“为什么明明调用了swap,x和y没变”。因为swap操作的是一份副本。要想让函数修改外部变量,就要传递地址,然后用指针接收:
void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int main(void) { int x = 10, y = 20; swap(&x, &y); printf("x=%d, y=%d\n", x, y); // 输出 x=20, y=10 return 0; }这是“指针的第一课”。指针变量存的是另一个变量的地址,*a是“通过a中存放的地址找到那个变量”。没有指针,C语言就无法写出真正修改外部变量的函数,这也是C与Java等现代语言“引用传参”最大的区别之一。建议新手把这段代码亲手敲三遍,加上很多打印来观察地址和值的变化,比背十遍概念都管用。
6. 数组、字符串与指针入门
6.1 数组的边界与sizeof
数组是一段连续的内存区域,声明写法是:
int nums[5] = {1, 2, 3, 4, 5};数组下标从0开始,最后一个合法下标是4。访问nums[5]在语法上合法,编译器不会报错,但它是越界访问,行为是未定义的。可能恰好读到了相邻变量的值,也可能直接导致段错误崩溃,有时还能正常跑,完全取决于运气。C语言不会给你做下标检查,这是它与现代语言最大的差别。
计算数组元素个数,推荐用sizeof:
int nums[] = {1, 2, 3, 4, 5}; int n = sizeof(nums) / sizeof(nums[0]);但是把这个数组传给函数后,sizeof失效了,原因要看清楚:
void print_len(int arr[]) { printf("%zu\n", sizeof(arr)); // 结果是8(指针大小),不是20 }因为函数参数中的数组写法只是语法糖,函数内arr真实类型是int*,sizeof(arr)计算的是指针的大小,而不是原数组的大小。所以数组长度信息不会自动传进函数,要么接受一个长度参数,要么定义成全局数组。这是C语言里一个非常重要的认知拐点,明白了它,才算真正理解“数组与指针的关系”。
6.2 字符串与指针的初始理解
C语言没有原生字符串类型,字符串就是字符数组,以'\0'结尾:
char name[] = "C lang"; char name2[] = {'C', ' ', 'l', 'a', 'n', 'g', '\0'};"C lang"这个字面量,实际上在内存里会额外多存一个'\0'。strlen("C lang")返回6,sizeof("C lang")返回7,这个差异很多初学者第一次接触时都会愣住。
指针初体验可以这样理解:某个变量有自己的地址,指针保存这个地址,星号运算符负责通过地址去“找”实际变量。
int x = 100; int *p = &x; printf("%d\n", *p); // 输出100 x = 200; printf("%d\n", *p); // 输出200指针指向的不是一个固定值,而是一个位置。通过这个位置,每次都能瞄到那个变量当前的值。画图是理解指针最好的方法——在纸上画一个框代表变量x,再画一个框代表指针p,p里写着x框的位置,然后手动模拟代码执行过程。我见过太多人跳过画图直接去背“指针就是地址”,结果一到复杂场景就晕。这个画图习惯一定要养成。
7. 遇错不慌:常见报错与逻辑问题排雷指南
7.1 编译期报错怎么读
编译器报错信息是写给人的,第一条报错往往最关键,后面的错误可能是“一条错引发连锁反应”。常见报错可以对着表格自查:
| 报错特征 | 常见原因 | 排查思路 |
|---|---|---|
| undeclared identifier | 变量未声明或拼写错误 | 检查变量名,检查声明是否在作用域内 |
| expected ';' | 行尾漏了分号 | 看上一行,补全分号 |
| conflicting types | 函数声明与调用不一致 | 检查参数类型和返回类型 |
| undefined reference | 链接阶段找不到函数实现 | 检查函数名拼写,检查是否漏编译源文件 |
| segmentation fault | 指针或数组越界访问 | 检查指针是否初始化,检查数组下标 |
| implicit declaration | 缺少头文件 | 检查#include是否齐全 |
见过最离谱的一个错误是,有个同学把printf拼成print,编译器提示隐式声明,他满世界找函数问题,最后才发现是拼写错误。教训是:编译报错先看行号和列号,再看错误类型,最后一个才看“是哪行代码引起的”。
7.2 运行期逻辑错误怎么查
编译通过只说明语法没问题,不代表逻辑正确。遇到结果不对、死循环、随机错乱,我是这样排查的。
先在关键节点加printf打印中间变量。比如写排序算法,在每轮循环后打印数组内容,立刻能看到是交换错了还是比较条件反了。这个“土办法”虽然原始,但比想象中高效,也适合刚入门还不会调试器的阶段。
然后可以学习用调试器设断点单步执行。VS Code里配置好C/C++调试后,可以在变量面板实时查看每个值,在“监视”窗口添加表达式。单步执行能亲眼看到变量怎么变的,对理解循环、指针尤其有帮助。
除了排查错误,写法习惯也很重要。我看到不少新手在一个main函数里堆几百行代码,出了bug排起来非常痛苦。建议养成一个朴素原则:一个函数只做一件事。输入、计算、输出尽量拆成不同函数,每段逻辑可以独立测试。哪怕代码简单,也应该按这个思路来,前期习惯好,后期写复杂项目会轻松非常多。
最后说点个人体会。我当初学C语法时,最难的坎不是循环也不是函数,而是指针和“内存到底是什么”这个抽象概念。后来发现,最好的办法就是大量画示意图、手写打印地址、故意制造几次越界崩溃,逼自己亲手观察地址变化。C语法的每个点都不难,但它们串起来以后,如果你每一条都只是“看过概念”而没有“亲手验证”,就一定会卡壳。建议学完一篇就动手改代码、折腾程序,别急着往下赶进度,语法基础这一关慢一点,后面才快得起来。