Windows 编程
Windows 编程
Windows 程序设计
窗口和消息
总体结构
Windows 程序设计实质上是面向对象的升序设计,操作对象为窗口(window),程序员与用户的视角基本一致。
使用RegisterClass
注册窗口类,使用CreateWindow
创建窗口。
Windows 会在特定情况下向窗口发送消息,程序建立的每一个窗口都有窗口消息处理函数,Windows 会调用消息处理函数,函数根据收到的消息进行处理,完成后将控制权交还给 Windows。
Windows 程序开始执行后,系统为该程序建立一个消息队列,用于存放部分该程序将要接收到的消息。程序通过一段称为消息循环的代码从消息队列中取出消息,将消息发送给窗口处理函数。有些消息会直接发送给窗口处理函数,不会进入消息队列。消息循环一般如下:
1 | while(GetMessage(&msg, NULL, 0, 0)) |
窗口生命周期
- 注册窗口类
- 建立窗口
- 显示窗口
- 消息循环
- 处理消息(窗口消息处理程序)
部分标识符
前缀 | 英文 | 含义 |
---|---|---|
CS | ClassStyle | 窗口样式 |
CW | CreateWindow | 创建窗口 |
DT | DrawText | 绘制文字 |
IDI | ID of Image | 图标ID |
IDC | ID of Cursor | 光标ID |
MB | MessageBox | 消息框 |
SND | Sound | 声音 |
WM | WindowMessage | 窗口消息 |
WS | WindowStyle | 窗口样式 |
结构体
类型 | 含义 |
---|---|
MSG | 消息结构体 |
WNDCLASS | 窗口类别结构体 |
PAINTSTRUCT | 绘图结构 |
RECT | 矩形结构 |
句柄
类型 | 含义 |
---|---|
HINSTANCE | 程序句柄 |
HWND | 窗口句柄 |
HDC | 设备句柄(Device Contxt) |
输出文字
绘制和更新
Windows 中只能在窗口的显示区域绘制文字和图形,而且不能确保显示区域内的内容一直保存直到下一次修改。
WM_PAINT 消息
Windows 向程序发送 WM_PAINT 消息时,表示窗口部分或全部显示区域需要重新绘制。
多数程序在 WinMain 进入消息循环之前会调用UpdateWindow
来发送第一个 WM_PAINT 消息。之后在下列事件之一发生时,窗口消息处理程序会收到一个 WM_PAINT 消息:
- 移动或显示窗口时
- 改变窗口大小时
- 代码使用
InvalidateRect
或InvalidateRgn
函数刻意产生 WM_PAINT 消息 - 一个窗口或者下拉菜单挡住部分窗口时,Windows 尝试保存遮挡的部分,但不一定成功,失败时也会发送 WM_PAINT
有效矩形和无效矩形
窗口消息处理程序收到 WM_PAINT 消息后,会准备更新整个显示区域,但通常只需要更新一个较小的区域,称为无效区域/更新区域。
Windows 为每个窗口保存一个绘图消息结构,这个结构包含了包围无效区域的最小矩形的坐标和其他信息,这个矩形称为无效矩形。
如果消息处理程序处理 WM_PAINT 消息之前,显示区域的另一个区域也变为无效,Windows 将计算一个新的包围两个无效区域的无效区域并存入绘图信息结构,而不是将多个 WM_PAINT 放入消息队列。
在处理 WM_PAINT 期间,窗口消息处理程序调用了BeginPaint
,整个区域都会变为有效,程序也可以调用ValidateRect
使任意矩形变为有效。整个区域都有效时,目前消息队列中的 WM_PAINT 将被删除。
WM_SIZE
当窗口尺寸发生变化时,Windows 会发送 WM_SIZE 消息。
GDI
要在窗口的显示区域绘图(文字)需要使用 WIndows 图形设备接口(GDI)函数。
键盘
基础
键盘输入以消息形式传递给程序的窗口消息处理程序,Windows 有八种不通的消息。
获得焦点
按下键盘时,只有一个窗口消息处理程序接受该消息,并且此消息包含窗口控件码。接收特定键盘事件的窗口具有输入焦点。
获取焦点时,Windows 发送 WM_SETFOCUS,失去焦点时,发送 WM_KILLFOCUS。
队列和同步
按下键盘时,由 Windows 和键盘驱动程序将硬件扫描码转换成格式消息,消息首先存入 Windows 的系统消息队列中,当应用程序处理完前一个输入的消息时,Windows 从系统消息队列中取出下一个消息,放入程序的消息队列中。
按键和字符
键盘消息分为按键和字符两类。对于能产生字符的按键或组合(比如 Shift+A), Windows 发送按键和字符消息,否则(比如Home)只发送按键消息。
按键消息
按下按键时,Windows 发送 WM_KEYDOWN 或 WM_SYSKEYDOWN,松开按键时发送 WM_KEYUP 或 WM_SYSKEYUP,按键消息也有实践信息,可以通过调用GetMessageTime
获得。
系统按键和非系统按键
包含 SYS 的为系统按键消息,通常程序忽略这些消息并交给DefWindowProc
处理
虚拟键码
虚拟键码保存在四个消息(WM_(SYS)?KEY[DOWN|UP])中的 wParam 参数中,标识按下或者释放的按键。
大部分虚拟键码以 VK 开头,定义在 WINUSER.H 中。
lParam
低16位为重复计数,当消息处理速度不如消息的产生速度时,积累的多个 KEYDOWN 消息会组合成一个,并增加重复计数。
16-23位为 OEM 扫描码,一般忽略。
30位为该键的先前状态,释放为0,按下为1。
31位为转换状态,与先前不同则为1,相同为0。
特殊按键
需要知道处理消息时是否按下了 Shift/Alt/Ctrl 或锁定键,可以调用GetKeyState
函数获取消息。
1 | iState = GetKeyState(VK_SHIFT) |
为负则按下了 Shift键。
字符消息
消息循环中,TranslateMessage
函数将按键消息转换为字符消息。如果消息为 WM_KEYDOWN 或者 WM_SYSKEYDOWN,并且按键或者组合产生了一个字符,该函数就会将字符消息放入消息队列中。
字符消息分类
字符消息有四类:WM_CHAR、WM_DEADCHAR、WM_SYSCHAR 、WM_SYSDEADCHAR。
通常程序只处理 WM_CHAR。
字符消息与按键消息的 lParam 相同,wParam包含的是 ANSI 或 Unicode 字符代码。
插入符号
插入符号只在获取了焦点的窗口中有效,因此应在 WM_SETFOCUS 和 WM_KILLFOCUS 中处理。
鼠标
鼠标消息
鼠标移动过窗口的显示区域时,Windows 发送 WM_MOUSEMOVE 消息。二按下或松开按键时,发送以下消息:
键 | 按下 | 松开 | 双击 |
---|---|---|---|
左 | WM_LBUTTONDOWN | WM_LBUTTONUP | WM_LBUTTONDBLCLK |
中 | WM_MBUTTONDOWN | WM_MBUTTONUP | WM_MBUTTONDBLCLK |
右 | WM_RBUTTONDOWN | WM_RBUTTONUP | WM_RBUTTONDBLCLK |
以上9个消息中,lParam 低16位为 x 坐标,高16位为 y 坐标,坐标原点为显示区域的左上角。提取值时可使用宏
1 | int x = LOWORD(lParam); |
wParam 指出鼠标按键以及 Shift 和 Ctrl 的状态,通过与运算进行测试。
1 | wParam |
如果在活动窗口按下鼠标,该窗口会变成活动窗口,然后收到 WM_LBUTTONDOWN 消息。
希望收到双击消息时,注册窗口类应包含 SC_DBLCLK 标识符。
非显示区域鼠标消息
在窗口内显示区域外,Windows 会发送非显示区域鼠标消息,通常不需要处理。
命中测试
代表非显示区域命中测试,优先于所有其他显示区域和非显示区域的鼠标消息。
通常程序将这个消息传给DefWindowProc
,wParam 参数会存有测试结果:HTCLIENT 显示区域中,HTNOWHERE 不在窗口中,HTTRANSPARENT 窗口被另一个窗口覆盖, HTERROR 产生警示声音。
拦截鼠标
在窗口外也可以接受鼠标消息。使用SetCapture
拦截,ReleaseCapture
释放。
鼠标滚轮
滚动滚轮产生 WM_MOUSEWHEEL 消息。
子窗口控件
概述
子窗口和父窗口在有必要时可以互相发送消息。
存在一些预定义的子窗口类别,使用 CreateWindow
可以直接使用,然后使用MoveWindow
调整位置和尺寸。
子窗口获取焦点后,不能将焦点传回父窗口。
按钮类
按钮样式以 BS 开头,子窗口 ID 必须强制转换为 HMENU 类型。
按钮样式 | 按钮 |
---|---|
BS_PUSHBUTTON | 按钮 |
BS_DEFPUSHBUTTON | 按键 |
BS_CHECKBOX BS_AUTOCHECKBOX | 复选框 |
BS_RADIOBUTTON BS_AUTORADIOBUTTON | 单选按钮 |
BS_GROUPBOX | 分组方块 |
子窗口向父窗口发送消息
点击按钮,子窗口空间会向父窗口发送一个 WM_COMMAND
消息,LOWORD(wParam)
为子窗口ID,HIWORD(wParam)
为通知码,lParam
为子窗口句柄。
通知码 | 含义 |
---|---|
0 | BN_CLICKED |
5 | BN_DOUBLECLICKED or BN_DBLCLK |
6 | BN_SETFOCUS |
7 | BN_KILLFOCUS |
点击按钮时子窗口获得输入焦点,此时只有空格可用,忽略其他键盘输入。
父窗口向子窗口发送消息
除了发送 WM 开头的消息,也可以发送 BM 开头的消息。
消息 | 含义 |
---|---|
BM_GETCHECK / BM_SETCHECK | 取得或设定复选框和单选按钮的状态 |
BM_GETSTATE / BM_SETTATE | 表示按钮是否处于被按下的状态 |
BM_SETSTYLE | 改变按钮样式 |
子窗口控件具有唯一的窗口句柄和 ID 值,知道一个可以获取另一个。
1 | id = GetWindowLong(hwndChild, GWL_ID); |
改变按钮文字
可以使用函数改变按钮文字
1 | SetWindowText(hwnd, pszString); |
获取目前文字
1 | iLength = GetWindowText(hwnd, pszBuffer, iMaxlength); |
可见和启用
建立窗口时如果没有设置 WS_VISIBLE,那么调用 ShowWindow 才会显示窗口。
1 | ShowWindow(hwndChild, SW_SHOWNORMAL);//不包含WS_VISIBLE显示子窗口 |
静态类别
在 CreateWindow 函数中指定窗口为 static,就可以建立静态文字的窗口控件,它不接受鼠标和键盘输入,也不发送 WM_COMMAND 消息。
鼠标在静态子窗口中移动或按下时,子窗口会拦截 WM_NCHITTEST 消息并将 HTTRANSPARENT 的值传给 Windows,Winodws 再发送相同的 WM_NCHITTEST 消息给父窗口,通常父窗口不处理这个消息,发送给DefWindowProc
。
滚动条类别
建立窗口时通过加入 WS_VSCROLL 和 WS_HSCROLL 可以加入滚动条。
编辑框类别
菜单及其他资源
资源保存在 EXE 文件中,在不可执行程序的数据区。
多任务和多线程
Windows 中的多线程
通过CreateThread
创建线程
1 | hThread = CreateThread(&security_attributes, dwStackSize, ThreadProc, pParam, dwFlags, &idThread) |
ThreadProc 是一个函数指针,指向线程函数,可以任意命名,但必须有如下格式
1 | DWORD WINAPI ThreadProc(PVOID pParam); |
pParam 就是传给 ThreadProc 的参数。
dwFlags 通常设置为零,如果设置为CREATE_SUSPENDED
,表示这个线程创建后不会立即执行,保持在挂起状态,直到调用ResumeThread
。
idThread 是一个指针,指向接受新线程ID的变量。
使用_beginThread
函数创建线程,调用后,此线程函数中的代码和其他会被此线程调用的函数,会与程序中的其他代码同时运行,两个或多个线程可以使用一个进程中的相同函数。静态变量时所有线程共享的,自动局部变量(存储在栈上)是每个线程私有的数据。
1 | _beginThread(Thread, 0 NULL); |
线程的同步
临界区
临界区(Critical Section)用于防止共享的数据呗破坏。使用临界区前必须定义一个临界区对象,然后初始化。
1 | CRITICAL_SECTION cs; |
一个线程可以通过调用EnterCriticalSection
进入临界区
1 | EnterCriticalSection(&cs); |
此时这个线程拥有这个临界区对象。两个线程不能同时拥有同一个临界区对象。如果一个线程进入了临界区,下一个线程尝试进入同一个临界区时将被挂起,直到第一个对象调用LeaveCriticalSection
才会返回
1 | LeaveCriticalSection(&cs); |
不需要临界区对象时,可以删除
1 | DeleteCriticalSection(&cs); |
临界区机制涉及互斥(mutual exclusion),任何时刻只有一个线程拥有临界区。临界区的局限在于只能在同一个进程中使用。如果需要在进程间实现互斥,则应使用互斥对象(mutex)。
触发事件
事件对象有两种状态,已触发(已设置)或未触发(已复位)。
1 | hEvent = CreateEvent(&sa, fManual, fInitial, pszName); |
sa 是指向 SECURITY_ATTRIBUTES 的引用,pszName 是事件对象名,这两个参数只在事件对象在进程间共享时使用,单进程环境中通常被设置为 NULL。
fInitial 表示事件初始的触发状态,TRUE 为已触发。
fManual 表示是否手动设置。
触发或解除出发使用以下函数
1 | SetEvent(hEvent); |
等待事件触发
1 | WaitForSingleObject(hEvent, dwTimeOut); |
如果事件处于触发状态,该函数会立刻返回;否则会等待事件触发,最长等待 dwTimeOut 毫秒。dwTimeOut 可设置为 INFINITE。
如果 CreateEvent 的 fManual 参数为 FALSE,那么 WaitForSingleObject 返回后,事件对象的触发状态会自动设置为未触发。
线程本地存储
线程本地存储(Thread Local Storage)是线程私有并一直存在的存储单元。
使用线程本地存储,首先需要创建一个包含所有静态私有数据的的结构
1 | typedef struct |
然后使用 TlsAlloc
获取一个索引
1 | dwTlsIndex = TlsAlloc(); |
这个索引可以保存在全局变量中,或者通过参数结构传递给线程函数。
线程函数一开始为这个数据结构分配内存并调用 TlsSetValue
1 | TlsSetValue(dwTlsIndex, GlobalAlloc(GPTR, sizeof(DATA))); |
这个函数将指向线程本地数据结构的指针和特定的线程以及特定的线程索引关联。现在可以使用以下方法访问这个指针
1 | PDATA pdata; |
线程函数终止运行之前释放已分配的内存
1 | GlabalFree(TlsGetValue(dwTlsIndex)); |
所有使用这个本地存储的线程结束后,在主线程释放索引
1 | TlsFree(dwTlsIndex); |
可以使用前置符__declspec(thread)
1 | __declspec(thread) int iGlobal = 1; //定义全局静态变量 |
动态链接库
关于库的基本知识
动态链接库一般不能直接执行,而且他们一般也不接收消息。他们是包含许多函数的独立文件,这些函数可以被应用程序和其他DLL调用以完成默写特定的工作。一个动态链接库只在另一个模块调用其所包含的函数时才会被启动。
动态链接指 Windows 的链接过程。这个过程中它把模块中的函数调用与在库模块中的实际函数链接在一起。静态链接一般是在程序开发过程中发生的,用于把一些文件链接在一起创建一个 Windows 可执行文件。这些文件包括各种各样的对象模块(.OBJ),运行时库文件(.LIB),通常还有已编译的资源文件(.RES)。与其相反,动态链接发生在程序运行时。
一些动态链接库(如字体文件)被称为 resource-only 的,它们只包含数据,没有代码。
动态链接库的扩展名是任意的,但只有扩展名为 .DLL 的动态链接库才能被 Windows 自动加载。如果该文件有其他的扩展名,则程序必须调用LoadLibrary
或LoadLibraryEx
函数加载相应模块。
库
除了动态链接库,还有对象库(object library)和导入库(import library)。
对象库是扩展名为 .LIB 的文件,这个文件中的代码在运行链接器进行静态链接时被添加到程序的 .EXE 文件中。
导入库是一种特殊的对象库,扩展名也是 .LIB。链接器用它来解析源代码中的函数调用。然而导入库不含代码,它给链接器提供信息,以建立 .EXE 文件中用于动态链接的重定位表格。
一个简单的DLL
DLL 中供应用程序所用的函数必须先导出,EXPORT 关键字确保函数名称被添加到 .DLL 文件中,使得链接器在链接使用这些函数的应用程序时可以正确解析该函数名,并且确保这些函数在 DLL文件中可见。
如果头文件由 C++ 模式编译,则 EXPORT 标识符会包含存储类关键字__declspec(dllexport)
和exter "C"
,这可以防止编译器对 C++ 函数进行名称重整,使 C 和 C++ 程序都能使用该 DLL。
库的入口点和退出点
1 | int WINAPI DllMain (HINSTANCE hInstance, DWORD fdwReason, PVOID pvReserved); |
当库第一次开始和终止时, DllMain 都会被调用。DllMain 的第一个参数时库的实例句柄。如果库使用的资源需要实例句柄,则应该把 hInstance 作为一个全局变量保存。
fdwReason 参数可以是四个值中的一个,用来说明 Windows 调用 DllMain 函数的原因。一个程序可以被多次加载,并在 Windows 环境下同时运行,每一次程序加载都可以认为是一个独立的进程。
当 fdwReason 值为 DLL_PROCESS_ATTACH
时,表明动态链接库已经被映射到一个进程的地址空间,这相当于一个初始化信号,让库针对所服务进程的后续请求进行初始化工作。在一个进程的整个生命周期内,DllMain 只有一次会在调用中带有DLL_PROCESS_ATTACH
参数。
如果初始化成功,DllMain 应该返回非零值,否则将导致 Windows 无法运行该程序。
fdwReason 值为 DLL_PROCESS_DETACH
时,表示这个进程不再需要该 DLL 了,这给了库一个自我清理的机会。
fdwReason 值为DLL_THREAD_ATTACH
时,表示一个关联的进程创建了一个新的线程,该线程终止时,Windows 会以 fdwReason 参数为 DLL_THREAD_DETACH
调用 DllMain。即使之前没有DLL_THREAD_ATTACH
的情况下,也可能出现DLL_THREAD_DETACH
的调用,例如动态链接库连接到一个进程时,该线程已经创建。以DLL_THREAD_DETACH
调用时,线程仍然存在,它甚至还可以在此过程中发送线程消息,但它不应该再使用PostMessage
。
在 DLL 中共享内存
Windows 让同时使用相同动态链接库的进程相互隔离。
Windows 核心编程
错误处理
Windows 函数的错误
调用 Windows 函数时,会先验证参数,然后开始执行。如果传入的参数无效,或者由于其他原因导致操作无法执行,则由返回值指出函数因为某些原因失败了。
数据类型 | 调用成功返回值 | 调用失败返回值 | 备注 |
---|---|---|---|
VOID | 该函数不可能失败 | ||
BOOL | 非0值 | 0 | 应检查其是否不为 FALSE |
HANDLE | 标识一个可操纵对象 | NULL 或 INVALID_HANDLE_VALUE(通常为-1) | |
PVOID | 标识一个数据块的内存地址 | NULL | |
LONG/DWORD | LONG 或 DWORD | 0 或 -1 |
必要时查看 Platform SDK 文档。
错误代码
在内部,Windows 函数检测到错误时,使用线程本地存储区机制(thread-local storage)将错误代码与主调i线程(calling thread)关联,在返回时用返回值指出发生了错误。使用GetLastError
函数查看错误,该函数返回上一个函数调用设置的线程的 32 位错误代码。
Windows 函数失败后应立刻调用 GetLastError
,因为下一个函数很可能改写此值,即使成功调用的函数也可能用 ERROR_SUCCESS
改写此值。成功调用也可能有不同的情况,需要获取额外信息时也应调用GetLastError
。
WinError.h
头文件包含了 MS 定义的错误代码列表,每个错误由消息ID(宏),消息文本(注释)和编号(LONG值)组成
1 | #define ERROR_INVALID_FUNCTION 1L |
使用FormatMessage
函数可以将错误代码转换为相应的文本描述,该函数也有翻译功能。
自定义错误
编写函数时,通过设置错误代码(调用SetLastError
)并返回指出发生了错误的值(FALSE,INVALID_HANDLE_VALUE,NULL 或其他值)来指出发生了错误
1 | VOID SetLastEror(DWORD dwErrCode) |
尽量使用WinError.h
中的代码,在需要时创建自己的错误代码,它是一个32位数,由以下几个字段组成
位 | 31-30 | 29 | 28 | 27-16 | 15-0 |
---|---|---|---|---|---|
内容 | 严重性 | MS/客户 | 保留 | Facility代码 | 异常代码 |
含义 | 0(成功),1(信息/提示),2(警告),3(错误) | 0(MS定义的代码),1(客户定义的代码) | 必须为0 | 前256个值由MS保留 | MS/客户定义的代码 |
字符与字符串处理
字符编码
分为 ANSI 和 Unicode,Vista 的 Unicode 字符使用 UTF-16 编码
.NET Framework
始终使用 UTF-16 编码所有字符和字符串。
C语言中,数据类型wchar_t
表示一个 UTF-16 字符,字符和字符串前加L
提示编译器将该字符串用 UTF-16 编码,使用方法如下
1 | wchar_t c = L'a'; |
数据类型
WinNT.h
提供了一些 typedef
1 | typedef char CHAR; |
使用宏可以使得 Unicode 和 ANSI 子丰富都能通过编译,WinNT.h
中还有如下类型和宏
1 |
|
使用时
1 | TCHAR c = TEXT('A'); |
Windows 中的 Unicode 函数和 ANSI 函数
如果一个 Windows 函数的多数列表中有字符串,则该函数通常有两个版本。例如CreateWindowEx
有CreateWindowExW
和CreateWindowExA
两个版本,W 表示宽字节,A 表示 ANSI 字符。
使用时只需使用CreateWindowEx
,在 WinUser.h
中有如下宏
1 |
ANSI 版本一般只是分配内存,执行转换操作,然后调用 Unicode 函数。
程序中应尽量使用 Unicode 字符。
C 运行库中的 Unicode 函数 和 ANSI 函数
一般不会互相调用。
对应于strlen
的Unicode版本为wcslen
,两个原型都在 string.h
,TChar.h
包含以下宏,可以使用 Unicode 或 ANSI 编译
1 |
C运行库中的安全字符串函数
新版本的函数相对于旧版本在后面添加一个_s
,并接受一个新的参数
1 | PTSTR _tcscpy (PTSTR strDestination, PCTSTR strSource); |
将一个可写的缓冲区作为参数传递时,必须同时提供他的大小,值是一个字符数。对缓冲区使用_countof
宏(在stdlib.h
中定义)计算这个值。
安全函数会检查指针不为NULL,整数在有效范围内,枚举值有效,缓冲区足够容纳结果数据。如果其中有任何一个失败,函数都会设置 error值并返回一个 errno_t 值指出成功或者失败(并不实际返回),debug 版本显示 Debug Assertion Failed 对话框,release 版本直接终止运行。
也可以自己定义一个函数,检测到参数无效时调用该函数。在这个函数中可以记录失败,附上调试器或者做其他工作。原型如下
1 | void InvalidParamenterHandler(PCTSTR expression, PCTSTR function, PCTSTR file, unsigned int line, uintptr_t/*pReserved*/); |
expression 描述了代码中可能出现的函数失败,比如(L"Buffer is too small"&&0)
,function、file、line 分别描述了出现错误的函数名称、源代码文件和行号。
然后调用_set_invalid_parameter_handler
注册这个处理程序,还要调用_CrtSetReportMode(_CRT_ASSERT,0);
来禁止出现 Debug Assertion Failed 对话框。
定义了用于处理错误的函数后,使用安全函数时就可以检查返回的 errorno_t ,只有返回S_OK
才表明函数调用成功,其他返回值在error.h
中定义。
控制字符串
C 运行库也有在执行字符串处理时提供控制的函数,如控制天重复或指定如何截断,也有 W 和 A 版本。
Cch 表示 Count of characters,使用 _countof
宏获取, Cb 表示 count of bytes,使用 sizeof 获取。
这些函数返回一个 HRESULT
HRESULT值 | 描述 |
---|---|
S_OK | 成功 |
STRSAFE_E_INVALID_PARAMETER | 失败,将 NULL 传给了一个参数 |
STRSAFE_E_INSUFFICIENT_BUFFER | 失败,缓冲区太小 |
Windows 字符串处理函数
推荐的字符和字符串处理方式
编写程序时:
- 将文本字符串视为字符的数组,而不是 char 或字节的数组
- 使用通用数据类型(如TCHAR/PTSTR))表示文本字符和字符串
- 用明确的数据类型(如 BYTE/PBYTE)表示字节、字节指针和数据缓冲区
- 用 TEXT 或
_T
宏(两者之一)表示字面量字符和字符串 - 执行全局替换(如用 PTSTR 替换 PSTR)
- 修改与字符串有关的计算。例如函数幼传入缓冲区大小的字符数而不是字节数时,应传入
_countof(szBuffer)
而不是sizeof(szBuffer
。分配内存时按字节分配,所以应调用malloc(nCharacters* sizeof(TCHAR))
而不是malloc(nCharacters)
。 - 避免使用 printf 系列函数,尤其不要使用
%s
和%S
占位符进行 ANSI 和 Unicode 字符串的转换。正确做法是使用MultiByteTowideChar
和WideCharToMultiByte
函数 UNICODE
和_UNICODE
符号要么同时指定,要么都不指定
对于字符串处理函数
- 始终使用安全的字符串处理函数
- 不要是哟个不安全的C运行库字符串处理函数
- 使用
/GS
和/RTCs
编译器标志来自动检测缓冲区溢出 - 不要使用 Kernel32 方法进行字符串处理,如 lstrcat 和 lstrcpy
- 比较字符串时,应使用
CompareStringOrdinal
,因为它非常快而且不考虑用户的区域设置。用户字符串一般要在用户界面上显式,对于这些字符串应使用CompareString(Ex)
,因为它考虑用户的区域设置
Unicode 与 ANSI 字符串转换
导出 ANSI 和 Unicode DLL 函数
判断文本是 ANSI 和 Unicode
内核对象
内核对象概念
内核对象由函数创建,每个内核对象都是一个内存块,由操作系统内核分配,只能由操作系统内核访问。程序通过调用 Windows 提供的函数操作内核对象。
调用一个创建内核对象的函数后,函数返回一个句柄(handle),标识了所创建的对象,它是进程相关的,可以被进程中的任何线程使用
除了内核对象,还有用户对象和GDI(Grapical Device Interface)对象。几乎所有创建内核对象的函数都有有一个允许我们指定安全属性信息的参数。
使用计数(usage count)
操作系统内核知道当前有多少个进程正在使用一个特定的内核对象,因为内核对象都包含一个数据成员——使用计数。创建时,使用计数为 1,另一个进程获得对现有对象的访问权后,使用计数递增,进程终止时使用计数递减,使用计数为0时销毁该内核对象。
内核对象安全性
内核对象可以用一个安全描述符(security descriptor,SD)保护。它描述了谁拥有对象,哪些用户允许访问或使用对象,那些组和用户拒绝访问此对象。
用于创建内核对象的所有函数几乎都有一个指向 SECURITY_ATTRIBUTES
结构的指针作为参数,这个结构只有一个与安全性有关的成员IpSecurityDescriptor
。
访问现有对象时,必须指定将对此对象进行何种操作,函数会进行安全检查。
进程内核对象句柄表
一个进程初始化时,系统将为其分配一个句柄表(handle table),这个句柄表仅供内核对象使用。
一个句柄表就是一个结构组成的数组,每个结构保护一个指向内核对象的指针,一个访问掩码(包含标志位的一个DWORD)和一些标志。
创建内核对象
进程初始化时,其句柄表为空。当进程内的一个线程调用一个会创建内核对象的函数时,内核将为这个对象分配并初始化一个内存块,然后内核扫描进程的句柄表,查找一个空白的记录项(empty entry),并对其进行初始化。
用于创建内核对象的任何函数都会返回一个与进程相关的句柄,这个句柄可以由同一个进程中运行的所有线程使用。系统用索引表示内核对象的信息保存在进程句柄表中的具体位置。要得到实际的索引值,聚丙酯应该右移两位来忽略 Windows 操作系统内部使用的最后两位。
调用一个函数时,如果它接受一个内核对象句柄作为参数,必须把 Create* 函数返回的值传给它。在内部,这个函数会查找进程的句柄表,获得目标内核对象的地址,然后用某种方式操作对象的数据结构。
如果传入一个无效的句柄,函数会失败,GetLastError 会返回6(ERROR_INVALID_HANDLE),由于句柄值实际上时作为进程句柄表的索引使用的,所以句柄与当前进程相关,无法供其他进程使用。否则实际引用的只是那个进程句柄表中位于同一个索引位置的内核对象。
使用函数创建内核对象失败时,返回的句柄值通常为0(NULL),所以第一个有效句柄值为4。另外有几个函数失败时会返回 -1(INVALID_HANDLE_VALUE)。
关闭内核对象
无论用什么方法创建内核对象,都要调用CloseHandle
向系统表明我们已经结束使用对象
1 | BOOL CloseHandle(HANDLE hobject); |
内部,该函数首先检查主调进程的句柄表,验证”传给函数的句柄值“标识的是”进程确实有权访问“的一个对象。如果句柄有效,系统将获得内核对象数据结构的地址,并将结构中的”使用计数“成员递减。如果句柄无效,分两种情况:如果程序是正常运行的,CloseHandle 将返回 FALSE,而 GetLastError 返回 ERROR_INVALID_HANDLE;如果进程正在被调试,系统将抛出异常(指定了无效的句柄)来方便调试。
CloseHandle 返回之前会清楚进程句柄表中对应的记录想,所以不能再试图使用它。
跨进程边界共享内核对象
使用对象句柄继承
只有在进程之间有父子关系时,才能使用对象句柄继承。
父进程需要进行如下步骤:
首先父进程创建内核对象时,父进程必须向系统指出它希望这个对象的句柄是可继承的。初始化一个SECURITY_ATTRIBUTES
,指定句柄可继承,并传给具体的 Create 函数
1 | SECURITY_ATTRIBUTES sa; |
进程句柄表中的记录项有一个标志位(最低位),默认为0,为1时表示这个句柄时科技城的。
然后由父进程生成子进程,通过调用CreateProcess
函数完成,
1 | BOOL CreateProcess{ |
如果bInheritHandles
为TRUE,子进程就会继承父进程的“可继承的句柄”的值,系统创建子进程时,为子进程创建一个新的进程句柄表,并遍历父进程的句柄表,包含“可继承的句柄”的项都会被完整复制到子进程的句柄表。在子进程的句柄表中,复制项的位置与之前一致,所以子进程和父进程中对一个内核对象进行标识的句柄值也是一致的。内核对象的使用计数也会递增。
子进程不知道自己继承了句柄,为了使子进程得到继承的内核对象的句柄值,常见方式是将句柄值作为命令行参数传给子进程。也可以使用其他进程间通信技术,或者让父进程添加子进程知道的环境变量。
改变句柄的标志
使用SetHandleInformation
函数可以修改函数标志
1 | BOOL SetHandleInformation{ |
hObject 标识了一个有效句柄,dwMask告诉我们向修改哪个或那些标志,目前每个句柄关联两个标志
1 |
HANDLE_FLAG_PROTECT_FROM_CLOSE
表示禁止系统关闭句柄
第三个参数指出希望把标志修改为什么。
1 | //打开继承标志 |
为对象命名
为对象命名也可以实现跨进程边界共享内核对象,多数内核对象都可以进行命名,创建内核对象的函数会有一个pszName
参数,传入NULL时表示创建匿名对象,也可以传入字符串为对象命名。
MS 不存在保证对象名唯一的机制,所有对象共享同一个命名空间。
共享对象时,首先进程 A 创建一个命名的对象
1 | HANDLE hMutexProcessA = CreateMutex(NULL, FALSE, TEXT('AMutex')); |
进程 B 执行
1 | HANDLE hMutexProcessB = CreateMutex(NULL, FALSE TEXT('AMutex')); |
系统首先查看是否存在一个名为AMutex
的内核对象,如果存在,接下来检查该对象的类型。进程 B 试图创建一个互斥量对象,AMutex 也是互斥量对象,检查通过。然后系统进行安全检查,验证调用者是否拥有对该对象的完全访问权限,如果是,系统会在 B 的句柄表中查找一个空白记录项,将其初始化为现存的内核对象。如果中间有一处检查失败,函数返回NULL。
创建内核对象的函数总是返回具有完全访问权限的句柄,如果需要限制句柄的访问权限,可以使用这些函数的扩展版本(EX)。
除了调用Create*
函数,也可以使用Open*
函数实现共享。Open函数在对象不存在时不会创建新的对象,只会调用失败。
通常使用 GUID 防止出现命名冲突。
终端服务命名空间
终端服务(Terminal Service)的情况不通,正在运行终端服务的计算机中,有多个用于内核对象的命名空间,其中一个是全局空间
可以使用ProcessIdToSessinId
函数查看当前进程在哪个 Terminal Service 会话中运行(由kernel32.dll导出,在WinBase.h中声明)
1 | DWORD processID = GetCurrentProcessId(); |
一个服务的命名内核对象始终位于全局命名空间内,默认情况下,终端服务中的应用程序把自己的内核对象放在会话的命名空间内。使用Global\
前缀可以强制把一个命名对象放入全局命名空间
1 | HANDLE h = CreateEvent(NULL, FALSE FALSE, TEXT('Global\\MyName')); |
也可以使用Local\
前缀显式指出希望将内核对象放入当前会话的命名空间。
专有命名空间
创建内核对象时,可以传入一个指向SECURITY_ATTRIBUTES
结构的指针,来保护对象。Vista 之前,任何进程都能用任何指定的名称创建一个对象。可以创建专有的命名空间(类似 Global 和 Local)来保护对象。
创建边界(bondary),将对应于本地管理员(Local Administrators)的安全描述符(Security Identifier,SID)与它关联起来,然后创建或打开其名称被用作内核对象前缀的命名空间。
复制对象句柄
使用DuplicateHandle
函数可以获得一个进程句柄表的一个记录项,然后再另一个进程的句柄表中创建这个记录项的一个副本。
1 | BOOL DuplicateHandle ( |
进程
进程由两部分组成:内核对象和地址空间,这个地址空间包含所有可执行文件或 DLL 模块的代码和数据,还有堆和栈的分配。
第一个 Windows 程序
入口点函数有两种
1 | int WINAPI _tWinMain ( |
链接可执行文件时,链接器选择正确的 C/C++ 运行库启动函数。启动函数会:
- 获取指向新进程的完整命令行的一个指针
- 获取指向新进程的环境变量的一个指针
- 初始化 C/C++ 运行库的全局变量
- 初始化 C 运行库内存分配函数和其他底层 IO 使用的堆
- 调用所有全局变量和静态 C++ 类对象的构造函数
程序可以访问的 C/C++ 运行库全局变量如下
名称 | 类型 | 描述和推荐使用的 Windows 函数 |
---|---|---|
_osver | ||
_winmajor | ||
_winminor | ||
__argc | ||
__argv | ||
_environ | ||
_pgmptr |
之后会调用程序的入口点函数,入口点函数返回后,启动函数调用 C 运行库函数 exit,向其传递返回值nMainTreVal
,exit 函数执行以下任务
- 调用
_onexit
函数调用所注册的任何一个函数 - 调用所有全局和静态 C++ 类对象的析构函数
- 在 DEBUG 生成中,如果设置了
_CRTDBG_LEAK_CHECK_DF
标志,则通过调用_CrtDumpMemoryLeaks
生成内存泄漏报告 - 调用操作系统的 ExitProcess 函数,想起传入 nMainRetVal。这会导致操作系统 kill 我们的进程,并设置其退出代码
进程实例句柄
加载到进程地址空间的每一个可执行文件或者 DLL 文件都有一个独一无二的实例句柄,可执行文件的实例被当作 WinMain 函数的第一个参数 hInstanceExe 传入,需要加载资源的函数调用中一般都要提供此句柄的值。比如从可执行文件中加载图标
1 | HICON LoadIcon { |
Platform SDK 文档中,有些函数需要一个 HMODULE 类型的参数。HMODULE 和 HINSTANCE 完全相同。
GetModuleHandle
函数可以获取一个可执行文件或者 DLL 文件被加载到进程地址空间的什么位置
1 | HMODULE GetModuleHandle(PCTSTR pszModule); |
pszModule 参数指定已在主调进程的地址空间中加载的一个可执行文件或 DLL 文件的名称。如果找到了指定的文件,会返回基地址,否则返回 NULL。如果传入 NULL,则返回主调进程的可执行文件的基地址。
如果代码在 DLL 中运行,为了知道代码在什么模块中运行,可以使用链接器提供的伪变量__ImageBase
或者调用GetModuleHandleEx
,第一个参数传入GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
,第二个参数传入当前函数的地址,该函数会用传入函数所在 DLL 的基地址填入第三个参数中。
GetModuleHandle
函数只检查主调进程的地址空间,如果传入 NULL,会返回进程的地址空间中的可执行文件的基地址,即使调用该函数的代码是在一个 DLL 文件中。
进程前一个实例的句柄(hPrevInstance)
C/C++ 运行库启动代码总是向 WinMain 的 hPrevInstance 参数传递 NULL,该参数用于16位 Windows 系统,平时不要引用这个参数。建议将 WinMain 按如下写法编写
1 | int WINAPI _tWinMain( |
也可以使用UNREFERENCED_PARAMETER
宏指出该参数未使用
1 | int APIENTRY _tWinMain( |
进程的命令行
系统在创建一个新进程时,会传一个命令行给它。C于悉尼国库的启动代码开始执行一个 GUI 程序时,会调用 Windows 函数 GetCommandLine 来获取进程的完整命令行,忽略可执行文件的名称,然后将指向命令行剩余部分呢的一个指针传给 WinMain 的 pszCmdLine 参数。
虽然 pszCmdLine 指向的缓冲区可写,但应注意越界问题,最好将它当作只读的。
也可以像 C 运行库一样获取完整命令行
1 | PTSTR GetCommandLine(); |
利用 CommandLineToArgvW 可以将 Unicode 字符串分解为单独的标记
1 | PWSTR* CommandLineToArgvW( |
第一个参数指向一个命令行字符串,通常是GetCommandLineW
函数调用的返回值,pNumArgs 参数是一个整数的地址,该整数会被设置位命令行中的实参数目。CommandLineArgW 返回一个 Uniclde 字符串指针数组的地址。
CommandLineToArgvW 在内部分配内存,如果需要手动释放内存,可以调用 HeapFree
1 | int nNumArgs; |
进程的环境变量
每个进程都有一个与它相关的环境块(environment block),这是在进程地址空间分配的一块内存,包含的字符串与下面类似
1 | =::=::\ ... |
除了第一个=::=::\
字符串,还可能存在其他以=
开头的字符串,它们不作为环境变量使用。
访问环境块的第一种方式是使用GetEnvironmentStrings
函数获取完整的环境块,得到的字符串与上面一致。不再使用这块内存时,应使用FreeEnvironmentStrings
释放。
1 | PTSTR pEnvBlock = GetEnvironmentStrings(); |
第二种方式是 CUI 程序专用的,它通过应用程序 main 入口点函数接受的 TCHAR* env[]
参数实现。env 是一个字符串指针数组,每个指针指向一个不同的环境变量(名称=值 的格式)。指向最后一个环境变量字符串的指针后面,会有一个 NULL 指针表明这是数组的末尾。以=
开头的字符串在接收到 env 之前就会被移除。
环境变量中的空格是有意义的。ABC=XYZ
不同于ABC = XYZ
。
用户登录时,系统会创建 shell 进程,并将一组环境字符串与其关联,系统通过检查注册表中的两个注册表项来获得出事的环境字符串。
第一个注册表项包含应用于系统的所有环境变量的列表,HKEY_LOCAL_MACHINE\SYSTEM\Current\Set\Control\Session Manager\Environment
,第二个注册表项包含应用于当前登录用户的所有环境变量的列表,HKEY_CURRENT_USER\Environment
。
用户可以通过控制面板或者调用注册表函数修改注册表项。修改后,有些程序的主窗口可以接受 WM_SETTINGCHANGE 消息,更新环境块,如希望其立刻更新,可以使用如下方法
1 | SendMessage(HWND_BROAUCAST, WM_SETTINGCHANGE, 0, (LPARAM)TEXT("Environment")); |
通常,子进程会继承一组环境变量,与父进程相同。但是父进程可以控制哪些环境变量允许子进程继承。父进程和子进程不共享同一个环境块。
使用GetEnvironmentVariable
可以检查环境变量是否存在,如果存在,它的值是什么
1 | DWORD GetEnvironmentVariable( |
字符串中有时包含可替换部分,如%USERPROFILE%\Documents
,可以使用ExpandEnvironmentStrings
替换
1 | DWORD ExpandEnvironmentStrings( |
可以使用SetEnvironmentVaribal
添加删除或修改变量的值
1 | BOOL SetEnvironmentVariable( |
进程的关联性
通常,进程中的线程可以在主机的任何 CPU 上执行,但是也可以强迫进程在可用 CPU 的一个子集上运行,这称为“处理器关联性”(processor affinity)。
进程的错误模式
每个进程都关联了一组标志,这些标志的作用是让系统直到进程如何响应严重错误,包括磁盘介质错误,未处理的异常,文件查找错误以及数据对其错误等。进程可以调用SetErrorMode
告诉系统如何处理这些错误
1 | UINT SetErrorMode(UINT fuErrorMode); |
fuErrorMode 参数时下表中按位或的结果
标志 | 描述 |
---|---|
SEM_FAILCRITICALERROR | 系统不显示严重错误处理程序(critical-error-handler)消息框,并将错误返回主调进程 |
SEM_NOGPFAULTERRORBOX | 系统不显示常规保护错误(general-protection-fault)消息框,此标志只应该由调试程序设置;该调试程序用一个异常处理程序来自行处理常规保护(general protection,GP)错误 |
SEM_NOOPENFILEERRORBOX | 系统查找文件关联,不显示消息框 |
SEM_NOALIGNMENTEAULTEXCEPT | 系统自动修复内存对齐错误,并使应用程序看不到这些错误,对 x86/x64 处理器无效 |
默认情况下,子进程会继承父进程的错误模式标志。
进程当前所在的驱动器和目录
如果不提供完整的路径名,各种 Windows 函数会在当前驱动器的当前目录查找文件和目录。系统在内部追踪记录着一个进程的当前驱动器或目录,这种信息以进程为单位维护,如果进程中的一个线程改变了当前目录,这个进程中的其他线程也会受到影响。
可以调用以下两个函数获取和设置所在进程的当前驱动器和目录
1 | DWORD GetCurrentDirectory( |
进程的当前目录
系统跟踪记录进程的当前驱动器和目录,但它没有记录每个驱动器的当前目录。可以在环境变量中添加如下的字符串来指出各个驱动器的当前目录
1 | =C:=C:\Utility\bin |
如果调用一个函数,传入的路径名限定的是当前驱动器以外的驱动器,系统会在进程的环境块中查找与指定的驱动器号关联的变量。如果找到,则将该变量的值作为当前目录使用,如果没有找到,则假定指定驱动器的当前目录为它的根目录。
如果父进程创建了一个希望传给子进程的环境块,紫荆城的环境块就不会自动继承父进程的当前目录。可以在子进程生成前,将这些驱动器号环境变量添加到环境块中。
1 | DWORD GetFul |
驱动器号环境变量通常必须放在环境块的开始处。
系统版本
GetVersion
函数的低位字为主版本号,高位字为次版本号。
1 | GetVersionEx(POSVERSIONINFOEX pVersionInformaton); |
dwTypeMask 参数指出我们初始化了此结构的那些成员,dwlConditionMask描述比较方式,可以使用宏来设置
1 | VER_SET_CONDITION( |
建立一组条件之后就可以调用 VerifyVersionInfo,成功返回非零值,如果返回0,说明主机系统不符合要求或者调用函数方式不正确,可以使用 GetLastError 查看错误原因。
DLL 基础
将 DLL 文件映像映射到调用进程的地址空间有两种方式:隐式载入时链接(implicit lod-time linking)或显式运行时链接(explicit run-time linking),本章讨论隐式链接。
DLL 和进程的地址空间
DLL 不会拥有任何对象,DLL 中函数创建的对象都为调用线程活调用进程拥有。
一个模块提供一个内存分配函数的时候,必须同时提供另一个用来释放内存的函数。
1 | VOID EXEFunc() { |
纵观全局
如果一个可执行模块需要从另一个 DLL 模块中导入函数活变量,必须先构建 DLL 模块,然后构建可执行模块。
构建 DLL 模块:
- 创建一个头文件,包含想要在 DLL 模块中导出的函数原型、结构以及符号。所有 DLL 的源文件都要包含这个头文件
- 创建 C/C++ 源文件来实现想要在 DLL 模块中导出的函数和变量
- 构建该 DLL 模块的时候,编译器会对每个源文件进程处理并生成一个 .obj 模块,每个源文件对应一个 .obj 模块
- 所有 .obj 模块构建完成后,链接器将所有 .obj 模块内容合并,产生一个单独的 DLL 映像文件,包含 DLL 中所有的二进制代码以及全局/静态变量
- 如果链接器检测到 DLL 源文件道出了至少一个函数活变量,那么链接器还会生成一个 .lib 文件,只列出了所有被导出的函数和变量的符号名,用于构建可执行模块
构建可执行模块:
- 在所有引用了导出的函数、变量、活符号的源文件中,包含上述 DLL 的头文件
- 创建 C/C++ 源文件实现功能
- 构建可执行模块时,编译器对每个源文件进行处理并生成一个 .obj 模块,没课源文件对应一个 .obj 模块
- 所有.obj模块构建完毕后,链接器把所有 .obj 模块的内容合并起来,产生一个单独的可执行映像文件,这个文件包含了可执行文件的所有二进制代码和全局/静态变量。除此以外,还包含一个导入段,包含它需要的 DLL 模块的名称和可执行文件的二进制代码从中引用的函数和符号名。操作系统加载程序会解析这个导入段
- 加载程序为新的进程创建一个虚拟地址空间,并将可执行模块映射到新进程的地址空间中,接着解析导入段,对导入段中每个 DLL进行定位,并映射到地址空间中
构建 DLL 模块
应只从 DLL 模块导出函数,不应导出变量和C++类。
Mylib.h
1 |
|
MylibFile1.cpp
1 |
|
编译器看到 __declspec(dllexport
就知道应该在生成的 DLL 模块中导出该变量、函数或 C++ 类。需要被导出的变量和函数必须在头文件定义的前面加上MYLIBAPI
extern "C"
只有在编写 C++ 代码时才能使用,告诉编译器不要对函数名或变量名进行改变。
可执行文件不应在 DLL 的头文件之前定义MYLIBAPI
。可执行模块中的MYLIBAPI
会被头文件定义为declspec(dllimport)
,这样编译器就知道需要从 DLL 模块中导入变量或函数。
构建可执行模块
MyExeFile1.cpp
1 |
|
运行可执行模块
启动一个可执行模块时,操作系统的加载程序会先为进程创建虚拟地址空间,接着把可执行模块映射到进程的地址空间中,之后加载程序会检查可执行程序的导入段,试图对所需的 DLL 进行定位并映射到进程的地址空间中。
导入段只包含 DLL 的名称,所以加载程序必须在用户的磁盘上搜索 DLL,顺序为:
- 包含可执行文件的目录
- Windows 的系统目录
- 16 位 的系统目录
- Windows 目录
- 进程的当前目录
- PATH 环境变量中列出的目录
为了减少应用程序的载入时间,应该对自己的可执行模块和 DLL 模块进行基址重定位和绑定。
DLL 高级技术
DLL 模块的显式载入和符号链接
指在运行的过程中,显式地载入所需的 DLL 并显式地域想要输出的符号进行链接。
显式载入 DLL 模块
使用以下两个函数将 DLL 映射到进程的地址空间
1 | HMODULE LoadLibrary(PCTSTR pszDLLPathName); |
这两个函数会定位 DLL,并试图将该文件映像映射到调用进程的地址空间中。返回的 HMODULE 表示文件映像被映射到的虚拟内存地址。
dwFlags 参数可以执行标志。
显式卸载 DLL 模块
当进程不需要引用 DLL 中的符号时,可以调用下面的函数显式卸载 DLL
1 | BOOL FreeLibrary(HMODULE hInstDll); |
不应在 DLL 中使用单独的 FreeLibrary
和 ExitThread
代替上述函数。
即使 LoadLibrary
和LoadLibraryEx
载入的 DLL 影视磁盘上的同一个文件,也不能将他们返回的映射地址互换使用。
显式地链接到导出符号
通过调用以下函数在载入 DLL 后得到想要引用的符号地址
1 | FARPROC GetProcAddress( |
有两种方式指定希望得到的符号的地址
1 | // 通过符号名 |
在使用返回的函数指针调用函数之前,需要将它转型称为域函数签名匹配的类型。
1 | typedef void (CALLBACK *PFN_DUMPMODULE)(HMODULE hModule) |
DLL 的入口点函数
一个 DLL 可以有一个入口点函数,系统会在不同的时候调用这个入口点函数。这些调用是通知性质的,通常被 DLL 用来执行一些域进程或线程有关的初始化和清理工作。入口点函数不是必须实现的。
1 | BOOL WINAPI DllMain(HINSTANCE hInstDll, DWORD fdwReason, PVOID fImpLoad) { |
hInstDll 包含该 DLL 实例的句柄,表示一个虚拟内存地址。通常将它保存在一个全局变量中,这样在调用资源载入函数时可以使用它。
fImpLoad 当 DLL 是显式载入时为 0,否则不为零。
fdwReason 表示系统调用入口点函数的原因。
DllMain 只应进行简单的初始化,应避免调用从其他 DLL 中导入的函数,避免在 DllMain 中使用 LoadLibrary(Ex) 和 FreeLibrary。
DLL_PROCESS_ATTACH
系统第一次将一个 DLL 映射到进程的地址空间中会调用 DllMain 并传入 DLL_PROCESS_ATTACH。如果之后一个线程再调用 LoadLibrary(Ex) 载入一个已经被映射到进程的地址空间的 DLL,那么操作系统只会增加该 DLL 的使用计数。
DllMain 的返回值用来表示该 DLL 是否初始化成功,成功应返回 TRUE,如果 fdwReason 不是 DLL_PROCESS_ATTACH,返回值将被忽略。
创建新进程时,系统会分配进程地址空间并将 exe 文件映像及所需的 DLL 文件映像映射到地址的进程空间中。然后系统将创建进程的主线程并用这个线程来调用每个 DLL 的 DllMain 函数,同时传入 DLL_PROCESS_ATTACH,然后让进程的主线程喀什执行可执行模块的 C/C++ 运行时的启动代码,然后执行可执行模块的入口点函数。如果任何一个 DLL 的 DllMain 函数返回 FALSE,系统会把所有文件映像从地址空间中清除,然后终止整个进程。
显式载入时,进程调用 LoadLibrary(Ex) ,系统会对指定的 DLL 进行定位,并将该 DLL 映射到进程地址空间中,然后系统会用调用 LoadLibrary(Ex) 的线程调用 DLL 的 DllMain,并传入 DLL_PROCESS_ATTACH,当 DllMain 函数完成处理后,系统会让 LoadLibrary(Ex) 调用返回。如果 DllMain 返回 FALSE,系统会自动从进程的地址空间中撤销对 DLL 文件映像的映射,然后让 LoadLibrary 返回 NULL。
DLL_PROCESS_DETACH
系统将一个 DLL 从地址空间撤销映射时,会调用 DLL 的 DllMain 函数,并在 fdwReason 中传入 DLL_PROCESS_DETACH。
只有当所有 DLL 都处理完 DLL_PROCESS_DETACH 之后,操作系统才会真正终止进程。
DLL_THREAD_ATTACH
系统不会让进程的主线程用 DLL_THREAD_ATTACH 调用 DllMain,只会使用 DLL_PROCESS_ATTACH。
DLL_THREAD_DETACH
DllMain 的序列化调用
延迟载入 DLL
函数转发器
已知的 DLL
注册表项HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs
记录了已知的 DLL,操作系统载入它们时总会在同一个目录中查找。
DLL 重定向
如果有一个SuperApp.exe
文件,可以创建一个SuperApp.exe.local
文件和一个.local
文件夹。
LoadLibarary(Ex) 会检查这个文件是否存在,如果存在就会载入这个目录中的模块。
这个特性默认关闭,可能会使系统从应用程序的文件夹中载入伪造的系统 DLL。可以修改注册表HKLM\Software\Microsoft\WindowsNT\CurrentVersion\Image File Execution Options
,增加一个条目DWORD DevOverrideEnable
并设置为 1 .
模块的基地址重定向
如果 DLL 无法被加载到希望的基地址,系统会打开模块的重定位段并遍历其中的条目。对每一个条目,加载程序会先找到包含机器指令的那个存储页面,然后将模块的首选及地址域模块的实际映射地址之间的差值加到机器指令当前使用的内存地址上。
这样做存在两个缺点:
- 加载程序必须遍历重定位段并修改大量代码,损失性能
- 当加载程序写入到模块的代码页面时,系统的写时复制基址会强制这些页面以系统的页交换文件为后备存储器
模块的绑定
线程局部存储区
线程局部存储区(Thread Local Storage),将数据与线程关联起来。
动态 TLS
Windows 中每个进程都有一组正在使用标志(in-use flag),每个标志可设置为 FREE 或 INUSE,表示该 TLS 元素是否正在使用。至少有 TLS_MINIMUM_AVAILABLE
个标志可使用。
应用程序使用一组 4 个函数使用动态 TLS
1 | WORD TlsAlloc(); |
对进程中的位标志进行检索,找到一个 FREE 标志。然后将该标志改为 INUSE,并返回该标志的索引,通常该索引存储在全局变量中。如果无法找到一个 FREE 标志,TlsAlloc 会返回 TLS_OUT_OF_INDEXS。
系统创建一个线程时,会分配 TLS_MINIMUL_AVAILABLE
个 PVOID 值,将他们初始化为 0,并与线程关联起来,每个线程都有自己的 PVOID 数组。TlsAlloc 返回的索引就是该线程此时可用的 PVOID 在数组中的索引。
1 | BOOL TlsSetValue( |
这个函数把 pvTlsValue 放入调用线程的 PVOID 数组中,成功返回 TRUE。
1 | PVOID TlsGetValue(DWORD dwTlsIndex); |
TlsFree 会将进程内对应的标志位重新设置为 FREE,还会将所有线程中该元素的内容设置为 0。
静态 TLS
1 | __declspec(thread) DOWRD t_dwStartTime = 0; |
这样声明的变量必须是全局变量gt_
或者静态变量st_
DLL 注入和 API 拦截
DLL注入的一个例子
简单来说,就是不能使用SetWindowLongPtr
从另一个进程地址空间中创建的窗口中派生子窗口
使用注册表来注入 DLL
1 | HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_Dlls |
这个注册表键的值可能包含一个或多个 DLL 文件名(通过空格或逗号分隔)。第一个文件名可以包含路径,其他文件名的路径将被忽略。所以最好将自己的 DLL 放到 Windows 系统目录中。
当 User32.dll 映射到一个新的进程时,会收到 DLL_PROCESS_ATTACH
通知,此时它取出上述注册表键的值,并调用 LoadLibrary
载入这个字符串中指定的每个 DLL。被注入的 DLL 是在进程生命期的早期载入的,调用其他函数时应慎重,Kernel32.dll 中的函数基本可以正常使用。
缺点:
- 这个 DLL 只会被映射到使用了 User32.dll 的进程中,对于多数使用 CUI 的程序不可行
- 每个基于 GUI 的进程都载入了这个 DLL,如果这个 DLL 出错影响会很大
- 在进程早期就注入了 DLL,使用后应该卸载这个 DLL
使用 Windows 挂钩来注入 DLL
进程 A 需要查看系统中各窗口处理了哪些消息,它通过安装 hook 注入 dll
1 | HHOOK hHook = SetWindowsHookEx(WH_GETMESSAGE, GetMsgProc, hInstDll, 0); |
第一个参数表示安装 hook 的类型,第二个参数时函数地址(在该进程的地址空间中),窗口即将处理消息时,会调用这个函数。第三个参数标识一个 DLL,这个 DLL 包含了 GetMsgProc 函数,hInstDll 的值时进程地址空间中 DLL 被映射到的虚拟内存地址。最后一个参数 0 表示给哪个进程安装挂钩。一个线程可能调用SetWindowHookEx
并传入另一个线程的标识符。如果传入0,表示要给系统中所有 GUI 线程安装挂钩。
运行过程:
- 进程 B 的一个线程准备向一个窗口发送一条消息
- 系统检查该线程是否安装了
WH_GETMESSAGE
挂钩 - 系统检查
GetMsgProc
所在的 DLL 是否被映射到了进程 B 的地址空间中 - 如果该 DLL 还未被映射,那么系统会强制该 DLL 映射到进程 B 的地址空间中,并将进程 B 中该 DLL 的锁计数器(lock count)递增
- 由于 DLL 的 hInstDll 是在进程 B 中映射的,系统会检查该 DLL在两个进程中的位置是否相同。如果相同,那么系统可以直接在 A 的地址空间中调用
GetMsgProc
;如果不同,系统必须确定GetMsgProc
在进程 B 的地址空间中的虚拟地址(GetMsgProc B = hInstDll B + (GetMsgProc A + hInstDll A)
) - 系统在进程 B 中递增该 DLL 的所计数器
- 系统在进程 B 中调用 GetMsgProc 函数
- GetMsgProc 返回时,系统递减该 DLL 在进程 B 中的锁计数器
系统把挂钩过滤函数(hook filter function)所在的 DLL 注入或映射到地址空间中时,会映射整个 DLL 而不仅仅是挂钩过滤函数。
相比于使用注册表注入,该方法可以在不再需要该 DLL 时撤销映射
1 | BOOL UnhookWindowsHookEx(HHOOK hHook); |
调用该函数时,系统遍历内部一个已经注入过该 DLL 的进程列表,并递减该 DLL 的锁计数器。当锁计数器递减到 0 的时候,系统会自动从进程的地址空间中撤销对该 DLL 的映射。锁计数器的意义在于防止进程 B 中的一个线程执行 GetMsgProc 时,另一个线程调用 UnhookWindowsHookEx,引发内存访问违规。
使用远程线程注入 DLL
第三种方法是使用远程线程(remote thread),提供了最高的灵活性。
根本上来说,DLL 注入要求目标线程中的一个线程调用 LoadLibrary
载入我们想要的 DLL。这种方法要求我们在目标进程中创建一个新的线程,使用CreateRemoteThread
。
1 | HANDLE CreateRemoteThread( |
除了第一个参数外,其余参数与 CreateThread 完全相同。第一个参数表示新建的进程由哪个进程所有。pfaStartAddr
是线程函数的内存地址,这个地址应该在远程进程(remote process)的地址空间中,而不是自己进程的空间中。
创建线程后,接下来需要调用 LoadLibrary 函数载入自己的 DLL。如果 DLL 名称以 ANSI 字符串存储就调用 LoadLibraryA,以 Unicode 存储就调用 LoadLibraryW。
LoadLibrary 与线程函数的函数原型基本相同,所以可以将线程函数的地址设置为 LoadLibraryA 或者 LoadLibraryW 的地址。
1 | HANDLE hThread = CreateRemoteThread(hProcessRemote, NULL, 0, LoadLibraryW, L"C:\\MyLib.dll", 0, NULL); |
此时还存在两个问题。
第一个是编译链接生成一个程序时,胜澈的二进制文件包含一个导入段。这个段由一系列传唤函数(thunk)构成,转换函数用于跳转到导入的函数。代码调用 LoadLibraryW 制类的函数时,链接器会生成一个调用,来调用我们模块中导入段的一个转换函数,转换函数之后会跳转到实际的函数。
如果直接引用 LoadLibraryW,该引用会被解析为模块导入段的 LoadLibraryW 的转换函数的地址。这个地址在远程线程中并不是 LoadLibraryW 的真正地址。所以必须通过 GetProcAddress 得到 LoadLibraryW 的确切地址。
每个应用程序都需要 Kernel32.dll,每个进程中 Kernel32.dll 都会被映射到同一个地址,但重启后可能改变。应像下面这样调用 CreateRemoteThread
1 | //Get the real address of LoadLibraryW |
第二个问题是字符串"C:\\MyLib.dll"
位于调用进程的地址空间中。我们需要为 CreateRemoteThread 传入在远程进程的地址空间中存放字符串的地址。使用VirtualAlloc
在另一个进程中分配空间
1 | PVOID VirtualAllocEx( |
使用VirtualFree
释放这块空间
1 | BOOL VirtualFreeEx( |
在另一个进程中分配空间后,还需要把字符串复制到该进程空间中。以下函数允许一个进程对另外一个进程的地址空间进行读写
1 | BOOL ReadProcessMemory( |
使用远程线程注入 DLL 的步骤如下
- 用 VirtualAllocEx 在远程进程的地址空间中分配一块内存
- 用 WriteProcessMemory 函数把 DLL 的路径名复制到上述空间中
- 用 GetProcAddress 获取 LoadLibraryW (在 Kernel32 中)的实际地址
- 用 CreateRemoteThread 在远程进程中创建一个线程,让新线程调用正确的 LoadLibraryW 并在参数中传入第一步分配的内存地址。 用于注入的 DLL 的 DllMain 函数会受到 DLL_PROCESS_ATTACH 并可以执行需要的代码。当 DllMain 返回时,远程线程会从 LoadLibrary 返回到 BaseThreadStart 函数,这个函数执行 ExitThread,线程终止
- 使用 VirtualFreeEx 释放第一步分配的内存
- 使用 GetProcAddress 获取 FreeLibrary 的实际地址
- 使用 CreateRemoteThread 在远程进程中创建一个线程,让远程线程调用 FreeLibrary,并在参数中传入远程 DLL 的HMODULE
使用木马 DLL 来注入 DLL
另一种方式是,如果我们知道这个进程一定会载入某个 DLL,我们可以创建自己的 DLL 并给它起相同的文件名,然后替换掉原来的 DLL。
把 DLL 作为调试器注入
使用 CreateProcess 来注入代码
API 拦截的一个例子
Windows 编程