毕业也有几年了,也看了和学了不少东西。有时也想写点什么,但总是觉得头绪很多,一直没有
动笔。最近翻了翻梁先生的《编程高手箴言》,突然想写点什么,权且用读书笔记的形式写点东西。
等号上面的摘字《箴言》,下面则是笔者自己的感想。希望大家指教,但是谩骂就不必了,谢谢。
注:这一部分涉及《箴言》第四章。
需要指出,同一个方向的goto会有很大的益处。
=========================================
我用索引工具在linux kernel 2.2.x里稍微搜索了一下,呵呵,太多goto了,以致检索工具只给了我1000个。
我想,既然有人主张彻底取缔goto,这大概可以说明人们可以证明完全可以用结构化程序设计的几个条件
语句来取代goto。然而,“高手”们写程序不会因为很多人对goto的诟病而抛弃不用,他们关心的是如何
写出高效清晰的代码来。事实上,在有些场合用goto反而让程序清楚的多。举个例子,Linux/BSD kernel在
从下层收到一个IP包后,会对这个包的合法性作个检查。要知道,IP包的包头还是有不少域的,所以检查的
代码就是重复这个过程:取处一个域,效验,合法则往下走,不合法就清理现场然后退出。这段代码看起来
象这样:
// Sanity check
getField_1();
checkField_1();
if (failed)
goto abortPoint;
getField_2();
checkField_2();
if (failed)
goto abortPoint;
... ...
getField_n();
checkField_n();
if (failed)
goto abortPoint;
return success;
abortPoint:
doClean()
return error;
大家看看,这种用goto的代码是不是反而清晰的很呢?如果要用那些if/switch之类的,反而不美了,呵呵。
到了Pascal和C语言中,数据和代码之间的关系就模糊了。
==================================================
呵呵,Pascal是我的第一门语言,C也用了这么多年了,怎么就没觉得它们数据和代码之间关系模糊啊?
不理解。
有了重入功能,操作系统就很好设计了,就能实现多任务。
===================================================
这种说法真的还是第一次听说。只要慎用全局变量,那么一个函数的可重入性还是比较容易做到的啊。
操作系统的某些部分确实需要重入功能,但是单独拔高重入的重要性以前确实是没见过:-)
另外,多任务实现的基础恐怕不是什么重入功能吧?
C++是这样一种工作方式,即把数据和对其操作的代码进行捆绑。这样就可以和
结构一样,进行内存的分配和使用。在C++中,函数内部用动态的可重入的变量,
而C++又是工作在自己独立的数据区和代码区。
=====================================================================
C++的数据和相应的操作(方法)其实不在一个地方,至少VC生成的代码是这样,在下面我写了几个小程序,大家
可以跑跑,自然就会得出自己的结论了。
另外,C++函数中内部用动态的可重入变量很正常啊,C也是这样啊。至于说C++又是工作在自己独立的数据区和
代码区就让人困惑了,搞不清楚作者想要告诉我们些什么。
其实,一个C++ class如果只有public成员变量而没有方法的话,那么它和一个struct其实没什么不同,即使有了
普通的方法,C++ class的内存表示还是没有变化,只不过编译器会另找一个地方存放方法代码本身。下面两个
class:
class A
{
public:
int value;
};
class B
{
public:
int value;
void sayHello() { cout << "B::sayHello()" << endl; }
};
如果你用sizeof()作用在这令各class上,就会发现,它们大小是一样的,B不会因为多了一个方法而导致其size变大。
(当然,你如果将sayHello用virtual申明为虚方法,那么编译器会在你的类前面加一个所谓的VTable,其实就是一个
32位指针,会导致你的class变大的,如果你用virtual修饰sayHello,那么class B就会比class A大4个bytes)
另外,对于类里面的方法,只要在程序里定义了,那么在运行期不管你有没有生成这个类的实例,其实这些方法代码
已经存在了,甚至我们可以用一些“脏”代码来调用:
#include <iostream>
using namespace std;
class A
{
public:
int a;
void sayHello() { cout << "A::sayHello()" << endl; }
};
int main()
{
A *pa;
pa = NULL; //注意这里,并不会导致下面的语句出错,在vc和gcc里都pass
pa->sayHello();
return 0;
}
大家可以编译出来看看,运行的话就会打出"A::sayHello()",呵呵,我们根本就没有生成A的实例A的方法就已经存在
了。编译器会“理解”我们的目的,看到(A *)->sayHello,就会替我们调用适当的函数。当然了,sayHello里可不能
使用自己的成员变量,比如int a,否则就会因为this指针不对而出错,毕竟我们没有生成A的实例嘛。
BTW,大家注意一下下面的这段代码:
struct A
{
int a;
int b;
int c;
};
&(((struct A *)NULL)->b)); //这里是取b在结构里的偏移,一些OS kernel代码会实现一些通用数据结构,比如单向
链表,就会利用这种技术。具体的可以看DDK,或者在linux kernel代码里用list搜索一下,这些实现蛮巧妙的。
在这个类中的函数不会取出正确的地址,因为它生成时就是动态分配一个地址。
==================================================================================
这话其实也不对,在VC里可以用下面的“脏”代码来获取一个类成员函数的地址,并且来调用它:
#include <stdio.h>
#include <iostream>
using namespace std;
class A
{
public:
int a;
void sayHello() { cout << "A::sayHello()" <<endl; }
};
typedef void (A::*PFN)(); //不清楚的同志去看《Thinking in C++》
typedef void (*P_SAYHELLO)(); //不清楚的同志随便去查一本C语言的书
int main()
{
char str[100];
PFN pFn;
P_SAYHELLO pSayHello;
pFn = &A::sayHello;
sprintf(str, "%d", pFn);
pSayHello = (P_SAYHELLO)atoi(str);
(*pSayHello)();
return 0;
}
OK,编译运行之,至少在VC环境里会出现“A::sayHello()”。
之所以要用sprintf和atoi是因为我不知道怎么把PFN转化程P_SAYHELLO,只好用这种“不干净”
的代码了,呵呵:-)
因为定义C++的类时,代码和数据都浮动。然而代码浮动是没有意义的。
==============================================================
不懂是什么意思。至于浮动代码没意义就不对了,编译出来的程序不会知道自己会被加载到虚拟空间
的什么地方,于是程序里都是一些相对于程序起始地址的偏移量,一些loaders甚至在加载的时候
动态修改这些代码,完成最后的定位工作。大家去看看John R. Levine的《Linkers & Loaders》
就什么都明白了。
对于同一个进程,代码当然只能有一份,因为代码不会自己改变自己的。所以,在C++中加了
不能取类函数地址的限制。
==================================================================================
上面给出的代码说明了可以取函数地址。至于上面这句话其中的因果关系,我还是没有能导出
来。
代码不会自己改变自己吗?决大多数情况下是这样,然而,一些特殊的程序,比如病毒,就完全
有可能自己修改自己。其实从CPU的角度看,只要你在ring0把代码的属性改为可写,那么你想怎么
改都可以:-)
C++的设计理念是数据和代码都是浮动的,这样可以把整个对象传给一个进程活是当成一个数据
类型,把这个对象从这个进程传入到另一个进程中去用。在这基础上,发展出分布对象。
==================================================================================
呵呵,真的不知道当初在设计C++的时候,那个名字有点怪的大牛有没有考虑过对象的传递和
分布式对象的问题。
把对象放入另一个计算机中去进行运算,运算完成后,只需要返回运算的结果。这种理论的模型
到现在还不可能都做到,就是操作系统也做不到这一点。
==================================================================================
做不到?不至于吧。我想将类打包,传递,解包,实例化,计算,返回结果,这些无论从原理
上还是实现上好像都没有很难跨越的鸿沟啊。我分布式对象的东东搞的很少,希望懂行的同志跟跟
帖子来谈谈这个问题。
当时在设计C++的时候就考虑到了这一点,但事实上这种模型太理想化了,在实践中不可能完全
实现的。
==================================================================================
老实说从我学C++的第一天一直到现在,我实在一点都看不出标准C++怎么来支持对象传递和分布式
对象。我觉得对象传递和分布式对象怎么看都象是一个软件平台应该提供的功能,硬要让C++来承担
不怕C++负担太重啊,呵呵:-)
后来在Pascal的基础上产生了C语言。
================================
C语言实在Pascal的基础上产生的吗?虽说Pascal比C早两年,然而直接导致C诞生的B语言好像还比
Pascal还早吧?C应该脱胎于贝尔实验室的B语言,而这些语言都和Algol 60关系十分密切。
正是现在所有的编程语言都以人为中心,以人的思维为出发点,所以编写程序就会跟机器的实现
相脱离。这就导致很多人看上去都会编程序,但是编写出的程序出现这样、那样的问题,或者效率
极低。而这样的程序员又不能从根本上解决这样的问题。因为他对机器和编程语言的关系和原理
不了解。
==================================================================================
不知道看了上面这段话,CPU设计员会不会说那些用汇编编程的家伙以人的思维为出发点,所以写出来
的程序机会和CPU逻辑设计相脱离,这就导致很多人看上去都会编程序,但是编写出的程序出现这样、那
样的问题,或者效率极低。而这样的程序员又不能从根本上解决这样的问题。因为他对CPU
逻辑设计和汇编语言
的关系和原理不了解。 :-)
程序的入口和出口
================
不管是windows PE格式文件还是Linux/BSD ELF文件,程序的入口点都记录在相应的文件头里面。
通常,链接程序会根据情况把适当的入口点替你填好了。并且,一般情况下我们不需要去干预
这个过程,否则可能会导致程序运行环境没有被正确初始化,全局对象没有正确构造等等隐患。
当然,也不是绝对。在VC里写纯UNICODE程序的时候,除了要将缺省的宏定义_MBCS改成
_UNICODE外,很重要的一点就是修改程序的入口点,如果是CONSOLE程序,就是wmainCRTStartup,
如果是WINDOWS程序,就是wWinMainCRTStartup,否则就会报链接错误。
至于出口,正常情况下,C/C++程序的出口就是主函数的返回点,然而,如果程序不是正常退出
的话,就不能假设出口点的位置了。
汇编语言中一部分是代码,另一部分是数据,在汇编内是很清楚的。
===========================================================
我觉得恰恰是在汇编代码里数据和代码是分不清楚的,因为这完全取决于你让CPU怎么解释你生成的
这些0、1串。其实大家都明白,在汇编代码里用db伪操作来将一些数字定义为代码的例子实在是太多
了。
《箴言》一书中在4.1.1中最后的部分讨论了一点点程序链接的信息。
============================================================
其实程序的链接和加载要远远复杂的多,用一本书描述都不为过。推荐大家去看看John R. Levine
的《Linkers & Loaders》。这本书你哪怕看明白了十分之一,都会更有收获。
编程时一定要有一个好习惯,即数据和代码放到不同的地方,而不要在数据中插入一些变量的申明。
只有这样,才能让编译器非常容易的处理数据......这样因为编译的原因产生的错误就少了。
======================================================================================
呵呵,编译器才不管你程序怎么写呢,只要你符合相应的规则 :-) 第一次看到有这样一种编程原则
是用来防止编译器出错的。如果你的程序符合语言的语法定义,而且又是逻辑正确的,那么如果汇编
出错我建议你去换个编译器。你又没有编译器代码,怎么可能知道什么样的代码会让编译器“舒服”
呢?
其实在Windows内,TextOut有两个函数相对应。
=========================================
用NT技术构建的windows各种版本,内核都是unicode的,如果调用ANSI版本的API,系统会在后台做
转换,所以我们在coding的时候就直接用纯unicode版本的API,应该可以有一点点的性能提升,呵呵:-)
【实例】:自定义程序的入口点
============================
在汇编语言里,程序的入口点其实是个标号而已,根本没有我们C/C++里的诸如main,WinMain之类的
特殊要求。C/C++这种要求的原因是其实一个C/C++程序的入口点其实不是我们的程序,而是编译环境
自己定义的一小段代码,而最后这一小段代码会call我们的函数。那么这一小段代码怎么知道我们的
函数在哪里呢?要解决这个问题办法很多,最容易的就是替我们给出函数名字和原型。这也就是在
C/C++程序里必须有main,或者WinMain的原因。
《箴言》给出的这个程序的一个问题就是并没有正确的象WinMainCRTStartup那样的初始化运行环境。
如果将C与C++相比较,C++就会为了某个问题会绕一大圈,所以代码会比较大,并且里面有一些没用
的代码。
========================================================================================
可惜《箴言》没有给个例子说明C++是怎么绕一个大圈子了。C++的对象机制应该是对问题的一种更高级别
的抽象。作为一个一般的规律,计算机里面的每一次向上的抽象都会使的对人的界面友好一点,而这是要
付出空间和时间的代价的。C++的对象显然从语义上要比C的结构丰富的多,因此C的结构可以不需要那些
诸如构造函数,析构函数之类的东西,而C++在很多时候就需要它们了。如果以没用到就说它们没用,我
只能表示遗憾了。这只能说明你还是用C的眼光看C++,而不是用C++的眼光看C++。
比如用结构的指针的处理写出来的C代码就会很复杂,因为里面有很多结构的指针,指来指去。
==================================================================================
如果你指针的概念清楚,一般情况下不会觉得指针有什么复杂的。只是一个训练不够的程序员可能
会在不经意的情况下犯错误,而有时这种错误很难查罢了。很多时候,指针有助于表达问题,比如
二叉树的实现,我就觉得一些不支持指针的语言,比如Basic里的实现就不太清楚,至少我觉得这样。
我想,如果C里面没有指针的话,估计C早就消亡了 ;-)
C++主要解决的是一个重入的问题,重入也是对象化的问题... ...早期的操作系统就是用结构来做的,
否则就没有办法解决文件的问题。
========================================================================================
不懂。 不用结构就不能解决文件问题?什么是结构?结构还不是一种对数据组织方式的一种抽象?只要
CPU的指令支持间接寻址,实现结构及其简单。再说,一堆数据放在那里,你是否把他看成是结构完全是
你自己的事。
然后在代码里真正链接的时候,只包含DATA和TEXT区,而BSS区域是程序装进来的时候给它分配空间的。
==========================================================================================
大家编译下面这两个程序,看看生成的可执行程序的大小就明白了。
程序一:
#include <stdio.h>
long array[10 *1024 * 1024];
int main()
{
printf("Hello World!\n");
}
程序二:
#include <stdio.h>
long array[10 *1024 * 1024] = { 0xcccccccc };
int main()
{
printf("Hello World!\n");
}
所以,从这里看出为什么要了解平台。任何的程序编译出来都和平台有关。如果脱离平台,任何语言
都没有什么意义了。
==========================================================================================
呵呵,当年SUN推Java的口号就是其平台独立性了。虽说这一点Sun也不是完美的,但是很多小程序跨平台
完全没有问题。
局部变量完全在堆栈里面去实现。
=============================
什么意思呢?我们可以看看一小段VC程序反汇编的结果
push ebp
mov ebp,esp
sub esp,44h
... ...
pop ebp
这段代码的sub esp, 44h就是在栈里给局部变量预留空间了,以后的push/pop操作只会从减去44h的esp处
开始了。而在ebp和esp-44h之间的这段空间就是局部变量的空间了。
《箴言》里提到函数不能把自己的局部变量传递给上层函数,明白了上面的原理我们就很清楚了:返回到
上层函数后,堆栈指针也就相应上去了,谁也不知道栈里是些什么值了。
其实,把函数局部变量传递给上层函数会导致很隐秘的bug,因为在有的程序流程里,栈里面的值没有被破坏,
那么程序“好像”还可以工作,而如果在另外的流程里栈值被破坏了,那么程序立即就不对了。所以对这种
问题一定要当心。
自动的意思就是自动的分配和清除,并且初始的值也是随机的。
=======================================================
在VC Debug版本里,栈中分配的值都会先用0xCCCCCCCC来处理一下,所以大家在Debug模式下调试程序发现在
引用0xCCCCCCCC这样的值,就说明在试图使用一个没有初始化的值。这就是在Debug模式下调试的好处之一,
如果在Release模式下,系统就不会用0xCCCCCCCC来处理一下了。至于为什么选择0xCCCCCCCC大概是因为 端点中断int 3 对应的机器码就是0xCC吧,我也不是很有把握。
用固定的地址是可以访问指针所指向的数据的。但是在一般情况下,Windows可能会报非法操作。
====================================================================================
在Windows程序里直接给一个指针赋予一个常量的情况及其稀少。尤其在windows里访问物理存贮器和I/O口也不能
象在DOS里那样直接进行了。网上有一个WinIO的库(http://www.internals.com)可以访问物理存贮器和I/O口,
但它其实是用一个WDM Device Driver来实现的。这个库的接口DLL会自动的加载一个.sys驱动程序,然后把需要
访问的物理地址和I/O信息传到Kernel里,操作完成后在返回给应用程序。
但是在函数的调用过程中,引用和指针又不一样,引用往往会在编译器的代码里面,加上一个自动搬移的过程,
也就是把那个值搬过来。
==================================================================================================
还是看个简单的例子吧。 其中的汇编代码是Debug模式下VC的汇编视图里拷贝出来的。
#include <iostream>
using namespace std;
struct A
{
int a;
long b;
};
void handle_1(struct A *a)
{
a->a = 0x1234;
a->b = 0x4321;
}
void handle_2(struct A a)
{
a.a = 0x1234;
a.b = 0x4321;
}
void handle_3(struct A& a)
{
a.a = 0x1234;
a.b = 0x4321;
}
int main(int argc, char* argv[])
{
A a;
handle_1(&a); //传地址
lea eax,[ebp-8]
push eax
call @ILT+0(handle_1) (00401005)
add esp,4
handle_2(a); //传值
mov ecx,dword ptr [ebp-4]
push ecx
mov edx,dword ptr [ebp-8]
push edx
call @ILT+10(handle_2) (0040100f)
add esp,8
handle_3(a); //传引用
lea eax,[ebp-8]
push eax
call @ILT+5(handle_3) (0040100a)
add esp,4
return 0;
}
大家看到了吧,传递移用和传递地址(结构的引用)在汇编后没什么不同,其实我们只要按照引用的定义去用,C++编译
器会根据当时的上下文去解释的。
编译中,事实上引入了很多不可预测的因素。编译器是帮你把你的想法变成机器可以运行的代码,即把你的思想变成
在内存中的相应映射。如果你真正了解了这些,编译器也就不是很重要了... ...很多做程序的人并不知道平台的作用,
其实平台才是最重要的。有些人认为自己懂了VC,就懂了计算机了,实际上离计算机还有十万八千里。
=======================================================================================================
呵呵,只要你对语言了解,那么从原理上看你就应该知道你的代码的具体含义,除非编译器有bug。老实说,在windows上
的这些开发语言中,VC的门槛还是相对较高的,我想只要是真正懂VC的人,恐怕都不会的认为自己就懂计算机了吧。别说
计算机这么个大范畴了,你就算精通VC了又怎么样,windows源码没有给你,你还是有无数个问号!懂计算机?谈何容易啊。
如果把类剖析出来会发现有一堆Vtable的指针,这个指针再jmp到一个函数的地址。
========================================================================
实际上,如果一个类没有虚函数,编译器才懒的生成VTable呢。用VC生成的代码是在call后jmp的,然而用g++编译出来的
程序就是直接call,而没有一个jmp的过程。VTable的作用就是用来解决所谓的“后期绑定”的,仅此而已。而如果一个
class没有virtual function,那么这个class几乎等价于一个struct加一堆函数而已,根本没有“后期绑定”的必要,因此
也没有必要带上个VTable。这一点与Java不同,Java我印象中好像都是后期绑定的。
其实虚函数,后期绑定,VTable这些概念很重要,尤其在看Microsoft的COM的时候。想必大家都研究过《Thinking in C++》
了,因该很清楚了吧,呵呵~~~
如果你用到类里面的函数时,并且不是静态分配,而都是动态分配,当程序做了很多内存的操作后,如果某个地方出了问题,
就会出现call地址错误。
=============================================================================================================
呵呵,这十有八九是把栈给冲垮了。有时候,在你的函数里调用系统的一些库函数,如果出一些莫名其妙的问题,你就要注意
是不是你没有注意那些库函数的细节,导致你在栈上分配的变量传给这些库函数后,又被这些库函数用和你想法不一样的方法
给修改了,结果造成了栈被破坏了。
老一点的编译器在写struct时,可以把成员函数和变量放在一起使用,结构和类是一样的。但是,这个结构的属性相当于
全部是public。
===========================================================================================================
MSDN里类似这种初始化COM的代码
struct XXX_OLE_INIT
{
XXX_OLE_INIT() { CoInitialize(NULL); }
~XXX_OLE_INIT() { CoUninitialize(); }
}XXX_OLE_INIT;
总是可以在VC的各个版本里Pass的,此时的struct XXX_OLE_INIT就相当于一个全部成员都public的class。
当编译器调用时,就会产生一个相应的CALL ?add@@YAHHH@Z或CALL ?add@@YAMMMM@Z。
==========================================================================
遇到这种“神秘”的说明,我们可以用Platform SDK自带的一个工具UndName.exe来找出究竟这些
看起来乱七八糟的字符串到底是在定义些什么。
class class_abc
{
void fuc(int a, int b, int c);
}
extern "C" class_abc _fuc(void *lpthis, int a, int b)
{
((abc *)lpthis)->fuc(int a, int b, int c);
}
void main()
{
int a = 1,b=2,c=3;
class_abc abc;
class_abc _fuc((void *)&abc, a, b, c);
}
===================================================================================
实在搞不动上面这段程序是什么?
COM的继承都是分级指针的,因为COM没有给你提供源代码。
====================================================
个人认为COM里的继承应该和C++的多重继承有可比性。不知道所谓的分级指针是什么意思。
再说COM只是个标准,而不是实现,有点象Java里面的什么J2EE什么的。规范是不含实现的,
当然参考实现出外。网上就有第三方的COM实现,在UNIX平台上。对于用户而言,一个COM
服务给你个接口就行了,你用它就可以直接调用服务了,不需要知道具体的某个COM服务是
如何实现的。(当然,知道了更好:-)
一个全局变量在A文件中,在B文件中用extern把这个变量引入,如果全局变量是由两个人写的,且
名字都相同,两个程序都要LINK成一个EXE,这样就会出现问题。
===================================================================================
其实C/C++中全局变量的申明有几个原则,尽量不要在.h中定义变量,而是在.h中用extern申明变量。
全局变量只在一个文件中定义,其他地方只要用extern说明即可。为了防止.h文件被重复包含,
用诸如
#ifndef _XXX_H_
#define _XXX_H_
#endif
将头文件内容扩起来。在VC环境里,也可以把#pragma once放在.h文件的开头达到同样的目的。
以前有些书为了避免这些问题的发生,就说不要用全局变量,这些会导致程序互相冲突。
===================================================================================
呵呵,幸亏我没有看到过这种书,其上按照上面的原则,应该不会产生冲突,只要逻辑没有问题。
如果是链,就得一个一个找。如果是一个并行的数组,就可以同时进行比较或对其先排序,后查找,
这样速度会快很多。
===================================================================================
链式结构难道就不能先排序后查找吗?一个双向链表,除了不能像数组那样支持随机定位外,其它几乎
和数组比没有特别明显的不便了。并且数组的插入,删除操作的开销还是很大的。
在程序中,用链一般会带来很多不稳定的因素。...... 程序的可读性会变差。
=====================================================================
呵呵,功夫不到家你用什么数据结构都会带来不稳定的因素。链式机构自然有其适合的场合。比如二叉树
的实现,我觉得用链式结构就很好,如果在不支持指针的语言,比如Basic里,个人觉得反而不直观了。
解释的运行方式中最常见的式Basic... ...其实,解释程序就是一个字符串的解释器。
===========================================================================
很多脚本语言,比如大名鼎鼎的Perl,Python等等都是解释执行的。这些脚本语言的语义十分丰富,像
Perl,Python都支持面向对象的编程,因此,这些脚本的解释程序本身十分复杂,编译原理的各个方面
几乎都会涉及到,远远不是什么字符串的解释器可以比拟的。
许多书中没有说为什么参数从右向左传递... ...从而便于汇编和调试。
==============================================================
个人感觉应该和调试没什么关系,倒是可能和可变参数函数的实现有点关系。
下面摘自VC提供的代码,va_xxx是可变参数编程的几个重要的宏。
#ifdef _M_IX86
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )
我们可以看到,所谓可变,其实就是利用va_arg每次从栈中取出适当的字节
数拼成所需要的参数。如果参数从右往左入栈,那么利用ebp可以顺利的依序
取出第一个,第二个...第n个参数,否则,就没法用直接的简单的方法取到第一
个参数了,原因是利用ebp只能直接定位到最后一个入栈的参数,而函数参数
的个数未知,因此第一个参数在栈中的位置就不太容易定位了。当然,这不是
绝对的,用些辅助的方法也可以做到,但是就不如让参数从右向左进栈实现起来
轻松简单了。