简介:本资源是一套面向嵌入式与Windows内核开发初学者的实践型源码项目,聚焦I2C设备在Windows平台下的全栈通信实现,适用于高校电子/计算机专业学生、驱动开发入门者及对KMDF和SPB总线机制感兴趣的工程师。项目完整覆盖用户态WinRT API调用、KMDF内核驱动开发(支持多I2C设备接入与传感器数据读取)、以及配套用户模式应用程序(通过DeviceIoControl与驱动交互),具备清晰的学习路径与工程可复现性。压缩包共38个文件,含12个头文件(定义常量与接口)、9个C++源文件(分属winrtapi/kmdf/exe三层架构)、3个Visual Studio工程文件(sln/vcxproj/filters)及ASL固件描述、驱动INF配置等关键构建要素,整体体积仅1.83MB,轻量易导入。目前已有51人学习下载,读者可直接获取从硬件抽象、驱动建模到应用层控制的完整代码结构、模块化目录设计及README说明文档,是理解Windows嵌入式外设通信机制的优质教学范例。
1. 这不是“Windows 上跑 I2C”那么简单:Sensy 是一套可调试、可验证、能进蓝屏现场的 KMDF I2C 驱动教学闭环
你有没有试过在 Windows 上直接读一个温湿度传感器?不是用 Arduino 或树莓派,而是真正在 Win10/Win11 的 x64 系统里,从内核态发起 I2C transaction,把数据拿回来,再通过 DeviceIoControl 推给用户程序,最后在 WinRT UI 上显示——全程不依赖第三方 USB 转接器、不走 HID 模拟、不绕开 ACPI SPB 总线规范。Sensy 就是干这个的。它不是 demo,不是玩具,而是一套完整覆盖「用户态 WinRT 通信 → 内核态 KMDF 驱动开发 → SPB 总线抽象 → ACPI DSDT 适配 → 用户模式控制台交互」的嵌入式驱动教学链路。项目里带的rpi2_dsdt.ASL不是摆设,asl.exe和winrtapi/下的I2CDisplay.cpp是真实联动的;sensy.sys编译出来能加载、能枚举、能响应IOCTL_SENSY_READ_TEMP;而main.cpp里那几行CreateFile(L"\\\\.\\Sensy", ...)也不是示意代码——它真能打开设备、发控制码、收回 4 字节浮点温度值。适合谁?正在啃《Windows Driver Development》第 7 章却卡在 SPB 初始化的某开发者;想把 STM32 传感器板子直连 Surface Pro 做边缘采集但被 INF 签名拦住的某实验室成员;或者刚学完 WDK 构建流程、急需一个“改三行就能看到蓝屏/成功日志”的最小可运行 KMDF 实体。这不是教你怎么装驱动,是教你怎么让驱动自己开口说话。
2. 从 WinRT API 入手:为什么 Sensy 选择先做用户态 I2C 通信,而不是一上来就写 .sys?
Sensy 的设计逻辑很务实:先确认硬件通路存在,再封装成内核服务。很多初学者一上来就猛扎 KMDF,结果驱动加载成功、设备管理器里也出现黄色感叹号,但WdfIoQueueCreate后死活等不到EvtIoDefault回调——根本原因是:I2C 总线压根没被系统识别,SPB 控制器驱动没加载,或者 ACPI 表里缺_HID或_CID。Sensy 把 WinRT API 作为第一道探针,就是因为它足够轻、足够快、足够“无感”:只要你的主板支持 UEFI + ACPI SPB(Intel 平台常见于 2015 年后,AMD 平台需确认芯片组是否含 SPB Host Controller),且 BIOS 开启了相关接口(如 “I2C Controller Enable”),WinRT 的I2cDevice.FromIdAsync()就能直接列出可用总线并完成一次读写。这步成功了,说明:① 硬件物理连接 OK;② ACPI DSDT 描述正确;③ Windows 已加载spb.sys和i2cminidriver.sys;④ 用户权限足够(无需管理员)。这才是启动 KMDF 开发的黄金前提。
2.1 WinRT I2C 通信实操:用I2CDisplay.cpp验证 SPB 总线可达性
项目中src/winrtapi/I2CDisplay.cpp是完整的 WinRT 用户态 I2C 测试入口。它不依赖任何第三方库,只引用<windows.devices.i2c.h>和<ppltasks.h>,编译目标为Windows Application (Universal Windows)。关键逻辑如下:
// I2CDisplay.cpp 核心片段(已精简注释) #include <windows.devices.i2c.h> #include <ppltasks.h> using namespace Windows::Devices::I2c; using namespace concurrency; void TestI2cRead() { auto selector = I2cDevice::GetDeviceSelector(); // 获取系统所有 I2C 总线 ID 列表 create_task(DeviceInformation::FindAllAsync(selector)).then([](DeviceInformationCollection^ devices) { if (devices->Size == 0) { OutputDebugString(L"[ERR] No I2C bus found. Check ACPI DSDT or BIOS setting.\n"); return; } // 取第一个总线(通常为 SPB0) auto busId = devices->GetAt(0)->Id; auto settings = ref new I2cConnectionSettings(0x40); // 假设传感器地址为 0x40 settings->BusSpeed = I2cBusSpeed::StandardMode; // 必须匹配硬件规格 create_task(I2cDevice::FromIdAsync(busId, settings)).then([](I2cDevice^ device) { if (device == nullptr) { OutputDebugString(L"[ERR] Failed to open I2C device at 0x40.\n"); return; } Platform::Array<uint8_t>^ writeBuf = ref new Platform::Array<uint8_t>({0x00}); // 寄存器地址 Platform::Array<uint8_t>^ readBuf = ref new Platform::Array<uint8_t>(2); try { device->Write(writeBuf); // 发起寄存器地址 device->Read(readBuf); // 读取 2 字节数据 float temp = (readBuf[0] << 8 | readBuf[1]) / 100.0f; wchar_t buf[64]; swprintf_s(buf, L"[OK] Temp: %.2f°C\n", temp); OutputDebugString(buf); } catch (Platform::Exception^ e) { OutputDebugString(L"[ERR] I2C transaction failed.\n"); } }); }); }参数说明与逻辑拆解
I2cDevice::GetDeviceSelector()返回的是系统级总线枚举器,不是设备地址。它查的是ACPI\SPB*类型的总线实例,对应spb.sys加载的控制器。若返回空,90% 是 DSDT 缺失或 BIOS 关闭 I2C 控制器。I2cConnectionSettings(0x40)中的0x40是目标传感器的 7-bit 地址(如 HTU21D),必须与硬件 DIP 开关或上拉电阻配置一致;若用 8-bit 地址(如0x80),会直接失败。BusSpeed必须严格匹配传感器手册要求:StandardMode(100kHz)、FastMode(400kHz)或 FastPlus(1MHz)。Windows 默认只支持前两者,FastPlusMode在部分平台会静默降级,导致读数错位。Write()+Read()是原子操作,但 WinRT 不暴露 START/STOP 控制权。若传感器要求“重复启动”(Repeated Start),此方式会失败,需切到 KMDF 手动构造 transaction。
2.2 为什么不用libusb或i2c-tools?WinRT 的边界在哪?
有人会问:既然 WinRT 能读,为啥还要费劲写 KMDF 驱动?答案藏在三个硬限制里:
①地址空间隔离:WinRT I2C API 只允许访问ACPI\SPB*总线下挂载的设备,且设备必须在 DSDT 中明确定义_HID(如"MSFT0001")和_CID(如"PNP0C50")。你无法用它去碰 SMBus、GPIO 模拟 I2C 或自定义 PCIe I2C IP 核。
②事务粒度锁定:WinRT 强制单次Write()+ 单次Read(),不支持多寄存器连续读(如0x00→0x01→0x02)、不支持写后立即读(Write-Read combined)、不支持 clock stretching 处理。而工业传感器(如 BME280)常需此类操作。
③无中断支持:WinRT 是轮询模型,无法注册OnDataReceived事件。你想做“温度超阈值立刻通知”,只能靠用户程序每 100ms 主动Read(),功耗和延迟都不可控。
Sensy 的价值,正是用 WinRT 快速验明“这条路能走”,再用 KMDF 把路修宽、修深、修出红绿灯和应急通道。
3. KMDF 驱动核心:SPB 总线抽象与多设备复用的Sensy.sys架构设计
Sensy 的.sys文件不是传统意义上的“独占 I2C 控制器驱动”,而是一个SPB Function Driver—— 它不直接操作硬件寄存器,而是通过 Windows 提供的SpbCx(SPB Class Extension)与底层spb.sys交互。这种分层设计是微软推荐的现代做法,也是它能同时管理多个 I2C 设备(如温度传感器 + OLED 显示屏)的关键。驱动主体位于src/kmdf/,核心文件是Sensy.cpp(主框架)、SensySpbTarget.cpp(SPB 通信封装)和SensyIoctl.h(控制码定义)。整个架构规避了直接操作 MMIO、处理中断向量、编写 HAL 层代码等高危操作,把复杂度锁死在 KMDF 框架内。
3.1 SPB Target 封装:如何用SpbCx统一管理不同 I2C 设备?
KMDF 驱动中,每个物理 I2C 设备(如地址0x40的温感、0x3C的 OLED)在驱动内表现为一个WDFDEVICE对象,其背后绑定一个SPB_TARGET句柄。Sensy 通过SensySpbTarget.cpp封装了创建、读写、清理全流程:
// SensySpbTarget.cpp 片段:创建 SPB target 并缓存句柄 NTSTATUS SensySpbTargetCreate( _In_ WDFDEVICE Device, _In_ PCUNICODE_STRING DeviceId, _Out_ PSPB_TARGET* SpbTarget ) { SPB_TARGET_CONFIG config; SPB_TARGET_CONFIG_INIT(&config); config.DeviceId = DeviceId; // 来自 ACPI _ADR 或 INF 中指定的设备 ID config.RequestType = SpbRequestTypeI2c; config.I2cAddress = 0x40; // 此处应从设备资源中读取,非硬编码! NTSTATUS status = SpbTargetCreate( Device, &config, WDF_NO_OBJECT_ATTRIBUTES, SpbTarget ); if (!NT_SUCCESS(status)) { KdPrint(("Sensy: SpbTargetCreate failed for %wZ, status=0x%08X\n", DeviceId, status)); return status; } // 关键:将 SPB target 句柄挂到 WDFDEVICE 的 Context 中,供后续 IOCTL 使用 PSensyDeviceContext pCtx = GetSensyDeviceContext(Device); pCtx->SpbTarget = *SpbTarget; return STATUS_SUCCESS; }为什么
I2cAddress不能硬编码?
Sensy 支持多设备,地址必须从 ACPI DSDT 的_CRS(Current Resource Settings)中解析。例如 DSDT 片段:Device (TMP0) { Name (_HID, "MSFT0001") Name (_CID, "PNP0C50") Name (_ADR, 0x0000000040000000) // 低 8 位即 I2C 地址 0x40 Name (_CRS, ResourceTemplate () { I2cSerialBus (0x40, ControllerInitiated, 0x00061A80, AddressingMode7Bit, "\\_SB.PCI0.SPB0") }) }驱动在
EvtDeviceAdd中调用WdfFdoQueryForInterface()获取SPB_BUS_INTERFACE_STANDARD,再用SpbTargetCreate()传入_ADR或_CRS解析出的地址。硬编码0x40只适用于单设备调试,量产必须动态获取。
3.2 IOCTL 控制码设计:IOCTL_SENSY_READ_TEMP如何穿透三层进入 SPB?
用户程序通过DeviceIoControl()发送控制码,驱动在EvtIoDeviceControl中分发。Sensy 定义了 4 个核心 IOCTL(见SensyIoctl.h):
| IOCTL 名称 | 功能 | 数据流向 |
|---|---|---|
IOCTL_SENSY_READ_TEMP | 读取温度传感器原始值 | 驱动 → 用户缓冲区(4 字节 float) |
IOCTL_SENSY_WRITE_DISPLAY | 向 OLED 写入 128×64 像素缓冲区 | 用户缓冲区(1024 字节) → 驱动 → SPB |
IOCTL_SENSY_GET_DEVICE_INFO | 返回当前 SPB 总线 ID 和设备地址 | 驱动 → 用户缓冲区(结构体) |
IOCTL_SENSY_SET_SAMPLE_RATE | 设置采样间隔(毫秒) | 用户缓冲区(uint32) → 驱动(更新定时器) |
以IOCTL_SENSY_READ_TEMP为例,其内核执行链为:main.cpp→CreateFile("\\\\.\\Sensy")→DeviceIoControl(hDev, IOCTL_SENSY_READ_TEMP, NULL, 0, &temp, sizeof(float), &bytes, NULL)
↓Sensy.cpp:EvtIoDeviceControl()匹配到IOCTL_SENSY_READ_TEMP
↓
调用SensySpbTargetReadTemp(pCtx->SpbTarget, &rawData)
↓SensySpbTargetReadTemp()构造SPB_REQUEST:
-RequestType = SpbRequestTypeI2c
-I2cAddress = pCtx->DeviceAddress
-WriteBuffer = {0x00}(读取温度寄存器 0x00)
-ReadBuffer = rawBytes[2](读 2 字节原始值)
↓SpbRequestSubmitSynchronously()提交至spb.sys
↓spb.sys调用i2cminidriver.sys操作硬件寄存器,返回数据
↓
驱动将rawBytes转为 float,拷贝至Irp->AssociatedIrp.SystemBuffer
关键细节:SPB_REQUEST 的生命周期
SpbRequestSubmitSynchronously()是阻塞调用,会占用 IRQL <= DISPATCH_LEVEL 的线程。Sensy 未使用异步SpbRequestSubmit(),因为温度读取是低频操作(默认 1s 一次),同步更易调试。若需高吞吐(如音频 I2S),必须改用异步 +EvtSpbRequestComplete回调,并确保WDFQUEUE配置为Parallel模式。
4. 避坑 / 常见问题 / 排查:Sensy 编译、加载、通信失败的五类血泪现场
Sensy 是教学项目,但它的坑比商用驱动还密集——因为每一步都暴露在开发者眼前。以下是我在三台不同主板(Intel NUC、AMD B550 Mini-ITX、Surface Pro 7)上反复踩出的 5 个高频问题,按现象→原因→解决结构整理,拒绝模糊描述。
4.1 现象:Build succeeded,但sc create sensy type= kernel binPath= C:\sensy.sys后sc start sensy报Error 2: The system cannot find the file specified.
原因:.sys文件路径含中文、空格或长路径,sc工具无法解析;或sensy.sys依赖的WdfLdr.sys未预加载(Windows 10 1809+ 默认启用 Driver Signature Enforcement,未签名驱动无法加载)。
解决:
① 将sensy.sys放入纯英文短路径,如C:\drv\sensy.sys;
② 以管理员身份运行bcdedit /set testsigning on→ 重启 → 进入测试签名模式;
③ 用Inf2Cat和signtool对sensy.inf签名(项目中sensy.inf已提供模板,需替换PublisherName);
④sc create后务必sc qc sensy确认TYPE为kernel、START_TYPE为demand(非auto,避免开机自启失败蓝屏)。
4.2 现象:驱动加载成功(sc query sensy显示RUNNING),但main.cpp中CreateFile("\\\\.\\Sensy")返回INVALID_HANDLE_VALUE,GetLastError()为2(ERROR_FILE_NOT_FOUND)
原因:符号链接未创建。KMDF 驱动必须在EvtDeviceAdd中调用WdfDeviceCreateSymbolicLink(),否则用户程序找不到设备对象。Sensy 的Sensy.cpp第 127 行有该调用,但若WdfDeviceCreateSymbolicLink()返回失败(如链接名已被占用),驱动不会报错退出,而是静默跳过。
解决:
① 在EvtDeviceAdd中添加日志:KdPrint(("Sensy: Creating symbolic link %wZ\n", &linkName));;
② 检查linkName是否为L"\\??\\Sensy"(注意双反斜杠);
③ 运行winobj.exe(Sysinternals 工具),导航到\??\目录,确认Sensy链接是否存在且指向\Device\Sensy0;
④ 若存在冲突,卸载旧驱动:sc delete sensy→devcon remove @ROOT#SENSY#0→ 清理注册表HKLM\SYSTEM\CurrentControlSet\Services\sensy。
4.3 现象:WinRTI2cDevice::FromIdAsync()成功,但 KMDF 中SpbTargetCreate()失败,状态码0xC0000001(STATUS_UNSUCCESSFUL)
原因:ACPI DSDT 中设备节点的_HID或_CID不被 Windows SPB 驱动识别。Sensy 依赖MSFT0001或PNP0C50,若你修改 DSDT 时误写为MSFT0002或ACPI0001,spb.sys就不会将该设备暴露给SpbTargetCreate()。
解决:
① 用acpidump.exe导出当前 DSDT,搜索设备节点的_HID值;
② 确认spb.sys支持的 HID 列表(可通过strings spb.sys | findstr "MSFT\|PNP"查看);
③ 修改 DSDT,确保_HID为MSFT0001,_CID为PNP0C50;
④ 重新编译 DSDT(asl.exe rpi2_dsdt.ASL),用iasl工具注入(需关闭 Secure Boot)。
4.4 现象:IOCTL_SENSY_READ_TEMP返回成功,但读出的float值恒为0.00或乱码(如1.4013e-45)
原因:SPB transaction 中WriteBuffer和ReadBuffer长度/顺序错误。I2C 传感器读取通常需两步:先Write寄存器地址(1 字节),再Read数据(N 字节)。若WriteBuffer为空或ReadBuffer大小不足,SpbRequestSubmitSynchronously()会静默返回成功,但ReadBuffer未被填充。
解决:
① 在SensySpbTargetReadTemp()中打印WriteBuffer.Length和ReadBuffer.Length;
② 确保WriteBuffer至少 1 字节(存放寄存器地址),ReadBuffer大小匹配传感器手册(如 HTU21D 为 2 字节,BME280 为 3 字节);
③ 检查SPB_REQUEST结构体初始化:Request->WriteBuffer.Length必须等于WriteBuffer实际长度,不能为 0。
4.5 现象:驱动加载后设备管理器中显示“Sensy”带黄色感叹号,属性页提示“Windows 无法验证此设备的数字签名”
原因:驱动未签名,且系统未启用测试签名模式;或签名证书链不完整(如仅用自签名证书,未包含中间 CA)。
解决:
① 最简方案:bcdedit /set testsigning on→ 重启(仅限开发机);
② 生产方案:申请 EV 代码签名证书,用signtool sign /v /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /a sensy.sys签名;
③ 验证签名:signtool verify /pa sensy.sys,输出必须含Successfully verified;
④ 若用Inf2Cat生成.cat文件,需确保sensy.inf中[Version]段的CatalogFile=sensy.cat与生成文件名一致,且.cat文件与.sys同目录。
5. 从 DSDT 注入到实时调试:用TraceView抓取 SPB 通信波形与驱动状态流转
Sensy 的最大教学价值,不是让你写出一个能跑的驱动,而是让你看清“数据从传感器引脚进来,到用户程序变量赋值”之间每一帧发生了什么。这需要两套工具协同:ACPI DSDT 注入用于硬件描述落地,WPP 跟踪 + TraceView用于内核行为可视化。我一般会强制走一遍这套组合技,哪怕只是验证一行KdPrint是否生效。
5.1 DSDT 注入实战:把rpi2_dsdt.ASL改造成你的主板可用版本
rpi2_dsdt.ASL是 Raspberry Pi 2 的参考实现,不能直接用于 x86 主板。改造核心是三步:
①提取原厂 DSDT:用acpidump.exe -t导出当前 BIOS 的DSDT.dat,再用iasl -d DSDT.dat反编译为DSDT.dsl;
②定位 SPB 控制器节点:在DSDT.dsl中搜索SPB0、I2C0或PCI0.SPB0,找到类似结构:
Device (SPB0) { Name (_HID, "INT33C2") // Intel SPB Host Controller Name (_CID, "PNP0C50") Method (_STA, 0, NotSerialized) { Return (0x0F) } ... }③嫁接 Sensy 设备节点:将rpi2_dsdt.ASL中的Device (TMP0)节点复制到DSDT.dsl的SPB0下,并修改_ADR为你的传感器地址(如0x0000000040000000),_HID改为MSFT0001。保存后iasl -tc DSDT.dsl编译为DSDT.aml。
注入注意事项
- 必须关闭 Secure Boot,否则 UEFI 拒绝加载自定义 DSDT;
DSDT.aml需放入 EFI 分区/EFI/Microsoft/Boot/目录,重命名为DSDT.aml(部分主板要求OEM1.aml);- 注入后用
acpidump -t验证新 DSDT 是否生效,搜索TMP0确认节点存在。
5.2 WPP 跟踪配置:让KdPrint变成可过滤、可时间戳、可导出的结构化日志
Sensy 的Sensy.cpp已启用 WPP(Windows Software Trace Preprocessor),但默认配置不输出到TraceView。需手动配置:
① 在sensy.vcxproj中确认<EnableWppTracing>true</EnableWppTracing>已开启;
② 在驱动源码顶部添加 WPP 宏定义:
#include <wpp.h> #define WPP_CONTROL_GUIDS \ WPP_DEFINE_CONTROL_GUID(SensyTraceGuid, (b0e5c9a1, 1f2b, 4c8e, 0x9a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x6a, 0x7b), \ WPP_DEFINE_BIT(TRACE_DRIVER) \ WPP_DEFINE_BIT(TRACE_SPB) \ WPP_DEFINE_BIT(TRACE_IOCTL) \ )③ 在EvtDeviceAdd等函数中用DoTraceMessage(TRACE_DRIVER, "Device added, context=%p", pCtx);替代KdPrint;
④ 编译后生成sensy.tmh和sensy.wpp,用tracepdb.exe sensy.sys提取 PDB 信息。
5.3 TraceView 实时抓包:亲眼看见 SPB transaction 的 START/STOP 时刻
配置好 WPP 后,启动TraceView(WDK 自带):
①File → New Log Session→ 勾选Kernel Logger和Real-time;
②Provider → Add Provider→ 输入SensyTraceGuid(即上面定义的 GUID);
③Start开始捕获;
④ 运行main.exe触发IOCTL_SENSY_READ_TEMP;
⑤TraceView中将看到结构化日志:
[14:22:31.123] Sensy: TRACE_SPB - SpbTargetCreate for TMP0, addr=0x40 [14:22:31.125] Sensy: TRACE_SPB - SpbRequestSubmit: Write=1byte, Read=2bytes [14:22:31.127] Sensy: TRACE_IOCTL - IOCTL_SENSY_READ_TEMP completed, temp=23.45°C关键技巧:关联硬件波形
若你有逻辑分析仪,可将SPB0_SCL和SPB0_SDA引出,用Saleae Logic抓取实际 I2C 波形。对比TraceView中SpbRequestSubmit时间戳与波形 START 时刻,误差应 < 1ms。若偏差 > 10ms,说明SpbCx层有调度延迟,需检查WDFQUEUE是否配置为Sequential(强制串行)导致阻塞。
从那以后我每次调试 KMDF I2C 驱动,都强制走一遍 DSDT 注入 + WPP 跟踪 + TraceView 抓包三件套。不是为了炫技,是因为只有看到SpbRequestSubmit日志和示波器波形严丝合缝对齐的那一刻,你才真正相信——数据真的从传感器流进了你的驱动,没有丢包、没有错位、没有玄学。希望帮到你。
本文还有配套的精品资源,点击获取