ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言第十四天指针墙:数组与字符串的踩坑复盘

C语言第十四天指针墙:数组与字符串的踩坑复盘 学C语言第十四天我终于撞上了传说中的“指针墙”。前十三天我还挺自信变量、分支、循环、数组、函数一路写下来PTA上的题也刷了不少连九九乘法表和冒泡排序都能不看书默写出来。结果第十四天翻开指针和字符串章节整个人直接懵了为什么数组名有时候像个值有时候又像个地址为什么char*和char[]看上去一样一个能改一个不能改为什么明明按教材抄的代码一运行就崩溃那天晚上我对着屏幕发了半小时呆最后是靠一把一把的printf和gdb硬生生把问题查明白的。这篇文章不是教程式的总结就是我第十四天真实的学习记录和踩坑复盘。我会把当天围绕“数组与指针、二维数组与鞍点问题、字符串处理、gdb调试”这几个主题摸爬滚打的全过程写下来包括那些教材里不会明说的“为什么”。如果你也正卡在C语言指针这堵墙前面希望这篇能帮你少走几个弯路。1. 为什么说第十四天是个坎从数组到指针的思维切换1.1 前十三天我到底学会了什么先盘点一下前面十三天的进度方便你判断自己卡在哪里。我用的教材是翁恺老师的C语言网课浙江大学搭配PTA平台刷题。前十三天基本覆盖了数据类型int、float、char、unsigned、long这些以及它们各自的存储大小和取值范围。这里我顺便把limits.h里的INT_MAX、LONG_MIN这些常量也过了一遍后面写程序判断溢出很有用。输入输出scanf和printf的格式控制包括%d、%f、%c、%s以及常见坑——比如scanf里%d 加了空格会要求再输一个字符才能结束还有输入缓冲区残留换行符导致%c读取异常。分支循环if-else、switch、while、do-while、for。这里特别理解了do-while至少执行一次的特性跟while完全不一样。数组一维数组和二维数组的声明、初始化和遍历也做了经典的“输入年月日计算这是一年中的第几天”这种题用二维数组存每月的天数再判断闰年算是把数组和分支的综合运用练了一遍。函数函数的声明、定义、调用、参数传递和返回值理解局部变量和全局变量的区别。简单算法冒泡排序、九九乘法表这些入门必写程序。你看这些内容其实都不难按部就班跟着做就能过关。但第十四天不一样指针这个东西它要求的不是“多敲代码”而是“换一套思维方式”。1.2 指针初遇地址、房间号和门牌那天我看的第一节内容是“指针的基本概念”教材上的定义是这样的指针就是地址指针变量就是用来存放地址的变量。我盯着这句话看了半天脑子里全是浆糊。后来我是用这个类比才真正想通的内存是一栋宿舍楼每个房间都有房间号也就是内存地址。变量就是住进房间的房客房客有自己的名字比如int a意思就是“在某个房间住了一个叫a的房客它占4个床位字节”。那指针是什么呢指针是一张写着别人门牌号的小纸条。intp a意思就是“我拿了一张纸条上面写着a住的那间房的门牌号”。如果你想通过纸条找到a就要拿着纸条去敲那个门这个“顺着纸条找过去”的动作就是解引用也就是p。这个类比帮我解决了一个关键疑问为什么指针变量本身也要占空间因为纸条本身也是一张纸也放在某个房间所以指针变量自己也是有地址的这就是为什么会有“指针的指针”存在。int **pp p就是记录了存放纸条的那个房间的门牌号。1.3 指针和数组之间说不清道不明的亲戚关系理解指针基本概念之后教材下一个动作就是讲指针和数组的关系这一下又把我整不会了。课本上说“数组名在大多数场合可以看作指向首元素的指针”但又不完全等价比如sizeof(数组名)返回的是整个数组的字节数而sizeof(指针)在64位系统上固定是8字节。我当时写了一段验证代码编译环境是Ubuntu虚拟机里的gcc#include stdio.h int main(void) { int a[5] {10, 20, 30, 40, 50}; int *p a; printf(a %p\n, (void *)a); printf(a[0] %p\n, (void *)a[0]); printf(p %p\n, (void *)p); printf(sizeof(a) %zu\n, sizeof(a)); printf(sizeof(p) %zu\n, sizeof(p)); printf(a[3] %d, *(a 3) %d, *(p 3) %d\n, a[3], *(a 3), *(p 3)); return 0; }结果a、a[0]、p打印出来的地址完全相同a[3]、(a 3)、(p 3)都是40但sizeof(a)是20sizeof(p)是8。这就证明了数组名确实在值上等于首元素地址但它并不是一个真正的指针变量因为它没有自己独立的存储空间它更像一个“标签”编译器知道它绑定了一块连续的存储区域。这里我要特别提醒一下printf打印指针要强制转成(void *)这是规范写法不然有些编译器在64位环境下会警告format指定了%p但参数类型不是void *。这种小细节往往就是网上代码一复制过来就编译报错的根源。2. 数组与指针的等价性a[i]和*(ai)背后的计算法则2.1 下标运算的本质编译器在偷偷做加法搞清楚数组名和指针的关系后下一个认知升级就是a[i]这个东西本质上就是*(a i)的语法糖。编译器看到a[i]的时候会把它翻译成“从地址a开始向后偏移i个元素宽度然后取出那个位置的值”。偏移的单位不是字节而是元素类型的大小。int数组偏移一位就是4字节double数组就是8字节。这也是为什么指针加1实际地址增加的是sizeof(类型)而不是1——这个我一开始总是搞混总觉得地址加1就该是加一个字节。理解这一点后很多看起来奇怪的写法就合理了。比如你可以写p[-1]只要p指向数组中间某个元素p[-1]就表示它前面那个元素。C语言的下标运算符根本不检查你是不是越界它只负责算地址。还有个加分的冷知识因为a[i]等价于*(ai)而加法满足交换律所以*(ai)等价于*(ia)也就是说a[i]可以写成i[a]。我第一次看到i[a]这种写法的时候还以为是别人打错字了后来试了一下居然能编译通过。不过实际项目中没人这么写纯粹是理解等价关系时的一个玩具。2.2 二维数组在内存里是“一条线”鞍点问题为什么折磨人第十四天的重头戏是让我用二维数组解决经典算法题找出一个5x5矩阵中的鞍点。鞍点的定义是该位置上的元素值在所在行最大、同时所在列最小一个矩阵最多只有一个鞍点也可能没有。我第一次写这个题的时候思路是这样的遍历每个元素先判断它是不是当前行的最大值如果是再判断它是不是当前列的最小值。听起来没毛病吧写出来的代码逻辑上也对但运行时我隐隐觉得有哪里不对——二维数组在内存中本来就是连续存放的a[i][j]的真正含义是*(*(a i) j)。只不过编译器把“看成一维数组的数组”这个逻辑帮我们处理了。二维数组的指针运算比一维复杂一层a是int[5][5]类型的数组名它指向的是第0行这个“长度为5的int数组”所以a 1跳过了整整5个int也就是20字节大小。这就是为什么int (*row)[5] a;这种“行指针”声明能用来遍历每一行。我当时被这个声明卡了快一个小时最后是写了下面这段代码把内存布局看明白的#include stdio.h void print_address(int a[][5], int rows) { int i, j; for (i 0; i rows; i) { for (j 0; j 5; j) { printf(a[%d][%d] - %p\n, i, j, (void *)a[i][j]); } printf(第%d行起始地址: %p\n, i, (void *)a[i]); } } int main(void) { int a[5][5] {0}; print_address(a, 5); printf(a 1 跳过的字节数: %ld\n, (char *)(a 1) - (char *)a); return 0; }运行结果里所有元素的地址连成了一条连续的线a 1和a地址差20字节。这是理解二维数组非常关键的实践所谓二维只是我们为了让思维更直观而加上的“地图网格”物理内存里它们就是排成一排的25个int。2.3 鞍点问题的标准解法与踩坑细节搞清二维数组的存储细节后我写出了完整版本#include stdio.h #define ROWS 5 #define COLS 5 int main(void) { int a[ROWS][COLS]; int i, j; printf(请输入5x5矩阵的25个整数:\n); for (i 0; i ROWS; i) { for (j 0; j COLS; j) { scanf(%d, a[i][j]); } } int found 0; for (i 0; i ROWS !found; i) { // 步骤1找第i行的最大值及其列号 int max_val a[i][0]; int max_col 0; for (j 1; j COLS; j) { if (a[i][j] max_val) { max_val a[i][j]; max_col j; } } // 步骤2检查这个最大值在它的列里是否最小 int is_saddle 1; for (int k 0; k ROWS; k) { if (a[k][max_col] max_val) { is_saddle 0; break; } } if (is_saddle) { printf(鞍点是 a[%d][%d] %d\n, i, max_col, max_val); found 1; } } if (!found) { printf(该矩阵不存在鞍点\n); } return 0; }这里有几个坑必须说一是“找行最大值”时遇到相同值怎么处理。如果题目没有明确说唯一最大那处理方式直接影响结果。我用的是“大于”而不是“大于等于”来更新max_col也就是取第一个最大值的位置。如果题目要求去重或者取最后一个就要改成“”。我一开始没注意这个调试时发现结果和预期不符排查了很久才发现是这个细节。二是优先级问题很多新手包括我会写成if (a[i][j] max_val 1)这种虽然能编译但完全是错误逻辑。正确写法就是if (a[i][j] max_val)比较运算符本身的值就是0或1不需要再跟1比较。三是判断列最小值时要注意不要漏掉当前行本身。我在循环里没有跳过k i因为a[i][max_col] max_val拿它跟max_val比不会触发小于条件所以不跳也没关系。但如果你写的是“严格小于”那当前行不会干扰结果这是逻辑上比较巧妙的点。3. 字符串的两种打开方式char*与char[]的六个不同3.1 字面量、可修改性、生命周期学完数组和指针紧接着就是字符串。C语言没有专门的字符串类型字符串就是字符数组加一个\0结尾。第十四天的练习里有一道PTA题题目是“字符串逆序”输入一个字符串逆序输出。题目本身不难但它把所有字符串的坑都集中引爆了。字符串有两种常见声明方式char str1[] hello; char *str2 hello;这两行代码第一眼看上去一模一样但本质差别非常大char str1[]是把“hello”这个字面量的内容拷贝到本地数组里数组自己拥有一份51字节的存储空间所以你可以修改str1[0]把它改成H。char *str2是指针指向编译期放在只读数据段的字符串字面量修改str2[0]是未定义行为很多平台上直接段错误崩溃。另外str2可以用指针运算遍历str1不行因为str1作为数组名不是可修改的左值你不能写str1但str2是可以的。我用下面这个小实验验证了差别#include stdio.h #include string.h int main(void) { char str1[] hello; char *str2 hello; printf(str1 %s\n, str1); printf(str2 %s\n, str2); str1[0] H; printf(修改后 str1 %s\n, str1); // 下面这行会段错误不要在自己的机器上尝试 // str2[0] H; return 0; }这个“看起来相同、实际不同”的特性是第十四天字符串章节最大的一道坎。我的经验是在你明确需要修改字符串内容时一律用字符数组声明如果用指针声明记住它是只读的。3.2 字符串逆序PTA题的完整解法和隐蔽bug字符串逆序的标准解法是把字符串从两端向中间逐字符交换。我第一次写的时候思路清晰代码也很短#include stdio.h #include string.h int main(void) { char s[81]; gets(s); // 仅用于演示实际强烈不推荐 int len strlen(s); for (int i 0, j len - 1; i j; i, j--) { char tmp s[i]; s[i] s[j]; s[j] tmp; } printf(%s\n, s); return 0; }但我犯了好几个错误第一个错误是用了gets(s)读取字符串。gets没有边界检查输入超过80个字符就会缓冲区溢出PTA的测试点里往往有超长输入。后来改成了fgets(s, sizeof(s), stdin)但要记得fgets会把换行符也读进去需要手动去掉末尾的\n否则逆序结果里会带着一个换行符提交后总是格式错误。第二个错误是对strlen的理解它返回的是不包括\0的字符个数所以最后一个字符的索引是len - 1而不是len。我在循环里一开始写成j len结果把\0交换到了开头输出的字符串直接变空。这个错误几乎每个初学者都会犯一次我的建议是犯错之后写一行注释提醒自己。第三个错误是关于中文输入。有一个测试用例包含中文字符比如“中国你好”。C语言里中文字符在UTF-8编码下是3个字节strlen数的是字节数而不是字符数直接用上面的交换程序会把一个汉字的字节拆开导致逆序后产生乱码。这道PTA题没要求处理中文但如果你自己扩展做中文逆序就必须先把UTF-8序列解码成码点数组再逆序再编码输出复杂度瞬间上一个台阶。3.3 函数参数传递为什么swap(a, b)没用字符串练习做完后我顺手复习了第十四天之前学过的函数参数传递。C语言参数传递是值传递函数内部形参的任何修改都不会影响实参。这也是为什么写swap函数必须传地址void swap(int *px, int *py) { int tmp *px; *px *py; *py tmp; }而错误示范是不传指针只传值。我当时在PTA上遇到过一个题要求用函数交换两个变量的值第一次写出来调试了半小时发现根本换不了打印出来还是老样子。后来才意识到形参和实参是两个不同的变量函数只是把值拷贝了一份进去改变形参对实参毫无影响。只有通过指针拿到实参的地址才能修改实参本体。这个道理也延伸到字符串处理函数上如果你想在函数里改变一个指针变量的指向必须传入指针的指针即char **类型。比如void alloc_string(char **p) { *p malloc(20); strcpy(*p, new content); }一开始我以为char *就够了结果函数返回后实参还是NULL。这其实是“我们没有真正的引用类型只能靠指针指向指针”带来的连锁问题。当你理解了“数组名退化为指针”和“指针变量自己也有地址需要指针来修改”这两件事这个坑就自然填平了。4. 调试技能救了大命gdb查看程序内部发生了什么4.1 什么样的调试才算有效printf之外的第二层武器第十四天中午我的鞍点程序突然输出一个奇怪的结果无论怎么改条件都不对。刚开始我只会用printf到处打印中间变量一行一行地找。这个方法不能说没用但效率太低了尤其是二维数组我一口气要打印25个值眼睛都看花了。下午我打开虚拟机开始正式学gdb。虽然前面几天编译时遇到过segmentation fault但我一直是靠猜来修的。这一天我下定决心与其瞎猜不如把程序“拆开”看看每一步执行到哪里。gdb是Linux下最经典的调试器。我平时在Ubuntu虚拟机里写C编译时加一个-g选项程序里就带上了符号和源码信息gdb才能对应到具体行号。基础的指令我来列一个高频速查表这也是我日常用得最多的几个指令作用gcc -g test.c -o test编译时加入调试信息gdb ./test启动调试器break 行号 或 break 函数名下断点run运行程序到断点next简写n单步执行跳过函数调用step简写s单步执行进入函数内部print 变量名简写p打印变量的当前值watch 变量名监视变量值一变就停下continue简写c继续运行到下一个断点backtrace简写bt查看函数调用栈quit退出调试器4.2 一次真实调试鞍点程序为什么输出错误我用一个具体案例讲讲gdb帮我解决问题的全过程。当时鞍点程序对某些输入矩阵输出正确对另一些却输出“不存在”我怀疑是逻辑问题但找不到在哪。我在gdb里设置了断点位置在判断列最小值的那一行if (a[k][max_col] max_val) {用gdb运行输入一个测试矩阵程序停在断点处。我用print打印了当前所有关键变量(gdb) p i $1 0 (gdb) p max_col $2 1 (gdb) p max_val $3 9 (gdb) p a[0][0] $4 5这时我突然发现max_col的值是1但我本来预期第0行的最大值是在第3列。也就是说我的“查找行最大”循环里比较条件写成了if (a[i][j] max_val)而这个问题中要求取第一个最大值时应该用。因为代码是我前一天熬夜写的我已经忘了自己写的是。这是gdb第一次直接帮我找到问题的根因而不是我肉眼扫描半天代码。第二次调试是字符串逆序的段错误。我在gdb中运行程序输入字符串后程序崩溃。gdb直接给出提示Program received signal SIGSEGV, Segmentation fault。然后我执行bt看到崩溃发生在strlen调用内部。这就说明是传递的指针有问题——我在某个地方把未初始化的指针传给了strlen。顺着调用栈回到主函数发现是一个分支里忘了给指针赋初值。如果不用gdb这种问题靠printf根本无从下手因为程序崩得太快了printf根本来不及刷新缓冲区。4.3 常见的段错误类型和预防第十四天晚上我把当天遇到的所有段错误整理了一遍常见的主要有三种访问未初始化的野指针。比如char *p;然后直接strcpy(p, hello)p不知道指向哪里大概率崩溃。数组越界。比如int a[5];却写a[5] 1;这访问了a末尾之后的内存可能不会立刻崩溃但在错误时机造成“幽灵bug”。修改只读字符串字面量。就是上一章说的char*指向字符串字面量后去改它。这些错误的共性是C语言不像Java或Python那样有运行时检查越界访问在大多数时候“碰巧”能运行所以特别容易潜伏。我现在的习惯是写完一段访问数组的代码先问自己三个问题这个数组有多少个元素我的下标范围是什么字符串有没有\0结尾三个问题都答对了再编译。5. 第十四天的收尾我留下的练习题和自查清单5.1 用两个小程序巩固概念当天晚上我给自己额外安排了两个小程序用来加固对指针和数组的理解。第一个是“用指针遍历冒泡排序”。不是新想法但我强迫自己不用下标全程只用指针访问数组#include stdio.h void bubble_sort(int *arr, int n) { int i, j; for (i 0; i n - 1; i) { for (j 0; j n - 1 - i; j) { if (*(arr j) *(arr j 1)) { int tmp *(arr j); *(arr j) *(arr j 1); *(arr j 1) tmp; } } } } int main(void) { int a[] {64, 34, 25, 12, 22, 11, 90}; int n sizeof(a) / sizeof(a[0]); bubble_sort(a, n); for (int i 0; i n; i) { printf(%d , a[i]); } printf(\n); return 0; }这个练习特别有价值因为它逼迫你把a[i]彻底改写成*(a i)的形式。一开始很不习惯写出来的每个下标都要换算成偏移量但练完一遍指针与数组等价的理解再也不飘了。第二个练习是“一维数组指针的加减运算练习”我把一个数组的头尾指针各打印出来然后观察指针相减的结果。比如int *p a; int *q a[4];打印q - p结果是4而不是16这让我彻底明白了指针相减得到的是元素的个数差而不是字节差。这个知识点在很多算法题里都很有用比如判断两个元素之间隔了几个元素。5.2 一份给未来自己的高频错误自查表第十四天快结束的时候我在笔记里写了一份自查表现在也分享出来。核对到每一条都OK我的C入门阶段基本就稳了scanf有没有写除了%s和数组名其他变量都要加取地址符。判断相等是不是写成了if(a 1)这种bug编译器不一定报错但逻辑全错。数组下标有没有从0开始strlen返回值是否减1再当作最后下标字符串用char[]声明还是char*能否修改指针使用前是否初始化malloc后是否检查返回值函数内需要修改外部变量时是否传了地址这份清单我之后再写代码会经常对照。很多人觉得这些都是低级错误但真正到了压力大的时候最容易犯的就是低级错误。5.3 我的第十四天学习建议最后分享几条在第十四天这个节点我觉得特别重要的学习建议。第一不要跳过指针。很多人在这一步放弃C语言转而去学别的语言说“C太难了”。但指针就是C的基因跳过它等于白学。如果觉得难就回到内存地址、字节、门牌号这些基本概念上一步一步拆。第二学习环境一定要顺手。我用的虚拟机是Ubuntu装上gcc和gdb没有集成开发环境的花哨功能反而逼着自己学会命令行编译和调试。如果你用VSCode也一定把tasks.json和launch.json配置好让F5能直接调试而不是每次都靠printf。第三把PTA当日常训练。翁恺老师的课配PTA的题难度曲线很合理。我前十三天几乎每天都按计划刷两到三题第十四天虽然进度慢但因为做的是鞍点和字符串逆序这类综合题反过来帮我把数组、指针、函数、调试全串起来了。第四写完一段程序尝试给同伴讲一遍。我有个学习伙伴每次我卡住的时候我会把代码一行一行讲给他听讲到一半自己就发现逻辑漏洞了。这就是“橡皮鸭调试法”的现实版非常有效。学到这里我对C语言有了和上周末完全不同的感觉。指针不再是课本上那个绕口的概念而是变成了一张可以用来精确定位内存的房间号地图字符串也不再是那个经常让我崩溃的未知领域。第十四天虽然慢但我能感觉到自己的调试能力和代码理解能力在明显变快。希望这份记录也能给你一些启发让你在学C的路上少走几个夜路。
返回列表