一个只读的 Windows 体检脚本:内存到底够不够、网卡为什么只跑百兆

0 阅读10分钟

我把"看自己这台电脑到底卡在哪"这件事写成了两个只读脚本。跑完你会得到一份 JSON: CPU / 内存(含内存条与插槽)/ 硬盘(型号、健康、卷余量)/ 显卡 / 主板固件 / 网卡 / 内存占用最高的进程。

写的过程中踩到三个坑,每个都会让人把错的数当成对的,所以先讲坑,再给完整代码。

坑一:同一个"内存占用",两个口径差 7 个点

Windows 上"内存用了多少"至少有两个算法:

  • GlobalMemoryStatusEx().dwMemoryLoad —— 和任务管理器「使用中」一致(推荐);
  • 100 − 可用物理内存 / 总物理内存 —— 常比前者低 5~8 个点。

差在哪:后者把"待机缓存/压缩内存"算成了"可用"。所以看到 78% 别高兴,任务管理器可能写着 86%。

规矩:报数时写明用的是哪个口径。脚本里统一用第一个,并在 JSON 的 source 字段里标明。

坑二:显存字段有 4 GB 截断

Win32_VideoController.AdapterRAM 是 32 位字段,超过 4 GB 的显卡会读出错值(常见:0 或 4294967295 附近)。 我自己的机器上它把一块 2 GB 独显读成 2 GB(正常),把核显读成 1 GB,把虚拟显示适配器读成 0 —— 三个数摆在一起很容易被误当成"显存总量"。

规矩:这个字段可以打印,但不能拿来下结论;要准确显存请用厂商工具。

坑三:"读不到"不等于 0

温度(MSAcpi_ThermalZoneTemperature)和 SSD 磨损(Get-StorageReliabilityCounter)在消费级笔记本上经常读不到 (需要管理员权限或驱动支持)。很多脚本这时候会打印 0,于是你看到的是"温度 0 度、磨损 0%"—— 两个都是假的。

规矩:读不到就写 未测 + 原因(本脚本写在 _err 字段里),绝不用 0 顶替。


工具一:net_link_check.py(查"千兆网卡为什么只跑百兆")

判断方式不是猜:看链路协商速率(Get-NetAdapter.LinkSpeed)与是否被写死 (Speed & Duplex 高级属性)。若显示 Auto Negotiation 而速率只有 100 Mbps,那不是配置问题, 瓶颈在网线或路由器/交换机端口(换一根 Cat5e/Cat6、换个口再测)。

# -*- coding: utf-8 -*-
r"""net_link_check.py —— 只读:查「千兆网卡为什么只跑百兆」。

判据(三态,不许糊):
  · 网卡**协商速率**:`Get-NetAdapter.LinkSpeed`(100 Mbps / 1 Gbps …);
  · 网卡**是否被写死**:`Speed & Duplex` 高级属性 —— `Auto Negotiation` 表示**不是配置问题**,
    对端(网线/路由器口)才是瓶颈;反之若是 `100 Mbps Full Duplex` 这种固定值,则是被配置写死。
  · 读不到就写 `_err`,**不写 0**。

用法:py -3.12 -X utf8 net_link_check.py [--json]
"""
import argparse
import json
import subprocess
import sys

PS_HEAD = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; "


