Windows 内存的深层(第1回) ── 虚拟地址变成物理 RAM 的瞬间:页错误从头到尾

· · Windows, 内存管理, VirtualAlloc, 页错误, VAD, 性能监视

VirtualAlloc 传入 MEM_COMMIT 时,Commit 会立刻增加。不过 Working Set 不一定会同步增加同样的量。那么,你以为已经分配的内存在哪里?

答案是 大多数页还没有对应的物理 RAM。Windows 会把物理页的指派推迟到应用程序真正碰到该页。第一次访问让 CPU 发出页错误时,内存管理器会检查 VAD、PTE、保护属性和后备存储,必要时一页一页把 RAM 绑上去。1

本文跟着「你碰到的第一个字节」走到物理 RAM。若想先整理 Working Set 与 Commit 等数字的含义,请看入门篇「Windows的「内存使用量」究竟表示什么 ── 正确解读 Working Set・Private Bytes・Commit・页面文件」。本系列不重定义那里用过的术语,而是从机制面追「为什么会出现那个数字」。

「Windows 内存的深层」全 3 回

  1. 第1回(本文):虚拟地址与页错误
    跟着以 VirtualAlloc 分配的区域何时取得物理 RAM。
  2. 第2回:物理页的一生
    跟着离开 Working Set 的页如何在 Modified、Standby、Free、Zeroed 之间移动。
  3. 第3回:节对象与写时复制
    跟着 DLL、文件映射与共享内存为何能共享物理页。

第1回要回答的问题只有一个。

已提交的虚拟地址,在哪一瞬间变成物理 RAM?

预定读者是想从机制理解 Windows 应用内存用量、启动直後的页错误、0xC0000005,以及 VMMap 与 PerfMon 数字的开发者与运维人员。前提环境是 Windows 10/11 或现行 Windows Server,必要背景是指针与 VirtualAlloc 基础;不需要页表位布局或内核调试器经验。难度为中级。会用到内部结构名称,但不假设依赖特定 Windows 版本的未公开布局。

1. 先讲结论

普通私有内存的流程,可以用一句话说完。

Reserve 预留虚拟地址范围,Commit 从系统提交限额计入 commit charge 以保证将来有地方保存内容,第一次访问的页错误才指派物理页。

也就是说,MEM_COMMIT 不是「现在立刻分配 RAM」的命令。Microsoft 的 VirtualAlloc 文档也保证已提交页的初始内容为零,同时说明实际物理页要到访问该虚拟地址才会被指派。1

不过,说「Reserve/Commit 只写进 VAD」也不准确。实务上,Reserve 主要建立代表虚拟地址范围与属性的 VAD,Commit 则增加系统的 Commit Total 并记录该范围的提交状态。中间页表层与各个 PTE 会在需要时延迟建立,与物理 RAM 的最终绑定通常发生在第一次访问。

Commit 不是空头支票,而是 全系统保证将来能把内容保存在 RAM 或适当后备存储的承诺。把这项承诺一页一页兑现的入口,就是页错误。

Reserve、Commit 与第一次访问时发生的事MEM_RESERVE 把范围与属性写进 VAD,MEM_COMMIT 消耗 Commit Total 以承诺存放,第一次访问的页错误指派物理页并加入 Working Set1. MEM_RESERVE2. MEM_COMMIT3. 第一次访问(Touch)把范围与属性记进 VAD消耗 Commit Total(尚无物理页)页错误把已清零的物理页绑到 PTE加入 Working Set 并重执行指令

图 1: Reserve、Commit 与 Touch 是分开的事件。物理 RAM 只在最后一步、也就是第一次访问时被绑上。

2. 追踪虚拟页的三本账本

要理解从虚拟地址到物理 RAM 的路径,必须分清 Windows 保存的三种账本。

账本 单位 角色
VAD 虚拟地址范围 管理区域是什么、Reserve/Commit、保护、与节的对应
页表 / PTE 虚拟页 表示目前对物理页的转换,或尚未实现的状态
PFN 数据库 物理页 追踪每页 RAM 的所有者、引用与状态

VAD 握的是 范围,PTE 握的是 虚拟页,PFN 数据库握的是 物理页。页错误处理程序交叉核对它们,决定访问能否继续。

