指针学到“字符传送”这一节,可以说是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语言里无处不在,而字符传送就是你与它打交道的第一扇门。