def ps(cmd: str, timeout=45):
    try:
        r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", PS_HEAD + cmd],
                           capture_output=True, text=True, encoding="utf-8", errors="replace",
                           timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        out = (r.stdout or "").strip()
        if r.returncode != 0:
            return {"_err": "rc=%s %s" % (r.returncode, (r.stderr or "")[-120:])}
        return json.loads(out) if out else {"_err": "empty_output"}
    except Exception as e:
        return {"_err": "%s: %s" % (type(e).__name__, str(e)[:100])}


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args()
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

    up = ps("Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object Name,InterfaceDescription,"
            "LinkSpeed,MediaType,FullDuplex | ConvertTo-Json")
    adv = ps("Get-NetAdapterAdvancedProperty -ErrorAction SilentlyContinue | "
             "Where-Object DisplayName -match 'Speed|Duplex|速度' | "
             "Select-Object Name,DisplayName,DisplayValue | ConvertTo-Json")
    up_list = up if isinstance(up, list) else [up]
    adv_list = adv if isinstance(adv, list) else [adv]

    verdicts = []
    for n in up_list:
        speed = str(n.get("LinkSpeed") or "")
        forced = [x for x in adv_list if str(x.get("DisplayName", "")).lower().find("speed") >= 0
                  and str(x.get("DisplayValue", "")) == "Auto Negotiation"]
        if speed.startswith("100 M"):
            verdicts.append({
                "nic": n.get("Name"), "speed": speed,
                "looks_like": "链路只有百兆",
                "speed_duplex": (forced[0].get("DisplayValue") if forced else "未测"),
                "conclusion": ("网卡是自动协商 ⇒ **不是配置被写死**,瓶颈在网线或路由器/交换机口"
                               "(换一根 Cat5e/Cat6 网线、换一个口再测)"
                               if forced else
                               "读不到 Speed&Duplex ⇒ 未测;先看是否被写死为 100M"),
                "acceptance": "换线/换口后重跑本脚本,`LinkSpeed` 应变为 1 Gbps",
            })
        else:
            verdicts.append({"nic": n.get("Name"), "speed": speed, "looks_like": "非百兆(无需处理)"})

    payload = {"uplinks": up_list, "speed_duplex_props": adv_list, "verdicts": verdicts}
    if a.json:
        print(json.dumps(payload, ensure_ascii=False, indent=1))
    else:
        for v in verdicts:
            print("%-18s %-10s %s" % (v.get("nic"), v.get("speed"), v.get("conclusion", v.get("looks_like"))))
        if not verdicts:
            print("没有处于 Up 状态的网卡(或读取失败):", json.dumps(payload, ensure_ascii=False)[:200])
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

工具二:ai_pc_inventory.py(整机体检)

每一段都带 source(取自哪个 CIM 类/命令),产物写到 %PC_CHECK_OUT% 或 ~/pc_check_reports/:

# -*- coding: utf-8 -*-
r"""ai_pc_inventory.py —— **AI 电脑改造 · 现状扫描**(总纲第五节 1.(1)/2.(1)/6.(1))。

允许自主的部分:扫描 CPU / 内存 / 硬盘 / GPU / 主板 / 固件 / 温度 / 网络 / 进程;**只读**。
纪律:每段带 `source`(哪条 CIM 类/命令);**取不到就 `_err` + 说明,绝不写 0 冒充**;
      `_items` 保留原始列表(多盘的机器不会被"只取第一条"吃掉)。

产物:%PC_CHECK_OUT% 或 ~/pc_check_reports/ 下的 ai_pc_inventory_<时间戳>.json(+ latest)
用法:py -3.12 -X utf8 ai_pc_inventory.py [--md]

★ 打包版改动(2026-10-16):**输出目录不再写死本机工作区路径**(原来写死 D:\8月\正式版1),
  改成 `%PC_CHECK_OUT%` 或 `~/pc_check_reports/` —— 别人拿到这一个文件就能直接跑。
"""
import argparse
import datetime as dt
import json
import os
import pathlib
import subprocess
import sys

OUT_DIR = pathlib.Path(os.environ.get("PC_CHECK_OUT") or (pathlib.Path.home() / "pc_check_reports"))
PS_HEAD = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; "


def ps(cmd: str, timeout=90):
    try:
        r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", PS_HEAD + cmd],
                           capture_output=True, text=True, encoding="utf-8", errors="replace",
                           timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        out = (r.stdout or "").strip()
        if r.returncode != 0:
            return {"_err": "rc=%s %s" % (r.returncode, (r.stderr or "")[-160:])}
        return json.loads(out) if out else {"_err": "empty_output"}
    except Exception as e:
        return {"_err": "%s: %s" % (type(e).__name__, str(e)[:120])}


def sect(source: str, payload) -> dict:
    """把任意 CIM 返回(dict / list / 单值)统一成 {source, _items, ...首条字段}。"""
    d = {"source": source}
    if isinstance(payload, dict):
        if "_err" in payload:
            d["_err"] = payload["_err"]
        d.update(payload)
        d["_items"] = [payload]
    elif isinstance(payload, list):
        d["_items"] = payload
        if payload and isinstance(payload[0], dict):
            d.update(payload[0])
        else:
            d["_value"] = payload
    else:
        d["_value"] = payload
    return d


def items(inv, key):
    v = inv.get(key) or {}
    it = v.get("_items")
    return it if isinstance(it, list) else ([v] if v else [])


