C语言编译过程

1、第一个C语言程序

大多数接触的第一个 C 语言程序便是经典的 Hello World 程序,程序的功能是在当前终端上打印 “Hello World” 字符串!

该程序的实现代码如下:

#include <stdio.h>

void main()
{
  printf("Hello World\n");
}

在 GNU/Linux 系统中,使用 gcc 编译器,编译并执行 helloworld 程序的指令为:

  1. 通过 vi 编辑器编写上面代码,并保存为 helloworld.c
  2. 使用 gcc 编译器编译源代码生成可执行文件 helloworld: gcc -o helloworld helloworld.c
  3. 执行当前目录中的 helloworld 程序:./helloworld

当前终端屏幕就会打印 Hello World,如下图:

上述gcc命令其实依次执行了四步操作:1.预处理(Preprocessing), 2.编译(Compilation), 3.汇编(Assemble), 4.链接(Linking)。

2、C 语言编译原理

为了下面步骤讲解的方便,我们需要一个稍微复杂一点的例子。假设我们自己定义了一个头文件mymath.h,实现一些自己的数学函数,并把具体实现放在mymath.c当中。

然后写一个test.c程序使用这些函数。程序目录结构如下:

├── test.c
└── inc
    ├── mymath.h
    └── mymath.c

程序代码如下:

// test.c
#include <stdio.h>
#include "mymath.h"// 自定义头文件
int main(){
    int a = 2;
    int b = 3;
    int sum = add(a, b); 
    printf("a=%d, b=%d, a+b=%d\n", a, b, sum);
}

头文件定义:

// mymath.h
#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sum(int a, int b);
#endif

头文件实现:

// mymath.c
int add(int a, int b){
    return a+b;
}
int sub(int a, int b){
    return a-b;
}

1. 预处理(Preprocessing)

预处理用于将所有的#include头文件以及宏定义替换成其真正的内容,预处理之后得到的仍然是文本文件,但文件体积会大很多。gcc的预处理是预处理器cpp来完成的,你可以通过如下命令对test.c进行预处理:

gcc -E -I./inc test.c -o test.i

或者直接调用cpp命令:

$ cpp test.c -I./inc -o test.i

上述命令中-E是让编译器在预处理之后就退出,不进行后续编译过程;-I指定头文件目录,这里指定的是我们自定义的头文件目录;-o指定输出文件名。

经过预处理之后代码体积会大很多:

预处理之后的程序还是文本,可以用文本编辑器打开。

2. 编译(Compilation)

这里的编译不是指程序从源文件到二进制程序的全部过程,而是指将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程。编译的指定如下:

$ gcc -S -I./inc test.c -o test.s

上述命令中-S让编译器在编译之后停止,不进行后续过程。编译过程完成后,将生成程序的汇编代码test.s,这也是文本文件,内容如下:

// test.c汇编之后的结果test.s
    .file   "test.c"
    .section    .rodata
.LC0:
    .string "a=%d, b=%d, a+b=%d\n"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset 5, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register 5
    andl    $-16, %esp
    subl    $32, %esp
    movl    $2, 20(%esp)
    movl    $3, 24(%esp)
    movl    24(%esp), %eax
    movl    %eax, 4(%esp)
    movl    20(%esp), %eax
    movl    %eax, (%esp)
    call    add 
    movl    %eax, 28(%esp)
    movl    28(%esp), %eax
    movl    %eax, 12(%esp)
    movl    24(%esp), %eax
    movl    %eax, 8(%esp)
    movl    20(%esp), %eax
    movl    %eax, 4(%esp)
    movl    $.LC0, (%esp)
    call    printf
    leave
    .cfi_restore 5
    .cfi_def_cfa 4, 4
    ret 
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.8.2-19ubuntu1) 4.8.2"
    .section    .note.GNU-stack,"",@progbits

3. 汇编(Assemble)

汇编过程将上一步的汇编代码转换成机器码(machine code),这一步产生的文件叫做目标文件,是二进制格式。gcc汇编过程通过as命令完成:

$ as test.s -o test.o

等价于:

gcc -c test.s -o test.o

这一步会为每一个源文件产生一个目标文件。因此mymath.c也需要产生一个mymath.o文件。

4. 链接(Linking)

链接过程将多个目标文以及所需的库文件(.so等)链接成最终的可执行文件(executable file)。

命令大致如下:

$ ld -o test.out test.o inc/mymath.o ...libraries...

3、程序运行原理

