ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言第四章精讲:数组、字符串、函数与指针避坑指南

C语言第四章精讲:数组、字符串、函数与指针避坑指南

学完第四章的那阵子,我最大的感受是:C语言前几章像在学“单词和语法”,到了数组、字符串、函数这一章,才算真正开始写“句子”和“文章”。这是很多人从“会敲指令”到“能写程序”的转折点,也是劝退率最高的章节之一。我这份个人整理版,聚焦第四章最核心的三块——数组、字符串、函数,外加一个常常被提前夹带的指针初体验。适合正在跟教材自学、刚上完理论课但做题总卡壳的同学,也适合准备期末回头梳理知识框架的人。整理里没有教材式的长篇大论,只有我当时反复琢磨、亲自跑过代码之后留下的关键结论和坑点记录。内容顺序按数组、字符串、函数、指针初体验四块展开,最后附一张高频报错速查表,方便日常查漏。

1. 第四章的版图:先弄清教材为什么这样编排

1.1 不同教材里第四章的定位差异

很多人拿到教材就直接从头啃,遇到卡壳就硬背。我建议先翻目录看一眼你的教材第四章到底把哪些内容放在一起。主流教材大致分两类:一类把数组、字符串、函数并在一章,强调“结构化编程的三大基件”;另一类把数组单独成章,函数单独成章,第四章只讲数组和字符串。还有些流行的在线课程会把指针提前到第四章讲,理由是数组名本身就隐含指针操作,晚讲反而绕。所以你看网络笔记时,标题同样是“第四章”,内容可能差很多。

我这篇整理的顺序是:数组 -> 字符串 -> 函数 -> 指针初体验,基本覆盖了最主流的范围。看的时候对号入座,已经学过的部分可以直接跳到避坑清单那一节。需要提醒的是,网络上的个人笔记往往是作者在自己教材体系下的总结,和你手里的教材存在小节分布差异很正常,关键不是抱怨“和老师讲的对不上”,而是把知识点本身吃透。

1.2 我的整理主线

我整理章节的时候给自己定了一条主线:不管哪一块,都要回答三个问题——它到底在内存里长什么样、它和前面的语法有什么关联、它会导致哪些最常见的运行错误。数组回答的是“连续空间的批量管理”,字符串回答的是“字符数组的边界约束”,函数回答的是“代码复用和栈帧跳转”,指针回答的是“通过地址间接操作数据”。

这条主线后来帮了我特别多。很多同学问为什么数组越界有时候不报错,为什么scanf输入字符串会崩溃,为什么函数里修改不了外部变量,其实答案都在这条主线上。所以这篇笔记表面是知识点清单,实际是一条“内存视角 + 错误视角”的理解路径。如果你能顺着这条路径走一遍,第四章的很多疑问都会自动解开。

2. 数组:看似简单,坑全在细节里

2.1 一维数组的声明与初始化细节

数组的声明公式很简单:类型 数组名[元素个数]。但我见过太多人在初始化上翻车。int a[5] = {1,2,3}; 这种写法只显式初始化前三个元素,剩余两个自动补0,很多人以为会随机值,实际不是。int a[5] = {0}; 是把所有元素清零的常用写法,效率也不差。

还有一个细节:数组的长度必须是常量表达式。int n; scanf("%d", &n); int a[n]; 这种“变长数组”是C99的特性,在一些老旧的考试编译环境里会报错。我的建议是初学阶段就用明确数字或者宏定义,避免给自己挖坑。

再说一个经典误区:int a[] = {1,2,3,4}; 编译器会自动推断为4个元素,但当你用sizeof(a)/sizeof(a[0])计算元素个数时,要注意这个表达式只在数组自身作用域内有效。一旦把数组作为参数传进函数,sizeof(a)就变成了指针的大小,64位机上是8字节。

#include <stdio.h> void test(int arr[]) { printf("%zu\n", sizeof(arr)); // 输出8,因为arr退化成指针 } int main(void) { int a[] = {1,2,3,4}; printf("%zu\n", sizeof(a) / sizeof(a[0])); // 输出4 test(a); return 0; }

这个坑几乎每个人都会踩一次。记住:数组作参数时,函数里拿到的不是数组本身,而是指向首元素的指针,长度信息丢失了。

2.2 二维数组的内存布局

很多教材画二维数组时会画成一个矩形表格,容易让人误以为内存也是二维的。实际上C语言的二维数组在内存里是连续排布的,比如int b[3][4],本质就是一个拥有12个int的连续数组,看起来有三行四列,只是用b[i][j]这种语法做下标换算。

这里有个重要推论:b[1]这个表达式本身是一个“一维数组名”,类型是int[4],它指向第二行的起始位置。所以b[i][j]可以写成*(*(b+i)+j),虽然平时很少这么写,但能帮你理解为什么int (*p)[4]能指向二维数组的一行,而int *p却不行。

