Explainer 3 of 11

Gen/kill and reaching definitions

Read first: Fixpoints and worklists

Which write a read sees depends on the path the program took to get there. A write inside an IF may not have run, and a later write can replace an earlier one before the read. A search of the source for writes to the name finds them all, including the ones that are always overwritten first and the ones that only come after the read.

In the example below, WS-A is written three times and read twice. For the first read the analysis keeps the two writes in the arms of the IF. For the second it keeps only MOVE 'DONE', because that write replaces both of the others.

Definitions, gen and kill are introduced as the walk uses them; the worklist is on fixpoints.

The example

Both arms of an IF write WS-A. Then it is read, overwritten, and read again. Each box in the graph is one statement, named the way cobble names it. The labels on the arrows into the two reads say which write of WS-A arrives along that arrow.

flowchart TD
    I["MAIN-PARA/0
IF WS-FLAG = 'Y'"] -->|"true"| T["MAIN-PARA/0.then.0
MOVE 'YES ' TO WS-A"] I -->|"false"| E["MAIN-PARA/0.else.0
MOVE 'NO#nbsp;#nbsp;' TO WS-A"] T -->|"WS-A ← then.0"| M1["MAIN-PARA/1
MOVE WS-A TO WS-B"] E -->|"WS-A ← else.0"| M1 M1 --> M2["MAIN-PARA/2
MOVE 'DONE' TO WS-A"] M2 -->|"WS-A ← MAIN-PARA/2"| M3["MAIN-PARA/3
MOVE WS-A TO WS-C"] M3 --> S["STOP RUN"]

That is the whole answer: one row per read of WS-A, listing the writes it could see. The first read has two, one from each arm of the IF. The second has one, because MAIN-PARA/2 sits between it and both arms.

read atreadsdefiners
MAIN-PARA/1WS-A@working_storage[1:5]MAIN-PARA/0.else.0, MAIN-PARA/0.then.0
MAIN-PARA/3WS-A@working_storage[1:5]MAIN-PARA/2

The statements behind the graph, from the PROCEDURE DIVISION:

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.
Full listing (REACHDEF)
       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.

In the graph, 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; the arrow labels shorten those two to then.0 and else.0. In the table, a read is identified by its statement and the bytes it reads: WS-A@working_storage[1:5] is WS-A, occupying bytes [1, 5) of working storage — bytes 1, 2, 3 and 4, after the one-byte WS-FLAG at byte 0. Byte ranges in these pages count from 0 and are half-open: [start, end) includes start and stops just before end, and cobble writes the same range as [1:5]; byte extents builds on this.

Step by step

The walk below carries a set of definitions, each one “statement S wrote variable V”, written V ← S. It starts from an empty set, as cobble's does. Fixpoints instead credits every field to a start pseudo-definition on entry; that is a teaching device cobble does not have (see the last point under what goes wrong).

Gen and kill, one statement at a time (7 steps)
  1. IF WS-FLAG = 'Y' writes nothing. Its read of WS-FLAG is a condition, not the source of a write, so it gets no row; nothing has written WS-FLAG yet in any case.

    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

Why a search by name is not enough

You are looking at MOVE WS-A TO WS-C and want to know where the value in WS-A came from. Searching the listing for writes to WS-A finds three statements, for either read. The walk keeps fewer, as the table shows:

read atwrites of WS-A found by namewrites that reach it
MAIN-PARA/1then.0, else.0, MAIN-PARA/2then.0, else.0
MAIN-PARA/3then.0, else.0, MAIN-PARA/2MAIN-PARA/2

In the first row, MAIN-PARA/2 only runs after the read: no arrow in the graph leads from it back to MAIN-PARA/1. In the second row, both arms are always overwritten by MAIN-PARA/2 on the way down, which is step 5 of the walk. In a real program there may be dozens of writes to one field, behind branches and loops, and the gap between the two columns grows with them. What you want is only the writes with a path to the read along which nothing else overwrites the variable.

Gen, kill and union, read off the walk

Here is the example again with the definitions of WS-A written between the statements (the walk also carries WS-B and WS-C; they are left out here):

                 WS-A: {}
IF WS-FLAG = 'Y'
    MOVE 'YES ' TO WS-A
                 WS-A: {then.0}
ELSE
    MOVE 'NO  ' TO WS-A
                 WS-A: {else.0}
END-IF.
                 WS-A: {else.0, then.0}
MOVE WS-A TO WS-B.
                 WS-A: {else.0, then.0}
MOVE 'DONE' TO WS-A.
                 WS-A: {MAIN-PARA/2}
MOVE WS-A TO WS-C.

Three moves make every line of it, and a fourth reads the answer off. The table names each, says what it does, and points at where the fragment above and the walk show it:

movewhat it doeswhere you can see it
gena statement that writes V adds its own definition of V{then.0} and {else.0} under the two arms; the amber chips of steps 2 and 3
killthe same statement throws away every other definition of V: the new value replaced them{else.0, then.0} becomes {MAIN-PARA/2} under MOVE 'DONE'; the greyed chips of step 5
unionwhere two paths meet, either could have been taken, so the definitions from both are kept{else.0, then.0} under END-IF; the incoming chips of step 4
readat a read of V, the definitions of V in the set just above it are the answer{else.0, then.0} above MOVE WS-A TO WS-B and {MAIN-PARA/2} above MOVE WS-A TO WS-C: the two rows of the table under the example

Where this shows up in reverse engineering

This is the question behind almost every “why is this value wrong” investigation into unfamiliar code: ask the dataflow which writes could have produced the value a read sees, instead of reading top to bottom hoping to spot the write.

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) }

