字符串操作4
361.2.4.4.字符串操作4
/* 引入 printf 函数的声明;这行位于 _GNU_SOURCE 之前,因而会让后面的特性宏定义得过晚。 */
#include <stdio.h>
/* 按视频最终源码保留该位置;正确位置应当是所有头文件之前。 */
#define _GNU_SOURCE
/* 引入字符串处理函数的声明;由于特性宏生效过晚,本次编译没有获得 strchrnul 的声明。 */
#include <string.h>
/* 保留前面课程建立的字符串长度测试函数;本节结束时 main 没有调用它。 */
void StringLength_Test()
{
/* 保存十个可见字符;该数组没有字符串终止符 '\0'。 */
unsigned char buf1[] = {'H', 'e', 'l', 'l', 'o', 'W', 'o', 'r', 'l', 'd'};
/* 创建五字节数组,并先把全部元素初始化为零。 */
unsigned char buf3[5] = {0};
/* 用字符串字面量初始化数组,末尾会自动带有 '\0'。 */
unsigned char buf2[] = "HelloWorld";
/* 将 buf3 的五个字节全部改写为字符 '-'。 */
memset(buf3, '-', sizeof(buf3));
/* 输出两个数组所占的字节数;%ld 按视频源码保留。 */
printf("buf1 len:%ld, buf2 len:%ld\r\n", sizeof(buf1), sizeof(buf2));
/* 把没有终止符的 buf1 当作字符串输出,这行存在未定义行为。 */
printf("buf1 :%s\r\n", buf1);
/* 输出具有终止符的 buf2。 */
printf("buf2 :%s\r\n", buf2);
/* 从返回类型为 void 的测试函数返回。 */
return;
}
/* 保留前面课程建立的字符串复制测试函数;本节结束时 main 没有调用它。 */
void StringStrcpy_Test()
{
/* 保存源字符串;数组包含 11 个可见字符和末尾的 '\0'。 */
unsigned char buf1[] = "Hello,World";
/* 为 strcpy 的结果准备 128 字节目标缓冲区。 */
unsigned char buf2[128];
/* 为 strncpy 的结果准备另一块 128 字节目标缓冲区。 */
unsigned char buf3[128];
/* 把 buf2 的全部字节清零。 */
memset(buf2, 0, sizeof(buf2));
/* 把 buf3 的全部字节清零。 */
memset(buf3, 0, sizeof(buf3));
/* 将 buf1 连同末尾的 '\0' 一起复制到 buf2。 */
strcpy(buf2, buf1);
/* 输出源字符串 buf1。 */
printf("buf1 data:%s \r\n", buf1);
/* 输出 strcpy 复制得到的 buf2。 */
printf("buf2 data:%s \r\n", buf2);
/* 复制 buf1 的前 11 字节;该长度沿用上一节结束时的源码。 */
strncpy(buf3, buf1, sizeof(buf1) - 1);
/* 输出 buf3;其终止符来自清零后未被覆盖的 buf3[11]。 */
printf("buf3 data:%s \r\n", buf3);
/* 从返回类型为 void 的测试函数返回。 */
return;
}
/* 保留前面课程建立的字符串拼接测试函数;本节结束时 main 没有调用它。 */
void StringStrcat_Test()
{
/* 保存准备追加到其他字符串末尾的源字符串 "Hello"。 */
unsigned char buf1[] = "Hello";
/* 创建容量为 128 字节的目标数组,并让它先保存字符串 "world"。 */
unsigned char buf2[128] = "world";
/* 创建第二个目标数组,并让它先保存字符串 "ncat"。 */
unsigned char buf3[128] = "ncat";
/* 把完整的 buf1 追加到 buf2 的字符串末尾。 */
strcat(buf2, buf1);
/* 输出 strcat 拼接后的 buf2。 */
printf("buf2:%s\r\n", buf2);
/* 最多取 buf1 的前两个非零字节追加到 buf3,并在结果末尾写入 '\0'。 */
strncat(buf3, buf1, 2);
/* 输出 strncat 拼接后的 buf3。 */
printf("buf3:%s\r\n", buf3);
/* 从返回类型为 void 的测试函数返回。 */
return;
}
/* 保留前面课程建立的字符串比较测试函数;本节结束时 main 没有调用它。 */
void StringStrcmp_Test()
{
/* 保存第一个待比较字符串,其末尾可见字符是 '1'。 */
unsigned char buf1[] = "Hello,World1";
/* 保存第二个待比较字符串,其末尾可见字符是 '2'。 */
unsigned char buf2[] = "Hello,World2";
/* 定义整型变量,用于保存字符串比较函数的返回值。 */
int ret;
/* 比较两个完整字符串,并把结果保存到 ret。 */
ret = strcmp(buf1, buf2);
/* 输出 strcmp 的比较结果。 */
printf("ret val:%d\r\n", ret);
/* 最多比较两个字符串开头的五个字节。 */
ret = strncmp(buf1, buf2, 5);
/* 输出 strncmp 的比较结果。 */
printf("strncmp ret:%d\r\n", ret);
/* 从返回类型为 void 的测试函数返回。 */
return;
}
/* 定义本节新增的字符查找测试函数。 */
void StringStrchr_Test()
{
/* 保存待查找字符串 "Hello",数组末尾另有一个 '\0'。 */
unsigned char buf1[] = "Hello";
/* 查找第一个 'l';unsigned char * 实参的不兼容类型按视频源码保留。 */
char *ptr = strchr(buf1, 'l');
/* 输出数组首地址;严格匹配 %p 时应把实参转换为 void *。 */
printf("strchr buf1:%p \r\n", buf1);
/* 输出 strchr 返回的地址。 */
printf("strchr ptr:%p \r\n", ptr);
/* 从字符串末尾方向查找最后一个 'l'。 */
char *ptr2 = strrchr(buf1, 'l');
/* 输出 strrchr 返回的地址。 */
printf("strrchr ptr:%p \r\n", ptr2);
/* 查找不存在的 '7';本次编译缺少 strchrnul 声明,这一赋值会收到关键警告。 */
char *ptr3 = strchrnul(buf1, '7');
/* 输出 ptr3;视频中的结果已经是被错误转换后的无效地址。 */
printf("strchrnul ptr:%p \r\n", ptr3);
/* 再用标准 strchr 查找不存在的 '7'。 */
char *ptr4 = strchr(buf1, '7');
/* 如果没有找到,则立即结束测试函数,避免继续使用空指针。 */
if (ptr4 == NULL)
{
/* 从测试函数返回;因此当前数据下不会执行后面的 printf。 */
return;
}
/* 仅当找到 '7' 时才输出它的地址。 */
printf("strchr find '7' ptr:%p \r\n", ptr4);
/* 查找成功并输出地址后,从测试函数返回。 */
return;
}
/* 程序从 main 函数开始执行。 */
int main()
{
/* 输出这一组 GNU/Linux 字符串函数实验的标题。 */
printf("GNU String Function Test\r\n");
/* 旧的字符串长度测试在本节结束时已被注释,不会执行。 */
// StringLength_Test();
/* 旧的字符串复制测试在本节结束时已被注释,不会执行。 */
// StringStrcpy_Test();
/* 旧的字符串拼接测试在本节结束时已被注释,不会执行。 */
// StringStrcat_Test();
/* 旧的字符串比较测试在本节结束时已被注释,不会执行。 */
// StringStrcmp_Test();
/* 最终只调用本节新增的字符查找测试函数。 */
StringStrchr_Test();
/* 返回 0,表示程序正常结束。 */
return 0;
}
上面是本节视频结束时的完整 main.c:保留前面课程的四个测试函数,新建 StringStrchr_Test(),并将 main() 的活动调用切换到它。源码按照画面如实收录,因此也保留了 _GNU_SOURCE 位于 <stdio.h> 之后、字符串数组使用 unsigned char[]、%p 实参未显式转换成 void * 等问题;这些不是推荐写法,正文会逐一说明。
特别要区分“视频最后一次运行”和“视频最后的源码”。画面在加入 ptr4 == NULL 判断之前运行过程序,之后只完成了判断代码而没有再次编译运行。因此,最后一次屏幕输出中还能看到 strchr find '7' ptr:(nil);若执行顶部最终源码,查找失败后会直接 return,这一行不会再输出。
一、字符串操作
本节的三个函数都在以 \0 结尾的字节字符串中查找一个字符。strchr() 和 strrchr() 属于标准 C 库;strchrnul() 是 GNU 扩展,可移植性不同。
| 函数 | 匹配成功时 | 匹配失败时 | 可移植性 |
|---|---|---|---|
strchr(s, c) | 指向第一个匹配字符 | NULL | ISO C、POSIX |
strrchr(s, c) | 指向最后一个匹配字符 | NULL | ISO C、POSIX |
strchrnul(s, c) | 指向第一个匹配字符 | 指向字符串末尾的 \0 | GNU 扩展 |
三者返回的都是指向原字符串内部的指针,并不复制字符,也不返回“地址索引”。传入的 s 必须指向一个在有效可读区域内能遇到 \0 的字符串;否则查找会越界,行为未定义。它们查找的是字节值,不理解 UTF-8 字符边界;用于 ASCII 字符没有这个歧义。
1.1 例题: strchr函数演示
strchr() 的经典函数原型是:
char *strchr(const char *s, int c);
它从 s[0] 开始向后查找 c,找到后返回指向第一次出现位置的指针,找不到则返回 NULL。字符串终止符 \0 也被视为字符串的一部分,所以 strchr(s, '\0') 会返回指向终止符的指针。
视频使用 "Hello" 查找 'l',内存位置可表示为:
索引: 0 1 2 3 4 5
内容: 'H' 'e' 'l' 'l' 'o' '\0'
↑
strchr 返回 buf1 + 2
因此 ptr 指向索引 2,也就是第一个 'l'。视频画面中某次运行的 buf1 地址末尾为 ...b2,ptr 地址末尾为 ...b4,两者相差两个字节,这与索引 2 一致。具体地址由每次运行时的内存布局决定,不能写死,也不应通过肉眼做十六进制减法来编写程序。
在 C 中,正确取得索引的方式是对同一数组中的两个指针做减法:
#include <stddef.h>
char buf[] = "Hello";
char *p = strchr(buf, 'l');
if (p != NULL) {
ptrdiff_t index = p - buf;
printf("index:%td\n", index);
}
指针相减只在两个指针都指向同一个数组对象(或尾后位置)时有定义,结果类型是 ptrdiff_t;对 char 数组来说,差值同时就是字节偏移和从零开始的元素索引。必须先判断 p != NULL,不能拿空指针参与减法。
视频最初误用 %d 输出指针,编译器因“格式要求 int,实际却是指针”而报警,随后改成了 %p。%p 是 C 的 printf() 格式,不是 Linux 独有语法;其对应实参严格来说应为 void *,推荐写成:
printf("%p\n", (void *)p);
%p 的具体显示形式由实现决定。在视频所用环境中,空指针显示为 (nil),但可移植程序不能依赖这一文本格式。
课程代码把普通字符串声明为 unsigned char[],而 strchr() 的参数是 const char *。这两种字符指针类型并不兼容,启用警告后通常会收到诊断。保存普通 C 字符串时宜使用 char[];如果处理的是任意二进制字节块,应改用带长度的 memchr(),而不是依赖 \0 的字符串函数。
1.2 strrchr函数介绍
strrchr() 的经典函数原型为:
char *strrchr(const char *s, int c);
它返回 c 在字符串中最后一次出现的位置;找不到时同样返回 NULL。虽然可以把效果理解为“从末尾找”,但接口语义只保证返回最后一个匹配位置,不应据此假定某种特定内部实现。
对于 "Hello" 中的 'l':
| 调用 | 返回位置 | 索引 |
|---|---|---|
strchr(buf1, 'l') | 第一个 'l' | 2 |
strrchr(buf1, 'l') | 第二个 'l' | 3 |
视频先加入了 ptr2,但第一次仍误把 ptr 传给 printf(),所以两个地址相同;随后将实参改为 ptr2,画面中的三个地址末尾依次为 ...a42、...a44、...a45,正确显示了首地址、索引 2 和索引 3。最后一个 'l' 到终止符 \0 的距离是两个字节;第一个 'l' 到终止符的距离则是三个字节,必须说明比较基准,不能笼统写成“地址增加 2”。
与 strchr() 一样,当待查字符就是 \0 时,strrchr() 也返回终止符的位置;字符串中只有这一个终止符属于本次搜索范围。
1.3 strchrnul函数介绍
strchrnul() 的函数原型为:
char *strchrnul(const char *s, int c);
它与 strchr() 都返回第一个匹配位置,区别只在查找失败时:strchr() 返回 NULL,strchrnul() 返回指向字符串终止符 \0 的指针。这里不是指向“最后一个可见字符”;例如 "Hello" 最后一个可见字符 'o' 的索引为 4,而终止符的索引为 5。
strchrnul() 是 GNU 扩展。使用 glibc 时,需要让 <string.h> 暴露它的声明,正确的源码顺序是:
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
也可以在编译命令中定义,例如 gcc -D_GNU_SOURCE ...。关键条件不是“放在 <string.h> 之前”这么简单,而是必须在任何头文件之前定义特性测试宏:头文件可能彼此包含,先包含 <stdio.h> 后再定义就可能已经太晚。
视频和顶部原样源码采用了:
#include <stdio.h>
#define _GNU_SOURCE
#include <string.h>
因此本次编译没有得到 strchrnul() 的正确声明。宏名和函数名也要准确:原笔记转写出的 string_x_new、strchr_new 都不是本节函数,正确名称只有 strchrnul。
1.4 strchrnul函数应用
视频用三个调用展示 "Hello" 的查找结果。先只看接口在声明正确时应有的语义:
| 表达式 | 应返回的位置 | 相对 buf1 的偏移 |
|---|---|---|
strchr(buf1, 'l') | 第一个 'l' | 2 |
strrchr(buf1, 'l') | 最后一个 'l' | 3 |
strchrnul(buf1, '7') | 末尾 \0 | 5 |
strchr(buf1, '7') | NULL | 不可计算 |
但视频中的实际编译缺少 strchrnul() 原型,最后一次运行显示的关键地址是:
strchr buf1:0x7ffcaeb87c52
strchr ptr:0x7ffcaeb87c54
strrchr ptr:0x7ffcaeb87c55
strchrnul ptr:0xffffffffaeb87c57
strchr find '7' ptr:(nil)
前三项分别对应基址、基址加 2、基址加 3。第四项却不是合法的“基址加 5”:它的低位虽然以 ...7c57 结尾,高位已经从 0x7ffc... 变成 0xffffffff...。这是缺少函数声明后,编译器按错误的整型返回值处理,再把它转换成指针所造成的截断/符号扩展现象。这个 ptr3 是坏指针,不能解引用或继续使用;画面中把它当成正确字符串末尾地址的解释是错误的。
最后一项来自标准 strchr(),(nil) 表示本次实现对空指针的输出。视频随后添加:
if (ptr4 == NULL)
{
return;
}
它能阻止后续代码使用查找失败得到的 ptr4。由于添加判断后没有再次运行,屏幕上的 (nil) 属于添加判断之前的运行;若运行顶部最终源码,会在该判断处返回,不再执行其后的 printf()。
strchrnul() 也不能笼统称为“更安全”。它只是用结尾指针替代 NULL,适合调用者无论是否匹配都需要继续扫描到字符串末尾的场景;如果业务必须区分“找到了字符”和“没找到”,调用者仍要检查返回位置指向的是不是 \0,并注意查找字符本身为 \0 时也会返回同一位置。标准 C 中可移植的等价思路是先调用 strchr(),失败时再令指针指向 s + strlen(s)。
在嵌入式工具链中能否使用 strchrnul(),取决于所用 C 库及其头文件,而不能只看编译器是否名为 GCC。若库不提供该 GNU 扩展,应使用标准 strchr() 并检查 NULL,或者自己编写受测试和维护的兼容封装。
1.5 编译警告
本节出现的警告都不能直接忽略:
- 把指针交给
%d:%d读取的是int,与指针类型和大小都可能不同。视频已改成%p;严格写法还要将对应实参转换成void *。 strchrnul隐式声明:根因是_GNU_SOURCE定义得太晚,导致<string.h>没有声明该函数。C99 及之后不允许依赖旧式的隐式函数声明。- 由整数生成指针:这是上一项错误的连锁结果。64 位指针通常比旧式假定的
int更宽,视频输出已经直观展示了高位丢失后生成的无效地址。 unsigned char *与char *不兼容:本节所有字符串函数都声明为接收const char *,课程源码却传入unsigned char[]。顶部为忠实记录而保留,新代码应使用char[]保存普通字符串。%p的实参类型:变参调用不会自动把任意对象指针转换成void *,可移植写法应显式转换,例如printf("%p\n", (void *)ptr);。
建议在练习时开启较完整的诊断,并把关键警告当作错误处理,例如:
gcc -std=gnu11 -Wall -Wextra -Wpedantic -Werror main.c -o main
这里使用 GNU 方言是因为示例包含 strchrnul();若目标是严格 ISO C 可移植代码,则应改用标准接口并选择 -std=c11 等标准模式。警告是编译器指出类型契约可能已被破坏的诊断,本节的错误地址正说明“程序还能生成并运行”不等于行为正确。