请稍侯

Hello World这件“小事”

13 January 2014

前言

  学过编程的人对于hello world一定不陌生,每当接触一门语言时,我们总会从这个简单却又让人兴奋不已的例子开始,也正是这么一个只有一行或者几行的代码带领了无数的人走进了编程的世界。
  在平常的程序开发中,大多数人都是使用的IDE进行开发,比如Visual Studio、Eclipse等等。在IDE中,我们只需要点击运行,程序结果就出来了,是不是少了点什么?大部分的IDE会将编译链接合并为构建(Build),所有的配置都由IDE默认指定了,或许对于平时的应用需求已经足够,但是,作为一个计算机专业的人来说,你连你的程序运行背后的机理都没有搞清楚,会不会有点虚?遇到性能瓶颈时如何提升性能是不是感到很无助?如果能够深入理解它是如何工作的,那么遇到问题时也就不会那么手足无措了。

#include <stdio.h>
int main()
{
    printf("Hello World!\n");
    return 0;
}

  对于这样一个简单的c语言写的Hello World程序,我们仔细思考下这个程序,就会发现我们对于这个程序其实还有很多我们以为我们懂了但是具体到细节时又模棱两可或者根本不知道的问题,比如:
  - 为什么要编译这个程序?
  - 为什么一个文件可以使用其他文件中的变量或者函数?
  - main函数时程序的入口吗?如果不是那程序是从哪里开始的?
  接下里我们从这三个问题出发看看程序到底是怎么被执行的。


程序执行流程

  首先我们把上面那段代码保存为hello.c,使用gcc编译,最简单的情况下只需要一个命令,命令执行完之后,会发现当前目录下多了一个a.out文件,运行这个文件,便得到了我们想要的结果。

gcc hello.c
./hello.c
Hello World!

  这个简单的命令所做的事确实不简单的,我们具体可以把它所做的工作分为4步:预处理,编译,汇编,链接,下面对这四个步骤做个简单的介绍。 5

预编译

  预编译器cpp将源文件和头文件预编译为.i的文件。预处理的过程中主要处理那些源代码文件中以#开始的预编译指令,比如#include、#if、#define等等。主要工作如下:

