Files
revng-revng/lib/StackAnalysis/OVERVIEW.md
Alessandro Di Federico 7fe00c08dd Rewrite the stack and introduce the ABI analyses
This is a very large commit importing the reviewed (and heavily
simplified) stack analysis and the new ABI analysis, which provides
information on the calling convention of each function and so on.

For an overview of the new analyses please consult OVERVIEW.md.
2018-09-18 15:58:20 +02:00

16 KiB

This document describes from an high level point of view the stack analysis and its components.

The StackAnalysis pass

The StackAnalysis pass is where everything begins. Its run method does the following:

  • It keeps an instance of the Cache class, which holds the results of the analyses for each function analyzed so far. The Cache also identifies, for each function call, where the return address is stored (i.e., the link register or the top of the stack) and what's the most common place to store the return address (an information that will be employed for functions that have no direct calls).
  • It identifies the function entry points. They are divided in two sets, one for the entry points that are highly likely to represent a function (in particular, those that are target of a direct call) and then the rest of possible candidates.
  • It runs the analysis on the first set of functions.
  • It runs the analysis on the functions of the second set whose entry basic block has not been identified as being part of a function of the first set.
  • It collects the results of the analysis in ResultsPool.
  • It produces the final version of the results contained in ResultsPool, obtaining a FunctionsSummary object.

The InterproceduralAnalysis

What we called the analysis is actually InterproceduralAnalysis. A run of the InterproceduralAnalysis analyzes a single function and collects the results of the analysis in a ResultsPool object. The ResultsPool contains intermediate information that will be then merged into the final object exposed by the analysis, i.e., FunctionsSummary. Note that the Cache is used exclusively for handling function calls during the intraprocedural analysis, it has nothing to do with the collection of the final results, which is handled by ResultsPool and FunctionsSummary.

To perform the analysis, InterproceduralAnalysis performs one or more Intraprocedural::Analysis, starting from the entry point, but, if necessary, analyzing all the functions in the call graph starting from the original entry point.

In practice, each time an Intraprocedural::Analysis meets a call site targeting a function not already analyzed (i.e., not present in Cache), the Intraprocedural::Analysis is suspended and the control is returned to the InterproceduralAnalysis that will start a new Intraprocedural::Analysis for the callee. The list of suspended Intraprocedural::Analysis is recorded on a stack, whose top element is the currently running analysis.

The InterproceduralAnalysis also handles recursion. In case the Intraprocedural::Analysis has been suspended due to a uncached function call, the interprocedural part will detect if the callee is a function already present in the stack of the currently in-progress analyses and, in such case, it will inject a temporary top entry in the cache. The intraprocedural analysis can now proceed with a safe (and pessimistic) assumption.

Once the analysis of the recursive function is terminated, as usual, it is recorded in the cache. If the result is different from the cached one, the analysis is restarted from scratch, but in this case the recursive call site will use the more accurate information provided by the last analysis. This process is repeated until a fixed point is reached, i.e., the result of the analysis matches the one already present in the cache.

The intraprocedural analysis (stack analysis)

The core goal of Intraprocedural::Analysis is to analyze how the registers are used in a function and the extension of the function itself.

In particular, the intraprocedural analysis can:

  • detect if a register is a callee-saved register in a function;
  • if an indirect jump is a return instruction;
  • if a function misbehaves with the stack and should therefore be considered a "fake function", i.e., a function to inline in the callers;

The intraprocedural analysis (in the StackAnalysis::Intraprocedural namespace) is an instance of the MonotoneFramework class, and, as such, it has a couple of interesting parts: the lattice and the transfer function.

The intraprocedural analysis is a forward analysis.

The lattice

The lattice is defined by the Element class. The Element class is basically a container for a set of AddressSpace. An Element can have 0 or 3 address spaces.

The first AddressSpace (or "alias domain") represents the CPU state (CPU), the second the global variables and the heap (GLB), while the third is for the the stack frame of the current function (SP0).

An AddressSpace tracks its content in the form of "slots" (ASSlot), i.e., a pair of the identifier of an address space and an offset within it. For the CPU address space, the offset represent the index of the CSV.

The GLB address spaces is also used to track constants. This means that the constant 42 will be represented as an ASSlot relative to GLB with offset 42 (GLB+42).

