C语言字符串处理的惊天大坑问题解决

2023-05-20 05:05:39 字符串 大坑 惊天

引言

毋庸置疑,在使用 C 字符串时必须小心,否则你就会因为各种的未定义行为而感到头疼。

最近,我一直在学习 C 语言,也因此领教了低级编程所涉及的复杂性。作为一名数据科学家或者是 python 程序员,我一直在与字符串打交道。有人说,C 语言中的字符串处理非常糟糕。我很好奇,所以想一探究竟。

C 语言字符串

C 语言的字符串是以空终止符 \0 结尾的字符数组。在 C 语言操作字符串时,空终止符会告诉函数已到达字符串的末尾。在 C 中,我们可以通过两种不同的方式声明一个字符串。

第一种也是最困难的方法是定义字符数组。

#include <stdio.h>
int main() {
char myString[] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd','!','\n','\0'};
printf("%s", myString);
return 0;
}

这种方式易出错,且需要手动插入空终止符。如果单词很长,键入的时间也会很长。

第二种方式是用双引号括起来的字符串。

#include <stdio.h>
int main() {
char myString[] = "Hello, World!\n";
printf("%s", myString);
return 0;
}

在这种情况下,C 知道字符串的长度,就可以自动插入空终止符。

字符串操作
正确创建字符串之后,你就可以执行许多操作了。常用的字符串操作函数包括 strcpy、strlen 和 strcmp。

●strcpy:将存储在一个变量中的字符串复制到另一个变量中。

●strlen:获取字符串的长度(不包括空终止符)。

●strcmp:用于比较两个字符串并根据比较结果返回整数。基本形式为 strcmp(str1,str2),若 str1=str2,则返回零;若 str1<str2,则返回负数;若 str1>str2,则返回正数。

然而坏消息是,每个字符串函数的使用都有细微的差别。首先,我们来看一个 strcpy 的示例。

int main() {
char source[] = "Hello, world!";
char destination[20];
strcpy(destination, source); // Copy the source string to the destination string
printf("Source: %s\n", source);
printf("Destination: %s\n", destination);
return 0;
}

输出结果如下:

Source: Hello, world!
Destination: Hello, world!

如你所料,strcpy 的作用就是复制一个字符串并将其内容放入另一个字符串中。但你可能会问:“为什么我不能直接将源变量分配给目标变量?”

int main() {
char source[] = "Hello, world!";
char* destination = source;
strcpy(destination, source); // Copy the source string to the destination string
printf("Source: %s\n", source);
printf("Destination: %s\n", destination);
return 0;
}

事实上,这样也未尝不可。只不过现在 destination 变成了 char*,而且是作为指向源字符数组的指针存在。

下一个字符串操作是 strlen,它的作用是获取字符串的大小,但不包括空终止符。

#include <stdio.h>
#include <string.h>
int main() {
char str[] = "Hello, world!"; // The string to find the length of
int length = strlen(str); // Find the length of the string
printf("The length of the string '%s' is %d.\n", str, length);
return 0;
}

输出结果如下:

The length of the string 'Hello, world!' is 13.

这个函数很简单,就是统计字符数量,直到遇到空终止符。

我们的最后一个函数是 strcmp,它的作用是比较两个字符串,看看它们是否相等。如果相等,则返回 0;若 str1<str2,则返回负数;若 str1>str2,则返回正数。

#include <stdio.h>
#include <string.h>
int main() {
char str1[] = "Hello, world!";
char str2[] = "hello, world!";
int result = strcmp(str1, str2); // Compare the two strings
if (result == 0) {
printf("The strings are equal.\n");
} else {
printf("The %s is not equal to %s\n", str1, str2);
}
return 0;
}

输出结果如下:

The strings are not equal

我们了解了如何复制字符串、获取字符串的长度,以及如何比较字符串,下面我们来看一些难点。

上述这些函数没有一个是安全的操作,而且很容易产生未定义的行为。根源在于使用 \0 作为空终止符。对于上述 C 函数以及其他函数,C 希望找到一个 \0,然后告诉函数停止读取字符串所在的内存区域。但是如果没有空终止符呢?在字符串应该结束后,C 会继续读取内存中的内容。如果我们的程序函数需要验证用户提供的密码,那么不法分子可能会利用字符串的缓冲区溢出,跳过检查密码的内存区域,直接调用获取密码的函数。这样就可以避开授权。 

那么,我们应当如何处理呢?

保证 C 代码的安全性

四处搜寻,你可能会发现一个名为 strncpy 的函数。查看定义,你会发现这个函数可以将源字符串复制到目标字符串中,并允许指定复制的字节数。你可能会说:“这个函数看起来很完美!”我可以确保目标字符串只接收它可以处理的字节数。下面的代码展示了这个函数的用法及其输出。

