CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Windows 10系统编程 引言
  • 第1章:基础
    • Windows架构概述
      • 进程
      • 动态链接库
      • 虚拟内存
      • 线程
      • 通用系统架构
    • Windows应用程序开发
      • 你的第一个应用程序
    • 处理字符串
      • C/C++运行时库中的字符串
      • 字符串输出参数
      • 安全字符串函数
    • 32位与64位开发
    • 编码规范
      • C++的使用
    • 处理API错误
      • 定义自定义错误码
    • Windows版本
      • 获取Windows版本号
    • 练习
    • 总结
  • 第2章:对象和句柄
  • 第3章:进程
  • 第4章:作业(Jobs)
  • 第5章:线程基础
  • 第6章:线程调度
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
  • 第17章:注册表
目录

第1章:基础

# 第1章:基础

Windows NT系列操作系统有着相当长的历史,始于1993年发布的3.1版本。如今的Windows 10是最初的NT 3.1的最新继任者。当前Windows系统的基本概念与1993年时基本相同,这体现了最初操作系统设计的优势。尽管如此,自诞生以来,Windows系统有了显著发展,增加了许多新功能,并对现有功能进行了改进。

本书主要讲述系统编程,系统编程通常被视为对操作系统核心服务的底层编程,若没有这些,就无法完成重要的工作。系统编程使用底层API来使用和操作Windows中的核心对象和机制,如进程、线程和内存。

在本章中,我们将从核心概念和API入手,了解Windows系统编程的基础知识。

本章内容包括:

  • Windows架构概述
  • Windows应用程序开发
  • 处理字符串
  • 32位与64位开发
  • 编码规范
  • 处理API错误
  • Windows版本

# Windows架构概述

我们将首先简要介绍Windows中的一些核心概念和组件,后续相关章节会对这些内容进行详细阐述。

# 进程

进程是一个包含和管理对象,代表程序的运行实例。人们常说的“进程运行”其实并不准确,进程本身并不运行,而是进行管理工作,真正执行代码并在技术意义上运行的是线程。从高层次来看,一个进程拥有以下内容:

  • 可执行程序:包含用于在进程内执行代码的初始代码和数据。
  • 私有虚拟地址空间:用于为进程内代码所需的各种目的分配内存。
  • 访问令牌(有时也称为主令牌):这是一个存储进程默认安全上下文的对象,供在进程内执行代码的线程使用(除非线程通过模拟使用不同的令牌)。
  • 一个指向执行体(内核)对象(如事件、信号量和文件)的私有句柄表。
  • 一个或多个执行线程。正常的用户模式进程在创建时会有一个线程(执行进程的主入口点)。没有线程的用户模式进程大多没什么用,在正常情况下会被内核销毁。

进程的这些元素如图1-1所示。

进程的重要组成部分

图1-1:进程的重要组成部分

进程由其进程ID唯一标识,只要内核进程对象存在,进程ID就保持唯一。一旦进程对象被销毁,相同的ID可能会被新进程复用。需要注意的是,可执行文件本身并不能唯一标识一个进程。例如,可能同时有五个notepad.exe实例在运行。每个进程都有自己的地址空间、线程、句柄表和唯一的进程ID等。这五个进程都使用同一个映像文件(notepad.exe)作为初始代码和数据。图1-2展示了任务管理器“详细信息”选项卡的截图,其中显示了五个Notepad.exe实例,每个实例都有各自的属性。

五个记事本实例

图1-2:五个记事本实例

# 动态链接库

动态链接库(Dynamic Link Libraries,DLLs)是可执行文件,至少包含代码、数据和资源中的一种。DLLs在进程初始化时(称为静态链接)或之后被显式请求时(动态链接)动态加载到进程中。我们将在第15章更详细地介绍DLLs。DLLs不像可执行文件那样包含标准的main函数,因此不能直接运行。DLLs允许在使用相同DLL的多个进程之间共享物理内存中的代码,存储在System32目录下的所有标准Windows DLL都是这种情况。其中一些被称为子系统DLL的文件,实现了有文档记录的Windows API,这也是本书的重点。

图1-3展示了两个进程使用映射到相同物理(和虚拟)地址的共享DLL。

共享DLL代码

图1-3:共享DLL代码

# 虚拟内存

每个进程都有自己的虚拟、私有、线性地址空间。这个地址空间最初几乎是空的(实际上,可执行映像和NtDll.Dll通常是最先被映射进去的)。一旦主(第一个)线程开始执行,就可能会分配内存、加载更多DLL等。这个地址空间是私有的,意味着其他进程不能直接访问。地址空间范围从0开始(尽管从技术上讲,前64KB的地址不能被分配),最大值取决于进程的“位数”(32位或64位)、操作系统的“位数”以及链接器标志,具体如下:

  • 对于32位Windows系统上的32位进程,默认情况下进程地址空间大小为2GB。
  • 对于32位Windows系统上使用了增加用户地址空间设置的32位进程,进程地址空间大小最大可达3GB(具体取决于设置)。要获得扩展的地址空间范围,创建该进程的可执行文件必须在其头部标记有LARGEADDRESSAWARE链接器标志,否则仍将限制在2GB。
  • 对于64位进程(自然是在64位Windows系统上),地址空间大小在Windows 8及更早版本中为8TB,在Windows 8.1及更高版本中为128TB。
  • 对于64位Windows系统上的32位进程,如果可执行映像使用LARGEADDRESSAWARE标志进行链接,地址空间大小为4GB,否则仍为2GB。

LARGEADDRESSAWARE标志的要求源于这样一个事实:2GB的地址范围只需要31位,最高有效位(MSB)可留作应用程序使用。指定此标志表明程序不会使用第31位,因此将该位设置为1(对于大于2GB的地址会发生这种情况)不会有问题。

这里的内存之所以被称为虚拟内存,是因为地址范围与它在物理内存(RAM)中的实际存储位置之间存在间接关系。进程内的缓冲区可能被映射到物理内存,也可能临时存储在文件(如页面文件)中。“虚拟”这个术语指的是,从执行的角度来看,无需知道即将访问的内存是否在RAM中。如果内存确实映射到了RAM,CPU将直接访问数据;如果没有,CPU会引发页面错误异常,这将导致内存管理器的页面错误处理程序从相应文件中获取数据,将其复制到RAM中,在映射缓冲区的页表项中进行必要的更改,然后指示CPU再次尝试访问。

# 线程

实际执行代码的实体是线程。线程包含在进程内,利用进程提供的资源来执行任务(如虚拟内存和内核对象的句柄)。线程拥有的最重要属性如下:

  • 当前访问模式,分为用户模式或内核模式。
  • 执行上下文,包括处理器寄存器。
  • 一个栈,用于局部变量分配和调用管理。
  • 线程本地存储(Thread Local Storage,TLS)数组,它提供了一种以统一的访问语义存储线程私有数据的方式。
  • 基本优先级和当前(动态)优先级。
  • 处理器亲和性,表示线程允许在哪些处理器上运行。

线程最常见的状态有:

  • 运行中:当前正在(逻辑)处理器上执行代码。
  • 就绪:由于所有相关处理器都处于忙碌或不可用状态,正在等待被调度执行。
  • 等待:正在等待某个事件发生,事件发生后,线程将进入就绪状态。

