爲什麼operator>>(istream&, string&)能夠安全地讀入長度未知的字符串?

一般而言,實現“讀入用戶輸入的字符串”,程序中自然不能對用戶輸入的長度有所限定。這在C++中很容易實現,而在C中確沒那麼容易。

這一疑問,我在剛學C++的時候也在腦中閃現過;不過很快將它拋在腦後了。直到最近,我在百度知道上討論一個單詞統計問題(鏈接)時,才重新想起。於是,翻出gcc 4.6.1的代碼,瀏覽了一番。

首先,明確這裏探討的場景——從標準輸入(或字符模式打開的文件)中讀取一個字符串(換行、空格、tab間隔均可)。用C++實現這一功能有兩種選擇——使用C標準庫和使用C++標準庫,典型代碼如下(錯誤處理代碼省略):

 

C風格

C++風格

標準輸入

char word[16];

scanf(”%s”, word);

string word;

cin >> word;

文件輸入(字符模式)

char word[16];

FILE* fptr = NULL;

fptr = fopen(”input.txt”, ”rt”);

fscanf(fptr, ”%s”, word);

string word;

ifstream ifs(”input.txt”); // 默認ifsteam::in,可以不寫

ifs >> word;

(由於標準輸入輸出和字符模式打開的文件並無太大區別,所以下面C風格只對fscanf探討。C++ 標準輸入和文件輸入原本調用的就是同一個函數。)

下面,對分別對C風格的字符串輸入和C++風格的字符串輸入進行探討。

scanf讀入字符串

在使用fscanf(fptr, “%s”, word);方式讀入字符串時,格式串”%s”上可以加長度限定,但不是必須的。

格式串上沒有長度限定時

通常,我們不會在格式串上加長度限定。但是這種寫法是不安全的,比如有下面的一段程序:

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

void login()
{
	int valid = 0;
	char password[6] = {0};
	
	printf("Please input password: ");
	scanf("%s", password);
	
	if( strcmp("pass", password) == 0 ) 
		valid = 1;
	
	if( valid ) {
		printf("password correct!\n");
	}
	else {
		printf("PASSWORD INCORRECT!\n");
	}
}

int main()
{
	while(1) {
		login();
	}
	
	return 0;
}

這段程序模擬典型的密碼驗證過程。可以看看程序幾次的運行結果:


當用戶輸入的字符串長度小於等於6時,不會有問題。但如果長度大於6,可能會出現:1) password不正確已然能夠驗證成功(剛超出6);或者出現 2)程序崩潰(更長)。

爲什麼這麼做不安全?

1)棧向下生長,所以valid,和password在login()棧幀上相鄰;且password的地址更低。如下所示:

如果scanf(“%s”, password);讀入的字符串是”123456A”,內存映像如下:

valid所在內存被scanf讀入password時越界寫入,若此時輸出password,valid應分別爲”123456A”, 65(’A’的ASCII值,Intel機器,小端對齊)


2)實際輸入的越長,讀入password時越界寫入的字節數就越多。

而在多數體系結構(比如Intel機器)上,函數調用時(執行call指令)會將當前的指令指針(IA32的EIP)壓棧,函數返回時(執行ret指令)會將指令指針彈出;再繼續運行。一旦password越界寫到該位置,函數執行ret語句後EIP將指向與調用該函數前不同的地址。通常,這個地址是個無效的值,會導致段錯誤。如果,你向這裏寫入了一個可執行的函數地址(可以是操作系統API、庫函數等),順帶寫入了這個函數所需的參數;這樣該函數返回時就會執行另外的代碼,這就是著名的“緩衝區溢出攻擊”。



格式串上有長度限定時

我們習慣於在printf的格式串上加長度限定,比如printf(”%5d\n”, icount);
而scanf的格式串也是可以加長度限定的,比如scanf(”%5s”,buf);
這時又會怎樣呢?可經如下代碼實驗之:

#include <stdio.h>

int main(int argc, char *argv[])
{
	char buf[6];
	while( scanf("%5s", buf) == 1 ) {
		printf("scaned:%s\n", buf);
	}
	return 0;
}

運行如下:


(第一行爲輸入,後面連續幾行爲輸出)

