防止程序多开的几种方法

发布时间:

最后更新:

关键词: C/C++ Mutex 单进程 多开

你编写了一个游戏,它有着绚丽的画面和宽广的地图,这需要占用极大的资源,一般用户的电脑不足以同时运行多个,而且这在大多情况下也没有意义;同时在游戏进行时还需要时时存储一些数据到硬盘上,这又要求数据文件不能同时被其他程序所改写。

由于种种原因,你发现让你的游戏同时运行多个实例是没有意义的,当然通常情况下也没人会这么做,可总免不了一些意外情况,比如手抖打开了两下游戏,或者某个第三方启动器中出现了BUG,启动了两个进程。最终你决定强制禁止你的游戏启动多个实例。

你开始思考如何实现这个方案,那么该怎么做呢?下面就以C语言为例,简单讲解几个常用的方法。

1. Windows Mutex内核对象

这大概是Windows平台下最常见的方式了,Mutex是互斥类,用于多线程访问同一个资源的时候,保证一次只有一个线程能访问该资源。在《Windows核心编程》一书中,对于这种互斥访问有一个很形象的比喻:想象你在飞机上如厕,这时卫生间的信息牌上显示“有人”,你必须等里面的人出来后才可进去。这就是互斥的含义。

在Windows系统中,与普通的线程同步对象不同,Mutex可以在跨进程访问,通过创建一个命名的Mutex对象,在另一个实例中再次以相同的名字创建将失败,直到原程序关闭了它,或是其所在的程序结束,利用这一点就能非常方便的实现单进程运行。

HANDLE hObject = CreateMutex(NULL, FALSE, L"互斥体名称");
if (GetLastError() == ERROR_ALREADY_EXISTS)
{
	CloseHandle(hObject);
	return; // 已经有一个程序运行了,直接退出
}
StartGame(); // 没有检测到已经运行的程序,启动游戏
CloseHandle(hObject);

实现的代码也是非常简单,这里StartGame()是游戏运行的入口,游戏结束后该函数才会返回。首先调用CreateMutex()函数创建Mutex,该函数有3个参数

  • 第一个参数是一个SECURITY_ATTRIBUTES型的结构体,用来设置安全属性的,这里直接设置为NULL即可
  • 第二个是BOOL类型的参数,决定了是否一创建就获得所有权,因为这里使用名称唯一性来判断,所以不获的也没关系
  • 第三个参数就是互斥体的名称了,这个名字应当保证每次运行都是相同的,所以最好设置成一个常量,同时也不要使用太常见的名字,以免和别人的程序重名

CreateMutex函数返回Mutex的句柄,通过GetLastError()函数来获取执行结果有关其他的信息,如果具有指定名字的Mutex已经存在,则GetLastError()返回ERROR_ALREADY_EXISTS,这也就表明已经有程序运行了。另外用完了别忘记调用CloseHandle()释放对象

该方法的安全性还是比较好的,想要破解就需要强制修改内核对象,而这需要调用更底层的方法,并且还要求管理员权限。在一些高级系统工具(比如冰刃,PCHunter)中都有相关的功能

2. 进程名称检测法

检测是否存在同名进程也是十分常用的方法,这种方法的逻辑就是获取系统的进程列表挨个检测每一个进程的名字,如果遇到同名的就说明之前已经运行了一个。代码上稍微复杂点

HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == NULL) {
	MessageBox(NULL, L"无法检测进程", L"启动失败", MB_OK);
	return;
}

//用于存储进程信息的变量
PROCESSENTRY32 ProcessInfo;
ProcessInfo.dwSize = sizeof(ProcessInfo);

//Process32First + Process32Next组合遍历进程列表
BOOL bStatus = Process32First(hSnapshot, &ProcessInfo);
while (bStatus) {
	if (_tcscmp(L"进程名", ProcessInfo.szExeFile) == 0) {
		CloseHandle(hSnapshot);
		return; // 已经有一个程序运行了,直接退出
	}
	bStatus = Process32Next(hSnapshot, &ProcessInfo);
}

CloseHandle(hSnapshot);
StartGame(); // 没有检测到已经运行的程序,启动游戏

首先使用CreateToolhelp32Snapshot()函数获取进程列表的快照(快照的意思就是某一时刻的状态),之后通过Process32FirstProcess32Next套装来遍历进程列表,通过_tcscmp来比较进程名和我们指定的名字,过如果发现列表中某个进程的名字跟我们指定的名字相同,那么就退出程序。快照对象不需要一直保留,在用完后就赶紧关掉。

这种方式安全性就不怎么样了,因为通常情况下进程名都与文件名有关,只要把执行的文件改个名字它就没辙了。

3. 窗口名检测法

上面的进程名很容易改,但是窗口名就没那么容易了,而且还有函数直接可以按名字查找窗口,代码上跟Mutex方式一样的简单

HWND hWindow = FindWindow(NULL, L"窗口的名称");
if (hWindow != NULL) {
	CloseHandle(hWindow);
	return; // 已经有一个程序运行了,直接退出
}
CloseHandle(hWindow);
StartGame(); // 没有检测到已经运行的程序,启动游戏

