Functions, Basic Blocks and Instructions
Chapter 3 ended with a fully analyzed Graph — the ARM64 foo(x) example
disassembled via analyze() (Python) / the Graph-overload of
disassemble() (C++), with blocks and functions already recovered. This
chapter is about querying that structure: Graph, Function, and
BasicBlock, and how they relate to each other and to Instruction.
The ownership model: Graph owns, everything else views
Graph is the only object that owns anything — it holds the full
instruction listing (std::map<uint64_t, Instruction>) and the sets of
addresses that analysis determined are valid block/function starts.
BasicBlock and Function are lightweight views: each is constructed
from a const Graph* and a start address, and internally walks the graph’s
listing to answer questions. They don’t copy instructions; they hold
pointers into the graph they were built from.
Practically, this means:
- A
BasicBlock/Functionis only valid as long as theGraphit points into is alive. - Constructing one is cheap and side-effect-free —
dragon::BasicBlock(&graph, addr)doesn’t mutategraph. - If the address you pass isn’t a start address analysis actually recorded, you get back an object with
isValid() == false, not an exception or null.
dragon::BasicBlock bb(&graph, 0x1000);
if (!bb.isValid()) { /* 0x1000 isn't a known block start in this graph */ }Graph: the container and its query helpers
Beyond blocks()/functions() (chapter 3) and get(address) (instruction
lookup), Graph exposes the raw validity sets that BasicBlock/Function
construction checks against:
bool valid = graph.isValidBlock(0x1000);
bool validFn = graph.isValidFunction(0x1000);
const std::set<std::uint64_t>& blockStarts = graph.validBlockAddresses();
const std::set<std::uint64_t>& fnStarts = graph.validFunctionAddresses();print(graph.block_addresses()) # every recovered block start
print(graph.function_addresses()) # every recovered function startBasicBlock
A BasicBlock is the span of instructions from a start address to the next
terminator (a branch, return, or trap — or the last instruction before
another block’s start, if control simply falls through).
| Member | Meaning |
|---|---|
isValid() | false if startAddress isn’t a known block start, or the block is non-contiguous |
startAddress() | First instruction’s address |
terminatorAddress() | Last instruction’s address (not necessarily a branch — see below) |
instructions() | vector<const Instruction*>, start to terminator inclusive |
get(address) | Look up one instruction within this block |
terminator() | Pointer to the terminator instruction |
size() / byteSize() / empty() | Instruction count / total encoded bytes / emptiness |
successors() | CFG successor addresses — cached after first call |
predecessors() | CFG predecessor addresses — cached, C++ only, not exposed in the Python/C API |
clearCache() | Drop the cached successors/predecessors (e.g. after mutating the graph) |
terminatorAddress()is not always a branch. A block ends either at an actual control-flow instruction (branch/return/trap) or simply because the next instruction happens to be another block’s start (e.g. it’s a jump target from somewhere else, forcing a split even though control falls through to it).successors()accounts for both cases — it returns the branch targets for a real terminator, or the single fallthrough address for a split-but-not-branching block. Don’t assumeterminator()->isJump()is true; checksuccessors()instead if what you actually want is “where does control go from here.”
for (const dragon::BasicBlock& block : graph.blocks()) {
std::cout << "block @ 0x" << std::hex << block.startAddress()
<< " (" << block.size() << " instrs, " << block.byteSize() << " bytes)\n";
for (uint64_t succ : block.successors())
std::cout << " -> 0x" << succ << "\n";
}for addr in graph.block_addresses():
block = graph.get_block(addr)
print(f"block 0x{addr:x} ({block.size} instrs, {block.byte_size} bytes)")
for succ in block.successors():
print(f" -> 0x{succ:x}")Function
A Function is a BFS over blocks starting from an entry point, following
successors() until no new blocks are reachable — so it’s exactly “every
block reachable from this entry,” which for well-formed code is the whole
function body.
| Member | Meaning |
|---|---|
isValid() | false if the address isn’t a known function start, or it has no blocks |
startAddress() | Entry point address |
blocks() | map<uint64_t, BasicBlock>, keyed by block start address |
block(address) | Look up one block by address |
instructions() | Every instruction across every block, in address order |
numBlocks() / numInstructions() / byteSize() | Counts and total size |
edges() | Sum of each block terminator’s Instruction::edges() — only counts actual branch edges, not fallthrough (see below) |
cyclomaticComplexity() | edges() - numBlocks() + 2 (the standard McCabe formula) |
edges()undercounts fallthrough-only blocks. It sums each block’s terminator instruction’sedges()field — 2 for a conditional branch, 1 for an unconditional branch, and 0 for anything else, including aretor a block that ends purely because the next address happens to be another block’s start. This matters forcyclomaticComplexity(): it’s computed from this sameedges(), so a function built entirely from fallthrough splits (no actual branches) can report a lower complexity than “number of paths through the CFG” would suggest. Verified against the running example below.
Querying the running example
Continuing directly from chapter 3’s graph (the ARM64 foo(x) analysis) —
no need to redisassemble:
fn = graph.get_function(0x1000)
print("valid:", fn.is_valid)
print("blocks:", fn.num_blocks)
print("instructions:", fn.num_instructions)
print("bytes:", fn.byte_size)
print("cyclomatic complexity:", fn.cyclomatic_complexity)
for addr in fn.block_addresses():
block = graph.get_block(addr)
print(f"\nblock 0x{addr:x} (terminator @ 0x{block.terminator_address:x})")
for insn in block.instructions():
print(f" 0x{insn.address:x} {insn.mnemonic} {insn.operands}")
print(" successors:", [hex(s) for s in block.successors()])Actual output:
valid: True
blocks: 4
instructions: 9
bytes: 36
cyclomatic complexity: 1
block 0x1000 (terminator @ 0x100c)
0x1000 stp x29, x30, [sp, #-16]!
0x1004 mov x29, sp
0x1008 cmp w0, #0
0x100c b.le #12
successors: ['0x1010', '0x1018']
block 0x1010 (terminator @ 0x1014)
0x1010 mov w0, #1
0x1014 b #8
successors: ['0x101c']
block 0x1018 (terminator @ 0x1018)
0x1018 mov w0, #-1
successors: ['0x101c']
block 0x101c (terminator @ 0x1020)
0x101c ldp x29, x30, [sp], #16
0x1020 ret
successors: []This is a good illustration of the fallthrough case above: block 0x1018’s
terminator is mov w0, #-1 — not a branch at all — yet it still has a
resolved successor (0x101c), because that block was forced to end there
by end_branch: being a jump target from block 0x1010. Walking through
edges(): block 0x1000’s terminator (b.le, conditional) contributes 2;
block 0x1010’s terminator (b, unconditional) contributes 1; blocks
0x1018 and 0x101c (terminators mov and ret, neither a branch)
contribute 0 each. Total edges() == 3, numBlocks() == 4, so
cyclomaticComplexity() == 3 - 4 + 2 == 1.
Where this fits in the pipeline
Function and BasicBlock are the structures the rest of dragon-tales
builds on:
- Chapter 5’s IGNIL lifter operates per-block —
graph.getIGNILBlock(startAddress)returns the lifted IL for exactly oneBasicBlock’s worth of code. - Chapter 6’s LLVM lifter takes a whole
Functionand lifts every block in it, usingsuccessors()to wire up LLVM basic block terminators. - The backward slicer (see the
docs/cpp_api.mdreference and IDEAS.md) walks blocks viapredecessors().