我们在调试错误的时候,要明白一点,每一个.cpp是单独编译的,要记得这一点,这一点非常关键,也就是说它跟其他.cpp文件之间是不会有关联的,你排查错误的时候,它在一个.cpp错误之后,它只可能跟它的头文件预处理这部分有关,不会跟其他部分有关联,它是单个编译的,比方说你在里面用了另外一个程序中某个函数的实现,你只是把它的头文件引过来了,但是它的定义没引过来,它在定义当中的错误,不会影响你这一个.cpp的编译过程。
1、在实际的项目当中我们用次新版本的vs,因为你在实际开发的项目当中会引用第三方库,第三方库一般不会支持到最新版本,一般会支持次新版本或更早的版本,最新版本一般都会有一些BUG,有很多坑等你填,会有很多潜在问题的;
2、当我们做项目提交,上传用vs写的涉及到项目配置和源码,我们创建一个默认配置的win32应用程序(非空项目):
我们可以看到有项目文件,有源码文件,有资源文件:
只需要上传项目文件当中的.vcxproj、.cpp、.c和.h即可;
3、.vcxproj是用xml写的,是项目文件,包含了项目配置,包含了哪些源码,用到了哪些图标、资源文件,.sln是解决方案;
4、.vcxproj.filters是vs左边栏解决方案中的目录结构,例如外部依赖项、头文件、源文件、资源文件,它其实定义了一个目录,你如果把.vcxproj.filters这个文件删掉的话,就会造成vs左边的这个目录就没有了;
5、.vcxproj.user这个文件是不需要提交的,这是我们的用户配置,主要配我们的调试路径,因为每个人的调试路径可能会不一样,但是我们在实际开发中呢我们都用相对路径,可能都是一致的;这是调试路径,你删掉也不要紧;
6、.sln这是一个解决方案,相当于linux的cmake、maikefile,一个解决方案里面可以包含多个项目.vcxproj,而且多个项目之间可以设置它的依赖关系,比如说一个执行程序依赖于某一个库;鼠标右击解决方案,属性,项目依赖项,进行设置即可;
我们可以在解决方案里新建一个静态库ConsoleApplication1,并设置我们可执行程序依赖于该库:
一个解决方案中可以添加多个项目。
7、.vs、ipch这几个文件夹都是编译的缓冲文件,因为它把一些编译调试信息放在里面,有时候你编译出错的时候,怎么清理都没用的时候,你就试着手动把这几个缓冲文件删掉,你再重新编译可能就又好了;
8、.rc是资源文件,也是一个文本文件,里面定义了窗口、图标等一些资源配置。
9、解决方案的属性,需要关注的有项目依赖项,还有配置属性中的配置,如果右栏的平台里面没有x64,你可以在配置管理器当中,活动解决方案平台,新建,把x64给加进来;你编译win32还是x64,是在解决方案的属性当中配置的;
10、项目的属性,我们常用的有,配置属性的常规里面,输出目录你要设置到一个相对路径,例如…\bin,最终你输出的exe和它的调试文件都在这个目录里面;
平台工具集,你如果要设置xp平台的话需要设置一下;
配置类型中你可以直接配置成动态库、应用程序、静态库等;
MFC,我们就使用标准的Windows库就行了,因为系统里面都自带了MFC的dll;
字符集涉及到我们所有调用的win32的API,一般win32的API都提供了两个版本,函数名只是一个宏,经过我们测试,vs使用的是多字节字符集;你的源码默认还是基于多字节的,在写程序的时候你会发现有的函数参数需要加一个TEXT或者_L,每次在代码当中传的字符串都要转;
11、项目的配置属性中的调试,命令参数里面的可以传给main或者WinMain;
工作目录是指你在点了调试按钮之后,你的程序放在哪个路径下执行,它默认的是项目路径ProjectDir,项目路径就是我们.vcxproj这些文件所在的路径,一般我们会把项目路径设置成我们的…\bin;
12、项目的配置属性中的C/C++,我们一定要配置的附加包含目录,也就是说你如果要用到一些第三方的库,它肯定有一个头文件,这里就指定这个头文件比方说…\include下面,这样的话include的时候就会在这个目录下面找,这是我们头文件的路径;
预处理器中的预处理器定义,什么叫预处理,其实就是我们的宏,你可以预先设定一些宏,这样你就可以在代码当中做一些判断,比如Debug版本下的_DEBUG宏和Release版本下的NDEBUG宏,这样就会很方便的调试;
代码生成中有个运行库,我们在开发的时候需要根据实际运行情况进行设置;
预编译头,大部分情况下我们是给它关掉的,但是预编译头会加快你的编译速度,但它会带来很多的编译问题,所以说我们不愿意解决这些问题的时候就把它关掉了;
高级里面的调用约定,默认我们所有的函数是__cdecl这种格式,当我们调用win32函数的时候需要在函数前面加上STDCALL,所以我们一般常用的方式还是__cdecl,我们不去改它;
13、项目的配置属性中的链接器,输出文件这里我们一般会加一个_d,表示Debug版本,当我们Debug版本和Release版本都在一个目录下的时候,你这里加一个_d就可以方便区分,例如(OutDir)(TargetName)_d(TargetExt);
附加库目录,这里要指定…\lib,你要把你引用的库的所在路径给它加进来;
输入中的附加依赖项,就是指定要你用到的动态链接库,它默认已经把win32的核心库都加进来了,并且这些库你无法改;
开发当中有时候经常会涉及到一些库的冲突,所以就可以忽略有些库,比如附加依赖项中的某些库,我们就可以在忽略默认库这里设置这些库;
高级这里如果你是编译的动态链接库,它有一个lib文件的导出,你可以在导入库这里指定,这个地方比较反人性,有时候很难找到这个设置,所以要注意;
调试,生成程序数据库文件里面的OutDir要与我们的调试路径设成一致,这样它在调试的时候就能找到这个pdb文件;
系统,其中的子系统里面的设置我们主要讲两种,一种是控制台,一种是窗口,你这个设置完之后呢,那它对入口函数的要求就不一样了,子系统的设置会影响到你程序的入口函数,窗口程序的入口函数要求是WinMain,而如果我们把子系统设置成控制台,你再编译下看看,它会报链接错误LNK201,它会说找不到main函数,因为它的入口函数会变成main函数。
我们双击打开notepad.exe这个程序,打开两次该程序,可以从任务管理器看到这两个进程就脱离了程序,而成为了一个所谓的“执行过程”的概念:
这两个进程所对应的都是一个记事本程序,然而这两个进程都有自己独立的内容,从上图可以看到它们各自在接收各自的数据,这两个进程所属的数据是互不相关的;
我们如果把一个程序加载到内存当中,得到了操作系统的一次调度并进行推进,那么就会形成了一次进程,同时,在这一次程序被加载之后,和这个程序相关联的一些数据又形成了这一个进程所私有的数据,那么这两个进程就出现了,而且这两个进程的数据互不干扰、互相隔离、互相私有。
当我们把system32目录下的notepad.exe这个可执行程序拉起来,放到内存当中的时候,我们windows操作系统就会得到了和notepad.exe相关的一个名为记事本的进程,那么我们把这样一个notepad.exe程序加载到内存当中并执行完毕的全过程,称之为一个进程。
我们通过自己写的一个程序,把记事本程序拉起来,形成一个进程。
我们发现拉起来的这两个进程的全局变量地址是一样的,那说明这两个变量都被映射到同一个地址值上去了,作为同一块物理内存这显然是不合理的,我们知道当一个可执行程序作为一个进程被拉起来的时候,它得到的地址是所谓的线性地址,也就是我们讲的叫进程地址空间的概念,它被映射到了一个从0到4GB、从低到高排列的线性地址空间当中,而获得的一个叫做逻辑地址值的地方,这就是我们讲的叫进程地址空间。
在咱们的windows当中,我们会把这一个个的地址空间按照4K一个页面进行切分(上图小方框),我们把4K这样的一个页面,通过MMU地址转换单元映射到不同的物理地址当中,由CPU加载内存进行推进,从而完成了程序的运行;
由此我们可以知道,当我们的应用程序被拉起来了以后形成进程,它得到了独立的进程地址空间,它们的进程地址空间都是一个线性的从0到4G的空间,这样做的好处是当我们程序运行的时候,并不需要知道我们程序的实际地址是什么,我们只需要按照线性地址编排就可以了,而实际运行当中的时候,我们通过地址转换单元把这些逻辑上重叠的空间映射到不同的物理地址当中加以推进,这样就会使得我们程序的运行和我们操作系统进行了解耦合,大大提高了程序运行的灵活性、并发度和可控性。
大家可以看到,关闭了新创建进程的内核句柄后,仍然能打印出进程ID,说明我们关闭内核对象句柄,是为了防止资源泄漏,并不会直接关闭该内核对象,所创建出来的用户态进程和进程内核对象是两个不同的概念。
当我们一个应用程序被拉起来放到进程地址空间执行的时候,它被分为了用户空间和内核空间的概念,也就是说它所获得的所有信息都是操作系统内核映射给它的一个副本或者是一个引用,因此它所拿到的东西都是由操作系统内核传递给它的;
如果假定我们在8点钟在用户空间获得了一次当前进程的信息,当操作系统内核把这个信息返回给我们的时候,它获得的信息是8点钟当时所拿到的这样的一个所有进程的列表,那么此时当我们刚刚把进程获取完毕的时候,又来了一个新进程添加进来,那么这个时候拿到的进程信息是没有办法实时传递到用户空间的,因此从我们用户态拿到的只能是一个系统进程的快照。
CreateToolHelp32Snapshot函数的第1个参数dwFlags的相关取值为:
我们如果想要获取这个进程列表里面的内容,我们需要使用的结构体叫PROCESSENTRY32,根据我们前面的编程经验我们大概知道了,由于我们所有的这些信息都是操作系统内核给我们的,我们先声明一个变量,这个变量用来接收从内核态返回给我们的副本,所以我们首先要申请这样一个变量存在这里,这样的变量声明完毕以后,我们还要对这样的变量大小进行设置,准备好预接我们操作系统传过来的副本。
我们所谓的进程终止自己的运行,其实就是进程申请操作系统停止对自己的调度(从进程队列里面把该进程撤销)。
这里要提醒大家注意,ExitProcess是一个C语言风格的win32api,它不会处理C++运行时相应的概念,这也就意味着如果我们显式的调用ExitProcess就会造成操作系统无法回收全局或者是静态的对象,从而造成资源的泄漏,这样的问题是在我们实际工程开发当中必须避免的。
修改代码调用ExitProcess函数:
我们可以看到析构函数没有被调用,这也就意味着全局对象并没有被销毁,造成资源泄漏。
我们打开一个notepad作为测试终止的进程,打开任务管理器查看它的PID:
为了把目标Testor.exe程序放到我们MemChange程序的Debug目录底下,我们先生成一下我们MemChange程序以生成Debug目录:
如果出现上述使用了不安全函数的警告提示的话,我们只要定义一个宏就可以了,记得一定要放在头文件之前:
#define _CRT_SECURE_NO_WARNINGS
我们发现并没有修改成功,这是什么问题呢?
这是特定win10系统的版本以及vs编译器的版本会出现的问题,我们MemChange不需要修改,只需要修改Testor让它在win7到win10下运行正常。
实现方案有多种,我们可以创建一个全局命名的互斥变量来实现:
如果其中一个程序创建了这个命名的互斥量,另外一个创建就会失败,从而达到只让一个程序运行的效果。
在我们开发过程中,控制台的提示信息、调试信息非常有帮助,因为很方便我们把提示信息打印出来,但是在实际交付给客户的时候呢,你是不能包含控制台的,如何在开发的过程中快速的切换有控制台、无控制台呢,我们有几种方案,一个方案是你建两个项目方案,一个项目是进行调试用的Debug版本,还有一个是给客户用的Release版本;
在我们实际开发工程中需要反复切换的方案,我们有3种方案:
我们通过预处理指令,在链接的时候让子系统变成一个windows窗口,而不是控制台;还有该程序如果被设置从windows窗口程序的话,它的入口就变成winMain函数了,我们还想让它用这个main函数,所以把入口指定成mainCRTStartup;
经过测试不再显示控制台了,但是这样测不出效果,所以我们用MessageBox函数弹出一个窗口:
如果我们把该预处理指令注释掉,控制台窗口也会出现:
这种方案有一个缺点,就是每次都要如上修改代码才可以实现隐藏还是显示。
通过同一套代码来实现既能显示控制台,也能隐藏控制台(自己实现一个入口函数):
通过设置项目属性子系统,我们就可以使用同一套代码,实现了既有控制台,又没有控制台。
能够通过项目配置项来确定它显示不显示控制台,QT就是这么做的。
另一个方案(如果你不建立XMain这个统一入口函数,你该怎么做呢?):
只要在项目设置改一下,你就既能隐藏控制台,也能显示控制台。
进程的推动,它主要是通过线程来完成的;主线程实际上就是一个主函数;
我们可以把每一个函数看成是线程的一个载体,我们所谓的线程就是用来推进CPU执行的,CPU执行的是什么,就是指令序列,而指令序列就是一行一行的代码,我们通过编写函数就可以推进线程,我们把一个函数映射成一个线程从而完成CPU的推进,完成程序执行的指令序列。
我们为什么会进一步梳理win32 API呢,我们windows应用程序开发的根本,大多数的win32 API集中在上述3个DLL中,这些DLL当中封装了大量的函数,这些函数是我们使用windows开发的一个核心根本所在,我们学windows操作系统之上的开发,我们就要熟悉大量的API,我们知道的API越多,我们的能力越强,我们的手段就越丰富;
我们一方面要熟悉这些API函数来大大丰富我们程序设计的能力,另一方面呢结合我们线程可以把API函数理解的更加深刻一点;
在工程应用当中我们就可以简单明了的认为,一个函数就可以称为一个线程,一个线程就可以被CPU进行一次分发,这一次分发的过程全部都是由操作系统内核来调度的,我们就可以使用这样的技术来帮助我们尽好尽快的刻画出我们程序,我们可以把相关的逻辑封装在某一个函数之上,把这个函数做成一个线程,把这一个线程的概念交给操作系统来分发,那么操作系统就可以加快我们程序推进的速度,让它来更多的响应我们的CPU,来推进我们的执行序列。
我们认为,线程是windows程序执行的一个核心单元,可以这么讲,我们建立好了进程以后,我们就是把应用程序拉起来放到了内存当中,供我们操作系统来调度,如果这个进程想要被推进,那么它必然会走入它的主函数,也就是我们讲的主线程;同样的,在我们本身人为的业务逻辑当中,我们可能认为主线程不能够满足我们程序开发的需要,我们还会设计辅助线程。
所以我们讲,在我们的程序当中,如果我们想更好的推进我们的程序,让CPU能尽快的推进我们的函数,尽可能多的执行我们的指令序列,我们就要用多线程来帮助我们推进。
所谓线程函数就是指我们需要把这一个指令序列做成一个单元,供操作系统调度来推进,那么我们调度的单元就是一个函数,也就是说我们是以函数作为载体,变成线程的概念,供操作系统推进来分发的。
这个立即开始运行,并不是说它能够立刻抢占CPU资源,而是把它投入到了一个就绪队列,供操作系统调度,如果操作系统轮到当前时间片的时候,它才会执行,它并没有立即抢占CPU,这是需要特别强调的。
线程立即执行就是告诉你,我们将把这个线程创建完毕了以后,会把这个线程推进到线程的就绪队列,告诉操作系统我已经准备好了,请你调度我来执行,我随时可以运行,而并不是说我的代码段即将获得CPU的使用权;
请大家一定要在这里树立起这样的概念,我们只能申请操作系统,由操作系统来调度,而没有办法直接去申请硬件资源。
所谓的线程立即执行,并不是意味着ThreadProc这样一个线程可以获得CPU,而是告诉你我所要获得的ThreadProce这样一个指令序列已经进入就绪队列,请求操作系统调度,而主线程中的printf这一句表明主线程在做完17行这条创建新线程的语句之后,它紧接着执行了25行这条语句,此时在它把主线程执行了25行指令以后,才执行了新线程ThreadProc的运行;
可以认为主线程的时间片到了,才运行新的辅助线程ThreadProc;
这是我们反复强调的概念,线程立即执行,仅仅是进入线程就绪队列,而不是立刻获得CPU。
什么叫线程的上下文呢?
简单的来讲,我们结合一个线程的运行模型,假定我们有一个线程A在运行,运行到一半的时候它的时间片正好到了,那么操作系统就会为第2个线程进行运行,那么对于上一个线程运行到哪一个语句、语句运行到什么状态,数据又到什么情况,操作系统必须要对当前的线程A的运行状态,进行一个现场暂存,这个暂存的就是线程的上下文;
当上面的其他线程运行完,又轮到当前这个线程A来运行的时候,操作系统必须要恢复现场,操作系统在恢复现场的时候呢它主要就是恢复这些上下文。
这个受信是什么呢?
我们线程主要是用来交互的,尤其是我们希望可以有多个CPU的执行的序列来帮我们进行运行;
打一个比方,这个就有点类似于我们在食堂里打饭,如果我们只开一个窗口的话,那么这个等待打饭的人就会很长,那么我们开多个线程就相当于我们开多个窗口,来加快饭食的分发速度,那么这当中还会有这样的问题,比如说在第一个窗口当中的米饭少了,而第二个窗口当中的菜多了、米饭还剩不少,那么第一个窗口和第二个窗口就会有一些交互,那么这个交互就会形成线程和线程之间进行是否通信,所以这个受信就是说,是否让第一个线程来启动,是否让线程来唤起,我们也会通过受信这个状态来进行使用,这个主要是用线程交互来用的。
在windows规约当中,我们认为线程就是堆栈加寄存器加函数,每一个线程都有自己的一组CPU寄存器,它用来维护这一个线程的现场运行情况。
大家要注意的是,2、3、4这3种函数都是win32的API函数,也就是说都是C风格的函数,不会调用C++运行时,会引起没有办法析构全局对象这样一个问题,会造成潜在的资源泄漏。
我们可以ctrl+A全选源代码,ctrl+k ctrl+c把代码全都注释了,然后添加新的源文件:
始终不出现我被析构,这说明我们的析构函数没有被调用,使用C风格的win32 API函数会出现这样的问题,它不会调用C++运行时的,会引起资源泄漏。
因为我们是先把这个线程创建起来,并不让它投入运行,不让它接收操作系统的调度,此时我们才会有机会去修改一下线程的优先级。
我们创建的这个Normal优先级的线程,立即被放到就绪队列,并且优先级也比Idle高,我们想它肯定会被调度。
我们发现,优先级高的确实首先被调度,优先级低的稍后被调度。
那么我们接下来看看是不是优先级高的就一定会被先调度呢?
根据我们前面讲过的,如果在我们的这个主线程创建了这两个辅助线程之后,我们主线程应该去管理一下这两个线程是否执行完毕,我们最常见的就是说,如果我们这两个线程都完毕了以后,应该通知一下主线程,那么按照windows的设计,我们主线程需要去等待辅助线程完成,我们windows提供的方法就叫“受信”,我们等待这两个线程运行完毕以后把信息给我们。
一个奇怪的现象出现了,好像Idle线程被提前运行了,表明并不是优先级高的会先运行,这个现象并不是偶然的,这个究竟是为什么呢?
我们并不能够仅仅看表象就认为优先级高的就一定先被运行,对于这个问题我们一定要深刻理解,我们要透过现象看本质。
尽可能多的使用CPU(充分利用CPU硬件资源),短作业优先、平均等待时间最少,要避免公平性的丧失(不能让最先进就绪队列的耗时长的线程一直等待),还有,这种现象在多核的环境下也很常见。
所以,我们不能想当然的认为,高优先级的线程会先于低优先级的线程运行,这个优先级只是一个相对的概念。
接下来的问题就是,怎么让主线程通知子线程开始工作呢?
这就是主线程和子线程的交互问题,因为我们说过了,主线程和辅助线程它们共享同一个进程空间,很显然全局变量是它们都有的,我们再定义一个全局变量bFlag=FLASE;
当bFlag为TRUE的时候,这个while循环才会运行,那让我们想想看,当我们主线程不推进到第15行代码,这个辅助线程即使时间片到了,这个25行代码也不会推进,因为这个bFlag为FALSE,这个就是我们主线程和辅助线程最常用的一种通信手段;
由于主线程和辅助线程都在同一个进程空间里面,所以它们都能看到这样一个公共的全局变量。
当主线程得到运行机会,它就会修改这个bFlag,这个bFlag一旦为FALSE了以后,这个ThreadFunc中的g_Cnt++就会停止运行,因为主线程把全局的这个bFlag进行修改了。
为了充分说明这个问题,画个示意图帮大家解释:
在咱们线程编程当中请大家时刻要注意,我们之所以要有所谓的同步互斥的概念,是因为我们所有的计算机指令都是乱序推进的,我们在线程开发中,必须人为在逻辑上严格限定程序指令序列的推进。
生产者消费者问题:
FUNC1和FUNC2是相同的两个函数,这就相当于两个线程正在并发,只要是在多线程当中,如果我们不加以规约,是没有办法避免这两个线程发生的冲突的,因为当我们把FUNC1作为完整的序列推进给CPU的时候,我们并不知道CPU什么时候会被打断,多个线程的指令执行顺序是不可控的,所以我们说计算机指令是乱序推进的。
这3条汇编语句会被乱序推进的。
只要我们的指令序列能够被任意的打断,那么g_Cnt的运行结果是无法预料的;
如果我们想要确保g_Cnt的值是准确的,我们有且只有一个方法,就是请让操作系统告诉我们,这一个线程当中的指令序列是不能够被打断的,如果被打断,就会造成运行结果不可预料。
根据前面的介绍,我们就知道了在我们开发过程当中,我们线程的运行它都是乱序推进的,如果我们不加约束,操作系统是乱序推进我们的指令序列,我们的线程函数体并不是按照我们想象的一个一个语句有序执行的,而是随时会被打断;
那么为了解决这样的问题,操作系统提供了两种方法来帮我们保证我们线程的运行时可靠的:
所谓的临界资源、临界变量,就是在我们的运行当中,我们这个全局变量由于是对全进程空间可见的,任何一个线程都有可能访问它,而线程的推进又是不受我们人为意志而转移的,因此这样一个可能会被修改的变量,我们称之为临界变量。
临界区最直中要害的理解,就是和临界变量相关的代码段,我们对这些代码段进行控制,就控制了这些临界变量的访问过程,只要我们把这个访问过程按照我们想要的人为规定的语句,开始逐一有序的执行,就能确保这个执行过程不被打断,从而避免那些与时间有关的错误。
修改03CountErr程序,ctrl+a全选,ctrl+k ctrl+c 全部注释,ctrl+a全选,复制后,新建源文件,粘贴,ctrl+a ctrl+k ctrl+u取消注释:
说明我们的运行结果和我们预期是一样的。
互锁函数提供了一个共享变量的更加简单的机制,我们来看下是如何使用的:
回顾一下我们前面讲的机场余票的例子,请问g_Cng1_1和g_Cng_2运行出来结果的值相同么?
我们看到,g1和g2的值并不相等;
根据我们前面所学的知识,我们加两个临界区:
我们看到这两个数值明显相同,说明加了锁以后,虽然保证了这两个运行结果的一致,但会使我们程序的推进效率下降,这就是锁的代价,虽然锁保证了逻辑的严密、结果的准确,但是必然会降低并发性。
这个值明显比前面临界区的值有所改进,说明这种互锁函数的量级更轻一些,它的效率更好一点。
比方说主线程和子线程之间的通信:一个进程当中有一个主线程和多个子线程来共同完成一个工作,其中某个子线程完成自己相应的工作后,需要向主线程汇报,这个必然就产生了线程和线程之间的通信;
这种通信的机制,在windows当中它使用了一种方法,就叫做事件内核对象。
我们需要一种机制,来让A打印完了B打,B打印完了A打,实现这种A和B的交互打印。
同步,它一定是有对象的,A和B之间的交互,可以通过模拟一种交通信号灯的方式来完成。
我们说一个工具解决一个问题,南北向的通行由南北向的灯来解决,东西向的通行由东西向的等来解决;南北向红灯的时候东西向是绿灯,东西向看到绿灯的时候车辆就可以正常的通行,这个实际上暗含有一个问题,南北向和东西向有一个协调问题:
当南北向为红灯的时候,东西向为绿灯,反之亦然。
这个就是告诉我们了,在这样的过程当中,除了我们要是互斥的去判断车辆的这样一个运行,同时还有一个相互协调的问题,你是红灯的时候我是绿灯,我是红灯的时候你是绿灯,那这样的交互我们就必须有一种新的机制来解决,我们前面光用的临界区不足以解决这样的问题,windows操作系统提供的方法就叫事件内核对象。
bManualReset参数是说是否需要人工重置,如果设置为FALSE,就是说这个事件对象是通过windows操作系统来设置的;
bInitialState参数是说在主线程创建这个事件对象EW(东西向)的时候,让这个事件对象为受信状态(让这个灯先亮),再创建另一个事件对象NS(南北向)让这个灯是灭的状态,然后我们再依次变换这两个灯的状态,这个参数就是一个初始状态。
首先不是线程A想推进就能推进的,我得先看一看我有没有机会推进,只有东西向的灯亮了以后(g_hEventEW),我才可以通行(打印A),同时,当我做完了以后,还得把南北向的灯打开。
我们可以看到A和B是交互打印的,证明咱们的这个思路是可行的。
以前我们都通过实例句柄HINSTANCE来标识一个应用程序,那么到了视窗这样一个操作系统以后,我们通过一个一个窗口来标识一个程序,那么窗口当中的每一个对象我们都用HWND这样一个句柄来进行访问,可以这么讲,我们有了一个HWND,我们就有了一个窗口对应,这是一个非常重要的标识。
窗口和窗口之间的动作如何来交互呢?
我们把这个窗口句柄作为目标对象,然后我们向这个窗口句柄发消息,那么窗口和窗口之间的通信就完成了,这个就是我们图形用户程序的一个非常重要的思想,我们通过:1、找窗口;2、对窗口之间进行消息传递;从而完成应用程序之间的交互,以便我们搭起更大的更复杂的综合的应用系统。
这个回调函数也是windows操作系统的规约,这个规约告诉你,我的这个窗口过程对应的是哪个窗体(hwnd),它所接受的消息是哪个消息(uMsg),这个消息的高字节(wParam)和低字节(lParam)究竟是什么东西;
我们在第一个窗口应用程序里面用的SendMessage函数就是一种消息交互的手段,拿到记事本这个窗口的实例句柄,给这个句柄发一个WM_CLOSE消息把这个窗体关闭,简单的来讲,这个关闭也不是由我们手动去关闭的,而是我们向操作系统内核提出了这样一个请求,把这个消息传递给windows操作系统,操作系统接收到了这个请求以后进行调度,从而把它关闭掉;
简单的来讲,就是说我们会有一个窗口过程函数专门来响应和分发;
在我们图形用户程序中,大家要树立起这样一个概念,我们所有的请求都是向操作系统内核发起的,然后由操作系统内核进行调度;
回调函数的回调,指的就是你的这个窗口过程都是由操作系统调用的,而不是你主动发起的。
我们要把进程的实例句柄hInstance和窗体给关联起来。
注册窗口的意义就是告诉你,请在操作系统内核把我的这样一个相应的结构(WNDCLASS)去做一个注册、去做一个申请,请你调度把相应的内核内存给我分配完毕,让我把这个东西给它进行一个调度申请,把这个东西排上队(消息排队),接下来windows就会根据我们的这个WNDCLASS中设置的若干分量开始进行一个一个把这个窗口实实在在的画出来。
消息循环它是由操作系统来完成的,当你这个窗口已经画出来了以后,你能接收到的消息也不是你主动去获取的,而是操作系统有一个消息队列在不停的捡取各种硬件的交互信息,这些信息变成若干个windows的消息给你,你所需要做的事情就是,你向windows提出来我现在要进入消息循环了,我会不停的向我这个消息队列里面去查,有消息的话请你把这个消息放到我的这个MSG里面来;
那么在GetMessage的过程当中,windows就会把消息传递过来放到这个MSG结构体里面来。
我们用户态程序在消息循环中,通过DispatchMessage函数把捡取到的windows消息MSG发送给内核,windows内核就负责把这个用户态的MSG映射到窗口过程的参数message里面去,同时又把这个MSG里面的若个信息分拆成wParam和lParam,这个里面的所有过程都由windows内核来做,不需要我们关心,但是我们要了解这里面的逻辑,因为windows的这个消息队列无时无刻不在推进的,所以我们就要捡取这个里面的消息,然后我们在窗口过程中通过switch…case…语句来分拣这个消息,我们只要有一个windows消息推给我们,我们就看是不是需要我们处理的,我们对我们需要处理的消息,我们额外的来做业务逻辑的处理;
69行的PostQuitMessage函数发一个消息(0)给谁,是给操作系统;
也就是说只要我在应用层这个窗口的右上角点一下关闭,那么这个时候windows就会把这一个关闭的WM_DESTROY消息发送给我这个窗体过程MainWndProc,我们这个窗体过程知道了以后呢,我会捡取这个消息,因为它肯定会走到case这个分支,走到case这个分支以后呢,我再通过PostQuitMessage函数向windows的消息队列发一个消息WM_QUIT(0),这个消息发给windows操作系统内核,由于我们主程序中的while死循环在做GetMessage,这个GetMessage是向windows操作系统内核做的,也就是说它将会得到这个消息WM_QUIT(0),当GetMessage得到0这个消息的时候,GetMessage函数就返回0,这个50行的while消息死循环就结束了,也就意味着消息不再进入到消息窗口回调过程中了。
注意这句:wndclass.cbSize = sizeof(wndclass);
这里不能用sizeof(WNDCLASS),否则注册窗口类失败。
消息循环中的都是win32应用程序向操作系统内核做的工作,将这个消息进行相应的分发;
就是告诉你我捡取到这个消息了,并且我对这个消息进行了一个处理,同时要把这个消息分发给操作系统,让操作系统把我的这个msg给当前的hWnd(msg结构体中的hwnd)对应的窗体回调函数;
当出了这个消息循环的时候,就意味着我这个主程序即将运行完毕,我得告诉你有一个退出码:msg.wParam,这个就是告诉你我要退出了,这个就相当于我们主函数WinMain结束的时候,要向操作系统给一个返回值,表明我这个进程销毁是正常的还是非正常的。
错误,说明创建窗体的CreateWindowEx函数那里有问题,我们下个断点观察后发现问题所在:
程序运行成功。
windows把我们一系列硬件动作(比如鼠标单击,键盘按键按下)全部封装成了一个一个的事件,我们通过对这些事件的响应进行开发,这就是事件驱动开发的一个基本概念。
我们一起来看一下windows应用程序它的活动生命周期,这样我们就比较容易理解事件驱动编程了:
在我们做win32应用程序的时候,我们有一个主函数的入口点,叫APIENTRY,这个说明从此我们当前的应用程序被拉起来了以后,从APIENTRY入口点开始推进,那么首先它有一个WNDCLASS结构,我们对这个WNDCLASS结构当中的每一个分量进行设置,设置完了以后向我们windows内核做一个提交,就是我们使用RegisterClassEx函数向windows内核注册我们的窗体类(WNDCLASS,相当于创建窗口的蓝图、向银行提交的汇款单),同时在我们这个WNDCLASS结构当中有一个分量叫lpfnWndProc,这是一个窗体的过程回调函数,这个函数也按照windows规约设计好了以后,也同时提交给windows内核进行判断,windows内核发现这两个符合我们的规约,并且操作系统当前有条件和资源进行分配,那么在内核当中提供一个内存空间,把这个内存空间分配好了以后,窗体实际上已经由windows内核生成了,生成完了以后它把注册成功的消息给我们窗体,接下来我们的这个主窗口开始推进到ShowWindow、UpdateWindow函数这里,这个窗体就被windows操作系统画出来给我们用户看了;
接下来它进入while…GetMessage…,向我们windows消息队列进行捡取,注意这个消息队列也不是我们窗体自己的,而是我们windows内核来进行维护的,它会把我们所有硬件事件变成一个个排列好的消息放在这里;
那么我们去捡取消息的时候呢,这个捡取消息也不是我们窗体自己来做的,而是由windows内核把这个消息再路由给我们注册的这个窗体过程函数,我们在这个窗体过程函数进行switch…case…,只要是windows当中的一个消息,就会路由给我们窗体过程函数。
简单的来讲,我们所谓的消息驱动编程,或者说事件驱动编程,就是对消息进行响应,我们把和消息相关的业务逻辑设计好了,我们就可以完成上层的应用,这个就是我们windows图形用户界面设计框架原理的一个解释。
ctrl+s保存一下,我们可以看到这个菜单叫IDR_MENU1,这是资源编译器自动帮你生成的,在我们编辑过程中,我们肯定要对这个IDR_MENU1进行修改,这个时候大家可能会直接在resource.h中进行修改:
看到上图中IDR_MENU1,有些学员可能会在这里直接编辑,这个是不合适的,因为这个resource.h过会我们会加载进来,这个.h文件是帮我们vs2015编译器去找到相应的内容,我们直接在resource.h里面修改的话,可能会造成工作不正常;
正确的修改方式是,打开资源视图,只要我们把resource.h打开的话,就会出现冲突(如下图右边栏中红叉所示),所以我们要先把打开的resource.h文件关闭:
这个时候我们再双击资源视图中的03Typer.rc,Menu就出来了:
我们在下方属性那里修改ID的值IDR_MENU1,这个修改是通过vs2015这个编译器完成的,因此它就会自动通过rc这样一个资源编译器和Link链接器,能够给它完整的关联起来,否则我们直接去改那个.h文件的话,我们vs这个环境它是没有办法帮你做关联的,大家切记,一定要在vs当中来做,不要直接修改resource.h文件。
我们修改Menu的ID值为IDR_TYPER,这个时候菜单就OK了,我们把.rc脚本文件关掉。
虽然点击打开没有工作,但是菜单这个资源加载进来了,说明我们这个菜单建立是正常的。
这样子才能保证我们vs2015开发环境,能够帮我们把导入的资源和最后的可执行文件关联起来;
它只支持.ico这种格式的文件,我们选择一个.ico文件:
我们修改IDI_ICON1为IDI_APP,然后我们修改代码把我们这个Icon资源加载进来:
我们通过LoadIcon函数加载这个Icon,第一个参数hInstance是说我们现在的这个窗体和我们当前进程的实例句柄是关联起来的。
这说明我们的图标资源也加载进来了。
然后按ctrl+s保存这个资源脚本文件,然后关闭这个脚本文件。
接下来要来响应我们的这个WM_COMMAND消息,首先窗口回调函数中大的路由是叫case WM_COMMAND这样一个消息,我们在这个消息里面进行编码,这是一个大的编码,这个时候我们还要再做一次switch,得看看你这个消息是哪一个ID发出来的,根据windows规约,通过LOWORD对wParam解封包,并对菜单的响应进行编码;
这个就是我们讲的资源的添加,以及添加资源以后如何响应windows消息,实现事件驱动编程这么一个模型。
接下来就要来实现如何把我们键盘的输入(打字)显示在咱们的图形化用户界面当中;
通过BeginPaint这个函数来获取设备环境(DC),具体的方法就是有一个hdc这样的一个比较重要的资源,BeginPaint在WM_PAINT消息里面进行访问,拿到这样一个图形化界面的设备上下文(DC),同时定义了一个绘画的结构(PAINTSTRUCT),在结束WM_PAINT消息之后一定要有一个EndPaint,从而完成把相应的东西给它绘制上去。
PAINTSTRUCT这个结构是我们应用程序用来接收windows内核给我们的这样一个结构体,这个HDC是在WM_PAINT消息里面可以获得windows给我们绘制图形的东西。
注:上图倒数第二行有误,应该是WM_CHAR wParam才对。
对于我们键盘按下的时候,这是一个非常粗糙的IO输入,对于我们用户态编程是不太方便的;
TanslateMessage这个函数会把windows当中最原始的WM_KEYDOWN消息进行一次转化,把它转化成对于我们上层应用比较方便的消息WM_CHAR,可以这么讲,有了WM_CHAR,我们的编程可以变得更加方便。
如何能够让我们的窗体显示呢?
我们让当前的整个窗体无效,让我们客户区无效,会调用一个API:
InvalidateRect这个API的最终目的,是要触发我们的WM_PAINT消息进行一个重绘;
也就是说,把str这个字符串放到WM_PAINT消息里面进行显示。
我们发现按了键盘按键,字符还是没有显示出来,这是一个比较重要的问题,帮助我们进一步深刻理解窗口回调过程;
这个窗口回调过程并不是由自己调用的,而是由windows的消息路由来调用的,这也就意味着每当一个消息投递过来的时候,就会进入到59行的MainWndProc这个回调函数,换句话说,每次我按下按键的时候,代码首先走到61行,这行代码执行后str被清空了,如果我们把这行代码注释掉后,再来看看程序的运行效果:
我们想做这样一件事情,在鼠标按下的时候能够找到鼠标当前的位置,然后显示这个位置信息给我们:
wParam参数里面封装的是你是否按下了shift键这类系统键(alt、ctrl等)。
当我们想显示给客户看的str字符串已经根据业务逻辑设计完毕后,在第11行我们让客户区失效,触发WM_PAINT消息,也就是说,我们通过使用InvalidateRect函数触发了一个WM_PAINT消息,投递到了消息队列里面,这个消息被windows操作系统内核抓住了以后,又把WM_PAINT消息路由给了我们的当前窗体回调函数,再次进入到我们的WM_PAINT消息处理分支,再一次重绘就显示出字符串信息了。
定时器的安装:SetTimer
定时器的关闭:KillTimer
演示程序:
窗口当中鼠标单击开启一个定时器,再次单击鼠标左键的话就把定时器给关闭,在第一次按下鼠标左键和第二次按下鼠标左键之间,我们做个计数,看看一共走了多少次,每隔一秒种我们做一次计数。
我们启动一个定时器,这个定时器是一个系统资源,所以我们会给这个系统资源提供一个人为的客户区这么一个编号,这个编号是我们定义的,为了防止和其他内置的编号冲突,我们一般定义在1万以上,接下来这个IDT_TIMER就会指定给我们定时器,变成定时器的身份标识。
每一次WM_TIMER进来的时候都会路由到我们这个MainWndProc窗口回调函数;
我们再定义一个计时器的开关变量bSetTimer,奇数次是打开计时器,偶数次是关闭计时器。
当窗体创建的时候,我们得把计时器插销bSetTimer关闭掉,不让定时器打开:
在鼠标左键单击窗体的时候,就要判断定时器开关变量了,如果为真,说明此事定时器正在运行,我们就要把它关闭:
并将开关变量设置为FALSE,以便下一次再有鼠标左键点击的时候,就要把定时器打开。
如果定时器开关变量是FALSE,没有定时器的话,我们就要给当前窗体安装定时器:
我们还要再加一个消息WM_CLOSE,因为WM_DESTROY消息是销毁我们这一个窗体,在我们窗体销毁之前,windows在我们这个界面中会把这个窗体擦除掉,就是你看不到这个窗体,它发的消息是WM_CLOSE,这个一般是用户要求关窗体:
这是在我们工程设计当中最常用的一个手段,我们试想一下,在这个过程当中如果定时器正在运行,我们又把窗体给关了,那这个定时器要不要关啊,肯定要关,因为定时器不关的话会造成资源泄漏,所以这个时候我们得做一次判断,当用户要求关窗体的时候,我们要看一下定时器开关是否为真,如果为真说明在你关之前,我们得先做一些资源的清理,把这个定时器给关掉,防止资源泄漏。
这个WM_TIMER消息是怎么来的呢?
只要在我们窗体当中绑定了一个定时器(在WM_LBUTTONDOWN消息处理分支那里),在SetTimer执行了以后,windows内核就会不停的往我们的这一个窗体推送WM_TIMER消息,推得间隔是由你决定的,就是那个1000毫秒(这个时间不是特别精确的),换言之就是说,每个1秒钟windows就会向当前的这个窗体推一个WM_TIMER消息。
有了这个WM_TIMER消息以后我们就可以开始做业务逻辑了。
windows可能会开多个定时器,所以要先判断下,看看推过来的TIMER消息里面的wParam参数是否是我现在这个定时器推过来的:
这个时候你可能会问了,你在这个WM_TIMER消息这里为什么不用BeginPaint和EndPaint呢?
在我们windows当中,你这个TextOut就是向你指定的设备上下文hdc去发写字符的这么一个操作,在我们widnows编程规约里面规定,所有的消息当中只有我们的WM_PAINT是比较特殊的消息,它的HDC的获取一定得用BeginPaint和EndPaint,而在通常大多数消息当中,比如说WM_TIMER消息,你要拿HDC就要用GetDC函数,这是windows规定的。
在工程当中,如果我们没有深刻了解windows消息机制的话,可能会认为我加一个定时器就是一个异步线程,这个观点是错误的,这个TIMER消息还是窗体级的消息,也就是说我的这个主窗体还是在接收这个TIMER消息,因此如果TIMER请求过于频繁的话,它会阻塞界面的这个UI线程,所以TIMER不是异步消息;
如果我们想要去做这一个额外工作的话不能放在TIMER里面来做,TIMER还是会占用主线程的,如果TIMER阻塞住了,主线程界面就会阻塞住,就会造成界面假死,这个是我们工程当中需要注意的地方,也对TIMER的工程意义做了进一步的解读。