1. 字元串操作函數

程序按功能劃分可分為數值運算、符號處理和I/O操作三類,符號處理程序占相當大的比例,符號處理程序無處不在,編譯器、瀏覽器、Office套件等程序的主要功能都是符號處理。無論多複雜的符號處理都是由各種基本的字元串操作組成的,本節介紹如何用C語言的庫函數做字元串初始化、取長度、拷貝、連接、比較、搜索等基本操作。

1.1. 初始化字元串

#include <string.h>

void *memset(void *s, int c, size_t n);
返回值:s指向哪,返回的指針就指向哪

memset函數把s所指的內存地址開始的n個位元組都填充為c的值。通常c的值為0,把一塊內存區清零。例如定義char buf[10];,如果它是全局變數或靜態變數,則自動初始化為0(位於.bss段),如果它是函數的局部變數,則初值不確定,可以用memset(buf, 0, 10)清零,由malloc分配的內存初值也是不確定的,也可以用memset清零。

1.2. 取字元串的長度

#include <string.h>

size_t strlen(const char *s);
返回值:字元串的長度

strlen函數返回s所指的字元串的長度。該函數從s所指的第一個字元開始找'\0'字元,一旦找到就返回,返回的長度不包括'\0'字元在內。例如定義char buf[] = "hello";,則strlen(buf)的值是5,但要注意,如果定義char buf[5] = "hello";,則調用strlen(buf)是危險的,會造成數組訪問越界。

1.3. 拷貝字元串

第 1 節 “本章的預備知識”中介紹了strcpystrncpy函數,拷貝以'\0'結尾的字元串,strncpy還帶一個參數指定最多拷貝多少個位元組,此外,strncpy並不保證緩衝區以'\0'結尾。現在介紹memcpymemmove函數。

#include <string.h>

void *memcpy(void *dest, const void *src, size_t n);
void *memmove(void *dest, const void *src, size_t n);
返回值:dest指向哪,返回的指針就指向哪

memcpy函數從src所指的內存地址拷貝n個位元組到dest所指的內存地址,和strncpy不同,memcpy並不是遇到'\0'就結束,而是一定會拷貝完n個位元組。這裡的命名規律是,以str開頭的函數處理以'\0'結尾的字元串,而以mem開頭的函數則不關心'\0'字元,或者說這些函數並不把參數當字元串看待,因此參數的指針類型是void *而非char *

memmove也是從src所指的內存地址拷貝n個位元組到dest所指的內存地址,雖然叫move但其實也是拷貝而非移動。但是和memcpy有一點不同,memcpy的兩個參數srcdest所指的內存區間如果重疊則無法保證正確拷貝,而memmove卻可以正確拷貝。假設定義了一個數組char buf[20] = "hello world\n";,如果想把其中的字元串往後移動一個位元組(變成"hhello world\n"),調用memcpy(buf + 1, buf, 13)是無法保證正確拷貝的:

例 25.1. 錯誤的memcpy調用

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

int main(void)
{
	char buf[20] = "hello world\n";
	memcpy(buf + 1, buf, 13);
	printf(buf);
	return 0;
}

在我的機器上運行的結果是hhhllooworrd。如果把代碼中的memcpy改成memmove則可以保證正確拷貝。memmove可以這樣實現:

void *memmove(void *dest, const void *src, size_t n)
{
	char temp[n];
	int i;
	char *d = dest;
	const char *s = src;

	for (i = 0; i < n; i++)
		temp[i] = s[i];
	for (i = 0; i < n; i++)
		d[i] = temp[i];

	return dest;
}

借助于一個臨時緩衝區temp,即使srcdest所指的內存區間有重疊也能正確拷貝。思考一下,如果不借助于臨時緩衝區能不能正確處理重疊內存區間的拷貝?

memcpy如果得到的結果是hhhhhhhhhhhhhh倒不奇怪,可為什麼會得到hhhllooworrd這個奇怪的結果呢?根據這個結果猜測的一種可能的實現是:

