学完第四章的那阵子我最大的感受是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]可以写成*(*(bi)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]等价于*(ai)。更隐蔽的是a和a的值在数值上相同但类型不同导致指针加减步长不同。举例p a; p1指向a[1]而p a; p1直接跳过整个数组。这个坑我踩过调试时看到输出的地址一模一样但数值跳变很大起初完全摸不着头脑。第四章如果只把指针当“地址”两个字记忆不深入数组的结合规则后面学到动态内存和链表时会非常吃力。再看函数指针。函数名也能作为地址把函数指针加一不知道该跳到哪里这已经超纲了但知道“函数名也是地址”能帮你理解为什么要用回调机制。第四章不需要掌握混个脸熟就行。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语言就是这样一门有意思的语言你踩过的每一个坑都会变成你将来排查问题的直觉。