ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言指针进阶:const修饰与字符串函数深度解析

C语言指针进阶:const修饰与字符串函数深度解析 很多朋友学到指针这一章就开始迷糊尤其是const一出现代码直接看不懂。再加上字符串处理函数一堆char *、const char *在眼前晃写着写着就不知道谁是谁了。这篇内容就是围绕 C 语言指针的进阶用法来写的const关键字到底在限制什么字符串处理函数为什么偏偏长这样以及把这些函数自己实现一遍之后指针和const这两件事才算真正焊死在脑子里。这篇文章适合正在学指针的初学者、准备考试或刷题的学生也适合已经工作但偶尔被指针坑一把的开发者。我会尽量用说人话的方式把这些概念拆开配代码、配排查思路让你看完能直接上手用。1. 指针糊里糊涂先分清指向谁和改谁的值先说一个最常见的困惑。很多同学学指针的时候只记住了指针就是地址但一到具体操作就乱本质原因是没把指针的两个层面分开指针变量本身和指针指向的对象。1.1 指针变量的两个基本操作假设有一行代码int a 10; int *p a;这里p是一个指针变量它存放的是a的地址。我们可以对指针做的事情有两类改变p本身让p指向别处比如p b;改变p指向的对象通过*p去操作a比如*p 20;这两件事在语法上长得差不多但本质完全不同。const关键字要做的事情就是分别限制这两类操作。1.2 为什么 const 会让代码变拧巴C 语言的const逻辑其实很直白它修饰谁谁就不能被改变。这句话说起来简单但一旦放到指针上修饰谁就变得不那么容易看出来了因为const可以出现在好几个位置。比如这三行代码长得非常像含义却完全不同const int *p1; int * const p2; const int * const p3;想要一眼看穿它们关键是一个读法从右往左读看 const 靠近谁。const int *p1里const修饰的是int也就是说p1指向的是一个不可修改的 int所以*p1 xxx会报错但p1 other没问题。int * const p2里const修饰的是指针变量p2本身所以p2的指向不能变但*p2 xxx是可以的。最后一种就是两者都锁死。这一个读法如果能熟练用起来后面所有的 const 指针代码都不会再错。我在教学里经常打的一个比方是指针变量像一个门牌号指向的对象是房子。不能改 const 修饰的东西房子被修葺成什么样不能动和门牌号不能换是两码事。2. const 修饰指针的三种写法一条规则秒懂这一节把三种写法完全拆开配合代码和场景让大家彻底弄清楚每一种的用途。这也是后面理解字符串处理函数签名的地基。2.1 const int *p指向常量的指针pointer to constint a 10; int b 20; const int *p a; p b; // 正确p 的指向可以改变 *p 30; // 错误不能通过 p 修改它所指向的对象这种写法在工程中最常见比如读取一个外部数据包、遍历一段只读配置你只想看数据不想改数据。它的好处在函数传参时会放大——你把地址传进函数函数内部只能读不能写传参的人放心。2.2 int * const p常量指针const pointerint a 10; int b 20; int * const p a; p b; // 错误p 的指向不能改变 *p 30; // 正确可以通过 p 修改它所指向的对象这种写法有点像绑定。p从出生到销毁都死盯着同一个地址不能再指向别处。常见于固定寄存器的映射地址、固定的硬件缓冲区基地址。嵌入式开发里这一招用得特别多某个寄存器地址不想让变量在运行中偷偷换指向就在定义时加const。2.3 const int * const p双重锁定int a 10; const int * const p a; p b; // 错误 *p 30; // 错误这种就是门牌号不能换房子也不能装修一般用在不能动的全局配置表、常量池入口这类场景。实际业务代码里用得不算多但笔试面试特别喜欢拿出来考所以必须能把另外两种独立理解之后再合起来。2.4 等价的写法int const *p 到底怎么读还有一个高频疑问const int *p和int const *p是不是一样的答案是完全等价。const修饰的是int本身放在int前面还是后面不改变含义。判断方法依然是读const 修饰谁这两行里const都修饰int所以都是指向常量的指针。我建议不要死记等价关系而是记住判断逻辑const 离谁近就修饰谁。这样int const *p看起来 const 紧挨着 int自然是指向 const int 的指针int * const p里 const 紧挨着 p修饰的是指针变量。写法能否改变指向能否修改指向对象常见场景const int *p能不能函数参数只读数据、遍历只读数组int * const p不能能固定地址映射、硬件寄存器访问const int * const p不能不能全局配置表入口、常量对象地址3. 函数参数里的 const是给别人看的承诺理解了三种写法之后再去看标准库的字符串函数你会发现一个规律几乎所有只读字符串参数的函数形参都写成了const char *而不是char *。这不是随手写的而是有讲究的。3.1 字符串处理函数为什么偏爱 const char *看几个标准函数的原型size_t strlen(const char *s); char *strcpy(char *dest, const char *src); char *strcat(char *dest, const char *src); int strcmp(const char *s1, const char *s2); char *strchr(const char *s, int c);strlen只是数长度不会改字符串所以s是const char *。strcpy要往dest里写内容所以dest是char *但src只需要被读取所以src是const char *。看名字就能猜意图哪个参数要被修改哪个就是普通指针哪个参数只读哪个就带 const。这个设计至少带来三个好处对调用者来说看到const就知道这个参数传进去后不会被改动心里有底。对实现者来说编译器帮忙盯着函数体里不小心写了*src x这种代码直接编译不过。对代码阅读者来说函数签名本身就是文档扫一眼就知道谁是被写入方、谁是只读方。3.2 类型兼容的规则不是你想转就能转这里有一个很多初学者忽略的规则char *可以赋给const char *但const char *不能随意赋给char *。char str[] hello; const char *p str; // 正确可变对象的地址可以交给只读指针 char *q p; // 警告/错误试图把只读权限升级成可写权限为什么因为如果允许const char *赋给char *那后面通过char *就能修改原本应该是只读的数据const 就形同虚设了。C 语言在这个地方给编译器的指令是很坚决的权限只能收窄不能放宽。在函数调用里这个规则经常带来困惑你写了一个函数void foo(char *s)想传一个字符串字面量foo(hello)给它编译器可能报警告。原因就是字符串字面量在 C 中类型是char[]但存储在只读区域修改它属于未定义行为如果函数不修改参数声明时就应该用const char *。这也是大家在写自己的函数时只要不修改入参就应该加 const的原因。3.3 链式调用的设计返回值为什么是 char *strcpy的返回值是char *也就是目标字符串的首地址。有人问为什么要返回dest直接传址不就能让外部拿到结果了吗这其实是为了支持链式调用比如size_t len strlen(strcpy(buf, hello));strcpy返回了buf的地址外层strlen直接复用这个返回值代码一行就完成了拷贝并求长度两件事。这种设计理念在 C 标准库里很常见返回值往往不是计算结果而是方便继续调用的参数。4. 字符串处理函数逐个拆开看原理和易错点这一节把最常用的四个字符串函数从原理层面拆开讲清楚它们内部在做什么、返回值到底是什么以及最经典的坑在哪里。4.1 strlen数到 \0 为止strlen的实现思路极其简单从传入地址开始逐个字符看只要不是\0就继续往后走。它的返回值类型是size_t也就是无符号整数。const char *s hello; size_t len strlen(s); // len 5一个非常容易犯的错误是把 size_t 和 int 混用导致比较结果意外。因为size_t是无符号的如果直接写strlen(s) - 10 0当字符串长度小于 10 时strlen(s) - 10会变成一个巨大的无符号数条件依然成立。写代码时如果要在长度上做减法或判断尽量先转成int或者用int类型接结果避免无符号数带来的负数变正数问题。另一个坑是字符数组没有 \0 就调用 strlen。比如char buf[4] {a, b, c, d}; size_t len strlen(buf); // 危险没有 \0strlen 会一直读过去这在栈上可能“幸运”地遇到下一个字节是 0也可能读到很远才遇到 0结果就是越界访问程序行为没法预期。用字符数组时一定要确认末尾有\0或者手动补一个。4.2 strcpy拷贝字符串到目标缓冲区strcpy的完整签名是char *strcpy(char *dest, const char *src)。它从src逐个复制字符到dest包括末尾的\0然后返回dest。这个函数最大的坑是不检查目标缓冲区够不够大。经典翻车现场char buf[5]; strcpy(buf, hello world);buf只有 5 个字节但hello world加上结尾的\0需要 12 个字节。strcpy会毫不犹豫地一路写过去把栈上相邻的内存全部覆盖。这种问题不会总在第一时间崩溃而往往表现为程序逻辑莫名其妙不对比如某个变量值变了、某个函数返回地址坏了。调试这类问题非常头疼因为在时间上写入越界和真正出问题可能隔得很远。这也是很多人转向strncpy的原因但strncpy也有自己的坑如果源字符串长度大于 n它不会自动补\0。换句话说strncpy(dest, src, n)拷贝了 n 个字符后dest[n]处并不保证是 0。安全做法是strncpy(dest, src, sizeof(dest) - 1); dest[sizeof(dest) - 1] \0;这里最后一步是必须的不是可有可无。类似的思路也可以用在strncat上但strncat的行为比strncpy稍微好一点它会自动在追加结束后补\0。4.3 strcat追加字符串到末尾strcat(dest, src)做的事情是先找到dest的\0位置然后从那里开始把src的内容复制过去保留最后的\0。它也完全不检查dest剩余空间所以同样是缓冲区溢出高发区。有一个隐蔽的错误是char *p hello; strcat(p, world);p指向字符串字面量字面量存储在只读区域写入时轻则崩溃、重则破坏数据。想让strcat正常工作dest必须是一块可写的、并且有足够空间的缓冲区。写成char buf[32] hello;然后strcat(buf, world)才对。4.4 strcmp比较的是字典序而不是指针地址strcmp(s1, s2)逐字符比较两个字符串一旦遇到不同字符就返回这两个字符的差值如果一直比到\0都相同返回 0。相等的时候返回 0这个设计坑了很多人因为如果把strcmp的返回值当成布尔值用if (strcmp(s1, s2))判断的是不相等而不是相等。正确写法是if (strcmp(s1, s2) 0)。另外strcmp比较的是字符串内容不是指针本身。如果写if (s1 s2)那比较的是两个指针变量的值也就是地址。只有当两个指针确实指向同一个字符串时才会相等哪怕两个字符串内容一模一样只要存储位置不同结果也是不相等。一个容易被忽略的实现细节是strcmp内部通常用unsigned char来做差值计算而不是直接return *s1 - *s2。因为char在有的平台上有符号直接相减可能因为符号扩展得到奇怪的结果。自己实现时最好也统一转成unsigned char。函数原型要点返回值主要风险strlensize_t strlen(const char *)字符串长度无\0时越界读取strcpychar *strcpy(char *, const char *)dest 首地址目标缓冲区过小strcatchar *strcat(char *, const char *)dest 首地址目标缓冲区过小、dest 不可写strcmpint strcmp(const char *, const char *)0 相等非 0 为差值返回值不能直接当布尔值用5. 自己实现一遍字符串函数把 const 和指针彻底焊死在脑子里看再多原型不如自己动手实现一次。下面我给出四个标准函数的简化版本每行都有注释和讲解。这些实现不是为了替换标准库而是为了让你彻底搞明白内部机制。5.1 实现 strlen指针相减的经典用法size_t my_strlen(const char *s) { const char *p s; while (*p) { p; } return p - s; }这里有几个值得注意的地方。s声明为const char *因为strlen只需要读取字符串不能修改它。我在函数内部又定义了一个const char *p去遍历而不是直接用s移动原因也很简单后面return p - s需要保留起始地址。如果直接用s移动起始地址就丢了。指针相减p - s得到的是两个地址之间相差的元素个数正好就是字符串的长度。整个过程不访问任何长度变量也不数数字符数量只是通过移动指针再相减来实现这就是指针算术的典型应用。5.2 实现 strcpy赋值表达式加优先级char *my_strcpy(char *dest, const char *src) { char *p dest; while ((*p *src)) ; return dest; }这段代码最精华的部分是*p *src。拆解一下*src等价于*(src)先取出src当前指向的字符然后src向后移动*p同理先取出p当前指向的位置然后p向后移动。整个表达式的赋值行为是把src当前字符赋给p当前指向的位置这个赋值的表达式值就是被赋的字符。当字符是\0时表达式值为 0循环结束。因此这个while循环把所有字符包括最后的\0全部复制过去循环体里面什么都不用做只需要一个空语句。我自己在实际教学中发现很多小伙伴第一次看到while后面直接加分号会觉得奇怪其实那只是空语句配合循环条件来完成所有事情。如果写成while (*src) { *dest *src; dest; src; } *dest \0;也行但少了那种一行搞定拷贝加移动加判空的简洁感。两种写法我建议都亲手敲一遍尤其是第一种能极大提升对指针自增运算和赋值表达式的理解。5.3 实现 strcat找到尾巴再复制char *my_strcat(char *dest, const char *src) { char *p dest; while (*p) { p; } while ((*p *src)) ; return dest; }这个函数其实就是strcpy 的变体先用一个循环把p移到dest的\0位置接着复用复制逻辑。注意const char *src的声明保证了src指向的内容在这个函数里不会被改动这是标准库契约的一部分。自己写函数时只要你不修改传入指针的内容就一定要声明成 const这个习惯能帮你减少很多不必要的 bug。5.4 实现 strcmp返回值到底是啥int my_strcmp(const char *s1, const char *s2) { while (*s1 (*s1 *s2)) { s1; s2; } return (unsigned char)*s1 - (unsigned char)*s2; }循环条件是*s1非零并且两个字符相等。一旦遇到不相等或者s1走到\0就退出循环。返回值是(unsigned char)*s1 - (unsigned char)*s2的差。有人问为什么要转unsigned char上面也说过char的符号性取决于编译器实现如果直接用char做减法可能在比较\200这类高位为 1 的字符时出现符号扩展导致结果和字典序不一致。转成unsigned char后所有字符都按 0 到 255 来比较结果是确定且符合预期的。这个实现也解释了为什么strcmp的返回值不是一个固定值比如 -1 或 1而是差值。因为两个字符串的差异字符各不相同差值大小也可能不一样。所以判断相等只能用 0而不能依赖非零就是相等之类的错误直觉。5.5 亲手实现一遍的收获把这四个函数都写一遍之后你会明显感觉到以下几点const加在哪里完全取决于函数对参数的需求不是随便加的。指针的移动、取值、自增这三个动作在*p里被完美组装了。所谓字符串函数其实全是围绕\0在做文章找它、跳过它、复制它、比较它。这个练习做完再看任何标准库字符串函数的声明都能直接读懂作者的意图。6. 调试器里翻车的那些事指针和字符串的常见故障排查理论知识再扎实实际写代码时该踩的坑一个也躲不掉。这里分享几个我平时调试和教学中反复遇到的故障场景以及完整的排查思路。这些都是可以在自己的代码里主动复现一遍的踩过之后印象会非常深。6.1 通过指针修改字符串字面量导致崩溃先看一段很典型的错误代码char *s hello; s[0] H;这段代码在某些编译器上能跑在某些上直接段错误。原因在于字符串字面量hello存储在只读数据区试图写入它属于未定义行为。我自己第一次遇到时特别困惑明明能编译运行也没立刻报错但后续字符串操作全乱套了。排查思路很简单崩溃时用调试器看s指向的地址段或者直接查代码里有没有把const char *赋给char *。修复也很简单把声明改成const char *s hello;一旦你写s[0] H编译器直接报错就不会把问题带到运行时。或者如果你确实需要修改字符串应该定义成数组char s[] hello;6.2 缓冲区溢出strcpy 写爆目标数组int下面这段代码几乎每个学 C 的人都写过char name[8]; char input[100]; scanf(%s, input); // 假设用户真的输入了很长的字符串 strcpy(name, input);name只有 8 个字节input 却可以长得多。strcpy复制时不会停下来直到遇到input的\0。它会一路写把栈上name之后的内存全部覆盖。这类问题的排查偶尔会用看哪个变量莫名其妙变了来判断但最直接的方法还是在调用 strcpy 之前和之后分别打印关键变量的地址和值看有没有被改写。避免这种问题的长期方案一是使用有界版本strncpy并手动保证\0结尾二是先计算src长度再判断是否超过目标缓冲区。无论哪种核心思想都是目标缓冲区的容量是红线谁也不能越。6.3char *和const char *混用导致的编译告警另一种常见情况是函数调用时参数类型不匹配。比如你写了一个函数用来修改字符串void process(char *s);然后调用process(hello)。编译器会提示把字符串字面量转成char *是非法的至少是危险的。原因是字面量本身不可写传给一个准备修改它的函数等于把一把只读钥匙交到一个想写保险箱的人手里。正确的做法是如果函数真的不修改参数内容就把形参声明为const char *如果函数必须修改调用方必须传入可写的数组而不是字面量。这个习惯一旦养成你的函数签名会变得非常有表达力别人看一遍就知道哪些参数是输入、哪些参数是输出。6.4 函数返回局部数组的地址导致悬垂指针再看一个经典错误char *get_name(void) { char buf[32]; sprintf(buf, hello); return buf; }buf是栈上的局部数组函数返回后这块内存就不再有效。调用者拿到get_name()的返回值看起来是个地址但地址对应的内容随时可能被下一个函数调用的栈帧覆盖。这属于悬垂指针的典型场景。排查办法是在调试器里看返回值指向的内存往往能看到已经变化的字符串或者乱码。修复方式有三种在函数内使用static数组不过要注意线程安全和重复调用覆盖问题、让调用者传入缓冲区、或者使用动态内存分配。在嵌入式场景里我通常倾向于让调用者传入缓冲区这个方式最可控也不引入额外的内存管理负担。6.5 运算符优先级搞混*p 与 (*p) 的差距最后提一个让初学者非常头疼的细节。*p在 C 语言中会被解释成*(p)也就是先取出当前指向的值然后让指针加一。(*p)是完全不同的意思取出当前指向的值然后把那个值加一。两者差之毫厘谬以千里。我在排查代码时经常看到这样的 bug写错了之后数组元素的值显示不对指针位置也乱了。遇到这种情况先搞清楚到底加在指针上还是加在对象上。一个快速检查方法是在调试器里分别看p和*p的值变化而不是只在脑子里推演。纸上推演容易一错再错看实际值变化是最直观的。C 语言指针和字符串的这道坎说到底就是两个核心问题const 修饰谁指针指向哪里。把这两个问题在每一行代码里都问一遍错误率会直线下降。最后再分享一个小习惯写函数时凡是只读参数就加const先把这个函数会不会改数据这件事用类型系统固定下来剩下的事编译器会帮你兜底。
返回列表