Separation Logic and Reasoning About the Heap
Learning Objectives
- Explain why Hoare logic scales badly to programs with aliased pointers, and how local reasoning with a footprint fixes it.
- Read and write heap assertions:
emp, the points-to , and the separating conjunction with its disjointness meaning. - State the small axioms for heap operations and the frame rule, and use them to verify a short pointer program.
- Define an inductive predicate for linked lists, and explain how Infer's bi-abduction and Rust's ownership relate to separation logic.
Context & Motivation
ai-theory/formal-methods developed Hoare logic: triples , rules for assignment, sequencing, conditionals, and loops with invariants. That logic works well for programs whose variables are independent. It breaks down on programs with pointers: if x and y may point to the same cell, then {x.f = 0} y.f := 1 {x.f = 0} is not valid, so every proof about x must be re-checked against every write through any other pointer, with explicit non-aliasing side conditions for each pair. The conditions grow quadratically with the number of pointers and swamp the proof.
John Reynolds, Peter O'Hearn, Samin Ishtiaq, and Hongseok Yang developed separation logic around 2000 to 2002, an extension of Hoare logic whose assertions describe pieces of the heap and say which pieces are disjoint. It is the basis of Meta's Infer analyzer, of the VeriFast and Viper verifiers, of the Iris framework in which the RustBelt proof was carried out, and of verified systems code in proof assistants. O'Hearn's CACM article and Reynolds's LICS 2002 paper are the standard introductions.
Core Theory
States and heap assertions
A program state is a store (variables to values) together with a heap, a finite partial map from addresses to values. Assertions in separation logic describe the heap:
- : the heap is empty.
- : the heap contains exactly one cell, at address , holding . The notation means "some value".
- (separating conjunction): the heap can be split into two parts with disjoint addresses, one satisfying and the other . Formally, iff with and disjoint, and .
- (the magic wand): extending the heap with any disjoint part satisfying yields a heap satisfying . It is used in some proofs and ignored here.
- Pure facts like or , which do not depend on the heap.
The key property: implies , because one cell cannot be split into two disjoint parts. Disjointness is built into the connective instead of being a side condition.
Triples and the footprint
A separation-logic triple is read in a fault-avoiding way: if holds before runs, then does not access any address outside the heap described by (it does not fault), and holds afterward. therefore describes the footprint of : exactly the memory it needs. The rules for assignments, sequencing, conditionals, and loops are those of Hoare logic.
Small axioms and the frame rule
The heap commands have specifications that mention only the one cell they touch:
and a read has . The rule that makes these small specifications usable is the frame rule:
If works correctly in the small heap , it works in any larger heap and leaves the rest untouched. This is local reasoning: prove a specification about the footprint once and reuse it in every context. Aliasing is handled by the logic, not by hand.
Inductive predicates for data structures
Shapes like lists and trees are defined by recursive predicates. A list segment from to :
and a complete list is . The in the recursive case says the head cell and the rest of the list occupy disjoint memory, so the list has no cycle and no sharing. Loop invariants for list-processing code are written with such predicates.
Concurrency and tools
For two threads working on disjoint memory, concurrent separation logic (O'Hearn, Brookes, 2004) gives a simple rule: from and derive . Data-race freedom follows from disjointness. Ownership of a cell can also be transferred to or from a lock.
Tools build on this in different ways. Infer (Meta) introduced bi-abduction at scale: given a procedure body, it infers a precondition (the anti-frame, the memory the procedure needs that the caller must provide) and a postcondition, plus the frame that is left untouched. Each procedure gets a summary that is reused at its call sites, which makes the analysis compositional and fast enough to run on code changes. Its current Pulse checker keeps the same compositional design and finds null dereferences, use-after-free, and leaks in C, C++, Objective-C, and Java. VeriFast and Viper are verifiers in which users annotate code with separation-logic specifications, and the Prusti tool uses Viper for Rust. Iris embeds higher-order concurrent separation logic in the Coq proof assistant.
Relation to ownership types
A points-to says that the current thread owns the cell at , and says that two owned things are disjoint. This is the logic behind single-owner moves in Rust: giving a value away is giving up its assertion. Fractional permissions (Boyland) refine it: is shared read-only access, and only full ownership allows a write, which is the "many readers or one writer" rule of the previous borrow-checker concept expressed in a logic.
Worked Examples
Example 1: why the frame rule removes aliasing side conditions
Problem: Show that [x] := 1 leaves a second cell y unchanged, without a hand-written side condition x ≠ y.
Solution: The small axiom gives . Apply the frame rule with (no variable is modified by the command, so the side condition holds): . The precondition already implies , because requires disjoint cells. The postcondition says that y still holds 9. If instead the caller passes x and y that might be the same cell, the precondition is simply not satisfied by that heap, and the verification of the call fails.
Example 2: freeing a linked list
Problem: Verify that the loop frees every node and leaves an empty heap.
text// {list(x)} while (x != nil) { t := x.next; // read the next pointer of the node at x dispose(x); // free the node x := t; } // {emp}
Solution: Take the invariant . On entry it holds. Inside the loop , so unfolding the second disjunct gives . The read gives the next pointer, dispose consumes the cell and leaves by the frame rule, and the assignment x := t re-establishes . On exit , so unfolds to . The proof also rules out a double free: after dispose(x) the assertion for that cell is gone, so a second dispose of it would have no footprint to run on and would fail verification, and a list with a cycle would not satisfy in the first place.
Example 3: bi-abduction on a method
Problem: What does Infer-style analysis infer for void setVal(Node n) { n.next.val = 1; }?
Solution: Executing n.next.val = 1 from an empty assumption fails: it needs to read the field next of n and write the field val of the node it points to. Abduction records these missing resources as the inferred precondition: for some m. The postcondition is . This summary is stored. At a call site where the argument is known to have next == null, the caller cannot supply because there is no node m, so the analysis reports a possible null pointer dereference there, without re-analyzing setVal. Any part of the caller's heap not mentioned in the summary is the frame, which is preserved by the frame rule.
Common Misconceptions & Pitfalls
- " is just for different variables." says both hold of the same heap. says they hold of disjoint parts of the heap, which also gives non-aliasing.
- "The frame rule is always applicable." It needs the side condition that the command modifies no variable free in the frame . Heap contents are protected by , but variables are not.
- "Separation logic is a tool for C only." The logic applies to any language with mutable shared state: Java and Objective-C in Infer, Rust in RustBelt and Prusti, and concurrent data structures in Iris.
- "Rust makes separation logic unnecessary." Rust's borrow checker is a fixed, automatic instance of its ideas (ownership, disjointness). Separation logic is the general setting in which that instance, and the
unsafecode beneath it, is proved correct.
Summary
Hoare logic needs non-aliasing side conditions for every pair of pointers, which does not scale. Separation logic adds heap assertions, the points-to and the separating conjunction for disjoint parts, with fault-avoiding triples that describe a command's footprint. Small axioms for heap commands plus the frame rule give local reasoning: prove a specification about the footprint and extend it to any context. Inductive predicates such as describe data structures, concurrent separation logic gives a rule for threads on disjoint memory, and bi-abduction in Infer infers footprints automatically. Ownership types are a restricted, automatic form of the same idea. The next concept moves from proving to testing, where the program's inputs are generated rather than reasoned about: coverage-guided fuzzing.