在虚拟机、远程控制、或者诸如云电脑之类的应用中,我们经常能够看到虚拟设备的身影。对于初学者来说,从0到1实现一个虚拟设备或许非常困难,但从0.99到1改造一个虚拟设备就简单多了。本文根据微软提供的UMDF版本HID minidriver的示例代码,稍加改造,将其变成一个虚拟HID键盘设备。

HID minidriver sample

微软提供的这个示例代码的仓库地址是:https://github.com/microsoft/Windows-driver-samples/tree/main/hid/vhidmini2

这个示例代码实现了一个hid驱动程序和一个客户端,演示了客户端如何与hid驱动程序通信。我想将其改造成一个HID键盘设备,通过客户端与其通信,控制其按键。

第一步 修改HID报告描述符

原代码的HID报告描述符包含了一个Vender Usage的顶级集合,集合里定义了一个HID报告,用于客户端和驱动程序的通信。这个集合可以保留下来继续用来通信。

要实现HID键盘的功能,需要一个Keyboard的顶级集合,下面是一个常见的HID键盘的报告描述符,我把LED相关的输出报告删除了,仅保留了按键相关的输入报告。报告描述符直接添加到原来的报告描述符的尾部即可。

HID_REPORT_DESCRIPTOR       G_DefaultReportDescriptor[] = {
//......
//原本的报告描述符
//...... 0x05, 0x01, // USAGE_PAGE (Generic Desktop)
0x09, 0x06, // USAGE (Keyboard)
0xA1, 0x01, // COLLECTION (Application) 0x85, 0x02, // REPORT_ID (2) 0x05, 0x07, // USAGE_PAGE (Keyboard)
0x19, 0xE0, // USAGE_MINIMUM (Left Control)
0x29, 0xE7, // USAGE_MAXIMUM (Right GUI)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x01, // LOGICAL_MAXIMUM (1)
0x75, 0x01, // REPORT_SIZE (1)
0x95, 0x08, // REPORT_COUNT (8)
0x81, 0x02, // INPUT (Data, Var, Abs) 0x95, 0x01, // REPORT_COUNT (1)
0x75, 0x08, // REPORT_SIZE (8)
0x81, 0x03, // INPUT (Const, Var, Abs) 0x95, 0x06, // REPORT_COUNT (6)
0x75, 0x08, // REPORT_SIZE (8)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x65, // LOGICAL_MAXIMUM (101)
0x05, 0x07, // USAGE_PAGE (Keyboard)
0x19, 0x00, // USAGE_MINIMUM (No Event)
0x29, 0x65, // USAGE_MAXIMUM (Keyboard Application)
0x81, 0x00, // INPUT (Data, Ary, Abs) 0xC0, // END_COLLECTION
};

添加了报告描述符后,安装一下看下效果,安装的方法是,在管理员权限的cmd中,执行以下命令

devcon.exe install "VhidminiUm.inf" root\VhidminiUm

或者

devgen.exe /add /bus root /hardwareid root\VhidminiUm
pnputil /add-driver "VhidminiUm.inf" /install

以上两种方式都是创建一个硬件ID为root\VhidminiUm的设备,然后将我们编译的驱动安装到设备上。devcon和devgen可以在WDK的工具目录下找到,pnputil是全局安装的内置工具。因为这是一个UMDF驱动程序,所以不要进入测试模式也可以安装。

安装完成后可以在设备管理器看到一个HID minidriver设备,在它下面有一个键盘设备和一个Vendor-defined设备,对应HID报告描述符中的两个顶级集合。

第二步 修改IOCTL_HID_READ_REPORT请求的处理函数

现在,这个键盘设备还不会上报任何按键。要让键盘设备上报按键,需要在IOCTL_HID_READ_REPORT请求的处理函数中提供正确的HID报告。

根据HID报告描述符,HID报告包含报告ID在内应该有9个字节

byte(s) description
0 报告ID(2)
1 每个bit代表一个控制键,1表示按下
2 保留
3~8 每个字节表示一个按下的按键

例如,下面这个HID报告表示'A'键处于按下状态

[0x02, 0, 0, 0x04, 0, 0, 0, 0, 0]

在接收到IOCTL_HID_READ_REPORT请求的时候,把上面的数据复制到输出缓冲区,就可以模拟'A'键按下的状态。在后续的IOCTL_HID_READ_REPORT请求中,我们再将下面的数据复制到输出缓冲区,就可以模拟'A'键抬起的状态

