[Windows翻译]剖析Windows Hello World程序

2,871 阅读13分钟

原文地址:pratikone.github.io/c++/2020/06…

原文作者:twitter.com/pratikone

2020年6月7日

自从引入Win32 api后,Windows编程就处于不断变化的状态--无论是移到.NET,还是再次移到WPF,引入Modern Apps后又改成UWP,现在终于是Project Reunion。这其中有一点是完全没有改变的,那就是如何在Windows中写一个hello world程序。自1995年Windows 95发布以来,它一直没有改变。但是,内部发生了很多变化。Windows为了确保简单的hello world在25年后和大规模的操作系统变化后还能继续工作,在下面做了很多工作。本篇博客试图深入到hello世界中去,用一段历史来展示windows hello world丰富的技术背景。

所有Windows版本的logo

为什么现代windows的api还叫Win32?

Win32 api是系统级的api,用于对Windows进行最底层的编程。当Windows 95过渡到32位系统时,他们希望有一种方法来区分这些新的32位api和现有的只有16位的Windows api。这就是这些新的api选择Win32这个名字的原因。Windows 95大受欢迎,很多开发者开始使用这些api来开发Windows。这些api越是流行,重命名就越是困难。如果把64位的Windows改成Win64,或者直接改成Windows Api,就会引起更多的混乱,也会修改文件,把32从名字中去掉。Win32这个名字被卡住了。现在每一个Windows api都是Win32,不管它是32位、64位还是未来的任何位数。

Hello World

自Win32的hello world代码诞生以来,除了那个臭名昭著的3页长的hello world程序因为对初学者来说太过吓人而被一个较短的版本所取代外,其他的代码基本没有变化。这个解剖学不是那个臭名昭著的代码,而是自Win95时代以来的继任者,它的尺寸相当大,而且本身就抓住了很多Windows功能。让我们先把这个hello world和它的控制台对应的代码进行比较。

控制台的hello world

#include <iostream>

int main()
{
    std::cout << "Hello World!\n";
}

这是很直接的。你有IO头,切入点是main(),它调用std命名空间中的cout来打印hello world。比较一下Win32的hello world。这段代码直接来自MSDN官方的Win32 hello world系列教程,未作改动。

Windows的hello world

#ifndef UNICODE
#define UNICODE
#endif

#include <windows.h>

LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)。

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR pCmdLine, int nCmdShow)
{
    
    const wchar_t CLASS_NAME[] = L "Sample Window Class";
    
    WNDCLASS wc = { };

    wc.lpfnWndProc = WindowProc.hInstance; wc.hInstance = hInstance; wc.lpfnWndProc = { }; wc.lpfnWndProc = WindowProc;
    wc.hInstance = hInstance。
    wc.lpszClassName = CLASS_NAME;

    RegisterClass(&wc);

    // 创建窗口。

    HWND hwnd = CreateWindowEx(
        0,//可选窗口样式。
        CLASS_NAME, // 窗口类别
        L "学习Windows编程", // 窗口文本
        WS_OVERLAPPEDWINDOW, // 窗口样式。

        // 尺寸和位置
        CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT。

        NULL, // 父窗口    
        NULL, //菜单
        hInstance, // Instance handle
        NULL // 附加应用数据
        );

    if(hwnd == NULL)
    {
        return 0。
    }

    ShowWindow(hwnd, nCmdShow)。

    // 运行消息循环。

    MSG msg = { };
    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg).DispatchMessage(&msg);。
        DispatchMessage(&msg);
    }

    return 0。
}

LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)。
{
    switch (uMsg)
    {
    case WM_DESTROY.PostQuitMessage(0);。
        PostQuitMessage(0);
        return 0。

    case WM_PAINT.PostQuitMessage(0); return 0; {
        {
            PAINTSTRUCT ps.HDC hdc = BeginPaint(hwnd, &ps);。
            HDC hdc = BeginPaint(hwnd, &ps);



            FillRect(hdc, &ps.rcPaint, (HBRUSH) (COLOR_WINDOW+1));

            EndPaint(hwnd, &ps);
        }
        return 0。

    }
    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

乍一看,这看起来大得多,而且不必要的复杂。但它展示了很多创建GUI窗口应用程序的功能。它启动了一个典型的矩形UI窗口,充满了背景颜色。它有一个功能性的用户界面,适当的键盘和鼠标支持,甚至事件处理。与其他平台的hello world程序不同,它在教程和第一个程序之外没有什么用处,这个hello world是任何Win32巨型代码库的基础。它教会了你在Windows上编程所需的所有基本东西。无论是Photoshop还是Windows的Firefox,它们的巨型代码库中都会有这段代码。 现在,它已经被淘汰了,让我们按照这段代码逐块进行学习。

剖析

#ifndef UNICODE
#define UNICODE
#endif   
#include <windows.h>

多年来,Windows的编程经历了很大的变化,比如Win95的api从16位到32位,XP的NT内核,以及后来Vista和Windows 8的变化。不管是1995年还是2008年开发的应用程序,只要调用windows.h,都可以在最新版本的Windows中继续工作(极好的向后兼容性)。Windows.h和Win32 apis做了很多繁重的工作,以确保所有这些应用程序保持兼容,即使它们都包含相同的头。例如,如果你正在开发一个新的应用程序,其中使用了一个遗留的组件(来自Windows 95时代,指的是那个时代的windows.h头文件),那么你的新组件和遗留组件有可能会使用相同的windows.h(来自最新的Windows SDK),但却可以使用适合时代的功能。

它是一个包含了大多数常见的Windows系统调用的头文件。Windows.h,本身就是一个小文件,为多个头文件进行了前向声明。对于这段hello world代码,apis在winuser.h中,使用user32.dll链接。对于任何其他功能,可能需要一些其他的头文件。Windows.h通过作为所有这些头文件的 “路由器”使其变得简单。你只需要包含windows.h头,你就可以得到所有这些功能。正如MSDN页面提到的,你可以通过定义一些全局标识符来仔细选择不同的或较小的功能子集,比如这里的UNICODE表示使用特定apis的unicode变体,用W表示。这里介绍了CreateFoo如何解释为CreateFooW

主函数

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR pCmdLine, int nCmdShow)
{
    
    const wchar_t CLASS_NAME[] = L "Sample Window Class";
    
    WNDCLASS wc = { };

    wc.lpfnWndProc = WindowProc.hInstance; wc.hInstance = hInstance; wc.lpfnWndProc = { }; wc.lpfnWndProc = WindowProc;
    wc.hInstance = hInstance。
    wc.lpszClassName = CLASS_NAME;

    RegisterClass(&wc);

    // 创建窗口。

    HWND hwnd = CreateWindowEx(
        0//可选窗口样式。
        CLASS_NAME, // 窗口类别
        L "学习Windows编程", // 窗口文本
        WS_OVERLAPPEDWINDOW, // 窗口样式。

        // 尺寸和位置
        CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT。

        NULL, // 父窗口    
        NULL, //菜单
        hInstance, // Instance handle
        NULL // 附加应用数据
        );

    if(hwnd == NULL)
    {
        return 0。
    }

    ShowWindow(hwnd, nCmdShow)。

    // 运行消息循环。

    MSG msg = { };
    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg).DispatchMessage(&msg);。
        DispatchMessage(&msg);
    }

    return 0。
}

它是程序的入口点。如果需要的话,Win32允许使用/entry linker选项来选择4个不同的入口点。一眼望去,函数中传递了很多0和NULL作为参数。这些函数中的许多自Win32 apis诞生以来就已经存在,并且在行为上发生了变化。因此,很多参数已经没有任何作用,为了兼容性而留下。这些参数总是NULL或0,对于其余的参数,标志可以与逻辑OR |相结合。

这段代码使用了一个现在已经过时的匈牙利符号来命名变量,变量的类型是在名字前加上的。变量bvalue表示它是一个类型为bool的变量。lpszClassName中的lpsz代表长指针(历史上是16位指针,但在现代是普通的32位/64位指针)到字符串(以/0结尾)。lpfn中的lpfnWndProc代表函数指针。微软建议不要在现代Windows编程中使用匈牙利符号,因为它增加的价值很小,却让代码更难读。Joel On Software也有一篇不错的文章。

