EagleBear2002 的博客

这里必须根绝一切犹豫,这里任何怯懦都无济于事

C++ 高级程序设计-02-函数

函数

  1. 一个函数就是一个功能
  2. 函数包括
    1. 系统函数(库函数)
    2. 用户自己定义的函数
      1. 无参函数
      2. 有参函数

函数的原则

  1. 函数不可以被嵌套定义:函数内部不可以再次定义新的函数
  2. 函数可以通过原型完成有默认参数的函数
  3. 函数是先定义后使用,具体是指上下文环境
  4. Runtime Environment 在我们 C++中是使用 Stack

函数模板

  1. template <typename T>
  2. T max(T a, T b, T c){}
  3. 在运行时确定 T 的类型

函数编译链接

  1. 编译时只编译当前模块
1
2
3
g(){//a.cpp
f();//b.cpp
}
  1. 编译每个编译单元(.cpp)是相互独立的,即每个 cpp 文件之间是不知道对方的存在的,.cpp 编译成 .obj 后,link 期时 a.obj 才会从 b.obj 中获得 f() 函数的信息(这就是为什么要预处理)
  2. link 时将编译的结果连接成可执行代码,主要是确定各部分的地址,将编译结果中的地址符号全换成实地址(call 指令在 a.cpp 被编译时只是 call f 的符号,而不知道 f 确切的地址)

重载与重写

  1. overload:语言的多态
  2. override:父子类的,OO 语言独有多态
  3. 多态不是程序语言独有的,而是语言拥有的特性。
  4. C++支持重载,C 不支持重载。

函数的重载(Overload)

  1. 原则:
    1. 名称相同,参数不同(重载函数的参数个数、参数类型、参数顺序至少一个不同)
    2. 返回值类型不作为区别重载函数的依据
  2. 匹配原则:
    1. 严格匹配
    2. 内部转换
    3. 用户定义的转换
  3. 以下为几个例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void bar(int i) {
cout << "bar(1)" << endl;
}
void bar(const char c) {
cout << "bar(2)" << endl;
}
void func(int a) {
cout << "func(1)" << endl;
}
void func(char c) {
cout << "func(2)" << endl;
}
void func(long long ll) {
cout << "func(3)" << endl;
}
void hum(int i, ...) {
cout << "hum(1)" << endl;
}
void hum(int i, int j) {
cout << "hum(2)" << endl;
}
int main() {
char c = 'A';
bar(c);
short s=1;
func(s);
hum(12, 5);
hum(10, 12, 1);
system("pause");
}
//输出结果为
//bar(2)
//func(1)
//hum(2)
//hum(1)

//下面这种是不被允许的,ambiguous
void f(long);
void f(double);
f(10);

函数的默认参数(是对函数重载的补充)

  1. 默认参数的声明:默认参数是严格从右至左的顺序使用的
    1. 在函数原型中给出
    2. 先定义的函数中给出
  2. 默认参数的顺序:
    1. 右->左
    2. 不间断
  3. 默认参数与函数重载要注意
    • void f(int); void f(int, int=2);
  4. 在定义中一般不给出默认参数(给出了也不会 CE),在调用的时候使用函数原型的时候给出默认参数。
  5. 函数默认重载,在面向对象编程中,子类即便修改默认参数,也不生效(即,以父类为准)。
1
2
3
4
5
6
7
8
9
//a.cpp 中
void f(int a,int b,int c){}
//b.cpp 中
void f(int,int = 2,int = 3);//使用函数原型
void g(){
f(1);//==f(1,2,3)
f(1,3);//==f(1,3,3)
f(1,5,5);//==f(1,5,5)
}

外部函数 extern

  1. 符号表:Name mangling: extern "C"
    • 在 C++的 g 中调用 C 中的 f,会在 link 的时候出问题(因为不在 C++ 的符号表中)
    • 解决方案:在函数名前面加上 extern 的关键词(这样子编译器就会在编译过程中从外部进行寻找)
  2. C 编译器编译的文件被放置在 lib 库中,C++不能直接调用,而是需要 extern 才可以
  3. 原因:符号表机制
1
2
3
4
extern void f();
void g(){
f();
}

符号表机制

  1. 符号表:与编译的各个阶段都有交互,存有函数名、地址等信息;编译时会创建一个函数符号表<name,address>,对应的符号后面的地址还没确定(link 期决定),call name 根据 name 找到符号表对应的地址,再执行
  2. 对于 c 语言来说,编译得到的符号表内函数 f 在符号表里的 name 就是 f(不存在函数重载)
  3. 对于 c++来说,因为有重载,所以 f(int) f(float) 符号表里的 name 是不同的
  4. c++对于 c 语言的函数 f 会按 c++的方式生成函数表中的 nameA,但 c 编译好的函数表内 f 对应的 nameB 和 nameA 不一致,导致 c++无法找到该函数

函数与内存

  1. 在内存中的 code,是不可以断章取义的。
  2. 需要按照类型来进行
  3. 函数是使用临时性存储空间,