从虚拟地址到物理 RAM 的三本账本虚拟地址由 VAD 以范围粒度管理、由 PTE 以虚拟页粒度管理,PTE 的转换目标物理页则由 PFN 数据库以物理页粒度追踪判断 Reserve/Commit 与保护有效转换虚拟地址VAD(范围账本)PTE(虚拟页账本)PFN 数据库(物理页账本)物理 RAM 页

图 2: 粒度不同的三本账本。错误处理交叉核对 VAD 与 PTE,再把结果反映到 PFN 侧。

本文的主角是 VAD 与 PTE。PFN 数据库会在第2回从物理页那一侧来看。

3. Reserve、Commit 与 Touch 是分开的事件

3.1. Reserve ── 先占住地址

先预留一段连续 256MiB 的虚拟地址范围。

void* base = VirtualAlloc(
    nullptr,
    256ull * 1024 * 1024,
    MEM_RESERVE,
    PAGE_NOACCESS);

此刻发生的事,只是在进程的虚拟空间里把地址占住,让其他分配不能用这段范围。MEM_RESERVE 不会在 RAM 或页面文件指派物理存储。1

64 位进程的虚拟空间很大,先 Reserve 大范围、之后只 Commit 需要的部分,实务上很常见。

3.2. Commit ── 承诺将来保得住

接着把已预留的范围 Commit。

void* committed = VirtualAlloc(
    base,
    256ull * 1024 * 1024,
    MEM_COMMIT,
    PAGE_READWRITE);

成功时,反映在系统 Commit Total——通常也反映在进程 Private Bytes——的承诺量会增加。即便如此,也不会一次排出 256MiB 的物理页。普通页在第一次访问前仍保持物理未指派。12

那么 Commit 的意义是什么?在于 系统无法承担这项承诺时,可以在 Commit 当下返回失败,而不是用到一半才失败

3.3. Touch ── 物理页变得必要的时候

最后,下面这次赋值第一次写入第一页。

static_cast<unsigned char*>(base)[0] = 1;

CPU 试着把虚拟地址转成物理地址,但 PTE 还没有对物理页的有效转换。页错误就发生在这里。

接到控制权的内存管理器判断这是「对已提交、可写私有页的第一次访问」,取得已清零的物理页、绑到 PTE,并加入 Working Set。然后重执行失败的写入指令。

对应用来说只是一次赋值,内部却是 在赋值途中进入内核、指派物理页,再回到同一条指令

4. VAD ── 虚拟空间的范围账本

VAD 是 Virtual Address Descriptor 的缩写,Windows 用 VAD 树管理进程正在使用的地址范围。用 WinDbg 的 !vad 可以查看起止 VPN、Commit、保护属性、Private/Mapped、Control Area 等。3

VAD 记录的代表性信息包括下列项目。

  • 地址范围的起点与终点
  • Private、Mapped、Image 等种类
  • Reserve/Commit 状态
  • 读、写、执行、写时复制等保护
  • 与文件或节的对应
  • 防护页等特殊属性

用范围管理是为了效率。256MiB 以 4KiB 计是 65,536 页。与其一开始就为每一页建完整管理结构,不如在 VAD 里记下「这段连续范围是一次预留」,需要时再实现各页,比较不浪费。

4.1. 找到 VAD 不保证能恢复

「在 VAD 里就能解决错误,不在就访问违规」当作入门说明很方便,但过度简化。即使找到 VAD,下列情况仍无法继续一般访问。

  • 只有 Reserve,目标页尚未提交
  • PAGE_NOACCESS
  • 对只读页写入
  • 在不可执行页上执行指令
  • 第一次碰到防护页
  • 碰到节有效范围之外

反过来说,即使 PTE 无效,只要 VAD 与 PTE 的软件状态显示这是正当访问,就能以 demand-zero、Transition 还原、换入或 CoW 解决。更精确的答案是 把 VAD、PTE、保护属性与访问类型一起判断

5. 页表与 TLB

应用握着的指针是虚拟地址。CPU 要访问 RAM,必须把虚拟页号转成物理页号。那份分层转换表就是页表,叶项是 PTE(Page Table Entry)。

