操作系统系列:快速了解C语言的编译

2023-12-16 21:43:42

关于C语言的编译

开发者写好一段代码后,需要将编码语言转换为设备认识的机器语言才能执行,也就是说将C语言文件转化为可执行文件,这个过程称为编译。

编译时会发生什么?

编译 C 程序时会发生什么呢,值得我们花一些时间来看一看。 下面这个示例来自于 Unix 编译器,但原理适用于任何 C 编译器。创建可执行文件的过程至少涉及四个单独的步骤:预处理输入、进入实际的编译工作来生成汇编程序文件、汇编该文件以创建目标文件,以及链接这些目标文件来创建可执行文件。

C预处理器

预处理器是一个以包含预处理指令的C程序作为输入的程序,它会展开这些指令,预处理器的输出是带有这些展开的预处理指令的C程序。预处理指令有时被称为,尤其是在汇编中。宏展开总是使用字符串替换的形式。
C预处理器指令由每一行的第一列的**#**字符指示。
你很熟悉的一条指令是:

#include

这条指令后面紧跟着一个文件名(通常文件带有后缀名.h),并且文件的内容会被拷贝到输出文件。文件名可以用< >或者" “包起来,如果文件名用< >索引,那么预处理器会在路径/usr/include路径查找它(或者某些由管理员设置的其他路径)。如果文件用” "索引,那么预处理器会在当前路径中查找,或者将其作为绝对路径名来跟踪。
另一个简单的预处理器指令是:

#define

它通常有两个参数,预处理器会简单的用第二个字符串替换第一个字符串。比如说:

#define BUFSIZE 1024

预处理器会替换输入文件中所有使用BUFSIZE的实例,在输出文件中用1024代替。比如说,

char buffer[BUFSIZE]

要被替换为

char buffer[1024];

注意!这里有个常见的错误是在第二个字符串的结尾处放了个 ; ,那么会导致很难检查到的编译错误,比如:

#define BUFSIZE 1024;

会导致输出字符串是,

char buffer[1024;];

在句法上这是错误的。

也有可能写一些使用参数的宏定义的表达式,比如:

#define SQUARE(X)   X * X

如果有一行类似于这样的文本:

n = SQUARE(m);

那么它将会被扩展为,

n = m * m;

这里有一个再复杂一点的例子:

#define SWAP(TYPE,M,N) {TYPE temp; temp=M; M=N; N = temp;}

那么文本中的这一行,

SWAP(int,a,b)

会被展开为,

{int temp; temp=a; a=b; b=temp;}

要搞清楚一点,这个过程没有发生过实际的处理,或者说预处理期间甚至不检查语法是否正确。预处理器只是简单的把一个字符串替换为另一个。
预处理器可以定义变量,而不用去设置实际的值。比如:

#define _MYHEADER_H_

这种方式通常用于控制条件编译,是预处理器的另一个特性。条件编译意味着只有当某些变量被定义或者未被定义时,某些代码才会被编译,相应地,预处理器关键字#ifdef(如果变量被定义)和#ifndef(如果变量未被定义)和#endif搭配使用。例如:

#ifndef _MYHEADER_H_
#define _MYHEADER_H_
/*
code for my header.h, which will only be compiled if _MYHEADER.H had not been previously defined.
*/

也可以通过gcc命令行来定义变量,同样地,要使用-D选项,比如:

>gcc -D __sparc__ -o outfile infile.c

你稍后可以在infile.c文件中输入如下代码:

#ifdef __sparc__
/* code to be compiled for sparc,but not for other architectures. */
#endif

C预处理器是一个cpp,输入是带有预处理指令的文件,输出是所有预处理指令都已经展开的文件。这个文件会通过一个带有后缀.i的临时文件名给出,临时文件使用后会自动删除,但是开发者可以在这一步后通过传递-E标志给gcc来停止这个操作。输出文件会写道标准输出,所以如果开发者想看一看预处理器做了什么,那么可以将它重定向到一个文件。比如:

>gcc -E myfile.c > myfile.i

下面的程序经过C预处理器,但是不进入编译器,输出是什么?下面给出一个关于预处理器的小测验。

#define BUFSIZE 1024
#define putchar(x) putc(x,stdout)
#define NULL (void *0)
#define DWORD unsigned long int
#define LOBYTE(X)  X & 0x0F
#define SUM(x,y)  x + y

int main()
{
	int a,b,c;
	DWORD d;
	char buffer[BUFSIZE];
	char *s;
	a = 5000;
	s = NULL;
	b = LOBYTE(a);
	d = SUM(a,b);
	putchar(e);
	return 0;
}

实际的编译

C编译器被称为cc1,它的输入是预处理器输出的文件,换句话说,一个纯C文件。它的输出是一个汇编文件,在Solaris上,汇编文件带有后缀.s,如果开发者想要查看以下这个汇编文件,你可以通过传递-S标志给gcc,在汇编之前停止这个操作。否则,汇编过程之后,.s文件会被删掉。

汇编

gcc脚本紧接着召唤主机上的汇编器,大多数的Unix机器上,自带的汇编器是as,并且GNU相当于是gas,输入是编译器生成的汇编文件,输出是一个带有后缀.o的对象文件(在Windows上对象文件的后缀是.obj)。
开发者可以在对象文件创建后,但是在实际的可执行文件创建以前,通过传输-c标志到gcc,来停止这个过程。
如果输入文件不止一个,那么在进入下一步之前,上述过程(预处理,编译,汇编)要对每一个输入文件重复一遍。

链接

假设我们有2个这样的源文件:
第一个文件是file1.c

/* file1.c */
#include <stdio.h>
int g; /* a gloal variable */
extern double dg; /* another global var, defined in some other file */
void fctnOne(); /* a function prototype */

int main()
{
	int x; /* a variable local to main (an automatic variable) */
	x = 3;
	dg = 3.14;
	g = 17;
	fctnOne();
	printf("x is %d, g is %d, dg is %f \n",x,g,dg);
	return 0;
}
void fctnTwo()
{
	int x;
	x = 5;
	g = 11;
	dg = dg * 2;
}

第二个文件 file2.c

/* file2.c */
extern int g;
double dg;
void fctnTwo(); /* function prototype */
void fctnOne()
{
	int x = 44;
	g = x;
	dg = dg +2;
	fctnTwo();
}

如果编译行是这个样子的

gcc -g -Wall file1.c file2.c

对这两个源文件进行预处理,编译和汇编,应该会产生2个对象文件,称为file1.o和file2.o,不管如何,这两个对象文件都有未被解析的引用,也就是说,在其它文件中定义的变量和函数。当file1.o被生成的时候,它会包含名为fctnOne的函数调用,但是在汇编时它并不知道这个函数的地址。同样地,有一个对变量dg的引用,汇编器并不知道这个变量的地址。还有一个printf调用,也没在这个文件中定义。文件file2.o也有未被解析的引用fctnTwo和g。链接器的工作是解析所有这些未被解析的引用,完成这件事需要使用2个依赖对象文件的表。一个表是definition table,列出了这个文件中定义的所有全局函数和变量以及相应的地址。另一个表,是use table,列出了每个被使用的未定义的变量和函数的实例。
这里有4个表供这2个文件使用。

用于file1.c的definition table                         	用于file1.c的use table
g														gd (line 13)
main()													fctnOne() (line 15)
fctnTwo()												printf() (line 16)
														dg (line 17)
														dg (line 26, first instance)
														dg (line 26, second intance)
Definition table for file2.c							Use table for file2.c
gd														g (line 9)
fctnOne()												fctnTwo() (line 11)

链接器通常通过2个pass来完成它的工作,首先遍历所有的定义表,构建一个包含所有函数和变量的全局定义表,这些函数和变量与它们的地址一起定义在任何文件中,然后再次遍历所有的文件,用真实地址替换使用列表中的所有未解析的引用。
最终,链接器搜索库去解析更多未被解析的引用。链接器通常配置为自动搜索标准C库libc,它包含像是printf函数的代码。开发者可以告诉链接器搜索其它库,使用-l标志(该标志可以传递给gcc。比如,如果开发者使用math库中的函数,可以通过-lm标志来告诉链接器。)
更常见的是编译器使用动态链接去链接库函数。使用静态链接的话,用于可执行程序的库是直接作为可执行程序的一部分的。使用动态链接,那么库符号的名字存储在可执行程序中,程序运行时如果需要调用库函数,那么操作系统会在执行前为可执行程序查找代码的位置。动态链接的优势是,对于经常使用的库函数,比如printf,只有一个代码实例(静态链接的情况,用于printf的代码拷贝到每个使用它的可执行程序中)。动态链接的缺点是,需要一点运行时间,因为每次调用时,系统都需要花点时间去查找库函数的地址。
链接器合并了所有输入到一个可执行程序镜像,某些情况下,这可能需要解决在某些模块或者全部模块中的地址的再分配。
下面时2个分别编译的程序,并且由链接器链接到一起创建一个可执行文件。展示用于One.o和Two.o One.c的定义表和使用表的内容。

extern int a(int);
extern int b;
int c;
int d(int x)
{
	int y;
	y = x * 2;
	return y;
}
int main()
{
	int e;
	c = 7;
	b = a(c);
	e = d(b);
	printf("%d\n",b);
	return 0;
}

Two.c

int b;
int d(int);
int a(int x)
{
	int z;
	z = d(x);
	return z;
}

传递参数到程序中

当开发者从命令行运行程序的时候,可以传递参数到程序中。函数main()可以访问这些参数。main默认由2个参数int argc和char *argv[],变量argc会自动设置为在运行时需要包含的所有参数数目,包括可执行程序自己,变量argv(参数向量)时一个指针数组,指向字符串。数组的尺寸时argc,这里有一个简短的程序,展示了它的参数。

#include <stdio.h>
int main(int argc, char *argv[])
{
	int i;
	for(i = 0;i<argc;i++){
		printf("%s\n",argv[i]);
	}
	return 0;
}

假设这是一个命令行

a.out first second third

那么输出应该是

a.out
first
second
third

那么argc的值应该是4.

在Windows上编译C或者C++程序:
在windows机器上,开发者将会使用Microsoft.NET编译器,如果没有合适的访问Microsoft 编译器,那么可以使用任何免费的编译器,总之,编译器要可以访问WIN32 APIs.

匈牙利命名法

如果开发者阅读Microsoft C/C++文档,或者Microsoft的样例代码,需要理解匈牙利命名法。这是一种Microsoft惯常使用的变量命名方法。例如,描述函数ReadFile的在线帮助就是这种命名方式。

BOOL ReadFile(
    HANDLE hFile,
    LPVOID lpBuffer,
    DWORD  nNumberOfBytesToRead,
    LPDWORD lpNumberOfBytesRead,
    LPOVERLAPPED lpOverlapped
);

匈牙利命名发是由Charles Simonyi 开发的,他是最初的Microsoft软件总架构师(他可能有过匈牙利血统),它是一个命名惯例,允许编程人员确定类型并且使用标识符(变量,函数,常量等)。
变量名字包含可选的前缀,指示变量类型的标签,以及变量名。前缀是小写字母,变量名自身用大写字母开头。
通用的标签有:

FlagTypeExample
bBooleanbGameOver
ch or csingle charchGrade
dwdouble word(32 bits)dwBytesRead
nintegernStringLength
ddouble precision realszLastName
sznull terminated char stringszLastName
ppointerpBuffer
lplong pointerlpBuffer
CClass NameCWidget
另外,多数Microsoft的样例代码都包含头文件<windows.h>,它定义了大多数的数据类型,开发者不需要去看变量类型int在Microsoft代码中的定义,按惯例,所有这些都是大写的,这里有一些例子:
DWORD unsigned long(代表双字)
WORD unsigned short(16比特)
BOOL boolean
BYTE unsigned char
LPDWORD pointer to a DWORD
LPVOID pointer to type void(通用指针)
LPCTSTR pointer to a const string

