简介:针对打印任务信息获取,这份工具包提供了解析SPOOL文件(SHD/SPL)的完整方案,适用于需要旁路监控打印行为的开发及运维人员。与Hook打印函数、注册消息等侵入式手段不同,直接从系统生成的SHD与SPL文件中提取任务元数据和压缩图像数据,信息更全面,实施也更轻量,适合用于打印审计、用量统计或故障排查等场景。包内共55个文件,包含C++工程源码、编译好的exe工具、SHD/SPL测试样本、说明文档以及“将SPL剥离成EMF”的技术博客存档,压缩包整体约19.93MB,目录层级简洁,便于按需检索。已有1969人学习下载。示例工程展示了从SPL中剥离EMF文件的解析方法,配合splview.exe可直观查看解析结果,帮助读者快速掌握SPOOL内部结构,进而自定义扩展监控功能。
1. 打印信息获取:从 SPOOL 文件里翻出每一次打印的底账
很多网管都遇到过这种尴尬:有人把公司内部资料打印走了,打印机自带面板只显示“已打印 47 页”,系统日志里只剩一个 Job 编号,文档名叫什么、哪个账号发的、几点发的,全是黑匣子。我处理这类打印监控需求时,最顺手也最可靠的办法,就是直接去解析 Windows 打印后台 Spooler 落盘的 SPOOL 文件。这套方法绕过厂商接口,不依赖打印机管理平台,只要你能拿到 FileServer 或打印服务器上C:\Windows\System32\spool\PRINTERS目录里的 .shd 和 .spl 文件,就能把文档名、用户名、页数、份数、纸张方向、时间戳逐项抠出来。适合做打印审计、耗材成本核算、涉密追溯的从业者,也适合想搞懂 Windows 后台打印机制的开发人员。
2. 认清弹药库:SPOOL 文件的结构与选型依据
2.1 现场地形:PRINTERS 目录下的三种成分
Windows 的后台打印服务(Spooler)接到打印任务后,会在PRINTERS目录下生成三种类型文件。第一步先要把它们认全,否则后续解析时经常会拿错对象。
| 文件类型 | 扩展名 | 角色 | 生命周期 |
|---|---|---|---|
| 任务控制文件 | .shd | 记录任务概要:文档名、账号、端口、优先级、大小 | 任务进入队列时创建,任务完成删除 |
| 页面数据文件 | .spl | 保存交给打印驱动的实际绘图指令 | 同上,部分 RAW 任务完成后立即删 |
| 临时文件 | .tmp | Spooler 写数据时的中间缓冲 | 崩溃或断电后常残留,可忽略 |
三类文件共用同一个数字前缀,比如00012.shd和00012.spl,数字就是后台打印任务的 Job ID。文件名后半段往往会带端口名,例如00012_FRP403_1.spl这种,能省去查端口映射的功夫。注意.shd是纯二进制结构体,可以用 Python 的struct直接拆;.spl的结构则由打印驱动决定,常见的 EMF 和 RAW 两种格式差异极大,下面详细说。
2.2 为什么非读不可的往往是 .spl 而不是 .shd
.shd文件体积通常在 1KB 到 4KB 之间,存的只是任务的“封面信息”——谁发的、发到哪、任务名是什么。.spl才是“正文”,里面的数据量动辄几百 KB 到几十 MB,页面数量、每页的绘图指令、文字内容都藏在其中。
从打印监控角度,我一般把.shd当“目录”,把.spl当“账本”。数页数是打印审计中最核心的指标,如果只看.shd,多数版本的 SHD 头里确实有Pages字段,但存在两个坑:一是某些驱动在上送任务时页数字段填的是 0,由 EMF 回放时才真正确定;二是打印异常中断时 SHD 里的页数停留在发送中状态,误差很大。因此,页数必须从.spl里读:如果内容是 EMF(绝大多数驱动默认),可以按 EMF 记录边界精确回放页数;如果内容是 RAW(PostScript 或 PCL 转义流),则只能统计数据块大小或匹配换页指令,可靠性低一档。
2.3 采集时机决定成败:Spooler 删文件比你想象中快
解析技术上不难,难的是把文件抓到手里。默认情况下,Spooler 在打印任务渲染完成后,会在几秒内释放并删除对应的 .shd/.spl。如果监控脚本按固定间隔去扫目录,经常只看到一堆还没来得及处理的 .tmp 文件。我常用的三种前置处理手段,按稳妥程度排序:
- 在打印服务器上把 Spooler 的“保留失败任务”策略打开,但这样只对失败任务有效。
- 修改注册表让 Spooler 在打印完成后保留 SPL 文件,代价是磁盘占用增长,适合临时取证。
- 用卷影复制(VSS)或文件系统过滤驱动直接抓取快照,这种方法对线上打印无干扰,是长期监控的首选。
后面第 3 章会给出一个完整的解析流程,第 4 章专门讲这些窗口期和权限相关的坑。
3. 完整分析流程:从目录快照到页数回放的三个步骤
3.1 第一步:用 Robocopy 做只读快照,别手点复制粘贴
直接在资源管理器里复制 PRINTERS 目录,你会复制到一半文件缺失或目录锁死的报错。因为 Spooler 持有文件句柄,普通用户复制时会碰到文件正在被占用。正确的做法是用 Robocopy 以备份模式拉取快照,相当于把被占用的文件也抓下来。
robocopy "\\PrintServer\C$\Windows\System32\spool\PRINTERS" "D:\spool_snapshot" /MIR /B /R:1 /W:1 /XJ逻辑说明:/MIR镜像整个目录,确保首次抓取后每次增量只拉新文件;/B走备份模式读取,不需要逐个文件分配读写权限,能绕过部分文件占用锁;/R:1 /W:1把重试次数和等待时间降到最低,避免在某个坏文件上卡死;/XJ排除目录联接点,防止递归到错误位置。
参数说明:如果目标打印服务器不在域内,/B模式要求执行账号有“备份文件和目录”权限,普通域账号默认就有;若是本机分析,则直接用管理员身份运行。抓取时间建议选业务低峰,因为PRINTERS里的文件本身就是待打印任务的实时快照,业务高峰时文件正在疯狂写入,镜像出来的后半段文件可能旧、前半段文件可能新,统计口径会乱。
3.2 第二步:剖析 .shd 头结构,把文档名和账号抠出来
.shd文件不是一个扁平结构,而是由若干固定版本的结构体组成。解析时不要迷信固定偏移,要按头部的版本号字段分流。下面这段 Python 代码能解析最常见的 Windows 10 和 Server 2016+ 的 SHD 格式,拿到任务 ID、文档名、用户名、端口名。
import struct from pathlib import Path def parse_shd(file_path): with open(file_path, 'rb') as f: data = f.read(4096) # SHD 头足够小,读前 4KB # 前 16 字节:版本头。常见魔数 0x00000001 表示旧版结构 # 不同系统版本该字段不同,解析前先判断 version = struct.unpack_from('<I', data, 0)[0] if version != 1: # 高版本 Windows 的 SHJOB 头从偏移 0 处也是版本号 version = struct.unpack_from('<I', data, 12)[0] # 多数版本结构:JobId(4) 后面是定长 Unicode 字符串 job_id = struct.unpack_from('<I', data, 4)[0] # 文档名和用户名在不同版本中偏移不同。 # 常见做法:先扫整个 data,按 UTF-16LE 解码后找两个连续字符串 text = data.decode('utf-16-le', errors='ignore') parts = [p for p in text.split('\x00') if p.strip()] doc_name = parts[0] if len(parts) > 0 else '' user_name = parts[1] if len(parts) > 1 else '' return { 'job_id': job_id, 'doc_name': doc_name, 'user_name': user_name, 'device_name': parts[2] if len(parts) > 2 else '' } if __name__ == '__main__': p = Path('D:/spool_snapshot/00012.shd') print(parse_shd(p))逻辑说明:SHD 结构体的核心是将定长二进制头和后续的 Unicode 字符串块相连,struct.unpack_from负责跳过数字头,字符串段则用utf-16-le解码后按\x00切分。打印服务器上中文账号名和中文文档名很常见,所以解码时必须显式用 UTF-16LE。
参数说明:version字段在不同系统版本上位置和含义都有差异,代码里做了两次尝试。如果你手头全是 Windows Server 2019,可以只解析偏移 12 的版本号,但碰到混杂环境还是按“版本号分流”的思路做,否则会翻车。返回的字段中,device_name(端口名)对得上打印机实物,后期审计报表需要它来归属打印机设备。
3.3 第三步:从 .spl 里精确统计页数:EMF 记录边界法
拿到.spl之后,先判断是 EMF 还是 RAW。EMF 文件的特征是开头是\x01\x00\x00\x00这样的四字节记录类型EMR_HEADER(值 1),RAW 文件是%!PS(PostScript)或\x1B%(PCL 转义)开头。对 EMF 类型的 SPL 文件,我一般直接用“读 EMF 记录边界”的方式数页:
import struct EMR_HEADER = 1 EMR_EOF = 14 # EMR_EOF 记录表示一个页面/绘图段的结束 def count_pages_from_spl(spl_path): page_count = 0 offset = 0 with open(spl_path, 'rb') as f: data = f.read() while offset + 8 <= len(data): # 每一条 EMF 记录:类型(4字节) + 长度(4字节) rec_type, rec_len = struct.unpack_from('<II', data, offset) if rec_len < 8 or offset + rec_len > len(data): break if rec_type == EMR_HEADER: # 新 EMF 流开始,页数加 1(同一 SPL 内可能打包多个 EMF) page_count += 1 if rec_type == EMR_EOF: # 页绘制结束,同一 EMF 内可能有多段 EOF # 我们只数 EMR_HEADER,避免把单页多段描绘重复计页 pass offset += rec_len return max(page_count, 1) # 至少一页 if __name__ == '__main__': print(count_pages_from_spl('D:/spool_snapshot/00012.spl'))逻辑说明:EMF 记录是“类型 + 长度”的 TLV 结构,遍历时按长度跳转即可。新页面开始时,驱动会写入一个新的EMR_HEADER记录;页面绘制完成后写EMR_EOF。数EMR_HEADER比数EMR_EOF更稳,因为有些驱动在一个页面范围内会写多段EMF_EOF表示图层结束,但不会重复写EMR_HEADER。
参数说明:rec_len小于 8 或超出文件尾部说明数据被 Spooler 截断,此时直接break,页面数以已解析部分为准。对 RAW 类型的 SPL,上面的代码不适用,常见做法是扫描\x0c(换页符)统计页数,但 PostScript 的showpage和 PCL 的\x0c混用时这个统计误差很大,建议对 RAW 文件只做“字节数统计”,不要硬做精确页数,否则打印审计数据对不上账会很被动。
4. 避坑指南:解析 SPOOL 文件时最容易翻车的五个场景
4.1 文件还没抓完,Spooler 就把任务删了
现象:快照目录里全是.tmp,.shd和.spl数量寥寥,监控报表显示“全天只有 3 个打印任务”,与打印服务器的实际任务数严重不符。
原因:打印任务完成之后,Spooler 默认立即删除对应的 .shd/.spl。Robocopy 镜像速度再快,也快不过 Spooler 的清理线程。特别在打印机直通和 RAW 打印模式下,数据文件几乎一闪而过。
解决:临时取证场景,在打印服务器上运行以下命令修改注册表,让 Spooler 保留已完成任务的 SPL 文件,然后重启 Spooler 服务。
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Print\Spooler" -Name PreserveJob -Value 1 Restart-Service Spooler说明:PreserveJob置 1 后,SPL 文件会保留到新任务覆盖,适合做审计实验。长期监控建议改成每 5 分钟用 VSS 写一次快照,这样不会影响生产环境。用完记得把注册表改回 0 并重启 Spooler,否则磁盘会被残留 SPL 撑爆。
4.2 SHD 结构体按固定偏移解析,字段全错位
现象:解析出来的用户名是乱码,文档名是打印机端口名,Job ID 是个巨大数字,每条记录都对不上。
原因:SHD 结构体在 Windows Server 2003、2008、2012、2016 及 Win10 之间改了至少三个版本。旧版SHJOB结构里DocumentName是定长 64 字节的char[],新版则是WCHAR[]变长字段。按旧版偏移硬解析新版文件,必然错位。
解决:解析前先读前 4 字节版本号,再按版本分支处理。代码里给出的是兼容写法,实际我开发时会为每个版本写一个解析类。建议你在做这个功能时直接收集三类版本文件各 20 个,做一次回归样本,比啥文档都好使。
4.3 把 PCL 的字体下载指令当成换页符,页数统计虚高
现象:同一份 5 页 Word 文档,从 RAW SPL 统计出的页数是 23 页,和真实页数差了近 4 倍。
原因:PCL 流里的\x0c(换页符)出现在字体下载、宏定义、介质选择等多种控制序列里,根本不是真正的物理分页。直接扫描换页符统计 RAW 页数,属于拿“内容字节特征”当“逻辑语义”,统计值只能当估算。
解决:对 RAW 文件停止“精确数页”,改为统计数据量(KB)和首次/末次时间。如果必须精确页数,就改打印机驱动配置,强制输出 EMF 而不是 RAW。具体操作:控制面板 → 打印机属性 → 高级 → 选中“启用高级打印功能”,通常驱动就会走 EMF 路线。
4.4 中文文档名导出 CSV 后全是乱码和断行
现象:解析结果在控制台打印正常,导出 CSV 用 Office 打开后,中文显示乱码,文档名字段被截断到一半。
原因:代码用 UTF-8 写 CSV,但 Windows 版 Office 默认按 ANSI 读取;同时 SHD 中定长字符串可能包含尾部填充的中断字符\x00,字符串在填充处被截断。
解决:写 CSV 时加上 UTF-8 BOM,并把字符串统一做\x00清理。
import csv with open('output.csv', 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['JobID', 'DocName', 'UserName', 'Pages']) for rec in records: writer.writerow([ rec['job_id'], rec['doc_name'].split('\x00')[0].strip(), rec['user_name'].split('\x00')[0].strip(), rec['pages'] ])说明:utf-8-sig是 Python 内置的带 BOM 编码器,Excel 打开时自动识别;\x00只在解码后还残留时出现,做一次切分清理是防御性编程,成本极低。另注意 SHD 里的定长字段有时会带尾部空格,别忘了.strip()。
4.5 抓取 PRINTERS 目录时权限不足,Robocopy 直接报错退出
现象:robocopy镜像是 0 个文件复制成功,错误代码 5,提示“拒绝访问”。
原因:C:\Windows\System32\spool\PRINTERS目录默认只有 SYSTEM 和管理员组才能列目录,普通域用户可以发起打印任务,但没有权限读该目录。
解决:不要直接给用户账号单独加 NTFS 权限,那样会在审计时留下 ACL 变更记录,容易被安全团队盯上。推荐以下两种方式:
# 方式一:临时以 SYSTEM 身份运行镜像命令(需 PsTools) psexec -s robocopy "C:\Windows\System32\spool\PRINTERS" "D:\spool_snapshot" /MIR /B # 方式二:在服务中使用 Service Account 赋予“备份文件和目录”权限 # 然后在任务计划程序里用该账号执行 robocopy说明:方式一适合一次性取证,方式二适合长期监控。psexec -s不是去提升当前用户权限,而是让进程完全以 SYSTEM 身份运行,同时不影响目标目录的 ACL 结构。注意psexec命令在杀软环境中容易被拦截,配置杀软白名单后再用。
5. 把解析流程工程化:做成能日常跑的打印监控工具
5.1 封装 CLI 工具:一次扫描输出结构化报表
手动跑脚本只能处理单个文件,实际监控场景要批量扫描整个快照目录。我用argparse写一个命令行入口,支持--directory指定快照目录、--format切换 CSV 或 JSON 输出。
import argparse import json import csv from pathlib import Path from .parse_shd import parse_shd from .parse_spl import count_pages_from_spl def process_directory(spool_dir, fmt='csv', raw_threshold=50): records = [] for shd_file in sorted(Path(spool_dir).glob('*.shd')): try: meta = parse_shd(shd_file) spl_file = shd_file.with_suffix('.spl') pages = count_pages_from_spl(spl_file) if spl_file.exists() else 0 records.append({**meta, 'pages': pages, 'src_file': shd_file.name}) except Exception as e: records.append({'error': str(e), 'src_file': shd_file.name}) return records if __name__ == '__main__': ap = argparse.ArgumentParser(description='Print job parser') ap.add_argument('--directory', required=True, help='snapshot dir') ap.add_argument('--format', choices=['csv', 'json'], default='csv') args = ap.parse_args() recs = process_directory(args.directory) if args.format == 'json': print(json.dumps(recs, ensure_ascii=False, indent=2)) else: w = csv.DictWriter(open('print_jobs.csv', 'w', newline='', encoding='utf-8-sig'), fieldnames=['job_id', 'doc_name', 'user_name', 'pages', 'src_file']) w.writeheader() w.writerows(recs)逻辑说明:glob('*.shd')拿到全部任务控制文件后,再按文件名替换后缀找同名.spl;.shd能解析但.spl缺失时,页数补 0,并把src_file属性保留给排查人员。异常捕获放在单条记录粒度,某条坏数据不会中断整个扫描。
参数说明:raw_threshold参数(RAW 类型数据量阈值)在代码里保留了接口,但实际开发时我一般不放它进外层 CLI,因为 RAW 本身就退化为字节级统计,给用户一个模糊参数反而容易让人误解“可以精确统计 RAW 页数”。CLI 最好只有--directory和--format两个参数,保持简单。
5.2 把结果导入 SQLite,做按用户聚合的打印报表
CLI 输出 CSV 只是为了快速查看,真正的审计需要按部门、按时间段、按打印机聚合。我一般会把结果导入 SQLite,然后用一句 GROUP BY 产生汇总表。
-- 每月每个用户的打印总量和总页数 SELECT strftime('%Y-%m', print_time) AS month, user_name, COUNT(*) AS job_count, SUM(pages) AS total_pages FROM print_jobs GROUP BY month, user_name ORDER BY total_pages DESC;逻辑说明:如果直接拿 CSV 做聚合,需要在 Excel 里做透视表,数据量大了卡顿;SQLite 单文件、零配置,非常适合这种几十万行的审计数据。print_time字段在真实部署时我会从快照文件的修改时间推算,不需要依赖 SHD 里可能缺失的时间字段。
表结构设计:至少包含job_id、doc_name、user_name、pages、device_name、scan_time六列。scan_time是快照生成时间,job_id作为去重键,因为同一次抓取可能因为这批文件被 Spooler 清理而只能看到部分,下次抓取又会补齐,重复导入会虚增任务数。
5.3 实时监控扩展:用目录事件代替定时任务
如果不想依赖 Robocopy 定时快照,还有一种更实时但更脆的方案:直接用文件系统监听PRINTERS目录,当新的.spl文件出现时立刻触发解析。前端用 Python 的watchdog库就够了。
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class SpoolHandler(FileSystemEventHandler): def on_created(self, event): if event.src_path.endswith('.spl'): shd_path = event.src_path[:-4] + '.shd' if os.path.exists(shd_path): # 延迟 2 秒解析,等待 SHD 完整写入 time.sleep(2) meta = parse_shd(shd_path) pages = count_pages_from_spl(event.src_path) print(f"新任务: {meta['user_name']} 打印 {meta['doc_name']} 共 {pages} 页") if __name__ == '__main__': observer = Observer() observer.schedule(SpoolHandler(), r'C:\Windows\System32\spool\PRINTERS', recursive=False) observer.start()逻辑说明:on_created触发时机是文件创建事件,此时 Spooler 可能还在写内容。加 2 秒延迟是为了等文件大小稳定,实际部署时建议改成“等文件大小连续 3 次不变再解析”,避免读到半截数据。
参数说明:此方案仅适用于解析进程和 Spooler 在同一台机器上运行的情况。如果快照在远程 Server 上,则建议改走共享目录监控或直接把快照做成持续同步到本地,再本地触发。
5.4 与系统日志对账:确认解析数据可信度的最后一环
工具跑通后,不能直接上线,先用至少一个工作日的打印记录和“打印服务器的事件日志”对账。具体做法是让同事打印 10 份不同页数的测试文档,然后用解析工具导出任务清单,再和Microsoft-Windows-PrintService/Operational事件日志里的Event ID 307(打印任务完成)做逐条比对。
| 对比字段 | 事件日志来源 | SPOOL 解析来源 | 常见不一致原因 |
|---|---|---|---|
| 任务 ID | Event 307 里 Job ID | SHD 文件前缀数字 | 事件日志保留周期短,旧的缺失 |
| 用户名 | 事件日志关联账号 | SHD 内 UserName 字段 | 快照时间晚于用户注销,SHD 已删 |
| 页数 | Event 307 的 PagedPrinted | EMF 记录数 | 驱动渲染前取消任务导致 EMF 不完整 |
对账能发现两类问题:一类是 Spooler 清理太快导致快照缺失,你需要在监控策略上把快照周期缩小到 1 分钟以内;另一类是 EMF 页数与驱动报告的页数不一致,这说明当前打印机驱动不是纯 EMF 模式,需要回到第 4.3 节去调整驱动配置。
6. 进阶验证技巧:造一批可控测试页,把解析逻辑钉死在正确轨道上
6.1 用纯脚本生成 EMF 测试文件,自测解析器不依赖打印机
没有打印机也能验证count_pages_from_spl的逻辑,方法是用 Python 裸写一个最小 EMF 文件。虽然 EMF 的规范很庞大,但我们只需要构造“头部 + 两条空记录 + EOF”的合法骨架,让解析器能判别页数。
import struct def build_emf_page(page_count): # 简化 EMF 文件:每个页一个 EMR_HEADER + EMR_EOF data = b'' for _ in range(page_count): # EMR_HEADER 记录:类型 1,长度 88(标准最小头) header = struct.pack('<II', 1, 88) + b'\x00' * 80 # EMR_EOF 记录:类型 14,长度 20 eof = struct.pack('<II', 14, 20) + b'\x00' * 12 data += header + eof return data with open('test_pages.emf', 'wb') as f: f.write(build_emf_page(5))逻辑说明:EMR_HEADER的长度不是固定的 88,但解析器只关心类型和长度两个字段,构造一个 88 字节的合法记录足以通过遍历。EMR_EOF标准长度是 20 字节,同样只保留最简字段。用 5 页的骨架文件喂给解析器,输出pages=5,解析逻辑才算过了第一关。
参数说明:这只是验证解析器的逻辑正确性,不验证“真实驱动程序写出的 EMF 是否符合规范”。所以自测通过后,还要走到第 6.2 步用真实打印任务做端到端测试。
6.2 端到端验证:让某位同事打印测试文档,反向核对解析结果
端到端测试是最容易暴露版本兼容问题的一步。我在实际项目中会让 A 同学在 Word 里打一份标题为“测试文档-2024秋季”的五页文档,PDF 另存后打印,然后立刻抓取快照并比对。
验证清单:
- 文档名是否与 SHD 里解析出的
doc_name完全一致(含中文)。 - 页数是否为 5,如果解析出 4 或 6,优先怀疑驱动把封面和空白页过滤掉了。
- 用户名是否与 A 同学的登录账号一致。
- 打印时间是否与打印服务器事件日志中的提交时间相差在 10 秒以内。
这一步通常能发现两类问题:一是中文文档名编码错乱,二是 EMF 页数与 PDF 真实页数不一致(PDF 最后一页空白被驱动忽略,属于正常情况,按打印机会计口径算 4 页即可)。
6.3 我的习惯:每次搭打印解析流程,都强制先造 5 个可控任务再动生产数据
起初我开发第一版打印监控工具时,直接拿生产打印服务器的历史 SPL 来做测试,结果解析结果的页数和打印机实际计数对不上,排查了整整一天,最后发现是 SHD 版本判断错了,而不是页数算法的问题。白费了一整天的情绪和时间。
从那以后我每次搭解析流程,都强制先造 5 个可控打印任务再动生产数据:1 页纯文本、2 页带图片的 PPT、3 页PDF、1 页重复打印 5 份、1 个故意取消的任务。这五个用例覆盖了单页、多页、份数、文档大小、取消留痕五种典型状态,全部跑通后再把脚本挂到生产环境。解析 SPOOL 文件不是那种看一篇文档就能一把过的事,版本差异、驱动差异、时序差异都会让结果静默出错,但只要有这几个固定用例垫底,每次改代码都能在 5 分钟内确认“没改坏东西”。希望帮到你。
本文还有配套的精品资源,点击获取