news 2026/9/28 7:54:14

字符传送:C语言指针学习的分水岭与实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
字符传送:C语言指针学习的分水岭与实战解析

指针学到“字符传送”这一节,可以说是C语言学习路上一个真正的门槛。数组下标用的好好的,突然要改用*p、*(p+i)这套写法,很多人第一反应是“这不是脱裤子放屁吗”。但扛过这一关之后再回头看,你会发现自己突然能看懂很多以前看不懂的代码,比如字符串函数、字符指针数组、二级指针这些东西,本质上都是从“字符传送”这一个点长出来的。

这篇文章我打算沿着教材《C语言程序设计(第四版)》何钦铭、颜晖版第八章的脉络,把“字符传送”这个经典话题掰开揉碎。内容包括这个例子在整章里的位置、教材三种写法的设计逻辑、踩坑高发区,以及从字符传送延伸到实际开发场景的几个方向。不管你是刚开始啃指针,还是教完这章想知道学生卡在哪,这篇应该都能给你一些参考。

1. 本章定位:为什么“字符传送”是指针学习的分水岭

1.1 教材编排的递进逻辑

何钦铭、颜晖版的第八章,章名叫“指针”,但它并不是一上来就甩一堆晦涩概念。前面几节先讲指针变量、指针与数组、指针与函数,到了后面才出现专门用指针处理字符串的内容。而“字符传送”这个例子,正处在“指针与数组”和“字符串处理”之间,承上启下的作用非常明显。

承上的部分在于,它把前面学的数组名、下标访问、指针变量、指针运算全部串起来了。从下的部分在于,它第一次强迫你用指针的视角来思考“数据如何流动”——不是拿着下标去取某个位置的元素,而是让一个指针沿着内存一步一步往前走,一边走一边把数据搬到另一个地方。

我在实际教学和带新人的过程中观察到,绝大多数人在这里出现的第一道坎,不是语法不会写,而是思维上转不过弯。用下标法写字符串复制,三分钟就写完;改成指针法,就开始纠结while (*s != '\0')和while (s[i] != '\0')到底差在哪里。所以这一节的意义,本质上不是教你写一个复制函数,而是帮你完成从“数组思维”到“指针思维”的切换。

1.2 字符串在C语言里到底长什么样

理解字符传送之前,必须先解决一个前置问题:字符串并不是C语言内置的数据类型,它只是“以'\0'结尾的一串字符”,存放在字符数组里,或者由字符指针指向某个字符常量区。

这个“以'\0''结尾”的设计,是整个字符传送逻辑的基石。为什么传送循环不用for (i = 0; i < 长度; i++)这样的次数控制,而偏偏要用while (*to++ = *from++)这种“看着像在赋值、其实在判断”的写法?原因很简单:字符串没有显式的长度字段,它的终点只能通过'\0'来识别。

我们来看内存布局。假设有一个字符串"Hello"放在字符数组src里,它的真实占用是6个字节:H e l l o \0。所有字符串操作函数,包括我们即将写的自定义复制函数,都依赖这个末尾的0来判定“到站了”。一旦字符串末尾没有'\0',所有依赖这个约定去遍历字符串的代码都会越界读内存,直到碰上一个随机出现的0字节才能停下来。

这里我还想把字符数组和字符指针变量的区别说透,因为它俩在字符传送中的表现完全不同:

对比项字符数组字符指针变量
定义char s[] = "Hello";char *p = "Hello";
存放位置栈或全局数据段,可修改字符串常量通常存只读区,指针本身可改指向
能否通过指针修改内容能修改p[0]可能直接崩溃
sizeof(s)6,包含'\0'在64位系统上是8(指针自身大小)
作为函数实参退化为指向首元素的指针直接传指针值

在字符传送这个例子里,源串和目标串一般都用字符数组,因为目标串必须可写。如果图省事把目标也定义成char *dest = "...",然后往里面写数据,轻则运行时崩溃,重则悄悄改坏了只读区,排查起来相当痛苦。这一点后面我还会专门展开。

2. 教材经典写法拆解:三种“把src搬到dest”的实现

2.1 下标法版本:先建立正确的“基线”

