ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C语言指针与数组深度解析:从地址、下标到二维数组与字符串

C语言指针与数组深度解析:从地址、下标到二维数组与字符串 先说个我教C语言时最常见的场景讲到指针讲义上写着“指针是一种保存变量地址的变量”学生点头说懂了。一做题看到“数组名是首元素地址”“a[i] 就是 *(ai)”又卡住了。指针难往往不是难在“指针是什么”而是难在它跟数组、字符串、函数参数缠在一起。很多教程把指针当作孤立概念讲一上来就是 *p、a可一到数组、字符串大家又开始靠背。今天这篇不贪多就盯住“指针与数组”这一条主线把地址、下标访问、指针运算、二维数组、字符串这串东西一层层捋开。如果你刚学到C语言指针或者在指针和数组之间反复横跳搞不明白这篇应该对你有用。我也尽量按实际写代码、调程序时的经验来讲不搞纯理论。1. 先搞清楚“地址”是什么——指针的地基1.1 内存地址就是一个个带编号的格子内存本质上是一大片连续的存储空间你可以把它想成一条很长的走廊走廊里的每个房间都有编号编号就是地址。程序运行时变量就得占房间。定义一个int a 5;编译器会在这条走廊里给你划出4个房间因为int在多数平台占4字节然后把5放进去。这4个房间的起始编号就是a的值。为什么要先说这个因为指针的一切都建立在“地址”之上。变量名是给人看的编译器在生成指令时根本不认识“a”这个名字它只认识地址。所以当你写下a 10这样的语句编译器翻译出来的机器指令其实是“往某个地址写入10”。理解了这一层再看指针就会觉得它非常具体不是什么玄乎的魔法。用生活里的类比普通变量就像是房间里放着东西变量名是门上贴的标签地址则是门牌号。指针变量呢它是“门后面贴着一张纸条纸条上写着另一个房间的门牌号”。你要找东西可以顺着纸条上的门牌号走到另一个房间去取。这就是“引用”和“解引用”的关系。1.2 指针变量把门牌号存下来的变量看一段最简单的代码int a 10; int *p a; printf(%d\n, *p); // 输出 10 *p 20; printf(%d\n, a); // 输出 20int *p声明了一个指针变量它的类型是“指向int的指针”。p里面存的值是a的地址也就是a。*p的含义是通过p里存的地址找到那个房间然后取出或修改房间里的东西。这里有个特别容易混淆的点*在声明语句里和表达式里意思完全不同。声明中的int *p里星号只是告诉编译器“p是一个指针”而在表达式*p 20里星号是解引用运算符意思是“访问p所指向的变量”。很多初学者把这两者当成一回事后面就会觉得代码到处都是星号、越看越乱。你只要记住声明里的星号是类型标记表达式里的星号是操作符。还有一个细节值得注意p自己也有地址也就是p。p这个变量本身也占内存它的内存里存的是a的地址。所以三层关系要理清楚a变量里面存的是数值10aa的起始地址p指针变量里面存的是a*p通过p找到a得到10。1.3 和 * 的互逆关系是取地址*是按地址找变量这两个操作在某种意义上是互逆的。*a实际上就等于a因为先取a的地址再按地址解引用绕了一圈又回到a本身。同理*p就等于p里面存的那个地址。理解这个关系有什么实际用处它帮你理解“为什么*p可以出现在赋值号左边”。*p不是把一个值读出来给你看它代表的是“p所指向的那个变量本身”是一个左值。所以*p 20才会真正修改a变量。这也是C语言指针能修改“函数外部变量”的原理函数里拿到的是外部变量的地址通过地址去修改那个房间里的内容而不是只改一个拷贝。我在实际教学里发现很多学生写*p 20时不理解为什么a会变总觉得这是某种“远距离传送”。其实没有传送p就像一个持有门牌号的人他走到a的房间门口把里面的东西换了。地址还在原地变的只是值。2. 数组名到底是不是指针——一维数组与指针的纠缠2.1 数组名是首元素地址但不是指针变量int arr[5] {1, 2, 3, 4, 5}; printf(%p %p\n, (void *)arr, (void *)arr[0]);这段代码的输出两个地址完全相同。所以很多人说“数组名就是指针”其实这个表述不够严谨。准确的说法是数组名代表数组首元素的地址它是一个地址常量而不是一个可修改的指针变量。区别就在“能不能改”上。指针变量可以改比如p、p x都合法。但数组名不能改你不能写arr也不能写arr x编译器直接报错。原因也很简单数组名不是一个独立的存储单元它没有自己的一块内存可以用来存放地址。编译器在遇到arr的时候就直接把它替换成首元素的地址了。指针变量则不同它自己有存储空间里面存的值可以随时换。从类型上说arr的类型是int *也就是指向int的指针类型。你可以写int *p arr;让p指向数组的第一个元素。之后p就真的变成了一个可以自由移动的指针p会让它指向下一个元素。数组名本身不动但通过指针你可以遍历整个数组。另外提一个让人困惑的点arr是什么它虽然数值上和arr一样但类型完全不同。arr的类型是int (*)[5]指向“整个数组”的指针。后面讲二维数组时会再碰到这种“行指针”。现在你只需要知道arr1跳到第二个元素而arr1会跳过整个数组5个元素。这俩差远了。2.2 a[i] 与 *(ai) 的等价关系这是C语言里最经典的关系之一对任何指针p和整数ip[i]完全等价于*(pi)。编译器在处理下标访问时本质上是先做指针偏移再做解引用。所以a[i]的真实面目就是*(ai)这个结论可以在C标准里找到依据。有了这个关系很多现象就说得通了。比如i[a]这种写法在C语言里竟然能编译通过因为i[a]被解释成*(ia)跟a[i]是一样的。这算是C语言一个著名的“彩蛋”但实际工程里千万别这么写纯属给自己找麻烦。不过它确实帮助印证了“下标访问的本质是指针运算”。再看一个实际应用既然p[i]对任何指针都成立那么int *p arr;之后p[2]和arr[2]就是同一个东西。这个就是算法题里常见的“指针当数组用”申请一块堆内存然后用下标访问它。C语言里动态内存和数组其实走的是同一套访问逻辑原因就在这里。为了加深理解可以想想*(arr i)的运算过程arr是首元素地址arr i表示从首元素往后数i个int元素的位置再解引用取出那个位置上的值。注意这里的“i”不是简单加i个字节而是加i * sizeof(int)个字节。指针加减运算的粒度取决于指针指向的类型这个后面还会多次遇到。2.3 数组遍历的三种写法以及函数传参时的“退化”三种遍历数组的写法本质上是一回事// 写法1下标 for (int i 0; i 5; i) printf(%d , arr[i]); // 写法2指针偏移 for (int i 0; i 5; i) printf(%d , *(arr i)); // 写法3移动指针 for (int *p arr; p arr 5; p) printf(%d , *p);写法1可读性最好写法2能让你时刻意识到下标背后的指针运算写法3则展示了指针变量可以自增的特性。三者的效率在现代编译器优化之后几乎没有差别选哪种主要看场景和你想要表达的意图。但在某些底层或嵌入式环境里写法3因为少了“基址偏移”的重复计算有时会让代码意图更直接。真正坑人的是函数传参。看这个void print_arr(int a[], int n) { printf(%zu\n, sizeof(a)); // 输出8不是数组大小 for (int i 0; i n; i) printf(%d , a[i]); }函数声明里写int a[]和写int *a是等价的。也就是说数组作为参数传进来时已经退化成指向首元素的指针了。在函数内部对a做sizeof得到的是指针的大小64位系统下是8字节而不是整个数组的字节数。很多新手在函数里用sizeof(a) / sizeof(a[0])想算数组长度结果算出一个奇怪的值就是这个原因。为什么C要这样设计因为C语言的函数参数传递是值传递如果传整个数组需要把每一个元素都拷贝一份开销很大。干脆退而求其次传首元素的地址函数内部通过地址访问原数组。代价就是丢失了数组长度信息。所以C的约定是传数组时要么额外传一个长度参数要么像字符串那样用结束标记。记住这一点能帮你躲开很多莫名其妙的bug。遍历方式写法特点下标法arr[i]最直观推荐日常使用指针偏移*(arr i)体现下标本质笔试常见指针移动for(parr; parrn; p)指针自身变化灵活但注意别越界3. 指针数组和数组指针——一字之差天壤之别3.1 用运算符优先级拆解 int *p[3] 和 int (*p)[3]这两个东西长得几乎一样含义却完全不同每次讲到这里都要停下来反复强调。int *p[3]; // 指针数组p是数组元素是指针 int (*p)[3]; // 数组指针p是指针指向一个含3个int的数组怎么区分看运算符优先级。[]的优先级比*高所以int *p[3]里p先和[3]结合说明p首先是一个数组数组里有3个元素每个元素的类型是int *。这就是“指针数组”翻译成人话就是一个数组里面装的是地址。而int (*p)[3]里括号强制p先和*结合说明p首先是一个指针它指向的类型是“含3个int元素的数组”。这就是“数组指针”也叫行指针。可以这样记忆指针数组的“数组”是主体指针是它的元素数组指针的“指针”是主体数组是它指向的对象。一个柜子里放纸条跟手里拿着一张写着整排柜子门牌号的纸条完全不是一回事。代码验证一下int x 1, y 2, z 3; int *p[3]; p[0] x; p[1] y; p[2] z; printf(%d\n, *p[1]); // 输出 2 int a[3] {10, 20, 30}; int (*q)[3] a; // q指向整个数组 printf(%d\n, (*q)[1]); // 输出 20(*q)[1]这里必须加括号因为先解引用q拿到a这个数组再用下标访问第二个元素。如果不加括号写成*q[1]q会先和[1]结合变成取第二个“指向数组的指针”结果就全错了。3.2 指针数组的实际用处让一排指针各管一段数据指针数组最常见的应用就是集中管理一组地址。比如命令表、错误消息表、菜单项或者一组不定长的字符串。举一个典型的例子const char *cmd[] { start, stop, pause, quit }; for (int i 0; i 4; i) { if (strcmp(input, cmd[i]) 0) { // 匹配到第i个命令执行对应逻辑 break; } }这里cmd是一个数组里面每个元素都是const char *也就是指向字符串的指针。为什么要用 const因为字符串常量通常存放在只读区通过指针修改它会导致未定义行为用const提前堵住这个坑。对比用二维字符数组char cmd[4][10]指针数组有两个明显优势一是灵活每个字符串长度不受固定列宽限制二是节约空间不会因为最长字符串是8个字节就把所有行都撑到10字节。缺点是这些字符串大多指向只读常量不能原地修改内容如果确实需要修改就得自己拷贝到可写内存里。我这几年看过的代码里指针数组还常用于函数指针表、状态机跳转表等场景。思路都一样把一组“地址”放在一个数组里通过下标或遍历来选择用哪一个。理解了指针数组后面学函数指针数组就轻松很多因为只是把“指向数据的指针”换成“指向函数的指针”。3.3 二维数组与行指针二维数组在内存里并不是“二维”的它就是连续排列的一维内存块。int matrix[3][4]一共12个int在内存里一个挨一个排着。关键在于matrix这个数组名的类型它是int (*)[4]指向一个含4个int的数组也就是指向“一行”。既然是“一行”那么matrix 1就不是简单加4字节而是加4 * sizeof(int)也就是16字节直接跳过了整整一行。这个“一行”的概念就是行指针的由来。访问某个元素有三种等价方式matrix[i][j]*(*(matrix i) j)*(matrix[i] j)展开讲第二种*(matrix i)先取第i行因为matrix是行指针加i跳了i行解引用后得到的是这一行数组的首地址类型是int *。然后再加j就指向这一行的第j个元素最后再解引用取数值。这就是matrix[i][j]在编译器层面的真实展开过程。写函数处理二维数组时有一个硬性规定形参必须指定列数。比如void print_matrix(int m[][4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) printf(%d , m[i][j]); printf(\n); } }你不能写int m[][]因为编译器计算m[i][j]的地址时需要知道每行有几个元素位置等于m i * 列数 j。不告诉它列数它就算不出偏移。这也顺带解释了为什么二维数组传参时第一维行数可以省略第二维列数绝对不能省。4. 字符串与指针——C语言里最常用的组合4.1 字符串常量、字符数组、字符指针的区别C语言没有真正的字符串类型。所谓字符串本质上就是“以\0结尾的字符数组”。这句话值不少分因为围绕字符串和指针的大部分错误都是因为没理解这个本质。看三种声明方式char s1[] hello; // 字符数组内容可修改 char *s2 hello; // 字符指针指向字符串常量 char s3[] {h, e, l, l, o, \0}; // 和s1本质相同s1会在栈上分配6个字节把hello和结尾的\0拷进来之后s1[0] H;是合法的你改的是自己阵地的拷贝。s2则是指针它指向编译器放在只读区的一个字符串常量如果你写*s2 H;那是未定义行为在很多平台上直接段错误。这个区别用个类比字符串常量像墙上的标语你看得见但涂改不了字符数组像自己抄下来的纸条想怎么改怎么改。所以说char *s hello这种写法本身没问题但最好写成const char *s hello明确告诉别人“这是个只读字符串别动它”。这个习惯能避免大量莫名其妙的崩溃。还有一个日常容易踩的坑char s[10]; scanf(%s, s);和char *s; scanf(%s, s);完全不同。前者s有实际分配的10字节空间scanf把输入写进这块内存后者s只是一个未初始化的指针scanf想往里写但s指向的是未知地址直接崩溃。所以遇到scanf或fgets这类往缓冲区里写入的函数第一件事就是确认缓冲区真的存在并且够大。4.2 指针数组存放多个字符串的实际应用把指针数组和字符串结合是C语言里处理“一组文本”最优雅的方式const char *weeks[] { Sun, Mon, Tue, Wed, Thu, Fri, Sat }; for (int i 0; i 7; i) printf(weekday %d: %s\n, i, weeks[i]);这里的weeks是一个指针数组每个元素指向一个字符串常量。想按索引取字符串一行搞定。工程里常见用途包括枚举值转字符串、错误码转错误描述、命令行参数表、状态机状态名等。这种“一个数组搞定一组固定文本”的写法比写一堆if else或者switch清晰得多。如果这组字符串需要在运行时修改就不能再用字符串常量可以考虑二维字符数组或动态分配。二维字符数组char names[3][20]的好处是每行都是可写内存坏处是必须给每一行预留固定空间如果有的名字长有的名字短会造成浪费。动态分配更灵活但用完必须记得释放对初学者来说反而增加负担。我的建议是能只用常量表的场景优先用指针数组需要改内容再考虑其它方案。另外注意strcmp(weeks[i], input)这类比较时weeks[i]的类型是const char *它传给strcmp正好符合函数期望。这些联动其实都是指针的功劳字符串函数平时接收的“字符串”本质就是char *你给它的可以是一个数组名也可以是一个字符指针变量也可以是一个指针数组的元素。4.3 常见字符串函数的指针实现思路自己写一遍strlen和strcpy是理解指针运算非常有效的练习。先看strlenint my_strlen(const char *s) { int n 0; while (*s) n; return n; }这里有个容易搞错的优先级问题*s实际上是*(s)。因为后缀的优先级比解引用*高所以s先取出旧值参与解引用然后s再自增指向下一个字符。循环里判断的是*s是否为\0遇到字符串结尾就停止。整个过程就是“挨个看字符数个数直到看见结束符”。再看strcpychar *my_strcpy(char *dst, const char *src) { char *ret dst; while (*dst *src) ; return ret; }这个循环很多人第一眼看不懂。关键在于赋值表达式的值就是被赋的那个字符当*src是\0时把\0赋给*dst整个表达式的值是0循环结束。也就是说它不仅拷贝了所有字符连结尾的\0也一并拷贝了然后恰好停在正确的位置。为什么要返回ret而不是dst因为循环结束时dst已经指到字符串末尾去了返回原来的dst才有意义。标准库strcpy返回的是目标字符串的首地址这样支持链式调用比如strcpy(strcpy(a, b), c)。这些小细节面试官特别爱问。自己动手实现一遍比背十遍“指针就是地址”有用得多。5. 指针使用中我踩过的坑——常见错误与排查经验5.1 野指针与未初始化指针int *p; *p 100;这段代码是典型的定义未初始化就使用。局部变量p的值是不确定的它可能恰好指向某个合法内存也可能指向完全随机的地址。*p 100就是在往一个未知地址写数据轻则改写无关数据重则直接把程序搞崩。这种问题还特别难复现因为每次运行时p的初始值可能都不同。所谓“野指针”还包括几类指针声明了但没赋值动态内存释放后没有置NULL继续使用形成 use-after-free返回指向局部变量的指针函数返回后地址失效指针越界后指向了未知区域。实操建议定义指针时就给初值不知道指向哪里就写NULL使用前检查if (p ! NULL)用malloc分配内存后先检查返回值再使用别急着解引用free之后立刻置NULL。这套习惯看着琐碎能帮你省掉大量调试时间。5.2 数组越界不报错不等于没事C语言不像Java或Python它根本不检查数组边界。int arr[5]; arr[5] 100;编译时不报错运行时也不一定崩。因为arr后面的内存可能正好是可写的于是100被写进了相邻变量的地盘。你可能会发现某个无关变量突然变了程序行为变得不可理喻但完全找不到是谁改的。这种问题在栈上特别明显。假设你定义了int arr[5], b 3;然后写arr[5] 100;这种越界写很可能把b的内存覆盖掉打印b时发现它变成了100。更麻烦的是如果越界发生在堆上可能会破坏malloc内部的管理结构导致后面的内存操作全部失控。排查办法第一循环边界写对凡是i n的地方别写成i n第二编译加-Wall -Wextra -fsanitizeaddressaddress sanitizer能精确定位越界发生的位置和访问的地址第三善用调试器观察变量地址和值的变化。我见过不少同学被这种问题折磨一整天最后发现就是循环写成了所以边界这种细节真的值得一遍遍检查。5.3 函数里返回局部数组地址int *bad() { int a[10] {0}; return a; }a是函数的局部数组存的是栈上的空间。函数返回时栈帧被弹出这块空间理论上就“还回去”了。虽然地址值还在但你拿着一个已经失效的地址到处用就是悬空指针。悬空指针比野指针更隐蔽它看起来有值编译器也不报错关键时候才翻车。更微妙的是这种代码有时能“碰巧工作”。因为栈空间还没被其他函数覆盖时读取旧值或许还是对的一旦有别的函数调用把栈帧覆盖了数据就变成垃圾。这种“时好时坏”的bug最难查。正确做法一般是三个方向由调用方提供缓冲区void fill(int *buf, int n)函数往buf里写调用方负责分配空间使用malloc在堆上分配函数返回后由调用者负责free使用static局部变量让a的生命周期延续到程序结束但多线程环境下要谨慎使用。工程里更常见的是第一种调用方掌控内存生命周期函数只管往里填数据这样最清晰。5.4 排查指针问题的实用调试技巧分享几个我个人平时排查指针相关bug的习惯都是实战里摸出来的。第一凡是指针问题先把关键地址全部打印出来。用%p格式化输出同时打印变量的地址和值printf(a的地址: %p, a的值: %d\n, (void *)a, a); printf(p的地址: %p, p的值: %p, *p的值: %d\n, (void *)p, (void *)p, *p);一旦某个值被神秘改写先把相邻变量的地址都列出来看内存布局往往一眼就能看出是不是越界写到了邻居头上。第二用gdb的watch命令盯住一个变量。比如怀疑b被改就在gdb里watch b程序一旦写入b就会自动停下来你就能看到是哪个函数、哪行代码下的手。这比到处加打印效率高得多。第三在Linux上调试时推荐用AddressSanitizer编译程序gcc -g -fsanitizeaddress -Wall -Wextra test.c -o test ./test这个工具能非常精准地报告越界、释放后再用、栈缓冲区溢出等问题给出详细的函数调用栈。用它排查一次你会对“指针写错了地方”这件事产生极强的直觉。第四给自己留一手调试宏也很好用。在代码里预留类似这样的宏出问题时打开日志复现概率会高很多#ifdef DEBUG_PTR #define PLOG(...) fprintf(stderr, __VA_ARGS__) #else #define PLOG(...) #endif每次进入关键函数先打印指针入参比对调用前后的值很多“灵异事件”其实就是指针在某处被改写日志一比对原因立刻浮出水面。5.5 拿来检验自己的三道小练习最后给你三道题是我认为指针数组这一块最值得亲手验一遍的练习也是我自己当初练完才真正觉得“总算不懵了”的题目。建议不要只看答案一定要自己写完再编译运行。第一写一段代码用int (*p)[4]指针遍历一个int matrix[3][4]按行输出所有元素。这个题能检验你是否理解行指针的步长p1到底跳过了多少字节。第二用指针实现一个字符串逆序函数要求不能使用下标访问只允许用指针和解引用操作。比如输入hello输出olleh。这个题能训练你同时操作前后两个指针想清楚结尾\0怎么处理。第三定义一个const char *cmds[]指针数组里面放至少5条命令字符串。写一个run_cmd(const char *input)函数遍历cmds用strcmp找到匹配的命令并打印“执行某命令”。这个练习贴近真实工程里的命令表实现做完你自然明白为什么指针数组比大段if else优雅。三道题做完你对“指针就是地址”、“数组名代表首元素地址”、“指针数组里存的是地址”这三句话才算真正有了肌肉记忆。剩下的就是在实际项目里多写多调慢慢积累感觉了。
返回列表