# 通用系统架构

图1-4展示了Windows的通用架构,它由用户模式和内核模式组件组成。

Windows系统架构

图1-4:Windows系统架构

以下是对图1-3中各个组件的简要介绍:

  • 用户进程:这些是基于映像文件在系统上执行的普通进程,如Notepad.exe、cmd.exe、explorer.exe等实例。
  • 子系统DLL:子系统DLL是实现子系统API的动态链接库(DLLs)。子系统是对内核所暴露功能的一种特定视角。从技术上讲,自Windows 8.1起,只有一个子系统——Windows子系统。子系统DLL包括一些常见文件,如kernel32.dll、user32.dll、gdi32.dll、advapi32.dll、combase.dll等。其中大多包含Windows官方文档记录的API。本书重点介绍如何使用这些DLL所暴露的API。
  • NTDLL.DLL:这是一个系统范围的DLL,实现了Windows原生API,是仍处于用户模式的最底层代码。它最重要的作用是在进行系统调用时实现从用户模式到内核模式的转换。NTDLL还实现了堆管理器、映像加载器以及用户模式线程池的部分功能。尽管原生API大多没有文档记录,但在标准的有文档记录的Windows API无法实现某些目标时,本书仍会使用其中一部分。
  • 服务进程:服务进程是普通的Windows进程,它们与服务控制管理器(Service Control Manager,SCM,在services.exe中实现)进行通信,并允许对其生命周期进行一定控制。SCM可以启动、停止、暂停、恢复服务并向服务发送其他消息。第19章将更详细地介绍服务。
  • 执行体:执行体是NtOskrnl.exe(即“内核”)的上层部分,承载了大部分内核模式代码,主要包括各种“管理器”,如对象管理器、内存管理器、I/O管理器、即插即用管理器、电源管理器、配置管理器等。它的规模远大于下层的内核层。
  • 内核:内核层实现了内核模式操作系统代码中最基本且对时间敏感的部分,包括线程调度、中断和异常分发,以及各种内核原语(如互斥锁和信号量)的实现。为了提高效率并直接访问特定于CPU的细节,部分内核代码使用特定于CPU的机器语言编写。
  • 设备驱动程序:设备驱动程序是可加载的内核模块,其代码在内核模式下执行,因此拥有内核的全部权限。传统的设备驱动程序在硬件设备和操作系统的其他部分之间起到连接作用,其他类型的驱动程序则提供过滤功能。有关设备驱动程序的更多信息,请参阅我的《Windows内核编程》一书。
  • Win32k.sys:这是Windows子系统的内核模式组件,本质上是一个内核模块(驱动程序),用于处理Windows的用户界面部分和经典的图形设备接口(Graphics Device Interface,GDI)API。这意味着所有窗口操作都由该组件处理,系统的其他部分对用户界面几乎一无所知。
  • 硬件抽象层(Hardware Abstraction Layer,HAL):HAL是位于最接近CPU的硬件之上的抽象层,它允许设备驱动程序使用API,而无需详细了解诸如中断控制器或DMA控制器等内容。自然地,这一层对于为处理硬件设备而编写的设备驱动程序最为有用。
  • 系统进程:系统进程是一个统称,用于描述通常“默默运行”的进程,一般不会直接与它们进行通信。尽管如此,它们很重要,有些甚至对系统的正常运行至关重要。终止其中一些进程会导致系统崩溃。部分系统进程是原生进程,这意味着它们仅使用原生API(由NTDLL实现的API)。系统进程的示例包括Smss.exe、Lsass.exe、Winlogon.exe、Services.exe等。
  • 子系统进程:Windows子系统进程运行Csrss.exe映像,可被视为内核管理Windows系统下运行进程的助手,是一个关键进程,若被终止,系统将会崩溃。通常每个会话会有一个Csrss.exe实例,因此在标准系统中会存在两个实例,一个用于会话0,另一个用于已登录用户的会话(通常为会话1)。尽管Csrss.exe是Windows子系统(如今仅存的一个)的“管理器”,但其重要性不止于此。
  • Hyper-V虚拟机管理程序:如果Windows 10和服务器2016(及更高版本)系统支持基于虚拟化的安全性(Virtualization Based Security,VBS),就会存在Hyper-V虚拟机管理程序。VBS提供了额外的安全层,实际的计算机实际上是由Hyper-V控制的虚拟机。VBS超出了本书的范围,更多信息请查阅《Windows内核原理》一书。

# Windows应用程序开发

Windows为开发者提供了一个应用程序编程接口(Application Programming Interface,API),用于访问Windows系统功能。经典的API被称为Windows API,它主要由一长串C函数组成,其功能涵盖从处理进程、线程和其他低级对象的基础服务,到用户界面、图形、网络等各个方面。本书主要聚焦于使用这个API进行Windows编程。

从Windows 8开始,Windows支持两种略有不同的应用程序类型:经典桌面应用程序(在Windows 8之前,这是唯一的应用程序类型)和通用Windows应用程序(可以上传到Windows应用商店)。从内部机制来看,这两种类型的应用程序是相同的。它们都使用线程、虚拟内存、动态链接库(DLL)、句柄等。应用商店应用程序主要使用Windows运行时API(本节后面会介绍),但也可以使用经典Windows API的一个子集。相反,桌面应用程序使用经典Windows API,同时也可以利用Windows运行时API的一个子集。本书专注于桌面应用程序,因为经典Windows API的全部功能都可供桌面应用程序使用,而且这个API包含了系统编程所需的大部分实用功能。

Windows提供的其他API风格,尤其是从Windows Vista开始,都是基于组件对象模型(Component Object Model,COM)技术的。COM是一种面向组件的编程范式,于1993年发布,如今在Windows中的许多组件和服务中都有应用。例如DirectX、Windows图像组件(Windows Imaging Component,WIC)、DirectShow、媒体基础(Media Foundation)、后台智能传输服务(Background Intelligent Transfer Service,BITS)、Windows管理规范(Windows Management Instrumentation,WMI)等等。COM中最基本的概念是接口(interface),它是一个契约,由单个容器下的一组函数组成。我们将在第18章介绍COM的基础知识。

