做设备盘点工具那阵子,我被易语言自带的命令逼得没脾气。CPU型号读出来经常是一串乱码,硬盘物理序列号压根没有对应命令,内存占用率、开机时长、电源状态这些更是一个比一个难搞。最后我翻了一整天系统DLL导出表,从kernel32、user32到powrprof,把硬件与系统函数一个个声明进易语言,才把工具补完整。这篇文章就是把这套调用逻辑和踩坑过程整理出来,给同样需要用易语言做系统小工具、硬件采集、机器授权绑定的朋友参考。我会尽量写清楚每一步为什么这么做,而不只是把函数名抄一遍。
1. 为什么易语言自带命令不够用:一次设备清单生成的启发
1.1 自带命令和系统API差在哪里
易语言核心库里的系统命令,比如“取系统信息”“取硬盘特征字”“取CPU型号”,日常写点小程序是够用的。但真正到项目里,你会发现这些命令是“平均能力”:只提供最常见字段,想要稍微底层一点的数据就没了。
我当时的需求是生成一台Windows主机的设备台账,至少要拿到六项数据:CPU型号、物理内存总量、内存占用率、硬盘物理序列号、开机运行时长、当前电源状态。逐一对下来,自带命令的缺口很快就暴露了。取内存信息勉强可以,取开机时长得自己累加,硬盘序列号直接没有对应命令,电源状态更是想都别想。而且最关键的是,易语言命令拿到的结果是一个二次包装,一旦源数据出问题,你没法判断是哪一层出的错。
系统API则是另一码事。你直接向系统查询数据,拿到的是函数返回值、缓冲区内容和错误码,每一步都可以验证。说白了,自带命令像你去物业前台问“我家水电费多少”,物业给你一个整理好的数字;API调用像你直接去水电公司的系统查明细,字段、单位、误差范围都由你自己控制。对于做资产盘点、设备巡检、软件授权这类对准确性有要求的工具,这个差别就很致命了。
1.2 谁会用到这些硬件与系统API
我把适合读这篇文章的人分成三类。第一类是像我一样用易语言做运维辅助工具的,需要读取硬件信息做设备台账、资产编号、巡检报表;第二类是做软件授权验证的,需要通过CPU、硬盘序列号生成机器码,绑定授权;第三类是纯粹想理解Windows工作原理的易语言学习者,API调用是绕不开的一课。
不管你是哪一类,核心能力是一样的:能够声明一个DLL函数、正确传入参数、接收返回值、处理结构体。这几件事单独看都不难,组合起来就是经验问题了。后面我会把每一件事都拆开讲,尤其会把最容易出错的缓冲区、结构体、编码问题单独拿出来说。
2. 调用API前的三块基石:声明方式、数据类型、字符串编码
2.1 DLL命令声明是API调用的入口
在易语言里调用API,第一步不是写代码,而是在“DLL命令”编辑器里把函数声明出来。Windows所有公开API都寄存在系统DLL里,比如kernel32.dll、user32.dll、advapi32.dll、powrprof.dll,你要做的就是告诉易语言:这个函数在哪个DLL、函数名叫什么、参数怎么传、返回值是什么类型。
以最简单的获取计算机名为例,Windows SDK里的函数原型是这样:
BOOL GetComputerNameA(LPSTR lpBuffer, LPDWORD nSize);翻译成易语言的DLL命令定义就是:
.版本 2 .DLL命令 取计算机名A, 逻辑型, "kernel32.dll", "GetComputerNameA" .参数 缓冲区指针, 文本型, 传址 .参数 缓冲区长度指针, 整数型, 传址这里有两个关键点。一是“库文件名”必须是kernel32.dll或user32.dll这种真实存在的DLL名称,不能随便写“系统.dll”之类的别名,调用的实质是让系统到DLL导出表里去定位函数入口。二是“传参方式”必须双击选对:函数需要向缓冲区里写入数据时,就要选“传址”;只是把一个数字原样送进去,就选“传值”。
我见过太多新手在这里翻车。声明时偷懒全选“传值”,运行结果是函数明明返回了真,缓冲区却什么都没收到。原理其实不复杂:传值等于把数据的复印件塞给系统,系统把结果写进自己那份里就扔了;传址才是把地址给对方,系统才能把结果写到你的变量里。
2.2 数据类型对照表与结构体的处理
API的参数类型看起来很多,但映射到易语言里其实就那么几类。我给自己整理过一张表,声明函数之前先对照一次:
| Windows SDK类型 | 说明 | 易语言对应写法 |
|---|---|---|
| DWORD / BOOL / HANDLE | 32位整数、布尔值、句柄 | 整数型 |
| INT / UINT | 32位有符号/无符号整数 | 整数型 |
| LPTSTR / LPSTR | 字符串指针,可能作为输出缓冲区 | 文本型,传址 |
| LPCSTR | 只读字符串指针 | 文本型,传值即可 |
| ULONGLONG / SIZE_T | 64位整数(视系统位数而定) | 长整数型 |
| 结构体指针 | 需要传入结构体地址 | 自定义数据类型或字节集,传址 |
| VOID * | 原始缓冲区指针 | 字节集,传址 |
结构体是个大头。API函数经常要求传入一个结构体,函数往里面填充数据。易语言支持自定义数据类型,可以完美对应,但Windows不少结构体带有联合体,比如SYSTEM_INFO里就有一段联合体存储不同架构的信息。易语言的“自定义数据类型”是不支持联合体的,强行定义会让字段错位。我的一般做法是:先把结构体定义成足够的整数型成员,或者干脆开一个字节集缓冲区传进去,再按文档里的字节偏移手工解析。后者麻烦一点,但最稳,后面讲内存状态时我会给实例。
2.3 字符串编码:A版还是W版是个大坑
Windows的字符串API大多分成两个版本,以“A”结尾的是ANSI版,以“W”结尾的是宽字符版。易语言的文本型变量内部是ANSI编码,所以调用A版函数最省心,直接把文本型变量传址就行。如果因为某些原因必须调用W版,比如某些接口只提供W版,那就要先把易语言的文本转成宽字符字节集,再把字节集的地址传过去。
这里出现乱码的根源我排查过很多次,九成是编码不匹配:调用了W版函数,却把ANSI的文本型直接传了进去;或者反过来,A版函数读到了宽字符缓冲区。更隐蔽的情况是返回缓冲区里的字符串没有正常结尾符,比如函数要先设置“nSize”为缓冲区大小,调用后还会把需要的长度写回nSize,如果你把nSize当固定值传进去,缓冲区里的文本可能被截断,后面跟一串莫名其妙的字符。
所以我的习惯是:能选A版就优先选A版,在DLL命令名称里直接指定带“A”后缀的系统函数名,减少一层编码转换;只有明确知道该API只有W版时才走宽字符路径。
3. 硬件信息读取:CPU型号、硬盘序列号与设备绑定的API实现
3.1 系统基本信息:GetSystemInfo和GetNativeSystemInfo的差异
操作系统自身的硬件视图,最基本的入口是GetSystemInfo。它会返回一个SYSTEM_INFO结构体,里面包含处理器架构、页面大小、最小和最大应用程序地址、处理器掩码、逻辑处理器数量等字段。在易语言里,这个结构体用20个字节的缓冲区就能装下来,其中第三个成员是处理器类型,表示x86还是ARM。
但这里有一个很隐蔽的坑:易语言编译出来的程序是32位,在64位Windows上运行时,GetSystemInfo默认返回的是当前进程所见的系统信息。如果你在64位系统上跑,可能拿到的是x86处理器架构,处理器数量也偏小,因为32位进程可能被限制在部分的处理器组里。要拿真实的64位系统信息,得用GetNativeSystemInfo,这是Vista之后出现的新函数,语义就是“无视当前进程位数,报告操作系统原生的处理器信息”。
我建议在工具里这样做:先用IsWow64Process判断当前进程是否跑在64位系统上,如果是,就调用GetNativeSystemInfo;这样不管你的易语言程序是32位还是未来某天有人用64位方式编译,拿到的数据都是真实值。判断函数至少要确认两件事:逻辑处理器数量、处理器架构字符串,两个字段都对了,后面计算负载分配才有意义。
3.2 CPU型号怎么取最稳:注册表优先于第三方命令
CPU型号看起来是基础信息,实际上很多易语言方案都不靠谱。核心库的“取CPU型号”命令在部分机器上返回的是空格和乱码,原因在于它直接读一个原始描述串,没有做编码清洗。我的做法是转向注册表项:
- 注册表路径:HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
- 数据项:ProcessorNameString
用系统自带的“注册表操作”支持库就能读出来,不用额外声明API。这个值来自Windows内核对CPU的识别,正常情况下就是“Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz”这种完整名称。读取时要留意,部分精简版系统或特殊驱动环境可能出现空值,所以代码里要做兜底:注册表取不到,就退回去调用“取系统信息”里的CPU描述。
如果你要做的是更底层的CPU指令集支持检测,比如判断是否支持SSE4.2、AVX2,那注册表和命令都提供不了,得通过CPUID指令拿原始特征位。易语言不内嵌汇编,通常只能借助支持库或动态加载VcRuntime接口,这个链路比较深,一般工具用不上,我就不展开了。
3.3 硬盘物理序列号:DeviceIoControl与WMI两条路线
硬盘序列号是最让易语言开发者头疼的部分。首先要区分两个概念:卷序列号是格式化时生成的随机标识,不是物理序列号,拿来做授权绑定很不靠谱;物理序列号存在硬盘固件里,是厂商写入的,这个才能唯一标识一块硬盘。
获取物理序列号的第一条路线是DeviceIoControl,向物理驱动器发送IOCTL_STORAGE_QUERY_PROPERTY请求,返回的存储设备描述里包含SerialNumber字段。流程是先用CreateFileA打开"\.\PhysicalDrive0"设备,然后调用DeviceIoControl,最后解析返回缓冲区。这个路线在C/C++里很常见,放到易语言里却非常难受,原因是嵌套结构体太多,缓冲区偏移很容易算错,而且每次只能查询一个物理磁盘,要遍历所有盘符对应的物理盘号。
第二条路线是走WMI查询,这也是我最终采用的方式。在易语言里创建“WbemScripting.SWbemLocator”对象,连接root\cimv2命名空间,执行一条SELECT查询就能拿到Win32_PhysicalMedia下的SerialNumber:
对象.创建 ("WbemScripting.SWbemLocator", ) 服务 = 对象.连接服务器 (, , , , "root\cimv2") 集合 = 服务.ExecQuery ("SELECT Tag, SerialNumber FROM Win32_PhysicalMedia")WMI方式的缺点是依赖系统WMI服务,极少数精简系统会禁用;优点是不必关心结构体偏移,遍历多块磁盘也简单。做机器码绑定的朋友要注意:SerialNumber可能包含空格,建议去除所有空格后再参与哈希计算,否则同样一块盘在不同系统上读出的格式可能有差异。
至于网卡MAC地址,如果只是取第一块网卡,易语言核心库自带命令就够了;要枚举所有网卡并区分物理网卡和虚拟网卡,就需要GetAdaptersInfo。这里面要处理ADAPTER_INFO结构体链表,同样是个结构体噩梦,需要的话可以单独写一篇,本文不再展开。
4. 系统状态监控:内存占用、磁盘余量与开机时长该怎么取
4.1 全局内存状态:GlobalMemoryStatusEx与结构体定义
内存占用率是系统工具最常见的指标,精准的API是GlobalMemoryStatusEx,它比老旧的GlobalMemoryStatus多支持超过4GB的物理内存。传入的结构体MEMORYSTATUSEX,在易语言里按下面的自定义数据类型定义:
.版本 2 .数据类型 MEMORYSTATUSEX .成员 dwLength, 整数型 .成员 dwMemoryLoad, 整数型 .成员 ullTotalPhys, 长整数型 .成员 ullAvailPhys, 长整数型 .成员 ullTotalPageFile, 长整数型 .成员 ullAvailPageFile, 长整数型 .成员 ullTotalVirtual, 长整数型 .成员 ullAvailVirtual, 长整数型 .成员 ullAvailExtendedVirtual, 长整数型是不是一眼就能看出坑在哪?第一个成员dwLength必须先赋值结构体自身的字节数,我一般直接填“64”。Windows API有个惯例:很多结构体的第一个字段都是长度,函数先检查这个值,判断你传的是哪一版结构体,也用于防止缓冲区越界。漏掉这一步,函数直接返回失败,错误码是ERROR_INVALID_PARAMETER。
调用方式把结构体变量传址进去,函数执行完后,dwMemoryLoad就是0到100的内存占用率,ullTotalPhys就是物理内存总量。这里注意易语言长整数型是带符号64位,内存总量数值再大也不可能超过2的63次方,所以直接相减相除没有问题。
4.2 磁盘剩余空间:GetDiskFreeSpaceEx的64位整数处理
磁盘容量经常爆掉32位整数的范围,所以老接口GetDiskFreeSpace已经不适合现代硬盘了,要用GetDiskFreeSpaceExA。它的参数有四个:
BOOL GetDiskFreeSpaceExA( LPCSTR lpDirectoryName, // 盘符路径,如 "C:\" PULARGE_INTEGER lpFreeBytesAvailableToCaller, // 可用字节数 PULARGE_INTEGER lpTotalNumberOfBytes, // 总字节数 PULARGE_INTEGER lpTotalNumberOfFreeBytes // 总剩余字节数 );后面三个都是64位整数指针。在易语言中,建议把它们都声明为“长整数型,传址”,这样调用完直接拿变量值。部分参考资料会写“两个整数型拼起来”,那是32位时代的老做法,在64位字段上用易语言长整数型会更简洁。
拿到原始字节数后,转换成人类可读的大小是另一个容易出错的地方。很多人直接把字节数除以1024三次,得到GB,但要注意剩余空间和总空间分别除以同一个换算基数,并且保留一位小数,避免在报表里显示成“999MB”这种尴尬值。我这里通常自己做一个小函数,入参是长整数字节数,按阈值输出KB/MB/GB单位。
4.3 开机时长与CPU使用率的近似计算
获取开机时长最常见的API是GetTickCount64,返回系统启动后经过的毫秒数。易语言的整数型只有32位,存放不了几十天的毫秒值,必须用长整数型,然后除以1000得到秒数,再拆成天、时、分、秒格式化输出。注意GetTickCount64是Vista之后才有的,如果你的工具要兼容Windows XP,只能退回去用GetTickCount,并且要考虑32位毫秒值约49.7天回绕的问题。
CPU使用率没有现成的API能一次性返回一个百分比,常规做法是GetSystemTimes。这个函数返回三个FILETIME结构体:空闲时间、内核时间、用户时间。两次采样之间求差,就能算出这个时间段的CPU使用率:
- 第一次采样,取三组时间;
- 等待一段时间,比如1000毫秒;
- 第二次采样,取三组时间;
- 总时间差 = 内核时间差 + 用户时间差;空闲时间差单独记录;
- CPU使用率 = (1 - 空闲时间差 ÷ 总时间差) × 100%。
函数原型里三个参数都是LP FILETIME指针,FILETIME本质是两个32位整数组成一个64位值,在易语言里可以定义成两个整数型成员的自定义数据类型,也可以用长整数型接收后做差。这里最常见的问题是把两次采样的空闲时间直接拿来除以总时间,没有先做差,算出来的结果接近99%,看着像是对的其实完全错了。我后来把采样封装成函数后,每次都会单独打印差值,习惯做“数据合理性”自检。
内存、磁盘、CPU使用率这三个指标可以说是任何系统体检工具的地基,把它们稳住,剩下的都是锦上添花。
5. 控制类API:关机、休眠、屏幕状态与空闲检测
5.1 ExitWindowsEx关机授权失败的原因:令牌权限
做到控制类功能时,第一道坎通常是权限。ExitWindowsEx函数声明异常简单,接收一个标志参数和一个保留参数,调用方式一看就懂。但很多新手写完发现函数返回0,明明参数都对,Windows却不让关机。
原因在于关机、重启这类操作需要SE_SHUTDOWN_NAME特权,而默认进程令牌里并不会主动启用它,需要你用OpenProcessToken打开当前进程的令牌,再调用AdjustTokenPrivileges把这个特权启用。这段提权代码是标准的,我整理成一个自定义命令“启用关机权限”:
- OpenProcessToken打开当前进程令牌,传入参数为当前进程句柄、TOKEN_ADJUST_PRIVILEGES|TOKEN_QUERY,得到令牌句柄;
- LookupPrivilegeValue找到SE_SHUTDOWN_NAME对应的本地唯一标识LUID;
- AdjustTokenPrivileges把该特权设为启用状态;
- 关闭令牌句柄。
因为要操作进程令牌,涉及advapi32.dll里的函数,声明会比一般API多一些,但套这个模板就不会错。完成提权后再调用ExitWindowsEx:
- 注销:EWX_LOGOFF,值是0;
- 关机:EWX_POWEROFF,值是8,配合EWX_FORCE(值4)可在应用程序不响应时强制关机;
- 重启:EWX_REBOOT,值是2,配合EWX_FORCE。
强制关机会丢未保存数据,这个我在工具里只会在明确点击“强制”时才会传EWX_FORCE。不建议在常规按钮里默认加这个标志,谁用谁知道,恢复不了的东西哭都来不及。
5.2 休眠与电源状态读取
休眠相关API有两个容易混淆:SetSystemPowerState在user32.dll里,SetSuspendState在powrprof.dll里。实际测试下来,PowrProf的SetSuspendState休眠功能在更多机器上稳定,它的第一个参数为真表示休眠,为假表示挂起。调用方式同样很简单,注意它需要SE_SHUTDOWN_NAME权限,所以前面那个“启用关机权限”的命令要先执行一遍。
读取当前电源状态用GetSystemPowerStatus,它返回SYSTEM_POWER_STATUS结构体,字段包括:
| 字段 | 含义 |
|---|---|
| ACLineStatus | 是否连接交流电:0代表电池供电,1代表使用交流电,2代表未知 |
| BatteryFlag | 电池状态:1高,2低,4临界,8正在充电,128无电池 |
| BatteryLifePercent | 剩余电量百分比0到100,255代表未知 |
| BatteryLifeTime | 剩余使用秒数,-1代表未知 |
对于机房无人值守巡检工具,这个结构体非常关键:可以检测到设备突然切到电池供电,再配合邮件或桌面提醒,能第一时间发现断电。这里要注意BatteryLifePercent为255时不要直接显示为255,而是显示“未知”,否则报表里会出现一个很离奇的数字。
5.3 空闲检测:GetLastInputInfo实现自动锁屏与屏保
还有一个我很常用的系统函数是GetLastInputInfo,可以用来判断用户离开工位多久了。它要做的事情很简单:传入一个LASTINPUTINFO结构体,函数往里面写入自启动以来的最后一次用户输入(键盘或鼠标活动)的时间戳。
LASTINPUTINFO在易语言里的定义:
.数据类型 LASTINPUTINFO .成员 cbSize, 整数型 .成员 dwTime, 整数型同样不要忘记给cbSize赋结构体大小,也就是8。调用后,用GetTickCount32获取当前时间,两者相减就是用户空闲的毫秒数。这个值用途很广:超过设定时间自动弹出气泡、自动锁定工作站、记录考勤外勤时长等等。
在Windows 10较新版本上,不再建议用免打搅时间段里的按键当作有效用户输入,因为系统可能把自动行为也算进去。如果要严格统计,需要排除睡眠唤醒等场景,这个只能在应用层做逻辑判断,API本身不提供区分能力。
6. 把API调用沉淀为易语言模块:封装思路与调试习惯
6.1 先跑通最小示例,再考虑复杂API
每次接触一个新API,我的习惯是先写一个最小测试程序,而不是直接塞进大工具。最小测试程序只干一件事:调用目标函数,把返回值、缓冲区内容、错误码输出到一个文本框里。比如测试GetComputerNameA,就开一个文本框显示计算机名;测试GlobalMemoryStatusEx,就显示内存总量和占用率。这样一旦出问题,你只需要面对一个变量。
调试的定位顺序也很固定。先看返回值是0还是非0,0说明调用本身失败,这时优先排查参数类型、传址传值、缓冲区长度、结构体长度字段这四个点。如果返回值正常但数据不对,就要怀疑编码问题,字符串乱码基本是A/W版本选错。实在定位不出来,调用GetLastError读取错误码,再去查对应的系统错误说明,绝大多数情况下错误码会直接告诉你“缓冲区太小”还是“参数无效”。
6.2 封装成统一风格的“系统工具模块”
调试通过之后,我才会把它封装进项目模块。封装时我统一用“API_”前缀命名自定义命令,比如API_取内存占用率、API_取开机时长、API_取硬盘物理序列号,返回值统一为文本型或整数型,调用方不需要看到复杂的结构体和缓冲区逻辑。
遇到结构体的地方,我坚持一个原则:对外暴露简单数据类型,对内隐藏复杂定义。比如内存状态函数,模块内部定义一个全局结构体变量,每次调用前自动填dwLength,调用后把需要的字段拆出来返回,使用者不用知道MEMORYSTATUSEX长什么样。这样的封装能让你在多个项目间复制模块,一年下来能攒下一套自己的API库,之后写新工具基本就是拼积木。
6.3 32位目标程序的兼容性注意事项
最后聊一个容易被忽略的背景:易语言编译出来的程序是32位进程,运行在64位Windows上时,调用系统API的绝大部分场景没问题,系统会自动做转换。但一旦涉及“指针大小随系统位数变化”的参数,比如某些结构体里有SIZE_T字段,32位下是4字节,64位下是8字节,你的易语言结构体定义就必须按64位分配,否则后续字段全部错位。
这也是我前面坚持“用字节集缓冲手工解析”的原因之一,字节集缓冲可以按实际需要分配尺寸,不受自定义数据类型固定字段的限制。另外,在64位系统上操作注册表的某些重定向路径时,32位程序可能被映射到Wow6432Node节点,读写同一个键值可能得到不同结果,处理硬件信息时如果发现注册表取不到预期数据,要想到这个重定向。
再有就是发布易语言工具时,部分杀毒软件对易语言编译产物的特征识别比较敏感,容易误报。我的经验是在尽量干净的编译环境里生成程序,并且给程序加上数字签名或至少提供源码说明,能减少不少交付阻力。这是易语言生态的客观情况,不影响API调用本身的正确性,但提前知道能避免发布前手忙脚乱。
从系统API到易语言模块,整个过程说穿了就是“声明、调试、封装”三步。别贪多,从最简单的取计算机名开始跑通,再逐步啃内存、硬盘和电源,遇到错误返回先查GetLastError,积累的声明和坑都能沉淀成自己的模块。这套方法不只适用于易语言,换到任何能调用系统DLL的语言里,排查思路都是同一个。