嵌入式C语言自我修养 10:内联函数探究

10.1 属性声明:noinline & always_inline

这一节,接着讲 attribute 属性声明,attribute可以说是 GNU C 最大的特色。我们接下来继续讲一下跟内联函数相关的两个属性:noinline 和 always_inline。这两个属性的用途是告诉编译器:编译时,对我们指定的函数内联展开或不展开。它们的使用方法如下。

static  inline __attribute__((noinline)) int func();
static  inline __attribute__((always_inline)) int func();

内联函数使用 inline 声明即可,有时候还会用 static 和 extern 修饰。使用 inline 声明一个内联函数,和使用关键字 register 声明一个变量一样,只是建议编译器在编译时内联展开。使用关键字 register 修饰变量时,只是建议编译器在给变量分配存储空间时,将这个变量放到寄存器里,这样,程序的运行效率会更高。那编译器会不会放呢?编译器就要根据寄存器资源紧不紧张,这个变量用得频不频繁来做权衡。

同样,当一个函数使用 inline 关键字修饰,编译器在编译时一定会内联展开吗?未必。编译器也会根据实际情况,比如函数体大小、函数体内是否有循环结构、是否有指针、是否有递归、函数调用是否频繁来做决定。比如 GCC 编译器,一般是不会对内联函数展开的,只有当编译优化选项开到 -O2 以上,才会考虑是否内联展开。当我们使用 noinline 和 always_inline 对一个内联函数作了属性声明后,编译器的编译行为就变得确定了。使用 noinline 声明,就是告诉编译器,不要展开;使用 always_inline 属性声明,就是告诉编译器,要内联展开。

什么是内联展开呢?我们不得不说一下内联函数的基础知识。

10.2 什么是内联函数

函数调用开销

说起内联函数,又不得不说函数调用开销。一个函数在执行过程中,如果需要调用其它函数,一般会执行下面这个过程。

  • 保存当前函数现场
  • 跳到调用函数执行
  • 恢复当前函数现场
  • 继续执行当前函数

比如一个 ARM 程序,在一个函数 f1() 中,我们对一些数据进行处理,运算结果暂时保存在 R0 寄存器中。接着要调用另外一个函数 f2(),调用结束后,接着返回到 f1() 函数中继续处理数据。如果我们在 f2() 函数中使用到 R0 这个寄存器(用于保存函数的返回值),此时就会改变 R0 寄存器中的值,那么就篡改了 f1() 函数中的暂存运算结果。当我们返回到 f1() 函数中继续进行运算时,结果肯定不正确。

那怎么办呢?很简单,在跳到 f2() 执行之前,先把 R0 寄存器的值保存到堆栈中,f() 函数执行结束后,再将堆栈中的值恢复到 R0 寄存器中,这样 f1() 函数就可以接着继续执行了,就跟什么事情都没发生过一样。

这种方法证明是 OK 的,现代计算机系统,无论是什么架构和指令集,都是采用这种方法。虽然麻烦了点,但至少能解决问题,无非就是多花点代价,需要不断地保存现场、恢复现场,这就是函数调用带来的开销。

内联函数的好处

对于一般的函数调用,这种方法是没有问题的。但对于一些极端情况,比如说一个函数很小,函数体内只有一行代码,而且被大量频繁的调用。如果每次调用,都不断地保存现场,执行时却发现函数只有一行代码,又要恢复现场,往往造成函数开销比较大,性价比不高。这就跟你去五星级饭店订个餐位吃饭一样,VIP 包间、刀叉餐具、空调、服务人员都准备好了,你到了之后只要了一碗面条,吃完之后抹嘴走人,而且一天三顿你都这么干,你说服务员烦不烦?

函数调用也是如此。有些函数很小,而且调用频繁,调用开销大,算下来性价比不高。我们就可以将这个函数声明为内联函数。编译器在编译过程中遇到内联函数时,像宏一样,将内联函数直接在调用处展开。这样做的好处就是减少了函数调用开销,直接执行内联函数展开的代码,不用再保存现场、恢复现场。

10.3 内联函数与宏

看到这里,可能就有人纳闷了,内联函数既然跟宏的功能差不多,那为什么不直接定义一个宏,而去定义一个内联函数呢?

