编程探秘之函数

在进入主题之前,需要提前说明:
本文中的函数是广义上的函数,包括纯面向对象语言的中成员函数。文中的例子都是基于C++的,运行环境:64位MacOS Big Sur Xcode 12。为了避免干扰,代码中省略了内存管理的代码。

在软件开发过程中,经常会发现相同功能的代码,差别仅仅是几个参数不一样。DRY原则告诉我们,不要写重复的代码。于是,程序员们想到了使用函数对特定功能进行封装。几乎所有编程语言都支持编写函数。编程中使用函数是如此地自然,人们甚至忘了它的由来。

一、从函数早期聊起

1.诞生的故事

EDSAC计算机于1949年5月6日投入运行,它是世界上第一台实用的存储程序计算机。此前的计算机每次执行新的运算,都需要插入不同的线路进行重新装配,而EDSAC 则通过存储器中的软件实现各种不同的运算操作,这就对编程提出了很高的要求。许多程序在运行的过程中,都需要重复执行某个操作,比如在某个复杂的数字运算中,需要多次进行开平方操作。如果每次开平方都得把平方根代码写上,那么程序当中就会出现许多重复代码,占用不必要的空间(EDSAC 的内存只有两千字节左右),使程序变得庞大。

为了简化编程过程,威尔克斯的方法是建立子程序库,也就是将常见的子程序单独列出,集中起来。一旦程序在运行的过程中需要使用到某个常见的子函数,计算机就会在子程序库中“查找定义”,执行相应的子程序代码,根据输入值进行运算,再将运算结果返回。

威尔克斯团队比冯·诺依曼更早使用了汇编语言,其子程序的思想也成了后来的高级编程语言中的函数。

2.为什么需要函数

“函数”这个名词是从英文function翻译过来的,function的原意是“功能”。顾名思义,一个函数就是一个功能。在很多编程语言中,main函数就是编程入口。一个较大的程序不可能把所有代码都放到一个主函数中。

函数的出现解决了指令级别的重复问题。在早期,计算机存储还比较小的时候,避免重复指令可以显著的节约空间。现今的计算机硬件相比50年代的有了巨大优势,节约空间这一作用显得没那么重要了。相比于早期,函数更重要的作用是避免编写重复代码,提高代码的复用性,便于规划、组织、编程和调试

二、探秘普通函数

1.不同角度看编程语言

高级编程语言屏蔽了语言特性的实现细节,让使用者站在一个更高层次上去使用编程语言。在更高层次的角度使用编程语言,便于我们关注业务本身,不必关注技术细节,专注于使用编程语言去解决问题;当我们从更底层的角度来思考编程语言提供的语法与特性,会更透彻地理解语言运行机制,从而认清各种语法的本质。

编程语言会告诉你支持哪些数据类型,不会告诉你数据类型是一种对内存数据的解释方式;编程语言会告诉如何定义一个类或结构体,不会告诉你类或结构体是相关性数据的组织方式。编程语言会告诉你如何定义并实现一个函数,同样不会告诉你函数在底层是什么。下面,带着好奇心,我们开启探索之旅吧。

2.函数是如何执行的

2.1函数的汇编表示

先来看一段简单的代码。定义了foo函数,foo函数有一个long类型的bar参数,函数体中定义long类型的两个变量a和b,函数返回了3个变量的和。在main函数中调用foo函数,返回值赋值给了result。

// 代码1
long foo(long bar) {
    long a = 1;
    long b = 2;
    return bar + a + b;
}

int main(int argc, const char * argv[]) {
    long result = foo(10);
    return 0;
}

代码1对应的汇编代码如下:

