从strcpy函数移植学riscv汇编

一、如何将strcpy函数从arm移植到riscv

一个的strcpy函数,如果用c语言实现,只有以下几行代码,其目的是实现字符串的拷贝。但是如果需要拷贝的量很大的时候一个个拷贝效率可想而知是非常低的。所以在arm对于strcpy函数的实现是比较复杂的,涉及到需要拷贝的字节较少不对齐的情况,拷贝的字节数较多需要对齐等情况。

char * strcpy(char *dst,const char *src)   //[1]
{
    assert(dst != NULL && src != NULL);    //[2]
    char *ret = dst;  //[3]
    while ((*dst++=*src++)!='\0'); //[4]
    return ret;
}

但是我们的工作任务是实现函数的移植,如果非要盯着strcpy函数看arm函数是如何实现的,光这个工作量就是很复杂的,strcpy汇编的代码有245行,这玩意晦涩难懂而且就算你看懂了对于移植的帮助也不大。对于strcpy函数的理解,对于感兴趣的同学可以专门花时间去研究。接下来的内容就针对如何移植进行分析,对于整体函数的逻辑并不做过多的解释,移植工作中最重要的内容就是把arm的每一条汇编命令看懂并清楚如何用riscv的指令去实现这条指令。

二、关于寄存器

2.1 arm寄存器的使用有什么规定?

可以看到arm使用了许多的寄存器,这个strcpy函数的实现甚至一下子实现了两个相似的函数strcpy和stpcpy,本次工作只关注strcpy,所以所有if stpcpy的地方都可以忽略不看,移植的过程也直接跳过。arm对于寄存器的使用也是有一定限制的,dstin使用的是x0寄存器,src使用的是x1寄存器,在编译的时候,系统默认会将x0设定为传入的第一个参数,x1为传入的第二个参数。而之后的寄存器如何使用如何安排就看就没什么限定了,合理安排就行。

2.2 为什么有两个dst

那为什么还要搞个dst作为x2寄存器,dstin作为x0寄存器呢?那是因为函数执行的时候,我希望使用并操作dst的地址,但是当函数执行结束返回时,x0将自动作为返回地址进行返回,如果在后面函数的执行过程中直接对dst地址进行了修改,那么返回的地址将是无法预测的,很有可能函数你实现成功了但是地址返回出错导致函数最终执行失败。dstin作为dst的地址,将不进行任何操作,当函数执行结束时,x0将dstin的地址原样返回,但是需要对dst内容进行操作的部分,都是采用复制了dstin的dst执行。

2.3 arm中x寄存器和w寄存器是什么?

在定义的时候,arm对很多寄存器都定义了两个如data1定义成为了x3,data1w定义成了w3,其实这两个寄存器是一个寄存器,只是x代表着arm中的64位,而w代表着32位,在使用中的区别(影响很大)将在下面的章节中详细叙述。如果使用了x寄存器,那么意味着对寄存器64位进行操作,而w寄存器则对寄存器的0-31位的32位进行操作。
syrcpy-arm

2.4 riscv中寄存器

在移植的过程中,宏定义的办法对照着arm保持移植,给接下来的工作减少难度。定义方法如图:
strcpy-riscv

2.5 关于a寄存器,t寄存器和s寄存器

和arm类似,riscv中a0,a1是函数参数寄存器,分别代表传入的第一个参数和第二个参数。a0 ~ a7 t0 ~ t6的这些寄存器使用就是正常使用,你想怎么用怎么用。但是由于riscv是超精简指令集,arm的一句话往往需要riscv十句来实现,这种情况下寄存器会不够用怎么办?这个时候就需要使用s0~s12寄存器,保存寄存器在使用时先把寄存器暂放到栈中,等用完了再还回去,也就是说用和这个寄存器需要比a和t寄存器多一些操作。需要注意的是因为汇编函数的执行不是按照顺序执行的,而是不断地跳转。如果申请了sp寄存器,在程序运行的之后没有复原就ret退出了,会出现emulator启动后里面的文件夹呈现read only的情况。

riscv中寄存器

#保存寄存器的使用方法:    
   addi sp, sp, -8
    sd  s0, 0(sp)
    addi sp, sp, 8
   ......(期间使用了s0寄存器)
   addi sp, sp, -8
   ld  t1, 0(sp) 
   addi sp, sp, 8  

三、如何读懂arm中汇编命令

1、网上搜。v8的arm汇编命令网上的并不全,就算有也只是一般常见的指令,也不排除有些网上的东西是错的。2、查阅v8官方手册,通过介绍你可以大概知道这个命令的作用,但是由于介绍的太笼统,并不一定能看得懂。接下来要介绍两个非常重要的工具:内嵌汇编和gdb单步调试。

