用VC进行64位编程

分类: C/C++2014-04-30 15:14 532人阅读 评论(0) 收藏 举报

本文转自:http://www.usidcbbs.com/read-htm-tid-5247.html

 
献给c/c++的同学。它包括创建一个64位安全的应用程序或者是从32位迁移到64系统的所有步骤。该介绍一共包括28课,涉及的内容有64位系统,64位应用程序的构建,如何找64位代码的问题和如何优化。
第一课:64位的系统是什么
  在写这个课程的时候,有2个流行的微处理器的64位架构:IA64和Intel64.
  1、IA-64:是Intel和Hewlett Packard 共同研发64位处理。他是在安腾和安腾2微处理器上实现的。要了解更多关于IA64的架构请看维基的Itanium(http://en.wikipedia.org/wiki/Itanium)
  2、Intel 64(EM64T / AMD64 / x86-64 / x64):兼容x86的一个架构,他有很多的名字,入x86-64,AA-64,AMD64,yamhill技术,EM64T,IA-32e,Intel64,x64。他们指的都是同一件事情。详情可看x86-64(http://en.wikipedia.org/wiki/X86-64
  接着我们就要说IA-64和Intel64这两种完全不同的,不兼容,微处理架构。考虑到Intel64(x64/AMD64)架构师windows软件开发者最床用的,因而我们后面提到的windows操作系统,指的是Intel64架构下的65位应用程序。例如:windows XP的x64版的,vista X64,WIN7 x64。我们简称他们为win64.
Intel 64架构
  以下的内容是根据“AMD64 Architecture Programmer's Manual. Volume 1. Application Programming”第一卷来写的
  Intel64是一个过时的x86架构的一个简单而强大的扩展,并向后兼容x86.他添加了64位地址空间,并且支持过时的16位和32位的应用程序代码和操作系统;你也可以不需要重新编译他们来保证在Intel64上运行。
  64位架构的决定是源于“应用程序需要大的地址空间”的这个需求。首先,他可以诞生出高性能的服务器、数据管理器、CAD、游戏等。应用程序能从64位地址空间里获得更多的寄存器和地址空间。过时的x86只有少数的寄存器从而限制了计算机的性能。x64的寄存器的增加有利于提高应用程序的性能。
x86-64的主要优点如下:
  *64位的地址空间
  *扩展的寄存器集合
  *熟悉的命令集合
  *32位应用程序可以在64位的操作系统下启动
  *兼容32位的操作系统
64位操作系统
  几乎所有的现代的操作系统都有Intel64的版本。例如MS的Win XP x64。大的Unix开发者也会推出64位的版本,例如LInux Debian 3.5 x86-64.但是他并不意味着该操作系统的所有代码都是64位的。操作系统的部分代码和很多应用程序仍然是32位不需要改变,因为Intel64是向后兼容的。因而,64位的版本用一个特殊的模式WoW64(windows-on-windows 64)来处理32位应用程序,使之能运行在64位操作系统上
地址空间
  尽管64位处理器理论上能够寻址2的64次方的字节的地址,Win64现在只支持16T字节(也就是2的44次方)。因为当代的处理器只能获取到1T字节(2的40次方)的物理内存。架构最多能够扩展到2^52字节的空间(不是依靠硬件哦)。而且这种情况下你需要极多数量的页表来实现它。
  除了这个限制外,每个特定版本的64位windows能获得的内存大小是依赖于MS的商业原因的。不同的windows版本有不同的限制,如下表

Win64程序模型
  和win32类似,win64的页大小也是4KB。地址空间的起始的64KB从来都不能使用,因而正确的最低的地址是0X10000.不像win32,系统的dll能获得比4GB更多的空间。
  Intel 64的编译器有一个特性:他们充分利用寄存器来作为参数传给函数,而不是用栈空间。这就使win64架构的开发者摆脱调用约定(calling convertion)的概念。在win32里,你会用到各种调用约定,如__stdcall,__cdecl,__fastcall等。在win64里,只有一种调用约定。以下是通过寄存器来传递4个整数类型的例子:
  *RCX:第一个参数
  *RDX:第二个参数
  *R8:第三个参数
  *R9:第四个参数
  参数里开头的4个整数会这样传给栈。传递浮点数参数时,使用的是XMMO-XMM3寄存器。
  调用约定的不同导致在一个程序里不可能既使用64位的代码,又使用32位的代码。换句话说,如果应用程序是以64位的模式编译的,所有的dll都必须是64位的。
  通过寄存器传递参数是让64位程序比32位快的一项改革。你通过使用64位的数据类型也许能获得额外的性能。我们接下来要讲的第二课就是:在64位的windows环境下使用32位的应用程序
64位windows环境下支持32位应用程序
在我们开始讨论64位程序开发前,我们先说说64位windows上对32位应用程序的向后兼容性。向后兼容是通过WoW64来实现的。
  WoW64(Windows-on-windows 64-bit)是windows操作系统的一个子系统,它允许你在所有的64位的windows上执行32位的程序。
  WoW64不支持如下的程序:
  *16位操作系统上编译的程序
  *为32位操作系统编译的内核级程序
间接的消耗
  不同的处理器架构有不同的WoW64。例如:Intel 安腾二处理器上的64位的windows用WoW64来模拟x86的指令。这种模拟和Intel64架构上的WoW64相比,是会大量占用资源的,因为当执行32位程序时,系统不得不从64位模式切换到兼容模式。
  Intel65(AMD64/X64)上的WoW64不需要模拟指令。WoW64子系统只用通过在32位应用程序和64位windows API之间增加一层,来模拟32位环境。有些地方这一层会很薄,其他地方则会厚一些。一般来说,因为这一层,你会损失2%的性能。2%虽然不是很多,但是还是请记住32位的应用程序在64位的windows上会比32位的windows慢一些。
  64位代码的编写不仅仅让你避免使用WoW64,同时也让你获得更好的性能。这是因为64位架构的不同,比如说增加了更多的寄存器。一般情况下,你的程序在重新编译后可能会提升5~15%的性能(不太相信)。
64位环境下可以支持32位程序的好处
  尽管因为WoW64,32位程序在64位环境下效率会比32位环境下低一些。但是32位程序仍然能从中获得好处。大家知道,可以通过切换“/LARGERADDRESSAWARE:YES”来编译一个程序,使他在32位windows上能分配多达3G的内存。同样的方式,32位程序在64位系统上也能分配多达4G的内存(事实上通常只有3.5G)
重定向
  WoW64子系统通过重定向文件和寄存器的调用来隔离32位程序。这样他可以阻止32位程序来方位64位程序的数据。例如,一个32位程序从“%systemroot%System32”目录下启动一个DLL文件,有可能寻址到一个64位的dll(和32位的程序不兼容)。为了避免这种情况,WoW64子系统重定向,使从“%systemroot%System32”文件夹下获取,重定向为“%systemroot%SysWOW64”文件夹。这种重定向的方式能让你避免发生兼容性错误,因为32位应用程序需要一个32位的Dll。
  要了解更多的文件系统和寄存器重定向的机制,可以参看MSN的“Runnint 32-bit Applications”
为什么32位的dll不能用于一个64位的程序?有方法可以避开这个限制么?
  从64位的程序中加载一个32位的DLL,并执行它的代码这是不可能的事情。之所以不可能是源于64位系统的设计。没有诡计和明文的方法帮助。如果你真的要这么做,你必须加载和初始化WoW64。你可以从“Why can't you thunk between 32-bit and 64-bit Windows”里获得更多的信息。你也可以读读“Accessing 32-bit Dlls from 64-bit code”
  但还有一种从64位的程序中加载一个32位的DLL的方法。你用LOAD_LIBRARY_AS_DATAFILE来调用LoadLibraryEx就可以了。
逐步的抛弃32位软件的支持
  微软将会通过在一些Windows操作系统的版本里逐步取消32位程序的支持,鼓励迁移到64位系统上。当然这是一个很漫长的过程。
  许多管理员了解叫做Server Core的操作系统的服务器版本的安装和操作模式。这种模式出现的原因之一是服务器上喜欢用linuxs的人,希望MS的服务器操作系统的安装也能没有GUI,而是通过命令行的方式。
  这种能力(Server Core installation)是在windows server 2008里出现的。在安装windows server 2008R2(Server Core)时,你可以选择是否支持32位应用程序。
  在通常(完全安装)模式下,默认是支持32位应用程序的,但不是在Server Core。
  64位的趋势越来越明显,越来越多的操作系统版本支持创建64位的应用程序的。
移植代码到64位系统上
开始课程前,先提个问题:“重新编译成64位的工程有多合理呢?”。如果你知道,那么这一课可以不用看了。如果你不知道,那么可以花点时间看看。
  以下的一些因素可以帮你选择,要不要重新编译成64位的工程
应用程序的生命周期
  一个应用程序生命周期很短时,你不应该把它移植到64位系统上。WoW64子系统允许在64位windows系统上使用过时的32位的应用程序。如果你2年内就要停止对你的程序的维护,那么让你的程序变成64位的是不合理的。毕竟操作系统过渡到64位windows版本是一个很缓慢和平衡的过程。也许大多数你的用户用你写的32位的程序已经够用了。2009年里大多数用户还是工作在32位的操作系统上。不过32位的程序将随着时间会逐渐过时。
  如果你计划延长你的程序产品的开发和维护,那么你应该考虑用它的64位的版本。毕竟以后64位的操作系统会越来越广泛。
应用程序的性能要求
  在重新编译成64位的程序后,在64位的操作系统上该程序会使用大量的内存,并且也会获得5~15%的速度提升。其中5%~10%的提升是由于64位处理器的架构特点引起的,另外1~5%的提升是因为没有使用WoW64(它要负责转换32位应用程序的调用使之可运行在64位的操作系统上)。
  例如,Adobe公司说新的64位“Photoshop CS4”会比32位的版本快12%
  需要大量的内存的应用程序理应得到大的性能的提升。例如图形编辑器,CAD系统,数据等。因为把所有的数据储存在内存中,可以避免从硬盘上加载他们,从而提高程序运行的速度。
  例如,拿Alfa-Bank,一个集成了安腾2的IT的基础系统。随着投资业务的增多,导致该系统不能再承载日趋增多的客户数目。经分析,该系统的瓶颈是对于处理器的性能无能为力,因为他是32位的系统架构,不允许使用超过4GB的地址空间。但是数据库的大小是9GB。这样就导致了在输入输出子系统的负载问题。Alfa-Bank最后决定买一组2个4四核的12GB的安腾2的服务器。这个决定使他们获得了性能的提升,消除了瓶颈。
在程序里用第三方的库和第三方库的依赖性
  在计划开发64位程序以前,请辨别出你的程序是否使用了64位的库和组件。你应该找出使用64位版本的库。如果没有支持64位的库,你应该寻找到支持64位操作系统的第三方库。
  如果你开发库,组件或者其他的东西给第三方的开发者去使用,你最好及时的增加64位的版本。否则,对64位感兴趣的用户将会寻找其他的方案。
  另外一个发表64位版本库的好处是:你也许可以把它作为一个独立的产品来售卖它。因而当需要创建32位和64位两个版本的应用程序的客户,会不得不购买者两个不同的产品。
16位应用程序
  如果你的解决方案仍然是16位模式的,你应该去除它。64位windows版本不支持16位应用程序。
工具箱
  如果你决定创建64位版本的程序,你还需要使用一些工具帮你处理可能遇到的问题。
  最明显最严重的问题是没有64位的编译器。2009年时仍然没有一个64位的c++编译器。你不能避开这个问题,除非你重写整个程序,例如,Microsoft visual studio。
创建64位的配置
编译器
  受限你要确保你用的Visual studio是可以构建64位代码的。如果你想要开发64位程序可以用最新的Visual Studio 2008,这里有个表帮助你理解Visual Studio

  如果你的Visual Studio允许创建64位的代码,你应该检查你是否安装了64位编译器。如图,现实你的visual studio2008组件里没有安装64位的编译器

创建64位的配置
  用VC2005/2008来创建64位程序是一个相当简单的过程。共4步,如下:
  步骤一:
  打开配置管理器

  第二步:
  选择支持新的平台

  第三步:
  选择64位平台和32位版本作为基础。VC环境会自动修改成可兼容的模式

  注:选择x64作为平台,加载win32的配置
  第四步:
  选择64位配置版本开始编译64位程序

修改参数
  如果你在编译64位程序时,请注意修改你的栈的大小。如果你的工程32位版本用的是默认的站的大小1MB,你不得不在64位版本里改成2MB。这不是必须的,但是它可以预先让你远离可能出现的问题。如果你用栈的大小不是默认(默认填0),你应该让它在64位的版本里编程2倍的大小。修改栈的参数的方法如下:(栈参数包括Stack Reserve Size和Stack Commit Size)

接下来是什么?
  创建了64位的程序并不意味着它就能很好的编译和工作。编译和检查错误的过程都将在下一个课讨论
构建64位程序
由于每个为64位系统构建的64位应用程序都有它唯一的设置,我们只描述通用的步骤。
库(libraries)
  在试图构建你的64位程序前,请球包你需要的64位库的版本和路径都安装正确。例如:32bit和64bit库文件应该是在不同的目录下。
  注意:如果库是以源码的形式,那么64位的程序用到它时,该库必须有64位的配置。
汇编程序(Assembler)
  VC不提供64位内联的汇编程序。你必须用一个额外的64bit的汇编程序(例如MASM)或者改写c/c++的汇编代码
兼容性错误和警告的例子
  在开始编译程序时,你也许会遇到很多关于"类型转换"的兼容性错误或警告。例如:
  void foo(unsigned char) {} 
  void foo(unsigned int) {}  
  void a(const char *str) 
  { 
     foo(strlen(str)); 
  } 
  这段代码在32位模式下是编译成功的,但是有警告:
   warning C4267: 'argument' : conversion from 'size_t' to 'unsigned int', possible loss of data
  但是在64位模式下,VC编译器会有如下错误:
   error C2668: 'foo' : ambiguous call to overloaded function 
     .\xxxx.cpp(16): could be 'void foo(unsigned int)' 
     .\xxxx.cpp(15): or 'void foo(unsigned char)' 
     while trying to match the argument list '(size_t)' 
  strlen()函数返回的类型是size_t。在32位系统上,size_t与unsigned int一致,因而编译器会选择"void foo(unsigned int)"函数。在64位模式下,size_t就不是unsigned int了,size_t变成了64位的,而unsigned int还是32位。因而,编译器就不知道该选哪个foo()函数了。
  现在让我们看看用64位编译代码时,VC的另一个警告
  CArray<char, char> v; 
  int len = v.GetSize();  
  warning C4244: 'initializing' : conversion from 'INT_PTR' to 'int', 
                  possible loss of data 
  GetSize()函数返回的是INT_PTR,在32位的代码里,是和int一致的。但是在64位的代码里,INT_PTR是64位,因而会被隐式的转换成32位的int。这样就会有bit位的丢失。一个隐式的类型转换也许会产生一个错误,例如当一个数组的数目扩展到INT_MAX。为了消除警告和可能的错误,你应该使用INT_PTR或者ptrdiff_t来表示长度。
  不要尝试屏蔽警告。因为你可能会隐藏一个错误,而且也让他更难被发现。你将会在接下来的课程里学习64位下的错误类型和检查纠正他们的方法。
size_t和ptrdiff_t
  size_t和ptrdiff_t,是64位编程里和我们最紧密的两种类型,也常常产生兼容性的错误。如果你用VC,这些类型是集成进VC的,因而你不需要库文件。但是如果你用GCC,你将需要用头文件"stddef.h"
  size_t是c/c+=的无符号整数。他是sizeof操作返回的结果的数据类型。这个类型的大小是可选的,以便它能够储存一个任意类型的数组的最大的size。例如:size_t在32位系统上是32位,在64位系统上是64位。换言之,你可以用size_t来安全的存储一个指针。不过有一种情况例外,就是指向类函数的指针。size_t常常被用于循环里的计数,数组的索引,大小的表示,和地址之间的计算。下面的几种类型和size_t是类似的作用:SIZE_T, DWORD_PTR, WPARAM, ULONG_PTR。尽管你可以把指针存储在size_t里,但是最好还是用另一个无符号的整数类型uintptr_t,因为他的名字更能反映它的功能。size_t和uintptr_t实质上是一样的。
  ptrdiff_t是c/c++的有符号类型。32位系统上他是32位,64位系统上他是64位。像size_t一样,ptrdiff_t能用于安全的存储一个指针(除了指向类函数的指针)。ptrdiff_t是两个指针相减后的结果所用的类型。ptrdiff_t常常被用来做循环的计数、数组的索引和地址之间的计算。下面的几种类型和它是类似的作用:SSIZE_T, LPARAM, INT_PTR, LONG_PTR。ptrdiff_t和intptr_t是同义词,不过intptr_t更能表现出它储存的是一个指针。
  size_t和ptrdiff_t都可以做地址运算。以前int被认为和机器字一致,能用来表示指针或者对象的索引和大小。因而地址运算使用int或者unsigned来表示。以前常见的代码如下:
  for(int i = 0; i < n; i++)
    a = 0;
  但是当处理器的能力变强大后,上述这样的代码就可能不合理了。需要改成下面这种,在64位上
  for(ptrdiff_t i = 0; i < n; i++)
    a = 0;
  这段代码更安全,性能也很好,可移植。下一课我们会具体介绍。
  size_t和ptrdiff_t,是memsize类型。memsize表示所有能储存指针大小或者是储存最大的数组索引的类型。memsize表示一类数据,它在32位机器上是32位的,在64位机器上是64位的。例如:size_t,ptrdiff_t,指针,SIZE_T,LPARAM
64位代码里常见的错误
即便你修改了编译时的所有错误和警告,也不意味着你的64位应用程序就能良好工作。因而我们需要学会如何诊断64位错误。另外i,不要依赖于切换到/WP64,虽然它常常被描述为是查找64位代码问题的好工具。
/WP64切换
  切换到/WP64允许程序员找到一些编译64位程序的错误。检查是用下述方式实行的:在32位的代码里,类型用关键字_w64来修饰,这样在检查时会被作为64位的类型来解释。例如:
  typedef int MyInt32;


  #ifdef _WIN64


    typedef __int64 MySSizet; 

  #else 

    typedef int MySSizet; 

  #endif 

  void foo() { 

    MyInt32 value32 = 10; 

    MySSizet size = 20; 

    value32 = size; 

  }


  表达式"value32 = size",将会在64位系统上截断size,因而可能会有个错误。我们想要诊断这个错误。但是当我们试图去编译32位程序时,都是正确的,而且没有警告。
  为了把程序移植到64位系统上,我们需要切换/Wp64,并且在32位程序里增加关键字__w64。代码如下:
typedef int MyInt32; 
  #ifdef _WIN64

    typedef __int64 MySSizet; 
  #else 
    typedef int __w64 MySSizet; //Add __w64关键字
  #endif 
  void foo() { 
    MyInt32 value32 = 10; 
    MySSizet size = 20; 
    value32 = size;   //C4244 64-bit int assigned to 32-bit int
  }
  这段代码编译完,我们会获得C4244的警告,帮助我们写出可移植到64位平台的代码。
  注意:/Wp64在64位兼容模式下会被忽略,因为所有的类型已经有了确定的size,并且编译器会进行必要的检查。所以,我们即便禁用了/Wp64,也能在编译64位版本时获得C4244的警告。
  所以,切换到/Wp64帮助32位应用程序的开发者位64位编译器做准备。切换到/Wp64,构建64位代码时,会产生编译警告或错误。你需要修改它。
  另外,/Wp64在VC2008里会被弃用,因为我们早该编译64位的应用程序,而不是为可移植到64位上做准备。
64-bit错误
  当我们谈论64位错误时,我们是指一段代码在32位下工作良好,但是编译成64位时却产生错误。64位错误常常以如下形式出现:
  *错误的假定类型的大小(例如:假定指针的大小总是4字节)
  *处理64位系统上超过2GB的大小的数组
  *数据的读写时
  *bit操作的代码
  *复杂的地址计算
  *过时的代码
  事实上,所有的错误都发生在当他重新编译为64位系统时。我们的课程目的是为了总结一些64位的错误,帮助你发现和消除他们。
64位错误的例子
  我们将用2个例子来帮助你弄明白64位的错误是什么。
  第一个底子是用魔术常量数字"4",来表示指针的大小(在64位的代码下是错误的)。注意这段代码在32位版本上工作良好,也不会有任何警告
  size_t pointersCount  = 100;
  int **arrayOfPointers = (int **)malloc(pointersCount * 4)
  第二个例子是数据读取的机制。这段代码在32位版本上是正确的,但是64位的版本时,这段代码读32位应用程序保存的数据会失败
  size_t PixelCount;
  fread(&PixelCount, sizeof(PixelCount), 1, inFile)
  接下来的课程我们会讨论更多的64位错误的类型和代码例子。不过人们常常会辩解说当移植代码到非64位架构上也会出现很多错误。
  是的,但是我们的目标不仅仅是学习可移植代码的问题。我们还将解决一些问题,来帮助开发者掌握64平台。
  当我们谈到64位错误时,我们是指这样一类代码:它在32位系统上是正确的,但是移植到64位处理器上却是错误的
检测64位错误的问题
有很多技术来检测程序代码里的错误。让我们来看看最常用的检测方法
Code Reviewer
  这个最古老,最可靠和最经得起检验的找错误的方法是code Review。这个方法依赖于让一些开发者一起阅读代码。不幸的是,这种方法不能应用于针对现代的巨大规模的程序的大规模的测试。
  code review也许是个好的方法,能避免64位错误。但是这个方法很昂贵。
静态代码分析
  静态代码分析能帮助开发者减少code review的量和时间。相当多的人喜欢静态代码分析,并且提供大量的方法来检查潜在的问题和风险。静态代码分析的好处是它的可扩展性。你能在一个合理的时间段测试这个程序。并且静态代码分析可以帮助你侦测很多写代码时的错误。
  静态代码分析是侦测64位错误的最常用的方法。以后,当我们讨论64位错误时,我们将会告诉你怎样通过PVS-STUDIO里的Viva64来检查这些错误。下一课我们会学到更多的关于PVS-Studio的静态代码分析的技术
白盒方法
  通过白盒,我们能弄懂函数执行的各个不同代码分支。通过分析和测试我们能覆盖更多的代码。当然,白盒测试作为简单的调试引用程序的方法能找到一些错误。毕竟单步调试是很昂贵的(晕啊,这里的白盒测试居然是单步跟踪!)
黑盒方法(单元测试)
  黑盒方法会显得更好些。这里是指单元测试。单元测试的原则是为独立的单元和方法写一个测试集合,来检查他们的操作的主要模式。一些作者更喜欢单元测试是因为他依赖于程序组织结构的知识。但是我们认为函数测试和单元测试应该被认为是黑盒的,因为单元测试不应该重视函数内部的实现。当测试是在函数写之前就开发了的话,那么单元测试应该是提供一个关于函数的增量的保证。
  单元测试已经证明是很有效的。单元测试的一个优点是你可以独立于你的开发程序来检查程序发生的变化是不是正确的。这就可能让你的测试在很短的时间内跑完,这样开发可以发现错误后立刻修改它。如果一次跑所有的测试不可能,那么可以把长时间的测试分开来执行,例如在晚上跑。这样第二天就可以发现错误了。
  当用单元测试来检查64位错误时,你也许会遇到一些不愉快的事情。为了进行更快的测试,程序员会试图用少量的记录和数据。例如,当你为从一个数组里进行查找功能的函数,设计一个测试时,他不关心是有100项数据,还是10000000项数据。一百项数据也许足够,但是如果函数处理10000000时,他的速度就会极大的衰减。但是如果你想设计一个有效地测试,来检测你的函数在64位系统上是否正确时,你也许需要多余40亿的数据项。你设想这个函数用100项和几十亿项的区别。例如下面的例子:
bool FooFind(char *Array, char Value, size_t Size)

  { 
      for (unsigned i = 0; i != Size; ++i) 
        if (i % 5 == 0 && Array == Value) 
          return true; 
      return false; 
  } 
  #ifdef _WIN64 
    const size_t BufSize = 5368709120ui64; 
  #else 
    const size_t BufSize = 5242880; 
  #endif 
  int _tmain(int, _TCHAR *) { 
    char *Array =  (char *)calloc(BufSize, sizeof(char)); 
    if (Array == NULL) 
      std::cout << "Error allocate memory" << std::endl; 
    if (FooFind(Array, 33, BufSize)) 
      std::cout << "Find" << std::endl; 
    free(Array); 
  }  
  这个错误在于用循环计数使用了Unsigned。因此这个计数在64位系统上会溢出,产生死循环。
  看到这个例子时,你会发现如果你的程序在64位操作系统上处理大量的数据时,你是不能依靠过时的单元测试的。你必须在测试里补充大数据量的处理。
  不幸的是,写新的单元测试是不够的。我们虽然增加了处理大量数据的用例,但是它的执行时间也会变成。这样你的测试时间可能会增加到超过一天。这个也是我们在修改关于64位版本的测试时需要考虑的问题
手工测试
  这个方法被认为是开发的最后阶段。手工测试必须存在因为自动化测试不能检测所有错误。但是你不能完全依赖他。
静态代码分析来检查64位错误
静态代码分析
  静态代码分析是一项依赖于学习标记的代码片段来检查错误的技术。标记的代码片段通常包括一些特殊类型的错误。换句话说,一个静态代码检查工具检查的是有错误倾向或者有坏的格式的代码。这样的代码片段是开发者要学习和决定是否要修改的。
  静态分析器也许是通用的目的(例如,prefast, pc lint, Parasoft c++test)和特殊的用于查找特定类型错误的目的(例如Chord来检查并行的java程序)。通常静态代码分析工具时相当昂贵的,并且要求你们学会如何使用他们。他们常常提供相当复杂和灵活的子系统。因为静态代码检查工具可能会被很多公司使用和定制规则,来提高软件的质量,因而他以复杂为代价,让开发者在早期阶段检查很多错误。静态代码分析还能帮助管理者更好的管理年轻的新手。
  静态代码分析的主要优点是减少了消除缺陷的代价。早期一个错误被检测,修复它的代价就会越低。因而根据《代码大全》里,在测试阶段修复错误的成本是设计阶段的5倍:

  因而静态代码检查工具减少了开发成本,因为他在设计代码阶段侦测除了很多错误。
静态分析来检查64位错误
  静态代码分析的优点如下:
  1、你能检测整个代码。分析器能提供代码的接近完全的覆盖。它保证了你的代码在移植到64位代码以前,已经被检查过一次。
  2、可扩展性。无论程序大小,静态代码分析都可以使用。你可以在开发者之间很容易的传播。
  3、当刚开始开发一个工程时,开发者会忽略可能的问题,因为不了解64位的特性。分析器能指出危险的区域,并告诉你存在整个问题
  4、错误修复的代价降低
  5、无论你是移植代码到64位系统上,或者是开发一个64位的代码,你都可以有效的使用静态分析工具
PVS-Studio的Viva64分析器
  PVS-Studio是一个的静态分析器的包,它包括一个特殊的静态分析器Viva64来检查64位错误.PVS-Studio分析器是用于windows平台的。它集成在VC2005\2008\2010里。PVS-Studio的接口允许你过滤警告,保存和加载警告条目

  分析器的系统要求和VC的要求是一致的:
  *操作系统:Win7/Win2000/XP/2003/Vista/2008 x86或x64。注意你的操作系统不需要64位的。
  *开发环境:VC 2005/2008/2010(
Standard Edition, Professional Edition, Team Systems
)。你必须有一个组件"x64 compliers and tools"已经安装了,才能测试64位程序。VC的版本里已经集成了,安装时可以选择安装它。
  *硬件:PVS-Studio能在不少于1GB的系统上工作(推荐是2GB或者更多)。分析器能利用多核(核越多,则越快)
魔法数字

留意代码里的魔术数字,如果魔术数字是用来参与地址计算,或者大小,bit操作时,要特别留意

  下面的表列出常见的会影响可移植的魔术数字

 

Table 1 - The basic magic numbers which are dangerous when porting 32-bit applications to a 64-bit platform

小心检查你的代码,尽量用sizeof()或者<limits.h><inttypes.h>里的特殊值来取代你的魔术数字

下面是一些和魔法数字有关的错误例子,最常见的错误是写类型大小时,如下:

错误:

  1. 1) size_t ArraySize = N * 4; 
  2.    intptr_t *Array = (intptr_t *)malloc(ArraySize);

 正确:

  • 1) size_t ArraySize = N * sizeof(intptr_t); 
  •    intptr_t *Array = (intptr_t *)malloc(ArraySize);
 

