硬禾2024年寒假练——基于TI F280049C平台的
程序运行时装载与动态重定位实现
一、实验背景
根据编译原理我们知道,一个程序从源码编辑到实际运行,会经历预编译、编译、链接、装载、运行时重定位等一系列复杂的过程。得益于日渐完善的工具链以及各种集成开发环境(EDA),人们在编程时往往只需要掌握基本的高级编程语言语法,而无需关心各种中间与底层的细节。
由此带来的益处虽然不小,但随之而来的弊端,却是对编程细节以及底层原理的日渐生疏,以至于在很多情况下,受制于徒有外在轮廓、内在却含糊不清的知识树,而无法解决一些看似“玄学”“无厘头”的问题。
因此,为了巩固与验证已经习得的有关编译原理的知识、强化软硬件协同能力、同时加深对TI F280049C平台的理解,本人基于TI F280049C平台,完成了一次简单的程序运行时装载与动态重定位实验。
二、实验概述
(1)实验内容概要
本实验首先将用户编辑的test_func.c源文件通过cl2000.exe交叉编译工具编译(只编译不链接)之后,生成ELF格式的test_func.c.obj可重定位目标文件。随后,通过串口(SCI)将ELF格式的test_func.c.obj发送到片上;片上的接收程序将接收到的目标文件存储在RAM中,在用户发送完毕并按下“确认”按键后,片上的接收解析程序对接收到的ELF文件进行解析、重定位符号的修正与入口函数跳转执行,最终在屏幕上显示出了预期的结果。
项目所用的源码与运行的直观效果如下所示。
//contents of "test_func.c" source file
unsigned int my_add(unsigned int a, unsigned int b);
unsigned int my_add(unsigned int a, unsigned int b){
return a+b;
}
void nomain(void){
unsigned int sum;
OLED_Clear();
sum = my_add(12300,45);
OLED_ShowNum(64,12,sum,5,12,1);
OLED_Refresh();
}
图2.1 经过串口(SCI)实时装载与动态解析重定位后的执行效果
从代码中可以看到, OLED_Clear、OLED_Refresh和OLED_ShowNum这三个函数,根本就没有定义在用户的源文件中,然而,在将该源文件编译(但不链接)、通过SCI发送到片上并按下一个“确认”按键后,经过片上实时解析程序对接收到的ELF格式目标文件的解析、重定位符号修正与跳转执行,最终成功实现了图2.2中所示的清屏并打印“12300+45”的运行效果。
下面的图2.2展示了整体的系统实物图。
图2.2系统实物图(左图为未上电状态,右图为上电运行状态)
(2)实验难度总结
本实验的难度较大,所涉及的知识范围较广、软硬兼有、综合性强,包括但不限于:
·编译原理相关知识
·ELF文件结构相关知识
·C2000Ware软件包的使用
·TI C2000指令集架构(内嵌汇编需要使用)
·TI C2000特殊的ABI(不支持8bit)
·C/C++基础
整个实验过程就是对这些知识不断深化,融会贯通的过程——这将在接下来的第三和第四部分中充分体现。
(3)实验报告结构
实验报告后续章节的组织方式如下:
在第三章中,将介绍项目的思路;
在第四章中,将介绍项目工程实现的细节;
在第五章中,将对项目过程和心得体会进行总结;
在第六章中,将会列出可供项目复现参考的硬件型号、引脚连接表等信息,提供四个可复现的实验模板,并对该项目的灵活性、普适性与局限性进行简要的论证。
三、实验思路
(1)可行性分析
图3.1从描述了本实验的顶层构想。
图3.1 本实验的顶层构想
在有了这个顶层设计的构想之后,首要的一步就是分析其可行性,以免落入舍本逐末、做到最后才发现设想脱离实际的尴尬境地。
而在本实验中,可行性分析最基础最根本的一步就是明确:C2000的PC(Program Counter)到底能不能跳转到一个RAM地址。这显然是一个与微架构密切相关的问题,因为基本所有单片机在设计的时候,都会定义一个合法的地址范围,如果取指或是读写数据的访存地址超出这个范围,则会在无法实现访问的同时,直接掉入一个地址错误异常,导致PC停摆,进而程序崩溃。
(2)RAM地址跳转
粗略地遍阅了数篇技术文档,包括C编译器、ABI手册、汇编工具、芯片本身的技术指南在内,似乎都没有找到一个非常直观的图表,来将芯片的Memory Mapping这个最为关键的信息直观地展现出来。然而,这并不代表毫无办法。如果我们能够从C2000Ware中成功导入一个F280049C的样例工程,则我们会在工程的<Build_Option>/syscfg/device_cmd.cmd文件中找到等价的信息。
这里,<Build_Option>表示构建选项,一般来说会有CPU1_FLASH,CPU1_RAM,CPU1_LAUNCHXL_FLASH,CPU1_LAUNCHXL_RAM这四个选项,如图3.2所示。其中,_FLASH后缀的两个表明程序会被烧录到Flash然后执行,_RAM后缀的两个表明程序会立即在RAM中被执行,但在断电后消失。
图3.2 device_cmd.cmd所在路径示例,以及其中记录的Memory Mapping信息(部分)
看到这里,有人可能要说,既然构建选项里都可以选择在RAM里执行了,那么PC可以跳转RAM地址还需要怀疑吗?
事实上,我认为这并不能直接证明我们的构想可以成立。因为烧录的过程并不是简简单单地将一堆字节复制到芯片的存储里,还会伴随着一系列的辅助指令与握手指令的来往。对于用户来讲,上位机的USB驱动与芯片BootLoader之间的这一套交互机制与协议往往是晦涩、模糊的,我们也不太可能花大量的时间去将它弄懂——这其实没有太大的意义。因此,严谨地讲,我们无法确定在选择“_RAM”后缀的选项时,上位机是否在将程序烧录至RAM后,又通过USB发送的某个指令设置了某个标志位,从而导致PC是在这个标志置位的情况下,才得以以某种特殊模式跳转RAM地址。如果是这样,那么我们的设想恐怕就不一定可行。
最直观的验证方法是:参考C2000的ISA(指令集架构)指南手册,并从中掌握一些比较简单而又效果明显的指令的机器码,例如,如果我们掌握了函数调用(LCR)、加载立即数到通用寄存器(MOV)、存储通用寄存器值到指定地址(MOV),函数返回(LRETR)这四条指令的机器码,并将它们依次存储在指定的RAM地址中,那么,在RAM中的跳转调用是否成功,就是一目了然的事情——只需要取出通用寄存器值理论上应该被存储到的那个地址,看看它是否和我们想要搬进通用寄存器的那个立即数相同,就可以验证我们的猜想。
这部分实验的关键代码如下。
/*embedded assembly call*/
// MOV ACC,#64 1111 1111 0010 0000 0000 0000 0100 0000
*((volatile unsigned short*)(0x008000)) = 0xff20;
*((volatile unsigned short*)(0x008001)) = 0x0040;
// ADD ACC,#1 1111 1111 0001 0000 0000 0000 0000 0001
*((volatile unsigned short*)(0x008002)) = 0xff10;
*((volatile unsigned short*)(0x008003)) = 0x0007;
// PUSH DP 0111 0110 0000 1011
*((volatile unsigned short*)(0x008004)) = 0x760b;
// MOVW DP,#513 0111 0110 0001 1111 0000 0010 0000 0001
*((volatile unsigned short*)(0x008005)) = 0x761f;
*((volatile unsigned short*)(0x008006)) = 0x0201;
// use "MOVL loc32,ACC" to store ACC value into a RAM address
// MOVL loc32,ACC 0001 1110 0000 0000
*((volatile unsigned short*)(0x008007)) = 0x1e00;
// POP DP 0111 0110 0000 0011
*((volatile unsigned short*)(0x008008)) = 0x7603;
// LRETR 0000 0110
*((volatile unsigned short*)(0x008009)) = 0x0006;
__asm(" LCR #0x008000");
/*embedded assembly call*/
这段代码实现的功能,就是加载立即数64到ACC寄存器,加1之后再存入0x008040这个地址,最后将0x008040地址的内容显示在屏幕上。
这段代码中涉及到几个关键的技术细节:
1.存储端序。
C2000的技术文档中明确指出,C2000是小端序,因此在将机器码写入RAM时,要留意机器码高低字节存放的端序。
2.不支持8-bit访问。
《C28x Embedded Application Binary Interface ApplicationNote》第11页1.8小节的第5行第1句明确指出:C28x系列不支持8-bit对象——它是16-bit addressable的。对此最形象的解读就是:地址值的LSB对应硬件上的16-bit。
例如,0x008001这个RAM地址,对应的是一个完整的16bit数据,你可以通过*((volatile uint16_t*)(0x008001))的方式进行16-bit的读写,然而,任何试图以*((volatile (unsigned) char*)(0x008001 +<offset>))的方式进行连续的单个字节访问的尝试,都会以失败告终。具体的实验现象,这里就不一一枚举了。
因此,在写入机器码的时候,我们不是以字节(8bit)为单位进行写入,而是以“字”(16bit)为单位进行写入。当看到任何一个地址值自增1时,我们必须反应过来这实际上是跳了2个Byte,而不是1个Byte。
3.C2000指令集架构。
《TMS320C28x CPU and Instruction Set Reference Guide》的第6章给出了全部支持的指令。C2000从最底层的指令集架构开始,就走出了一条“自起炉灶”、独树一帜的路,因此我们从前掌握的ARM汇编、RISCV汇编、x86汇编什么的全都不管用了,我们必须花费一点时间来熟悉它的这一套ISA。
这套ISA的特点之一,就是DP(Data Page)寄存器的存在。与其他架构较为直观的立即数寻址不同,C2000的寻址采用“页地址+偏移”的方式,从0x00_0000到0x3F_FFFF的地址区间被划分为65536个大小为64*2=128bit的地址页,然后再加上MOV之类的指令带进来的7bit偏置进行寻址。
这也是为什么在上面的代码中,我要对DP进行入栈和退栈。因为这是内嵌汇编,如果在我通过DP访问0x008040时其实是改变了DP,又没有保存它此前的值,那么后面程序的偏置访存就可能访问到一个意料之外的地址,从而引入运行时错误。
上述实验最终成功地在屏幕上显示了“65”,这表明我们的构想中最为核心的部分是可以实现的。
(3)ELF文件解析
这是本实验中最为核心,也是最难的一个部分。
正如第二章的实验效果图所示,与OLED相关的函数其实是包含在我们烧录在片上FLASH里的接收解析固件中的,相当于一个简易的运行时库。
因此,在根据《TMS320C28x Optimizing C/C++ Compiler v22.6.0.LTS》中描述的方法调用cl2000.exe交叉编译器,并指定输出格式为ELF后,对于cl2000而言,这些OLED_函数最终都会输出为未定义符号。因此,我们必须非常了解ELF文件的结构,才能在cl2000输出的可重定位对象中精确地找到并修正这些未定义的调用符号,同时精准地定位并调用我们约定为默认入口的“nomain”函数。
ELF文件的基本逻辑,是以“段(section)”组织数据、而以“段头(header)”索引数据。我的电脑安装了WSL2,在/usr/include/elf.h中,包含着几乎所有在解析ELF文件时必不可少的结构体定义和宏定义。在第四部分实际实现时,我们只需要把我们用到的一小部分复制过来就可以了。
在ELF文件的结构中,与本实验密切相关的包括:
1.“.text”段。
指令序列被存储在其中,但需要根据可重定位表修正其中那些未定义的符号之后,才能被正确地执行。
2.“.symtab”段。
符号表本质上是一个长度等于(<所有对外可见的符号个数>+1)的结构体数组,其中包含着所有对外部可见的符号,包括函数、静态变量、未定义符号等。其第一个元素没有意义,其余的元素中,依次保存着每个符号的名称(以字符串表索引表示)、属性、所在段索引、段内偏移、符号值等信息。
3.“.rel.<section_name>”段。
段重定位表。以数组的形式保存着对应段内可重定位(未定义)的符号的信息。其中的每个元素依次记录着每个可重定位符号的段内偏移、类型、在符号表中的序号等信息。
4.“.strtab”段。
除了段头名(被保存在“.shstrtab”中)外,所有的符号名以及其他的一些附加信息均被保存在该段中。Elf32_Sym结构体的st_name成员记录着该成员名称的第一个字符在.strtab段中的索引。
5.ELF文件头。
ELF文件头的文件偏移为0。其中记录着ELF魔数、文件头大小、段头大小、段表偏移、段头数目(即段的数目)、入口地址等关键信息,这一切决定了,任何解析一个ELF文件的尝试,都应该从解析ELF文件头开始。
明确了这些结构及其含义之后,解析ELF文件的思路也基本明确:
1.根据段表偏移定位段表;
2.遍历段表,搜索的步进值等于段头大小(单位为Byte);通过判断段头中记录的段属性,找到“.symtab”段的段头,并通过其中记录的段偏移(也就是符号表偏移)定位符号表;
3.遍历段表,用同样的方法定位段可重定位表。从原则上讲,我们应该先获取段可重定位表的段名,再通过字符串匹配来确定表中的符号所在的段,但为了简化实验,这里可以使用一个小技巧:只要我们关掉cl2000的“per function per section”功能(即--gen_func_subsections=off),并且暂时只考虑函数的重定位,那么所有的函数——包括我们的“nomain”函数在内——都会被放进同一个段。至于这个段的名字,其实并不重要,我们只需要在符号表中找到“nomain”,并通过其sh_shndx成员解析其所在段的索引,也就找到了其他函数所在段的索引。正因如此,我们的实验最终其实只用到了“.strtab”段,而有点取巧地绕开了“.shstrtab”段;
4.解析“nomain”所在的段索引(该索引就是该段的段头在段表中的索引),并通过段表解析该段的偏移地址;
5.匹配并修正所有的可重定位符号。如果可重定位符号在ELF文件中已有匹配,则直接完成匹配(就像my_add函数);如果没有在ELF文件中检索到(就像那三个OLED_函数),则在片上的“运行时库”中进行匹配。完成匹配后,根据可重定位表中记录的各个可重定位符号的段内偏移,对相应地址的数据进行修正;
6.通过Inderect long call(LCR *XARn)指令跳转nomain地址。
然而,在实际实验时,C2000不支持8-bit的特性,还是会使我们在一些地址对齐上感到比较棘手。
四、实验实现
(1)OLED驱动移植
屏幕驱动移植已经是老生常谈的问题了,也没有太多值得细写的东西。
总地来说,问题可分为两类。一是GPIO模拟时序,二是调用SPI、UART等片上硬件。
第一类相对简单。在商家给出的驱动中,一般会有一些控制引脚电平的宏,如下所示。
#define OLED_SCL_Clr() GPIO_writePin(6, 0)//SCL
#define OLED_SCL_Set() GPIO_writePin(6, 1)
#define OLED_SDA_Clr() GPIO_writePin(7, 0)//SDA
#define OLED_SDA_Set() GPIO_writePin(7, 1)
#define OLED_RES_Clr() GPIO_writePin(8, 0)//RES
#define OLED_RES_Set() GPIO_writePin(8, 1)
#define OLED_DC_Clr() GPIO_writePin(9, 0)//DC
#define OLED_DC_Set() GPIO_writePin(9, 1)
#define OLED_CS_Clr() GPIO_writePin(10, 0)//CS
#define OLED_CS_Set() GPIO_writePin(10, 1)
只需要将对应的宏定义改成对应的GPIO引脚控制函数,重新修改OLED_Init()中的外设初始化,并替换可能存在的delay函数(控制屏幕复位会用到)即可;
第二类稍显复杂。需要将底层的WriteByte函数修改成对应的外设控制API,并修改OLED_Init()中的外设初始化。
本实验采用的屏幕为自己购买的中景园OLED,分辨率为128*64,单色显示。驱动移植属于第一类。
图4.1 本实验所用的OLED屏实物图
(2)串口接收
在迁移到一个新的平台后,直接尝试通过寄存器级的操作控制串口收发并不是一个明智的选择,相反,学习SDK的示例工程以及官方的API指南则会受益颇丰。
本实验以C2000Ware自带的sci_ex1_loopback工程为模板进行开发。
在打开工程时我们可能疑惑:“loopback”是什么意思呢?查询《TMS320F28004x Real-Time Microcontrollers Technical Reference Manual》后得知,“loopback”其实是SCI模块的一个自检调试模式。大概来说,开启这个模式之后,SCI模块会不断地循环发送从0x00到0xFF的字符,起到测试收发的作用。显然,这个loopback模式必须关掉。
除了关闭loopback之外,还必须设置串口的波特率、字长、停止位、校验、以及GPIO映射等关键属性。这里,TI的sysconfig工具会为我们提供极大的便利。如图4.2所示,只需要在sysconfig界面中进行配置,保存之后重新编译即可。
图4.2 利用sysconfig工具配置SCI模块
在配置选项中,有一个关键的“FIFO”选项,由于cl2000遵循4-Byte alignment,所以在本实验中,可以将FIFO大小设置为4。
用FIFO接收ELF文件的代码如下。
for(;;)
{
while(SCI_getRxFIFOStatus(mySCI0_BASE) != SCI_FIFO_RX4)
{
if(GPIO_readPin(0) == 0){
stopReceive = 1;
break;
}
}
if(stopReceive == 0){
//extract 4 received bytes from FIFO
for(i=0;i<2;i++){
receivedChar = SCI_readCharBlockingFIFO(mySCI0_BASE);
receivedChar |= (SCI_readCharBlockingFIFO(mySCI0_BASE)<<8);
*((volatile uint16_t*)(curr_code_ptr++)) = receivedChar;
}
file_size += 4;
}
if((GPIO_readPin(0) == 0)||(stopReceive==1)){
break;
}
}
其中,宏“SCI_FIFO_RX4”表示等待FIFO接收到4个字符。这里我们并没有用DMA实现,是因为CPU的速度完全跟得上。如图4.5所示,通过sysconfig界面查看得知,CPU时钟频率为100MHz,而对于串口模块,波特率就是比特率,为9600 bit/s。串口发送一个字符至少需要8个周期,但CPU从FIFO中转移一个数据可能甚至不需要8个周期。由此可见,在9600的波特率下,即使不用DMA也不会造成数据丢失。
图4.3 通过sysconfig界面查看时钟频率
此外,我们在GPIO0引脚外接了一个按键,如图4.4所示,在上位机发送完毕之后,按下按键,程序就会跳出接收循环。
图4.4 自制的简易按键,按下低电平,连接到GPIO0
(3)潜在的地址重叠问题
为了方便起见,我们希望:收到的ELF文件以及后面我们解析出来的字符串表,都被存储在我们明确指定的片上地址。
例如,我希望接收到的ELF文件从地址0x008000开始存储,那么在串口接收循环中,我就可以直接通过*((volatile uint16_t*)(0x008000 + <offset>))这种方式来写入。这种方式在没有“虚拟地址“和”进程空间“概念的裸机编程下是没有问题的,但存在一定的隐患。
因为编译器和链接器只知道你要对0x008000+<offset>进行写入,但它并不知道0x008000这个地址对你而言的意义。因此,在链接器确定符号地址时,有可能恰好就把某个数组放在了和0x008000相重叠的存储区域上,那么这样一来,我们在接收ELF文件的同时就会无意中破坏了那个数组中的内容,这显然是不希望发生的。
因此,最为严谨的做法,是使用__attribute__((location(0x008000)))显式地声明一个从0x008000起始的全局数组,同时,还需要把.cmd的MemoryMap中对应的地址段加以修改。这样一来,链接器就会知道,从0x008000开始的这段地址已经“名花有主(这个‘主‘就是全局数组的符号名)”了,别的符号不能占用,只能另寻地址。
简而言之就是:你可以不显式调用,但你必须显式声明。
图4.5 显式声明数组并修改MemoryMapping,以避免潜在的访问冲突
(4)elf.h头文件
在接收完ELF文件之后,就来到了本实验最有难度的部分。
为了解析收到的ELF文件,我们需要用到Linux系统下的/usr/include/elf.h头文件。有人可能会疑问:Linux系统下的文件,能直接复制过来用吗?答案是可以,因为这是C语言的头文件,而C语言是跨平台的。
然而,elf.h头文件中内容极多,我们的实验只需要用到其中的如下部分:
·#include “stdint.h“;
·所有的数据类型typedef;
·Elf32_Ehdr结构体,它定义了ELF文件头结构;
·Elf32_Shdr结构体,它定义了段头结构;
·Elf32_Rel结构体,它定义了重定位表中的元素(即重定位入口)的结构;
·Elf32_Sym结构体,它定义了符号表中的元素(即符号入口)的结构;
·所有与上述结构体的成员有关的宏定义。
在我们的工程下新建一个elf.h文件,并将上述内容按照顺序复制进去,本实验所需的elf.h头文件就准备完成了。
(5)解析ELF文件头
Elf32_Ehdr结构体的定义如下:
#define EI_NIDENT (16)
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
值得注意的是,复制过来的Elf32_Ehdr,第一行定义的数据类型是unsigned char[],宏EI_NIDENT的值是16,这表明它的本意意指一片大小为16*8=128bit的区域。然而,由于C2000不支持8bit,所以一个unsigned char实际上是16bit,由此一来,第一个成员e_ident占用的空间实际上是16*16=256bit,这与ELF文件的实际情况不符。因此,我们要修改其类型为unsigned short[](或uint16_t[]),并修改EI_NIDENT宏为8。
解析ELF文件头的代码实现如下:
uint32_t e_ehsize, e_shoff, e_shentsize, e_shnum, e_shstrndx;
e_ehsize = ((Elf32_Ehdr*)(ram_code_start))->e_ehsize; //header size
e_shoff = ((Elf32_Ehdr*)(ram_code_start))->e_shoff; //section header table offset
e_shentsize = ((Elf32_Ehdr*)(ram_code_start))->e_shentsize; //section header table size
e_shnum = ((Elf32_Ehdr*)(ram_code_start))->e_shnum; //section header table count
e_shstrndx = ((Elf32_Ehdr*)(ram_code_start))->e_shstrndx; //string table index
(6)定位各关键段
ELF文件头中包含着两个至关重要的信息:记录着段表(section header table)偏移的e_shoff成员,以及记录着每个段头(section header table entry,即section header)大小的e_shentsize成员。有了它们两个,就好比有了数组的起始地址与单个元素大小。
段表中的每个元素都是一个段头。段头的定义如下所示:
typedef struct
{
Elf32_Word sh_name; /* Section name (string tbl index) */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section virtual addr at execution */
Elf32_Off sh_offset; /* Section file offset */
Elf32_Word sh_size; /* Section size in bytes */
Elf32_Word sh_link; /* Link to another section */
Elf32_Word sh_info; /* Additional section information */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Entry size if section holds table */
} Elf32_Shdr;
在段头中,sh_type成员记录着该段的属性,凭借该成员,我们可以判断当前段表所指向的段到底是代码段、数据段、字符串表、符号表、还是可重定位表,而sh_offset和sh_size成员则指示着该段的偏移地址与大小。
定位关键段的代码如下所示:
//The size of a section header is typically even.
uint32_t section_header_start = ram_code_start + e_shoff / 2;
uint32_t section_pointer = section_header_start;
Elf32_Word section_header_type;
//Search for STRTAB section header
section_pointer = section_header_start;
while(1){
section_header_type = ((Elf32_Shdr*)(section_pointer))->sh_type;
if(section_header_type == SHT_STRTAB){
break;
}
else{
section_pointer += e_shentsize / 2;
}
}
uint32_t str_tab_pointer;
uint16_t str_tab_off_is_odd;
//Store STRTAB pointer
str_tab_pointer = ram_code_start + (((Elf32_Shdr*)(section_pointer))->sh_offset) / 2;
str_tab_off_is_odd = (((Elf32_Shdr*)(section_pointer))->sh_offset) % 2;
//Search for SYMTAB section header
section_pointer = section_header_start;
while(1){
section_header_type = ((Elf32_Shdr*)(section_pointer))->sh_type;
if(section_header_type == SHT_SYMTAB){
break;
}
else{
section_pointer += e_shentsize / 2;
}
}
uint32_t sym_tab_pointer;
uint32_t sym_tab_off_is_odd;
uint16_t num_of_symbol = (((Elf32_Shdr*)(section_pointer))->sh_size) / 16;
//Store SYMTAB pointer
sym_tab_pointer = ram_code_start + (((Elf32_Shdr*)(section_pointer))->sh_offset) / 2;
sym_tab_off_is_odd = (((Elf32_Shdr*)(section_pointer))->sh_offset) % 2;
//Search for REL section header
section_pointer = section_header_start;
while(1){
section_header_type = ((Elf32_Shdr*)(section_pointer))->sh_type;
if(section_header_type == SHT_REL){
break;
}
else{
section_pointer += e_shentsize / 2;
}
}//Now the section_pointer should be pointing at the first Relocation Entry
//Store REL section header pointer
uint32_t rel_tab_pointer;
rel_tab_pointer = ram_code_start + (((Elf32_Shdr*)(section_pointer))->sh_offset) / 2;
这里有必要注意,为什么section_pointer每次自增的值是e_shentsize/2,而不是e_shentsize呢?因为C2000地址值的LSB对应的是16bit,而section_pointer是一个地址值,所以自增的值要除以2。
事实上,C2000不支持8bit,引入的一个更严重的问题是地址对齐(address alignment)——段表偏移的单位可是Byte啊!试想一下,万一哪次在ELF文件头里面解析出一个奇数的段表偏移,那么段头的解析就会变得更加复杂。尽管到目前为止,似乎还没有遇到这种情况——但在接下来字符串表的解析中,这种情况却是完全可能出现的。
(7)存储字符串表
我们都知道,在绝大部分情况下,字符串都是以8bit(1Byte)为单位进行组织和索引的,ELF文件的字符串表——本质上就是以“\0”分割的一段段字符串——自然也不例外。然而,C2000不支持8bit,这也就意味着,我们必须将ELF文件中的字符串表转存到一个uint16_t数组中。这是因为,符号表的元素——Elf32_Sym结构体中,记录符号名的st_name成员,其实是符号名的第一个字符在字符串表中的索引值,如果不对字符串表进行转存,那么以16bit读取再进行8bit解析的工作量,将会成倍地转移到解析每一个符号名的过程中。
转存字符串表,并通过串口发回的代码如下:
uint32_t str_tab_pointer;
uint16_t str_tab_off_is_odd;
//Store STRTAB pointer
str_tab_pointer = ram_code_start + (((Elf32_Shdr*)(section_pointer))->sh_offset) / 2;
str_tab_off_is_odd = (((Elf32_Shdr*)(section_pointer))->sh_offset) % 2;
uint32_t rec_strtab_ptr = 0x00C000;
j = (((Elf32_Shdr*)(section_pointer))->sh_size);
//Store String Table, and send it back.
uint16_t high_byte, low_byte, tmp;
while(j){
tmp = *((volatile uint16_t*)(str_tab_pointer++));
high_byte = (tmp >> 8) & 0x00ff;
low_byte = tmp & 0x00ff;
if(str_tab_off_is_odd){
if(j==(((Elf32_Shdr*)(section_pointer))->sh_size)){
*((volatile uint16_t*)(rec_strtab_ptr++)) = high_byte;
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, high_byte);
j--;continue;
}
else if(j==1){
*((volatile uint16_t*)(rec_strtab_ptr++)) = low_byte & 0x00ff;
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, low_byte);
j=0;break;
}
else{
*((volatile uint16_t*)(rec_strtab_ptr++)) = low_byte;
*((volatile uint16_t*)(rec_strtab_ptr++)) = high_byte;
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, low_byte);
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, high_byte);
}
}
else{
if(j==1){
*((volatile uint16_t*)(rec_strtab_ptr++)) = low_byte & 0x00ff;
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, low_byte);
j=0;break;
}
else{
*((volatile uint16_t*)(rec_strtab_ptr++)) = low_byte;
*((volatile uint16_t*)(rec_strtab_ptr++)) = high_byte;
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, low_byte);
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, high_byte);
}
}
j-=2;
}
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, 0x000a);
在这段代码中,已经考虑了字符串表的段偏移为奇数个字节的情况——这由str_tab_off_is_odd变量来记录。由于C语言的整数相除默认向下取整(也就是floor),所以,假如字符串偏移是奇数,考虑到C2000是小端序,那么根据str_tab_pointer读出来的第一个16bit数据中,高8bit才是字符串表的第一个字符,低8bit是上一个段的最后一个字节,应该被舍弃。
该段代码对我们发送到片上的test_func.c.obj文件的解析结果如图4.6所示。
图4.6 串口助手收到的字符串表与readelf -s test_func.c.obj 的输出结果的比较
我们将串口助手收到的字符串表,与readelf -s test_func.c.obj 的输出结果进行比较。可以看到,串口助手收到的字符串表,依次就是readelf -s解析出来的符号的名称,由此可见我们对字符串表的解析与转存是完全成功的。这也意味着,在接下来解析符号时,可以直接使用Elf32_Sym结构体的st_name成员对我们转存的字符串表进行索引了。
(8)重定位符号解析
Elf32_Rel结构体定义如下:
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;
段可重定位表是一个数组,其中的每个元素——称为“可重定位入口”——其类型就是Elf32_Rel。
此前提到过,段可重定位表的段头中,记录着它的偏移和大小。而根据结构体定义,我们又可以知道其中每个元素的大小,则可重定位符号的个数可轻易求出。
Elf32_Rel结构体的r_info成员中,记录着可重定位符号在符号表中的索引——因为归根结底,可重定位符号也是符号;在符号表中,st_shndx成员值为0的符号,就是可重定位(未定义)符号。
在本实验中,为了简化起见,只引入了外部函数,并且所有代码都放在同一个段内,因此,段可重定位表只有一个。只要遍历这个段可重定位表,根据r_info成员索引符号表中的Elf32_Sym元素,再根据其st_name成员索引字符串表,就可以实现打印出每个可重定位符号的符号名(当然,直接遍历符号表,打印出st_shndx为0的符号也可以实现同样的效果,只不过,接下来可重定位符号修正的操作,依然要求我们去遍历段可重定位表。为了清晰的程序层次,所以选择这种方式)。
实现解析并发送所有可重定位符号的符号名的代码如下(其中包含后面匹配与修正重定位符号的代码,它们是一个完整的整体):
for(i=0;i<rel_entries;i++){
//Relocation entry offset in section.
uint32_t rel_off_in_sec = ((Elf32_Rel*)(rel_tab_pointer + i*(8 / 2)))->r_offset;
//Relocatable symbol index in symbol table.
uint32_t rel_ndx_in_sym = ((((Elf32_Rel*)(rel_tab_pointer + i*(8 / 2)))->r_info)>>8);
//Pointing to the Relocatable symbol in the symbol table.
section_pointer = sym_tab_pointer + (16 / 2) * rel_ndx_in_sym; //Size of Elf32_Sym is 16.
if(sym_tab_off_is_odd == 0){
sym_ndx_in_str = ((Elf32_Sym*)(section_pointer))->st_name;
}
//If symbol table doesn't align the 16-bit boundary,
//then a copy must be performed to extract the members.
else{
//copy the current Elf32_Sym to tmp_buf
//size of an Elf32_Sym is 16 bytes.
uint32_t tmp_ptr_sec = sym_tab_pointer;
uint32_t tmp_ptr_buf = tmp_buf_start;
uint16_t low_byte, high_byte, tmp_sec, tmp_buf;
uint16_t ii = 15;
//copy the first byte
tmp_sec = *((volatile uint16_t*)(tmp_ptr_sec++));
high_byte = (tmp_sec >> 8) & 0x00ff;
while(ii){
tmp_sec = *((volatile uint16_t*)(tmp_ptr_sec++));
low_byte = tmp_sec & 0x00ff;
tmp_buf = (low_byte << 8) | high_byte;
*((volatile uint16_t*)(tmp_ptr_buf++)) = tmp_buf;
high_byte = (tmp_sec >> 8) & 0x00ff;
ii--;
}
tmp_sec = *((volatile uint16_t*)(tmp_ptr_sec));
low_byte = tmp_sec & 0x00ff;
tmp_buf = (low_byte << 8) | high_byte;
*((volatile uint16_t*)(tmp_ptr_buf)) = tmp_buf;
sym_ndx_in_str = ((Elf32_Sym*)(tmp_buf_start))->st_name;
}
uint32_t tmp1 = sym_ndx_in_str, tmp2;
while(rec_strtab[tmp1] != 0x0000){
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, rec_strtab[tmp1]);
tmp1++;
}
SCI_writeCharBlockingNonFIFO(mySCI0_BASE, 0x000a);
//Look for symbols of the same name within the symbol table.
section_pointer = sym_tab_pointer; //symtab offset is typically even
for(j=0;j<num_of_symbol;j++){
matched=0;
//tmp1 points to the name of the unresolved symbol
//tmp2 points to the name of the current symbol
tmp1 = sym_ndx_in_str;
tmp2 = ((Elf32_Sym*)(section_pointer))->st_name;
while(((rec_strtab[tmp1]&0x00ff)!=0)&&((rec_strtab[tmp2]&0x00ff)!=0)){
if((rec_strtab[tmp1]&0x00ff) == (rec_strtab[tmp2]&0x00ff)){tmp1++;tmp2++;matched=1;}
else{matched=0;break;}
}
if(matched==0){section_pointer+=(16/2);}
else if((rec_strtab[tmp1]&0x00ff) != (rec_strtab[tmp2]&0x00ff)){matched=0;section_pointer+=(16/2);}
else{matched=1;break;}
}
uint32_t fetched_symbol_value;
fetched_symbol_value = RELOCATION_FAILURE_DEFAULT_ADDRESS; //relocation_failure_default
//--if found:
if(matched && (((Elf32_Sym*)(section_pointer))->st_shndx != 0) && (((((Elf32_Sym*)(section_pointer))->info_n_other)&0x000f) == STT_FUNC)){
//Check whether its "st_shndx" member is not 0.
//Check whether its type is a function("FUNC").
//Get its symbol value, that's the offset within the section.
fetched_symbol_value = ((Elf32_Sym*)(section_pointer))->st_value;
//Calculate its runtime address.
fetched_symbol_value += text_section_offset / 2;
fetched_symbol_value += ram_code_start;
//Return to relocation table, modify the corresponding "LCR" instruction.
uint16_t lcr_instruction_high, lcr_instruction_low;// 0x0000, 0x7640
lcr_instruction_low = *((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec));
lcr_instruction_high = *((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec + 1));
//LCR has a 22-bit operand.
lcr_instruction_low &= ~(0x003f);
lcr_instruction_low |= ((fetched_symbol_value>>16)&0x0000003f);
lcr_instruction_high &= ~(0xffff);
lcr_instruction_high |= (fetched_symbol_value & 0x0000ffff);
*((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec)) = lcr_instruction_low;
*((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec + 1)) = lcr_instruction_high;
}
//--if not found;
else{
//Search in "my_symtab"
for(j=0;j<my_symtab_len;j++){
matched=0;
k=0;
tmp1 = sym_ndx_in_str;
while(((rec_strtab[tmp1]&0x00ff)!=0)&&((my_symtab[j].symbol_name[k]&0x00ff)!=0)){
if((rec_strtab[tmp1]&0x00ff) == (my_symtab[j].symbol_name[k]&0x00ff)){tmp1++;k++;matched=1;}
else{matched=0;break;}
}
if(matched==0){;}
else if((rec_strtab[tmp1]&0x00ff) != (my_symtab[j].symbol_name[k]&0x00ff)){matched=0;}
else{matched=1;break;}
}
if(matched){
//Fetch the corresponding symbol value
fetched_symbol_value = my_symtab[j].symbol_value;
//Return to relocation table, modify the corresponding "LCR" instruction.
uint16_t lcr_instruction_high, lcr_instruction_low;// 0x0000, 0x7640
lcr_instruction_low = *((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec));
lcr_instruction_high = *((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec + 1));
//LCR has a 22-bit operand.
lcr_instruction_low &= ~(0x003f);
lcr_instruction_low |= ((fetched_symbol_value>>16)&0x0000003f);
lcr_instruction_high &= ~(0xffff);
lcr_instruction_high |= (fetched_symbol_value & 0x0000ffff);
*((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec)) = lcr_instruction_low;
*((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec + 1)) = lcr_instruction_high;
}
else{
//Default to relocation failure
fetched_symbol_value = RELOCATION_FAILURE_DEFAULT_ADDRESS;
//Return to relocation table, modify the corresponding "LCR" instruction.
uint16_t lcr_instruction_high, lcr_instruction_low;// 0x0000, 0x7640
lcr_instruction_low = *((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec));
lcr_instruction_high = *((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec + 1));
//LCR has a 22-bit operand.
lcr_instruction_low &= ~(0x003f);
lcr_instruction_low |= ((fetched_symbol_value>>16)&0x0000003f);
lcr_instruction_high &= ~(0xffff);
lcr_instruction_high |= (fetched_symbol_value & 0x0000ffff);
*((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec)) = lcr_instruction_low;
*((volatile uint16_t*)(ram_code_start + text_section_offset / 2 + rel_off_in_sec + 1)) = lcr_instruction_high;
}
}
}
(9)片上符号表的建立
至此,我们已经完成了可重定位符号的解析。接下来,只需要根据Elf32_Rel的r_offset成员确定重定位入口的段内偏置,并对偏置处的值进行恰当的修正,即可完成所有可重定位符号的修正。
我们将本实验使用的用户源代码重写于此:
//contents of "test_func.c" source file
unsigned int my_add(unsigned int a, unsigned int b);
unsigned int my_add(unsigned int a, unsigned int b){
return a+b;
}
void nomain(void){
unsigned int sum;
OLED_Clear();
sum = my_add(12300,45);
OLED_ShowNum(64,12,sum,5,12,1);
OLED_Refresh();
}
可以看到,OLED_Clear、OLED_ShowNum、OLED_Refresh这三个OLED_函数,显然并不是定义在test_func.c中的,所以,我们自然不可能在我们接收到的ELF文件(也就是test_func.c.obj)中找到它们的实现代码——它们的实现代码,实际上是我们烧录到片上的那个接收解析程序的一部分。
那么,当接收解析程序解析出这三个符号的时候,如何确定它们的入口地址——也就是这三个函数在接收解析程序中的FLASH地址呢?
事实上,在我们编译链接生成的可执行文件——sci_ex1_loopback.out中,也是有符号表和字符串表的。那么理论上,只需要遍历符号表,再通过Elf32_Sym结构体的st_name成员索引字符串表,从而找到名字分别为“OLED_Clear”、“OLED_ShowNum”和“OLED_Refresh”的三个符号,那么——由于它们的符号类型均为“STT_FUNC”——因此对应符号的st_value成员的值,就是它们的装载地址,也就是函数的入口地址。
然而遗憾的是,尽管readelf -s 的输出结果表明.symtab段和.strtab段的确存在于sci_ex1_loopback.out中,CCS IDE却似乎会默认将.symtab段和.strtab段排除在装载(烧录)的段之外。不管是用链接器的--retain选项,还是在cmd文件中指定,似乎都无法实现将这两个段烧录到FLASH中,就连TI的资料中,似乎也没有对链接器处理这两个段的行为的描述。
因此,我们只能另寻他法。
如下所示,我们可以以常量结构体数组的形式,以酷似“键值对”的方式,自己定义一个自己的“符号表”。
typedef struct{
const char * symbol_name;
const uint32_t symbol_value;
} my_symbol;
uint16_t my_symtab_len=3;
const my_symbol my_symtab[3] = {
{"OLED_Clear\0", 0x00083c9a}, //00083c9a OLED_Clear
{"OLED_Refres\0", 0x000839a5}, //000839a5 OLED_Refresh
{"OLED_ShowNum\0", 0x0008355f} //0008355f OLED_ShowNum
};
在这个符号表中,“键”就是函数名,“值”就是函数的入口地址。某种程度上,这非常像是Python中的“__all__”。在撇下了sci_ex1_loopback.out那本就冗长不堪的字符串表之后,一切反而变得清晰明了、简洁轻巧了,我们现在可以选择性地“export”那些我们希望暴露给(expose to)用户程序的函数,只让用户调用在这个数组中指定的函数。
在这里,一定有人会问:你是怎么知道OLED_Refresh的地址就是0x0008391b的呢?事实上,CCS在生成可执行文件的同时,还会生成一个对应的.map文件,如图4.7所示。
图4.7 CCS输出的.map文件
某种程度上,这就等价于对应的.out的一个“符号表”,其中记录着所有对外可见的符号(函数,静态变量等)的值,如果这个符号是一个函数名,那么对应的符号值就是函数的入口地址。
这样做的不便之处也很明显。由于无法通过索引字符串表实现动态解析函数地址,每当程序改动之后,所有函数的装载地址都可能发生变化,此时,就需要重新根据.map文件更改对应的函数地址。
这样做并不会导致陷入一个“套娃”的死循环,在确定其他代码不会再改动之后,我们可以先将上面数组中所有元素的symbol_value成员都设为任意非0值,先编译一遍,再根据.map文件改回来,再编译。因为一般而言,常量影响其他函数装载地址的方式,只有是通过其占位的大小,比方说,如果.data段挤在.text段前面,那么如果.data段的其中一个常量由uint32_t变成了uint16_t,就可能导致紧跟在后面的.text段也往前移。
(10)重定位符号检索匹配
利用WLS2下的objdump工具,通过objdump -r test_func.c.obj,可以打印出test_func.c.obj中的所有可重定位入口,如图4.8(1)所示。
图4.8(1) objdump -r test_func.c.obj的输出结果
图4.8(2) readelf -s test_func.c.obj的输出结果
令人困惑的是,在我们看来,my_add完全是一个已经定义好的函数,怎么还会被列入未定义符号中呢?
观察第一列的“OFFSET”属性,可以推断出,是源文件第10行中,“sum=my_add(12300, 45)”一句,导致了这个可重定位入口。
readelf -s test_func.c.obj的输出结果进一步证实了我们的猜想。如图4.8(2)所示,此时图中输出的这个my_add,其属性并不是“未定义”,可见它对应于test_func.c第1行的那个声明以及第3行的那个定义。
这表明,cl2000在只编译不链接时,哪怕是在同一个文件内,都不会去执行符号检索匹配与修正——也许在它看来,这已经是属于链接器的工作范畴了。
由此可见,我们在(8)中解析出的重定位符号,就要面临两种情况:
其一、是像my_add这种其实是定义了,只是因为还没链接,所以报未定义的情况,这个时候,我们的程序通过在test_func.c.obj的符号表内部检索匹配,就可以完成可重定位符号的修正,相当于是简单地为链接器未完的工作收了个尾;
其二、是像那三个OLED_函数这种,确实就是没有定义的,此时,只需要在上面提到的“my_symtab”数组中检索就可以了。
实现可重定位符号检索匹配的代码,就是上面所展示的“实现解析并发送所有可重定位符号的符号名”的那段代码。前面的解析符号名与这里的“检索-匹配-修正”,对于每个可重定位符号而言都是连续执行的,强行将某一步单独拿出来,反而会破坏代码的逻辑连贯性。因此,这里就不再重复展示代码了。
(11)重定位符号修正
在完成重定位符号的检索与匹配之后,图4.19中的代码对所有的可重定位符号进行了最终的修正。
对于符号my_add,其地址被修正为 :
ram_code_start + <”nomain” section offset>/2 + <my_add.st_value>
其中,ram_code_start为test_func.c.obj文件在RAM中的起始地址,<”nomain” section offset>表示“nomain”所在段(也就是代码段)的段偏移,<my_add.st_value>表示。
这里可能会让人比较困惑,为什么加的这两个值,一个除以2,一个又不除以2呢?这是因为,<”nomain” section offset>的单位是Byte,而<my_add.st_value>——也就是sh_shndx!=0的那个my_add符号的符号值——其单位是16bit。由此可见,C2000的这种不支持8bit的特殊属性,确实会在某些应用场景下引入比较大的麻烦。
在重定位符号修正时,关键的一点在于:在根据Elf32_Rel的r_offset成员定位到地址值后,我们不是直接往这个地址值里面写入我们匹配检索到的函数地址就行了。
为了说明这一点,我们将test_func.c.obj通过dis2000.exe的反汇编结果,以及objdump -r test_func.c.obj输出的重定位入口段内偏移对比,如图4.9所示。
图4.9 dis2000.exe test_func.c.obj输出(左)与objdump -r test_func.c.obj输出(右)
对比可以发现,重定位表的OFFSET(即Elf32_Rel的r_offset成员)值所指向的地方,都是一条LCR 0x000000指令。其中,“0x000000”是编译器对未定义符号赋的默认值。
通过查阅ISA可知,这是一条立即数寻址的调用指令,其定义如下图所示。
图4.10 LCR指令的机器码定义
由此可见,我们在反汇编结果中看到的那个“0x7640_0000”的高10bit,其实是LCR指令的机器码,真正需要我们修正的部分,只是低22bit的立即数地址。如果不管三七二十一直接写入覆盖掉,那么程序是不可能运行成功的。
比方说,我要把这条指令修正成“调用地址0x000834d5”,那么正确的做法,应该是先读出该地址的值,先用“&=~(0x003F_FFFF)”位操作清除低22bit(这是严谨起见的做法,万一编译器赋的默认值不是0呢?),再用“|=((0x000834d5)&(0x003F_FFFF))”(这也是严谨起见,为了防止用户提供超出22bit的地址)修改低22bit,最后再写入。
具体的操作,在前面所展示的、实现重定位符号名解析、检索、匹配、修正的代码中已有体现。
(12)nomain调用执行
当所有的重定位符号被修正完毕,最后的一步,就是调用我们规定的入口函数“nomain”。
具体的做法是,首先通过符号名匹配到“nomain”对应的Elf32_Sym结构体,在利用其st_value成员获得nomain函数的段内偏移的同时,利用其st_shndx成员索引段表中的Elf32_Shdr元素——也就是找到代码段对应的段头,然后,根据段头结构体Elf32_Shdr的sh_offset成员获得代码段相对于ELF文件在RAM中的起始地址(0x008000)的偏移,最后只要留意一下,这些偏置值各自的单位究竟是8bit还是16bit,就可以计算出最终nomain函数的入口地址。
显然nomain函数的入口地址并不是一个常量,而是一个变量,所以,在调用nomain时,我们不能再用“LCR #22bit”立即数直接寻址,而应该使用“LCR *XARn”寄存器间接寻址。只需要将计算出的nomain入口地址存入其中一个XARn寄存器(这里用的是XAR7),再进行跳转即可。
值得注意的是,为了程序运行的稳定性,最好保证这里用到的XARn寄存器没有在接收解析程序的其他地方用到,或者在“LCR *XARn”的前后分别加入对XARn寄存器的入栈(PUSH)与退栈(POP)操作。
实现nomain的最终定位与跳转的代码如下:
//Calculate "nomain" runtime address.
nomain_entry_address = ram_code_start + text_section_offset / 2 + nomain_section_offset;
//LCR call nomain.
__asm(" MOVL XAR7,@nomain_entry_address");
__asm(" LCR *XAR7");
五、实验总结
实验的结果最终完全符合我预期的构想。对于过程以及最终的结果,我都是比较满意的。
总体而言,这次实验既是对我的整个知识树的一次很好的总结、梳理与细化,也是对我的工程能力与软硬件协同能力的一次很好的考验。
一方面,此前我对编译原理进行了相当一段时间的学习和研究。从最开始学得一塌糊涂,到最后逐渐能够将大量的知识消化之后,我一直希望能够有一个机会,将这些知识付诸实践,以检验我在理论学习的过程中对它们的理解是否正确——在多大程度上正确。这一项目的灵感在理论学习的过程中就已具雏形,而现在,正好借着这次寒假练的机会变为了现实,如愿以偿。
另一方面,对我而言,F280049C是一款相对陌生的芯片,然而——与两年前模拟邀请赛的“翻车”经过截然不同——通过充分地学习官方指南文档,我快速上手了C2000Ware的基本使用,并能够实现基本的程序烧录;随后,根据对项目的顶层构想,我快速地整理出了完成项目可能需要用到的知识。随后,通过对各种技术文档高效的查阅与合理的使用,在覆盖了项目80%工作量的短短的两天内,尽管各种五花八门、怪异刁钻的问题层出不穷,我处理问题的效率却可以说始终是见招拆招,势如破竹,每一句代码的增减都是在无比清晰的思路下做出的决策,每一个实验的现象都是对知识体系的检验与反馈。如此的感觉,或许就是嵌入式开发令人欲罢不能的魅力所在。
受目前知识水平的限制,我在本项目的实施过程与总结报告中,可能难免出现对一些关键术语或概念的不够清晰的表述或理解,但我认为,这恰恰是使我想要进步的理由,而不是使我却步的理由。知识应该丰富而不是限制我们的想象力,在理论学习与实践的路上我将会继续不懈前进。
六、附录
(1)硬件型号与引脚连接表
本实验的硬件包括:
·TI C2000 LaunchPad
·杜邦线
·一个自制的简易按键
·正点原子CH340串口转接模块,版本为V1.2
·中景园0.96寸OLED显示屏,分辨率128*64,驱动芯片为ssd1306,颜色为蓝色单色,协议为4线SPI。 由于这款OLED可能存在多个版本,因此特将本项目所用的OLED实物图附于此处。
(2)引脚连接表
C2000板卡引脚 | 硬件引脚 | 备注 |
GPIO 0 | 自制按键 | GPIO0已在固件中设为上拉输入,并开启了内置的数字滤波功能,可以用“将GPIO0短接到GND”代替“按下按键”的操作 |
3.3V | CH340 模块的VCC | 部分CH340转接模块上,会有一个跳线帽,让VCC连接到3.3V或5V的其中一个。为了防止电平不一致导致引脚烧毁,最好将这个跳线帽(如果有)接到3.3V |
GND | CH340 模块的GND |
|
GPIO 11 | CH340模块的TX |
|
GPIO 10 | CH340 模块的RX |
|
GND | OLED GND | 表格中OLED引脚从上到下的顺序,对应于实物正面(屏幕那面)OLED引脚从左到右的顺序 |
5V | OLED VCC | |
GPIO 6 | OLED D0(别名可能为CLK、SLC) | |
GPIO 7 | OLED D1(别名可能为SDA、MOSI) | |
GPIO 8 | OLED RES | |
GPIO 9 | OLED DC | |
GPIO 12 | OLED CS |
需要注意的是,LaunchPad正面(排针的那面)标在引脚旁边的数字不是GPIO编号,LaunchPad背面(排母的那面)标注的才是GPIO编号。
(3)固件版本说明
项目附件中,名为“sci_ex1_loopback.out”的文件是最新的固件。
相比于B站视频中展示的版本,这一版固件仅仅是对一些无用的调试信息(例如在接收.obj文件时,从串口连续发回来的缓存指针位置信息,这在非16进制模式下会显示为一堆乱码)进行了移除,使得调试信息可读性更强,并增加了从串口打印文件大小这一信息。
除此之外,视频中“片上符号表建立”这一部分所展示的结构体常量数组中只列出了OLED_Clear、OLED_ShowNum、OLED_Refresh这三个函数,但在这一版固件中,可供用户调用的函数增加到了9个,不仅支持数字显示,还支持单个字符显示,以及打点、画线、画圆等,如下图所示。
这些函数的参数接口定义,可以在项目目录下的source/oled.h文件中找到。
(4)实验复现
相信很多人在看完报告或者视频效果之后,第一个质疑就是:这个程序是不是只针对报告和视频中的那个源文件才行得通?是不是我换个源文件编译,这个程序就不行了?
事实上,尽管本程序确实存在一定的局限性(下面一节将会论述),但程序从设计之初开始,就是以适配所有由cl2000.exe交叉编译器按照c11标准编译的、目标器件为F280049C的、ELF格式的.obj文件为目标进行编写的,不存在说故意把这个.obj文件的大小等参数提前录入,然后拿出来装装样子这种行为。
为了更有力地证明这一点,我们进行如下实验:
实验一
1.烧录固件到LaunchPad;
2.在CCS的安装路径下搜索“cl2000.exe”,并将其所在文件夹路径加入环境变量;
3.创建一个任意名字的、.c后缀的源文件(项目附件中,我使用的源文件名字为test_func.c);
4.在.c文件中输入图2.1的内容;
5.在项目附件解压后的最顶层目录中,除了test_func.c文件,还有一个compile_test.bat批处理文件,其中包含着调用cl2000.exe的命令,并附带一些编译选项。如果你的源文件不叫“test_func.c”,那么请将compile_test.bat文件中紧跟在“cl2000.exe”后面的“.\test_func.c”改成“.\<你的源文件名字>”,并在最后的“--output=”后面同步修改输出文件名字。输出文件最好以.c.obj为后缀;
6.在命令行输入compile_test.bat,执行编译。编译器可能会报出declared implicitly的警告,但只要没有error,就没有关系。编译结束后,会在当前目录下生成对应的目标文件,文件名就是你在--output中指定的名字;
7.按照引脚连接表正确地接线,随后将CH340模块连接上位机,同时连接LaunchPad的供电线;
8.打开正点原子的串口调试助手,按照下图设置波特率等参数;
9.点击“打开文件”,打开刚刚生成的.c.obj文件;
10.选择CH340模块对应的那个Port,点击打开串口。这一步需要格外注意:此时接收程序已经在运行了,所以不要误触“发送”,不要通过串口发送除了.c.obj文件以外的其他任何东西,否则会导致ELF文件解析失败,需要按下LaunchPad的复位并重新发送;
11.发送文件,等待串口助手发送完成;
12.在发送完成之后,将GPIO0短接到地一下(一下就好了,不用一直拉低),这将告知接收程序接收完成,开始解析;
13.片刻之后,OLED屏上会显示如下效果:
这表明,我们的程序执行成功了。
14.与此同时串口调试助手应该会显示接收到了如下信息:
其中,第一行以10进制表示了.c.obj文件的大小;第二行开始的一长串以“nomain”结尾的字符串,是这个.c.obj文件的整个字符串表;而后面输出的那些函数名,是解析程序在解析重定位表的过程中依次遇到的符号的名字。
实验二
接下来,我们将难度升级一下。注意,在执行完实验一之后,不用复位LaunchPad。因为我们的程序中没有死循环,所以解析程序跳转到nomain并执行完之后,又轮到接收程序等待着下一个.c.obj文件——等待着下一次GPIO0拉低了。
1.我们向源文件中多加入几个函数。例如简单的整数乘法和除法,并在nomain中调用它们,如下所示。
unsigned int my_add(unsigned int a, unsigned int b){
return a+b;
}
unsigned int my_mul(unsigned int a, unsigned int b){
return a*b;
}
void nomain(void){
unsigned int sum;
unsigned int mul;
unsigned int quo;
OLED_Clear();
sum = my_add(12300,45);
mul = my_mul(123,456);
quo = my_div(54321,12345);
OLED_ShowNum(0,12,mul,5,12,1);
OLED_ShowNum(0,24,sum,5,12,1);
OLED_ShowNum(0,36,quo,5,12,1);
OLED_Refresh();
}
2.清空串口助手的接收,以便观察接下来新的输出;
3.重新编译,生成.c.obj文件,并再次通过串口发送给LaunchPad。发送完成之后,同样拉低一下GPIO0;
4.这次,显示效果如下:
串口调试助手接收到的信息如下:
实验三
接下来,难度再升级。我们注意到,厂家提供的函数中没有浮点数显示函数。那么,我们就在源文件中自己定义一个简单的浮点数显示函数吧。并且除此之外,我们还要以(96,48)为圆心画一个半径为12的圆,代码如下所示:
unsigned int my_add(unsigned int a, unsigned int b){
return a+b;
}
unsigned int my_mul(unsigned int a, unsigned int b){
return a*b;
}
long my_pow(int a, unsigned int b);
long my_pow(int a, unsigned int b){
int result = 1;
while(b){
result *= a;
b--;
}
return result;
}
float my_div(float a, float b){
return (float)(a / b);
}
void OLED_ShowFloat(unsigned int x, unsigned int y, float num, unsigned int integer_len, unsigned int fraction_len, unsigned int size, unsigned int mode){
unsigned int my_char = '-';
unsigned int is_negative = num >= 0 ? 0 : 1;
unsigned int a,b;
if(is_negative)num *= -1.f;
a = (int)num;
b = (int)((num - a*1.0f)*my_pow(10,fraction_len));
if(is_negative){OLED_ShowChar(x,y,my_char,size,mode);x+=(size/2);}
OLED_ShowNum(x,y,a,integer_len,size,mode);
x+=(size/2)*integer_len;
my_char = '.';
OLED_ShowChar(x,y,my_char,size,mode);x+=(size/2);
OLED_ShowNum(x,y,b,fraction_len,size,mode);
}
void nomain(void){
unsigned int sum;
unsigned int mul;
unsigned int quo;
OLED_Clear();
sum = my_add(12300,45);
mul = my_mul(123,456);
quo = my_div(54321,12345);
OLED_ShowNum(0,12,mul,5,12,1);
OLED_ShowNum(0,24,sum,5,12,1);
float num = my_div(-2.0f,3.0f);
OLED_ShowNum(0,36,quo,5,12,1);
OLED_ShowFloat(0,48,num,2,4,12,1);
OLED_DrawCircle(96,48,12);
OLED_Refresh();
}
不过这一次,在编辑完源文件之后,我们需要修改一下compile_test.bat文件。由于引入了浮点数除法,所以我们需要加上一个编译选项“--fp_mode=relaxed”来使能浮点除法的硬件支持。注意编译选项加入的位置,只要加在源文件名之后、“--output”之前即可。
接下来,依旧是不用复位,执行compile_test.bat、清空串口助手接收、打开并发送文件、随后拉低一下GPIO0。
片刻之后,屏幕上显示出如下效果:
串口助手接收到的信息如下:
实验四
这时候,有人可能会问:那万一我的源文件中调用了一个既没有在源文件中定义,又没有在“片上符号表”中提供的函数,会发生什么呢?
事实上,程序设计时已经考虑到了这一点。我们的解析固件中定义了一个relocation_failure_default函数,其定义如下:
void relocation_failure_default(void){
return
}
可是,这岂不是什么都没做吗?没错,解析程序的处理方式是:如果未定义符号既无法在.c.obj文件中“内部消化”,也未能成功匹配“片上符号表”中的函数,那么解析程序就会将它的入口地址默认定位到这个relocation_failure_default函数。这个函数虽然什么都没做,但它存在的意义,是作为占位符号提供一个合法的地址,使得未定义函数可以跳转到这个地址又返回,相当于无事发生。
否则,倘若放任未定义函数的默认值“0x0000_0000”留在代码段中,就相当于是一条“LCR #0”指令,即尝试去跳转0x0000_0000这个地址——这种光是看着就很吓人的跳转行为是绝对会触发地址错误,然后导致CPU停止的。
为了说明这一点,我们故意在我们的源文件中加入“undefined_func0”和“undefined_func1”这两个函数,参数随便写,反正我们知道它们肯定是无法被解析的。
unsigned int my_add(unsigned int a, unsigned int b){
return a+b;
}
unsigned int my_mul(unsigned int a, unsigned int b){
return a*b;
}
long my_pow(int a, unsigned int b);
long my_pow(int a, unsigned int b){
int result = 1;
while(b){
result *= a;
b--;
}
return result;
}
float my_div(float a, float b){
return (float)(a / b);
}
void OLED_ShowFloat(unsigned int x, unsigned int y, float num, unsigned int integer_len, unsigned int fraction_len, unsigned int size, unsigned int mode){
unsigned int my_char = '-';
unsigned int is_negative = num >= 0 ? 0 : 1;
unsigned int a,b;
if(is_negative)num *= -1.f;
a = (int)num;
b = (int)((num - a*1.0f)*my_pow(10,fraction_len));
if(is_negative){OLED_ShowChar(x,y,my_char,size,mode);x+=(size/2);}
OLED_ShowNum(x,y,a,integer_len,size,mode);
x+=(size/2)*integer_len;
my_char = '.';
OLED_ShowChar(x,y,my_char,size,mode);x+=(size/2);
OLED_ShowNum(x,y,b,fraction_len,size,mode);
}
void nomain(void){
unsigned int sum;
unsigned int mul;
unsigned int quo;
OLED_Clear();
sum = my_add(12300,45);
mul = my_mul(123,456);
quo = my_div(54321,12345);
OLED_ShowNum(0,12,mul,5,12,1);
OLED_ShowNum(0,24,sum,5,12,1);
float num = my_div(-2.0f,3.0f);
OLED_ShowNum(0,36,quo,5,12,1);
OLED_ShowFloat(0,48,num,2,4,12,1);
undefined_func0();
undefined_func1(123,456);
OLED_DrawCircle(96,48,12);
OLED_Refresh();
}
随后,依旧是不用复位,编译、清空串口助手接收、发送、拉低一下GPIO0。
随后可以观察到,屏幕显示都没有任何的变化,而串口收到的信息中,多了“undefined_func0”和“undefined_func1”
这两个名字——但是也仅此而已。这表明,解析函数看似简单的处理方式,却是成功防止了未定义函数引起的非法地址跳转。
除了这几个实验之外,读者也可以进行更多的实验。然而由于作者水平所限,你在实验的过程中也许会触碰到一些
将会在下一节中提到的——亦或是作者本人至今仍未意识到的——功能局限性与运行稳定性问题。这些问题都需要在日后的学习研究中,随着理解的深入而循序渐进地改进完善。
(5)功能局限性的不完全论述
1.本程序对.c.obj文件的大小有一定的限制。固件中定义的.c.obj文件接收缓冲区大小为8KB(8192Byte),字符串表
的片上存储区大小为4KB(但实际上只支持2KB=2048Byte的字符串表,因为C2000不支持8bit,所以这个存储区实际上是用16bit=2Byte来存储一个字符的)。在sci_ex1_loopback.c的第86和第88行,分别有对这两个缓冲区的定义。
__attribute__((location(0x008000))) uint16_t rec_buffer[4096]; //8KB memory
__attribute__((location(0x00C000))) uint16_t rec_strtab[2048]; //4KB memory
理论上,可以通过增加这两个数组的大小,来增加可支持的.c.obj文件的最大大小。修改数组的同时,需要按照报告
中提到的方法来修改.map文件,并重新编译,并且这两个数组的起始地址最好都对齐4Byte(32bit)边界。
2.本程序尚未支持数据段(.data段)以及外部变量的重定位。程序只对nomain所在的代码段进行了重定位,也就是
只支持未定义函数的重定位,而且是用了一种比较取巧的方式。这意味着,一方面,我们暂时还无法实现类似于“环境变量”的那种效果,也就是在片上定义一些静态的变量,然后通过我们的源文件去访问它们;另一方面,如果我们的源文件中定义了静态的变量,那么程序也许无法被正确地解析与运行。
3.本程序尚未支持多段重定位。正如报告中指出的那样,出于简单起见,我们假设的一个大前提是:所有的函数都
位于同一个段内。这样就可以绕开.shstrtab段头字符串表,只要定位到“nomain”所在的段,就可以定位到代码段。然而,如果这个大前提不成立,那么目前的这版程序就无法完成全部重定位符号的修正了。换句话说,段重定位表与段之间是一一对应的关系,而目前的程序相当于是只解析了它遇到的首个段重定位表。
4.本程序尚未考虑对C++标准的兼容。C++标准与C标准的显著区别之一,在于符号名的修饰。与C标准不同,
如果你在.cpp源文件里定义了一个叫做“func0”的函数,那么当你在字符串表里见到它的时候,它就未必还是叫做“func0”了。此时,我们目前用的这套逐字符匹配的方法就完全失灵了——尽管要求在这样一块转为DSP设计的小板子上支持C++标准所描述的大量纷繁复杂的特性,确实听起来有点强人所难。
5.本程序尚未支持从死循环中退出。其实在某种程度上是支持的:要是你设计的nomain最终会进入死循环,那你按
复位也算是一种退出吧?退一步讲,复位中断难道不也是一个中断吗?(好!言之有理!)
6.与用户交互时的鲁棒性。在实验过程中我们提到,除了文件以外不要发送其他任何的东西,这其实就是这个问题
的一个缩影。好吧——虽然我坚持认为,误触发送破坏ELF魔数是操作者理亏在先——但是,如果你问我:“如果我GPIO0不小心拉低了很多下怎么办?如果我在接受过程中就拉低GPIO0会怎么样?串口热插拔会怎么样?……”那我只能很诚实地告诉你:“会需要你在做完这些之后去按一下复位,然后从头开始。”
*7.其他尚未遇到以及尚未意识到的局限性,将随着实验的推进、读者的指正与本人水平的日渐提高而逐一浮出水
面。