系统调用

现在的操作系统可以运行在2个(或多个)模式,典型的称为用户模式和内核模式。编译和运行普通程序的普通用户在用户模式,意味着程序只可以访问它自己的内存区域。然而,多数用户程序需要访问内核提供的服务。一个显著的例子是从文件中进行读取。通常用户是不允许访问读取独立块的底层代码的,需要某些机制能允许用户程序去访问这些服务。这些对内核服务的调用就被称为Unix的系统调用和应用程序接口,或者Microsft的APIs.

Unix系统调用

大概有190个Unix系统调用(对于每种系统这个数字可能略有不同),这些调用总是使用C函数调用的格式,也就是说,它们可以使用普通变量做参数,也可以使用指向普通变量的指针,而且它们会返回一个值。这个返回值通常是int型的。一般来说,返回值是正数或者0表示调用成功,而负返回值表示系统调用由于某些原因失败了。如果系统调用失败,全局的external int变量errno会被设置,程序可以查看该值来确认系统调用失败的原因。
尽管errno是个整数,它的值都是有符号名称的,可以查看每个系统调用的man pages来确认这些值的意义。例如打开文件的系统调用open,成功的话,它会返回可以给其它系统调用访问这个文件的文件描述符,然而,总是有一些原因使打开文件的尝试失败,这里展示了一部分open的在线man page:

ERRORS
     The open() function will fail if:

     EACCES
           Search permission is denied on a component of the path
           prefix,  or the file exists and the permissions speci-
           fied by oflag are denied, or the file does  not  exist
           and  write  permission is denied for the parent direc-
           tory of the file to be created, or O_TRUNC  is  speci-
           fied and write permission is denied.

     EDQUOT
           The file does not exist,  O_CREAT  is  specified,  and
           either the directory where the new file entry is being
           placed cannot be extended because the user's quota  of
           disk blocks on that file system has been exhausted, or
           the user's quota of inodes on the  file  system  where
           the file is being created has been exhausted.

     EEXIST
           The O_CREAT and O_EXCL flags are set,  and  the  named
           file exists.

     EINTR A signal was caught during open().

     EFAULT
           The path argument points to an illegal address.

     EISDIR
           The named file  is  a  directory  and  oflag  includes
           O_WRONLY or O_RDWR.

     EMFILE
           OPEN_MAX file descriptors are currently  open  in  the
           calling process.

     ENFILE
           The maximum allowable number  of  files  is  currently
           open in the system.

每个可能的错误条件都有符号名称(所有符号名称都以大写的字母E开头),定义在头文件中。变量errno在失败时设置,可以通过写代码发现导致错误的原因,这里有一个实现了这件事的C程序架构。


#include <errno.h>
extern int errno;

int main()

int returnval

returnval = open(…)
if(returnval < 0) /* the open failed */
switch(errno){
case EACCES:…
case EDQUOT:…


}

良好的程序总是会检查可能导致失败的系统调用的返回值,并且在失败事件中执行合适的操作。对于所有开发人员来说都要这样做。
Unix有一个库函数void perror(const char *msg),它可以向开发者提供带有标准错误消息的信息(基于errno的值)。
这里有一段代码段展示它要怎么用。

int fd;
fd = open(...)
if(fd < 0) perror("Error opening file")

如果因为没有文件名导致调用open失败,那么这个消息可能会展示在终端上:

Error opening file: No such file or directory

也有一个函数char *strerror(int errno) (确保包含了头文件string.h),它返回一个对应错误码参数的字符串。

文章来源:https://blog.csdn.net/wss_xt/article/details/135009882
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。