简介:面向嵌入式驱动开发学习者的教育级源码包,呈现了从用户态I2C通信到KMDF内核驱动实现的完整演化路径。项目基于Windows驱动框架运行,调用WinRT API与Simple Peripheral Bus(SPB)协议栈与I2C设备交互,并在此基础上构建KMDF驱动,最终通过符号链接与DeviceIoControl机制,将传感器采集的温度数据实时显示在液晶屏上。对于正在研究内核驱动、总线通信以及Windows设备栈的开发者,是一份可运行的对照范本。资源共38个文件,核心源码以12个h头文件与9个cpp源文件为主,另有vcxproj工程、filters筛选器、ASL固件表、inx安装配置、可执行exe及说明文档,压缩包整体约1.83MB,目录结构紧凑且模块划分清晰。已有51人学习/下载。包内同时给出用户态与内核态两套通信实现,并附ASL表与安装说明,可帮助理解从系统固件描述到驱动加载、再到应用层访问的I2C设备完整链路,省去大量底层协议调研与工程搭建时间。
1. 先搞清楚 Sensy 是什么:一份跑通用户态到内核态的 I2C 完整链路
第一次在 Windows 上接 I2C 传感器,大多数人会被一个问题卡住:应用层的代码到底怎么碰到总线上的那颗芯片?Sensy 就是来解决这个问题的,它不是一个封装好的库,而是一整套教育性质源码包,从用户模式用 WinRT API 直接读写 I2C 设备开始,再深入到用 KMDF(Kernel Mode Driver Framework)写一个真正的 Windows 驱动,通过 SPB(Simple Peripheral Bus)总线框架与传感器通信,最后把温度数据通过符号链接和 DeviceIoControl 交还给用户态程序。适合三类人:想入门 Windows 驱动开发但不想只看文档的;在树莓派 2 上跑 Windows 10 IoT Core 并准备接传感器的;以及一直搞不清"用户态访问内核态硬件到底要走几条路"的嵌入式开发者。这份源码的价值在于同时覆盖应用层、驱动层、ACPI 描述和硬件接线,照着走一遍,整条 I2C 通信链路就串起来了。
2. 用户模式 I2C 通信:为什么先走 WinRT,再谈 KMDF
2.1 选型逻辑:I2cDevice 与 SPB 框架的关系
手上有一颗温度传感器和一块 I2C 显示屏,想先在树莓派 2 上验证硬件能不能跑通。第一反应可能是找现成的 GPIO 读写库去操作寄存器,但这个方向在 Windows 上不对。Windows 把 I2C 控制器交给 Simple Peripheral Bus 框架统一管理,由 SpbCx 驱动负责总线调度和时序,应用层不能直接碰物理寄存器。官方推荐的用户模式入口是Windows.Devices.I2c命名空间下的I2cDevice类,它底层就是 SPB 框架。
这套设计带来的好处是隔离了总线的竞争问题。多个进程可以同时打开同一个 I2C 控制器下的不同设备,只要SharingMode配成Shared,系统会帮你做好访问仲裁。从源码包的目录结构能看出作者也走了同样的路线:src/winrtapi下放着I2CDisplay.cpp、main.cpp、constants.h,这就是用户模式那套代码,先把这条路走通,再往 KMDF 驱动里搬。
// constants.h 中的总线与地址定义 #define I2C_CONTROLLER_NAME L"SPB0" #define TMP102_I2C_ADDRESS 0x48 // 温度传感器地址 #define OLED_I2C_ADDRESS 0x3C // OLED 显示屏地址SPB0是系统为第一个 I2C 控制器分配的设备名,在树莓派 2 的 ACPI 表里定义。0x48 这类地址不是随便填的,它由芯片的 A0/A1 引脚电平决定,接线时如果地址对不上,后面所有读写都会超时。0x3C 是 SSD1306 控制器的常见 I2C 地址,部分模块通过跳线可以改成 0x3D,这一点在踩坑章节会再提到。
using namespace Windows::Devices::I2c; using namespace Windows::Foundation; // 创建 I2C 连接设置 I2cConnectionSettings^ settings = ref new I2cConnectionSettings(TMP102_I2C_ADDRESS); settings->BusSpeed = I2cBusSpeed::FastMode; // 400kHz 快速模式 settings->SharingMode = I2cSharingMode::Shared; // 允许共享总线 // 获取设备选择器并异步打开设备 String^ selector = I2cDevice::GetDeviceSelector(I2C_CONTROLLER_NAME); I2cDevice^ sensor = I2cDevice::FromIdAsync(selector, settings)->GetAwaiter().GetResult();这段代码有两个参数值得注意。BusSpeed决定了通信时序的快慢,StandardMode是 100kHz,FastMode是 400kHz,TMP102 和 SSD1306 都支持快速模式,但如果你的杜邦线超过 20 厘米或者环境干扰大,我会主动降到 StandardMode 换稳定性。SharingMode设成Shared意味着别的进程也能打开这个设备,这在调试阶段特别有用,你可以同时开一个 I2C 扫描工具去枚举总线上的所有地址。
2.2 I2CDisplay.cpp 里的关键参数:控制字节与初始化序列
拿到I2CDisplay.cpp,第一件事是按 I2C 时序把代码拆开看。I2C 通信协议里每一次写操作都是先发送从设备地址字节,再发送控制字节,最后才是数据。对于 SSD1306,控制字节 0x00 表示后续字节属于命令,0x40 表示后续字节属于显示数据。很多新手直接往 OLED 写要显示的字符却发现屏幕不亮,多半是把控制字节漏了或者写反了。
// I2CDisplay.cpp 中向 OLED 发送命令的封装 BYTE controlByte = 0x00; // 0x00=命令,0x40=数据 BYTE cmd[] = { 0xAE }; // 0xAE = SSD1306 显示关闭 sensor->Write(ref new Platform::Array<BYTE>{ controlByte, cmd[0] });注意这里的Write是一次性把控制字节和命令字节打包成一个数组发送,这是在一条 I2C 写事务里完成的。SSD1306 的初始化序列通常包含显示关闭、设置显示时钟分频、设置多路复用比、设置列地址模式、打开电荷泵、显示开启等十几个命令,源码里这段初始化数组是按 0.9 寸 OLED 最常见的配置写的。1.3 寸的 SSD1306 多路复用比参数要改成 0x40 而不是 0x3F,直接套用不同尺寸屏幕会出现显示偏移或者花屏。
2.3 数据链路怎么走:从温度寄存器到 OLED 像素
用户模式下读温度的逻辑不复杂,但 I2C 时序必须严格按芯片手册走。TMP102 的温度寄存器地址是 0x00,读流程是先写一个字节指定寄存器地址,再发起读事务取回两个字节。12 位温度数据在这两个字节里左对齐排列,低四位是无效位,需要右移四位后乘以 0.0625 才能得到摄氏度值。
// 读取 TMP102 温度寄存器 BYTE regAddr = 0x00; // TMP102 温度寄存器地址 BYTE data[2] = { 0 }; sensor->Write(®Addr, 1); // 先写入寄存器地址 sensor->Read(data, 2); // 再连续读回 2 字节 short raw = (data[0] << 8) | data[1]; raw >>= 4; // 12 位有效数据,右移去掉低 4 位 float tempC = raw * 0.0625f; // 每 LSB 代表 0.0625°C这段代码里最容易忽略的是Write和Read是两次独立的事务,中间不能拆开做别的操作。因为 TMP102 的寄存器指针在写完 0x00 后会自动指向温度寄存器,如果中间插入了对 OLED 的写操作,下次读回来的就可能不是温度数据。把温度转成字符串再通过 OLED 显示时,同样要记得先发 0x40 控制字节再发字符数据,显示缓存才能正确刷新。
用户模式这条路验证通过后,意味着硬件接线、设备地址、传感器寄存器映射都已经确定了。接下来要做的就是把同一套读写逻辑下沉到内核态,这时候才轮到 KMDF 驱动登场。
3. KMDF 驱动与 DeviceIoControl:把温度传感器变成内核资源再还给用户态
3.1 KMDF 驱动的骨架:EvtDriverDeviceAdd 里绑定 SPB 资源
KMDF 驱动的入口是一个DriverEntry函数,它做的事情只有一件:初始化配置结构体并注册回调。真正的设备初始化发生在EvtDriverDeviceAdd回调里,系统在检测到设备匹配 INF 后调用它,驱动在这里创建设备对象、绑定总线资源、初始化读写目标。
// DriverEntry:所有 KMDF 驱动的统一入口 NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { WDF_DRIVER_CONFIG config; WDF_DRIVER_CONFIG_INIT(&config, EvtDriverDeviceAdd); return WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, &config, nullptr); }关键在WDF_DRIVER_CONFIG_INIT的第二个参数,它告诉框架"设备到达时调用哪个函数"。整个 KMDF 驱动可以没有别的回调也能工作,设备层面的资源初始化都在EvtDriverDeviceAdd里完成。
// EvtDriverDeviceAdd:创建设备并打开 SPB I/O 目标 NTSTATUS EvtDriverDeviceAdd(WDFDRIVER Driver, PWDFDEVICE_INIT DeviceInit) { WDFDEVICE device; WDF_OBJECT_ATTRIBUTES deviceAttributes; WDF_IO_TARGET_CONFIG ioTargetConfig; WDF_OBJECT_ATTRIBUTES_INIT(&deviceAttributes); NTSTATUS status = WdfDeviceCreate(&DeviceInit, &deviceAttributes, &device); if (!NT_SUCCESS(status)) return status; // 打开 SPB 总线目标,后续通过 IoTarget 发起 I2C 读写 WDF_IO_TARGET_CONFIG_INIT(&ioTargetConfig); status = WdfIoTargetOpen(device, &ioTargetConfig, WdfIoTargetOpenAsParent); return status; }为什么这里用的是WdfIoTargetOpen而不是像操作内存映射设备那样直接访问寄存器?因为 I2C 是协议总线,不是内存映射设备。驱动不能通过MmMapIoSpace拿到端口地址,它必须依赖 SPB 框架提供的 I/O 目标来发起写请求和读请求。这也是 Sensy 这套代码最值得学的地方:用户态走I2cDevice,内核态走WdfIoTarget,两者最终都汇聚到同一个 SPB 驱动栈,只是入口层级不同。
3.2 符号链接与 DeviceIoControl:用户态应用怎么找到驱动并通信
驱动建好了,应用层要怎么找到它?答案是符号链接。设备对象默认只有内核态可见,必须在EvtDriverDeviceAdd里创建一个 DOS 设备名,比如\DosDevices\Sensy,用户态应用就能用\\.\Sensy这个路径打开设备句柄。
// 创建符号链接 DECLARE_CONST_UNICODE_STRING(SENSY_DOS_DEVICE_NAME, L"\\DosDevices\\Sensy"); status = WdfDeviceCreateSymbolicLink(device, &SENSY_DOS_DEVICE_NAME);符号链接名字是全局命名空间里的一个条目,如果名字被其他驱动占用,WdfDeviceCreateSymbolicLink会返回STATUS_OBJECT_NAME_COLLISION。我习惯在设备名里加上厂商前缀或者版本后缀,降低冲突概率。
设备控制码的定义决定了应用层能对驱动做什么。Sensy 需要从驱动读取温度,所以定义了两个 IOCTL:一个读温度,一个查设备信息。
// IOCTL 控制码定义 #define FILE_DEVICE_SENSY 0x8000 #define IOCTL_SENSY_READ_TEMP CTL_CODE(FILE_DEVICE_SENSY, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_SENSY_GET_INFO CTL_CODE(FILE_DEVICE_SENSY, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)METHOD_BUFFERED表示系统把用户态缓冲区和内核态缓冲区各复制一份,适合温度这种几字节的小数据。应用层调用DeviceIoControl时,驱动在EvtIoDeviceControl回调里检查控制码、从请求缓冲区取数据、执行 I2C 读操作、把结果写回缓冲区。
// 用户态调用 DeviceIoControl 读取温度 HANDLE hDevice = CreateFileW(L"\\\\.\\Sensy", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hDevice == INVALID_HANDLE_VALUE) { // 符号链接未创建或驱动未加载 return; } BYTE buffer[4] = { 0 }; DWORD returned = 0; DeviceIoControl(hDevice, IOCTL_SENSY_READ_TEMP, nullptr, 0, buffer, sizeof(buffer), &returned, nullptr);CreateFileW失败时最常见的不是权限问题,而是符号链接根本不存在。先查驱动有没有加载,再查设备管理器里设备实例是否正常,不要一上来就怀疑代码。
3.3 编译 sys 和 exe:sensy.sln 工程结构与 WDK 配置
源码包里sensy.sln是 Visual Studio 的解决方案文件,打开能看到两个工程:src/kmdf下的驱动工程和src/winrtapi下的应用工程。sensy.vcxproj是工程文件,sensy.filters控制源码在 IDE 里的目录分组,这两个文件决定了源码放在哪个过滤器下,不影响编译结果。
编译 KMDF 驱动前要确认 WDK(Windows Driver Kit)版本和 KMDF 版本匹配。我用的是 Win10 对应 WDK,KMDF 库版本选 1.19,这与目标系统的 SPB 框架兼容性最好。驱动工程属性里需要把KMDF Version设置为具体的1.19,否则默认值可能在后续Inf2Cat阶段出问题。Release 版本编译产物包括.sys文件和配套.inf。
# 测试模式下允许加载未签名驱动 bcdedit /set testsigning on编译生成的.sys文件需要签名才能被 Windows 加载,开发阶段可以用测试签名模式略过。这一条写在前面,后面避坑章节会展开讲具体现象。
4. 树莓派 2 部署:ACPI 表、wiring 接线与驱动加载顺序
4.1 rpi2_dsdt.ASL:为什么树莓派 2 需要额外的 ACPI 表
树莓派 2 原本跑的是 Linux,固件里没有标准的 ACPI 表。Windows 10 IoT Core 要在这种板子上枚举 I2C 控制器,必须知道硬件在哪里、寄存器地址是多少、中断挂在哪个引脚上,这些信息全部来自 DSDT(Differentiated System Description Table)。源码包里的rpi2_dsdt.ASL就是描述这类硬件信息的 ACPI 源文件,asl.exe是微软的 ASL 编译器。
工程实践里,ACPI 表是树莓派上跑 Windows IoT 这类板子的命脉。I2C 控制器、GPIO、SD 卡控制器如果没在 DSDT 里描述清楚,Windows 的设备枚举就少一截,你的传感器在用户态永远扫不到。把 ASL 文件编译成 AML 的过程很简单:
# 编译 ACPI 源文件 asl.exe rpi2_dsdt.ASL编译成功后生成对应的.aml文件,把它放到启动分区的 ACPI 表目录。不同版本的 Windows IoT Core 加载 ACPI 表的路径有差异,有的是直接替换系统盘下的 DSDT 文件,有的是放到 EFI 目录下。第一次做这个步骤时我建议编译成功后把原始 ASL 文件备份一份,改一个变量就重新编译替换一次,别在原文件上反复改。
4.2 对照 wiring.JPG 接线:SDA、SCL、供电和上拉电阻
源码包里的wiring.JPG是现成的接线图,它的作用比读一百遍芯片手册都直观。树莓派的 I2C1 控制器映射到 GPIO2(SDA)和 GPIO3(SCL),电平是 3.3V。TMP102 这类温度传感器可以直接挂到 3.3V 供电,SSD1306 OLED 模块也支持 3.3V,不需要电平转换。
| 传感器引脚 | 树莓派 GPIO | 说明 |
|---|---|---|
| VCC | 3.3V (Pin 1) | 供电,不要接 5V |
| GND | GND (Pin 6) | 共地 |
| SDA | GPIO2 (Pin 3) | I2C 数据线 |
| SCL | GPIO3 (Pin 5) | I2C 时钟线 |
一个常见问题:如果模块不带板上拉电阻,SDA 和 SCL 需要外接 4.7k 欧姆上拉电阻到 3.3V,否则通信时好时坏。如果你用的是那种面包板用的裸 SSD1306,不是带转接板的模块,接上电源后先量一下 SDA 和 SCL 的静态电平是不是 3.3V,不是的话多半就是缺上拉。
4.3 部署验证:从 INF 安装到符号链接确认
驱动编译好之后,部署顺序有讲究。不要直接双击 INF,用devcon.exe命令行安装,能看到完整错误码。先安装驱动,再检查设备实例,最后跑应用。
# 安装驱动并查询设备状态 devcon.exe install sensy.inf "ROOT\SENSY" devcon.exe status "ROOT\SENSY"设备实例 ID 要和 INF 文件里[Standard.NT$ARCH$]节定义的硬件 ID 匹配,$ARCH$在 ARM 平台上会替换成arm,x64 平台替换成x64。树莓派 2 是 ARM 架构,编译驱动时也要选 ARM 配置,用 x64 的 sys 放到 ARM 板上只会得到错误码 577。
设备在设备管理器里正常显示后,驱动文件有没有加载可以用driverquery验证,但最直接的还是跑一次用户态 exe。应用能正常读到温度数值并显示到 OLED 上,整条链路就通了。我在这一步见过最多的问题是设备管理器正常、符号链接创建成功,但应用CreateFile返回句柄无效,原因是应用和驱动位数不匹配,ARM 驱动只能配 ARM 应用。
5. 避坑与常见问题:从驱动蓝屏到 I2C 无应答的五条记录
5.1 驱动装不上:设备管理器黄感叹号与签名问题
现象:用 devcon 安装 INF 后,设备管理器里看到设备带黄色感叹号,属性里提示"Windows 无法验证此驱动程序软件的发布者"。
原因:Windows 10 默认要求内核驱动有数字签名。开发阶段没有购买签名证书,驱动装上去直接被系统拦截。
解决:在目标机上打开测试签名模式,然后重启。
bcdedit /set testsigning on bcdedit /set nointegritychecks on重启后桌面右下角出现"测试模式"水印,再重新安装 INF。每次重启后这两项设置还在,但如果更新系统补丁可能会被重置,装驱动前检查一下当前状态。
5.2 符号链接创建失败:STATUS_OBJECT_NAME_COLLISION
现象:WdfDeviceCreateSymbolicLink返回错误码0xC0000035,对应的就是STATUS_OBJECT_NAME_COLLISION,设备对象创建成功但用户态打不开。
原因:之前装过的旧版本驱动没有完全卸载,符号链接名字还被占用,新驱动拿不到同一个名字。
解决:先卸载旧驱动再装新的。
devcon.exe remove "ROOT\SENSY"如果还不行,把符号链接名字改成一个带版本号的唯一值,比如\DosDevices\SensyV2。从那以后我每次改驱动版本都会同步改符号链接名,避免开发阶段反复安装冲突。
5.3 I2C 读写超时:ACPI 表地址与设备实际地址不一致
现象:应用能打开符号链接,但每次DeviceIoControl发 IOCTL 都执行超时,OLED 屏幕无响应,温度读回来全是 0。
原因:ACPI 表里描述的 I2C 控制器 Slave 地址和实际硬件地址不一致。ACPI 表告诉系统这个设备在地址 0x48,但传感器实际挂在 0x48 而控制器配置成了 0x49,或者时序参数和硬件不匹配。
解决:先用用户模式 WinRT API 做一次地址扫描,确认设备真实地址后,回源头去改 ACPI 表对应字段,再重新编译部署。直接改驱动里的地址是治标不治本,因为 SPB 框架分配总线访问权时读的是 ACPI 表。
5.4 温度读数全是 0xFF:发送方向与等待时间问题
现象:I2C 读写不报错,但读回来的数据显示为0xFF 0xFF,传感器好像不存在。
原因:TMP102 这类设备在读操作前需要先写入寄存器地址,驱动只发了读请求,没有先写寄存器指针。另外部分 I2C 芯片在寄存器地址写入后需要几个时钟周期的稳定时间,紧接着的数据读操作如果来得太快,会返回总线默认电平 0xFF。
解决:确保读温度前先完成一次写寄存器地址的事务,然后在两个事务之间加一个小的延时。我一般在写完成后插入一次 1 毫秒的WdfSleep,虽然看起来暴力,但对低速 I2C 传感器是稳妥做法。0.9 寸 OLED 对 I2C 兼容性尤其敏感,地址不对或者初始化序列少一条命令都会表现为全 0xFF,用逻辑分析仪抓 SDA 线上的地址位是最快的排查手段。
5.5 OLED 不亮或残影:地址跳线与初始化序列不完整
现象:OLED 模块能通电能通信,但屏幕一直黑屏,或者有残影不刷新。
原因:SSD1306 的 I2C 地址不固定,0x3C 和 0x3D 由模块背面的电阻跳线决定。部分模块默认是 0x3D,代码里写死 0x3C 就永远枚举不到。另一种情况是初始化序列里漏了充电泵命令0x8D 0x14,屏幕没有驱动电压,IC 在干活但像素点不亮。
解决:写一个简单的 I2C 地址扫描函数,枚举 0x03 到 0x77 所有地址,看哪个地址有 ACK 响应,把OLED_I2C_ADDRESS改成扫描结果。初始化序列参照芯片手册逐条核对,特别是 0x8D 充电泵、0x81 对比度、0xAF 显示开启这三条不能少。
6. 进阶验证:把温度采样改成带时间戳的轮询日志
能读温度、能显示 OLED,这条链路就算通了。但工程上还差一步:验证采样时间的稳定性。很多 I2C 传感器在实验室单独读没问题,放进循环轮询后数据偶尔跳变,就是因为轮询间隔不稳定或者读写事务被总线上的其他设备打断。我用一个小技巧把这个问题暴露出来:把每次采样打上GetTickCount64时间戳,同时写入 CSV 文件,用 Excel 打开看时间戳间隔,如果相邻两行间隔超过设定值的两倍,说明有人抢占了总线或者系统调度出了问题。
// 轮询读温度并追加到 CSV 日志文件 HANDLE logFile = CreateFileW(L"\\temp_log.csv", GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); char line[64]; for (int i = 0; i < 120; ++i) { ULONGLONG ts = GetTickCount64(); // 毫秒级时间戳,不受系统时间跳变影响 BYTE buffer[4] = { 0 }; DWORD returned = 0; DeviceIoControl(hDevice, IOCTL_SENSY_READ_TEMP, nullptr, 0, buffer, sizeof(buffer), &returned, nullptr); // 解析 buffer 中的温度值 float tempC = *(float*)buffer; int len = sprintf(line, "%llu,%.2f\r\n", ts, tempC); // 写入文件并立即刷新,防止崩溃丢数据 WriteFile(logFile, line, len, &returned, nullptr); FlushFileBuffers(logFile); Sleep(1000); // 1 秒采样周期 }用GetTickCount64而不是GetSystemTime是有原因的:系统时间可能被 NTP 校准跳变,时间戳会出现负数间隔;而GetTickCount64是系统启动后的毫秒计数,单调递增。采样周期设为 1 秒,统计 120 个点,能覆盖两分钟的链路稳定性。如果时间戳间隔稳定在 1000 毫秒附近,说明 I2C 总线调度正常;如果出现 1600 毫秒这种毛刺,就去检查 OLED 刷新操作是不是占了太多总线时间。
这个日志文件配合 OLED 实时显示一起跑,能交叉验证两件事:一是显示的温度和 CSV 里的温度是否一致,排除显示缓冲错位;二是长时间运行后驱动有没有内存泄漏或者句柄泄漏,CSV 文件显示卡住基本就是 I2C 总线被占死。从那以后我每次拿到带 ACPI 和驱动的 I2C 项目,都强制先跑一段带时间戳的轮询日志,再谈功能优化,这套流程帮我省了大量调硬件的时间。希望帮到你。
本文还有配套的精品资源,点击获取