有效 PTE 在概念上握有 PFN、读写执行保护、用户模式权限、Accessed/Dirty 等信息。实际位布局依 CPU 与 Windows 版本而异。

每次都走页表会太慢,所以 CPU 把最近的转换缓存在 TLB(Translation Lookaside Buffer)。地址转换按这个顺序进行。

  1. 若 TLB 有转换且访问符合该保护,就用那个结果。
  2. 若 TLB 没有转换,CPU 走页表。
  3. 若有有效 PTE 且保护也符合,就登记到 TLB 并继续执行。
  4. 若没有有效转换,或发生保护违规,就走进页错误入口。保护检查在转换来自 TLB 时也会做。

这个流程显示 TLB 未命中与页错误是两件事。若只是 TLB 没有转换而 PTE 有效,发生的只是页表遍历。反过来说,即使 TLB 已有转换,对只读页写入或在不可执行页上执行指令这类保护违规,仍会走进页错误入口。因此对 CoW 页写入,即使转换已缓存,仍可能出错。

地址转换流程与页错误入口即使 TLB 有转换,保护不符仍走进页错误入口。TLB 没有转换就走页表;有效 PTE 且保护也符合则登记 TLB 并继续,无效转换或保护违规则走进页错误入口符合保护违规没有无效或保护违规内存访问TLB 有转换吗?访问符合保护吗?用该转换继续走进页错误入口走页表有效 PTE 且保护也符合?登记 TLB 并继续(无错误)

图 3: TLB 未命中可以用走页表解决。转换无效或有保护违规时才走进页错误;保护违规在 TLB 命中时也会发生。

5.1. 无效 PTE 不是空白

即使是无效 PTE 也不是空的。Windows 从无效 PTE 的软件状态区分下列情况。

  • 从未实现的 demand-zero 页
  • 仍留在 RAM 的 Transition 页
  • 引用 Prototype PTE 的共享页
  • 保存在页面文件的私有页
  • 保护违规或无效区域

CPU 的工作只是判断「这不是普通的有效转换」并交给内核;之后的含义由内存管理器补上。

6. 页错误从头到尾

让我们用六个阶段跟着对已提交私有页的第一次写入。

  1. CPU 尝试写入。
    它检查 TLB 与页表,但目标 PTE 没有有效 PFN。
  2. CPU 发出页错误。
    把发生错误的虚拟地址、读写执行类型、用户/内核,以及问题是缺少转换还是保护违规,交给内核。
  3. 内存管理器检查 VAD 与 PTE。
    判断页是否已提交、保护是否符合,以及属于 demand-zero、Transition、共享、换入、CoW 或异常的哪一种。
  4. 若是 demand-zero,取得已清零的物理页。
    新交出的页必须是零,以免泄漏另一个进程的数据。
  5. 更新 PTE 与 PFN 管理信息。
    在 PTE 设置 PFN 与保护,把物理页设为 Active,并加入进程的 Working Set。
  6. 重执行失败的指令。
    因为错误正常解决,不会把用户模式异常交出去,应用像平常一样继续赋值。

ETW 页错误事件也把 Transition、Demand Zero、Copy-on-Write、Guard Page、Hard Page Fault、Access Violation 记成不同种类。4

所以页错误一开始就不是「异常」这个词。它是 CPU 无法走普通路径转换时,请操作系统判断的共用入口。

页错误解决的分支内存管理器判断 VAD、PTE、保护属性与访问类型,分派到 demand-zero、重新接上仍在 RAM 的页、从后备存储来的硬错误、写时复制、防护页通知或异常第一次访问仍在 RAM需要读磁盘CoW 写入防护页无法解决发生页错误判断 VAD、PTE、保护、类型Demand-zero(软)从 Standby 重接(软)硬错误(磁盘 I/O)复制并更换 PTE清掉防护并通知异常(0xC0000005 等)

图 4: 从同一入口进来的错误,依判断分成六种结果。防护页细节见第 9 节。

7. Demand-zero ── 不读磁盘的软错误

Demand-zero 是第一次碰到已提交私有页时发生的代表性软错误。Microsoft 的 Working Set 文档也把「进程第一次引用已分配的虚拟页」列为软错误的例子。5

