標籤:

cout 和 cin 的底層實現是怎樣的?


一步一步單步跟蹤即可。沒有源代碼跟下去了就跳轉到反彙編。

我就拿最簡單的為例吧:

std::cout &<&< "hahaha";

在這個語句下斷點,然後F11單步跟蹤,我只列出關鍵的部分:

std::cout &<&< "hahaha";

---------------------------&> 進入 operator&<&<

...
...
_Ostr.rdbuf()-&>sputn(_Val, _Count) // 事實上_Ostr就是std::cout,
... // _Val就是指向"hahaha"的指針,
... // _Count就是字元串長度,為6。

---------------------------&> 進入sputn,這貨在streambuf頭文件里

streamsize __CLR_OR_THIS_CALL sputn(const _Elem *_Ptr,
streamsize _Count)
{ // put _Count characters from array beginning at _Ptr
return (xsputn(_Ptr, _Count)); // 差不多就是個stub function吧,跳轉
}

---------------------------&> 進入xsputn,仍然是在streambuf頭文件里

virtual streamsize __CLR_OR_THIS_CALL xsputn(const _Elem *_Ptr,
streamsize _Count)
{ // put _Count characters to stream
streamsize _Size, _Copied;

for (_Copied = 0; 0 &< _Count; ) ... ... else if (_Traits::eq_int_type(_Traits::eof(), overflow(_Traits::to_int_type(*_Ptr)))) // 傳入一個字元。 break; // single character put failed, quit else { // count character successfully put ++_Ptr; ++_Copied; --_Count; } return (_Copied); }

這個函數里會掃描從左到右字元串,每次讀取一個字元,並將之轉成int_type傳入到overflow函數里,這個overflow函數是在fstream頭文件里。

---------------------------&> 進入overflow

...
...
if (_Pcvt == 0)
return (_Fputc(_Traits::to_char_type(_Meta), _Myfile)
...
...

在這個函數里,調用了_Fputc,_Meta其實就是字元的ascii值,不過在這裡是int_type,這個Myfile我傾向於認為是指向FILE結構的指針。

---------------------------&> 進入_Fputc,還是在fstream頭文件里

template&<&> inline bool _Fputc(char _Byte, _Filet *_File)
{ // put a char element to a C stream
return (fputc(_Byte, _File) != EOF);
}

很明顯了調用fputc。

---------------------------&> 進入fputc,這次是在fputc.cpp文件里啦

extern "C" int __cdecl fputc(int const c, FILE* const stream)
{
_VALIDATE_RETURN(stream != nullptr, EINVAL, EOF);

int return_value = 0;

_lock_file(stream);
__try
{
_VALIDATE_STREAM_ANSI_RETURN(stream, EINVAL, EOF);

return_value = _fputc_nolock(c, stream); // 關鍵是在這裡,準備進入真正的fputc
// 前面只是檢查參數+加鎖
}
__finally
{
_unlock_file(stream);
}

return return_value;
}

---------------------------&> 進入_fputc_nolock,仍然在fputc.cpp文件里。

extern "C" int __cdecl _fputc_nolock(int const c, FILE* const public_stream)
{
...
...

// If there is no room for the character in the buffer, flush the buffer and
// write the character:
if (stream-&>_cnt &< 0) return __acrt_stdio_flush_and_write_narrow_nolock(c, stream.public_stream()); ... ... }

唔,其實還要進入__acrt_stdio_flush_and_write_narrow_nolock,那就再進去吧。

---------------------------&> 進入__acrt_stdio_flush_and_write_narrow_nolock,這次是在_flsbuf.cpp里

extern "C" int __cdecl __acrt_stdio_flush_and_write_narrow_nolock(
int const c,
FILE* const stream
)
{
return common_flush_and_write_nolock&(c, __crt_stdio_stream(stream));
}

還得繼續跳轉

---------------------------&> 進入common_flush_and_write_nolock&,還是在_flsbuf.cpp里

這個函數有一丁點長,真正的寫入操作在函數的末端。

... // 省略不寫了
...
// Write the character; return (W)EOF if it fails:
if (!write_buffer_nolock(static_cast&(c character_mask), stream))
{
stream.set_flags(_IOERROR);
return stdio_traits::eof;
}

return c character_mask;
}

唔,調用write_buffer_nolock準備寫入。

---------------------------&> 進入write_buffer_nolock,還是在_flsbuf.cpp里

寫入操作還是在函數末端。

... // 省略不寫了
...
// Otherwise, perform a single character write (if we get here, either
// _IONBF is set or there is no buffering):
else
{
return _write(fh, reinterpret_cast&(c), sizeof(c)) == sizeof(Character);
}
}

