3 c 语言中的函数出了一个c 语言函数和其翻译为汇编代码的形式。...
TRANSCRIPT
第 3章 C语言中的函数
函数是一种特殊的控制流程,只有理解函数调用的本质才能够更好地理解程序中的存
储类别和模块化程序设计的思想。本章将详细介绍函数。
3.1 函数的本质
函数的代码存储在内存中的代码段中,每个 C 语言程序都有一个代码段。下面实例给
出了一个 C 语言函数和其翻译为汇编代码的形式。
说明:函数的本质是一段二进制可执行代码,这些代码是一些可以被机器直接执行的
指令。
程序清单 3-1 swap.c 实现两个整数交换位置并且相加的函数
01 int swap(int *a, int *b)
02 { 03 int tmp; 04 tmp = *a; /* 交换两个指针所指向的内容 */ 05 *a = *b; 06 *b = tmp; /* 交换两个变量的值 */ 07 return *a + *b; /* 返回二者的和 */
08 } 在翻译为汇编语言时,函数被翻译成为一段相对独立的汇编代码,并且使用函数名作
为标号,表示此段代码的入口。当程序中需要调用该函数时,只需要跳转到这个标号处执
行标号后面的函数代码就可以了。在内存中该函数
机器指令的存储如图 3-1 所示。
由此可知,函数的本质是一段机器指令代码。
而函数名的本质是一个标号,该标号的值等于内存
中存储函数代码的内存空间的首地址。
函数在调用时会使进程空间中的栈不断增
长,从当前进程空间中的栈顶的位置到函数保存返
回地址的位置,这块内存称为函数的栈帧。所有函
数中定义的局部变量都存储在函数的栈帧上。当函
数结束调用时该块栈帧就消失了,如图 3-2 所示。 图 3-1 函数调用的示意图
第 3 章 C 语言中的函数
·47·
图 3-2 函数的栈帧
3.2 变量的作用域和生命期
C 语言中每一个变量都有自己的作用域和生命期。作用域表示可以引用该变量的代码
区域,生命期表示变量的存储空间所保存的时间。如果以变量的生命期来分类的话,C 语
言中的变量包括两种类型:一种是全局变量,一种是局部变量。本节将详细介绍变量的作
用域和生命期。
3.2.1 全局变量
全局变量也称为外部变量,这种变量是在函数外部定义的。因此它们不属于任何一个
函数,而是属于一个源程序文件。其作用域从定义该变量的这一行开始,一直可以持续到
定义该变量的源程序文件的结束。在该区间内所有的函数都可以引用该变量。
下面实例演示了一个全局变量的使用。该程序定义了一个全局变量,之后在两个函数
中引用这个全局变量,并分别计算这个全局变量和函数参数的和, 后输出结果。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-2 global.c 全局变量的使用
01 #inlcude <stdio.h> 02 int add(int a, int b) 03 { 04 return a + b; /* 全局变量 b的作用域达不到这里,因此 add()函数看不到全局 变量 b */
05 } 06 int b = 10; /* 定义一个全局变量 b,以后的函数可以应用这个全局变量 */
07 int mul(int a) 08 { 09 return a * b; /* 在 mul()函数中引用全局变量 b */
10 } 11 int main(void) 12 { 13 int res1; /* 保存结果的临时变量 */
第 1 篇 Linux 下 C 语言基础
·48·
14 int res2; 15 res1 = add(1, 5); /* 调用 add()函数 */ 16 res2 = mul(1); /* 调用 mul()函数 */
17 printf("res1 : %d, res2 : %d\n", res1, res2); 18 printf("the global b : %d\n", b); /* 在 main()函数中引用全局变量 b */
19 return 0; 20 } (2)在 shell 中编译该程序。 $gcc global.c -o global (3)在 shell 中运行该程序。 $./global res1 :6, res2 : 10 the global b : 10 在全局变量 b 定义之前先定义了 add()函数,因此 add()函数不能引用这个全局变量。
如果希望在 add()函数中也可以引用该变量,则需要在 add()函数之前加上对全局变量 b 的
声明,之后 add()函数就可以引用该变量了。 ... int b; /* 添加对全局变量 b的声明,编译器会自动寻找该变量的定义 */
int add(int a) { return a + b; /* 声明之后可以在函数 add()中引用该变量 */
} int b = 10; /* 定义该变量 */
...
3.2.2 局部变量
局部变量也称为内部变量,其定义在函数或者复合语句的内部。其作用域仅限于函数
或者复合语句内,离开该函数或者复合语句后就无法再引用该函数。
注意:定义在复合语句内部的变量同样是局部变量。
下面实例演示了局部变量的使用。该程序在复合语句内部定义了一个局部变量 res,该
局部变量在复合语句之外不能引用。因此在定义该变量的复合语句之外此变量就失效了。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-3 local.c 演示使用局部变量
01 #include <stdio.h> 02 int add(int a, int b) 03 { 04 return a + b; /* add()函数内部不能引用 main()函数中定义的局部变量 05 */
06 } 07 int main(void) 08 {
第 3 章 C 语言中的函数
·49·
09 int array[5]; /* 局部变量数组 */
10 int i=0; 11 while(i < 5){ 12 int res; /* res定义在复合语句内部,因此 res也是一个局部变
量,在复合语句之外不能引用 */ 13 res = add(i, 1); /* 对数组每个元素赋值 */ 14 array[i] = res; /* 将结果保存在数组中 */
15 i++; 16 } 17 for(i = 0; i < 5; i++) 14 printf("array[%d] : %d\n", i + 1, array[i]); /* 输出每一个数组元
素 */
15 return 0; 16 } (2)在 shell 中编译该程序。 $gcc local.c -o local (3)在 shell 中执行该程序。 $./local
array[1] : 1
array[2] : 2
array[3] : 3
array[4] : 4
array[5] : 5 res 是一个局部变量,在复合语句结束之后,变量 res 同样不能再被该函数中的其他语
句所引用。也就是说 res 变量的作用域仅限于 while 语句的循环中。
说明:全局变量的生命期是整个程序,而局部变量的生命期仅在函数调用未结束之前有效。
因此,变量的作用域实际上是由变量的生命期来决定的,如图 3-3 所示。
图 3-3 变量生命期示意图
第 1 篇 Linux 下 C 语言基础
·50·
3.3 变量的初始值
变量在定义之后要占用存储空间,如果未对变量进行初始化,这时变量中的值是多少
呢?本节将介绍变量的初始值。
3.3.1 全局变量的初始值
对于全局变量来说,如果一个全局变量未被初始化,其初始值由编译器自动设置为 0。
因此在使用一个全局变量时,不需要考虑其初值问题,直接使用就可以了。
下面实例演示了输出一个未初始化的全局变量的初始值。该程序定义了一个全局变
量,但是未对其进行初始化。之后在程序中输出该变量的值,以观察该变量的初始值。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-4 uninit_global.c 输出一个未初始化的全局变量的值
01 #incllude <stdio.h>
02 int a; /* 全局变量 */
03 int main(void)
04 {
05 printf("the global : %d\n", a); /* 输出全局变量的值 */
06 return 0;
07 }
(2)在 shell 中编译该程序。 $gcc uninit_global.c -o uninit_global (3)在 shell 中运行该程序。 $./ uninit_global
the global : 0
3.3.2 局部变量的初始值
局部变量存储在内存的堆栈中。定义了局部变量之后,编译器不会将其初始化为 0,
而是使用其占用的内存空间原有的值。这时的值是一个随机值,与本次程序无关,如图 3-4
所示。
因此,如果对局部变量不初始化就直接引用是很危险的。下面实例演示了这种危险的
用法。该程序计算 1~10 之间整数的和。在 main()函数中定义了一个局部变量 sum,并使
用它作为存储累加和的临时变量。
(1)在 vi 编辑器中编辑该程序。
第 3 章 C 语言中的函数
·51·
图 3-4 函数栈帧占用内存空间示意图
程序清单 3-5 sum.c 累加 1~10 之间整数的和
01 #include <stdio.h> 02 int main(void) 03 { 04 int i; 05 int sum; /* 定义一个局部变量,该局部变量作为一个累加和的临时值 */
06 for(i = 1; i <= 10; i++) 07 sum += i; /* 每次累加 i */ 08 printf("the sum is : %d\n", sum); /* 输出运算结果 */
09 return 0; 10 } (2)在 shell 中编译该程序。 $gcc sum.c -o sum (3)在 shell 中运行该程序。 $./sum the sum is : -10926734 程序执行之后的结果和预料的大相径庭。原因在于在使用 sum 作为累加和之前没有对
sum 进行初始化,所以 sum 的起始值并不是 0,而是 sum 所在的存储单元中之前保存的一
个无用值。这个值是随机的,可能是一个非常大的整数。这样的累加造成了 sum 变量溢出。
由此可知,对一个局部变量进行初始化是多么重要。
注意:如果不进行初始化,这种错误不仅无法避免,而且很难调试。
3.4 与函数有关的优化
函数作为程序的一个重要组成部分,其优化作用是不容小视的。本节将介绍有关函数
第 1 篇 Linux 下 C 语言基础
·52·
的优化。
3.4.1 函数调用与程序优化
函数的作用是使代码模块性更强,利于代码的修改和阅读,并且可以有效地减小代码
的体积。但是函数的调用通常很费时,一个函数调用的主要步骤有以下 4 步:
函数调用需要将参数压入堆栈。
函数调用需要保存寄存器的值。
函数调用需要保存返回地址。
函数调用会造成跳转。
以上 4 步操作中的前 3 个步骤都需要访问内存,而 后 1 步造成一次跳转。访问内存
在计算机的操作中很消耗时间,如果每次调用函数都需要大量的时间访问内存,那么其执
行速度慢是理所当然的。因此从这个角度来讲,函数可以说是一个时间换空间的例子。
说明:由于函数调用耗费时间,因此在程序执行过程中应当减少函数的调用,这样才能
提高程序的执行速度。
下面实例演示了一个执行速度慢的程序。该程序调用 3 次 func()函数,但是该函数每
次返回的值都是一样的。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-6 slow.c 一个多次调用函数,执行速度慢的程序
01 #include <stdio.h> 02 int func(int a) 03 { 04 return a + 1; /* 返回参数加 1 */
05 } 06 int main(void) 07 { 08 int res; 09 res = func(1) + func(1) + func(1); /*多次调用函数,计算 3次函数调用的 结果*/ 10 printf("the result : %d\n", res); /*输出变量*/
11 return 0; } (2)在 shell 中编译该程序。 $gcc slow.c -o slow (3)在 shell 中运行该程序。 $./slow the result : 6 该程序调用 3 次 func()函数,每次该函数的返回值都是 2,函数 func()的调用栈帧如
第 3 章 C 语言中的函数
·53·
图 3-5 所示。
图 3-5 3 次调用 func()函数的栈帧
说明:由此可知,该程序调用 3 次 func()函数完全没有必要,由于 func()函数每次计算
后的返回值都相同,因此只需要调用 1 次即可。
下面是一个改进版的函数调用程序。该程序只调用 1 次 func()函数之后,将 func()函数
的返回值保存起来,用于后面的计算。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-7 fast.c 执行速度快的函数调用版本
01 #include <stdio.h> 02 int func(int a) 03 { 04 return a + 1; /* 返回参数加 1 */
05 } 06 int main(void) 07 { 08 int res; 09 res = 3 * func(1); /* 只调用一次 func()函数,并使用函数的返回值进行乘法
运算 */ 10 printf("the result : %d\n", res); /* 输出结果 */
11 return 0; 12 } (2)在 shell 中编译该程序。 $gcc fast.c -o fast (3)在 shell 中运行该程序。 $./ fast the result : 6 可以看出,改进版的函数调用程序执行速度更快,设计思想也更合理。将多个运行结
果相同的函数合并是一种合理的优化方法,但是这种优化方法的基础在于运行结果相同,
而并不是简单的返回值相同。
注意:有时即使每次调用函数的返回值相同,但这种优化方法也是不能成立的。
第 1 篇 Linux 下 C 语言基础
·54·
下面实例演示了这种优化方面的错误。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-8 optim.c 合并多次返回值相同的函数调用出错
01 #include <stdio.h> 02 int count = 0; /* 全局变量 */
03 int func(int a)
04 { 05 count++; /* func函数内部操作 */
06 return a + 1; /* 返回参数加 1 */
07 }
08 int main(void)
09 {
10 int res; 11 res = 3 * func(1); /* 只调用一次 func()函数,并使用函数的返回值进行乘法
运算 */ 12 printf("the count : %d\n", count); /* 输出全局变量的值 */ 13 printf("the result : %d\n", res); /* 输出函数返回值的和 */
14 return 0; 15 } (2)在 shell 中编译该程序。 $gcc optim.c -o optim (3)在 shell 中运行该程序。 $./optim
the count : 1
the result : 6 由于 func()函数只调用了 1 次,所以全局变量 count 只累加了 1 次,其值为 1。如果不
做优化,将“3 * func(1);”展开为“func(1) + func(1) + func(1)”,这时 count 的值应当是 3,
而不是 1。因此合并多个函数的前提条件是每个函数的运行结果一样,而不是简单的返回
值一样。
3.4.2 变量存储优化
C 语言程序中的局部变量存储在栈上,而全局变量存储在数据段上。由于二者存储位
置的不同导致了二者生命期的不同。
注意:全局变量的生命期是整个程序,而局部变量的生命期仅在定义局部变量的函数结
束调用后就结束了。
对于在程序中调用频率很高的局部变量,编译器会自动将其存储在寄存器中,但是全
局变量不会存储在寄存器中。因为全局变量需要在整个程序运行的过程中一直存在,如果
全局变量存储在寄存器中的话,那么在整个程序的执行过程中,该寄存器都将无法存储临
第 3 章 C 语言中的函数
·55·
时变量或者中间值了。这对于寄存器资源稀少的计算机来说是不能接受的,程序的运行效
率也会大打折扣。
因此全局变量一定存储在数据段上,也就是存储在内存中。这一点很重要,因为计算
过程中访问内存的时间要远超过 CPU 的计算时间。所以尽量减少内存的访问是一个提高程
序执行速度的好方法。下面演示了一个这样的实例,对比程序清单 3-9 和程序清单 3-10,
比较一下这两个代码片段的执行速度。下面程序使用一个全局变量作为循环算子,在函数
f()内部执行一个循环。该循环共循环 100 次,每次循环需要访问全局变量 i。
程序清单 3-9 global.c 使用全局变量作为循环算子
01 int i; /* 全局变量作为循环因子 */
02 void f() 03 { 04 int a[10]; 05 for(i = 0; i < 100; i++ ) /* 将数值赋值到数组中 */
06 a[i] = i; 07 } 下面程序使用一个局部变量作为循环算子,在函数 f()内部执行一个循环。该循环同样
循环 100 次,每次访问局部变量 i。
程序清单 3-10 local.c 使用局部变量作为循环算子
01 void f()
02 {
03 int i; /* 局部变量作为循环因子 */
04 for(i = 0; i < 100; i++ )
05 a[i] = i; /* 将数值赋值到数组中 */
06 } 程序清单 3-9 的代码中循环算子 i 是一个全局变量,全局变量存储在内存上。因此每
次访问该全局变量时需要访问内存数据,如图 3-6 所示。
CPU 内存
CPU
CPU
CPU(将 i 的值递增)
内存
内存
内存 将 i 的值回写
取 i 的值
确定 i 的地址
图 3-6 访问一个作为循环因子的全局变量
在程序清单 3-10 的代码中循环算子 i 是一个局部变量,对于这种高频率使用的局部变
第 1 篇 Linux 下 C 语言基础
·56·
量编译器会自动将其存储在寄存器中。因此每次访问这个局部变量时实际上是在做一个寄
存器访问。
对比两个代码的执行流程,不难发现程序清单 3-9 中每次循环需要做以下 3 步操作。
(1)从内存单元中读取变量 i 的值。
(2)对变量 i 进行累加。
(3)将累加后的 i 的值存储到内存中。
而在程序清单 3-10 中每次循环只需要 1 步,那就是累加变量 i 所在的寄存器的值。由
此可知,程序清单 3-9 的执行效率远不及程序清单 3-10 的执行效率。因此在编程过程中,
不要将循环算子等高频率使用的变量设置为全局变量或者静态变量。
3.5 编写多文件程序——变量的存储类别
当一个程序很大时不能将所有的代码都书写在一个源文件中,这样做会严重破坏程序
的模块化,使程序变得难以维护。因此这个时候需要将源程序代码书写在多个源文件中。
3.5.1 存储类别
auto:自动变量,默认的存储类别,根据变量定义的位置决定变量的生命期和作用
域。如果变量定义在任何一个函数的外面,该变量就是一个全局变量。如果定义
在函数的内部,则该变量是局部变量。在 C 语言中,如果忽略变量的存储类别,
编译器会认为该变量的存储类别为 auto。这时编译器会自动为用户决定该变量应
当存储的位置和性质。
register:寄存器变量,此类别的变量会被优先分配寄存器。通常作为循环因子的
变量会被分配寄存器。
extern:外部变量(全局变量),该关键字用来扩展全局变量的作用域,扩展的范
围是从使用 extern 变量出现开始到该文件结束。由于全局变量不像局部变量那样
会因为栈帧的消失而消失。所以 extern 关键字所做的工作只是让其他文件中的程
序可以引用该变量,并不改变该变量的生命期。
static:静态变量,用于限制作用域,这种变量存储在数据段上,无论该变量是全
局变量还是局部变量。静态全局变量的作用域仅限于该文件,而静态局部变量的
作用域仅在其定义所在的复合语句内。对于静态局部变量而言,static 关键字可以
改变其生命期,而对静态全局变量则不能。
3.5.2 static 变量的作用——改变变量的生命期
说明:static 关键字的作用之一就是可以改变变量的作用域和生命期。
对于一个存储类别声明为 static 的全局变量而言,其生命期并没有发生改变,在整个
程序执行过程中该变量一直存在。但其作用域反而有所减小,只有本文件的函数可以引用
第 3 章 C 语言中的函数
·57·
该全局变量。
对于一个存储类别声明为 static 的局部变量而言,其作用域没有改变,只有定义该局
部变量的函数可以引用该变量。但是其生命期发生了变化,在整个程序执行期间,该变量
都存在。
下面实例演示了使用 static 局部变量。该程序打开若干个文件并且将文件的内容输出
到屏幕上,每输出 5 行就会输出 1 个空行。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-11 static_local.c 打开文件输出文件内容
01 #include <stdio.h>
02 #include <stdlib.h>
03 #define MAX 1024 04 int output(char *file_name)
05 { 06 FILE *fp;
07 char buf[MAX]; 08 static int count = 0; /* 静态局部变量,保存输出的行数 */
09 fp = fopen(file_name, "r"); /* 打开文件 */
10 if(fp == NULL){ 11 perror("fail to open"); /* 输出则提示 */
12 return -1;
13 } 14 while(fgets(fp, buf, MAX) != NULL){ /* 每次读入文件的一行 */
15 n = strlen(buf);
16 buf[n - 1] = '\0'; 17 printf("%s\n", buf); /* 输出读入的一行 */
18 if(count++ % 5 == 0) /* 累加 count,如果 count能够被 5整除,则输出
换行 */
19 printf("\n");
20 } 21 fclose(fp); /* 关闭文件 */
22 return 0;
23 }
24 int main(void)
25 {
26 char file_name[][10] = {"test.txt",
27 "test1.txt",
28 "test2.txt"
29 };
30 int i;
31 i = 0;
32 while(i < 3){ 33 if(output(file_name[i]) == -1) /* 输出每一个文件中的内容 */
34 exit(1);
35 i++;
第 1 篇 Linux 下 C 语言基础
·58·
36 }
37 return 0;
38 } (2)在 shell 中编译该程序。 $gcc static_local.c -o static_local (3)使用 cat 命令查看 test.txt 文件的内容。 $cat test.txt hello world hi beijing (4)使用 cat 命令查看 test1.txt 文件的内容。 $cat test1.txt this is a test no warning have fun (5)使用 cat 命令查看 test2.txt 文件的内容。 $cat test2.txt linux is great what a game happy coding GNU is not unix linux is not unix, too (6)在 shell 中运行该程序。 $./static_local hello world hi beijing this is a test no warning have fun linux is great what a game happy coding GNU is not unix linux is not unix, too
注意:如果将局部变量 count 的存储类别设置为非 static,每个文件输出行数的累加就不
会被保存了。
用户也可以使用全局变量的方法代替使用 static 局部变量,但这样做就不能保证模块
中的其他函数不会因为误操作而修改该变量的值。因此,使用 static 局部变量还是很有必
要的。
3.5.3 static 变量的作用——实现封装和模块化设计
说明:static 变量的另一个显著作用就是可以实现一个模块的封装。
第 3 章 C 语言中的函数
·59·
static 存储类别的特性决定了使用 static 关键字声明的全局变量,只有本文件内的函数
可以引用,因此在 C 语言中一个源程序文件就是一个模块。当用户在一个文件中定义了一
个 static 全局变量后,其他文件(模块)只能通过该模块提供的接口函数来访问这个 static
全局变量,而不能直接对其进行操作。
下面实例演示了一个链表模块的实现。该模块中定义了一个链表头结点指针 head,head
指针被定义为 static 全局变量。该模块同时定义了链表的一般性操作函数。这些操作函数
可以操作该链表,而该模块外的函数不能直接操作 head 指针,只能通过调用模块中定义好
的链表操作函数来操作 head 指针和整个链表。链表操作函数实现如下。
程序清单 3-12 list.c 实现一个链表操作模块
01 #include <stdio.h> 02 #include <stdlib.h> 03 typedef struct node * Node; /* 自定义结点指针类型 */ 04 static Node head; /* 链表头 */ 05 /* 链表结点结构 06 * val :结点的值 07 * next :下一个结点的指针 08 */
09 struct node{ 10 int val; 11 Node next; 12 }; 13 /* 插入结点函数 */
14 int insert(int val) 15 { 16 Node p, q; 17 p = head; 18 if(p != NULL){ /* 链表非空 */
19 while(p->next != NULL){ 20 p = p->next; 21 } 22 } 23 q = (Node)malloc(sizeof(struct node)); /* 创建一个新的结点 */
24 if(q == NULL) 25 return -1; 26 q->next = NULL; /* 对结点赋值 */
27 q->val = val; 28 if(p == NULL){ /* 空链表 */
29 head = q; 30 return 1; 31 } 32 p->next = q; /* 结点链入链表 */
33 return 1; 34 } 35 /* 遍历链表,打印每个结点的值 */
36 void print() 37 { 38 Node p = head;
第 1 篇 Linux 下 C 语言基础
·60·
39 while(p != NULL){ /* 输出每个结点的值 */
40 printf("%d\n", p->val); 41 p = p->next; 42 } 43 } 44 /* 遍历链表,释放每一个结点 */
45 void destroy() 46 { 47 Node p = head; 48 while(p != NULL){ /* 遍历链表 */
49 Node q; 50 q = p; 51 p = p->next; /* 到下一个结点 */ 52 free(q); /* 释放该结点 */
53 } 54 head = NULL; /* 清空链表的头指针 */
55 } 这样做就实现了这个链表操作对外界的封装。程序中其他模块不用了解链表的操作是
如何进行的,只需要使用模块中提供的接口函数就可以了。同样,如果用户企图不按照规
则操作链表也是不可能的,因为如果用户除了使用接口函数之外就不能操作链表。因此整
个链表操作模块就完全独立于其他模块了,如图 3-7 所示。
图 3-7 程序中其他函数操作链表
后一步就是向外界提供一个链表操作模块的接口函数声明,这些函数的声明包含在
list.h 中。
程序清单 3-13 list.h 链表操作函数的接口声明
extern int insert(int val);
extern void print();
extern void destroy();
说明:由于模块实现的细节对外部模块来讲是未知的,因此这时修改链表操作并不影响
使用该模块用户的其他代码。
假设用户使用链表操作模块的代码如下。
程序清单 3-14 main.c 用户使用链表操作接口函数
01 #include <stdio.h>
第 3 章 C 语言中的函数
·61·
02 #include <stdlib.h> 03 #include "list.h" 04 int main(void) 05 { 06 int i; 07 for(i = 0; i < 5; i++) /* 使用 insert()函数插入 5个结点 */
08 if(insert(i) == -1) 09 exit(1); 10 print(); /* 输出链表的所有结点 */ 11 destroy(); /* 销毁链表 */
12 return 0; 13 } 如果将链表操作函数中 insert()修改,只要不修改函数的接口,用户程序的代码就不会
改变。但是如果修改了函数接口,用户的代码就要做很大改动了。
注意:因此在修改模块的时候,修改函数接口本身是一个大忌。
3.6 编写多文件的程序——链接的作用
当一个多个文件的程序编译结束后,需要由链接器将这些独立的模块链接为一个整体
的可执行程序。本节将详细介绍链接的过程。
3.6.1 链接多个文件
为了使程序的模块化更强,代码更易于分类管理,有时需要将同类型的代码存储在一
个文件中。这时每一个文件代表着一类函数代码。这些代码使用同样的资源,完成同样的
操作。由于代码被划分为若干个模块,这时就会导致多个 C 语言文件(模块)链接的问题。
说明:在一个文件中很可能需要引用另一个文件中定义的全局变量或者函数。
下面实例演示了一个简单的数组操作程序。这个程序计算数组中所有元素的和,可以
得到数组中 大的元素,可以遍历输出数组。这些操作均实现为函数。由于这些函数都是
用来操作数组的,因此属于同一类型的操作,这些函数被实现在一个 operate.c 文件中。目
标数组使用一个全局变量来实现,同样定义在 operate.c 文件中。Main()函数定义在 main.c
文件中,该函数需要引用 operate.c 文件中的函数和变量。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-15 operate.c 操作数组的函数定义
01 #include <stdio.h> 02 #define MAX 5 03 int array[MAX] = {2,7,6,4,8, }; 04 /* 计算数组中所有元素的和 */ 05 int sum() 06 {
第 1 篇 Linux 下 C 语言基础
·62·
07 int i; 08 int n; /* 临时变量 */ 09 n = 0; 10 for(i = 0; i < MAX; i++) /* 遍历数组,累加每个数组元素,计算数组中所有
元素的和 */ 11 n += array[i]; 12 return n; 13 } 14 /* 得到数组中 大的元素 */ 15 int get_max() 16 { 17 int max; 18 int i; 19 i = 0; 20 max = array[i]; 21 for(i = 0; i < MAX; i ++) /* 遍历数组,比较每个元素,得到数组中 大的元
素 */ 22 if(array[i] > max) 23 max = array[i]; /* 找到 大的值 */ 24 return max; 25 } 26 /* 输出数组中每个元素的值 */ 27 void print() 28 { 29 int i; /* 临时变量,作为循环因子 */ 30 for(i = 0; i < MAX; i++) /* 遍历数组,输出数组中每个元素的值 */ 31 printf("array[%d] : %d\n", i + 1, array[i]); 32 } 该程序的 main()函数定义在 main.c 文件中。
程序清单 3-16 main.c 定义 main 函数
01 #include <stdio.h> 02 extern int array[ ]; /* 导出全局变量 array的声明 */ 03 extern int sum(); /* 导出函数的声明 */ 04 extern int get_max(); 05 extern void print(); 06 int main(void) 07 { 08 int all, max; /* 大值 */ 09 all = sum(); 10 max = get_max(); /* 得到 大值 */ 11 print(); /* 打印数组元素 */ 12 printf("the sum : %d, the max : %d\n", all, max); /* 输出结果 */ 13 return 0; 14 } (2)在 shell 中编译该程序如下。 $gcc main.c operate.c -o main (3)在 shell 中运行该程序如下。
$./main array[1] : 2 array[2] : 7
第 3 章 C 语言中的函数
·63·
array[3] : 6 array[4] : 4 array[5] : 8 the sum : 27, the max : 8
3.6.2 链接时符号解析规则
在使用多文件编译时要特别注意变量的引用和符号解析问题。
注意:开发人员一定要了解多个文件中各种符号的解析规则。
在介绍 C 语言的符号解析规则之前,首先解释两个主要的概念——声明和定义。声明
表示告知编译器该变量的存在,此时是不为该变量分配存储空间的,如下所示。 int a; 而定义变量时不仅告知编译器该变量的存在,而且为该变量赋值。由于需要赋值,这
时该变量的存储空间就被分配了,如下所示。 int a = 1; 当该变量的作用域范围内只有声明,而没有该变量的定义时,编译器会自动将第一个
声明认为是变量的定义,如下所示。 01 int f(int a) 02 { 03 /* 在这里声明变量 b,由于在该函数内找不到对变量 b的定义。 04 * 因此该声明被认为是变量的定义,这时分配了 4个字节存储空间给 b 05 */
06 int b; 07 b = 2; /* 这里是对变量 b的赋值,不是定义。 */
08 return a + b; 09 } C 语言中的符号解析规则如下。
不允许有多个符号的定义,这里的符号指的是变量或者函数。
如果有一个符号定义和多个符号的声明,则选择被定义的符号。
如果有多个符号的声明,则从其中任选一个作为符号的定义。
3.6.3 链接规则的应用
根据 3.6.2 节介绍的链接规则,判断当使用下面各例中的文件进行链接的时候,在进
行符号解析时可能发生的问题。
实例 1:a.c 包含一个 main()函数的定义。 01 int main(void) 02 { 03 printf("hello world"); /* 输出一行信息 */
04 return0;
第 1 篇 Linux 下 C 语言基础
·64·
05 } b.c 中也包含一个 main()函数的定义。 01 int main(void)
02 { 03 printf("bye-bye\n"); /* 输出一行信息 */
04 return 0;
05 }
注意:该例会造成链接错误,原因是两个文件中都对一个函数进行了定义。这种现象违
反了规则 1。
实例 2:a.c 中包含一个全局变量 a 的定义。 01 int a = 123; /* 一个全局变量 */
02 int main(void)
03 {
04 return0;
05 } b.c 中也包含一个全局变量 a 的定义。 01 int a = 121; /* 同名的全局变量 */
02 void f(void)
03 {
04 printf("function f\n");
05 }
注意:该例会造成链接错误,原因是两个文件中都对一个变量进行了定义。这种现象同
样违反了规则 1。
实例 3:a.c 中包括一个全局变量 a 的定义。 01 #include <stdio.h> 02 void f(void); /* 函数的声明 */
03 int a = 123; /* 全局变量的定义 */
04 int main(void)
05 { 06 f( ); /* 调用函数 */
07 printf("a = %d\n",a); /* 输出全局变量 */
08 return 0;
09 } b.c 中包含一个全局变量 a 的声明。 01 int a; /* 全局变量的声明 */
02 void f( )
03 { 04 a = 121; /* 对全局变量赋值 */
05 }
第 3 章 C 语言中的函数
·65·
(1)在 shell 下编译该程序,该例不会造成链接错误。 $gcc a.c b.c -o app (2)在 shell 下运行该程序。 $./app a = 121 由于两个源文件中出现了对变量 a 的定义和声明,因此其符号解析符合解析
规则 2——当出现一个变量定义和多个变量声明的时候应当选择变量的定义。所以全局变
量 a 在函数 f()中被修改为 121,这个结果被保存到全局变量 a 的存储空间中。
实例 4:a.c 中包括一个全局变量 a 的声明。 01 #include <stdio.h> 02 void f(void); /* 函数的声明 */ 03 int a; /* 全局变量的声明 */ 04 int main(void) 05 { 06 a = 123; 07 f( ); /* 调用函数 */ 08 printf("a = %d\n",a); 09 return 0; 10 } b.c 中也包含一个全局变量 a 的声明。 01 int a; /* 全局变量的声明 */ 02 void f( ) 03 { 04 a = 121; /* 对全局变量赋值 */ 05 } (1)在 shell 下编译该程序,该例不会造成链接错误。 $gcc a.c b.c -o app (2)在 shell 下运行该程序。 $./app a = 121 由于两个文件中都含有对全局变量 a 的声明,因此根据符号解析规则 3,编译器会选
择 先扫描到的那个声明作为变量的定义。无论编译器选定哪一个符号,其都不会对程序
的本质造成影响。该程序的情况和实例 3 类似。
实例 5:a.c 中包含全局变量 a 的定义。 01 #include<stdio.h> 02 void f(void); 03 int a = 123; /* 全局变量的定义 */ 04 int b = 121; 05 int main() 06 { 07 f( ); /* 调用函数 f */ 08 printf("a = %d, b = %d\n", a, b); /* 输出变量 a和 b */ 09 return 0; 10 }
第 1 篇 Linux 下 C 语言基础
·66·
b.c 包含对全局变量 a 的声明。 01 double a; /* 变量声明 */ 02 void f() 03 { 04 a = 0.0; /* 对变量 a赋值 */ 05 } (1)在 shell 下编译该程序,该例不会造成链接错误。 $gcc a.c b.c -o app (2)在 shell 下运行该程序。 $./app
a = 0,b = 0 两个源文件包含全局变量 a 的定义和全局变量 a 的声明。根据符号解析规则 2 不会造
成链接错误,编译器会选择符号定义。这时 b.c 中函数 f()内对变量 a 的操作实际上操作的
是 a.c 中的全局变量 a。所不同的是由于在 b.c 中声明的变量为双精度型,所以对该变量进
行赋值为 0.0 时,需要将由 a 所指向的内存空间的首地址开始的 8 个字节清 0,而不是 4
个。这个时候,变量 a 后面的变量 b 的存储空间也被覆盖了,如图 3-8 所示。
b 121 4
a 123 4
b 0 4
a 0 4
double a double a
图 3-8 变量 a 和变量 b 的存储示意图
因此输出 a 和 b 的值都是 0。由此可以了解 C 语言中符号的解析规则是很重要的。许
多不了解内情的开发者通常会弄不清楚这个问题,因而不知错误的真正原因。
3.7 可 变 参 数
可变参数是 C 语言中一个比较高级的应用,使用可变参数后,用户可以在调用函数时
再确定该函数所需要的参数。
3.7.1 可变参数的概念
C 语言中支持参数可变的函数,在编程过程中经常使用的输出函数 printf()就是一个典
型的参数可变的函数,其函数原型如下。
第 3 章 C 语言中的函数
·67·
#include <stdio.h> int printf(const char* format, ...); printf()函数是一个可变参数的函数,其参数数目在函数调用的时候确定。
注意:该函数至少有一个参数,之后的参数是可有可无的。
printf()函数的原型中第 1 个参数 format 是固定的,后面的参数个数和类型都是可变的。
编译器使用 3 个点“…”作为参数的占位符,告知编译器第 1 个参数 format 的后面还可能
会有若干的参数。
可变参数的函数在实际调用时确定其实际的参数个数,以 printf()函数举例,其调用形
式如下: 01 int num = 0; 02 char *p = "hello world\n"; /* 字符串 p */ 03 printf("%d\n", num); /* 输出数字 */ 04 printf("%s\n", str); /* 输出字符串 */
05 printf("the number is %d, the string is:%s\n", num, str); 第 3 行的 printf()函数调用包括 2 个参数。第 1 个参数是一个字符串,这个字符串是每
一个 printf()函数必须包括的;第 2 个参数是一个整型变量,表示需要输出的整型变量的值。
第 4 行的 printf()函数调用也包括 2 个参数,但第 2 个参数不是一个整型变量,而是一个字
符串的首地址,其类型为指针。
第 5 行的 printf()函数调用包括了 3 个函数,第 1 个参数仍然是一个字符串的首地址,
后面 2 个参数分别是整型和地址型。因此可变参数的函数个数和类型都是在函数调用的时
候才确定的。虽然参数可变,但是这种函数必须包含 1 个参数,且这第 1 个参数的类型在
定义的时候就已经确定。
注意:后面的所有可变的参数都是以此参数为基点的。
3.7.2 实现一个简单的可变参数的函数
了解了可变参数的函数概念之后,下面通过实现一个可变目标的函数给读者一个更直
观的印象,为后面讲解可变目标函数的实现做好准备。下面示例演示了一个可变目标参数
的实例。该程序调用一个可变参数的函数,这个函数输出所有的参数。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-17 print_args.c 列出所有的参数
01 #include <stdio.h> 02 #include <stdarg.h> 03 /* 可变参数函数,其中第一个参数的类型是固定的,在定义的时候必须已经定义好 */
04 int print_args(int begin, ...) 05 { 06 va_list ap; 07 char *p;
08 int n;
第 1 篇 Linux 下 C 语言基础
·68·
09 va_start(ap, begin); /* 从可变参数的第一个参数开始遍历 */ 10 p = va_arg(ap, char *); /* 得到第一个参数 */ 11 n = 0; 12 while(p != NULL){ /* 可变参数以 NULL结尾,在遇到 NULL结束符之前输出所 有的参数 */ 13 n++; /* 累计参数的个数 */ 14 printf("arg %d : %s\n", n, p); /* 输出每个参数 */ 15 p = va_arg(ap, char*); /* 得到下一个参数 */ 16 } 17 va_end(ap); /* 参数处理结束,做一些清理工作 */ 18 return n; /* 返回参数的个数 */
19 } 20 int main(void) 21 { 22 int n; 23 n = print_args(-1, "hello", "world", NULL); /* 第一次调用,使用 4 个参数 */
24 printf("first, without NULL : %d\n", n); 25 n = print_args(-1, "China", "beijing", "Olympic", NULL);/* 第二次 调用,使用 5个参数*/
26 printf("second, without NULL : %d\n", n); 27 return 0; 28 } (2)在 shell 中编译该程序。 $gcc print_args.c -o print_args (3)在 shell 中运行该程序。 $./ print_args
hello
world
first without NULL : 3
China
beijing
Olympic
second without NULL : 4 下面对 print_args()函数实现的关键点做一下解释。
标准头文件 stdarg.h:这个头文件中定义了一系列的宏来处理这个可变长度的参数
列表。如果需要实现一个可变参数的函数,该头文件是必不可少的。
类型 va_list:这个类型定义在 stdarg.h 头文件中。va_list 定义为这样一个数据类型,
循环使用且每次指向一个可变的参数。因此该类型的变量代表整个参数的列表。
在上例中定义变量 ap 为 va_list 类型的变量,所以 ap 代表整个参数列表。
宏 va_start:va_stat 宏初始化一个 va_list 类型的变量(上例中使用变量 ap),使其
指向第 1 个可变的参数。经过初始化后,变量 ap 就可以代表整个参数列表了,因
此该宏必须在使用参数列表之前使用。每个可变参数的函数的第 1 个参数必须固
定,否则无法进行初始化,并且将变量 ap 指向该参数列表。
宏 va_arg:va_arg 宏返回一个可变长度参数的值并使 ap 指向下一个可变的参数,
第 3 章 C 语言中的函数
·69·
该宏使用一个类型名来确定要返回的类型和指针 ap 需要移动的字节单位。
宏 va_end:做一些必要的清理工作,需要在程序结束前使用。
通过分析以上实例的关键点代码,可以总结一个可变参数的函数实现流程如下:
(1)使用 va_start 宏初始化 va_list 类型的变量,使其指向可变参数列表的头。
(2)使用 va_arg 宏得到每一个参数并对其进行处理,当遇到一个结束标志时停止
处理。
(3)使用 va_end 宏做清理工作。
将这 3 个步骤使用流程图表示如图 3-9 所示。
图 3-9 使用可变参数的流程
3.7.3 可变参数实例
本节介绍一个可变函数的实现实例——my_printf()函数。printf()函数使用可变参数的方
法实现,并且对 my_printf()函数的第 1 个参数 format 进行字符串处理即可。
(1)在 vi 编辑器中编辑该程序。
程序清单 3-18 my_printf.c 使用可变参数实现一个简单的 printf()函数
01 #include <stdio.h> 02 #include <stdarg.h> 03 #include <string.h> 04 #define MAX 64 05 /* 将一个 short型变量转换为字符串形式成功返回转换后的字符串首地址,失败返回 NULL
第 1 篇 Linux 下 C 语言基础
·70·
06 * i : 需要转换的短整型, 大值为 65536,不处理负数的形式 07 * p : 转换后的字符串的首地址。p代表存储该串的数组空间的起始位置 08 */ 09 char * itoa(int i, char *p)
10 { 11 char *q;
12 if(p == NULL) 13 return NULL; 14 p[0] = (i / 10000) + '0'; /* 将整型转换为字符串,整型的 大不会超过 65536, 且不处理负数 */ 15 i = i % 10000; 16 p[1] = (i / 1000) + '0'; 17 i = i % 1000; 18 p[2] = (i / 100) + '0'; 19 i = i % 100; 20 p[3] = (i / 10) + '0'; 21 i = i % 10; 22 p[4] = i + '0'; 23 p[5] = '\0'; 24 /* 下面的操作用于去除多个 0,将第一个有效数字作为第一个数字 */ 25 q = p; 26 while(*q!='\0' && *q == '0')/* 找到第一个非 0的数字 */ 27 q++; 28 if(*q != '\0') 29 strcpy(p, q); /* 将 0后面的数字移动到缓冲区的首地址处 */
30 return p; 31 } 32 /* 自定义的 printf()函数,这是一个可变参数的函数。第一个参数固定为字符指针型 33 * 返回值是实际输出的字符数
34 */
35 int my_printf(const char *format, ...)
36 {
37 va_list ap;
38 char c, ch;
39 int i; 40 char *p;
41 char buf[MAX]; /* 保存字符串的缓冲区 */
42 int n = 0; /* 累计输出字符数 */
43 va_start(ap, format); /* 到达可变参数的起始位置 */
44 c = *format;
45 while(c != '\0'){
46 if(c == '%'){ 47 format++; /* 使用'%'进行转义,跳过'%'字符,处理后面的转义
字符 */
48 c = *format;
49 switch(c){ 50 case 'c': /* 处理字符 */
51 ch = va_arg(ap, int); /* 取第一个字符参数 */
52 putchar(ch); /* 输出该字符 */
53 n++;
54 break;
第 3 章 C 语言中的函数
·71·
55 case 'd': /* 处理整数(short) */
56 i = va_arg(ap, int); /* 取该整数参数 */
57 itoa(i, buf); /* 将整数转换为字符串 */
58 n += strlen(buf); /* 累计输出字符数 */
59 fputs(buf, stdout); /* 输出该整数的字符串形式 */
60 break; 61 case 's': /* 处理字符串 */
62 p = va_arg(ap, char *);/* 取下一个指针参数,保存字符串的首
地址*/
63 n += strlen(p); 64 fputs(p, stdout); /* 输出该字符串 */
65 }
66 }else{ 67 putchar(c); /* 普通字符,则输出该字符 */
68 n++;
69 } 70 format++; /* 处理下一个字符 */
71 c = *format;
72 } 73 va_end(ap); /* 做一些清理工作 */
74 return n; /* 返回实际输出的字符数 */
75 }
76 int main(void)
77 { 78 /* 调用 my_printf()函数输出字符、整型和字符串 */
79 my_printf("the char is : %c\n, the number is : %d\n, the string is :
%s\n",'a', 100, "hello world\n");
80 return 0;
81 } (2)在 shell 中编译该程序。 $gcc my_printf.c -o mu_printf (3)在 shell 中运行该程序。 $./my_printf the char is : a the number is : 100 the string is : hello world 该程序中调用两个函数,一个是 my_printf()函数,一个是 itoa()函数。
说明:其中 itoa()函数用于将一个整型转换为字符串。
该函数首先提取整数的各个位,并将其转换为字符。 后需要将该字符串前面的若干
个 0 去掉,其实现流程如图 3-10 所示。
my_printf()函数是一个可变参数的函数,首先使用 va_start 宏进行初始化,之后处理
format 字符串所表示的每一个字符。如果遇到'%'字符表示这个字符后面是一个转义字符,
需要使用 va_arg 宏得到一个可变参数,并且对其进行适当的处理。my_printf()函数是一个
第 1 篇 Linux 下 C 语言基础
·72·
简单的版本,只处理'c'、'd'和's'的情况。
图 3-10 itoa()函数的执行流程
对于'c'的情况,my_printf()函数只需要输出一个字符即可;对于'd'的情况,需要将参数
中的整型转换为字符串后输出,因此需要调用 itoa()函数,之后输出转换好的字符串即可;
对于's'的情况,直接将参数所表示的字符串输出即可。
说明:由于 format 参数所表示的字符串中带有'\0'结束符,因此当处理到'\0'的时候循环
就结束了。
在my_printf()函数退出之前使用va_end宏进行清理工作,并且返回实际输出的字符数。
整个 my_printf()函数的执行流程如图 3-11 所示。
第 1 篇 Linux 下 C 语言基础
·74·
图 3-12 输出整型数据的流程图 图 3-13 输出一个字符串和一个字符的流程图
3.7.4 关于 printf()函数的疑问——缺少整型参数
说明:错误地使用 printf()函数会出现一些问题,这些问题有时会输出一些莫名其妙的
东西。
现在读者根据 printf()函数的实现方式,可以通过分析可变参数的原理来理解这些错误
出现的原因。
一个典型的错误是在输出整型数据的时候缺少要输出的整型变量,如下所示。 printf("%d"); 这种写法本身是错误的,用户需要输出一个整型数据,但是却没有指定整型数据的参
数。为了验证该语句的输出结果,需要读者自己编写一个验证程序。
(1)在 vi 中编辑程序如下。
程序清单 3-19 err_int.c 错误的 printf()函数用法
01 #include <stdio.h> 02 int main(void) 03 { 04 printf("%d\n"); /* 输出一个整型,但是缺少整型参数 */
05 return 0; 06 } (2)在 shell 中编译该程序。 $gcc err_int.c -o err_int (3)在 shell 中运行该程序。 $./err_int -1067345
第 3 章 C 语言中的函数
·75·
该程序输出了一个值,也就是说即使没有指定要输出的整型数据,printf()函数仍然会
输出一个数据。现在的问题是这个输出的数字是什么呢?printf()函数使用可变参数来实现,
其原理是从第 1 个参数(format 字符串)开始,每次向后取一个参数作为格式化输出的数
据。因此正常的 printf()函数调用如下。 int a; printf("%d\n", a); /* 正确的写法 */ printf()函数内部的可变参数示意图如图 3-14 所示。
图 3-14 可变参数的存储示意图
对于错误的 printf()函数来说,虽然用户没有将一个整型变量存储在 format 字符串的地
址后面,但是 printf()函数根据流程,还是把存储在 format 字符串地址后面的 4 个字节的内
容,当作用户传入的整型变量输出了,如图 3-15 所示。
图 3-15 错误的可变参数的存储示意图
3.7.5 关于 printf()函数的疑问——缺少字符串地址参数
另一个典型的实例就是在输出字符串时,缺少字符串地址的参数,如下所示。 printf("%s\n"); 这种写法本身是错误的,用户需要输出一个字符串型数据,但是却没有指定字符串型
数据的参数。为了验证该语句的输出结果,需要读者自己编写一个验证程序。
(1)在 vi 中编辑程序如下。
第 1 篇 Linux 下 C 语言基础
·76·
程序清单 3-20 err_str.c 错误的 printf()函数用法
01 #include <stdio.h> 02 int main(void) 03 { 04 printf("%s\n"); /* 输出一个字符串,但是缺少字符串参数 */
05 return 0; 06 } (2)在 shell 中编译该程序。 $gcc err_str.c -o err_str (3)在 shell 中运行该程序。 $./err_str Segmetation fault 执行程序之后程序出现了段错误,道理同上一个例子。printf()函数将第 1 个参数 format
字符串地址后面的 4 个字节,作为用户需要输出的字符串的首地址,因此正常的 printf()函
数调用如下。 char *p = "hello"; printf("%s\n", p); /* 正确的写法 */ printf()函数内部的可变参数的示意图如图 3-16 所示。
图 3-16 可变参数的存储示意图
注意:对于错误的 printf()函数来说,虽然用户没有将一个指针存储在 format 字符串的地
址后面,但是 printf()函数根据流程,还是把存储 format 字符串地址后面的 4 个
字节的内容,当作用户传入的字符串首地址,并且寻找其所表示的存储空间的内
容。但是这块存储空间并不一定是可访问的,因此出现了段错误,如图 3-17
所示。