1. 程式人生 > >靜態程式碼分析——字串

靜態程式碼分析——字串

一、字串在百度PS的地位
字串在百度PS的地位自然不必多說,如果你有程式碼許可權的話,在檢索端任意模組的原始碼中grep一下strcpy就知道了。從使用者輸入的一個query到返回給使用者的整個頁面都是用字串來組織的,怎樣將使用者輸入的字串經過縝密的分析、最終決定返回給使用者哪些結果,並持續提升這兩者的相關性是我們不斷追求並永遠追求的目標。這個過程中充斥著對各種字串倒來倒去的無休止的複雜操作。詳細瞭解字串相關技術並思考如何最大程度、最小成本的保證程式中對字串操作的正確性就顯得尤為重要了,本文就與您共同探索一下字串的奧祕。
二、字串的基本概念
先介紹大家都知道的字串定義:由零個或多個字元順序排列組成的有限序列。它是一種特殊的線性表,其特殊性主要體現在組成表的每個元素均為一個字元,以及與此相應的一些特殊操作。
這個簡單定義中值得我們關注的地方有:
1、 字串可能由0個字元組成,也就是空串,千萬不要小瞧空串,很多程式bug就是在沒有考慮空串這個特殊情況時產生的。
2、 字串是有限序列,一般的說,有兩種型別的字串資料型別:“定長字串”,和“變長字串”。在現代高階程式語言中大多支援變長字串,例如c++中的StringBuffer類。所有變長字串還是在長度上有個極限,一般的說這個極限只依賴於可獲得的記憶體的數量。
3、 定義中並沒有限定字串的表示法和組織形式,雖然通常c程式語言中都是以NULL(ASCII碼是0)為結束符,而組織形式通常是以字元陣列的形式。但是並不排除其他的組織形式存在,例如,c++和java中的string型別就是將基本字串包裝成了類的高階應用;而Pascal語言的組織更是詭異:以一個整數值開頭來表示整個字串的長度,而沒有任何結束符標誌,大家坐公交車時也可以想一想這種組織方法有哪些弊端,反正我只知道Pascal是輸掉了與c的爭寵,?。當然,可能還有其它的表示法,使用樹和列表可以使得一些字串操作(如插入和刪除)更高效,這裡就不多加討論了,後面討論的都是c風格的字串,即以’\0’結尾的字串,這裡大家不要認為只有一種字串的組織形式就好了。
4、 定義中提到每個元素均為一個字元,這裡的一個字元是廣義的字元概念,並不是我們日常理解的1個byte,這個字元是編碼字元,常見的就是單位元組表示的ascII編碼字元,但是有些意音文字的語言比如漢語、日語和朝鮮語(合稱為CJK)的合理表示需要遠遠多於256個字元(每字元一個位元組編碼的極限),由此產生的編碼方式真是五花八門,足夠寫一本史記,從GBK、BIG-5到UTF-8、再到強大的Unicode,百家爭鳴的繁華背後全是字元編碼測試人員的眼淚,Unicode有望一統天下,但也是任重而道遠……
5、 各種針對字串操作的庫可能隨著c語言的出現就存在了,古老的要死,雖然我們一直在使用它們,但是對其中某些函式的瞭解還不夠深入,基於效率考慮,函式的設計者將某些責任推給了程式設計師,我們仍然非常有必要知道這些規範。

檢查點

測試方法

注意空串

[程式碼檢查]
看到程式碼中有對字串的操作就要想起是否可以正確處理空串,一般字串為空的處理邏輯都比較簡單(誰也不可能一個勁折騰空串玩),用眼睛“執行”一下應該可以發現問題。

是否在棧上定義了超大字元陣列

[程式碼檢查+valgrind]
考慮是否可以把這個陣列放在堆上,棧不是無限用的,聽說過“爆棧”麼?我們的線上機器的每執行緒8M棧空間曾經就爆過,可慘啦,

三、字串與陣列的曖昧關係
1、共同點——char型陣列
因為字串本質與陣列並沒有什麼實質區別,字串完全可以看做是char型陣列加結束符,因此可以像運算元組那樣自由操作字串,可以充分發揮c語言指標的強大作用,在一個字串上做你想要的任何操作,比如:可以使用&(取地址)運算子來找到字串中某個字元的記憶體地址;也可以通過對指向某個字串的指標使用*(取內容)運算子來得到實際的字元;再取地址、地址++、再取值、值++、等等,有點像那些變態的面試題哈?。


2、不同點——結束符
結束符是字串和陣列最大的不同,一些輸入輸出函式就是通過判斷結束符來執行的,比如在執行printf("%s",str);函式時,每輸出一個字元檢查一次,看下一個字元是否'\0'。遇'\0'就停止輸出,沒有’\0’當然就停不下來了,結束符的重要性立顯。