[0x02, 0, 0, 0, 0, 0, 0, 0, 0]

在原始的示例代码中,IOCTL_HID_READ_REPORT请求没有直接在处理函数中进行处理,而是传递给另一个请求队列,然后由一个定时器去轮询,当这个队列中有请求时就处理这个请求。这模拟的是真实设备中:

  1. 请求达到时设备还没有准备好数据,请求处理未完成状态
  2. 一定时间后(也就是定时器到期后),数据准备好了,填充数据,完成请求

改造后的虚拟HID键盘设备中,这个逻辑可以保留,也可以删除。

  1. 如果保留的话,将定时器的处理函数EvtTimerFunc修改为
void
EvtTimerFunc(
_In_ WDFTIMER Timer
)
{
NTSTATUS status;
WDFQUEUE queue;
PMANUAL_QUEUE_CONTEXT queueContext;
WDFREQUEST request;
HIDMINI_KBD_INPUT_REPORT readReport; KdPrint(("EvtTimerFunc\n")); queue = (WDFQUEUE)WdfTimerGetParentObject(Timer);
queueContext = GetManualQueueContext(queue); //
// see if we have a request in manual queue
//
status = WdfIoQueueRetrieveNextRequest(
queueContext->Queue,
&request); if (NT_SUCCESS(status)) { memset(&readReport, 0, sizeof(readReport));
readReport.ReportId = 2;
readReport.Data[2] = 0x04; //'A'键 status = RequestCopyFromBuffer(request,
&readReport,
sizeof(readReport)); WdfRequestComplete(request, status);
}
}

其中,_HIDMINI_KBD_INPUT_REPORT是在common.h中新增的结构体,用来表示键盘设备的HID输入报告,代码如下

typedef struct _HIDMINI_KBD_INPUT_REPORT {

    UCHAR ReportId;

    UCHAR Data[8];

} HIDMINI_KBD_INPUT_REPORT, * PHIDMINI_KBD_INPUT_REPORT;
  1. 如果不需要这个定时器逻辑的话,就将ReadReport函数改为
NTSTATUS
ReadReport(
_In_ PQUEUE_CONTEXT QueueContext,
_In_ WDFREQUEST Request,
_Always_(_Out_)
BOOLEAN* CompleteRequest
)
{
NTSTATUS status;
HIDMINI_KBD_INPUT_REPORT readReport; UNREFERENCED_PARAMETER(CompleteRequest);
KdPrint(("ReadReport\n")); memset(&readReport, 0, sizeof(readReport));
readReport.ReportId = 2;
readReport.Data[2] = 0x4; status = RequestCopyFromBuffer(Request,
&readReport,
sizeof(readReport)); return status;
}

修改完代码后,再次安装,就会发现,彷佛有一个键盘一直在长按'A'键。

第三步 使用客户端控制按键的状态

现在这个驱动一旦安装之后,就会马上按下'A'键,并且不会抬起,因此需要一个客户端来控制按哪个键,什么时候按下,什么时候抬起。

原来的代码定义了一个vendor defined设备,这个设备的输出报告中可以传递一个字节的数据,那么客户端就可以利用这个字节将需要按下的HID键码发送给设备。如果要控制多个按键的话,就需要修改这个输出报告,本文先讨论单个按键的情况。

客户端通过IOCTL_HID_WRITE_REPORT请求将输出报告发送到设备,输出报告携带了一个字节的数据,在该请求的处理函数WriteReport中,这个字节的数据被保存到了设备上下文的自定义字段中,代码如下

QueueContext->DeviceContext->DeviceData = outputReport->Data;

这是原始的代码,不需要修改,需要修改的是第二步中IOCTL_HID_READ_REPORT请求的处理函数,将固定填充'A'键键码的代码,改成用DeviceData来填充

readReport.Data[2] = QueueContext->DeviceContext->DeviceData;

这样一来,客户端向设备发送哪个键的HID键码,设备就会模拟哪个键的按下,客户端如果发送0,设备就会模拟按键抬起。