好了,調用_write,應該很快就能到達底層實現了。

---------------------------&> 進入_write,這次是在write.cpp里

在這個函數里經過參數檢查+加鎖操作後調用_write_nolock。。。。你是不是感覺很無語?

... // 省略不寫了
...
int result = -1;
__try
{
if ((_osfile(fh) FOPEN) == 0)
{
errno = EBADF;
_doserrno = 0;
_ASSERTE(("Invalid file descriptor. File possibly closed by a different thread",0));
__leave;
}

result = _write_nolock(fh, buffer, size);
}
__finally
{
__acrt_lowio_unlock_fh(fh);
}
...
...

別急,我們真的快到終點了。

---------------------------&> _write_nolock,還是在write.cpp里

...
...
else if (_osfile(fh) FTEXT)
{
switch (fh_textmode)
{
case __crt_lowio_text_mode::ansi:
result = write_text_ansi_nolock(fh, char_buffer, buffer_size);
break;

case __crt_lowio_text_mode::utf16le:
result = write_text_utf16le_nolock(fh, char_buffer, buffer_size);
break;

case __crt_lowio_text_mode::utf8:
result = write_text_utf8_nolock(fh, char_buffer, buffer_size);
break;
}
}

看到了write_text_ansi_nolock沒有,這就是我們的終點站了。(當然如果你要是還關心windows api的實現我也無能為力了。)

---------------------------&> write_text_ansi_nolock,還是在write.cpp里

這裡就是終點站了,(真)寫入操作在函數尾部,摘錄如下:

...
...
DWORD written;
if (!WriteFile(os_handle, lfbuf, lfbuf_length, written, nullptr))
{
result.error_code = GetLastError();
return resu< } result.char_count += written; if (written &< lfbuf_length) return resu< // The write succeeded but didn"t write everything } return resu< }

WriteFile是不是很熟悉?這貨就是windows api(至少對windows開發者來說再熟悉不過了)。換句話說,我們這個字元串"hahaha"是一個字元一個字元地調用WriteFile給寫入到os_handle句柄里的。

以上結果是在x64 Debug模式下得到的,在你看來為了寫入一個字元串花費的代價是驚人的,事實也確實如此,所以acm等一些演算法比賽中請你儘可能減少對cout或cin的操作。(如果時間要求苛刻的話。)

當然了如果是Release模式下很多過程會被優化掉,console的io性能會得到一定程度的提升,但是無論如何肯定是不會繞過windows api的。

至於linux下如何,我想交給別人回答吧。


打開vc++,寫一個合法包含cin和cout的程序,右鍵go to definition


相信很多人都對這個問題感到過迷惑,但是要了解這些東西還是要學習不少知識的

所以我這篇回答只是一個簡單的概覽(不然得寫好幾本書了)

先說 cout(也就是其他語言裡面的 print,假設你問的是標準輸出)

首先,下面的鏈接解釋了計算機如何顯示器上輸出字元

蕭井陌:如何有格調地輸出「Hello, World!」?

然後,操作系統會把這個過程寫成一個函數,供你調用,在 C++ 中進一步被封裝為 cout

實際上在不同操作系統中 cout 都有不同的實現

並且 cout 下面還有很多其他的功能但是按照你的提問假定你問的就只是最底層的

再說 cin(假設你問的是標準輸入)

你想問的估計是鍵盤輸入如何被程序讀取到

實際上在現代的操作系統中,所有的外設都是通過驅動程序控制的,本質就是讀寫數字設置寄存器和內存

鍵盤的原理就不講了,但是在整個過程中是比較簡單的,只要一兩個月就可以學會完全自製的知識(其他部分的知識需要有正確的人用正確的教法才能 2 個月掌握)