另一个有趣的过去的产物是wParam和lParam参数。在Win95之前,Windows是16位的操作系统,wParam是WORD param,是16位的,lparam是LONG param,是32位的。Win95之后,Windows进入了32位时代,WORD和LONG现在都是32位的(或基于arch的64位),所以wParam和lParam之间没有区别。 HINSTANCE是另一个曾经的例子--一个实例的句柄。Raymond Chen的这篇博文很好地解释了背后的原因。

Windows窗口--Hwnd(读作H-wind)

什么是Hwnd?

窗口界面的图片

很明显,窗口是Windows的核心。它们是如此重要,以至于他们用它们来命名操作系统。” - MSDN官方引语

窗口是屏幕上的一个矩形区域,它接受用户的输入,并以文本和图形的形式显示输出。在Win32编程惯例中,一个窗口用HWND--窗口的句柄来引用。句柄是一个与该窗口相关联的数值。在内核中,每一个窗口都会被创建一个具有唯一id的对象。如果一个窗口是一个人,hwnd就是它的名字。

《房间》电影的oh hi hwnd的表情包

一切都是HWND

按照电影《窃听风云》的精神,典型的hwnd中的每一个UI元素本身就是一个hwnd(想想递归)。这是用子窗口/自有窗口实现的。这就导致了在一个中等复杂的应用程序中会有很多hwnd。

hwnd的父子关系和子hwnds的关系

现代Windows HWNDs是硬件加速的,即利用图形管道(如Direct2D),使用GPU更快地绘制像素。在现代Windows中,桌面窗口管理器(DWM)处理绘制像素。

窗口注册和窗口消息系统是一些让人想起80年代面向对象(OO)设计的代码。微软很早就搭上了OO的列车,即使它还没有被业界完全接受。那时候微软一直只用C语言来编程Windows。C语言的OO需要在Windows中选择很多奇怪的设计,由于向后兼容,很多设计一直保留到今天。

向操作系统注册窗口类

    // 注册窗口类。
    const wchar_t CLASS_NAME[] = L "Sample Window Class";
    
    WNDCLASS wc = { };

    wc.lpfnWndProc = WindowProc.hInstance; wc.hInstance = hInstance; wc.lpfnWndProc = { }; wc.lpfnWndProc = WindowProc;
    wc.hInstance = hInstance。
    wc.lpszClassName = CLASS_NAME;

    RegisterClass(&wc).wc.lpfnWndProc = WindowProc; wc.lpfnWndProc = hInstance; wc.lpszClassName = CLASS_NAME; RegisterClass(&wc);

Windows有一种奇怪的继承方式。你必须向OS注册你新创建的hwnd对象,它才能开始与之通信。

创建窗口

    // 创建窗口。
    HWND hwnd = CreateWindowEx(
        0//可选窗口样式。
        CLASS_NAME, // 窗口类别
        L "学习Windows编程", // 窗口文本
        WS_OVERLAPPEDWINDOW, // 窗口样式。

        // 尺寸和位置
        CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT,CW_USEDEFAULT。

        NULL, // 父窗口    
        NULL, //菜单
        hInstance, // Instance handle
        NULL // 附加应用数据
        );

    if (hwnd == NULL)
    {
        return 0。
    }

这段代码创建了HWND。我们提供了与这个HWND相关联的窗口类(我们刚刚注册)。窗口创建api是非常丰富的,可以创建100多个具有不同配置和行为的窗口。一个很好的例子是创建子窗口。因为所有的东西都是一个窗口。一个UI窗口中的按钮就是这个hwnd的子窗口。这意味着父窗口可以处理该子窗口的消息处理。这种层次结构是非常有用的。它是OO范式中继承概念的一种实现。通过这种方式,您可以添加一个新窗口作为父窗口的子窗口,并且您不必为其基本操作(如调整大小、最大化、关闭等)编写任何额外的代码。

显示窗口

ShowWindow(hwnd, nCmdShow)。

不出所料,它在屏幕上显示了窗口。显式调用show window有什么用?有很多情况下,一个窗口是不能直接显示的。它可以用上面的命令创建,然后用UpdateWindow更新,当准备好后,显示在屏幕上。也有AnimateWindow做90年代的PPT幻灯片一样的过渡动画,在现代社会,没有人应该使用。

Windows消息系统