void *memcpy(void *dest, const void *src, size_t n)
{
	char *d = dest;
	const char *s = src;
	int *di;
	const int *si;
	int r = n % 4;
	while (r--)
		*d++ = *s++;
	di = (int *)d;
	si = (const int *)s;
	n /= 4;
	while (n--)
		*di++ = *si++;

	return dest;
}

在32位的x86平台上,每次拷貝1個位元組需要一條指令,每次拷貝4個位元組也只需要一條指令,memcpy函數的實現儘可能4個位元組4個位元組地拷貝,因而得到上述結果。

C99的restrict關鍵字

我們來看一個跟memcpy/memmove類似的問題。下面的函數將兩個數組中對應的元素相加,結果保存在第三個數組中。

void vector_add(const double *x, const double *y, double *result)
{  
	int i;  
	for (i = 0; i < 64; ++i)  
		result[i] = x[i] + y[i];  
}

如果這個函數要在多處理器的計算機上執行,編譯器可以做這樣的優化:把這一個循環拆成兩個循環,一個處理器計算i值從0到31的循環,另一個處理器計算i值從32到63的循環,這樣兩個處理器可以同時工作,使計算時間縮短一半。但是這樣的編譯優化能保證得出正確結果嗎?假如resultx所指的內存區間是重疊的,result[0]其實是x[1]result[i]其實是x[i+1],這兩個處理器就不能各幹各的事情了,因為第二個處理器的工作依賴于第一個處理器的最終計算結果,這種情況下編譯優化的結果是錯的。這樣看來編譯器是不敢隨便做優化了,那麼多處理器提供的並行性就無法利用,豈不可惜?為此,C99引入restrict關鍵字,如果程序員把上面的函數聲明為void vector_add(const double *restrict x, const double *restrict y, double *restrict result),就是告訴編譯器可以放心地對這個函數做優化,程序員自己會保證這些指針所指的內存區間互不重疊。

由於restrict是C99引入的新關鍵字,目前Linux的Man Page還沒有更新,所以都沒有restrict關鍵字,本書的函數原型都取自Man Page,所以也都沒有restrict關鍵字。但在C99標準中庫函數的原型都在必要的地方加了restrict關鍵字,在C99中memcpy的原型是void *memcpy(void * restrict s1, const void * restrict s2, size_t n);,就是告訴調用者,這個函數的實現可能會做些優化,編譯器也可能會做些優化,傳進來的指針不允許指向重疊的內存區間,否則結果可能是錯的,而memmove的原型是void *memmove(void *s1, const void *s2, size_t n);,沒有restrict關鍵字,說明傳給這個函數的指針允許指向重疊的內存區間。在restrict關鍵字出現之前都是用自然語言描述哪些函數的參數不允許指向重疊的內存區間,例如在C89標準的庫函數一章開頭提到,本章描述的所有函數,除非特別說明,都不應該接收兩個指針參數指向重疊的內存區間,例如調用sprintf時傳進來的格式化字元串和結果字元串的首地址相同,諸如此類的調用都是非法的。本書也遵循這一慣例,除非像memmove這樣特別說明之外,都表示“不允許”。

關於restrict關鍵字更詳細的解釋可以參考[BeganFORTRAN]

字元串的拷貝也可以用strdup(3)函數,這個函數不屬於C標準庫,是POSIX標準中定義的,POSIX標準定義了UNIX系統的各種介面,包含C標準庫的所有函數和很多其它的系統函數,在第 2 節 “C標準I/O庫函數與Unbuffered I/O函數”將詳細介紹POSIX標準。

#include <string.h>

char *strdup(const char *s);
返回值:指向新分配的字元串

這個函數調用malloc動態分配內存,把字元串s拷貝到新分配的內存中然後返回。用這個函數省去了事先為新字元串分配內存的麻煩,但是用完之後要記得調用free釋放新字元串的內存。