def collect() -> dict:
    inv = {"t": dt.datetime.now().isoformat(timespec="seconds")}
    inv["host"] = sect("Win32_ComputerSystem + Win32_OperatingSystem", ps(
        "$cs=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem;"
        "@{model=$cs.Model;vendor=$cs.Manufacturer;ram_gb=[math]::Round($cs.TotalPhysicalMemory/1GB,2);"
        "os=$os.Caption;version=$os.Version;boot=$os.LastBootUpTime.ToString('s');"
        "uptime_h=[math]::Round(((Get-Date)-$os.LastBootUpTime).TotalHours,1)}|ConvertTo-Json"))
    inv["cpu"] = sect("Win32_Processor", ps(
        "Get-CimInstance Win32_Processor | Select-Object Name,NumberOfCores,NumberOfLogicalProcessors,"
        "MaxClockSpeed,CurrentClockSpeed,L2CacheSize,L3CacheSize,LoadPercentage | ConvertTo-Json"))
    inv["memory"] = sect("Win32_PhysicalMemory", ps(
        "Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,DeviceLocator,Capacity,Speed,"
        "ConfiguredClockSpeed,Manufacturer,PartNumber | ConvertTo-Json"))
    inv["mem_array"] = sect("Win32_PhysicalMemoryArray", ps(
        "Get-CimInstance Win32_PhysicalMemoryArray | Select-Object MemoryDevices,MaxCapacityEx | ConvertTo-Json"))
    inv["mem_load"] = sect("Win32_OperatingSystem", ps(
        "[math]::Round(100*((Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize-"
        "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory)/"
        "(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize,1)"))
    inv["physical_disks"] = sect("Get-PhysicalDisk", ps(
        "Get-PhysicalDisk | Select-Object FriendlyName,MediaType,BusType,Size,HealthStatus,"
        "OperationalStatus | ConvertTo-Json"))
    inv["disk_reliability"] = sect("Get-StorageReliabilityCounter(可能需提权)", ps(
        "Get-PhysicalDisk | ForEach-Object { $c=$_ | Get-StorageReliabilityCounter;"
        "[pscustomobject]@{disk=$_.FriendlyName;temp_c=$c.Temperature;wear=$c.Wear;"
        "read_err=$c.ReadErrorsTotal;write_err=$c.WriteErrorsTotal;power_on_hours=$c.PowerOnHours} } | ConvertTo-Json"))
    inv["volumes"] = sect("Win32_LogicalDisk(DriveType=3)", ps(
        "Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | Select-Object DeviceID,VolumeName,"
        "@{n='size_gb';e={[math]::Round($_.Size/1GB,1)}},@{n='free_gb';e={[math]::Round($_.FreeSpace/1GB,1)}} | ConvertTo-Json"))
    inv["gpu"] = sect("Win32_VideoController", ps(
        "Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,DriverDate,"
        "@{n='vram_gb_reported';e={[math]::Round($_.AdapterRAM/1GB,2)}} | ConvertTo-Json"))
    inv["board"] = sect("Win32_BaseBoard + Win32_BIOS", ps(
        "$b=Get-CimInstance Win32_BaseBoard;$f=Get-CimInstance Win32_BIOS;"
        "@{board=$b.Product;vendor=$b.Manufacturer;bios=$f.SMBIOSBIOSVersion;bios_date=$f.ReleaseDate}|ConvertTo-Json"))
    inv["thermal"] = sect("MSAcpi_ThermalZoneTemperature(消费级主板常不暴露 ⇒ 可能为空)", ps(
        "Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue |"
        " Select-Object InstanceName,@{n='temp_c';e={[math]::Round($_.CurrentTemperature/10-273.15,1)}} | ConvertTo-Json"))
    inv["net"] = sect("Get-NetAdapter(Up)", ps(
        "Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object Name,InterfaceDescription,LinkSpeed,"
        "MacAddress | ConvertTo-Json"))
    inv["top_proc"] = sect("Get-Process by WorkingSet", ps(
        "Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 12 "
        "Name,Id,@{n='MB';e={[math]::Round($_.WorkingSet64/1MB,1)}} | ConvertTo-Json"))
    inv["disk_free_bytes"] = sect("shutil-free", {})
    try:
        import shutil
        # ★ 打包版:原来用 WS.drive(写死的工作区盘符);改成"用户主目录所在盘"的根,
        #   否则在别的机器上会 NameError ⇒ disk_free_gb 变成 null(我在 20:55 的打包自测里抓到的)
        _root = pathlib.Path(pathlib.Path.home().drive + "\\")
        t, u, f = shutil.disk_usage(str(_root))
        inv["disk_free_bytes"].update({"drive": str(_root), "total_gb": round(t / 1024 ** 3, 1),
                                       "free_gb": round(f / 1024 ** 3, 1),
                                       "used_pct": round(100 * u / t, 1)})
    except Exception as e:
        inv["disk_free_bytes"]["_err"] = "%s: %s" % (type(e).__name__, str(e)[:80])
    return inv


