17. 字符串的本质:字符指针与字符串处理

前面几篇文章,我们从指针一路聊到数组、二级指针、命令行参数,很多地方都出现了字符串的影子——printf%s 输出,argv 是一堆 char*。但 C 语言里并没有一个叫 string 的类型。那字符串到底是个什么东西?答案其实很纯粹:字符串,就是以空字符 '\0' 结尾的字符数组。

今天我们就来彻底搞清楚 C 语言字符串的里里外外。你会学到字符串在内存里怎么存、char[]char* 的真正区别、怎么安全地读写字符串,以及标准库提供的那些字符串处理函数。学完这篇,你就再也不会被 strcpystrcmp 搞迷糊,也能写出不怕缓冲区溢出的健壮代码。


一、C 风格字符串的本质:'\0' 是灵魂

在 C 语言里,字符串就是一段连续内存中、以空字符 '\0'(ASCII 码 0)结尾的字符序列。

比如字符串 "hello",在内存里实际占用 6 个字节:

地址:  100  101  102  103  104  105
内容:  'h'  'e'  'l'  'l'  'o'  '\0'

所有的字符串操作,不管是 printfstrlenstrcmp,都是从起始地址开始往后逐字节扫描,直到遇到 '\0' 为止。这个终止符就是字符串的灵魂——没有它,字符串函数就无法知道该在哪里停下,结果要么读到垃圾值,要么直接崩溃。

所以,一个字符数组如果没有 '\0',就只是一堆字符,不是合法的 C 字符串。


二、字符数组 vs 字符指针:再辨析

第十六篇我们已经初步对比过,这里再从字符串角度强化一下。

方式一:字符数组(可修改)

char str[] = "hello";

编译器在栈上分配一个 6 字节的数组,把 "hello" 的内容(包括 '\0')复制进去。这块内存是你的,随便改:

str[0] = 'H';   // 合法,变成 "Hello"

sizeof(str) 得到的是整个数组的大小(6 字节),因为 str 是数组名。

方式二:字符指针(指向只读字面量)

char *ptr = "hello";

这里 ptr 只是一个指针,指向存储在只读数据区的字符串字面量 "hello"。试图修改会导致未定义行为(通常崩溃):

ptr[0] = 'H';   // 危险!不要这样做

sizeof(ptr) 得到的是指针的大小(4 或 8 字节),而不是字符串长度。

方式三:const char *(最诚实)

如果你只是想“借来看一看”一个字符串而不修改它,用 const char * 最能表达意图,而且编译器会帮你拦下误改的代码:

const char *msg = "hello";
// msg[0] = 'H';   // 编译错误,完美

一张表总结

声明内存位置可否修改内容sizeof 结果
char str[] = "hi"可以数组大小(3)
char *ptr = "hi"指针在栈,字面量在只读区不可以指针大小(4/8)
const char *ptr = "hi"同上不可修改,编译器检查指针大小

三、字符串的输入输出

printf%s

%s 从给定地址开始逐个输出字符,直到遇到 '\0'(不包括它)。

char str[] = "hello";
printf("%s\n", str);   // 输出 hello

可以指定最小宽度和精度(.5s 表示最多输出前 5 个字符),这在我们讲第六篇时提过,复习一下。

scanf%s:极度危险

char name[10];
scanf("%s", name);
  • %s 会在遇到第一个空白字符(空格、换行、Tab)时停止读取。
  • 不会做越界检查!如果用户输入 20 个字符,scanf 照样往 name 里写,直接覆盖后面的栈空间,这就是典型的缓冲区溢出

解决方案:给 %s 加宽度限制,比如 %9s,表示最多读 9 个字符(留一个位置给 '\0'):

char name[10];
scanf("%9s", name);   // 最多读 9 个字符,安全

%s 仍然不能读取带空格的字符串(比如 “John Doe”)。

fgets:更安全的输入

char buffer[100];
if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
    // 读到的内容可能带换行符
}

fgets 会一直读,直到遇到换行符、文件结束,或读满 sizeof(buffer) - 1 个字符,然后在末尾加 '\0'。换行符会被保留在字符串里(如果空间够),你不想要的话得手动去掉。

去换行的小技巧:

buffer[strcspn(buffer, "\n")] = '\0';

strcspn 返回第一个匹配字符的索引,把那个位置的字符替换成 '\0' 就行。


四、常用字符串处理函数

C 标准库 <string.h> 提供了一组操作字符串的函数。我们用例子一个一个说明。

