上位机与下位机通讯的中间介质是HID设备。上位机通过把信息传给HID设备,下位机从hid设备中读取数据,大概下位机将信息烧录进HID设备,当HID设备相应时会把数据发给上位机。
前置知识
什么是HID?
HID(Human Interface Device)是直接与人交互的电子设备,通过尺度化协议实现用户与计算机或其他设备的通讯,典型代表包括键盘、鼠标、游戏手柄等。
通讯背景
我这里的应用场景是用户通过上位机给MCU设置快捷键指令,MCU按下后在电脑实现快捷键按下效果。
VID PID
VID和PID是HID设备的唯一标识,可以通过VID和PID毗连HID设备
怎样知道当前HID设备的VID,PID?
- 打开 设备管理器,找到您的设备(通常在 人体学输入设备 或 通用串行总线设备 类别下)。
- 右键点击设备 -> 属性 -> 事件选项卡
怎样通讯?
1. 引入 hidapi 库
hidapi库是一个第三方库必要下载。下载完编译之后把 hidapi.h,hidapi.dll,hidapi.lib放到项目根目录下。
hid设备初始化有以下几个步骤:
2. 初始化 hidapi
3. 罗列和选择设备
- void enumerateDevices() {
- struct hid_device_info *devs = hid_enumerate(0x0, 0x0); // 参数为VID和PID,0x0表示匹配所有
- struct hid_device_info *cur_dev = devs;
- while (cur_dev) {
- printf("Device Found\n type: %04hx %04hx\n path: %s\n serial_number: %ls",
- cur_dev->vendor_id, cur_dev->product_id, cur_dev->path, cur_dev->serial_number);
- printf("\n");
- printf(" Manufacturer: %ls\n", cur_dev->manufacturer_string);
- printf(" Product: %ls\n", cur_dev->product_string);
- printf(" Release: %hx\n", cur_dev->release_number);
- printf(" Interface Number: %d\n\n", cur_dev->interface_number);
- cur_dev = cur_dev->next;
- }
- hid_free_enumeration(devs);
- }
复制代码 4. 打开设备
- hid_device *handle;
- handle = hid_open(0x1234, 0x5678, NULL); // 替换为设备的VID和PID
复制代码 5. 设置非阻塞模式(可选)
- int res = hid_set_nonblocking(handle, 1); // 参数为1表示非阻塞模式
- if (res < 0) {
- // 处理设置失败的情况
- }
复制代码 6. 读取和写入数据
- // 读取数据
- unsigned char buf[256];
- int res = hid_read(handle, buf, sizeof(buf));
- if (res < 0) {
- // 处理读取错误
- } else {
- // 处理读取到的数据
- }
- // 写入数据
- unsigned char data[] = {0x00, 0x01, 0x02}; // 示例数据
- res = hid_write(handle, data, sizeof(data));
- if (res < 0) {
- // 处理写入错误
- }
复制代码 7. 关闭设备和释放资源
- hid_close(handle);
- hid_exit();
复制代码
示例代码整合
- void MainWindow::HidInit()
- {
- // 1. 初始化HIDAPI
- if (hid_init() != 0) {
- qDebug() << "[错误] HIDAPI初始化失败";
- return;
- }
- else{
- qDebug() << "[正确] HIDAPI初始化成功";
- }
- // 2. 枚举设备
- qDebug() << "[调试] 开始枚举HID设备...";
- hid_device_info *devs = hid_enumerate(0x0, 0x0);
- if (!devs) {
- qDebug() << "[错误] 无法枚举HID设备,可能没有HID设备连接";
- hid_exit();
- return;
- }
- hid_device_info *cur_dev = devs;
- char* devicePath = nullptr;
- bool deviceFound = false;
- int deviceCount = 0; // 用于统计发现的HID设备数量
- qDebug() << "[调试] 开始遍历HID设备列表...";
- while (cur_dev) {
- deviceCount++;
- // 打印所有HID设备信息,用于调试
- qDebug().nospace() << "[调试] 设备 #" << deviceCount
- << ": VID=0x" << QString::number(cur_dev->vendor_id, 16).toUpper()
- << ", PID=0x" << QString::number(cur_dev->product_id, 16).toUpper();
- // << ", 路径=" << QString::fromWCharArray(cur_dev->path);
- if (cur_dev->vendor_id == TARGET_VID && cur_dev->product_id == TARGET_PID) {
- devicePath = _strdup(cur_dev->path);
- deviceFound = true;
- qDebug() << "\n[信息] 找到目标设备:";
- qDebug() << "路径:" << devicePath;
- qDebug() << "制造商:" << (cur_dev->manufacturer_string ? QString::fromWCharArray(cur_dev->manufacturer_string) : "N/A");
- qDebug() << "产品名:" << (cur_dev->product_string ? QString::fromWCharArray(cur_dev->product_string) : "N/A");
- qDebug() << "接口号:" << cur_dev->interface_number;
- break;
- }
- cur_dev = cur_dev->next;
- }
- qDebug() << "[调试] 遍历完成,共发现" << deviceCount << "个HID设备";
- hid_free_enumeration(devs);
- if (!deviceFound) {
- qDebug() << "[错误] 未找到目标设备 (VID: 0x" << QString::number(TARGET_VID, 16).toUpper()
- << ", PID: 0x" << QString::number(TARGET_PID, 16).toUpper() << ")";
- hid_exit();
- return;
- }
- // 3. 打开设备
- qDebug() << "[调试] 尝试打开目标设备...";
- hid_device* handle = hid_open_path(devicePath);
- if (!handle) {
- qDebug() << "[错误] 无法打开设备:" << QString::fromWCharArray(hid_error(nullptr));
- free(devicePath);
- hid_exit();
- return;
- }
- // 3. 打开设备
- handle = hid_open_path(devicePath);
- if (!handle) {
- qDebug() << "[错误] 无法打开设备:" << QString::fromWCharArray(hid_error(nullptr));
- free(devicePath);
- hid_exit();
- return;
- }
- // 设置非阻塞模式
- hid_set_nonblocking(handle, 1);
- qDebug() << "\n[信息] 设备已成功打开";
- // 4. 尝试通信
- const int REPORT_SIZE = 65; // 64字节数据 + 1字节报告ID
- unsigned char buf[REPORT_SIZE] = {0};
- // 尝试不同报告ID (0x00-0xFF)
- for (int report_id = 0x00; report_id <= 0xFF; report_id++) {
- // 4.1 尝试特性报告
- buf[0] = report_id;
- buf[1] = 0x01; // 示例命令
- qDebug() << "\n[调试] 尝试报告ID: 0x" << QString::number(report_id, 16).toUpper();
- int res = hid_send_feature_report(handle, buf, REPORT_SIZE);
- if (res > 0) {
- qDebug() << "[成功] 特性报告发送成功 (ID: 0x"
- << QString::number(report_id, 16).toUpper() << ")";
- break;
- } else if (report_id == 0xFF) {
- qDebug() << "[警告] 所有特性报告尝试失败";
- }
- // 4.2 尝试输出报告
- res = hid_write(handle, buf, REPORT_SIZE);
- if (res > 0) {
- qDebug() << "[成功] 输出报告发送成功 (ID: 0x"
- << QString::number(report_id, 16).toUpper() << ")";
- break;
- } else if (report_id == 0xFF) {
- qDebug() << "[警告] 所有输出报告尝试失败";
- }
- }
- // 5. 读取响应 (5秒超时)
- qDebug() << "\n[信息] 等待设备响应...";
- int timeout_ms = 5000;
- QElapsedTimer timer;
- timer.start();
- while (timer.elapsed() < timeout_ms) {
- int res = hid_read(handle, buf, REPORT_SIZE);
- if (res > 0) {
- qDebug() << "[成功] 收到" << res << "字节数据:";
- // 打印接收到的数据 (十六进制格式)
- QString hexData;
- for (int i = 0; i < res; i++) {
- hexData += "0x" + QString::number(buf[i], 16).toUpper().rightJustified(2, '0') + " ";
- if ((i+1) % 8 == 0) hexData += "\n";
- }
- qDebug() << hexData;
- break;
- } else if (res == 0) {
- QThread::msleep(100); // 避免CPU占用过高
- } else {
- qDebug() << "[错误] 读取失败:" << QString::fromWCharArray(hid_error(handle));
- break;
- }
- }
- if (timer.elapsed() >= timeout_ms) {
- qDebug() << "[警告] 读取超时,未收到响应";
- }
- // 6. 清理资源
- hid_close(handle);
- free(devicePath);
- hid_exit();
- qDebug() << "\n[信息] HID通信结束";
- }
复制代码 运行效果演示(我接入的是wacom数位板):
全是0xFF为未激活状态(初始状态)。
总结操作流程
- 确认设备功能与协议:明白设备是输入型(主动上报)还是命令型(需指令触发)。
- 发送测试指令:若无文档,通过简单指令探索设备相应模式。
- 剖析数据结构:根据相应数据的变革规律,逆向推导字节含义(如坐标、状态、校验等)。
- 编写业务逻辑:基于剖析效果,实现数据处置惩罚或控制功能(如鼠标模拟、设备配置等)。
剖析陈诉数据
怎样剖析
用以下结构来存储陈诉:
- struct TabletData {
- quint8 reportId; // 报告ID
- quint16 x; // X坐标(0-最大值)
- quint16 y; // Y坐标(0-最大值)
- quint16 pressure; // 压力值(0-最大值)
- QList<int> buttons; // 按下的按钮列表(按钮编号从1开始)
- };
复制代码 创建一个TabletData 类型的函数:
该函数对陈诉进行剖析,第0字节是陈诉ID,第1字节是按钮位置........
- TabletData HidManager::parseTabletData(const QByteArray& data) {
- TabletData result;
- if (data.isEmpty()) return result;
- result.reportId = data[0];
- switch (result.reportId) {
- case 0x11: // 按钮报告(假设按钮在字节1-2)
- for (int byteIdx = 1; byteIdx < 3; byteIdx++) {
- if (byteIdx >= data.size()) break;
- unsigned char byte = data[byteIdx];
- for (int bitIdx = 0; bitIdx < 8; bitIdx++) {
- if ((byte & (1 << bitIdx)) != 0) { // 1表示按下
- result.buttons.append((byteIdx - 1) * 8 + bitIdx + 1);
- }
- }
- }
- break;
- case 0x10: // **关键修改**:坐标/压力报告ID改为0x10
- // 解析坐标和压力(假设坐标在字节1-4,压力在字节5-6)
- if (data.size() >= 5) {
- // 小端序解析:低字节在前,高字节在后
- result.x = static_cast<quint16>(data[1]) | (static_cast<quint16>(data[2]) << 8);
- result.y = static_cast<quint16>(data[3]) | (static_cast<quint16>(data[4]) << 8);
- }
- if (data.size() >= 7) {
- result.pressure = static_cast<quint16>(data[5]) | (static_cast<quint16>(data[6]) << 8);
- }
- result.buttons.clear(); // 坐标报告不含按钮,清空列表
- break;
- default:
- qWarning() << "未知报告ID:" << QString::number(result.reportId, 16);
- break;
- }
- return result;
- }
复制代码 写一个打印输出函数
- void HidManager::handleHidData(const QByteArray& data)
- {
- // 数据为空或与上次完全相同则直接返回
- static QByteArray lastDataFrame;
- if (data.isEmpty() || data == lastDataFrame) return;
- lastDataFrame = data;
- // 解析数据到结构体
- TabletData currentData = parseTabletData(data);
- // 打印原始数据和解析结果(调试用)
- if (debugMode) { // 可添加调试开关
- QString hexData;
- for (int i = 0; i < data.size(); i++) {
- hexData += "0x" + QString::number((unsigned char)data[i], 16).toUpper().rightJustified(2, '0') + " ";
- if ((i+1) % 8 == 0) hexData += "\n";
- }
- qDebug() << "收到新数据:" << hexData;
- qDebug() << "解析后数据:"
- << "报告ID:" << QString::number(currentData.reportId, 16)
- << "坐标: (" << currentData.x << ", " << currentData.y << ")"
- << "压力:" << currentData.pressure
- << "按钮:" << currentData.buttons;
- }
- // 静态变量存储上次数据,用于检测变化
- static TabletData lastData;
- // 检查关键数据是否变化(按钮、坐标、压力)
- bool isButtonChanged = (currentData.buttons != lastData.buttons);
- bool isPositionChanged = (currentData.x != lastData.x || currentData.y != lastData.y);
- bool isPressureChanged = (currentData.pressure != lastData.pressure);
- // 根据变化类型发送不同信号
- if (isButtonChanged) {
- emit buttonStateChanged(currentData.buttons);
- }
- if (isPositionChanged || isPressureChanged) {
- emit tabletMoved(currentData.x, currentData.y, currentData.pressure);
- }
- // 更新上次数据缓存
- lastData = currentData;
- }
复制代码 打印输出:
拿wacom数位板举例。以下是毗连wacom数位板之后,数位笔滑动之后wacom数位板发送过来的陈诉:
剖析内容
陈诉第一个字节为陈诉ID,用来区分用户进行的是什么操作。
当陈诉ID为0X10期间表坐标移动
当陈诉ID为0X11期间表按键按下
例:
按下第一个按键,此时陈诉ID为0x11,表示按键事件发生。此时第2个字节发生了变革,也就是第一个字节被按下了:
当用数位笔在数位板上滑动之后收到如下陈诉:
陈诉ID为0x10,表示坐标发生变革。坐标在字节1-4,压力在字节5-6:
上位机向HID设备发送陈诉,HID设备向系统发送指令
- // ================== **发送HID报告(核心功能)** ==================
- void HidManager::sendReportInThread(const QByteArray &reportData, bool useFeatureReport) {
- // 添加设备状态检查
- if(!hidHandle || !hidRunning) {
- qDebug() << "设备未就绪";
- return;
- }
- // 确保报告长度正确(多数HID需要64字节)
- QByteArray paddedData = reportData;
- if(paddedData.size() < 64) {
- paddedData.resize(64, 0x00);
- qDebug() << "自动填充报告至64字节";
- }
- // 尝试两种发送方式
- int result = -1;
- if(useFeatureReport) {
- result = hid_send_feature_report(hidHandle,
- (uchar*)paddedData.constData(), paddedData.size());
- } else {
- // 先尝试Output报告
- result = hid_write(hidHandle,
- (uchar*)paddedData.constData(), paddedData.size());
- // 失败后尝试Feature报告
- if(result < 0) {
- qDebug() << "尝试改用特性报告发送";
- result = hid_send_feature_report(hidHandle,
- (uchar*)paddedData.constData(), paddedData.size());
- }
- }
- // 错误处理
- if(result != paddedData.size()) {
- qDebug() << "发送失败详情:";
- qDebug() << " 请求长度:" << paddedData.size();
- qDebug() << " 实际发送:" << result;
- qDebug() << " 最后错误:" << QString::fromWCharArray(hid_error(hidHandle));
- }
- }
- bool HidManager::sendReport(const QByteArray &reportData, bool useFeatureReport) {
- qDebug() << "[准备发送] 数据大小:" << reportData.size()
- << "使用特性报告:" << useFeatureReport
- << "线程状态:" << (hidThread ? hidThread->isRunning() : false);
- if (!hidThread || !hidThread->isRunning()) {
- qDebug() << "[错误] HID线程未运行";
- return false;
- }
- // 打印要发送的数据内容
- QString hexData;
- for (int i = 0; i < reportData.size(); ++i) {
- hexData += QString("0x%1 ").arg((uchar)reportData.at(i), 2, 16, QChar('0'));
- }
- qDebug() << "[发送数据] " << hexData;
- QMetaObject::invokeMethod(this, "sendReportInThread", Qt::QueuedConnection,
- Q_ARG(QByteArray, reportData),
- Q_ARG(bool, useFeatureReport));
- return true;
- }
复制代码 主函数调用写一个测试陈诉发送:
- void MainWindow::InitHid()
- {
- HID = new HidManager(this);
- HID->hidInit(TARGET_VID, TARGET_PID);
- // 定义测试报告数据(在lambda外部)
- auto createTestReport = []() {
- QByteArray cmd;
- cmd.append(0x01); // 报告ID
- cmd.append(0x02); // 保留字节
- cmd.append(0x55); // 测试模式标识
- cmd.append(0xAA); // 验证码
- cmd.resize(64, 0x00); // 填充至标准长度
- return cmd;
- };
- // 使用 [this, createTestReport] 捕获必要的变量
- QTimer::singleShot(1000, this, [this, createTestReport]() {
- QByteArray report = createTestReport();
- qDebug() << "准备发送测试报告,长度:" << report.size();
- qDebug() << "报告内容:" << report.toHex(' ').toUpper();
- // 先尝试Output报告
- //作用:Output 报告由主机(如电脑)发送到 HID 设备,用于向设备发送命令或数据。例如,向键盘发送背光控制命令、向游戏手柄发送振动指令等。
- if(HID->sendReport(report, false)) {
- qDebug() << "Output报告发送成功\n";
- } else {
- qDebug() << "Output报告发送失败\n";
- }
- QThread::msleep(50);
- // 再尝试Feature报告
- // 作用:Feature 报告用于在主机和设备之间传输配置信息或特殊命令。与 Output 报告不同,Feature 报告通常用于获取或设置设备的持久化配置(如保存设备的校准数据)。
- if(HID->sendReport(report, true)) {
- qDebug() << "Feature报告发送成功\n";
- } else {
- qDebug() << "Feature报告发送失败\n";
- }
- });
- }
复制代码 这个时间下位机写一个接受陈诉,然后再给接收到的数据做一个取反再发回给上位机,上位机如果接收到取反发回的数据则通讯乐成。
效果如下:
上位机发送:

下位机获取数据进行取反发回,上位机接受取反之后的数据:
至此,上位机与HID设备已经完成了一次通讯。
业务通讯
业务需求:
上位机上四个UI按钮与MCU4个按钮映射。
当用户点击UI界面某个按钮时,会弹出该按钮的对话框,用户可以在该对话框内设置该按钮所投射的快捷键,同时该映射关系也会被同步到HID设备对应的按钮上。
场景:
按下按钮1之后在弹出的快捷键按钮输入框里按下快捷键后,会有一个陈诉发送给MCU。
陈诉如下:

实现:
快捷键捕获:
新建一个类,该类中重写了eventFilter事件,用来捕获用户按下的快捷键。
另有在UI表现用户按下的多组合快捷键这一操作。
- #include "shortcutcapturedialog.h"
- #include <QLabel>
- #include <QLineEdit>
- #include <QVBoxLayout>
- #include <QDialogButtonBox>
- #include <QKeyEvent>
- #include <QDebug>
- /**
- * @brief 构造函数实现
- * @param parent 父窗口指针
- * @param buttonId 关联的按钮ID
- */
- ShortcutCaptureDialog::ShortcutCaptureDialog(int buttonId,QWidget *parent)
- : QDialog(parent), m_buttonId(buttonId)
- {
- // 设置窗口属性
- setWindowTitle(tr("设置快捷键")); // 使用tr()支持国际化
- setFixedSize(300, 180); // 固定对话框大小
- setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); // 移除帮助按钮
- // 创建界面布局
- QVBoxLayout *layout = new QVBoxLayout(this);
- layout->setContentsMargins(15, 15, 15, 15); // 设置边距
- // 创建提示标签
- m_label = new QLabel(tr("为按钮 %1 设置快捷键:").arg(m_buttonId), this);
- // 创建快捷键显示输入框
- m_lineEdit = new QLineEdit(this);
- m_lineEdit->setReadOnly(true); // 设置为只读
- m_lineEdit->setAlignment(Qt::AlignCenter); // 文本居中
- m_lineEdit->setPlaceholderText(tr("请按下快捷键...")); // 提示文本
- m_lineEdit->installEventFilter(this); // 安装事件过滤器
- // 创建按钮盒(确定/取消)
- QDialogButtonBox *buttonBox = new QDialogButtonBox(
- QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
- connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
- connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
- // 将控件添加到布局
- layout->addWidget(m_label);
- layout->addWidget(m_lineEdit);
- layout->addWidget(buttonBox);
- // 设置对话框布局
- setLayout(layout);
- }
- /**
- * @brief 获取当前设置的快捷键
- */
- QKeySequence ShortcutCaptureDialog::getShortcut() const
- {
- if (m_keySequence.size() == 1) {
- return QKeySequence(m_keySequence.first());
- }
- else if (m_keySequence.size() > 1) {
- // 构造多键组合的 QKeySequence
- QList<int> keys;
- for (const auto& combo : m_keySequence) {
- keys.append(combo.toCombined());
- }
- return QKeySequence(keys[0], keys.size() > 1 ? keys[1] : 0,
- keys.size() > 2 ? keys[2] : 0, keys.size() > 3 ? keys[3] : 0);
- }
- return QKeySequence();
- }
- /**
- * @brief 获取关联的按钮ID
- */
- int ShortcutCaptureDialog::getButtonId() const
- {
- return m_buttonId;
- }
- // QKeySequence ShortcutCaptureDialog::getShortcut() const
- // {
- // return m_currentShortcut;
- // }
- /**
- * @brief 事件过滤器实现
- * @param obj 事件目标对象
- * @param event 事件对象
- * @return bool 是否处理该事件
- */
- bool ShortcutCaptureDialog::eventFilter(QObject *obj, QEvent *event) {
- if (obj == m_lineEdit) {
- if (event->type() == QEvent::KeyPress) {
- QKeyEvent *keyEvent = static_cast<QKeyEvent*>(event);
- Qt::Key key = static_cast<Qt::Key>(keyEvent->key());
- // 更新修饰键状态
- if (key == Qt::Key_Control || key == Qt::Key_Shift ||
- key == Qt::Key_Alt || key == Qt::Key_Meta) {
- m_currentModifiers |= keyEvent->modifiers();
- return true;
- }
- // 开始捕获
- if (!m_isCapturing) {
- m_keySequence.clear();
- m_isCapturing = true;
- }
- // 限制最多3键组合
- if (m_keySequence.size() >= 3) {
- return true;
- }
- // 对于第一个按键,记录完整组合(修饰键+按键)
- // 对于后续按键,只记录按键本身(不带修饰键)
- if (m_keySequence.isEmpty()) {
- m_keySequence.append(QKeyCombination(m_currentModifiers, key));
- } else {
- m_keySequence.append(QKeyCombination(Qt::NoModifier, key));
- }
- updateShortcutText();
- return true;
- }
- else if (event->type() == QEvent::KeyRelease) {
- QKeyEvent *keyEvent = static_cast<QKeyEvent*>(event);
- Qt::Key key = static_cast<Qt::Key>(keyEvent->key());
- // 更新修饰键状态
- if (key == Qt::Key_Control || key == Qt::Key_Shift ||
- key == Qt::Key_Alt || key == Qt::Key_Meta) {
- m_currentModifiers &= ~keyEvent->modifiers();
- }
- return true;
- }
- }
- return QDialog::eventFilter(obj, event);
- }
- // 辅助函数:判断是否为修饰键
- bool ShortcutCaptureDialog::isModifierKey(Qt::Key key) {
- return key == Qt::Key_Shift || key == Qt::Key_Control ||
- key == Qt::Key_Alt || key == Qt::Key_Meta;
- }
- //快捷键文本显示
- void ShortcutCaptureDialog::updateShortcutText() {
- if (m_keySequence.isEmpty()) {
- m_lineEdit->setText(tr("请按下快捷键..."));
- return;
- }
- QStringList parts;
- // 处理第一个键(带修饰键)
- if (!m_keySequence.isEmpty()) {
- parts.append(QKeySequence(m_keySequence.first()).toString(QKeySequence::NativeText));
- }
- // 处理后续按键(不带修饰键)
- for (int i = 1; i < m_keySequence.size(); ++i) {
- parts.append(QKeySequence(m_keySequence[i].key()).toString(QKeySequence::NativeText));
- }
- m_lineEdit->setText(parts.join("+"));
- }
复制代码 给四个按钮绑定槽函数,把自己映射的对应按钮的按钮ID发送过去:
- connect(btn10, &QPushButton::clicked, this, [this]() {
- BingShortcutKey(0x01);
- });
- connect(btn11, &QPushButton::clicked, this, [this]() {
- BingShortcutKey(0x02);
- });
- connect(btn12, &QPushButton::clicked, this, [this]() {
- BingShortcutKey(0x04);
- });
- connect(btn13, &QPushButton::clicked, this, [this]() {
- BingShortcutKey(0x08);
- });
复制代码 构建映射表
- namespace HidKeyCodes {
- const quint8 A = 0x04;
- .......
- const quint8 Z = 0x1D;
- // 方向键
- const quint8 UpArrow = 0x52;
- const quint8 DownArrow = 0x53;
- const quint8 LeftArrow = 0x50;
- const quint8 RightArrow= 0x51;
- // 常用组合键的控制字节(示例)
- // Ctrl = LeftCtrl (0x01), Shift = LeftShift (0x02), Alt = LeftAlt (0x04)
- const quint8 CtrlMask = LeftCtrl; // Ctrl键控制字节
- const quint8 ShiftMask = LeftShift; // Shift键控制字节
- const quint8 AltMask = LeftAlt; // Alt键控制字节
- const quint8 CtrlShiftMask = CtrlMask | ShiftMask; // Ctrl+Shift组合
- ......
- }
复制代码 映射
- quint8 MainWindow::mapQtKeyToHidKey(Qt::Key key) {
- // ------------------------- 字母键映射(连续范围) -------------------------
- // Qt::Key_A到Qt::Key_Z是连续的枚举值(0x41-0x5A)
- // HID键码中字母A到Z也是连续的(0x04-0x1D)
- if (key >= Qt::Key_A && key <= Qt::Key_Z) {
- return HidKeyCodes::A + (key - Qt::Key_A); // 例如:Qt::Key_B -> 0x04 + (0x42-0x41) = 0x05
- }
- // ------------------------- 功能键映射(连续范围) -------------------------
- // Qt::Key_F1到Qt::Key_F12是连续的枚举值(0x01000030-0x0100003B)
- // HID键码中F1到F12也是连续的(0x3A-0x45)
- else if (key >= Qt::Key_F1 && key <= Qt::Key_F12) {
- return HidKeyCodes::F1 + (key - Qt::Key_F1); // 例如:Qt::Key_F2 -> 0x3A + (2-1) = 0x3B
- }
- // ------------------------- 特殊键映射(离散值) -------------------------
- else {
- switch (key) {
- // 常用字母键(未包含在连续范围中的)
- case Qt::Key_J: return HidKeyCodes::J; // HID: 0x0D (对应USB HID标准中的Key J)
- case Qt::Key_K: return HidKeyCodes::K; // HID: 0x0E
- // 特殊功能键
- case Qt::Key_Space: return HidKeyCodes::Space; // HID: 0x2C (空格)
- // 方向键
- case Qt::Key_Left: return HidKeyCodes::LeftArrow; // HID: 0x50
- case Qt::Key_Right: return HidKeyCodes::RightArrow; // HID: 0x4F
- case Qt::Key_Up: return HidKeyCodes::UpArrow; // HID: 0x52
- case Qt::Key_Down: return HidKeyCodes::DownArrow; // HID: 0x51
- // 其他常用键
- case Qt::Key_Control: return HidKeyCodes::LeftCtrl; // HID: 0xE0 (左Ctrl)
- case Qt::Key_Shift: return HidKeyCodes::LeftShift; // HID: 0xE1 (左Shift)
- case Qt::Key_Alt: return HidKeyCodes::LeftAlt; // HID: 0xE2 (左Alt)
- default:
- qDebug() << "Unmapped Qt key:" << key; // 调试未映射的键
- return 0x00; // 未知键返回0x00(HID协议中表示无按键)
- }
- }
- }
复制代码 剖析多组合快捷键
- HidMultiKeyData MainWindow::parseMultiKeyShortcut(const QKeySequence &shortcut) {
- HidMultiKeyData data; // 用于存储HID多键数据的结构体
- // 校验快捷键有效性:空序列或超过6个按键(HID规范最多支持6个按键)
- if (shortcut.isEmpty() || shortcut.count() > 6) {
- qWarning() << "Invalid shortcut length:" << shortcut.count(); // 输出警告信息
- return data; // 返回空数据
- }
- // 遍历快捷键中的每个按键组合(最多处理6个)
- for (int i = 0; i < shortcut.count(); ++i) {
- int keyCombination = shortcut[i]; // 获取第i个按键的组合值(包含键值和修饰键)
- // 拆分按键组合:高字节为修饰键,低字节为主按键
- // 使用按位与操作分离键值和修饰键(Qt::KeyboardModifierMask为0xFF000000)
- Qt::Key key = static_cast<Qt::Key>(keyCombination & ~Qt::KeyboardModifierMask); // 提取主按键(清除修饰键位)
- Qt::KeyboardModifiers mods = static_cast<Qt::KeyboardModifiers>(keyCombination & Qt::KeyboardModifierMask); // 提取修饰键(仅保留高位修饰键位)
- // ------------------------- 修饰键转换 -------------------------
- // 将Qt修饰键映射到HID键码(仅保留左部修饰键,忽略重复类型)
- // HID规范中每个修饰键仅需一个(如LeftCtrl和RightCtrl不同,但此处统一用Left)
- if (mods & Qt::ControlModifier) data.modifiers |= HidKeyCodes::LeftCtrl; // Ctrl键映射为HID左Ctrl
- if (mods & Qt::ShiftModifier) data.modifiers |= HidKeyCodes::LeftShift; // Shift键映射为HID左Shift
- if (mods & Qt::AltModifier) data.modifiers |= HidKeyCodes::LeftAlt; // Alt键映射为HID左Alt
- if (mods & Qt::MetaModifier) data.modifiers |= HidKeyCodes::LeftMeta; // Meta/Win键映射为HID左Meta
- // ------------------------- 主按键转换 -------------------------
- quint8 keyCode = mapQtKeyToHidKey(key); // 调用自定义函数将Qt键转换为HID键码
- // 校验转换结果并添加到数组(HID最多支持6个主按键)
- if (keyCode != 0x00 && data.count < 6) { // 0x00表示无效键码
- data.keyCodes[data.count++] = keyCode; // 存入键码数组,并递增计数
- } else {
- qWarning() << "Unsupported key in multi-key:" << key; // 输出不支持的键警告
- }
- }
- return data; // 返回填充后的HID多键数据
- }
复制代码 构建陈诉,发送陈诉
- void MainWindow::BingShortcutKey(quint8 buttonId) {
-
- if (dialog.exec() != QDialog::Accepted) return;
- // 解析组合键为多键数据(支持同时按下Ctrl+J+K)
- QKeySequence shortcut = dialog.getShortcut();
- HidMultiKeyData multiKey = parseMultiKeyShortcut(shortcut);
- if (multiKey.count == 0) {
- qWarning() << "Invalid multi-key shortcut";
- return;
- }
- // 构建HID报告(一次性发送所有键)
- QByteArray report(64, 0x00);
- report[0] = 0x01; // 报告ID
- report[1] = buttonId; // 按钮掩码
- report[2] = multiKey.modifiers; // 修饰键(如Ctrl=0x01)
- // 填充非修饰键(J和K分别在第3、4字节)
- for (int i = 0; i < multiKey.count; ++i) {
- report[3 + i] = multiKey.keyCodes[i];
- }
- qDebug() << "发送多键HID报告:"
- << "按钮:" << Qt::hex << static_cast<int>(buttonId)
- << "修饰键:" << Qt::hex << static_cast<int>(multiKey.modifiers)
- << "键值:" << QString::asprintf("0x%02X, 0x%02X",
- multiKey.keyCodes[0], multiKey.keyCodes[1]);
- // 发送报告(仅需一次发送,无需延时)
- if (!HID || !HID->sendReport(report, false)) {
- qWarning() << "HID多键报告发送失败";
- }
- }
复制代码
下位机对我们发送的陈诉进行剖析,然后剖析出来的指令由HID触发时发给操作系统就可以实现效果了。
效果演示:
WeChat_20250605170224
HID设备给上位机发送陈诉,上位机给系统发送指令
因为MCU只有三个接口:键盘,鼠标,自界说。与上位机通讯占了1接口,给系统发送键盘鼠标快捷键占了2个接口,如果说有更灵活的操作不止于键盘鼠标的操作。比方像下面这种滚动+按键调整画笔浓度,MCU就不是很好去实现了。
WeChat_20250606193128
那么我们就换一种思绪,由上位机去实现,用户首先在上位机软件上设定按钮/旋钮/触摸板快捷键,上位机必要将按钮ID和快捷键以键值对的情势生存起来,MCU按下某一个按钮的时间下位机只必要发送陈诉给上位机,陈诉中表明按下的按钮ID即可。上位机收到陈诉之后对陈诉进行剖析,剖析出来按钮ID,将按钮ID与生存起来的配置进行匹配,匹配到相同的按钮ID,将其对应的快捷键设置发给系统。
效果如下:
WeChat_20250606193848
(此时关闭上位机软件无法再实现功能,说明功能是由上完机完成的,而非下位机。)
进阶功能
通过旋转旋钮调整画笔浓度
首先必要获取当前SAI的画笔浓度值,在此值基础上通过旋转旋钮去增量或减量
获取SAI的画笔浓度
通过Cheat Engline获取SAI的基地址和画笔浓度地址:
SAI基地址:
画笔浓度地址:
通过输入当前画笔浓度值进行扫描可得出(可以扫描2次):
此时打扫了2个地址:
测试一下哪个是画笔浓度地址(更改值该地址对应的值,画笔浓度跟着变革的就是)。我这里测出来的是第一。
求画笔浓度偏移地址(画笔浓度地址-基地址偏移量):