*********

 错误:

  1. 2) size_t values[ARRAY_SIZE]; 
  2.    memset(values, ARRAY_SIZE * 4, 0); 

正确: 

  1. 2) size_t values[ARRAY_SIZE]; 
  2.    memset(values, ARRAY_SIZE * sizeof(size_t), 0);

memset(values, sizeof(values), 0); //preferred alternative

*********

错误:

  1. 3) size_t n, r; 
  2.    n = n >> (32 - r); 

 正确:

  1. 3) size_t n, r; 
  2.    n = n >> (CHAR_BIT * sizeof(n) - r);  

有时我们需要一个特定的常量,例如:当我们用size_t类型的变量来填充低位的4个字节。在32位程序里,常常如下定义:

  1. // constant '1111..110000' 
  2. const size_t M = 0xFFFFFFF0u; 

在64位系统上这就是错的。要发现类似的错误要花费大量的时间,而且不幸的是,没有其他途径可以查找和纠正这种类型的代码,除非用#ifdef或者一个特殊的宏

  1. #ifdef _WIN64 
  2.   #define CONST3264(a) (a##i64) 
  3. #else 
  4.   #define CONST3264(a)  (a) 
  5. #endif 
  6.  
  7. const size_t M = ~CONST3264(0xFu); 

有时'-1'备用来表示错误码,或者特殊的标志,常常被写为"0xffffffff"。这种表达式在64位平台上是错误的,你应该显示的定义该数值位-1.下面就是一个错误的将0xffffffff作为错误码的代码例子:

  1. #define INVALID_RESULT (0xFFFFFFFFu) 
  2.  
  3. size_t MyStrLen(const char *str) { 
  4.   if (str == NULL) 
  5.     return INVALID_RESULT; 
  6.   ... 
  7.   return n; 
  8.  
  9. size_t len = MyStrLen(str); 
  10. if (len == (size_t)(-1)) 
  11.   ShowError();

为什么是错的呢?因为"(size_t)(-1)"在64位平台上,它的数值,会转换成大类型的有符号数。-1是0xffffffffffffffff,而不是0xffffffff;因而如果你要用宏的话,那么请这样使用:

  1. #define INVALID_RESULT (size_t(-1)) 
  2. ... 

和0xffffffff有关的错误例子还有:

  • hFileMapping = CreateFileMapping ( 
  •     (HANDLE) 0xFFFFFFFF, 
  •     NULL, 
  •     PAGE_READWRITE, 
  •     (DWORD) 0, 
  •     (DWORD) (szBufIm), 
  •     (LPCTSTR) &FileShareNameMap[0]); 
 

这里0xffffffff也会导致64位系统的错误。

让我们来看下一个错误的0xffffffff例子。

  • void foo(void *ptr) 
  •   cout << ptr << endl; 
  • int _tmain(int, _TCHAR *[]) 
  •   cout << "-1\t\t"; 
  •   foo((void *)-1); 
  •   cout << "0xFFFFFFFF\t"; 
  •   foo((void *)0xFFFFFFFF); 
  • }