GNU/Linux 系统中可执行程序都是 elf 格式二进制文件,该文件跟 Windows 系统的 exe 文件类似,通过 Linux 的 Shell 比如 Bash 加载到内存,由操作系统启动新线程,然后开始执行。

我们可以通过 file 命令查看目标文件的格式:

:~$ file helloworld
helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=203388067920d237ab234e8eb97714f56919799f, not stripped

1. 编译,链接

从源代码生成可执行文件,需要很多步骤,最主要的步骤就是编译和链接。在我们上述的过程中,编译和链接都是由 gcc 程序完成的。

当然我们也可以分开来执行编译和链接过程:

gcc -c helloworld.c
ld -o helloworld helloworld.o -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o -lc

可以看到,简单的 helloworld 程序依赖了大量的系统文件,其中主要的是程序运行环境相关的 crt (C RunTime Library)和 系统 c 语言库 glibc。

当然不同的平台这个步骤可能不同,可以在 gcc 命令中添加 -v 参数,查看编译和链接的完整步骤。

2. 运行时

我们从代码可见的程序起始是 main 函数,但是编译器在编译链接的过程中,在我们的程序中添加了运行时代码,所以程序的起始并不是 main 函数了,可以通过 nm 查看我们的程序的地址和符号:

$ nm helloworld
0000000000600734 D __bss_start
0000000000600730 D __data_start
0000000000600730 W data_start
0000000000600570 d _DYNAMIC
0000000000600734 D _edata
0000000000600738 D _end
0000000000400464 T _fini
0000000000600708 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
0000000000400340 T _init
0000000000600570 d __init_array_end
0000000000600570 d __init_array_start
000000000040047c R _IO_stdin_used
0000000000400460 T __libc_csu_fini
00000000004003f0 T __libc_csu_init
                 U __libc_start_main@@GLIBC_2.2.5
00000000004003a0 T main
                 U puts@@GLIBC_2.2.5
00000000004003c0 T _start

可以看到 main 函数已经不是在程序的代码段开头了。可以通过对 gcc 添加 -Map 参数,来生成程序的 map 文件,方便我们查看程序的代码段,数据段等信息:

gcc -o helloworld helloworld.c -Wl,-Map,helloworld.map

通过 helloworld.map 可以清晰的看到 main 函数所在的 text 段,和相关的地址信息。

3. 链接库

gcc 默认动态库的搜索路径搜索的先后顺序是:

  1. 编译目标代码时指定的动态库搜索路径;
  2. 环境变量LD_LIBRARY_PATH指定的动态库搜索路径;
  3. 配置文件/etc/ld.so.conf中指定的动态库搜索路径;
  4. 默认的动态库搜索路径/lib、/usr/lib。
    所以指定目标库的时候需要使用 -rpath 参数传递路径给 gcc。
    我们这里只是使用了标准 c 库,版本为 ldd 展示的 /lib/x86_64-linux-gnu/libc.so.6 GLIBC_2.2.5
    ldd helloworld
    linux-vdso.so.1 => (0x00007ffd493f3000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5f12756000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f5f12b20000)

4. 编译器优化

我们显示调用的 c 库函数是 printf,在 c 语言库中 stdio.h 中定义:

/* Write formatted output to stdout.

   This function is a possible cancellation point and therefore not
   marked with __THROW.  */
extern int printf (const char *__restrict __format, ...);

但是实际上,我们通过 nm 命令看到可执行文件中调用的 c 库的 puts, 通过汇编更能清晰的看到这个调用的详细情况:

gcc -S helloworld.c
cat helloworld.s
        .file   "helloworld.c"
        .section        .rodata
.LC0:
        .string "Hello World"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    $.LC0, %edi
        call    puts
        nop
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609"
        .section        .note.GNU-stack,"",@progbits

当打印的全部是字符串,即没有需要转为字符串的操作的时候, gcc 会把 printf 优化成 puts。所以对于编译器的优化对程序员来说有时候是透明的。

我们需要仔细的检查编译器是否对我们的代码进行了优化。

4、Hello World 打印原理

从上面的分析,我们知道,我们的 helloworld 程序主要是调用了 puts 函数进行打印,puts 在 glibc 中的实现如下:

/* Write the string in S and a newline to stdout.  */
int
puts (const char *s)
{
  return fputs (s, stdout) || putchar ('\n') == EOF ? EOF : 0;
}

该函数主要是调用 fputs 将字符串送到 stdout (标注输出),并送出一个换行符!

换行符同样是送到 stdout :

