项目简介:
完成客户端(控制端)+服务端(被控端)的开发,客户端主要包括:磁盘及文件信息的获取;文件下载;监控、锁定和解锁对方屏幕等功能,服务端实现开机自动运行功能。
部分代码整理:
1,数据包的封装
包设计简图
CPacket(const BYTE* pData, size_t& nSize)
{
size_t i = 0; //i:标尺
/*解析包头*/
for (; i < nSize; i++)
{
if (*(WORD*)(pData + i) == 0xFEFF)
{
sHead = *(WORD*)(pData + i);//设置包头的值
i += 2;//i后移2字节,处理下一数据内容(读取数据的长度)
break;
}
}
/*nSize若小于包头(2)+包长(4)+命令(2)+和校验(2)的长度,说明解析失败,因为还没有一个基本的包长*/
if (i + 4 + 2 + 2 > nSize)
{
nSize = 0;
return;
}
/*读取包数据的长度*/
nLength = *(DWORD*)(pData + i);
i += 4;//i后移4字节,处理下一数据内容(解析命令)
if (nLength + i > nSize)
{//nSize若小于包数据的长度+(包头2字节+写有包数据长度的4字节),说明解析失败,因为还没有一个基本的包长
nSize = 0;
return;
}
/*解析命令*/
sCmd = *(WORD*)(pData + i); //这里的两个字节为命令,
i += 2; //i后移2字节,处理下一数据内容(解析数据)
if (nLength > 4)
{
strData.resize(nLength - 2 - 2);//数据的长度=包数据长度-命令(2)-和校验(2)
memcpy((void*)strData.c_str(), pData + i, nLength - 4); //拷贝数据内容到strData
i += nLength - 4;//i后移nLength-4字节,处理下一数据内容(解析和校验)
}
/*解析和校验*/
sSum = *(WORD*)(pData + i);
i += 2;//i后移2字节,此时i指向包尾处
WORD sum = 0;
for (size_t j = 0; j < strData.size(); j++)
sum += BYTE(strData[j]) & 0xFF;//求和
if (sum == sSum)//数据接收成功
{
nSize = i; //一个完整包的大小
return;
}
/*解析失败 返回0*/
nSize = 0;
}
2,线程同步几种方式的分析
a.互斥
该机制下,线程访问公共变量需先lock并在使用结束后unlock,此时如果有一个未lock就直接访问该变量的线程,则该机制无效。所以互斥依赖于开发人员的编程;其次线程1lock变量后,若此时线程2也需要访问变量,则此时的线程2会阻塞直至线程1unlock,也就是说“同时”变成了“排队”,降低了效率,且线程数越多,对效率的影响越显著。
b.消息
WM_XXX的参数WPARAM和LPARAM,在传递的过程,变量的值是被复制到了参数中,所以就不存在lock及unlock的问题,在同一对话框内SendMessage,跨线程的PostThreadMessage。但消息的缺点是参数能携载的数据量有限,而且其依赖于消息队列。
c.网络
单机程序也有网络(回环网络,127.0.0.1,本机网络)。优点:速度快;可服务多个请求;无需关注队列,消息的队列是利用软件处理的,而网络的队列则是利用硬件(网卡)来处理的,效率完全不一样,几乎无需关注后者;完成端口映射(epoll / IOCP)可以继续提升效率。
3,利用IOCP实现一个简单的线程安全的队列
大致流程为:①创建一个完成端口对象(该对象由操作系统接管)句柄,通过句柄与内核沟通;②创建一个线程,该线程负责处理队列。
//操作枚举值
enum
{
IocpListEmpty=0,
IcopListPush=1,
IocpListPop=2,
}
struct IOCP_PARAM
{
int Opr;//操作
std::string strData;//数据
_beginthread_proc_type cbFun;//回调
IOCP_PARAM() { Opr=-1; }
IOCP_PARAM(int inOpr,const char* inData,_beginthread_proc_type inCbFun=NULL)
{
Opr=inOpr;
strData=inData;
cbFun=inCbFun;
}
}
void threadMain(HANDLE hIOCP)
{
std::list<std::string> lstString;
DWORD dwTransferred=0;
ULONG_PTR CompletionKey=0;
OVERLAPPED* pOverlapped=NULL;
//获取完成端口的状态
while(GetQueuedComletionStatus(hIOCP,&dwTransferred,
&CompletionKey,&pOverlapped,INFINITE))
{
if(dwTransferred==0 || CompletionKey==NULL)
{
printf("Thread is prepare to exit!\r\n");
break;
}
IOCP_PARAM* parm=(IOCP_PARAM*)CompletionKey;
//push操作
if(parm->Opr==IocpListPush)
lstString.push_back(parm->strData);
//pop操作
if(parm->Opr==IocpListPop)
{
std::string* pStr=NULL;
if(lstString.size()>0)
{
pStr=new std::string(lstString.front());
lstString.pop_front();
}
if(parm->cbFun)
parm->cbFun(pStr);
}
//empty操作
if(parm->Opr==IocpListEmpty)
lstString.clear();
delete parm;
}
}
void threadQueueEntry(HANDLE hIOCP)
{
threadMain(hIOCP);
//代码到此为止,导致本地对象无法调用析构,进而内存泄漏
_endthread();
}
void Fun(void* arg)
{
std::string* pStr=(std::string*)arg;
if(pStr!=NULL)
{
printf("Pop from list,value is : %s",pStr->c_str());
delete pStr;
}
else
printf("List is empty!");
}
int main()
{
HANDLE hIOCP=INVALID_HANDLE_VALUE;
//创建一个完成端口对象
hIOCP=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,NULL,1);//因为是队列,所以线程数传1
//将完成端口对象传给线程
HANDLE hThread=(HANDLE)_beginthread(threadQueueEntry,0,hIOCP);
ULONGLONG tick=GetTickCount64();
while(_kbhit()==0)//完成端口 把请求和实现分离了
{
//投递状态
if(GetTickCount64()-tick>1300)
{
PostQueuedComletionStatus(hIOCP,sizeof(IOCP_PARAM),
(ULONG_PTR)new IOCP_PARAM(IocpListPop,"Hello World",Fun),NULL);
}
if(GetTickCount64()-tick>2000)
{
PostQueuedComletionStatus(hIOCP,sizeof(IOCP_PARAM),
(ULONG_PTR)new IOCP_PARAM(IocpListPush,"Hello World"),NULL);
tick=GetTickCount64();
}
Sleep(1);
}
if(hIOCP!=NULL)
{
PostQueuedComletionStatus(hIOCP,0,NULL,NULL);
WaitForSingleObject(hThread,INFINITE);
}
CloseHandle(hIOCP);
}
该实现主要是方便刚接触IOCP的理解。当前写法还有一个逻辑漏洞,当在WaitForSingleObject执行时,hIOCP还有效,此时若有线程投递数据,则会导致内存泄漏。
在threadQueueEntry中又单独调用threadMain的原因:当调用_endthread时,代码的执行就到这里了,后面的内容不会继续被执行(或调用),所以当代码都写在threadQueueEntry中时,在退出的那一下就会导致内存泄漏,因为本地对象无法去调用析构。而将该部分代码单独拿出去做一个函数,当函数执行结束的时候会,本地对象会去调用析构函数,继而解决了内存泄漏的问题。
4,重叠结构(OVERLAPPED)
大致流程:
使用重叠结构要将初始化稍稍修改一下,
bool Init()
{
//normal
WSADATA data;
if( WSAStartup(MAKEWORD(1,1),&data)!=0 )
return false;
return true;
//unnormal,use overlapped
WSADATA data;
if( WSAStartup(MAKEWORD(2,0),&data)!=0 )
return false;
return true;
}
class COverLapped
{
public:
//必须把它放在最前面
OVERLAPPED m_overlapped;
DWORD m_opr;//操作值 或者命令
char m_buffer[4096];
COverLapped()
{
m_opr=0;
memset(&m_overlapped,0,sizeof(m_overlapped));
memset(&m_buffer,0,sizeof(m_buffer));
}
}
enum
{
opr_Accept=1,
opr_Send=2,
opr_Recv=3,
//other opr value...
}
void testOverLapped()
{
//正常用法
SOCKET sock_normal=socket(AF_INET,SOCK_STREAM,0);
//使用重叠结构的用法
SOCKET sock_unnoemal=WSASocket(AF_INET,SOCK_STREAM,0,NULL,0,WSA_FLAG_OVERLAPPED);
if(sock_unnormal==INVALID_SOCKET)
return;
HANDLE hIOCP=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,sock_unnormal,4);
//绑定IOCP与套接字
CreateIoCompletionPort((HANDLE)sock_unnormal,hIOCP,0,0);
SOCKET client=WSASocket(AF_INET,SOCK_STREAM,0,NULL,0,WSA_FLAG_OVERLAPPED);
sockaddr_in addr;
addr.sin_family=PF_INET;
addr.sin_addr.s_addr=inet_addr("0.0.0.0");
addr.sin_port=htons(9527);
bind(sock_unnormal,(sockaddr*)addr,sizeof(addr));
listen(sock_unnormal,5);
COverLapped overlapped;
overlapped.m_opr=opr_Accept;
memset(&overlapped,0,sizeof(OVERLAPPED));
DWORD received=0;
if(!AcceptEx(sock_unnormal,client,overlapped.m_buffer,0,sizeof(sockaddr_in)+16,sizeof(sockaddr_in)+16,&received,&overlapped.m_overlapped))
return;
//todo:开启一个线程
//代表一个线程
while(true)
{
DWORD dwTransferred=0;
DWORD CompletionKey=0;
LPOVERLAPPED pOverlapped=NULL;
if(GetQueuedCompletionStatus(hIOCP,&dwTransferred,&CompletionKey,&pOverlapped,INFINITY))
{
COverLapped* ptrOL = CONTAINING_RECORD(pOverlapped,COverLapped,m_overlapped);
switch(ptrOL->m_opr)
{
case opr_Accept:
//todo:处理accept
case opr_Send:
//todo:处理send
case opr_Recv:
//todo:处理recv
}
}
}
}
5,线程类和线程池类的设计与实现
为什么要设计线程池?代码中所提到的线程,其主要任务就是不断的去GetQueuedCompletionStatus,而IOCP是可以达到上万的并发量的,如果每一个请求都在该线程里处理,就已经失去了使用IOCP的意义。所以正确的应该是,当线程Get到一个状态之后,就立刻将工作分给一个新的线程,从而继续执行下一次的Get,至于被分配到工作的线程,如何处理业务,需要花多久处理业务,这并不是当前这个主线程需要去关注的。
class ThreadFuncBase{};
typedef int (ThreadFuncBase::* FuncType)();
class ThreadWorker
{
public:
ThreadWorker():thiz(NULL),func(NULL) {}
ThreadWorker(ThreadFuncBase* obj,FuncType func);
ThreadWorker(const ThreadWorker& worker);
ThreadWorker& operator=(const ThreadWorker& worker);
int operator()()
{
return (isValid()?(thiz->*func)():-1);
}
bool isValid()
{
return (thiz!=NULL)&&(func!=NULL);
}
private:
ThreadFuncBase* thiz;//ThreadFuncBase成员对象的指针
FuncType func;//ThreadFuncBase成员函数的指针
}
class MyThread
{
public:
MyThread() { m_hThread=NULL; }
~MyThread() { Stop(); }
//启动线程 true:启动成功 false:启动失败
bool Start();
//是否有效 true:有效 false:线程异常或已终止
bool isValid()
{
//如果句柄为空会返回错误;如果句柄已结束会返回WAIT_ABANDONED
return WaitForSingleObject(m_hThread,0)==WAIT_TIMEOUT;
}
//关闭线程
bool Stop();
//更新工作
void updateWorker(const ::ThreadWorker& worker=::ThreadWorker())
{
m_worker.store(worker);
}
//是否空闲 true:空闲 可以分配工作 false:非空闲 已经被分配了工作
bool isIdle()
{
return !m_worker.load().isValid();
}
private:
void threadWorker()
{
while(m_bStatus)
{
::ThreadWorker worker=m_worker.load();
if(worker.isValid())
{
int iRet=worker();
if(iRet!=0)
//打印警告日志
if(iRet<0)//出现问题时 就设置一个无效的
m_worker.store(::ThreadWorker());
}
else
Sleep(1);
}
}
static void threadEntry(void* arg)
{
MyThread* thiz=(MyThread*)arg;
if(thiz)
thiz->threadWorker();
_endthread();
}
private:
HANDLE m_hThread;
bool m_bStatus;//false:线程将要关闭,true:线程正在运行
std::atomic<::ThreadWorker> m_worker;
}
void MyThreadPool
{
public:
MyThreadPool();
MyThreadPool(size_t size) { m_threads.resize(size); }
~MyThreadPool();
bool Invoke();
void Stop();
//分配工作 返回值为将工作分给了第几个线程,若所有线程都忙则返回-1
int dispatchWorker(const ThreadWorker& worker)
{
m_lock.lock();
int index=-1;
for(size_t i=0;i<m_threads.size(),++i)
{
if(m_threads[i].isIdle())
{
m_threads[i].updateWorker(worker);
index=i;
break;
}
}
m_lock.unlock();
return index;
}
private:
std::vector<MyThread> m_threads;
std::mutex m_lock;
}
单独封装一个ThreadWoker类的意义是,在MyThread中threadWoreker这个线程函数与要执行的内容分离,由用户继承ThreadFuncBase类,然后在创建一个ThreadWoker对象,通过MyThread类的updateWorker拿到要执行的内容就可以开始工作。这样做的好处是,当某线程执行完任务后,无需再去关闭该线程,因为当再次分配工作时还需要再创建线程,而创建线程是要耗费时间的,其执行结束后将其置空,并依然保留它在while中,当updateWorker拿到工作后,可以快速的去执行。
6,UDP穿透
a.原理
原理其实并没有很复杂,UDP的穿透利用了公网在公共服务器的IP固定的特性,以及UDP协议的可主动发送请求且无需应答的特性。
在这样的条件下,就可以利用UDP建立与公共服务器的连接,在通过公共服务器返回IP和端口来建立与另一客户端的连接。
在此过程中,公共服务器是必须存在的,并且与公共服务器的连接不可中断。
b.网络环境
普通PC机器连接到互联网时,都需要先经过运营商(拨号)连接到互联网的域名服务器,由域名服务器转发请求致实机服务器,进而实现内外网连接。
在内网(局域网)中,同一时间段内,无论局域网有多少联网设备,对于外网而言都是同一个临时IP;同一局域网内的设备连接外网时,IP是相同的,端口是不同的;同一局域网内的联网设备都是共用局域网内同一个临时的IP。
c.问题
基于网络环境情况,会面临以下问题:
①因为局域网IP是临时的,即会发生变化,因此其他机器想要通过IP主动连接到PC端就变得很困难;
②猫和路由器默认是不允许外网IP主动连接内网的;
③即使猫或路由器可以将IP发到公网上,使得目标机器可以连接到你,那么同样其他机器也可以连接到你,由此会引发数据泄露等安全问题。
d.协议的分析
基于网络环境引出的问题,在对通信协议分析后可以确定,需要使用UDP协议,因为它近乎完美的解决了上述问题。
e.解决方案
①临时IP的问题:需要在公网中有一个公共服务器,通过PC向服务器发起UDP请求,并从服务器获取到应答包后建立连接。在连接之后到结束前,PC端的IP和端口不会改变。
②猫和路由器的防火墙:在PC主动发起连接请求时,防火墙其实是会留一个通道来接收服务器的应答包,以建立连接的。但要注意,必须是由PC主动发起请求且收到应答包后才可以确认通道已打开。
③安全性问题:因为防火墙的机制,外网是无法主动向PC发起请求的,防火墙会将这些请求拦截在外。
④主端与从端的连接:由于主端与从端都以与公共服务器建立连接,所以公共服务器就同时拥有了主端与从端的IP和端口。只要服务器将IP和端口分别发送给对方,这样双方就可以直接建立连接。但即使是在建立连接后,双方也不可与服务器中断连接,因为①中已说明,一旦与服务器的连接断开,局域网内的IP和端口就会发生变化。
void udp_server()
{
SOCKET sock=socket(PF_INET,SOCK_DGRAM,0);
if(sock==INVALID_SOCKET)
return;
sockaddr_in server,client;
memset(server,0,sizeof(server));
memset(client,0,sizeof(client));
server.sin_family=AF_INET;
server.sin_port=htons(20000);
server.sin_addr.s_addr=inet_addr("127.0.0.1");
}
void udp_client(bool ishost)
{
Sleep(2000);
sockaddr_in server, client;
int len = sizeof(client);
server.sin_family = AF_INET;
server.sin_port = htons(20000); //udp的端口最好取在20000-40000之间
server.sin_addr.s_addr = inet_addr("127.0.0.1");
SOCKET sock = socket(PF_INET, SOCK_DGRAM, 0);
if (sock == INVALID_SOCKET)
return;
if(ishost) //主客户端代码
{
EBuffer msg = "Hello World!\n";
int ret = sendto(sock, msg.c_str(), msg.size(), 0, (sockaddr*)&server, sizeof(server));
if (ret > 0)
{
msg.resize(1024);
memset((char*)msg.c_str(), 0, msg.size());
//收服务器发来的数据
ret = recvfrom(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)&client, &len);
if (ret > 0)
//打印获取到的数据
//收另一个客户端发来的数据
ret = recvfrom(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)&client, &len);
if (ret > 0)
//打印获取到的数据
}
}
else //从客户端代码
{
std::string msg = "Hello World!\n";
int ret = sendto(sock, msg.c_str(), msg.size(), 0, (sockaddr*)&server, sizeof(server));
if (ret > 0)
{
msg.resize(1024);
memset((char*)msg.c_str(), 0, msg.size());
ret = recvfrom(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)&client, &len);
if (ret > 0)
{
sockaddr_in addr;
memcpy(&addr, msg.c_str(), sizeof(addr));
sockaddr_in* paddr = (sockaddr_in*)&addr;
msg = "Hello,I am client!\n";
ret = sendto(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)paddr, sizeof(sockaddr_in));
}
}
}
closesocket(sock);
}
int main(int argc,char* argv[])
{
InitSock();//socket初始化
if (argc == 1)
{
char wstrDir[MAX_PATH];
GetCurrentDirectoryA(MAX_PATH, wstrDir); //取得当前进程的路径
STARTUPINFOA si;
PROCESS_INFORMATION pi;
memset(&si, 0, sizeof(si));
memset(&pi, 0, sizeof(pi));
std::string strCmd = argv[0];//strCmd为该程序的路径
strCmd += " 1"; //路径后面加个数字1做区分,用于开启一个新进程
BOOL bRet = CreateProcessA(NULL, (LPSTR)strCmd.c_str(), NULL, NULL, FALSE, 0, NULL, wstrDir, &si, &pi);//创建一个新进程
if (bRet)
{
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
TRACE("进程ID : %d\n", pi.dwProcessId);
TRACE("线程ID : %d\n", pi.dwThreadId);
strCmd += " 2"; //然后在刚才的基础上 在路径名字后面再加个数字2,用于再次开启一个新进程
bRet = CreateProcessA(NULL, (LPSTR)strCmd.c_str(), NULL, NULL, FALSE, 0, NULL, wstrDir, &si, &pi);//创建一个新进程
if (bRet) //若创建成功
{
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
TRACE("进程ID : %d\n", pi.dwProcessId);
TRACE("线程ID : %d\n", pi.dwThreadId);
udp_server();// 服务器代码,开始启动服务器服务端
}
}
}
else if (argc == 2)//主客户端代码
udp_client();
else
udp_client(false);//从客户端代码
ClearSock();//清理socket
}
7,管理员权限和开机启动
a.管理员权限:
vs在编译时可以进行选择:右键项目→属性→链接器→清单文件→UAC执行级别→选择Administrator,选择好之后应用并确定,再重新生成一下该项目,编译出的exe文件便会在右下角多出一个盾牌的图标,即管理员权限。
b.开机启动
b-1:注册表修改
HKEY_LOCAL_MACHINE→SOFTWARE→Microsoft→Windows→CurrentVersion→Run,这个路径下记录的都是一些开机启动的程序。
注意程序的路径必须要指向系统目录,一般为system32,即要把欲开机启动的程序复制到system32下面去。但在Windows下使用mklink创建一个软链接会更好。
软链接与快捷方式的区别:
首先,文件格式不一样,快捷方式后缀为.lnk;
其次,使用type出的值也不一样,快捷方式是L开头的,软链接的文件则是MZ开头的,其实软链接的文件就是原始的exe文件,是等效的;
第三,文件大小不一样;
bool WriteRegisterTable(const CString& strPath) //注册表方式完成开机启动需要的函数
{
CString strSubKey = _T("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run");
TCHAR sPath[MAX_PATH] = _T("");
GetModuleFileName(NULL, sPath, MAX_PATH);
BOOL ret = CopyFile(sPath, strPath, FALSE);
if (ret == FALSE)
{
MessageBox(NULL, _T("文件复制失败,是否权限不足?\n"), _T("错误"), MB_ICONERROR | MB_TOPMOST);
return false;
}
HKEY hKey = NULL;
ret = RegOpenKeyEx(HKEY_LOCAL_MACHINE, strSubKey, 0, KEY_ALL_ACCESS | KEY_WOW64_64KEY, &hKey);
if (ret != ERROR_SUCCESS)
{
RegCloseKey(hKey);
MessageBox(NULL, _T("设置开机自动启动失败,是否权限不足?\n程序启动失败!"), _T("错误"), MB_ICONERROR | MB_TOPMOST);
return false;
}
ret = RegSetValueEx(hKey, _T("RemoteCtrl"), 0, REG_SZ, (BYTE*)(LPCTSTR)strPath, strPath.GetLength() * sizeof(TCHAR));
if (ret != ERROR_SUCCESS)
{
RegCloseKey(hKey);
MessageBox(NULL, _T("设置开机自动启动失败,是否权限不足?\n程序启动失败!"), _T("错误"), MB_ICONERROR | MB_TOPMOST);
return false;
}
RegCloseKey(hKey);
return true;
}
b-2:开机启动的第二种方法
将程序复制到启动的文件夹中,相对第一种修改注册表的方法要简单一些,但这种方法需要等到机器完全启动以后才去启动程序,所以时间上要慢于修改注册表的方式。
BOOL WriteStartupDir(const CString& strPath) //直接复制文件到启动文件夹下完成开机启动所需要的函数
{
TCHAR sPath[MAX_PATH] = _T("");
GetModuleFileName(NULL, sPath, MAX_PATH);
return CopyFile(sPath, strPath, FALSE);
}