這樣的寫法是安全的,但並不是我們所需——讀取未知長度的字符串。

比如在圖片所示的測試中,用戶輸入的字符串是: 1234567890123和123456。而程序每次只能讀取5個字符(結束符佔一個,所以比緩衝小一個),它會將1234567890123截斷。單從其返回後的狀態並不能區分:這些截斷原本是一個長的字符串,還是用戶分別輸入的多個獨立的字符串,也就是:

另外,這種寫法存在一個隱患,即必須時刻保證:(格式串上限定的長度)≤ (實際輸入緩衝長度 - 1)。實際編程時,要一直保證這一關係比較困難。尤其是緩衝的大小和格式串長度不在一個代碼片段中時;這同時也給修改代碼帶來了不小的麻煩。

下面,來看看C++標準庫是如何實現的。

operator>>()讀入字符串

使用C++標準庫,實現讀入一個(長度未知的)字符串的代碼非常簡單,只需聲明一個std::string str;,然後cin>>str;即可:

#include <iostream>
#include <string>
using namespace std;

// 略...
	string str;
	cin >> str;

這和,輸入一個int,float的代碼沒什麼兩樣——對程序員和用戶都很友好。

對於cin >> str;可以讀入任意長度的字符串(只要內存夠用),就和std::string的operator+=一樣,大家都司空見慣了(我也是)。直到最近,當我從會C的角度來看這個問題時,便有了標題的疑問。
(同時發現Eclipse(CDT)對運算符重載代碼也能很方便的定位,只需按住Ctrl,鼠標點擊你要查看的運算符即可,該opertor的定義立刻出現)
Eclipse直接定位到的是:

  template<>
    basic_istream<char>&
    operator>>(basic_istream<char>& __is, basic_string<char>& __str);

而,該段特化版本的聲明在我的gcc 4.6.1中並不能繼續定位到實現。而緊鄰它的泛型版的聲明:
<basic_string.h>:

  /**
   *  @brief  Read stream into a string.
   *  @param is  Input stream.
   *  @param str  Buffer to store into.
   *  @return  Reference to the input stream.
   *
   *  Stores characters from @a is into @a str until whitespace is found, the
   *  end of the stream is encountered, or str.max_size() is reached.  If
   *  is.width() is non-zero, that is the limit on the number of characters
   *  stored into @a str.  Any previous contents of @a str are erased.
   */
  template<typename _CharT, typename _Traits, typename _Alloc>
    basic_istream<_CharT, _Traits>&
    operator>>(basic_istream<_CharT, _Traits>& __is,
	       basic_string<_CharT, _Traits, _Alloc>& __str);

  template<>
    basic_istream<char>&
    operator>>(basic_istream<char>& __is, basic_string<char>& __str);

可以找到實現:
<basic_string.tcc>:

995

996

997

998

999

1000

1001

1002

1003

1004

1005

1006

1007

1008

1009

1010

1011

1012

1013

1014

1015

1016

1017

1018

1019

1020

1021

1022

1023

1024

1025

1026

1027

1028

1029

1030

1031

1032

1033

1034

1035

1036

1037

1038

1039

1040

1041

1042

1043

1044

1045

1046

1047

1048

1049

1050

1051

1052

1053

1054

1055

1056

1057

1058

1059

1060

1061

1062

1063

1064

1065