1.4. 連接字元串

#include <string.h>

char *strcat(char *dest, const char *src);
char *strncat(char *dest, const char *src, size_t n);
返回值:dest指向哪,返回的指針就指向哪

strcatsrc所指的字元串連接到dest所指的字元串後面,例如:

char d[10] = "foo";
char s[10] = "bar";
strcat(d, s);
printf("%s %s\n", d, s);

調用strcat函數後,緩衝區s的內容沒變,緩衝區d中保存着字元串"foobar",注意原來"foo"後面的'\0'被連接上來的字元串"bar"覆蓋掉了,"bar"後面的'\0'仍保留。

strcatstrcpy有同樣的問題,調用者必須確保dest緩衝區足夠大,否則會導致緩衝區溢出錯誤。strncat函數通過參數n指定一個長度,就可以避免緩衝區溢出錯誤。注意這個參數n的含義和strncpy的參數n不同,它並不是緩衝區dest的長度,而是表示最多從src緩衝區中取n個字元(不包括結尾的'\0')連接到dest後面。如果src中前n個字元沒有出現'\0',則取前n個字元再加一個'\0'連接到dest後面,所以strncat總是保證dest緩衝區以'\0'結尾,這一點又和strncpy不同,strncpy並不保證dest緩衝區以'\0'結尾。所以,提供給strncat函數的dest緩衝區的大小至少應該是strlen(dest)+n+1個位元組,才能保證不溢出。

1.5. 比較字元串

#include <string.h>

int memcmp(const void *s1, const void *s2, size_t n);
int strcmp(const char *s1, const char *s2);
int strncmp(const char *s1, const char *s2, size_t n);
返回值:負值表示s1小於s2,0表示s1等於s2,正值表示s1大於s2

memcmp從前到後逐個比較緩衝區s1s2的前n個位元組(不管裡面有沒有'\0'),如果s1s2的前n個位元組全都一樣就返回0,如果遇到不一樣的位元組,s1的位元組比s2小就返回負值,s1的位元組比s2大就返回正值。

strcmps1s2當字元串比較,在其中一個字元串中遇到'\0'時結束,按照上面的比較準則,"ABC""abc"小,"ABCD""ABC"大,"123A9""123B2"小。

strncmp的比較結束條件是:要麼在其中一個字元串中遇到'\0'結束(類似於strcmp),要麼比較完n個字元結束(類似於memcmp)。例如,strncmp("ABCD", "ABC", 3)的返回值是0,strncmp("ABCD", "ABC", 4)的返回值是正值。

#include <strings.h>

int strcasecmp(const char *s1, const char *s2);
int strncasecmp(const char *s1, const char *s2, size_t n);
返回值:負值表示s1小於s2,0表示s1等於s2,正值表示s1大於s2

這兩個函數和strcmp/strncmp類似,但在比較過程中忽略大小寫,大寫字母A和小寫字母a認為是相等的。這兩個函數不屬於C標準庫,是POSIX標準中定義的。

1.6. 搜索字元串

#include <string.h>

char *strchr(const char *s, int c);
char *strrchr(const char *s, int c);
返回值:如果找到字元c,返回字元串s中指向字元c的指針,如果找不到就返回NULL

strchr在字元串s中從前到後查找字元c,找到字元c第一次出現的位置時就返回,返回值指向這個位置,如果找不到字元c就返回NULLstrrchrstrchr類似,但是從右向左找字元c,找到字元c第一次出現的位置就返回,函數名中間多了一個字母r可以理解為Right-to-left。

#include <string.h>

char *strstr(const char *haystack, const char *needle);
返回值:如果找到子串,返回值指向子串的開頭,如果找不到就返回NULL