所以跳過鍵盤工作原理,簡化為當你按/松鍵盤的時候,相當於閉開一個開關,然後電信號被輸出到 CPU 並運行鍵盤處理程序(中斷)

然後被操作系統傳給 cin

然後 cin 根據你的輸入再顯示出來(一般是會回顯的,所以我 cout 先於 cin 講原理)並寫入給你的變數對應的內存地址


總結

如果你能在理解這篇文章的基礎上去掌握這些知識

那麼恭喜你,在這方面的知識已經達到了我 8 年前的水準,已經可以年薪十萬了


問題提得不好。這個底層到底有多底?

從最粗略粗略的分析上來說,至少有C++標準庫、C運行庫、操作系統系統調用、字元設備驅動四大層次,你要問哪個?


寫過printf,c++剛學不久,cin和cout不太清楚,但感覺底層實現應該類似,講講printf是怎麼實現的吧,希望有參考性。

在計算機啟動的時候,BIOS通過 int 10h來調用set video mode。默認來講,加電初始化後會把顯卡初始化80*25的文本模式。

這裡字元的顯示,使用到的是內碼。每兩個位元組顯示一個字元,一個位元組是ASCII碼,另一個是附加碼。

對應的顏色表:

所以,例如來講使用0x00作為屬性位元組就是黑色黑色(你將看不到任何東西)。

0x07是黑底淺灰DOS默認),0x1F是藍白色(Win9x的藍屏死機),0x2a是綠色 - 單色懷舊風格。

所以,顯示的字元實際上都是一串串編碼好的數字,通過硬體來做解析。具體來講,顯示的過程,是首先把要顯示的內容存到顯卡中,然後CPU發出指令,通過IO控制來控制顯卡

1 顯示的內容如何放到顯卡呢

這裡有三種方式。第一種,就是framebuffer,也就是一塊內存,裡面放上要顯示的東西,顯卡通過DIsplay Controller掃描內存,然後把內容顯示到屏幕上。第二種,DMA。就是硬體設備直接從內存取數據。第三種,內存共享。

對於最基礎的東西,就是framebuffer就可以。所以我們把對應要寫的內容放到內存就行了。那麼放在哪塊內存呢?答案是放在底層1M以下的內存中,這部分內存用處巨大,不僅僅用來放顯卡的內容,很多操作的埠也映射到了這裡。

2 那麼CPU如何控制顯卡呢

這裡同樣涉及兩個概念:MMIO,即memory mapped I/O;以及PMIO(埠映射I/O),這兩種方法是在CPU和外圍設備之間執行輸入/輸出(I/O)的基本方法。

這裡說下MMIO,所謂MMIO,就是說,把I/O設備的內存和寄存器映射到我們的主存中,這樣的話,訪問對於的地址,就相當於訪問對於的設備了。(剛剛說了,1M以下的內存還映射了很多埠) I/O設備會監視CPU的地址匯流排,然後響應CPU對於相關的設備的讀取,把數據匯流排連到設備的寄存器上。這一過程的往往是通過特定的CPU的指令來實現的,比如in和out這種基於x86和x86-64架構在微處理晶元上設置的指令,它可以在EAX(或者AX,AL)和I/O設備特定的埠通信。I/O設備具有與通用存儲器分離的地址空間,通過CPU物理介面上的額外「I/O」引腳或專用於I/O的整條匯流排來實現。

系統的硬體通過特定的設計,可以使得連接在地址匯流排上的設備只響應特定的地址訪問, 而其它地址訪問不會觸發它們。這一部分由地址解碼電路實現,它可以建立系統的地址映射。比如一種常見的映射方式:

什麼意思呢,就是說從A000-A7FF這個地址實際上是給了顯卡控制的RAM的。

那麼開關是什麼呢?開關就是顯示控制單元,一般是寄存器,這些內容被編製在獨立的I/O空間里,需要用特殊的in/out指令去讀寫。(這部分內容較為底層,即使學過彙編也不一定很熟悉)。而實際上由於不同的硬體太多,控制寄存器也太多了,顯然無法一一映射到I/O埠的地址空間,實際上的解決方法是將一個埠作為內部寄存器的索引:0x3D4,再通過0x3D5埠來設置相應寄存器的值。