1066

  // 21.3.7.9 basic_string::getline and operators

  template<typename_CharT,typename_Traits,typename_Alloc>

    basic_istream<_CharT,_Traits>&

    operator>>(basic_istream<_CharT,_Traits>&__in,

           basic_string<_CharT,_Traits,_Alloc>& __str)

    {

      typedef basic_istream<_CharT, _Traits>     __istream_type;

      typedef basic_string<_CharT, _Traits, _Alloc__string_type;

      typedef typename __istream_type::ios_base        __ios_base;

      typedef typename __istream_type::int_type     __int_type;

      typedef typename __string_type::size_type     __size_type;

      typedef ctype<_CharT>             __ctype_type;

      typedef typename __ctype_type::ctype_base        __ctype_base;

 

      __size_type __extracted = 0;

      typename __ios_base::iostate __err = __ios_base::goodbit;

      typename __istream_type::sentry __cerb(__in,false);

      if (__cerb)

    {

      __try

        {

          // Avoid reallocation for common case.

          __str.erase();

          _CharT __buf[128];

          __size_type __len = 0;      

          const streamsize __w = __in.width();

          const __size_type __n = __w > 0 ? static_cast<__size_type>(__w)

                                      : __str.max_size();

          const __ctype_type& __ct = use_facet<__ctype_type>(__in.getloc());

          const __int_type __eof = _Traits::eof();

          __int_type __c = __in.rdbuf()->sgetc();

 

          while (__extracted < __n

             && !_Traits::eq_int_type(__c, __eof)

             && !__ct.is(__ctype_base::space,

                 _Traits::to_char_type(__c)))

        {

          if (__len == sizeof(__buf) / sizeof(_CharT))

            {

              __str.append(__buf, sizeof(__buf) /sizeof(_CharT));

              __len = 0;

            }

          __buf[__len++] = _Traits::to_char_type(__c);

          ++__extracted;

          __c = __in.rdbuf()->snextc();

        }

          __str.append(__buf, __len);

 

          if (_Traits::eq_int_type(__c, __eof))

        __err |= __ios_base::eofbit;

          __in.width(0);

        }

      __catch(__cxxabiv1::__forced_unwind&)

        {

          __in._M_setstate(__ios_base::badbit);

          __throw_exception_again;

        }

      __catch(...)

        {

          // _GLIBCXX_RESOLVE_LIB_DEFECTS

          // 91. Description of operator>> and getline() for string<>

          // might cause endless loop

          __in._M_setstate(__ios_base::badbit);

        }

    }

      // 211.  operator>>(istream&, string&) doesn't set failbit

      if (!__extracted)

    __err |= __ios_base::failbit;

      if (__err)

    __in.setstate(__err);

      return __in;

    }

暫且不看錯誤處理,從Ln1017到Ln1045即爲主要邏輯。看到1018行的__buf[]數組,我們已經能夠猜出大概。
這裏調用了basic_istream的一些成員函數:
    width()  用於控制輸入輸出的寬度,相當於printf,scnaf格式串上的長度限定的作用。它有兩個版本,這裏都調用了。
            無參數版本返回當前字段的寬度,默認情況下返回0;有參數版本用於設定當前字段寬度,這裏設定爲了0。
    rdbuf()  返回和basic_istream相關的basic_streambuf(存放實際字符)
和streambuf的一些成員函數:
    sgetc()  讀取當前字符
    snextc()  前進到下一位置,並讀取字符
以及string的成員函數:
    append()  字符串追加
此時,再看Ln1025—Ln1041就非常清楚了:

	      __int_type __c = __in.rdbuf()->sgetc();

	      while (__extracted < __n
		     && !_Traits::eq_int_type(__c, __eof)
		     && !__ct.is(__ctype_base::space,
				 _Traits::to_char_type(__c)))
		{
		  if (__len == sizeof(__buf) / sizeof(_CharT))
		    {
		      __str.append(__buf, sizeof(__buf) / sizeof(_CharT));
		      __len = 0;
		    }
		  __buf[__len++] = _Traits::to_char_type(__c);
		  ++__extracted;
		  __c = __in.rdbuf()->snextc();
		}
	      __str.append(__buf, __len);

遷移到C環境

據此我們可以寫出一個C語言版本的fgetvs():

// get variable length string.
char *fgetvs(FILE *stream)
{
	int c;
	char buf[4] = {0};
	size_t len = 0;

	char *str = NULL;
	size_t slen = 0;

	c = fgetc(stream);
	while( c != EOF && !isspace(c) )
	{
		if(len == sizeof(buf)/sizeof(char))
		{
			void *old = str;
			str = strapp(str, slen, buf, sizeof(buf)/sizeof(char));
			slen += len;
			free(old);
			len = 0;
		}
		buf[len++] = (char)c;
		c = fgetc(stream);
		// printf("DEBUG: %c, %d, %s\n", c, len, buf);
	}
	str = strapp(str, slen, buf, len);
	// slen += len;

	return str;
}
(這段代碼的變量命名基本上與上面operator>>裏的一致,但沒有__下劃線)