3.1 使用内嵌汇编的办法了解arm指令

在strcpy函数中的第132行,出现了这个命令rev has_nul1, has_nul1。刚开始接触这个命令,不知其用途,不知道有没有用,也不知道该如何实现。经过实际的汇编移植经验证实:arm中没有一条指令是多余的,任何数字或者指令的错误都会导致在ut测试的时候挂掉。经过查阅,rev的作用是实现大小端的互换,因为在arm中默认为小端模式,需要转化成为大端模式。那么如何实现大小端互换的?又如何在riscv中实现这条命令?首先可以通过内嵌汇编的方法对指令的作用进行了解,操作如下:

#include <stdio.h>
#include <private/bionic_asm.h>
#include <string.h>
int main()
{
    long src=0x1234567891234567;
    long dst=0;
     __asm__ __volatile__(
    "rev  %0, %1;"
    :"=r"(dst)
    :"r"(src)
    );
    printf("src[%lx]\n",src);
    printf("dst[%lx]\n",dst);
    return 0;
}

内嵌汇编就是在c语言中插入汇编指令,c语言在各个架构都通用,但不同的架构汇编指令是不相同的,不能通用,所以该函数需要lunch arm的环境,然后将编译好的文件push到emulator中执行才可以,在pc上的gcc默认是x86的,是没办法编译和执行arm的指令的,emulator可以模拟arm或者riscv的环境。执行后的输出结果如下:
rev内嵌汇编
第二步是通过c语言的方法实现并验证rev,具体实现的办法大家可以自己琢磨一下,就不把琢磨的过程分享了(曹老板实现的)。要确保用c写的来实现的指令和rev的指令的实现是一样的。

#include <stdio.h>
#include <private/bionic_asm.h>
int main()
{
 long src=0x1234567891234567;
 long src1=0x1234567891234567;
 long tmp1=0x00000000000000ff;
 long tmp2=0;
 long tmp3=0;
 long dst=0;

 tmp2=src1&tmp1;
 tmp2=tmp2<<56;  //8->1
 tmp3|=tmp2;
 tmp1=tmp1<<8;  //7->2
 tmp2=src1&tmp1;
 tmp2=tmp2<<40; //14,13
 tmp3=tmp3|tmp2;
 tmp1=tmp1<<8;  //6->3
 tmp2=src1&tmp1;
 tmp2=tmp2<<24; //12,11
 tmp3|=tmp2;
 tmp1=tmp1<<8;  //4->5
 tmp2=src1&tmp1;
 tmp2=tmp2<<8;
 tmp3|=tmp2;
 tmp1=tmp1<<8; //5->4
 tmp2=src1&tmp1;
 tmp2=tmp2>>8;
 tmp3|=tmp2;
 tmp1=tmp1<<8; //6->3
 tmp2=src1&tmp1;
 tmp2=tmp2>>24;
 tmp3|=tmp2;
 tmp1=tmp1<<8; //7->2
 tmp2=src1&tmp1;
 tmp2=tmp2>>40;
 tmp3|=tmp2;
 tmp1=tmp1<<8; //8->1
 tmp2=src1&tmp1;
 tmp2=tmp2>>56;
 tmp3|=tmp2;
 __asm__ __volatile__(
 "rev  %0, %1;"
 :"=r"(dst)
 :"r"(src)
 );
printf("src[%lx]\n",src);
printf("dst[%lx]\n",dst);
printf("tmp[%lx]\n",tmp3);
return 0;
}

第三步,把c语言转化成riscv汇编语言。其中也涉及到了汇编的优化,在初始的版本中直接将c转化成了汇编,造成的结果就是一条rev指令要对应riscv中几十条指令,在后续的改进中采用了循环的办法缩短了代码量,为了避免rev在指令的使用中对其他寄存器造成影响,使用了s寄存器,当再次碰到想要翻译rev命令时,现在的指令可以当做rev的万能模板使用。

 //rev has_nul1,has_nul1
    addi    sp,sp, -48
    sd      s0,0(sp)
    sd      s1,8(sp)
    sd      s2,16(sp)
    sd      s3,24(sp)
    sd      s4,32(sp)
    sd      s5,40(sp)
    addi    sp,sp, 48

    mv      s0,has_nul1 
    li      s1,0xff     //tmp1
    li      s2,0        //tmp2
    li      s3,0        //tmp3
    li      s4,56      
    li      s5,8      

.LSRC1loop1:
    and     s2,s0,s1
    sll     s2,s2,s4
    or      s3,s3,s2
    slli    s1,s1,8
    addi    s4,s4,-16
    bge     s4,s5,.LSRC1loop1
    li      s4,8
    li      s5,56