存储空间与内存

  1. 从上往下分别是
    • code:每个指令都有对应的单元地址。函数的代码存放的位置是受到限制的
    • data:存放数据(全局变量、静态局部变量)
    • Stack:由系统管理,存放函数
    • Heap:可以用程序员进行分配,可以在运行时动态确定,int p = (int )malloc(4),归还内存 free(在 C++中不推荐使用这种方法进行处理,而是使用 new 和 delete)
  2. compiler 组织成符号表。CPP 是一个文件一个文件进行编译的。
    • 在编译 A 文件的时候,是不知道 B 文件存在的,也就是说每一个文件都是单独编译的。
    • 借助符号表来获取存储地址,问题? 函数名相同,重载(多态)的问题,解决:不仅仅按照函数名,还要按照函数参数来划分。
    • 所以函数表,不仅仅存储函数名,还存储函数的参数返回值类型。
  3. 问题:可以在不降低可读性的前提下,降低 COST 吗?
  4. 运行逻辑是由 Runtime Environment 是有差异的:注意合作方的运行环境(使用 Lib 的注意)

RunTime Environment

  1. 每一个函数都有栈空间,被称为 frame(active frame 是当前运行函数的栈空间)
  2. 以下类似是一种契约,这种约定被 compiler 和 linker 共同管理

_cdecl

函数空间(参数)归调用者管理,本章讲解的是这种,调用者清空栈。

问题:函数调用结束后,原空间的参数仍然在(未归还)

好处:由调用者管理所有的调用参数,可以灵活管理参数

  • 例子:printf() 函数是可变参数,根据字符串形式决定(由调用者控制):int printf(const char format,...)。这种情况只能由调用者归还。

坏处:安全问题,调用者环境被破坏。

  • 无法控制传递参数的个数,写了 8 个 %d,但是只传递了 1 个,则会导致调用者环境被破坏。
  • 同样的问题,就算环境不被破坏,也会导致软件内部不应该被看到的数据被拿出来

_stdcal

函数调用后,函数空间由被调用者管理,被调用者清空栈。调用者来传递参数(申请空间),由被调用者归还参数(归还空间),这部分空间被称为中间地带。

好处:空间节省,跨平台性:比如 C++ 调用 C 的时候(C 不允许重载)

坏处:对于可变参数的函数无法计算 %ebp 的参数个数,但是对于调用者是知道的,这样只能使用_cdecl

_fastcall

第 1 个参数放入 %ecx,第 2 个参数放入 %edx,其余参数放入栈中。

函数执行机制

建立被调用函数的栈空间

  • 栈空间是从高地址向低地址生长
  • 栈底:ebp(当前函数的存取指针,即存储或者读取数时的指针基地址)
  • 栈顶:esp(当前函数的栈顶指针)
  • 保存:返回地址、调用者的基指针
  • 过程描述:调用一个函数时,先将堆栈原先的基址(ebp)栈,以保存之前任务的信息。然后将栈顶指针的值赋给 ebp,将之前的栈顶作为新的基址(栈底),然后在这个基址上开辟相应的空间用作被调用函数的堆栈。函数返回后,从 ebp 中可取出之前的 esp 值,使栈顶恢复函数调用前的位置;再从恢复后的栈顶可弹出之前的 EBP 值,因为这个值在函数调用前一步被压入堆栈。这样,EBP 和 ESP 就都恢复了调用前的位置,堆栈恢复函数调用前的状态。

参数传递

值传递

  1. 最上面是 main 函数,左侧,下面是 Function.
  2. 为什么 ebp 和 esp 之间距离很大,因为我们要对齐,提高内存管理效率。
  3. 数据类型决定存放数据的空间的大小
  4. 函数调用过程:
    1. 开始调用 esp 从栈顶向下移动 32 位,存 ret_addr,开辟 main 函数的栈空间
    2. 然后 esp 继续向下存 ebp_main
    3. 然后 ebp 到 esp 处
    4. 然后 esp 到新的函数空间的栈顶
    5. 函数处理
    6. esp 先返回到 ebp
    7. 然后 ebp 根据 ebp_main 返回,然后 esp 加一(向上)
    8. 之后 esp 回到 ret_addr 位置即可。
    9. 动画过程看 PPT 50 页
  5. eip 存放了 ret_addr

引用传递:函数副作用

  1. 传递的是地址,会同时修改对应地址单元中的值。

call by name

  1. call by name 是指在用到该参数的时候才会计算参数表达式的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
void p(int x){
++i;
++x;
}
int a[10];
int i = 1;
a[1] = 1;
a[2] = 2;
p(a[i]);
//值传递:对于 i 的修改会影响全局,但是不影响 a[i]
//引用传递:同时影响 i 和 a[i]
//call by name:将 p 函数中的 x 进行替换。(Delayed Evaluation),也就是 a[2] = 3;x -> a[i]
//call by name:主要是对于没有函数副作用的时候

call value-result: copy-restore