Demand-zero 有下列特征。

  • 不必从磁盘读原始数据
  • 初始内容为零
  • 绑上可用的物理页
  • Working Set 与累计 Page Fault Count 增加
  • 单靠这次处理不会增加 Memory\\Pages Input/sec

因此启动直後 Page Faults/sec 飙高,本身并不代表存储是瓶颈。

延迟分配的取舍也值得整理。若 Commit 了 256MiB 却实际只用 8MiB,把剩下 248MiB 留在 RAM 外是合理的。代价是第一次访问要付错误处理成本。对延迟敏感的工作,有一种设计是开始前先碰每一页做预错误(prefault),但那是先增加 RAM 常驻的取舍。

8. 软错误与硬错误

8.1. 软错误

软错误是不必对后备存储做读取 I/O 就能解决的错误。代表性例子包括下列项目。

  • Demand-zero
  • 把仍在 Standby/Transition 的页重新接上
  • 接上已在另一进程 Working Set 的共享页
  • 接上已预取的页
  • 原始页仍常驻的写时复制

内核转换、锁、PTE/PFN 更新、TLB 一致性等仍有 CPU 成本,但没有存储等待。5

8.2. 硬错误

另一方面,需要的页不在 RAM 任何地方、必须从后备存储读取时,就是硬错误。读取来源不只是页面文件。

  • 被写出到页面文件的私有页
  • 内存映射文件
  • EXE 或 DLL 映像
  • 文件缓存引用的数据文件

ETW HardFault 事件包含 FileObject、ReadOffset、ByteCount,因此可以追到实际读取来源。6

所以 Hard Fault = 读 pagefile.sys 并不成立。

需要读后备存储时,请求会进入 Windows I/O 栈。IRP 的发出与完成流程见「Windows I/O 的深层(第1回)」,与文件缓存的接点见「Windows I/O 的深层(第4回)」。页在 RAM 里时,内存管理器可以自己回来;不在时就发出 I/O,并让发生错误的线程等到完成。

9. 无法解决的错误会变成异常

检查 VAD 与 PTE 后,无法当成正当分配、换入或 CoW 解决的错误,会以异常交给用户模式。

代表性情况是 STATUS_ACCESS_VIOLATION,异常码 0xC0000005。它发生在对无效地址读、写或执行;第一个异常参数指出访问类型,第二个指出违规地址。7

典型模式包括下列项目。

  • 读取 NULL、已释放地址或数组外的地址
  • 对只读页写入
  • 从 DEP/NX 设成不可执行的页执行指令
  • 碰到尚未提交的预留范围

PAGE_GUARD 的含义稍有不同。它是对访问的一次性通知:引发 STATUS_GUARD_PAGE_VIOLATION,用于堆栈增长等情况。8

正常的延迟分配、换入、CoW、防护通知,以及最后的访问违规,从 CPU 看来都聚在同一个页错误入口。决定结果的是 VAD、PTE、保护属性与访问类型的组合。

10. 自己动手看

到目前为止的流程,可以在自己的机器上观察。下面的 C++ 程序 Reserve 256MiB、Commit,对每页写一个字节,最后 Release。每个阶段都会等 Enter,方便用 VMMap 与 PerfMon 观察变化。

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <psapi.h>

#include <cstdio>
#include <cstdlib>

#pragma comment(lib, "Psapi.lib")

constexpr SIZE_T kSize = 256ull * 1024 * 1024;

void PrintMemory(const char* stage)
{
    PROCESS_MEMORY_COUNTERS_EX c{};
    c.cb = sizeof(c);
    if (!GetProcessMemoryInfo(
            GetCurrentProcess(),
            reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&c),
            sizeof(c))) {
        std::printf("GetProcessMemoryInfo failed: %lu\n", GetLastError());
        return;
    }

    std::printf(
        "%-10s WS=%zu MiB  Private=%zu MiB  Faults=%lu\n",
        stage,
        c.WorkingSetSize / 1024 / 1024,
        c.PrivateUsage / 1024 / 1024,
        c.PageFaultCount);
}

void Pause(const char* message)
{
    PrintMemory(message);
    std::puts("Press Enter...");
    (void)std::getchar();
}