這段代碼使用(char*,size_t)模擬std::string,可以避免頻繁調用strlen。(也可以僅用char*,每次strlen求長度)
strapp返回新字符串而不改變傳入的兩個字符串。

代碼的難點由轉移到了strapp(),正確的寫出strapp也不難,完整的模擬程序代碼如下:

#include <stdio.h>
#include <stdlib.h> // for malloc free
#include <assert.h> // for assert
#include <ctype.h>  // for isspace
#include <string.h> // for memcpy

// string append.
static char *strapp(const char *str, size_t len,
			const char *appstr, size_t applen)
{
	char *pnew = NULL;

	pnew = (char*)malloc( (len + applen + 1)	* sizeof(char));
	assert(pnew != NULL);
	if(str) {
		memcpy(pnew, str, len);
	}
	memcpy(pnew+len, appstr, applen);
	pnew[len+applen] = '\0';
	return pnew;
}

// get variable length string.
char *fgetvs(FILE *stream)
{
	int c;
	char buf[4] = {0};
	size_t len = 0;

	char *str = NULL;
	size_t slen = 0;

	c = fgetc(stream);
	while( c != EOF && !isspace(c) )
	{
		if(len == sizeof(buf)/sizeof(char))
		{
			void *old = str;
			str = strapp(str, slen, buf, sizeof(buf)/sizeof(char));
			slen += len;
			free(old);
			len = 0;
		}
		buf[len++] = (char)c;
		c = fgetc(stream);
		// printf("DEBUG: %c, %d, %s\n", c, len, buf);
	}
	str = strapp(str, slen, buf, len);
	// slen += len;

	return str;
}

int main(int argc, char *argv[])
{
	puts(fgetvs(stdin));
	return 0;
}

getline也一樣

與讀取一個字符串相似的問題是——讀取一行(換行符間隔),二者並無太大區別,讀取字符串以空白字符(空格、TAB、換行)間隔,讀取一行則只以換行間隔。

只需將上面fgetvs()代碼while條件上的:
!isspace(c)
改成:
c != ‘\n’
即可實現fgetvl():

// get variable length line.
char *fgetvl(FILE *stream)
{
	int c;
	char buf[4] = {0};
	size_t len = 0;

	char *str = NULL;
	size_t slen = 0;

	c = fgetc(stream);
	while( c != EOF && c != '\n' )
	{
		if(len == sizeof(buf)/sizeof(char))
		{
			void *old = str;
			str = strapp(str, slen, buf, sizeof(buf)/sizeof(char));
			strcat();
			slen += len;
			free(old);
			len = 0;
		}
		buf[len++] = (char)c;
		c = fgetc(stream);
		// printf("DEBUG: %c, %d, %s\n", c, len, buf);
	}
	str = strapp(str, slen, buf, len);
	// slen += len;

	return str;
}

總結

爲什麼(未知長度的)字符串空間不能放在棧上?

上面對於scanf格式串上沒有長度限定的安全性的解釋已經同時回答了這個問題。C++的std::string對象本身只存放字符指針(以及緩衝長度),實際內存在堆上開闢(new/malloc申請)。我們用C模擬的版本可以很清楚的看到malloc。究其根本,其一,因爲棧向低地址方向生長,而字符串通常向高地址方向寫入。其二,只有函數調用棧頂的函數的棧幀可變,而該函數一旦返回,該函數棧幀上的所有內存都會被回收;在不借助堆的情況下,即便實現了在getvs棧上開闢空間,字符串向低地址方向寫入;那麼返回時這段空間還是會被撤銷,要想保存getvs讀入的字符串,必須在getvs調用前(上一棧幀)也在棧上開闢同樣大小的空間;而這與“只有調用棧頂的函數的棧幀可變”向矛盾。因此,不能將變長字符串存放在棧空間上。

這一場景下體現出C++標準庫的哪些好處?

個人感到的好處是string::append()。std::string實現了Buffer的功能——提供了append,insert等方法,而不需用戶關心內存操作。
另一個人感到的好處是由於operator>>被重載帶來的“接口一致性”,cin>>str和cin>>icount用起來差不多。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章