news 2026/9/7 10:22:03

Windows内核驱动开发实战:从环境搭建到进程监控回调

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Windows内核驱动开发实战:从环境搭建到进程监控回调

最近在调一个内核驱动,需求很普通:实时感知系统里每个进程的启动、退出和模块加载,给上层的安全策略做依据。听起来简单,真正动手才发现,Windows 内核驱动这套东西,难点从来不在“写代码”本身,而在环境怎么搭、驱动怎么装、API 怎么选,以及如何在 64 位系统的 PatchGuard 和强制签名机制下活下来。

这篇文章就围绕三个词展开:安装卸载、内核 API 分类、安全防御实战。不准备写成一章一章的 API 手册,那个看微软文档更全。我想分享的是实际操作中真正会卡住你的细节:测试签名怎么开、双机调试怎么配、驱动服务为什么有时候删不掉,以及安全类驱动最核心的那几类回调机制到底怎么用。适合已经写过用户态程序、最近准备入手内核开发的读者,也适合安全方向想弄清楚一个防御驱动在系统里到底扮演什么角色的朋友。

1. 环境与调试链路:先把测试签名和双机调试跑通

1.1 测试签名是第一个拦路虎

2007 年之后的 64 位 Windows,内核模块强制要求数字签名。开发阶段没有 EV 证书,更不可能每改一次代码就去做微软认证,所以必须打开测试签名模式。这一步做不干净,后面全是坑。

打开方式很固定,管理员权限的命令行执行:

bcdedit /set testsigning on

重启之后,桌面右下角会出现“测试模式”的水印,说明系统已经接受未签名或者测试签名的内核驱动。这里有个前提:如果机器开了 Secure Boot,这个命令基本是无效的。实体机做内核开发,建议要么在 BIOS 里暂时关掉 Secure Boot,要么干脆把主力开发环境放在虚拟机里。我的习惯是 Hyper-V 开一台 Win11 虚拟机专门跑驱动,宿主机只负责写代码和编译,互不干扰。

1.2 双机调试配置

没有调试器的内核开发等于盲人摸象。驱动一旦出现异常,最常见的结局就是蓝屏,而蓝屏信息稍纵即逝,靠截图根本来不及。所以我强烈建议把内核调试链路配好再动手。

调试器和被调试机各有分工。WinDbg 运行在宿主机上,目标驱动跑在虚拟机里。虚拟机需要开启内核调试,并在启动配置里指定调试通道。我用的是网络调试,配置如下:

bcdedit /debug on bcdedit /dbgsettings net hostip:192.168.137.1 port:50000 key:1.2.3.4
  • hostip 是宿主机的 IP,虚拟机需要通过这个地址连接调试器
  • port 是调试端口,需要保证虚拟机的防火墙放行
  • key 是自定义密钥,两端一致就行

宿主机打开 WinDbg 后,使用“文件 -> Attach to Kernel”或者直接命令行:

windbg -k net:port=50000,key=1.2.3.4

目标机重启后,WinDbg 如果停在调试器启动界面,按下 F5 继续运行,调试链路就算通了。之后驱动里的DbgPrint/KdPrint输出都会实时显示在 WinDbg 的 Output 窗口里,蓝屏时的!analyze -v排查命令也能直接用。

提示:虚拟机最好不要用默认的动态内存,固定内存再给大一点,不然内核调试连接偶尔会莫名其妙断开。

2. 装载不玄学:驱动服务的建立、启动与卸载全流程

2.1 驱动在 Windows 里就是一个特殊服务

刚接触驱动的人很容易把 .sys 文件当成一个普通的可执行文件,复制到 System32\drivers 底下就以为装好了。完全不是。Windows 对内核驱动的管理走的是 SCM(服务控制管理器),驱动本质上是一种特殊类型的服务:SERVICE_KERNEL_DRIVER。装驱动 = 创建服务 + 启动服务。卸载驱动 = 停止服务 + 删除服务。

命令行验证一下,你的系统里其实早就有一堆驱动服务:

sc query type= driver

看到 output 里那些 netio、ndis、tcpip 之类的名字,它们就是系统内核驱动,同样是由 SCM 管理的服务。

2.2 用 Win32 API 安装卸载一个驱动

常用的安装方式有两种:INF 文件和 SCM API。INF 文件主要面向即插即用设备,比如 USB、PCI 设备驱动;而安全防御类驱动属于“非 PnP 驱动”,不走设备枚举流程,而是通过 SCM API 手动创建服务。核心代码不复杂:

SC_HANDLE hSCM = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS); if (!hSCM) return -1; // 创建驱动服务 SC_HANDLE hSvc = CreateService( hSCM, L"ProcGuard", // 服务名 L"ProcGuard", // 显示名 SERVICE_ALL_ACCESS, SERVICE_KERNEL_DRIVER, // 内核驱动 SERVICE_DEMAND_START, // 手动启动 SERVICE_ERROR_NORMAL, L"C:\\Windows\\System32\\drivers\\ProcGuard.sys", NULL, NULL, NULL, NULL, NULL); if (!hSvc && GetLastError() != ERROR_SERVICE_EXISTS) { CloseServiceHandle(hSCM); return -1; } // 启动 StartService(hSvc, 0, NULL); // 停止 ControlService(hSvc, SERVICE_CONTROL_STOP, &status); // 删除 DeleteService(hSvc);

有几个容易被忽略的细节:

  • 驱动文件必须复制到绝对路径指向的位置,CreateService 不会自动帮你拷贝数据
  • 如果目标是让驱动开机自启,把SERVICE_DEMAND_START换成SERVICE_AUTO_START
  • 卸载时先停止服务再删除服务,顺序反了会删出一个残废的注册表项
  • 驱动内部如果没有正确实现停止逻辑,ControlService 会返回 ERROR_DRIVER_CANCEL_TIMEOUT

更轻量的做法是直接用命令行:

sc create ProcGuard type= kernel start= demand binPath= C:\Windows\System32\drivers\ProcGuard.sys sc start ProcGuard sc stop ProcGuard sc delete ProcGuard

注意type=start=后面是有空格的,这是 sc 命令的固定语法,写错会直接报参数错误。

2.3 服务能删除但驱动卸载失败的前因后果

有一种经典情况:服务删了,sc query 也查不到了,但驱动文件还被占用,或者驱动创建的符号链接还残留在系统里。这通常是因为驱动没有处理好资源回收。

驱动本身在收到SERVICE_CONTROL_STOP时,会经过 IRP_MJ_SHUTDOWN / 控制请求,最终触发 DriverUnload 例程。凡是 DriverEntry 里面分配过的资源,比如设备对象、符号链接、注册的回调句柄,都必须在这个例程里对称释放。漏了任何一个,轻则文件删不掉,重则下一次启动驱动时出现设备对象冲突。

我见过不少初学驱动的人,只在 DriverEntry 里写了 IoCreateDevice,DriverUnload 里却什么都没做,最后驱动永远卸载不干净。这不是语法问题,而是对驱动生命周期理解不到位。建议早期阶段就把下面这张清单刻在脑子里:

DriverEntry 分配的资源DriverUnload 必须做的事
IoCreateDevice / IoCreateSymbolicLinkIoDeleteSymbolicLink + IoDeleteDevice
PsSetCreateProcessNotifyRoutinePsRemoveCreateProcessNotifyRoutine
ObRegisterCallbacksObUnRegisterCallbacks
CmRegisterCallbackExCmUnRegisterCallback
内核线程、定时器对象停止线程、取消定时器并释放

3. 内核 API 的分类方法:别按头文件背,按职责认门

3.1 Nt 系和 Zw 系到底用哪个

刚开始接触内核编程,最先绕不开的就是 ZwQuerySystemInformation 和 NtQuerySystemInformation 这类的区别。说实话,这个坑不弄清楚,写出来的驱动可能在测试机上正常,换一台机器就行为诡异。

关键区别在于:以 Zw 开头的函数,调用时会把当前线程的 PreviousMode 强制设为 KernelMode;以 Nt 开头的函数会保留调用者原本的 PreviousMode。PreviousMode 决定内核在参数合法性校验上的严格程度。