def md(inv: dict) -> str:
    c, h, b = inv["cpu"], inv["host"], inv["board"]
    mem_mods = items(inv, "memory")
    arr = inv.get("mem_array") or {}
    lines = ["## 现状扫描(%s,只读)" % inv.get("t", ""), "",
             "| 维度 | 读数 | 来源 |", "|---|---|---|",
             "| 整机 | %s %s | %s | 开机 %s h | Win32_ComputerSystem/OS |" % (
                 h.get("vendor"), h.get("model"), h.get("os"), h.get("uptime_h")),
             "| CPU | %s | %sC/%sT | 标称 %s MHz,当前 %s MHz,负载 %s%% | Win32_Processor |" % (
                 c.get("Name"), c.get("NumberOfCores"), c.get("NumberOfLogicalProcessors"),
                 c.get("MaxClockSpeed"), c.get("CurrentClockSpeed"), c.get("LoadPercentage")),
             "| 内存 | 总 %s GB | 已用 %s%% | 插槽 %s(最高 %s GB/槽位)| Win32_PhysicalMemory/Array |" % (
                 h.get("ram_gb"), inv["mem_load"].get("_value"), arr.get("MemoryDevices"),
                 arr.get("MaxCapacityEx")),
             "| 内存条 | %s | Win32_PhysicalMemory |" % (";".join(
                 "%s %sGB %s MT/s @%s" % (m.get("Manufacturer"), round((m.get("Capacity") or 0) / 1024 ** 3, 1),
                                          m.get("Speed"), m.get("DeviceLocator")) for m in mem_mods) or "未测")]
    for d in items(inv, "physical_disks"):
        lines.append("| 物理盘 | %s | %s | %.1f GB | 健康 %s | %s | Get-PhysicalDisk |" % (
            d.get("FriendlyName"), d.get("MediaType"), (d.get("Size") or 0) / 1024 ** 3,
            d.get("HealthStatus"), d.get("BusType")))
    for r in items(inv, "disk_reliability"):
        if r.get("disk"):
            lines.append("| 盘可靠性 | %s | 温度 %s ℃ | 磨损 %s | 读/写错误 %s/%s | 通电 %s h | Get-StorageReliabilityCounter |" % (
                r.get("disk"), r.get("temp_c"), r.get("wear"), r.get("read_err"), r.get("write_err"),
                r.get("power_on_hours")))
    for v in items(inv, "volumes"):
        lines.append("| 卷 | %s %s | 总 %s GB | 余 %s GB | Win32_LogicalDisk |" % (
            v.get("DeviceID"), v.get("VolumeName"), v.get("size_gb"), v.get("free_gb")))
    df = inv.get("disk_free_bytes") or {}
    lines.append("| 数据盘余量 | %s GB / %s GB(已用 %s%%)| shutil.disk_usage |" % (
        df.get("free_gb"), df.get("total_gb"), df.get("used_pct")))
    for g in items(inv, "gpu"):
        if g.get("Name"):
            lines.append("| GPU | %s | 驱动 %s | 报告显存 %s GB(**Win32 的 AdapterRAM 有 4GB 截断,>4GB 不可信**)| Win32_VideoController |" % (
                g.get("Name"), g.get("DriverVersion"), g.get("vram_gb_reported")))
    lines.append("| 主板/固件 | %s %s | BIOS %s | Win32_BaseBoard/BIOS |" % (
        b.get("vendor"), b.get("board"), b.get("bios")))
    tz = [z for z in items(inv, "thermal") if z.get("temp_c") is not None]
    lines.append("| 温度 | %s | MSAcpi_ThermalZoneTemperature |" % (
        ";".join("%s %s℃" % (z.get("InstanceName"), z.get("temp_c")) for z in tz) if tz
        else "**未测**(本机不暴露该接口;消费级主板常见)"))
    for n in items(inv, "net"):
        if n.get("Name"):
            lines.append("| 网卡 | %s | %s | %s | Get-NetAdapter |" % (
                n.get("Name"), n.get("InterfaceDescription"), n.get("LinkSpeed")))
    return "\n".join(lines)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--md", action="store_true")
    a = ap.parse_args()
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    inv = collect()
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
    p1 = OUT_DIR / ("ai_pc_inventory_%s.json" % ts)
    p2 = OUT_DIR / "ai_pc_inventory_latest.json"
    for p in (p1, p2):
        p.write_text(json.dumps(inv, ensure_ascii=False, indent=1), encoding="utf-8")
    ok = json.loads(p1.read_text(encoding="utf-8")).get("t") == inv["t"]
    print(json.dumps({"ok": ok, "readback_ok": ok, "file": str(p1),
                      "cpu": inv["cpu"].get("Name"), "cores": inv["cpu"].get("NumberOfCores"),
                      "ram_gb": inv["host"].get("ram_gb"), "mem_load_pct": inv["mem_load"].get("_value"),
                      "mem_modules": len(items(inv, "memory")), "mem_slots": inv["mem_array"].get("MemoryDevices"),
                      "disks": len(items(inv, "physical_disks")), "volumes": len(items(inv, "volumes")),
                      "gpu": (items(inv, "gpu") or [{}])[0].get("Name"),
                      "gpu_vram_gb_reported": (items(inv, "gpu") or [{}])[0].get("vram_gb_reported"),
                      "thermal": len([z for z in items(inv, "thermal") if z.get("temp_c") is not None]),
                      "disk_free_gb": (inv.get("disk_free_bytes") or {}).get("free_gb"),
                      "err_sections": [k for k, v in inv.items() if isinstance(v, dict) and v.get("_err")]},
                     ensure_ascii=False))
    if a.md:
        print("\n" + md(inv))
    return 0 if ok else 4


