



上一节安装好了GNU编译工具,还设置了虚拟机与宿主机的共享文件夹,下面我们就小试牛刀,写一个简单的程序。需要明确的是本书所有的测试代码都是很简单的汇编程序,因为即使最简单的C语言代码(如:HelloWorld)经过编译后也会得到大量目标代码,而我们的目的是了解处理器内部的工作过程,所以大量的目标代码容易分散我们的精力,为此示例代码一律采用汇编,有的甚至只有几条指令,但也能说明问题。在Ubuntu中新建一个Document,文件名可以为Example.S,输入下面的代码,得到我们的第一个汇编程序。
通过注释可知上述代码非常简单,只是几个简单的寄存器操作指令,现在不懂没有关系,只需要知道程序执行最后使得寄存器r1、r2都为0x0A,借助这么一个简单的程序,足够我们了解编译、链接、模拟器执行和仿真的全部步骤了。
在终端中首先使用cd命令将路径调整到上述Example.S所在目录,然后使用如下指令编译代码。
上述指令得到目标代码Example.o。打开Example.o文件,可以发现其最初的4字节是:0x7F、0x45、0x4C和0x46。这说明Example.o是一个ELF文件。
图2.11 Example.o的开始部分
遇到什么学什么,这是笔者的一贯宗旨,下面就简单介绍一下ELF文件,读者如果对这不感兴趣或者希望尽快了解编译链接过程,可以跳过下面的分析,直接阅读2.2.3节。
ELF(Executable and Linkable Format)可执行链接格式,是UNIX系统实验室(USL)作为应用程序二进制数接口(ABI:Application Binary Interface)而开发和发布的。ELF目标文件有三种类型。
(1)可重定位(Relocatable)文件:保存着代码和适当的数据,用来和其他Object文件一起创建一个可执行文件或共享文件。
(2)可执行(Executable)文件:保存着一个用来执行的程序,该文件指出了如何来创建程序进程映象。
(3)共享目标文件:包含了在两种使用环境中链接的代码和数据。首先,链接器(ld)可以将它和其余可重定位文件和共享目标文件一起处理,生成另外一个目标文件(比如,编译器和链接器把*.o和*.so一起装配成一个*.exe文件)。其次,动态链接器(Dynamic Linker)可将它与某个可执行文件及其他共享目标文件组合在一起创建进程映像(比如,动态加载器把.exe程序和*.so加载进内存执行)。
无论何种类型的ELF文件,其结构都是相同的。ELF文件由4部分组成:ELF header、Program header table、Sections和Section header table。其最开始的部分就是ELF header,定义如下。
开始4字节是固定不变的:0x7F,紧接着是ELF三个字符的ASCII码,这4字节表明这个文件是一个ELF文件。此处以Example.o为例,介绍ELF header后面的字节含义,参考图2.11。
● e_type是01,表示是可重定位文件
● e_machine表示运行该程序需要的体系结构,此处为0x5C,就是OpenRISC
● e_version表示文件版本,此处是1
● e_entry表示程序的入口地址,此处是0x0
● e_phoff是Program header table在文件中的偏移量(以字节计数),此处是0x0
● e_shoff是Section header table在文件中的偏移量(以字节计数),此处为0x0178
● e_flags为0
● e_ehsize表示ELF header的大小,此处为0x34
● e_phentsize表示Program header table中每一个条目(一个Program header)的大小,此处为0x0
● e_phnum表示Program header table中有多少个条目,此处为0
● e_shentsize表示Section header table中每一个条目(一个Section header)的大小,此处为0x28
● e_shnum表示Section header table中有多少个条目,此处为0x07
● e_shstrndx保存着字符表相关入口的节区头部表索引,此处为0x04
通过上述解释可以了解到这个文件是一个可重定向文件(Relocatable),不是可执行文件,同时了解该文件包含的Program header table、Section header table信息。这里没有Program header table,按照给出的偏移信息,我们可以得到Section header table表的位置,通过Section header table得到每个Section的位置。
当然按照ELF header的内容及Section header table,我们可以按图索骥地分析所有Section,但是这样效率太慢,借助于GNU工具链中的or32-elf-readelf,我们可以直接得到Section信息,如图2.12所示。
图2.12 利用程序or32-elf-readelf可以得到所有的Section信息
注意添加“-S”参数。这里列出了7个Section的信息,注意其中的“.text”这个Section,它的起始地址是0x34,长度是0x118,列出这个Section的内容,如图2.13所示。
在这0x118字节中,前0x100字节都是0x00,接下来的24字节是什么呢?我们利用工具or32-elf-objdump对目标代码进行反汇编,得到指令与二进制数代码的对应关系,如图2.14所示。
图2.13 Section .text的内容
图2.14 使用Objdump查看反汇编结果
这里注意加上参数“-d”表示显示可执行Section的反汇编结果。显示出来的结果分为三栏,左边是指令执行时的地址,在程序中我们的第一条指令是从0x100开始的,中间一栏是对应的二进制数代码,右边一栏是对应的汇编指令,对比一下图2.13与图2.14,可以发现Section .text的最后24字节正是这6条汇编指令。
通过编译我们得到了一个可重定位的ELF文件,但这个文件还不能执行,需要通过链接转化为可执行文件,然后才能执行。使用or32-elf-ld完成这项工作,在or32-elf-ld的参数中需要声明一个链接描述脚本,链接描述脚本描述了输入文件的各个Section如何映射到输出文件的各Section中,并控制输出文件中Section和符号的内存布局。可以通过新建一个Document作为链接描述脚本,文件名为ram.ld,内容如下。
这里定义了一个存储块——ram,其起始地址是0x0,长度是0x5000,然后指示链接器输出文件包含三个Section,分别是.text、.data和.bss,其中.text从ram的起始地址开始存放,后面跟着.data、.bss,并且输入文件的Section .text存放在输出文件的.text中,输入文件的Section .data存放在输出文件的.data中,输入文件的Section .bss存放在输出文件的.bss中。最后的Entry指定程序的入口地址,也就是第一条执行指令的地址是_start符号的值,从汇编代码中可知_start符号就是0x100。现在就可以使用链接器了,在终端中输入如下命令。
得到链接后的文件Example.or32,这也是一个ELF格式的文件,其ELF header,如图2.15所示。
图2.15 Example.or32的ELF header
在分析Example.o的ELF header时我们是手工分析的,主要是为了便于读者理解,现在可以直接使用工具分析ELF header,在终端中输入如下命令。
这里加上参数“-h”表示只读取ELF header,得到结果如图2.16所示。
图2.16 Example.or32的ELF header信息
其中显示是一个可执行文件。读者可能注意到了,Example.or32比Example.o多了Program header,而这在Example.o里面是没有的,与Section header一样,Program header也可以使用一个结构体描述。
我们还是使用or32-elf-readelf从Example.or32中分析出一个Program header,然后结合这个Program header解释上面各个各项的含义。使用如下命令得到Program header的信息。
这里注意加上“-l”参数,表示列出Program header的信息,显示如图2.17所示。
图2.17 Program header信息
借助上图介绍Program header各个字段的含义。
● p_type为LOAD,表示可加载
● p_offset表示段的第一个字节在文件Example.or32中的偏移,此处为0x2000
● p_vaddr表示段的第一个字节在内存中地址,此处为0
● p_paddr为0,在物理地址定位有关联的系统中,该成员是为该段的物理地址而保留的
● p_filez表示段在文件中的长度,此处为0x118
● p_memsz表示段在内存中的长度,此处为0x118
● p_flags为RE,表示可读、可执行
● p_align为0x2000,根据此项确定段在文件及内存中如何对齐
该Program header表示将Example.or32的0x2000开始的0x118字节放置在内存的0x0处,打开Example.or32可以发现从0x2000开始的0x118字节的内容与Example.o中Section .text的内容一样,所以当这个Program Section加载入内存后,会使得内存的0x100处存放的就是我们的第一条指令,而Example.or32的入口地址正是0x100。
分析到这里,大家是不是对我们的编译、链接过程有了比之前更深的了解?其实这些与我们分析OR1200关系不大,但是有时候剖析也会上瘾,拿到什么都想拆开看看里面是什么,所以笔者就情不自禁地讲了这么大一段,读者如果没有这样的兴趣,那么只需要知道编译和链接的命令就可以了,很简单,重复如下。
为了得到可执行代码,我们需要输入两个指令:编译指令as和链接指令ld。还是有点麻烦,最好只输入一条指令就可以了,这需要用到Makefile文件。还是先给出Makefile文件,然后再作解释。在Example.S所在目录下新建一个Document,文件名为Makefile,内容如下。
这是一个很简单的Makefile,借助于它介绍Makefile的组成。Makefile的前半部分是对一些变量的定义,比如:定义CC为or32-elf-as,定义LD为or32-elf-ld,引用一个预定义的变量需要使用符号$。在文件的后半部分定义了多个目标,有all、clean等,采用的语法如下。
上述形式表示的意思是:(1)要想得到“目标”,那么需要执行“命令”;(2)“目标”依赖于“依赖文件”,当“依赖文件”中至少一个文件比“目标”文件新时,“命令”才被执行。在上面Makefile的“命令”中使用了Makefile一些预定义的变量,含义如下。
所以上述Makefile可以解读如下。
(1)用户输入make all,要求得到目标all,目标all的依赖文件是Example.or32,要先得到Example.or32
(2)要得到Example.or32,依赖文件$(OBJECTS),也就是Example.o
(3)要得到Example.o依赖于文件Example.S,这里已经提供了Example.S,满足依赖条件,然后通过执行命令$(CC) $< -o $@,实际就是or32-elf-as Example.S –o Example.o得到Example.o
(4)得到Example.o后就可以进一步得到Example.or32,通过执行命令$(LD) -T ram.ld$(OBJECTS) -o $@,实际就是or32-elf-ld -T ram.ld Example.o –o Example.or32得到Example.or32
(5)得到Example.or32,满足了目标all的依赖条件,从而实现目标all。
有了Makefile文件,我们在终端中输入“make all”就可以完成编译、链接的过程了。
急性子的读者一定早等得不耐烦了,别着急,现在就可以运行我们的第一个程序了,使用OR1KSim,这是一个OpenRISC架构的模拟器,已经在Ubuntu虚拟机中安装好了,在终端中输入如下命令。
参数“-t”表示跟踪每一条指令的执行,“Example.or32”表示要执行的文件,“-m1M”表示增加1M内存,“>Example.trace”表示跟踪信息输出到文件Example.trace文件中。上述命令执行完成后会自动结束,然后可以打开文件Example.trace,显示如下内容。
注意上面加粗显示的部分,一共有5行,每一行的内容依次是:当前处于特权模式还是用户模式、处理器要取的指令地址(即PC值)、指令的二进制数编码、指令的汇编代码、改变的寄存器、改变的寄存器的新值、flag标志位的值。我们以第1行为例,内容如下。
每执行一条指令PC加4,表示取下一条指令,程序执行最后r1为0xa,r2为0xa,满足预期。在源代码中还有一条空指令“l.nop 0x0001”,该指令只是告诉模拟器退出执行。这样通过OR1KSim模拟器就可以验证程序能不能得到预期结果。