二维数组传参有几种写法:void f(int a[][4])可以,void f(int (*a)[4])也可以,void f(int a[3][4])也可以,但3会被编译器忽略。最需要注意的是不能写成void f(int a[][]),因为第二维缺失导致编译器不知道如何计算行偏移。内存连续排布还带来一个小技巧:可以用一维指针去遍历二维数组,比如int *q = &b[0][0]; 然后q[5]就是b[1][1]。这种做法在刷题时偶尔能简化代码,但可读性差,不推荐新手使用。

2.3 数组越界:编译器不报错的“定时炸弹”

越界读取和越界写入是C语言新手最常遇到的“神隐故障”。你写a[5]访问一个长度为5的数组的第六个元素,编译器通常不会报错,运行时也可能不立刻崩溃,因为系统并不在每次数组访问时做边界检查。这其实是在牺牲安全性换取性能。

越界写入会覆盖相邻内存里的其他变量,轻则变量值变得莫名其妙,重则破坏函数返回地址导致段错误。我当年遇到过一个特别诡异的案例:一个数组越界把一个标志变量改掉了,导致后面的while循环怎么也不退出,找了一下午才发现是数组下标算错了。我的习惯是:凡是下标依赖计算的地方,先手算边界值,再用printf打印验证,不要凭感觉。

另外,C语言标准规定数组下标从0开始,所以长度为n的数组,合法下标是0到n-1。这个规则本身简单,但循环里写i <= n的人比比皆是。我每次写for遍历都会在心里默念“小于号,不是小于等于”。

3. 字符数组与字符串:第四章的“重灾区”

3.1 字符数组和字符串字面量不是一回事

C语言里没有真正的字符串类型,用的是字符数组加'\0'结尾的约定。字符串字面量比如"hello",编译器会自动在末尾补一个'\0'。如果自己声明char str[5] = "hello";,会有问题,因为hello占5个字符,加上'\0'就是6个,数组长度不够。

更隐蔽的是char *p = "hello";这种写法,p指向的是只读的字符串字面量区,对它做p[0]='H'会触发运行时错误。而char arr[] = "hello";则把内容复制到了栈上的可写数组里,可以正常修改。这个区分特别重要,很多同学分不清“指针指向字符串”和“数组保存字符串”的区别,后面做题时经常弄出段错误。

判断方法很简单:用char arr[] = "..."声明的,内容可改;用char *p = "..."声明的,内容不要动。指针p本身可以重新指向别的字符串,但指针所指向的那个字面量的内容不能通过指针修改。

3.2 字符串函数的边界风险

strcpy、strcat、sprintf这组函数是出了名的“不设防”。它们不会检查目标缓冲区够不够长,只管往内存里写,直到遇到'\0'。比如:

char dst[5]; strcpy(dst, "hello world");

编译不报错,运行时不立即崩溃,但已经越界写入了。解决方向有两个:用更安全的替代函数,比如strncpy、strncat、snprintf,在调用时传入缓冲区大小;或者干脆自己写一个检查长度的拷贝逻辑。我个人的处理习惯是:凡是处理不确定长度的字符串,一律用snprintf和strn函数,并且记住snprintf的返回值是“如果缓冲区够长应该写入的字符数”,不是实际写入数,靠它做长度判断更可靠。

还有一个小细节:strncpy不一定以'\0'结尾。当源字符串长度大于等于目标缓冲区大小时,strncpy不会追加'\0',这会导致目标缓冲区变成一段没有终止符的字符序列,后面再用strlen去算长度就出事了。所以用strncpy之后,建议手动在缓冲区末尾置0。

3.3 输入函数的选择陷阱

scanf("%s", buf)不会读入空格,遇到空白就停,所以无法输入带空格的句子。gets更是被标准直接废弃了,因为它不限制读入长度,缓冲区溢出风险极高。想要读一行带空格的内容,用fgets(buf, sizeof(buf), stdin),但它会把换行符也读进来,需要手动去掉末尾的'\n'。这是另一个经典坑:fgets读完后buf末尾带着换行,直接strlen判断长度比预期多1,常见处理是这样的:

#include <stdio.h> #include <string.h> int main(void) { char buf[100]; if (fgets(buf, sizeof(buf), stdin)) { size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { buf[len - 1] = '\0'; } } return 0; }

用scanf的%s时还要注意,它同样没有长度限制,输入过长一样越界。可以写成scanf("%99s", buf),用宽度限位。还有混合输入的问题:scanf("%d", &n); 后立即fgets(buf, sizeof(buf), stdin); 常常读到一个空行,因为scanf把数字读走后,缓冲区里的换行符被fgets读去了。