.LSRC1loop2:
    and     s2,s0,s1
    srl     s2,s2,s4
    or      s3,s3,s2
    slli    s1,s1,8
    addi    s4,s4,16
    ble     s4,s5,.LSRC1loop2
    mv      has_nul1,s3
 
    addi    sp,sp, -48
    ld      s0,0(sp)
    ld      s1,8(sp)
    ld      s2,16(sp)
    ld      s3,24(sp)
    ld      s4,32(sp)
    ld      s5,40(sp)
    addi     sp,sp, 48

3.2 使用gdb调试的办法

  • 碰到如tbz tmp1, #6, 1f这样的命令,它的作用是什么?怎么跳转的?
  • ccmp endloop, #0, #0, eqccmp干嘛用的?怎么跳转的?
  • ldr data1, [src1], #8ldp data1, data2, [src], #16对寄存器进行了哪些操作?
    很多指令在网上是找不到的,而且就算找到了你也不一定相信,还是实实在在看在机器中代码是如何操作的来的踏实些。开启办法,在c文件中调用strcpy函数,编译的时候bp文件会自动调用strcpy函数。接着在arm的emulator中将文件push进去,开启gdb调试,开启方式如图。(arm的gdb调试工具需要另外下载)
#include <stdio.h>
#include <private/bionic_asm.h>
#include <string.h>
int main()
{
    char src[16] = "12345678";
    char dst[16] = "abcdefghi";
    strcpy(dst,src); 
    printf("src[%s]\n",src);
    printf("dst[%s]\n",dst);
    return 0;
}

输入命令layout split,再输入layout next,再输入layout next,可以打开汇编和寄存器的窗口。将断点打在strcpy函数(此处可能是连接的问题,需要执行两次n程序才能识别到strcpy函数),gdb c,程序将运行到strcpy函数处,此时通过汇编命令si进行单步调试。每次执行都可以看到函数跳转的次序以及寄存器的地址的变化。
arm-gdb
arm-gdb-寄存器

四 如何找bug

汇编移植的过程中,任何细微的错误都会导致ut测试失败,除非是大牛,想要一步成型把移植函数写对是不可能的,但汇编移植的魅力就在于不断地解决bug,不断地失败,每次在寻找到新的bug然后满怀期待的去再测试,然后再失败,最终终于成功的喜悦。不同的汇编出错的地方会完全不一样,此次就以我在移植的时候找到的 bug为例讨论一下如何寻找bug以及如何解决的。

4.1 编译无法通过

编译对于bug的原谅度是非常高的,函数的具体的实现在编译过程中是完全不管的,所以只要你没有一些很低级的错误一般都是没什么问题,一些基本的错误编译器也是会提示的,比如add在riscv中是两个寄存器相加赋值给寄存器,你用成了立即数的话就会报错。但是有些错误是不会具体报错错在哪里了,比如如下情况:
第一次写完连编译都无法通过
一开始怀疑函数在该退出的地方没有退出,导致编译失败,但是即便在函数开头就加了ret还是会出现同样的情况。这个时候就要返回到函数中,根据函数执行的顺序,对代码进行依次放开并屏蔽其他代码,缩小bug出现的范围,最后发现是在写跳转的时候,制定跳转的名称和实际跳转的名称错了一个字母,那么编译器就无法辨别你要跳转到哪里去,解决之后编译顺利通过。

4.2 ldr系列命令详解