存在即合理,内联函数既然在 C 语言中广泛应用,自然有它存在的道理。相对于宏,内联函数有以下几个优势。

  • 参数类型检查。内联函数虽然具有宏的展开特性,但其本质仍是函数,编译过程中,编译器仍可以对其进行参数检查,而宏就不具备这个功能。
  • 便于调试。函数支持的调试功能有断点、单步……,内联函数也同样可以。
  • 返回值。内联函数有返回值,返回一个结果给调用者。这个优势是相对于 ANSI C 说的。不过现在宏也可以有返回值和类型了,比如前面我们使用语句表达式定义的宏。
  • 接口封装。有些内联函数可以用来封装一个接口,而宏不具备这个特性。

10.4 编译器对内联函数的处理

前面也讲过,我们虽然可以通过 inline 关键字,将一个函数声明为内联函数,但编译器不一定会对这个内联函数展开处理。编译器也要进行评估,权衡展开和不展开的利弊。

内联函数并不是完美无瑕,也有一些缺点。比如说,会增大程序的体积。如果在一个文件中多次调用内联函数,多次展开,那整个程序的体积就会变大,在一定程度上,会造成 CPU 的取址效率降低,程序执行效率降低。函数的作用之一就是提高代码的复用性,我们将常用的一些代码或代码块封装成函数,进行模块化编程,而内联函数往往是降低了函数的复用性。所以编译器在对内联函数作展开处理时,除了检测用户定义的内联函数内部是否有指针、循环、递归外,还会在函数执行效率和函数调用开销之间进行权衡。一般来讲,判断对一个内联函数到底展不展开,从程序员的角度,主要考虑以下几个因素。

  • 函数体积小
  • 函数体内无指针赋值、递归、循环等语句
  • 调用频繁

当我们认为一个函数体积小,而且被大量频繁调用,应该做内联展开时,就可以使用 static inline 关键字修饰它。但编译器会不会作内联展开,编译器也会有自己的权衡。如果你想告诉编译器一定要展开,或者不作展开,就可以使用 noinline 或 always_inline 对函数作一个属性声明。

//inline.c
static inline
__attribute__((always_inline))  int func(int a)
{
    return a+1;
}

static inline void print_num(int a)
{
    printf("%d\n",a);
}
int main(void)
{
    int i;
    i=func(3);
    print_num(10);
    return 0;
}

在这个程序中,我们分别定义两个内联函数 func() 和 print_num(),然后使用 always_inline 对 func() 函数进行属性声明。接下来,我们对生成的可执行文件 a.out 作反汇编处理,其汇编代码如下。