#include <stdio.h>
#include <string.h>
#define dest_size 12
int main(){
char source[] = "Hello, World!";
char dest[dest_size];
// Copy at most 12 characters from source to dest
strncpy(dest, source, dest_size);
printf("Source string: %s\n", source);
printf("Destination string: %s\n", dest);
return 0;
}
Source string: Hello, World!
Destination string: Hello, World

初看之下还不错,但还是有问题。如果源字符串的长度减去空终止符的长度后正好等于目标字符串的长度,结果会怎样?

答案是目标字符串会被源字符串的所有字符填满,没有空间留给空终止符。一个没有非 null 终止的字符串势必会引发各种令你头疼的问题。你可能会说,但至少它可以处理源字符串小于目标字符串的情况。是吗?没错,它确实可以处理这种情况,但 strcpy 也可以。如果源字符串的长度小于目标字符串,那么目标字符串中所有未使用的额外空间仍将保留,而且会被填充。因此,假设目标字符串的长度为 20 个字符,但源字符串只有 13 个字符,那么实际上你得到的是一个像下面这样的目标字符串。

char destination[20] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!', '\0', '\0', '\0', '\0', '\0', '\0', '\0'};

这个字符串没有正确的空终止,而且还有一大堆填充字符。情况不太妙。如果你碰巧在 windows 上使用 strncpy 函数,那么 Microsoft Visual c++ 甚至都编译不过去。你必须手动设置一个标志,允许使用已弃用的功能,当然我们不应该使用已弃用的功能。

编译器建议改用 strncpy_s。我们来看看,strncpy_s 接受这些参数:

●char *restrict dest:目标字符串。

●rsize_t destsz:目标字符串的大小。

●const char *restrict src:要复制的源字符串。

●rsize_t count:从源字符串复制的最大字节数。

如果目标字符串的长度大于源字符串,那么复制可以顺利进行。但如果目标字符串的长度小于源字符串,则只复制目标 -1 的大小。strncpy_s 进行的额外检查是确保将源字符串复制到目标字符串中,并且生成的字符串始终以 null 结尾。这很好,但是我们又遇到了两个问题。

●strncpy_s 不会处理额外的填充字符。

●strncpy_s 不可移植到 MacOS 或 linux

看到这里,你是不是拳头都硬了,想问一问已经过去 34 年了,为什么 C 标准委员会还是没能没有提供可移植且更安全的字符串操作?

那么,我们应该如何安全地应对这种情况呢?我想到了几种方法。

1.如果已知字符串的长度,就像我们人为设计的示例一样,那么只需将目标字符串初始化为 sizeof() 源字符串。

2.你可以直接使用指向源字符串的指针,并完全放弃复制。只要源字符串有正确的终止符,你就不会遇到缓冲区大小不匹配的情况。

3.你可以放弃可移植性,在 Windows 上使用 _s 版本的字符串函数,或在 macOS 上使用“ l ”版本。

4.你可以选用其他语言。

至此,你可能已经注意到我花了很多时间谈论 strcpy,而 strcmp 和 strlen 只是一笔带过。实际上,这两个函数也会遇到由于 C 的字符串终止方式引发的相同问题。由于字符串的长度在遇到空终止符之前是未知的,所以你会遇到各种未定义的行为和攻击向量。这与 C++ 形成了鲜明的对比,C++ 将字符串视为对象,并将字符串的长度和字符数保存到了一起。这就是人们倾向于用 C++ 编写 C 的原因之一。

为了在纯 C 中正确处理这些问题,你需要认真检查字符串的操作。这些操作很容易出错,而且随着程序规模增加,难度也会上升。这就是我们认为 C 不安全的原因之一。

非拉丁语言的处理

Unicode 是计算机文本编码的重要环节。如今文本使用最广泛的编码是 UTF-8。C 语言直到版本 C99 才获得了 Unicode 支持,而且即使你在 C 语言中正确处理 Unicode,也会遇到其他方面的问题。假设我们需要输出一些日文字符:

#include <stdio.h>
#include <string.h>
int main() {
printf("有り難う\n");
return 0;
}

输出就会出问题:

这是因为我们没有按照 Unicode 解释字符。下面我们来重写上述代码:

#include <stdio.h>
#include <wchar.h>
#include <locale.h>
int main() {
setlocale(LC_ALL, ""); // Set the locale to the user's default locale
wchar_t thankyou[] = L"有り難う";
wprintf(L"Thank You in Japanese is: %ls\n", thankyou);
return 0;
}

我添加了一个字符串:“Thank You in Japanese is”,仔细观察下面的屏幕截图,你就能明白其中的原因。但是输出结果依然没有显示日文。