32位的结果

  • -1              FFFFFFFF 
  • 0xFFFFFFFF      FFFFFFFF 

64的结果

  • -1              FFFFFFFFFFFFFFFF 
  • 0xFFFFFFFF      00000000FFFFFFFF
变参的函数

64位上不正确的使用printf和scanf的例子

例一:

  • const char *invalidFormat = "%u"; 
  • size_t value = SIZE_MAX; 
  • printf(invalidFormat, value); 

例二:

  • char buf[5]; 
  • sprintf(buf, "%p", pointer); 

  第一个例子里,64位平台上size_t不等价于unsigned类型,如果value的值大于UINT_MAX时,会打印错误

  第二个例子里,指针的大小可能比32位大。因而64位平台上它会导致溢出

  变参的函数被不正确使用是常见的错误,不仅仅是64位平台上才会出现。因此你可以尝试用cout代替print,boost::format或std::stringstream来代替sprintf。例如对于size_t,

  windows应用程序如下: 

  1. size_t s = 1;  
  2. printf("%Iu", s);

      linux应用程序如下:

  1. size_t s = 1; 
  2. printf("%zu", s);

同样在使用sccanf时,你也需要注意类似的关于size的使用问题。为了可移植,你的代码需要如下:

  • // PR_SIZET on Win64 = "I" 
  • // PR_SIZET on Win32 = "" 
  • // PR_SIZET on Linux64 = "z" 
  • // ... 
  • size_t u; 
  • scanf("%" PR_SIZET "u", &u); 
 