下面是一个用WPF实现的客户端的代码,这个客户端实现的功能是

  1. 当按下Alt+字母键的组合键时,让虚拟设备模拟该字母键长按
  2. 当按下Alt+F1的组合键时,让虚拟设备模拟按键抬起
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using HidSharp; namespace VirtualHidKbdClient
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private const int AltKeyEventId = 9000; private bool PressedKey = false;
private HidStream _stream; [DllImport("user32.dll")]
public static extern uint MapVirtualKey(uint uCode, uint type); [DllImport("user32.dll")]
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); [DllImport("user32.dll")]
public static extern bool UnregisterHotKey(IntPtr hWnd, int id); public MainWindow()
{
InitializeComponent();
SourceInitialized += MainWindow_SourceInitialized;
} private void MainWindow_SourceInitialized(object? sender, EventArgs e)
{
var handle = new WindowInteropHelper(this).Handle;
var source = HwndSource.FromHwnd(handle);
source?.AddHook(HwndHook); //注册Alt+F1和Alt+字母键的全局快捷键
for (int i = (int)Key.A; i <= (int)Key.Z; i++)
{
RegisterHotKey(handle, AltKeyEventId, (uint)ModifierKeys.Alt, (uint)KeyInterop.VirtualKeyFromKey((Key)i));
}
RegisterHotKey(handle, AltKeyEventId, (uint)ModifierKeys.Alt, (uint)KeyInterop.VirtualKeyFromKey(Key.F1)); //找到虚拟设备下面的vendor defined设备
var devices = DeviceList.Local.GetHidDevices(0xDEED, 0xFEED);
foreach (var d in devices)
{
Console.WriteLine(d.DevicePath);
} var device = devices.FirstOrDefault(d => !d.DevicePath.EndsWith("kbd")); _stream = device.Open();
} private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
const int wmHotkey = 0x0312; switch (msg)
{
case wmHotkey:
switch (wParam.ToInt32())
{
case AltKeyEventId:
var lp = lParam.ToInt32();
var virtualKey = lp >> 16;
var key = KeyInterop.KeyFromVirtualKey(virtualKey);
//虚拟键码到HID键码的转换,字母键是连续的,所以可以简单写成这样
var hidKey = virtualKey - 61;
var charKey = (char)MapVirtualKey((uint)virtualKey, 2); if (key == Key.F1 && PressedKey)
{
_stream.Write([1, 0]);
MessageBox.Show($"cancel long press");
PressedKey = false;
} else if(key != Key.F1 && !PressedKey)
{
PressedKey = true;
_stream.Write([1, (byte)hidKey]);
MessageBox.Show($"long press {charKey}");
} break;
}
break;
}
return IntPtr.Zero;
} protected override void OnClosing(CancelEventArgs e)
{
base.OnClosing(e); var handle = new WindowInteropHelper(this).Handle;
//关闭窗口时取消注册
UnregisterHotKey(handle, AltKeyEventId); //关闭窗口时控制虚拟设备按键抬起
_stream.Write([1, 0]);
_stream.Close();
}
}
}

最后的效果如下

