1. beq的跳转范围只有1 << 12
在之前的开发中遇到了beq跳转到外部的引用时出错的情况,当时跳转惊叹居然还有跳转范围这个说法,只晓得j的跳转范围比beq大,但为什么会有跳转范围这个说法,如何解决的,如果跳转的范围连j都无法满足了怎么办?本篇文章将基于android中assembler的处理方式,来看看如何处理的。
上图展示了beq的跳转范围,取1~12位,跳转的有效范围最大为4096字节,也就是1024条指令。超过1024条指令的跳转,beq就无法满足了。在工具链编译的过程中还不支持长跳转,这也是为什么我们之前在移植汇编解释执行的过程中beq识别到offset超出范围之后直接报错。
除了beq,riscv的绝大多数汇编指令支持的立即数有效范围都只有12位比如addi,最多的是j,可以识别20位。但在实际开发的过程中又是如何实现32位,甚至64位的操作的呢?通过beq扩容的过程,顺道对其他指令的操作过程也会进行了解,assembler通过c++巧妙的完成了所有的事情。通过学习该部分内容,对C++也会有更深的理解。
2. 每一个跳转都对应一个类型,type
对beq来说,属于条件跳转,默认的跳转类型为kCondBranch。每个branch的都对应相对应的type,也对应着不同的信息存放在结构体BranchInfo中,比如第一个元素表示branch的len,如果beq扩容就需要三条指令来帮助它完成跳转,当type转化成kLongCondBranch后,len变成了3。
const Riscv64Assembler::Branch::BranchInfo Riscv64Assembler::Branch::branch_info_[] = {
// short branches (can be promoted to long).
{ 1, 0, 0, Riscv64Assembler::Branch::kOffset21, 1 }, // kUncondBranch
{ 1, 0, 0, Riscv64Assembler::Branch::kOffset13, 0 }, // kCondBranch
{ 1, 0, 0, Riscv64Assembler::Branch::kOffset21, 0 }, // kCall
// Near label.
{ 2, 0, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLabel
// Literals.
{ 2, 0, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLiteral
{ 2, 0, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLiteralUnsigned
{ 2, 0, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLiteralLong
// Long branches.
{ 2, 0, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLongUncondBranch
{ 3, 1, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLongCondBranch
{ 2, 0, 0, Riscv64Assembler::Branch::kOffset32, 0 }, // kLongCall
};
关于跳转指令生成机器码的步骤就不再细讲,感兴趣可以看之前的文章:Assembler如何把跳转汇编变成机器码的
type的作用在将跳转指令编译成机器码的时候,EmitBranch会根据type类型调用不同的方式生成机器码。那么如果beq本身是需要扩容的,在EmitBranch函数被调用的时候就要更新type类型,如果是长跳转,type应该为kLongCondBranch。接下来我们来看type是如何一步步从kCondBranch变成kLongCondBranch的。
2.1 Branch调用类的构造函数,初始化type
在test.cc中,beq首先会调用Bcond函数,该函数在计算target之后会调用类容器,调用类的构造函数生成对象存入容器中。来看看Branch的构造函数做了什么。
void Riscv64Assembler::Bcond(Riscv64Label* label,
BranchCondition condition,
GpuRegister lhs,
GpuRegister rhs) {
// If lhs = rhs, this can be a NOP.
if (Branch::IsNop(condition, lhs, rhs)) {
return;
}
uint32_t target = label->IsBound() ? GetLabelLocation(label) : Branch::kUnresolved;
branches_.emplace_back(buffer_.Size(), target, condition, lhs, rhs); // branches为类容器,会调用Branch类的构造函数
FinalizeLabeledBranch(label);
}
Riscv64Assembler::Branch::Branch(uint32_t location,// 构造函数重载,根据参数类型选择构造函数
uint32_t target,
BranchCondition condition,
GpuRegister lhs_reg,
GpuRegister rhs_reg)
: old_location_(location), // 构造函数列表初始化
location_(location),
target_(target),
lhs_reg_(lhs_reg),
rhs_reg_(rhs_reg),
condition_(condition) {
...
InitializeType(kCondBranch); // 初始化type,跳转类型默认为kCondBranch
}
void Riscv64Assembler::Branch::InitializeType(Type initial_type){
OffsetBits offset_size_needed = GetOffsetSizeNeeded(location_, target_);
// 如果向前跳转的情况,target是已知的,在已知的情况下,先计算一下是否需要扩容
switch (initial_type){
...
case kCondBranch:
...
switch (condition_) {
case kUncond:
InitShortOrLong(offset_size_needed, kUncondBranch, kLongUncondBranch);
break;
default:
InitShortOrLong(offset_size_needed, kCondBranch, kLongCondBranch);
// 计算offset_size_needed结果,给定type的值
break;
}
break;
...
}
}
// 如果target已知,计算offset的数值范围是否超过了1 << 12
Riscv64Assembler::Branch::OffsetBits Riscv64Assembler::Branch::GetOffsetSizeNeeded(uint32_t location,
uint32_t target) {
// For unresolved targets assume the shortest encoding
// (later it will be made longer if needed).
if (target == kUnresolved) // 如果是向后跳转,则默认offset为13位
return kOffset13;
int64_t distance = static_cast<int64_t>(target) - location; // 计算跳转的偏移值
// To simplify calculations in composite branches consisting of multiple instructions
// bump up the distance by a value larger than the max byte size of a composite branch.
distance += (distance >= 0) ? kMaxBranchSize : -kMaxBranchSize;
// kMaxBranchSize 为branch分支的最大值,因为一条branch可能不止有一条指令,在没有处理之前dis是branch的loc到tar的距离
// 加上之后就是branch的end地址到tar的距离,是真正的offset
if (IsInt<kOffset13>(distance))
return kOffset13;// 判断dis的绝对值是否超过了13位的表示范围,返回offset的数量级范围
else if (IsInt<kOffset21>(distance))
return kOffset21;
return kOffset32;
}
在调用构造函数Branch生成对象的时候,调用InitializeType
来对branch的跳转类型进行初始化。如果跳转是向后跳转,此时的target还是未知的,在这种情况也自然也无法判断offset是否超过13位的表示范围,默认是不超过的,设定type的类型为kCondBranch
。等target在被计算出来之后再来计算。
如果是向前跳转,那么target已经被计算出来了,就可以判断target到loc的这个offset,是否是13位的有限表示范围,如果是,type还是默认的kCondBranch
,如果计算出offset超出了13位的有效表示范围,则设定type为kLongCondBranch
。
2.2 更新type
branch经过Bind之后,就会计算出Branch即此时的beq的跳转tar,流程具体:Assembler如何把跳转汇编变成机器码的
在除了跳转之外的汇编机器码都生成之后,接下来的任务就是生成跳转本身的机器码。在进入EmitBranches
生成机器码之前,还要进行是否跳转范围不够的情况下的扩容,其实现位于PromoteBranches
中
void Riscv64Assembler::PromoteBranches() {
// Promote short branches to long as necessary.
bool changed;
do {
changed = false;
for (auto& branch : branches_) { // 遍历容器中的对象,对每个跳转进行检查是否需要升级为长跳转
CHECK(branch.IsResolved());
uint32_t delta = branch.PromoteIfNeeded();
// 是否需要升级为长跳转,如果是,则Branch的len会变长,设置type,返回新旧Branch的大小差
// If this branch has been promoted and needs to expand in size,
// relocate all branches by the expansion size.
if (delta) {
changed = true;
uint32_t expand_location = branch.GetLocation();
// 如果需要扩容,则对容器中每一个对象进行遍历,所有在当前branch之后的tar和loc都因此要更新,更新的大小为delta
for (auto& branch2 : branches_) {
branch2.Relocate(expand_location, delta);
}
}
}
} while (changed);
...
}
那么type在哪一步被设置了呢?uint32_t delta = branch.PromoteIfNeeded();
字面意思,在调用promote的时候通过这个函数来判断是否需要将短跳转升级为长跳转。
uint32_t Riscv64Assembler::Branch::PromoteIfNeeded(uint32_t max_short_distance) {
// If the branch is still unresolved or already long, nothing to do.
if (IsLong() || !IsResolved()) {
return 0;
}
// Promote the short branch to long if the offset size is too small
// to hold the distance between location_ and target_.
if (GetOffsetSizeNeeded(location_, target_) > GetOffsetSize()) {// 计算loc到tar的距离
PromoteToLong(); // 如果进入此处,说明offset超过了13位有效表示范围,表示需要升级为长跳转,第一件事情就是讲type设置为kLongCondBranch
uint32_t old_size = GetOldSize();
uint32_t new_size = GetSize();
CHECK_GT(new_size, old_size);
return new_size - old_size;
}
...
GetOffsetSizeNeeded
在2.1中已经讲过,其实就是计算offset,branch到tar的距离,并返回offset是哪个数量级的,如果可以用13位表示,返回的就是13,如果超过了就会返回offset的相应表示位数。PromoteToLong
则真正完成了将type设置为kLongCondBranch
的步骤
void Riscv64Assembler::Branch:: PromoteToLong() {
switch (type_) {
// R6 short branches (can be promoted to long).
case kUncondBranch:
type_ = kLongUncondBranch;
break;
case kCondBranch:
type_ = kLongCondBranch;
break;
case kCall:
type_ = kLongCall;
break;
default:
// Note: 'type_' is already long.
break;
}
CHECK(IsLong());
}
此时type的类型已经被正确的计算出来了,但是只是计算出了type可远远不够。因为beq升级为长跳转的方式是将本来一条指令实现的调整通过多条指令来实现,那么这个过程就会将branch的本身大小扩容,这个扩容不是随便扩一下就行了。因为除了跳转指令之外,所有的汇编已经在bffer中生成了机器码,而且都有相应的位置。除此之外,由于需要在一个队列中多插入指令,那么所有的branch的loc和tar都要重新判断一下是不是需要重新定位。
打个比喻,一堆书,beq就是其中的一本,但是后来beq不满足了,本来一本书就行,现在要扩成三本,那么在把这三本书塞到原来beq代表那本书的位置,所有的书的位置关系图都要重新梳理。
3. 重置所有branch的tar和loc
把目光重回PromoteBranches函数
void Riscv64Assembler::PromoteBranches() {
// Promote short branches to long as necessary.
bool changed;
do {
changed = false;
for (auto& branch : branches_) { // 遍历容器中的对象,对每个跳转进行检查是否需要升级为长跳转
CHECK(branch.IsResolved());
uint32_t delta = branch.PromoteIfNeeded();
// 是否需要升级为长跳转,如果是,则Branch的len会变长,设置type,返回新旧Branch的大小差
if (delta) {
changed = true;
uint32_t expand_location = branch.GetLocation();
// 如果需要扩容,则对容器中每一个对象进行遍历,所有在当前branch之后的tar和loc都因此要更新,更新的大小为delta
for (auto& branch2 : branches_) {
branch2.Relocate(expand_location, delta);
}
}
}
} while (changed);
...
}
此时的dalta已经计算出brach因为升级之后,扩容的大小。然后遍历容器中的所有对象,要对所有的跳转指令进行检查,检查的内容如下:
void Riscv64Assembler::Branch::Relocate(uint32_t expand_location, uint32_t delta) {
if (location_ > expand_location) { // 当前branch 之后的所有branch loc += delta
location_ += delta;
}
if (!IsResolved()) {
return; // Don't know the target yet.
}
if (target_ > expand_location) {
target_ += delta; // 如果有branch的tar在当前branch之后,则tar += delta
}
}
此时,已经通过双重for循环更新了所有branch的tar和loc,type,但是更新完之后的值真的是正确的么?其实不然,为什么?因为每个branch都去判断,然后需要扩容的扩,接着改变所有branch的tar loc但是还有一个特别危险的因素,本来是不需要扩容成长跳转的branch在某些branch扩容的过程中,需要长跳转了。在这种情况下,是不是如何调用beq emit的时候,type是短条件跳转,但是offset的范围却不是13位的有效数。
怎么办?每一次的branch的过程都是牵一发动全身,所以Android assembler非常巧妙地又嵌套了一层循环,do 直到所有的扩容完成后type与offset都对应上才结束。三层的嵌套循环,运用的是在厉害。
4 移动代码块
此时,所有的branch的loc,tar和type已经更新完最新的值,该升级成长跳转的已经升级完了。但是buffer中的代码块并没有任何改动,改变的只是容器中Branch对象中成员变量的信息。接下来就要根据容器中该信息来移动buffer中机器码的位置。
void Riscv64Assembler::PromoteBranches() {
...
size_t branch_count = branches_.size();
if (branch_count > 0) {
// Resize.
Branch& last_branch = branches_[branch_count - 1]; // 获得容器中最后一个对象
uint32_t size_delta = last_branch.GetEndLocation() - last_branch.GetOldEndLocation();
// 将容器中最后一个对象的new loc - old loc,此时的new loc其实就是last branch的当前loc
// 当前loc已经被扩容而增加过了,就是new loc,而old loc在构造函数初始化的时候,把最开始的loc赋值给了old loc
// size_delta 就是buffer需要扩容的大小
uint32_t old_size = buffer_.Size();
buffer_.Resize(old_size + size_delta);
// 将buffer的size扩容成最新的大小
// Move the code residing between branch placeholders.
uint32_t end = old_size;
// end此时为原buffer的size
for (size_t i = branch_count; i > 0; ) {
Branch& branch = branches_[--i];
// 从后往前,取容器中的对象
uint32_t size = end - branch.GetOldEndLocation();
// size为两个branch之间的代码块需要移动的大小
buffer_.Move(branch.GetEndLocation(), branch.GetOldEndLocation(), size);
// 将起始位置为branch.GetOldEndLocation(),大小为size的这一段代码块,移动到branch.GetEndLocation()位置。buffer_.Move的实现是通过memmove来实现的。
end = branch.GetOldLocation();
// 设置end为当前branch的old loc
// 循环的目的是以两个branch的old loc之间的size为单位,移动到branh的new loc
}
...
}
每个branch在之前都更新了tar和loc,但生成的对象中还维护这一个成员变量old loc,记录branch在buffer中原来的地址。在对buffer中的代码块进行位移的之后,取两个最近的buffer之间的size,以此为单位移动到branch的new loc,循环结束后,所有的code移植工作就已经完成了。
接下来仅剩将branch本身在buffer中生成机器码的过程了。在结束了FinalizeCode()->PromoteBranches()之后,buffer中需要升级为长跳转的branch已经升级完成,相关占位也已经完成,真正生成branch机器码的过程位于FinalizeInstructions()->EmitBranches()中。
case Branch::kLongCondBranch:
// input parameter 4 in EmitBcond instruction is to skip the following auipc and jalr instruction if the condition is not fulfill
offset += (offset & 0x800) << 1; // Account for sign extension in jalr.
EmitBcond(Branch::OppositeCondition(condition), lhs, rhs, 12);
// 如果满足相反的情况,说明实现跳转,那么直接跳转3*4,直接跳过该长条件跳转
CHECK_EQ(overwrite_location_, branch->GetOffsetLocation());
Auipc(AT, High20Bits(offset)); // 取offset的高20位
Jalr(ZERO, AT, SignExtend64<kIImm12Bits>(offset));
break;
EmitBranches()中开始遍历容器中的对象,根据type类型生成机器码,如果是条件跳转则跳转到对应的条件跳转中调用对应的emit生成对应的机器码,对于长条件跳转其实是通过三条指令来完成的。
首先是选取跳转的相反的指令,然后使用auipc和jalr来实现跳转,如果是beq a0,a1, offset
,则被替换为三条指令, bne a0, a1, 12 auipc jalr
替换成相反的指令作用是如果满足相反的情况,说明beq的跳转不会实现,如果不会实现,就没有必要实现长跳转,直接跳转jalr之后即可。如果不满足bne,说明会走beq的条件跳转,那么继续执行。
最长距离的跳转,也无法满足32位的跳转。在这种情况下,可以将offset的高20位保存在一个临时寄存器中,然后通过jalr的跳转指令,基于该临时寄存器偏移offset的低12位进行跳转,即可实现32位的跳转。
Auipc(AT, High20Bits(offset));
取offset的高20位,static_cast<uint32_t>(offset>> 12);
右移12位之后,auipc在实现的时候会将这个立即数左移12位,所以去的是高20位的数。
void Riscv64Assembler::Auipc(GpuRegister rd, uint32_t imm20){
EmitU(imm20, rd, 0x17);
}
void Riscv64Assembler::EmitU(int32_t imm20, GpuRegister rd, int opcode){
uint32_t encoding = static_cast<uint32_t>(imm20) << 12 |
static_cast<uint32_t>(rd) << 7 |
opcode;
Emit(encoding);
}
Jalr(ZERO, AT, SignExtend64<kIImm12Bits>(offset));
AT寄存器位offset的高20位,跳转到基于AT寄存器的偏移量的地址中,这个偏移量就是offset的低12位。SignExtend64<kIImm12Bits>(offset)
实现: return int64_t(offset) << (64 - 12) >> (64 - 12);
理论上是这么理解的,但是实际上还存在一个问题:符号位。
4.1 如何实现32位的跳转
jalr的有效跳转范围是相对于寄存器的12位立即数。
在取立即数的低12位的时候,采用的办法是将立即数转化成64位之后,左移52位,右移52位,那么如果第11位为0, 那么整个offset在传递给jalr的时候就变成了负数。显然这不是我们想要的结果。
这个时候再把目光移动到这条:offset += (offset & 0x800) << 1;
如果offset的第11位0,这条指令就不存在。但如果符号位为1,这里我们来举个例子:offset为0x2800,第11位位1。
经历了offset += (offset & 0x800) << 1;
之后,offset变成了0x3800
在auipc取高20位的时候,auipc AT, 0x3
0x3就是offset高20位的数,将0x3左移12位加上pc的值给AT,AT得到的是高20位的值,为pc+0x3000
在jalr阶段,将offset扩展为64位,左移52位,右移52位之后得到 0xFFFFFFFFFFFFF800
寄存器 AT+ 这个值(px+0x3000 + 0xFFFFFFFFFFFFF800),将会得到pc+0x2800
而0x2800就是offset原本的值。回顾一下,其实就是把符号位不要保留在低12位,而是留存到高20位取保存。auipc在截取高20位的时候,得到了两个符号位所代表的数,在jal取低12位的时候,如果符号位位1,会让这个值变成负数,但是在最终实现的时候,却是2倍符号位的数加上一个负的符号位的数,得到的还是offset最本身的值。
至此,原本13位的条件跳转就扩容到了32位的长条件跳转。此时还有一个疑惑,64位的操作系统不是拥有极大的跳转范围么?那么此时的跳转也不过仅仅支持32位的跳转,难道64位的操作系统也只有这么大的跳转需求?是不是32位的操作系统和64位的操作系统的虚拟空间大小只有4g?
不过总体而言,在branch条件跳转空间不够用的情况下,已经成功地将原本只支持13位的offset扩容成32位的偏移值。其中涉及到了branch中成员变量type的修改,branch中tar和loc的偏移,根据branch中的new loc和old loc实现code的偏移。在汇编中用起来看似合理的对label的跳转,实际上经过了assembler的精心的处理。处理的过程,不得不佩服其逻辑缜密,没有一丝多余的代码。
至于为什么beq不一下子支持32位的跳转呢?因为beq指令只给了它四字节的内容,这个四字节需要实现的功能是将两个寄存器和一个跳转地址全都塞进这个32位的数,那么留给offset本身的大小只有4字节。那么为什么一条指令编译成机器码的大小只有四字节呢?如果一条指令可以编译成8字节的话岂不是可以实现长范围的跳转? 可能的原因是节省空间吧……如果每一条汇编指令都翻译成8字节,那么对内存的需求直接翻倍,在内存比较昂贵的时代显然没有必要。
但是未来假如内存的价格和能力可以支持一条汇编指令翻译成8字节的时候,assembler本身的工作量将会减少很多,可以直接读取一个64位的数,也可以直接实现两个大位数的数相加,也可以直接跳转到一个大地址,少了很多拼接和判断的过程,运算的速度会提高很多。