动态

详情 返回 返回

從0.99到1實現一個Windows上的虛擬hid鍵盤設備 - 动态 详情

在虛擬機、遠程控制、或者諸如雲電腦之類的應用中,我們經常能夠看到虛擬設備的身影。對於初學者來説,從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報告描述符中的兩個頂級集合。

img

第二步 修改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();
        }
    }
}

最後的效果如下
img

user avatar mjhz 头像
点赞 1 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.