$ arm-linux-gnueabi-gcc -o a.out inline.c
$ arm-linux-gnueabi-objdump -D a.out
00010438 <print_num>:
   10438:    e92d4800    push    {fp, lr}
   1043c:    e28db004    add fp, sp, #4
   10440:    e24dd008    sub sp, sp, #8
   10444:    e50b0008    str r0, [fp, #-8]
   10448:    e51b1008    ldr r1, [fp, #-8]
   1044c:    e59f000c    ldr r0, [pc, #12]
   10450:    ebffffa2    bl  102e0 <[email protected]>
   10454:    e1a00000    nop ; (mov r0, r0)
   10458:    e24bd004    sub sp, fp, #4
   1045c:    e8bd8800    pop {fp, pc}
   10460:    0001050c    andeq   r0, r1, ip, lsl #10

00010464 <main>:
   10464:    e92d4800    push    {fp, lr}
   10468:    e28db004    add fp, sp, #4
   1046c:    e24dd008    sub sp, sp, #8
   10470:    e3a03003    mov r3, #3
   10474:    e50b3008    str r3, [fp, #-8]
   10478:    e51b3008    ldr r3, [fp, #-8]
   1047c:    e2833001    add r3, r3, #1
   10480:    e50b300c    str r3, [fp, #-12]
   10484:    e3a0000a    mov r0, #10
   10488:    ebffffea    bl  10438 <print_num>
   1048c:    e3a03000    mov r3, #0
   10490:    e1a00003    mov r0, r3
   10494:    e24bd004    sub sp, fp, #4
   10498:    e8bd8800    pop {fp, pc}

通过反汇编代码可以看到,因为我们对 func() 函数作了 always_inline 属性声明,所以编译器在编译过程中,对于 main()函数调用 func(),会直接在调用处展开。

10470:    e3a03003    mov r3, #3
   10474:    e50b3008    str r3, [fp, #-8]
   10478:    e51b3008    ldr r3, [fp, #-8]
   1047c:    e2833001    add r3, r3, #1
   10480:    e50b300c    str r3, [fp, #-12]

而对于 print_num() 函数,虽然我们对其作了内联声明,但编译器并没有对其作内联展开,而是当作一个普通函数对待。还有一个注意的细节是,当编译器对内联函数作展开处理时,会直接在调用处展开内联函数的代码,不再给 func() 函数本身生成单独的汇编代码。这是因为其它调用该函数的位置都作了内联展开,没必要再去生成。在这个例子中,我们发现就没有给 func() 函数本身生成单独的汇编代码,编译器只给 print_num() 函数生成了独立的汇编代码。

10.5 思考:内联函数为什么常使用 static 修饰?

在 Linux 内核中,你会看到大量的内联函数定义在头文件中,而且常常使用 static 修饰。

为什么 inline 函数经常使用 static 修饰呢?这个问题在网上也讨论了很久,听起来各有道理,从 C 语言到 C++,甚至有人还拿出了 Linux 内核作者 Linus 作者关于对 static inline 的解释:

"static inline" means "we have to have this function, if you use it, but don‘t inline it, then make a static version of it in this compilation unit". "extern inline" means "I actually have an extern for this function, but if you want to inline it, here‘s the inline-version".

我的理解是这样的:内联函数为什么要定义在头文件中呢?因为它是一个内联函数,可以像宏一样使用,任何想使用这个内联函数的源文件,不必亲自再去定义一遍,直接包含这个头文件,即可像宏一样使用。那为什么还要用 static 修饰呢?因为我们使用 inline 定义的内联函数,编译器不一定会内联展开,那么当多个文件都包含这个内联函数的定义时,编译时就有可能报重定义错误。而使用 static 修饰,可以将这个函数的作用域局限在各自本地文件内,避免了重定义错误。理解了这两点,就能够看懂 Linux 内核头文件中定义的大部分内联函数了。至于其它的一些内联函数定义,基本上没怎么遇到过,就不再赘述了。

本教程根据 C语言嵌入式Linux高级编程视频教程 第05期 改编,电子版书籍可加入QQ群:475504428 下载,更多嵌入式视频教程,可关注:
微信公众号:宅学部落(armlinuxfun)
51CTO学院-王利涛老师:http://edu.51cto.com/sd/d344f

原文地址:http://blog.51cto.com/zhaixue/2348620

时间: 2024-10-11 01:22:37

嵌入式C语言自我修养 10:内联函数探究的相关文章

嵌入式C语言自我修养 12:有一种宏,叫可变参数宏

12.1 什么是可变参数宏 在上面的教程中,我们学会了变参函数的定义和使用,基本套路就是使用 va_list.va_start.va_end 等宏,去解析那些可变参数列表我们找到这些参数的存储地址后,就可以对这些参数进行处理了:要么自己动手,自己处理:要么继续调用其它函来处理. void print_num(int count, ...) { va_list args; va_start(args,count); for(int i = 0; i < count; i++) { printf(&qu

嵌入式C语言自我修养 03:宏构造利器 - 语句表达式

3.1 基础复习:表达式.语句和代码块 表达式 表达式和语句是 C 语言中的基础概念.什么是表达式呢?表达式就是由一系列操作符和操作数构成的式子.操作符可以是 C 语言标准规定的各种算术运算符.逻辑运算符.赋值运算符.比较运算符等.操作数可以是一个常量,也可以是一个变量.表达式也可以没有操作符,单独的一个常量甚至是一个字符串,也是一个表达式.下面的字符序列都是表达式: 2 + 3 2 i = 2 + 3 i = i++ + 3 "wit" 表达式一般用来数据计算或实现某种功能的算法.表

嵌入式C语言自我修养 11:有一种函数,叫内建函数

11.1 什么是内建函数 内建函数,顾名思义,就是编译器内部实现的函数.这些函数跟关键字一样,可以直接使用,无须像标准库函数那样,要 #include 对应的头文件才能使用. 内建函数的函数命名,通常以 __builtin 开头.这些函数主要在编译器内部使用,主要是为编译器服务的.内建函数的主要用途如下. 用来处理变长参数列表: 用来处理程序运行异常: 程序的编译优化.性能优化: 查看函数运行中的底层信息.堆栈信息等: C 标准库函数的内建版本. 因为内建函数是编译器内部定义,主要由编译器相关的

嵌入式C语言自我修养 09:链接过程中的强符号和弱符号

9.1 属性声明:weak GNU C 通过 attribute 声明weak属性,可以将一个强符号转换为弱符号. 使用方法如下. void __attribute__((weak)) func(void); int num __attribte__((weak); 编译器在编译源程序时,无论你是变量名.函数名,在它眼里,都是一个符号而已,用来表征一个地址.编译器会将这些符号集中,存放到一个叫符号表的 section 中. 在一个软件工程项目中,可能有多个源文件,由不同工程师开发.有时候可能会遇

嵌入式C语言自我修养 13:总结

13.1 总结 前面12节的课程,主要针对 Linux 内核中 GNU C 扩展的一些常用 C 语言语法进行了分析.GNU C 的这些扩展语法,主要用来完善 C 语言标准和编译优化.而通过 C 标准的发展过程我们又发现,对于一些编译器扩展的一些特性,或者其它编程语言(如:C++)中的好的特性和语法,C 标准也会适时地吸收进来,作为新的 C 语言标准. 在 GNU C 的这些扩展语法中,attribute 和宏定义是两大特色.在嵌入式底层系统中,尤其是 Linux 内核和 U-boot 中,大量使

嵌入式C语言自我修养 05:零长度数组

5.1 什么是零长度数组 顾名思义,零长度数组就是长度为0的数组. ANSI C 标准规定:定义一个数组时,数组的长度必须是一个常数,即数组的长度在编译的时候是确定的.在ANSI C 中定义一个数组的方法如下: int a[10]; C99 新标准规定:可以定义一个变长数组. int len; int a[len]; 也就是说,数组的长度在编译时是未确定的,在程序运行的时候才确定,甚至可以由用户指定大小.比如,我们可以定义一个数组,然后在程序运行时才指定这个数组的大小,还可以通过输入数据来初始化

嵌入式C语言自我修养 06:U-boot镜像自拷贝分析:section属性

6.1 GNU C 的扩展关键字:attribute GNU C 增加一个 atttribute 关键字用来声明一个函数.变量或类型的特殊属性.声明这个特殊属性有什么用呢?主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查.比如,我们可以通过使用属性声明指定某个变量的数据边界对齐方式. attribute 的使用非常简单,当我们定义一个函数.变量或类型时,直接在它们名字旁边添加下面的属性声明即可: __atttribute__((ATTRIBUTE)) 这里需要注意的是:attrib

嵌入式C语言自我修养 07:地址对齐那些事儿

7.1 属性声明:aligned GNU C 通过 attribute 来声明 aligned 和 packed 属性,指定一个变量或类型的对齐方式.这两个属性用来告诉编译器:在给变量分配存储空间时,要按指定的地址对齐方式给变量分配地址.如果你想定义一个变量,在内存中以8字节地址对齐,就可以这样定义. int a __attribute__((aligned(8)); 通过 aligned 属性,我们可以直接显式指定变量 a 在内存中的地址对齐方式.aligned 有一个参数,表示要按几字节对齐

嵌入式C语言自我修养 04:Linux 内核第一宏:container_of

4.1 typeof 关键字 ANSI C 定义了 sizeof 关键字,用来获取一个变量或数据类型在内存中所占的存储字节数.GNU C 扩展了一个关键字 typeof,用来获取一个变量或表达式的类型.这里使用关键字可能不太合适,因为毕竟 typeof 还没有被写入 C 标准,是 GCC 扩展的一个关键字.为了方便,我们就姑且称之为关键字吧. 通过使用 typeof,我们可以获取一个变量或表达式的类型.所以 typeof 的参数有两种形式:表达式或类型. int i ; typeof(i) j