四、字串的初始化
1、字串常量和字元陣列
說起字串的初始化就不得不說字串常量和字元陣列。字元陣列是元素型別為字元的陣列,它既具有普通陣列的一般性質,又具有某些特殊性質。字串常量是用雙引號包圍的字元序列。儲存字串常量時,系統會在字元序列後自動加上'\0',標誌字串的結束。字串變數是以'\0'作為結束標誌的字元陣列,這個特點也是字元陣列不同於一般陣列的最大特點。字元陣列有兩種用法:一是當作字元的陣列來使用。這時的用法與整數的陣列、實數的陣列等相同,對字元陣列的輸入、輸出、賦值、引用等都是針對單個的元素進行。二是更為重要的用法即儲存、處理字串。這時它除了可以像普通陣列一樣使用外,還可以把字串作為一個整體進行操作。
對字元陣列的初始化有兩種方式。一種是用字元常量進行初始化,另一種是用字串常量進行初始化。用單引號字元常量放在大括號中初始化字元陣列需要一個一個的指定,夠笨、工作量也比較大,?;用字串常量初始化一個字元陣列時如果字串常量長度小於陣列長度,則只將這些字元賦紿陣列中前面的元素,其餘元素自動定為空字元(即'\0'),夠方便,但是要注意系統會在字元陣列的末尾自動加上一個字元'\0'。因此,要考慮陣列的長度比實際字元的個數大1,比如:
char str[5]="HELLO";
在編譯時是會失敗的,並無大礙,但是有時程式中暫時不能確定字元陣列的初始值,需要先定義陣列空間,再進行賦值,程式碼如下:

char str[5];
strcpy(str,"hello");

這次編譯器就形同虛設了,strcpy函式也假定程式設計師知道源字串的長度而不做越界檢查,結果導致str後面的記憶體被寫越界,會引發大問題並很難追查。

檢查點

測試方法

直接賦值初始化越界

[編譯器+pclint]
直接賦值的初始化越界可以由編譯器發現,pclint能檢測出不計算結尾符的常量字串長度大於目標變數長度的情況,如:

char str[5]="123456";會有報警“Warning 540: Excessive size

strcpy方式初始化越界

[pclint]

對於使用strcpy方式的初始化越界pclint掃描輸出

Warning 419: Apparent data overrun for function

'strcpy(signed char *, const signed char *)', argument 2 (size=6) exceedsargument 1 (size=5)”很清晰的指出越界原因

2、是否有初始值
字串初始化另一個需要特殊關注的點是不同儲存區有不同的初始值,其實這個不是字串陣列特有的,全域性變數和靜態變數,不管是陣列還是簡單變數還是複合變數,都預設全部置0。區域性變數和動態分配的變數,不管是什麼變數都是隨機數,小時候初識vc6.0的人還記得變數檢視視窗中顯示的字元陣列“燙燙燙燙燙燙燙。。。”吧,隨機數0xcc所致,告訴你要注意我啦,我還沒有初始化,很燙!!!?


檢查點 測試方法
是否有預設初始值 [valgrind]
全域性變數和靜態變數有預設初始值,而堆疊上的區域性變數和動態變數未置初始值由valgrind掃描可以發現,在使用時報未初始化錯誤。


3、沒有什麼是不可改變的
另外字串常量會儲存在程式的常量儲存區,屬於只讀資料段,內容不允許修改(當然,原則上不可以修改,但是理論上沒有什麼是不可改變的,通過繞過編譯器檢查的機制來改變),如下程式碼:

char* str = "abc";
(*str)++;

將會在vc debug版執行時報非法訪問錯誤,linux環境下執行會出core,但是在vc Release下是可以更改的,不信您試一試,同樣,怎樣在gnu c編譯器下修改只讀區我也不會,期待高手指點。有人說,將str定義成const char*就萬事大吉,編譯時會報錯,不錯的想法,但別忘了c/c++是強型別語言,可以通過最流氓的方式——強制型別轉換方式來“逼良為娼”:

const char* str = "abc";
char *p = (char *)str;
(*p) ++;

編譯會過,但是還是會出core。定義成const就告訴編譯器這個東東我不想讓別人修改,但是擋不住就是想修改的人(執意要改的人也並非都是壞人)。但是話說回來,如果想避免無意的錯誤修改,那就將能定義成const的都定義成const好了,益處多多!
檢查點 測試方法
修改只讀儲存區 [功能測試+gdb]
只要保證功能測試走到這部分程式碼就會出core,正常功能測試走不到的異常分支可以通過gdb使異常分支被覆蓋。


4、編譯器的“智慧”分配
對於字串常量的初始化,再多說兩句,請看下面程式碼:

char* str1  = "abc";
char* str2  = "abc";
cout << ( str1==str2 ) << endl; // 輸出1

編譯器對相同的字串常量在分配記憶體空間時放在了一起,有任何不同都不會放在一個地址空間中,這樣做帶來的好處是節省了記憶體,但是像上面說的,只讀區儲存的字串常量也是可以改變的,當改變了str1時也會改變str2的值,這個就需要特別注意了。


