Explainer 2 of 11

Fixpoints and worklists

How a program analysis handles loops: keep applying the rules until nothing changes.

Where this shows up in reverse engineering

This is the central technique a static dataflow tool for a legacy corpus is built on: a byte-level fixpoint over every field, run once per program, that answers which writes reach each read and what values a field can hold — everything else in this series of pages is a refinement of that one loop.

The problem

You want to know, at every statement in a program, which statements may have last written each field — not on one run, but on any run, whatever the input data. You cannot run the program on every input, so you reason about it instead.

The intuition

Start the way you would by hand: walk the program from the top, keeping notes at each statement on which statements may have last written each field. A MOVE into a field replaces that field's note with itself. At an IF, walk both arms, because you do not know which one will run; where the arms meet, keep everything either arm noted.

That works until you reach a loop. The first time you arrive at the loop's test, the only thing that reaches it is what came from before the loop. Then you walk the body, and by the end of the body you know something new — say, a field was just written inside it. That flows back to the test, so the test's notes were incomplete. You have to go round again with the new information, and again, until a trip round the loop adds nothing. That end state, where one more trip would change nothing, is the fixpoint.

Re-walking the whole program each time is wasteful: a statement's notes can only change if something before it changed. So keep a list of the statements that need visiting — the worklist. It starts with the first statement. Take a statement off the list and work out its notes from what its predecessors have noted so far. If its notes changed, everything after it may change too, so put its successors on the list. If they did not change, add nothing. When the list runs dry, no statement's notes can change any more: you are at the fixpoint.

Two things make this trustworthy. It always stops, because notes only ever grow and there are only so many statements that could appear in them. And it reaches the same notes whatever order you take statements off the list: order changes how much work you do, not what you find.

The example

A loop whose body copies WS-A into WS-B, and a read of WS-B after the loop.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. FIXLOOP.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-A                 PIC X(4) VALUE 'AAAA'.
       01  WS-B                 PIC X(4).
       01  WS-C                 PIC X(4).
       01  WS-N                 PIC 9(2) VALUE 0.
       PROCEDURE DIVISION.
       MAIN-PARA.
           PERFORM LOOP-PARA UNTIL WS-N > 3.
           MOVE WS-B TO WS-C.
           STOP RUN.
       LOOP-PARA.
           MOVE WS-A TO WS-B.
           ADD 1 TO WS-N.

The notes here are, for each field, the set of statements that may have last written it. start stands for the value the field holds on entry. The statements are named the way cobble names them: paragraph, then position.

Step by step

When the worklist holds more than one statement, something has to pick which goes next. This run picks the one that comes earliest in reverse postorder — an order in which every statement comes after the statements that lead into it, except where a loop leads back to its own test. Visiting a graph builds it step by step. For this program it is MAIN-PARA/0, LOOP-PARA/0, LOOP-PARA/1, MAIN-PARA/1, MAIN-PARA/2: the loop body comes before the code after the loop, so the loop settles before that code is visited.

Each step shows the notes after the visited statement (amber: what changed) and the worklist after it.

The worklist run, one visit at a time (8 steps)
  1. Nothing has been visited. The worklist holds only the program's first statement, MAIN-PARA/0. It is the only statement with an input: the program's starting state, in which every field holds whatever it held when the program began — written start.

    Starting state, the input to MAIN-PARA/0

    fieldlast written by
    WS-A{start}
    WS-B{start}
    WS-C{start}
    WS-N{start}

    Worklist

    MAIN-PARA/0
  2. Visit MAIN-PARA/0 (PERFORM LOOP-PARA UNTIL WS-N > 3). It gets notes for the first time, so MAIN-PARA/1 and LOOP-PARA/0 are queued.

    Notes after MAIN-PARA/0: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{start}
    WS-C{start}
    WS-N{start}

    Worklist

    MAIN-PARA/1LOOP-PARA/0
  3. Visit LOOP-PARA/0 (MOVE WS-A TO WS-B). It gets notes for the first time, so LOOP-PARA/1 is queued.

    Notes after LOOP-PARA/0: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0}
    WS-C{start}
    WS-N{start}

    Worklist

    MAIN-PARA/1LOOP-PARA/1
  4. Visit LOOP-PARA/1 (ADD 1 TO WS-N). It gets notes for the first time, so MAIN-PARA/0 is queued.

    Notes after LOOP-PARA/1: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0}
    WS-C{start}
    WS-N{LOOP-PARA/1}

    Worklist

    MAIN-PARA/1MAIN-PARA/0
  5. Visit MAIN-PARA/0 (PERFORM LOOP-PARA UNTIL WS-N > 3). Its notes grew, so LOOP-PARA/0 is queued for a redo.

    Notes after MAIN-PARA/0: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0, start}
    WS-C{start}
    WS-N{LOOP-PARA/1, start}

    Worklist

    MAIN-PARA/1LOOP-PARA/0
  6. Visit LOOP-PARA/0 (MOVE WS-A TO WS-B). Its notes grew, so LOOP-PARA/1 is queued for a redo.

    Notes after LOOP-PARA/0: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0}
    WS-C{start}
    WS-N{LOOP-PARA/1, start}

    Worklist

    MAIN-PARA/1LOOP-PARA/1
  7. Visit LOOP-PARA/1 (ADD 1 TO WS-N). Nothing changed, so nothing is queued.

    Notes after LOOP-PARA/1: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0}
    WS-C{start}
    WS-N{LOOP-PARA/1}

    Worklist

    MAIN-PARA/1
  8. Visit MAIN-PARA/1 (MOVE WS-B TO WS-C). It gets notes for the first time, so MAIN-PARA/2 is queued.

    Notes after MAIN-PARA/1: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0, start}
    WS-C{MAIN-PARA/1}
    WS-N{LOOP-PARA/1, start}

    Worklist

    MAIN-PARA/2
  9. Visit MAIN-PARA/2 (STOP RUN). It gets notes for the first time; it has no successor left to queue.

    Notes after MAIN-PARA/2: which statements may have last written each field

    fieldlast written by
    WS-A{start}
    WS-B{LOOP-PARA/0, start}
    WS-C{MAIN-PARA/1}
    WS-N{LOOP-PARA/1, start}

    Worklist

    empty — fixpoint reached

A first-in-first-out worklist reaches exactly the same notes, but leaves the loop before it has settled, so it visits MAIN-PARA/1 and MAIN-PARA/2 twice: 10 visits instead of 8.

visitfirst in, first outreverse postorder
1MAIN-PARA/0MAIN-PARA/0
2MAIN-PARA/1LOOP-PARA/0
3LOOP-PARA/0LOOP-PARA/1
4MAIN-PARA/2MAIN-PARA/0
5LOOP-PARA/1LOOP-PARA/0
6MAIN-PARA/0LOOP-PARA/1
7MAIN-PARA/1MAIN-PARA/1
8LOOP-PARA/0MAIN-PARA/2
9MAIN-PARA/2
10LOOP-PARA/1

The rule

in(s) = ⋃ out(p) for every predecessor p of s (the entry also gets "start")
out(s) = transfer_s(in(s))
repeat: take s from the worklist; if out(s) grew, add s's successors
until the worklist is empty

Two properties make this terminate with one answer. Each transfer is monotone: more knowledge in never means less out. And there are only finitely many notes: each field can be credited to at most every write in the program. Every change makes some set strictly bigger, so changes run out.

What goes wrong

In cobble

The fixpoint stage (cobble-dataflow-fixpoint) runs this worklist over each member, queued in reverse postorder. Cobble's notes are kept per byte range rather than whole fields — see byte extents — but the loop is this one.