Note that our analyses currently ignore overlapping slots. Note also that overlapping slots are not possible in the CPU address space (CSVs never alias).

An AddressSpace associates to each slot a Value. A Value is composed by two ASSlot fields: a "direct content", i.e., the content of the slot in a certain program point (according to our analysis) and a "tag". A tag represents the fact that we are not able to track the actual content of that slot statically, but we know that it contains the value that another slot contained at the entry of the function. The tag is useful to represent, e.g., the information that a certain stack slot (where the value of a callee-saved register is saved) contains the initial value of a register, or that an indirect jump is targeting a Value representing the initial value, e.g., of the link register. In the latter case, we basically proved that the indirect jump is actually a return instruction.

The intraprocedural analysis associates to each SSA value an element of the lattice. However, only the lattice element at the end of the basic block currently being processed is of our interest (so that it can be propagated to its successors).

The transfer function

The transfer function of the intraprocedural analysis handles mainly the following types of instructions:

  • StoreInst: a store has an address and a value to store, they will be both associated to a Value. If the address Value has a direct content, the AddressSpace associated to the direct content ASSlot will be updated at the appropriate offset.

  • LoadInst: if the Value associated to the pointer operand has a direct content, we will look up in the corresponding address space at the corresponding offset if have recorded a Value. If so, the result is that Value. Otherwise this means that we are not aware of any store instruction targeting that address, therefore, the resulting Value won't have a direct content but just a tag representing the loaded address.

  • TerminatorInst: terminator instructions go through a classification depending on the context in which they are performed. For instance, an indirect jump might be detected as a return instruction if it's jumping to a Value tagged with the link register ASSlot and the stack is no higher than how it was at entry of the function.

    A TerminatorInst can also represent a function call, in such case, its callee (if not indirect) is looked up into the Cache, if it's available the result of its analysis replace the current state of the AddressSpaces, otherwise the intraprocedural analysis is suspended and the control is returned to the interprocedural part as described above.

Other basic instructions are handled in the straightforward way, e.g., addition.

The ABI analysis

As part of the finalization of the results of the intraprocedural analysis (Analysis::createSummary), the ABI analysis is performed. The goal of the ABI analysis is to detect arguments and return values of function and function calls.

The ABI IR

The ABI analysis is performed on a custom IR, the ABI IR, which is produced by the intraprocedural analysis during its execution. The main motivating reason for having this IR is to facilitate debugging and, most importantly, being able to perform backward analyses easily.

The ABI IR is quite simple: the ABIIRFunction is a container of ABIIRBasicBlocks which in turn are containers for ABIIRInstructions. Each basic block has links to its successors and predecessors.

An ABIIRInstruction can be of the following types:

  • Load: a read from an ASSlot;
  • Store: a write to an ASSlot (what is being written, is of no interest);
  • DirectCall: a function call for which a result of a previously run ABI analysis is available.
  • IndirectCall: a function call about which nothing is known.

The analyses

The FunctionABI class is responsible for performing all the analyses concerning the ABI. In particular, the analyze method performs two sets of analyses: the first set are forward, while the second one is backward.

Each set can be further divided into two groups: the analyses concerning the function itself and analyses concerning the function calls.

Each function-level analysis starts with a Default instance, then, each time a memory access to a certain CSV is met, the Default instance is cloned and associated to that CSV, whose analysis then proceeds independently.

The same holds for function call-level analyses, with the distinction that the same "lazy" instantiation of a set of analyses happens also each time a call site is met.

The set of function-level analyses contain all the function call-level analyses too so that their result can be used by the calling function performing function call-level analyses (as if they were inlined).

Each analysis is wrapped in an Inhibitor class, which, as the name suggests, is used to inhibit the wrapped analysis from applying the transfer function while walking the IR. Function-level analyses are never inhibited. Function call-level analyses, instead, start as inhibited and, once the corresponding function call is met, they are enabled. This helps us to simulate the beginning of the analysis in that point. The transfer function of a function call analysis reaching for the second time the function call is the unknown function call transfer function.

For the list of forward, backward, function and function call analyses, consult the source code and the .dot files used to generate them.

Merging the results

At the end of all of the analyses, the ResultsPool object will contain a summary of all the recovered information such as the basic block composing a function, the type of function, the status of each registers in terms of being an argument or a return value for each function and for each function call and so on.

