【创新性声明】本文没有本质上的创新性内容。属于一些实验和总结,有少量主观推测成分(有待进一步证实)。写这一类文章是非常危险的,因为有很多东西可能是我们不了解和比较模糊的,这很可能会出现错误的主观臆测,不仅仅是令明真相者贻笑大方的问题,更可怕的在于传播“错误”,这是我最为诚惶诚恐的一点。比如,我之前见到我指点过他的sun先生对于Photoshop中置换滤镜中的他的那些主观错误结论已经传遍网络,尽管可能没有太多人能关注到这个层面,但是我还是为这些错误的观点在网络上比比皆是而深感遗憾。正因为此,这篇文章我受限于个人水平,也不能100%确保自己不犯下认知性错误,因此此本(1)需要更专业的人的监督和意见,(2)可能随着我自己认知的变化而进行修订和变更。
最近在一些回复中提到了一些静态变量,线程安全性,递归函数调用的问题。尽管大概情况我已经清楚,但感觉自己在某些细节方面还稍显模糊,因此在这个问题应该从底层上做一个重新的总结,把一些比较容易产生模糊的问题总结下。
(1)全局变量,和静态变量位于进程空间什么位置。
为此,我用 VC6 新建了一个 Windows Console Application 。并输入代码如下:
test_code
#include <stdio.h>#include <stdlib.h>//#include <process.h>#include <tchar.h>#include <windows.h> typedef HANDLE (WINAPI *FuncPtr)( LPSECURITY_ATTRIBUTES lpThreadAttributes, SIZE_T dwStackSize, LPTHREAD_START_ROUTINE lpStartAddress, LPVOID lpParameter, DWORD dwCreationFlags, LPDWORD lpThreadId ); class TestClass{public: TestClass() { printf("TestClass's constructor.\n"); } ~TestClass() { printf("TestClass's destructor.\n"); }}; TestClass g_class; int g_Temp1 = 0x11223344;int g_Temp2;int g_Array[4];int g_Temp3; //The default size for the reserved and initially committed stack memory //is specified in the executable file header. DWORD WINAPI threadProc(LPVOID lpParameter){int a = 0;int threadIndex = (int)lpParameter; printf("%p: auto variable on new thread's stack (thread: %d)\n", &a, threadIndex);return 0;} int main(int argc, char* argv[]){static int nStatic1 = 0x12345678;static int nStatic2 = 0x00abcdef;static int nStatic3; int a = g_Temp1 & 0xffff;char* pConstData = "Hello World"; //pConstData[1] = 0; //内存访问冲突(不可写) char* pHeap1 = (char*)malloc(32);char* pHeap2 = (char*)malloc(32); printf("---enter main----\n"); HMODULE hModule = GetModuleHandle(_T("Kernel32.dll")); printf("%p: Module (Kernel32.dll)\n", hModule); FuncPtr pFunc = CreateThread; printf("%p: FuncPtr (CreateThread)\n", pFunc); DWORD tid1, tid2; HANDLE hThread1 = CreateThread(NULL, 0, threadProc, (LPVOID)1, CREATE_SUSPENDED, &tid1); HANDLE hThread2 = CreateThread(NULL, 0, threadProc, (LPVOID)2, CREATE_SUSPENDED, &tid2); ResumeThread(hThread1); WaitForSingleObject(hThread1, 5000); CloseHandle(hThread1); ResumeThread(hThread2); WaitForSingleObject(hThread2, 5000); CloseHandle(hThread2); printf("%p: auto variable on main thread's stack\n", &a); printf("%p: memory alloc on heap: pHeap1 (32 bytes)\n", pHeap1); printf("%p: memory alloc on heap: pHeap2 (32 bytes)\n", pHeap2); printf("%p: address of const data(\"hello world\")\n", pConstData); printf("%p: global variable g_Temp1 (initialized)\n", &g_Temp1); printf("%p: global variable g_Temp2 (not initialized)\n", &g_Temp2); printf("%p: global variable g_Array (not initialized)\n", g_Array); printf("%p: global variable g_Temp3 (not initialized)\n", &g_Temp3); printf("%p: static variable nStatic1 (initialized)\n", &nStatic1); printf("%p: static variable nStatic2 (initialized)\n", &nStatic2); printf("%p: static variable nStatic3 (not initialized)\n", &nStatic3); free(pHeap1); free(pHeap2); printf("---leave main----\n");return 0;}
在这个例子里我添加了全局变量,auto 类型的栈上临时变量,函数内的静态变量,以及新创建的线程的栈上的变量等。此程序产生的输入如下:
test_output
1 TestClass's constructor. 2 ---enter main---- 3 7C800000: Module (Kernel32.dll) 4 7C810647: FuncPtr (CreateThread) 5 0052FFB0: auto variable on new thread's stack (thread: 1) 6 0062FFB0: auto variable on new thread's stack (thread: 2) 7 0012FF7C: auto variable on main thread's stack 8 00370FE0: memory alloc on heap: pHeap1 (32 bytes) 9 00371038: memory alloc on heap: pHeap2 (32 bytes)10 00423148: address of const data("hello world")11 00425B40: global variable g_Temp1 (initialized)12 00428D78: global variable g_Temp2 (not initialized)13 00428D68: global variable g_Array (not initialized)14 00428D64: global variable g_Temp3 (not initialized)15 00425B44: static variable nStatic1 (initialized)16 00425B48: static variable nStatic2 (initialized)17 00428D60: static variable nStatic3 (not initialized)18 ---leave main----19 TestClass's destructor.
首先,我们可以看到一个类的实例作为全局变量,它的构造函数在进入入口点函数(main)之前被调用,在main函数之后被析构。这显示这个过程应该是包装在main函数外层的函数,也就是 CRT 做的。
上面的输出给出了各个变量在进程空间中的地址(虚拟内存地址),因此我们再对照一下编译后的PE文件的 SectionHeader 的信息,使用我编写的文件格式查看器打开编译后的EXE文件,如下图所示:
根据PE文件中的信息:
ImageBase = 0x0040 0000;
SectionAlignment = 0x1000;
FileAlignment = 0x1000;
可以根据 section header 的信息知道,各个段在进程空间中的地址是(对比程序输出,可知全局变量和常量字符串在那个段范围内):
section |
From |
To |
Characteristics |
备注 |
.text |
00421790 |
00422790 |
可执行,可读 |
代码段 |
.rdata |
00423000 |
00424777 |
可读 |
初始化数据段(常量) |
.data |
00425000 |
0042A730 |
可读,可写 |
初始化数据段(全局和静态变量) |
.idata |
0042B000 |
0042B7CC |
可读,可写 |
|
.reloc |
0042C000 |
0042CEC9 |
可读,可丢弃 |
基址重定位 |
上表是根据PE文件中的信息给出了进程空间中各个段的地址范围。因此我们根据运行时的输出情况,可以很容易的确定哪些变量具体位于进程空间的那个段中。例如针对这个具体的实例来说,全局变量和函数内静态变量位于 .data 段,常量字符串位于 .rdata 。
注意由于 SectionAlignment 和 FileAlignment 是相同的,因此文件中的内容和映射后,节之间的相对位置是没有变化的。节的特性被设置给内存页,因此如果代码中试图对内存执行没有权限的操作,例如对常量字符串的地址进行写入,系统就会告知错误。可以看到 section 的起始地址使用 sectionAlignment (默认 0x1000)对齐。数据段默认不具有共享属性,被映射以后数据是该进程空间所私有的。但共享的段则在所有进程实例中共享。可以设定某个段为共享,当使用一个DLL时,则数据通过DLL来给所有进程共享,共享意味着在所有进程空间中,该段被映射到相同的物理内存页。例如《windows 核心编程》中有一个例子,当创建多个进程实例,对话框上能实时更新的显示出当前实例的个数。(同时这也是作为用于控制只能运行一个进程实例的方法之一):
// Tell the compiler to put this initialized variable in its own Shared // section so it is shared by all instances of this application.//#pragma data_seg("Shared")volatile LONG g_lApplicationInstances = 0;#pragma data_seg() // Tell the linker to make the Shared section readable, writable, and shared.#pragma comment(linker, "/Section:Shared,RWS")
可以看出常量字符串代码中的"Hello World”位于.rdata节,该节是不可写的。全局变量和静态变量都位于.data节,可读可写。代码中已初始化的数据在节内位于靠近节前部地址较低的地方,未初始化的数据靠地址较高的地方。对于程序中的基本类型变量例如整形等,实际上它们从文件中被读入内存后就已具有了初值,相当于完成了初始化。但对于一个类的实例来说,对其初始化需要调用其构造函数。这些都需要在进入入口点函数之前完成。
因此现在我们可以明确:
char str[] = "Hello World";
含义是 str 数组是位于栈上的数组,然后从初始化段(只读)拷贝字符串内容到栈上的数组。(编译器可能对其用 DWORD 进行拷贝)
char *str = "Hello World";
str 是位于栈上的一个指针,指向初始化段的只读数据。
初学者一般不太容易立即区分出上面的代码在底层上的区别:前者是栈上的数组,数组空间在栈上分配。编译器会插入代码,在运行时把数据从只读数据段拷贝到栈上空间。所以前者的 str 是可写的。后者是栈上的一个指针变量,指向只读数据段上的某个位置(在C++里面这样的赋值合法,是为了照顾成千上万的已有 C 代码,此处参考了参考资料(5)《The C++ Programming Language》中的叙述)。后者的 str 是不可写的。如果试图修改它,在编译上是通过的,但运行时属于不确定行为。
我先后创建两个新的线程,是为了观察新线程的栈的位置,可以看到在这个例子中,在创建时我没有指定栈的大小,默认是 1MB。从低地址往高地址数,主线程的栈在进程空间中相当靠前,(中间的空隙属于堆),然后是那些 sections,然后此处剩下很大的一段空隙又基本上全部是属于堆。然后是新创建的线程的栈(默认 1MB),它们的地址看起来已经相当高。然后是那些被影射进来的DLL,系统 DLL 都经过了 Rebase,一般位于比较高地址的地方(靠近 2GB 边界),最后是系统控制的空间边界(0x80000000)。当然,默认情况下,是程序员和系统各占 4GB 空间的一半,但可以通过启动的配置参数让程序员负责的空间达到 3GB。因此上面的范例的进程空间大体如下图所示:
当然这个图只是针对这个控制台程序例子的特例而绘制,只是给出该例子中进程空间的示意图。图中箭头表示的是栈的增长方向。当一个进程中运行多个线程时,它们各自拥有独立的栈。当然这些栈之间由于位于相同的地址空间,所以它们彼此是可见的。因此一个线程可以把自己的栈上地址传递给其他线程进行处理,但这种地址随着线程退出就会失效,因此必须做线程同步,即阻塞式等待。例如不能使用 PostMessage 传递栈上的地址给其他窗口的窗口过程,而使用 SendMessage 是可行的。
当我们提到并发性和多线程安全,通常在针对一个函数讲时,是进程空间内的多个线程同时调用该函数是否会引发问题。并发还指多个线程对同一资源的访问和使用,例如数据库,文件等。如果一个函数是可重入的,则它不能直接或简介的使用,引用静态,全局性变量(尤其是对这些共享性变量的读写不是原子性的,且这些变量的值的准确性如果对于使用者来说是关键的状态性数据,则会引入诸如“脏读”等问题),也不能直接或简介调用不可重入的函数。栈上的一个 auto 类型变量是多线程安全的,是因为栈是属于线程的,彼此独立,因此 auto 类型变量对于线程来说相当于“私有数据”。对于递归函数,每一层递推都会在栈上增加一层 stackframe,因此函数内的临时变量位于其各自调用所属的 stackframe 中,也相当于彼此独立。
(2)系统调度,消息队列的基本单位。
这里我们提及的是进程和线程,不再考虑线程以下的概念。考虑下面的问题:
2.1 拥有消息队列的基本单位是什么?A。窗口;B。线程。
答案是线程。但通常我们更容易把消息队列和窗口关联在一起,这仅仅是因为事件驱动给我们的一种主观表象。由于调度的基本单位是线程,因此处理消息和拥有消息队列的基本单位也是线程。系统派送消息的目标,是把消息送到创建窗口的线程所拥有的消息队列。然后该线程被调度得到时间片,由消息循环(DispatchMessage)间接调用该窗口的窗口过程函数。但一个线程不一定拥有消息队列,例如只专注于运算不负责接受消息控制的后台线程,只有线程调用 PeekMessage,GetMessage 一类的函数时,系统才会为这个线程创建消息队列。拥有消息队列的线程也不一定会创建窗口,但是创建窗口的线程就会接收到和窗口有关的各种消息。
总结一下,线程中根据其功能又可以大致的分为主线程(或者在GUI程序中通俗的理解为 UI 线程)和后台线程。消息中的一部分属于窗口消息,这些消息是和一个具体的窗口关联并以创建该窗口的线程的消息队列作为目标的(最终线程的消息循环负责引发对窗口过程的调用,即 DispatchMessage),但并不是所有的消息都会和具体窗口关联。例如普通的线程消息(而一个线程也可能不会创建任何窗口)。窗口消息中的一部分又可以属于对话框消息。
2.2 比较特殊的几个消息:WM_MOUSEMOVE, WM_TIMER, WM_PAINT, WM_QUIT; (待完成)
线程消息分为三种类型:incoming sent message; posted message; input message; 在标准术语里面,第一种被称为非队列消息,后两种称为队列消息,也就是被理解为“位于消息队列中的消息”。
参考资料:
(1)《程序员的自我修养》,俞甲子 等。
(2)“Billy Belceb 病毒教程Win32篇”(选自《看雪学院五周年纪念收藏版》), Billy Belceb。
(3)《windows核心编程》, Jeffery Richer.
(4)《Tho Old New Things》, Raymond Chen.
(5)《The C++ Programming Language》,BS。
【补充说明】
本文已有的评论,因为涉及到有具体所指的个人主观性意见,于技术探讨交流上没有任何积极意义,因此不在技术博客保留,已转移到163博客的以下地址进行备份性保留(可以点击以下地址观看原有评论):
http://blog.163.com/jinfd@126/blog/static/62332277201211104431759/
本文评论在此处被删除的原因是,其内容和技术或讨论主题无关(例如无实质内容,明显针对人的主观评价等)。
--hoodlum1980,2012年2月1日。