然后把基地址和画笔浓度偏移量填入下面代码:
- import ctypes
- import math
- from typing import Optional
- class SAI2BrushDensityReader:
- """
- SAI2初始画笔浓度检测工具
- 功能:通过内存读取方式获取SAI2启动时的初始画笔浓度值(使用Int32类型)
- 注意:需要以管理员权限运行,且需预先通过逆向工程获取正确的内存地址
- """
- def __init__(self):
- self.process_handle = None # SAI2进程句柄
- self.sai2_pid = None # SAI2进程ID
- # 需通过逆向工程获取的内存地址(模块基址+浓度偏移量)
- self.MODULE_BASE = 0x000089A4 # SAI2主模块基址(需根据实际版本替换)
- self.DENSITY_OFFSET = 0x2AFD0E0 # 画笔浓度值相对偏移量(需根据实际版本替换)
- def get_initial_density(self) -> Optional[int]:
- """
- 获取SAI2初始画笔浓度值(返回0-100的整数百分比)
- 执行流程:
- 1. 查找并附加到SAI2进程
- 2. 计算浓度值内存地址
- 3. 以Int32类型读取内存数据
- 4. 规范化数值到0-100范围
- """
- try:
- # 1. 建立与SAI2进程的连接
- if not self._attach_to_sai2():
- return None
- # 2. 计算目标内存地址(基址+偏移量)
- density_addr = self.MODULE_BASE + self.DENSITY_OFFSET
- print(f"尝试读取地址: 0x{density_addr:X}")
- # 3. 以Int32类型读取内存(优先使用逆向确认的数据类型)
- buffer_type = ctypes.c_int32
- result = self._read_memory(density_addr, buffer_type)
- if result is not None:
- # 数值规范化处理(适配不同存储格式)
- normalized = self._normalize_value(result, buffer_type)
- if normalized is not None:
- print(f"[成功] 初始画笔浓度: {normalized}% (原始值: {result}, 类型: {buffer_type.__name__})")
- return normalized
- print("[错误] 未能读取有效浓度值")
- return None
- except Exception as e:
- print(f"[异常] 发生错误: {str(e)}")
- return None
- finally:
- # 释放进程资源,避免句柄泄漏
- self._cleanup()
- def _attach_to_sai2(self) -> bool:
- """附加到SAI2进程,获取进程句柄"""
- # 通过进程名查找SAI2进程ID
- self.sai2_pid = self._find_process_id("sai2.exe")
- if not self.sai2_pid:
- print("[错误] 未找到SAI2进程,请确认SAI2已启动")
- return False
- # 请求最高权限打开进程(需要管理员权限)
- PROCESS_ALL_ACCESS = 0x1F0FFF # 包含所有进程访问权限的标志
- self.process_handle = ctypes.windll.kernel32.OpenProcess(
- PROCESS_ALL_ACCESS, False, self.sai2_pid)
- if not self.process_handle:
- error_code = ctypes.GetLastError()
- print(f"[错误] 无法附加到进程(PID: {self.sai2_pid}), 错误码: {error_code}")
- return False
- print(f"[信息] 已附加到SAI2进程(PID: {self.sai2_pid})")
- return True
- def _read_memory(self, address: int, buffer_type) -> Optional[int]:
- """从指定内存地址读取Int32类型数据"""
- try:
- # 初始化数据缓冲区
- buffer = buffer_type()
- bytes_read = ctypes.c_ulong()
- # 调用Windows API读取进程内存
- if ctypes.windll.kernel32.ReadProcessMemory(
- self.process_handle,
- address,
- ctypes.byref(buffer),
- ctypes.sizeof(buffer),
- ctypes.byref(bytes_read)):
- # 打印原始字节数据用于调试(十六进制格式)
- raw_bytes = (ctypes.c_byte * ctypes.sizeof(buffer)).from_buffer_copy(buffer)
- print(f"地址 0x{address:X} 原始数据({buffer_type.__name__}): {bytes(raw_bytes).hex()}")
- return int(buffer.value)
- else:
- # 读取失败时获取系统错误码
- error_code = ctypes.GetLastError()
- print(f"[错误] 读取内存失败(地址: 0x{address:X}, 错误码: {error_code})")
- return None
- except Exception as e:
- print(f"[异常] 读取内存时出错: {str(e)}")
- return None
- def _normalize_value(self, value: int, value_type) -> Optional[int]:
- """将内存读取的原始值转换为0-100的百分比"""
- if value_type == ctypes.c_int32:
- # 场景1:浓度直接存储为0-100的整数(如50表示50%)
- if 0 <= value <= 100:
- return value
- # 场景2:浓度存储为0-255的整数(如8位无符号整型)
- elif 0 <= value <= 255:
- return int(round(value / 255 * 100)) # 转换为百分比
- # 场景3:浓度存储为0-1000的整数(如放大10倍存储)
- elif 0 <= value <= 1000:
- return int(round(value / 10)) # 缩小10倍转百分比
- else:
- # 异常值处理:强制映射到0-100范围
- print(f"[警告] 检测到异常整数值: {value},尝试转换为百分比")
- return max(0, min(100, int(round(value))))
- return None
- def _find_process_id(self, exe_name: str) -> Optional[int]:
- """通过进程名查找进程ID(使用Windows进程快照API)"""
- # 定义Windows进程枚举结构体(需与系统API匹配)
- class PROCESSENTRY32(ctypes.Structure):
- _fields_ = [
- ("dwSize", ctypes.c_ulong), # 结构体大小(需提前设置)
- ("cntUsage", ctypes.c_ulong), # 内核使用计数(未使用)
- ("th32ProcessID", ctypes.c_ulong), # 进程ID
- ("th32DefaultHeapID", ctypes.c_void_p), # 堆ID(未使用)
- ("th32ModuleID", ctypes.c_ulong), # 模块ID(未使用)
- ("cntThreads", ctypes.c_ulong), # 线程数(未使用)
- ("th32ParentProcessID", ctypes.c_ulong), # 父进程ID(未使用)
- ("pcPriClassBase", ctypes.c_long), # 优先级(未使用)
- ("dwFlags", ctypes.c_ulong), # 标志位(未使用)
- ("szExeFile", ctypes.c_char * 260) # 可执行文件名
- ]
- # 创建进程快照(包含所有进程信息)
- snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot(0x2, 0)
- process_entry = PROCESSENTRY32()
- process_entry.dwSize = ctypes.sizeof(PROCESSENTRY32) # 必须先设置结构体大小
- # 枚举进程列表
- if ctypes.windll.kernel32.Process32First(snapshot, ctypes.byref(process_entry)):
- while True:
- # 比较进程名(忽略大小写)
- if exe_name.lower().encode() in process_entry.szExeFile.lower():
- pid = process_entry.th32ProcessID
- ctypes.windll.kernel32.CloseHandle(snapshot) # 释放快照句柄
- return pid
- # 读取下一个进程
- if not ctypes.windll.kernel32.Process32Next(snapshot, ctypes.byref(process_entry)):
- break
- # 释放资源并返回失败
- ctypes.windll.kernel32.CloseHandle(snapshot)
- return None
- def _cleanup(self):
- """释放进程句柄资源"""
- if self.process_handle:
- ctypes.windll.kernel32.CloseHandle(self.process_handle)
- self.process_handle = None
- print("[信息] 已释放进程句柄资源")
- if __name__ == "__main__":
- """程序入口:执行浓度检测并输出结果"""
- print("=== SAI2初始画笔浓度检测工具 ===")
- print("注意:请确保以管理员权限运行,且SAI2已启动")
- print("提示:若检测失败,请使用Cheat Engine验证内存地址")
- reader = SAI2BrushDensityReader()
- density = reader.get_initial_density()
- if density is not None:
- print(f"\n检测完成:当前SAI2初始画笔浓度为 {density}%")
- else:
- print("\n检测失败,请检查:")
- print("1. SAI2是否已启动")
- print("2. 是否拥有管理员权限")
- print("3. MODULE_BASE和DENSITY_OFFSET地址是否正确")
- print("4. 尝试使用Cheat Engine确认地址有效性")
复制代码 运行效果(此时就得到了当前画笔浓度的值):

历程间通讯
需求:通过上位机设置之后,按下按钮1在SAI中达到切换到喷枪工具的效果。
如下所示,通过上位机步伐给按钮设置快捷键b,此时按下按钮1之后在sai内部乐成被切换到了喷枪工具(B),但是在sai步伐外部我按下按钮1仍旧会执行指令。
这是因为实际上执行的是系统级键盘模拟指令,全局相应,用的是keybd_event()接口
keybd_event 是 Windows API 中用于模拟键盘输入的底层函数,通过直接操作键盘设备驱动层实现按键模拟,相比 SendInput 或 PostMessage 更靠近硬件级交互。:
而我想要的是单给SAI发送该指令,所以我选择历程间通讯,去给SAI窗口单独发送指令。我这里选择用postmessage取代模拟全局按键按下。
- PostMessage
- 直接向目的窗口的消息队列发送消息,绕过了部分系统处置惩罚流程。
- 可以向没有焦点的窗口发送消息,但必要明白知道窗口的句柄和消息参数。
- 优点:可以在窗口没有焦点时生效。
- 缺点:必要了解窗口内部的消息处置惩罚机制,通用性较差。
如今我们加个配置列表切换就可以做到指定某个指令单独发送快捷键指令了:
1
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |