步步惊“芯”
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

第2章 实验环境的搭建

“工欲善其事,必先利其器。”本章将带领读者一步一步建立起实验平台、准备好实验工具,有了平台和工具的帮助,可以更加方便地认识和理解OR1200的内部细节。平台和工具的作用有两个。

(1)得到可执行代码,并验证程序运行是否如预期

主要是GNU开发工具链的安装,有了GNU工具链提供的工具,我们可以在Linux环境下将汇编、C代码编译成OR1200平台的可执行代码,这就是交叉编译的过程。有了OR1200的可执行代码,就能够在OR1200模拟器OR1KSim中运行该可执行代码,观察其是否如预期那样执行。

(2)深入OR1200内部,探究为何会得到预期执行效果

得到可执行代码,然后在模拟器中运行,这只是一个验证输入输出的过程,我们的最终目的是要分析在得到这个预期结果的过程中OR1200内部的工作过程,所以还需要使用仿真工具。本书使用的是ModelSim,借助于仿真工具,我们可以观察到处理器内部在每一个时钟下各个信号的变化,从而了解OR1200是如何执行指令得到结果的。

2.1 GNU开发工具链的安装

在介绍GNU开发工具链之前简单说明交叉编译的概念。在嵌入式开发中,开发平台与运行平台往往是不同的,这是因为运行平台上一般都资源有限,不能够安装编译开发环境,因此需要在其他性能更好的平台上安装编译环境,比如我们常在x86的平台上开发OR1200、ARM的应用程序,但是x86与OR1200、ARM是不同的处理器体系结构,也就是处理器的指令集不同,那么就需要x86上的编译器编译出能够在OR1200、ARM架构下执行的目标代码,这就是交叉编译。图2.1显示x86平台与OR1200平台上“寄存器相与”指令对应不同的二进制数编码。

图2.1 x86平台与OR1200平台上“寄存器相与”指令的二进制数编码是不同的

因此我们在x86上开发程序的时候就需要针对不同的目标平台使用不同的编译工具,从而得到可以在目标平台上执行的二进制数代码。在这方面GNU工具链是一个很好的选择。GNU工具链(GNU Toolchain)是一组用于开发应用程序和操作系统的编程工具的集合,这些工具构成了一个完整的系统,最重要的是它支持多种目标平台,其中包括OR1200。

GNU工具链包括GCC、Glibc、GDB和GNU Binutils。

1.GCC

GCC(GNU Compiler Collection)主要的工具包括cpp、g++、gcc、gcov和gprof等。其中cpp是C预处理器,主要用于在编译C、C++或Objective-C源代码之前对它们进行预处理,由编译器自动调用。g++和gcc可以认为分别是C++和C编译器。

2.Glibc

Glibc(GNU libc)是GNU的C标准库,是移植GNU工具链时一个不可或缺的部分。Glibc主要由两部分组成,一部分是头文件,另一部分是库的二进制数文件,包括动态和静态两个版本。有了Glibc就可以使用C语言开发程序。

3.GDB

GDB(GNU symbolic debugger)是一个功能很强大的调试工具,可以让用户调试一个程序,包括让程序在希望的地方停下,此时,用户可以查看变量、寄存器、内存及堆栈,而且可以修改变量及内存值。

4.GNU Binutils

GNU Binutils(Binary Utilities)是一组二进制数工具集,用来编译、测试和分析软件。其中主要的两个工具是as和ld。as是GNU汇编器,通常也称为GAS(GNU Assembler),ld是GNU链接器,as对GCC的输出文件进行汇编产生目标文件,然后由ld链接目标文件、重定位数据产生可执行文件。此外,GNU Binutils还包括其他工具,内容如下。

(1)addr2line:用于将程序中的地址对应到文件名和相应的行号。给定一个地址和一个可执行文件,addr2line使用可执行文件中的调试信息来确定该地址所对应的文件和行号。

(2)ar:用于建立、修改和提取档案文件(archives)。档案文件经常被用做库文件,保存程序常用的函数过程。

(3)c++filt:C++和Java中都有重载函数的特性,编译器要区分重载函数,就需要对函数标识符进行编码转换成一个汇编级别的标签(Label),这个过程称为名字改编(Name Mangling)或名字修饰(Name Decoration)。而c++filt所执行的就是这个过程的逆过程。

(4)dlltool:用于创建Windows动态链接库。

(5)ld:是链接器,编译程序最后是调用ld,把目标文件和归档文件链接在一起,重定位数据并链接符号引用。

(6)gold:是一种比ld更快,但只针对ELF文件格式的链接器。

(7)nm:用于列出二进制数文件(包括库文件和可执行文件)中的符号,这些符号可以是函数、全局变量、静态变量等。

(8)objcopy:用于将一种格式的目标文件复制成另外一种格式。

(9)objdump:用于列出关于二进制数文件的各种信息。

(10)ranlib:用于为档案文件生成索引信息,这样可以提高档案文件的访问速度。

(11)readelf:类似于objdump,但是它只能处理ELF格式的文件,并且没有使用GNU BFD库。

(12)size:用于列出目标文件或者档案文件各段的大小。

(13)strings:用于列出目标文件中的可打印字符串。

(14)strip:用于移除目标文件中的符号,以减少程序文件的大小,这对于嵌入式系统比较有用。

GNU工具链可谓博大精深,功能全面,诸位读者不要惊慌,本书只是使用到了其中一部分工具(实际只有as、ld,因为测试代码都是用汇编写的),所以不懂GNU工具链没有关系,笔者会在使用到其中工具的时候做一个简单介绍,这就足够了。