开始进行64位Windows 系统编程之前需要了解的所有信息
本文讨论:
64 位版本 Windows 的背景信息
适当地利用 x64 体系结构
使用 Visual C++ 2005 进行 x64 开发
针对 x64 版本的调试技术
本文使用以下技术:
Windows、Win64、Visual Studio 2005


本页内容
x64 操作系统
适当利用 x64
使用 Visual C++ 进行 x64 开发
使代码与 Win64 兼容
调试
关于托管代码
小结
使用 Windows® 先锋产品的乐趣之一是能够探究新技术以了解它的工作方式。实际上,我不太喜欢使用操作系统,直到对其内部结构有了一点深入了解之后。因此,当 Windows XP 64 位版本和 Windows Server® 2003 出现时,我简直快完蛋了。
Win64 和 x64 CPU 体系结构的优点是:它们与其前任完全不同,但不需要很长的学习过程。尽管开发人员认为迁移到 x64 只是一个重新编译的过程,但事实是我们仍然要在调试器中花费很多时间。拥有 OS 和 CPU 的应用知识十分宝贵。
本文,我将本人在 Win64 和 x64 体系结构方面的经验归结为一个高手 Win32® 程序员迁移到 x64 必备的几个要点。我假设您了解基本的 Win32 概念、基本的 x86 概念以及为什么代码应该在 Win64 上运行。这使我可以将关注的重点放在更重要的内容上。通过本概述,您可以在已经理解的 Win32 和 x86 体系结构基础上了解到一些重要差异。
有关 x64 系统的一个优点是:与基于 Itanium 的系统不同,您可以在同一台计算机上使用 Win32 或 Win64,而不会导致严重的性能损失。此外,除了 Intel 和 AMD x64 实现之间的几个模糊差异,与 x64 兼容的同一个 Windows 版本应该能够在这两个系统上运行。您不需要在 AMD x64 系统上使用一个 Windows 版本,在 Intel x64 系统上使用另一个版本。
我将讨论分为三大领域:OS 实现细节、适当地利用 x64 CPU 体系结构以及使用 Visual C++® 进行 x64 开发。
x64 操作系统
在 Windows 体系结构的所有概述中,我一般喜欢从内存和地址空间开始。尽管 64 位处理器在理论上寻址 16 EB 的内存 (264),但 Win64 目前支持 16 TB(由 44 位表示)。为什么不能在计算机中加载到 16 EB 以使用全部 64 位呢?原因有很多。
对初级用户而言,当前的 x64 CPU 通常只允许访问 40 位(1 TB)的物理内存。体系结构(不包括当前硬件)可以将其扩展到 52 位(4 PB)。即使没有该限制,映射如此大内存的页表大小也是巨大的。
与 Win32 中一样,可寻址范围分为用户模式区和内核模式区。每个进程都在底部获得其唯一的 8 TB,而内核模式的代码存在于顶部的 8 TB 中,并由所有进程共享。不同版本的 64 位 Windows 具有不同的物理内存限制,如图 1 和图 2 所示。
同样,与 Win32 中一样,x64 页大小为 4 KB。前 64 KB 的地址空间始终不映射,因此您看到的最低有效地址应该是 0x10000。与在 Win32 中不同,系统 DLL 在用户模式的地址范围顶部附近没有默认的加载地址。相反,它们在 4 GB 内存以上加载,通常在 0x7FF00000000 附近的地址上加载。
许多较新的 x64 处理器的一个出色功能是:支持 Windows 用于实现硬件数据执行保护 (DEP) 的 CPU No Execute 位。x86 平台上存在许多错误和病毒,这是因为 CPU 可以将数据当作合法代码字节执行。CPU 在供数据存储使用的内存中执行从而可终止缓冲区溢出(有意或无意)。通过 DEP,OS 可以在有效代码区域周围设置更清晰的边界,从而使 CPU 在执行超出这些预期边界时捕获到该事件。这推动着为使 Windows 减少受到的攻击而付出的不懈努力。
在为捕获错误而设计的活动中,x64 链接器将可执行文件默认的加载地址指定为在 32 位 (4 GB) 之上。这可以帮助在代码迁移到 Win64 之后能够在现有代码中快速找到这些区域。具体说,如果将指针存储为一个 32 位大小的值(如 DWORD),那么在 Win64 版本中运行时,它将被有效地截断,从而导致指针无效,进而触发访问冲突。该技巧使查找这些令人讨厌的指针错误变得非常简单。
有关指针和 DWORD 的主题将在 Win64 类型系统中继续讨论。指针有多大?LONG 怎么样?那么句柄(如 HWND)呢?幸好,Microsoft 在进行从 Win16 到 Win32 的复杂转换时,使新的类型模型能够轻松地进一步扩展到 64 位。一般地,除了个别几种情况外,新的 64 位环境中的所有类型(除了指针和 size_t)均与 Win32 中的完全相同。也就是说,64 位指针是 8 字节,而 int、long、DWORD 和 HANDLE 仍然是 4 字节。在随后讨论进行 Win64 开发时,我将讨论更多有关类型的内容。
Win64 的文件格式称为 PE32+。几乎从每个角度看,该格式在结构上都与 Win32 PE 文件完全相同。只是扩展了少数几个字段(例如,头结构中的 ImageBase),删除了一个字段,并更改了一个字段以反映不同的 CPU 类型。图 3 显示已更改的字段。
除 PE 头之外,没有太多的更改。有几个结构(例如,IMAGE_LOAD_CONFIG 和 IMAGE_THUNK_DATA)只是将某些字段扩展到 64 位。添加的 PDATA 区段很有趣,因为它突出了 Win32 和 Win64 实现之间的一个主要差异:异常处理。
在 x86 环境中,异常处理是基于堆栈的。如果 Win32 函数包含 try/catch 或 try/finally 代码,则编译器将发出在堆栈上创建小型数据块的指令。此外,每个 try 数据块指向先前的 try 数据结构,从而形成了一个链表,其中最新添加的结构位于表头。随着函数的调用和退出,该链表头会不断更新。如果发生异常,OS 将遍历堆栈上的数据块链表,以查找相应的处理程序。我在 1997 年 1 月的 MSJ 文章中非常详细地描述了该过程,因此这里只做简要说明。
与 Win32 异常处理相比,Win64(包括 x64 和 Itanium 版本)使用了基于表的异常处理。它不会在堆栈上生成任何 try 数据块链表。相反,每个 Win64 可执行文件都包含一个运行时函数表。每个函数表项都包含函数的起始和终结地址,以及一组丰富数据(有关函数中异常处理代码)的位置和函数的堆栈帧布局。请参见 WINNT.H 和 x64 SDK 中的 IMAGE_RUNTIME_FUNCTION_ENTRY 结构,了解这些结构的实质。
当异常发生时,OS 会遍历常规的线程堆栈。当堆栈审核遇到每个帧和保存的指令指针时,OS 会确定该指令指针属于哪一个可执行的模块。随后,OS 会在该模块中搜索运行时函数表,查找相应的运行时函数项,并根据这些数据制定适当的异常处理决策。
如果您是一位火箭科学家,并直接在内存中生成了代码而没有使用基本的 PE32+ 模块,该怎么办呢?这种情况也包含在内。Win64 有一个 RtlAddFunctionTable API,它可让您告诉 OS 有关动态生成的代码的信息。
基于表的异常处理的缺点(相对于基于堆栈的 x86 模型)是:在代码地址中查找函数表项所需的时间比遍历链表的时间要长。但优点是:函数没有在每次执行时设置 try 数据块的开销。
请记住,这只是一个简要介绍,而不是 x64 异常处理的完整描述,但是很令人激动,不是吗?有关 x64 异常模型的进一步概述,请参阅 Kevin Frei 的网络日记项
与 x64 兼容的 Windows 版本不包含最新 API 的具体数量;大部分新的 Win64 API 都添加到针对 Itanium 处理器的 Windows 版本。简言之,现有的两个重要的 API,分别是 IsWow64Process 和 GetNativeSystemInfo。它们允许 Win32 应用程序确定是否在 Win64 上运行,如果是,则可以看到系统的真正功能。否则,调用 GetSystemInfo 的 32 位进程只能看到 32 位系统的系统功能。例如,GetSystemInfo 只会报告 32 位进程的地址范围。图 4 显示的 API 以前在 x86 上不可用,但可用于 x64。
尽管运行完全的 64 位 Windows 系统听起来很不错,但事实是,在某些情况下,您很可能需要运行 Win32 代码。为此,x64 版本的 Windows 包含 WOW64 子系统,以允许 Win32 和 Win64 进程在同一个系统上并行运行。但是,将 32 位 DLL 载入 64 位进程(反之亦然)则不受支持。(相信我,这是件好事。)您终于可以向 16 位旧式代码吻别了!
在 x64 版本的 Windows 中,从 64 位可执行文件启动的进程(如 Explorer.exe)只能加载 Win64 DLL,而从 32 位可执行文件启动的进程只能加载 Win32 DLL。当 Win32 进程调用内核模式(例如,读取文件)时,WOW64 代码会安静地截断该调用,并在适当的位置调用正确的 x64 等效代码。
当然,不同系统(32 位与 64 位)的进程需要能够互相通信。幸运的是,Win32 中您知道并喜爱的所有常规进程间通信机制也可以在 Win64 中工作,包括共享内存、命名管道以及命名同步对象。
您可能在想,"那么系统目录呢?同一个目录不能同时保存 32 位和 64 位版本的系统 DLL(例如,KERNEL32 或 USER32),不是吗"?通过执行可选择的文件系统重定向,WOW64 魔法般地为您解决了这个问题。来自 Win32 进程的文件活动通常转到 System32 目录,而不是在名为 SysWow64 的目录中。在内部,WOW64 会默默地更改这些请求以指向 SysWow64 目录。Win64 系统实际上有两个 \Windows\System32 目录 - 一个用于 x64 二进制文件,另一个用于 Win32 等效文件。
这看上去没什么,但会令人混淆。例如,我在某一点上使用了 32 位命令行提示(我自己并不知道)。当我针对 System32 目录中的 Kernel32.dll 运行 DIR 时,所得到的结果与我在 SysWow64 目录中执行相同操作后所得到的结果完全相同。我绞尽脑汁后才发现,文件系统重定向的工作方式就是这样。也就是说,即使我认为是在 \Windows\System32 目录中工作,但 WOW64 实际上已将调用重定向到 SysWow64 目录。顺便说一下,如果您确实希望从 x64 应用程序访问 32 位 \Windows\System32 目录,则 GetSystemWow64Directory API 会提供正确的路径。请一定阅读 MSDN® 文档,了解完整的信息。
除了文件系统重定向之外,WOW64 施加的另一个小魔法是注册表重定向。请考虑我前面提到的 Win32 DLL 不能载入 Win64 进程的内容,然后再考虑一下 COM 及其使用注册表加载进程内服务器 DLL 的情况。如果 64 位应用程序要使用 CoCreateInstance 创建一个在 Win32 DLL 中实现的对象,该怎么办呢?该 DLL 不能加载,对吗?WOW64 通过将来自 32 位应用程序的访问重定向到 \Software\Classes(以及相关的)注册节点,再一次节省了时间。实际结果是,Win32 应用程序的注册表视图与 x64 应用程序的不同(但大部分是相同的)。如您所料,OS 通过在调用 RegOpenKey 及友元时指定新的标记值,为 32 位应用程序提供了一个读取实际 64 位注册表值的应急方法。
更进一步说,后几个正中我下怀的 OS 差异涉及线程的局部数据。在 x86 版本的 Windows 中,FS 寄存器用于指向每个线程的内存区域,包括"最后一个错误"和线程的本地存储(分别是 GetLastError 和 TlsGetValue)。在 x64 版本的 Windows 中,FS 寄存器由 GS 寄存器取代。另外,它们的工作方式几乎完全相同。
虽然本文主要从用户模式角度讨论 x64,但有一项重要的内核模式体系结构附加内容需要说明。针对 x64 的 Windows 中有一项称为 PatchGuard 的新技术,该技术主要针对安全性和健壮性。简言之,能够更改关键内核数据结构(例如,系统调用表和中断调度表 (IDT))的用户模式程序或驱动程序会导致安全漏洞和潜在的稳定性问题。对于 x64 体系结构而言,Windows 家族决定不允许以不受支持的方式修改核心内存。强制该操作的技术是 PatchGuard。它使用内核模式线程监视对关键核心内存位置的更改。如果该内存被更改,则错误检测时系统将停止。
总之,如果您熟悉 Win32 体系结构,并且了解如何编写在它上面运行的本机代码,那么在迁移到 Win64 的过程中您就不会感到很惊奇了。您可以在很大程度上将其视为一个更广阔的环境。
返回页首
适当利用 x64
现在,我们看一下 CPU 体系结构本身,因为对 CPU 指令集有一个基本的了解可以使开发(特别是调试)工作更轻松。在编译器生成的 x64 代码中,您将注意到的第一件事是,它与您了解并喜爱的 x86 代码是多么地相似。这对于了解 Intel IA64 编码的人们则完全不同。
随后您将注意到的第二件事是,注册名称与您所熟悉的略有不同,并且有很多名称。通用 x64 寄存器的名称以 R 开头,如 RAX、RBX 等等。这是针对 32 位 x86 寄存器的基于 E 的旧命名方案的发展演化。就像过去一样,16 位 AX 寄存器变为 32 位 EAX,16 位 BX 变为 32 位 EBX,以此类推。如果从 32 位版本转换,所有 E 寄存器都会变为其 64 位形态的 R 寄存器。因此,RAX 是 EAX 的继承者,RBX 超越 EBX,RSI 取代 ESI,以此类推。
此外,还添加了 8 个新的通用寄存器 (R8-R15)。主要的 64 位通用寄存器清单如图 5 所示。
此外,32 位 EIP 寄存器也会变为 RIP 寄存器。当然,32 位指令必须继续执行,以便这些寄存器(EAX、AX、AL、AH 等)的原始、较小类型的版本仍然可用。
为了照顾到图形和科学编程人员,x64 CPU 还有 16 个 128 位 SSE2 寄存器,分别以 XMM0 到 XMM15 命名。由 Windows 保存的 x64 寄存器的完整集合位于 WINNT.H 中定义的相应 #ifdef'ed _CONTEXT 结构中。
在任何时候,x64 CPU 不是以旧式的 32 位模式操作,就是以 64 位模式操作。在 32 位模式中,CPU 与任何其他 x86 类别的 CPU 一样对指令进行解码和操作。在 64 位模式中,CPU 对某些指令编码进行了少量调整,以支持新的寄存器和指令。
如果您熟悉 CPU 操作码编码模型,就会记得为新的指令编码提供的空间会很快消失,并且在 8 个新寄存器中挤出空间也不是一项轻松的任务。为此,一种方法是删除一些极少使用的指令。到目前为止,我留下的指令只有 64 位版本的 PUSHAD 和 POPAD,它们用于在堆栈上保存和恢复所有通用寄存器。释放指令编码空间的另一种方法是,在 64 位模式中完全消除区段。这样,CS、DS、ES、SS、FS 和 GS 的生命周期就结束了。没有太多人会想念它们的。
由于地址是 64 位的,您可能会担心代码大小。例如,下面是一个常见的 32 位指令:
[pre]CALL DWORD PTR [XXXXXXXX][/pre]这里,用 X 表示的部分是一个 32 位地址。在 64 位模式中,这会变为 64 位地址,从而将 5 字节的指令变为 9 字节吗?幸运的是,答案是"否"。指令大小保持不变。在 64 位模式中,指令的 32 位操作数部分被视为相对于当前指令的数据偏移。一个示例可以更清楚地说明这一点。在 32 位模式中,以下是调用地址 00020000h 中存储的 32 位指针值的指令:
[pre]00401000: CALL DWORD PTR [00020000h][/pre]在 64 位模式中,相同的操作码字节调用地址 00421000h (4010000h + 20000h) 中存储的 64 位指针值。这可以使您联想到,如果是自己生成代码,则这种相对寻址模式会造成重大分歧。您不能仅在指令中指定 8 字节的指针值,而是需要为实际 64 位目标地址驻留的内存位置指定一个 32 位相对地址。因而,有一个未提出的假设是:64 位目标指针必须在使用它的指令的 2GB 空间中。对大多数人而言,这并不是一个大问题,但如果您要生成动态代码或者修改内存中的现有代码,就会出现问题!
所有 x64 寄存器的一个主要优势是,编译器能够最终生成在寄存器中(而非堆栈上)传递大部分参数的代码。将参数推入堆栈会引发内存访问。我们都需要牢记,在 CPU 缓存中找不到的内存访问会导致 CPU 延迟许多个周期,以等待可用的常规 RAM 内存。
在设计调用约定时,x64 体系结构利用机会清除了现有 Win32 调用约定(如 __stdcall、__cdecl、__fastcall、_thiscall 等)的混乱。在 Win64 中,只有一个本机调用约定和 __cdecl 之类的修饰符被编译器忽略。除此之外,减少调用约定行为还为可调试性带来了好处。
您需要了解的有关 x64 调用约定的主要内容是:它与 x86 fastcall 约定的相似之处。使用 x64 约定,会将前 4 个整数参数(从左至右)传入指定的 64 位寄存器:
[pre]RCX: 1st integer argumentRDX: 2nd integer argumentR8: 3rd integer argumentR9: 4th integer argument[/pre]前 4 个以外的整数参数将传递到堆栈。该指针被视为整数参数,因此始终位于 RCX 寄存器内。对于浮点参数,前 4 个参数将传入 XMM0 到 XMM3 的寄存器,后续的浮点参数将放置到线程堆栈上。
更进一步探究调用约定,即使参数可以传入寄存器,编译器仍然可以通过消耗 RSP 寄存器在堆栈上为其预留空间。至少,每个函数必须在堆栈上预留 32 个字节(4 个 64 位值)。该空间允许将传入函数的寄存器轻松地复制到已知的堆栈位置。不要求被调用函数将输入寄存器参数溢出至堆栈,但需要时,堆栈空间预留确保它可以这样做。当然,如果要传递 4 个以上的整数参数,则必须预留相应的额外堆栈空间。
让我们看一个示例。请考虑一个将两个整数参数传递给子函数的函数。编译器不仅会将值赋给 RCX 和 RDX,还会从 RSP 堆栈指针寄存器中减去 32 个字节。在被调用函数中,可以在寄存器(RCX 和 RDX)中访问参数。如果被调用代码因其他目的而需要寄存器,可将寄存器复制到预留的 32 字节堆栈区域中。图 6 显示在传递 6 个整数参数之后的寄存器和堆栈。