很多读者会疑惑,教材为什么不直接上指针,非要先列一个下标法的版本。我的理解是:下标法是最符合直觉的写法,它用来定义“我们要做什么”,而指针法用来展示“C语言更喜欢的做法”。

下标法的核心思想很简单:定义一个整型变量i表示当前位置,从0开始,逐一判断from[i]是否为'\0',不是就把字符赋给to[i],然后i++。循环结束之后,再手动给to[i]补上'\0'。

void copy_string(char from[], char to[]) { int i = 0; while (from[i] != '\0') { to[i] = from[i]; i++; } to[i] = '\0'; }

这个版本有一个细节值得注意:from[]和to[]作为形参,本质上已经不是数组了。C语言在编译时会把数组形参退化为指针,所以char from[]和char *from是等价的,函数体内不管是写成from[i]还是*(from + i),最后生成的机器码几乎一样。

有一个方法可以验证这个“退化”:在函数里写printf("%d\n", sizeof(from));,你会发现它输出的不是源数组的长度,而是8(64位系统上一个指针的大小)。很多初学者在这个地方被坑过,误以为形参里写了[]就能拿到数组长度,所以我们总说“数组传参传的是首地址,不是整个数组”。

2.2 指针法版本:把“移动”这个动作显式化

再来看指针法版本。教材在讲完下标法之后,紧接着给出指针法,本质上是在告诉你:下标法里的from[i],其背后就是*(from + i);而更进一步,我们可以不保留那个i,直接移动指针本身:

void copy_string(char *from, char *to) { while (*from != '\0') { *to = *from; from++; to++; } *to = '\0'; }

指针法相比下标法,多了一个非常关键的概念:“指针的移动”。from++这个操作,在语义上就是“让from指向下一个字符”,它不再需要关心i是多少,只需关心当前指向哪里。这种写法和我们人脑处理字符串的方式更接近:一个手指指着源串的当前位置,一个手指指着目标串的当前位置,读一个、写一个、两个手指同时后移。

这节在讲课时,我习惯在黑板(或白板)上画两个方块数组,用两个箭头分别代表from和to。每执行一次*to = *from,就把两个箭头同时往后挪一格。等from指到'\0'停下来时,还要记得补一步*to = '\0'。这个“循环结束后补'\0'”的动作,是一大批人会漏的步骤,漏掉的后果就是目标串没有结束符,以后凡是拿strlen、printf("%s")去处理它,都会读到越界数据。

2.3 紧凑写法:职业选手的惯用套路

如果你在真实的C代码库里翻字符串复制类的实现,大概率见到的是下面这种写法:

while ((*to++ = *from++) != '\0') ;

第一次看到这行的人通常是一脸懵:怎么赋值语句还能当作判断条件?*to++ = *from++到底先算哪一步?

逐一拆解。*from++会被编译器解析成*(from++),也就是“取出from当前指向的字符,然后from自增”。然后这个字符被赋给*to++解引用出的位置,即“写入to当前指向的位置,然后to自增”。整个赋值表达式的值,就是写入的那个字符本身。最后拿这个字符和'\0'比较:不是0就继续循环,是0就退出。

展开写就是:

while (1) { char ch = *from++; *to++ = ch; if (ch == '\0') break; }

注意,这里的'\0'会被连同前面的所有字符一起复制过去,所以循环结束后不需要再手动补一个'\0'。这是紧凑写法和前面两个版本最大的行为差异。考试和作业里如果要求手写,我建议还是写展开版本,容易得分也容易检查;但在读源码时,这种紧凑写法必须看得懂,因为它是整个C语言社区流传最广的惯用法之一,从K&R时代就存在了。

3. 字符传送中最容易踩的概念坑

3.1 数组名是地址常量,不是指针变量

字符传送例子里,函数调用一般是copy_string(str1, str2)。这里的str1是字符数组名。很多初学者会误以为“数组名就是一个指针”,于是试图给数组名赋值——比如写str1++,编译器会直接报错。

正确的理解是:数组名是地址常量,它代表数组首元素的地址,这个地址在数组生命周期内不会改变。你可以把数组名赋值给一个指针变量char *p = str1;,之后p可以随意加加减减,但str1本身不行。

