站在巨人的肩膀上,才能看得更远。
If I have seen further, it is by standing on the shoulders of giants.
牛顿
这是向MIPS架构移植软件的问题系列之第四篇。在前三篇文章
*《MIPS架构深入理解8-向MIPS架构移植软件之大小端问题》
*《MIPS架构深入理解9-向MIPS移植软件之Cache管理》
*《MIPS架构深入理解10-向MIPS移植软件之内存序》
中,我们分别讨论了大小端模式、Cache和内存序对于移植代码的影响。那么本文,我们再从编程语言的角度,思考一下移植代码时应该注意的事项,尤指底层代码或操作系统代码。
大部分编程人员,可能习惯了C或C++语言,而MIPS架构缺乏特殊的I/O操作指令。这意味着,要想访问I/O寄存器,只能使用load或者store之类的指令,通过恰当的操作来实现。但是,I/O寄存器的访问有一些限制,因此,必须确保编译器不能太聪明,编译出了违背我们意愿的结果。另外,MIPS架构使用了大量的CP0寄存器,我们也可以使用C语言的伪汇编asm()
方法进行操作。
1 封装汇编代码
对于GCC编译器,几乎是家喻户晓,其允许在C文件中封装汇编代码。当然了,其它编译器也支持,只是语法上不同罢了。在这儿,我们只以GCC进行举例;至于其它的编译器,请自行google或者baidu。如果,想要写一个高效计算的库函数之类的,可以使用纯MIPS汇编语言进行编写;但是,如果只是想在某个C文件中,插入一小段汇编语言,可以使用asm()
伪指令实现。甚至,你可以让编译器根据一些约定,自行选择使用的寄存器。
比如说,下面的这段代码,调用乘法指令mul
,就可以在绝大数的MIPS架构CPU上运行。我们可以注意到,mul
指令后面跟着三个源操作数。如果我们直接使用C语言的*
乘法操作符,生成的乘法汇编指令一般只使用两个操作数,而且隐含地将生成的double类型的结果保存到hi/lo
寄存器中。
下面这段伪汇编代码实现的mymul
乘法函数,使用了三目乘法指令mul
,只保存double型结果的低有效部分到p变量中,高有效部分被抛弃。由我们自己决定如何避免溢出或者其它不相干的事情。
static int __inline__ mymul(int a, int b) { int p; asm( "mul %0, %1, %2" : "=r" (p) : "r" (a), "r" (b) ); return p; }
函数本身被声明为inline内联函数,这意味着应该使用该函数逻辑代码的拷贝去替代调用这个函数的地方的代码(这允许局部寄存器优化)。使用static进行限定,不允许其它模块文件调用该函数,所以,不会生成这个函数本身的二进制代码。封装asm()代码时,经常会这样干。然后,将这个伪汇编代码放到某个include文件中。当然,也可以使用C语言预处理宏来进行定义,但是,使用inline函数更简洁一些。
上面的代码,告知GCC,传递给汇编器一个MIPS的mul
指令,具有三个操作数,一个是输出,两个是输入。
%0
的意思就是指向索引为0的变量,也就是p
。首先,我们使用=
修改符指明这个值是write-only
的;其次,通过符号r
告诉GCC,可以自由选择任何一个通用寄存器保存这个值。
asm()
中的第3行代码,告诉GCC,操作数%1
和%2
分别是a
和b
,并且允许GCC将其保存到任何通用目的寄存器中。
示例函数的最后,就是表明,把结果返回给调用者。
从上面的示例可以看出,GCC允许对操作数进行相当自由的控制。你可以告诉某个值可读可写,某些寄存器可能会留下毫无意义的值等。详细的使用方法可以参考GCC手册中关于MIPS架构的部分章节内容。
2 内存映射的I/O寄存器和volatile
因为在MIPS架构中,将所有的I/O寄存器映射到内存上,可以很容易使用C语言编写代码进行访问。所以,不到迫不得已,不要使用汇编语言操作这些I/O寄存器。我们已经说过,随着编译器的发展,或者在你的代码中使用了大量的C++代码,很难预测最终生成的汇编指令的顺序。下面我们将再谈论一些老生常谈的问题。
下面是一段代码,用来轮询串口的状态寄存器。如果准备就绪,就发送一个字符:
unsigned char *usart_sr = (unsigned char *) 0xBFF00000; unsigned char *usart_data = (unsigned char *) 0xBFF20000; #define TX_RDY 0x40 void putc (ch) char ch; { while ((*usart_sr & TX_RDY) == 0); *usart_data = ch; }
这段代码,编译器很可能将映射到内存上的寄存器变量usart_sr
,视作一个不变的变量;而在while循环中也没有存储按位与表达式的结果的地方,编译器可能会自作主张的将其保存到一个临时变量中。最终,上面的代码可能等效于下面的代码。结果可能就是一直发送某个字符,也可能一直无法输出。
void putc(ch) char ch; { tmp = (*usart_sr & TX_RDY); while (tmp); *usart_data = ch; }
为了避免这种情况,我们必须让编译器意识到,usart_sr
是一个随时变化的值的指针,不能被优化。方法就是添加限定符volatile
,如下所示:
volatile unsigned char *usart_sr = (unsigned char *) 0xBFF00000; volatile unsigned char *usart_data = (unsigned char *) 0xBFF20000;
相似的情况,也可能发生在中断或者异常处理程序中要修改的变量身上。同样的,可以使用volatile
进行限定。但是,你需要避免像下面的代码那样使用volatile
:
typedef char * devptr; volatile devptr mypointer;
本意是想告诉编译器,重新从char *
类型的指针处加载数值,但是使用上面的方式,没有起到任何作用。应该如下所示,进行声明:
typedef volatile char * devptr; devptr mypointer;
通过上面的讨论过程,我们可以看出使用C编写驱动程序要更容易一些,代码的阅读性也更好。但是,你需要充分理解硬件行为和工具链生成机器指令的方式,保证系统按照想要的行为进行工作。
3 在MIPS架构上使用C编写程序时的一些其它问题
- 负指针
当在MIPS架构上运行比较简单的程序时,一般直接运行在非映射内存区,也就是kseg0
或kseg1
区域时,所有32位数据指针的最高位都置1,看起来像是一个负数。而在其它架构上,运行这种程序一般都在低于2G的内存地址上,也就是直接对应物理地址。所以,MIPS架构的这种负指针,如果对其进行比较运算的话,指针可能会隐式地被转为一个有符号的整数类型。所以,在进行指针和某个整数进行比较的时候,一定要显式地指定为无符号整数类型,比如unsigned long
。大部分的编译器都会对指针向integer类型进行转换时给出警告。 - 有符号与无符号字符类型
早期的C编译器,char类型一般用于string,通常是signed char类型;这与为了获取更大整数值的约定是一致的。但是,当处理超过127的字符编码时,比如转换或者比较,就会很危险。现代编译器一般都将char型等同于unsigned char类型。如果发现你的旧代码依赖于char类型的默认符号扩展,一定检查编译器是否有选项,恢复这个传统的约定。 - 16位int类型数据的使用
当我们从16位的机器架构的程序,比如x86或者ARM等,移植到MIPS架构上时,一定要注意最大值、溢出和符号位扩展。笨方法就是,直接将这些程序的int型替换成short类型,但这需要时间和耐心😊。大部分时候,可以直接使用MIPS架构的32位int类型替换。但是,需要特别注意的是signed类型比较时的bit16的溢出问题。
还有就是,使用两个16位整型数拼凑成一个32位整型数时,一定要使用无符号16位整型数。笔者在移植ARM架构的操作系统到MIPS架构上时,就是使用了signed short
类型的2个变量拼接成一个32位整数时,由于符号位扩展的原因(高16位全部被填充为1)导致高位数一直无法生效。 - 堆栈的使用
尽管MIPS架构缺乏对堆栈的支持,但是MIPS-C编译器还是实现了一个常规的栈结构,主要就是按照某种约定,指定通用寄存器作一些特殊的用途,比如使用哪几个寄存器传递函数参数,使用哪个寄存器作为stack指针寄存器等等。话虽如此,不要想当然的认为,堆栈就可以安全的移植了。必要的时候,使用下面的2个方法-宏和库函数-解决堆栈的问题:
- stdargs:
使用头文件,定义宏,允许函数接收可变参数。 - alloca():
使用这个函数动态分配内存。有些编译器实现alloca()为内嵌函数,来扩展堆栈;也可以使用单纯的库函数实现。但是,不要假设堆栈和其分配的内存有什么关系。