检查 Powershell 控制台的编码,我们发现它是 ASCII 格式的。我们来试试看修改编码方式:$OutputEncoding = [System.Text.Encoding]::UTF8。这样就变成了 UTF-8。但依然不起作用。可能是因为字体不支持日文。我快速上网搜索了以下,然后发现 MS Gothic 字体支持日文,所以我修改了字体。

怎么反斜杠(“ \ ”)变成了“ ¥ ”?但如果这样可以显示日文的话,我也可以接受。我将一个测试文件夹命名为“有り难う”,以确保 PowerShell 能够正确显示文件名。下面,我们来看一看这个文件夹,我们看到文件名可以正常显示。但即使这样修改代码,输出结果依然无法显示汉字字符!我尝试将语言环境设置为 ja_JP.UTF8,但仍然无法输出日文。继续上网搜索,我看到一篇文章讨论如何在 Windows Server 20222 上 PowerShell 控制台中显示中文、日文以及韩文的文章,其中指出:

默认情况下,Windows PowerShell .lnk 快捷方式会被硬编码为使用“ Consolas ”字体。“ Consolas ”字体不包含中文、日文以及韩文字符的字形,因此无法正确呈现这些字符。将字体更改为“ MS Gothic ”可以解决这个问题,因为“ MS Gothic ”字体拥有汉字字符。

命令提示符(cmd.exe)没有这个问题,因为 cmd .lnk 快捷方式没有指定字体。控制台会根据系统语言在运行时选择正确的字体。

解决方法

该问题很快就能在 Windows 11 和 Windows Server 2022 中得到修复,但不会向后移植到较低版本。

如果想解决这个问题,请使用以下两种解决方法之一。

虽然文中提到的问题与我遇到的问题略有差别,但似乎默认情况下 PowerShell 并不能很好地处理日文字符。我尝试结合使用命令提示符与 MS Gothic,但也没能解决问题。上网搜索的所有结果表明我的代码可以在 C 中运行。于是,我将代码恢复到了第一版:

#include <stdio.h>
#include <wchar.h>
#include <locale.h>
int main() {
setlocale(LC_ALL, ""); // Set the locale to the user's default locale
wchar_t thankyou[] = L"有り難う";
wprintf(L"Thank You in Japanese is: %ls\n", thankyou);
return 0;
}

然后在树莓派上运行,结果发现可以正常工作!

我在 Macbook Pro 上试了一下,也没有任何问题。我在 Macbook Pro 上启动 PowerShell,一切依然正常,所以这不是 C 中的一个 bug,但看起来确实是 Windows 在终端中处理非拉丁字符的方式有问题。

下面,我们来看看如何在 C 中正确输出日文字符,这是我们的最后一个例子。如上所述,我们可以通过 strlen 来获取字符串的长度。接下来,我们来修改 C 代码,获取日文字符串的 strlen,如下所示:

#include <stdio.h>
#include <string.h>
int main() {
printf("The length of the string is %d characters\n", strlen("有り難う"));
return 0;
}

输出结果如下:

The length of the string is 12 characters

改成前面最初版本的输出,就可以看到这 12 个字符。

你可能已经注意到了这个字符串包含 12 个字符,原因是我们将字符串解释为 ascii。由于每个汉字被编码成了 4 个字节,因此每个字节都被解释为一个单独的字母,而无法集中到一起形成一个汉字。如果我们给字符串加上前缀“ L ”,将字符串的类型从 char 改为 w_char,然后将函数 strlen 改为 wcslen,代码如下:

#include <stdio.h>
#include <wchar.h>
#include <locale.h>
int main() {
printf("The length of the string is %d characters\n", wcslen(L"有り難う"));
return 0;
}

输出结果如下:

The length of the string is 4 characters

这样问题就得到了解决!

在本文中,我们探讨的 C 语言字符串相关知识只不过是一些皮毛,我们甚至没有提及 C11 中引入的 Unicode 文字,例如“ u8 ”、“ u ”和“ U ”。毋庸置疑,在使用 C 字符串时必须小心,否则你就会因为各种的未定义行为而感到头疼。另一方面,你的代码还会受到不法分子的攻击。如果你只有使用垃圾收集编程语言的经验,那么要仔细想一想是否有必要大费周折学习 C 语言。Python 这类语言提供了很多数据科学领域使用的库,其中大部分建立在 C 和 C++ 之上。当然这些库也必须有人去编写,如果你有这方面的知识,几乎所有语言都有一个 C 外部函数接口,可以用来提高代码的运行速度,所以其他语言也能受惠。所以,我们都应该学习一下 C 语言,但也许不应该从字符串开始学习。

以上就是C语言字符串处理的惊天大坑?的详细内容,更多关于C语言字符串处理的资料请关注其它相关文章!

相关文章