Explainer 3 of 11

Gen/kill and reaching definitions

For every read of a variable, which writes could have produced the value it sees.

Where this shows up in reverse engineering

This is the question behind almost every "why is this value wrong" investigation into unfamiliar code: not reading the program top to bottom hoping to spot the write, but asking the dataflow directly which writes could have produced the value a specific read sees.

The problem

You are looking at MOVE WS-A TO WS-B and want to know where the value in WS-A came from. In a real program there may be dozens of statements that write WS-A, behind branches and loops. Some can reach this read; some are always overwritten before it. Searching for every write by name gives you all of them, including the ones that cannot matter. You want only the ones with a path to the read along which nothing else overwrites the variable.

The intuition

Walk the program carrying a set of definitions: each one is "statement S wrote variable V". When a statement writes V, it adds its own definition — it generates it — and throws away every other definition of V, because the new value replaced them: it kills them. Where two paths meet, either path could have been taken, so keep the definitions from both. At any read, the definitions of that variable in the set are the answer.

The example

Both arms of an IF write WS-A. Then it is read, overwritten, and read again.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. REACHDEF.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-FLAG              PIC X.
       01  WS-A                 PIC X(4).
       01  WS-B                 PIC X(4).
       01  WS-C                 PIC X(4).
       PROCEDURE DIVISION.
       MAIN-PARA.
           IF WS-FLAG = 'Y'
               MOVE 'YES ' TO WS-A
           ELSE
               MOVE 'NO  ' TO WS-A
           END-IF.
           MOVE WS-A TO WS-B.
           MOVE 'DONE' TO WS-A.
           MOVE WS-A TO WS-C.
           STOP RUN.

Statements are named the way cobble names them: MAIN-PARA/0 is the IF, and MAIN-PARA/0.then.0 and MAIN-PARA/0.else.0 are the first statement in each arm. A read is identified by its statement and the bytes it reads: WS-A@working_storage[1:5] is WS-A, bytes 1 to 5 of working storage, after the one-byte WS-FLAG.

Step by step

Gen and kill, one statement at a time (7 steps)
  1. IF WS-FLAG = 'Y' writes nothing. Its read of WS-FLAG has no reaching definition, so no row.

    Reaching on the way in

    none

    Reaching on the way out (new in amber)

    none

    Rows so far: each read and its definers

    none yet

  2. MOVE 'YES ' TO WS-A generates WS-A ← MAIN-PARA/0.then.0.

    Reaching on the way in

    none

    Reaching on the way out (new in amber)

    WS-A ← MAIN-PARA/0.then.0

    Rows so far: each read and its definers

    none yet

  3. MOVE 'NO ' TO WS-A generates WS-A ← MAIN-PARA/0.else.0.

    Reaching on the way in

    none

    Reaching on the way out (new in amber)

    WS-A ← MAIN-PARA/0.else.0

    Rows so far: each read and its definers

    none yet

  4. Two paths meet here, so the incoming set is the union of both arms. MOVE WS-A TO WS-B generates WS-B ← MAIN-PARA/1. Its read of WS-A sees MAIN-PARA/0.else.0 and MAIN-PARA/0.then.0.

    Reaching on the way in

    WS-A ← MAIN-PARA/0.else.0WS-A ← MAIN-PARA/0.then.0

    Reaching on the way out (new in amber)

    WS-A ← MAIN-PARA/0.else.0WS-A ← MAIN-PARA/0.then.0WS-B ← MAIN-PARA/1

    Rows so far: each read and its definers

    MAIN-PARA/1 reads WS-A@working_storage[1:5]

    MAIN-PARA/0.else.0MAIN-PARA/0.then.0
  5. MOVE 'DONE' TO WS-A generates WS-A ← MAIN-PARA/2 and kills WS-A ← MAIN-PARA/0.else.0, WS-A ← MAIN-PARA/0.then.0.

    Reaching on the way in

    WS-A ← MAIN-PARA/0.else.0WS-A ← MAIN-PARA/0.then.0WS-B ← MAIN-PARA/1

    Reaching on the way out (new in amber)

    WS-A ← MAIN-PARA/2WS-B ← MAIN-PARA/1

    Rows so far: each read and its definers

    MAIN-PARA/1 reads WS-A@working_storage[1:5]

    MAIN-PARA/0.else.0MAIN-PARA/0.then.0
  6. MOVE WS-A TO WS-C generates WS-C ← MAIN-PARA/3. Its read of WS-A sees MAIN-PARA/2.

    Reaching on the way in

    WS-A ← MAIN-PARA/2WS-B ← MAIN-PARA/1

    Reaching on the way out (new in amber)

    WS-A ← MAIN-PARA/2WS-B ← MAIN-PARA/1WS-C ← MAIN-PARA/3

    Rows so far: each read and its definers

    MAIN-PARA/1 reads WS-A@working_storage[1:5]

    MAIN-PARA/0.else.0MAIN-PARA/0.then.0

    MAIN-PARA/3 reads WS-A@working_storage[1:5]

    MAIN-PARA/2
  7. STOP RUN writes nothing.

    Reaching on the way in

    WS-A ← MAIN-PARA/2WS-B ← MAIN-PARA/1WS-C ← MAIN-PARA/3

    Reaching on the way out (new in amber)

    WS-A ← MAIN-PARA/2WS-B ← MAIN-PARA/1WS-C ← MAIN-PARA/3

    Rows so far: each read and its definers

    MAIN-PARA/1 reads WS-A@working_storage[1:5]

    MAIN-PARA/0.else.0MAIN-PARA/0.then.0

    MAIN-PARA/3 reads WS-A@working_storage[1:5]

    MAIN-PARA/2

The rule

gen(s) = { (s, v) | s writes v }
kill(s) = { (d, v) | s writes v, d ≠ s }
in(s) = ⋃ out(p) for every predecessor p
out(s) = gen(s) ∪ (in(s) − kill(s))
definers(read of v at s) = { d | (d, v) ∈ in(s) }

This is the same shape as every analysis in the fixpoints page: a transfer per statement and a union where paths meet. With no loop, a single walk in program order already reaches the fixpoint. With a loop, the worklist runs it until nothing grows.

What goes wrong

In cobble

The fixpoint stage (cobble-dataflow-fixpoint) collects each read's definers as it walks and writes one row per read into defs.jsonl. Its definitions are kept per byte range, not per variable name; the next page shows why.