1.前言
这个键盘记录器同样是我以前的兴趣之作,研究了良久才完成的,现在拿来作为大家的一个学习Qt的项目。
同时该项目里面不仅仅含有Qt知识,还含有很多其它WIndows开发的神奇知识点,也当是给大家开开眼界了。
它的作用就是,可以记录你每天电脑敲击键盘的数量,并显示出你敲击数量最多的前十个键,成品如下:
是不是感觉还不错呢?
所以接下来,我就带大家一步一步将这个软件给做出来,当然建议你跟我一样,使用VS开发,因为这个解决方案里面将至少包含两个项目,用Qtcreator
的话,确实不方便。
之所以说是至少,因为我本来还想给它写个服务器,弄个多人排行榜之类的东西。
也就是说,这个软件只是一个单机版的,单纯就是用来记录我们每天敲击键盘数量的软件,每天晚上看一看自己一天的成果,是不是也很有成就感呢!
2.理论知识
首先我们看这个项目的核心功能,是统计用户敲击键盘按键的次数。
为了实现这一功能,我们就需要用到Windows编程中的钩子概念。
在WIndows窗口编程,乃至MFC编程中,相信大家最记忆犹新的就是消息循环
,我们的窗口应用程序启动后,是如何接收到用户的鼠标、键盘消息的呢?
这就是通过我们的Windows系统,将我们移动鼠标,点击键盘的消息收集起来,然后发送给你应用程序的。
最常见的就是,我们窗口上开了很多个应用程序,比如微信、QQ,那么我们的按键消息应该发送给哪个应用程序呢?
经验来看,一般是最前面的那一个窗口,我们称当前接收系统发送消息的窗口,为焦点窗口。
一般来说,即使你开了很多程序,系统也只会将我们的按键消息发送给其中一个焦点窗口。
而所谓钩子,就是在系统发送给窗口的中途,设下了一个关卡,即系统无论发送给哪个窗口,都必须经过这个关卡。
现在你听着可能没啥感觉,举个例子,当你输入QQ账号密码时,你按下的每一个按键都将作为消息,发送给QQ,这是正常情况下。
然后当我们设下了一个钩子时,系统发送的消息就必须先经过我们这里,然后我们可以任意处理该消息,早期盗号的木马病毒,很多就是用的这个技术,你的QQ密码还没到QQ程序,在半路就被拦截下来了。
而想要做一个这样的钩子,就必须使用动态库,为什么呢?
这就涉及到了进程相关的知识,不懂的可以先看看本站的另一篇文章:进程与线程。
我们平时说的内存8G,16G,32G,一般指电脑的运行内存,即电脑同时可以容纳多大的程序运行,比如我目前的电脑就是16G的:
而我们平时在编写程序中,所说的申请内存,其实就是申请的这个内存,无论临时变量(栈中)还是全局变量,new变量(堆中),都是它,这些名称只是我们在特定情况下,给某些内存人为的赋予了不同的涵义而已。
一般32位应用程序最大为4G内存(2的32次方),而64位应用程序最大是很多T的内存(2的64次方)。
那为什么我们电脑运行这么多程序,电脑占用内存还是这么低呢?
这就涉及到了一个叫做虚拟内存的概念,可以查看本站的另一篇文章:逆向基础。
为什么需要了解这些看起来枯燥且乏味的理论知识呢?就是因为我们这里要用到这个。
动态库的作用就是,当它被加载到内存中时,可以被多个exe程序,即进程,加载到自己的地址空间中,虽然一般它在内存中只有一份(只要你不改变动态库中的变量值),但它却可以同时被所有进程使用。
这就是使用动态库的优点:节约空间。
由于进程之间的互不关联性,所以我们不能在我们的程序中,去统计系统发给别的应用程序的消息。
而这里通过动态库的这一特性:可以加载到所有exe程序(进程)中,我们就可以完成这样的任务!
我们将钩子写在动态库中,只要我们的exe程序启用并加载这个动态库,那么系统中其它所有的程序接收的系统信息,就都必须经由这个动态库。
因为一旦启用钩子,这个动态库就会被系统强制加载到所有接收消息的进程中。
这样我们就可以统计按键消息了!
说了这么多,可能你依旧还是云里雾里的,但没关系,我们边写代码边理解!
3.建立项目
还是创建Qt组件应用程序:
这里解决方案与项目直接同名即可,注意不要勾选将这两者放在同一个目录:
前面的选项都为默认即可,到下图的步骤时,选择QMainWindow比较方便,因为我们需要菜单等正常窗口所需要的东西,它里面就有现成的:
然后我还勾选了预编译头文件,因为如果你Qt用的比较多的话,就会发现它编译特别慢,每次编译运行都要等好久,而MFC就会比它快很多。
难道是MFC设计的比Qt好吗?那可不一定,最主要的原因就是MFC是自动开启了预编译的,而Qt我们一直没有使用过预编译,所以编译速度会相差很大。
所以这里我就带大家使用一下预编译,然后上图点击Finish
即可。
这样我们的基本项目就建立好了,剩下的就不用管它了,后面我们再对它进行写代码之内的操作
然后我们还需要给这个解决方案再添加动态库项目,至于如何使用,我们后面会谈到:
选择动态链接库:
名字就叫KLEngine吧,即KeyLog Engine,键盘日志引擎:
虽然名字看起来高大上,其实就是个动态库而已,不用太过纠结,其作用我们后面马上就会谈到。
建好之后,如下图:
注意动态库这个项目,我将自动生成的那个dllMain
文件删除了,然后分别添加了一个头文件与一个源文件,KLEngine
。
这么做也没啥其它原因,纯属是为了好看,因为动态库我们知道,要想给别人用,就得把头文件交给别人。
所以这里就有了这个KLEngine.h
,目的是拿给其它项目使用,既然头文件都叫这个名字,源文件也跟它同名不是更好吗?所以就有了对应的源文件。
至于自动生成的那个文件,里面的代码我们这里用不着,留着也没用。
源文件中除了包含的头文件,没有任何代码:
这样,我们的项目就搭建好了,接着我们还需要添加一个Qt的组件,因为我们这个软件是采用的图表的形式展示数据,那些基本的Qt控件肯定是无法完成这一重任的。
经过浏览搜索,我们可以查到,Qt提供了一个额外的QChar
组件,可以用来表示图表数据。
来到Qt的安装文件夹,点击下图这个程序:
进入后,先登录,然后来到下图,选择第一个:
然后找到我们当前安装的qt版本,从额外库中找到库Qt charts
,然后进行下载安装即可:
这样我们的前置工作就做好了。
4.动态库编写
前面建好了项目,下面我们就首先把动态库写好,方便我们的主程序使用。
首先介绍一下我们主要使用的一个函数,那就是win API函数SetWindowsHookEx:
HHOOK SetWindowsHookExW(
[in] int idHook,
[in] HOOKPROC lpfn,
[in] HINSTANCE hmod,
[in] DWORD dwThreadId
);
该函数的作用就是在我们的系统里面挂一个钩子,下面来看一看它的参数:
- 第一个参数,就是要挂的钩子类型,比如这里我们要挂的键盘钩子,还有其它的比如鼠标钩子等等,更多的请参考官方文档,这里我们要填的值是
WH_KEYBOARD
- 第二个参数,为回调函数,即当系统拦截到消息给钩子后,用哪个函数进行处理?
- 第三个参数,即本DLL的模块句柄,和前面提到的窗口句柄一样,用来标识的,可以通过函数
GetModuleHandle(L"KLEngine.dll")
获取,它唯一的参数就是模块的名称,如果你的DLL项目名称与我不同,那么这里这个名字就得改为你自己的1 - 第四个参数,启动这个钩子的线程ID,这里直接填
NULL
即可。
所以总结来说,填好参数的函数为:
SetWindowsHookEx(WH_KEYBOARD,keyfun,GetModuleHandle(L"KLEngine.dll"), 0);
keyfun
为回调函数。
而对应的回调函数也有讲究,格式如下:
LRESULT CALLBACK keyfun(int code, WPARAM wParam, LPARAM lParam) {
return CallNextHookEx(hookKey,code,wParam,lParam);
}
上面是一般写法,调用了一个API函数CallNextHookEx
,然后参数就是这个回调函数的参数。
看这个函数的名字我们也能大概猜到,将接收到的内容再交给下一个钩子,如果不用这个函数的话,就会导致你的键盘会彻底失效,因为系统所有的键盘消息都发送到你这个函数中,然后你就不往后面传了。
这就会导致电脑里面所有的应用程序都接收不到键盘消息,从而导致键盘失效。
看到这里有没有什么想法呢?如果再将鼠标消息也拦截了,不就无法操作电脑了……
回到主题,这三个参数的含义是根据你设置的什么钩子决定的,在官方文档,你就应该点击下面这个回调函数:
- 第一个参数,如果为
HC_ACTION
,就说明有按键消息。 - 第二个参数,虚拟键码,即前面我们提到过的 字符’A’代表的ASCII数值,就代表了我们的按下的A键,可以点击这里查看所有的虚拟键码。
- 第三个参数,按键消息标志,有很多,可以自己去官方查看,我们这里用到的是它的第31位(从1开始,共32位),代表先前键的状态,比如你按下A键并放开,会有有两个消息,一个是按下的,一个是弹起的。
然后我们的回调函数就可以写为:
LRESULT CALLBACK keyfun(int code, WPARAM wParam, LPARAM lParam) {
if (code== HC_ACTION&&(lParam&0x40000000)) {
keyLogs.k[wParam]++;
}
return CallNextHookEx(hookKey,code,wParam,lParam);
}
意思就是,如果有键盘消息,而且上一次该键是按下状态,那么就将对应的键盘值加1,这是我的写的一个结构体,暂时不用管。
下面来看一看0x400000000
这个值怎么来的:
0100 0000 0000 0000 0000 0000 0000 0000
上方是二进制的表示方法,根据2进制与16进制的关系,4位变一位,所以16进制最高位为0100,即4,其它都是0,所以就有了这个值,通过与运算,就能判断对应的参数位上,是否也是1
然后我们再来看一看上方提到的结构体:
struct Key
{
unsigned k[0xFF];//存储按键次数
unsigned Checksum;//和校验
};
这个结构体很简单,就一个数组和一个值,这里使用的unsigned
,等价于unsigned int
,即无符号整型,说人话就是正数。
那个数组的作用就是存储所有键值按下的数量,为什么是0xFF
个呢?因为官网说的最大值就是0xFE
:
所以前面我们就可以直接根据回调函数传入的虚拟键码值进行传参。
至于另一个值,我注释写的校验和,即防止被人逆向篡改(只是说增大其篡改的难度),至于怎么用,马上会讲到的,原理很简单,就是将所有的键值按键数量相加就行了。
然后就是很重要的,申请变量环节,因为前面我们理论已经提到过,各个进程虽然位于同一电脑内存中,但却是互不干涉的。
而动态库却可以用一份内存,加载到不同的进程中,但也仅限于加载使用,一旦你涉及更改动态库中的变量,系统为了防止影响其它进程,就会单独拷贝一份出来到内存里专门供该进程使用。
这就会导致,我们根本无法统计整合数据,因为动态库在内存中不再是一份内存,各个程序的按键值都是自己加自己的,没办法求和。
所以这里我们就得禁止系统的这一行为:
#pragma data_seg("Shared")
Key keyLogs{};
HHOOK hookKey = NULL;
#pragma data_seg()
#pragma comment(linker,"/section:Shared,rws")
详细介绍可以参考我的这一篇文章:共享内存。
上方的格式基本是固定的,只需要将变量申请在两个#pragma data_seg
之间即可,注意这里面的变量一定要进行初始化!
这里除了一个Key
变量,还有一个HHOOK
,即钩子句柄,用于标识我们挂的钩子。
上面基本就是动态库的所有重要内容,其实并不是很难,可能原理有点抽象,但你可以看一看我的那篇window
共享内存的解释,应该会有所感悟。
首先是头文件:
#pragma once
struct Key
{
unsigned k[0xFF];//存储按键次数
unsigned Checksum;//和校验
};
extern "C" __declspec(dllexport) bool SetHook();
extern "C" __declspec(dllexport) bool UnSetHook();
extern "C" __declspec(dllexport) Key GetKeyNum();
extern "C" __declspec(dllexport) void Clear();
然后是源文件:
#include "pch.h"
#include"KLEngine.h"
#pragma data_seg("Shared")
Key keyLogs{};
HHOOK hookKey = NULL;
#pragma data_seg()
#pragma comment(linker,"/section:Shared,rws")
LRESULT CALLBACK keyfun(int code, WPARAM wParam, LPARAM lParam) {
if (code== HC_ACTION&&(lParam&0x40000000)) {
keyLogs.k[wParam]++;
}
return CallNextHookEx(hookKey,code,wParam,lParam);
}
bool SetHook() {
hookKey=SetWindowsHookEx(WH_KEYBOARD,keyfun,GetModuleHandle(L"KLEngine.dll"), 0);
if (hookKey == NULL) {
return false;
}
return true;
}
Key GetKeyNum(){
return keyLogs;
}
bool UnSetHook() {
if (hookKey == NULL) {
return false;
}
return UnhookWindowsHookEx(hookKey);
}
void Clear() {
memset(&keyLogs,0,sizeof(keyLogs));
}
这些函数封装了相应的操作,方便我们在程序中使用:
SetHook
:设置钩子UnSetHook
:卸载钩子GetKeyNum
:获取统计的数据Clear
:清空数据
这样,我们的动态库就写好了,代码不难,难的可能是这个概念,需要自己好好理解一下。
然后我们就可以点击生成该动态库项目(这个不会还不会吧?解决方案右键该项目,点击生成即可),将头文件以及生成的动态库与静态库复制到主项目中:
注意这里是Debug版本,这个一般需要对应,即主程序是Debug版本,那么动态库也要是Debug,release同理,不要混用。
5.主程序
由于本程序不似前面的程序,涉及的知识面相当广。
我尝试过从头开始讲,但发现那会相当繁琐,而且能学到这里,相信各位已经有很强的自学能力了以及一定的看源码能力了。
所以我打算采取直接将项目文件上传,你可以直接点击keylog进行下载。
接下来,我将直接对着项目中的源码,根据逻辑直接一步一步进行讲解。
5.1 准备动态库的使用
首先将动态库的头文件添加进来,注意这里将dllexport
改为dllimport
:
然后在预编译头文件中,添加动态库的使用:
注意这里预编译文件名字为stdafx
,不是pch
,这个名字其实可以自己在设置里面改,不过一般我们用默认的即可。
然后在项目属性中,添加Qt Charts库的使用:
注意,release
版本与debug
版本需要分别添加,即需要操作两次,并且这个项目默认用的6.2.4
版本,你可以直接在这里更改为你当前所使用的版本。
首先我们来看看如何使用这个图表库,首先在预编译头文件中添加头文件:
QtCharts
这个头文件里面就包含了所有图表类,比如常见的扇形、柱形、折现等等。
然后我们就开始从构造函数开始讲起。
5.2 构造函数
构造函数很简单,仅仅只是调用了三个函数:
这三个函数都是我写的初始化函数,用于初始化三个不同方面的数据
至于第三个,那是我们前面编写的动态库里面的函数,就不多说了,这肯定是必须的。
首先是InitFileData
函数,这个函数用于初始化本软件的相关文件数据:
首先我们尝试随便创建一个文件:
//判断是否具有足够权限
QFile file("t.dat");
bool ret = file.open(QIODevice::WriteOnly);
if (!ret) {
QMessageBox::warning(this, "错误", "无法读写数据,请尝试以管理员权限启动!\n");
Exit();
}
file.close();
remove("t.dat");
//判断数据目录是否存在
这是为了判断当前程序是否有对当前目录写文件的权限,如果没有,那就直接退出,调用Exit
函数,这个函数也为自己写的,很简单:
void KeyLog::Exit()
{
this->close();
tray->hide();
UnSetHook();
QApplication::exit(0);
ExitProcess(0);
}
先是关闭当前程序,然后关闭托盘tray
,这个在后面的UI初始化会讲到,然后UnSetHook
删除钩子,最后 QApplication::exit(0);
退出程序,以及ExitProcess
退出进程,格式很固定。
接着就是正式读取或写入数据文件的代码了:
QDir dir(".\\Data\\");
ret = dir.exists();
if (!ret) {
dir.mkdir(".\\Data\\");
}
//判断当天文件数据是否存在
date = "Data\\" + QDate::currentDate().toString("yyyyMMdd");
file.setFileName(date);
ret = file.exists();
if (ret) {
ret = ReadFromFile();
if (!ret) {
bool ret = WriteToFile(true);
if (!ret) QMessageBox::warning(this, "错误", "写入数据失败");
}
}
else {
bool ret = WriteToFile(true);