// main函数
0x100003f80 <+0>:  pushq  %rbp
0x100003f81 <+1>:  movq   %rsp, %rbp
0x100003f84 <+4>:  subq   $0x20, %rsp
0x100003f88 <+8>:  movl   $0x0, -0x4(%rbp)
0x100003f8f <+15>: movl   %edi, -0x8(%rbp)
0x100003f92 <+18>: movq   %rsi, -0x10(%rbp)
0x100003f96 <+22>: movl   $0xa, %edi
0x100003f9b <+27>: callq  0x100003f50           
0x100003fa0 <+32>: xorl   %ecx, %ecx
0x100003fa2 <+34>: movq   %rax, -0x18(%rbp)
0x100003fa6 <+38>: movl   %ecx, %eax
0x100003fa8 <+40>: addq   $0x20, %rsp
0x100003fac <+44>: popq   %rbp
0x100003fad <+45>: retq   
// foo函数
0x100003f50 <+0>:  pushq  %rbp
0x100003f51 <+1>:  movq   %rsp, %rbp
0x100003f54 <+4>:  movq   %rdi, -0x8(%rbp)
0x100003f58 <+8>:  movq   $0x1, -0x10(%rbp)
0x100003f60 <+16>: movq   $0x2, -0x18(%rbp)
0x100003f68 <+24>: movq   -0x8(%rbp), %rax
0x100003f6c <+28>: addq   -0x10(%rbp), %rax
0x100003f70 <+32>: addq   -0x18(%rbp), %rax
0x100003f74 <+36>: popq   %rbp
0x100003f75 <+37>: retq   

2.2必要的汇编知识

在正式分析foo函数的执行过程之前,需要先补充一下必要的汇编知识。
本文中的汇编分析是在Xcode中进行的,汇编是x86-64汇编,汇编风格是AT&T。
AT&T汇编在指令后面加上q等字母表示操作数据的字节数,汇编指令后面的b表示操作1个字节的数据,w表示操作2个字节数据, l 表示操作4个字节数据,q表示操作8个字节数据。
%后面跟着的是寄存器,$后跟着的是操作数,()表示间接寻址。
参数传递按从左至右的顺序依次是:rdi, rsi, rdx, rcx, r8, r9,如果多余6个才有压栈的方式进行参数传递。
常用的汇编指令如下表:


2.3汇编分析

为了了解foo函数的执行过程,我们先分析下foo函数的汇编代码。

// foo函数汇编分析
0x100003f50 <+0>:  pushq  %rbp //将rbp寄存器的值压栈,目的是函数结束可以恢复rbp的值
0x100003f51 <+1>:  movq   %rsp, %rbp //将rsp寄存器的值赋值给rbp,也即rbp指向栈顶
0x100003f54 <+4>:  movq   %rdi, -0x8(%rbp) //将rdi的值存储到栈顶-8地址的空间,rdi中存储的函数main调用时传过来的10,稍后分析main函数会再次提到
0x100003f58 <+8>:  movq   $0x1, -0x10(%rbp) //将1存储到栈顶-16地址的空间,0x是16进制表示法,其中0x10即是10进制的16
0x100003f60 <+16>: movq   $0x2, -0x18(%rbp) //将2存储到栈顶-24地址的空间
0x100003f68 <+24>: movq   -0x8(%rbp), %rax //将10赋值给寄存器rax,此时rax中存储值是10
0x100003f6c <+28>: addq   -0x10(%rbp), %rax //将1加rax值的和赋值给rax,此时rax中存储值是11
0x100003f70 <+32>: addq   -0x18(%rbp), %rax //将2加rax值的和赋值给rax,此时rax中存储值是13
0x100003f74 <+36>: popq   %rbp //恢复rbp的值
0x100003f75 <+37>: retq   //函数调用结束结束,返回main函数

从上面的分析中,我们知道在foo返回前rax寄存器中存储的值13,在执行retq指令时并没有返回任何值,那main函数是如何拿到foo函数的返回值的呢?

我们直接分析和foo调用相关的代码,无关代码暂不分析。