针对目标平台OR1200的GNU工具链,有三种安装方法。

方法一:下载源文件,在Linux环境下自行编译。

方法二:下载编译好的针对OR1200处理器的GNU工具链,可在Linux环境下安装使用。

方法三:下载一个Ubuntu的虚拟机文件,里面安装好了针对OR1200处理器的GNU工具链。

方法一笔者试过,容易出问题,而且比较耗时,当然对GNU工具链有着强烈兴趣的朋友可以尝试。方法二笔者没用过。方法三简单直接,速度快,不易出错,而且基本都是Windows下的操作,适合新手,所以建议使用方法三,本书也只介绍方法三。

在浏览器中输入地址:ftp://openrisc.opencores.org/virtualbox-image/,FTP的用户名和密码都是openrisc,登录后会出现如图2.2所示的界面。

图2.2 Ubuntu虚拟机镜像下载

下载最新的那个文件就可以了,笔者使用的是2011-12-15版。下载完成后解压该文件,大约4GB左右。此时还需要下载VisualBox以打开该文件。VisualBox是一款开源的虚拟机软件,本书使用的是4.1.22版。下载完成后安装VisualBox,安装完成后打开VisualBox,界面如图2.3所示。

图2.3 VisualBox主界面

单击“新建”出现“新建虚拟机”向导,单击“下一步”,出现如图2.4所示的界面。

图2.4 新建虚拟机设置一

此处操作系统选择Linux,版本选择Ubuntu,单击下一步,设置内存大小,如图2.5所示。

图2.5 新建虚拟机设置二

内存大小依据个人情况设置,笔者设置的是512M,已经够用了,毕竟我们需要编译的程序都是十分简单的,单击下一步,选择“使用现有的虚拟硬盘”,然后选择解压后的虚拟机文件。

图2.6 新建虚拟机设置三

单击“下一步”,VisualBox会将用户刚才的设置都列出来,确认无误后,单击“创建”,这样虚拟机就创建好了。启动虚拟机,显示如图2.7所示。

图2.7 Ubuntu虚拟机桌面

双击左边的“终端”图标,就可以打开终端,在终端中输入or32-elf-,然后按两次Tab键,会列出虚拟机中已安装的针对OR1200平台的所有编译工具,如图2.8所示。

图2.8 Ubuntu中已安装的编译工具

可见之前介绍过的GNU工具链中的gcc、as、ld、gdb等都包括在内。这样GNU工具链就安装完成了。Ubuntu虚拟机提供的功能除了GNU工具链,还包括OR1K模拟器、用于硬件调试的OR_DEBUG_PROXY、OpenRISC平台参考SoC设计ORPSoCv2,其中在本书的研究过程中需要用到OR1K模拟器——OR1KSim,2.2.5节会介绍其使用方法。

最后,因为宿主机是Windows平台,而且在后面仿真时使用的ModelSim也是Windows平台的,为了方便文件的传递,这里需要设置虚拟机与宿主机的文件共享。打开VisualBox中虚拟机的设置界面,选择“共享文件夹”,如图2.9所示。

图2.9 虚拟机与宿主机共享文件夹设置步骤一

单击界面右边的添加文件夹按钮,出现如图2.10所示界面。

图2.10 虚拟机与宿主机共享文件夹设置步骤二

在其中选择共享文件夹的路径,设置名称,参考如上设置,然后启动虚拟机,打开终端,输入命令:

sudo mount –t vboxsf UbuntuShareFolder /mnt/sharefolder

该命令的作用是将共享文件夹挂载在/mnt/sharefolder目录下,sudo表示以Root用户身份执行该命令,终端会提示输入密码,Ubuntu虚拟机默认Root用户的密码是openrisc。这样就实现了虚拟机与宿主机的文件共享,对虚拟机而言共享文件放在/mnt/sharefolder路径下,对宿主机而言共享文件放在图2.10所示的E盘UbuntuShareFolder文件夹下。

2.2 GNU开发工具链的使用

2.2.1 一个简单的汇编程序

上一节安装好了GNU编译工具,还设置了虚拟机与宿主机的共享文件夹,下面我们就小试牛刀,写一个简单的程序。需要明确的是本书所有的测试代码都是很简单的汇编程序,因为即使最简单的C语言代码(如:HelloWorld)经过编译后也会得到大量目标代码,而我们的目的是了解处理器内部的工作过程,所以大量的目标代码容易分散我们的精力,为此示例代码一律采用汇编,有的甚至只有几条指令,但也能说明问题。在Ubuntu中新建一个Document,文件名可以为Example.S,输入下面的代码,得到我们的第一个汇编程序。

    .section .text,"ax"  #定义了一个Section,Section名为.text,并且是可执行
                             #Section
    .org 0x100           #OR1200处理器默认从地址0x100处开始执行,所以我们的代码从
                             #0x100开始
.global _start
_start:
    l.andi r0,r0,0       #OR1200的r0寄存器始终为0,所以这里将其设置为0
    l.extwz r1,r0        #下面两条指令是初始化寄存器r1、r2,使其都为0
    l.extwz r2,r0
    l.addi r1,r1,0x0A    #r1的值加上0x0A存储到r1中,使得r1为0x0A
    l.add r2,r2,r1       #r1的值加上r2的值存储到r2中,使得r2为0x0A
    l.nop 0x0001         #空指令,但对模拟器有特殊含义