解决办法有两个:一个是用循环读走残留字符,比如while(getchar() != '\n'); 另一个是统一用fgets读整行再用sscanf解析,后者更稳但要有多写两行代码的心理准备。很多初学者在这个问题上卡了很久,我也是其中之一。

4. 函数:传参、递归和作用域

4.1 值传递与地址传递的本质区别

C语言函数传参默认是值传递,形参是实参的一份拷贝。我在很长时间里都被一个问题困扰:为什么swap函数里交换两个变量,外面的实参没变。直到我理解了“拷贝”这个词:传递给swap的只是a和b的值,函数里交换的是两个局部副本,函数结束副本销毁,外面的原值当然不受影响。

要改外部变量,必须把变量的地址传进去,用指针间接修改。这也是第四章学完函数之后紧接着讲指针的直接原因。

#include <stdio.h> void swap_bad(int x, int y) { int t = x; x = y; y = t; } void swap_ok(int *x, int *y) { int t = *x; *x = *y; *y = t; }

区别在于swap_ok传递的是地址,函数通过地址找到原变量,间接写入。记住一个判断标准:凡是需要改变调用方变量的值,就把该变量的地址传进去;数组作为参数时传的是地址,所以函数里修改数组元素,外面的数组是真的被改了,这一点和普通变量完全不同。

4.2 递归函数与栈的配合

递归是第四章里最劝退的内容。难点不是“自己调用自己”这句话,而是它背后依赖的调用栈机制。每次函数调用,系统会把参数、返回地址、局部变量压进栈里。递归调用就是反复压栈,直到遇到基准条件,才开始一层层返回。

写递归最怕两件事:缺基准条件导致无限递归,最终栈溢出;基准条件写得不对导致结果错误。我的经验是动手前先明确两件事:基准条件返回什么,每一层递归向基准条件收缩多少。比如走台阶问题的递归实现:

int climb(int n) { if (n == 1) return 1; if (n == 2) return 2; return climb(n - 1) + climb(n - 2); }

这个写法很简洁,但n稍微大一点,重复计算就非常多,因为climb(3)会被反复调用很多次。课堂里教递归主要是为了理解思路,实际项目里往往要用记忆化或迭代优化。我整理第四章笔记时专门把两种写法都写了对照版本,复习时一眼就能看出各自的代价:递归优雅但可能低效,循环啰嗦但性能稳定。递归和迭代本质上可以互相转换,凡是递归能实现的,用循环加栈也能实现。理解这个对应关系,对后续学习二叉树遍历和深度优先搜索特别重要。

4.3 变量作用域与生命周期

第四章通常会讲局部变量、全局变量和静态变量。局部变量在函数进入时分配、离开时释放,生命周期是调用期间。全局变量在程序启动时分配,整个程序结束才释放,好处是多个函数共享数据,坏处是到处都能改,排查问题难度直线上升。

static修饰的局部变量值得单独说:它的存储位置和全局变量一样,不在栈上,但作用域仍然只限函数内部。也就是说,函数返回后它的值不会消失,再次调用时还能看到上一次修改的值。这个特性可以做计数器:

#include <stdio.h> int next_id(void) { static int id = 100; return id++; }

每次调用返回不同的值,而且不会被其他函数干扰。很多人以为static只能用于全局变量,其实是误解。static有两个作用:修饰局部变量时改变生命周期,修饰全局变量时限制外部文件访问。这里的关系用一句话概括:存储位置决定生命周期,作用域决定可见范围。

4.4 函数声明、定义与头文件的基本约定

函数必须先声明后使用。声明可以写在调用之前,也可以单独写函数原型。早期我经常把函数的定义写在main之后,然后编译报隐式声明错误。解决办法是:把函数声明放在文件开头,或者把函数定义整个放在main之前。

当程序拆成多个源文件时,函数原型放在头文件里,源文件用#include引用。头文件还需要防重复包含的写法,最常见的是#ifndef或#pragma once。这里有个容易被低估的坑:头文件里只放声明,不放定义,否则多个源文件包含同一个头文件时,链接阶段会报重定义错误。我在做某次模拟项目时就被这个坑折磨过,后来养成了写头文件前先想清楚“这是声明还是定义”的习惯。

5. 指针初体验:第四章的“隐藏副本”

5.1 指针变量到底是什么

指针变量保存的是地址,它本身也有类型。int *p意思是“p指向一个int”,但p这个变量存的是某个int的地址。理解它一定要区分:p是地址,p是该地址上的值。很多初学者在printf里写p和p时傻傻分不清。scanf里为什么要加&,因为scanf希望接收的是变量的地址;但数组名本身已经是地址,所以scanf("%s", str)不需要再加&。这一条规律能解释大量输入相关的代码。