多年来,针对这两种基本API风格,自然开发出了各种包装器,有些是微软开发的,有些则来自其他开发者。以下是一些微软开发的常见包装器:

  • 微软基础类库(Microsoft Foundation Classes,MFC)——主要是针对Windows所暴露的用户界面(UI)功能的C++包装器,用于处理窗口、控件、菜单、图形设备接口(GDI)、对话框等。
  • 活动模板库(Active Template Library,ATL)——一个基于C++模板的库,主要用于构建COM服务器和客户端。我们将在第18章使用ATL来简化与COM相关的代码编写。
  • Windows模板库(Windows Template Library,WTL)——ATL的扩展,为Windows用户界面功能提供基于模板的包装器。在功能方面,它与MFC类似,但更轻量级,并且不像MFC那样需要携带一个(大的)动态链接库。由于本书重点不在于UI,因此我们将在本书中使用WTL来简化与UI相关的代码。
  • .NET——一个框架和运行时(公共语言运行时,Common Language Runtime,CLR),它提供了一系列服务,例如将中间语言(Intermediate Language,IL)即时(Just in Time,JIT)编译为本机代码以及垃圾回收。.NET可以通过使用新的语言(其中最著名的是C#)来发挥作用,这些语言提供了许多功能,其中很多功能对Windows功能进行了抽象,提高了开发效率。.NET框架使用标准Windows API来实现其更高级别的功能。.NET超出了本书的范围,若想深入了解.NET的内部机制和功能,可以参考Jeffrey Richter所著的《CLR Via C#》一书。
  • Windows运行时(Windows Runtime,WinRT)——这是在Windows 8及更高版本中新增的最新API层。它的主要目标是基于通用Windows平台(Universal Windows Platform,UWP)开发应用程序。这些应用程序可以进行打包并上传到Windows应用商店,供任何人下载。Windows运行时是围绕COM的增强版本构建的,所以它也主要(但并非唯一)以接口作为构建块。尽管这个平台是原生的(并非基于.NET),但它可以被C++、C#(以及其他.NET语言)甚至JavaScript使用——微软提供了语言投影功能,以便更轻松地访问Windows运行时API。经典桌面应用程序也可以使用Windows运行时API的一个子集。我们将在第19章介绍Windows运行时的基础知识。

大多数标准Windows API函数定义都包含在windows.h头文件中。在某些情况下,还需要其他头文件以及导入库。本书会指出所需的头文件和 / 或库。

# 你的第一个应用程序

本节介绍如何使用Visual Studio编写一个简单的应用程序,并成功进行编译和运行。如果你已经熟悉这些内容,可以跳过本节。

首先,你需要安装适用于Windows开发的工具。以下是所需软件的简要列表:

  1. Visual Studio 2017或2019,任意版本,包括免费的社区版(可从 https://visualstudio.microsoft.com/downloads/ (opens new window) 下载)。早期版本的Visual Studio也能正常使用,但通常建议使用最新版本,因为新版本包含了编译器的改进和易用性的增强。在安装程序的主窗口中,确保至少选择了“使用C++进行桌面开发”工作负载,如图1 - 5所示。

    图 1-5:Visual Studio(微软可视化工作室)安装程序主窗口

  2. Windows软件开发工具包(Windows Software Development Kit,SDK)是一个可选安装项,它提供(可能是更新的)头文件和库,以及各种工具。

安装好Visual Studio 2017/2019后,运行它并选择创建一个新项目。

  • 在Visual Studio 2017中,从菜单中选择“文件”/“新建项目...”,找到“C++”/“桌面”节点,然后选择“Windows控制台应用程序”项目模板,如图1 - 6所示。
  • 在Visual Studio 2019中,从启动窗口选择“创建新项目”,分别在项目类型和语言中使用“控制台”和“C++”进行筛选,然后选择“控制台应用”(确保列出的语言是C++),如图1 - 7所示。

图1-6:Visual Studio 2017中的新建项目对话框

图1-7:Visual Studio 2019中的新建项目对话框

将项目命名为HelloWin,如果你愿意,还可以更改目标文件夹,然后点击“确定”。项目创建后,会在编辑器中打开一个HelloWin.cpp文件,其中包含一个基本的main函数。

在文件顶部添加对windows.h的#include引用:

#include <windows.h>
1

如果你的项目有预编译头文件(每个C/C++源文件顶部都有#include "pch.h"),将对windows.h的#include引用添加到这个文件中,这样在第一次编译后,后续的编译会更快。

你也可以根据自己的喜好将其包含在C/C++文件中,但这个包含必须紧跟在对pch.h的包含之后。

再添加一个对stdio.h的#include引用,以便使用printf函数:

#include <stdio.h>
1

在这个第一个应用程序中,我们将通过调用GetNativeSystemInfo函数来获取一些系统信息。

以下是main函数的代码:

int main() {
    SYSTEM_INFO si;
    ::GetNativeSystemInfo(&si);

    printf("Number of Logical Processors: %d\n", si.dwNumberOfProcessors);
    printf("Page size: %d Bytes\n", si.dwPageSize);
    printf("Processor Mask: 0x%p\n", (PVOID)si.dwActiveProcessorMask);
    printf("Minimum process address: 0x%p\n", si.lpMinimumApplicationAddress);
    printf("Maximum process address: 0x%p\n", si.lpMaximumApplicationAddress);

    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12

从“生成”菜单中选择“生成解决方案”来编译和链接项目(从技术上讲,是解决方案中的所有项目)。所有内容都应该能顺利编译和链接,不会出现错误。按下Ctrl + F5组合键可以在不附加调试器的情况下启动可执行文件(或者使用“调试”菜单并选择“不调试运行”)。这时会打开一个控制台窗口,显示类似以下的输出:

Number of Logical Processors: 12
Page size: 4096 Bytes
Processor Mask: 0x00000FFF
Minimum process address: 0x00010000
Maximum process address: 0x7FFEFFFF
1
2
3
4
5

如果你按下F5键(“调试”菜单,“开始调试”)来运行应用程序,控制台窗口会出现,但在应用程序退出时会很快消失。使用Ctrl + F5组合键会添加一个方便的“按任意键继续”提示,这样你就可以在关闭窗口之前查看控制台输出。

Visual Studio通常会创建两个解决方案平台(x86和x64),可以通过主工具栏中的“解决方案平台”组合框轻松切换。默认情况下,选择的是x86平台,会产生上述输出。如果你将平台切换到x64并重新生成(当然,前提是你运行的是英特尔/AMD 64位版本的Windows),你会得到略有不同的输出:

Number of Logical Processors: 12
Page size: 4096 Bytes
Processor Mask: 0x0000000000000FFF
Minimum process address: 0x0000000000010000
Maximum process address: 0x00007FFFFFFEFFFF
1
2
3
4
5

这些差异源于64位进程使用8字节大小的指针,而32位进程使用4字节大小的指针。SYSTEM_INFO结构中的地址空间地址信息是按指针类型定义的,所以它们的大小会因进程的“位数”而异。我们将在本章后面的“32位与64位开发”部分更详细地讨论32位和64位开发。

不用在意这个小应用程序所显示信息的含义(尽管其中一些信息显而易见)。我们将在后面的章节中探讨这些术语。

在上述代码中,函数名前使用双冒号(::GetNativeSystemInfo)是为了强调该函数是Windows API的一部分,而不是当前C++类的成员函数。在这个例子中,由于周围没有C++类,这一点很明显,但本书中会始终遵循这个约定(它也能稍微加快编译器查找函数的速度)。本章后面的“编码约定”部分会介绍更多编码约定。

# 处理字符串

在经典的C语言中,字符串并非真正的数据类型,而仅仅是指向以零结尾的字符的指针。Windows API在很多情况下采用这种方式处理字符串,但并非所有情况都是如此。在处理字符串时,编码问题就会随之而来。在本节中,我们将全面了解字符串,以及它们在Windows API中的使用方式。

在经典的C语言中,仅有一种类型用于表示字符,即char。char类型表示的字符最多为8位,其中前7位使用ASCII编码。如今的系统必须支持多种语言的字符集,而这些字符集无法完全用8位来表示。因此,在“Unicode”这一统称下,新的编码方式应运而生,其官方网站为http://www.unicode.org。

Unicode联盟定义了其他几种字符编码。以下是常见的几种:

  • UTF-8:网页中普遍使用的编码方式。对于属于ASCII集的拉丁字符,这种编码方式每个字符使用一个字节,而对于其他语言(如中文、希伯来语、阿拉伯语等)的字符,则每个字符使用更多字节。这种编码方式很受欢迎,因为如果文本大多是英文,其占用空间较小。一般来说,UTF-8每个字符使用1到4个字节。
  • UTF-16:在大多数情况下,每个字符使用两个字节,并且仅用两个字节就能涵盖所有语言。一些来自中文和日文的特殊字符可能需要四个字节,但这种情况很少见。
  • UTF-32:每个字符使用四个字节。它使用起来最简单,但可能最浪费空间。

“UTF”代表“Unicode转换格式(Unicode Transformation Format)”。

在考虑空间大小时,UTF-8可能是最佳选择,但从编程的角度来看,它存在问题,因为无法进行随机访问。例如,要获取UTF-8字符串中的第100个字符,代码需要从字符串开头开始扫描,按顺序查找,因为无法确定第100个字符的位置。另一方面,UTF-16在编程方面使用起来要方便得多(如果不考虑特殊情况),因为访问第100个字符,只需在字符串起始地址上加上200个字节即可。

UTF-32太浪费空间,很少被使用。

幸运的是,Windows内核使用UTF-16编码,其中每个字符恰好为2个字节。Windows API也遵循这一规则,同样使用UTF-16编码。这非常方便,因为当API调用最终进入内核时,字符串无需进行转换。然而,Windows API存在一个小问题。

Windows API的部分内容是从16位Windows和消费者版Windows(Windows 95/98)迁移过来的。这些系统主要使用ASCII编码,这意味着Windows API当时使用ASCII字符串,而不是UTF-16字符串。当引入双字节编码后,问题变得更加复杂,因为每个字符的大小为1个或2个字节,这样就失去了随机访问的优势。

综合以上情况,出于兼容性考虑,Windows API同时包含UTF-16和ASCII相关函数。鉴于上述系统如今已不存在,最好不要再使用每个字符占一个字节的字符串,仅使用UTF-16相关函数即可。使用ASCII函数会导致字符串先转换为UTF-16,然后再用于UTF-16函数。

UTF-16在与.NET Framework交互时也有优势,因为.NET的字符串类型仅存储UTF-16字符。这意味着将UTF-16字符串传递给.NET时,无需进行任何转换或复制。

以下是函数CreateMutex的示例,在网上搜索该函数,会找到CreateMutexA和CreateMutexW两个函数之一。离线文档给出的函数原型如下:

HANDLE CreateMutex(
    _In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
    _In_     BOOL                  bInitialOwner,
    _In_opt_ LPCTSTR               lpName);
1
2
3
4
_In_opt_及其他类似的注释称为语法注释语言(Syntax Annotation Language,SAL),用于向函数和结构定义传递元数据信息。这对人类以及静态分析工具都可能有用。目前C++编译器会忽略这些注释,但Visual Studio企业版中的静态分析器会在实际运行程序前,利用这些注释检测潜在错误。

目前,我们重点关注最后一个参数,它是一个类型为LPCTSTR的字符串指针。下面对其进行拆解:“L”代表“Long(长)”,“P”代表“Pointer(指针)”,“C”代表“Constant(常量)”,“STR”代表“String(字符串)”。唯一令人疑惑的是中间的“T”。实际上,LPCTSTR是一个类型定义(typedef),有以下两种定义之一:

typedef LPCSTR LPCTSTR;  // const char* (未定义UNICODE时)
typedef LPCWSTR LPCTSTR; // const wchar_t* (定义了UNICODE时)
1
2
术语“长指针”在如今已没有实际意义。在特定进程中,所有指针的大小都是相同的(32位进程中为4字节,64位进程中为8字节)。“长”和“短”(或“近”)这些术语是16位Windows时代的遗留产物,在当时它们确实有不同的含义。此外,LPCTSTR及类似类型还有另一种等效形式,即去掉“L”的PCTSTR、PCWSTR等。在源代码中,通常更倾向于使用这些形式。

编译常量UNICODE的定义会使LPCTSTR扩展为UTF-16字符串,若未定义UNICODE,则扩展为ASCII字符串。这也意味着CreateMutex不能是一个普通函数,因为C语言不允许函数重载(即一个函数名不能有多个函数原型)。CreateMutex是一个宏,在定义了UNICODE时会扩展为CreateMutexW,未定义UNICODE时会扩展为CreateMutexA。Visual Studio在所有新项目中默认定义了UNICODE常量,这是个不错的设置。我们始终应使用UTF-16相关函数,以避免从ANSI转换为UTF-16(当然,对于包含非ASCII字符的字符串,这种转换必然会导致信息丢失)。

CreateMutexW中的“W”代表“Wide(宽字符)”,CreateMutexA中的“A”代表“ANSI”或“ASCII”。

如果代码需要使用常量UTF-16字符串,在字符串前加上前缀“L”,以指示编译器将该字符串转换为UTF-16。以下是两个字符串示例,一个是ASCII字符串,另一个是UTF-16字符串:

const char     name1[] = "Hello";   //  6字节 (包括NULL终止符)
const wchar_t  name2[] = L"Hello";  //  12字节 (包括UTF-16 NULL终止符)
1
2
从这里开始,除非另有明确说明,我们将使用“Unicode”一词来指代UTF-16。

使用这些宏会引发一个问题:在不明确选择ASCII或Unicode的情况下,如何编译使用常量字符串的代码呢?答案在于另一个宏TEXT。以下是CreateMutex的示例:

HANDLE hMutex = ::CreateMutex(nullptr, FALSE, TEXT("MyMutex"));
1

TEXT宏会根据是否定义了UNICODE宏,扩展为带有或不带有“L”前缀的常量字符串。由于ASCII函数在调用宽字符函数之前需要将其值转换为Unicode,成本更高,所以我们不应使用ASCII函数。这意味着我们可以直接使用“L”前缀,而无需使用TEXT宏。在本书中,我们将采用这一约定。

在<tchar.h>中定义了一个比TEXT宏更简短的版本_T,它们是等效的。使用这些宏仍然是一种相当常见的做法,这本身并没有什么问题。不过,我个人倾向于不使用它。

与LPCTSTR类似,还有其他一些基于UNICODE编译常量的类型定义(typedef),用于选择使用ASCII或Unicode。表1 - 1展示了其中一些类型定义。 表1 - 1:字符串相关类型

常见类型 ASCII类型 Unicode类型
TCHAR char、CHAR wchar_t、WCHAR
LPTSTR、PTSTR char*、CHAR*、PSTR wchar_t*、WCHAR*、PWSTR
LPCTSTR、PCTSTR const char*、PCSTR const wchar_t*、PCWSTR

# C/C++运行时库中的字符串

C/C++运行时库中有两组用于操作字符串的函数。经典的(ASCII)函数以“str”开头,如strlen、strcpy、strcat等,同时也有以“wcs”开头的Unicode版本,如wcslen、wcscpy、wcscat等。

与Windows API类似,也有一组宏,根据另一个编译常量_UNICODE(注意有下划线),扩展为ASCII版本或Unicode版本的函数。这些函数的前缀是“_tcs”,例如_tcslen、_tcscpy、_tcscat等,它们都用于处理TCHAR类型的字符串。

Visual Studio默认定义了_UNICODE常量,因此如果使用“_tcs”系列函数,我们会得到Unicode版本的函数。如果只定义了“UNICODE”常量中的一个,那会很奇怪,所以应避免这种情况。

# 字符串输出参数

像在CreateMutex示例中那样,将字符串作为输入参数传递给函数是非常常见的操作。另一种常见需求是以字符串形式接收结果。Windows API有几种返回字符串结果的方式。 第一种(也是更常见的)情况是,客户端代码分配一个缓冲区来存储结果字符串,并将缓冲区的大小(字符串能容纳的最大大小)提供给API,API会将字符串写入缓冲区,写入的长度不会超过指定的大小。有些API还会返回实际写入的字符数,以及(如果缓冲区太小)所需的字符数。

以GetSystemDirectory函数为例,其定义如下:

UINT GetSystemDirectory(
    _Out_ LPTSTR lpBuffer,
    _In_  UINT   uSize);
1
2
3

该函数接受一个字符串缓冲区及其大小作为参数,并返回写入的字符数。注意,所有大小都是以字符为单位,而非字节,这很方便。如果函数执行失败,会返回零。以下是一个使用示例(暂时省略错误处理):

WCHAR path[MAX_PATH];
::GetSystemDirectory(path, MAX_PATH);
printf("System directory: %ws\n", path);
1
2
3

不要被指针类型所迷惑,GetSystemDirectory函数的声明并不意味着只需提供一个指针。相反,必须先分配一个缓冲区,然后传递该缓冲区的指针。

MAX_PATH在Windows头文件中定义为260,这是Windows中标准的最大路径长度(从Windows 10开始,这个限制可以扩展,我们将在第11章中介绍)。注意,printf函数使用%ws作为字符串格式说明符,表示这是一个Unicode字符串,因为默认定义了UNICODE,所以所有字符串都是Unicode字符串。

第二种常见情况是,客户端代码仅通过地址提供一个字符串指针,API会自行分配内存,并将结果指针存储在提供的变量中。这意味着当不再需要结果字符串时,客户端代码需要负责释放内存。关键是要使用正确的函数来释放内存,API的文档会指明应使用哪个函数。以下是使用FormatMessage函数(其Unicode版本)的示例:

DWORD FormatMessageW(
    _In_     DWORD dwFlags,
    _In_opt_ LPCVOID lpSource,
    _In_     DWORD dwMessageId,
    _In_     DWORD dwLanguageId,
    _When_((dwFlags & FORMAT_MESSAGE_ALLOCATE_BUFFER) != 0, _At_((LPWSTR*)lpBuffer, _Outptr_result z ))
    _When_((dwFlags & FORMAT_MESSAGE_ALLOCATE_BUFFER) == 0, _Out_writes z (nSize))
    LPWSTR lpBuffer,
    _In_     DWORD nSize,
    _In_opt_ va_list  *Arguments);
1
2
3
4
5
6
7
8
9
10

看起来很复杂,对吧?我特意包含了该函数完整的SAL注释,因为lpBuffer参数有些棘手。FormatMessage函数返回错误代码的字符串表示形式(我们将在本章后面的“API错误”部分更详细地讨论错误)。这个函数很灵活,它既可以自行分配字符串,也可以让客户端提供缓冲区来存储结果字符串。实际行为取决于第一个dwFlags参数:如果该参数包含FORMAT_MESSAGE_ALLOCATE_BUFFER标志,函数将分配合适大小的缓冲区;如果没有该标志,则由调用者提供存储返回字符串的空间。

这使得该函数有点棘手,至少在选择前一种方式时,指针类型应为LPWSTR*,即指向指针的指针,由函数来填充。这需要进行强制类型转换,才能让编译器通过。

以下是一个简单的main函数,它从命令行参数中获取一个错误代码,并显示其字符串表示(如果有的话)。这里使用了让函数分配内存的方式,原因是无法预先知道字符串的长度,所以最好让函数分配合适大小的内存。

int main(int argc, const char* argv[]) {
    if (argc < 2) {
        printf("Usage: ShowError <number>\n");
        return 0;
    }

    int message = atoi(argv[1]);

    LPWSTR text;
    DWORD chars = ::FormatMessage(
        FORMAT_MESSAGE_ALLOCATE_BUFFER |   // 函数分配内存
        FORMAT_MESSAGE_FROM_SYSTEM |
        FORMAT_MESSAGE_IGNORE_INSERTS,
        nullptr, message, 0,
        (LPWSTR)&text,     // 难看的强制类型转换
        0, nullptr);
    if (chars > 0) {
        printf("Message %d: %ws\n", message, text);
        ::LocalFree(text);
    }
    else {
        printf("No such error exists\n");
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
本书的Github仓库中,完整的项目名为ShowError

注意,如果调用成功,需要调用LocalFree函数来释放字符串。FormatMessage函数的文档指明,应调用这个函数来释放缓冲区。

以下是示例运行结果:

C:\Dev\Win10SysProg\x64\Debug>ShowError.exe 2
Message 2: The system cannot find the file specified.

C:\Dev\Win10SysProg\x64\Debug>ShowError.exe 5
Message 5: Access is denied.

C:\Dev\Win10SysProg\x64\Debug>ShowError.exe 129
Message 129: The %1 application cannot be run in Win32 mode.

C:\Dev\Win10SysProg\x64\Debug>ShowError.exe 1999
No such error exists
1
2
3
4
5
6
7
8
9
10
11

# 安全字符串函数

从安全性和可靠性的角度来看,一些经典的C/C++运行时字符串函数(以及Windows API中的一些类似函数)并不被认为是“安全的”。例如,strcpy函数存在问题,因为它会将源字符串复制到目标指针,直到遇到NULL终止符。这可能会导致目标缓冲区溢出,在最好的情况下会导致程序崩溃(例如,缓冲区可能在栈上,这会破坏存储在那里的返回地址),并且还可能被用于缓冲区溢出攻击,即将备用返回地址存储在栈上,跳转到预先准备好的恶意代码(shellcode)。

为了降低这些潜在的安全漏洞风险,微软在C/C++运行时库中添加了一组“安全”的字符串函数,这些函数通过一个额外的参数来指定目标缓冲区的最大大小,从而避免缓冲区溢出。这些函数的后缀为“_s”,例如strcpy_s、wcscat_s等。

以下是使用这些函数的一些示例:

void wmain(int argc, const wchar_t* argv[]) {
    // 假设在这个示例中argc >= 2
    WCHAR buffer[32];
    wcscpy_s(buffer, argv[1]);       // C++版本,可处理静态分配的缓冲区
    WCHAR* buffer2 = (WCHAR*)malloc(32 * sizeof(WCHAR));
    //wcscpy_s(buffer2, argv[1]);    // 无法编译
    wcscpy_s(buffer2, 32, argv[1]);  // 大小以字符为单位(而非字节)
    free(buffer2);
}
1
2
3
4
5
6
7
8
9

最大大小始终以字符为单位指定,而不是字节。还要注意,如果目标缓冲区是静态分配的,这些函数能够自动计算最大大小,这很方便。

Windows API中也添加了另一组安全字符串函数,至少是为了减少对C/C++运行时库的依赖。这些函数在头文件<strsafe.h>中声明(并实现)。它们遵循Windows API的约定,实际上是宏,会扩展为带有“A”或“W”后缀的函数。以下是一些简单的使用示例(使用与上述相同的声明):

StringCchCopy(buffer, _countof(buffer), argv[1]);
StringCchCat(buffer, _countof(buffer), L"cat");
StringCchCopy(buffer2, 32, argv[1]);
1
2
3

# 32位与64位开发

从Windows Vista开始,Windows有官方的32位和64位版本(Windows XP也有一个非商业的64位版本)。从Windows Server 2008 R2开始,所有服务器版本都只有64位。微软取消了32位服务器版本,因为服务器通常需要大量内存(RAM)和较大的进程地址空间,这使得32位系统在服务器工作中存在很大局限性。

从应用程序编程接口(API)的角度来看,32位和64位的编程模型是相同的。在Visual Studio中,你只需选择所需的配置并点击“生成”,就应该能够编译出32位或64位的程序。然而,如果代码要在32位和64位目标平台上都能成功构建,那么在编码时必须谨慎使用数据类型。在64位系统中,指针的大小是8字节,而在32位系统中只有4字节。如果假设指针的大小为某个固定值,这种变化可能会导致错误。例如,考虑下面这个类型转换操作:

void* p = ...;
int  value = (int)p;
// do something  with  value
1
2
3

这段代码存在缺陷,因为在64位系统中,指针值会被截断为4字节以存储到int类型变量中(在64位编译环境下,int类型仍然是4字节)。如果确实需要进行这样的类型转换,应该使用替代类型INT_PTR:

void* p = ...;
INT_PTR value = (INT_PTR)p;
// do something  with  value
1
2
3

INT_PTR的含义是:“与指针大小相同的整数”。Windows头文件中定义了好几个这样的类型,就是出于这个原因。其他一些类型无论编译的“位数”是多少,其大小都保持不变。表1 - 2展示了一些常见类型及其大小的示例。

类型名称 32位大小 64位大小 描述
ULONG_PTR 4字节 8字节 与指针大小相同的无符号整数
PVOID,void* 4字节 8字节 无类型指针
任意指针 4字节 8字节
BYTE,uint8_t 1字节 1字节 无符号8位整数
WORD,uint16_t 2字节 2字节 无符号16位整数
DWORD,ULONG,uint32_t 4字节 4字节 无符号32位整数
LONGLONG,int64,int64_t 8字节 8字节 有符号64位整数
SIZE_T,size_t 4字节 8字节 与本机整数大小相同的无符号整数

32位和64位的差异不止体现在类型大小上。64位进程的地址空间是128TB(Windows 8.1及更高版本),而32位进程仅有2GB。在x64系统(英特尔/AMD)上,由于有一个名为WOW64(Windows on Windows 64)的转换层,32位进程也能正常执行。我们将在第12章深入探讨这个转换层,该章也会讨论它带来的一些影响。

除非另有明确说明,本书中的所有示例应用程序都应能在x86和x64系统上同样顺利地构建和运行。在开发过程中,最好同时为x86和x64平台进行构建,并修复可能出现的任何问题。

在本书中,我们不会专门介绍ARM和ARM64相关内容。所有程序在这类系统上(ARM为32位,ARM64为64位)应该都能正常构建和运行,但我没有这类系统的使用权限,所以无法亲自验证。

最后,如果代码只需要编译为64位(或32位),在64位编译时会定义宏_WIN64。例如,我们可以修改HelloWin中的以下代码行:

printf("Processor Mask: 0x%p\n", (PVOID)si.dwActiveProcessorMask);
1

修改为:

#ifdef _WIN64
printf("Processor Mask: 0x%016llX\n", si.dwActiveProcessorMask);
#else
printf("Processor Mask: 0x%08X\n", si.dwActiveProcessorMask);
#endif
1
2
3
4
5

这样做比使用%p格式字符串更清晰,因为%p格式字符串在32位进程中自动按4字节处理,在64位进程中按8字节处理。由于dwActiveProcessorMask的类型是DWORD_PTR,使用%p时会强制转换为PVOID,还会产生警告。

这里更好的选择是指定%zu或%zX,它们用于格式化size_t类型的值,与DWORD_PTR等效。

# 编码规范

拥有统一的编码规范有助于保持代码的一致性和清晰度,当然,具体的规范会有所不同。本书使用以下编码规范:

  • Windows API函数使用双冒号前缀。例如:::CreateFile。
  • 类型名称采用帕斯卡命名法(首字母大写,每个单词的首字母也大写。例如:Book、SolidBrush。但与用户界面(UI)相关的类名以大写字母“C”开头,这是为了与Windows Template Library(WTL)保持一致。
  • C++类中的私有成员变量以下划线开头,采用驼峰命名法(首字母小写,后续单词首字母大写。例如:_size、_isRunning。但WTL类中的私有成员变量名以m_开头,这是为了与ATL/WTL风格保持一致。
  • 变量名不使用老式的匈牙利命名法。不过,偶尔也会有一些例外,比如句柄用h前缀,指针用p前缀。
  • 函数名遵循Windows API的规范,采用帕斯卡命名法。
  • 当需要使用常见的数据类型(如向量)时,除非有充分的理由使用其他类型,否则使用C++标准库中的类型。
  • 我们将使用微软发布的Windows Implementation Library(WIL),它通过Nuget包提供。这个库包含一些实用的类型,方便与Windows API协同工作。下一章将简要介绍WIL。
  • 部分示例包含用户界面。本书使用Windows Template Library(WTL)来简化与UI相关的代码。你当然也可以使用其他UI库,如Microsoft Foundation Classes(MFC)、Qt、直接使用Windows API,甚至是.NET库(如Windows Forms或Windows Presentation Foundation(WPF),前提是你知道如何从.NET中调用原生函数。UI并非本书的重点。如果你需要深入了解原生Windows UI开发,可以参考Charles Petzold所著的经典书籍《Programming Windows》第6版。

匈牙利命名法通过前缀让变量名暗示其类型。例如:szName、dwValue。虽然Windows API中的参数名和结构体成员大量使用这种命名法,但现在它已被视为过时的命名方式。

本书后续还会用到一些其他编码规范,在相关内容中会进行介绍。

# C++的使用

本书中的代码示例使用了一些C++特性。我们不会使用任何“复杂”的C++特性,主要使用那些能提高开发效率、有助于避免错误的特性。以下是我们将使用的主要C++特性:

  • nullptr关键字,用于表示真正的空指针。
  • auto关键字,在声明和初始化变量时允许进行类型推导。这有助于减少代码冗余,节省输入工作量,并让开发者专注于代码的关键部分。
  • new和delete运算符。
  • 作用域枚举(enum class)。
  • 包含成员变量和成员函数的类。
  • 在合适的场景下使用模板。
  • 构造函数和析构函数,尤其是用于构建RAII(Resource Acquisition Is Initialization,资源获取即初始化)类型的构造函数和析构函数。下一章将详细讨论RAII。

# 处理API错误

Windows API函数可能由于各种原因而执行失败。遗憾的是,不同函数表示成功或失败的方式并不统一。不过,情况大致可归纳为少数几种,简要介绍见表1 - 3。

函数返回类型 成功 失败 获取错误码的方式
BOOL 非FALSE(0) FALSE(0) 调用GetLastError
HANDLE 非NULL(0)且非INVALID_HANDLE_VALUE(-1) 0或 -1 调用GetLastError
void 通常不会失败 无 一般不需要,但在极少数情况下会抛出结构化异常处理(SEH)异常
LSTATUS或LONG ERROR_SUCCESS(0) 大于0 返回值即为错误码
HRESULT 大于或等于0,通常为S_OK(0) 负数 返回值即为错误码
其他 取决于函数 取决于函数 查看函数文档

最常见的情况是返回BOOL类型。BOOL类型与C++中的bool类型不同,实际上它是一个32位有符号整数。非零返回值表示成功,而返回零(FALSE)则意味着函数执行失败。需要注意的是,不要显式地与TRUE(1)进行比较,因为成功时返回的值有时可能不是1。如果函数执行失败,可以通过调用GetLastError获取实际的错误码,该函数负责存储当前线程上API函数调用产生的最后一个错误。换句话说,每个线程都有自己的最后错误值,这在像Windows这样的多线程环境中是合理的,因为多个线程可能同时调用API函数。

下面是一个处理这类错误的示例:

BOOL success = ::CallSomeAPIThatReturnsBOOL();
if (!success) {
    //  error  - handle  it  (just print  it  in  this  example)
    printf("Error: %d\n", ::GetLastError());
}
1
2
3
4
5

表1 - 3中的第二项是返回void的函数。实际上,这类函数很少见,并且大多数情况下不会失败。但遗憾的是,在极少数极端情况下(“极端”通常指内存资源极低),这类函数可能会失败,并抛出结构化异常处理(SEH)异常。我们将在第20章讨论SEH。你可能不必过于担心这类函数,因为如果其中一个函数失败,通常意味着整个进程甚至系统都陷入了严重问题。

接下来,有返回LSTATUS或LONG的函数,这两种类型都是32位有符号整数。使用这种返回机制的最常见API是我们将在第17章遇到的注册表函数。这些函数如果执行成功,会返回ERROR_SUCCESS(0)。否则,返回值就是错误码本身(无需调用GetLastError)。

表1 - 3中的下一项是HRESULT类型,它同样是一个32位有符号整数。这种返回类型在组件对象模型(COM,将在第18章讨论)函数中很常见。返回值为零或正数表示成功,负数表示错误,错误类型由返回值确定。在大多数情况下,使用SUCCEEDED或FAILED宏来检查函数执行是否成功,这两个宏分别返回true或false。在极少数情况下,代码需要查看实际的返回值。

Windows头文件中包含一个宏HRESULT_FROM_WIN32,用于将Win32错误(GetLastError返回的值)转换为合适的HRESULT。如果一个COM方法需要根据返回BOOL类型且执行失败的API返回错误信息,这个宏就很有用。

下面是一个处理基于HRESULT错误的示例:

IGlobalInterfaceTable* pGit;
HRESULT hr = ::CoCreateInstance(CLSID_StdGlobalInterfaceTable, nullptr, CLSCTX_ALL,
    IID_IGlobalInterfaceTable, (void**)&pGit);
if(FAILED(hr)) {
    printf("Error: %08X\n", hr);
}
else  {
    //  do  work
    pGit->Release();   // release  interface pointer
}
1
2
3
4
5
6
7
8
9
10

不用担心上述代码的细节,第21章专门介绍COM。

表1 - 3中的最后一项是“其他”函数。例如,我们在前面几节提到的FormatMessage函数,它返回一个DWORD类型的值,表示复制到提供的缓冲区中的字符数,如果函数执行失败则返回零。对于这类函数,没有固定的规则,最好的方法是查看函数文档。幸运的是,这类函数并不多。

# 定义自定义错误码

应用程序也可以使用GetLastError暴露的错误码机制,以类似的方式设置错误码。这可以通过调用SetLastError并传入要设置的错误码来实现,该错误码会设置在当前线程上。一个函数可以使用众多预定义的错误码之一,也可以定义自己的错误码。为了避免与系统定义的错误码冲突,应用程序定义的错误码应设置第29位。

下面是一个使用这种技术的函数示例:

#define MY_ERROR_1  ((1  <<  29)  | 1)
#define MY_ERROR_2  ((1  <<  29)  | 2)

BOOL SomeApi1(int32_t , int32_t*);
BOOL SomeApi2(int32_t , int32_t*);

bool  DoWork(int32_t  value, int32_t* result) {
    int32_t  result1;
    BOOL ok = ::SomeApi1(value, &result1);
    if  (!ok) {
        ::SetLastError(MY_ERROR_1);
        return  false;
    }

    int32_t  result2;
    ok = ::SomeApi2(value, &result2);
    if  (!ok) {
        ::SetLastError(MY_ERROR_2);
        return  false;
    }

    *result = result1 + result2;
    return  true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

注意,在我的函数中,我可以自由使用C++的bool类型,它只有true或false两种取值,而不是32位整数(BOOL)。自定义的错误码设置了第29位,确保不会与系统定义的错误码冲突。

# Windows版本

在某些情况下,我们希望查询系统,获取当前应用程序正在运行的Windows操作系统版本。Windows各版本的官方版本号如表1 - 4所示。

Windows发行版本名称 官方版本号
Windows NT 3.1 3.1
Windows NT 3.5 3.5
Windows NT 4.0 4
Windows 2000 5.0
Windows XP 5.1
Windows Server 2003 5.2
Windows Vista / Windows Server 2008 6.0
Windows 7 / Windows Server 2008 R2 6.1
Windows 8 / Windows Server 2012 6.2
Windows 8.1 / Windows Server 2012 R2 6.3
Windows 10 / Windows Server 2016 10.0

你可能想知道为什么版本号是这些值,我们稍后会解释。获取此信息的经典函数是GetVersionEx,其声明如下:

typedef  struct  _OSVERSIONINFO {
    DWORD dwOSVersionInfoSize;
    DWORD dwMajorVersion;
    DWORD dwMinorVersion;
    DWORD dwBuildNumber;
    DWORD dwPlatformId;
    TCHAR szCSDVersion[ 128 ];    //  Maintenance  string  for  PSS  usage
} OSVERSIONINFO, *POSVERSIONINFO, *LPOSVERSIONINFO;

BOOL GetVersionEx(
    _Inout_ POSVERSIONINFO pVersionInformation);
1
2
3
4
5
6
7
8
9
10
11

使用它相当简单:

OSVERSIONINFO vi = { sizeof(vi) };
::GetVersionEx(&vi);

printf("Version: %d.%d.%d\n",
    vi.dwMajorVersion, vi.dwMinorVersion, vi.dwBuildNumber);
1
2
3
4
5

然而,使用较新的软件开发工具包(SDK)编译这段代码时会出现编译错误:“error C4996: ‘GetVersionExW’: was declared deprecated”。

原因很快就会清楚。在包含<windows.h>之前添加以下定义,可以消除这个弃用警告:

#define  BUILD  WINDOWS #include  <Windows .h>
1

在Windows 8及更早版本上运行上述代码片段,可以正确返回Windows版本号。但是,在Windows 8.1或Windows 10(以及它们对应的服务器版本)上运行时,始终会显示以下输出:

Version: 6.2.9200
1

这是Windows 8的版本号。为什么会这样呢?这是微软在Windows Vista出现一些应用程序兼容性问题后采取的一种防御机制。由于Vista于2006年1月发布,距离Windows XP发布将近五年,许多应用程序是在XP时代开发的,有些应用程序会通过以下代码检查最低Windows版本是否为XP:

OSVERSIONINFO vi = { sizeof(vi) };
::GetVersionEx(&vi);

if(vi.dwMajorVersion >= 5 && vi.dwMinorVersion >= 1) {
    // XP or  later: good  to go?
}
1
2
3
4
5
6

这段代码存在缺陷,因为它没有预见到主版本号为6或更高且次版本号为零的可能性。因此,对于Vista系统,上述条件不成立,会通知用户 “请使用XP或更高版本”。正确的检查应该是这样的:

if(vi.dwMajorVersion > 5 || (vi.dwMajorVersion == 5 && vi.dwMinorVersion >= 1) {
	// XP或更高版本:可以正常使用!
}
1
2
3

遗憾的是,有太多应用程序存在这个缺陷,所以微软决定在Windows 7中不增加主版本号,只将次版本号增加到1,这样就解决了这个问题。那么Windows 8呢?微软仍然担心上述缺陷,所以也只增加了次版本号,变为6.2。Windows 8.1的情况类似(版本号为6.3)。但是Windows 10呢?它的版本号应该是6.4吗?这似乎是彻底的妥协——微软能把主版本号一直保持为6多久呢?实际上,Windows 10的版本号是10.0。这是否意味着一切都没问题了呢?并非如此。正如我们所见,即使在Windows 10系统上,调用 GetVersionEx 返回的也是Windows 8的版本号。这是怎么回事呢?

微软引入了一个新特性(称为 “Switchback”),它会将Windows版本号返回为不高于8(6.2),以避免兼容性问题,除非相关应用程序声明它知晓存在更高版本的Windows系统。这是通过一个清单文件(manifest file)来实现的,这是一个可选的XML文件,包含配置信息,可用于表明应用程序对从Vista到10的特定Windows版本的识别。

这不仅用于操作返回的版本号,还用于一些应用程序编程接口(API,Application Programming Interface )的行为改变,以实现兼容性。这是通过 “垫片”(Shims)来完成的,它会根据所选的操作系统版本改变API的行为。

在Visual Studio中,可以按照以下步骤添加清单文件:

  • 向项目中添加一个名为manifest.xml之类的XML文件,该文件将包含清单文件的内容。
  • 填写清单文件(在本列表之后展示)。
  • 打开 “项目”(Project)/“属性”(Properties),导航到 “清单工具”(Manifest Tool)节点下的 “输入和输出”(Input and Output)。在 “其他清单文件”(Additional Manifest Files)中,输入清单文件的名称(见图1 - 8)。
  • 正常生成项目。

img

图1-8:设置清单文件

注意图1 - 8中的 “嵌入清单”(Embed Manifest)设置为 “是”(Yes)。这会将清单作为资源嵌入到可执行文件中,而不是将其作为一个松散的文件留在与可执行文件相同的目录中,并且文件名总是{可执行文件名}.exe.manifest。

清单文件可以包含多个元素,但在本章中我们只关注其中一个(我们会在适当的时候研究其他元素)。如下所示:

<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
    <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
        <application>
        <!--  Windows  Vista  -->
        <!-- <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
        <!--  Windows  7  -->
        <!-- <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
        <!--  Windows 8  -->
        <!-- <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
        <!--  Windows 8 .1  -->
        <!-- <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
        <!--  Windows  10  -->
        <!-- <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
        </application>
    </compatibility>
</assembly>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

获得一个可调整的优质清单文件的最简单方法(可能有点讽刺)是创建一个简单的C#控制台应用程序,然后向项目中添加一个 “应用程序清单文件”(Application Manifest File)项,它会生成上述XML以及其他元素。

各个操作系统版本的全局唯一标识符(GUID,Globally Unique Identifier )是在这些版本发布时创建的。这意味着,例如,在Windows 7时代开发的应用程序不可能获取到Windows 10的版本号。

例如,如果你取消对Windows 8.1版本的注释并重新运行应用程序,输出将是:

Version: 6.3.9600
1

如果你取消对Windows 10的全局唯一标识符的注释(Windows 8.1的全局唯一标识符是否被注释掉并不重要),你将得到真实的Windows 10版本号(当然,前提是在Windows 10机器上运行):

Version: 10.0.18362
1

# 获取Windows版本号

鉴于 GetVersionEx 已被弃用(至少出于上一节讨论的原因),那么获取Windows版本号的正确方法是什么呢?现在有一组新的API可用,它们返回的不是简单的数字,而是针对Windows版本问题返回 “真”(true)或 “假”(false)。这些API在 <versionhelpers.h> 头文件中。

以下是其中包含的一些函数:IsWindowsXPOrGreater、IsWindowsXPSP3OrGreater、IsWindows7OrGreater、IsWindows8Point1OrGreater、IsWindows10OrGreater、IsWindowsServer。它们的用法很简单 —— 不接受任何参数,返回 TRUE 或 FALSE。它们的实现使用了另一个与版本相关的函数 VerifyVersionInfo:

BOOL VerifyVersionInfo(
_Inout_ POSVERSIONINFOEX pVersionInformation,
_In_    DWORD dwTypeMask,
_In_    DWORDLONG dwlConditionMask);
1
2
3
4

这个函数知道如何根据指定的条件(dwConditionMask)比较版本号,例如主版本号或次版本号。你可以在 versionhelper.h 中找到所有这些布尔函数的实现。

有一种未公开(但可靠)的方法可以在不调用 GetVersionEx 且不考虑清单文件的情况下获取版本号。它基于一个名为 KUSER_SHARED_DATA 的数据结构,该结构被映射到每个进程的相同虚拟地址(0x7FFE0000)。它的声明在这个微软链接中列出:https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/content/ntddk/ns-ntddk-kuser_shared_data (opens new window)。Windows版本号是这个共享结构的一部分,偏移量固定。以下是显示Windows版本号的一种替代方法:

auto sharedUserData = (BYTE*)0x7FFE0000;
printf("Version: %d.%d.%d\n",
*(ULONG*)(sharedUserData + 0x26c),  // 主版本号偏移量
*(ULONG*)(sharedUserData + 0x270),  // 次版本号偏移量
*(ULONG*)(sharedUserData + 0x260)); // 构建号偏移量(Windows 10) 
1
2
3
4
5

当然,建议使用官方API,而不是 KUSER_SHARED_DATA。

# 练习

  1. 编写一个控制台应用程序,通过调用以下API,输出比前面展示的 HelloWin 应用程序更多的系统信息:GetNativeSystemInfo、GetComputerName、GetWindowsDirectory、QueryPerformanceCounter、GetProductInfo、GetComputerObjectName。如果发生错误,进行错误处理。

# 总结

在本章中,我们从架构和编程两个方面探讨了Windows的基础知识。在下一章中,我们将深入研究内核对象(kernel objects)和句柄(handles),因为它们是Windows许多操作的基础。

Windows 10系统编程 引言
第2章:对象和句柄

← Windows 10系统编程 引言 第2章:对象和句柄→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式