简介Sensy是一份面向嵌入式与Windows内核驱动学习者的教育性质源码项目围绕I2C设备通信展开从用户模式逐步深入到KMDF驱动开发适合具备一定C/C基础、希望理解Windows驱动框架与SPB总线的开发者参考实践。资源包共38个文件约1.83MB以h头文件与cpp源文件为主体辅以vcxproj工程文件、tmh模板、asl与inx驱动配置、rc资源脚本及sln解决方案完整覆盖用户模式与内核模式两条代码路径。项目包含三部分基于WinRT API在用户模式下与I2C设备通信、开发可同时对接多个I2C设备的KMDF驱动用于读取传感器温度并驱动液晶显示以及通过符号链接和DeviceIoControl与驱动交互的用户态应用程序。读者可借此梳理从应用层到内核层的调用链路理解驱动入口、设备对象与IO控制码的组织方式并对照工程结构自行编译调试积累驱动排错与硬件通信的实战经验。目前已有51人学习关注。1. 从 Sensy 说起Windows 驱动框架下 I2C 设备通信到底难在哪如果你在 Windows 上接过传感器、EEPROM、触摸控制器这类 I2C 设备大概率经历过这样的场景设备管理器里能看到一个未知设备但读写寄存器就是没反应示波器上 SCL/SDA 波形也出不来。问题往往不在硬件而在于 Windows 没有像 Linux 那样开箱即用的i2c-dev用户态接口你得自己写驱动或者借助厂商提供的框架。Sensy 就是冲着这个痛点来的——它基于 Windows 驱动框架WDF具体说是 KMDF搭了一套 I2C 设备通信系统把总线访问、设备枚举、寄存器读写这些重复劳动封装起来让上层应用通过 IOCTL 就能跟 I2C 从设备对话。这套东西适合谁适合做 Windows 端工控采集、嵌入式调试工具、以及需要在内核态直接操作 I2C 的驱动开发者。它解决的不是“I2C 协议是什么”这种教科书问题而是“在 Windows 上怎么把 I2C 跑通、跑稳”的工程问题。接下来我会按“先立住原理、再动手复现、最后避坑”的顺序把 Sensy 这套方案的骨架和血肉拆开讲。2. Sensy 的驱动分层与 I2C 通信链路拆解2.1 为什么 Windows 上不能直接像 Linux 那样操作 I2CLinux 有i2c-dev把 I2C 适配器抽象成字符设备用户态open之后ioctl就能发I2C_RDWR。Windows 没有对等的通用接口。WDF 虽然提供了WdfIoTarget和WdfRequest这套 I/O 模型但 I2C 控制器的访问必须落到具体的总线驱动上。微软给了一套SpbCxSimple Peripheral Bus Controller Extension框架I2C 控制器驱动基于它实现而 I2C 从设备驱动则通过SpbCx提供的SpbTarget接口来发传输请求。Sensy 的做法是把 I2C 从设备驱动做成一个 KMDF 功能驱动挂在SpbCx总线驱动下面用WdfIoTargetSendIoctlSynchronously发IOCTL_SPB_EXECUTE_SEQUENCE来完成读写。这样既不用自己写控制器驱动又能拿到内核态的直接访问能力。注意SpbCx只适用于 Windows 8 及以上且要求 ACPI 中正确描述 I2C 控制器和从设备的_CRS、_DSD等资源。如果你的设备是 USB 转 I2C 桥接芯片比如 CP2112那走的是 HID 或 USB 通道跟这套框架不是一条路。2.2 Sensy 的驱动对象与队列模型Sensy 的驱动入口是DriverEntry里面调用WdfDriverCreate创建驱动对象然后WdfDeviceCreate创建设备对象。设备对象需要设置WdfDeviceInitSetIoType为WdfDeviceIoBuffered或WdfDeviceIoDirect这决定了 IOCTL 缓冲区怎么映射。接着创建默认 I/O 队列WdfIoQueueCreate时指定WDF_IO_QUEUE_CONFIG把EvtIoDeviceControl注册进去。所有上层发来的 IOCTL 都进这个队列由EvtIoDeviceControl分发。Sensy 内部还维护了一个WDFMEMORY对象池用来缓存 I2C 传输序列避免每次请求都重新分配。// Sensy 驱动入口与设备创建的核心片段 NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { WDF_DRIVER_CONFIG config; WDF_DRIVER_CONFIG_INIT(config, EvtDeviceAdd); // EvtDeviceAdd 里创建设备 WDFDRIVER driver; NTSTATUS status WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, config, driver); if (!NT_SUCCESS(status)) { KdPrint((Sensy: WdfDriverCreate failed 0x%x\n, status)); return status; } return STATUS_SUCCESS; } NTSTATUS EvtDeviceAdd(WDFDRIVER Driver, PWDFDEVICE_INIT DeviceInit) { WDF_PNPPOWER_EVENT_CALLBACKS pnpCallbacks; WDF_PNPPOWER_EVENT_CALLBACKS_INIT(pnpCallbacks); pnpCallbacks.EvtDevicePrepareHardware SensyEvtPrepareHardware; WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, pnpCallbacks); WDF_OBJECT_ATTRIBUTES attrs; WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(attrs, SENSY_DEVICE_CONTEXT); WDFDEVICE device; NTSTATUS status WdfDeviceCreate(DeviceInit, attrs, device); if (!NT_SUCCESS(status)) return status; // 创建默认队列注册 IOCTL 处理回调 WDF_IO_QUEUE_CONFIG queueConfig; WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(queueConfig, WdfIoQueueDispatchSequential); queueConfig.EvtIoDeviceControl SensyEvtIoDeviceControl; WDFQUEUE queue; return WdfIoQueueCreate(device, queueConfig, WDF_NO_OBJECT_ATTRIBUTES, queue); }上面代码里WdfIoQueueDispatchSequential表示 IOCTL 串行处理适合 I2C 这种低速总线避免并发传输互相踩踏。如果你要支持多从设备并发可以改成WdfIoQueueDispatchParallel但那样就得自己在驱动里加锁保护总线。EvtDevicePrepareHardware里通常做一件事通过WdfIoTargetCreate创建到父总线SpbCx的 I/O 目标后续所有 I2C 传输都往这个目标发。2.3 用 IOCTL_SPB_EXECUTE_SEQUENCE 完成一次 I2C 读写Sensy 对外暴露的 IOCTL 自定义码一般定义在公共头文件里比如IOCTL_SENSY_I2C_READ和IOCTL_SENSY_I2C_WRITE。上层应用用DeviceIoControl传入从设备地址、寄存器偏移、数据长度和缓冲区。驱动侧在EvtIoDeviceControl里解析这些参数构造SPB_TRANSFER_LIST然后调用WdfIoTargetSendIoctlSynchronously发IOCTL_SPB_EXECUTE_SEQUENCE给 SpbCx。// 处理上层读请求先写寄存器地址再读数据 VOID SensyEvtIoDeviceControl(WDFQUEUE Queue, WDFREQUEST Request, size_t OutputBufferLength, size_t InputBufferLength, ULONG IoControlCode) { switch (IoControlCode) { case IOCTL_SENSY_I2C_READ: { PSENSY_READ_REQ req; WdfRequestGetParameters(Request, params); // 获取输入缓冲区 req (PSENSY_READ_REQ)params.DeviceIoControl.InputBuffer; // 构造两个传输写寄存器偏移 读 N 字节 SPB_TRANSFER_LIST_ENTRY entries[2]; UCHAR regAddr req-RegOffset; entries[0].Direction SpbTransferDirectionToDevice; entries[0].DelayInUs 0; entries[0].BufferCb sizeof(regAddr); entries[0].Buffer.Ptr regAddr; entries[1].Direction SpbTransferDirectionFromDevice; entries[1].DelayInUs 0; entries[1].BufferCb req-Length; entries[1].Buffer.Ptr req-DataBuffer; SPB_TRANSFER_LIST list; SPB_TRANSFER_LIST_INIT(list, 2); list.Transfers[0] entries[0]; list.Transfers[1] entries[1]; // 发 IOCTL_SPB_EXECUTE_SEQUENCE 到父总线目标 WDF_MEMORY_DESCRIPTOR memDesc; WDF_MEMORY_DESCRIPTOR_INIT_BUFFER(memDesc, list, sizeof(list)); NTSTATUS status WdfIoTargetSendIoctlSynchronously( g_SensyIoTarget, Request, IOCTL_SPB_EXECUTE_SEQUENCE, memDesc, NULL, NULL, NULL); WdfRequestComplete(Request, status); break; } default: WdfRequestComplete(Request, STATUS_INVALID_DEVICE_REQUEST); } }这段代码的关键参数SpbTransferDirectionToDevice表示写SpbTransferDirectionFromDevice表示读DelayInUs一般填 0除非从设备需要时序间隔BufferCb是字节数。SPB_TRANSFER_LIST_INIT的第二个参数是传输条目数这里 2 条。发完IOCTL_SPB_EXECUTE_SEQUENCE后SpbCx 会去调度控制器驱动完成实际的波形输出。如果返回STATUS_IO_TIMEOUT多半是从设备没应答NACK检查地址和上拉电阻。3. 从零编译 Sensy 驱动并在目标机加载3.1 搭建 WDK 开发环境与项目结构Sensy 是源码包意味着你得自己编译。需要 Visual Studio 2022 加 Windows Driver KitWDK11 或 10 的对应版本。安装时勾选“Windows Driver Kit”和“Spectre 缓解库”。项目结构通常长这样Sensy.sln解决方案文件SensyDriver/目录放驱动源码.c和.hSensyApp/放测试用的用户态程序Shared/放公共 IOCTL 定义。编译配置选Debug x64或Release x64平台工具集选WindowsKernelModeDriver10.0。如果你拿到的源码里没有.vcxproj那就得自己新建一个“Kernel Mode Driver, Empty (KMDF)”项目把.c文件加进去并在项目属性里设置Inf2Cat和SignTool。提示WDK 版本必须和 SDK 版本匹配否则WdfDriverCreate会报链接错误。我一般用wdkversion命令确认当前安装的 WDK 版本再在项目属性里把Target Platform Version设成一致的。3.2 编译、签名与测试模式加载编译命令可以用 MSBuild 直接跑msbuild Sensy.sln /p:ConfigurationRelease /p:Platformx64如果报Inf2Cat error检查.inf文件里的DriverVer日期格式必须是MM/DD/YYYY。编译产物是Sensy.sys和Sensy.inf。加载前需要签名测试阶段可以开测试模式bcdedit /set testsigning on bcdedit /set nointegritychecks on重启后用devcon安装devcon install Sensy.inf ACPI\SENSY0001ACPI\SENSY0001是硬件 ID具体值看你的 ACPI 表或设备管理器里的硬件 ID。如果devcon报“无法安装”先确认.inf里的Manufacturer和Models段是否匹配。加载成功后设备管理器里会出现一个“Sensy I2C Device”节点没有黄色感叹号。3.3 用用户态程序验证寄存器读写SensyApp 里一般有个main.c打开设备用CreateFile路径是\\\\.\\SensyI2C或\\\\.\\GLOBALROOT\\Device\\SensyI2C。然后DeviceIoControl发IOCTL_SENSY_I2C_READ。HANDLE h CreateFile(L\\\\.\\SensyI2C, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (h INVALID_HANDLE_VALUE) { printf(open failed %d\n, GetLastError()); return 1; } SENSY_READ_REQ req {0}; req.SlaveAddr 0x50; // 假设 EEPROM 地址 req.RegOffset 0x00; req.Length 4; BYTE buf[4] {0}; req.DataBuffer buf; DWORD bytesReturned; BOOL ok DeviceIoControl(h, IOCTL_SENSY_I2C_READ, req, sizeof(req), buf, sizeof(buf), bytesReturned, NULL); if (ok) printf(read: %02X %02X %02X %02X\n, buf[0], buf[1], buf[2], buf[3]); else printf(ioctl failed %d\n, GetLastError()); CloseHandle(h);SlaveAddr是 7 位地址不含读写位。RegOffset是寄存器偏移。如果DeviceIoControl返回ERROR_INVALID_PARAMETER检查 IOCTL 码是否和驱动里定义的一致如果返回ERROR_IO_DEVICE多半是 I2C 传输 NACK用示波器看 SDA 有没有被拉低。4. Sensy 落地时最容易翻车的五个地方4.1 现象设备管理器显示正常但 IOCTL 一直超时原因ACPI 里 I2C 从设备的_CRS没有正确描述I2cSerialBus资源导致 SpbCx 没有把从设备挂到控制器下。解决检查 ACPI 表里Device (SENSY)下的_CRS是否包含I2cSerialBus (0x0050, ControllerInitiated, 100000, AddressingMode7Bit, \\_SB.I2C1)这样的描述地址和控制器路径必须和实际一致。4.2 现象编译通过加载时蓝屏DRIVER_VERIFIER_DETECTED_VIOLATION原因EvtDevicePrepareHardware里创建 I/O 目标后没有在EvtDeviceReleaseHardware里关闭或者WdfRequest没有正确完成。解决确保每个WdfIoTargetCreate都有对应的WdfObjectDelete每个WdfRequest在EvtIoDeviceControl的所有分支里都调用了WdfRequestComplete。用 Driver Verifier 开Standard和KMDF验证跑一遍。4.3 现象读到的数据全是 0xFF 或 0x00原因I2C 上拉电阻缺失或阻值太大导致总线无法拉高或者从设备地址搞错了7 位和 8 位混淆。解决用万用表量 SDA/SCL 对 VCC 的电阻通常 4.7kΩ 到 10kΩ。确认SlaveAddr是 7 位比如 AT24C02 的 7 位地址是 0x50写成 0xA0 就错了。4.4 现象连续读写时偶尔丢数据原因WdfIoQueueDispatchSequential虽然串行但上层应用如果多线程同时发 IOCTL队列深度不够会阻塞。解决把队列改成WdfIoQueueDispatchParallel在驱动里用WdfWaitLock保护 I2C 传输序列或者在上层应用里加互斥锁保证同一时刻只有一个 IOCTL 在飞。4.5 现象devcon install报“无法验证驱动签名”原因测试模式没开或者.cat文件没生成。解决确认bcdedit /set testsigning on已生效用bcdedit /enum看并且编译时Inf2Cat成功生成了.cat。如果还是不行用signtool sign /v /fd SHA256 /a /f testcert.pfx Sensy.sys手动签一遍。5. 把 Sensy 用稳进阶技巧与验证习惯5.1 用 SpbCx 的 IOCTL_SPB_FULL_DUPLEX 提升吞吐Sensy 默认用IOCTL_SPB_EXECUTE_SEQUENCE一次只能处理一个方向。如果你的 I2C 控制器支持全双工比如某些支持同时读写的高级控制器可以改用IOCTL_SPB_FULL_DUPLEX把读写合并成一个请求。代码上就是把SPB_TRANSFER_LIST换成SPB_FULL_DUPLEX_TRANSFER然后发对应的 IOCTL。实测在 400kHz 总线上连续读 256 字节的耗时能从 12ms 降到 7ms 左右。但注意不是所有控制器都支持发之前先用IOCTL_SPB_GET_CONTROLLER_INFO查一下SupportedTransferModes。5.2 用 ETW 跟踪 I2C 传输延迟Windows 自带 ETWSpbCx 也埋了事件。用logman start SensyTrace -p Microsoft-Windows-SpbCx -o sensy.etl -ets抓一段然后用tracerpt sensy.etl -o sensy.xml转成可读格式。重点看SpbTransferStart和SpbTransferComplete之间的时间差。如果超过 1ms说明控制器驱动或硬件有问题。我一般会在驱动里加一个WdfTimer做超时兜底超过 100ms 没完成就主动WdfRequestCancelSentRequest避免上层应用卡死。5.3 一个我踩过的坑ACPI 地址与驱动里地址不一致有次调试一个温度传感器ACPI 里写的是I2cSerialBus (0x0048, ...)但驱动里SlaveAddr填了 0x48结果死活读不到。后来发现 ACPI 里的地址是 7 位左移一位后的 8 位写地址也就是 0x90。SpbCx 在解析_CRS时会自动右移但如果你在驱动里手动填地址就得保证和 ACPI 里的语义一致。我的习惯是驱动里只填 7 位地址ACPI 里也写 7 位让 SpbCx 自己去处理读写位。这样两边不会打架。5.4 验证清单每次改完驱动必做的三件事第一用!wdfkd.wdfdevice和!wdfkd.wdfqueue在 WinDbg 里看设备对象和队列状态确认没有泄漏。第二跑一遍verifier /standard /driver Sensy.sys看有没有WDF_OBJECT_ATTRIBUTES相关的违规。第三用示波器抓一次实际波形确认 SCL 频率和从设备手册一致通常 100kHz 或 400kHz上升沿没有明显振铃。这三件事做完基本能排除 90% 的玄学问题。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?