通过注释可知上述代码非常简单,只是几个简单的寄存器操作指令,现在不懂没有关系,只需要知道程序执行最后使得寄存器r1、r2都为0x0A,借助这么一个简单的程序,足够我们了解编译、链接、模拟器执行和仿真的全部步骤了。

2.2.2 编译及ELF文件介绍

在终端中首先使用cd命令将路径调整到上述Example.S所在目录,然后使用如下指令编译代码。

or32-elf-as Example.S –o Example.o

上述指令得到目标代码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,定义如下。

#define EI_NIDENT 16
typedef struct{
    unsigned char    e_ident[EI_NIDENT];
    Elf32_Half       e_type;          //Elf32_Half表示2字节大小
    Elf32_Half       e_machine;
    Elf32_word       e_version;       //Elf32_Word表示4字节大小
    Elf32_Addr       e_entry;         //Elf32_addr也表示4字节大小
    Elf32_Off        e_phoff;         //Elf32_Off也表示4字节大小
    Elf32_Off        e_shoff;
    Elf32_Word       e_flags;
    Elf32_Half       e_ehsize;
    Elf32_Half       e_phentsize;
    Elf32_Half       e_phnum;
    Elf32_Half       e_shentsize;
    Elf32_Half       e_shnum;
    Elf32_Half       e_shstrndx;
}Elf32_Ehdr;

开始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所示。

图2.13 Section.text的内容

在这0x118字节中,前0x100字节都是0x00,接下来的24字节是什么呢?我们利用工具or32-elf-objdump对目标代码进行反汇编,得到指令与二进制数代码的对应关系,如图2.14所示。

图2.14 使用Objdump查看反汇编结果

这里注意加上参数“-d”表示显示可执行Section的反汇编结果。显示出来的结果分为三栏,左边是指令执行时的地址,在程序中我们的第一条指令是从0x100 开始的,中间一栏是对应的二进制数代码,右边一栏是对应的汇编指令,对比一下图2.13 与图2.14,可以发现Section .text的最后24字节正是这6条汇编指令。

2.2.3 链接

通过编译我们得到了一个可重定位的ELF文件,但这个文件还不能执行,需要通过链接转化为可执行文件,然后才能执行。使用or32-elf-ld完成这项工作,在or32-elf-ld的参数中需要声明一个链接描述脚本,链接描述脚本描述了输入文件的各个Section如何映射到输出文件的各Section中,并控制输出文件中Section和符号的内存布局。可以通过新建一个Document作为链接描述脚本,文件名为ram.ld,内容如下。

MEMORY
      {
      ram   : ORIGIN = 0x00000000, LENGTH = 0x00005000
      }
SECTIONS
{
    .text :
      {
      *(.text)
      } > ram
      .data :
      {
      *(.data)
      } > ram
      .bss :
      {
      *(.bss)
      } > ram
}
Entry(_start)

这里定义了一个存储块——ram,其起始地址是0x0,长度是0x5000,然后指示链接器输出文件包含三个Section,分别是.text、.data和.bss,其中.text从ram的起始地址开始存放,后面跟着.data、.bss,并且输入文件的Section .text存放在输出文件的.text中,输入文件的Section .data存放在输出文件的.data中,输入文件的Section .bss存放在输出文件的.bss中。最后的Entry指定程序的入口地址,也就是第一条执行指令的地址是_start符号的值,从汇编代码中可知_start符号就是0x100。现在就可以使用链接器了,在终端中输入如下命令。

or32-elf-ld –T ram.ld Example.o –o Example.or32

得到链接后的文件Example.or32,这也是一个ELF格式的文件,其ELF header,如图2.15所示。

图2.15 Example.or32的ELF header

在分析Example.o的ELF header时我们是手工分析的,主要是为了便于读者理解,现在可以直接使用工具分析ELF header,在终端中输入如下命令。

or32-elf-readelf –h Example.or32

这里加上参数“-h”表示只读取ELF header,得到结果如图2.16所示。

图2.16 Example.or32的ELF header信息

其中显示是一个可执行文件。读者可能注意到了,Example.or32比Example.o多了Program header,而这在Example.o里面是没有的,与Section header一样,Program header也可以使用一个结构体描述。

typedef struct{
      Elf32_Word    p_type;
      Elf32_Off     p_offset;
      Elf32_Addr    p_vaddr;
      Elf32_Addr    p_paddr;
      Elf32_Word    p_filez;
      Elf32_Word    p_memsz;
      Elf32_Word    p_flags;
      Elf32_Word    p_align;
}Elf32_Phdr;

我们还是使用or32-elf-readelf从Example.or32中分析出一个Program header,然后结合这个Program header解释上面各个各项的含义。使用如下命令得到Program header的信息。

or32-elf-readelf –l Example.or32

这里注意加上“-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关系不大,但是有时候剖析也会上瘾,拿到什么都想拆开看看里面是什么,所以笔者就情不自禁地讲了这么大一段,读者如果没有这样的兴趣,那么只需要知道编译和链接的命令就可以了,很简单,重复如下。

编译:or32-elf-as Example.S –o Example.o
链接:or32-elf-ld –T ram.ld Example.o –o Example.or32

2.2.4 Makefile文件

为了得到可执行代码,我们需要输入两个指令:编译指令as和链接指令ld。还是有点麻烦,最好只输入一条指令就可以了,这需要用到Makefile文件。还是先给出Makefile文件,然后再作解释。在Example.S所在目录下新建一个Document,文件名为Makefile,内容如下。