int main()
{
    SYSTEM_INFO si{};
    GetSystemInfo(&si);
    std::printf("PID=%lu, page=%lu bytes\n",
                GetCurrentProcessId(), si.dwPageSize);

    void* base = VirtualAlloc(nullptr, kSize, MEM_RESERVE, PAGE_NOACCESS);
    if (!base) {
        std::fprintf(stderr, "Reserve failed: %lu\n", GetLastError());
        return EXIT_FAILURE;
    }
    Pause("reserved");

    if (!VirtualAlloc(base, kSize, MEM_COMMIT, PAGE_READWRITE)) {
        std::fprintf(stderr, "Commit failed: %lu\n", GetLastError());
        VirtualFree(base, 0, MEM_RELEASE);
        return EXIT_FAILURE;
    }
    Pause("committed");

    auto* bytes = static_cast<volatile unsigned char*>(base);
    for (SIZE_T offset = 0; offset < kSize; offset += si.dwPageSize) {
        bytes[offset] = 1;
    }
    Pause("touched");

    if (!VirtualFree(base, 0, MEM_RELEASE)) {
        std::fprintf(stderr, "Release failed: %lu\n", GetLastError());
        return EXIT_FAILURE;
    }
    Pause("released");
}

在 Visual Studio 的 x64 Native Tools Command Prompt 可用下列命令生成。

cl /std:c++20 /EHsc /W4 memory_fault_demo.cpp

10.1. 在 VMMap 看什么

VMMap 会按类型显示已预留的虚拟内存、Commit、Working Set、Private 与 Shareable。9 各阶段预期的变化如下。

阶段 预期变化
Reserve Address Space Size 增加,但 Commit/WS 不会同步增加同样的量
Commit Private Commit 大约增加 256MiB
Touch Working Set 与 Private WS 明显增加,Fault Count 也增加
Release 目标范围消失,Commit 与 WS 下降

实际数字会随运行时、安全产品、内存压力与观察时机而变。看的是 各阶段之间数字往哪边移动,不是是否刚好等于 256MiB。

10.2. 在 PerfMon 分开软与硬

在 PerfMon 把下列计数器放在同一时间轴。

  • Process(<target>)\\Page Faults/sec
  • Memory\\Pages Input/sec
  • Memory\\Page Reads/sec
  • Memory\\Available MBytes
  • Process(<target>)\\Working Set - Private
  • Process(<target>)\\Private Bytes

Process\\Page Faults/sec 同时包含软错误与硬错误。另一方面,Memory\\Pages Input/sec 是为了解决硬错误而从磁盘读入的页数。10

在这个程序的 Touch 阶段,Page Faults/sec 应该跳升,而 Pages Input/sec 不该升太多。新提交的页由 demand-zero 实现,不必从磁盘读原始数据。

多个进程共用同一名称时,PerfMon 的 process#1 这类编号可能在重启后改变。请用显示 PID 的计数器交叉核对,或用 Process V2 或 ETW/WPA 按 PID 识别。

11. 实务上要避开的三种误读

11.1. 「Commit 上去了,所以是 RAM 泄漏」

Commit 是承诺保存的内容量;还没碰到的页可能不常驻在 RAM。要判断泄漏,请看 Private Bytes 的时间序列、分配的拆解,以及处理结束后数字是否回到基线。

11.2. 「Page Faults/sec 很高,所以磁盘很慢」

软错误没有磁盘 I/O。把 Page Faults/secPages Input/sec 与存储等待分开看,必要时用 ETW HardFault 事件追到源文件与调用栈。

11.3. 「清空 Working Set 就能修好泄漏」

把页从 Working Set 拿掉并不释放 Commit 或所有权。该页会走到 Standby 或 Modified,稍后再错误回来。修好泄漏需要分配者执行 VirtualFree、堆释放、对象析构等。

被拿掉的物理页去了哪里,第2回会跟着走。

12. 摘要

  • MEM_RESERVE 预留虚拟地址范围,但不在 RAM 或页面文件指派物理区域。1
  • MEM_COMMIT 消耗 Commit 并保证将来能保存内容,但普通物理页要到第一次访问才指派。12
  • VAD 是范围账本,PTE 是虚拟页账本,PFN 数据库是物理页账本。
  • TLB 未命中不是页错误。PTE 有效时,只走页表就能解决。
  • Demand-zero、Transition 还原、接上共享页,是不必磁盘 I/O 就能解决的软错误。5
  • 若必须从页面文件、DLL、EXE 或映射文件读取,就是硬错误。6
  • 检查 VAD、PTE 与保护属性后仍无法解决,会得到 0xC0000005 这类异常。7
  • 判断性能时不要只看 Page Faults/sec;把 Pages Input/sec、Available、Working Set、Private Bytes 与存储等待放在同一时间轴。

