基本概念
Win16 内存模式下,所有的应用程序都运行于同一个 4GB 地址空间,它们可以彼此"看"到别的程序的内容,这极易导致一个应用程序破坏另一个应用程序甚至是操作系统的数据或代码。
每一个Win32 应用程序,放到分开的虚拟地址空间(相互独立的 4GB 地址空间)中去运行,当然这倒不是说它们都拥有 4GB 的物理地址空间,而只是说能够在 4GB 的范围内寻址。操作系统将会在应用程序运行时,完成 4GB 的虚拟地址和物理内存地址间的转换(页表)。16 位 Windows 下的把代码分成 DATA,CODE 等段的内存模式不同,WIN32 只有一种内存模式,即 FLAT 模式,意思是"平坦"的内存模式,再没有 64K 的段大小限制,所有的 WIN32 的应用程序运行在一个连续、平坦、巨大的 4GB 的空间中。
80x86处理器的工作模式:
实模式,保护模式, 虚拟86模式,实模式,虚拟86模式就是为了兼容老软件,系统应用而做的一种向下兼容的功能,没必要深入了解,保护模式才是目前win32 下CPU的工作模式。
保护模式下最关键的地方就是内存寻址空间增加到4G;采用了优先级机制,分四个级别0-3,0级系统级最高,3级用户级别最低,1 2 是为了兼容alpha设置的,用不到。
经常说OD是ring 3级的程序调试工具,这个3级就是指保护模式里的3级用户级。
保护模式下用户级的程序不能够访问到系统级资源,通过级别的设置,用户级的程序无法通过提升自己的级别来操作系统级的资源。
windows的内存管理
首先每个进程自己的4G寻址空间不是完全可用的
NULL指针区域: 0x00000000-0x0000FFFF:65535字节
这个区域的作用是用来帮助程序员发现内存分配失败后未检查就使用的错误。
NULL定义为0-65535之间的任何数都可以达到,检测指针区域的效果。
64K禁入区域: 0x7FFF0000-0x7FFFFFFF:64K字节
用来隔离用户空间和内核空间,是一个分界线。
实际上进程可用的地址空间最后是到0x7FFE1000,到0x7FFF0000之间的60K内存空间就不让使用了。可以用Chect Engine 的Memory regions 查看进程的内存空间情况。
windows内核空间: 0x80000000-0xFFFFFFFF:2G
这个分区用来保存操作系统代码,内存管理,线程调度,文件系统支持,网络支持,和所有设备驱动代码都存放在这里,这个区域被所有进程共享。同样也是保护的,不可访问。
其中0x80000000-0xC0000000:1G 用来加载系统所需DLL,SYS,可以用Process Explorer 查看System进程可以看见系统自己加载的模块,大部分是.sys驱动,dll只有ntdll.dll, nv4_disp.dll等极少数的dll模块,确实是所有设备驱动的代码都再这里。
用户空间: 0x00001000-0x7FFFFFFF:2G-128K
可执行文件和用户自己的dll都加载到这个空间。系统DLL加载到系统内核空间.
其中0x00001000-0x00400000 是Dos兼容分区,这个还有用么?4M的空间...
0x00400000-0x10000000 是进程相关内容存放区域,这就是为啥默认的可执行文件加载地址是从0x00400000开始
0x10000000-0x80000000 是用户DLL映射空间,这就是为啥默认的dll文件加载地址是从0x10000000开始
从上面看出,并不是所有4G的寻址空间都是可用的,实际可供进程使用的只用2G-128K的空间。
分配粒度和内存页面大小
x86处理器平台的分配粒度是64K,32位CPU的内存页面大小是4K,64位是8K, 保留内存地址空间总是要和分配粒度对齐。一个分配粒度里包含16个内存页面。
这是个概念,具体不用自己操心,比如用VirtualAllocEx等函数,给lpAddress参数NULL系统就会自动找一个地方分配你要的内存空间。
一个分配粒度是64K,这就是为什么Null指针区域和64K进入区域都是64K的原因,刚好就是一个分配粒度。
一个内存页是4K,这就是为什么PE文件中的section都是0x1000对齐.
硬盘扇区大小是512字节,这就是为什么PE文件默认文件对齐是0x200.
内存页面的各种属性
PAGE_NOACCESS 禁止写入执行读取
查看进程内存区域能发现,NOACCESS属性的内存页面都是FREE状态的(未提交使用的内存区域),只有内存区域最后的0x7FFE1000-0x7FFF0000之间的60K内存区域状态是Reserve。(保留了,不让使用...)
PAGE_READONLY PAGE_READWRITE PAGE_EXECUTE 根据字面就很好理解
PAGE_WRITECOPY PAGE_EXCUTE_WRITECOPY 这2个页面属性是windows节省内存应用的一个机制.
难道要2个一样的可执行程序同时运行时各占一个独立4G的寻址空间么?既然是一样的程序,2个程序的代码段,数据段都是相同的。为了节省内存,windows就让2个进程共享单个内存块。
但是如果一个程序中的内存发生变化,另一个也同时发生变化,那岂不乱套了?开2个IE浏览网站,但是2个都显示同样的内容那还有什么意义?copy-on-write就是为解决这个问题而设置的。
PAGE_WRITECOPY 数据段
简单的说,2个一样的程序运行,如果内存中数据不发生变化,那么这段数据是共享的,如果其中一个程序的内存发生变化,比如记事本A写了一行字,那么就会把记事本的这个数据段复制出来一份放到新的内存区域让记事本A单独使用,这时候记事本A和记事本B进程的数据段就不再共享,而是各自用各自的。但是他们的代码段还是共享。
PAGE_EXCUTE_WRITECOPY 代码段
代码段也是一样,你用OD修改了A记事本中的代码段,系统就会自动把A记事本的代码段复制一份新的,不再和B共享,也就不会影响B记事本中的代码段。
实际上一个程序的代码段,资源段等数据也没多大。所以,这种机制也看不太出来能节省很大的内存。
关于内存单位
内存单位再书里,汇编里,都是用16进制单位描述的,10进制看习惯了,突然全16进制我就比较不习惯。我把常用的列出来,看长了就能有个大概的概念了,突然来个0x165700,也能不用计算器就能估算个大概。
0x100 256bit,0x200 512bit,0x400 1K,0x800 2K
0x1000 就是4K,0x10000就是64K,0x100000 1024K
用户空间里的0x00001000-0x00400000 的Dos兼容分区,现在还有用么,按照书上的说明,进程堆,内存非配堆,都再0x00400000-0x10000000区域里,那么如果我们设置可执行文件的加载地址从0x00001000开始,是否进程空间就又能多出4M的内存区域可供使用呢?
![]()
函数名修饰符
例子函数:int max(int,int);
对于C语言
_cdecl调用
名称修饰符是在函数前加一个下划线:_max
_fastcall调用
名称修饰符在函数前加一个@后面加一个@紧跟参数字节数:@max@8
_stdcall调用
名称修饰符在函数前加一个_后面加一个@紧跟参数字节数:_max@8
对于C++语言,不管任何调用约定,描述符都以?开头后边更函数名,然后是根据参数表查出的返回值类型,然后是参数类型,最有以@Z结束
?+函数名+调用规则名+返回类型+参数类型(从左到右)+@Z
其中调用规则名表:_cdecl:@@YA,_stdcall:@@YG,_fastcall:YI
标示符:参数类型
X:void,D:char,E:unsigned char,F:short,H:int,I:unsigned int,J:long,K:unsigned long,M:float,N:double,_N:bool,U:Struct
指针:PA,const指针:PB
对于max函数修饰名称就是:?max@@Y?HHH@Z。这里给了个问号,意思就是不同的调用规则就更具调用规则表变化,其他不变。
很明显,C++的修饰更为详细。
现在回过头看看刚才的错误提示:error LNK2001: 无法解析的外部符号 __imp__ExitProcess@4
__imp_ 这个是代表函数ExitProcess是从外部导入的,后面的_ExitProcess@4很明显参数是四字节的和ExitProcess(UNIT uExitCode)相符
实际上对于C++的类成员函数,描述符的规则又有不同,但是,如果你写的DLL动态链接库使用自定义类,估计没人会用的,使用类了就不能通用了。
程序框架
.386 //使用386指令集(当然你也可以使用.486,.586, 注意.386P/.486P/.586P表明程序可以使用特权级指令)
.MODEL Flat, STDCALL // 内存模式为Flat,参数的传递约定为STDCALL(C和Pascal约定的混合体,参数入栈从右到左,被调者恢复堆栈指针)
.DATA // 已经初始化的数据
<Your initialized data>
![]()
.DATA? // 未初始化的数据,不会占用可执行文件的大小
<Your uninitialized data>
![]()
.CONST // 常量定
<Your constants>
![]()
.CODE //
<label> // 代码范围
<Your code>
..
end <label> // 代码范围
消息框例子
.386
.model flat, stdcall
option casemap:none // 区分标号的大小写,譬如:start 和 START 是不同的
include \masm32\include\windows.inc // 常量和结构体的定
include \masm32\include\kernel32.inc // 我们可能使用到的函数原型
includelib \masm32\lib\kernel32.lib // 会在生成的目标文件中插入链接命令告诉链接器链入什么库
.data
.code
start:
invoke ExitProcess,0 // INVOKE expression [,arguments], 相对于Call更高层的函数调用方式
end start
现在保存例子,取名为msgbox.asm。把 ml.exe 的路径放到 PATH 环境变量中,键入下面一行 进行编译:
ml /c /coff /Cp msgbox.asm (命令行参数大小写是有区别的)
- /c 是告诉MASM只编译不链接。这主要是考虑到在链接前您可能还有其他工作要做。
- /coff 告诉MASM产生的目标文件用 coff 格式。MASM 的 coff 格式是COFF(Common Object File Format:通用目标文件格式) 格式的一种变体。在 UNIX 下的 COFF 格式又有不同。
- /Cp 告诉 MASM 不要更改用户定义的标识符的大小写。若您用的是 hutch 的包含文件的话,在.model 指令下加入 "option casemap:none" 语句,可达到同样的效果。
当您成功的编译了 msgbox.asm 后,编译器会产生 msgbox.obj 目标文件,目标文件和可执行文件只一步之遥,目标文件中包含了以二进制形式存在的指令和数据,比可执行文件相差的只是链接器加入的重定位信息。
链接目标文件:
link /SUBSYSTEM:WINDOWS /LIBPATH:c:\masm32\lib msgbox.obj
- /SUBSYSTEM:WINDOWS 告诉链接器可执行文件的运行平台
- /LIBPATH:〈path to import library〉 告诉链接器引入库的路径。
链接器做的工作就是根据引入库往目标文件中加入重定位信息,最后产生可执行文件。
函数的原型定义:函数名后加伪指令PROTO,再跟一串由逗号相隔的数据类型链表。
FunctionName PROTO [ParameterName]:DataType,[ParameterName]:DataType,...
下面我们在程序中加入一个对话框。该函数的原型如下:
MessageBox PROTO hwnd:DWORD, lpText:DWORD, lpCaption:DWORD, uType:DWORD
- hWnd 是父窗口的句柄。句柄代表您引用的窗口的一个地址指针。它的值对您编 Windows 程序并不重要(译者注:如果您想成为高手则是必须的),您只要知道它代表一个窗口。当您要对窗口做任何操作时,必须要引用该窗口的指针。
- lpText 是指向您要显示的文本的指针。指向文本串的指针事实上就是文本串的首地址。
- lpCaption 是指向您要显示的对话框的标题文本串指针。
- uType 是显示在对话框窗口上的小图标的类型。
.386
.model flat,stdcall
option casemap:none
include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
includelib \masm32\lib\kernel32.lib
include \masm32\include\user32.inc
includelib \masm32\lib\user32.lib
.data
MsgBoxCaption db "Iczelion Tutorial No.2",0
MsgBoxText db "Win32 Assembly is Great!",0
.code
start:
invoke MessageBox, NULL, addr MsgBoxText, addr MsgBoxCaption, MB_OK
invoke ExitProcess, NULL
end start
1) NULL 和 MB_OK常量在windows.inc 定义;
2)addr操作符用来把标号MsgBoxText的地址传递给被调用的函数,它只能用在 invoke 语句中,譬如您不能用它来把标号的地址赋给寄存器或变量,如果想这样做则要用offset 操作符:
![]()
Code
addr不可以处理向前引用,offset则能。所谓向前引用是指:标号的定义是在invoke 语句之后,譬如在如下的例子:
invoke MessageBox,NULL, addr MsgBoxText,addr MsgBoxCaption,MB_OK
![]()
MsgBoxCaption db "Iczelion Tutorial No.2",0
MsgBoxText db "Win32 Assembly is Great!",0
如果您是用 addr 而不是 offset 的话,那 MASM 就会报错。
addr可以处理局部变量而 offset 则不能。局部变量只是在运行时在堆栈中分配内存空间。而 offset 则是在编译时由编译器解释,这显然不能用offset 在运行时来分配内存空间。编译器对 addr 的处理是先检查处理的是全局还是局部变量,若是全局变量则把其地址放到目标文件中,这一点和 offset 相同,若是局部变量,就在执行 invoke 语句前产生如下指令序列:
lea eax, LocalVar
push eax
因为lea指令能够在运行时决定标号的有效地址,所以有了上述指令序列,就可以保证 invoke 的正确执行了。
![]()
MASM的优化
MASM的优化
都知道汇编效率高,但是MASM编译出的EXE真的就是最佳优化的么?让我们看看本章中的hello.exe 用OD反汇编看看是不是这样。
反汇编内容:
00011000 >/$ 6A 00 PUSH 0 ; /Style = MB_OK|MB_APPLMODAL
00011002 |. 68 00300100 PUSH Hello.00013000 ; |Title = "A MessageBox !"
00011007 |. 68 0F300100 PUSH Hello.0001300F ; |Text = "Hello, World !"
0001100C |. 6A 00 PUSH 0 ; |hOwner = NULL
0001100E |. E8 07000000 CALL <JMP.&user32.MessageBoxA> ; \MessageBoxA
00011013 |. 6A 00 PUSH 0 ; /ExitCode = 0
00011015 \. E8 06000000 CALL <JMP.&kernel32.ExitProcess> ; \ExitProcess
0001101A $- FF25 08200100 JMP NEAR DWORD PTR DS:[<&user32.Mess>; user32.MessageBoxA
00011020 .- FF25 00200100 JMP NEAR DWORD PTR DS:[<&kernel32.Ex>; kernel32.ExitProcess
看看那2个CALL,一个调用MessageBoxA,一个调用ExitProcess,这个JMP产生了额外的代码,并且增加执行时间,产生这样的代码是因为编译器不知道你调用的函数是从外部导入的。如果编译器预先知道这个函数是从外部引入的,编译器就会把CALL后面的地址直接指向,PE文件的IAT(import_address_table)输入表中的函数地址,当程序运行时由系统加载器更新IAT表(如果需要的话),这样就调用了函数在DLL中的正确地址,避免了这种低效能的调用方式。
高级语言,比如C语言在引入外部DLL函数时,再dll头文件里对于每一个函数都有一个描述 __declspec(dllimport),这就是告诉编译器,这个函数是从外部引入的,从而提高空间和时间效率。
看看C写的,功能呢个同样的代码,编译后的反汇编内容:
00401000 /$ 6A 00 PUSH 0 ; /Style = MB_OK|MB_APPLMODAL
00401002 |. 68 00304000 PUSH HelloMsg.00403000 ; |Title = "HelloMsg"
00401007 |. 68 0C304000 PUSH HelloMsg.0040300C ; |Text = "Hello, Windows 98!"
0040100C |. 6A 00 PUSH 0 ; |hOwner = NULL
0040100E |. FF15 AC204000 CALL NEAR DWORD PTR DS:[<&USER32.MessageBoxA>] ; \MessageBoxA
00401014 |. 33C0 XOR EAX, EAX
00401016 \. C2 1000 RETN 10
这个MessageBoxA的CALL才是效率最高的call!
但是悲剧的是在masm里我们无法用任何描述告诉编译器,当前使用的函数是从外部引入的。结果就是使用效率最高的语言确产生了效率最低的外部函数调用![]()
有没有办法解决,确实有,我google了一下,发现了一段代码。
比如我们调用ExitProcess函数,可以预先这样写
PROTO@4 TYPEDEF PROTO STDCALL :DWORD ;定义一个新的类型proto@4
EXTERNDEF STDCALL _imp__ExitProcess@4:PTR PROTO@4 ;定义一个外部变量,类型为上面定义的类型
ExitProcess EQU <_imp__ExitProcess@4> ;定义一个符号ExitProcess
把上面3行代码加到 模式定义后面,注释掉include 'kernel32.inc',重新编译,现在看反汇编的内容:
00011000 >/$ 6A 00 PUSH 0 ; /Style = MB_OK|MB_APPLMODAL
00011002 |. 68 00300100 PUSH Hello.00013000 ; |Title = "A MessageBox !"
00011007 |. 68 0F300100 PUSH Hello.0001300F ; |Text = "Hello, World !"
0001100C |. 6A 00 PUSH 0 ; |hOwner = NULL
0001100E |. E8 09000000 CALL <JMP.&user32.MessageBoxA> ; \MessageBoxA
00011013 |. 6A 00 PUSH 0 ; /ExitCode = 0
00011015 \. FF15 00200100 CALL NEAR DWORD PTR DS:[<&kernel32.Ex>; \ExitProcess
0001101B CC INT3
0001101C $- FF25 08200100 JMP NEAR DWORD PTR DS:[<&user32.Mess>; user32.MessageBoxA
看见没ExitProcess的调用汇编代码成了最佳调用了。
新加的着3行代码,我也是网上抄下来的,请高手看见的帮忙解释下。
我还发现了一个网站 http://www.japheth.de/JWasm.html 这个网站提供了一套自己修改过的.inc文件,而且使用整个代码里只需要include 他们的windows.inc文件。
编译生成后就是优化了的call代码。
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.686
.model flat,stdcall
option casemap:none
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 文件定义
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include windows.inc
includelib user32.lib
includelib kernel32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data
szCaption db 'A MessageBox !',0
szText db 'Hello, World !',0
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
start:
invoke MessageBox,NULL,offset szText,offset szCaption,MB_OK
invoke ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end start
编译后的反汇编:
00401000 >/$ 6A 00 PUSH 0 ; /Style = MB_OK|MB_APPLMODAL
00401002 |. 68 00304000 PUSH Hello.00403000 ; |Title = "A MessageBox !"
00401007 |. 68 0F304000 PUSH Hello.0040300F ; |Text = "Hello, World !"
0040100C |. 6A 00 PUSH 0 ; |hOwner = NULL
0040100E |. FF15 08204000 CALL NEAR DWORD PTR DS:[<&user32.Mess>; \MessageBoxA
00401014 |. 6A 00 PUSH 0 ; /ExitCode = 0
00401016 \. FF15 00204000 CALL NEAR DWORD PTR DS:[<&kernel32.Ex>; \ExitProcess
2个call完全优化了。
不过要注意,用他们这个inc,不能使用vs2008自带的ml否则编译就报错,可以用RadASM里自带的masm 编译。之需要把参数/I 指向下载的win32inc的include就可以了。具体功能怎么实现的,我是不理解,还得请高手们帮忙看看。