if __name__ == "__main__":
    raise SystemExit(main())

怎么用

py -3.12 -X utf8 ai_pc_inventory.py          # 体检,落一份 JSON
py -3.12 -X utf8 ai_pc_inventory.py --md     # 附带一张 Markdown 表格
py -3.12 -X utf8 net_link_check.py           # 只看网卡那一项

它不做什么

不改设置、不装东西、不动注册表/驱动/服务、不上传任何数据(全在本机跑完,结果写成本机 JSON)。 只读是故意的——只有只读的工具,才敢让人随手跑。

附:README(工具包里的说明,一并放上)

Windows 电脑体检工具包 v1

两个只读脚本,帮你看清三件事:内存够不够、盘快满没满、网卡为什么慢。

它不做什么(先看这条)

  • 不改任何设置、不装任何东西、不动注册表/驱动/服务;
  • 不上传任何数据:全部在本机跑完,结果写成本机 JSON 文件;
  • 不是跑分工具,也不预测"该买哪台电脑"。

环境

  • Windows 10 / 11;
  • Python 3.8+(只用标准库;部分读数通过 PowerShell 的 CIM 接口取)。

用法(两条命令)

py -3.12 -X utf8 ai_pc_inventory.py            # 体检:CPU/内存/硬盘/显卡/主板/网卡/进程
py -3.12 -X utf8 ai_pc_inventory.py --md       # 追加打印一张 Markdown 表格,方便贴给别人看
py -3.12 -X utf8 net_link_check.py             # 单独查"千兆网卡为什么只跑百兆"

产物:%PC_CHECK_OUT% 或 ~/pc_check_reports/ 下的 ai_pc_inventory_<时间戳>.json(另存一份 _latest.json)。

怎么读这些数(三条最容易误读的)

  1. 内存占用 %:脚本用的是 Windows GlobalMemoryStatusEx().dwMemoryLoad,与任务管理器「使用中」一致。 另一个算法(100−可用/总量)会低 5–8 个点,别拿它当"还有余量"。
  2. 显存(GPU 的 AdapterRAM):Windows 这个字段有 4 GB 截断,超过 4 GB 的卡会显示错值(常见 0 或 4 GB)。 要准确显存请用厂商工具;本脚本照样打印,但不要拿它下结论。
  3. "未测"不是 0:温度(MSAcpi_ThermalZoneTemperature)和 SSD 磨损(Get-StorageReliabilityCounter) 在很多机器上读不到——脚本会明确写 _err 或 未测,不会写 0 冒充。 想读到这两项通常需要管理员权限,或换用厂商工具。

每个数都带来源(可复核)

JSON 里每一段都有 source 字段写明取自哪个 CIM 类/命令,例如 Win32_PhysicalMemory(内存条)、Get-PhysicalDisk(物理盘)、Get-NetAdapter(网卡)。 看见哪个数可疑,就照着 source 去复跑那一条命令。

已知边界

  • 需要管理员权限才能读的项(SSD 磨损、部分温度)在普通权限下会显示"未测";
  • 多显卡机器会把核显/独显/虚拟显示器都列出来,不要把它们相加当显存;
  • 网卡那一项只判断"链路速率/是否自动协商",不测实际网速(想测网速请用测速站点)。

来源

由 DSH(本机 AI 值守)生成,可自由使用与修改。 它是"把电脑改造成更适合 AI 运行"那套流程里的第一步:现状扫描。