These information, and, specifically the last two pieces of information, have to be merged together in order to produce more accurate information or identify contradictions. This step is performed in ResultsPool::finalize.

Basically the idea is that if for a certain register we have a Yes from a call site and a NoOrDead from the function itself, we will produce as a final information for the call site Dead. We will produce Dead for the function too only in case all of the call sites agree.

Ad-hoc handling of peculiar situations

For various reasons, in part concerning our code generation pipeline and in part due to certain practices in compiler backends, we have to handle certain situations in an ad-hoc way to avoid mistakes.

In the following we will discuss the situations we currently handle.

Fake functions

Consider the following example of ARM code:

_start:
    push {lr}
    bl prologue
    ldr r0, [r0]
    b epilogue

prologue:
    push {r0}
    push {r1}
    bx lr

epilogue:
    pop {r1}
    pop {r0}
    pop {lr}
    bx lr

The compiler (or the developer) decided to outline the function prologue and epilogue, likely for code size reduction reasons. In this situation we don't really want to consider prologue and epilogue as standalone functions, for two reasons: they manipulate the stack in weird ways and prevent us from identifying callee-saved registers.

The jump to the epilogue is not a problem since it will automatically considered part of each function jumping there. On the other hand, we need to make sure that we can correctly identify prologue as a fake function, and, therefore, inline it in the caller.

To do this, we can note that at the end of the "function" the stack is higher than it was at the beginning. No sane function call would allow this. Therefore, we mark prologue as a fake function.

Note, on the other hand, than having a stack lower than it was at the beginning is allowed, since certain caller conventions mandate to the callee the cleanup of stack arguments (e.g., the Windows PASCAL calling convention).

As a consequence, when we analyze a terminator during the intraprocedural analysis (handleTerminator method), when we meet an instruction that jumps to the initial content of the link register, we understand it's a return, but if we see that the stack is higher than it initially was, we mark the functions as fake and resume the analysis of the caller (if any), in which we will inline the function call.

Consider now the following a variation of the previous snippet:

_start:
    push {lr}
    add sp,sp,-8
    bl prologue
    ldr r0, [r0]
    b epilogue

prologue:
    str r1,[sp,0]
    str r0,[sp,4]
    bx lr

epilogue:
    pop {r1}
    pop {r0}
    pop {lr}
    bx lr

In this case, the stack pointer is not touched by the prologue function. Therefore, the previous criteria is not effective. In this case we observe another fact: the prologue function writes in SP0+0 and SP0+4, which seem to be stack argument. This is fine, however we keep track of this. The next thing we observe is that the same stack slots are read by the caller (in the epilogue basic block). Under our assumptions, this is not allowed, since no return value is passed (directly) on the stack and stack arguments are no longer valid after the function returns. Therefore, we mark the called function as fake.

This analysis is performed by the StackAnalysis::IncoherentCallsAnalysis analysis, which is performed on the ABI IR. The analysis is triggered by the findIncoherentFunctions function in the createSummary method of the intraprocedural analysis, after the ABI analysis has been run.

Forwarded arguments

Consider the following snippet of x86-64 assembly:

push_pop:
    push rax
    pop rdx
    ret

This code is sometimes emitted by the compiler with the only goal of growing and decreasing the stack height. The rax and rdx registers do not contain anything meaningful.

The problem with this snippet is that, according to our analyses, rax is an argument and rdx is a return value, while, obviously this is not the case.

To detect this situation, the IntraproceduralFunctionSummary, which holds the final result of the intraprocedural analysis of a functions, has a process method the pattern matches it: if a return value is tagged with the initial value of different register and that value is also stored in a stack slot, we mark it as a forwarded argument.

Statements about such registers in terms of being arguments/return values will be weakened.

Identity loads

Identity loads are a particular type of load instructions. Consider the following x86-64 pseudo code:

a = rax & 0xffff0000
b = 0xaaaa
rax = a | b

This snippet is the result of writing only the lowest 16 bits of rax (with 0xAAAA), however, from our point of view we have a read of rax before any write and would, therefore, consider it an argument. This load is an identity load, a load whose value will end up as is in itself.

Identity loads are ignored completely. They are not even part of the ABI IR.

The Cache is in charge to identify and keep track of identity loads (Cache::identifyIdentityLoads).