这一点和字符传送直接相关。如果你写了void copy_string(char *from, char *to),然后在函数里对形参from做from++,没有任何问题,因为形参from是一个独立的指针变量,它复制了实参的地址值。搞清楚“实参传地址、形参是变量、可以移动”这层关系,你就能理解为什么教材上强调“形参用指针接收地址,才能在函数内修改实参指向的数据”。

3.2 sizeof 与 strlen 的纠缠

在字符传送相关代码里,另一个高频翻车点是sizeof和strlen混用。很多人在写复制逻辑之前,喜欢先计算源串长度,然后基于长度做for循环。这时候就出问题了:

char src[] = "Hello"; int len = strlen(src); // 5 int size = sizeof(src); // 6,因为包含'\0'

如果你用strlen得到5,然后复制5个字符就停下,目标串末尾没有结束符。如果你错误地把sizeof当作字符串长度,得到的又可能是整个数组的字节数,而不是字符串可见字符数。

在字符传送场景里,标准做法根本不关心“长度”,它只关心“是否遇到'\0'”,这就是为什么所有字符串处理循环都采用“边复制边判断”的模式,而不是“先量长度再复制”。这里面有个思维差异:长度是运行到这里才能确定的,而'\0'是字符串本身的属性。

3.3 往字符串常量里写数据:教科书不会手把手教你的崩溃

前面提到过,字符指针可以指向字符串常量,比如char *p = "Hello";,但字符串常量在很多系统上存放在只读数据区。如果你把这样一个指针当作字符传送的目标,比如:

char *dest = "World"; copy_string("Hello", dest);

程序多半会在*to = *from这一刻直接段错误。因为dest的内容根本不可写。

这一点在教材正文里未必会大篇幅强调,但它特别重要。我遇到过不止一个学生课后做实验,把目标串定义成char *dest,然后跑来问“为什么我的程序运行时直接崩了”。排查到最后,基本都是这个问题。经验法则很简单:需要被写入的字符串,一律用字符数组来定义;字符指针既可以指向只读字符串常量,也可以指向可写的数组,但能不能写取决于它指向的对象,而不是指针本身。

3.4 二级指针在字符传送中的延伸

第八章后面还会出现“指针数组”和“指向指针的指针”。字符传送这个基础例子学扎实了,这些内容会好理解很多。举个例子,若有一个指针数组char *books[3],里面每个元素是指向字符串常量的指针,现在你要在排序时交换两个字符串,交换的不是字符串内容,而是数组里两个指针变量的值:

char *temp; temp = books[i]; books[i] = books[j]; books[j] = temp;

这个过程本质上也是“传送”,只不过传送的“数据”从字符变成了指针。如果你需要在一个函数里真正修改指针数组的元素,形参就得更进一步,写成char **books,也就是“指向指针的指针”。

理解了字符传送,再看二级指针,你会发现它不过是在字符传送之上多套了一层“传地址的地址”:char *指向一个字符,char **指向一个“指向字符的指针变量”。这个递推关系在第八章的后面几个例题里反复出现,所以基础越牢,后面越顺。

4. 边界处理与安全性:教材没展开但你必须会的细节

4.1 目标缓冲区容量:复制前就要想清楚

教材上的例子通常假设目标数组足够大,但现实中没人会替你保证这一点。如果源串比目标数组还长,copy_string就会越界写,写到紧挨着目标数组后面的内存区域里去。轻则破坏相邻变量,重则把返回地址写坏,程序崩溃不说,还可能成为安全漏洞的入口。

所以在实际开发里,复制之前必须知道目标缓冲区的容量。标准库函数strncpy就是为了解决这个问题出现的,它接受一个长度参数n,最多复制n个字符。但它也有自己的坑:如果源串长度小于n,它会用'\0'把剩余位置填满;如果源串长度大于等于n,它不保证目标字符串以'\0'结尾。所以使用strncpy的正确姿势通常是这样:

char dest[16]; strncpy(dest, src, sizeof(dest) - 1); dest[sizeof(dest) - 1] = '\0';

先预留一个位置给结束符,再手动补'\0',双保险。你可以把这个思路套进自己写的字符传送函数里:给函数增加一个size参数,循环里同时判断'\0'和最大允许写入长度,写满就停,这就是自定义安全版本。

