版權聲明
本書是免費電子書。作者保留一切權利。但在保證本書完整性(包括版權聲明、前言、正文內容、後記、以及作者的信息),並不增刪、改變其中任何文字內容的前提下,歡迎任何讀者以任何形式(包括各種格式的文檔)複製和轉載本書。同時不限制利用此書贏利的行爲(如收費註冊下載,或者出售光盤或打印版本)。不滿足此前提的任何轉載、複製、贏利行爲則是侵犯版權的行爲。
發現本書的錯漏之處,請聯繫作者。請不要修改本文中任何內容,不經過作者的同意發佈修改後的版本。
作者信息
作者網名楚狂人。真名譚文。在上海從事Windows驅動開發相關的工作。對本書任何內容有任何疑問的讀者,可以用下列方式和作者取得聯繫:
QQ:16191935
Email:[email protected],[email protected]
前言
本書非常適合熟悉Windows應用編程的讀者轉向驅動開發。所有的內容都從最基礎的編程方法入手。介紹相關的內核API,然後舉出示範的例子。這本書只有不到70頁,是一本非常精簡的小冊子。所以它並不直接指導讀者開發某種特定類型的驅動程序。而是起到一個入門指導的作用。
即使都是使用C/C++語言的代碼,在不同的應用環境中,常常看起來還是大相徑庭。比如用TurboC++編寫的DOS程序代碼和用VC++編寫的MFC應用程序的代碼,看起來就幾乎不像是同一種語言。這是由於它們所依賴的開發包不相同的緣故。
在任何情況下都以寫出避免依賴的代碼爲最佳。這樣可以避免重複勞動。但是我們在學習一種開發包的使用時,必須習慣這個環境的編碼方式,以便獲得充分利用這個開發包的能力。
本書的代碼幾乎都依賴於WDK(Windows Driver Kit)。但是不限WDK的版本。WDK還在不斷的升級中。這個開發包是由微軟公司免費提供的。讀者可以在微軟的網站上下載。
當然讀者必須把WDK安裝的計算機上並配置好開發環境。具體的安裝和配置方法本書沒有提供。因爲網上已經有非常多的中文文檔介紹它們。
讀完這本書之後,讀者一定可以更輕鬆的閱讀其他專門的驅動程序開發的文檔和相關書籍。而不至於看到大量無法理解的代碼而中途放棄。如果有任何關於本書的內容的問題,讀者可以隨時發郵件到[email protected]或者[email protected]。能夠回答的問題我一般都會答覆。
寫本書的時候,我和wowocock合作的一本名爲《天書夜讀》(在網上有一個大約20%內容的縮減電子版本)正在電子工業出版社編輯。預計還有不到一個月左右就會出版。這也是我自己所見的唯一一本中文原創的從彙編和反彙編角度來學習Windows內核編程和信息安全軟件開發的書。希望讀者多多支持。有想購買的讀者請發郵件給我。我會在本書出版的第一時間,回覆郵件告知購買的方法。
此外我正在寫另一本關於Windows安全軟件的驅動編程的書。但是題目還沒有擬好。實際上,讀者現在見到的免費版本的《Windows驅動編程基礎教程》是從這本書的第一部分中節選出來的。這本書篇幅比較大,大約有600-800頁。主要內容如下:
第一章驅動編程基礎
第二章磁盤設備驅動
第三章磁盤還原與加密
第四章傳統文件系統過濾
第五章小端口文件系統過濾
第六章文件系統保護與加密
第七章協議網絡驅動
第八章物理網絡驅動
第九章網絡防火牆與安全連接
第十章打印機驅動與虛擬打印
第十一章視頻驅動與過濾
附錄A WDK的安裝與驅動開發的環境配置
附錄B 用WinDbg調試Windows驅動程序
這本書還沒有完成。但是肯定要付出巨大的精力,所以請讀者不要來郵件索取完整的免費的電子版本。希望讀者支持本書的紙版出版。因爲沒有完成,所以還沒有聯繫出版商。有願意合作出版本書的讀者請發郵件與我聯繫。
凡是發送郵件給我的讀者,我將會發送郵件提供本人作品最新的出版信息,以及最新發布的驅動開發相關的免費電子書。如果不需要這些信息的,請在郵件裏註明,或者回復郵件給我來取消訂閱。
譚文
2008年6月9日
目錄
第一章 字符串
1.1 使用字符串結構
常常使用傳統C語言的程序員比較喜歡用如下的方法定義和使用字符串:
char *str = { “my first string” }; // ansi字符串
wchar_t *wstr = { L”my first string” }; // unicode字符串
size_t len = strlen(str); // ansi字符串求長度
size_t wlen = wcslen(wstr); // unicode字符串求長度
printf(“%s %ws %d %d”,str,wstr,len,wlen); // 打印兩種字符串
但是實際上這種字符串相當的不安全。很容易導致緩衝溢出漏洞。這是因爲沒有任何地方確切的表明一個字符串的長度。僅僅用一個’/0’字符來標明這個字符串的結束。一旦碰到根本就沒有空結束的字符串(可能是攻擊者惡意的輸入、或者是編程錯誤導致的意外),程序就可能陷入崩潰。
使用高級C++特性的編碼者則容易忽略這個問題。因爲常常使用std::string和CString這樣高級的類。不用去擔憂字符串的安全性了。
在驅動開發中,一般不再用空來表示一個字符串的結束。而是定義瞭如下的一個結構:
typedef struct _UNICODE_STRING {
USHORT Length; // 字符串的長度(字節數)
USHORT MaximumLength; // 字符串緩衝區的長度(字節數)
PWSTR Buffer; // 字符串緩衝區
} UNICODE_STRING, *PUNICODE_STRING;
以上是Unicode字符串,一個字符爲雙字節。與之對應的還有一個Ansi字符串。Ansi字符串就是C語言中常用的單字節表示一個字符的窄字符串。
typedef struct _STRING {
USHORT Length;
USHORT MaximumLength;
PSTR Buffer;
} ANSI_STRING, *PANSI_STRING;
在驅動開發中四處可見的是Unicode字符串。因此可以說:Windows的內核是使用Uincode編碼的。ANSI_STRING僅僅在某些碰到窄字符的場合使用。而且這種場合非常罕見。
UNICODE_STRING並不保證Buffer中的字符串是以空結束的。因此,類似下面的做法都是錯誤的,可能會會導致內核崩潰:
UNICODE_STRING str;
…
len = wcslen(str.Buffer); // 試圖求長度。
DbgPrint(“%ws”,str.Buffer); // 試圖打印str.Buffer。
如果要用以上的方法,必須在編碼中保證Buffer始終是以空結束。但這又是一個麻煩的問題。所以,使用微軟提供的Rtl系列函數來操作字符串,纔是正確的方法。下文逐步的講述這個系列的函數的使用。
1.2 字符串的初始化
請回顧之前的UNICODE_STRING結構。讀者應該可以注意到,這個結構中並不含有字符串緩衝的空間。這是一個初學者常見的出問題的來源。以下的代碼是完全錯誤的,內核會立刻崩潰:
UNICODE_STRING str;
wcscpy(str.Buffer,L”my first string!”);
str.Length = str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
以上的代碼定義了一個字符串並試圖初始化它的值。但是非常遺憾這樣做是不對的。因爲str.Buffer只是一個未初始化的指針。它並沒有指向有意義的空間。相反以下的方法是正確的:
// 先定義後,再定義空間
UNICODE_STRING str;
str.Buffer = L”my first string!”;
str.Length = str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
… …
上面代碼的第二行手寫的常數字符串在代碼中形成了“常數”內存空間。這個空間位於代碼段。將被分配於可執行頁面上。一般的情況下不可寫。爲此,要注意的是這個字符串空間一旦初始化就不要再更改。否則可能引發系統的保護異常。實際上更好的寫法如下:
//請分析一下爲何這樣寫是對的:
UNICODE_STRING str = {
sizeof(L”my first string!”) – sizeof((L”my first string!”)[0]),
sizeof(L”my first string!”),
L”my first_string!” };
但是這樣定義一個字符串實在太繁瑣了。但是在頭文件ntdef.h中有一個宏方便這種定義。使用這個宏之後,我們就可以簡單的定義一個常數字符串如下:
#include <ntdef.h>
UNICODE_STRING str = RTL_CONSTANT_STRING(L“my first string!”);
這只能在定義這個字符串的時候使用。爲了隨時初始化一個字符串,可以使用RtlInitUnicodeString。示例如下:
UNICODE_STRING str;
RtlInitUnicodeString(&str,L”my first string!”);
用本小節的方法初始化的字符串,不用擔心內存釋放方面的問題。因爲我們並沒有分配任何內存。
1.3 字符串的拷貝
因爲字符串不再是空結束的,所以使用wcscpy來拷貝字符串是不行的。UNICODE_STRING可以用RtlCopyUnicodeString來進行拷貝。在進行這種拷貝的時候,最需要注意的一點是:拷貝目的字符串的Buffer必須有足夠的空間。如果Buffer的空間不足,字符串會拷貝不完全。這是一個比較隱蔽的錯誤。
下面舉一個例子。
UNICODE_STRING dst; // 目標字符串
WCHAR dst_buf[256]; // 我們現在還不會分配內存,所以先定義緩衝區
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
// 把目標字符串初始化爲擁有緩衝區長度爲256的UNICODE_STRING空串。
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷貝!
以上這個拷貝之所以可以成功,是因爲256比L” My source string!”的長度要大。如果小,則拷貝也不會出現任何明示的錯誤。但是拷貝結束之後,與使用者的目標不符,字符串實際上被截短了。
我曾經犯過的一個錯誤是沒有調用RtlInitEmptyString。結果dst字符串被初始化認爲緩衝區長度爲0。雖然程序沒有崩潰,卻實際上沒有拷貝任何內容。
在拷貝之前,最謹慎的方法是根據源字符串的長度動態分配空間。在1.2節“內存與鏈表”中,讀者會看到動態分配內存處理字符串的方法。
1.4 字符串的連接
UNICODE_STRING不再是簡單的字符串。操作這個數據結構往往需要更多的耐心。讀者會常常碰到這樣的需求:要把兩個字符串連接到一起。簡單的追加一個字符串並不困難。重要的依然是保證目標字符串的空間大小。下面是範例:
NTSTATUS status;
UNICODE_STRING dst; // 目標字符串
WCHAR dst_buf[256]; // 我們現在還不會分配內存,所以先定義緩衝區
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
// 把目標字符串初始化爲擁有緩衝區長度爲256的UNICODE_STRING空串
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷貝!
status = RtlAppendUnicodeToString(
&dst,L”my second string!”);
if(status != STATUS_SUCCESS)
{
……
}
NTSTATUS是常見的返回值類型。如果函數成功,返回STATUS_SUCCESS。否則的話,是一個錯誤碼。RtlAppendUnicodeToString在目標字符串空間不足的時候依然可以連接字符串,但是會返回一個警告性的錯誤STATUS_BUFFER_TOO_SMALL。
另外一種情況是希望連接兩個UNICODE_STRING,這種情況請調用RtlAppendUnicodeStringToString。這個函數的第二個參數也是一個UNICODE_STRING的指針。
1.5 字符串的打印
字符串的連接另一種常見的情況是字符串和數字的組合。有時數字需要被轉換爲字符串。有時需要把若干個數字和字符串混合組合起來。這往往用於打印日誌的時候。日誌中可能含有文件名、時間、和行號,以及其他的信息。
熟悉C語言的讀者會使用sprintf。這個函數的寬字符版本爲swprintf。該函數在驅動開發中依然可以使用,但是不安全。微軟建議使用RtlStringCbPrintfW來代替它。RtlStringCbPrintfW需要包含頭文件ntstrsafe.h。在連接的時候,還需要連接庫ntsafestr.lib。
下面的代碼生成一個字符串,字符串中包含文件的路徑,和這個文件的大小。
#include <ntstrsafe.h>
// 任何時候,假設文件路徑的長度爲有限的都是不對的。應該動態的分配
// 內存。但是動態分配內存的方法還沒有講述,所以這裏再次把內存空間
// 定義在局部變量中,也就是所謂的“在棧中”
WCHAR buf[512] = { 0 };
UNICODE_STRING dst;
NTSTATUS status;
……
// 字符串初始化爲空串。緩衝區長度爲512*sizeof(WCHAR)
RtlInitEmptyString(dst,dst_buf,512*sizeof(WCHAR));
// 調用RtlStringCbPrintfW來進行打印
status = RtlStringCbPrintfW(
dst->Buffer,L”file path = %wZ file size = %d /r/n”,
&file_path,file_size);
// 這裏調用wcslen沒問題,這是因爲RtlStringCbPrintfW打印的
// 字符串是以空結束的。
dst->Length = wcslen(dst->Buffer) * sizeof(WCHAR);
RtlStringCbPrintfW在目標緩衝區內存不足的時候依然可以打印,但是多餘的部分被截去了。返回的status值爲STATUS_BUFFER_OVERFLOW。調用這個函數之前很難知道究竟需要多長的緩衝區。一般都採取倍增嘗試。每次都傳入一個爲前次嘗試長度爲2倍長度的新緩衝區,直到這個函數返回STATUS_SUCCESS爲止。
值得注意的是UNICODE_STRING類型的指針,用%wZ打印可以打印出字符串。在不能保證字符串爲空結束的時候,必須避免使用%ws或者%s。其他的打印格式字符串與傳統C語言中的printf函數完全相同。可以盡情使用。
另外就是常見的輸出打印。printf函數只有在有控制檯輸出的情況下才有意義。在驅動中沒有控制檯。但是Windows內核中擁有調試信息輸出機制。可以使用特殊的工具查看打印的調試信息(請參閱附錄1“WDK的安裝與驅動開發的環境配置”)。
驅動中可以調用DbgPrint()函數來打印調試信息。這個函數的使用和printf基本相同。但是格式字符串要使用寬字符。DbgPrint()的一個缺點在於,發行版本的驅動程序往往不希望附帶任何輸出信息,只有調試版本才需要調試信息。但是DbgPrint()無論是發行版本還是調試版本編譯都會有效。爲此可以自己定義一個宏:
#if DBG
KdPrint(a) DbgPrint##a
#else
KdPrint (a)
#endif
不過這樣的後果是,由於KdPrint (a)只支持1個參數,因此必須把DbgPrint的所有參數都括起來當作一個參數傳入。導致KdPrint看起來很奇特的用了雙重括弧:
// 調用KdPrint來進行輸出調試信息
status = KdPrint ((
L”file path = %wZ file size = %d /r/n”,
&file_path,file_size));
這個宏沒有必要自己定義,WDK包中已有。所以可以直接使用KdPrint來代替DbgPrint取得更方便的效果。
第二章 內存與鏈表
2.1內存的分配與釋放
內存泄漏是C語言中一個臭名昭著的問題。但是作爲內核開發者,讀者將有必要自己來面對它。在傳統的C語言中,分配內存常常使用的函數是malloc。這個函數的使用非常簡單,傳入長度參數就得到內存空間。在驅動中使用內存分配,這個函數不再有效。驅動中分配內存,最常用的是調用ExAllocatePoolWithTag。其他的方法在本章範圍內全部忽略。回憶前一小節關於字符串的處理的情況。一個字符串被複制到另一個字符串的時候,最好根據源字符串的空間長度來分配目標字符串的長度。下面的舉例,是把一個字符串src拷貝到字符串dst。
// 定義一個內存分配標記
#define MEM_TAG ‘MyTt’
// 目標字符串,接下來它需要分配空間。
UNICODE_STRING dst = { 0 };
// 分配空間給目標字符串。根據源字符串的長度。
dst.Buffer =
(PWCHAR)ExAllocatePoolWithTag(NonpagedPool,src->Length,MEM_TAG);
if(dst.Buffer == NULL)
{
// 錯誤處理
status = STATUS_INSUFFICIENT_RESOUCRES;
……
}
dst.Length = dst.MaximumLength = src->Length;
status = RtlCopyUnicodeString(&dst,&src);
ASSERT(status == STATUS_SUCCESS);
ExAllocatePoolWithTag的第一個參數NonpagedPool表明分配的內存是鎖定內存。這些內存永遠真實存在於物理內存上。不會被分頁交換到硬盤上去。第二個參數是長度。第三個參數是一個所謂的“內存分配標記”。
內存分配標記用於檢測內存泄漏。想象一下,我們根據佔用越來越多的內存的分配標記,就能大概知道泄漏的來源。一般每個驅動程序定義一個自己的內存標記。也可以在每個模塊中定義單獨的內存標記。內存標記是隨意的32位數字。即使衝突也不會有什麼問題。
此外也可以分配可分頁內存,使用PagedPool即可。
ExAllocatePoolWithTag分配的內存可以使用ExFreePool來釋放。如果不釋放,則永遠泄漏。並不像用戶進程關閉後自動釋放所有分配的空間。即使驅動程序動態卸載,也不能釋放空間。唯一的辦法是重啓計算機。
ExFreePool只需要提供需要釋放的指針即可。舉例如下:
ExFreePool(dst.Buffer);
dst.Buffer = NULL;
dst.Length = dst.MaximumLength = 0;
ExFreePool不能用來釋放一個棧空間的指針。否則系統立刻崩潰。像以下的代碼:
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
ExFreePool(src.Buffer);
會招來立刻藍屏。所以請務必保持ExAllocatePoolWithTag和ExFreePool的成對關係。
2.2 使用LIST_ENTRY
Windows的內核開發者們自己開發了部分數據結構,比如說LIST_ENTRY。
LIST_ENTRY是一個雙向鏈表結構。它總是在使用的時候,被插入到已有的數據結構中。下面舉一個例子。我構築一個鏈表,這個鏈表的每個節點,是一個文件名和一個文件大小兩個數據成員組成的結構。此外有一個FILE_OBJECT的指針對象。在驅動中,這代表一個文件對象。本書後面的章節會詳細解釋。這個鏈表的作用是:保存了文件的文件名和長度。只要傳入FILE_OBJECT的指針,使用者就可以遍歷鏈表找到文件名和文件長度。
typedef struct {
PFILE_OBJECT file_object;
UNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
一些讀者會馬上注意到文件的長度用LARGE_INTEGER表示。這是一個代表長長整型的數據結構。這個結構我們在下一小小節“使用長長整型數據”中介紹。
爲了讓上面的結構成爲鏈表節點,我必須在裏面插入一個LIST_ENTRY結構。至於插入的位置並無所謂。可以放在最前,也可以放中間,或者最後面。但是實際上讀者很快會發現把LIST_ENTRY放在開頭是最簡單的做法:
typedef struct {
LIST_ENTRY list_entry;
PFILE_OBJECT file_object;
UNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
list_entry如果是作爲鏈表的頭,在使用之前,必須調用InitializeListHead來初始化。下面是示例的代碼:
// 我們的鏈表頭
LIST_ENTRY my_list_head;
// 鏈表頭初始化。一般的說在應該在程序入口處調用一下
void MyFileInforInilt()
{
InitializeListHead(&my_list_head);
}
// 我們的鏈表節點。裏面保存一個文件名和一個文件長度信息。
typedef struct {
LIST_ENTRY list_entry;
PFILE_OBJECT file_object;
PUNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
// 追加一條信息。也就是增加一個鏈表節點。請注意file_name是外面分配的。
// 內存由使用者管理。本鏈表並不管理它。
NTSTATUS MyFileInforAppendNode(
PFILE_OBJECT file_object,
PUNICODE_STRING file_name,
PLARGE_INTEGER file_length)
{
PMY_FILE_INFOR my_file_infor =
(PMY_FILE_INFOR)ExAllocatePoolWithTag(
PagedPool,sizeof(MY_FILE_INFOR),MEM_TAG);
if(my_file_infor == NULL)
return STATUS_INSUFFICIENT_RESOURES;
// 填寫數據成員。
my_file_infor->file_object = file_object;
my_file_infor->file_name = file_name;
my_file_infor->file_length = file_length;
// 插入到鏈表末尾。請注意這裏沒有使用任何鎖。所以,這個函數不是多
// 多線程安全的。在下面自旋鎖的使用中講解如何保證多線程安全性。
InsertHeadList(&my_list_head, (PLIST_ENTRY)& my_file_infor);
return STATUS_SUCCESS;
}
以上的代碼實現了插入。可以看到LIST_ENTRY插入到MY_FILE_INFOR結構的頭部的好處。這樣一來一個MY_FILE_INFOR看起來就像一個LIST_ENTRY。不過糟糕的是並非所有的情況都可以這樣。比如MS的許多結構喜歡一開頭是結構的長度。因此在通過LIST_ENTRY結構的地址獲取所在的節點的地址的時候,有個地址偏移計算的過程。可以通過下面的一個典型的遍歷鏈表的示例中看到:
for(p = my_list_head.Flink; p != &my_list_head.Flink; p = p->Flink)
{
PMY_FILE_INFOR elem =
CONTAINING_RECORD(p,MY_FILE_INFOR, list_entry);
// 在這裏做需要做的事…
}
}
其中的CONTAINING_RECORD是一個WDK中已經定義的宏,作用是通過一個LIST_ENTRY結構的指針,找到這個結構所在的節點的指針。定義如下:
#define CONTAINING_RECORD(address, type, field) ((type *)( /
(PCHAR)(address) - /
(ULONG_PTR)(&((type *)0)->field)))
從上面的代碼中可以總結如下的信息:
LIST_ENTRY中的數據成員Flink指向下一個LIST_ENTRY。
整個鏈表中的最後一個LIST_ENTRY的Flink不是空。而是指向頭節點。
得到LIST_ENTRY之後,要用CONTAINING_RECORD來得到鏈表節點中的數據。
2.3 使用長長整型數據
這裏解釋前面碰到的LARGE_INTEGER結構。與可能的誤解不同,64位數據並非要在64位操作系統下才能使用。在VC中,64位數據的類型爲__int64。定義寫法如下:
__int64 file_offset;
上面之所以定義的變量名爲file_offset,是因爲文件中的偏移量是一種常見的要使用64位數據的情況。同時,文件的大小也是如此(回憶上一小節中定義的文件大小)。32位數據無符號整型只能表示到4GB。而衆所周知,現在超過4GB的文件絕對不罕見了。但是實際上__int64這個類型在驅動開發中很少被使用。基本上被使用到的是一個共用體:LARGE_INTEGER。這個共用體定義如下:
typedef __int64 LONGLONG;
typedef union _LARGE_INTEGER {
struct {
ULONG LowPart;
LONG HighPart;
};
struct {
ULONG LowPart;
LONG HighPart;
} u;
LONGLONG QuadPart;
} LARGE_INTEGER;
這個共用體的方便之處在於,既可以很方便的得到高32位,低32位,也可以方便的得到整個64位。進行運算和比較的時候,使用QuadPart即可。
LARGE_INTEGER a,b;
a.QuadPart = 100;
a.QuadPart *= 100;
b.QuadPart = a.QuadPart;
if(b.QuadPart > 1000)
{
KdPrint(“b.QuadPart < 1000, LowPart = %x HighPart = %x”, b.LowPart,b.HighPart);
}
上面這段代碼演示了這種結構的一般用法。在實際編程中,會碰到大量的參數是LARGE_INTEGER類型的。
2.4使用自旋鎖
鏈表之類的結構總是涉及到惱人的多線程同步問題,這時候就必須使用鎖。這裏只介紹最簡單的自選鎖。
有些讀者可能疑惑鎖存在的意義。這和多線程操作有關。在驅動開發的代碼中,大多是存在於多線程執行環境的。就是說可能有幾個線程在同時調用當前函數。
這樣一來,前文1.2.2中提及的追加鏈表節點函數就根本無法使用了。因爲MyFileInforAppendNode這個函數只是簡單的操作鏈表。如果兩個線程同時調用這個函數來操作鏈表的話:注意這個函數操作的是一個全局變量鏈表。換句話說,無論有多少個線程同時執行,他們操作的都是同一個鏈表。這就可能發生,一個線程插入一個節點的同時,另一個線程也同時插入。他們都插入同一個鏈表節點的後邊。這時鏈表就會發生問題。到底最後插入的是哪一個呢?要麼一個丟失了。要麼鏈表被損壞了。
如下的代碼初始化獲取一個自選鎖:
KSPIN_LOCK my_spin_lock;
KeInitializeSpinLock(&my_spin_lock);
KeInitializeSpinLock這個函數沒有返回值。下面的代碼展示瞭如何使用這個SpinLock。在KeAcquireSpinLock和KeReleaseSpinLock之間的代碼是隻有單線程執行的。其他的線程會停留在KeAcquireSpinLock等候。直到KeReleaseSpinLock被調用。KIRQL是一箇中斷級。KeAcquireSpinLock會提高當前的中斷級。但是目前忽略這個問題。中斷級在後面講述。
KIRQL irql;
KeAcquireSpinLock(&my_spin_lock,&irql);
// To do something …
KeReleaseSpinLock(&my_spin_lock,irql);
初學者要注意的是,像下面寫的這樣的“加鎖”代碼是沒有意義的,等於沒加鎖:
void MySafeFunction()
{
KSPIN_LOCK my_spin_lock;
KIRQL irql;
KeInitializeSpinLock(&my_spin_lock);
KeAcquireSpinLock(&my_spin_lock,&irql);
// 在這裏做要做的事情…
KeReleaseSpinLock(&my_spin_lock,irql);
}
原因是my_spin_lock在堆棧中。每個線程來執行的時候都會重新初始化一個鎖。只有所有的線程共用一個鎖,鎖纔有意義。所以,鎖一般不會定義成局部變量。可以使用靜態變量、全局變量,或者分配在堆中(見前面的1.2.1內存的分配與釋放一節)。請讀者自己寫出正確的方法。
LIST_ENTRY有一系列的操作。這些操作並不需要使用者自己調用獲取與釋放鎖。只需要爲每個鏈表定義並初始化一個鎖即可:
LIST_ENTRY my_list_head; // 鏈表頭
KSPIN_LOCK my_list_lock; // 鏈表的鎖
// 鏈表初始化函數
void MyFileInforInilt()
{
InitializeListHead(&my_list_head);
KeInitializeSpinLock(&my_list_lock);
}
鏈表一旦完成了初始化,之後的可以採用一系列加鎖的操作來代替普通的操作。比如插入一個節點,普通的操作代碼如下:
InsertHeadList(&my_list_head, (PLIST_ENTRY)& my_file_infor);
換成加鎖的操作方式如下:
ExInterlockedInsertHeadList(
&my_list_head,
(PLIST_ENTRY)& my_file_infor,
&my_list_lock);
注意不同之處在於,增加了一個KSPIN_LOCK的指針作爲參數。在ExInterlockedInsertHeadList中,會自動的使用這個KSPIN_LOCK進行加鎖。類似的還有一個加鎖的Remove函數,用來移除一個節點,調用如下:
my_file_infor = ExInterlockedRemoveHeadList (
&my_list_head,
&my_list_lock);
這個函數從鏈表中移除第一個節點。並返回到my_file_infor中。
第三章 文件操作
在內核中不能調用用戶層的Win32 API函數來操作文件。在這裏必須改用一系列與之對應的內核函數。
3.1 使用OBJECT_ATTRIBUTES
一般的想法是,打開文件應該傳入這個文件的路徑。但是實際上這個函數並不直接接受一個字符串。使用者必須首先填寫一個OBJECT_ATTRIBUTES結構。在文檔中並沒有公開這個OBJECT_ATTRIBUTES結構。這個結構總是被InitializeObjectAttributes初始化。
下面專門說明InitializeObjectAttributes。
VOID InitializeObjectAttributes(
OUT POBJECT_ATTRIBUTES InitializedAttributes,
IN PUNICODE_STRING ObjectName,
IN ULONG Attributes,
IN HANDLE RootDirectory,
IN PSECURITY_DESCRIPTOR SecurityDescriptor);
讀者需要注意的以下的幾點:InitializedAttributes是要初始化的OBJECT_ATTRIBUTES結構的指針。ObjectName則是對象名字字符串。也就是前文所描述的文件的路徑(如果要打開的對象是一個文件的話)。
Attributes則只需要填寫OBJ_CASE_INSENSITIVE| OBJ_KERNEL_HANDLE即可(如果讀者是想要方便的簡潔的打開一個文件的話)。OBJ_CASE_INSENSITIVE意味着名字字符串是不區分大小寫的。由於Windows的文件系統本來就不區分字母大小寫,所以筆者並沒有嘗試過如果不設置這個標記會有什麼後果。OBJ_KERNEL_HANDLE表明打開的文件句柄一個“內核句柄”。內核文件句柄比應用層句柄使用更方便,可以不受線程和進程的限制。在任何線程中都可以讀寫。同時打開內核文件句柄不需要顧及當前進程是否有權限訪問該文件的問題(如果是有安全權限限制的文件系統)。如果不使用內核句柄,則有時不得不填寫後面的的SecurityDescriptor參數。
RootDirectory用於相對打開的情況。目前省略。請讀者傳入NULL即可。
SecurityDescriptor用於設置安全描述符。由於筆者總是打開內核句柄,所以很少設置這個參數。
3.2 打開和關閉文件
下面的函數用於打開一個文件:
NTSTATUS ZwCreateFile(
OUT PHANDLE FileHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttribute,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER AllocationSize OPTIONAL,
IN ULONG FileAttributes,
IN ULONG ShareAccess,
IN ULONG CreateDisposition,
IN ULONG createOptions,
IN PVOID EaBuffer OPTIONAL,
IN ULONG EaLength);
這個函數的參數異常複雜。下面逐個的說明如下:
FileHandle:是一個句柄的指針。如果這個函數調用返回成成功(STATUS_SUCCESS),那就麼打開的文件句柄就返回在這個地址內。
DesiredAccess:申請的權限。如果打開寫文件內容,請使用FILE_WRITE_DATA。如果需要讀文件內容,請使用FILE_READ_DATA。如果需要刪除文件或者把文件改名,請使用DELETE。如果想設置文件屬性,請使用FILE_WRITE_ATTRIBUTES。反之,讀文件屬性則使用FILE_READ_ATTRIBUTES。這些條件可以用|(位或)來組合。有兩個宏分別組合了常用的讀權限和常用的寫權限。分別爲GENERIC_READ和GENERIC_WRITE。此外還有一個宏代表全部權限,是GENERIC_ALL。此外,如果想同步的打開文件,請加上SYNCHRONIZE。同步打開文件詳見後面對CreateOptions的說明。
ObjectAttribute:對象描述。見前一小節。
IoStatusBlock也是一個結構。這個結構在內核開發中經常使用。它往往用於表示一個操作的結果。這個結構在文檔中是公開的,如下:
typedef struct _IO_STATUS_BLOCK {
union {
NTSTATUS Status;
PVOID Pointer;
};
ULONG_PTR Information;
} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;
實際編程中很少用到Pointer。一般的說,返回的結果在Status中。成功則爲STATUS_SUCCESS。否則則是一個錯誤碼。進一步的信息在Information中。不同的情況下返回的Information的信息意義不同。針對ZwCreateFile調用的情況,Information的返回值有以下幾種可能:
FILE_CREATED:文件被成功的新建了。
FILE_OPENED: 文件被打開了。
FILE_OVERWRITTEN:文件被覆蓋了。
FILE_SUPERSEDED: 文件被替代了。
FILE_EXISTS:文件已存在。(因而打開失敗了)。
FILE_DOES_NOT_EXIST:文件不存在。(因而打開失敗了)。
這些返回值和打開文件的意圖有關(有時希望打開已存在的文件,有時則希望建立新的文件等等。這些意圖在本小節稍後的內容中詳細說明。
ZwCreateFile的下一個參數是AllocationSize。這個參數很少使用,請設置爲NULL。 再接下來的一個參數爲FileAttributes。這個參數控制新建立的文件的屬性。一般的說,設置爲FILE_ATTRIBUTE_NORMAL即可。在實際編程中,筆者沒有嘗試過其他的值。
ShareAccess是一個非常容易被人誤解的參數。實際上,這是在本代碼打開這個文件的時候,允許別的代碼同時打開這個文件所持有的權限。所以稱爲共享訪問。一共有三種共享標記可以設置:FILE_SHARE_READ、FILE_SHARE_WRITE、FILE_SHARE_DELETE。這三個標記可以用|(位或)來組合。舉例如下:如果本次打開只使用了FILE_SHARE_READ,那麼這個文件在本次打開之後,關閉之前,別次打開試圖以讀權限打開,則被允許,可以成功打開。如果別次打開試圖以寫權限打開,則一定失敗。返回共享衝突。
同時,如果本次打開只只用了FILE_SHARE_READ,而之前這個文件已經被另一次打開用寫權限打開着。那麼本次打開一定失敗,返回共享衝突。其中的邏輯關係貌似比較複雜,讀者應耐心理解。
CreateDisposition參數說明了這次打開的意圖。可能的選擇如下(請注意這些選擇不能組合):
FILE_CREATE:新建文件。如果文件已經存在,則這個請求失敗。
FILE_OPEN:打開文件。如果文件不存在,則請求失敗。
FILE_OPEN_IF:打開或新建。如果文件存在,則打開。如果不存在,則失敗。
FILE_OVERWRITE:覆蓋。如果文件存在,則打開並覆蓋其內容。如果文件不存在,這個請求返回失敗。
FILE_OVERWRITE_IF:新建或覆蓋。如果要打開的文件已存在,則打開它,並覆蓋其內存。如果不存在,則簡單的新建新文件。
FILE_SUPERSEDE:新建或取代。如果要打開的文件已存在。則生成一個新文件替代之。如果不存在,則簡單的生成新文件。
請聯繫上面的IoStatusBlock參數中的Information的說明。
最後一個重要的參數是CreateOptions。在慣常的編程中,筆者使用FILE_NON_DIRECTORY_FILE| FILE_SYNCHRONOUS_IO_NONALERT。此時文件被同步的打開。而且打開的是文件(而不是目錄。創建目錄請用FILE_ DIRECTORY_FILE)。所謂同步的打開的意義在於,以後每次操作文件的時候,比如寫入文件,調用ZwWriteFile,在ZwWriteFile返回時,文件寫操作已經得到了完成。而不會有返回STATUS_PENDING(未決)的情況。在非同步文件的情況下,返回未決是常見的。此時文件請求沒有完成,使用者需要等待事件來等待請求的完成。當然,好處是使用者可以先去做別的事情。
要同步打開,前面的DesiredAccess必須含有SYNCHRONIZE。
此外還有一些其他的情況。比如不通過緩衝操作文件。希望每次讀寫文件都是直接往磁盤上操作的。此時CreateOptions中應該帶標記FILE_NO_INTERMEDIATE_BUFFERING。帶了這個標記後,請注意操作文件每次讀寫都必須以磁盤扇區大小(最常見的是512字節)對齊。否則會返回錯誤。
這個函數是如此的繁瑣,以至於再多的文檔也不如一個可以利用的例子。早期筆者調用這個函數往往因爲參數設置不對而導致打開失敗。非常渴望找到一個實際可以使用的參數的範例。下面舉例如下:
// 要返回的文件句柄
HANDLE file_handle = NULL;
// 返回值
NTSTATUS status;
// 首先初始化含有文件路徑的OBJECT_ATTRIBUTES
OBJECT_ATTRIBUTES object_attributes;
UNICODE_STRING ufile_name = RTL_CONST_STRING(L”//??//C://a.dat”);
InitializeObjectAttributes(
&object_attributes,
&ufile_name,
OBJ_CASE_INSENSITIVE|OBJ_KERNEL_HANDLE,
NULL,
NULL);
// 以OPEN_IF方式打開文件。
status = ZwCreateFile(
&file_handle,
GENERIC_READ | GENERIC_WRITE,
&object_attributes,
&io_status,
NULL,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ,
FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE |
FILE_RANDOM_ACCESS |
FILE_SYNCHRONOUS_IO_NONALERT,
NULL,
0);
值得注意的是路徑的寫法。並不是像應用層一樣直接寫“C://a.dat”。而是寫成了“//??//C://a.dat”。這是因爲ZwCreateFile使用的是對象路徑。“C:”是一個符號鏈接對象。符號鏈接對象一般都在“//??//”路徑下。
這種文件句柄的關閉非常簡單。調用ZwClose即可。內核句柄的關閉不需要和打開在同一進程中。示例如下:
ZwClose(file_handle);
3.3 文件的讀寫操作
打開文件之後,最重要的操作是對文件的讀寫。讀與寫的方法是對稱的。只是參數輸入與輸出的方向不同。讀取文件內容一般用ZwReadFile,寫文件一般使用ZwWriteFile。
NTSTATUS
ZwReadFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
OUT PVOID Buffer,
IN ULONG Length,
IN PLARGE_INTEGER ByteOffset OPTIONAL,
IN PULONG Key OPTIONAL);
FileHandle:是前面ZwCreateFile成功後所得到的FileHandle。如果是內核句柄,ZwReadFile和ZwCreateFile並不需要在同一個進程中。句柄是各進程通用的。
Event :一個事件。用於異步完成讀時。下面的舉例始終用同步讀,所以忽略這個參數。請始終填寫NULL。
ApcRoutine Apc:回調例程。用於異步完成讀時。下面的舉例始終用同步讀,所以忽略這個參數。請始終填寫NULL。
IoStatusBlock:返回結果狀態。同ZwCreateFile中的同名參數。
Buffer:緩衝區。如果讀文件的內容成功,則內容被被讀到這個緩衝裏。
Length:描述緩衝區的長度。這個長度也就是試圖讀取文件的長度。
ByteOffset:要讀取的文件的偏移量。也就是要讀取的內容在文件中的位置。一般的說,不要設置爲NULL。文件句柄不一定支持直接讀取當前偏移。
Key:讀取文件時用的一種附加信息,一般不使用。設置NULL。
返回值:成功的返回值是STATUS_SUCCESS。只要讀取到任意多個字節(不管是否符合輸入的Length的要求),返回值都是STATUS_SUCCESS。即使試圖讀取的長度範圍超出了文件本來的大小。但是,如果僅讀取文件長度之外的部分,則返回STATUS_END_OF_FILE。
ZwWriteFile的參數與ZwReadFile完全相同。當然,除了讀寫文件外,有的讀者可能會問是否提供一個ZwCopyFile用來拷貝一個文件。這個要求未能被滿足。如果有這個需求,這個函數必須自己來編寫。下面是一個例子,用來拷貝一個文件。利用到了ZwCreateFile,ZwReadFile和ZwWrite這三個函數。不過作爲本節的例子,只舉出ZwReadFile和ZwWriteFile的部分:
NTSTATUS MyCopyFile(
PUNICODE_STRING target_path,
PUNICODE_STRING source_path)
{
// 源和目標的文件句柄
HANDLE target = NULL,source = NULL;
// 用來拷貝的緩衝區
PVOID buffer = NULL;
LARGE_INTEGER offset = { 0 };
IO_STATUS_BLOCK io_status = { 0 };
do {
// 這裏請用前一小節說到的例子打開target_path和source_path所對應的
// 句柄target和source,併爲buffer分配一個頁面也就是4k的內存。
… …
// 然後用一個循環來讀取文件。每次從源文件中讀取4k內容,然後往
// 目標文件中寫入4k,直到拷貝結束爲止。
while(1) {
length = 4*1024; // 每次讀取4k。
// 讀取舊文件。注意status。
status = ZwReadFile (
source,NULL,NULL,NULL,
&my_io_status,buffer, length,&offset,
NULL);
if(!NT_SUCCESS(status))
{
// 如果狀態爲STATUS_END_OF_FILE,則說明文件
// 的拷貝已經成功的結束了。
if(status == STATUS_END_OF_FILE)
status = STATUS_SUCCESS;
break;
}
// 獲得實際讀取到的長度。
length = IoStatus.Information;
// 現在讀取了內容。讀出的長度爲length.那麼我寫入
// 的長度也應該是length。寫入必須成功。如果失敗,
// 則返回錯誤。
status = ZwWriteFile(
target,NULL,NULL,NULL,
&my_io_status,
buffer,length,&offset,
NULL);
if(!NT_SUCCESS(status))
break;
// offset移動,然後繼續。直到出現STATUS_END_OF_FILE
// 的時候才結束。
offset.QuadPart += length;
}
} while(0);
// 在退出之前,釋放資源,關閉所有的句柄。
if(target != NULL)
ZwClose(target);
if(source != NULL)
ZwClose(source);
if(buffer != NULL)
ExFreePool(buffer);
return STATUS_SUCCESS;
}
除了讀寫之外,文件還有很多的操作。比如刪除、重新命名、枚舉。這些操作將在後面實例中用到時,再詳細講解。
第四章 操作註冊表
4.1 註冊鍵的打開操作
和在應用程序中編程的方式類似,註冊表是一個巨大的樹形結構。操作一般都是打開某個子鍵。子鍵下有若干個值可以獲得。每一個值有一個名字。值有不同的類型。一般需要查詢才能獲得其類型。
子鍵一般用一個路徑來表示。和應用程序編程的一點重大不同是這個路徑的寫法不一樣。一般應用編程中需要提供一個根子鍵的句柄。而驅動中則全部用路徑表示。相應的有一張表表示如下:
應用編程中對應的子鍵 |
驅動編程中的路徑寫法 |
HKEY_LOCAL_MACHINE |
/Registry/Machine |
HKEY_USERS |
/Registry/User |
HKEY_CLASSES_ROOT |
沒有對應的路徑 |
HKEY_CURRENT_USER |
沒有簡單的對應路徑,但是可以求得 |
實際上應用程序和驅動程序很大的一個不同在於應用程序總是由某個“當前用戶”啓動的。因此可以直接讀取HKEY_CLASSES_ROOT和HKEY_CURRENT_USER。而驅動程序和用戶無關,所以直接去打開HKEY_CURRENT_USER也就不符合邏輯了。
打開註冊表鍵使用函數ZwOpenKey。新建或者打開則使用ZwCreateKey。一般在驅動編程中,使用ZwOpenKey的情況比較多見。下面以此爲例講解。ZwOpenKey的原型如下:
NTSTATUS
ZwOpenKey(
OUT PHANDLE KeyHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes
);
這個函數和ZwCreateFile是類似的。它並不接受直接傳入一個字符串來表示一個子鍵。而是要求輸入一個OBJECT_ATTRIBUTES的指針。如何初始化一個OBJECT_ATTRIBUTES請參考前面的講解ZwCreateFile的章節。
DesiredAccess支持一系列的組合權限。可以是下表中所有權限的任何組合:
KEY_QUERY_VALUE:讀取鍵下的值。
KEY_SET_VALUE:設置鍵下的值。
KEY_CREATE_SUB_KEY:生成子鍵。
KEY_ENUMERATE_SUB_KEYS:枚舉子鍵。
不過實際上可以用KEY_READ來做爲通用的讀權限組合。這是一個組合宏。此外對應的有KEY_WRITE。如果需要獲得全部的權限,可以使用KEY_ALL_ACCESS。
下面是一個例子,這個例子非常的有實用價值。它讀取註冊表中保存的Windows系統目錄(指Windows目錄)的位置。不過這裏只涉及打開子鍵。並不讀取值。讀取具體的值在後面的小節中再完成。
Windows目錄的位置被稱爲SystemRoot,這一值保存在註冊表中,路徑是“HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Windows NT/CurrentVersion”。當然,請注意注意在驅動編程中的寫法有所不同。下面的代碼初始化一個OBJECT_ATTRIBUTES。
HANDLE my_key = NULL;
NTSTATUS status;
// 定義要獲取的路徑
UNICODE_STRING my_key_path =
RTL_CONSTANT_STRING(
L” // Registry//Machine//SOFTWARE//Microsoft//Windows NT//CurrentVersion”);
OBJECT_ATTRIBUTE my_obj_attr = { 0 };
// 初始化OBJECT_ATTRIBUTE
InitializeObjectAttributes(
&my_obj_attr,
&my_key_path,
OBJ_CASE_INSENSITIVE,
NULL,
NULL);
// 接下來是打開Key
status = ZwOpenKey(&my_key,KEY_READ,&my_obj_attr);
if(!NT_SUCCESS(status))
{
// 失敗處理
……
}
上面的代碼得到了my_key。子鍵已經打開。然後的步驟是讀取下面的SystemRoot值。這在後面一個小節中講述。
4.2 註冊值的讀
一般使用ZwQueryValueKey來讀取註冊表中鍵的值。要注意的是註冊表中的值可能有多種數據類型。而且長度也是沒有定數的。爲此,在讀取過程中,就可能要面對很多種可能的情況。ZwQueryValueKey這個函數的原型如下:
NTSTATUS ZwQueryValueKey(
IN HANDLE KeyHandle,
IN PUNICODE_STRING ValueName,
IN KEY_VALUE_INFORMATION_CLASS KeyValueInformationClass,
OUT PVOID KeyValueInformation,
IN ULONG Length,
OUT PULONG ResultLength
);
KeyHandle:這是用ZwCreateKey或者ZwOpenKey所打開的一個註冊表鍵句柄。
ValueName:要讀取的值的名字。
KeyValueInformationClass:本次查詢所需要查詢的信息類型。這有如下的三種可能。
KeyValueBasicInformation:獲得基礎信息,包含值名和類型。
KeyValueFullInformation:獲得完整信息。包含值名、類型和值的數據。
KeyValuePartialInformation:獲得局部信息。包含類型和值數據。
很容易看出實際上名字是已知的,獲得基礎信息是多此一舉。同樣獲得完整信息也是浪費內存空間。因爲調用ZwQueryValueKey的目的是爲了得到類型和值數據。因此使用KeyValuePartialInformation最常見。當採用KeyValuePartialInformation的時候,一個類型爲KEY_VALUE_PARTIAL_INFORMATION的結構將被返回到參數KeyValueInformation所指向的內存中。
KeyValueInformation:當KeyValueInformationClass被設置爲KeyValuePartialInformation時,KEY_VALUE_PARTIAL_INFORMATION結構將被返回到這個指針所指內存中。下面是結構KEY_VALUE_PARTIAL_INFORMATION的原型。
typedef struct _KEY_VALUE_PARTIAL_INFORMATION {
ULONG TitleIndex; // 請忽略這個成員
ULONG Type; // 數據類型
ULONG DataLength; // 數據長度
UCHAR Data[1]; // 可變長度的數據
}KEY_VALUE_PARTIAL_INFORMATION,*PKEY_VALUE_PARTIAL_INFORMATIO;
上面的數據類型Type有很多種可能,但是最常見的幾種如下:
REG_BINARY:十六進制數據。
REG_DWORD:四字節整數。
REG_SZ:以空結束的Unicode字符串。
Length:用戶傳入的輸出空間KeyValueInformation的長度。
ResultLength:返回實際需要的長度。
返回值:如果說實際需要的長度比Length要大,那麼返回STATUS_BUFFER_OVERFLOW或者是STATUS_BUFFER_TOO_SMALL。如果成功讀出了全部數據,那麼返回STATUS_SUCCESS。其他的情況,返回一個錯誤碼。
下面請讀者考慮如何把上一小節的函數寫完整。這其中比較常見的一個問題是在讀取註冊表鍵下的值之前,往往不知道這個值有多長。所以有些比較偷懶的程序員總是定義一個足夠的大小的空間(比如512字節)。這樣的壞處是浪費內存(一般都是在堆棧中定義,而內核編程中堆棧空間被耗盡又是另一個常見的藍屏問題)。此外也無法避免值實際上大於該長度的情況。爲此應該耐心的首先獲取長度,然後不足時再動態分配內存進行讀取。下面是示例代碼:
// 要讀取的值的名字
UNICODE_STRING my_key_name =
RTL_CONSTANT_STRING(L”SystemRoot”);
// 用來試探大小的key_infor
KEY_VALUE_PARTIAL_INFORMATION key_infor;
// 最後實際用到的key_infor指針。內存分配在堆中
PKEY_VALUE_PARTIAL_INFORMATION ac_key_infor;
ULONG ac_length;
……
// 前面已經打開了句柄my_key,下面如此來讀取值:
status = ZwQueryValueKey(
my_key,
&my_key_name,
KeyValuePartialInformation,
&key_infor,
sizeof(KEY_VALUE_PARTIAL_INFORMATION),
&ac_length);
if(!NT_SUCCESS(status) &&
status != STATUS_BUFFER_OVERFLOW &&
status != STATUS_BUFFER_TOO_SMALL)
{
// 錯誤處理
…
}
// 如果沒失敗,那麼分配足夠的空間,再次讀取
ac_key_infor = (PKEY_VALUE_PARTIAL_INFORMATION)
ExAllocatePoolWithTag(NonpagedPool,ac_length ,MEM_TAG);
if(ac_key_infor == NULL)
{
stauts = STATUS_INSUFFICIENT_RESOURCES;
// 錯誤處理
…
}
status = ZwQueryValueKey(
my_key,
&my_key_name,
KeyValuePartialInformation,
ac_key_infor,
ac_length,
&ac_length);
// 到此爲止,如果status爲STATUS_SUCCESS,則要讀取的數據已經
// 在ac_key_infor->Data中。請利用前面學到的知識,轉換爲
// UNICODE_STRING
……
4.3 註冊值的寫
實際上註冊表的寫入比讀取要簡單。因爲這省略了一個嘗試數據的大小的過程。直接將數據寫入即可。寫入值一般使用函數ZwSetValueKey 。這個函數的原型如下:
NTSTATUS ZwSetValueKey(
IN HANDLE KeyHandle,
IN PUNICODE_STRING ValueName,
IN ULONG TitleIndex OPTIONAL,
IN ULONG Type,
IN PVOID Data,
IN ULONG DataSize
);
其中的TileIndex參數請始終填入0。
KeyHandle、ValueName、Type這三個參數和ZwQueryValueKey中對應的參數相同。不同的是Data和DataSize。Data是要寫入的數據的開始地址,而DataSize是要寫入的數據的長度。由於Data是一個空指針,因此,Data可以指向任何數據。也就是說,不管Type是什麼,都可以在Data中填寫相應的數據寫入。
ZwSetValueKey的時候,並不需要該Value已經存在。如果該Value已經存在,那麼其值會被這次寫入覆蓋。如果不存在,則會新建一個。下面的例子寫入一個名字爲“Test”,而且值爲“My Test Value”的字符串值。假設my_key是一個已經打開的子鍵的句柄。
UNICODE_STRING name = RTL_CONSTANT_STRING(L”Test”);
PWCHAR value = { L”My Test Value” };
…
// 寫入數據。數據長度之所以要將字符串長度加上1,是爲了把最後一個空結束符
// 寫入。我不確定如果不寫入空結束符會不會有錯,有興趣的讀者請自己測試一下。
status = ZwSetValueKey(my_key,
&name,0,REG_SZ,value,(wcslen(value)+1)*sizeof(WCHAR));
if(!NT_SUCCESS(status))
{
// 錯誤處理
……
}
關於註冊表的操作就介紹到這裏了。如果有進一步的需求,建議讀者閱讀WDK相關的文檔。
第五章 時間與定時器
5.1 獲得當前滴答數
在編程中,獲得當前的系統日期和時間,或者是獲得一個從啓動開始的毫秒數,是很常見的需求。獲得系統日期和時間往往是爲了寫日誌。獲得啓動毫秒數很適合用來做一個隨機數的種子。有時也使用時間相關的函數來尋找程序的性能瓶頸。
熟悉Win32應用程序開發的讀者會知道有一個函數GetTickCount(),這個函數返回系統自啓動之後經歷的毫秒數。在驅動開發中有一個對應的函數KeQueryTickCount(),這個函數的原型如下:
VOID
KeQueryTickCount(
OUT PLARGE_INTEGER TickCount
);
遺憾的是,被返回到TickCount中的並不是一個簡單的毫秒數。這是一個“滴答”數。但是一個“滴答”到底爲多長的時間,在不同的硬件環境下可能有所不同。爲此,必須結合另一個函數使用。下面這個函數獲得一個“滴答”的具體的100納秒數。
ULONG
KeQueryTimeIncrement(
);
得知以上的關係之後,下面的代碼可以求得實際的毫秒數:
void MyGetTickCount (PULONG msec)
{
LARGE_INTEGER tick_count;
ULONG myinc = KeQueryTimeIncrement();
KeQueryTickCount(&tick_count);
tick_count.QuadPart *= myinc;
tick_count.QuadPart /= 10000;
*msec = tick_count.LowPart;
}
這不是一個簡單的過程。不過所幸的是,現在有代碼可以拷貝了。
5.2 獲得當前系統時間
接下來的一個需求是得到當前的可以供人類理解的時間。包括年、月、日、時、分、秒這些要素。在驅動中不能使用諸如CTime之類的MFC類。不過與之對應的有TIME_FIELDS,這個結構中含有對應的時間要素。
KeQuerySystemTime()得到當前時間。但是得到的並不是當地時間,而是一個格林威治時間。之後請使用ExSystemTimeToLocalTime()轉換可以當地時間。這兩個函數的原型如下:
VOID
KeQuerySystemTime(
OUT PLARGE_INTEGER CurrentTime
);
VOID
ExSystemTimeToLocalTime(
IN PLARGE_INTEGER SystemTime,
OUT PLARGE_INTEGER LocalTime
);
這兩個函數使用的“時間”都是長長整型數據結構。這不是人類可以閱讀的。必須通過函數RtlTimeToTimeFields轉換爲TIME_FIELDS。這個函數原型如下:
VOID
RtlTimeToTimeFields(
IN PLARGE_INTEGER Time,
IN PTIME_FIELDS TimeFields
);
讀者需要實際應用一下來加深印象。下面寫出一個函數:這個函數返回一個字符串。這個字符串寫出當前的年、月、日、時、分、秒,這些數字之間用“-”號隔開。這是一個很有用的函數。而且同時用到上面三個函數,此外,請讀者回憶前面關於字符串的打印的相關章節。
PWCHAR MyCurTimeStr()
{
LARGE_INTEGER snow,now;
TIME_FIELDS now_fields;
static WCHAR time_str[32] = { 0 };
// 獲得標準時間
KeQuerySystemTime(&snow);
// 轉換爲當地時間
ExSystemTimeToLocalTime(&snow,&now);
// 轉換爲人類可以理解的時間要素
RtlTimeToTimeFields(&now,&now_fields);
// 打印到字符串中
RtlStringCchPrintfW(
time_str,
32*2,
L"%4d-%2d-%2d %2d-%2d-%2d",
now_fields.Year,now_fields.Month,now_fields.Day,
now_fields.Hour,now_fields.Minute,now_fields.Second);
return time_str;
}
請注意time_str是靜態變量。這使得這個函數不具備多線程安全性。請讀者考慮一下,如何保證多個線程同時調用這個函數的時候,不出現衝突?
5.3 使用定時器
使用過Windows應用程序編程的讀者的讀者一定對SetTimer()映像尤深。當需要定時執行任務的時候,SetTimer()變得非常重要。這個功能在驅動開發中可以通過一些不同的替代方法來實現。比較經典的對應是KeSetTimer(),這個函數的原型如下:
BOOLEAN
KeSetTimer(
IN PKTIMER Timer, // 定時器
IN LARGE_INTEGER DueTime, // 延後執行的時間
IN PKDPC Dpc OPTIONAL // 要執行的回調函數結構
);
其中的定時器Timer和要執行的回調函數結構Dpc都必須先初始化。其中Timer的初始化比較簡單。下面的代碼可以初始化一個Timer:
KTIMER my_timer;
KeInitializeTimer(&my_timer);
Dpc的初始化比較麻煩。這是因爲需要提供一個回調函數。初始化Dpc的函數原型如下:
VOID
KeInitializeDpc(
IN PRKDPC Dpc,
IN PKDEFERRED_ROUTINE DeferredRoutine,
IN PVOID DeferredContext
);
PKDEFERRED_ROUTINE這個函數指針類型所對應的函數的類型實際上是這樣的:
VOID
CustomDpc(
IN struct _KDPC *Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
);
讀者需要關心的只是DeferredContext。這個參數是KeInitializeDpc調用時傳入的參數。用來提供給CustomDpc被調用的時候,讓用戶傳入一些參數。
至於後面的SystemArgument1和SystemArgument2則請不要理會。Dpc是回調這個函數的KDPC結構。
請注意這是一個“延時執行”的過程。而不是一個定時執行的過程。因此每次執行了之後,下次就不會再被調用了。如果想要定時反覆執行,就必須在每次CustomDpc函數被調用的時候,再次調用KeSetTimer,來保證下次還可以執行。
值得注意的是,CustomDpc將運行在APC中斷級。因此並不是所有的事情都可以做(在調用任何內核系統函數的時候,請注意WDK說明文檔中標明的中斷級要求。)
這些事情非常的煩惱,因此要完全實現定時器的功能,需要自己封裝一些東西。下面的結構封裝了全部需要的信息:
// 內部時鐘結構
typedef struct MY_TIMER_
{
KDPC dpc;
KTIMER timer;
PKDEFERRED_ROUTINE func;
PVOID private_context;
} MY_TIMER,*PMY_TIMER;
// 初始化這個結構:
void MyTimerInit(PMY_TIMER timer, PKDEFERRED_ROUTINE func)
{
// 請注意,我把回調函數的上下文參數設置爲timer,爲什麼要
// 這樣做呢?
KeInitializeDpc(&timer->dpc,sf_my_dpc_routine,timer);
timer->func = func;
KeInitializeTimer(&timer->timer);
return (wd_timer_h)timer;
}
// 讓這個結構中的回調函數在n毫秒之後開始運行:
BOOLEAN MyTimerSet(PMY_TIMER timer,ULONG msec,PVOID context)
{
LARGE_INTEGER due;
// 注意時間單位的轉換。這裏msec是毫秒。
due.QuadPart = -10000*msec;
// 用戶私有上下文。
timer->private_context = context;
return KeSetTimer(&timer->timer,due,&mytimer->dpc);
};
// 停止執行
VOID MyTimerDestroy(PMY_TIMER timer)
{
KeCancelTimer(&mytimer->timer);
};
使用結構PMY_TIMER已經比結合使用KDPC和KTIMER簡便許多。但是還是有一些要注意的地方。真正的OnTimer回調函數中,要獲得上下文,必須要從timer->private_context中獲得。此外,OnTimer中還有必要再次調用MyTimerSet(),來保證下次依然得到執行。
VOID
MyOnTimer (
IN struct _KDPC *Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
)
{
// 這裏傳入的上下文是timer結構,用來下次再啓動延時調用
PMY_TIMER timer = (PMY_TIMER)DeferredContext;
// 獲得用戶上下文
PVOID my_context = timer->private_context;
// 在這裏做OnTimer中要做的事情
……
// 再次調用。這裏假設每1秒執行一次
MyTimerSet(timer,1000,my_context);
};
關於定時器就介紹到這裏了。
第六章 內核線程
6.1 使用線程
有時候需要使用線程來完成一個或者一組任務。這些任務可能耗時過長,而開發者又不想讓當前系統停止下來等待。在驅動中停止等待很容易使整個系統陷入“停頓”,最後可能只能重啓電腦。但一個單獨的線程長期等待,還不至於對系統造成致命的影響。另一些任務是希望長期、不斷的執行,比如不斷寫入日誌。爲此啓動一個特殊的線程來執行它們是最好的方法。
在驅動中生成的線程一般是系統線程。系統線程所在的進程名爲“System”。用到的內核API函數原型如下:
NTSTATUS
PsCreateSystemThread(
OUT PHANDLE ThreadHandle,
IN ULONG DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ProcessHandle OPTIONAL,
OUT PCLIENT_ID ClientId OPTIONAL,
IN PKSTART_ROUTINE StartRoutine,
IN PVOID StartContext);
這個函數的參數也很多。不過作者本人的使用經驗如下:ThreadHandle用來返回句柄。放入一個句柄指針即可。DesiredAccess總是填寫0。後面三個參數都填寫NULL。最後的兩個參數一個用於改線程啓動的時候執行的函數。一個用於傳入該函數的參數。
下面要關心的就是那個啓動函數的原型。這個原型比起定時器回調函數倒是異常的簡單,沒有任何多餘的東西:
VOID CustomThreadProc(IN PVOID context)
可以傳入一個參數,就是那個context。context就是PsCreateSystemThread中的StartContext。值得注意的是,線程的結束應該在線程中自己調用PsTerminateSystemThread來完成。此外得到的句柄也必須要用ZwClose來關閉。但是請注意:關閉句柄並不結束線程。
下面舉一個例子。這個例子傳遞一個字符串指針到一個線程中打印一下。然後結束該線程。當然打印字符串這種事情沒有必要單獨開一個線程來做。這裏只是一個簡單的示例。請注意,這個代碼中有一個隱藏的錯誤,請讀者指出這個錯誤是什麼:
// 我的線程函數。傳入一個參數,這個參數是一個字符串。
VOID MyThreadProc(PVOID context)
{
PUNICODE_STRING str = (PUNICODE_STRING)context;
// 打印字符串
KdPrint((“PrintInMyThread:%wZ/r/n”,str));
// 結束自己。
PsTerminateSystemThread(STATUS_SUCCESS);
}
VOID MyFunction()
{
UNICODE_STRING str = RTL_CONSTANT_STRING(L“Hello!”);
HANDLE thread = NULL;
NTSTATUS status;
status = PsCreateSystemThread(
&thread,0L,NULL,NULL,NULL,MyThreadProc,(PVOID)&str);
if(!NT_SUCCESS(status))
{
// 錯誤處理。
…
}
// 如果成功了,可以繼續做自己的事。之後得到的句柄要關閉
ZwClose(thread);
}
以上錯誤之處在於:MyThreadProc執行的時候,MyFunction可能已經執行完畢了。執行完畢之後,堆棧中的str已經無效。此時再執行KdPrint去打印str一定會藍屏。這也是一個非常隱蔽,但是非常容易犯下的錯誤。
合理的方法是是在堆中分配str的空間。或者str必須在全局空間中。請讀者自己寫出正確的方法。
但是讀者會發現,以上的寫法在正確的代碼中也是常見的。原因是這樣做的時候,在PsCreateSystemThread結束之後,開發者會在後面加上一個等待線程結束的語句。
這樣就沒有任何問題了,因爲在這個線程結束之前,這個函數都不會執行完畢,所以棧內存空間不會失效。
這樣做的目的一般不是爲了讓任務併發。而是爲了利用線程上下文環境而做的特殊處理。比如防止重入等等。在後面的章節讀者會學到這方面的技巧。
如何等待線程結束在後面1.6.3“使用事件通知”中進一步的講述。
6.2 在線程中睡眠
許多讀者一定使用過Sleep函數。這能使程序停下一段時間。許多需要連續、長期執行,但是又不希望佔太多CPU使用率的任務,可以在中間加入睡眠。這樣能使CPU使用率大大降低。即使睡眠的時間非常短(幾十個毫秒)。
在驅動中也可以睡眠。使用到的內核函數的原型如下:
NTSTATUS
KeDelayExecutionThread(
IN KPROCESSOR_MODE WaitMode,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Interval);
這個函數的參數簡單明瞭。WaitMode請總是填寫KernelMode,因爲現在是在內核編程中使用。Alertable表示是否允許線程報警(用於重新喚醒)。但是目前沒有必要用到這麼高級的功能,請總是填寫FALSE。剩下的就是Interval了,表明要睡眠多久。
但是這個看似簡單的參數說明起來卻異常的複雜。爲此作者建議讀者使用下面簡單的睡眠函數,這個函數可以指定睡眠多少毫秒,而沒有必要自己去換算時間(這個函數中有睡眠時間的轉換):
#define DELAY_ONE_MICROSECOND (-10)
#define DELAY_ONE_MILLISECOND (DELAY_ONE_MICROSECOND*1000)
VOID MySleep(LONG msec)
{
LARGE_INTEGER my_interval;
my_interval.QuadPart = DELAY_ONE_MILLISECOND;
my_interval.QuadPart *= msec;
KeDelayExecutionThread(KernelMode,0,&my_interval);
}
當然要睡眠幾秒也是可以的,1毫秒爲千分之一秒。所以乘以1000就可以表示秒數。
在一個線程中用循環進行睡眠,也可以實現一個自己的定時器。考慮前面說的定時器的缺點:中斷級較高,有一些事情不能做。在線程中用循環睡眠,每次睡眠結束之後調用自己的回調函數,也可以起到類似的效果。而且系統線程執行中是Passive中斷級。睡眠之後依然是這個中斷級,所以不像前面提到的定時器那樣有限制。
請讀者自己寫出用線程+睡眠來實現定時器的例子。
6.3 使用事件通知
一些讀者可能熟悉“事件驅動”編程技術。但是這裏的“事件”與之不同。內核中的事件是一個數據結構。這個結構的指針可以當作一個參數傳入一個等待函數中。如果這個事件不被“設置”,則這個等待函數不會返回,這個線程被阻塞。如果這個事件被“設置”,則等待結束,可以繼續下去。
這常常用於多個線程之間的同步。如果一個線程需要等待另一個線程完成某事後才能做某事,則可以使用事件等待。另一個線程完成後設置事件即可。
這個數據結構是KEVENT。讀者沒有必要去了解其內部結構。這個結構總是用KeInitlizeEvent初始化。這個函數原型如下:
VOID
KeInitializeEvent(
IN PRKEVENT Event,
IN EVENT_TYPE Type,
IN BOOLEAN State
);
第一個參數是要初始化的事件。第二個參數是事件類型,這個詳見於後面的解釋。第三個參數是初始化狀態。一般的說設置爲FALSE。也就是未設狀態。這樣等待者需要等待設置之後才能通過。
事件不需要銷燬。
設置事件使用函數KeSetEvent。這個函數原型如下:
LONG
KeSetEvent(
IN PRKEVENT Event,
IN KPRIORITY Increment,
IN BOOLEAN Wait
);
Event是要設置的事件。Increment用於提升優先權。目前設置爲0即可。Wait表示是否後面馬上緊接着一個KeWaitSingleObject來等待這個事件。一般設置爲TRUE。(事件初始化之後,一般就要開始等待了。)
使用事件的簡單代碼如下:
// 定義一個事件
KEVENT event;
// 事件初始化
KeInitializeEvent(&event,SynchronizationEvent,TRUE);
……
// 事件初始化之後就可以使用了。在一個函數中,你可以等待某
// 個事件。如果這個事件沒有被人設置,那就會阻塞在這裏繼續
// 等待。
KeWaitForSingleObject(&event,Executive,KernelMode,0,0);
……
// 這是另一個地方,有人設置這個事件。只要一設置這個事件,
// 前面等待的地方,將繼續執行。
KeSetEvent(&event);
由於在KeInitializeEvent中使用了SynchronizationEvent,導致這個事件成爲所謂的“自動重設”事件。一個事件如果被設置,那麼所有KeWaitForSingleObject等待這個事件的地方都會通過。如果要能繼續重複使用這個時間,必須重設(Reset)這個事件。當KeInitializeEvent中第二個參數被設置爲NotificationEvent的時候,這個事件必須要手動重設才能使用。手動重設使用函數KeResetEvent。
LONG
KeResetEvent(
IN PRKEVENT Event
);
如果這個事件初始化的時候是SynchronizationEvent事件,那麼只有一個線程的KeWaitForSingleObject可以通過。通過之後被自動重設。那麼其他的線程就只能繼續等待了。這可以起到一個同步作用。所以叫做同步事件。不能起到同步作用的是通知事件(NotificationEvent)。請注意不能用手工設置通知事件的方法來取代同步事件。請讀者思考一下這是爲什麼。
回憶前面的1.6.1 “使用線程”的最後的例子。在那裏曾經有一個需求:就是等待線程中的函數KdPrint結束之後,外面生成線程的函數再返回。 這可以通過一個事件來實現:線程中打印結束之後,設置事件。外面的函數再返回。爲了編碼簡單我使用了一個靜態變量做事件。這種方法在線程同步中用得極多,請務必熟練掌握:
static KEVENT s_event;
// 我的線程函數。傳入一個參數,這個參數是一個字符串。
VOID MyThreadProc(PVOID context)
{
PUNICODE_STRING str = (PUNICODE_STRING)context;
KdPrint((“PrintInMyThread:%wZ/r/n”,str));
KeSetEvent(&s_event); // 在這裏設置事件。
PsTerminateSystemThread(STATUS_SUCCESS);
}
// 生成線程的函數:
VOID MyFunction()
{
UNICODE_STRING str = RTL_CONSTANT_STRING(L“Hello!”);
HANDLE thread = NULL;
NTSTATUS status;
KeInitializeEvent(&event,SynchronizationEvent,TRUE); // 初始化事件
status = PsCreateSystemThread(
&thread,0L,NULL,NULL,NULL,MyThreadProc,(PVOID)&str);
if(!NT_SUCCESS(status))
{
// 錯誤處理。
…
}
ZwClose(thread);
// 等待事件結束再返回:
KeWaitForSingleObject(&s_event,Executive,KernelMode,0,0);
}
實際上等待線程結束並不一定要用事件。線程本身也可以當作一個事件來等待。但是這裏爲了演示事件的用法而使用了事件。以上的方法調用線程則不必擔心str的內存空間會無效了。因爲這個函數在線程執行完KdPrint之後才返回。缺點是這個函數不能起到併發執行的作用。
第七章 驅動與設備
7.1 驅動入口與驅動對象
驅動開發程序員所編寫的驅動程序對應有一個結構。這個結構名爲DRIVER_OBJECT。對應一個“驅動程序”。下面的代碼展示的是一個最簡單的驅動程序。
#include <ntddk.h>
NTSTATUS
DriverEntry (
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
NTSTATUS status = STATUS_UNSUCCESSFUL;
return status;
}
函數DriverEntry是每個驅動程序中必須的。如同Win32應用程序裏的WinMain。DriverEntry的第一個參數就是一個DRIVER_OBJECT的指針。這個DRIVER_OBJECT結構就對應當前編寫的驅動程序。其內存是Windows系統已經分配的。
第二個參數RegistryPath是一個字符串。代表一個註冊表子鍵。這個子鍵是專門分配給這個驅動程序使用的。用於保存驅動配置信息到註冊表中。至於讀寫註冊表的方法,請參照前面章節中的內容。
DriverEntry的返回值決定這個驅動的加載是否成功。如果返回爲STATUS_SUCCESS,則驅動將成功加載。否則,驅動加載失敗。
7.2 分發函數與卸載函數
DRIVER_OBJECT中含有分發函數指針。這些函數用來處理髮到這個驅動的各種請求。Windows總是自己調用DRIVER_OBJECT下的分發函數來處理這些請求。所以編寫一個驅動程序,本質就是自己編寫這些處理請求的分發函數。
DRIVER_OBJECT下的分發函數指針的個數爲IRP_MJ_MAXIMUM_FUNCTION。保存在一個數組中。下面的代碼設置所有分發函數的地址爲同一個函數:
NTSTATUS
DriverEntry (
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
ULONG i;
for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;++i)
{
DriverObject->MajorFunctions[i] = MyDispatchFunction;
}
…
}
這個設置固然不難。難的工作都在編寫MyDispatchFunction這個函數上。因爲所有的分發函數指針都指向這一個函數,那麼這個函數當然要完成本驅動所有的功能。下面是這個函數的原型。這個原型是Windows驅動編程的規範,不能更改:
NTSTATUS MyDispatchFunction(PDEVICE_OBJECT device,PIRP irp)
{
……
}
這裏出現了DEVICE_OBJECT和IRP這兩大結構。前一個表示一個由本驅動生成的設備對象。後一個表示一個系統請求。也就是說,現在要處理的是:發給設備device的請求irp。請完成這個處理吧。這兩個結構在後面再進一步描述。
還有一個不放在分發函數數組中的函數,稱爲卸載函數也非常重要。如果存在這個函數,則該驅動程序可以動態卸載。在卸載時,該函數會被執行。該函數原型如下:
VOID MyDriverUnload(PDRIVER_OBJECT driver)
{
……
}
這個函數的地址設置到DriverObject->DriverUnload即可。
由於沒有返回值,所以實際上在DriverUnload中,已經無法決定這個驅動能否卸載。只能做善後處理。
7.3 設備與符號鏈接
驅動程序和系統其他組件之間的交互是通過給設備發送或者接受發給設備的請求來交互的。換句話說,一個沒有任何設備的驅動是不能按規範方式和系統交互的。當然也不會收到任何IRP,分發函數也失去了意義。
但並不意味着這樣的驅動程序不存在。如果一個驅動程序只是想寫寫日誌文件、Hook某些內核函數或者是做一些其他的小動作,也可以不生成任何設備,也不需要關心分發函數的設置。
如果驅動程序要和應用程序之間通信,則應該生成設備。此外還必須爲設備生成應用程序可以訪問的符號鏈接。下面的驅動程序生成了一個設備,並設置了分發函數:
#include <ntifs.h> // 之所以用ntifs.h而不是ntddk.h是因爲我習慣開發文件
// 系統驅動,實際上目前對讀者來說這兩個頭文件沒區別。
NTSTATUS DriverEntry(
PDRIVER_OBJECT driver,
PUNICODE_STRING reg_path)
{
NTSTATUS status;
PDEVICE_OBJECT device;
// 設備名
UNICODE_STRING device_name =
RTL_CONSTANT_STRING("//Device//MyCDO");
// 符號鏈接名
UNICODE_STRING symb_link =
RTL_CONSTANT_STRING("//DosDevices//MyCDOSL");
// 生成設備對象
status = IoCreateDevice(
driver,
0,
device_name,
FILE_DEVICE_UNKNOWN,
0,
FALSE,
&device);
// 如果不成功,就返回。
if(!NT_SUCCESS(status))
return status;
// 生成符號鏈接
status = IoCreateSymbolicLink(
&symb_link,
&device_name);
if(!NT_SUCCESS(status))
{
IoDeleteDevice(device);
return status;
}
// 設備生成之後,打開初始化完成標記
device->Flags &= ~DO_DEVICE_INITIALIZING;
return status;
}
這個驅動成功加載之後,生成一個名叫“/Device/MyCDO”的設備。然後在給這個設備生成了一個符號鏈接名字叫做“/DosDevices/MyCDOSL”。應用層可以通過打開這個符號鏈接來打開設備。應用層可以調用CreateFile就像打開文件一樣打開。只是路徑應該是“"//./ MyCDOSL”。前面的“//./”意味後面是一個符號鏈接名,而不是一個普通的文件。請注意,由於C語言中斜槓要雙寫,所以正確的寫法應該是“////.//”。與應用層交互的例子在下一節“IRP和IO_STACK_LOCATION”中。
7.4 設備的生成安全性限制
上一節的例子只是簡單的例子。很多情況下那些代碼會不起作用。爲了避免讀者在實際編程中遇到哪些特殊情況的困繞,下面詳細說明生成設備和符號鏈接需要注意的地方。生成設備的函數原型如下:
NTSTATUS
IoCreateDevice(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
OUT PDEVICE_OBJECT *DeviceObject
);
這個函數的參數也非常複雜。但是實際上需要注意的並不多。
第一個參數是生成這個設備的驅動對象。
第二個參數DeviceExtensionSize非常重要。由於分發函數中得到的總是設備的指針。當用戶需要在每個設備上記錄一些額外的信息(比如用於判斷這個設備是哪個設備的信息、以及不同的實際設備所需要記錄的實際信息,比如網卡上數據包的流量、過濾器所綁定真實設備指針等等),需要指定的設備擴展區內存的大小。如果DeviceExtensionSize設置爲非0,IoCreateDevice會分配這個大小的內存在DeviceObject->DeviceExtension中。以後用戶就可以從根據DeviceObject-> DeviceExtension來獲得這些預先保存的信息。
DeviceName如前例,是設備的名字。目前生成設備,請總是生成在/Device/目錄下。所以前面寫的名字是“/Device/MyCDO”。其他路徑也是可以的,但是這在本書描述範圍之外。
DeviceType表示設備類型。目前的範例無所謂設備類型,所以填寫FILE_DEVICE_UNKNOWN即可。
DeviceCharacteristics目前請簡單的填寫0即可。
Exclusive這個參數必須設置FALSE。文檔沒有做任何解釋。
最後生成的設備對象指針返回到DeviceObject中。
這種設備生成之後,必須有系統權限的用戶才能打開(比如管理員)。所以如果編程者寫了一個普通的用戶態的應用程序去打開這個設備進行交互,那麼很多情況下可以(用管理員登錄的時候)。但是偶爾又不行(用普通用戶登錄的時候)。結果困繞很久。其實是權限問題。
爲了保證交互的成功與安全性,應該用服務程序與之交互。
但是依然有時候必須用普通用戶打開設備。爲了這個目的,設備必須是對所有的用戶開放的。此時不能用IoCreateDevice。必須用IoCreateDeviceSecure。這個函數的原型如下:
NTSTATUS
IoCreateDeviceSecure(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
IN PCUNICODE_STRING DefaultSDDLString,
IN LPCGUID DeviceClassGuid,
OUT PDEVICE_OBJECT *DeviceObject
)
這個函數增加了兩個參數(其他的沒變)。一個是DefaultSDDLString。這個一個用於描述權限的字符串。描述這個字符串的格式需要大量的篇幅。但是沒有這個必要。字符串“D:P(A;;GA;;;WD)”將滿足“人人皆可以打開”的需求。
另一個參數是一個設備的GUID。請隨機手寫一個GUID。不要和其他設備的GUID衝突(不要複製粘貼即可)。
下面是例子:
// 隨機手寫一個GUID
const GUID DECLSPEC_SELECTANY MYGUID_CLASS_MYCDO =
{0x26e0d1e0L, 0x8189, 0x12e0, {0x99,0x14, 0x08, 0x00, 0x22, 0x30, 0x19, 0x03}};
// 全用戶可讀寫權限
UNICODE_STRING sddl =
RLT_CONSTANT_STRING(L"D:P(A;;GA;;;WD)");
// 生成設備
status = IoCreateDeviceSecure( DriverObject,
0,
&device_name,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&sddl,
(LPCGUID)&SFGUID_CLASS_MYCDO,
&device);
使用這個函數的時候,必須連接庫wdmsec.lib。
7.5 符號鏈接的用戶相關性
從前面的例子看,符號鏈接的命名貌似很簡單。簡單的符號鏈接(之所以稱爲簡單,是因爲還有一種使用GUID的符號鏈接,這在本書討論範圍之外)總是命名在/DosDevices/之下。但是實際上這會有一些問題。
比較高級的Windows系統(哪個版本的操作系統很難講,可能必須判定補丁號),符號鏈接也帶有用戶相關性。換句話說,如果一個普通用戶創建了符號鏈接“/DosDevices/MyCDOSL”,那麼,其實其他的用戶是看不見這個符號鏈接的。
但是讀者又會發現,如果在DriverEntry中生成符號鏈接,則所有用戶都可以看見。原因是DriverEntry總是在進程“System”中執行。系統用戶生成的符號鏈接是大家都可以看見的。
當前用戶總是取決於當前啓動當前進程的用戶。實際編程中並不一定要在DriverEntry中生成符號鏈接。一旦在一個不明用戶環境下生成符號鏈接,就可能出現註銷然後換用戶登錄之後,符號鏈接“不見了”的嚴重錯誤。這也是常常讓初學者抓狂幾周都不知道如何解決的一個問題。
其實解決的方案很簡單,任何用戶都可以生成全局符號鏈接,讓所有其他用戶都能看見。路徑“/DosDevices/MyCDOSL”改爲“/DosDevices/Global/MyCDOSL”即可。
但是在不支持符號鏈接用戶相關性的系統上,生成“/DosDevices/Global/MyCDOSL”這樣的符號鏈接是一種錯誤。爲此必須先判斷一下。幸運的是,這個判斷並不難。下面是一個例子,這個例子生成的符號鏈接總是隨時可以使用,不用擔心用戶註銷:
UNICODE_STRING device_name;
UNICODE_STRING symbl_name;
if (IoIsWdmVersionAvailable(1, 0x10))
{
// 如果是支持符號鏈接用戶相關性的版本的系統,用/DosDevices/Global.
RtlInitUnicodeString(&symbl_name, L"//DosDevices//Global//SymbolicLinkName");
}
else
{
// 如果是不支持的,則用/DosDevices
RtlInitUnicodeString(&symbl, L"//DosDevices//SymbolicLinkName");
}
// 生成符號鏈接
IoCreateSymbolicLink(&symbl_name, &device_name);
第八章 處理請求
8.1 IRP與IO_STACK_LOCATION
開發一個驅動要有可能要處理各種IRP。但是本書範圍內,只處理爲了應用程序和驅動交互而產生的IRP。IRP的結構非常複雜,但是目前的需求下沒有必要去深究它。應用程序爲了和驅動通信,首先必須打開設備。然後發送或者接收信息。最後關閉它。這至少需要三個IRP:第一個是打開請求。第二個發送或者接收信息。第三個是關閉請求。
IRP的種類取決於主功能號。主功能號就是前面的說的DRIVER_OBJECT中的分發函數指針數組中的索引。打開請求的主功能號是IRP_MJ_CREATE,而關閉請求的主功能號是IRP_MJ_CLOSE。
如果寫有獨立的處理IRP_MJ_CREATE和IRP_MJ_CLOSE的分發函數,就沒有必要自然判斷IRP的主功能號。如果像前面的例子一樣,使用一個函數處理所有的IRP,那麼首先就要得到IRP的主功能號。IRP的主功能號在IRP的當前棧空間中。
IRP總是發送給一個設備棧。到每個設備上的時候擁有一個“當前棧空間”來保存在這個設備上的請求信息。讀者請暫時忽略這些細節。下面的代碼在MyDispatch中獲得主功能號,同時展示了幾個常見的主功能號:
NTSTATUS MyDispatchFunction(PDEVICE_OBJECT device,PIRP irp)
{
// 獲得當前irp調用棧空間
PIO_STACK_LOCATION irpsp = IoGetCurrentIrpStackLocation(irp);
NTSTATUS status = STATUS_UNSUCCESSFUL;
swtich(irpsp->MajorFunction)
{
// 處理打開請求
case IRP_MJ_CREATE:
……
break;
// 處理關閉請求
case IRP_MJ_CLOSE:
……
break;
// 處理設備控制信息
case IRP_MJ_DEVICE_CONTROL:
……
break;
// 處理讀請求
case IRP_MJ_READ:
……
break;
// 處理寫請求
case IRP_MJ_WRITE:
……
break;
default:
…
break;
}
return status;
}
用於與應用程序通信時,上面這些請求都由應用層API引發。對應的關係大致如下:
應用層調用的API |
驅動層收到的IRP主功能號 |
CreateFile |
IRP_MJ_CREATE |
CloseHandle |
IRP_MJ_CLOSE |
DeviceIoControl |
IRP_MJ_DEVICE_CONTROL |
ReadFile |
IRP_MJ_READ |
WriteFile |
IRP_MJ_WRITE |
瞭解以上信息的情況下,完成相關IRP的處理,就可以實現應用層和驅動層的通信了。具體的編程在緊接後面的兩小節裏完成。
8.2 打開與關閉的處理
如果打開不能成功,則通信無法實現。要打開成功,只需要簡單的返回成功就可以了。在一些有同步限制的驅動中(比如每次只允許一個進程打開設備)編程要更加複雜一點。但是現在忽略這些問題。暫時認爲我們生成的設備任何進程都可以隨時打開,不需要擔心和其他進程衝突的問題。
簡單的返回一個IRP成功(或者直接失敗)是三部曲,如下:
1. 設置irp->IoStatus.Information爲0。關於Information的描述,請聯繫前面關於IO_STATUS_BLOCK結構的解釋。
2. 設置irp->IoStatus.Status的狀態。如果成功則設置STATUS_SUCCESS,否則設置錯誤碼。
3. 調用IoCompleteRequest (irp,IO_NO_INCREMENT)。這個函數完成IRP。
以上三步完成後,直接返回irp->IoStatus.Status即可。示例代碼如下。這個函數能完成打開和關閉請求。
NTSTATUS
MyCreateClose(
IN PDEVICE_OBJECT device,
IN PIRP irp)
{
irp->IoStatus.Information = 0;
irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
當然,在前面設置分發函數的時候,應該加上:
DriverObject->MajorFunctions[IRP_MJ_CREATE] = MyCreateClose;
DriverObject->MajorFunctions[IRP_MJ_CLOSE] = MyCreateClose;
在應用層,打開和關閉這個設備的代碼如下:
HANDLE device=CreateFile("////.//MyCDOSL",
GENERIC_READ|GENERIC_WRITE,0,0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,0);
if (device == INVALID_HANDLE_VALUE)
{
// …. 打開失敗,說明驅動沒加載,報錯即可
}
// 關閉
CloseHandle(device);
8.3 應用層信息傳入
應用層傳入信息的時候,可以使用WriteFile,也可以使用DeviceIoControl。DeviceIoControl是雙向的,在讀取設備的信息也可以使用。因此本書以DeviceIoControl爲例子進行說明。DeviceIoControl稱爲設備控制接口。其特點是可以發送一個帶有特定控制碼的IRP。同時提供輸入和輸出緩衝區。應用程序可以定義一個控制碼,然後把相應的參數填寫在輸入緩衝區中。同時可以從輸出緩衝區得到返回的更多信息。
當驅動得到一個DeviceIoControl產生的IRP的時候,需要了解的有當前的控制碼、輸入緩衝區的位置和長度,以及輸出緩衝區的位置和長度。其中控制碼必須預先用一個宏定義。定義的示例如下:
#define MY_DVC_IN_CODE /
(ULONG)CTL_CODE(FILE_DEVICE_UNKNOWN, /
0xa01, /
METHOD_BUFFERED, /
FILE_READ_DATA|FILE_WRITE_DATA)
其中0xa01這個數字是用戶可以自定義的。其他的參數請照抄。
下面是獲得這三個要素的例子:
NTSTATUS MyDeviceIoControl(
PDEVICE_OBJECT dev,
PIRP irp)
{
// 得到irpsp的目的是爲了得到功能號、輸入輸出緩衝
// 長度等信息。
PIO_STACK_LOCATION irpsp =
IoGetCurrentIrpStackLocation(irp);
// 首先要得到功能號
ULONG code = irpsp->Parameters.DeviceIoControl.IoControlCode;
// 得到輸入輸出緩衝長度
ULONG in_len =
irpsp->Parameters.DeviceIoControl.InputBufferLength;
ULONG out_len =
irpsp->Parameters.DeviceIoControl.OutputBufferLength;
// 請注意輸入輸出緩衝是公用內存空間的
PVOID buffer = irp->AssociatedIrp.SystemBuffer;
// 如果是符合定義的控制碼,處理完後返回成功
if(code == MY_DVC_IN_CODE)
{
… 在這裏進行需要的處理動作
// 因爲不返回信息給應用,所以直接返回成功即可。
// 沒有用到輸出緩衝
irp->IoStatus.Information = 0;
irp->IoStatus.Status = STATUS_SUCCESS;
}
else
{
// 其他的請求不接受。直接返回錯誤。請注意這裏返
// 回錯誤和前面返回成功的區別。
irp->IoStatus.Information = 0;
irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
}
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
在前面設置分發函數的時候,要加上:
DriverObject->MajorFunctions[IRP_MJ_DEVICE_CONTROL] = MyCreateClose;
應用程序方面,進行DeviceIoControl的代碼如下:
HANDLE device=CreateFile("////.//MyCDOSL",
GENERIC_READ|GENERIC_WRITE,0,0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,0);
BOOL ret;
DWORD length = 0; // 返回的長度
if (device == INVALID_HANDLE_VALUE)
{
// … 打開失敗,說明驅動沒加載,報錯即可
}
BOOL ret = DeviceIoControl(device,
MY_DVC_IN_CODE, // 功能號
in_buffer, // 輸入緩衝,要傳遞的信息,預先填好
in_buffer_len, // 輸入緩衝長度
NULL, // 沒有輸出緩衝
0, // 輸出緩衝的長度爲0
&length, // 返回的長度
NULL);
if(!ret)
{
// … DeviceIoControl失敗。報錯。
}
// 關閉
CloseHandle(device);
8.4 驅動層信息傳出
驅動主動通知應用和應用通知驅動的通道是同一個。只是方向反過來。應用程序需要開啓一個線程調用DeviceIoControl,(調用ReadFile亦可)。而驅動在沒有消息的時候,則阻塞這個IRP的處理。等待有信息的時候返回。
有的讀者可能聽說過在應用層生成一個事件,然後把事件傳遞給驅動。驅動有消息要通知應用的時候,則設置這個事件。但是實際上這種方法和上述方法本質相同:應用都必須開啓一個線程去等待(等待事件)。而且這樣使應用和驅動之間交互變得複雜(需要傳遞事件句柄)。這毫無必要。
讓應用程序簡單的調用DeviceIoControl就可以了。當沒有消息的時候,這個調用不返回。應用程序自動等待(相當於等待事件)。有消息的時候這個函數返回。並從緩衝區中讀到消息。
實際上,驅動內部要實現這個功能,還是要用事件的。只是不用在應用和驅動之間傳遞事件了。
驅動內部需要製作一個鏈表。當有消息要通知應用的時候,則把消息放入鏈表中(請參考前面的“使用LIST_ENTRY”),並設置事件(請參考前面的“使用事件”)。在DeviceIoControl的處理中等待事件。下面是一個例子:這個例子展示的是驅動中處理DeviceIoControl的控制碼爲MY_DVC_OUT_CODE的部分。實際上驅動如果有消息要通知應用,必須把消息放入隊列尾並設置事件g_my_notify_event。MyGetPendingHead獲得第一條消息。請讀者用以前的知識自己完成其他的部分。
NTSTATUS MyDeviceIoCtrlOut(PIRP irp,ULONG out_len)
{
MY_NODE *node;
ULONG pack_len;
// 獲得輸出緩衝區。
PVOID buffer = irp->AssociatedIrp.SystemBuffer;
// 從隊列中取得第一個。如果爲空,則等待直到不爲空。
while((node = MyGetPendingHead()) == NULL)
{
KeWaitForSingleObject(
&g_my_notify_event,// 一個用來通知有請求的事件
Executive,KernelMode,FALSE,0);
}
// 有請求了。此時請求是node。獲得PACK要多長。
pack_len = MyGetPackLen(node);
if(out_len < pack_len)
{
irp->IoStatus.Information = pack_len; // 這裏寫需要的長度
irp->IoStatus.Status = STATUS_INVALID_BUFFER_SIZE;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
// 長度足夠,填寫輸出緩衝區。
MyWritePackContent(node,buffer);
// 頭節點被髮送出去了,可以刪除了
MyPendingHeadRemove ();
// 返回成功
irp->IoStatus.Information = pack_len; // 這裏寫填寫的長度
irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
這個函數的處理要追加到MyDeviceIoControl中。如下:
NTSTATUS MyDeviceIoControl(
PDEVICE_OBJECT dev,
PIRP irp)
{
…
if(code == MY_DVC_OUT_CODE)
return MyDeviceIoCtrlOut(dev,irp);
…
}
在這種情況下,應用可以循環調用DeviceIoControl,來取得驅動驅動通知它的信息。
後記:我的閒言碎語
寫這本小冊子的時候,我正在NED-LS辦離職手續。
想當初在東京的時候,NED的田上每夜好酒好肉的招待。北京幾個同事跳槽,搞得項目特別尷尬。田上特意說道:“拜託你們不要轉職......”。
半年不到,我就拋出一紙辭職信,真是負心人啊......
一眨眼間,就在NED-LS混了三年了。不是我非要走人,一方面匯率節節攀升,外包越來越困難。歐美企業不退反進,紛紛把更高檔的玩意搬來國內來發。許多日本公司卻還把外包當主業。另一方面我日語暴爛,又不肯學。繼續擺爛顯然不是辦法。
相對於Intel,我其實一直是看好AMD的。上次幫小D選筆記本,還特意選了AMD的CPU。都說AMD的東西便宜量又足,最適合國內的勞苦大衆,這可不是吹的。只可惜貌似每次都被Intel揍得鼻青臉腫。所以我便去面試了。跟我去後面那天去的還有那個寫“碟中諜虛擬光驅”的說話有點像唐僧的萬春。
上海的AMD在浦東的荒郊。先把2號線坐到終點站,然後打的到一處無人知道的野外。只看見長長的公路和茂盛的野草。兩邊有無數片工地,橫七豎八的堆着許多建築材料,卻沒有一個人。好處是內急的時候不用找公廁(也不可能找得到)。直接在路邊就可以解決。
工地的旁邊有一部分成品。AMD的綠色標記就坐落其中。有兩棟樓,都不大,袖珍型的。他們的面試沒有筆試(說本來是有,但我去的時候卷子還沒準備好,就免了)。是四個人輪番上陣,前三個是工程師,最後一個是經理。
我面的是存儲芯片驅動開發。問到驅動開發相關或者純C語言的問題,我自然是對答如流,這許多年的苦工不是白乾的。偏偏他們對效率很有興趣,總是不時拋出幾個位運算的妙用之類的幾個優化題。我只好明說了平時並不怎麼關心效率。所以這個不擅長。待加盟了你們項目組之後,一定好好學習天天向上云云。
最終當然是沒過了。他們Pending了好長一段時間。最終結論是我不適合做硬件驅動開發。因爲我以前的經驗比較上層。做虛擬SCSI設備的萬春按理比我好點,但是他死得更慘:結論是隻在小公司幹過,組織性、紀律性會比較差(其實這也沒說錯==!)。雜牌軍被BS了。
萬春沒多久就去廣州了。真是“浮雲遊子意,落日故人情”啊。不過話撂這兒了,莫怪勞資以後不支持AMD...
我的零八年的春夏真是愜意。做的幾個程序都還沒出大問題。有空還寫寫書,《天書夜讀》扔給出版社了。只可惜一審再編的沒完沒了。到現在也還沒有頭。我又開始寫新書。但不知道怎麼說,因爲題目還沒有擬好。然後又見到小D,一個不小心就掉到蜜罐子裏了。
後來又面試了幾個。Mavell的面試官實在太強了,無所不知,成功的鄙視了我。MS的面試官是老外。雖然我不懂他說什麼,但是我說的他也未見得明白。
不過MS的職位真的是很棒啊。做Windows Kernel,而且還在上海。一通過馬上先送去美利堅培訓。很美啊。
後來又去面Intel(屢敗屢面==!)。Intel的環境真是不錯。我到的時候正是早上。天氣又好,一個人也沒有,對面是一大片幾乎望不到邊的淺水,長着人深的草。幾隻長腳的蒼鷺站在水裏。還有一些棕色的像小鴨子的玩意在水裏搶食吃。一些小鳥在空中掠過,發出銅鈴一樣的聲音。Intel就在那邊沼澤地的對面。比AMD大。幾棟大樓。有上千人在那裏工作。吃飯都在食堂。方圓2公里內沒有飯店。
Intel的面試和AMD很像。沒有筆試。四人輪番上陣。不過他們四個人稍微有些分工。每個人問的方向都不大一樣。另一個情況是他們喜歡給你水筆,然後請直接在白板上寫代碼。還不能寫簡意,非要一行一行寫出來才行。寫白板手抖得厲害,沒點心理素質還真不行。
第一個人問內核編程。問到我的得意之處了。不過這幫人還真的有兩把刷子——他們能看Windows的代碼,我還得自己反彙編。世界真不公平啊。
然後來一個人問了很多設計模式和代碼管理之類的問題。這方面我當然是更滔滔不絕了。最有挑戰性的是第三人,髮型很像愛因斯坦那個,進來坐定之後,也不說什麼,就給出一張白紙,讓我寫一個矩形相交判斷以及一個形狀覆蓋的算法。時間又大概只有二十分鐘,大腦一片空白,汗就出來了。空白了大概十分鐘,還一個字沒寫。面試官都急了,就說:“你如果有什麼中間結果,就先拿出來。”意思就是你多少寫點,別交白卷啊。
不過好歹我以前是做過3D引擎的(雖然做得很爛的說)。慢慢冷靜下來,和他說了計算的步驟。不算優秀也不算高效(十幾分鍾哪裏有空考慮那麼多啊)。但是面試官說也算是邏輯完整。ok,又出了一道圖論算法題。這時候我已經緩過勁來,五分鐘內輕鬆搞定。愛因斯坦滿意的走了。
最後一個是部門經理。相與言歡。然後他請客在他們食堂吃飯。但是真的很難吃的說。下午回NEC-AS繼續上班,一面構思辭職信的措辭。
告別lu0,告別wowocock。
本書獻給小D。她是我今夏遇到的,生命裏最美的一縷陽光。