// mian函数汇编分析
0x100003f96 <+22>: movl   $0xa, %edi // 将10存入寄存器edi中,在foo的汇编分析中使用到了edi的值
0x100003f9b <+27>: callq  0x100003f50 // 调用foo函数        
0x100003fa0 <+32>: xorl   %ecx, %ecx // 清理寄存器ecx,此处和foo调用无关
0x100003fa2 <+34>: movq   %rax, -0x18(%rbp) // 将寄存器rax存储的值存储到-0x18(%rbp)的内存中,也即赋值给result

至此,我们已经分析明白整个函数的执行过程。下图描述了foo函数调用过程栈的变化。

三、探秘成员函数

1.对象内存模型

先来看一段代码。在main函数中,将一个数组的地址通过强制转换赋值给了Person类型的指针变量person1,person2指向的是通过new关键字初始化的对象。通过person1和person2调用introduceOneself函数,输出的结果分别是什么?

// 代码2
#include <iostream>
using namespace std;

class Person {
    long m_age;
    long m_height;
    long m_weight;
public:
    Person(long age, long height, long weight) {
        m_age = age;
        m_height = height;
        m_weight = weight;
    }
    void introduceOneself() {
        cout << m_age << endl;
        cout << m_height << endl;
        cout << m_weight << endl;
    }
    long foo(long bar) {
        long a = 1;
        long b = 2;
        return bar + a + b;
    }
};

int main(int argc, const char * argv[]) {
    long array[3] = {20, 180, 75};
    Person *person1 = (Person *)array;
    person1->introduceOneself();
    
    Person *person2 = new Person(20, 180, 75);
    person2->introduceOneself();
    
    return 0;
}

无论是通过person1还是person2调用introduceOneself函数,最终输出的结果都是20,180,75。


输出结果告诉我们,Preson对象的内存布局和数组并没什么差异。数组中的元素按照顺序依次从低地址到高地址排列,同样,对象中的成员变量也是(多继承和虚函数除外)。



person1内存数据如下图:



person2的内存数据如下图:

2.对象与成员函数

在上一小节中,我们分析了对象的内存模型,可以看出对象的内存模型中并没有存储和函数相关的信息,对象是怎么调用函数的呢?接下来,我们研究一下对象是如何调用自己的函数。introduceOneself函数中使用cout函数,分析起来干扰项太多,我们直接分析Person中的foo函数的调用过程。

// main函数的汇编分析
0x100003bfa <+106>: movq   -0x18(%rbp), %rdi // -0x18(%rbp)存储的是person2的对象地址,即寄存器rdi存储着preson2的地址
0x100003bfe <+110>: movl   $0xa, %esi // 将10存储到esi寄存器
0x100003c03 <+115>: callq  0x100003d10 // 调用foo函数

通过main函数的汇编,可以看到在调用foo函数时,传了2个参数,rdi中存储着对象的地址,esi中存储着函数的实参10。

// foo函数的汇编汇编分析
0x100003d10 <+0>:  pushq  %rbp
0x100003d11 <+1>:  movq   %rsp, %rbp
0x100003d14 <+4>:  movq   %rdi, -0x8(%rbp) // 将对象地址存(this)储到-0x8(%rbp)的内存单元
0x100003d18 <+8>:  movq   %rsi, -0x10(%rbp) // 将10储到-0x8(%rbp)的内存单元
0x100003d1c <+12>: movq   -0x8(%rbp), %rax // this存储rax中
0x100003d20 <+16>: movq   -0x10(%rbp), %rcx // 将10存储到rcx中
0x100003d24 <+20>: addq   (%rax), %rcx // 将20+10=30存储到rcx中
0x100003d27 <+23>: addq   0x8(%rax), %rcx // 将30+180=210存储到rcx中
0x100003d2b <+27>: addq   0x10(%rax), %rcx // 75+210=285存储到rcx中
0x100003d2f <+31>: movq   %rcx, %rax // 将285存储到rax中
0x100003d32 <+34>: popq   %rbp
0x100003d33 <+35>: retq