4.2 指针是否为空、源串是否合法

字符传送的输入参数是两个指针。调用方可能图省事直接传NULL进来,也可能源指针和目标指针指向了同一块区域,还可能是重叠但不完全相同的两块区域。这些都是边界问题。

空指针最简单:函数入口处判断一下,如果from == NULL || to == NULL,直接返回或者返回错误码即可。别觉得这是小题大做,C语言里空指针解引用必崩,这是稳定性的头号杀手之一。

重叠问题则要复杂一些。如果目标地址落在源串的范围内,且目标起始地址比源起始地址靠前,那么原地复制时可能会把还没读到的源字符覆盖掉。这属于memmove要解决的问题。如果你只是实现一个简单的字符串复制函数,最好在文档里写清楚:调用者必须确保源串与目标缓冲区不重叠,或者至少目标起始位置要大于等于源串末尾。

4.3 为什么标准库的 strcpy 一直被人吐槽

C标准库里的strcpy功能上和你写的copy_string几乎一样,它同样不检查目标缓冲区大小。正因为它太好用,人们经常用它做一些危险的事,比如把一个很长的串拷进一个很小的栈数组。这导致业内对它批评不断,很多公司规范里明确禁止使用strcpy,一律改用strncpy或者snprintf。

我自己做字符传送相关项目时的习惯是:在函数命名上直接区分语义。如果只是课堂实验,名字叫copy_string没问题;如果是项目代码,我会直接使用标准库的安全版本,或者封装一层带长度参数的内部函数。这样到了review的时候,别人一眼就能看出边界处理在哪里,不容易出事故。

还有一个容易被忽略的细节:字符传送不仅仅是横向复制。在字符串逆序、删除指定字符这类题目里,常常需要“自己搬自己”。比如删除一个字符串中的所有空格,常见写法是用两个指针,一个负责读、一个负责写,读到非空格就写下去。这个“同源传送”同样建立在理解字符传送的基础上。

5. 从字符传送到实战:键盘输入、逆序、指针数组

5.1 fgets读进来的换行符要不要去除

日常写C程序,从键盘读字符串最常用的是fgets,尤其是你不想踩gets那个无限越界的雷时。fgets会把换行符也读进缓冲区,比如你输入hello回车,缓冲区里实际是hello\n\0。

这个换行符常常干扰后续逻辑。比如你想把读到的字符串再传给某个字符传送类函数做处理,若不先去掉\n,字符串长度就会多算一个,或者后续比较精确匹配时永远对不上。常见处理方式有两种,我推荐这样写:

char buf[100]; if (fgets(buf, sizeof(buf), stdin) != NULL) { size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0'; }

这段代码的实质是:先用strlen定位到末尾的换行符位置,把这个字符改成字符串结束符。理解了“字符串以'\0'结束”这个约定,这种操作就是顺理成章的。

5.2 字符串逆序:字符传送的镜像操作

字符串逆序是一道特别经典的练习题,它的核心机理和字符传送非常像,只是方向反了过来。典型的双指针写法如下:

void reverse_string(char *s) { char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char temp = *left; *left = *right; *right = temp; left++; right--; } }

这个函数里做的“暂存、赋值、移动”,本质上就是三处字符传送:先把左边字符传送给临时变量,再把右边字符传送给左边,最后把临时变量传送给右边。理解透“传送”的人,写这个函数基本不需要背,画对称箭头就能推出来。

逆序题还有一个变种,就是按单词逆转句子,比如“I love C”变成“C love I”。这种题通常分解成两步:先整体逆序,再逐单词逆序。第二步恰恰又用到“两个指针扫描、交换字符”的技巧。可以说,字符传送练好了,字符串算法里一半的题都不会太费劲。

5.3 指针数组存放字符串:二维字符数组的升级版

教材第八章后面有一个常见例子:用指针数组存放多个字符串,然后排序或查找。我常在课上和学生说,如果字符传送的“地址流动”逻辑没建立起来,看到指针数组会一头雾水。