這裡具體給幾個埠讀寫的函數:

// 埠寫一個位元組
inline void outb(uint16_t port, uint8_t value)
{
asm volatile ("outb %1, %0" : :"dN" (port), "a" (value));
}

// 埠讀一個位元組
inline uint8_t inb(uint16_t port)
{
uint8_t ret;
asm volatile("inb %1, %0" : "=a" (ret) : "dN" (port));
return ret;
}

// 埠讀一個字
inline uint16_t inw(uint16_t port)
{
uint16_t ret;
asm volatile ("inw %1, %0" :"=a" (ret) : "dN" (port));
return ret;
}

這裡就不詳細講了,查查內聯彙編的相關內容吧。大概通過注釋理解函數的功能就行。

具體實現大概這樣:

首先,給出VGA 的顯示緩衝的起點是,即 0xB8000,然後從這裡往後到達80*25的區域,就是要顯示的內容了。也就是說,只要拿一個指針,指向這個地址,然後把要寫的內容從這裡依次寫下去即可。

寫好之後,操作實現:

static uint8_t cursor_x = 0;
static uint8_t cursor_y = 0;
// 移動游標
static void move_cursor()
{
// 屏幕是 80 位元組寬
uint16_t cursorLocation = cursor_y * 80 + cursor_x;

outb(0x3D4, 14); // 告訴 VGA 我們要設置游標的高位元組
outb(0x3D5, cursorLocation &>&> 8); // 發送高 8 位
outb(0x3D4, 15); // 告訴 VGA 我們要設置游標的低位元組
outb(0x3D5, cursorLocation); // 發送低 8 位
}

這裡的x和y,理解為cursor的位置,x從0到25,y從0到80.

而這裡的outb就是前面的函數,這裡為什麼14是設置高位元組,而15是低位元組,這些都是硬體規定的。查對應硬體的控制編碼。同理,這些埠也是,都是固定的,用的時候去查手冊就行。

所以,把對應的內容設置好,然後調用move_cursor()函數就行,就會把從0到目前的坐標(x,y)的內容寫進去。

ok,底層實現知道了,那麼看一下怎麼寫函數:

void console_putc_color(char c, real_color_t back, real_color_t fore)
{
uint8_t back_color = (uint8_t)back;
uint8_t fore_color = (uint8_t)fore;

uint8_t attribute_byte = (back_color &<&< 4) | (fore_color 0x0F); uint16_t attribute = attribute_byte &<&< 8; // 0x08 是 退格鍵 的 ASCII 碼 // 0x09 是 tab 鍵 的 ASCII 碼 if (c == 0x08 cursor_x) { cursor_x--; } else if (c == 0x09) { cursor_x = (cursor_x+8) ~(8-1); } else if (c == " ") { cursor_x = 0; } else if (c == " ") { cursor_x = 0; cursor_y++; } else if (c &>= " ") {
video_memory[cursor_y*80 + cursor_x] = c | attribute;
cursor_x++;
}

// 每 80 個字元一行,滿80就必須換行了
if (cursor_x &>= 80) {
cursor_x = 0;
cursor_y ++;
}

// 如果需要的話滾動屏幕顯示
scroll();

// 移動硬體的輸入游標
move_cursor();
}

這裡就實現了寫一個帶顏色字元的函數,可以理解成c語言中的putchar()。有了putchar的底層實現,那麼組合寫printf()也就可以理解了。

printf的複雜的地方主要在於它使用了可變參表,也就是說它的參數長度是可以變的。

平時寫的printf的函數,舉個例子

printf("hello world at %s", s);

這裡的第一個參數,就是固定的,是一個char *str,而後面的參數數量可以是任意的。

取後面的參數,是通過一個宏函數實現的,通過給定的類型,然後取對應長度的地址。

這部分用到的操作宏函數:

typedef int8_t * va_list;
#define va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )
#define _ADDRESSOF(v) ( (v) )
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) ~(sizeof(int) - 1) )

而具體printf的實現可以這樣:

// 進位轉換
char *convert(unsigned int num, int base)
{
static char Representation[]= "0123456789ABCDEF";
static char buffer[50];
char *ptr;

ptr = buffer[49];
*ptr = "";

do
{
*--ptr = Representation[num%base];
num /= base;
}while(num != 0);

return(ptr);
}

//printk的實現
void printk(char *str, ...)
{
va_list arg; //聲明一個va_list類型的變數,在宏定義里有說明
va_start(arg, str); //把printk()第一個參數取出到str中
char *temp; //用一個字元指針來作為我們的字元輸出
int i;
for(temp=str;*temp;temp++) //對我們的字元進行遍歷
{
if(*temp!="%")
putchar(*temp); //如果字元是正常字元,則輸出
else{ //當字元不是正常字元,而是用%標記的特定輸出
temp++; //加一,取到%的下一個字元
switch(*temp){ //對%標記做討論
case "d":
i=va_arg(arg,int); //將取到的參數按照int型賦值給i
if(i&<0) //對i轉換為10進位,討論正負,輸出 { putchar("-"); console_write(convert(-i,10)); } else console_write(convert(i,10)); break; case "c": i=va_arg(arg,int); //將取到的參數按照int型賦值給i putchar(i); //對i對於的ASCII碼輸出 break; case "o": i=va_arg(arg,unsigned int); //將取到的參數按照unsigned int型賦值給i console_write(convert(i,8)); //把i轉換為8進位輸出 break; case "s": i=(int)va_arg(arg, char*); //將取到的參數按照char*型賦值給i console_write((char*)i); break; case "x": i=va_arg(arg,unsigned int); //將取到的參數按照unsigned int型賦值給i console_write(convert(i,16)); break; case "b": i=va_arg(arg,int); //將取到的參數按照int型賦值給i console_write(convert(i,2)); break; } } } va_end(arg); //結束我們的arg,防止後序誤調用引發錯誤 }

讀起來可能比較複雜,理解一下,就是對第一個參數內容做輸出,每遇到%指示的內容,就根據%後面的內容,取一個參數,並按照類型做輸出。這裡的輸出使用的就是putchar了。

大概內容就是這樣,這個內容是我參考 @淺墨 的這個操作系統實現的https://www.zhihu.com/question/22463820/answer/22394667

這份教程無論是從編排還是內容都超級好~超級感謝淺墨大大,安利大家去學習研究。但是由於我比較菜啦。。。所以看起來很吃力。因此很多東西重新整理了,加入了很多基礎的只是,同時一些內容按照我的理解實現了,代碼略做了修改,但整理的不是很認真,就在Word裡面寫的。

我實現的這個小系統鏈接如下:

ForenewHan/OS

這個回答基本就是摘錄了我寫的教程裡面的第三章和第四章,如果有看不懂的話,戳原文有更詳細的解答。

同時我寫的東西裡面參考了很多其他人的優秀內容,博文什麼的,都在原來的doc裡面,如果有需要可以去看~

就是醬紫~喵!


不知到樓主問的底層有多底。如果是只是語言層面,最底不過調用個read或者write系統調用把東西寫到(讀出)文件里,只不過這些文件有點特殊,他們是由操作系統在進程創建時候就已經打開的兩個文件,叫做標準輸入輸出,fd分別是0和1。

如果樓主問的是格式化和反格式化(或曰parse)的內容,那麼可能涉及到自動機、涉及到C++的運算符重載這些內容。當然,樓主既然說的是底層,自動機和語言特性這種這麼高層的東西怎麼可能是樓主要討論的。樓主大概是想知道這些標準輸入輸出是怎麼來的,又是怎麼跟控制台聯繫起來的。

首先說到終端,也就是TTY,是一種古老但是歷久彌新的人機界面。狹義的TTY就是所謂的/dev/ttyN,這是一種可讀可寫的字元設備,讀連接著鍵盤驅動,寫連接著顯卡驅動,也就這這兩個驅動的一個上位驅動罷。VGA兼容顯卡的字元模式估計大家還有印象,直接往顯存寫ASCII碼就可以顯示字元。以前C語言課本里說的「標準輸入輸出是行緩衝」,其實是在說TTY的特性,要知道stdio可不一定是tty,還可以是真正的磁碟文件。當然,沒有前提下說標準輸入輸出都是在說tty。

現如今除了宕機需要進single緊急修復,大概我也不會去黑底白字的TTY下面工作了。要麼用的是終端模擬器,要麼用的是ssh。這些可以說是廣義的TTY了。PTY,也就是偽終端或虛擬終端,一般在/dev/pts/N可以找到。那麼像gnome-terminal, konsole, xterm這一類的終端模擬器,就會通過ptmx創建出一個pts,用法跟經典的TTY一樣,只不過ptmx替代了前面的鍵盤驅動和顯卡驅動,作為由應用程序控制的終端罷了。

接著再詳細描述一次整個流程。

  1. 你的系統啟動了,它執行一個將標準輸入輸出綁到了/dev/tty1的login,然後通過PAM……離題了,打死打死
  2. login當然就fork了一個進程執行bash,雖然login後來死了但是tty1繼承下來了,bash的tty也是tty1
  3. 事情發展到你的進程通過bash啟動了,bash也把tty1繼承到了你的進程身上,而bash則在你的進程身上wait。
  4. 你的進程執行了一個cout,(接下來是猜想,可能真正實現不是這樣,但是道理應該是相通的)cout是一個basic_ostream,它把你的內容通過overflow輸出到cout.rdbuf()中了,然後overflow想必就是調用了write,寫到了stdout這個文件中了
  5. write是一個阻塞操作啊,把緩衝區地址交給內核後,內核會把進程掛起,直到所有緩衝區所有內容通過tty1驅動,再通過顯卡驅動寫到屏幕位置。好,現在你的進程就緒了。
  6. 事情發展到你的進程執行了一個cin,相同地,通過underflow希望從cin.rdbuf()中讀內容,而underflow大概是調用了read,往stdin這個文件讀取內容
  7. 然而使用你進程的用戶並沒有急著輸入,read也阻塞了。
  8. 你的用戶開始輸入了,每敲一次鍵盤,便觸發了一系列又up又down的系統中斷,鍵盤驅動把這些事件送到tty1,並且組裝成字元。還不行,用戶還沒有敲回車,這些字元還是在tty的緩衝里。
  9. 用戶敲下了回車,tty1通知告訴進程管理你的進程在等的資源到手了。緩衝區複製到用戶空間里的streambuf,你的進程就緒,俄而調度器使其再次運行起來。
  10. 你的目的達到了。

疏漏想必很多,望各位斧正。


謝邀,cout和cin是標準輸出輸入設備,它們的實現其實就是計算機標準的數字信號和模擬信號的轉換過程。

用白話解釋一下基本的輸入輸出原理:

cout的具體實現涉及到數字信號轉換成模擬信號,當調用cout寫入一些字元串後,數據會通過cpu寫入到顯卡的顯存里,然後顯卡根據顯存里的數據轉換成點陣數據,再刷新屏幕上像素顯示,從而使我們看到輸出的字元串。

cin是標準的輸入,原理和cout剛好相反,當你調用cin函數的時候,會去監聽鍵盤輸入緩衝區,當你按鍵盤的時候,按鍵後面的線路會產生短路,產生模擬信號轉換成數字信號給cpu,cpu把數據放到鍵盤緩衝區,當緩衝區數據達到要求cin就返回給程序了。

如果在操作系統里,就涉及到相關驅動程序,cpu會通過驅動程序來讀寫相關數據,不過基礎的原理是一樣的。


你可以嘗試用單片機做一個帶鍵盤顯示器的東西 就大概明白髮生了些什麼


c++的流之所以設計成這樣,就是因為底層怎麼實現都可以


這是一個運算符重載的問題吧

C++標準庫,實現了 operator &<&< 對所有內部類型,以及string 等標準庫定義的數據類型的重載。 從而 可以輸出 內部類型,以及 string類型等等類型的數據。 最原始的, 運算符是 int operator &<&<(int a,int n) 即(x=) a &<&< n 這種運算符


你能不能寫一個print(...)函數,只接受一個參數,之後在參數類型不同的時候分別按照規則輸出。


補充一下 @Double Sine 的答案。實現的討論僅針對Microsoft Windows。

全文將以Visual Studio 2008 Professional Edition為基礎工具進行展示,但道理是通用的。

用Visual C++新建一個Win32 Console Application,選擇Empty Project即可。

在工程新建一個cpp源文件,包含這樣一段調用cout的代碼。啟用debug版本,方便調試。

#include &
using namespace std;
int main(void)
{
cout &<&< "Hello world!" &<&< endl; return 0; }

滑鼠右擊cout,選擇Go To Definition,會看到cout在頭文件iostream的聲明:

__PURE_APPDOMAIN_GLOBAL extern _CRTDATA2 ostream cout;

類似的方法,得到cout的等效聲明是這樣的:

extern __declspec(dllimport) ostream cout;

埋第一個坑:為什麼沒看到iostream成對出現的dllexport?)

類似的方法,得到ostream的聲明是這樣的:

typedef basic_ostream& &> ostream;

Good! 原來cout是一個某dll導出的全局ostream類型對象,教材上給他叫標準輸出流。

我們知道,C++全局對象,在進入main函數以前就由CRT初始化了。

C++全局對象,具體初始化成什麼值,要看他定義時的構造式。

我們使用cout時都是上來直接輸出,不曾改動句柄,所以用的應該是初值。

下面就琢磨一下cout是哪個dll定義的。然後順藤摸瓜,找到他的源代碼。

C Run-Time Libraries_MSDN提到:

If you include one of the Header Files in your code, a Standard C++ Library will be linked in automatically by Visual C++ at compile time. For example:

#include &

以及Visual Studio默認的工程設置。

所以,debug版的C++標準庫,具體的二進位存儲實體,其實就是:

msvcp90d.dll(VS2008版本,其他版本編譯會稍有不同)

使用Windows操作系統自帶的文件搜索功能,很容易知道兩文件的存儲位置。

msvcprtd.lib:C:Program Files (x86)Microsoft Visual Studio 9.0VClib

msvcp90d.dll:C:Program Files (x86)Microsoft Visual Studio 9.0VC
edistDebug_NonRedistx86Microsoft.VC90.DebugCRT

(但是注意,實際載入的dll位置不是這裡--看下圖,雖然二進位內容相同)

緊跟著,我們可以驗證一下,自己的見解和想法。可以借用兩個工具,都很常見。

一個就是Visual Studio自帶的調試工具,看程序啟動進入main後都載入了哪些dll;

另一個就是depends工具,看程序都依賴於哪些dll,以及這些dll的導出符號。

(或者用Visual Studio自帶的dumpbin工具,結合findstr和管道也可以)

************* 驗證步驟 *************

(1)注釋掉cout那一行代碼,觀察結果。

(2)取消第(1)步的注釋,觀察結果。

看到不同了吧!msvcp90d.dll!

那這個dll的源代碼到底在哪裡呢。是謎一樣的存在嗎?不,不,不。重要的事情說三遍。

MSDN告訴我們,微軟為我們提供了CRT的部分源代碼,其中就包含了標準C++庫的實現。

The functions in the CRT debug libraries are compiled with debug information (/Z7, /Zd, /Zi, /ZI (Debug Information Format)) and without optimization. Some functions contain assertions to verify parameters that are passed to them, and source code is provided. With this source code, you can step into CRT functions to confirm that the functions are working as you expect and check for bad parameters or memory states. (Some CRT technology is proprietary and does not provide source code for exception handling, floating point, and a few other routines.)

When you install Visual C++, you have the option of installing the C run-time library source code on your hard disk. If you do not install the source code, you will need the CD-ROM to step into CRT functions.

Visual Studio安裝時,默認就會安裝Visual C++ CRT Source Code,只是相當一部分同學,習慣了安裝軟體就一路Next,很少會認真看他都安裝了什麼。

CRT的源代碼路徑在這裡:C:Program Files (x86)Microsoft Visual Studio 9.0VCcrtsrc。

這時,為了試驗方便,可以將文件夾下的全部文件拷貝到一個新的文件夾下。

然後,用一個強大一點的C++編輯器包含這些源代碼,這樣你就可以很方便的穿梭。

鎖定了兩個源文件:mpiostream.cpp和cout.cpp,分別打開看一下。

//mpiostream.cpp
#ifdef MRTDLL
#undef MRTDLL
#endif

// This file is built into msvcurt.lib
#if !defined(_M_CEE_PURE)
#error This file must be built with /clr:pure.
#endif
//從msvcurt.lib和_M_CEE_PURE都可以看出這是給純託管代碼用的
//我們新建的工程默認是native code,所以跟他毛關係都沒有!

機智如你,肯定知道,排除法,就剩下一個候選人了。

//cout.cpp
_STD_BEGIN
// OBJECT DECLARATIONS

__PURE_APPDOMAIN_GLOBAL static filebuf fout(_cpp_stdout);
__PURE_APPDOMAIN_GLOBAL extern _CRTDATA2 ostream cout(fout);

其中_STD_BEGIN是一個宏,他是這樣定義的。

#define _STD_BEGIN namespace std {

_cpp_stdout也是一個宏,他是這樣定義的。

#define _cpp_stdout ((__iob_func())[1])

這個代碼看上去挺眼熟。眼熟並非空穴來風。

C語言時代就已經定義標準輸出流了。

C和C++就這樣,殊途共歸。

//stdio.h
#define stdout (__iob_func()[1])

__iob_func是msvcr90d.dll定義的函數。

//_file.c
_CRTIMP FILE * __cdecl __iob_func(void)
{
return _iob;
}

_CRTIMP是CRT源代碼定義的一個宏。

//crtdefs.h
#ifndef _CRTIMP
#ifdef CRTDLL
#define _CRTIMP __declspec(dllexport)
#else /* CRTDLL */
#ifdef _DLL
#define _CRTIMP __declspec(dllimport)
#else /* _DLL */
#define _CRTIMP
#endif /* _DLL */
#endif /* CRTDLL */
#endif /* _CRTIMP */

也就是說,__iob_func是msvcr90d.dll導出的函數。

這個函數返回的FILE *型變數_iob又是在哪裡初始化呢?

//_file.c
FILE _iob[_IOB_ENTRIES] = {
/* _ptr, _cnt, _base, _flag, _file, _charbuf, _bufsiz */

/* stdin (_iob[0]) */

{ _bufin, 0, _bufin, _IOREAD | _IOYOURBUF, 0, 0, _INTERNAL_BUFSIZ },

/* stdout (_iob[1]) */

{ NULL, 0, NULL, _IOWRT, 1, 0, 0 },

/* stderr (_iob[3]) */

{ NULL, 0, NULL, _IOWRT, 2, 0, 0 },

};

因此,stdout就相當於_iob+1,類型為FILE *。

FILE類型是這樣定義的。

//stdio.h
struct _iobuf {
char *_ptr;
int _cnt;
char *_base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char *_tmpfname;
};
typedef struct _iobuf FILE;

File input/output簡要介紹了一下FILE類型。

Defined in header &

Type Definition FILE type, capable of holding all information needed to control a C I/O stream

翻譯過來,FILE是持有控制C輸入/輸出流所需全部信息的一種數據類型。

結合上面_iob數組的初始化,並注意到FILE結構體第5個成員:int _file;

其實他就是文件描述符fd(file descriptor)。_fileno函數返回的就是他。

可以看出來:stdin,stdout,stderr依次被初始化為0,1,2。

到此,我們就明白了,cout對象是如何初始化的。

埋第二個坑:這裡面涉及到一些相互依賴的全局變數初始化,怎麼保證他們的初始化順序)

至於再往底層走,就是Windows API--&> OS kernel driver --&> hardware了。

展開講,就很多了,可以找一找相關方面的經典書籍,系統地學習一下。

最後,是一點點學習建議。CRT這部分,最好結合MSDN和代碼一起看,事半功倍。

坑先埋著,有空再填。。。(^o^)/


推薦閱讀:

C++中if(a!=b)和if(a^b)哪個效率更高?
如何設計一個真正高性能的spin_lock?
使用C++的痛苦來自哪裡?
最近在Oulu進行的關於c++17標準的會議有什麼進展?
c++開發轉向go開發是否是一個好的發展方向?

TAG:C |