关于armv8读取和存储字节的相关命令,网上至今没有很全的攻略,在移植过程中确实也有很多这方面的问题,再次就系列的讲述一下。
arm:ldp data1, data2, [src], #16
riscv:ld data1,0(src) ld data2,8(src) addi src,src,16
ldp一次读取的是两个16个字节,将src的前8个字节给data1,后8个字节给data2,src自身的地址+16
arm:ldr data1_w, [src], #4
riscv: lw data1_w,0(src) addi src,src,4
ldr此时操作的data1_w寄存器为w型,是对32位进行操作,将src的四个字节加载给data1_w,lw命令在riscv中的作用是字加载(load word),从src中读取四个字节。读取之后src的地址+4
arm:ldr data1, [src], #8
riscv:ld data1,0(src) addi src,src,8
此时的ldr操作的寄存器data1是x型,是64位进行操作,是将src的八个字节加载给data1,ld命令在riscv中的作用是双字加载(load doubleworld)。读取完成之后src的地址+8。当有src地址+的时候,我们很容易判断到底是加载了几个字节,但如果是直接读取,并没有#4,#8的时候,就要清楚到底是加载几个字节。
arm:strh data2_w, [dst], #2
riscv:sh data2_w,0(dst) addi dst,dst,2
将data_w的两个字节存入到dst,dst自身的地址+2,同时将data2_w的高16位清零。这里为什么要涉及到16位清零呢,是因为ldr操作在基于寄存器操作的时候,不是操作32位就是操作64位,直接把每个位都进行重新赋值操作,但是此处存入2个字节,对16位进行操作,那么前对于data_w这个32位的寄存器来说,前16位很有可能还有别的信息,如果没有擦除输出的结果很有可能是错误的。但此处在移植到riscv汇编的时候,并没有进行该操作,是因为发现没这样搞ut测试也过了,保险起见可以多加一步 andi data2_w,data2_w,0xFFF
arm:strb data2_w, [dst]
riscv: sb data2_w,0(dst)
将data2_w中的一个字节存入到dst,同时将data2_w的高24位清零。此时的dst地址并不改变
arm: ldr data1, [src1, limit]
riscv: add s1,src1,limit ld data1,0(s1)
data1此处为x寄存器,所以一次读取的是8个字节,将src+limit相加后,将8个字节加载给data1
arm: ldr data1, [src1, limit]!
riscv: add src1,src1,limit ld data1,0(src1)
如果在[]后加了!,意味着src1自身的地址等于src1+limit,之后再加载8个字节给data1,如果limit寄存器修改为立即数,原理差不多。

4.3 关于伪指令

这个bug很难找,出现在memmove和setjump中,伪指令是啥?就是明明是一条指令,但是其实它代表着两条甚至多条指令,以此次找了一个月才才找到问题的tail指令为例,riscv中tail的意思如下:
tail
对比上面2.5节给出的寄存器手册可以知道,x6寄存器即t1寄存器,相信没人会一开始就知道调用了tail寄存器居然私底下还对t1寄存器进行了操作,如果在程序中没有用到t1寄存器或者t1寄存器只是临时变量的话,是不会出错的,但是如果t1寄存器在下文中还要继续使用,那么岂不是破坏了其中的值,自然就出错了。这个bug无解,没有调试工具也没有其他方法,单纯考验对汇编命令的熟悉程度。

4.4 关于跳转问题

在arm中我们可以看到很多函数的跳转
ands limit, limit, #7 b.eq .Lnot_limit
这种就比较好理解,如果limit与7相与之后,是否等于0,如果等于0,那么就跳转到.Lnot_limit处,函数的某处会有对.Lnot_limit的具体执行
来讲讲这种没有具体的名字往往是数字,1,2,3之类的,tbz的意思是判断第5位是否为0,如果为
0,那么向下跳转到1:,这个向下跳转会跳转到最近1f,如果是1b的话,就不是向下跳转了,而是向上跳转。

1:
    tbz     tmp1, #5, 1f
    str     data1_w, [dst], #4
    lsr     data1, data1, #32
1:
    tbz     tmp1, #4, 1f
    strh    data1_w, [dst], #2
    lsr     data1, data1, #16
1:
    tbz     tmp1, #3, 1f
    strb    data1_w, [dst]

接下来讲一个比较复杂的跳转问题,先说执行的效果:此处limit-1之后,要满足limit-1后是否是无符号数大于0,且满足data1w大于等于1,且满足data1w等于data2w才会跳转到Lbyte_loop,不满足的话直接跳转到Ldone

    subs	limit, limit, #1
	ccmp	data1w, #1, #0, hi	/* NZCV = 0b0000.  */
	ccmp	data1w, data2w, #0, cs	/* NZCV = 0b0000.  */
	b.eq	.Lbyte_loop
.Ldone:
    sub	result, data1, data2
    ret

ccmp data1w, #1, #0, hi,hi是比较limit-1是否大于0,下一步的cd比较的是data1w和立即数1的大小,那么第三个参数#0干嘛用的?这个第三个参数就是NZCV的条件标志位,如果满足条件,cmp在比较data1w和立即数1的时候设定新的标志位,如果不满足条件,此时的NZCV被置零,如果是立即数2,那么C位被设定为1。
如果第一次ccmp执行失败,此时的条件标志位设置为0,判断在cs判断的时候,需要条件标志位C为1才行,由于上一步将NZCV已经全部设置为0,那么很明显失败了,失败了就继续执行第三个参数,将NZCV设为0。
接下来执行到b.eq函数,失败后的NZCV依然是0,但是eq需要的条件标志位为1,那么依然不执行跳转操作,直接继续执行.Ldone。最后的效果就是必须满足全部的条件,才能执行到.Lbyete_loop,下图中的条件码中置位意味着需要标志位为1。ccmp中关于条件位跳转的知识点是个难点。
Arm条件码

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值