ifndef CROSS_COMPILE
  CROSS_COMPILE = or32-elf-
endif
CC = $(CROSS_COMPILE)as
LD = $(CROSS_COMPILE)ld
OBJECTS = Example.o
export  CROSS_COMPILE
# ********************
# Rules of Compilation
# ********************
all: Example.or32
%.o: %.S
    $(CC) $< -o $@
Example.or32: ram.ld $(OBJECTS)
    $(LD) -T ram.ld $(OBJECTS) -o $@
clean:
    rm -f *.o *.or32

这是一个很简单的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”就可以完成编译、链接的过程了。

2.2.5 使用OR1KSim模拟器运行程序

急性子的读者一定早等得不耐烦了,别着急,现在就可以运行我们的第一个程序了,使用OR1KSim,这是一个OpenRISC架构的模拟器,已经在Ubuntu虚拟机中安装好了,在终端中输入如下命令。

sim –t Example.or32 –m1M > Example.trace

参数“-t”表示跟踪每一条指令的执行,“Example.or32”表示要执行的文件,“-m1M”表示增加1M内存,“>Example.trace”表示跟踪信息输出到文件Example.trace文件中。上述命令执行完成后会自动结束,然后可以打开文件Example.trace,显示如下内容。

Seeding random generator with value 0x07204ad8
Or1ksim 2011-08-15
Building automata... done, num uncovered: 0/213.
Parsing operands data... done.
Resetting PIC.
loadcode:      filename      Example.or32            startaddr=00000000
virtphy_transl=00000000
Not COFF file format
ELF type: 0x0002
ELF machine: 0x005c
ELF version: 0x00000001
ELF sec = 5
Section: .text, vaddr: 0x00000000, paddr: 0x0 offset: 0x00002000, size:
0x00000118
S 00000100: a4000000 l.andi r0,r0,0 r0 = 00000000 flag: 0
S 00000104: e020004d l.extwz r1,r0 r1 = 00000000 flag: 0
S 00000108: e040004d l.extwz r2,r0 r2 = 00000000 flag: 0
S 0000010c: 9c21000a l.addi r1,r1,0xa r1 = 0000000a flag: 0
S 00000110: e0420800 l.add r2,r2,r1 r2 = 0000000a flag: 0
exit(0)
@reset : cycles 0, insn #0
@exit  : cycles 5, insn #6
diff   : cycles 5, insn #6

注意上面加粗显示的部分,一共有5行,每一行的内容依次是:当前处于特权模式还是用户模式、处理器要取的指令地址(即PC值)、指令的二进制数编码、指令的汇编代码、改变的寄存器、改变的寄存器的新值、flag标志位的值。我们以第1行为例,内容如下。

S:                表示出于特权模式
PC值:             0x00000100
指令的二进制数编码:  0xa4000000
指令的汇编代码:     l.addi r0,r0,0
目的地址:          r0
目的地址的新值:     0x00000000
flag的值:         0x0

每执行一条指令PC加4,表示取下一条指令,程序执行最后r1为0xa,r2为0xa,满足预期。在源代码中还有一条空指令“l.nop 0x0001”,该指令只是告诉模拟器退出执行。这样通过OR1KSim模拟器就可以验证程序能不能得到预期结果。

2.3 创建OR1200运行的最小系统

在2.2节中使用GNU工具链可以得到可执行文件,然后在模拟器中运行这个可执行文件,并记录指令执行的信息到文件中,通过分析这个文件可以判断程序是否是按照预期那样执行。但这只是一个软件的模拟过程,用于前期的验证,对于剖析OR1200内部结构的作用并不大。为了剖析OR1200内部结构,我们还需要借助硬件仿真工具ModelSim,本节设计了一个OR1200可以运行的最小系统,并通过ModelSim仿真,观察OR1200内部执行细节。

2.3.1 最小系统的创建

本节设计的最小系统只使用了OR1200的CPU、QMEM两个模块,没有使用MMU、Cache及总线单元等其他模块,程序和数据都存储在QMEM中,CPU复位后从QMEM中读取指令,这样一个简单纯粹的系统使我们将注意力集中在CPU上,不用考虑其他模块的影响。最小系统的结构如图2.18所示。

图2.18 OR1200可以运行的最小系统结构

在第1章中已经介绍了下载地址,使用SVN从http://opencores.org/ocsvn/openrisc/openrisc这个地址CheckOut最新的代码。本书将以OR1200的rel3这个版本为例进行分析,所以进入/branches/or1200_rel3/rtl/verilog目录,可以找到所有的verilog设计文件。

1.新建工程mim_or1200

打开ModelSim,本书使用的是Windows环境下ASE(Altera Starter Edition)6.6d版。选择“File->New->Project”,出现如图2.19所示对话框。

图2.19 ModelSim新建Project

给新的工程命名为min_or1200,选择存储路径,注意不要包含中文,将Default Library Name也改为min_or1200,单击OK,出现如图2.20所示界面。

图2.20 ModelSim新建或添加已有文件对话框

选择“Add Existing File”表示添加已存在的文件,在出现的对话框中单击“Browse”按钮将/branches/or1200_rel3/rtl/verilog目录下的所有文件都选中添加,同时选择下面的“Copy to project directory”,单击OK,这样OR1200所有的Verilog文件就都添加到工程min_or1200中了。

图2.21 ModelSim中为min_or1200工程添加文件对话框

2.新建测试平台(Test Bench)