续篇是第2回,「物理页的一生:五份列表与页面文件的真相」。

Commit 承诺变成物理页之后,我们从 PFN 数据库与页列表跟着该页离开 Working Set 后去了哪里。

相关文章

相关咨询领域

小村软件有限公司承接 Windows 应用程序内存用量、访问违规、启动延迟、分页与原生代码缺陷的调查。

参考链接

  1. Microsoft Learn, VirtualAlloc function. 关于 MEM_RESERVE 预留虚拟地址范围而不指派物理存储;MEM_COMMIT 对系统整体内存与页面文件计入 commit charge;已提交页的初始内容为零;以及实际物理页要到被访问才指派。  2 3 4 5 6

  2. Microsoft Learn, PERFORMANCE_INFORMATION structure. 关于 CommitTotal 是当前系统 Commit 页数,以及 CommitLimit 是不扩展页面文件也能提交的上限。  2

  3. Microsoft Learn, !vad (WinDbg). 关于 !vad 显示 VAD 树,并可查看起止 VPN、Commit、Mapped/Private、保护属性、Control Area 等。 

  4. Microsoft Learn, PageFault_TypeGroup1 class. 关于 ETW 区分并记录 Transition Fault、Demand Zero Fault、Copy-on-Write、Guard Page Fault、Hard Page Fault 与 Access Violation。 

  5. Microsoft Learn, Working Set. 关于软错误可不访问后备存储就能解决,并因另一进程的 Working Set、Transition、第一次引用的 demand-zero 等而发生。  2 3

  6. Microsoft Learn, PageFault_HardFault class. 关于 HardFault 事件包含 FileObject、ReadOffset、ByteCount、VirtualAddress 与线程 ID,因此可追踪读取来源。  2

  7. Microsoft Learn, Access Violation C0000005. 关于 0xC0000005 发生在对无效内存地址读、写或执行,以及异常参数指出访问类型与违规地址。  2

  8. Microsoft Learn, Creating Guard Pages. 关于 PAGE_GUARD 提供页面访问的一次性通知,并引发 STATUS_GUARD_PAGE_VIOLATION。 

  9. Microsoft Learn, VMMap - Sysinternals. 关于 VMMap 按类型拆解已提交虚拟内存,并显示各类型的 Working Set 与详细地址图。 

  10. Microsoft Learn, Performance Analysis of Logs (PAL) Tool. 关于 Memory\\Pages Input/sec 是为了解决硬页错误而从磁盘读入的页数。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

向 VirtualAlloc 传入 MEM_COMMIT 的当下就会分配 RAM 吗?
在普通的私有内存里,Commit 会消耗系统的提交余量,但对应的物理页要到第一次访问才会被指派。以写入首次碰到的页,是在 demand-zero 错误处理中获得物理页。
页错误代表异常或有性能问题吗?
不是。像 demand-zero 或从 Standby 回来这种没有磁盘 I/O 的软错误是正常运行。判断性能时,除了 Page Faults/sec,还要看 Pages Input/sec、存储等待和 Available MBytes。
TLB 未命中和页错误是同一件事吗?
不是。即使 TLB 没有转换,只要页表的 PTE 有效,CPU 只是走表并重新登记转换。PTE 无效或发生保护违规时,才会走进页错误入口。
地址范围在 VAD 里,就不会发生访问违规吗?
不一定。除了 VAD 是否存在,内存管理器还会评估 Reserve 与 Commit、读写执行保护、防护页、PTE 状态等。错误无法解决时,会得到 0xC0000005 这类异常。
Page Faults/sec 很高就代表系统 RAM 不够吗?
单凭这个看不出来。Page Faults/sec 也包含大量软错误。必须和 Memory\Pages Input/sec、Memory\Page Reads/sec、Available MBytes、磁盘等待时间放在同一时间轴上对照。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表