图 6 传递整数



x64 系统上的参数堆栈清除比较有趣。从技术上说,调用方(而非被调用方)负责清除堆栈。但是,您很少看到在起始代码和结束代码之外的位置调整 RSP。与通过 PUSH 和 POP 指令在堆栈中显式添加和移除参数的 x86 编译器不同,x64 代码生成器会预留足够的堆栈空间,以调用最大目标函数(参数方法)所使用的任何内容。随后,在调用子函数时,它重复使用相同的堆栈区域来设置参数。
另一方面,RSP 很少更改。这与 x86 代码大不相同,在 x86 代码中,ESP 值随着参数在堆栈中的添加和移除而不断变化。
有一个示例可帮助说明这一点。请考虑一个调用三个其他函数的 x64 函数。第一个函数接受 4 个参数(0x20 个字节),第二个接受 12 个参数(0x60 个字节),第三个接受 8 个参数(0x40 个字节)。在起始代码中,生成的代码只需在堆栈上预留 0x60 个字节,并将参数值复制到 0x60 字节中的适当位置,以便目标函数能够找到它们。
您可以在 Raymond Chen 的网络日记中看到一个有关 x64 调用约定更详细的描述。我不会过多地讨论所有细节,仅在这里强调一些要点。首先,小于 64 位的整数参数进行了符号扩展,然后仍然通过相应的寄存器传递(如果在前 4 个整数参数内)。其次,任何参数所处的堆栈位置都应该是 8 字节的倍数,从而保持 64 位对齐。不是 1、2、4 或 8 字节的任何参数(包括结构)都是通过引用传递的。最后,8、16、32 或 64 位的结构和联合作为相同长度的整数传递。
函数的返回值存储在 RAX 寄存器中。如果返回到 XMM0 中的是浮点类型,就会引发异常。在所有调用中,以下寄存器必须保留:RBX、RBP、RDI、RSI、R12、R13、R14 和 R15。以下寄存器不稳定,可能会被毁坏:RAX、RCX、RDX、R8、R9、R10 和 R11。
我在前面提到过,作为异常处理机制的一部分,OS 会遍历堆栈帧。如果您曾经编写过堆栈遍历代码,就会知道 Win32 帧布局的这一特性可巧妙处理该过程。这种情况在 x64 系统上要好得多。如果某个函数需要分配堆栈空间,调用其他函数,保留任何寄存器或者使用异常处理,则该函数必须使用一组定义良好的指令来生成标准的起始代码和结束代码。
实行创建函数堆栈帧的标准方法是 OS 确保(在理论上)能够始终遍历堆栈的一种方法。除了一致、标准的起始代码,编译器和链接器还必须创建关联的函数表数据项。奇怪的是,所有这些函数项都在 IMAGE_FUNCTION_ENTRY64 的数组表(在 winnt.h 中定义)中结束。如何找到这个表呢?它由 PE 头的 DataDirectory 字段中的 IMAGE_DIRECTORY_ENTRY_EXCEPTION 项指出。
我在短短的一段中讨论了许多体系结构内容。但是,通过大体了解这些概念以及 32 位程序集语言的现有知识,您应该能够在一段较短的时间内了解调试器中的 x64 指令。总是实践出真知。
返回页首
使用 Visual C++ 进行 x64 开发
尽管可以使用 Visual Studio® 2005 之前的 Microsoft® C++ 编译器编写 x64 代码,但这在 IDE 中是一项沉闷的体验。因此,在本文中,我假定您使用的是 Visual Studio 2005,并选择在默认安装中未启用的 x64 工具。我还假定您在 C++ 中拥有要为 x86 和 x64 平台构建的现有 Win32 用户模式项目。
针对 x64 构建的第一步是创建 64 位生成配置。作为一个优秀的 Visual Studio 用户,您应该已经知道项目在默认情况下有两种配置:Debug 和 Retail。这里,您只需创建另外两个配置:x64 形态下的 Debug 和 Retail。
首先,加载现有项目/解决方案。在 Build 菜单上,选择 Configuration Manager。在 Configuration Manager 对话框中,从 Active solution platform 下拉菜单中选择 New(参见图 7)。现在,您应该看到另一个标题为 New Solution Platform 的对话框。