--将所有的宏定义(#define)展开。  
--处理所有条件预编译指令(#if、#ifdef、#elif、#else等等)。  
--处理所有包含指令(#include),将被包含的文件插入到该语句的位置,该过程是递归执行的,因为一个文件可能有又包含其他文件。  
--删除所有的注释。  
--添加行号和文件名标示。以便错误时定位错误信息。
--保留所有的#pragma指令,因为编译时会用到,比如说指定字节对齐数等等。

  这就意味着如果我们无法判断宏定义是否正确或者头文件是否包含的时候我们可以查看预编译后的文件来确定。gcc下预编译命令为:

gcc -E hello.c -o hello.i

编译

  编译的过程就是将高级语言翻译成机器语言的过程,因为直接使用汇编语言或者机器语言编写代码十分乏味,而且使得我们的程序依赖于特定的机器运行,可移植性太差。所以人们希望用类似自然语言的形式来定义语言,但是自然语言又不够精确,所以便出现了以数学形式定义的编程语言,从而使得编程的人更加专注于程序本身,第一个问题的答案已经有了。编译的过程中涉及一些列的词法分析、语法分析、语义分析然后优化产生汇编代码,gcc中编译命令为:

gcc -S hello.i -o hello.s

汇编

  汇编器所做的工作是将汇编代码转为机器可以执行的机器码,每条汇编指令基本都对于一条或多条机器指令。根据汇编指令和机器指令的对照表翻译就可以了,汇编过程可以使用汇编器as来实现:

as hello.s -o hello.o
//或者:
gcc -c hello.s -o hello.o

  经过这些过程,源代码被编译成了目标代码,现在的问题是假如目标代码A中有全局变量或者调用了其他模块的变量或者函数怎么办?最后在程序运行的时候如何找到这些符号所对应的地址呢?这时候就要靠链接器出马了。

链接

  如果要生成最后可执行文件a.out,我们需要把这些目标文件(.o)链接起来:

//下列文件都省略了路径
ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc-end-group crtend.o crtn.o

  全局变量、其他模块的变量、函数最终运行的绝对地址都要在链接的时候才能定下来,链接器负责将所有的目标文件链接起来形成可执行文件。在c语言中,最小的单位是变量和函数,若干个变量和函数组成一个模块,存放在.c的文件中,若干个这样的模块构成了一个工程。这些模块组成一个单一的工程的过程中一个重要的问题就是模块之间的通信。
  常见的静态语言模块之间的通信方法主要两种,一种是模块间函数的调用,另外一种就是模块间变量的访问。无论是前者还是后者都需要知道所要访问的变量或者函数的地址,归结为一点就是模块间符号的引用(符号的概念是随着汇编语言迅速普及被使用,它表示一个地址,即可能是一个函数的起始地址,也有可能是一个变量的起始地址)。所以链接的主要作用是把各个模块之间的相互符号引用处理好,使得各个模块可以准确的衔接,这样第二个问题解决了。链接主要做的工作有地址和空间分配、符号决议和重定位,到此为止,我们便得到了一个可执行文件,那么这个可执行文件时如何运行的呢?喝杯水,继续看下去。


目标文件与可执行文件

目标文件

  目标文件是在经过编译但是还没有进行链接的(Windows下的.obj和Linux下的.o)文件,格式和可执行文件类似。目标文件中至少应该包含的信息有:编译后的指令代码、数据、符号表、文字表、调试信息、字符串等等,这些信息以段的形式存储,段的含义简单来说就是指定一块区域存放特定的东西,下图表明了程序被编译后生产的目标文件的结构。
6
  对照上图,我们可以看到,c语言编译后执行的语句编译为机器码后存储在.text段;初始化的全局变量和静态变量存储在.data段;未初始化的全局变量和静态变量存储在.bss(block started by symbol)段。为什么不把未初始化的全局变量和静态变量也存储在.data段呢?因为未初始化的全局变量和静态变量的值为0,不需要在.data段中放着占位置,但是又必须记录这些变量,因为在运行的时候他们是要占内存的,所以另外划分了一块区域,给那些未初始化的全局变量和静态变量留了个位置(这与具体编译器有关,有些编译器会将未初始化的全局变量存放在.bss段,有些知识预留一个未定义的全局变量符号,等到最终链接生成可执行文件的时候在给.bss段分配空间),等到最后链接完成生成可执行文件(可执行文件必须记录这些未初始化的全局变量和静态变量总共的大小),只要在程序启动时将.bss段对应的位置全部置为0就可以了。
  现代大多数操作系统在加载程序时,会把所有的.bss段清零。而在一些嵌入式设备上,我们在编写启动代码的时候,需要手动把.bss段置0。但为保证程序的可移植性,手工把.bss段初始化为0是一个好习惯,这样未初始化的全局变量和静态变量都有个确定的初始值0。

可执行文件

  目前PC机上常见的可执行文件主要由两种,一种是Windows下的PE(portable executable)和Linux的elf(executable linkable format)文件,这两者都是COFF格式的变种,COFF是由Unix System V Release 3首先提出并且使用的格式规范,后来Microsoft基于COFF格式制定了PE格式标准,并将其用于当时的Windows NT。System V Release 4在COFF的基础上引入了ELF格式,目前流行的Linux系统也以ELF作为基本可执行文件格式。这就是为什么PE和ELF相似的主要原因,因为它们的老祖宗都是COFF格式。Unix最早的可执行文件格式为a.out格式,它的设计非常地简单,以至于后来共享库这个概念出现的时候,a.out格式就变得捉襟见肘了。于是有人设计了COFF格式来解决这些问题,这个设计非常通用,以至于COFF的继承者到目前还在被广泛地使用。这里为什么要介绍COFF格式呢,因为这篇文章介绍目标文件中有个很重要的概念,那就是“段(segment)”(或者叫“节(section)”),而COFF格式的主要贡献就是在目标文件里面引入了“段”的机制,不同的目标文件可以拥有不同数量及不同类型的“段”。
  代码经常会被存放在代码段(code segment),代码段中常见的名字有.code或者.text;全局变量和静态变量常被放在数据段(data segment)。elf文件的开头是一个文件头,描述了整个文件的文件属性,包括文件是否可执行、静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等等,还有段表,描述各个段在文件中的偏移位置和段的属性。除了上面介绍的几个常见的段之外,elf文件还有些其他段,如.rodata1(和.rodata一样)段,用于存放只读数据,字符串常量和全局的const变量;.debug段存放调试信息等等,感兴趣可以自行查阅相关信息。
  若干目标代码经过链接,将相同的段进行合并,将符号引用问题处理好、使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体,这就是可执行文件,接下来它是怎么运行的呢?


main之前的那些事

  接下来以freescale的一款ARM Cortex M4内核的芯片K60的样例程序为例看一个最简单的gpio程序,了解下程序编译完之后是如何执行的,程序编译完之后我们将可执行文件烧写到芯片中,上电复位之后,系统会查询中断向量表,把第一项作为堆栈指针值复制给堆栈指针SP,第二项赋值给PC指针,也就是让pc指指向了__startup函数:

  .extern start
    .global startup
    .global __startup
    .text
startup:
__startup:
    MOV     r0,#0                   
    MOV     r1,#0
    MOV     r2,#0
    MOV     r3,#0
    MOV     r4,#0
    MOV     r5,#0
    MOV     r6,#0
    MOV     r7,#0
    MOV     r8,#0
    MOV     r9,#0
    MOV     r10,#0
    MOV     r11,#0
    MOV     r12,#0
    CPSIE   i                      
    BL      start                 
done:
    B       done
    .end

  这个函数使用汇编所写,所做的工作就是初始化所有的通用寄存器,并且跳转到start函数:

void start(void)
{
    wdog_disable();    //关闭看门狗
 common_startup();  //复制.data段、中断向量表到ram
 sysinit();         //时钟初始化
 cpu_identify();    //cpu型号确认
 main();            //跳转到main函数
 while(1);
}

  在start函数中,common_startup所做的工作就是我们之前提到的,

1 复制中断向量表

  因为在lcf文件(lcf文件是指示linker如何链接编译好的文件的,与ld文件类似,freescale的 ARM compiler 用lcf作为linker的配置文件; GCC compiler用ld文件作为linker的配置文件. 二者看上去很相似但却也有不同。)中记录了在连接之后各个段的起始地址,所以我们需要从lcf文件中得到这些段的起始和结束的地址信息,只需要extern这些变量就可以了,因为链接之后在lcf文件中这些变量已经被赋值了。相比于ARM上一代主流的ARM7/ARM9处理器,新一代的Cortex系列处理器启动的方式变化很大,ARM7/ARM9处理器在程序复位后会从0x0000_0000地址取出第一条指令执行复位中断服务程序,固定了复位后的起始地址为0x0000_0000,但是中断向量表里面各个项的位置是不固定的;而Cortex-M3/4系列处理器规定了起始地址必须存放堆栈指针,第二个地址必须存放复位中断入口向量,这样芯片复位之后,会自动从起始地址的下一个32位空间去取出复位中断入口向量,跳转执行复位中断的服务例程。所以相比于ARM7/ARM9处理器,Cortex-M3/4系列处理器固定了中断向量表中各个项的位置但是起始地址是可以变化的,通过设置vtor寄存器,但是这个值不是任意设置的,需要满足一定条件,偏移值的对齐值一定为2的整数次幂个字,还有根据外部中断的个数,这个偏移值的对其值不一样,下表为不同的外部中断数目对应的对齐方式。
7
  为什么0-16个外部中断要32个字呢?因为除了外部中断,还有16个内核中断,对应序号0~15,所以实际上中断数量为16-32个,所以要进行32字对齐。当外部中断为21个时,对齐方式为64字。

2 复制.data段,将.bss段清0

  接下来2就比较简单了,先计算.data和.bss段大小,然后将数据从rom拷贝到ram,再将.bss段清0(对应之前讲到的手动清.bss段)。

void common_startup(void)
{
    //这些变量是在lcf文件中定义,所以在这边只需要extern即可
    extern char __START_BSS[];
    extern char __END_BSS[];
    extern uint32 __DATA_ROM[];
    extern uint32 __DATA_RAM[];
    extern char __DATA_END[];
    //计数值
    uint32 n;
    uint8 * data_ram, * data_rom, * data_rom_end;
    uint8 * bss_start, * bss_end;

    //这些变量也是在lcf文件中定义,
    extern uint32 __VECTOR_TABLE[];
    extern uint32 __VECTOR_RAM[];
    //1 复制中断向量表到ram
    if (__VECTOR_RAM != __VECTOR_TABLE)
    {
        for (n = 0; n < 0x410; n++)
            __VECTOR_RAM[n] = __VECTOR_TABLE[n];
    }
    //更新vtor寄存器,vtor的值代表了中断向量表偏移0x0000_0000地址的值
 
    write_vtor((uint32)__VECTOR_RAM);
    //2 将.data段从rom拷贝到ram  
 data_ram = (uint8 *)__DATA_RAM;
    data_rom = (uint8 *)__DATA_ROM;
    data_rom_end  = (uint8 *)__DATA_END; 
    n = data_rom_end - data_ram;  
    while (n--)
        *data_ram++ = *data_rom++;
    
    bss_start = (uint8 *)__START_BSS;
    bss_end = (uint8 *)__END_BSS;
    //手动将.bss段清0
    n = bss_end - bss_start;
    while(n--)
      *bss_start++ = 0;
}

  做完上面的工作,解析来初始化时钟(时钟配置在这里不做赘叙),然后便进入main函数了。到了这里,我们可以知道,main函数绝对不是程序的开始,在main函数之前,我们已经做了许多工作,这一点我们平时细心一点就可以留意到,比如我们定义了一个全局变量并给它赋了值,当程序执行到main时,这个全局变量已经有值了。程序的入口点才是程序真正的开始,入口点视平台不同而有不同,实际上它是一个程序的初始化和结束部分,往往是运行库的一部分,或者由你自己编写。PC机上的入口函数相比于嵌入式设备来说更加复杂,感兴趣可以《参考程序员的自我修养》一书,有详细介绍。一般来说一个典型的程序运行步骤大致如下:

--操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个函数。  
--入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等。  
--入口函数在完成初始化之后,调用main函数,正是开始执行程序主体部分  
--main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量的析构,堆销毁、关闭I/O等。然后进行系统调用结束进程  

  我们以一个简单的例子粗略的回答了第三个问题,如果还有疑惑的话,可以移步这里,有更加详细的介绍。如果大家认真看下来,可能会有个疑惑,文中从编译一直到链接,然后介绍了程序的执行流程,似乎少了点什么,不错,中间忽略了可执行文件是如何装载的。在嵌入式系统中,装载的过程就是将可执行文件烧写到芯片的过程,而在PC机上则是一个很复杂的过程,上面那本书中都有介绍。写的这些东西是我看完这本书之后再联系自己手头所做的事的一些小的体会,希望对不是很明白的朋友有所帮助,如果有地方写错了或者有歧义,麻烦大家告知我改正过来,免得误导其他人。