当你的驱动以标准调用方式调用这些 API,没有直接处理来自用户模式的 IRP 请求时,用 Zw 系是安全的,因为参数校验会被跳过,驱动自己负责保证参数合法性。但是如果你在处理用户态传过来的 IOCTL,在这个上下文里直接调 Nt 系函数,系统会认为调用来自用户模式,从而做严格的参数检查,稍有不对就是 STATUS_ACCESS_VIOLATION。

一句话总结:驱动内部逻辑用 Zw,凡是涉及用户态请求的处理路径,必须清楚当前 PreviousMode 是什么。

3.2 按职责划分的内核 API 家族

内核 API 数量庞大,如果按头文件去背,背到脱发也背不完。更实用的分类方式是按职责“认门”。我在实际项目中基本只关注这几个门类:

职责域代表性 API 前缀/系列典型用途
进程与线程PsSetCreateProcessNotifyRoutineEx、PsGetCurrentProcess进程监控、获取当前进程
对象管理ObRegisterCallbacks、ObOpenObjectByPointer句柄权限控制、保护关键对象
注册表CmRegisterCallbackEx、ZwQueryValueKey拦截注册表操作、读取配置
文件与卷FltRegisterFilter(微过滤)文件系统过滤、文件保护
内存管理MmGetSystemRoutineAddress、ExAllocatePool2获取内核函数地址、分配内核内存
定时器/DPCKeSetTimerEx、KeInitializeDpc延时任务、定时检查
IO 设备IoCreateDevice、IoCreateSymbolicLink创建设备对象、与应用层通信

这个分类还有一个好处:安全防御驱动常用的回调机制,几乎全部集中在前三行。你只要把进程、对象、注册表这三条线玩明白,就已经能覆盖很大一部分终端安全场景。

3.3 使用文档化 API 是底线

内核开发领域一直有修改内核数据结构、直接操作未导出函数的做法。到了 64 位系统时代,这条路已经基本被堵死。PatchGuard 会定期检测 SSDT、IDT、关键 MSR、内核模块列表等区域,一旦发现异常修改,直接触发 BugCheck 0x109(CRITICAL_STRUCTURE_CORRUPTION)。

所以安全防御驱动的正确姿势非常明确:只用微软文档化的 API,通过官方提供的回调机制拿到事件通知,再用对象权限控制来做拦截。任何需要靠硬编码偏移访问_EPROCESS内部字段的写法,都应该先停下来想想有没有替代方案。

4. 防御驱动真正依赖的三类回调机制

4.1 进程创建与映像加载回调

进程监控是所有安全产品的刚需。微软提供了两个直接可用的回调接口:

NTSTATUS PsSetCreateProcessNotifyRoutineEx( PCREATE_PROCESS_NOTIFY_ROUTINE_EX NotifyRoutine, BOOLEAN Add );

回调例程的原型如下:

VOID ProcessNotifyRoutine( PEPROCESS Process, HANDLE ProcessId, PPS_CREATE_NOTIFY_INFO CreateInfo );

当进程创建时,系统会在进程初始化早期调用这个回调。此时 CreateInfo 非空,可以拿到创建者 PID、命令行、进程路径等信息。如果回调里设置CreateInfo->CreationStatus = STATUS_ACCESS_DENIED,就能直接阻止进程创建,这是实现“进程白名单”的底层能力。

这里有个非常容易被忽略的点:该回调运行在任意线程上下文中,可能处于 DPC 级别,绝对不能调用需要等待的内核函数,更不能直接访问用户态内存。如果拿到路径是一个用户态指针,必须用ProbeForRead+MmProbeAndLockPages之类的方式验证之后再用。实际项目中,我更喜欢在回调里只记录 ProcessId 和 ParentProcessId,快速入队,具体信息查询交给用户态服务去慢悠悠地做,驱动只负责“快准狠”。

4.2 对象回调实现关键保护

ObRegisterCallbacks是一个功能很猛但是需要小心使用的 API。它能注册针对进程和线程两种对象类型的句柄权限回调,在每次打开进程句柄/线程句柄时触发,允许你修改返回给调用者的权限掩码。

典型场景是“保护指定进程不被结束”。攻击者想结束一个受保护进程,需要拿到该进程的句柄并请求 PROCESS_TERMINATE 权限。对象回调可以在句柄权限被授予之前介入,把 PROCESS_TERMINATE 从 DesiredAccess 里去掉,操作系统层直接拒绝这个权限请求。代码原型大致是:

OB_CALLBACK_REGISTRATION obReg; OB_OPERATION_REGISTRATION opRegs[1]; RtlZeroMemory(&obReg, sizeof(obReg)); obReg.Version = OB_FLT_REGISTRATION_VERSION; obReg.OperationCount = 1; obReg.Altitude = 325000; // 海拔高度,数字越小优先级越高 obReg.RegistrationContext = NULL; obReg.OperationRegistration = opRegs; opRegs[0].ObjectType = PsProcessType; opRegs[0].Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE; opRegs[0].PreOperation = PreProcessCallback; opRegs[0].PostOperation = NULL; ObRegisterCallbacks(&obReg, &obHandle);

PreProcessCallback 内部可以检查目标进程 PID 是否在受保护列表里,如果是,就把AccessMask里对应的权限位清零。

提示:ObRegisterCallbacks 的 Altitude 需要使用微软分配的合法海拔数值,发布级驱动必须向微软登记。随便填一个数字,正规发布时会被拒绝。

4.3 注册表回调拦截持久化

注册表回调是防御“自启动型恶意程序”的核心手段。使用CmRegisterCallbackEx可以监控系统内几乎所有的注册表操作,包括打开、写入、删除键值等。

典型场景:恶意软件想把自身路径写入 Run 键实现开机自启。驱动在注册表回调里检查到写的是...\CurrentVersion\Run,且写入的进程又在可疑名单里,就可以让操作返回一个错误状态码,注册表写入最终失败。

回调原型:

NTSTATUS RegistryCallback( PVOID CallbackContext, PVOID Argument1, // 操作类型 PVOID Argument2 // 对应数据结构 );

Argument1 是 REG_NOTIFY_CLASS 类型的枚举,比如 RegNtPreSetValueKey、RegNtPreCreateKeyEx。Argument2 指向的 pre-operation 参数结构里有完整键路径和值信息。注意这里的完整路径需要用CmGetBoundTransactionCmGetCallbackVersion配合解析,很多新手直接访问结构里的字段会踩到路径格式不是标准 Win32 路径的坑。

4.4 为什么防御驱动离不开这些回调

底层原因很简单:用户态程序没有任何可靠的、系统级的办法感知进程创建或者拦截句柄操作。轮询虽然能解决问题,但响应速度太慢,而且很多关键权限操作在用户态根本碰不到。

当这些操作下沉到内核并通过回调机制通知你的驱动时,驱动能拿到事件发生的真实源头,甚至在事件完成之前介入。这就是安全防御驱动存在的意义。它做的不是“检测”,而是“在操作系统信任的边界上抢一步先手”。

5. 可落地的进程监控驱动骨架与使用说明

5.1 主框架设计

我写了一个精简版的进程监控驱动,目标就是把“进程创建事件捕捉”这条链路跑通。这个骨架可以在你的环境里直接编译,作为后续扩展的底子。

DriverEntry 里做三件事:注册进程创建回调、创建设备对象、设置分发例程。

#include <ntddk.h> #define DEVICE_NAME L"\\Device\\ProcGuard" #define SYMBOL_NAME L"\\DosDevices\\ProcGuard" #define IOCTL_GET_PID CTL_CODE(0x8000, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) static PVOID g_ObHandle = NULL; static PROCESS_ID_BUFFER g_Buffer = {0}; // 自定义结果缓冲区 static KSPIN_LOCK g_Lock; // 进程创建回调 VOID OnProcessNotify( PEPROCESS Process, HANDLE ProcessId, PPS_CREATE_NOTIFY_INFO CreateInfo) { if (!CreateInfo) return; // 进程退出事件,CreateInfo 为 NULL KIRQL oldIrql; KeAcquireSpinLock(&g_Lock, &oldIrql); g_Buffer.Pid = (ULONG)ProcessId; g_Buffer.ParentPid = (ULONG)CreateInfo->ParentProcessId; g_Buffer.CreatorPid = (ULONG)(ULONG_PTR)CreateInfo->CreatingThreadId->UniqueProcess; KeReleaseSpinLock(&g_Lock, oldIrql); } NTSTATUS OnDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) { // 正常内核驱动需要处理 IOCTL 逻辑,把 g_Buffer 拷贝回用户态 } VOID UnloadDriver(PDRIVER_OBJECT DriverObject) { if (g_ObHandle) PsRemoveCreateProcessNotifyRoutine(OnProcessNotify, FALSE); UNICODE_STRING symLink = RTL_CONSTANT_STRING(SYMBOL_NAME); IoDeleteSymbolicLink(&symLink); if (DriverObject->DeviceObject) IoDeleteDevice(DriverObject->DeviceObject); } NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { DriverObject->DriverUnload = UnloadDriver; DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = OnDeviceControl; NTSTATUS status = PsSetCreateProcessNotifyRoutineEx(OnProcessNotify, TRUE); if (!NT_SUCCESS(status)) return status; UNICODE_STRING devName = RTL_CONSTANT_STRING(DEVICE_NAME); UNICODE_STRING symName = RTL_CONSTANT_STRING(SYMBOL_NAME); PDEVICE_OBJECT deviceObj = NULL; status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &deviceObj); if (!NT_SUCCESS(status)) return status; status = IoCreateSymbolicLink(&symName, &devName); if (!NT_SUCCESS(status)) { IoDeleteDevice(deviceObj); return status; } DbgPrint("[ProcGuard] driver loaded\n"); return STATUS_SUCCESS; }

这个骨架没有做太复杂的业务逻辑,核心是展示一个结构正确的驱动应该长什么样:入口创建资源,回调快速记录,卸载时彻底回收。

5.2 代码里容易被扩展和优化的地方

  • 回调里的CreatingThreadId是一个 PCLIENT_ID 指针,不要直接保存它,而是要立刻把里面的 PID 数值复制到自管理结构里
  • 进程退出时 CreateInfo 是 NULL,这个分支不能省,很多事件丢失问题都出在漏掉退出事件
  • 缓冲区建议用ExAllocatePool2(POOL_FLAG_NON_PAGED, size, 'gGpP'),不要用已经过时的 ExAllocatePool
  • IOCTL 分发例程里别忘了对IoGetCurrentIrpStackLocation返回的 Parameters.DeviceIoControl.InputBufferLength 做校验

写完驱动之后,用第 2 节的 sc 命令装载运行,在 WinDbg 里应该能直接看到[ProcGuard] driver loaded的输出,然后每打开一个进程,g_Buffer 都会被更新。把 DeviceIoControl 读出 Pid 的代码接上,一个最简陋但完整的进程钩子链路就跑通了。

5.3 性能和安全性的平衡拿捏

内核回调有个常见误解:回调里能做任何事。恰恰相反,回调执行时间越短越好。理论上可以在这个进程回调里同步调用其他 API 去做判断,但一旦涉及等待、文件操作、内存申请,很容易让整个系统感受到卡顿,严重时甚至触发看门狗或者超时错误。

我的经验是:驱动回调只做采集和快速决策,把结果塞进共享缓冲区;决策逻辑、策略判断、日志记录全部放到用户态服务。内核态的另一个选择是使用 WORK_ITEM 或者 SYSTEM_THREAD 做异步处理,把耗时操作从回调里搬出去,但这个复杂度明显高一些,初期不建议直接上。

6. 面向真实环境:签名、蓝屏与 PatchGuard 下的生存手册

6.1 从测试签名到正式签名的路径

开发期可以用测试签名,但你不可能把一个测试签名的驱动发给客户。正式环境下 Windows 对内核驱动的要求很苛刻:驱动必须有受信任的代码签名证书签名,并且需要走微软硬件开发者中心提交验证。

发布签名的基本命令:

signtool sign /v /sm /a /s PrivateCertStore /n "YourCertName" /fd sha256 /t http://timestamp.digicert.com ProcGuard.sys

还有人会问:我用自己的自签名证书签行不行?64 位系统默认不认自签名证书,除非把证书安装到“受信任的根证书颁发机构”,并且关闭 Secure Boot。自签名证书能做内部小范围测试,正规场景基本不可用。

6.2 蓝屏之后的排查习惯

驱动开发没有不蓝屏的,关键是蓝屏后能不能快速定位。Windows 默认在蓝屏时生成 minidump,位置在 C:\Windows\Minidump。用 WinDbg 打开 dump 文件,先执行:

!analyze -v

这条命令会自动解析出蓝屏的错误代码、触发蓝屏的驱动模块名以及调用栈。最常见的两类:

  • DRIVER_IRQL_NOT_LESS_OR_EQUAL:多半是回调里访问了分页内存,或者使用了错误的 IRQL 锁
  • SYSTEM_SERVICE_EXCEPTION:通常是调用了不受支持的内核 API,或者参数结构错误

调试时我习惯在关键入口加 DbgPrint,把函数名和参数一起打出来。这些输出会同步到 WinDbg,蓝屏之后翻最后的日志输出,基本能猜到是哪一行出了问题。这比盯着汇编看调用栈省力得多。

6.3 面对 PatchGuard 和强制签名的心态与策略

PatchGuard 和强制签名其实是一体两面:微软不希望内核被随意篡改,也不希望不靠谱的代码进入内核。防御驱动设计时必须顺着这条路走,把所有功能建立在文档化 API 上。

这意味着你以为的“高级内核技术”其实正在回归本质:事件回调、对象管理、注册表过滤、文件系统微过滤。合理使用这些机制,已经能覆盖绝大多数真实安全需求。反过来,试图绕过 PatchGuard、隐藏进程、篡改内核对象的那些做法,既不稳定,也存在极大的兼容性和法律风险。

我的选择是:把驱动设计得越“普通”越好。内核态代码越接近微软官方文档示例的样子,出问题的可能性越低,发布过审的概率越高。

6.4 最后聊聊我踩过的一个闷坑

就在写这个骨架的过程中,我犯过一个低级错误:在 DriverEntry 里先注册进程回调,然后才创建设备对象。结果因为设备对象创建失败,回调已经注册成功,DriverUnload 里又按“回调未注册”处理,直接跳过清理。等第二次装载驱动时,重复注册回调,系统直接蓝屏。

这类问题暴露了一个习惯问题:驱动的初始化顺序必须把“失败回滚”放在首位。任何一步失败,都要把已经成功注册的资源逐个回收干净。资源回收代码不要偷懒写在 Unload 里就完事,初始化路径也需要一份。

这也是驱动开发最有意思的地方:它不像普通程序写坏了顶多退出重来,内核里的每一个资源都要求你精确掌握它的生命周期。一个 .sys 文件装进系统再卸出来,本身就是一场对细致程度的检验。把这个流程吃透了,后面再去写更复杂的过滤驱动、文件监控驱动,思路就顺了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 10:20:40

OpenCV 4.9.0 Windows下VS2019编译CUDA GPU加速完整指南

简介&#xff1a;基于Visual Studio 2019编译的OpenCV4.9.0 GPU release版本&#xff0c;是一份面向C开发者的计算机视觉资源包&#xff0c;适合在Windows平台上开展高性能图像处理、目标检测与实时视觉应用构建。借助GPU并行计算能力&#xff0c;图像算法运行速度可得到大幅提…

作者头像 李华
网站建设 2026/9/7 10:20:08

CLion环境STM32串口重定向:一文搞懂printf到_write的完整链路

最近在嵌入式开发群里经常能看到这样一条提问&#xff1a;CLion 里做 STM32 串口重定向&#xff0c;网上清一色让重写_write&#xff0c;可我在 Keil 里面明明重写fputc就能让 printf 输出到串口&#xff0c;怎么换个工具链就完全换了一套玩法&#xff1f;如果你也有同样的疑惑…

作者头像 李华
网站建设 2026/9/7 10:19:39

在RP2350上实现本地AI图像生成:微型扩散模型与量化部署实践

第一次看到“AI Image Generation on an RP2350 Microcontroller”这个选题的时候&#xff0c;我的第一反应是&#xff1a;又是标题党。RP2350就是树莓派Pico 2上那颗双核Cortex-M33芯片&#xff0c;满打满算520KB内存&#xff0c;150MHz主频&#xff0c;连个正经GPU都没有&…

作者头像 李华