Windows消息系统是一个利用多态性实现的事件驱动系统。操作系统通过传递消息与程序进行交流,它会在你的程序中调用一个特殊的函数,让你可以选择处理这些消息。这些消息可以是与程序交互时产生的键盘、鼠标、触摸事件,也可以是操作系统创建的事件,比如当你的程序最小化、最大化或关闭时。对于这种消息传递模型,Windows为一个线程创建了一个单一的消息队列,处理该线程上创建的所有HWND的消息。

消息循环

    // 运行消息循环。

    MSG msg = { };
    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg).DispatchMessage(&msg);。
        DispatchMessage(&msg);
    }

消息循环代码是负责将这个消息队列归档的。每个线程只能有一个消息循环。这个消息队列是隐藏的,你的代码无法访问。它完全由操作系统处理。你的代码可以做的就是使用GetMessage()api调用从这个队列中移除最上面的消息。然后,这条消息会被翻译成键盘输入,这样它就可以处理快捷键和进行其他键盘输入处理(在这里阅读更多关于TranslateMessage的内容)。之后,它被派发到处理函数WndProc(下面讨论)。GetMessage是一个阻塞函数,所以如果循环为空,它将会等待。但这并不意味着你的UI将是无响应的。一个替代的方法是PeekMessage函数,它可以偷看并判断队列顶部是否有消息。因为它不会阻塞,所以在 "Get "之前做一个 "Peek "对某些场景是有好处的。

WndProc--你的代码中最重要的函数。

LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)。
{
    switch (uMsg)
    {
    case WM_DESTROY.PostQuitMessage(0);。
        PostQuitMessage(0);
        return 0。

    case WM_PAINT.PostQuitMessage(0); return 0; {
        {
            PAINTSTRUCT ps.HDC hdc = BeginPaint(hwnd, &ps);。
            HDC hdc = BeginPaint(hwnd, &ps);



            FillRect(hdc, &ps.rcPaint, (HBRUSH) (COLOR_WINDOW+1));

            EndPaint(hwnd, &ps);
        }
        return 0。

    }
    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

WndProc是每个Win32程序代码中必须直接或间接存在的特殊函数。每当Windows操作系统需要与你的运行代码进行任何通信时,它都会调用这个函数。它通常有一个巨大的开关语句来处理窗口消息,比如WM_DESTROY(当用户点击窗口右上角的小x时该怎么做)。它可以选择忽略它,它不会关闭窗口。值得庆幸的是,还有其他方法可以关闭窗口。这显示了Windows操作系统为开发者提供的控制和灵活性水平,这可能是有益的,但也可能被滥用,最近的Windows编程模型已经发展到了应对这个问题的程度。PostQuitMessage将WM_QUIT消息添加到消息队列中,这将导致GetMessage()为false,退出循环并退出程序。你的程序不一定要处理所有的消息。它可以处理一些感兴趣的特殊消息,然后调用DefWindowProc——OS提供的默认处理程序来处理其余的消息。WndProc可以选择处理一个线程中所有窗口的消息,也可以把它们交给各自的WndProc处理。请参阅子类作为动态多态的例子来实现这一点。

绘制窗口

case WM_PAINT内的代码是在窗口中绘制任何东西的模板代码。Windows图形驱动接口(GDI)是即时模式(链接到它)。很多新的UI库,比如WPF和WinUI,因为内存和性能的原因,都是保留模式的GUI框架。MSDN的Painting the Window - Win32 apps对这个代码做了很好的解释。

编译

MSVC是首选的编译器。它可以用Visual Studio编译,也可以在终端使用msbuild编译。它需要kernel32.dll、user32.dll、gdi32.dll和/SUBSYSTEM:WINDOWS。是的,从Windows NT开始,子系统的概念就已经存在了(因为Windows本来应该以子系统的形式运行OS/2,但没有实现)。这对30多年后Windows推出Windows Subsystem for Linux(WSL)很有帮助。

结束语

谢谢你能走到这一步。这个帖子的想法是在我开始学习Win32 apis的时候产生的。MSDN在解释api方面做得很好,但除此之外,很少有文章和博客存在。这篇文章的目的就是为了改善这一点。 如果你发现博客上有什么错误,或者有什么其他建议,请在twitter上告诉我。

参考文献


通过www.DeepL.com/Translator(免费版)翻译