【CSDN 編者按】毋庸置疑,在使用 C 字串時必須小心,否則你就會因為各種的未定義行為而感到頭疼。
原文連結:https://www.deusinmachina.net/p/c-strings-and-my-slow-descent-to
未經允許,禁止轉載!
作者 | DIEGO CRESPO 譯者 | 彎月
責編 | 王子彧
最近,我一直在學習 C 語言,也因此領教了低級程式設計所涉及的複雜性。作為一名資料科學家或者是 Python 程式設計師,我一直在與字串打交道。有人說,C 語言中的字串處理非常糟糕。我很好奇,所以想一探究竟。

C 語言字串
C 語言的字串是以空終止符 \0 結尾的字符陣列。在 C 語言操作字串時,空終止符會告訴函數已到達字串的末尾。在 C 中,我們可以通過兩種不同的方式聲明一個字串。
第一種也是最困難的方法是定義字符陣列。
#include
int main() {
char myString[] = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd','!','\n','\0'};
printf("%s", myString);
return 0;
}
這種方式易出錯,且需要手動插入空終止符。如果單詞很長,鍵入的時間也會很長。
第二種方式是用雙引號括起來的字串。
#include
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
然而壞訊息是,每個字串函數的使用都有細微的差別。首先,我們來看一個 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
#include
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
#include
#include
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
#include
#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
#include
int main() {
printf("有り難う\n");
return 0;
}
輸出就會出問題:
這是因為我們沒有按照 Unicode 解釋字符。下面我們來重寫上述程式碼:
#include
#include
#include
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
#include
#include
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
#include
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
#include
#include
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 語言,但也許不應該從字串開始學習。