LV01-22-C语言-内联函数与宏定义

内联函数是什么?有什么用?为什么要用它?若笔记中有错误或者不合适的地方,欢迎批评指正😃。

点击查看使用工具及版本
PC端开发环境 Windows Windows11
Ubuntu Ubuntu20.04.2的64位版本
VMware® Workstation 17 Pro 17.6.0 build-24238078
终端软件 MobaXterm(Professional Edition v23.0 Build 5042 (license))
Win32DiskImager Win32DiskImager v1.0
Linux开发板环境 Linux开发板 正点原子 i.MX6ULL Linux 阿尔法开发板
uboot NXP官方提供的uboot,使用的uboot版本为U-Boot 2019.04
linux内核 linux-4.19.71(NXP官方提供)
点击查看本文参考资料
分类 网址 说明
官方网站 --- ---
点击查看相关文件下载
分类 网址 说明
--- --- ---

一、背景

为什么想到内联函数了呢?

我在学习linux驱动开发的时候,到了设备模型这一部分,要写三个驱动文件,想要清楚的区分不同的打印信息来自哪一个文件,这个时候我就要去自定义printk函数,通过__FILE__这个预定义的宏来打印出文件名,可是这里有个坑,就是它自带文件绝对路径,打印出来很长。

怎么解决打印路径很长的问题?在linux下路径都是/分割的,所以,我们找到字符串中最后一个/,然后返回后面的内容就可以了。我们可以自己实现,也可以使用string.h头文件中的strrchr()函数。一开始的时候,我以为内核下没有实现string.h中相关的函数,包括strrchr(),所以就考虑自己去实现。(后来才发现,其实内核是实现了的,就声明在include/linux/string.h中,使用的时候包含</linux/string.h>就可以了)。

实现时候遇到了什么问题呢?其实也没有什么问题,就是把 C 语言函数直接定义在头文件中看着有点不习惯。就用宏实现了一下这个函数,但是宏不会检查参数类型,可能是会有风险的。后来想起来有个内联函数,它是经常被定义在头文件中的,会在调用的地方直接展开,也会对参数类型进行检查。

所以到最后虽然还是用了include/linux/string.h中的strrchr()函数,但还是来详细学习下内联函数吧。

二、内联函数

1. 什么是内联函数?

函数定义中,函数返回类型前加上关键字 inline 即把此函数指定为内联函数。例如:

1
2
3
4
5
6
7
8
9
inline char *sstrrchr(const char *s, int c)
{
const char *last = NULL;
do {
if (*s == (char)c)
last = s;
} while (*s++);
return (char *)last;
}

inline,翻译成“内联”或“内嵌”。意指:当编译器发现某段代码在调用一个内联函数时,它不是去调用该函数,而是将该函数的代码,整段插入到当前位置

Tips:

(1)关键字inline 必须与函数定义体放在一起才能使函数成为内联,仅将inline 放在函数声明前面不起任何作用。

(2)inline关键字仅仅是建议编译器在编译的时候做内联展开处理,不是强制。如果把编译选项设置为 -O0,即使是 inline 函数也不会被内联展开,除非设置了强制内联展开的属性(__attribute__((always_inline)))。

这里有两个问题

  • 函数前面加上inline一定会有效果吗?
  • 如果不加inline就不是内联函数了吗?

2. 为什么要使用内联函数?

  • 内联函数最初的目的:代替部分 #define 宏定义;
  • 使用内联函数替代普通函数的目的:提高程序的运行效率;

2.1 为什么要代替部分宏定义?

(1)宏是预处理指令,在预处理的时候把所有的宏名用宏体来替换;内联函数是函数,在编译阶段把所有调用内联函数的地方把内联函数插入;

(2)宏没有类型检查,无论对还是错都是直接替换;而内联函数在编译时进行安全检查;

(3)宏的编写有很多限制,例如只能写一行,不能使用return控制流程等;

(4)对于C++ 而言,使用宏代码还有另一种缺点:无法操作类的私有数据成员。

2.2 为什么可以提高运行效率?