从0.99到1实现一个Windows上的虚拟hid键盘设备的更多相关文章

  1. 写一个Windows上的守护进程(7)捕获异常并生成dump

    写一个Windows上的守护进程(7)捕获异常并生成dump 谁都不能保证自己的代码不出bug.一旦出了bug,最好是崩溃掉,这样很快就能被发现,若是不崩溃,只是业务处理错了,就麻烦了,可能很长时间之 ...

  2. 写一个Windows上的守护进程(4)日志其余

    写一个Windows上的守护进程(4)日志其余 这次把和日志相关的其他东西一并说了. 一.vaformat C++日志接口通常有两种形式:流输入形式,printf形式. 我采用printf形式,因为流 ...

  3. 写一个Windows上的守护进程(1)开篇

    写一个Windows上的守护进程(1)开篇 最近由于工作需要,要写一个守护进程,主要就是要在被守护进程挂了的时候再把它启起来.说起来这个功能是比较简单的,但是我前一阵子写了好多现在回头看起来比较糟糕的 ...

  4. 写一个Windows上的守护进程(8)获取进程路径

    写一个Windows上的守护进程(8)获取进程路径 要想守护某个进程,就先得知道这个进程在不在.我们假设要守护的进程只会存在一个实例(这也是绝大部分情形). 我是遍历系统上的所有进程,然后判断他们的路 ...

  5. 写一个Windows上的守护进程(6)Windows服务

    写一个Windows上的守护进程(6)Windows服务 守护进程因为要开机启动,还要高权限,所以我就把它做成Windows服务了. 关于Windows服务的官方文档,大家可以看https://msd ...

  6. 写一个Windows上的守护进程(5)文件系统重定向

    写一个Windows上的守护进程(5)文件系统重定向 在Windows上经常操作文件或注册表的同学可能知道,有"文件系统/注册表重定向"这么一回事.大致来说就是32位程序在64位的 ...

  7. 写一个Windows上的守护进程(3)句柄的管理

    写一个Windows上的守护进程(3)句柄的管理 在Windows中编程,跟HANDLE打交道是家常便饭.为了防止忘记CloseHandle,我都是使用do-while-false手法: void f ...

  8. 写一个Windows上的守护进程(2)单例

    写一个Windows上的守护进程(2)单例 上一篇的日志类的实现里有个这: class Singleton<CLoggerImpl> 看名字便知其意--单例.这是一个单例模板类. 一个进程 ...

  9. 在Windows 上的 Python

    在 Windows 上, 安装 Python 有两种选择. ActiveState 制作了一个 Windows 上的 Python 安装程序称为 ActivePython, 它包含了一个完整的 Pyt ...

  10. 新公司,环境搭建,windows上的坑

    1 在windows上安装nodejs环境,node -v 后可以看到版本号 2 在windows上安装nvm管理node的版本,可以对node版本自由切换,使用5.3.0版本的node 3 在win ...

随机推荐

  1. 搭建邮局-1.安装hMailserver和配置邮局

    目录 搭建邮局-1.安装hMailserver和配置邮局 https://www.cnblogs.com/daen/p/16040202.html 搭建邮局-2.添加域名和域名解析 https://w ...

  2. mybatis下的ResultMap配置一对一以及一对多

    一对一: 在数据库里面有这样的一个主外键关系的表: 我需要查找身份证的号码就要知道这个人的姓名(通过一个SQL语句要查到两个实体类里面的信息): SELECT c.*,p.* FROM idcard ...

  3. python 获取mac地址

    python 获取mac地址 方法一:使用socket库 使用了socket库中的ioctl函数和fcntl模块来获取MAC地址 import socket import fcntl import s ...

  4. vert.x 初识

    中文文档:https://vertx-china.github.io/ 官方文档:https://vertx.io/ 客户端仓库模块:https://github.com/vert-x3/vertx- ...

  5. 支持5G WIFI的串口服务器

    WiFi 串口服务器(RS485 ⇌WiFi),主要实现 RS485 数据通过 WiFi 实现设备联网数据交互设备,市场主流工作在频段为2.4G和5.8G ,2.4G WiFi信号的频段处于2.400 ...

  6. Excel中两表数据核对方法

    日常工作中经常会需要对比数据,查找差异.重复值等.本篇整理汇总各种Excel数据对比方法,让大家能在不同情况下都能快速完成数据的对比. 单列/多列.按位置对应比较数据 快捷键对比Ctrl+/ 如下图所 ...

  7. jenkins部署完微信推送

    前言 我需要一款使用简单方便: 支持微信.企业微信.短信.邮件等通知: 消息多样化,支持markdown等格式消息: 支持消息存储: 免费使用: 于是我找到了一款插件.目前还是免费pushplus. ...

  8. 前端开发系列112-工程化篇之Yeoman脚手架工具核心机制

    这篇文章我们将接着探讨Yeoman这个脚手架工具内部的核心机制,主要包括以下内容 ❏ Yeoman脚手架工具的价值讨论 ❏ generator[生成器]的内部结构 ❏ generator[生成器]的项 ...

  9. 前端开发系列047-基础篇之Vue的安装和初始化

    本文介绍Vue项目的安装和初始化等内容. 一.Vue框架安装 我们在项目中使用vue.js框架的方式主要有以下几种情况 > ① 已有项目可以选择下载并导入独立版本 > ② 使用 CDN 方 ...

  10. 前端开发系列042-基础篇之TypeScript语言特性(二)

    这篇文章中我们将继续在语言特性方面展开探讨,主要介绍了TypeScript中流程控制结构.类以及接口等方面的内容,需要说明的是这篇文章中并不会就相关特性的细节深入展开,你能得到的将只有对它们进行的浅尝 ...