在foo函数中通过寄存器rdi取到对象地址,将对象地址存储到寄存器rax中,然后分别通过(%rax)、0x8(%rax)、0x10(%rax)获取到m_age、m_height、m_weight。

分析了foo函数的调用过程,也不难理解为什么通过person1和person2调用introduceOneself函数输出的结果是一样的了。

3.继承与多态

3.1继承与函数

同样,先来看段代码。Student类继承自Person类,在main函数中person变量前后指向了Person对象和Student对象,并调用了introduceOneself函数。

// 代码3
class Person {
public:
    void introduceOneself() {
        cout << "Person" << endl;
    }
};

class Student : public Person {
public:
    void introduceOneself() {
        cout << "Student" << endl;
    }
};

int main(int argc, const char * argv[]) {
    Person *person = new Person();
    person->introduceOneself();
    
    person = new Student();
    person->introduceOneself();
    
    return 0;
}

运行上面的代码,输出结果都会是Person。

看一下main函数的汇编,可以看到两次调用了introduceOneself ,调用地址都是0x1000031f0,说明两次调用同一个函数。第一次调用introduceOneself时,寄存器rdi存储的是Person对象的地址,第二次调用时,寄存器rdi存储的是Student对象的地址。

调用普通函数,会直接根据指针的类型调用对应的函数,不会考虑指针实际的指向,这是在编译期做的事情。

// main函数的汇编
0x1000031a0 <+0>:  pushq  %rbp
0x1000031a1 <+1>:  movq   %rsp, %rbp
0x1000031a4 <+4>:  subq   $0x20, %rsp
0x1000031a8 <+8>:  movl   $0x0, -0x4(%rbp)
0x1000031af <+15>: movl   %edi, -0x8(%rbp)
0x1000031b2 <+18>: movq   %rsi, -0x10(%rbp)
0x1000031b6 <+22>: movl   $0x1, %edi
0x1000031bb <+27>: callq  0x100003e24               
0x1000031c0 <+32>: movq   %rax, -0x18(%rbp)
0x1000031c4 <+36>: movq   -0x18(%rbp), %rdi
0x1000031c8 <+40>: callq  0x1000031f0   // 第一次调用introduceOneself           
0x1000031cd <+45>: movl   $0x1, %edi
0x1000031d2 <+50>: callq  0x100003e24              
0x1000031d7 <+55>: movq   %rax, -0x18(%rbp)
0x1000031db <+59>: movq   -0x18(%rbp), %rdi
0x1000031df <+63>: callq  0x1000031f0  // 第一次调用introduceOneself            
0x1000031e4 <+68>: xorl   %eax, %eax
0x1000031e6 <+70>: addq   $0x20, %rsp
0x1000031ea <+74>: popq   %rbp
0x1000031eb <+75>: retq   

3.2多态与虚函数

简单修改一下代码3,修改后的代码如下。代码4和代码3的区别在于在introduceOneself函数前面多个virtual关键字,表明introduceOneself是一个虚函数。

// 代码4
class Person {
    long m_age = 10;
public:
    virtual void introduceOneself() {
        cout << "Person" << endl;
    }
};

class Student : public Person {
    long m_no = 20201234;
public:
    void introduceOneself() {
        cout << "Student" << endl;
    }
};

int main(int argc, const char * argv[]) {
    Person *person = new Person();
    person->introduceOneself();
    
    person = new Student();
    person->introduceOneself();
    
    return 0;
}

运行上面代码,输出结果将分别是Person和Student。

person指向的对象是运行时决定的,在编译时是无法决定的。那么,上面的代码是怎么调用到对应对应的函数呢?