1
2
3
4
5
6
7
void p(int x,int y){
++x;
++y;
}
int a = 1;
p(a,a);
//a = 1,如果两个都为引用传递,则 a=3

保存调用函数的运行状态(额外的 Cost)

  • 存储新的基指针:如上面,将 ret_addr 和 main_esp 进行存储。
  • 分配函数存储的空间
  • 执行某些功能
  • 释放不必要的存储空间

将控制转交给被调函数

  • 加载调用者的基指针
  • 记载返回地址

Summary

  1. 加载参数(进栈)
  2. 保存上下文环境
    • 保存返回地址
    • 保存调用者基指针
  3. 执行函数
    • 设置新的基指针
    • 分配空间(可选)
    • 执行一些任务
    • 释放空间(如果分配了的话)
  4. 恢复上下文环境
    • 加载调用者基指针
    • 加载返回指针
  5. 继续执行调用者的功能

思考

如果所有数据都放置在内存中的数据区: - 好处:方便管理 - 坏处:占用空间大,没有利用程序的局部性。

函数原型

  1. 遵守先定义后使用原则
  2. 自由安排函数定义位置
  3. 语句:只需参数类型,无需参数名称
  4. 编译器检查
  5. 函数原型:只需要看到函数名和参数读取到即可:int func(int,int)
    • 在调用点一定要能看到接口
    • 仅仅需要函数名和参数类型即可
  6. 函数原型应当放置在头文件中

内外部函数划分使用

内部函数

  1. static 修饰

外部函数

  1. 默认状态的 extern

内联函数 inline

  1. 目的:
    1. 提高可读性
    2. 提高效率
    3. 解决了两个 cost 的问题
  2. 对象:使用频率高、简单、小段代码
  3. 实现方法:编译系统将为 inline 函数创建一段代码,在每次调用时,用相应的代码替换
  4. 限制:
    1. 必须是非递归函数,因为已经加入主体部分了
    2. 由编译系统控制,和编译器是完全相关的
  5. inline 关键字仅仅是请求
    1. 有可能是递归,无法加入
    2. 也有可能是很复杂的函数,导致无法理解(上下文比较复杂)
  6. 提请 inline 但是被拒绝可能是有代价的
  7. 如果对象的初始化-构造函数为明确给出,计算机会给出 inline 的构造函数
  8. 宏:max(a,b) (a) > (b) ? (a) : (b):不同于 inline 函数,一定要有括号,因为运算数据中的优先级不同

例子

  1. 没有进行替换,只是将 ascii 函数体内操作直接进行替换。
  2. 内联必须和函数体放在一起,而不是和原型放在一起,并且函数体必须出现在调用之前,否则函数可以编译,但是不出现内联。

使用 inline 的优点和缺点

  1. 只有对编译系统的提示
    1. 过大、复杂、循环选择和函数体过大的会导致被拒绝
    2. 函数指针
  2. 编译器:静态函数
  3. 缺点:
    1. 增大目标代码
    2. 病态的换页:如果有过长的代码,被替换进入代码的段中,代码页在内存和磁盘中反复换页抖动(每调用一次内联函数就会将那段代码复制到主文件中,内存增加,内存调用时原本一页的内容可能出现在第一页+第二页的一部分,造成操作系统的“抖动”)
    3. 降低指令快取装置的命中率(instruction cache hit rate)

问题

  1. 是所有的编译器都能做到 inline 吗?不是都能做到
  2. 如果我向编译器要求 inline,是否一定能做到吗?如果做不到按照正常函数进行处理
  3. 函数放在头文件中被多次包含的重定义问题

ROP

  1. 在返回地址的时候,攻击我们的程序,调整 Bad_addr 导致调用到坏的代码(将错误的代码注入 stack 中去,在传入参数的过程中传入错误的代码)
  2. 防止这种攻击:禁止在执行过程中写入 stack
  3. 新的攻击方式:修改 return 前面的短序列(rop 链攻击)
    • 使用正确代码的错误组合进行攻击
    • 如果太长,需要依赖寄存器,导致攻击困难
  4. 防止这种攻击:禁止读系统中的代码
    1. 因为这种攻击需要先读出来所有的操作,然后进行组合,如果不能读出也就没有了

什么是 ROP

  1. 所谓 ROP:就是面向返回语句的编程方式,它就用 libc 代码段里面的多个 retq 前的一段指令的一段指令拼凑出一段有效的逻辑,从而达到攻击目的。
  2. 什么是 retq:retq 指定决定程序返回值在哪里执行,由栈上的内容决定,这是攻击者很容易控制的地址。
  3. 控制参数:在 retq 前面执行的 pop reg 指令,将栈上的内容弹到指令的寄存器上,以达到预期。(重复上述操作指导达成目的)
  4. 我们利用 glibc 进行逆向工程来查看返回前的 pop 指令

参考

使用 ROP 攻击技术

函数副作用

函数副作用可以实现 call by reference,参考 scanf,而并不是通过 return 多参数而实现。