C#实现PS/2设备禁用:从枚举到策略落地的完整指南
2026/9/15 3:43:08 网站建设 项目流程

简介:这是一套基于C#开发的设备管理系统源码,定位很明确:面向正在准备课程设计,或想通过完整项目练习WinForms桌面应用开发的初学者。系统围绕设备全生命周期设计,功能点覆盖设备信息添加、更新、类型维护,设备维修登记与查询,设备报废登记与查询,设备借出和归还登记,以及修改密码等操作,模块划分清晰、业务流程互相衔接,非常适合用于学习“增删改查”在管理系统中的实际落地。压缩包为rar格式,整体大小约3.53MB,目前已有92人浏览学习,虽然压缩包内部文件清单暂未公开,但下载后可通过源码目录快速定位各功能模块。读者能从中获得一套可直接编译运行的C#项目,通过阅读和调试代码,既能掌握设备管理系统的数据流转,也能参考其实现方式快速做出自己的学习或课设项目,具有较强的实战参考价值。

1. 设备管理系统为什么要碰 PS/2 接口

如果只做设备台账,没人会去碰 PS/2。但银行柜面、医院工位、老式工业一体机上,PS/2 键盘到现在还在服役,审计要求“非白名单输入设备一律不许生效”,设备管理系统里就多了一个 preventps2 模块:用 C# 把 PS/2 控制器、PS/2 键盘鼠标识别出来,再按策略禁用或放行。麻烦的是 PS/2 不像 USB 走标准即插即用路径,设备由 i8042prt 驱动在系统早期阶段接管,枚举、禁用、恢复的姿势都和普通 USB 设备不同。接下来按设备枚举、策略落地、数据回传、验证验收这条链路讲,思路可以直接搬到自己的上位机或终端管理项目里。

2. C# 设备枚举:用 WMI 和 SetupAPI 把 PS/2 设备抓全

要禁用 PS/2,第一步是稳定地找到它。常见做法是先用 WMI 做粗筛,再用 SetupAPI(配置管理器 API)确认状态,两层配合才不会漏掉厂商改过设备名的机器。

2.1 先按设备类过滤,再按 PNP ID 精确匹配

WMI 里最直觉的查法是 Win32_Keyboard 和 Win32_PointingDevice,但这两个类只返回“当前正常活动”的设备,设备一旦被禁用就查不到,做台账可以,做管控不行。我一般直接查 Win32_PnPEntity,把设备类固定在 Keyboard 和 Mouse 上,这样禁用前后的设备都能枚举出来。

