简介:Sensy是一份面向嵌入式与Windows驱动开发学习者的教育型项目源码包,聚焦从用户态到内核态的I2C设备通信实践。项目先使用WinRT API在用户模式下访问I2C设备,进而开发KMDF内核驱动,借助Simple Peripheral Bus(SPB)与多个传感器通信,读取温度并在LCD上显示;同时配套用户模式程序,通过符号链接和DeviceIoControl与驱动交互,完整呈现了驱动框架、设备通信和上层调用链条。压缩包共38个文件,大小仅1.83MB,主要包括C++头文件与实现(h/cpp)、Visual Studio工程文件(sln/vcxproj)、驱动跟踪消息头(tmh)、INX安装文件以及ACPI源语言(ASL)表等,不同文件覆盖从驱动编译到设备表配置的各个阶段。目前已有51人浏览学习,适合想快速理解KMDF与SPB驱动工作原理的开发者作为精简参照。
1. 一条 I2C 总线上的事故现场:0xFF 读数背后是驱动边界缺失
第一次把 Windows 上位机直接挂到 I2C 总线上时,我把希望全押在一根 USB-I2C 适配器上。结果一夜跑下来,日志里堆满 0xFF,换了三根线还是同一个毛病。最后换成 Sensy 这种基于 Windows 驱动框架(WDF)的 I2C 设备通信系统,才意识到问题不在线材,而在“应用层直接管总线”这个模型本身。Sensy 的思路一句话说清:把 I2C 总线抽象成一个标准设备节点,上位机只对着句柄读写,时序、重试与错误恢复全部下沉到驱动程序。它适合产测软件、上位机监控和所有对时序一致性有要求的 Windows 场景——如果你也被 I2C 读回 0xFF 折磨过,这个方向值得继续往下看。
2. 先把架构想明白:WDF 驱动框架怎么和 I2C 总线协议握手
I2C 是两线制协议,SDA 数据线加 SCL 时钟线,总线事务的边界、ACK 位和 restart 条件都靠电平边沿来定义。Windows 内核并不原生“认识”I2C,它提供的是 SPB(Simple Peripheral Bus)这套通用框架,由 WDF 驱动通过 IOCTL 去控制总线传输。Sensy 这类系统本质上是在 WDF 里把 I2C 从机的寄存器访问包成一层内核设备,再映射给应用层的 Win32 句柄。
2.1 为什么是驱动框架而不是应用层直通
如果只是临时读一个温度传感器,用适配器厂商提供的 DLL 确实最快。但 Windows 上的应用层 DLL 有两个先天问题:进程调度不可控,总线事务没有原子性。I2C 总线上一个“写寄存器地址 + 读数据”的组合操作不允许中途被打断,应用层线程一旦被调度出去,SCL 上的时序就悬停,从机很容易进入不可恢复的状态。Sensy 选择 KMDF 驱动模型,让 IO 控制请求在内核态排队并由总线驱动完成事务,“不允许被打断”这个约束才真正落到了实处。
第二个理由是异常恢复能力。I2C 没有类似 CAN 的自动错误帧检测,一个从机把 SCL 拉死,整条总线上所有通信都会卡住。内核态驱动可以在总线层记录超时,并在超时后主动翻转 9 个时钟周期来释放总线。这个动作在应用层 DLL 里很难做,因为拿不到对应的内核资源。
第三个点常常被忽略:权限边界。应用层程序以普通用户身份运行,也要能访问 I2C 设备。驱动设备节点通过 CreateFile 直接打开,配合 Windows 的 ACL 可以精确到用户和组,比给整个上位机进程提权到管理员要安全得多。用 Linux 那套词汇打比方,Sensy 相当于在 Windows 上重建了一个字符设备驱动框架:应用层看到的是一个可以 open/read/write 的设备节点,而不是一个裸露的端口。
2.2 数据链路拆解:从 CreateFile 到从机寄存器的一个完整往返
一条完整的读请求会经过这样一条链:应用层 CreateFile 打开\\.\SensyDevice0,拿到句柄后 DeviceIoControl 下发一个内嵌了从机地址、寄存器偏移和读长度的缓冲区,I/O 管理器把它包成 IRP,WDF 框架调度到 EvtIoDeviceControl 回调,驱动解析参数后构造 I2C 传输描述符数组,再通过 SPB 框架向底层控制器发起IOCTL_I2C_TRANSFER,控制器产生 SCL/SDA 时序,数据回到缓冲区,驱动把内容复制回应用层。
这条链路最容易翻车的地方在“组合事务”。很多新手把“写寄存器地址”和“读数据”拆成两次独立的 DeviceIoControl 调用,这在总线上就是两段独立事务,中间必然让出总线。如果总线上还有别的设备,数据串位是大概率事件。驱动层要做的是把写和读放在同一个描述数组里,由 SPB 框架处理成一次带 restart 条件的完整总线事务。
提示:
判断驱动是否真的支持组合事务,就看发送 IOCTL_I2C_TRANSFER 时传入的描述数组有几项。大于等于两项且在同一请求里下发的,才是组合事务。
2.3 参数落位:地址、速度、超时和 ACK 处理
驱动里最值得事先确定的参数就五类:从机地址模式、总线时钟、组合事务结构、超时和重试次数。下面这张表是我在做这类驱动时常用的起点:
| 参数 | 典型值 | 说明 |
|---|---|---|
| 从机地址 | 7 位:0x08~0x77 | 10 位地址要单独设标志位 |
| 总线时钟 | 100 kHz / 400 kHz / 1 MHz | 以从机手册上限为准 |
| 组合事务 | write + read | EEPROM 等从机必须 |
| 超时 | 10 ms ~ 100 ms | 时钟拉伸时按从机规格放大 |
| 重试次数 | 0 ~ 3 次 | 对瞬时 NACK 有效,对总线锁死无效 |
地址换算是个经典细节。I2C 数据帧格式里,7 位从机地址在总线上实际发送时要左移一位,再补上读写位。7 位地址 0x50 写操作时对应 0xA0,读操作对应 0xA1。这个换算散落在驱动各回调里很容易出错,我在驱动里一般只留两个宏:SENSY_ADDR_W(addr) ((addr) << 1)和SENSY_ADDR_R(addr) (((addr) << 1) | 1),所有派生处统一调用,避免地址扫描和组合事务里用两套约定。
ACK/NACK 处理也值得事前设计好。从机地址不对或寄存器索引非法,从机会在 ACK 位回 NACK,SPB 框架往往把这次传输标记为超时或设备未就绪。我的经验是把重试放在驱动层而不是应用层:检测到 NACK 后重新发起一次 START,最多 3 次,仍失败再把错误原样返回,同时把最后一次总线状态存进设备扩展,方便排障时拉出来分析。
3. 照着源码思路把驱动跑起来:从 WDK 环境准备到第一次总线读写
这一章按“拉一个最小可用驱动”的顺序走。你不需要一开始就照搬 Sensy 的全部设计,只需要把设备对象、I2C 传输和应用层访问这条主链路跑通,后面再逐步加功能。
3.1 搭建 WDK 驱动编译环境与测试签名
先装 Visual Studio,然后在 VS 安装器里勾选 Windows 驱动程序工具集(WDK)组件。装完后要确认项目模板里能看到“Kernel Mode Driver (KMDF)”这一项。编译器位数要和目标系统一致,现在绝大多数机器是 x64,驱动工程也选 x64,别默认编译成 x86。
开发阶段建议直接开启测试签名模式,省去每次改驱动都要签一遍证书的麻烦:
# 以管理员身份运行 PowerShell bcdedit /set testsigning on # 确认当前生效状态 bcdedit /enum {current}逻辑说明:第一条命令修改引导配置,允许加载未签名或测试签名的驱动;第二条命令检查当前启动项的配置是否已启用测试签名。参数说明:testsigning是开发调试常用的开关,生产环境必须关闭,改用正式代码签名证书。
3.2 写最小驱动骨架:DriverEntry 与设备初始化回调
新建一个 KMDF 空工程,把 DriverEntry 和 EvtDeviceAdd 这两个回调先搭起来。这段骨架是这类驱动的固定起点:
#include <ntddk.h> #include <wdf.h> DRIVER_INITIALIZE DriverEntry; typedef struct _DEVICE_CONTEXT { WDFIOTARGET I2CController; // 绑定的 I2C 控制器目标 ULONG BusSpeed; // 总线速率,单位 kHz ULONG TimeoutMs; // 传输超时,单位毫秒 UCHAR DefaultSlave; // 默认从机地址(7 位) } DEVICE_CONTEXT, *PDEVICE_CONTEXT; WDF_DECLARE_CONTEXT_TYPE_WITH_NAME(DEVICE_CONTEXT, SensyGetCtx) NTSTATUS DriverEntry( _In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath ) { WDF_DRIVER_CONFIG config; NTSTATUS status; WDF_DRIVER_CONFIG_INIT(&config, SensyEvtDeviceAdd); status = WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, &config, WDF_NO_HANDLE); return status; }逻辑说明:DriverEntry 是驱动唯一入口点,WdfDriverCreate完成驱动对象注册,并注册设备添加回调SensyEvtDeviceAdd。参数说明:RegistryPath是驱动在注册表里的服务路径,框架会自己管理,这里不需要读写。设备上下文用WDF_DECLARE_CONTEXT_TYPE_WITH_NAME声明,方便在后续回调里安全取用。
3.3 实现 I2C 组合读写:EvtIoDeviceControl 与描述符数组
驱动对应用层暴露功能,最朴素的通道就是 DeviceIoControl。先把 IOCTL 分派回调写好,再把核心的组合读实现补上:
#define IOCTL_SENSY_I2C_COMBINED_READ \ CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_SENSY_I2C_WRITE \ CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS) VOID SensyEvtIoDeviceControl( _In_ WDFQUEUE Queue, _In_ WDFREQUEST Request, _In_ size_t OutputBufferLength, _In_ size_t InputBufferLength, _In_ ULONG IoControlCode ) { NTSTATUS status = STATUS_SUCCESS; PDEVICE_CONTEXT ctx = SensyGetCtx(WdfIoQueueGetDevice(Queue)); switch (IoControlCode) { case IOCTL_SENSY_I2C_COMBINED_READ: status = SensyCombinedRead(ctx, Request); break; case IOCTL_SENSY_I2C_WRITE: status = SensyBusWrite(ctx, Request); break; default: status = STATUS_INVALID_DEVICE_REQUEST; break; } WdfRequestComplete(Request, status); }逻辑说明:WdfIoQueueGetDevice从队列拿到设备对象,再取到设备上下文。串行队列模式下,同一时间只放行一个 I2C 请求,这是保证总线原子性的关键。CTL_CODE里的METHOD_BUFFERED表示输入输出共用同一个系统缓冲区,驱动侧用WdfRequestRetrieveInputBuffer和WdfRequestRetrieveOutputBuffer访问。
组合读内部的核心是构造传输描述符数组:
NTSTATUS SensyCombinedRead( _In_ PDEVICE_CONTEXT Ctx, _In_ WDFREQUEST Request ) { PSENSY_READ_REQ inBuf; // 应用层输入:地址、寄存器、长度 PUCHAR outBuf; // 输出缓冲区:读回的数据 I2C_TRANSFER_DESCRIPTOR desc[2]; NTSTATUS status; // 从请求里取出应用层传入的参数 status = WdfRequestRetrieveInputBuffer(Request, sizeof(*inBuf), (PVOID*)&inBuf, NULL); if (!NT_SUCCESS(status)) { return status; } status = WdfRequestRetrieveOutputBuffer(Request, inBuf->ReadLen, (PVOID*)&outBuf, NULL); if (!NT_SUCCESS(status)) { return status; } // 段 1:写寄存器地址,方向为写 desc[0].Length = inBuf->RegLen; desc[0].Flags = I2C_AP_TRANSFER_WRITE; desc[0].Buffer = &inBuf->RegAddr; // 段 2:读数据,方向为读,SPB 会在两段间插入 restart desc[1].Length = inBuf->ReadLen; desc[1].Flags = I2C_AP_TRANSFER_READ; desc[1].Buffer = outBuf; // 把两个描述符一次性下发给 SPB 控制器 status = SensySubmitTransfer(Ctx, inBuf->SlaveAddr, desc, 2); return status; }逻辑说明:段 1 是写操作,把寄存器地址送上总线;段 2 是读操作,数据直接落到outBuf。两个描述符在同一个请求里提交,SPB 框架会保证中间不释放总线。参数说明:I2C_AP_TRANSFER_READ和I2C_AP_TRANSFER_WRITE的具体名称以你所用 WDK 版本头文件为准,有的版本还要求显式加I2C_AP_TRANSFER_STOP标志,习惯上是最后一段加 STOP。
3.4 应用层用 CreateFile 打开设备并完成第一次读
驱动装好之后,应用层就能用标准 Win32 API 访问。注意设备路径要和驱动在代码里设置的设备接口 GUID 一致,这里假设安装后设备符号链接为\\.\SensyDevice0:
HANDLE hDev = CreateFileW(L"\\\\.\\SensyDevice0", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hDev == INVALID_HANDLE_VALUE) { // 检查 GetLastError(),常见 2 或 3:设备路径没配对 } SENSY_READ_REQ req = { 0 }; BYTE buf[8] = { 0 }; DWORD retLen = 0; req.SlaveAddr = 0x50; // EEPROM 的 7 位地址 req.RegAddr = 0x00; // 从 0x0000 寄存器开始 req.RegLen = 1; // 地址占 1 字节 req.ReadLen = 8; // 连续读 8 个字节 BOOL ok = DeviceIoControl(hDev, IOCTL_SENSY_I2C_COMBINED_READ, &req, sizeof(req), buf, sizeof(buf), &retLen, NULL); if (ok) { // 此时 buf[0..7] 就是 EEPROM 前 8 字节 } CloseHandle(hDev);逻辑说明:SENSY_READ_REQ是应用层和驱动约定的输入结构,包含从机地址、寄存器地址和长度。DeviceIoControl的输入缓冲区传请求结构,输出缓冲区收读回的数据。参数说明:GENERIC_READ | GENERIC_WRITE是访问权限,OPEN_EXISTING要求设备节点已存在;如果驱动没加载或路径写错,CreateFile会失败,先看事件查看器里的驱动加载日志。
3.5 编译、签名、加载与快速验证
全部代码就绪后,编译和安装这一步要按顺序来:
# 编译 64 位 Debug 驱动 msbuild Sensy.sln /p:Configuration=Debug /p:Platform=x64 /m # 开启测试签名后重启,再安装驱动 pnputil /add-driver Sensy.inf /install逻辑说明:msbuild直接编译解决方案,Debug 配置下生成的驱动可以配合 WinDbg 调试。pnputil是 Windows 自带的驱动安装工具,/add-driver添加驱动包,/install立即对匹配的设备执行安装。参数说明:如果设备是 ACPI 枚举的 I2C 从机,inf里要写对硬件 ID;如果是动态创建的软件设备,则要考虑用设备接口 GUID 让应用层能找到节点。
4. 驱动跑起来之后的避坑清单:签名、代码 10 与总线锁死
这一章列出我在这个方向上踩过且值得记录的五个坑。每一条不保证所有环境都复现,但现象、原因和解决思路是通用的。
4.1 安装阶段的坑:驱动签名和代码 10
坑一:Windows 提示“无法验证此设备驱动程序的数字签名”。现象是安装驱动时系统直接拒绝,设备管理器里出现黄色感叹号。原因是开发阶段没签名或只签了测试证书。解决方法是先执行bcdedit /set testsigning on并重启,再用signtool sign给.sys文件签名,最后用inf2cat /driver:... /os:10_X64生成目录文件,把签名附到目录文件上。生产环境要换正式代码签名证书,测试签名不能带到现场。
坑二:设备管理器报“Windows 无法启动这个硬件设备,代码 10”。现象是驱动安装成功但设备起不来。原因比较多,最常见是EvtDeviceAdd里某个初始化失败直接返回了错误,或者驱动绑定的 I2C 控制器路径不匹配。解决方法是先用 WinDbg 挂内核调试,断点在DriverEntry和设备回调入口,看返回的 NTSTATUS;再确认 ACPI 表里声明的 I2C 控制器资源与 WDF 打开的目标路径一致。务实一点的做法是先把所有初始化步骤拆开,逐步回归,定位到具体哪一个 API 失败。
4.2 通信阶段的坑:超时、0xFF 和 ERROR_INVALID_PARAMETER
坑三:DeviceIoControl 返回 121(STATUS_TIMEOUT)。现象是读操作偶尔超时,重试几次又能成功。原因是总线速率设太高,从机跟不上;或者总线上存在地址冲突,从机在争抢 ACK 位。解决方法是先把驱动里的BusSpeed降到 100 kHz,用示波器抓 SCL 和 SDA,确认每个字节的 ACK 位是否正常;再逐一遍历从机地址,确认没有两个设备用了同一地址。时钟拉伸也是超时的高发原因,从机把 SCL 拉低后要检查它的拉伸时间是否超过驱动的超时上限。
坑四:读回来的数据全是 0xFF,或者位置错乱。现象是数据能读回,但内容不对。原因最常见是两个:地址换算出错,7 位地址当 8 位用,导致总线上的设备不对;另一个是组合事务被拆成了两次独立请求,中间插入别的总线访问。解决方法是统一用SENSY_ADDR_W/R宏做换算,别在多个回调里各写一套;再把组合读的验证写成驱动内的单元测试,固定读一个已知寄存器,读 100 次对比内容。
提示:
7 位地址 0x50 在总线上实际发送的字节是 0xA0 / 0xA1。驱动层如果按“device address + R/W bit”拼装,就别再手动左移,这个坑几乎每个新项目都会踩一次。
坑五:DeviceIoControl 直接返回 87(ERROR_INVALID_PARAMETER)。现象是应用层一调用就失败,驱动回调甚至没进。原因是 IOCTL 的缓冲区访问方式和实际传入长度不匹配。METHOD_BUFFERED模式下,驱动侧用WdfRequestRetrieveInputBuffer要求最小长度,应用层传的结构体太小就会失败。解决方法是确认SENSY_READ_REQ结构体在应用层和驱动侧定义一致,特别是结构体对齐方式,32 位和 64 位程序混用时尤其容易踩。
5. 把 Sensy 做成产线能用的状态机:自检指令、失败重试和恢复线索
5.1 给驱动加一个总线自检 IOCTL
我经手这类系统的做法是:在驱动里预留一个IOCTL_SENSY_SELF_TEST。它的逻辑不复杂,挑一个固定寄存器地址做组合读写,连续执行 10 次,全部通过才返回成功;任何一次失败就返回错误码,并把失败时的寄存器地址、从机地址和总线状态一并放到输出缓冲区。这个自检指令在生产环境里价值很大,产线上位机每次启动前都先跑一遍,跑不通就不往下推进。
5.2 把失败重试和状态码留到驱动层
产线场景里,上位机换班、断电重启、夹具松动都会让 I2C 通信出问题。如果重试逻辑放在应用层,每个上位机开发者的重试策略都不一样,出了问题很难统一排查。我一般把重试下沉到驱动层:NACK 重发、总线释放、再次发起 START,这些动作对应用层透明。应用层只收到最终结果,同时驱动把最近一次失败的详细状态留在设备扩展里,用另一个 IOCTL 读取,相当于给驱动留了一扇能看到内部状态的窗口。
早期翻过车:适配器刚插上时能通,灌了半小时数据就死,排查时总线状态完全看不进去,整个就是黑匣子。后来我坚持在驱动里做总线自检和失败留痕,产线再出问题,几分钟就能定位是夹具接触不良还是从机寄存器越界。如果你也要做 Windows 下的 I2C 设备通信,建议先按第 3 章把最小驱动跑通,再把自检和重试补上,这两步能帮你省掉大半现场调试时间。希望帮到你。
本文还有配套的精品资源,点击获取