指针数组的每个元素是一个char *指针,它保存的是某个字符串首字符的地址。用指针数组排序的时候,不需要像二维字符数组那样整体搬动字符串内容,只需要交换指针值,效率高得多。但代价是你要时刻警惕这些指针指向的是不是同一个底层缓冲区。比如你用了同一个字符数组反复fgets,再把地址存进指针数组,最后会发现所有指针都指向同一个地方,打印出来全是最后一行——这是经典的悬空指针和共享缓冲区的组合问题。

解决办法是把每个字符串放到独立的内存里,可以用动态分配,也可以用二维字符数组让编译器为每行分配独立空间。理解了“传送的是地址还是数据”这个区分,这类问题就迎刃而解:指针数组传的是地址,二维字符数组传的是数据。

最后说一个我自己在练习和教学中的体会:字符传送这一节,不要急着背代码,而是把每一行都翻译成内存动作。“从from指向的地址取出一个字节,写入to指向的地址,然后两个地址同时加1”——这句话能流畅说出口,闭着眼都能写对代码。后续不管是做习题,还是回头读Linux内核、嵌入式驱动里的字符串处理,都靠这个底子。字符串在C语言里无处不在,而字符传送就是你与它打交道的第一扇门。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 7:53:54

Redis核心技术与实战:从数据类型到分布式锁的高可用架构指南

1. 为什么所有技术团队都在聊RedisRedis&#xff0c;全称Remote Dictionary Server&#xff0c;是目前应用最广的内存键值数据库&#xff0c;没有之一。你在任何招聘网站上搜后端岗位&#xff0c;Redis几乎是必写项&#xff1b;打开任何一份系统架构图&#xff0c;Redis要么出现…

作者头像 李华
网站建设 2026/9/28 7:53:40

Canal实战:MySQL binlog实时同步到Redis与ES的完整方案

最近帮一个订单系统接Canal&#xff0c;用它监听MySQL的binlog日志&#xff0c;把数据准实时同步到Redis和ES&#xff0c;整个过程踩了不少坑。整理一下这整套方案的思路、配置细节和排障经验&#xff0c;给后面接手类似"业务数据库变一下&#xff0c;Redis缓存和ES索引马…

作者头像 李华
网站建设 2026/9/28 7:52:59

SWD协议实战:从数据包到波形,彻底搞懂Cortex-M调试链路

1. 为什么搞懂 SWD 协议比背命令更重要调试 ARM Cortex-M 时&#xff0c;10 个人里有 9 个人只是点一下 Keil 的 Download 按钮&#xff0c;或者 OpenOCD 里敲一句reset halt。真正问起调试器和芯片之间到底发生了什么&#xff0c;很多人会卡壳。直到你在现场遇到could not sto…

作者头像 李华
网站建设 2026/9/28 7:52:57

布面胶鞋里后跟胶料掺用轮胎再生胶的配方与工艺要点

做布面胶鞋的配方工程师&#xff0c;几乎没人没跟“里后跟”较过劲。这个部位藏在鞋帮和胶底之间&#xff0c;承担着脚后跟每走一步的冲击力&#xff0c;既要挺得住不变形&#xff0c;又要耐磨扛得住摩擦&#xff0c;还得跟帆布、胶浆粘得牢靠。说白了&#xff0c;它不吃颜值&a…

作者头像 李华
网站建设 2026/9/28 7:52:19

JavaScript提案机制与Stage 3新特性:从TC39演进到未来编码方式

JavaScript 社区每隔一段时间就会冒出一批“新东西”&#xff0c;而比新东西更早出现在你时间线上的&#xff0c;往往是各种提案。做前端的人应该都感受过那种矛盾&#xff1a;一边是生产环境里写着 ES2020 时代的老代码&#xff0c;一边是 TC39 会议上刚讨论到一半、连语法糖都…

作者头像 李华
网站建设 2026/9/28 7:52:19

JavaScript未来特性前瞻:从TC39提案看语言演进

“未来 JavaScript 特性展望”这七个字&#xff0c;放在十年前是个 To-Do 清单&#xff0c;放在今天更像一张会自己长大的地图。我在前端圈混了十几年&#xff0c;每年最期待的事就是点开 TC39 的 proposals 仓库&#xff0c;看看攒了一整年的新提案里有没有那些能真正改变写代…

作者头像 李华