新建一个Verilog文件添加到工程中,文件名为or1200_tb.v,这是一个简单的测试平台(Test Bench)文件,内容如下。

`timescale 1ns/100ps
module or1200_tb();
    reg     CLOCK_50;
    reg     rst;
    initial begin
            CLOCK_50 = 1'b0;          //时钟20ns一个周期,所以时钟频率是50MHz
            forever #10 CLOCK_50 = ~CLOCK_50;
    end
    initial begin
            rst = 1'b1;               //复位信号
            #200 rst= 1'b0;           //在200ns处复位结束
            #1000 $stop;              //仿真过程持续1000ns
    end
    or1200_top    or1200_top_inst     //因为是最小系统,所以除了时钟、复位信号外,其
                                      //余全都为0
    (
      .clk_i(CLOCK_50),
      .rst_i(rst),
      .pic_ints_i(20'b0),
      .clmode_i(2'b00),
      // 指令Wishbone总线接口
      .iwb_clk_i(clk_i),     .iwb_rst_i(rst),     .iwb_dat_i(32'b0),
      .iwb_ack_i(1'b0),      .iwb_err_i(1'b0),    .iwb_rty_i(1'b0),
      .iwb_cyc_o(),          .iwb_adr_o(),        .iwb_dat_o(),
      .iwb_stb_o(),          .iwb_we_o(),         .iwb_sel_o(),
    `ifdef OR1200_WB_CAB
      .iwb_cab_o(),
    `endif
      // 数据Wishbone总线接口
      .dwb_clk_i(clk_i),     .dwb_rst_i(rst),     .dwb_dat_i(32'b0),
      .dwb_ack_i(1'b0),      .dwb_err_i(1'b0),    .dwb_rty_i(1'b0),
      .dwb_cyc_o(),          .dwb_adr_o(),        .dwb_dat_o(),
      .dwb_stb_o(),          .dwb_we_o(),         .dwb_sel_o(),
    `ifdef OR1200_WB_CAB
      .dwb_cab_o(),
    `endif
      // 外部调试接口
      .dbg_stall_i(1'b0),    .dbg_ewt_i(1'b0),    .dbg_lss_o(),
      .dbg_is_o(),           .dbg_wp_o(),         .dbg_bp_o(),
      .dbg_stb_i(1'b0),      .dbg_we_i(1'b0),     .dbg_adr_i(0),
      .dbg_dat_i(0),         .dbg_dat_o(),        .dbg_ack_o(),
      // 电源管理接口
      .pm_cpustall_i(0),    .pm_clksd_o(),       .pm_dc_gate_o(),
      .pm_ic_gate_o(),      .pm_dmmu_gate_o(),   .pm_immu_gate_o(),
      .pm_tt_gate_o(),      .pm_cpu_gate_o(),    .pm_wakeup_o(),
      .pm_lvolt_o()
    );
endmodule

OR1200有很多外部接口,从上面可以知道有指令Wishbone总线接口、数据Wishbone总线接口、外部调试接口和电源管理接口等,但是由于我们是最小系统,所以整个OR1200只有时钟、复位信号有效,其余接口的输入信号都直接设置为0。

3.修改OR1200配置

打开文件or1200_defines.v,这是OR1200的配置文件,其中定义了很多宏定义,可以通过注释、取消注释来修改OR1200的配置。因为最小系统没有数据缓存(DCache)、指令缓存(ICache)、数据MMU(DMMU)、指令MMU(IMMU),所以需要下面的宏定义,这些宏定义默认是被注释掉的,取消宏定义前的注释符。

`define OR1200_NO_DC             //表示不需要数据Cache
`define OR1200_NO_IC             //表示不需要指令Cache
`define OR1200_NO_DMMU           //表示不需要数据MMU
`define OR1200_NO_IMMU           //表示不需要指令MMU

另外,还要给下面的宏定义加上注释符,最小系统中不使用调试单元(DU:Debug Unit)、可编程中断控制器(PIC:Programmable Interrupt Controller)、定时器单元(TT:Tick Timer)。

//`define OR1200_DU_IMPLEMENTED  //注释掉这个宏定义表示不使用调试单元
//`define OR1200_PIC_IMPLEMENTED //注释掉这个宏定义表示不使用可编程中断控制器
//`define OR1200_TT_IMPLEMENTED  //注释掉这个宏定义表示不使用定时器单元

最后要修改or1200_defines.v中关于QMEM的设置,如下。

`define OR1200_QMEM_IMPLEMENTED //默认是没有Implement QMEM,此处需要取消掉注释符

4.修改or1200_qmem.v文件

最小系统设计将指令存放在QMEM中,为了使用QMEM存储指令,还需要修改QMEM的代码,打开or1200_qmem_top.v,找到如下代码。

`ifdef OR1200_QMEM_IADDR
    assign iaddr_qmem_hit = (qmemimmu_adr_i & `OR1200_QMEM_IMASK) == `OR1200_QMEM_IADDR;
`else
    assign iaddr_qmem_hit = 1'b0;
`endif
`ifdef OR1200_QMEM_DADDR
    assign daddr_qmem_hit = (qmemdmmu_adr_i & `OR1200_QMEM_DMASK) == `OR1200_QMEM_DADDR;
`else
    assign daddr_qmem_hit = 1'b0;
`endif

修改为:

`ifdef OR1200_QMEM_IADDR
    assign iaddr_qmem_hit = 1'b1;
