有朋友问我:怎么在自己的 Windows 程序里知道系统什么时候睡了、什么时候醒了?这问题听着冷门,实际场景却不少——比如日志审计要记录设备断电休眠时段,监控工具要在唤醒后第一时间补数据,有些低功耗盒子程序需要在睡眠前保存现场、唤醒后恢复任务。这里说的“睡眠唤醒事件”,指的就是 Windows 电源状态切换时系统广播给应用程序的那组通知,捕获它的常见路线有两条:一条是 C/C++ 走 Win32 消息循环,一条是 WPF 走 .NET 托管事件。两条路线我都实际写过,这篇就把原理、完整代码和几个容易踩的坑一次讲清楚,适合正在做桌面工具、系统守护程序或者物联网边缘设备控制面板的开发者参考。
1. 睡眠唤醒事件在系统里是怎么流转的
1.1 睡眠和休眠不是一回事,事件通知也不一样
先说底层的电源状态模型。传统 Windows 笔记本和台式机主要涉及 S3(挂起到内存)和 S4(休眠,挂起到磁盘)两种低功耗状态。我们日常说的“睡眠”绝大多数是 S3,内存保持供电,CPU 停止执行,唤醒后恢复速度很快;“休眠”是 S4,内存内容写入硬盘,彻底断电,恢复速度慢,但更省电。
系统进入这两种状态前、以及恢复后,会向所有顶层窗口广播一条 WM_POWERBROADCAST 消息。这条消息的 wParam 里带具体事件类型,最常见的几个:
| 事件常量 | 数值 | 含义 |
|---|---|---|
PBT_APMQUERYSUSPEND |
0x0000 | 系统询问是否允许进入睡眠,可返回 BROADCAST_QUERY_AND_DONT_SUSPEND 拒绝 |
PBT_APMQUERYSUSPENDFAILED |
0x0002 | 系统进入睡眠的请求被某个程序拒绝 |
PBT_APMSUSPEND |
0x0004 | 系统即将进入睡眠,此时已无法拒绝 |
PBT_APMRESUMECRITICAL |
0x0006 | 系统因临界状态恢复(比如电池耗尽),不会自动再发自动恢复通知 |
PBT_APMRESUMESUSPEND |
0x0007 | 用户通过按键等操作唤醒了系统 |
PBT_APMRESUMEAUTOMATIC |
0x0012 | 系统因定时器、网络事件等自动唤醒 |
一个关键点是:并不是所有程序都能收到这些广播。广播只发给顶层窗口,如果你的程序是控制台程序,或者没有创建任何窗口,那 WM_POWERBROADCAST 基本到不了你手里。这就是很多人写了个 while (true) { printf(...); } 然后发现死活收不到事件的原因。
1.2 事件触发的时序:睡前和醒后各自有几步
睡眠的完整流程大致是:用户点击睡眠或系统空闲超时后,系统先向每个顶层窗口发 PBT_APMQUERYSUSPEND,此时各程序可以拒绝。如果没有程序拒绝,系统接着发 PBT_APMSUSPEND,表示“我马上要睡了”,然后系统进入 S3/S4。
唤醒流程则是:系统从低功耗状态恢复后,先发 PBT_APMRESUMECRITICAL(仅在临界唤醒时有),然后发 PBT_APMRESUMEAUTOMATIC 或 PBT_APMRESUMESUSPEND。正常情况下收到 PBT_APMRESUMEAUTOMATIC 就代表系统已经完全可用。
这个时序对开发者的意义在于:如果程序在睡眠前有清理工作、保存状态、断开网络连接等需求,应该在 PBT_APMSUSPEND 里做;如果要恢复服务、重新建连、补拉数据,应该在唤醒事件里做,而不是靠轮询系统时间来判断。轮询不仅费电,而且在你被挂起的那段时间里,定时器根本不会触发,醒来的第一秒其实是“补偿式的一堆定时器同时到期”,乱得很。
另外注意一点:PBT_APMSUSPEND 发出后系统并不会无限等你,应用必须尽快处理完手头的事情返回,否则系统可能强制继续睡眠。所以睡眠事件里别做重活,哪怕你只是想在睡前把日志写完,也要保持轻量。
1.3 为什么很多程序收不到事件
收不到事件的原因主要有三种:
- 没有创建窗口,
WM_POWERBROADCAST无法投递; - 有窗口但窗口过程没有处理
WM_POWERBROADCAST并随手DefWindowProc了; - 程序跑在服务进程中,服务默认在 Session 0 里,和用户会话的电源状态广播隔离。
这些坑在下面两种语言实现里都会遇到,但解决方式不同,接下来分开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. C/C++ 路线:用 Win32 消息循环接住广播
2.1 为什么用隐藏窗口而不是控制台程序
C/C++ 里最经典的接法就是创建一个顶层隐藏窗口,然后跑消息循环,在窗口过程里处理 WM_POWERBROADCAST。隐藏窗口不需要 UI,纯粹是为了让 Windows 有一个可以投递广播的地方。这就像你留了个收件地址,系统才可能把信送到你手上。
也可以不用窗口,直接调 RegisterPowerSettingNotification 注册回调,这属于更精细的一套 API,后面单独讲。先看消息循环方案,它可以让你彻底理解事件流转机制,而且代码依赖极小,一个 .c 文件就能编译跑起来。
2.2 最小可运行示例:隐藏窗口 + 消息循环
我自己常用的模板是这样,先注册窗口类,然后创建一个消息窗口(父窗口传 HWND_MESSAGE 也可以,但直接创建普通隐藏窗口更省事):
c复制#include <windows.h>
#include <stdio.h>
#include <tchar.h>
const wchar_t* kWindowClassName = L"PowerEventMonitorWindow";
// 事件名称转字符串,方便看日志
const char* PowerEventToString(DWORD evt)
{
switch (evt)
{
case PBT_APMQUERYSUSPEND: return "PBT_APMQUERYSUSPEND";
case PBT_APMQUERYSUSPENDFAILED: return "PBT_APMQUERYSUSPENDFAILED";
case PBT_APMSUSPEND: return "PBT_APMSUSPEND";
case PBT_APMRESUMECRITICAL: return "PBT_APMRESUMECRITICAL";
case PBT_APMRESUMESUSPEND: return "PBT_APMRESUMESUSPEND";
case PBT_APMRESUMEAUTOMATIC: return "PBT_APMRESUMEAUTOMATIC";
default: return "UNKNOWN";
}
}
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_POWERBROADCAST:
{
DWORD evt = (DWORD)wParam;
FILE* f = NULL;
fopen_s(&f, "power_event.log", "a");
if (f)
{
fprintf(f, "[%llu] WM_POWERBROADCAST: %s\n",
(unsigned long long)GetTickCount64(), PowerEventToString(evt));
fclose(f);
}
// 睡眠事件里立刻返回,别做耗时操作
if (evt == PBT_APMQUERYSUSPEND)
{
// 如果这里返回 BROADCAST_QUERY_AND_DONT_SUSPEND,就能阻止系统睡眠
// 但除非你在写电源管理工具,否则不建议拒绝
return TRUE;
}
return TRUE;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, msg, wParam, lParam);
}
}
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow)
{
WNDCLASSEXW wc = { 0 };
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.lpszClassName = kWindowClassName;
RegisterClassExW(&wc);
// 创建一个不可见窗口
HWND hwnd = CreateWindowExW(0, kWindowClassName, L"PowerMonitor",
0, 0, 0, 0, 0,
HWND_MESSAGE, NULL, hInstance, NULL);
if (!hwnd)
{
MessageBoxW(NULL, L"CreateWindowEx failed", L"Error", MB_ICONERROR);
return 1;
}
MSG msg;
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
编译直接用 Visual Studio 的 cl 或者 MinGW 的 gcc 都行:
bash复制# MSVC
cl /EHsc /O2 power_monitor.c /link user32.lib
# MinGW
gcc -O2 -o power_monitor.exe power_monitor.c -luser32
这段代码的核心就两个点:一是窗口过程里必须处理 WM_POWERBROADCAST,二是 PBT_APMQUERYSUSPEND 分支里返回 TRUE 表示同意睡眠。如果你希望程序在某个关键时刻阻止系统睡眠,可以在这个分支里返回 BROADCAST_QUERY_AND_DONT_SUSPEND,但绝大多数应用没必要这么做,乱拦系统睡眠会导致用户困惑,甚至会引发电池耗尽的问题。
我实际用这套代码做过一个小守护程序,power_event.log 里会把每次睡眠和唤醒时间记下来。要注意日志文件打开关闭本身虽然有开销,但睡眠前的最后一次写入量很小,实测在 PBT_APMSUSPEND 里写日志基本不会导致系统睡眠延迟。
2.3 进阶:RegisterPowerSettingNotification 与回调方式
消息循环方案有一个局限:它只告诉你系统“要睡了”“醒了”,但没法区分这次睡眠是由电源按钮、合盖、还是空闲超时引起的。如果你需要更细的电源状态变化信息,Windows 8 以后提供了 RegisterPowerSettingNotification,可以针对指定电源设置 GUID 注册通知。
常见 GUID 有这些(定义在 guiddef.h 和头文件里):
c复制// 睡眠相关的电源设置 GUID
DEFINE_GUID(GUID_SLEEP_SUB_BUTTON, 0x96996bc0, 0xad50, 0x47ec, 0x92, 0x3b, 0x6f, 0x41, 0x87, 0x4d, 0xd9, 0xeb);
DEFINE_GUID(GUID_SLEEP_IDLE_THRESHOLD, 0xd3c1b672, 0x3e8d, 0x4b5c, 0x9e, 0x6d, 0x1f, 0x1a, 0x6c, 0x8a, 0x9d, 0x30);
// 交流/电池状态变化 GUID
DEFINE_GUID(GUID_ACDC_POWER_SOURCE, 0x5d3e9a59, 0xe9d5, 0x4b00, 0xa6, 0xbd, 0xff, 0x34, 0xff, 0x51, 0x65, 0x48);
用法上分两种订阅方式:回调方式和窗口消息方式。如果你已经在跑消息循环,用窗口消息方式最省事:
c复制// 注册:这个消息号需要自定义
#define WM_POWER_SETTING_CHANGE (WM_APP + 100)
HPOWERNOTIFY hNotify = RegisterPowerSettingNotification(
hwnd,
&GUID_ACDC_POWER_SOURCE,
DEVICE_NOTIFY_WINDOW_HANDLE
);
// 在 WndProc 里处理
case WM_POWER_SETTING_CHANGE:
{
POWERBROADCAST_SETTING* pSettings = (POWERBROADCAST_SETTING*)lParam;
if (pSettings->PowerSetting == GUID_ACDC_POWER_SOURCE)
{
DWORD acdc = *(DWORD*)pSettings->Data; // 0 = 电池, 1 = 交流
// 处理电源来源变化
}
return 0;
}
// 程序退出前注销
PowerSettingUnregisterNotification(hNotify);
回调方式则适合不想起窗口的纯后台程序,把 DEVICE_NOTIFY_WINDOW_HANDLE 换成 DEVICE_NOTIFY_CALLBACK,并传入回调函数指针。回调运行在系统线程上下文,不能在里面直接做 UI 操作,否则跨线程访问会出问题。
这套 API 的好处是它不仅能收到睡眠唤醒事件,还能收到电源来源变化、电池电量阈值、显示器状态等底层电源信息。如果你的程序本身就是一个电源管理类工具,建议直接用它替代裸的 WM_POWERBROADCAST。
2.4 C/C++ 路线的两个配套操作
写 C/C++ 版本时经常会顺手做两件事:
一是 SetThreadExecutionState,它可以临时告诉系统“这个线程正在工作,别睡眠”。比如用户正在跑一个长时间计算任务,你可以在计算开始时设置 ES_CONTINUOUS | ES_SYSTEM_REQUIRED,计算结束再清除。但要小心:这只能阻止系统空闲睡眠,用户手动按电源键睡眠还是拦不住的。
二是恢复后的延迟处理。系统从睡眠恢复后,Wi-Fi、网卡、蓝牙的重连是异步的,经常出现程序立刻执行网络请求却失败的场景。我的做法是在唤醒事件里启动一个延迟任务,睡 3 到 5 秒后再做网络初始化,或者干脆监听网络状态变化,等网络可用再执行。
3. WPF 路线:托管环境下的两种接法
3.1 方案一:SystemEvents.PowerModeChanged,最简单直接
C/C++ 的窗口消息方案在 WPF 里当然也能用,但 WPF 程序员有更方便的工具——Microsoft.Win32.SystemEvents。这个类封装了系统级事件,PowerModeChanged 就是我们要的睡眠唤醒事件,不需要自己创建隐藏窗口,也不需要写窗口过程,几行代码就能接住。
下面是一个最小示例:
csharp复制using System;
using System.Windows;
using Microsoft.Win32;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// 在窗口加载后订阅
Loaded += (s, e) =>
{
SystemEvents.PowerModeChanged += OnPowerModeChanged;
};
// 窗口关闭时取消订阅
Closed += (s, e) =>
{
SystemEvents.PowerModeChanged -= OnPowerModeChanged;
};
}
private void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e)
{
// 注意:这个事件可能不在 UI 线程触发
var message = $"收到电源事件: {e.Mode},时间: {DateTime.Now}";
Dispatcher.Invoke(() =>
{
LogListBox.Items.Add(message);
});
}
}
PowerModeChangedEventArgs.Mode 是 PowerModes 枚举,有三个值:
| 枚举值 | 触发时机 |
|---|---|
PowerModes.Resume |
系统从睡眠或休眠恢复 |
PowerModes.Suspend |
系统即将进入睡眠或休眠 |
PowerModes.StatusChange |
电源状态变化,比如插拔电源、电池电量变化 |
这个方案的优点是代码量最小,不用关心 Win32 细节。但它有一个明显的取舍:SystemEvents.PowerModeChanged 不区分唤醒的具体原因,你只知道“醒了”,但不知道是用户按键唤醒、闹钟唤醒还是网络唤醒。大多数桌面应用其实不需要区分,所以够用。
还有一点要注意:这个事件触发的线程不是 UI 线程。SystemEvents 内部通过系统消息泵接收广播,然后回调你的处理器。如果你在事件处理器里直接更新 ListBox、TextBlock 等控件,会抛出“调用线程无法访问此对象”的异常。所以我在代码里用了 Dispatcher.Invoke 转回 UI 线程。如果处理完事件后还要读取 UI 控件状态,也可以用 Dispatcher.InvokeAsync 或者 await Dispatcher.InvokeAsync(...) 避免阻塞事件回调。
3.2 方案二:HwndSource 拦截 WM_POWERBROADCAST
有些场景下你需要比 SystemEvents 更底层的控制。比如你要区分 PBT_APMRESUMEAUTOMATIC 和 PBT_APMRESUMESUSPEND,想在 PBT_APMQUERYSUSPEND 时决定是否拒绝睡眠,这时候就得回到 Win32 消息层面。WPF 里拦截窗口消息的通用方式是通过 HwndSource.AddHook。
csharp复制using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
public partial class MainWindow : Window
{
private const int WM_POWERBROADCAST = 0x0218;
private const int PBT_APMSUSPEND = 0x0004;
private const int PBT_APMRESUMEAUTOMATIC = 0x0012;
private const int PBT_APMRESUMESUSPEND = 0x0007;
private HwndSource _source;
public MainWindow()
{
InitializeComponent();
SourceInitialized += OnSourceInitialized;
Closed += (s, e) =>
{
if (_source != null)
{
_source.RemoveHook(WndProc);
}
};
}
private void OnSourceInitialized(object sender, EventArgs e)
{
var helper = new WindowInteropHelper(this);
_source = HwndSource.FromHwnd(helper.Handle);
_source?.AddHook(WndProc);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_POWERBROADCAST)
{
int eventCode = wParam.ToInt32();
string desc = eventCode switch
{
PBT_APMSUSPEND => "系统即将睡眠",
PBT_APMRESUMEAUTOMATIC => "系统自动唤醒",
PBT_APMRESUMESUSPEND => "用户操作唤醒",
_ => $"其他电源事件 0x{eventCode:X}"
};
Dispatcher.Invoke(() => LogListBox.Items.Add($"{DateTime.Now}: {desc}"));
// 如果处理完了,置 handled = true,避免消息继续走默认流程
if (eventCode == PBT_APMRESUMEAUTOMATIC || eventCode == PBT_APMRESUMESUSPEND)
{
handled = true;
}
}
return IntPtr.Zero;
}
}
这里有个细节:SourceInitialized 事件必须处理。WPF 窗口的 Handle 不是在构造函数里就创建的,只有 SourceInitialized 触发之后,WindowInteropHelper.Handle 才有效。很多初学者在这里踩坑,HwndSource.FromHwnd 拿不到对象,导致 AddHook 无效。
另外,handled 参数代表“这个消息你处理完了吗”。对于电源广播,一般建议你记录完之后就把 handled 设为 true,避免系统默认处理逻辑干扰。但如果你不想破坏其他组件的处理,也可以只记录不拦截,把 handled 留为 false。我一般会在恢复事件里设为 true,在睡眠事件里只记录不干预。
3.3 MVVM 模式下怎么把电源事件变成可绑定状态
如果你在用 MVVM 框架,建议别把所有逻辑都堆在窗口代码里。把电源事件封装成一个服务,界面层只管订阅和展示,这样既方便单元测试,也方便以后把事件源从 SystemEvents 切换成 HwndSource 而不动 ViewModel。
简单的封装思路:
csharp复制public interface IPowerEventService
{
event EventHandler<PowerModeChangedEventArgs> PowerModeChanged;
}
public sealed class SystemPowerEventService : IPowerEventService
{
public event EventHandler<PowerModeChangedEventArgs> PowerModeChanged;
private readonly object _gate = new object();
public void Start()
{
SystemEvents.PowerModeChanged += OnPowerModeChanged;
}
public void Stop()
{
SystemEvents.PowerModeChanged -= OnPowerModeChanged;
}
private void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e)
{
// 统一转成事件,内部自己加锁和线程切换
lock (_gate)
{
PowerModeChanged?.Invoke(this, e);
}
}
}
然后在 App 启动时注册 Start(),退出时 Stop()。ViewModel 里订阅事件后,通过 Dispatcher 更新可观察集合或者属性。这样做的好处是,电源事件的生命周期和窗口解耦——即使窗口关了,服务还能继续监听,适合做后台托盘应用。
4. 两条路线的选型:从事件粒度到运行形态
很多人纠结到底用 C/C++ 还是 WPF,这里直接给一张我实测后的对比表:
| 对比维度 | C/C++ (Win32 消息循环) | C/C++ (RegisterPowerSettingNotification) | WPF (SystemEvents) | WPF (HwndSource) |
|---|---|---|---|---|
| 代码量 | 中 | 中 | 少 | 中 |
| 事件粒度 | 系统级睡眠唤醒 | 可细分到电源设置变化 | 仅 Resume/Suspend/StatusChange | 系统级睡眠唤醒,可区分唤醒原因 |
| 是否可拒绝睡眠 | 可(PBT_APMQUERYSUSPEND) | 一般不用来拒绝 | 不可 | 可 |
| 线程模型 | 窗口消息线程 | 回调线程 | 系统线程,需 Dispatcher | UI 线程挂钩,天然可更新控件 |
| 依赖 | Win32 API | PowrProf.lib | .NET Framework/Core | WPF + Win32 interop |
| 适合场景 | 服务、守护进程、底层工具 | 电源管理工具、笔记本电池调试 | 普通桌面工具、监控面板 | 需要精细区分唤醒类型的桌面应用 |
如果程序是一个无 UI 的系统守护进程,我会直接选 C/C++ 消息循环或 RegisterPowerSettingNotification。原因很简单:无 UI 程序不需要 .NET 运行时,启动快、内存占用低、方便注册成 Windows 服务。注意服务程序在 Session 0 里跑,WM_POWERBROADCAST 的行为和用户会话里不一样,需要额外测试。
如果程序是一个有界面的桌面工具,比如电源监控仪表盘、运维小助手,选 WPF 会舒服很多。SystemEvents.PowerModeChanged 足够应付 80% 需求,剩下 20% 要用 HwndSource 精准拦截。
如果程序是混合形态——底层一个 C/C++ 守护进程负责监听和落盘,上层一个 WPF 面板负责展示——就让底层负责事件捕获,把事件结果通过文件、命名管道或 TCP 发给面板。这种架构我做过,好处是面板崩溃或重启都不影响事件记录,睡眠唤醒日志永远不丢。
5. 实战中躲不开的边界情况和四个大坑
5.1 现代待机(Modern Standby)改变了事件结构
近几年新出的轻薄本很多默认使用 Modern Standby(S0 低功耗空闲),系统在“看起来关了屏幕”的状态下,后台实际上还在低功耗运行,网络可能持续连接,定时任务仍能触发。这种模式下,传统 S3 的 PBT_APMSUSPEND / PBT_APMRESUMEAUTOMATIC 时序完全可能不出现,或者出现的形式很奇怪。
我的建议是:如果目标设备包含 Modern Standby 的笔记本,不要想当然地认为睡眠一定触发 Suspend 事件。最好先跑一下前面那套监控程序,开着机器合盖半小时,然后看日志里到底有没有事件、有没有乱序。我在测试中发现,有些机器的合盖只触发显示器关闭和空闲状态,并不触发传统电源广播,这时候你需要结合 PowerModeChanged.StatusChange 或者系统电源状态查询来判断设备是否处于低功耗。
5.2 快速启动(Fast Startup)和休眠会把日志搞乱
Windows 默认开启快速启动后,关机过程会把系统内核会话写入硬盘,下次开机时“冷启动”和“恢复”的界限变得模糊。对普通用户态程序来说,快速启动不会给你发 PBT_APMRESUMEAUTOMATIC,因为你的进程在新会话里被重新拉起来了,不涉及真正的“唤醒”。
但这个特性会带来一个衍生问题:如果你用 powercfg /lastwake 或系统事件日志来排查唤醒原因,快速启动的关机记录可能让日志看起来像一次“假唤醒”。我在日志审计工具里专门加了一个过滤规则:开机后 30 秒内的“唤醒”记录不算唤醒,而是冷启动。否则统计出来的唤醒次数会虚高,非常误导人。
5.3 恢复后网络没就绪是最容易被忽视的坑
睡眠恢复后,无线网卡从省电模式恢复需要时间,有时是 1 到 2 秒,有时是 5 到 6 秒,取决于驱动和网络环境。如果你在 PBT_APMRESUMEAUTOMATIC 里立刻发起 HTTP 请求,大概率会碰到 SocketException 或“网络不可达”。这不是你代码的问题,是系统还没准备好。
我的处理方式是做带重试的延迟初始化,伪代码思路是这样:
csharp复制private async Task OnResumeAsync()
{
for (int i = 0; i < 5; i++)
{
try
{
await Task.Delay(2000);
using var http = new HttpClient();
var result = await http.GetAsync("https://example.com/api/health");
if (result.IsSuccessStatusCode)
{
break;
}
}
catch (HttpRequestException)
{
// 网络未就绪,继续重试
}
}
}
如果是 C/C++ 程序,可以考虑注册网络状态变化事件 NotifyAddrChange,等网络可用再继续,比固定延迟更优雅。但小工具图省事,固定延迟 3 秒基本够用。
5.4 调试手段:日志落盘比断点靠谱得多
睡眠事件最麻烦的地方在于:一旦系统进入睡眠,调试器也跟着断线,你没法在断点处观察变量。我调这类程序时的标准做法:
先在机器上跑一个最简单的监控程序,记录前后几个事件的时间戳,再结合系统命令排查。
| 命令 | 作用 |
|---|---|
powercfg /a |
查看当前机器支持哪些睡眠状态 |
powercfg /lastwake |
查看上一次唤醒的来源(按键、定时器、网络等) |
powercfg /waketimers |
列出当前注册的唤醒定时器 |
powercfg /energy |
生成一份电源效率诊断报告 |
powercfg /sleepstudy |
生成睡眠状态诊断报告(部分新机型支持) |
我自己排查过一次“为什么程序恢复后总是报错”的问题,最后定位到不是事件顺序错误,而是 USB 设备枚举还没完成,程序去读串口设备列表时是空的。这种情况纯粹看代码看不出所以然,必须在日志里记录“系统唤醒时间”和“设备枚举完成时间”两个点,对比之后才恍然大悟。
日志本身也要注意落盘策略:睡眠前最后一条日志要保证 fflush 和 fclose,否则系统直接断电进入睡眠,日志缓冲区里的内容还没写进去就丢了。C/C++ 里文件流不手动冲洗,断电丢失是常态;WPF 里如果用 File.AppendAllText,它内部会打开关闭,正常情况不会丢。
5.5 别轻易拦截系统睡眠
最后说个观念层面的坑。PBT_APMQUERYSUSPEND 返回拒绝值确实能阻止系统睡眠,但这种“全局拦截”副作用很大:用户点了睡眠,结果电脑没睡,过一会儿电池耗完了,用户完全摸不着头脑。如果你确实有“正在执行关键任务时不允许睡眠”的需求,优先用 SetThreadExecutionState 告诉系统当前线程忙,而不是在电源广播里做一刀切。
我做过一个采集工具,跑批任务执行期间需要电脑保持清醒,但用户可能随时手动锁屏或合盖。我的方案是:任务开始前调 SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) 请求保持唤醒,任务结束后恢复 ES_CONTINUOUS。这样既不破坏用户主动睡眠的行为,也能满足业务需要。系统在合盖时是否尊重这个标志,取决于电源设置里的“合盖操作”,所以最好在文档里告诉用户把合盖操作设为“不采取任何操作”或者“睡眠但不强制”。
最后再分享一个我实际项目里的小技巧
做这类程序,我习惯在每次收到 PBT_APMSUSPEND 时把当前时间戳写进一个独立的小文件,比如 last_suspend.txt,每次唤醒时读取它,再算出本次睡眠时长。这个方法在你排查电池异常掉电、系统半夜自动唤醒等问题时特别好用。配合 powercfg /lastwake,能快速判断是定时器唤醒、按键唤醒、还是网络唤醒。
另外,如果你打算在 WPF 程序里同时用 SystemEvents.PowerModeChanged 和 HwndSource,记得只保留一种订阅方式,不要两个都挂。我在一个项目里同时挂了两个,结果日志里每个事件都重复出现两遍,排查了半天才发现是重复订阅造成的。选 SystemEvents 就够日常用,只有在需要拦截 PBT_APMQUERYSUSPEND 或者区分唤醒类型时才值得引入 HwndSource。
