简介:这是一套基于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 | 含义 | 设备管理器里常见名称 |
|---|---|---|
| PNP0303 | 8042 兼容 PS/2 键盘控制器 | Standard PS/2 Keyboard |
| PNP0F13 | PS/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、策略值 |
| CRC16 | 2 字节 | 从帧头到数据体的校验值 |
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 事件日志一起使用,后续审计时能精确还原每次策略变更的链路。
本文还有配套的精品资源,点击获取