1. strlen(s):求字符串长度(不包括 '\0'

#include <string.h>
#include <stdio.h>

int main(void) {
    char str[] = "hello";
    printf("长度: %zu\n", strlen(str));  // 5
    return 0;
}

它的内部实现就像我们上回写的:

size_t strlen(const char *s) {
    const char *p = s;
    while (*p) p++;
    return p - s;
}

2. strcpy(dest, src):拷贝字符串

src(包括 '\0')复制到 dest 指向的空间。

char src[] = "world";
char dest[20];
strcpy(dest, src);   // dest 现在是 "world"

危险dest 必须足够大,否则溢出。更安全的是用 strncpy

3. strncpy(dest, src, n):限制长度的拷贝

char dest[5];
strncpy(dest, "hello, world", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';   // 重要!手动加 '\0'

strncpy 最多拷贝 n 个字符,但如果 src 的长度 >= n,它不会自动在 dest 末尾加 '\0'。这是个大坑,你必须自己手动补上。

4. strcat(dest, src):拼接字符串

src 追加到 dest 末尾(从 dest'\0' 处开始覆盖)。

char dest[20] = "hello ";
strcat(dest, "world");   // dest 变成 "hello world"

同样有溢出风险,推荐 strncat

5. strncat(dest, src, n):限制长度的拼接

char dest[10] = "hello ";
strncat(dest, "world!!!", sizeof(dest) - strlen(dest) - 1);

strncat 最多追加 n 个字符,然后自动加上 '\0'。它比 strncpy 安全得多。

6. strcmp(s1, s2):比较两个字符串

按字典序比较,返回:

  • 0:相等
  • 负数:s1 小于 s2
  • 正数:s1 大于 s2
if (strcmp("apple", "banana") < 0) {
    printf("apple 在 banana 前面\n");
}

陷阱:不能用 == 比较两个字符串的内容!if (s1 == s2) 比较的是两个指针的地址,而不是字符串内容。

7. strncmp(s1, s2, n):前 n 个字符比较

if (strncmp("hello", "helicopter", 3) == 0) {
    printf("前三个字符相同\n");
}

五、snprintf:格式化到字符串(更安全的利器)

很多时候你不需要手动拼接,snprintf 能把格式化的结果安全地写入缓冲区,并保证不溢出、有 '\0' 结尾(C99 起)。

char buffer[50];
int year = 2026;
snprintf(buffer, sizeof(buffer), "今年是 %d 年", year);
printf("%s\n", buffer);

它让格式化输出到字符串变得既安全又灵活,强烈推荐。


六、自己动手写:深入理解

实现自己的字符串函数是检验理解的绝佳方式。这里写两个:my_strcpymy_strcmp

#include <stddef.h>

char *my_strcpy(char *dest, const char *src) {
    char *original = dest;
    while (*src != '\0') {
        *dest = *src;
        dest++;
        src++;
    }
    *dest = '\0';   // 记得加终止符
    return original;
}

int my_strcmp(const char *s1, const char *s2) {
    while (*s1 != '\0' && *s2 != '\0') {
        if (*s1 != *s2) {
            return (unsigned char)*s1 - (unsigned char)*s2;
        }
        s1++;
        s2++;
    }
    // 谁先到 '\0' 谁更短
    return (unsigned char)*s1 - (unsigned char)*s2;
}

观察循环里指针的移动,你会发现它们和数组遍历完全一致。自己写一遍,就再也不会忘记 '\0' 的意义。


七、常见错误与陷阱

1. 用 == 比较字符串内容

char s1[] = "abc";
char s2[] = "abc";
if (s1 == s2)   // 永远为假!比较的是地址

应该用 strcmp

2. 忘记留 '\0' 的位置

char buf[5];
strncpy(buf, "hello", 5);   // buf 没有 '\0',printf 会越界

永远保证缓冲区比最大字符数多 1。

3. 使用 gets(已从 C11 标准移除,但老代码中可能有)

char buf[100];
gets(buf);   // 无论输入多长都往里写,极度危险

fgets 替代。

4. 返回局部字符数组的地址

char *get_message(void) {
    char msg[] = "hello";
    return msg;   // 返回栈上数组地址,无效
}

应返回字面量指针、静态数组地址,或动态分配的内存。


八、小结

今天我们真正理解了 C 语言字符串的本质:一个以 '\0' 结尾的字符数组。你学到了:

  • '\0' 是所有字符串函数工作的基础。
  • 字符数组和字符指针的区别决定了你可不可以修改字符串。
  • scanf("%s") 危险,fgets 安全,宽度限制不能忘。
  • strlenstrcpystrcatstrcmp 的用法,以及它们的安全版本 strncpystrncatstrncmp
  • snprintf 是一个更安全的格式化工具。

字符串是 C 语言中最常用的“非标量”类型,后续我们写文件操作、网络通信、用户交互,几乎都离不开它。现在你已经有了安全使用它的基础。下一篇,我们要迈入另一个重量级话题:函数与指针的强强联合——如何让函数接收函数作为参数(回调函数),以及那个让很多人头疼的“函数指针”到底怎么用。


课后小练习

  1. 写一个函数 size_t my_strlen(const char *s),不使用任何库函数,实现 strlen 的功能。
  2. 写一个函数 void safe_concat(char *dest, const char *src, size_t dest_size),安全地把 src 拼接到 dest 末尾,保证不溢出并以 '\0' 结尾。
  3. fgets 读取用户输入的一行文字(可能含空格),去掉末尾换行符,然后统计其中的字母、数字和空格的个数。
  4. (陷阱题)下面的代码有问题吗?如果有,指出问题并修正:
    char *p;
    strcpy(p, "hello");
    
  5. (实战)实现一个简单的 grep 过滤器:从标准输入逐行读取字符串,如果该行包含用户指定的关键词(通过 argv[1] 传入),就打印这一行。提示:使用 fgetsstrstrstrstr(haystack, needle) 返回指向 needle 首次出现位置的指针,找不到返回 NULL)。

我们下期见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值