在OC中,调用对象的函数是通过对象中的isa指针找到对应的类对象,类对象中保存着函数列表,然后就可以间接找到函数地址进行调用了。其实,在C++中,也有类似的机制,当有虚函数存在时,在对象的内存空间前8个字节中存储着虚函数列表的地址,也即虚表(这里指向的并不是虚表的表头,是表头+16的位置,从这里开始存储着各个虚函数的地址,这里可以不关心这个细节)。通过虚表,就可以找到对应的函数的地址。

下图显示的是Student对象的内存数据,前8个字节(红色框中)的数据是0x0100004068,也即Student对象的虚函数地址是0x0100004068


下图显示的是0x0100004068内存的数据,可以看到黄色框中的数据是0x0100003D60 ,这个地址其实就是introduceOneself的地址。


下图显示的是断点introduceOneself函数调用的截图,可以看到introduceOneself函数调用地址和上图黄色框中的数据是0x0100003D60是一样的。



通过上面的分析,我们已经明白了虚函数的机制。下面换个角度,从汇编的代码看下虚函数是怎么调用的。由于汇编比较长,省略了部分无关代码,只需关注和虚函数相关的代码。

// main函数的汇编
// 省略部分代码              
0x100003056 <+54>:  movq   -0x20(%rbp), %rdi // Person对象地址
0x10000305a <+58>:  callq  0x1000030c0 // 调用Person构造函数
0x10000305f <+63>:  movq   -0x20(%rbp), %rax // Person对象地址存储到rax
0x100003063 <+67>:  movq   %rax, -0x18(%rbp) // -0x18(%rbp)就是person变量的地址,Person对象地址赋值给person变量
0x100003067 <+71>:  movq   -0x18(%rbp), %rcx // person变量的值存储到rcx,rcx此时存储的是Person对象的地址
0x10000306b <+75>:  movq   (%rcx), %rdx // 虚表地址存在Person对象的开头位置,通过Person对象地址取到虚表地址并存储到rdx
0x10000306e <+78>:  movq   %rcx, %rdi // Person对象地址存储到rdi
0x100003071 <+81>:  callq  *(%rdx) // rdx存储的是虚表地址,*(%rdx)表示取到虚表前8个字节的内容(也即虚函数的地址),然后调用虚函数
// 省略部分代码               
0x100003093 <+115>: movq   -0x28(%rbp), %rdi // Student对象地址
0x100003097 <+119>: callq  0x1000030e0 // 调用Person构造函数             
0x10000309c <+124>: movq   -0x28(%rbp), %rax // Student对象地址存储到rax
0x1000030a0 <+128>: movq   %rax, -0x18(%rbp) // Student对象地址赋值给person变量
0x1000030a4 <+132>: movq   -0x18(%rbp), %rax // -0x18(%rbp)就是person变量的地址,Student对象地址赋值给person变量
0x1000030a8 <+136>: movq   (%rax), %rcx // 虚表地址存在Student对象的开头位置,通过Student对象地址取到虚表地址并存储到rdx
0x1000030ab <+139>: movq   %rax, %rdi // Student对象地址存储到rax,也即this指针
0x1000030ae <+142>: callq  *(%rcx) // rcx存储的是虚表地址,*(%rcx)表示取到虚表前8个字节的内容(也即虚函数的地址),然后调用虚函数
0x1000030b0 <+144>: xorl   %eax, %eax
0x1000030b2 <+146>: addq   $0x30, %rsp
0x1000030b6 <+150>: popq   %rbp
0x1000030b7 <+151>: retq  

3.4思考与提升

  1. 上面的内容并没分析多继承下的虚表情况,读者可以自行思考这种情况下的虚表和对象的关系,可以使用上面的分析方法进行验证。
  2. 子类如果没有重写父类的虚函数,情况会怎么样?
  3. OC是怎么实现继承与多态的,与C++的实现方法有什么异同?

参考资料:

【1】《计算机:一部历史》皮得·本特利 著
【2】《汇编语言》第三版 王爽 著
【3】《深度探索C++对象模型》Stanley B.Lippman斯坦利·B.李普曼) 著,侯捷

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容