函数怎么来的
函数最本质是跳转和压栈。
跳转早在汇编时代就广泛应用:做一个汇编子模块,需要时jmp到其起始地址,执行完jmp回之前的地址(想起学微机原理时一堆人围着单板机输入子模块指令,像发报一样)。
后来发现光有跳转也不够,赤裸裸去,再赤裸裸回,带不走一片云彩。于是想到可以划出一段内存空间,跳转到子模块时传递一些参数信息过去,顺带也可以把跳转前的地址也存进去,jmp回时可以间接跳回。
最后伟大的栈结构出现了,后进先出的结构简直是为这种场景量身定做:调用者和子模块可以分别用内存递增和递减访问的方式自然而简单的交换信息。
C编译器把以上这一切封装到一起,再与硬件配合起来专门维护一个栈空间,终于形成了函数:调用者先把参数依次压栈,再跳转到函数入口地址;函数内部从栈中读取参数,即依次出栈;函数结束后,根据栈中获取的返回地址间接跳回。以跳转和压栈为基础的函数使编程变得轻松。有的CPU提供专门指令,跳转时自动压栈保护现场,退出时自动从栈中弹出地址并返回,因此现在函数已经和CPU硬件层紧密结合,可以说是软件最根本的基础之一。
函数调用规范
编译器处理函数调用时,隐藏着一个调用规范,它用来定义编译器内部实现函数的所有机制细节,包括对以下问题的规定:
a. 编译器产生的函数名,比如_test还是__test等等;
b. 参数传递机制(栈or CPU寄存器)以及多个参数的传递顺序(右到左或左到右);
c. 函数调用结束后,谁把栈恢复原状,调用者还是被调用者;
d. 函数的返回值放在哪里;
这些问题都隐藏在编译器之后,似乎不影响函数使用,但最好对此有所了解,因为:
1)跨语言编程时,要考虑不同函数调用规范的匹配。比如VC默认__cdecl方式,windows API是__stdcall,所以VC开发dll给其他语言用,要转换成__stdcall规范(Setting...\C/C++ \Code Generation项设置)。
2)不能用不兼容的调用规范给函数指针赋值。例如:
__stdcall int callee(int); //被调函数是以int为参数,以int为返回值
void caller(__cdecl int(*ptr)(int)); //主调函数以函数指针为参数
__cdecl int(*p)(int) = callee; //错,存储不匹配调用规范的函数地址是非法操作
指针p和callee()的类型不兼容,它们的调用规范不同,因此不能把callee地址赋给p,尽管两者有相同的返回值和参数列表,所以说调用规范也是函数类型的一部分。
3)手工编写汇编函数与C代码混合调用时,要对具体编译器的调用规范和CPU架构非常清楚,汇编函数的调用规范必须和C编译器完全兼容,才能相互吻合调用。大家可以尝试在VC下用纯汇编实现函数add(int a, int b),再在c文件main函数里调用,很有趣。
函数是二进制数的集合
typedef void (*pfunc) ( ); /*一个无参数、无返回类型的函数指针*/
pfunc preset = (pfunc)0x0; /*定义函数指针,指向CPU启动后第一条指令位置*/
*preset(); /*调用函数,重启*/
上面示例伪代码中没有函数实体,执行函数调用preset()却能"软重启",即执行启动后的首条指令,前提是位置0事先准备好了一条二进制指令。所以“函数无它,唯指令二进制集合耳!”,调用一个函数,本质只是换个地址执行代表机器指令的二进制码,这些二进制码理论上当然可以篡改的,如何绕开操作系统和编译器修改二进制指令,大概这就是最初黑客的动力和乐趣来源吧。