指针初始化时最好置空,int *p = NULL;,防止野指针。野指针就是指向了未知内存的指针,对它赋值或者解引用,轻则值被破坏,重则段错误。这个习惯能避免一半以上的调试时间,我实践中确实如此。还有一点,指针变量本身也有地址,对指针取地址就是二级指针,int **pp = &p;,虽然第四章一般不展开二级指针,但你要知道这一层关系是存在的,知识体系才能连起来。

5.2 指针与数组的关系

数组名在大部分表达式中会退化成指向首元素的指针。这解释了为什么int a[5]; intp = a;是合法的,而p = &a;却是类型错误,因为&a的类型是int ()[5],是“指向整个数组的指针”,不是int*。平时遍历数组,a[i]等价于*(a+i)。

更隐蔽的是a和&a的值在数值上相同,但类型不同,导致指针加减步长不同。举例:p = a; p+1指向a[1],而p = &a; p+1直接跳过整个数组。这个坑我踩过,调试时看到输出的地址一模一样但数值跳变很大,起初完全摸不着头脑。第四章如果只把指针当“地址”两个字记忆,不深入数组的结合规则,后面学到动态内存和链表时会非常吃力。

再看函数指针。函数名也能作为地址,把函数指针加一不知道该跳到哪里,这已经超纲了,但知道“函数名也是地址”能帮你理解为什么要用回调机制。第四章不需要掌握,混个脸熟就行。

5.3 指针的常见错误

新手指针错误集中在三类:未初始化就解引用、越界后继续移动指针、混淆数组和指针的sizeof。第一类通常表现为访问了随机地址;第二类典型场景是遍历字符串时指针跑到了'\0'后面;第三类在2.1已经讲过。

这里补充一个调试技巧:不要急着在代码里到处加printf猜问题,先看崩溃手段。用调试器设置断点,观察指针变量的地址和值,这样能立刻发现是空指针、野指针还是越界。我实习时带我的前辈说过一句话:程序崩溃不可怕,可怕的是你连崩在哪一行都不知道。排查指针问题,第一件事是确认指针有没有值,第二件事是确认这个值指向的内存是否存在。

6. 避坑手册:这些错误我几乎每次复习都遇到

6.1 高频编译错误速查

我整理了一份小表格,专门记录那些反复出现的错误,每次考试前翻一遍很有用:

常见的出错场景典型报错信息原因与应对
数组下标越界运行时无报错或Segmentation fault访问超出数组范围的内存,先检查下标边界
字符串初始化长度不足error: initializer-string for array of chars is too long字符串字面量需要预留'\0'位置
%s输入未取地址warning: format specifies type ...scanf("%s", name)直接用数组名即可
函数调用缺前置声明error: implicit declaration of function函数定义在前或添加函数声明
给字符串数组赋新串error: assignment to expression with array type数组名不能直接赋值,用strcpy
指针未初始化就解引用Segmentation fault初始化为NULL并检查后再使用

这张表只写了最常碰到的六条,实际上还有一类运行时警告,比如输出格式符写错,%d传了double,编译器不一定报错,但结果完全不对。这类问题靠的是细心,或者把编译器的警告级别调高。

6.2 运行时崩溃的排查思路

程序崩了别慌,先判断是段错误还是逻辑错误。段错误一般是指针访问了非法内存,按5.3的流程排查:先定位崩溃行,再看该行的指针变量是否合法。逻辑错误更隐蔽,因为程序能跑,但结果不对。

我的排查顺序是:先怀疑数组越界修改了别的变量,再怀疑函数参数传递方式,最后怀疑运算符优先级。这三个方向覆盖了第四章80%的错因。还有一个忠告:不要连续改代码然后重新编译跑一次看结果,而是每改动一处就保留一次运行记录,这样能知道是哪次改动引入了新问题。我踩过最大的坑就是用printf调试时忘记加换行符,导致输出缓冲没刷新,看到的信息全是乱序的,后来改用调试器才根治。

6.3 我的学习与复习小习惯

最后说说我怎么用这份笔记。第一轮学习时,每看完一个小节,我就合上教材自己写一遍该小节的示例代码,不抄,默写。默写不出来的地方就是没理解透的地方。第二轮复习时,我只看章节末尾的报错速查表和代码片段,因为在真实写题和调试时,真正卡住我的永远是那些边界细节。

这份笔记后来还被一起自习的同学借着看,他们说最有价值的不是知识点本身,而是每个知识点旁边标注的“我当时错在哪”。我把这句话当作最真实的反馈。如果你也在学C语言第四章,希望这份整理能帮你少走几个弯路。毕竟,C语言就是这样一门有意思的语言:你踩过的每一个坑,都会变成你将来排查问题的直觉。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进