strstr在一個長字元串中從前到後找一個子串(Substring),找到子串第一次出現的位置就返回,返回值指向子串的開頭,如果找不到就返回NULL。這兩個參數名很形象,在乾草堆haystack中找一根針needle,按中文的說法叫大海撈針,顯然haystack是長字元串,needle是要找的子串。

搜索子串有一個顯而易見的算法,可以用兩層的循環,外層循環把haystack中的每一個字元的位置依次假定為子串的開頭,內層循環從這個位置開始逐個比較haystackneedle的每個字元是否相同。想想這個算法最多需要做多少次比較?其實有比這個算法高效得多的算法,有興趣的讀者可以參考[算法導論]

1.7. 分割字元串

很多檔案格式或協議格式中會規定一些分隔符或者叫界定符(Delimiter),例如/etc/passwd檔案中保存着系統的帳號信息:

$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
...

每條記錄占一行,也就是說記錄之間的分隔符是換行符,每條記錄又由若干個欄位組成,這些欄位包括用戶名、密碼、用戶id、組id、個人信息、主目錄、登錄Shell,欄位之間的分隔符是:號。解析這樣的字元串需要根據分隔符把字元串分割成幾段,C標準庫提供的strtok函數可以很方便地完成分割字元串的操作。tok是Token的縮寫,分割出來的每一段字元串稱為一個Token。

#include <string.h>

char *strtok(char *str, const char *delim);
char *strtok_r(char *str, const char *delim, char **saveptr);
返回值:返回指向下一個Token的指針,如果沒有下一個Token了就返回NULL

參數str是待分割的字元串,delim是分隔符,可以指定一個或多個分隔符,strtok遇到其中任何一個分隔符就會分割字元串。看下面的例子。

例 25.2. strtok

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

int main(void)
{
	char str[] = "root:x::0:root:/root:/bin/bash:";
	char *token;

	token = strtok(str, ":");
	printf("%s\n", token);
	while ( (token = strtok(NULL, ":")) != NULL)
		printf("%s\n", token);
	
	return 0;
}

$ ./a.out 
root
x
0
root
/root
/bin/bash

結合這個例子,strtok的行為可以這樣理解:冒號是分隔符,把"root:x::0:root:/root:/bin/bash:"這個字元串分隔成"root""x""""0""root""/root""/bin/bash"""等幾個Token,但空字元串的Token被忽略。第一次調用要把字元串首地址傳給strtok的第一個參數,以後每次調用第一個參數只要傳NULL就可以了,strtok函數自己會記住上次處理到字元串的什麼位置(顯然這是通過strtok函數中的一個靜態指針變數記住的)。

gdb跟蹤這個程序,會發現str字元串被strtok不斷修改,每次調用strtokstr中的一個分隔符改成'\0',分割出一個小字元串,並返回這個小字元串的首地址。