这段代码没什么好解释的,直接使用现成的函数FindWindow查找窗口,有就退,没有就继续运行。虽说窗口名比文件名难些,也难不倒哪里去,拿着窗口+改名之类的关键字去网上一查,各种工具一大堆,更何况不是什么程序都有窗口的。所以这种方式并没有多大卵用

4. Socket绑定检测

写过网络通信的都知道,要收发信息,首先得有个唯一的地址,对于本机来说不同的就是协议和端口号,于是就有了创建Socket并绑定来作为唯一标识的方式。

WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
SOCKET mSocket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

if (mSocket == INVALID_SOCKET) {
	MessageBox(NULL, L"无法创建Socket", L"启动失败", MB_OK);
	return;
}

//创建一个SOCKADDR结构体,设置协议及端口号
SOCKADDR_IN addr;
addr.sin_addr.S_un.S_addr = INADDR_ANY;
addr.sin_family = AF_INET;
addr.sin_port = htons(12345);

int r = bind(mSocket, (SOCKADDR*)&addr, sizeof(SOCKADDR));
if (r == SOCKET_ERROR) {
	closesocket(mSocket);
	return; // 已经有一个程序运行了,直接退出
}
StartGame(); // 没有检测到已经运行的程序,启动游戏
closesocket(mSocket);

首先WSAStartup初始化Socket库,用socket函数创建一个Socket对象,接下来使用bind来把它绑定到一个本地地址上(代码中是TCP协议,12345端口)。如果另一个程序又想绑定到这个端口,就会返回一个SOCKET_ERROR错误码,简单判断一下即可。

绑定的端口号最好选择一个不常用的,以免跟其他正常程序冲突。

Socket绑定的方式有种种局限,比如防火墙不允许,跟已有的端口冲突啦(虽然端口号有6万5,但这种可能也是有的,特别是某一些程序可能建立很多连接)。在实际中主要是一些服务端程序会监听固定的端口,顺便防止了多个实例的运行。

5. PID写入文件法

在进程启动时,系统都会为进程分配一个唯一的数字作为编号,也就是PID(Process ID),这个编号是随机分配的,没法在程序中预测。但是仔细一想,可以把这个数字写到一个文件里,下一个实例启动时从文件中读取PID,然后判断指定PID的进程是否存在从而实现检测进程的功能。

int existPid;
FILE* stream;

//首先尝试读取Pid文件中的PID,存入existPid变量
errno_t err = fopen_s(&stream, "PidFile", "r");
if (err == ENOENT) { 
	existPid = -1;	//文件不存在
} else if(err != 0) {
	MessageBox(NULL, L"PID读取失败", L"启动失败", MB_OK);
	return;    //出现IO错误
} else {
	fread_s(&existPid, sizeof(existPid), sizeof(existPid), 1, stream);
}
if (stream != NULL) {
	fclose(stream);
}
	
//这段跟以进程名检测的部分类似,只是判断部分改成进程ID
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == NULL) {
	MessageBox(NULL, L"无法检测进程", L"启动失败", MB_OK);
	return;
}
PROCESSENTRY32 ProcessInfo;
ProcessInfo.dwSize = sizeof(ProcessInfo);
BOOL bStatus = Process32First(hSnapshot, &ProcessInfo);

while (bStatus) {
	if (ProcessInfo.th32ProcessID == existPid) { //判断PID是否与存在的进程ID相等
		CloseHandle(hSnapshot);
		return; // 已经有一个程序运行了,直接退出
	}
	bStatus = Process32Next(hSnapshot, &ProcessInfo);
}
CloseHandle(hSnapshot);

//没有重复的进程,吧自身进程ID写入文件
err = fopen_s(&stream, "PidFile", "w");
if (err != 0) {
	MessageBox(NULL, L"PID写入失败", L"启动失败", MB_OK);
	return;
}
int pid = _getpid();
fwrite(&pid, sizeof(pid), 1, stream);
fclose(stream);
StartGame(); // 启动游戏

代码上看起来比较长,中间循环的部分与第二种方式相似,只是判断的地方不一样其他细节就请看注释吧。

该方法避免了文件改名而找不到进程的弊端,但却在Windows上十分罕见,因为它主要的功能并不是防止多开,而是在Unix和Linux系统中实现与后台服务以信号的方式通信,在这类系统里没有像Windows一样自带服务功能,启动和停止后台服务进程的主要方式就是Socket通信和发送信号,而发送信号需要知道目标进程的ID,故使用这种方式保存进程的ID,当然后台服务一般不需要运行多个,所以顺便用它防一下多开。

总结

上面介绍了5种方式,其中Windows平台上使用第一种就行,窗口名检测在早期Windows软件上能见到,现在基本绝迹了,Socket和PID的方法除了防多开之外还多用于进程间通信。事实上这个问题开放性非常高,除了这几种以外发挥想象,你就能发现各种各样奇葩的实现方法。

靠程序的逻辑最终只能防止无意间的多开,如果刻意要开多个,这些招数其实都有相应的对抗方法,更何况还有多开器啊,沙箱多开啊之类的利器,实在不行还有虚拟机吧,虚拟机还不行......我在开台机子总行吧╮(╯▽╰)╭

评论加载中