using System.Management; var scope = new ManagementScope(@"\\.\root\cimv2"); var query = new ObjectQuery( "SELECT DeviceID, Name, PNPClass, Status FROM Win32_PnPEntity " + "WHERE PNPClass = 'Keyboard' OR PNPClass = 'Mouse'"); using var searcher = new ManagementObjectSearcher(scope, query); foreach (ManagementBaseObject mo in searcher.Get()) { string pnpId = mo["DeviceID"]?.ToString() ?? ""; string name = mo["Name"]?.ToString() ?? ""; if (IsPs2Device(pnpId, name)) { Console.WriteLine($"{pnpId}\t{name}\t{mo["Status"]}"); } } static bool IsPs2Device(string pnpId, string name) { return pnpId.Contains("PNP0303") // PS/2 键盘控制器 ACPI ID || pnpId.Contains("PNP0F") // PS/2 鼠标端口 ACPI ID || name.IndexOf("PS/2", StringComparison.OrdinalIgnoreCase) >= 0; }

这里关键不是 Name,而是 DeviceID 里的 PNP ID。ACPI 固件暴露出来的硬件 ID 改不了,厂商最多改设备名,所以按 PNP ID 匹配是最不容易误判的方式。下面的表是我在实际机器上见过的常见组合:

PNP ID含义设备管理器里常见名称
PNP03038042 兼容 PS/2 键盘控制器Standard PS/2 Keyboard
PNP0F13PS/2 兼容鼠标端口PS/2 Compatible Mouse
PNP0F0E第二 PS/2 鼠标端口一体机上偶尔出现

Name 过滤适合做展示层,PNP ID 过滤适合做策略层。两台不同厂商的主机,Name 可能一个叫“Standard PS/2 Keyboard”,一个叫“OEM PS/2 Keyboard”,但 DeviceID 里都带着 PNP0303。

2.2 用 SetupAPI 读取设备状态和问题码

WMI 返回的 Status 只有 OK/Error/Degraded 几档,判断“是否被策略禁用”不准确。比如用 CM_Disable_DevNode 禁用设备后,WMI 的 Status 可能仍然是 OK。正确做法是 P/Invoke 配置管理器 API,它直接读系统的 devnode 树,反映的是设备真实动态状态。

using System.Runtime.InteropServices; using System.Text; internal static class CfgMgr { [DllImport("CfgMgr32.dll", EntryPoint = "CM_Locate_DevNodeW", CharSet = CharSet.Unicode)] public static extern int CM_Locate_DevNode( out IntPtr pdnDevInst, string pDeviceID, int ulFlags); [DllImport("CfgMgr32.dll", EntryPoint = "CM_Get_DevNode_StatusW")] public static extern int CM_Get_DevNode_Status( out uint pulStatus, out uint pulProblemNumber, IntPtr dnDevInst, int ulFlags); [DllImport("CfgMgr32.dll")] public static extern int CM_Disable_DevNode(IntPtr dnDevInst, int ulFlags); [DllImport("CfgMgr32.dll")] public static extern int CM_Enable_DevNode(IntPtr dnDevInst, int ulFlags); } // 调用示例 const uint DN_STARTED = 0x00000001; // 设备已启动 const uint DN_DRIVER_LOADED = 0x00000002; // 驱动已加载 int hr = CfgMgr.CM_Locate_DevNode(out IntPtr devInst, pnpId, 0); if (hr != 0) { Console.WriteLine($"设备不存在或不可定位,错误码 0x{hr:X8}"); return; } uint status, problem; hr = CfgMgr.CM_Get_DevNode_Status(out status, out problem, devInst, 0); if (hr == 0) { bool started = (status & DN_STARTED) != 0; Console.WriteLine($"已启动={started}, 已加载驱动={((status & DN_DRIVER_LOADED) != 0)}, 问题码={problem}"); }

参数说明:CM_Locate_DevNode 的第三个参数 ulFlags 传 0 即可,预留给系统扩展;CM_Get_DevNode_Status 的输出参数 problem 是问题码,启用正常的设备 problem 为 0,被禁用的设备典型值是 22(CM_PROB_DISABLED)。返回的 devInst 由配置管理器管理,不需要手动释放。

这里有个隐蔽点:Win32_PnPEntity 的 DeviceID 和 SetupAPI 的 device instance ID 是同一串字符串,可以直接拿来用,不需要做格式转换。

2.3 清单绑定到 DataGridView 的最小刷新写法

设备清单一般会绑到 DataGridView 上,如果每次刷新都重新 new 一个 DataTable 再赋给 DataSource,几百台终端轮流上报时 UI 会明显掉帧。我一般用 BindingList 做数据源,后台线程采集,主线程通过 BeginInvoke 回写。

public sealed class DeviceInfo { public string InstanceId { get; set; } = ""; public string Name { get; set; } = ""; public bool Enabled { get; set; } } private readonly BindingList<DeviceInfo> _devices = new(); // Form 构造函数里 dataGridView1.DataSource = _devices; private async Task RefreshLoopAsync() { while (true) { var list = await Task.Run(() => QueryDevices()); BeginInvoke(new Action(() => { _devices.Clear(); foreach (var d in list) { _devices.Add(d); } })); await Task.Delay(5000); } }

用 BindingList 的好处是增量更新时 UI 只重绘变化行,Clear 再 Add 全量刷新在这种场景下也够用。关键是 Task.Run 把 WMI 和 SetupAPI 的耗时查询挪出 UI 线程,BeginInvoke 把结果切回主线程更新控件,这是 C# WinForm 里避免 UI 刷新卡顿的基本盘。

3. preventps2 落地:C# 禁用 PS/2 设备的三种做法

枚举之后就是禁用。这里要区分三件事:禁用驱动服务、禁用设备实例、停用类驱动,三者的生效时机、恢复成本完全不同。下面三种方式我都用过,按推荐程度排序讲。

3.1 方式一:改 i8042prt 服务的 Start 值

PS/2 控制器由 i8042prt.sys 在系统启动早期接管,把这个服务的 Start 值改成 4,驱动不会加载,PS/2 设备直接不出现。这是启动级的做法,适合“这台终端就是不允许接 PS/2”的固定策略。

using Microsoft.Win32; const string servicePath = @"SYSTEM\CurrentControlSet\Services\i8042prt"; using (var key = Registry.LocalMachine.OpenSubKey(servicePath, writable: true)) { if (key == null) { Console.WriteLine("没有 i8042prt 服务,说明这台机器没有 PS/2 控制器"); return; } key.SetValue("Start", 4, RegistryValueKind.DWord); // 4 = 禁用 }

参数说明:Start 的常见取值是 0(系统启动加载)、1(系统启动时加载)、2(自动)、3(手动)、4(禁用)。PS/2 控制器必须在启动早期就绪,所以改为 1 或 2 都行,但禁用一定要填 4。这个方式重启才生效,恢复时把 Start 改回 1 再重启。

注意这个方案的边界:新平台如果没有 i8042prt 服务,这段代码会直接跳过;部分笔记本触摸板走的是厂商自定义 PS/2 驱动,改 i8042prt 拦不住。所以它适合老工控机批量预置,不适合做动态策略。

3.2 方式二:用 CM_Disable_DevNode 即时禁用设备实例

这是最常用的做法,立即生效、不用重启,回滚也简单。配置管理器下发禁用后,设备管理器里会显示“此设备已禁用,代码 22”。

public static bool SetDeviceEnabled(string instanceId, bool enable) { int hr = CfgMgr.CM_Locate_DevNode(out IntPtr devInst, instanceId, 0); if (hr != 0) { return false; // 设备枚举不到,可能已被拔出 } hr = enable ? CfgMgr.CM_Enable_DevNode(devInst, 0) : CfgMgr.CM_Disable_DevNode(devInst, 0); return hr == 0; // 返回 0 即 CR_SUCCESS }

这个函数的入参就是 2.1 节拿到的 DeviceID。执行前要确认当前进程有管理员权限,否则 CM_Disable_DevNode 会直接返回 ERROR_ACCESS_DENIED。判断权限可以这样做:

using System.Security.Principal; bool isAdmin = new WindowsPrincipal(WindowsIdentity.GetCurrent()) .IsInRole(WindowsBuiltInRole.Administrator); if (!isAdmin) { throw new UnauthorizedAccessException("需要管理员权限执行设备禁用操作"); }

提示:如果这台机器只有 PS/2 键盘,禁用前务必确认有 USB 键盘或远程桌面通道。设备被禁用后,本机键盘立刻停止响应,误操作只能靠远程恢复。

3.3 方式三:三种方案的取舍

还可以通过停掉 kbdclass 服务来让所有键盘失效,但 kbdclass 是键盘类驱动,USB 键盘、笔记本内置键盘也会被一起禁掉,误伤面太大,实际部署中不建议。

方式生效时机恢复成本适用场景
改 i8042prt Start 值重启后改回 1 再重启固定终端长期策略
CM_Disable_DevNode立即调用 Enable 或设备管理器启用动态白名单、临时管控
停 kbdclass 服务立即重装驱动或改服务强管控,不推荐

实际项目里我会组合使用:CM_Disable_DevNode 做日常动态管控,i8042prt 的 Start 值做重启后的兜底,防止设备管理器里被手动启用后,重启又回到禁用状态。

4. 设备管理系统的数据链路:串口、Modbus 与 UI 防卡顿

preventps2 只是策略端,完整系统还要把状态回传、把策略下发到每个终端。常见做法是管理端通过串口或 Socket 连接终端设备,单机上位机场景下,串口 + Modbus RTU 用得最多。

4.1 定义一帧业务数据:命令字、长度、CRC16

如果设备端是单片机,通常不会直接跑 Modbus,而是自定义帧。帧结构我在项目里习惯这样定:

字段长度说明
帧头2 字节固定 0xAA55
命令字1 字节0x01 查询 / 0x02 禁用 / 0x03 启用
数据长度1 字节数据体字节数
数据体N 字节设备实例 ID、策略值
CRC162 字节从帧头到数据体的校验值

CRC16 的 C# 实现如下,多项式是 0xA001,初始值 0xFFFF,这是 Modbus CRC 的标准参数:

static ushort Crc16(byte[] buffer, int offset, int length) { ushort crc = 0xFFFF; for (int i = offset; i < offset + length; i++) { crc ^= buffer[i]; for (int bit = 0; bit < 8; bit++) { crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1); } } return crc; }

代码逻辑:外层循环遍历每个字节,内层循环处理 8 个 bit,按位异或后做右移和多项式回退。发送端和接收端用同一套实现即可完成校验。如果走 Modbus 协议,CRC 由库内部处理,这段可以不用。

4.2 用 SerialPort 与 NModbus4 读取设备保持寄存器

设备端如果支持标准 Modbus,PS/2 开关状态通常会映射到保持寄存器。用 NModbus4 读取的代码很短:

using Modbus.Device; using System.IO.Ports; using var port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); port.Open(); var master = ModbusSerialMaster.CreateRtu(port); byte slaveAddress = 1; // 从站地址 ushort startAddress = 0; // 对应 40001 ushort numRegisters = 4; // 读 4 个保持寄存器 ushort[] regs = master.ReadHoldingRegisters(slaveAddress, startAddress, numRegisters); bool ps2KeyboardEnabled = regs[0] != 0; // 40001: PS/2 键盘策略 bool ps2MouseEnabled = regs[1] != 0; // 40002: PS/2 鼠标策略

寄存器地址的含义要和设备点位表对应,我一般把 40001 固定为 PS/2 键盘开关状态,40002 固定为鼠标开关状态,写进接口文档里。串口参数方面,工业场景 9600 波特率最稳,115200 在长线上容易出现误码,除非线缆很短并且做了屏蔽。

4.3 循环采集数据不卡 UI:Task.Run 与 BeginInvoke

数据采集是长周期的,如果直接在 Timer 事件里跑串口读,UI 会被串口等待时间卡住。我的做法是定时器只负责触发,真正耗时操作放进 Task.Run,结果通过 BeginInvoke 回写。

public sealed class DeviceMonitor : IDisposable { private readonly CancellationTokenSource _cts = new(); private System.Windows.Forms.Timer? _timer; public void Start() { _timer = new System.Windows.Forms.Timer { Interval = 500 }; _timer.Tick += async (_, _) => await PollOnceAsync(); _timer.Start(); } private async Task PollOnceAsync() { var data = await Task.Run(ReadFromDevice); dataGridView1.BeginInvoke(new Action(() => { for (int i = 0; i < data.Length && i < dataGridView1.Rows.Count; i++) { dataGridView1.Rows[i].Cells["colStatus"].Value = data[i]; } })); } }

这里的关键是:Timer 的 Tick 在 UI 线程触发,但 ReadFromDevice 里的 SerialPort 读写发生在线程池线程,UI 线程只做一次 BeginInvoke 回调更新单元格。BeginInvoke 是异步的,不会等 UI 绘制完成才返回,所以高频轮询也不会堆积卡顿。这是 WinForm 上位机开发里最实用的一套刷新模型。

5. preventps2 生效后的验证技巧:问题码 22 是金标准

5.1 用 CM_Get_DevNode_Status 验证,不是看“设备不存在”

禁用后最容易犯的错是用“设备消失了”来判断成功。实际上 PS/2 设备禁用后实例仍然在,只是启动标志被清掉、问题码变成 22。判断代码很简单:

uint status, problem = 0; int hr = CfgMgr.CM_Get_DevNode_Status(out status, out problem, devInst, 0); bool disabled = (hr == 0) && (problem == 22); // CM_PROB_DISABLED

记住这个区分:CM_Locate_DevNode 失败是设备真正不存在;CM_Get_DevNode_Status 返回 problem=22 才是策略禁用生效。验收脚本里只需要前者报错、后者写日志,就能精确区分硬件故障和策略状态。

5.2 回归脚本:每 10 秒轮询,防止被设备管理器重新启用

设备管理器里可以手动启用被禁用的设备,这对安全策略是漏洞。我在终端代理里做了一个守护轮询:定时扫描白名单外的 PS/2 设备,发现被启用就再次禁用,并写入 Windows 事件日志。

using System.Diagnostics; while (!stoppingToken.IsCancellationRequested) { foreach (string instanceId in GetPs2DeviceIds()) { if (!IsDeviceDisabled(instanceId)) { SetDeviceEnabled(instanceId, false); EventLog.WriteEntry("DeviceGuard", $"PS/2 设备被重新启用,已自动禁用: {instanceId}", EventLogEntryType.Warning, 1001); } } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); }

轮询参数我固定成三个常量:PollInterval=10s,FailureRetry=3,LogChannel=Application。10 秒对人工操作足够敏感,对终端 CPU 占用可以忽略;连续失败 3 次才告警,避免设备枚举抖动造成误报;日志里记录 instanceId、oldStatus、newStatus 和执行时间,配合 Windows 事件日志一起使用,后续审计时能精确还原每次策略变更的链路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询