函数是一个可以重复使用的代码块,CPU 会一条一条地挨着执行其中的代码。CPU 在执行主调函数代码时如果遇到了被调函数,主调函数就会暂停,CPU 转而执行被调函数的代码;被调函数执行完毕后再返回到主调函数,主调函数根据刚才的状态继续往下执行。

一个 C/C++程序的执行过程可以认为是多个函数之间的相互调用过程,它们形成了一个或简单或复杂的调用链条,这个链条的起点是main(),终点也是main()。当main()调用完了所有的函数,它会返回一个值(例如return 0;)来结束自己的生命,从而结束整个程序。

函数调用是有时间和空间开销的。程序在执行一个函数之前需要做一些准备工作,要将实参、局部变量、返回地址以及若干寄存器都压入栈中,然后才能执行函数体中的代码;函数体中的代码执行完毕后还要清理现场,将之前压入栈中的数据都出栈,才能接着执行函数调用位置以后的代码。

栈空间就是指放置程序的局部数据,也就是函数内数据的内存空间,在系统下,栈空间是有限的,假如频繁大量的使用就会造成因栈空间不足所造成的程序出错的问题,函数的死循环递归调用的最终结果就是导致栈内存空间枯竭。

如果函数体代码比较多,需要较长的执行时间,那么函数调用机制占用的时间可以忽略;如果函数只有一两条语句,那么大部分的时间都会花费在函数调用机制上,这种时间开销就就不容忽视。

为了消除函数调用的时空开销,这里有一种提高效率的方法,即在编译时将函数调用处用函数体替换,类似于宏展开,这样做的好处是省去了调用的过程,加快程序运行速度。这种在函数调用处直接嵌入函数体的函数称为内联函数(Inline Function)。但也存在缺点,每当代码调用到内联函数,就需要在调用处直接插入一段该函数的代码,所以程序的体积将增大

2.3 两个疑问

我们知道内联函数是在调用的地方展开函数定义,那么展开也好,替换也好,都存在下面两个问题:

  • 内联函数一定就会展开吗?
  • 在什么情况下内联函数会展开?

3. 内联的局限性

内联能提高函数的执行效率,为什么不把所有的函数都定义成内联函数?

(1)内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。(一般情况,在函数频繁调用且函数内部代码很少的情况下使用内联)

(2)每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。

(3)在C++中,类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。

(4)一个好的编译器将会根据函数的定义体,自动地取消不值得的内联。对函数作inline声明只是程序员对编译器提出的一个建议,而不是强制性的,并非一经指定为inline编译器就必须这样做。编译器有自己的判断能力,它会根据具体情况决定是否这样做。一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了inline 不应该出现在函数的声明中)。具体是否会被编译器优化为内联也要看优化级别。有些函数即使声明为内联的也不一定会被编译器内联,这点很重要。比如虚函数和递归函数就不会被正常内联。通常,递归函数不应该声明成内联函数。(递归调用堆栈的展开并不像循环那么简单,比如递归层数在编译时可能是未知的,大多数编译器都不支持内联递归函数)。虚函数内联的主要原因则是想把它的函数体放在类定义内,为了图个方便,亦或是当作文档描述其行为, 比如精短的存取函数。将内联函数放在头文件里实现是合适的,省却我们为每个文件实现一次的麻烦。而所以声明跟定义要一致,其实是指,如果在每个文件里都实现一次该内联函数的话,那么,最好保证每个定义都是一样的,否则,将会引起未定义的行为,即是说,如果不是每个文件里的定义都一样,那么,编译器展开的是哪一个,那要看具体的编译器而定。所以,最好将内联函数定义放在头文件中。

4. 什么时候用?

  • 只有当函数只有 10 行甚至更少时才将其定义为内联函数。

滥用内联将导致程序变慢。内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。一个较为合理的经验准则是, 不要内联超过 10 行的函数,而且函数体内没有内循环。

  • 内联函数的定义应该放在头文件中, 方便编译器在调用点内联展开定义。

如果内联函数的定义比较短小, 逻辑比较简单, 实现代码放在 .h 文件里没有任何问题。对于复杂的内联函数的定义, 可以把它萃取到单独的 -inl.h 中. 这样把实现和类定义分离开来, 当需要时包含对应的 -inl.h 即可。