图 7 创建新的生成配置



选择 x64 作为您的新平台(参见图 8),并将另一个配置保留为默认状态;然后单击 OK。就这么简单!现在,您应该拥有四个可能的生成配置:Win32 Debug、Win32 Retail、x64 Debug 和 x64 Retail。使用 Configuration Manager,您可以轻松地在它们之间切换。
现在,我们看一下您的代码与 x64 的兼容性。将 x64 Debug 配置设为默认值,然后生成项目。除非代码不重要,否则可能会收到一些不会在 Win32 配置中发生的编译器错误。除非您已经完全摒弃了编写可移植 C++ 代码的所有原则,否则修正这些问题以使代码能够随时用于 Win32 和 x64 相对比较轻松,而无需大量的条件编译代码。

图 8 选择生成平台



返回页首
使代码与 Win64 兼容
将 Win32 代码转换为 x64,所需的最重要的工作可能是确保类型定义正确。还记得先前讨论的 Win64 类型系统吗?通过使用 Windows typedef 类型而非 C++ 编译器的本机类型(int、long 等),Windows 头使得编写干净的 Win32 x64 代码很轻松。您应该在自己的代码中继续保持这一点。例如,如果 Windows 将一个 HWND 传递给您,请不要仅仅为了方便就将其存储在 FARPROC 中。
升级完许多代码之后,我看到的最常见而简单的错误可能就是:假定指针值可以存储或传递到 32 位类型(如 int 和 long)甚至 DWORD 中。Win32 和 Win64 中的指针长度视需要而不同,而整数类型长度保持不变。但是,让编译器不允许指针存储在整数类型中也是不现实的。这是一个根深蒂固的 C++ 习惯。
解救方法是 Windows 头中定义的 _PTR 类型。DWORD_PTR、INT_PTR 和 LONG_PTR 之类的类型可让您声明整数类型的变量,并且这些变量始终足够长以便在目标平台上存储指针。例如,定义为 DWORD_PTR 类型的变量在针对 Win32 编译时是 32 位整数,在针对 Win64 编译时是 64 位整数。经过实践,我已经习惯了声明类型以询问"这里是否需要 DWORD 或者实际是指 DWORD_PTR 吗?"。
正如您期望的,可能有机会明确指定整数类型需要多少字节。定义 DWORD_PTR 及其友元的同一头文件 (Basetsd.h) 还可以定义特定长度的整数,如 INT32、INT64、INT16、UINT32 和 DWORD64。
与类型大小差异相关的另一个问题是 printf 和 sprintf 格式化。我对于在过去使用 %X 或 %08X 格式化指针值感到懊悔万分,并且在 x64 系统上运行该代码时还遇到了阻碍。正确的方法是使用 %p,%p 可以在目标平台上自动考虑指针大小。此外,对于与大小相关的类型,printf 和 sprintf 还具有 I 前缀。例如,您可能使用 %Iu 来打印 UINT_PTR 变量。同样,如果您知道该变量始终是 64 位标记值,则可以使用 %I64d。
在清除了无法用于 Win64 的类型定义所导致的错误之后,可能还有只能在 x86 模式下运行的代码。或者,您可能需要编写函数的两个版本,一个用于 Win32,另一个用于 x64。这就是一组预处理器宏的用武之地:
[pre]_M_IX86_M_AMD64_WIN64[/pre]正确使用预处理器宏对于编写正确的跨平台代码而言至关重要。_M_IX86 和 _M_AMD64 仅在针对特定处理器编译时进行定义。_WIN64 在针对任何 64 位版本的 Windows(包括 Itanium 版)编译时定义。
在使用预处理器宏时,请仔细考虑您的需要。例如,只需要代码真正特定于 x64 处理器,没有别的需要了吗?然后,使用与以下类似的代码:
[pre]#ifdef _M_AMD64[/pre]另一方面,如果同一代码既可以在 x64 又可以在 Itanium 上工作,则使用如下所示的代码可能更好:
[pre]#ifdef _WIN64[/pre]我发现一个有用的习惯是:只要使用其中一个宏,就始终显式创建 #else 情况,以便提前知道是否忘记了某些情况。请考虑以下编写错误的代码:
[pre]#ifdef _M_AMD64// My x64 code here#else// My x86 code here#endif[/pre]如果现在针对第三个 CPU 体系结构编译该代码,会发生什么情况?系统将无意识地编译我的 x86 代码。上面代码的一个更好的表达方式如下:
[pre]#ifdef _M_AMD64// My x64 code here#elif defined (_M_IX86)// My x86 code here#else#error !!! Need to write code for this architecture#endif[/pre]在我的 Win32 代码中无法轻松移植到 x64 的一部分代码是内联汇编,Visual C++ 不支持它的 x64 目标。不要害怕,汇编有办法。它提供了一个 64 位 MASM (ML64.exe),这在 MSDN 中有所说明。ML64.exe 和其他 x64 工具(包括 CL.EXE 和 LINK.EXE)可以从命令行调用。您可以只运行 VCVARS64.BAT 文件,该文件可以将它们添加到您的路径中。
返回页首
调试
最后,您需要在 Win32 和 x64 版本上干净地编译代码。最后一个难题是运行和调试代码。无论是否在 x64 盒上生成 x64 版本,您都需要使用 Visual Studio 远程调试功能在 x64 模式下进行调试。幸运的是,如果您在 64 位计算机上运行 Visual Studio IDE,则 IDE 将为您执行以下所有步骤。如果您出于某些原因无法使用远程调试,则另一个选项是使用 x64 版本的 WinDbg。但是,您会失去 Visual Studio 调试器提供的许多调试优势。
如果您从未使用过远程调试,也不需要过于担心。一旦设置好,远程调试就可以像在本地一样无缝使用。
第一步是在目标计算机上安装 64 位 MSVSMON。这通常是通过运行 Visual Studio 随附的 RdbgSetup 程序来完成的。一旦 MSVSMON 运行,请使用 Tools 菜单为 32 位 Visual Studio 和 MSVSMON 实例之间的连接配置适当的安全设置(或者缺失)。
接下来,您需要在 Visual Studio 中将项目配置为针对 x64 代码使用远程调试,而不是尝试进行本地调试。您可以从调试项目的属性开始启动这个过程(参见图 9)。