五、字串的輸入輸出
1、字串的輸入
 scanf函式
用帶 %s格式符的scanf函式可以進行字串的輸入。在使用中要注意兩個問題:一是scanf函式讀入的字串開始於第一個非空白符,包括下一個空白符('\n','\t',' ')之前的所有字元,最後自動加上’\0’。

    例: char str[10];
         scanf("%s",str);
    輸入:” hello world”

實際存入str的只有"hello",前面的空格被忽略掉,而"world"被留在輸入緩衝區中等待下一次輸入函式的讀入。
二是要保證字元陣列的長度足夠大,能容納下可能的最大輸入串。

    例: char t[10],s[5];
        scanf("%s",s);
        printf("%s  %s",t,s);
    輸入:ddddddddddddddddddddddddddddddd

則不僅存入了s的空間,還侵佔了t的空間,如果輸入再長一些的話就會出core。
 gets函式
gets函式專門用於輸入字串,一般形式是:
        gets(字元陣列名);
其中, 函式引數"字元陣列名" 規定了只能使用陣列名而不能是字串常量。與scanf函式不同,gets函式將使用者鍵入的換行符之前的所有字元(包括'\t'和' ')存入字元陣列,然後加上'\0',但換行符被丟掉。與scanf函式相同的是gets 函式也不檢查使用者輸入字串長度是否超過了字元陣列的容納能力,因此程式設計者要確保陣列足夠大。
2、字串輸出
 printf函式
用帶%s格式字元的printf函式能進行字串的輸出。存放在字元陣列中的字串被全部輸出,直至遇到'\0'為止。
 puts函式
puts函式專門用於字串輸出。一般形式是:
        puts(字串);
其中,引數"字串" 可以是字串常量, 也可以是字串變數。puts函式列印字串的全部內容,直至遇到'\0'為止,然後自動多列印一個'\n',起到換行的作用。而printf函式無此功能。
到底應該使用scanf和printf還是使用gets和puts,沒有一個絕對的標準。一般而言,當多種型別的混合輸入輸出時,選用scanf和printf;當大量文字資訊輸入輸出時,使用gets和puts,這兩個函式要稍稍快一些。
檢查點 測試方法
是否使用危險輸入輸出函式  [程式碼檢查+valgrind]
上述函式都屬於危險函式,儘量都不要使用,很容易發生緩衝區溢位,可以使用snprintf、fgets等有長度限制的輸入輸出函式,同時注意判斷sscanf的返回值是否合法。對於緩衝區溢位的情況valgrind也可以檢查出來。


六、基本字串處理函式介紹
字元測試函式想必大家都使用過,如isalpha(測試字元是否為英文字母)、islower(測試字元是否為小寫字母)、isdigit(測試字元是否為阿拉伯數字)等等,它們會省去我們自己來判斷某個字元的型別,不必擔心他們的效率,這些字元測試函式實際都是巨集而非函式呼叫,放心使用就好了。
介紹最有用又最常見的四個字串處理函式:strlen、strcat、strcmp、strcpy。這些函式的原型存放在string.h檔案中,在程式中使用它們時別忘了用#include命令包含string.h檔案。
 strlen函式
strlen函式測試字串的實際長度(不包括'\0'),並將該長度作為函式的值返回。函式引數“字串”可以是字串常量,也可以是字元變數,一般形式是:
    length=strlen(字串)
例:"ABC" 長度為3。
       "" 長度為0,空字串沒有有效字元,所以長度為0。
 strcat函式
strcat函式用於連結兩個字串。一般形式是:
        strcat(字串1,字串2);
strcat函式把字串2連結在字串1的後面。其中,引數“字串1"必須是字串變數,而"字串2"則可以是字串常量或變數。
呼叫strcat函式後,str1中字元後的'\0'取消,只在新串最後保留一個'\0'。
注意:strcat函式不檢查字串1的空白位置是否裝得下字串2。如果沒有足夠的空間,多餘的字元將溢位至鄰近的記憶體單元,破壞這些單元原來的內容。所以連結前應呼叫strlen函式進行檢驗,確保不發生溢位。記住在檢驗時給長度加1,為新字串的結束符'\0'留一個位置。
 strcmp函式
strcmp函式是比較兩個字串的大小,返回比較的結果。一般形式是:
        rtn=strcmp(s1,s2);
其中,s1、s2均可為字串常量或變數;rtn是用於存放比較結果的整型變數。比較結果是這樣規定的:
①s1小於s2,strcmp函式返回一個負值;
②s1等於s2,strcmp函式返回零;
③s1大於s2,strcmp函式返回一個正值;
字串大小的比較是以ASCII 碼錶上的順序來決定,此順序亦為字元的值。strcmp()首先將s1第一個字元值減去s2第一個字元值,若差值為0則再繼續比較下個字元,若差值不為0則將差值返回。
 strcpy函式