/* Write the character C on stdout.  */
int
putchar (int c)
{
  return __putc (c, stdout);
}

1. stdout, stdin 和 stderr

那么 stdout 是什么,glibc 是如何通过 stdout 将我们的终端相连接的呢?

stdout 在 glibc 中是 FILE 类型的指针:

/* Standard streams.  */
extern FILE *stdin, *stdout, *stderr;
#ifdef __STRICT_ANSI__
/* ANSI says these are macros; satisfy pedants.  */
#define	stdin	stdin
#define	stdout	stdout
#define	stderr	stderr
#endif

这 3 个指针分别是对应 fd 号为 0,1,2 的 3 个 标准 fd 的封装:

/* Standard streams.  */
#define	READ		1, 0
#define	WRITE		0, 1
#define	BUFFERED	0
#define	UNBUFFERED	1
#define	stdstream(name, next, fd, readwrite, unbuffered)		      \
    {									      \
      _IOMAGIC,								      \
      NULL, NULL, NULL, NULL, 0,					      \
      (void *) fd,							      \
      { readwrite, /* ... */ },						      \
      { NULL, NULL, NULL, NULL, NULL },					      \
      { NULL, NULL },							      \
      -1, -1,								      \
      (next),								      \
      NULL, '\0', 0,							      \
      0, 0, unbuffered, 0, 0, 0, 0					      \
    }
static FILE stdstreams[3] =
  {
    stdstream (&stdstreams[0], &stdstreams[1], STDIN_FILENO, READ, BUFFERED),
    stdstream (&stdstreams[1], &stdstreams[2], STDOUT_FILENO, WRITE, BUFFERED),
    stdstream (&stdstreams[2], NULL, STDERR_FILENO, WRITE, UNBUFFERED),
  };
FILE *stdin = &stdstreams[0];
FILE *stdout = &stdstreams[1];
FILE *stderr = &stdstreams[2];

其中可以明确的知道:

  1. 只有 stderr 是不缓冲的,stdin 和 stdout 都是缓冲的,那么输出到 stdout 的字符可能不会立即显示
  2. stdin 是只读的, stdout 和 stderr 是只能写的,其他的操作,比如读 stdout 是不可预知的。
  3. fd 是显示直接强制赋值的,就是说 0,1,2 应该是已经打开的描述符,否则会出现输入输出错误。

那么是在何时打开的标准描述符呢?

2. stdio 与 tty

stdio 是与 tty 对应的,一个系统中可以有很多用户,或者一个用户打开了多个终端,但是 printf 等输出都是在当前终端上。
stdio 是与 tty 一一对应。从 glibc 的代码我们可以找到打开标准描述符 0,1,2 的位置:

login_tty.c:

int
login_tty(fd)
	int fd;
{
	(void) setsid();
#ifdef TIOCSCTTY
	if (ioctl(fd, TIOCSCTTY, (char *)NULL) == -1)
		return (-1);
#else
	{
	  /* This might work.  */
	  char *fdname = ttyname (fd);
	  int newfd;
	  if (fdname)
	    {
	      if (fd != 0)
		(void) close (0);
	      if (fd != 1)
		(void) close (1);
	      if (fd != 2)
		(void) close (2);
	      newfd = open (fdname, O_RDWR);
	      (void) close (newfd);
	    }
	}
#endif
	(void) dup2(fd, 0);
	(void) dup2(fd, 1);
	(void) dup2(fd, 2);
	if (fd > 2)
		(void) close(fd);
	return (0);
}

每次登陆的时候,系统会将当前的 login 程序传入的 fb, dump 出来 3 份,分别的 fb 值就是 0,1,2
因此, stdin、stdout、stderr 其实对应的是同一个文件,这个文件就是当前 login 使用的 tty 。

3. 从内存到设备

我们的 helloworld 程序被 shell 加载到内存, “Hello World” 字符串也是在内存的位置,如何输出到 tty 设备呢?

我们 tty 设备是虚拟的设备,可能是 LCD 显示器,可能是串口,也可能是 LED 显示器。其中的对应和输出流,那就是要牵涉到具体的设备驱动,那又是另一个领域才能讲清楚的了。

大概的数据流就是:

  1. 输出设备和 tty 是绑定的,输出到 tty 就会把数据传递给显示设备驱动程序
  2. 设备驱动程序会把字符串数据最后通过 DMA 或者其他总线方式发给设备
  3. 最终的设备会显示我们需要看到的字符串 “Hello World”

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wespten

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值