第二部分:工作機理
第一章:進程
上一章介紹了內核對象,這一節開始就要不斷接觸各種內核對象了。首先要給大家介紹的是進程內核對象。進程大家都不陌生,它是資源和分配的基本單位,而進程內核對象就是與進程相關聯的一個數據結構。操作系統內核通過它管理進程,也就是操作系統原理上介紹的進程控制塊(PCB)。舉個例子,它就相當於每個學生都有的學籍,學校管理我們都是通過學籍,什么記過了,處分了,開除學籍了,都是在學籍上做文章。
進程一般被定義為一個正在運行的程序的一個實例,它由兩部分組成:
1:內核對象,操作系統用它來管理進程。內核對象也是系統保存進程統計信息的地方。
2:一個地址空間,其中包含所有可執行文件或DLL模塊的代碼和數據。
Windows支持兩種類型的應用程序:GUI程序和CUI程序。前者是我們經常接觸的,具有窗口外觀的窗口應用程序。后者是控制台應用程序。在使用vc來開發應用程序時,會設置各種鏈接器開關。鏈接器根據這些開關將子系統的正確類型嵌入最終生成的可執行文件。對於CUI程序這個開關是/SUBSYSTEM:CONSOLE。對於GUI程序,則是/SUBSYSTEM:WINDOWS。
這些開關會告訴鏈接器在鏈接時鏈接什么入口函數。對於GUI程序它的入口點函數時WinMain,CUI程序是main。
有人以為入口函數就是程序執行的開始,其實這是不正確的。在入口點函數之前還有一個被稱為啟動函數的函數。該函數用來初始化C/C++運行庫、構造全局和靜態的C++對象等。
根據應用程序類型的不同,啟動函數也不一樣。ANSI字符集下,GUI程序的啟動函數是WinMainCRTStartup,入口函數是WinMain。CUI的啟動函數是mainCRTStartup,入口函數是main。Unicode字符集下,GUI程序的啟動函數是wWinMainCRTStartup,入口函數是wWinMain,CUI的啟動函數是wmainCRTStartup,入口點函數時wmain。
我們在寫控制台下的應用程序時,可以通過argv來引用命令行參數,當時也很疑惑,為什么可以直接用呢?原來都是啟動函數的功勞。它會在進入入口函數之前幫我們做其他工作:
1:獲取命令行指針。
2:獲取指向環境變量的指針
3:初始化C/C++運行庫的全局變量。
4:初始化C運行庫內存分配函數。
5:調用所有全局和靜態C++類對象的構造函數。
完成所有這些工作后,啟動函數就會調用應用程序的入口點函數。入口點函數返回后啟動函數獲得入口點函數返回值,並將其傳遞給C運行庫函數exit。Exit函數將調用所有全局和靜態C++類對象的析構函數和其他清理工作。然后將入口函數的返回值傳遞給ExitProcess函數,結束進程並設置返回值為退出代碼。
加載到進程地址空間的可執行文件或是DLL都有一個實例句柄。用以標識它在進程地址空間的位置。可執行文件的實例句柄被當做WinMain函數的第一個參數傳入。它實際上是一個內存基地址。系統將可執行文件的映像加載到進程地址空間中的這個位置。映像加載到哪個地址是由鏈接器決定的。不同的鏈接器使用不同的首先基地址。exe文件和dll都會有一個默認的首選基地址。exe文件是400000,dll是10000000。
為了獲得一個可執行文件或dll文件被加載到進程地址空間的位置,可以使用GetModuleHandle函數。它需要一個以/0結尾標示可執行文件或dll的名字字符串為參數。當傳入NULL時,此時將會返回主調進程可執行文件的基地址,即使此時代碼在一個dll文件中仍然是這樣。如果此時代碼在dll中執行,我們想何知道此時代碼正在什么模塊中運行,這可以通過GetModuleHandleEx得到。將GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS作為它的第一個參數,再將當前函數的地址作為它的第二個參數,函數執行完畢,最后一個參數將保存出入的函數所在dll的基地址。
系統在創建進程時會傳給他一個命令行,這個命令行總是非空,因為它至少存儲有可執行文件的名稱。C運行庫的啟動代碼在執行一個GUI應用程序時,會調用windows函數GetCommandLine來獲得進程的完整命令行,它忽略可執行文件名稱,然后將剩余部分傳給WinMain的pszCmdLine參數。
每個進程都有一個與它相關聯的環境塊。用以定義工作環境、保存有用信息,使系統獲得相關設置。應用程序經常利用環境變量讓用戶精細其行為。用戶創建一個環境變量並進行初始化,此后應用程序運行時會正在環境塊中查找變量,如果找到變量就會解析變量的值,並調整自己的行為。它所占用的內存是在進程地址空間內分配的。同樣調用GetEnvironmentStrings函數可以獲得完整的環境塊。通常子進程會繼承一組環境變量,這些環境變量和父進程的環境變量相同,父進程可以控制那些環境變量允許子進程繼承。注意子進程繼承的僅僅是父進程環境變量的副本,它們不共享同一個環境塊。GetEnvironmentVariable函數可以用來判斷一個環境變量是否存在。
在下圖中我們可以看到形如%USERPROFILE%的字符串,它表示兩個%之間的這部分內容是一個可替換的變量。該變量在環境變量中已經被定義。
可以使用SetEnvironmentVariable來添加、刪除或修改一個變量。
Windows不建議使用入口函數的參數來訪問命令行或是環境變量,而應該使用以上介紹的各種函數。應該將它們當做只讀變量,不要對它們進行修改。
在多處理器的系統中,可以強迫線程在某個cpu上運行,這成為處理器關聯性。子進程繼承了其父進程的關聯性。
如果不提供完整的路徑名,windows函數就會在當前驅動器的當前目錄查找文件和目錄。如:調用CreateFile打開一個文件時,如果僅指定文件名,系統將在當前驅動器和目錄查找該文件。
系統在內部跟蹤記錄這一個進程當前驅動器和目錄,這些信息是以進程為單位來維護的,如果該進程的一個線程更改了當前驅動器和目錄,則只影響本進程的所有線程。
一個線程可以使用GetCurrentDirectory和SetCurrentDirectory來獲得和設置當前驅動器和目錄。子進程的當前目錄默認為每個驅動器的根目錄。如果父進程希望子進程繼承它的當前目錄,就必須在生成子進程之前,添加環境變量。
使用GetVersionEx可以獲得window系統的版本號。
CreateProcess函數
接下來進入到本章最重要的知識點:CreateProcess函數。
該函數用以創建一個進程:
- Bool CreateProcess(
- PCTSTR pszApplicationName,
- PTSTR pszCommandLine,
- PSECURTITY_ATTRIBUTES psaProcess,
- PSECUTRITY_ATTRIBUTE psaThread,
- Bool hInheritHandles,
- DWROD fdwCreate,
- PVOID pvEnvironment,
- PCTSTR pszCurDir,
- PSTARTUPINFO psiStartInfo,
- PPROCESS_INFORMATION ppiProcInfo
- );
當此函數被調用時,系統將首先創建一個進程對象,其初始使用計數為1。進程內核對象並不是進程本身,而是操作系統用來管理這個進程的一個數據結構。此后系統為新進程創建一個虛擬地址空間,並將可執行文件的代碼及數據加載到進程的地址空間。然后系統會新進程的主線程創建一個線程內核對象,也將其使用計數設為1。和進程內核對象一樣它也是操作系統用以管理線程的數據結構。主線程首先會執行C/C++運行時的啟動函數,啟動函數調用入口函數。進程被創建成功后CreateProcess返回true,函數返回前CreateProcess可能還沒有完全初始化好。
psaApplicationName和pszCommandLine分別指定新進程要使用的可執行文件稱和要傳給新進程的命令行字符串。
注意此處的pszCommandLine是非常量字符串。傳入常量字符串將會導致訪問違規,因為在內部CreateProcess會修改傳入的命令行字符串,返回時再將這個字符串還原。
所以一下代碼是錯誤的:
STARTUPINFO si={sizeof(si)};
PROCESS_INFORMATION pi;
CreateProcess(NULL,TEXT
(“NOTEPAD”),NULL,NULL,FALSE,0,NULL,NULL,&si,&pi);
因為TEXT("NOTEPAD")是常量字符串,當CreateProcess試圖修改字符串會引起訪問違規。解決方法是將TEXT("NOTEPAD")放在一個緩沖區內:
- STARTUPINFO si={sizeof(si)};
- PROCESS_INFORMATION pi;
- TCHAR cmdLine[200]=TEXT("NOTEPAD");
- CreateProcess(NULL,cmdLine,NULL,NULL,FALSE,0,NULL,NULL,&si,&pi);
這一點要特別注意,很容易出錯!!!!這也有個例外,windows vista以及win7的ANSI版本以上是不會發生訪問違規的,因為它們會為命令行創建一個臨時副本。
在解析命令行時,CreateProcess會檢查字符串中第一個標記,假定此標記就是我們想運行的可執行文件名稱。如果可執行文件沒有擴展名,就會默認是.exe擴展名。CreateProcess會在以下目錄下搜索可執行文件:
1:主調進程.exe文件所在目錄。
2:主調進程的當前目錄。
3:windows系統目錄。即System32目錄。
4:windows目錄。
5:PATH環境變量中列出的目錄。
如果為可執行文件制定完整路徑,系統就會按指定路徑尋找。
以上情況在pszApplicationName為NULL時才發生。當然也可以在pszApplicationName傳遞可執行文件名稱,此時必須指定擴展名,否則進程不會被創建。系統會按照此處指定的路徑尋找,如沒有指定完整路徑,系統會假定文件位於當前目錄。如找不到,函數調用失敗。
當pszApplicationName指定文件名,pszCommandLine參數中的內容也會作為新進程的命令行傳給它。
如:
- STARTUPINFO si={sizeof(si)};
- PROCESS_INFORMATION pi;
- TCHAR cmdLine[200]=TEXT("WORDPAD a.txt");
- CreateProcess(TEXT("C:\\windows\\system32\\NOTEPAD.exe"),
- cmdLine,NULL,NULL,FALSE,0,NULL,NULL,&si,&pi);
命令行是“WORDPAD a.txt”,記事本程序將詢問:a.txt不存在是否創建a.txt文件。第一個參數WORDPAD應該是作為程序名稱傳入的。
為了創建一個新的進程,系統必須創建一個進程內核對象和一個線程內核對象,由於這些都是內核對象,所以父進程必須將安全屬性關聯到這兩個對象上。可以通過psaProcess和psaThread為進程和線程安全對象指定默認的安全描述符。可以都為它們指定NULL,使用默認的安全屬性,也可以分配並初始化兩個SECURITY_ATTRIBUTES結構,以便創建安全權限並將它們分配給進程和線程對象。
fdwCreate參數影響新進程創建的方式。如:指定CREATE_SUSPENDEd標識讓系統在創建新進程時掛起其主線程。這樣父進程就可以修改子進程地址空間的內存、更改主線程優先級或是將其添加到作業中。修改完后可以調用ResumeThread來允許子進程執行代碼。傳入0 表示創建進程后立即運行。多個標志位可以組合使用。
pvEnvironment參數指定一塊內存,包含新進程要使用的環境字符串。但多數情況下都是傳入NULL,表示子進程要繼承父進程使用的一組環境字符串。也可以使用GetEnvironmentStrings函數,此函數獲取主調進程正在使用的環境字符串地址,當我們為pvEnvirtonment傳入NULL時,CreateProcess就是調用這個函數。不使用這塊內存時應該使用FreeEnvironmentStrings函數。
pszCurDir允許父進程設置子進程的當前驅動器和目錄。如果為NULL,新進程的工作目錄與父進程的一樣。
psiStartInfo參數指向一個STARTUPINFO結構。該結構包含很多成員。Windows在創建新進程的時候使用它們,但是大多數應用程序僅僅使用它們的默認值。因此我們要做的最起碼的工作就是將此結構的所有成員都初始化為0,將cb成員設為此結構的大小,如:
STARTUPINFO si={sizeof(si)};
此時除cb成員外,其他成員均為0。不能僅僅si.cb=sizeof(si);因為此時其他成員的值都是垃圾數據。
ppiProcInfo參數指向PROCESS_INFORMATION結構,CreateProcess在返回時會初始化這個結構。
Typedef struct _PROCESS_INFORMATION
{
HANDLE hProcess;
HANDLE hThread;
HANDLE dwProcessId;
HANDLE dwThreadId;
}PROCESS_INFORMATION;
CreateProcess創建的進程和線程對象將通過它返回。創建時系統會為每個對象指定一個初始的使用計數1,CreateProcess返回時由於PROCESS_INFORMATION結構中再次引用了進程和線程內核對象 此時它們的使用計數都變為2。可以理解為進程和線程實例本身也占有一個計數。當它們結束運行時這個使用計數被遞減1。
此時如果系統要釋放進程對象,1:進程必須終止,此時使用計數遞減1。2:父進程必須調用CloseHandle,使用計數再次減去1。線程類似。
因此為了使不再使用的內核對象能夠得到釋放,一定要在不使用時調用CloseHandle關閉對句柄的引用。所有對該句柄的引用都被關閉后,當進程或線程終止時它們關聯度的內核對象才能夠被釋放。
CreateProcess還會為進程和線程分配一個ID號。進程和線程分享同一個號碼池。這意味着它們不可能相同。一個對象的ID不可能分配到0,因為windows任務管理器將進程ID 0與系統空閑進程關聯。該進程代表未被真實使用的cpu使用率。
dwProcessId和dwThreadId成員就是存儲進程和線程的ID。使用GetCurrentProcessId可以獲得當前進程的ID。GetCurrentThreadId來獲得當前正在運行的線程的ID。另外使用GetProcessId和GetThreadId可以獲得指定句柄對應的進程和線程的ID。使用GetProcessIDOfThread可以獲得某句柄關聯的線程所在進程的ID。
由於ID可能會立即重用。也就是說當我們獲得某個進程的ID並保存后,此后在使用時有可能出現它已經被釋放了。此時此ID就對應着其他進程了。避免這種情況的唯一方法就是:保證進程或線程對象不被銷毀。
進程終止
進程可以通過三種方式終止:
1:主線程從入口函數返回。
2:進程中的一個線程調用ExitProcess。
3:另一個進程中的線程調用TerminateProcess。
在以上介紹的三種方式中僅有第一種,當主線程從入口函數返回才保證主線程的所有資源都會被正確清理。
清理操作包括:
1:調用所有在本進程內使用的任何C++對象的析構函數。
2:釋放各個線程線程棧使用的內存。
3:進程的退出代碼被設為入口函數的返回值。
4:進程內核對象使用計數遞減1。
正常情況下入口點函數會返回到啟動函數,啟動函數將正確清理進程使用的所有C運行時資源,清理之后啟動代碼顯式調用ExitProcess並將入口函數返回值傳給它。這也是為什么只需從入口函數返回卻可以終止整個進程的原因。
進程的一個線程調用ExitProcess可以終止本進程。其后的別的代碼將不會被執行。
與ExitProcess相類似的還有ExitThread,它會導致一個線程終止。在創建線程時常出現這種情況:子線程還沒有怎么執行程序就已經結束了,這有可能是在創建完線程后,主線程沒有調用WaitForSingleObject之類的函數,主線程創建完其他線程后就返回到啟動函數函數返回整個進程被終止。這一點很容易出錯。
調用ExitProcess或是ExitThread會導致進程或線程當場終止運行,再也不會返回到啟動函數,清理工作(C++對象的析構)當然沒法執行。雖然最終隨着進程的結束,該進程內所有線程所使用的資源都會被釋放,但是應該避免調用這些函數,它們阻止了C++對象析構函數對善后工作的處理。順便提下,如果在主線程調用ExitThread,雖然主線程當場終止,但是如果進程內還有其他線程,則進程不會終止。
如以下代碼:
- #include<windows.h>
- #include<iostream>
- DWORD ThreadProc(PVOID)
- {
- int i=0;
- int j=0;
- while(i<1000000)
- {
- i++;
- while(j<10000)
- j++;
- std::cout<<i<<","<<j<<std::endl;
- }
- return 0;
- }
- int main(int argc,char**argv)
- {
- DWORD id;
- CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)ThreadProc,NULL,0,&id);
- ExitThread(0);
- return 0;
- }
ExitProcess,ExitThread只能由本進程的其他線程調用。而TerminateProcess和TerminateThread卻可以由任何其他進程的線程調用。它的第一個參數指定要終止進程的句柄。這種情況下應用程序得不到自己要被殺死的通知,也不能阻止自己被殺死,當然也無法為自己准備好后事(得不到清理)。例如已經修改的文件沒有刷新到磁盤上。
但要明白進程終止后屬於它的任何東西都會被釋放。TerminateProcess是異步的,此函數調用后我們並不能保證進程已經被強行終止了。要確定進程是否終止可以調用WaitForSingleObject函數,並傳入進程句柄。
進程終止時進行的操作。
一個進程終止時,系統會依次執行以下操作:
1:終止進程中遺留的任何線程。
2:釋放進程分配的所有用戶對象,關閉所有內核對象。如果它們的使用計數變為0,內核對象將會釋放。
3:將進程的退出代碼從STILL_ACTIVE變為傳給ExitProcess或是TerminateProcess的參數存儲在內核對象中。
4:進程內核對象變為一觸發狀態。這也是為什么其他線程可以掛起他們自己直至另一個進程終止運行。
5:進程內核對象的使用計數遞減1。
進程內核對象的生命期至少能像進程本身一樣長。當進程終止時如果系統中還有另一個進程打開了這個進程的內核對象的句柄,進程內核對象的使用計數就不會變為0。當父進程忘記關閉子進程的句柄時往往發生這種情況。
進程終止了內核對象還沒有被釋放,這樣做有用嗎?當然有用!!即使進程終止了,存儲在內核對象的信息也有可能被使用,如我們想知道進程占用了多少Cpu時間,或是想獲得它的退出代碼。GetExitCodeProcess此函數會查找進程內核對象並從內核對象的數據結構中取出退出代碼。任何時候都可以調用此函數。如此時進程正在運行那么將會得到STILL_ALIVE。
WaitForSingleObject將會掛起當前線程,知道它所等待的對象變為已觸發狀態。進程或線程對象在終止時就會變成已觸發狀態。