本文翻译自 C Strings and my slow descent to madness - by Diego Crespo
C Strings and my slow descent to madness
最近我一直在学习C语言,深入了解底层编程的复杂性。作为一名数据科学家/Python程序员,我经常与字符串打交道。人们说在C语言中处理字符串从棘手到非常糟糕。我很好奇,所以决定看看这个兔子洞有多深。
C 字符串
C 字符串是一个以 null 终止符\0
结尾的字符数组。当C语言操作字符串时, null 终止符告诉函数已经到达字符串的结尾。在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;
}
这种方式容易出错,需要你自己插入 null 终止符。对于长单词,写起来也很费时,评分0/10。第二种方式是作为双引号括起来的字符串。
#include <stdio.h>
int main() {
char myString[] = "Hello, World!\n";
printf("%s", myString);
return 0;
}
在这种情况下,C语言知道字符串的确切长度,并可以自动插入 null 终止符。
String Operations 字符串操作
一旦你正确编码了字符串,就可以执行许多操作。常见的字符串函数包括strcpy
、strlen
和strcmp
。strcpy
将一个变量中存储的字符串复制到另一个变量中。strlen
获取字符串的长度(不包括 null 终止符),strcmp
接受两个字符串,当它们相等时返回0。不幸的是,使用字符串函数时有很多细微差别。首先,让我们看看每个函数的示例,从strcpy
开始。
int main() {
char source[] = "Hello, world!";
char destination[20];
strcpy(destination, source); // 将源字符串复制到目标字符串
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); // 将源字符串复制到目标字符串
printf("Source: %s\n", source);
printf("Destination: %s\n", destination);
return 0;
}
你可以这样做。只是destination
现在变成了char*
,作为指向source
字符数组的指针存在。如果这不是你想要的,这几乎肯定会引起问题。
我们的下一个字符串操作是strlen
,它获取字符串的大小,不包括 null 终止符。
#include <stdio.h>
#include <string.h>
int main() {
char str[] = "Hello, world!"; // 要查找长度的字符串
int length = strlen(str); // 查找字符串的长度
printf("The length of the string '%s' is %d.\n", str, length);
return 0;
}
strlen
的打印函数输出结果如下。
The length of the string 'Hello, world!' is 13.
这很简单直接。它只是计算字符,直到遇到 null 终止符。
我们的最后一个函数是strcmp
。它查看两个字符串,确定它们是否相等。如果相等,它返回0。如果不相等,它返回1。
#include <stdio.h>
#include <string.h>
int main() {
char str1[] = "Hello, world!";
char str2[] = "hello, world!";
int result = strcmp(str1, str2); // 比较两个字符串
if (result == 0) {
printf("The strings are equal.\n");
} else {
printf("The %s is not equal to %s\n", str1, str2);
}
return 0;
}
我们的 strcmp
函数的输出…
The strings are not equal
现在我们知道了如何复制、获取长度和比较字符串,我要抛出一个重磅炸弹。这些操作都不安全,很容易产生未定义行为。这主要围绕使用\0
作为 null 终止符。在上述C函数以及其他函数中,C语言期望找到一个\0
来告诉函数停止读取字符串所在的内存区域。但如果不存在 null 终止符呢?那么,C语言会很高兴地继续读取字符串本应结束后的内存内容。如果我们的程序功能是验证用户提供的密码,恶意行为者可能会利用字符串的缓冲区溢出,跳过密码检查所在的内存区域,直接进入密码成功时调用的函数,从而绕过整个授权过程。那么我们该如何处理呢?
Attempting to make C safe 尝试使 C 安全
如果你四处打听,可能会找到一个名为strncpy
的函数。查看它的定义时,你会看到它将源字符串复制到目标字符串,并允许你指定要复制的字节数。你可能会说 “这看起来很完美!”。我可以确保我的目标字符串只接收它能处理的那么多字节。下面的代码展示了这一点以及它的输出结果。
#include <stdio.h>
#include <string.h>
#define dest_size 12
int main(){
char source[] = "Hello, World!";
char dest[dest_size];
// 从源复制最多12个字符到目标
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 终止符的长度与目标字符串的大小一样长时,会发生什么?答案是目标字符串被源字符串的所有字符填满,没有空位留给空终止符。没有正确 null 终止的字符串肯定会给你后面带来麻烦。你可能会说“好吧”。至少它可以处理源字符串小于目标字符串的情况吧?是的,它可以处理这种情况,但strcpy
也可以。如果源字符串小于目标字符串,目标字符串中未使用的额外空间仍然被保留并填充。所以,如果目标字符串长20个字符,但源字符串只有13个,你会得到一个看起来像这样的目标字符串。
char destination[20] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!', '\0', '\0', '\0', '\0', '\0', '\0', '\0'};
所以没有正确的 null 终止和过多的填充。这不太好。如果你碰巧在 Windows 上使用strncpy
函数,微软Visual C++(MSVC)编译器甚至不会编译这个程序。你必须手动设置一个标志来允许使用已弃用的功能,这是一个暗示,你可能不应该要使用它。
它建议使用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
此时,如果你正对着天空挥拳,诅咒 C 标准委员会拖延了34年仍未实现可移植的安全字符串操作,我不会怪你。
那么我们如何安全地处理这种情况呢?我能想到几种方法。
-
如果你处理的是已知长度的字符串,就像我们这个虚构的例子一样,可能简单到将目标字符串初始化为源字符串的
sizeof()
大小。 -
你可以直接使用源字符串的指针,完全放弃复制。只要源字符串正确终止,你就不用处理缓冲区大小不匹配的问题。
-
你可以放弃可移植性,在Windows上使用字符串函数的
_s
版本,在macOS上使用 “l” 版本的函数。 -
你可以使用其他语言 🙊
到目前为止,你可能已经注意到我花了大量时间谈论strcpy
,但只简要提到了strcmp
和strlen
。它们都受到C字符串终止方式的影响。因为字符串的长度直到遇到 null 终止符才可知,所以你会遇到各种未定义行为和攻击向量。这与C++形成对比,C++将字符串作为对象处理,并将字符串的长度与字符数一起编码。这就是为什么人们倾向于用 C++ 写 C 语言的原因之一。使用你认为“好”的所有部分,忽略其他部分。
要在纯C语言中正确处理这些,需要仔细实现围绕字符串操作的检查。这是容易出错的,随着程序变大,难度也会增加。这就是C语言被认为是不安全语言的原因之一。
Dealing with non Latin languages 💃 处理非拉丁语言 💃
Unicode是计算机文本编码的重要一步。今天,UTF-8是文本的主导编码。我在文章《Breaking the Snake: How Python went from 2 to 3》中简要总结了它的历史,所以这里不再赘述。C语言直到 C99 标准才增加了 Unicode支持,即使你在C语言中正确处理它,也可能以其他方式遇到问题,你马上就会看到。如果我们尝试打印一些日文字符……
#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, ""); // 将区域设置为用户的默认区域设置
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中的dir命令输出
我的“\”现在变成了“¥”,但如果这能奏效,我可以忍受。我给一个测试文件夹命名为“有り難う”,以确保PowerShell能正确显示它。如果我们现在查看测试文件夹,我们会看到日文字符现在可以正确显示了。但即使做了这些更改,代码仍然不能打印字符!我尝试将区域设置改为ja_JP.UTF8
,但仍然无法得到输出。经过更多的谷歌搜索,我找到了一篇标题为《PowerShell控制台字符在Windows Server 2022上对中文、日文和韩文语言乱码》的文章,它说……
默认情况下,Windows PowerShell .lnk快捷方式被硬编码为使用“Consolas”字体。“Consolas”字体没有CJK字符的字形,所以字符不能正确渲染。明确将字体更改为“MS Gothic”可以解决这个问题,因为“MS Gothic”字体有 CJK 字符的字形。
命令提示符(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, ""); // 将区域设置为用户的默认区域设置
wchar_t thankyou[] = L"有り難う";
wprintf(L"Thank You in Japanese is: %ls\n", thankyou);
return 0;
}
我在Raspberry PI上运行它……它奏效了!
我在Macbook Pro上尝试它,它也能正常工作。我在Macbook Pro上的 PowerShell 上运行它……它仍然可以工作……所以谢天谢地,这不是C语言中的一个bug,但这确实看起来像是Windows在终端中处理非拉丁字符的方式的一个问题。我只能说……对微软宽容一点。他们是小型独立开发者……但说真的,如果有人知道如何在Windows 10上让这奏效,请告诉我!
现在我们可以在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个字符。原因是我们将字符串解释为ascii。由于日文字符需要多个字节来编码,所以4个日文字符的每个字节都被解释为一个单独的字母,而不是将每个字节簇关联为一个日文字符。如果我们把字符串改为宽字符(wchar_t
),通过在前面加上“L”,并使用wcslen
代替strlen
,我们就得到了下面的代码……
#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 Strings and my slow descent to madness - by Diego Crespo
本文链接:https://blog.csdn.net/u012028275/article/details/145213226