0x01 Windows 中对文件的底层操作
- Windows 为了方便开发人员操作 I/O 设备(这些设备包括套接字、管道、文件、串口、目录等),对这些设备的差异进行了隐藏,所以开发人员在使用这些设备时不必关心使用的哪一种设备,只需要调用 CreateFile 这一个函数打开设备的操作即可
- CreateFile 这个函数功能强大,不仅可以打开 I/O 设备、限制文件的访问、创建临时文件、定制缓存、甚至可以对 I/O 进行异步操作
0x02 同步方式访问和操作 I/O 设备
- 那么打开文件之后怎么操作文件中的数据呢,可以使用 ReadFile 和 WriteFile 两个函数,下面是读取文件的例子
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <strsafe.h>
using namespace std;
DWORD WINAPI Create_File(WCHAR *FileName);
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode);
int main(int argc, char **argv)
{
WCHAR FileName[12] = TEXT("D:\post.txt");
DWORD res = Create_File(FileName);
// 打印错误函数
if (res != TRUE) ErrorCodeTransformation(res);
return 0;
}
DWORD WINAPI Create_File(WCHAR *FileName)
{
// 以只读方式打开 D 盘中的文件,并且独占该文件的访问
DWORD LastError; HANDLE file;
file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, NULL, NULL);
// 如果错误返回错误代码
LastError = GetLastError();
if (file == INVALID_HANDLE_VALUE) return LastError;
// 查询文件大小
LARGE_INTEGER FileSize = { 0 };
GetFileSizeEx(file, &FileSize);
cout << "[*] 打开文件成功" << endl;
cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
// 判断文件大小是否小于 4096 个字节,太大就不打印了
if (FileSize.LowPart < 4096)
{
// 读取文件当中的内容
DWORD dwNumBytes;
BYTE *Buffer = (BYTE *)malloc(FileSize.LowPart);
ReadFile(file, Buffer, FileSize.LowPart, &dwNumBytes, NULL);
cout << "[*] 文件内容: " << endl << Buffer << endl;
}
// 关闭文件句柄
CloseHandle(file);
return TRUE;
}
- 这一种方式就是同步 I/O 访问,也是菜鸟经常使用的一种方式。首先使用 CreateFile 打开 D 盘的文件,并且规定第二个参数为 GENERIC_READ 第五个参数为 OPEN_EXISTING,意思是以只读和独占方式打开文件,然后使用 GetFileSizeEx 获取文件的大小为之后读取文件做铺垫,最后如果文件小于 4096 就使用 ReadFile 函数打开文件,ReadFile 函数第二个参数是读取的数据存放的缓冲区,第三个参数表示读取的数据的大小
0x03 异步方式访问和操作 I/O 设备
- 以同步方式访问和操作 I/O 接口的好处是非常的方便,因为只需要等待 I/O 操作完成就可以了,但是如果 I/O 操作变多缺点也随之而来,每次进行 I/O 操作时线程都会等待操作完成,更何况与计算机的大多数操作相比,I/O 操作是最慢的,会浪费掉大量的时间,不利于程序的伸缩性。这样的话异步 I/O 的优势得以显现,异步 I/O 并不会让线程等待,线程可以干其他的事情,等到异步 I/O 操作完成时应用程序就会接收到一个通知,通知 I/O 读写操作已经完成了,这样就可以处理剩下的工作
- 通过 CreateFile 函数就可以很轻易的以异步方式打开 I/O 设备,但是在异步 I/O 完成时通过什么样的手段才能接收到 I/O 完成的通知呢,目前有 4 中方法 (1) 触发设备内核对象 (2) 使用可提醒的 I/O (3)触发事件内核对象 (4) 使用 I/O 完成端口,其中使用 I/O 完成端口获取通知的方式是最好的,同时也是最复杂的
4 种获取异步 I/O 完成通知的方式,难度随序号顺序逐级增加
0x03 -> (1) 以触发设备内核对象的方式获取异步 I/O 完成通知
- 以触发设备内核对象来获取异步 I/O 通知(将上面同步方式的代码稍作修改即可):
DWORD WINAPI Create_File(WCHAR *FileName)
{
// 以只读方式打开 D 盘中的文件,并且独占该文件的访问,倒数第二个参数表示以异步方式进行 I/O 操作
DWORD LastError; HANDLE file;
file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
// 如果错误返回错误代码
LastError = GetLastError();
if (file == INVALID_HANDLE_VALUE) return LastError;
// 查询文件大小
LARGE_INTEGER FileSize = { 0 };
GetFileSizeEx(file, &FileSize);
cout << "[*] 打开文件成功" << endl;
cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
// 判断文件大小是否小于 4096 个字节,太大就不打印了
if (FileSize.LowPart < 4096)
{
// 读取文件当中的内容
BOOL res; DWORD dwNumBytes; OVERLAPPED overlapped = { 0 };
// 设置文件偏移,0 表示从文件开头读取数据,10 表示从文件第 10 个字节读取文件内容
overlapped.Offset = 0;
BYTE *Buffer = (BYTE *)malloc(FileSize.LowPart);
// 以异步方式读取文件中的内容
res = ReadFile(file, Buffer, FileSize.LowPart, &dwNumBytes, &overlapped);
LastError = GetLastError();
// 之后就可以干别的事了
// 满足条件说明异步 I/O 操作成功
if (res == FALSE && (LastError == ERROR_IO_PENDING))
{
// 等待异步 I/O 完成通知
WaitForSingleObject(file, INFINITE);
cout << "[*] 文件内容: " << endl << Buffer << endl;
}
else
{
// 异步操作失败则返回错误代码
return LastError;
}
}
// 关闭文件句柄
CloseHandle(file);
return TRUE;
}
- 从以上代码可以看出,CreateFile 函数如果想以异步方式打开文件,必须向倒数第二个参数传递 FILE_FLAG_OVERLAPPED 标志,之后使用 WaitForSingleObject 函数等待通知即可,当然在这之前可以干别的事
0x03 -> (2) 以触发事件内核对象方式获取异步 I/O 完成通知
- 什么是事件内核对象,事件内核对象是内核模式下同步线程的一种方式,事件内核对象有两种状态,分别为触发态和非触发态,当异步 I/O 没有完成时为非触发态,当异步 I/O 对象完成时为触发态,这也就是为什么事件内核对象可以获取异步 I/O 通知。修改过的代码如下所示:
DWORD WINAPI Create_File(WCHAR *FileName)
{
// 以只读方式打开 D 盘中的文件,并且独占该文件的访问,倒数第二个参数表示以异步方式进行 I/O 操作
DWORD LastError; HANDLE file;
file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
// 如果错误返回错误代码
LastError = GetLastError();
if (file == INVALID_HANDLE_VALUE) return LastError;
// 查询文件大小
LARGE_INTEGER FileSize = { 0 };
GetFileSizeEx(file, &FileSize);
cout << "[*] 打开文件成功" << endl;
cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
// 判断文件大小是否小于 4096 个字节,太大就不打印了
if (FileSize.LowPart < 4096)
{
// 读取文件当中的内容
BOOL res; DWORD dwNumBytes; OVERLAPPED overlapped = { 0 };
// 将 overlapped 结构体中的 hEvent 成员绑定一个事件内核对象
overlapped.Offset = 0; overlapped.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
BYTE *Buffer = (BYTE *)malloc(FileSize.LowPart);
// 以异步方式读取文件中的内容
res = ReadFile(file, Buffer, FileSize.LowPart, &dwNumBytes, &overlapped);
LastError = GetLastError();
// 满足条件说明异步 I/O 操作成功
if (res == FALSE && (LastError == ERROR_IO_PENDING))
{
// 等待异步 I/O 完成通知
WaitForSingleObject(overlapped.hEvent, INFINITE);
cout << "[*] 文件内容: " << endl << Buffer << endl;
}
else
{
return LastError;
}
}
// 关闭文件句柄
CloseHandle(file);
return TRUE;
}
- 以上代码相对于以触发内核对象方式获取异步 I/O 完成通知的方式只改了两个部分,第一个是将 overlapped 结构体中的 hEvent 成员绑定一个事件内核对象,该事件内核对象是自动重置且处于未触发状态;第二个是将 WaitForSingleObject 函数等待的句柄变为了 overlapped.hEvent,看起来好像比上一个复杂一些
0x03 -> (3) 使用可提醒的 I/O 获取异步 I/O 完成通知
- 使用可提醒的 I/O 获取异步 I/O 完成通知相对于上面两种方式要更为复杂,同时也显得高大上许多,因为借助了 APC 队列。当发出一个异步 I/O 请求时,系统会将其添加到调用线程的 APC 队列当中,当异步 I/O 完成之后会调用回调函数,相当于接收了通知,示例代码如下
#include <Windows.h>
#include <iostream>
#include <stdio.h>
#include <strsafe.h>
using namespace std;
DWORD WINAPI Create_File(WCHAR *FileName);
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode);
VOID WINAPI CompletionRoutine(DWORD dwError, DWORD dwNumByte, OVERLAPPED *po);
BYTE *Buffer;
int main(int argc, char **argv)
{
WCHAR FileName[12] = TEXT("D:\post.txt");
DWORD res = Create_File(FileName);
// 打印错误函数
if (res != TRUE) ErrorCodeTransformation(res);
return 0;
}
DWORD WINAPI Create_File(WCHAR *FileName)
{
DWORD LastError; HANDLE file;
file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
LastError = GetLastError();
if (file == INVALID_HANDLE_VALUE) return LastError;
LARGE_INTEGER FileSize = { 0 };
GetFileSizeEx(file, &FileSize);
cout << "[*] 打开文件成功" << endl;
cout << "[*] 文件大小为: " << FileSize.LowPart << " 字节" << endl;
// 读取文件当中的内容
BOOL res; OVERLAPPED overlapped = { 0 };
overlapped.Offset = 0;
Buffer = (BYTE *)malloc(FileSize.LowPart);
// 以异步方式读取文件中的内容
res = ReadFileEx(file, Buffer, FileSize.LowPart, &overlapped, &CompletionRoutine);
// 将线程设置为可提醒状态
SleepEx(0, TRUE);
// 关闭文件句柄
CloseHandle(file);
return TRUE;
}
// 回调函数
VOID WINAPI CompletionRoutine(DWORD dwError, DWORD dwNumByte, OVERLAPPED *po)
{
cout << "[*] 文件内容: " << endl << Buffer << endl;
}
// 如果返回错误,可调用此函数打印详细错误信息
VOID WINAPI ErrorCodeTransformation(DWORD ErrorCode)
{
LPVOID lpMsgBuf; LPVOID lpDisplayBuf; DWORD dw = ErrorCode;
// 将错误代码转换为错误信息
FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, dw, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&lpMsgBuf, 0, NULL
);
lpDisplayBuf = (LPVOID)LocalAlloc(LMEM_ZEROINIT, (lstrlen((LPCTSTR)lpMsgBuf) + 40) * sizeof(TCHAR));
StringCchPrintf((LPTSTR)lpDisplayBuf, LocalSize(lpDisplayBuf), TEXT("错误代码 %d : %s"), dw, lpMsgBuf);
// 弹窗显示错误信息
MessageBox(NULL, (LPCTSTR)lpDisplayBuf, TEXT("Error"), MB_OK);
LocalFree(lpMsgBuf); LocalFree(lpDisplayBuf); ExitProcess(dw);
}
- 结合以上程序总结出使用可提醒的 I/O 需要具被这么几个条件:(1) 线程必须设置为可提醒状态 (2) 必须有回调函数 (3) 必须使用 ReadFileEx 而非 ReadFile,因为只有 ReadFileEx 才可以传入回调函数(好像是废话)
- 当使用 ReadFileEx 开始进行异步 I/O 操作时,传入了 CompletionRoutine 作为回调函数,同时系统会将这个异步 I/O 请求添加到 APC 队列中去,之后使用 SleepEx 函数将当前线程设置为可提醒状态(其他可提醒函数也可以替代 SleepEx),当异步 I/O 操作完成之后,回调函数就会被调用,从而打印出文件中的数据,打印结果如下:
0x03 -> (4) 使用 I/O 完成端口获取异步 I/O 完成通知
- 接收异步 I/O 的最后一种方式就是使用 I/O 完成端口(大佬极力推荐,菜鸟表示无力),这是所有接收 I/O 通知方式中最复杂的一个,毕竟 Windows 团队花了数年的时间研究创建了这个机制,这个机制真的很强大,上到可以加速处理网络请求,下到可以增强 I/O 访问速度。来看看使用这套机制需要哪些函数:
(1) CreateIoCompletionPort:创建 I/O 完成端口或者将一个 I/O 完成端口与设备相绑定 (2) GetQueuedCompletionStatus:用于等待 I/O 完成端口等待队列中处理完的 I/O 操作
- 函数很简单,下面来看一个例子:
#include <process.h>
#include <Windows.h>
#include <iostream>
using namespace std;
DWORD WINAPI IOTestFun(PWCHAR FileName);
unsigned __stdcall ThreadFun(void* pvParam);
// 打开的文件设备的句柄
HANDLE file;
// 文件的大小
DWORD Size;
// ReadFile 读取文件的缓冲区
PBYTE BufferFile;
// 创建的 I/O 完成端口的句柄
HANDLE IOPort;
int main(int argc, char **argv)
{
WCHAR FileName[12] = TEXT("D:\post.txt");
IOTestFun(FileName);
return 0;
}
DWORD WINAPI IOTestFun(PWCHAR FileName)
{
// 打开 D盘 post.txt 文件
DWORD LastError;
// FILE_FLAG_OVERLAPPED 参数表示以异步方式打开文件
file = CreateFile(FileName, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
LastError = GetLastError();
// 获取文件的大小
LARGE_INTEGER FileSize = { 0 };
GetFileSizeEx(file, &FileSize);
Size = FileSize.LowPart;
// 用于 ReadFile 读取文件数据的缓冲区,下面会使用到
BufferFile = (PBYTE)malloc(Size);
// 创建 IO 完成端口并且绑定设备 file
ULONG_PTR ptr = 0;
// CreateIoCompletionPort 最后一个参数表示同时只有两个线程被唤醒
IOPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 2);
// 将 IO 完成端口 IOPort 绑定设备 file
CreateIoCompletionPort(file, IOPort, ptr, 0);
// 创建 10 个线程并且立刻执行
HANDLE Threads[10];
for (size_t i = 0; i < 10; i++)
{
Threads[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadFun, (PVOID)&i, CREATE_SUSPENDED, NULL);
ResumeThread(Threads[i]);
}
// 创建 10 个 IO 读取操作
for (size_t i = 0; i < 10; i++)
{
// overlapped.Offset = 0 表示从文件中的开头读取文件
OVERLAPPED overlapped = { 0 }; overlapped.Offset = 0;
ReadFile(file, BufferFile, Size, NULL, &overlapped);
}
// 后续等待清理工作
WaitForMultipleObjects(10, Threads, TRUE, INFINITE);
for (size_t i = 0; i < 10; i++)
{
CloseHandle(Threads[i]);
}
CloseHandle(file);
return TRUE;
}
unsigned __stdcall ThreadFun(void* pvParam)
{
DWORD NumberByte; ULONG_PTR ptr; LPOVERLAPPED lapped = { 0 };
// 等待 I/O 操作完成
GetQueuedCompletionStatus(IOPort, &NumberByte, &ptr, &lapped, INFINITE);
// 打印文件内容
cout << "文件内容: " << BufferFile << endl;
return 0;
}
注:这个程序将 CreateIoCompletionPort 函数拆开来使用,先创建后绑定,方便理解
- 以上这个程序是干什么的呢,首先使用 CreateFile 打开 D 盘的 post.txt 文件设备,之后创建 I/O 完成端口并绑定这个文件设备,然后创建 10 个线程立刻执行,最后使用 ReadFile 循环 10 次读取文件当中的内容,打印结果如下:
- 值得注意的是 CreateFile 是以异步方式打开文件的,且每个线程的一开始都会使用 GetQueuedCompletionStatus 将线程变为等待状态,由于我们循环了 10 次读取文件的操作,而每一次读取文件的时间都很长,只要有一次读取文件成功,就会唤醒 10 个线程中的两个去处理剩下的工作,也就是将文件内容打印出来,那为什么是 10 个线程中的 2 个呢,因为在使用 CreateIoCompletionPort 函数时将最后一个参数传递的是 2,表示同时只有两个线程去处理剩下的工作
注:这个理解起来确实很复杂,可以想象为开始循环 10 次使用 ReadFile 读取文件内容时,I/O 完成端口会将这 10 个 I/O 请求按顺序放入一个队列中,并且此时有 10 个线程使用 GetQueuedCompletionStatus 函数等待,每当队列中的一个请求完成也就是 ReadFile 读取文件成功时就会从队列中出来,同时等待的线程中的 1 个也会被唤醒处理剩下的工作。需要知晓的是唤醒的线程是随机的,就像小鸟母亲叼着虫子喂小鸟,健壮的小鸟才会抢到食物
-参考资料:Windows 核心编程