`else
    assign iaddr_qmem_hit = 1'b0;
`endif
`ifdef OR1200_QMEM_DADDR
    assign daddr_qmem_hit = 1'b1;
`else
    assign daddr_qmem_hit = 1'b0;
`endif

至于为什么作上述修改,笔者会在QMEM分析的时候再解释,此处读者只需要明白经过上面的修改,OR1200将从QMEM中读取指令、加载存储数据。

这样我们的最小系统就创建结束了,在ModelSim中选择“Compile ALL”会编译整个工程。

2.3.2 运行仿真

上一节创建了最小系统,但此时还不可以仿真,因为还没有将程序存入QMEM,这个步骤可以称为QMEM初始化。修改文件or1200_spram_2048x32.v,在第611行,添加如下代码。

initial $readmemh ( "mem.data", mem );

表示从mem.data中读取数据初始化mem,而这个mem正是QMEM的存储空间,mem.data是一个文本文件,里面存储的是指令,其每行存储一个32位的数据(使用十六进制表示),readmemh函数会将mem.dada中的数据依次填写到mem中。

有的读者可能会认为直接使用在2.2.4节得到的Example.or32作为mem.data的内容,就可以使用上述语句初始化QMEM了,这个想法是不对的。Example.or32是ELF格式的文件,需要一个操作系统,或者一个Loader来解释该文件,并按照文件的要求将其代码存放到合适的内存地址,然后CPU跳转到该地址执行。但此时我们的最小系统是一个裸机,也就是说当我们加电复位的时候CPU只知道从0x100处读入指令开始执行,并不会理解什么ELF格式,所以我们需要自己初始化内存,也就是将ELF文件中的可执行代码从0x100处开始存放。使用2.2.1节中程序初始化QMEM,那么mem.data的内容应该如下。

00000000
00000000
……                        //这里一共有0x40个00000000,因为0x100是字节地址,
                          //这里每一行是4字节,
                          //所以一共有(0x100/4)=0x40行00000000
00000000
a4000000                  //在第0x41行存储的是第一条指令
e020004d
e040004d
9c21000a
e0420800
15000001

为了快点看到仿真结果,先按照上面的说明手工创建一个mem.data文件,稍后再介绍如何编程得到mem.data。将新建的mem.data文件复制到min_or1200工程的根目录下,然后单击Simulate图标,出现如图2.22所示的对话框,选择min_or1200下的or1200_tb。

在出现的sim选项卡中,分别在下面4个信号上单击鼠标右键,选择Add->Add To Wave,

/or1200_tb/CLOCK_50
/or1200_tb/or1200_top_inst/or1200_cpu/or1200_ctrl/ex_insn
/or1200_tb/or1200_top_inst/or1200_cpu/or1200_rf/rf_b/mem[1]
/or1200_tb/or1200_top_inst/or1200_cpu/or1200_rf/rf_b/mem[2]

图2.22 Simulate选择对话框

这4个信号分别是时钟、执行指令、寄存器r1、寄存器r2,然后选择菜单Simulate->Run->Run-All开始仿真,在or1200_tb.v中设置仿真时间为1000ns,所以会很快结束,显示波形如图2.23所示。

图2.23 ModelSim仿真结果

仿真结果显示,当指令“0x9c21000a”执行结束后r1 变为0xa,这条指令正是“l.addi r1,r1,0xa”;当指令“0xe0420800”执行结束后r2变为0xa,这条指令正是“l.add r2,r2,r1”,与模拟器执行的效果是一样的。读者可以添加其余感兴趣的信号,观察其在执行过程中的变化。本书光盘的Chapter2目录下包括ModelSim仿真工程,Chapter2/Code目录下包括示例程序源代码。

2.3.3 修改Makefile

上一节中我们手工制作了一个mem.data文件来初始化QMEM,这种做法在代码量很大或者ELF文件稍微复杂一点的情况下就会产生问题,因此需要一个更好的办法。

在GNU工具链中提供了一个工具or32-elf-objcopy,用于将一种格式的目标文件复制成另外一种格式。因此可以先使用or32-elf-objcopy得到Example.or32的二进制数(Binary)的形式,然后编写一个小程序将二进制数的形式的代码按照ModelSim中存储器初始化文件的格式保存。这个小程序很简单,此处不再列出代码,在本书附带的光盘中可以找到源程序,程序名为Bin2Mem.exe,其使用方法如下。

./Bin2Mem.exe –f mem.bin –o mem.data

其中mem.bin是通过or32-elf-objcopy得到的Binary文件,mem.data是输出文件。这样从ELF文件得到ModelSim中使用的存储器初始化文件的步骤如下。

(1)生成Binary文件:or32-elf-objcopy –O binary Example.or32 mem.bin
(2)格式转化:     ./Bin2Mem.exe –f mem.bin –o mem.data

好了,现在回忆一下从源代码得到ModelSim中可以使用的QMEM存储器初始化文件一共需要4步:编译、链接、得到Binary文件和格式转化,每写一个程序或者修改一个程序都需要上面的4步过程,读者一定想到了可以使用Makefile简化操作,是的,正是需要借助于Makefile,修改Makefile的方法如下。

ifndef CROSS_COMPILE
      CROSS_COMPILE = or32-elf-
endif
CC = $(CROSS_COMPILE)as
LD = $(CROSS_COMPILE)ld
OBJCOPY = $(CROSS_COMPILE)objcopy
OBJECTS = Example.o
export  CROSS_COMPILE
# ********************
# Rules of Compilation
# ********************
all: Example.or32 Example.trace mem.data
%.o: %.S
        $(CC) $< -o $@
Example.or32: ram.ld $(OBJECTS)
        $(LD) -T ram.ld $(OBJECTS) -o $@
mem.bin: Example.or32
        $(OBJCOPY) -O binary $<  $@
mem.data:mem.bin
        ./Bin2Mem.exe -f $< -o $@
Example.trace: Example.or32
        sim -t $< -m1M > $@
clean:
        rm -f *.o *.or32 *.bin *.data *.trace

有了前面的基础相信这个Makefile很好理解,笔者就不再解释了,需要注意的是这里将模拟器执行也加入到Makefile中了,这样使用Makefile可以同时得到模拟器执行结果。

好了,现在是万事俱备啊,简单总结一下我们的实验步骤。

(1)编写源代码,当然是汇编代码,文件名为Example.S。

(2)复制我们上面的Makefile、Bin2Mem.exe、ram.ld到源代码所在目录。

(3)打开终端,路径调整到源代码所在目录,输入“make all”。

就这么容易,我们得到了OR1KSim模拟器的执行结果Example.trace、得到了可以在ModelSim中使用的存储器初始化文件mem.data。使用前者可以查看程序的执行是否有预期效果,使用后者可以在ModelSim中仿真硬件执行效果,查看需要观察的信号在每个时钟周期的变化情况。

2.3.4 观察流水线

上文提及之所以要使用ModelSim就是为了深入探究OR1200内部运行原理,下面我们就来做一个实验。程序代码还是不变,所以mem.data也是不变的,只是在ModelSim仿真的时候多观察几个信号,我们观察如下信号。

/or1200_tb/CLOCK_50
/or1200_tb/or1200_top_inst/or1200_cpu/or1200_ctrl/if_insn
/or1200_tb/or1200_top_inst/or1200_cpu/or1200_ctrl/id_insn
/or1200_tb/or1200_top_inst/or1200_cpu/or1200_ctrl/ex_insn

在ModelSim中选择菜单Simulate->Run->Restart,将会重新开始仿真,然后单击Run-All按钮,开始仿真,步骤如图2.24、2.25所示。

图2.24 单击Restart重新开始仿真

图2.25 单击Run-All开始仿真

会出现如图2.26所示仿真波形。

图2.26 观察流水线之一

请各位读者仔细看,看到了什么?

没错,这就是传说中的流水线。or1200_ctrl这个模块中的三个变量if_insn、id_insn和ex_insn分别表示当前取到的指令、正在译码的指令和正在执行的指令,图2.27会更加清楚。

从上图可以直观地观察到随着时钟的前进,每一条指令都依次成为当前取到的指令、正在译码的指令和正在执行的指令,换句话说每一条指令都依次经过取指阶段、译码阶段和执行阶段,并且译码的同时,上一条指令在执行,下一条指令被取到。这就是流水线!

图2.27 观察流水线之二

通过这个小实验我们将计算机教科书上抽象的流水线变得具体,也给我们带了很大的乐趣,随着后续分析工作的展开,我们会看到更多处理器内部的工作原理,相信它会给我们带来更大的乐趣。不过在此之前先给大家介绍一下流水线的有关知识,熟悉流水线的读者可以直接跳过下一节。

2.3.5 流水线介绍

关于计算机中的流水线,首先听一听维基百科中对计算机流水线的定义:流水线是指将计算机指令处理过程拆分为多个步骤,并通过多个硬件处理单元并行执行来加快指令执行速度。此处有两个关键词:(1)拆分;(2)并行。指令的处理从直观上分析至少可以拆分为三步:从存储器取出指令、解释指令、按照解释的结果执行,简单的说就是:取指、译码、执行。如果我们只有一个硬件处理单元,这个单元既要取指,又要译码,还要执行,假设上述三种操作的完成都需要时间T,那么一条指令的处理时间为3T,n条指令的处理时间就为3nT,但是如果我们设计有三个硬件单元,分别做这三项工作的一项,那么就可以在执行的同时对下一条指令译码,在对下一条指令译码的同时还可以再取一条指令,这就是经典的三级流水线,如图2.28所示。

图2.28 三级流水线示意图

从图中可知在三级流水线上执行3条指令所需时间为5T,而如果没有使用流水线则需要9T,流水线确实加快了指令的执行。ARM7采用的就是三级流水线。但事情没有这么简单,上面假设取指、译码、执行需要的时间都是T,实际并非如此,比如取指的时间就可能很长,假设取指需要时间为2T,那么如图2.29所示。

图2.29 取指时间为2T时的流水线示意图

可见在3T~4T的时间段、5T~6T的时间段流水线在等待取指结束,此时译码阶段、执行阶段都停滞,这样一来自然就慢下来,最后执行3条指令所需时间为8T。解决取指时间过长的措施是引入缓存(Cache),处理器从缓存读取指令的时间只需要1个时钟周期,读者可能注意到在OR1200运行的最小系统中并没有使用缓存,但从图2.27中观察发现流水线是一个时钟前进一步,取指也只需要1 个时钟周期,是的,最小系统中确实没有使用缓存,但是使用了QMEM。读者目前可以认为QMEM是OR1200的片上存储器,不经过输入输出总线,所以读取速度很快,可以在1个时钟周期中读取到指令,最小系统使用QMEM的目的也是为了尽量简化问题,不引入过多需要考虑的因素,从而关注流水线本身的运行情况。

还有一种情况是执行阶段时间过长,比如指令为加载存储指令(Load/Store)时,由于涉及访问存储器,所需执行阶段的时间就可能大于T,此时也会导致流水线停滞。为了解决这种情况下的流水线停滞问题,引入了五级流水线,分别是:取指、译码、执行、访存和回写。

图2.30 五级流水线示意图

其中访存(Memory Access)的作用是从存储器装载数据到寄存器或者将寄存器数据存储到存储器,当然如果不是Load/Store指令则不需要这一步,此时在访存阶段就只是将执行阶段的运算结果送到下一级回写阶段。回写阶段(Write Back)的作用是将数据写入目的寄存器。ARM9就采用了这种五级流水线,OR1200也声称是五级流水线,但是通过第9章对加载存储类指令的剖析,可以明白OR1200实际只是三级流水线,本书也是按照三级流水线来分析OR1200中指令处理过程的。

从没有流水线到三级流水线再到五级流水线,我们可以发现流水线实际就是将一项工作分为若干个子工作,达到并行执行这些子工作的目的,那么流水线是不是越多越好呢?当然不是,首先是成本问题;其次会加大“流水线相关”这一问题的发生概率,流水线相关的问题我们会在第4 章结合指令分析讲解;还有一个原因,如果执行转移指令,但是要转移目的地址不是在流水线中的指令,那么此时就会清空流水线,然后读取目的地址的指令进入流水线,这就会带来延迟,在第7章分析指令l.sys时会有更加深刻的体会;最后,流水线越多,编译程序的复杂度也会相应增加。所以流水线并不是越多越好。

2.4 本书的一些说明及定义

2.4.1 一些说明

第3~9章都是采用本章建立的最小系统,在其上运行一些简单的示例程序,因为在这些章节我们的剖析目标是OR1200 中的CPU、QMEM模块,最小系统足够满足需求,在第10章剖析MMU时也只需要在OR1200的配置文件中使能MMU,即在最小系统添加上MMU模块。之后在剖析Cache、Store Buffer(SB)、Wishbone总线接口单元时会设计一个简单的基于OR1200的片上系统,在此基础上再进行实验。

2.4.2 一些定义

在第1章介绍本书的分析方法时提到本书采用的是流水线驱动的分析方法,对单条指令进行分析,紧密结合流水线的工作过程,分析在流水线各个阶段指令需要完成的工作及引起的信号变化。流水线的每个阶段至少是一个时钟周期,如图2.31所示。

图2.31 流水线驱动下指令的状态变化

在这一个时钟周期中,有的信号变化是通过组合逻辑电路引起的,有的是在时钟边沿引起的,需要加以区分,因此定义以下名词,分别对应图2.31中用虚线分隔开的6个时间段的输出。

(1)取指阶段的组合逻辑输出

取指阶段的组合逻辑输出指的是被分析指令处在流水线的取指阶段时,OR1200中由于组合逻辑电路引起的信号变化。

(2)取指阶段的时序逻辑输出

取指阶段的时序逻辑输出指的是被分析指令从流水线的取指阶段进入译码阶段时,OR1200中由于时序逻辑电路引起的信号变化。

(3)译码阶段的组合逻辑输出

译码阶段的组合逻辑输出指的是被分析指令处在流水线的译码阶段时,OR1200中由于组合逻辑电路引起的信号变化。

(4)译码阶段的时序逻辑输出

译码阶段的时序逻辑输出指的是被分析指令从流水线的译码阶段进入执行阶段时,OR1200中由于时序逻辑电路引起的信号变化。

(5)执行阶段的组合逻辑输出

执行阶段的组合逻辑输出指的是被分析指令处在流水线的执行阶段时,OR1200中由于组合逻辑电路引起的信号变化。

(6)执行阶段的时序逻辑输出

执行阶段的时序逻辑输出指的是被分析指令在流水线的执行阶段结束时,OR1200中由于时序逻辑电路引起的信号变化。

从图2.27 中可以发现在最小系统中指令会在每一个时钟周期进入到流水线的下一个阶段,也就是流水线的一个阶段就是一个时钟周期,但有时指令在流水线某一个阶段可能需要超过一个时钟周期的时间,如图2.32所示。

图2.32 流水线执行阶段需要两个时钟周期

指令在流水线执行阶段需要两个时钟周期,相应有如下定义分别对应上图中用虚线隔开的时间段的输出。

(7)执行阶段第1个时钟周期的组合逻辑输出

该输出指的是被分析指令处在流水线执行阶段的第1 个时钟周期时,OR1200 中由于组合逻辑电路引起的信号变化。

(8)执行阶段第1个时钟周期的时序逻辑输出

该输出指的是被分析指令处在流水线执行阶段的第1个时钟周期结束时,在时钟跳变边沿,OR1200中由于时序逻辑电路引起的信号变化。

(9)执行阶段第2个时钟周期的组合逻辑输出

指的是被分析指令处在流水线执行阶段的第2 个时钟周期时,OR1200 中由于组合逻辑电路引起的信号变化。

(10)执行阶段第2个时钟周期的时序逻辑输出

指的是被分析指令处在流水线执行阶段的第2 个时钟周期结束时,在时钟跳变边沿,OR1200中由于时序逻辑电路引起的信号变化。

当执行阶段的时间多于两个时钟周期时,可以使用“执行阶段第3个时钟周期的组合逻辑输出”、“执行阶段第3 个时钟周期的时序逻辑输出”,很容易理解其含义。同理,当取指、译码阶段需要多个时钟周期时,也有类似的定义。上述定义在进行指令分析时会用到。