(gdb) start
Breakpoint 1 at 0x8048415: file main.c, line 5.
Starting program: /home/akaedu/a.out 
main () at main.c:5
5	{
(gdb) n
6		char str[] = "root:x::0:root:/root:/bin/bash:";
(gdb) 
9		token = strtok(str, ":");
(gdb) display str
1: str = "root:x::0:root:/root:/bin/bash:"
(gdb) n
10		printf("%s\n", token);
1: str = "root\000x::0:root:/root:/bin/bash:"
(gdb) 
root
11		while ( (token = strtok(NULL, ":")) != NULL)
1: str = "root\000x::0:root:/root:/bin/bash:"
(gdb) 
12			printf("%s\n", token);
1: str = "root\000x\000:0:root:/root:/bin/bash:"
(gdb) 
x
11		while ( (token = strtok(NULL, ":")) != NULL)
1: str = "root\000x\000:0:root:/root:/bin/bash:"

剛纔提到在strtok函數中應該有一個靜態指針變數記住上次處理到字元串中的什麼位置,所以不需要每次調用時都把字元串中的當前處理位置傳給strtok,但是在函數中使用靜態變數是不好的,以後會講到這樣的函數是不可重入的。strtok_r函數則不存在這個問題,它的內部沒有靜態變數,調用者需要自己分配一個指針變數來維護字元串中的當前處理位置,每次調用時把這個指針變數的地址傳給strtok_r的第三個參數,告訴strtok_r從哪裡開始處理,strtok_r返回時再把新的處理位置寫回到這個指針變數中(這是一個Value-result參數)。strtok_r末尾的r就表示可重入(Reentrant),這個函數不屬於C標準庫,是在POSIX標準中定義的。關於strtok_r的用法Man Page上有一個很好的例子:

例 25.3. strtok_r

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

int main(int argc, char *argv[])
{
	char *str1, *str2, *token, *subtoken;
	char *saveptr1, *saveptr2;
	int j;

	if (argc != 4) {
		fprintf(stderr, "Usage: %s string delim subdelim\n",
			argv[0]);
		exit(EXIT_FAILURE);
	}

	for (j = 1, str1 = argv[1]; ; j++, str1 = NULL) {
		token = strtok_r(str1, argv[2], &saveptr1);
		if (token == NULL)
			break;
		printf("%d: %s\n", j, token);

		for (str2 = token; ; str2 = NULL) {
			subtoken = strtok_r(str2, argv[3], &saveptr2);
			if (subtoken == NULL)
				break;
			printf(" --> %s\n", subtoken);
		}
	}

	exit(EXIT_SUCCESS);
}

$ ./a.out 'a/bbb///cc;xxx:yyy:' ':;' '/'
1: a/bbb///cc
 --> a
 --> bbb
 --> cc
2: xxx
 --> xxx
3: yyy
 --> yyy

a/bbb///cc;xxx:yyy:這個字元串有兩級分隔符,一級分隔符是:號或;號,把這個字元串分割成a/bbb///ccxxxyyy三個子串,二級分隔符是/,只有第一個子串中有二級分隔符,它被進一步分割成abbbcc三個子串。由於strtok_r不使用靜態變數,而是要求調用者自己保存字元串的當前處理位置,所以這個例子可以在按一級分隔符分割整個字元串的過程中穿插着用二級分隔符分割其中的每個子串。建議讀者用gdbdisplay命令跟蹤argv[1]saveptr1saveptr2,以理解strtok_r函數的工作方式。

Man Page的BUGS部分指出了用strtokstrtok_r函數需要注意的問題:

  • 這兩個函數要改寫字元串以達到分割的效果

  • 這兩個函數不能用於常量字元串,因為試圖改寫.rodata段會產生段錯誤

  • 在做了分割之後,字元串中的分隔符就被'\0'覆蓋了

  • strtok函數使用了靜態變數,它不是綫程安全的,必要時應該用可重入的strtok_r函數,以後再詳細介紹“可重入”和“綫程安全”這兩個概念

習題

1、出於練習的目的,strtokstrtok_r函數非常值得自己動手實現一遍,在這個過程中不僅可以更深刻地理解這兩個函數的工作原理,也為以後理解“可重入”和“綫程安全”這兩個重要概念打下基礎。

2、解析URL中的路徑和查詢字元串。動態網頁的URL末尾通常帶有查詢,例如:

http://www.google.cn/search?complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=
http://www.baidu.com/s?wd=linux&cl=3

比如上面第一個例子,http://www.google.cn/search是路徑部分,?號後面的complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=是查詢字元串,由五個“key=value”形式的鍵值對(Key-value Pair)組成,以&隔開,有些鍵對應的值可能是空字元串,比如這個例子中的鍵meta

現在要求實現一個函數,傳入一個帶查詢字元串的URL,首先檢查輸入格式的合法性,然後對URL進行切分,將路徑部分和各鍵值對分別傳出,請仔細設計函數介面以便傳出這些字元串。如果函數中有動態分配內存的操作,還要另外實現一個釋放內存的函數。完成之後,為自己設計的函數寫一個Man Page。