图 9 调试属性



确定 64 位配置是当前配置,然后选择 Configuration Properties 下面的 Debugging。靠近顶端是标题为 Debugger to launch 的下拉菜单。通常,它设置为 Local Windows Debugger。将其更改为 Remote Windows Debugger。在下面,您可以指定在启动调试时要执行的远程命令(例如,程序名),以及远程计算机名和连接类型。
如果您正确设置了所有内容,就可以使用与启动 Win32 应用程序相同的方式开始调试 x64 目标应用程序。您可以知道是否已经成功连接到 MSVSMON,因为每次调试器成功连接后,MSVSMON 的跟踪窗口都会显示一个"connected"字符串。在这里,通常都是您知道并喜爱的同一个 Visual Studio 调试器。确保屏幕显示寄存器窗口,并查看所有这些出色的 64 位寄存器,然后转到反汇编窗口以查看"非常熟悉但略有不同的"x64 程序集代码。
请注意,不能将 64 位小型转储直接加载到 Visual Studio 之类的 32 位转储中,而是需要使用远程调试。此外,Visual Studio 2005 目前不支持本机 64 位代码和托管 64 位代码之间的互操作调试。
返回页首
关于托管代码
使用 Microsoft .NET Framework 进行编码的一个优势是,大部分基础操作系统都归纳为通用代码。此外,IL 指令格式是 CPU 不可知的。因此,从理论上说,在 Win32 系统上生成的基于 .NET 的程序二进制文件应该无需修改就可以在 x64 系统上运行。但实际情况却有一点复杂。
.NET Framework 2.0 提供了 x64 版本。在 x64 计算机上安装 .NET Framework 2.0 之后,我能够运行先前在 Win32 环境中运行的 .NET 可执行文件。这真棒!当然,虽然不能保证每个基于 .NET 的程序无需重新编译就可以在 Win32 和 x64 上都运行良好,但它确实在一段合理的时间内"很有用"。
如果您的托管代码显式调用本机代码(例如,通过 C# 或 Visual Basic® 中的平台调用),则在尝试针对 64 位 CLR 运行时可能会遇到问题。但是,有一个编译器开关 (/platform) 可让您更清楚地了解代码应该在哪个平台上运行。例如,您可能希望托管代码在 WOW64 中运行,即使可以使用 64 位 CLR。
返回页首
小结
总之,对于我而言,迁移到 x64 版本的 Windows 是一个相对比较轻松的经历。一旦您很好地掌握了 OS 体系结构和工具中相对较小的差异,就可以轻松地使一个代码基在这两个平台上运行。Visual Studio 2005 可从根本上使这些工作更加轻松。此外,由于每天都会出现更多特定于 x64 版本的设备驱动程序和工具(如 SysInternals.com 提供的 Process Explorer),因此没有理由不进行讨论!
Matt Pietrek 与他人合著有几本关于 Windows 系统级编程的书籍,以及 MSDN Magazine 的 Under the Hood 专栏。他以前曾是 NuMega/Compuware BoundsChecker 系列产品的首席架构师。现在,他是 Microsoft Visual Studio 小组的一员。
posted @ 2015-01-23 11:15  廖先生  阅读(1240)  评论(0编辑  收藏  举报