ARTICLE DETAIL

资讯详情

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

C语言指针入门:字符传送核心原理与字符串操作实战

C语言指针入门:字符传送核心原理与字符串操作实战 很多人学《C语言程序设计》第四版何钦铭、颜晖版的时候对第八章指针最深的印象就是一个字绕。尤其“字符传送”这一节教材里讲的是用指针去处理字符串复制、传递从char *p到p[i]再到*p代码很短概念很碎但总有一种“能看懂不会用”的悬浮感。这篇文章我就盯着这个点讲透字符传送到底传送的是什么、指针在其中扮演什么角色、怎么写代码才能既安全又高效。适合正在啃指针的初学者也适合准备期末复习、考研刷题的朋友。1. 字符传送这件事先搞清楚它在传送什么1.1 字符串的底层本质连续的字符数组加一个结束标记C语言里没有原生的字符串类型所谓“字符串”本质上就是一段连续内存中的char数据并且以\0作为结束标志。hello并不是一个值而是编译器在某个内存区域连续存放了h/e/l/l/o/\0六个字节然后把这个首字节地址交给你。理解了这一点“字符传送”这个标题就不神秘了传送的不是字符本身而是字符所在内存的起始地址。你把一个地址交给函数函数就能顺着这个地址往后读直到遇到\0为止。教材第八章反复强调的“字符指针”就是用来装这个地址的变量。我见过不少初学者写代码时把char *p当成“字符串变量”来理解比如char *p hello;然后试图p[0] H;结果运行直接崩溃。原因后面会细说但核心要先建立起来指针存地址不是存字符串。字符串常量本身存放在只读区域指针只是“指着”它想去改写它自然要出事。1.2 为什么传送字符要用指针而不是直接传数组这里涉及C语言最重要的一个机制函数参数传递默认是值传递。如果你把整个字符数组传给函数编译器不会真的把数组内容整体复制一份而是把数组名“退化”成指向第一个元素的指针传过去。所以C语言里写void func(char s[])和void func(char *s)在编译层面基本等价数组写法只是给人看的语法糖。这个设计看似偷懒实际非常聪明。数组可能几千字节传值复制一遍既浪费栈空间又拖慢速度传地址只花4字节32位环境或8字节64位环境函数内部还能直接修改调用者那边的数据。这就是字符传送的底层逻辑传地址省空间改原值。1.3 字符数组与字符指针的“形似神不似”当年我用一个对比表才彻底把这两个东西理清楚也建议你对照着记对比项char s[] hello;char *p hello;存放位置栈区可读可写静态存储区/只读区内容通常不可修改数据可改可以s[0] H;合法不能p[0] H;运行报错sizeof结果6含\04或8指针本身大小销毁时机函数结束自动失效字符串常量程序运行期间一直存在典型用法需要拼装、修改内容指向固定文本、函数返回、遍历操作这个区别不是钻牛角尖而是实际问题我见过有人用char *p接收用户输入结果scanf(%s, p)直接把程序搞崩溃因为指针压根没有初始化也没指向任何可用内存。字符传送类题目里凡是要对字符串内容做修改第一反应就应该是字符数组而不是字符指针指向字符串常量。2. 字符传送的三种写法从最直观到最指针化2.1 数组下标法最接近人类思维的方式假设要实现一个字符串复制函数把src指向的字符串复制到dest指向的数组中。很多人第一反应是写循环void my_strcpy(char dest[], char src[]) { int i 0; while (src[i] ! \0) { dest[i] src[i]; i; } dest[i] \0; }这段代码完全没毛病逻辑清晰从src逐个取字符放进dest最后手动补结束符。数组下标src[i]本质上是*(src i)的简写这是教材一开始就会铺垫的等价关系。我建议初学者先把这种写法吃透因为它的每一步都在“明牌操作”不容易出错。但这版代码有个问题复制完i已经停在\0的位置如果后面还想用得自己记录长度。这也是为什么许多库函数的实现会用另一种完全不同的思路。2.2 指针偏移法同一件事的另一种视角把上面的循环改成指针方式核心在于理解p和i是对同一块内存的两种描述方式void my_strcpy(char *dest, char *src) { char *d dest; char *s src; while (*s ! \0) { *d *s; d; s; } *d \0; }这里d和s的含义是让指针往前走一个char的宽度。不要小看这个“走一步”C语言指针加减的本质是按指向类型的大小做偏移char是1字节所以p1地址加1如果换成int *p1地址就加4。很多“指针谜题”考的就是这个底层规则实际写代码时也经常会因为类型搞错而访问到错误的内存。指针法比下标法好的地方在于循环退出后src和dest的“当前位置”信息天然保留在指针里不需要额外变量。缺点是阅读门槛稍高如果对指针不熟写起来容易手滑把*s写在赋值符号左边。2.3 指针自增法*p的终极形态教材里、网络题解里最常见的字符串复制代码长这样void my_strcpy(char *dest, char *src) { while (*dest *src) ; }我第一次看到这代码时人傻了循环体居然是空的表达式里还把赋值和自增塞一起。拆解一下你就明白了。*dest *src这一句其实是三步连续动作取出src当前指向的字符把它赋给dest当前指向的位置两个指针各自向后移动一个字符。这个表达式整体的值就是赋进去的那个字符。当它是\0时作为循环条件的值变成0循环结束。因为\0也顺带复制过去了所以不需要再手动补结束符。这就是“字符传送”里最经典的紧凑写法能体现对指针运算的熟练度但我必须说一句看得懂和能自己写是两码事。考试或面试中能用出来加分但平时项目代码我更推荐写清晰版本维护的人会感谢你。2.4 三种写法的对比与选择建议写法可读性执行效率推荐场景数组下标法高编译器优化后和指针基本相同初学者学习、维护性要求高的代码指针偏移法中通常略优于下标需要控制指针位置的场景自增法低最高追求简洁、刷题、面试炫技说起来可能颠覆你的印象现代编译器开启优化后下标法和指针法的机器码往往一模一样根本不存在“指针比数组快”这种古老传说了。所以写代码时优先保证思路清晰、不出边界错误性能差异远没有教科书暗示的那么大。3. 手写字符串操作函数把指针玩明白的关键练习3.1 字符串逆序的指针实现两端夹逼法逆序是很经典的字符传送练习题目通常长这样“把abc def变成fed cba”PTA上“字符串逆序”那题就是这么引出来的。用指针做逆序思路是设两个指针一左一右向中间交换void reverse_str(char *str) { if (str NULL) return; char *left str; char *right str; while (*right ! \0) right; // 让 right 指向最后一个字符 right--; // 回退到最后一个有效字符 while (left right) { char temp *left; *left *right; *right temp; left; right--; } }这是一个非常典型的“指针相遇”模型也是后面学链表反转、双指针算法的基础。写这个函数时容易踩的坑是right的定位如果直接让right停在\0上就开始交换会把\0交换到开头字符串直接报废。所以一定记得right--回退一步。另一处细节是循环条件写left right而非left ! right因为字符个数为偶数时两个指针会“擦肩而过”用!会导致越界。3.2 字符串连接与查找指针也能“在哪里跌倒就在哪里爬起来”再实现一个简单的字符串连接my_strcat。核心思想是先让dest指针跑到末尾的\0处然后从那里开始不断复制src的内容最后补一个结束符void my_strcat(char *dest, const char *src) { while (*dest ! \0) dest; // 找到 dest 结束位置 while ((*dest *src)) ; }这个例子能帮你理解“指针不仅仅能从头开始遍历还能在运算中改变指向位置”。连接操作本质上就是“定位到目标串尾部然后启动传送”。注意我写的是const char *src表示这个函数承诺不改动源字符串。这个习惯非常重要别人一看你的函数签名就知道哪些数据会被改哪些只是只读。3.3 函数返回指针的坑局部数组不能用字符传送里还有一类问题写一个函数返回字符串的地址。初学者最容易踩的经典雷区是这样的char *get_string(void) { char buf[20] hello; return buf; // 大错特错buf 是局部数组函数结束内存就被回收 }buf是在栈上分配的函数一旦返回这块内存在语义上已经不属于你了虽然地址数值还在但内容随时可能被其他函数覆盖。正确的做法有三种用调用方提供的缓冲区把结果写进参数使用static局部数组延长生命周期但线程不安全用malloc动态分配并在外面free。我个人最推荐第一种因为不涉及内存管理最不容易泄漏。这里额外提醒一句永远不要返回指向局部数组的指针这是C语言考试和面试的“钉子户考点”。如果你在写代码时遇到“居然跑出了正确结果”的情况那不是你运气好而是栈内存还没来得及被覆盖换一个编译器或操作系统可能立刻崩。4. 指针使用的高危雷区这些坑我全踩过4.1 未初始化指针与野指针崩溃的第一大元凶char *p; scanf(%s, p); // 错误p 没初始化不知道指向哪里这是字符传送题目里最经典的崩溃现场。不是scanf本身有问题而是p这个指针变量创建时里面的值是个随机垃圾你让输入的数据写到一个随机地址上不崩才怪。解决手段很朴素要么用字符数组接收输入要么malloc一块空间再传指针。我还遇到过更隐蔽的情况函数里接收指针参数后没有判空直接访问。调用方传进来一个NULL函数直接段错误。所以只要是处理字符指针第一行习惯性写if (str NULL) return;成本极低收益极大。尤其是在嵌入式单片机这类环境里RAM资源紧张静态分配的内存也不一定都在合理位置判空能帮你省掉无数排查崩溃的时间。4.2 字符串常量的只读属性别名“写保护”前面举过char *p hello; p[0] H;的例子。这里再补充一个真实场景很多人写字符串查找、替换程序时直接用字符串常量做测试替换函数一跑就崩。原因还是那个字符串常量放在只读存储区硬件上就不允许写。你需要先把它复制进自己的数组再做修改。我见过一位学弟写的字符串转换程序逻辑全对就是初始数据用了char *s AbC然后试图把小写改成大写程序秒挂。他一度怀疑是编译器问题。其实只要把声明改成char s[] AbC;问题就从根源消失。这个教训总结成一句话要修改字符串内容先问自己这块内存是不是只读的。4.3 缓冲区溢出strcpy的隐患远超你的想象char dest[10]; strcpy(dest, this is a very long string);标准库的strcpy没有任何边界检查它会一直复制直到遇到源字符串的\0。目标空间不够时数据继续往栈上写直接把相邻变量、返回地址全部破坏。轻则程序行为诡异重则被利用做安全攻击。实际项目里更推荐strncpy但它有个特性同样坑人如果源字符串超过nstrncpy不会自动补\0。所以标准姿势是char dest[10]; strncpy(dest, src, sizeof(dest) - 1); dest[sizeof(dest) - 1] \0; // 手动保证结束符这个“拷完必须手动补零”的习惯我能强调一千遍。评论区经常有人问为什么用了strncpy字符串还是乱的十有八九就是漏了最后这一行。4.4 数组和指针的sizeof陷阱你认为的长度不是长度void print_len(char s[]) { printf(%zu\n, sizeof(s)); // 结果是4或8不是数组长度 }数组作为参数传入函数后函数内的sizeof(s)拿到的是指针的大小而不是原来数组的字节数。这个知识点考试爱考实际调试中也常出问题。要正确处理要么额外传一个长度参数要么用strlen但strlen遇到没有结束符的数组会崩溃或者越界访问。这也是为什么很多工程规范里要求所有接收缓冲区的函数必须同时接收缓冲区大小目的就是避免这种“函数内部不知道边界”的尴尬。5. 进阶延伸二级指针、指针数组与函数指针5.1 指针数组管理多个字符串的高效姿势实际项目里经常需要保存一批字符串比如菜单项、文件列表、错误信息。直接定义二维数组char menu[][20]可以但浪费空间不同字符串长短不一二维数组必须按最长那个分配。用指针数组就好很多char *menu[] { 打开文件, 保存文件, 退出系统 };这里menu是一个数组每个元素是char *sizeof(menu) / sizeof(menu[0])可以算出字符串个数。字符传送在这种场景下的意义非常明显当你把menu[i]作为参数传给处理函数时传的只是指针函数通过指针去读取对应的字符串。如果要对这些字符串排序指针数组的优势更突出——不需要移动字符数据本身只需要交换指针值就能“逻辑上”改变顺序。相比二维数组那种整块整块搬数据的方式性能差异在数据量大时特别明显。5.2 二级指针什么时候才需要“指针的指针”热词里“指针的指针”出现频率不低但很多人没搞懂它到底解决什么问题。教科书里最经典的例子是在函数里修改外部的指针变量。比如你想写一个函数让外部的一个char *指向新分配的字符串void init(char **str) { *str (char *)malloc(100); if (*str ! NULL) { strcpy(*str, hello); } } int main(void) { char *p NULL; init(p); printf(%s\n, p); free(p); return 0; }如果函数签名写成void init(char *str)函数内部修改str只是让形参指向新地址外部的p纹丝不动最终函数返回后p还是NULL后续访问直接崩。所以“指针的指针”本质上是给指针变量开了一个可供函数修改的窗口。这个模式在链表增删节点、以及在某些动态配置的库接口里大量出现。进阶理解还有“指针数组”和“数组指针”的区别。char *s[]是“指针的数组”char (*s)[]是“指向数组的指针”。这两个声明看着类似用途和运算规则完全不同。前者更常用后者多用于多维数组传递。分类记忆比硬背声明要有效得多。5.3 函数指针把字符处理的函数“递来递去”字符传送相关场景里函数指针最常见的一个应用就是配合qsort给字符串数组排序#include stdio.h #include stdlib.h #include string.h int compare(const void *a, const void *b) { return strcmp(*(char **)a, *(char **)b); } int main(void) { char *names[] {banana, apple, cherry}; qsort(names, 3, sizeof(char *), compare); for (int i 0; i 3; i) { printf(%s\n, names[i]); } return 0; }compare接收的是指向数组元素的指针而数组元素本身是char *所以形参要先转成char **再解引用才能拿到字符串地址。这个转换过程很多初学者觉得绕但它是“函数指针 字符指针数组”结合的典型模板看得懂之后再多库函数读起来都不虚。嵌入式开发里函数指针同样很有用比如STM32的中断回调、HAL库的HAL_UART_RxCpltCallback都是通过函数指针机制实现“交给用户代码去处理字符数据”。你写的那段对接收到的字符做处理的函数本质上就是挂在系统指针上的一个可替换模块。6. 手写代码时的实战问答常见报错与排查思路6.1 经典的scanf读取字符串问题问为什么scanf(%s, buf)输入带空格的字符串空格后面的内容总是丢了答%s在遇到空白字符空格、制表符、换行时就会停止读取。想要读取整行内容标准做法是fgetschar buf[100]; fgets(buf, sizeof(buf), stdin);需要注意fgets会把换行符也保存进缓冲区如果需要纯字符串可以手动去掉末尾的\n。这个细节在PTA等平台的题目里经常成为“输出格式不对”的隐藏原因网上搜“翁恺c语言练习题”时也经常见到相关讨论。6.2 段错误Segmentation Fault排查顺序如果你写字符传送的代码时程序突然崩溃我的排查顺序一直是这样的看报错行号确认是否访问了未初始化的指针检查是否有修改字符串常量检查strcpy/strcat目标缓冲区够不够检查循环条件是否会造成指针越界特别是while (*p ! \0) p;这类循环要注意结束后的位置逐个打印指针的值和buf[0]看是不是传参传错了。这套流程能解决绝大多数段错误。说句实在话指针调试没什么高深技巧就是不断缩小范围把出错的代码块切成肉眼可以审查的小段。我至今记得第一次调试野指针时花了一晚上最后发现就是少初始化了一行从那以后任何指针声明我都顺手赋NULL或直接建数组。6.3 嵌入式单片机场景下的注意事项热搜词里“stm32指针的使用”“单片机c语言没有堆栈吗为什么”这几条都反映了嵌入式场景对指针的特殊要求。首先要澄清单片机有没有堆栈有但非常有限往往只有几KB甚至几百字节。这意味着不要在函数里定义超大的char buf[1024]栈会爆用指针操作字符串时要特别小心越界你不知道它下一步会踩到哪个关键寄存器相关的内存很多字符串常量在单片机里存放在Flash区用指针读取没问题但写入会触发HardFaultmalloc能用但慎用频繁动态分配容易产生碎片而且堆空间本来就小。所以嵌入式环境下做字符传送最稳妥的方式是预先分配好全局字符数组函数只接收指针和长度参数。工程项目的规范说白了都是惨痛教训堆出来的。6.4 指针变量、指针数组、字符数组的关系一张表看明白声明含义常见用途char ch;单个字符变量存一个字符char *p;指向字符的指针指向字符串/字符数组char a[10];长度为10的字符数组存可修改的字符串char *ps[5];有5个指针的数组管理多个字符串char (*pa)[10];指向“含10个char数组”的指针二维数组的行指针这张表建议抄在笔记里。我个人觉得搞清楚这几个声明第八章指针的内容就掌握了六成。剩下的四成靠写代码和踩坑补齐。聊一点个人体会这些东西我在教学生、带实习生的时候讲过无数遍。每次讲完我都愿意多嘴一句指针的学习没有捷径但也没有想象中难。从“字符传送”这个小切口入手把字符串复制、逆序、拼接一个个手写一遍比看十遍PPT都管用。遇到报错不要慌先想清楚指针指向的内存是哪里、那块内存可不可写、边界在哪大部分问题都能迎刃而解。希望这篇梳理能帮你在指针这条路上少绕几个弯——毕竟我当年就是在这个“字符传送”上跟strcpy和\0较劲了一整个晚上。
返回列表