C 语言字符串与内存函数:从字符处理到字节级操作
C 语言没有独立的字符串类型。我们平时写下的字符串,要么是存放在只读区域中的字符串字面量,要么是以字符数组形式保存在内存中的一段连续空间。字符串函数和内存函数的本质,都是按照某种约定读取、复制、比较或修改这段空间。
理解这些函数,不能只记住函数名和参数顺序,更要明确三个问题:
- 数据是否以 \0 作为结束标志。
- 目标空间是否足够大、是否允许修改。
- 操作的对象是字符串,还是任意字节序列。
前两个问题决定字符串函数是否安全,第三个问题决定应该使用字符串函数还是内存函数。
一、字符串在 C 语言中的表示
字符串字面量可以直接写在程序中:
constchar*message="hello";这里的指针保存字符串首字符的地址,字符串内容通常位于不可修改的存储区域。需要修改内容时,应使用字符数组:
charmessage[]="hello";message[0]='H';字符数组中的字符串必须以 ‘\0’ 结尾。对字符串来说,\0 不是普通的可见字符,而是表示内容结束的标记:
charword[]={'c','o','d','e','\0'};上面的数组有 5 个元素,但字符串长度是 4。字符串函数通常从首地址开始逐个读取字符,直到遇到 ‘\0’。
如果字符数组没有结尾的 ‘\0’,就不能把它当作 C 字符串传给 strlen、strcpy 或 printf(“%s”, …)。函数可能越过数组边界继续读取,造成未定义行为。
二、获取字符串长度:strlen
strlen 用于计算字符串中 ‘\0’ 之前的字符数量,不包含结束标志:
#include<stdio.h>#include<string.h>intmain(void){constchar*text="hello";printf("%zu\n",strlen(text));return0;}函数原型是:
size_tstrlen(constchar*str);参数必须指向以 ‘\0’ 结尾的有效字符串。返回值类型是 size_t,它是无符号整数类型,因此进行减法或比较时要特别小心:
size_ta=strlen("abc");size_tb=strlen("abcdef");if(a-b>0){/* 这里不一定代表 a 比 b 长 */}因为无符号数不能表示负数,a - b 可能发生回绕。比较两个字符串的长度时,可以直接写 a > b,不要通过两个 size_t 相减后判断正负。
手动实现 strlen
计数器版本:
#include<stddef.h>size_tmy_strlen(constchar*str){size_tcount=0;while(str[count]!='\0'){++count;}returncount;}指针相减版本:
#include<stddef.h>size_tmy_strlen(constchar*str){constchar*end=str;while(*end!='\0'){++end;}return(size_t)(end-str);}递归版本也可以表达同样的逻辑,但字符串很长时可能产生较深的调用栈,因此实际工程中通常使用循环版本。
三、复制字符串:strcpy
strcpy 从源字符串复制字符到目标数组,并且会把源字符串结尾的 ‘\0’ 一起复制:
char*strcpy(char*destination,constchar*source);使用时必须满足以下条件:
- source 必须指向以 ‘\0’ 结尾的字符串。
- destination 必须有足够空间保存源字符串和结尾的 ‘\0’。
- destination 必须是可写空间。
- 源区域和目标区域不能重叠。
示例:
#include<stdio.h>#include<string.h>intmain(void){chardestination[32];constchar*source="C language";strcpy(destination,source);puts(destination);return0;}目标数组的容量不能只看字符串的可见字符数量,还要为 ‘\0’ 预留一个字节。对于外部输入,直接使用 strcpy 很容易造成缓冲区溢出,应该先检查长度,或使用更明确的长度限制策略。
手动实现 strcpy
#include<assert.h>char*my_strcpy(char*destination,constchar*source){char*result=destination;assert(destination!=NULL);assert(source!=NULL);while((*destination++=*source++)!='\0'){/* copy including the terminator */}returnresult;}返回目标地址是标准库函数的一个常见设计,这样可以把复制函数放进表达式中使用。
四、拼接字符串:strcat
strcat 会找到目标字符串末尾的 ‘\0’,从那里开始追加源字符串,并在新内容末尾写入新的 ‘\0’:
char*strcat(char*destination,constchar*source);使用时需要确保:
- destination 本身已经是一个合法字符串。
- source 是以 ‘\0’ 结尾的字符串。
- destination 的剩余空间足够容纳追加内容和新的 ‘\0’。
- 两个区域不能重叠。
#include<stdio.h>#include<string.h>intmain(void){charmessage[32]="hello";strcat(message," world");puts(message);return0;}不要让字符串自己追加到自己,例如 strcat(buffer, buffer)。源字符串和目标字符串重叠时,标准并不保证结果,应该改用更明确的临时缓冲区或内存移动方案。
手动实现 strcat
#include<assert.h>char*my_strcat(char*destination,constchar*source){char*result=destination;assert(destination!=NULL);assert(source!=NULL);while(*destination!='\0'){++destination;}while((*destination++=*source++)!='\0'){/* append including the terminator */}returnresult;}五、比较字符串:strcmp
strcmp 按字典序比较两个字符串,从第一个字符开始逐个比较。遇到字符不同,或者某个字符串先结束时,比较停止:
intstrcmp(constchar*left,constchar*right);返回值只需要关注符号:
- 返回值小于 0,表示 left 小于 right。
- 返回值等于 0,表示两个字符串内容相同。
- 返回值大于 0,表示 left 大于 right。
不要把返回值当成固定的 -1、0 或 1。标准只保证正负关系,不保证具体数值。
字符串比较的是字符编码顺序,而不是字符串长度。比如 “z” 的长度小于 “apple”,但按常见字符编码顺序,“z” 可能大于 “apple”。
手动实现 strcmp
#include<assert.h>intmy_strcmp(constchar*left,constchar*right){assert(left!=NULL);assert(right!=NULL);while(*left!='\0'&&*left==*right){++left;++right;}if((unsignedchar)*left<(unsignedchar)*right){return-1;}if((unsignedchar)*left>(unsignedchar)*right){return1;}return0;}转换为 unsigned char 可以避免某些平台上 char 为有符号类型时产生负值比较问题。
六、带长度限制的字符串函数
无长度限制的 strcpy、strcat 和 strcmp 使用方便,但调用者必须自行保证空间和边界。带 n 的函数可以限制本次处理的最大字符数,但它们并不自动解决所有安全问题。
strncpy
char*strncpy(char*destination,constchar*source,size_tcount);strncpy 最多复制 count 个字符。如果源字符串在 count 个字符之前遇到 ‘\0’,目标区域剩余位置会补零;如果源字符串长度大于或等于 count,目标区域可能不会自动添加 ‘\0’。
charname[8];strncpy(name,"Alexander",sizeofname-1);name[sizeofname-1]='\0';这种显式补终止符的写法更容易保证结果是合法字符串。不要简单地认为 strncpy 永远会返回以 ‘\0’ 结尾的字符串。
strncat
char*strncat(char*destination,constchar*source,size_tcount);strncat 最多从 source 追加 count 个字符,并在结果末尾写入一个 ‘\0’。目标数组必须有足够空间容纳原字符串、实际追加内容和结束标志。
charmessage[32]="hello ";strncat(message,"world",5);需要注意,count 是允许从源字符串读取的最大字符数,不是目标数组的总容量。
strncmp
intstrncmp(constchar*left,constchar*right,size_tcount);strncmp 最多比较 count 个字符。如果在此之前发现不同字符、遇到字符串结束,或者已经比较完 count 个字符,函数就会停止:
if(strncmp(command,"quit",4)==0){puts("exit");}它适合比较固定前缀,但如果要判断两个完整字符串是否相同,必须同时考虑长度和结束符。
七、查找字符串:strstr
strstr 在一个字符串中查找另一个字符串第一次出现的位置:
char*strstr(constchar*text,constchar*pattern);找到时返回匹配位置的指针,找不到时返回 NULL。如果 pattern 是空字符串,标准行为是返回 text 本身:
#include<stdio.h>#include<string.h>intmain(void){charmessage[]="C makes memory visible";char*match=strstr(message,"memory");if(match!=NULL){printf("found: %s\n",match);}return0;}返回的指针仍然指向原字符串。如果原字符串是字符数组,可以通过它修改匹配位置;如果原字符串是字符串字面量,则不能写入。
基础实现可以从主串的每一个位置开始逐字符比较:
#include<stddef.h>char*my_strstr(constchar*text,constchar*pattern){if(*pattern=='\0'){return(char*)text;}for(constchar*start=text;*start!='\0';++start){constchar*a=start;constchar*b=pattern;while(*a!='\0'&&*b!='\0'&&*a==*b){++a;++b;}if(*b=='\0'){return(char*)start;}}returnNULL;}这种实现易于理解,但在大量文本匹配时可能效率较低。对性能要求较高的场景,可以进一步研究 KMP 等模式匹配算法。
八、切分字符串:strtok
strtok 根据分隔符集合把一个字符串拆成多个标记:
char*strtok(char*str,constchar*separators);第一次调用时传入待切分字符串,之后继续调用时传入 NULL,函数会从上一次保存的位置继续查找:
#include<stdio.h>#include<string.h>intmain(void){charinput[]="user@example.com";constchar*separators=".@";for(char*token=strtok(input,separators);token!=NULL;token=strtok(NULL,separators)){puts(token);}return0;}strtok 会直接修改输入字符串,把分隔符替换成 ‘\0’,因此不能把字符串字面量作为第一个参数。需要保留原始内容时,应先复制到可修改的字符数组。
传统 strtok 使用内部状态保存切分位置,不适合交错切分多个字符串,也不适合需要重入性的场景。具体平台如果提供 strtok_r 或其他可重入版本,可以根据项目需求选择。
九、错误信息:strerror 与 errno
许多库函数失败时会设置 errno,它是一个错误码。strerror 可以把错误码转换为可读的说明:
#include<errno.h>#include<stdio.h>#include<string.h>intmain(void){FILE*file=fopen("missing.txt","r");if(file==NULL){fprintf(stderr,"open failed: %s\n",strerror(errno));return1;}fclose(file);return0;}errno 只有在相关函数报告失败后才有意义。不要在成功调用后无条件读取它,也不要把 errno 的数值直接当作用户可读的错误信息。
十、字符分类与转换
头文件 ctype.h 提供了一组字符分类函数和大小写转换函数:
- isspace:空白字符。
- isdigit:十进制数字。
- isxdigit:十六进制数字。
- islower、isupper:小写或大写字母。
- isalpha:字母。
- isalnum:字母或数字。
- ispunct:标点符号。
- isprint:可打印字符。
- iscntrl:控制字符。
- tolower、toupper:大小写转换。
#include<ctype.h>#include<stdio.h>intmain(void){chartext[]="C Language 2025!";for(size_ti=0;text[i]!='\0';++i){unsignedcharch=(unsignedchar)text[i];if(isupper(ch)){text[i]=(char)tolower(ch);}}puts(text);return0;}除了 EOF 之外,传给这些函数的参数应该能够转换为 unsigned char。如果直接传入一个值为负的普通 char,在某些平台上可能触发未定义行为。因此,处理可能包含非 ASCII 字节的数据时,先转换为 unsigned char 是稳妥的习惯。
十一、按字节复制:memcpy
字符串函数以 ‘\0’ 为结束条件,而 memcpy 完全按照字节数复制,不会因为遇到零字节而提前停止:
void*memcpy(void*destination,constvoid*source,size_tcount);它可以复制字符数组、结构体或任意对象的字节表示:
#include<string.h>structPerson{charname[32];intage;};structPersonsource={"Ada",36};structPersoncopy;memcpy(©,&source,sizeofcopy);memcpy 要求源区域和目标区域不能重叠。如果存在重叠,复制结果是未定义的,此时应该使用 memmove。
十二、处理重叠区域:memmove
void*memmove(void*destination,constvoid*source,size_tcount);memmove 与 memcpy 的主要区别是能够正确处理重叠区域。它会根据源地址和目标地址的关系,选择从前往后或从后往前复制:
#include<stdio.h>#include<string.h>intmain(void){chartext[]="abcdef";memmove(text+2,text,4);puts(text);return0;}这里目标区域与源区域发生重叠,使用 memcpy 并不安全,而 memmove 会保证每个源字节在被覆盖前已经完成读取。
手动实现 memmove
#include<stddef.h>void*my_memmove(void*destination,constvoid*source,size_tcount){unsignedchar*dst=destination;constunsignedchar*src=source;if(dst==src||count==0){returndestination;}if(dst<src||dst>=src+count){for(size_ti=0;i<count;++i){dst[i]=src[i];}}else{for(size_ti=count;i>0;--i){dst[i-1]=src[i-1];}}returndestination;}真实库实现会针对平台进行优化,但“无重叠时从前往后,有重叠且目标在后方时从后往前”的思路是理解它的关键。
十三、设置内存:memset
memset 把目标区域的每一个字节设置为同一个 unsigned char 值:
void*memset(void*destination,intvalue,size_tcount);#include<string.h>charbuffer[16];memset(buffer,0,sizeofbuffer);需要注意,memset 按字节填充,而不是把一个整数整体写入每个元素。下面的代码会把每个字节设置为 1,因此整型数组中的每个元素通常会变成 0x01010101,而不是整数 1:
intvalues[4];memset(values,1,sizeofvalues);如果想把整型数组初始化为 1,应使用循环或显式赋值;把内存清零则通常可以使用 memset(values, 0, sizeof values)。
十四、按字节比较:memcmp
memcmp 比较两个内存区域前 count 个字节:
intmemcmp(constvoid*left,constvoid*right,size_tcount);返回值同样只需要关注正负和零。它比较的是字节序列,不是 C 字符串,也不会因为遇到 ‘\0’ 停止:
#include<stdio.h>#include<string.h>intmain(void){unsignedcharfirst[]={1,2,0,4};unsignedcharsecond[]={1,2,0,5};if(memcmp(first,second,sizeoffirst)<0){puts("first is smaller");}return0;}对结构体直接使用 memcmp 判断是否相等要谨慎,因为结构体可能包含填充字节,而填充字节的内容未必相同。判断结构体逻辑是否相等,通常应逐个比较有意义的成员。
十五、字符串函数与内存函数的选择
| 场景 | 合适的函数 |
|---|---|
| 计算以 ‘\0’ 结尾的字符串长度 | strlen |
| 复制完整字符串 | strcpy 或经过边界设计的替代方案 |
| 追加字符串 | strcat 或长度受控的替代方案 |
| 按字典序比较字符串 | strcmp、strncmp |
| 查找子串 | strstr |
| 按分隔符切分可修改字符串 | strtok |
| 报告 errno 对应的文字 | strerror |
| 复制任意字节序列 | memcpy |
| 复制可能重叠的内存区域 | memmove |
| 将字节填成同一个值 | memset |
| 比较任意字节序列 | memcmp |
字符串函数依赖 ‘\0’,适合文本;内存函数依赖显式字节数,适合二进制数据、结构体和包含零字节的缓冲区。把二者混用,是很多内存错误的根源。
十六、实现和使用时的安全检查
编写字符串和内存操作代码时,可以遵循这些习惯:
- 明确源数据和目标数据的容量,给字符串结束符预留空间。
- 不把字符串字面量传给会修改内容的函数。
- 需要处理重叠区域时使用 memmove,不要使用 memcpy。
- 使用 strncpy 后检查目标是否有 ‘\0’,必要时手动补上。
- 不要用字符串函数处理没有结束符的字符数组。
- 使用 strlen 的返回值时注意 size_t 是无符号类型。
- 调用 ctype.h 函数前将普通字符转换为 unsigned char。
- 使用 memset 时记住它填充的是字节,不是完整整数。
- 用 memcmp 比较二进制数据时传入准确的字节数。
- 模拟实现库函数时,先写清参数顺序、停止条件、返回值和重叠规则。
字符串函数让文本处理更方便,内存函数则提供了更底层、更通用的字节操作能力。真正可靠的 C 代码,不是简单地把一个函数名换成另一个函数名,而是先弄清楚数据的表示方式、生命周期、容量和重叠关系,再选择与问题匹配的工具。