strcpy函式用於實現兩個字串的拷貝。一般形式是:
        strcpy(dest, source)
其中,dest必須是字串變數,而不能是字串常量。strcpy函式把source的內容完全複製到dest所指的記憶體空間中,而不管dest中原先存放的是什麼,如果dest空間不夠,則會引起 buffer overflow,複製後,source保持不變。
注意,由於字串是陣列型別,所以兩個字串複製不通過賦值運算進行。

    t=s; /*錯誤的字串複製*/
    strcpy(t,s); /*正確的字串複製*/

對於strcpy函式的使用要尤其注意,強調一下:
1)、要正確使用,確保dest所在的記憶體空間大於等於source所在的記憶體空間
2)、複製的結束是以source指標遇到'\0’為依據的,沒有遇到'\0’,就會一直複製下去。
3)、要確保dest有實際的記憶體也是很重要的,而這往往被忽略,dest為NULL,或未初始化,都是會產生執行時錯誤的。
同時附上strcpy的函式原型,世界上最精簡的函式(與bs中一個函式2000行相比小巫見大巫了哈):

char * strcpy(char *dest, char *source){
      char *temp = dest;
      while((*dest++ = *source++) != '\0');
      return temp;
}

除此之外,還有很多關於字串的庫函式,比如字串轉換以及更復雜的字串操作,請參見http://man.chinaunix.net/develop/c&c++/linux_c/default.htm,可能您已經在使用了。
另外string類給我們封裝了更加豐富的方法,僅查詢函式就由數十種之多,什麼插入、刪除、替換、比較、連線、賦值等等,琳琅滿目的,甚至在字串超過了當前分配的空間長度string類還會自動分配空間,非常方便,各種用法上網隨處都能找到。


七、常見錯誤分析
除了上面介紹的技術原理和錯誤之外,還有一些常見錯誤,如下:
1、sizeof和strlen
如果您的程式碼中充分考慮了程式未來的可維護性和擴充套件性,那麼就對下面程式碼中的sizeof和strlen不會陌生,請看下面的程式碼:

while(fgets(Buffer, sizeof(Buffer), fp) != NULL)
{
 if (Buffer[0] != '\0' && Buffer[strlen(Buffer)-1] != '\n')
 {
  ul_writelog(UL_LOG_WARNING, "line is too long", file);
 }
}

是否對sizeof和strlen的使用非常清楚呢?這段程式碼的意思是從fp檔案中讀取最大Buffer緩衝區長度,然後判斷讀取到的字串是否非空以及末尾是否以換行結束,非空並且沒有換行符則說明行超長,列印日誌。首先,Sizeof是運算子,strlen是庫函式;再者,Sizeof計算的是結構體空間大小,而strlen只判斷結尾符之前的字元個數(不包括結尾符),兩者有根本的區別,而且大多數情況下計算出來的值並不相等。
一個容易犯錯的例子如下:需求時傳送一個字串給對端,但是不傳送最後的\0字元,程式碼如下:

char str[] = "hello";
if ((len = send(socket_descriptor,str,sizeof(str),0)) == -1)
{
        perror("Error in send\n");
        exit(1);
}

傳送socket資料時多傳送了一個位元組,sizeof(str)為6,而實際要傳送的應該是5,錯用了sizeof,如果需要按實際長度傳送,應使用strlen(str)取字串的長度。
另外,sizeof的物件也要格外注意,例如:

char str[][6] = {"hello","world","haha"};
for(int i=0;i<3;i++)str[i][sizeof(str)-1] = 0;

這又是一個記憶體寫越界,sizeof(str)和sizeof(str[i])是完全不同的。
檢查點 測試方法
區分strlen與sizeof [程式碼檢查]
見到strlen想想是否該用sizeof,見到sizeof想想是否該用strlen,沒有更好的辦法。
sizeof物件是否正確 [pclint]
對於上例中的由於sizeof物件被放大造成記憶體寫越界,pclint可以直接掃描出來:Warning 661: Possible access of out-of-bounds pointer,不戰而屈人之兵
2、提防魔鬼數字
一些庫函式,比如strncat、strncpy、strncasecmp等都提供指定字串長度的引數,這些引數非常有用,可以指定最大參與操作的字元個數,可以有效避免目的地址越界問題,但是如果有如下程式碼:
strncpy(目的字串,源字串,13);
那麼想必誰都很頭疼“13”這個數字的由來,這個數字的學名也叫“魔鬼數字”,像魔鬼一樣來無影、去無蹤,這樣的編碼很可能本次升級沒有任何問題,但是如果以後的某次升級修改了源字串的有效長度很可能這個“13”就會出問題,而這種問題的追查也是破費心機的,因為要想定位這種改動程式碼對以往程式碼的影響的bug是比較麻煩的。所以最好的方法是用sizeof、strlen或巨集來避免魔鬼數字吧,讓我們在程式碼review時一起捉鬼!
檢查點 測試方法
找出魔鬼數字 [程式碼檢查]
建議用巨集或其他形式,程式中幾乎沒有必須要寫成魔鬼數字而不能用巨集定義的地方
3、時刻想到結束符
既然字串與陣列的最大不同在於末尾的結束符,我們就沒有理由忽視它的存在,沒有結束符的字串就像脫繮的野馬,不知道哪裡才是它的終點,strlen函式的計算值錯誤,printf函式也無法正常工作,怎麼樣,很可怕吧。
丟失結束符的情況是很多的,典型的是使用了某些庫函式比如strncpy,這個函式一度被認為是避免strcpy記憶體越界的完美替代函式,包括當前asm模組程式碼大量使用strncpy,但是這個函式有很大的問題,函式宣告是:
char *strncpy(char *s1, const char *s2, size_t n);
但 strncpy 其行為是很詭異的(不符合我們的通常習慣)。關於strncpy最大的誤解就是:它會用'\0'來結束目的字串。而實際上僅當源字串的長度小於引數n時才正確,大於等於的情況會丟掉結束符。當拷貝源字串的一部分時,正確的做法是使用strncpy之後,自己手工新增NUL來結束字串。請看程式段:

char buf[8];
strncpy( buf, "123456789", sizeof(buf));   //執行後buf為”12345678”,無結尾’\0’
buf[sizeof(buf)-1]=’\0’;    //這句話是很容易忘記的

另外,如果s2的內容比較少,而n又比較大的話,strncpy將會把之間的空間都用‘\0’填充。這又出現了一個效率上的問題,如下:

char buf[80];
strncpy( buf, "1234", 80 );

上面的 strncpy 會填寫 80 個char,有效字元”1234”之後全部以’\0’填充,做了很多無用功。
關於strncat最大的誤用就是錯誤地使用長度引數n。雖然strncat保證以NUL來結束字串,但你不應該將NUL也計算在引數n內,下面的長度引數計算是否讓你看了都頭疼呢?

char url[100] = “http://www.baidu.com”;
strncat(url, "/",sizeof(url) - strlen(url) - 1);
strncat(url, "family",sizeof(url) - strlen(url) - 1);
len = strlen(url);

其實我們可以使用另外2個OpenBSD的替代函式,宣告如下:
size_t strlcpy(char *dst, const char *src, size_t n);
size_t strlcat(char *dst, const char *src, size_t n);
strlcpy函式從NUL結尾的字串src複製size-1個字元到dst,用NUL作為結果的結尾。它們總是保證以NUL結束字串,它們都把目的字元陣列的全部長度作為引數,而且它們返回的是程式設計師想得到的字串的總長度。上面的函式用strlcpy和strlcat實現起來如下:

char url[100];
strlcpy(url, “http://www.baidu.com”,sizeof(url));
strlcat(url, "/",sizeof(url));
len = strlcat(url, "family",sizeof(url));
if ( len >= sizeof(url) )
       printf("buffer overflow");

strlcpy 返回的是strlen(src),因此我們也很方便的通過返回值可以判斷資料是否被截斷。通過判斷strlcat 的返回值可以知道緩衝區是否定義的太小以至於不足以放下2個字串,怎麼樣,方便吧,損失的一點效率換來的是太平盛世!唯一的不足之處是這兩個函式不是標準庫函式,不是大多數類Unix系統預設安裝的,不過這並不是個難題:因為它們只是小函式,甚至可以在自己程式的原始碼裡包含它們(至少作為一個選項),附上strlcpy和strlcat的原始碼如下:

size_t  strlcpy (char *dst, const char *src, size_t dst_sz)
{
    size_t n;
    for (n = 0; n < dst_sz; n++) {
    if ((*dst++ = *src++) == '\0')
        break;
    }
    if (n < dst_sz)
     return n;
    if (n > 0)
     *(dst - 1) = '\0';
    return n + strlen (src);
}
size_t  strlcat (char *dst, const char *src, size_t dst_sz)
{
    size_t len = strlen(dst);
    if (dst_sz < len)
     /* the total size of dst is less than the string it contains;
           this could be considered bad input, but we might as well
           handle it */
     return len + strlen(src);
    return len + strlcpy (dst + len, src, dst_sz - len);
}
Windows 下是沒有 strlcpy 的,對應的是strcpy_s函式。扯遠了哈,迴歸正題。
相似的丟失結束符的例子還有fread以二進位制的方式將檔案載入記憶體,不會在未尾加'\0',此時需要呼叫者在fread之後主動為文字的末尾加上'\0'結束符。
檢查點 測試方法
字串拷貝時是否丟掉結尾符 [valgrind]
在使用丟失了結束符的字串時是很容易被valgrind捕捉到的,例如,在列印時會報:Conditional jump or move depends on uninitialised value(s)
字串拷貝的效率問題 [程式碼檢查]
效率問題需要權衡,最安全的方式是strlcpy和strlcat,但是函式內部也會多一些判斷而影響效率,一個折中的方案是使用strncpy和strncat(glibc中沒有加入strlcpy和strlcat估計也是基於效率考慮),只是要計算好最後一個引數,避免上面說過的向大的目標緩衝區拷貝小字串帶來的低效率問題

一些人認為strncpy或strcpy在做記憶體拷貝時需要判斷是否到達結束符而沒有memcpy等記憶體操作函式高效,memcpy 雖然高效,但是memcpy卻有如下死穴:
A. 需要額外提供拷貝的記憶體長度這一引數,易錯且使用不便;
B. 如果長度指定過大的話(最優長度是源字串長度 + 1),還會帶來效能的下降。其實 strcpy 函式一般是在內部呼叫 memcpy 函式或者用匯編直接實現的,以達到高效的目的。因此,使用 memcpy 和 strcpy 拷貝字串在效能上沒有什麼大的差別;
C. 使用memcpy時指定的長度引數不是指定成源字串長度(用strlen)就是指定成目的buffer的大小(用sizeof)都是不好的,前者容易越界、後者容易截斷字串導致丟失結束符,沒有結束符的危害大家都知道了。
儘管strncpy有時候做的不是那麼完美,但卻也差強人意了,所以不要對正統的字串使用記憶體操作函式為好。一些無法作為字串看待的情況(比如一些包含\0的“字串”)就只能使用memcpy了,使用這個函式要格外小心。
4、目的地址空間是否足夠大
正如前面所說的,一些庫函式的設計者假定程式設計師都是謹慎的,把諸如檢查目的記憶體是否足夠放下源字串的任務推給了程式設計師,比如strcpy和strcat函式,如果引數dest所指的記憶體空間不夠大,可能會造成緩衝溢位(buffer Overflow)的錯誤情況,在編寫程式時請特別留意,或者用strncpy()來取代,這在上面已經說過了。
另外一個解決方法是在strcpy、strcat甚至memcpy實際拷貝之前就對字串和目的記憶體長度作檢查,而不是在拷貝之後做,因為那時緩衝區溢位可能已經發生了,當然,合法性檢查肯定會帶來效率的損失,這是需要衡量的,一定可以保證沒有問題的話就不必做了。
檢查點 測試方法
目的空間是否夠大 [valgrind+pclint]
除了上面初始化一節介紹的pclint可以檢查出直接賦值的緩衝區溢位外,這類錯誤在使用發生溢位的字串時一般都會發生訪問越界或使用未初始化錯誤,這類錯誤是逃不過valgrind的法眼的,valgrind和pclint一動一靜,確保程式碼質量!
5、地址重疊
即使檢查了目的地址空間夠大也不能保證字串拷貝一定正確,一個很難發現的bug是地址重疊導致記憶體覆蓋的問題,比如在vc6下執行如下程式碼:

char s[100]=”1234”
strcpy(s+3,s);

結果s變成了”1231234234”,如果字串長一些輸出會更詭異,以至於你不跟蹤到strcpy內部都搞不清楚是怎麼拷貝的,如果執行strcpy(s+4,s);則會無限遞迴拷貝,最終耗盡buffer,產生非法訪問錯誤。筆者在測試機上試驗了一下,上面例子在64位機上都不會有問題,猜想是因為64位機器暫存器一下子讀入8個位元組,在拷貝時已經讀到了結束符,不會再讀源串的緣故,改成下面的樣子:

char s[100]=”12345678”
strcpy(s+8,s);

core就出來了?,這個地址重疊的例子可能很容易發現,但是真實的產品線程式碼結構體都很大,而且多層巢狀,靠review來發現就不是很容易了。
檢查點 測試方法
目標地址與源地址是否有交疊 [valgrind + pclint]
那上面第一個不會出core的例子來說,pclint檢查的結果很直接的指出了存在資料越界,但是pclint對於發現複雜結構體的記憶體交疊不是很準確,用valgrind執行demo程式竟然出core,分析可能是valgrind不是一次讀入64位,而是逐個位元組的讀取源字串,嚴格按照strcpy函式的實現來執行程式,絲毫沒有編譯器的優化,valgrind說:“我很慢,但是我很可靠!”
6、函式返回值有效麼
還有這麼一類庫函式,他們完成的是查詢功能,比如:strchr(查詢字串中第一個出現的指定字元),如果找到指定的字元則返回該字元所在地址,否則返回NULL。這個函式的應用場景絲毫不比字串拷貝少,問題程式碼如下:

if(strcmp(grepstr,"")!=0)
{
    strncpy(name,grepstr,(strchr(grepstr,':')-grepstr-1));
}

程式中只考慮了正常情況下找到的情況,對於異常情況,即如果沒有找到指定字元函式strchr返回NULL的情況沒有相應處理,如果if中的程式碼寫成如下形式,程式就會健壯的多:

Uint index=strchr(grepstr,':');
If(index){
strncpy(name,grepstr,(index-grepstr-1));
}

正如stl容器stack類模板中pop和top沒有在一個方法中實現一樣,在程式設計中每一時刻只做一件事的原則是有它的道理所在的,如果想同時做多件事情並且做的漂亮、沒有bug可不是件容易的事情。
檢查點 測試方法
函式返回值是否做有效性判斷 [程式碼檢查]
碰見上面那種恐怖的鏈式表示式就要加萬分小心,畢竟幹多件事又能幹好的人不多,特別注意字串查詢函式,由於查不到而返回值NULL非常容易被遺漏,同樣,字串比較類函式也要關注相等情況的處理。
7、遊標越界
利用指標操作字串是程式設計師司空見慣的事情,如下程式碼會有遊標越界的問題:

char str[10] = "123",*p=str;
while(*(p++)!='\0'){
 cout<<"haha"<<endl;
}
strcpy(p,”456”);
cout<<str;

迴圈退出後p沒有指向str的結束符,而是指向了str後面的地址,一般都是不符合rd預期的(rd預期輸出”123456”),”456”被接在了”123”的結束符後面。
檢查點 測試方法
遊標越界 [程式碼檢查]
這類錯誤屬於遊標(指標)的非預期移動,並沒有記憶體非法訪問之類的錯誤,所以只能靠程式碼review來發現,其實上面的bug也是由於想同時幹兩件事引起的(既++又取內容),大家要養成用懷疑的眼光看待這種同時幹多件事情的表示式。
8、是否負越界
絕大多數記憶體越界都是正越界,即寫過了;其實還有一種越界是寫到前面去了,因為不容易發生,所以更難追查,問題程式碼如下:
在使用fgets等函式時,為了處理尾部的轉行,通常會使用下面這樣的程式碼:

int len = strlen(str);
while (str[len-1] == '\r' || str[len-1] == '\n')
    str[--len] = '\0';
以32位機為例,如果str[0xFFFFFFFF]中恰好是\r或\n(畢竟沒有什麼是不可能的),則可能產生len<0的情況,導致越界。
檢查點 測試方法
檢查負越界 [valgrind]
valgrind掃吧,一定可以發現
9、別操作string變數的底層實現
上面講到了,String類很強大,但是由於它封裝了字串長度、遊標偏移等資訊,不能將string變數直接賦值給基本字串變數,需要通過c_str()或data()方法轉換之後才能取出真正的c型別字串,他們的區別是c_str會返回以null結尾的“完整”字串,而data方法會返回非null終止的“裸”字串,示例程式碼如下:

string s1;
char *str = s1;// 編譯時報錯
char *str = s1.c_str();// 編譯還是過不去,這是為什麼?

幾乎就成功了,問題出在string類為了防止有人試圖直接修改其存放字串的實際地址(這樣會破壞c++的封裝思想,而且會帶來很多風險),c_str()返回值了一個const 指標,賦給char *當然編譯過不去了,解決方法大家都知道了,不贅述了。但是字串變數卻能直接賦給string變數,那是因為string類過載了“=”運算子的緣故。但是我強烈的建議你不要使用這個char *變數做任何操作,下面的bug無論從程式碼review和執行期間都極難發現:

string str="123",str_long[1000];
char * p= (char *)str.c_str();
str+="very long str…";//這裡加上一個很長的字串常量
cout<<p<<endl;

在linux下輸出的東東還是原來的“123”,而vc6就會輸出亂碼。因為就像前面說的,str會在空間不夠時自動分配其他地址空間,p所指向的字串實際地址被string釋放掉了,再使用釋放了的記憶體肯定是非常危險的,所以使用string類的話就儘量不要再操作它的內部實現好了,否則既破壞了c++的封裝思想,又極有可能費力不討好。
檢查點 測試方法
是否使用了指向string類的實際字串地址的指標 [valgrind]
程式碼review要想發現這類錯誤是不太可能了,幸好我們有強大的valgrind,執行時會報非法讀錯誤:Invalid read of size 1。
10、是否初始化
把未初始化錯誤放在最後的原因很簡單:由於記憶體未初始化導致的程式bug可能是各種bug中比例最高的,使用未初始化字串指標就像在非洲原始森林中撿起一個無比好看的果子往嘴裡放,能吐出來算你走運!!!
檢查點 測試方法
字串是否未初始化 [valgrind]
valgrind是檢查未初始化的專家,不再囉嗦了

checklist彙總
檢查點 測試方法
注意空串  [程式碼檢查]
看到程式碼中有對字串的操作就要想起是否可以正確處理空串,一般字串為空的處理邏輯都比較簡單(誰也不可能對空串情況執行什麼複雜的操作),用眼睛“執行”一下應該可以發現一些問題。
是否在棧上定義了超大字元陣列  [程式碼檢查+valgrind]
考慮是否可以把這個陣列放在堆上,棧空間不是無限使用的,聽說過“爆棧”麼?我們的線上ui的每執行緒8M棧空間曾經就,因為堆疊“爆炸”,core記錄的呼叫棧資訊也不可讀,原因追的那叫一個慘烈,但是用valgrind套件的massif工具可以記錄棧空間的使用情況,“爆棧事件與原因分析”詳見 http://com.baidu.com/twiki/bin/view/Test/UiStack%E6%BA%A2%E5%87%BA

直接賦值初始化越界  [編譯器+pclint]
直接賦值的初始化越界可以由編譯器發現,pclint能檢測出不計算結尾符的常量字串長度大於目標變數長度的情況,如: char str[5]="123456";會有報警“Warning 540: Excessive size”
strcpy方式初始化越界  [pclint]
對於使用strcpy方式的初始化越界pclint掃描輸出 “Warning 419: Apparent data overrun for function 'strcpy(signed char *, const signed char *)', argument 2 (size=6) exceeds argument 1 (size=5)”很清晰的指出越界原因”
是否有預設初始值  [valgrind]
全域性變數和靜態變數有預設初始值,而堆疊上的區域性變數和動態變數未置初始值由valgrind掃描可以發現,在使用時報未初始化錯誤。
是否修改只讀儲存區  [功能測試+gdb]
只要保證程式的執行流走到這部分程式碼就會出core,正常功能測試走不到的異常分支可以通過gdb使異常分支被覆蓋。
是否使用危險輸入輸出函式  [程式碼檢查+valgrind]
上述函式都屬於危險函式,儘量都不要使用,很容易發生緩衝區溢位,可以使用snprintf、fgets等有長度限制的輸入輸出函式,同時注意判斷sscanf的返回值是否合法。對於緩衝區溢位的情況valgrind也可以檢查出來。
區分strlen與sizeof  [程式碼檢查]
見到strlen想想是否該用sizeof,見到sizeof想想是否該用strlen,沒有更好的辦法。
sizeof物件是否正確  [pclint]
對於上例中的由於sizeof物件被放大造成記憶體寫越界,pclint可以直接掃描出來:Warning 661: Possible access of out-of-bounds pointer,不戰而屈人之兵。
找出魔鬼數字  [程式碼檢查]
魔鬼數字都是很好識別的,建議用巨集或其他形式,程式中幾乎沒有必須要寫成魔鬼數字而不能用巨集定義的地方
字串拷貝時是否丟掉結尾符  [valgrind]
在使用丟失了結束符的字串時是很容易被valgrind捕捉到的,例如,在列印時會報:Conditional jump or move depends on uninitialised value(s)
字串拷貝的效率問題  [程式碼檢查]
效率問題需要權衡,最安全的方式是strlcpy和strlcat,但是函式內部也會多一些判斷而影響效率,一個折中的方案是使用strncpy和strncat(glibc中沒有加入strlcpy和strlcat估計也是基於效率考慮),只是要計算好最後一個引數,避免上面說過的向大的目標緩衝區拷貝小字串帶來的低效率問題
目的空間是否夠大  [valgrind+pclint]
除了上面初始化一節介紹的pclint可以檢查出直接賦值的緩衝區溢位外,這類錯誤在使用發生溢位的字串時一般都會發生訪問越界或使用未初始化錯誤,這類錯誤是逃不過valgrind的法眼的,valgrind和pclint一動一靜,確保程式碼質量!
目標地址與源地址是否有交疊  [valgrind + pclint]
拿上面第一個不會出core的例子來說,pclint檢查的結果很直接的指出了存在資料越界,但是pclint對於發現複雜結構體的記憶體交疊不是很準確,用valgrind執行demo程式竟然出core,分析可能是valgrind不是一次讀入64位,而是逐個位元組的讀取源字串,嚴格按照strcpy函式的實現來執行程式,絲毫沒有編譯器的優化,valgrind說:“我很慢,但是我很可靠!”,這個正是我們想要的。
函式返回值是否做有效性判斷  [程式碼檢查]
碰見上面那種恐怖的鏈式表示式就要加萬分小心,畢竟同時做多件事又能幹好的人不多,特別注意字串查詢函式,由於查不到而返回值NULL非常容易被遺漏,同樣,字串比較類函式也要關注相等情況的處理。
遊標越界  [程式碼檢查]
這類錯誤屬於遊標(指標)的非預期移動,並沒有記憶體非法訪問之類的錯誤,所以只能靠程式碼review來發現,其實上面的bug也是由於想同時幹兩件事引起的(既++又取內容),大家要養成用懷疑的眼光看待這種同時幹多件事情的表示式。
檢查負越界  [valgrind]
valgrind掃掃吧,一定可以發現這類bug
是否使用了指向string類的實際字串地址的指標  [valgrind]
程式碼review要想發現這類錯誤是不太可能了,幸好我們有強大的valgrind,執行時會報非法讀錯誤:Invalid read of size 1。
字串是否未初始化  [valgrind]
valgrind是檢查變數未初始化的專家,不再囉嗦,掃吧