SystemInformer 开发规范实战指南:从 Ph 前缀到引用计数,一次讲清源码阅读与贡献的约定
【免费下载链接】systeminformerA free, powerful, multi-purpose tool that helps you monitor system resources, debug software and detect malware. Brought to you by Winsider Seminars & Solutions, Inc. @ https://windows-internals.com项目地址: https://gitcode.com/GitHub_Trending/sy/systeminformer
导读
SystemInformer(Process Hacker 的后续项目)是一个由几十位贡献者长期协作维护的 C 代码库,它的可读性不是靠注释堆出来的,而是靠一份写在 HACKING.md 里的、近乎可执行的开发规范。读完这篇文章,你能做三件事:仅凭符号名判断它是公开 API、模块私有还是文件内局部实现;看懂四种函数失败语义和 SEH 的边界;按约定写出能直接通过评审的线程入口、锁使用、字符串操作与引用计数代码。
从符号名读起:谁公开,谁私有
先给规则:一个符号的"前缀"决定了它的可见性等级,这是整个代码库最省读者时间的约定。
命名风格分三档:
| 对象 | 风格 | 示例 |
|---|---|---|
| 函数、参数、全局变量 | CamelCase | PhCreateThread |
| 局部变量 | lowerCamelCase | myString |
| 结构体、枚举、联合体 | CAPS_WITH_UNDERSCORES | PH_STRING |
前缀体系只有一条推导规则——在公开前缀后追加字母p即得私有前缀:Ph→Php、Pha→Phap、PH_→PHP_。配套三条硬性规则:
- 无前置的函数与全局变量必须声明为
static,且static名称禁止携带公开前缀; - 带私有前缀的名称不要求
static,可以跨文件可见但属于模块内部; - 无前置的结构体必须定义在
.c/.cpp文件里。
为什么:仅看首字母就能判断一个符号属于公开 API、模块私有还是文件内局部实现,评审代码时能立刻建立可信边界,不用反复翻头文件确认声明位置。
数据怎么表达:固定宽度类型、布尔与注解
规则一:除 Win32 API 调用场景外,一律使用固定宽度类型。原因很直接:Windows 与 NT 内核的调用约定里,"宽度"是协议的一部分,int或long在不同平台上的位宽不一致。
| 用途 | 类型 | 宽度 |
|---|---|---|
| 布尔 | BOOLEAN/LOGICAL | 1 字节 / 4 字节 |
| 无符号 | UCHAR、USHORT、ULONG、ULONG64 | 1/2/4/8 字节 |
| 有符号 | SHORT、LONG、LONG64 | 2/4/8 字节 |
| 字符 / 字符串 | CHAR/WCHAR、PSTR/PWSTR | 1/2 字节 |
规则二:测试布尔值直接写变量本身,禁止与TRUE比较。
if (booleanVariable) // 而不是 if (booleanVariable == TRUE) { ... }为什么:TRUE的宏值是 1,但不少 API 返回非 1 的真值,比较写法反而制造歧义。若要把NTSTATUS、指针这类表达式规整成严格的 0/1 布尔值,用!!expr双重取反即可。
规则三与两条注解约定:
- 所有函数签名必须带 SAL 注解(
_In_、_Inout_、_Out_等)。它们不是装饰,MSVC 的 /analyze 等静态分析工具依赖这些注解检查内存与别名问题,声明处同时配合DECLSPEC_ALLOCATOR、DECLSPEC_NOALIAS、DECLSPEC_RESTRICT等限定符。 - 定义中禁止使用
volatile。确有必要时,把指针强制转换为指向volatile的指针再访问,只在真正需要"每次重新读取"的场合生效,避免无差别抑制编译器优化。
字符串对象的表达约定放在后面生命周期一节里讲,因为PH_STRING由引用计数管理,和对象规则一体。
失败怎么报告:四种返回语义与两条契约
规则:失败只能靠返回值报告,全代码库只有四种合法语义:
| 返回值 | 成功判定 | 备注 |
|---|---|---|
BOOLEAN | TRUE为成功 | 最轻量的语义 |
NTSTATUS | NT_SUCCESS宏 | 与 NT 内核接口对齐 |
HRESULT | HR_SUCCESS宏 | 禁止使用SUCCEEDED宏:第三方代码会用S_FALSE表示错误,SUCCEEDED会把它误判为成功 |
| 结果值(如指针) | 非特殊值 | 失败用特殊值(如NULL)表示 |
语义之外还有两条契约,评审时常被抓的就是这两条:
- 失败了的函数保证不修改任何输出参数(
_Out_、_Out_opt_等),除非文档另有说明。写 API 时若中途会填充输出,必须在注释里显式声明这一例外。 - 带回调的函数,失败时不保证回调未被调用。所以回调内部必须自带状态防护,不能假设"整体失败 = 自己没跑过"。
SEH 的三种例外:什么时候允许抛异常
默认策略是:SEH 异常只留给"无法恢复"的极端情况(如锁获取函数无法阻塞、引用计数出现负数)。允许抛异常的白名单只有三处,每处都有明确理由:
PhAllocate(phlib/include/phbasesup.h):分配失败直接抛异常。理由是分配失败极其罕见,为每次分配都检查返回值不划算。确实需要防御性处理的路径用PhAllocateSafe,它带_Must_inspect_result_注解强制检查返回值。PhProbeAddress(phlib/include/phsup.h):地址超出指定范围时抛异常。好处是一个 SEH 块可以合并完成多次地址范围检查,不用每次检查都写分支。STATUS_NOT_IMPLEMENTED:用于纯程序员错误、本不该被到达的路径,等价写法是assert(FALSE)。
除此之外,任何函数都应当返回错误码而不是抛出异常。
线程入口怎么写:固定签名加统一创建入口
规则:每个线程启动例程必须使用固定签名:
_Function_class_(USER_THREAD_START_ROUTINE) NTSTATUS NTAPI NameOfRoutine( _In_ PVOID Parameter );创建入口同样不许各自为政,统一走PhCreateThread家族(声明见 phlib/include/phbasesup.h):
HANDLE NTAPI PhCreateThread(SIZE_T StackSize, PUSER_THREAD_START_ROUTINE Start, PVOID Parameter); NTSTATUS NTAPI PhCreateThreadEx(PHANDLE ThreadHandle, PUSER_THREAD_START_ROUTINE Start, PVOID Parameter);PhCreateThread直接返回句柄;PhCreateThreadEx通过输出参数返回句柄并用NTSTATUS报告结果;另有PhCreateThread2(忽略句柄)与线程池投递函数PhQueueUserWorkItem。为什么:所有线程的生命周期管理(创建、等待、清理)收敛到同一组入口,审计时不用满库找CreateThread的裸调用。
侵入式与非侵入式集合怎么选
集合选型的判断维度是"链接字段放哪里"。侵入式集合把链接字段嵌进用户结构体,省去每个元素的一次额外分配;非侵入式集合由容器统一管理元素存储。
| 数据结构 | 非侵入式 | 侵入式 |
|---|---|---|
| 数组 | PH_ARRAY/PH_LIST/PH_POINTER_LIST | — |
| 链表 / 栈 / 队列 | — | LIST_ENTRY(双向)/SINGLE_LIST_ENTRY(单向) |
| 二叉树(AVL) | RTL_AVL_TABLE | PH_AVL_LINKS |
| 二叉树(splay) | RTL_GENERIC_TABLE | — |
| 哈希表 | PH_HASHTABLE | PH_HASH_ENTRY |
| 环形缓冲区 | PH_CIRCULAR_BUFFER | — |
选择建议:元素频繁增删、数量动态变化大时优先侵入式链表;需要随机访问与索引语义时用PH_ARRAY、PH_POINTER_LIST。完整清单见 HACKING.md 的 Collections 一节。
锁该用哪把:Queued Lock 与它的同步家族
规则:所有同步都必须使用 queued lock,理由是尺寸小、性能好。PH_QUEUED_LOCK(phlib/include/queuedlock.h)的全部状态就是一个ULONG_PTR Value。它本质是读写锁,但只用独占获取/释放两个函数就能当互斥量:
PhInitializeQueuedLock(&lock); PhAcquireQueuedLockExclusive(&lock); /* 临界区 */ PhReleaseQueuedLockExclusive(&lock);可以把等待机制想象成一个"按号排队的等待区":阻塞线程按到达顺序挂在 16 字节对齐的等待块链表(_PH_QUEUED_WAIT_BLOCK)上,PH_QUEUED_WAITER_EXCLUSIVE、PH_QUEUED_WAITER_SPINNING等标志实现公平排队与自旋优化,先来的先唤醒,避免饥饿——"queued"(排队)由此得名。
事件与 rundown 保护
PH_EVENT:惰性内核事件。它不会在真正阻塞前创建内核事件对象,平时只测试用户态标志位(如PH_EVENT_SET),所以状态测试非常快。适合"高频设置、低频等待"的场景。PH_RUNDOWN_PROTECT:对象销毁时的"浇灭"同步。它允许已有读者进入临界区,但阻止新读者进入,并等待已有读者全部退出后销毁才能完成——监控工具里"后台刷新线程正在遍历列表,而用户正要销毁窗口"的场景就靠它。
条件变量与 Wake Event
条件变量不需要额外结构:PH_CONDITION就是_PH_QUEUED_LOCK的别名,用PhPulseCondition/PhPulseAllCondition与PhWaitForCondition工作(二者是映射到Phf*的宏,见 queuedlock.h),带超时版本为PhWaitForConditionEx。锁与条件变量共享同一数据结构,没有额外的同步对象要管理。
Wake Event 是同一基座上的另一种玩法,适合在循环里轮询多个条件的低开销自定义锁:
/* 循环中测试一个或多个条件,不满足则 */ PhQueueWakeEvent(&wake, &waitBlock); /* 排队准备阻塞 */ PhWaitForWakeEvent(&wake, &waitBlock, FALSE, NULL); /* 条件变化的一侧调用 */ PhSetWakeEvent(&wake, NULL);PH_WAKE_EVENT同样是 queued lock 别名;若调用PhQueueWakeEvent之后发现不该阻塞,就把等待块传回PhSetWakeEvent取消本次等待。整套组合不引入内核事件对象,就能实现"条件驱动 + 高效唤醒"。
对象的一生:分配、引用、解引用与动词配对
规则:基础分配统一走PhAllocate/PhFree,复杂对象一律走引用计数系统(phlib/include/ref.h)。PhCreateObject创建时引用数为 1,PhReferenceObject加一,PhDereferenceObject减一,归零即销毁。
auto-dereference 池是半自动引用计数,可以理解为"统一延后释放的托盘":PhAutoDereferenceObject把对象放进当前线程的池(类似 Apple 的NSAutoreleasePool),对象在未来某个未指定时刻自动解引用,但保证当前函数执行期间不解引用。
引用时机的两条铁律:
- 只要把对象指针存入全局可见位置,或传递给另一个线程,就必须引用它;大多数其他场景不需要引用。
- 传给函数的对象,在调用期间必须至少持有一个引用。典型错误:窗口过程里保存了一个可能已被销毁的对象引用并隐式使用它——窗口过程执行期间消息泵仍在工作(比如弹了对话框),对象随时可能被销毁,所以必须在进入窗口过程后尽快引用。
生命周期动词的配对必须精确,评审时会按表核对:
| 动词 | 含义 | 配对收尾 |
|---|---|---|
Allocate | 分配内存,不初始化 | Free |
Create | 分配并初始化 | Free/Destroy |
Initialize | 用调用者提供的存储初始化 | Delete(不释放内存,存储归调用者) |
Create(引用计数语境) | 经引用计数系统创建 | PhDereferenceObject |
对照实例:PhCreateFileDialog/PhFreeFileDialog、PhInitializeWorkQueue/PhDeleteWorkQueue、PhCreateString/PhDereferenceObject。
计数单位也要分清,这是 API 签名里的"计量协议":
- Length 以字节计:如
PhCreateString的长度参数必须是字节数; - Count 以元素计:如
PhSubstring的长度参数是字符数; - Index 以元素计:如
PhSubstring的起始索引是字符数。
另外,向输出写入 null 结尾字符串时,若有返回计数,必须把 null 终止符本身计入写入字符数。
字符串对象:创建、拼接、格式化与自动收尾
字符串使用PH_STRING类型,引用计数管理。结构定义见 phlib/include/phbasesup.h:头部是Length(字节数)加Buffer(可直接按PH_STRINGREF看待),数据区嵌入WCHAR Data[1],与缓冲连续分配,另保留分配跟踪字段。所有字符串内嵌长度、始终以字节计,并额外以 null 结尾兼容传统 C 接口。
PPH_STRING myString = PhCreateString(L"My string"); wprintf(L"My string is \"%s\", and uses %Iu bytes.\n", myString->Buffer, myString->Length);PhCreateString是内联函数,内部即PhCreateStringEx(Buffer, PhCountStringZ(Buffer) * sizeof(WCHAR)),自动按字节算长度;空串场景用PhReferenceEmptyString拿共享空串,避免重复分配。
不可变性是硬性约定:字符串对象必须视为不可变。唯一例外——用PhCreateString/PhCreateStringEx创建后、指针被其他函数看到或存入全局变量之前,允许本地修改;指针一旦"见光",即只读。
拼接与格式化:
PPH_STRING newString; newString = PhConcatStrings( 4, L"My first string, ", L"My second string, ", aStringFromSomewhere, L"My fourth string." ); newString = PhConcatStrings2(L"My first string, ", L"My second string."); newString = PhFormatString(L"%d: %s, %#x", 100, L"test", 0xff);PhFormatString的格式串带_Printf_format_string_注解,编译器与静态分析器会校验参数匹配。面向PH_STRINGREF还有PhConcatStringRef系列变体,PhConcatStrings_V内部带长度缓存,避免反复重分配。
最省样板的写法是Pha前缀函数(如PhaConcatStrings):它们把创建结果直接包进PH_AUTO_T放入 auto-dereference 池,作用域结束自动解引用,不用再手写PhDereferenceObject收尾。
提交前过一遍这份清单
- 命名:函数/参数/全局变量 CamelCase,局部变量 lowerCamelCase,结构体大写带下划线
- 公开符号带
Ph/PH_,模块私有带Php/PHP_,无前置的函数与全局变量已声明static - 类型是固定宽度(
BOOLEAN/LOGICAL/UCHAR/USHORT/LONG/ULONG/LONG64/ULONG64),Win32 场景除外 - 布尔直接测试,未写
== TRUE;非布尔表达式用!!规整为 0/1 - 函数签名带 SAL 注解;定义中未出现
volatile - 失败语义四选一,
HRESULT检查用的是HR_SUCCESS而非SUCCEEDED - 失败路径未触碰输出参数;带回调的失败路径,回调内部已做状态防护
- 线程入口是固定签名,创建走
PhCreateThread家族 - 同步用 queued lock 独占获取当互斥量;事件用
PH_EVENT,销毁同步用 rundown protect,未裸建内核事件 - 跨线程或存入全局位置的引用已引用;传给函数的对象在调用期间至少持有一个引用
- 生命周期动词配对正确(
Initialize/Delete、Create/Free),长度参数区分了字节与元素单位 - 字符串指针共享后只读;创建收尾交给
PhDereferenceObject或Pha*自动池
约定不是风格偏好,而是架构本身:Ph/Php前缀划定 API 边界,queued lock 是唯一的同步基石,引用计数与 auto-dereference 池统一管理对象生命周期。SystemInformer 几十万行代码之所以还能被持续协作维护,靠的就是这份写在 HACKING.md 里的清单——按它提交,代码就有资格进入这个库。
【免费下载链接】systeminformerA free, powerful, multi-purpose tool that helps you monitor system resources, debug software and detect malware. Brought to you by Winsider Seminars & Solutions, Inc. @ https://windows-internals.com项目地址: https://gitcode.com/GitHub_Trending/sy/systeminformer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考