三、内联函数与编译器

1. 先解答问题

(1)函数前面加上inline一定会有效果吗?

答:不会,使用内联inline关键字修饰函数只是一种提示,编译器不一定认。

(2)如果不加inline就不是内联函数了吗?

答:存在隐式内联,不用inline关键字,C++中在类内定义的所有函数都自动称为内联函数。

(3)内联函数一定就会展开吗?

答:其实和第一个问题类似,还是看编译器认不认。

(4)在什么情况下内联函数会展开?

答:首先需要满足有inline修饰或者是类中的定义的函数,然后再由编译器决定。

其实内联函数管不管用是由编译器说了算的!

2. 如何要求编译器展开内联函数

这里的几条可以在这个网站用在线的gcc编译器查看区别:Compiler Explorer

  • (1)编译器开优化
1
gcc -O2 -Wall test.c -o test 

只有在编译器开启优化选项的时候,才会有inline行为的存在,比如对gcc在-O0(不做任何优化)时就不会作任何的inline处理,对于-O2的优化方式,编译器会通过启发式算法决定是否值得对一个函数进行内联,同时要保证不会对生成文件的大小产生较大影响。 而-O3模式则不在考虑生成文件的大小。(gcc的优化选项可以看这里Optimize Options (Using the GNU Compiler Collection (GCC))。不展开内联的时候,甚至有些版本的gcc编译的时候还会有链接错误:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

inline void inline_function(void)
{
printf("[inline_function]========= Get!!!\n");
}

int main(int argc, const char * argv[])
{
inline_function();
return 0;
}

我们使用以下命令编译的时候会有不同的效果:

image-20241230220959054
  • (2)使用attribute属性:
1
2
3
static inline __attribute__((always_inline)) int add_i(int a,int b){
//函数体
}
  • (3)使用auto_inline:

#pragma auto_inline(on/off),当使用#pragma auto_inline(off)指令时,会关闭对函数的inline处理,这时即使在函数前面加了inline指令,也不会对函数进行内联处理。

上述操作都仅仅是对编译器提出内联的建议,最终是否进行内联由编译器自己决定,大多数编译器拒绝它们认为太复杂的内联函数(例如,那些包含循环或者递归的),而且对于C++而言,类的构造函数、析构函数和虚函数往往不是内联函数的最佳选择。

3. 内联函数应该定义在哪?

内联函数既然也是直接替换的,那么我们应该把它放在 .c 源文件还是 .h 头文件呢?内联展开是在编译时进行的,只有链接的时候源文件之间才有关系。所以内联要想跨源文件必须把实现写在头文件里。如果一个内联函数会在多个源文件中被用到,那么必须把它定义在头文件中。

内联函数的定义不一定要跟声明放在一个头文件里面:定义可以放在一个单独的头文件中,里面需要给函数定义前加上inline 关键字,原因看下面优点(2);然后声明 放在另一个头文件中,此文件include上一个头文件。

这种用法 boost里很常见,有如下优点:

  • 优点(1)实现跟API分离封装。

  • 优点(2)可以解决有关inline函数的循环调用问题。

4. 两种内联

虽然C语言标准没有明确区分“隐式内联”和“显式内联”,但可以通过不同的方式来实现类似的效果。

显式内联

  • 使用 inline 关键字显式地告诉编译器某个函数应该被内联。
  • 编译器会优先考虑内联,但最终是否内联仍然由编译器决定。

隐式内联

  • 编译器根据优化策略自动决定是否将函数内联。
  • 编译器会考虑函数的大小、调用频率等因素。

4.1 隐式内联:不需要inline关键字

前面提到的C++中在类内定义的所有函数都自动称为内联函数,类的成员函数的定义直接写在类的声明中时,不需要inline关键字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

class Trace{
public:
Trace()
{
noisy = 0;
}
void print(char *s)
{
if (noisy)
{
printf("%s", s);
}
}
void on(){ noisy = 1; }
void off(){ noisy = 0; }
private:
int noisy;
};

4.2 显式内联:需要使用inline关键字

对于显式内联,C和C++都是一样的,需要加上inline关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

class Trace{
public:
Trace()
{
noisy = 0;
}
void print(char *s); //类内没有显示声明
void on(){ noisy = 1; }
void off(){ noisy = 0; }
private:
int noisy;
};
//类外显示定义
inline void Trace::print(char *s)
{
if (noisy)
{
printf("%s", s);
}
}

四、内联函数和重定义

1. 什么是重定义

C/C++语法中,如果变量、函数在同一个工程中被多次定义,链接期间会报类似“对 xxx 多重定义”的错误。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

void normal_function(void)
{
printf("[normal_function]========= Get!!!\n");
}

void normal_function(void)
{
printf("[normal_function]========= Get!!!\n");
}

int main(int argc, const char * argv[])
{
normal_function();
return 0;
}
image-20241230215556060

2. 内联函数不报错?

内联函数应该在头文件中定义,这一点不同于其他函数。编译器在调用点内联展开函数的代码时,必须能够找到 inline 函数的定义才能将调用函数替换为函数代码,而对于在头文件中仅有函数声明是不够的。

当然内联函数定义也可以放在源文件中,但此时只有定义的那个源文件可以用它,而且必须为每个源文件拷贝一份定义(即每个源文件里的定义必须是完全相同的),当然即使是放在头文件中,也是对每个定义做一份拷贝,只不过是编译器替我们完成这种拷贝罢了。但相比于放在源文件中,放在头文件中既能够确保调用函数是定义是相同的,又能够保证在调用点能够找到函数定义从而完成内联(替换)。

那重复定义那么多次,不会产生重定义错误吗?我在C中试了一下,报错了,也许是我写的demo有问题,这里就不深究了。但是cpp中是没问题的。具体可以看这里的demo。

LV02_C_BASICS/22_inline · 苏木/imx6ull-app-demo - 码云 - 开源中国

3. static inline?

一般来说,需要在inline前加static。因为inline 是一个关键字,用于建议编译器在编译时将函数的具体代码插入到函数调用的地方,而不是像传统的函数调用那样跳转到函数的地址执行。也就是说,开发者只有建议权,只有编译器具有决定权。

如果编译器没有将函数作为内联函数,那么如果没有static修饰,而定义函数的头文件又被多个文件include,会造成函数的多重定义,导致编译错误,这种情况加上static将函数的有效范围限制在文件内部。

可以看这个demo:LV02_C_BASICS/22_inline/05_static_inline · 苏木/imx6ull-app-demo - 码云 - 开源中国。当inline前面没有static的时候,需要在gcc的编译选项中加入-O2或者之后的优化等级,不然会报重定义错误。不想加优化选项的话,就在头文件中的inline关键字前面加static就可以了。

五、内联与宏

在C程序中,可以用宏代码提高执行效率。宏代码本身不是函数,但使用起来象函数。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、 返回参数、执行return等过程,从而提高了速度。

使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。宏看起来像函数调用,但没有参数类型及返回值,实际会有隐藏的难以发现的问题。例如:

1
2
3
4
5
6
7
8
9
10
#define TABLE_MULTI(x) (x*x)

TABLE_MULTI(10+10)// 结果为400?
// 宏的调用结果为(10+10*10+10) = 120

// 加上括号?
#define TABLE_MULTI(x) ((x)*(x))
int a = 3;
TABLE_MULTI(a++); // 依然会出错
// 我们希望是(a+1)*(a+1)的结果,实际上是(a++)*(a++) = 12

具体情况如下:

image-20241230223030373

内联函数和宏的区别在于,宏是由预处理器对宏进行替代,而内联函数是通过编译器控制来实现的。而且内联函数是真正的函数,只是在需要用到的时候,内联函数像宏一样的展开,所以取消了函数的参数压栈,减少了调用的开销。我们可以像调用函数一样来调用内联函数,而不必担心会产生于处理宏的一些问题。

参考资料

【1】内联函数 inline- 定义在头文件中的简单函数_内联函数定义在头文件-CSDN博客

【2】【C++】C++中内联函数详解(搞清内联的本质及用法)-CSDN博客

【3】从编译器的角度理解内联函数_编译原理 函数内联-CSDN博客

【4】深入理解内联函数(C语言)_c语言 内联函数-CSDN博客