Every line of the box ran in the walk. The table points each one back at the step that applied it.

linewhere it ran
gen(s)step 2: MAIN-PARA/0.then.0 generates WS-A ← MAIN-PARA/0.then.0 (and steps 3–6, each with its own write)
kill(s)step 5: MAIN-PARA/2 kills WS-A ← MAIN-PARA/0.else.0 and WS-A ← MAIN-PARA/0.then.0
in(s)step 4: the two predecessors are the arms, so the way in is the union of steps 2 and 3's way out
out(s)step 5: {WS-A ← MAIN-PARA/2} ∪ (the way in − both arms) = {WS-A ← MAIN-PARA/2, WS-B ← MAIN-PARA/1}
definerssteps 4 and 6: the two rows, read off the WS-A chips on the way in

This is the same shape as every analysis in the fixpoints page: a transfer per statement and a union where paths meet. The example has no loop, so the single walk above, in reverse postorder, already reaches the fixpoint — not necessarily in source order in general, since a PERFORMed paragraph may sit later in the text than the code that runs after it. With a loop, the worklist runs it until nothing grows; the second point below shows one.

What goes wrong

Each point below changes the example slightly. The rows quoted for each variant are what cobble wrote to defs.jsonl for it.

In cobble

The fixpoint stage (cobble-dataflow-fixpoint) collects each read's definers as it walks and writes one row per read whose value goes into a write into defs.jsonl. Its definitions are kept per byte range, not per variable name. These are the commands as run on this page's program, and the two rows it wrote — the table under the example. The values column holds what those definers can have left there, which value sets builds on.

cobble-parse-corpus --config corpus.toml
cobble-extents --asg-dir parsed --out extents.jsonl
cobble-dataflow-fixpoint --asg-dir parsed \
    --extents extents.jsonl --out-dir fx --all

{"program": "REACHDEF", "node": "MAIN-PARA/1",
 "extent": "WS-A@working_storage[1:5]",
 "definers": ["MAIN-PARA/0.else.0",
              "MAIN-PARA/0.then.0"],
 "values": ["NO  ", "YES "], "reasons": []}
{"program": "REACHDEF", "node": "MAIN-PARA/3",
 "extent": "WS-A@working_storage[1:5]",
 "definers": ["MAIN-PARA/2"],
 "values": ["DONE"], "reasons": []}