isla-axiomatic allows running concurrency litmus tests w.r.t. an axiomatic memory model specified in the cat language used by herdtools.

Setup

The isla-axiomatic repository contains:

  • Isla itself, as a git submodule.

  • Some pre-compiled ISA IRs for Arm and RISC-V.

  • Isla-cat models for Arm-v9.4 and RISC-V.

  • RISC-V and Arm-systems litmus tests.

Running make from the root directory should ensure that all the components are collected and installed and up-to-date, as well as run any necessary checks on other prerequisites.

Other prerequisites include:

Command line interface

isla-axiomatic executes test files in either the herdtools litmus format, or the isla-specific .litmus.toml format. To do so, it needs to be supplied the architecture (the IR file that defines the intra-instruction semantics for the ISA), the initial machine configuration, and the concurrency model.

As a simple example the following command will test whether the test in tests/AArch64-MP+pos.litmus.toml is allowed by the aarch64_base.cat model, using the Arm-v9.4 intra-instruction semantics defined by armv9p4.ir, starting from the initial machine configuration given in configs/armv9p4.toml.

isla-axiomatic -A ir/armv9p4.ir -C isla/configs/armv9p4.toml -m models/aarch64/models/aarch64_base.cat tests/AArch64-MP+pos.litmus.toml
Tip
The isla-axiomatic repository provides short scripts for common options, e.g. try ./run_aarch64.sh tests/AArch64-MP+pos.litmus.toml to perform the same invocation as above.

The -A option and -C options are used to specify the architecture and the configuration file. The .ir file is a compiled representation of the intermediate representation generated by Sail. Sample configurations for our models are included in the isla repository itself, within the configs directory.

The -m (--model) option gives a model specified in the Cat language.

The -s (--timeout) option sets a timeout in seconds.

The -e (--exhaustive) option causes the tool to attempt to exhaustively enumerate all possible executions.

Any files passed on the command line without any options such as MP.toml above specify tests using the TOML-based litmus format specified below. Multiple files can be passed, e.g. by using *.toml or similar. It can also be specified using the .litmus format used by Herd. If this is the case, isla-axiomatic will attempt to invoke the isla-litmus conversion tool automatically. See the section below on .litmus files for more information.

Viewing the output

The --view flag will cause the output to be displayed as a graphviz generated graph using xdg-open to open an image viewer. The flag --dot <path> will instead generate the dot files used by graphviz in the directory specified by <path>. The --temp-dot flag will generate the dot files in either the default system temporary files directory (typically /tmp on Linux) or in TMPDIR if the environment variable is set. --view implies --temp-dot unless --dot is used.

If we append --dot . to our command in the previous section it will generate a file MP_1.dot in the current directory (if the test had multiple allowed executions, they would be MP_2.dot and so on). We can then use the dot command from graphviz to generate an image, like so:

dot -Tpng -o MP.png MP_1.dot

This will generate the following picture:

MP

Comparing with reference results

The --refs flag will take a set of reference results produced by Litmus, Herd, or RMEM, and compare the output with the contents of that file.

Advanced features

The --ifetch flag generates additional events for our ARMv8 instruction fetch model. This option primarily changes the base set of relations and sets available to the Cat memory model. Roughly speaking, it causes instruction fetch (ifetch) events to be ordered by fetch-program-order (fpo) and non-ifetch events to be ordered by regular program order (po). ifetch events are related to regular events by the (fetch-to-execute) fe relation. The ifetch events will be in the set IF. Additionally there is a weak-coherence-order (wco) in addition to the normal coherence order, and an instruction-reads-from (irf) in addition to reads-from.

The --armv8-page-tables flag causes page tables to be created. See isla-axiomatic for full details of support for virtual memory and address translation for AArch64.

Litmus input format

The native input file format for isla-axiomatic tests is a TOML file, with various specific keys. Below is an example of the ARMv8 MP (message-passing) test:

arch = "AArch64"
name = "MP"
hash = "211d5b298572012a0869d4ded6a40b7f"
symbolic = ["x", "y"]

[thread.0]
init = { X3 = "y", X1 = "x" }
code = """
	MOV W0,#1
	STR W0,[X1]
	MOV W2,#1
	STR W2,[X3]
"""

[thread.1]
init = { X3 = "x", X1 = "y" }
code = """
	LDR W0,[X1]
	LDR W2,[X3]
"""

[final]
expect = "sat"
assertion = "1:X0 = 1 & 1:X2 = 0"

The arch key is technically optional, but in the future may be used to avoid having to specify the architecture and/or configuration on the command line. The name key specifies the name of the test, which is usually the same as the filename, it is mandatory. The hash key is optional, and is used when the file was converted from a Herd .litmus file.

The symbolic key gives names to virtual addresses. In this test we have two unknown addresses, x and y, which are provided as a list of strings.

Note
addresses can also be used as an alternative name for this key

Threads

Next the test contains the data for each thread. There is a thread.N section for each thread numbered starting from thread.0. The code key contains the code for each thread - isla-axiomatic does not parse this code however, and it is directly passed to an assembler. For each thread N its code will be compiled into an ELF section called litmus_N. Where that section is located is determined by the [threads] section of the architecture configuration file:

[threads]
base = "0x400000"
top = "0x500000"
stride = "0x1000"

It contains the base address for loading the code for each thread in a litmus test, and a stride which is the distance between each thread in bytes. The overall range for thread memory is the half-open range [base,top). Each thread is therefore located at threads.base + (threads.stride * N).

The initial state of registers can be set using the init key for each thread via a table of register = value pairs. The register names must correspond to the register names used in the Sail model (which may differ to those used by the assembler!). For example, in ARM assembly the general purpose registers are called X0 to X30 for their full 64-bit values, and W0 to W30 for their lower 32-bits. In the Sail model, these registers are represented using registers called R0 to R30. To facilitate using the assembler names, there is a [registers.renames] section in the configuration which allows for synonyms to underlying Sail model registers.

An important thing about the init section is it sets the register values at the beginning of time before any Sail code has been (symbolically-)executed by the tool. However, this can be problematic, as often the top-level of a Sail ISA specification looks something like:

function main() = {
    setup();
    while true {
        fetch_decode_execute();
    }
}

Here each register in the init key will be set before main() is run. What happens if setup() initialises some registers to architecturally-defined values? isla allows initialising registers at an arbitrary user-defined point in time, using the reset_registers builtin. This would be set up in our example model as such:

#ifdef SYMBOLIC
val isla_reset_registers = "reset_registers" : unit -> unit
#else
function isla_reset_registers() -> unit = ()
#endif

function main() = {
    setup();
    isla_reset_registers();
    while true {
        fetch_decode_execute();
    }
}

We can now use the reset key in our thread sections, much like the init key, and the registers will be set when isla_reset_registers() is called.

[thread.0]
reset = { "PSTATE.EL" = "0b00" } # EL0

The register keys in the reset table are actually slightly more general than in init and support setting individual subfields of a larger Sail register, as is shown above for PSTATE.

Note
This is because each key in init must set the entire value of the register, and cannot leave any parts unspecified. By the time we call isla_reset_registers however, the register already has a value and we can update only part of it. You might wonder why not use reset for everything? The answer is that some registers may be used by reset() as configuration registers that specify how the model should be set up.
Warning
This is currently called reset due to terminology used in the ARM specification. We may change the naming at some future point to make things more clear.

Final State

The last section of the file, [final] contains the assertion that the test must satisfy. We can either expect this assertion to be satisfiable (sat) or unsatisfiable (unsat). The assertion is written using a small assertion language, specified by the grammar:

hex = 0x[0-F]+
bin = 0b[0-1]+
nat, thread_id = [1-9][0-9]*

loc ::= thread_id ":" register
      | "*" address

exp ::= loc "=" exp
      | hex
      | bin
      | nat
      | "true"
      | "false"
      | exp "&" exp
      | exp "|" exp
      | "~" exp
      | "(" exp ")"

The operators &, |, and ~ must be parenthesised to remove ambiguity. There are no implicit precedence rules to ensure clarity. The address terminal can be one of the addresses specified by the symbolic key at the start of the file, and register must be a Sail register name.

Custom sections

The file format also supports custom ELF sections in the generated litmus test binary, these are specified using a section like so:

[section.thread1_el1_handler]
address = "0x1400"
code = """
     mov x2, #1
     eret
"""

The section is called [section.NAME] where name will the the name of the section in the ELF. There is a check to ensure this does not clash with any of the the generated thread sections. It will be assembled at the specified address in the generated ELF.

Self-modifying code

To constraint the non-determinism for self-modifying code, we must declare which addresses in the thread’s code can be modified and how, using the self_modify toml array:

[[self_modify]]
address = "f:"
bytes = 4
values = [
  "0x14000001",
  "0x14000003"
]

Note that the address is a label from the code, which is shown below:

[thread.0]
init = { X1 = "f:", X0 = "0x14000001" }
code = """
        STR W0,[X1]
        BL f
        MOV W2,W10
        B Lout
f:
        B l0
l1:
        MOV W10,#2
        RET
l0:
        MOV W10,#1
        RET
Lout:
"""

As can be seen, such labels can also be used as the initial value for registers, like X1 above.

Constrained addresses

Constrained addresses work like the self_modify sections, but allow restricting the values that are allowed at an address declared in a litmus file. For example:

symbolic = ["x", "y", "z"]

[locations]
"x" = "z"

[[constrained]]
address = "x"
bytes = 8
values = ["y", "z"]

Here we have three addresses x, y, and z. The initial value at x is the address of z. The constrained section says the 8-bytes at this address can only contain the values of y or z. This constrains the symbolic execution for all the threads, so we don’t get a blow-up in the number of traces when we use a value read from memory as an address in one thread.

Herd .litmus files

Herd has its own custom format for litmus files. To facilitate working with these files, we include a tool in the github repository isla-litmus which can convert from Herd’s .litmus format into the TOML format described above. This tool is written in OCaml, as it uses the parser from Herd itself.

Memory model language

Overview

The memory model language used by isla-axiomatic is based on the cat language used by Herd

The basic relational operators are mostly the same as in cat: We have | for union, ; for relational composition, & for intersection, \\ for difference, and * for the cartesian product.

Documentation on the full cat language can be found at: http://diy.inria.fr/doc/herd.html#herd%3Alanguage

The postfix operators are ^+, ^*, and ^-1 for transitive closure, reflexive-transitive closure, and the inverse of a relation. As in cat, R? is the union of the relation R and the identity relation.

Much like in cat the [S] operator will lift a set S to the identity relation over S.

We support arbitrary N-ary relations, and the various operators are defined to work on N-ary relations (and sets) where appropriate.

As an example, see the dependency-ordered-before (dob) relation in the Arm model:

let dob = addr | data
	| ctrl; [W]
	| (ctrl | (addr; po)); [ISB]; po; [R]
	| addr; po; [W]
	| (ctrl | data); coi
	| (addr | data); rfi

Cat has some features which are not easy (or even possible at all) to translate into SMT. Roughly-speaking, we support the fragment of cat that defines sets and relations over events. More formally the fragment of cat we support is defined by the grammar:

expr ::= 0
       | id
       | expr?
       | expr^-1
       | expr^+
       | expr^*
       | ~expr
       | [expr]
       | expr "|" expr
       | expr ; expr
       | expr \ expr
       | expr & expr
       | expr * expr
       | expr expr
       | let id = expr in expr
       | ( expr )

binding ::= id = expr

id ::= [a-zA-Z_][0-9a-z_-]*

def ::= let binding
      | include string
      | show expr as id
      | show id {, id }
      | [ flag ] check expr [ as id ]

check ::= checkname | ~checkname

checkname ::= acyclic | irreflexive | empty

Extensions to Cat

SMTLIB types

The type of events is denoted Event. The boolean type is bool with members true and false. In addition we allow bitvectors, denoted using the Sail-style syntax bits(N) for some postive integer N. Bitvector literals are written as they are in Sail, as either 0x[A-Fa-f0-9]+ or 0b[01]+ for hexadecimal and binary respectively. The number of digits gives precisely the width of the bitvector, so 0xABC has a width of 12. As in SMT, zero-width bitvectors are not supported.

For bitvectors the SMT operations from https://smtlib.cs.uiowa.edu/theories-FixedSizeBitVectors.shtml can all be used, with a C/Sail-style function application syntax e.g. bvlshr(0xA0, y) to logically shift 0xA0 right y times. The syntax x[hi .. lo] can also be used to create a slice of the bitvector x from the bit hi down to the bit lo (inclusive). Both hi and lo must be constants.

We also allow using enumerations. Currently enumerations can be declared in the memory model file using the same syntax as Sail:

enum Barrier = Barrier1 | Barrier2

(* Or alternatively *)
enum Barrier = {
  Barrier1,
  Barrier2
}

in the future it should be possible to import these enumerations from the Sail model automatically.

These additions extend the grammar as follows:

expr ::= id ( expr {, expr } )
       | 0x[A-Fa-f0-9]+
       | 0b[01]+
       | ...

ty ::= id
     | bits ( [1-9][0-9]* )

enum_def_bar ::= id
               | id | enum_def_bar

enum_def_comma ::= id
                 | id , enum_def_comma

def ::= enum id = enum_def_bar
      | enum id = "{" enum_def_comma "}"
      | ...

SMTLIB style definitions

We compile our memory model definitions directly into SMT, and we allow lower-level pointwise definitions rather than the more high-level point-free style of cat. The top-level construct define can be used to write definitions such as:

define is_dmb_ld(ev: Event): bool = is_dmb(ev) & (barrier_types(ev) == MBReqTypes_Reads)

declare is similar but it’ll declare an uninterpreted function or constant, so we don’t provide a function body or name the parameters.

declare is_dmb_ld(Event): bool

These constructs will be lowered straightforwardly into corresponding define-const, define-fun, declare-const, and declare-fun constructs in the SMT output.

For an Event set S, we can use it like a function Event to bool, applying it like a function S(ev) for some event ev. We can use _ to partially apply relations, so R(a, _) would be the set {b | R(a, b)}. We can also use the relation, set, forall, and exists keywords to drop-down into a pointwise style where appropriate, so in the above dob declaration, we could (somewhat pointlessly) write:

let dob = (relation a, b => addr(a, b)) | data
        | ...

We can also use the in keyword like ev in S to test if ev is in the set S.

Finally, in addition to the usual acyclicity, irrefexivity, and emptiness constraints allowed by Herd we support an assert keyword that allows arbitrary SMT assertions for each candidate execution. With these features we can write anything that is expressible in the SMT theory of bitvectors+quantifiers.

The grammar is extended to support these constructs as follows:

param ::= id [: ty]

expr ::= expr in expr
       | relation param , param => expr
       | set param => expr
       | forall param {, param } => expr
       | exists param {, param } => expr
       | ...

def ::= define id ( param {, param } ) : ty = expr
      | define id : ty = expr
      | declare id ( ty {, ty } ) : ty
      | declare id : ty
      | assert expr
      | ...

Note that whenenver a parameter type is omitted it is assumed to have type Event.

Extracting values from the Sail model.

Values can be extracted from the Sail model by using accessors. The possible outcomes/events of the model are declared in the Sail library (in lib/concurrency_interface) of the Sail model. As an example, the outcome declaration for a barrier looks like:

outcome sail_barrier : 'barrier -> unit
with
  'barrier : Type

The idea here is that the outcome declarations are part of the Sail library, and therefore shared between the various ISA models, but the type variables such as 'barrier above can be instantiated with architecture-specific types in each ISA model.

enum Barrier = Barrier1 | Barrier2

// from lib/option.sail
union option('a: Type) = {
  Some : 'a,
  None : unit
}

instantiation sail_barrier with
    'barrier = option(Barrier)

Inline accessors

Simple accessors are used inline. For example, for an memory read or write event ev, we can access its address using ev.address(). If we wanted to access just the lower 16 bits of the address we could use ev.address()[16 .. 0].

Internally the accessor .address()[16 .. 0] will be compiled to a SMT function from Event to (_ BitVec 16). All functions in SMT must be total, so if the accessor does not make sense for the event we will end up with a default value.

The grammar for accessors is as follows:

nat ::= [1-9][0-9]*

field_accessor ::= 0x[A-Fa-f0-9]+
                 | 0b[01]+
                 | id
                 | self
                 | default
                 | accessor

match_arm ::= id => field_accessor

dot_accessor ::= id accessor
               | match "{" match_arm {, match_arm} "}"
               | extz ( nat )
               | exts ( nat )
               | length ( nat )
               | address()
               | data()
               | nat

accessor ::= "[" nat .. nat "]"
           | is id
           | . dot_accessor {accessor}

expr ::= expr accessor

Named accessors

For more complex cases, we can create named accessor functions using the following extension to the cat grammar:

def ::= accessor id : ty = accessor
      | ...

Going back to our example above with the sail_barrier outcome. In the memory model we can declare some predicates and relations to work with this type:

enum Barrier = Barrier1 | Barrier2

accessor is_some: bool = .match { Some => true, None => false }
accessor unwrap_some: Barrier = .match { Some => self, None => default }

define is_barrier(ev: Event, b: Barrier): bool =
    ev is sail_barrier & is_some(ev) & (unwrap_some(ev) == b)

let barriered = instruction-order; [is_barrier(_, Barrier2)]; instruction-order

(* We could equivalently write *)
let barriered2 = instruction-order; [set b => is_barrier(b, Barrier2)]; instruction-order

Here we see the use of the .match accessor to destructure a Sail option type. As mentioned, each accessor will generate a total function over events, so we need to use both the is_some and unwrap_some function to define the is_barrier predicate — without using is_some, unwrap_some would return a default value of type Barrier when ev is None. We also use the is keyword to ensure that the event is really a sail_barrier event — it could be the case we have some other outcome event instantiated with a compatible option type.

Notice that all our accessors are written postfix in a point-free style. Rather than

match x { Some(y) => y.field, None => default }

we instead write

x.match { Some => .field, None => default }

If we want the equivalent of Some(y) => y, we use the self keyword in a match arm, as shown in the example above.

Other Sail types are destructured as follows:

  • The first element of a tuple t can be accessed as t.0, with t.1, t.2 and so on for the subsequent elements.

  • A bitvector bv can be sliced as bv[n .. m] where n and m are inclusive indices where n >= m.

  • A bitvector bv can be zero-extended to a length n as bv.extz(n), and sign-extended using bv.exts(n).

  • bv.length(n) will filter any bitvectors that do not have a length of exactly n.

  • A struct s can have its fields accessed using the s.field syntax. Note that this means that identifiers with dots in them are forbidden in our variant of the cat language.

  • .address() and .data() will return the address and data values for memory reads and writes. These are treated somewhat specially because Isla needs to know about them for symbolic execution.

Indexed Relations (experimental)

By default, each event in the execution graph corresponds to a single event generated by the Sail model (as described by the outcomes in the interface). So a single memory access becomes a unique node in the graph, as does each fence, system register access (when selected for inclusion in the graph), and so on. This has two main issues:

  • It results in graphs with an excessively large number of events, resulting in poor performance.

  • It can lead to excessively complex models, as one gets lost in the mass of edges between the overly fine-grained events, obscuring the higher level design principles behind the model.

A example of this is in models for address translation. Generating a separate event for every read in a translation table walk quickly explodes, as (in Arm) there can be up to 24 page table memory accesses for every regular memory access.

Isla allows avoiding this by creating graph events that correspond to a sequence of many trace events, while still allowing us to access the data from those events if required. We do this by creating an index type that allows us to map into the sequence of trace events, for example:

index T

This declares a natural number T, such that the type bits(T) can be used to index the underlying trace events making up a single axiomatic event. Isla will automatically pick the smallest value possible for this index.

This number can be used when declaring indexed accessors. For the rest of this section we will assume that we are working with translation events that contain a sequence of memory read events. Let’s assume these memory read events contain a translation field, which in turn contains a struct with various information about the state of the page table walk where they were issued. Here we assume that there is a stage field telling us if an access is in the stage 1 or stage 2 tables:

enum Stage = S1 | S2

accessor translation-stage[T]: bits(2) = .translation.stage

Assume we have a graph that looks something like:

translation

Here we have a translation event (in set Tr), a memory write (W), and a stage 1 TLB invalidate (TLBI-S1). We define a i-indexed relation tlbi-affects that relates TLBI events to Tr events if the trace sub-event i shares the same stage with the TLB invalidate. We assume also that the built-in relation trf (translation reads-from) is defined similarly.

define TLBI-S1(tlbi: Event): bool = tlbi.stage = S1

define tlbi-affects(i: bits(T), tlbi: Event, t: Event): bool =
  Tr(t) & TLBI(tlbi) & tlbi.stage == translation-stage(i, t)

Now we want to define the write-before-tlbi relation in red. How can we do this? We can use a where exists clause to combine a standard cat-style relational definition using let, with an additional constraint that the trf index must be less than the tlbi-affects index. Since the indices are bitvectors, we can use the SMT function bvult (bitvector unsigned less than) for this.

let write-before-tlbi =
    trf(i); tlbi-affects(j)^-1
  where
    exists i: bits(T), j: bits(T) => bvult(i, j)

Linking Sail models with memory models

The previous sections have mostly discussed how the input formats to the tool look. This section discusses how the events are generated from the Sail model, and therefore how isla-axiomatic can be integrated with new Sail ISA models. If you are only interested in using the tool with existing models, this section is not-necessary, but might still be useful for understanding how everything fits together internally.

We can declare the events that are generated by Sail using the outcome keyword in the Sail language. A standard set of these outcomes covering things like memory accesses, barriers, and so on, are defined in the Sail library in the lib/concurrency_interface folder. Arbitrary Sail types can be put in these outcome types, and extracted by the memory model language (see the section on that above).

There are some other builtins we must add to our ISA specification to ensure the concurrency model works correctly. The __branch_announce builtin informs the model about the addresses used by branch instructions. This is used for computing control dependencies (the ctrl relation in Cat). For example, the BranchToAddr function in the ARMv8 model contains:

function BranchToAddr (target, branch_type) = {
    Hint_Branch(branch_type);
    if 'N == 32 then {
        assert(UsingAArch32());
        __branch_announce(64, ZeroExtend(64, target));
        _PC = ZeroExtend(target)
    } else {
        assert('N == 64 & ~(UsingAArch32()));
        __branch_announce(64, slice(target, 0, 64));
        _PC = slice(target, 0, 64)
    };
    __PC_changed = true;
    return()
}

We can’t just rely on writes to the program counter (_PC) because other instructions may write to it, but not be involved in the ctrl relation.

The __instr_announce builtin is used to tell the concurrency model which instruction is being executed each cycle, allowing events to be associated with the instruction that executed them. This should happen directly after the instruction is fetched from memory. It is very important that the top level fetch-decode-execute loop in Sail increments the cycle counter (via the "cycle_count" builtin) so each instruction announce event is associated with the right set of events. A minimal example in Sail might look something like:

val __cycle_count = "cycle_count" : unit -> unit
val __monomorphize = "monomorphize" : forall 'n, 'n >= 0. bits('n) -> bits('n)

function main() -> unit = {
    setup();
    __cycle_count();
    while true {
        instr = __monomorphize(fetch());
        __instr_announce(instr);
        decode_and_execute(instr);
        __cycle_count()
    }
}
Note
Cycle 0 (before the first call to __cycle_count) is reserved for initialization.

Notice the use of the __monomorphize builtin here. This is a special builtin that is a no-op in all other Sail backends, but in Isla forces the symbolic execution to case-split on the value of its argument if the argument is symbolic. This means that instr is forced to always be a concrete value in __instr_announce.

Dependency analysis

The axiomatic concurrency models depend on syntactic dependencies between instructions. In a perfect world this information would be provided to us explicitly as part of the architecture specification, but as large imperative ISA specifications have not typically been integrated with concurrency tools such as Isla, this is not the case in the real world at present.

The dependency relations we need are:

  • addr We get an address dependency from a load to a store or load when the address of the store or load depends on the value of the first load.

  • data We get a data dependency from a load to a store when the data of the store depends on the value read by the load.

  • ctrl We get a control dependency from a load to every event after a branch when the branch’s address depends on the value read by the load.

We have a way to derive sensible syntactic dependencies from the semantics of instructions. This may seem odd - how can one derive syntactic dependencies from semantics? The assumption here is that the syntax itself should determine all the possible behaviours, so if we use symbolic execution to explore all the possible behaviours of an instruction in any starting state, we should end up with the correct syntactic dependencies.

Note
It is neither correct to under-approximate or over-approximate these dependencies, they must be exact. Under-approximating would allow bad executions, and over-approximating would forbid good ones.

The approach is roughly as follows: for each instruction in the litmus test we execute it in an unconstrained starting state. This produces a set of all the possible behaviours of the instruction. We then look at those behaviours and track which registers were tainted by data read from memory, as well as what registers flow into store, load, and branch addresses. Using this information we can then compute the addr, data, and ctrl relations in a straightforward way.

The isla-footprint command with the -d(--dependency) option can be used to view the information generated by this process:

isla-footprint -A aarch64.ir -C configs/aarch64.toml -i "ldr w0, [x1]" -d

generates:

opcode: #xb9400020
Execution took: 159ms
Footprint:
  Memory write:
  Memory read: R0
  Memory address: R1
  Branch address:
  Register reads: TCR_EL1 SCR_EL3 PSTATE.EL SCTLR_EL1 __defaultRAM CFG_ID_AA64PFR0_EL1_EL3 EDSCR __CNTControlBase OSLSR_EL1 PSTATE.D R1 OSDLR_EL1 CFG_ID_AA64PFR0_EL1_EL1 CFG_ID_AA64PFR0_EL1_EL0 CFG_ID_AA64PFR0_EL1_EL2 PSTATE.nRW DBGEN __highest_el_aarch32
  Register writes: __LSISyndrome R0
  Register writes (ignore):
  Is store: false
  Is load: true
  Is exclusive: false
  Is branch: false

Some registers in the Sail ARM model aren’t really architectural registers and should be ignored for dependency analysis, these can be added to registers.ignore in the architecture configuration. Usually in ASL and therefore the ARMv8 Sail, these are prefixed by two underscores.

Unfortunately it is sometimes possible that this process doesn’t give us exactly the dependencies we need. There are two special builtins

val __mark_register = "mark_register" : forall ('a: Type). (register('a), string) -> unit
val __mark_register_pair = "mark_register_pair" : forall ('a: Type) ('b: Type). (register('a), register('b), string) -> unit

That allows annotating registers with information (in the form of a string) at specific points during symbolic execution, for example:

__mark_register_pair(ref R0, ref R1, "ignore_edge")

will cause read-write edges from R0 to R1 to be ignored, "ignore_write" can also be used with a single register to ignore all read-write edges into a register.

Note
We use the ref register syntax in Sail to pass the registers by name to this builtin.

Dependency analysis for systems semantics

While the notions of address, data, and control dependencies seem simple enough for user-mode concurrency, things become more unclear when we start thinking about systems features. For example: What if an instruction behaviour changes between exception levels? Should we included dependency information generated at all exception levels? Does dependency information cross between exception level boundaries? How does the MMU and address translation affect this?

In truth it seems syntactic dependencies are bit of a fuzzy concept once we start thinking at this level. In practice when we have the MMU enabled we can make instruction execution so non-deterministic that it becomes computationally infeasible to evaluate all paths through an instruction without abstracting away features. To work around these issues in systems tests, we have a --footprint-config option for isla-axiomatic that allows a separate architecture configuration to be used during dependency analysis.

Virtual memory tests

This section describes the features of Isla applicable to writing and running virtual memory litmus tests in isla-axiomatic. It covers the DSL used to specify page table configurations, enhancements to the initial state description in tests for virtual memory and address translation, and options to control how Isla generates and treats translate read events. Finally, an example is given of how Isla can be used to test more than just strictly memory models, via a simple break-before-make detection example, and some tips for debugging virtual memory tests are given.

Page table setup language

For each litmus test the configuration for the in-memory page tables are specified using the page_table_setup key of the toml litmus test format. For example, the following is the beginning of a particularly simple virtual memory litmus test that contains a single virtual address x, which is mapped to some physical address x.

arch = "AArch64"
name = "W"
addresses = ["x"]

page_table_setup = """
    physical pa1;
    x |-> pa1;
"""

Declaring addresses

The addresses field declares virtual addresses. In non-translation tests where page_table_setup is not used, this is used for all addresses. It assigns addresses in a deterministic way using the architecture configuration, where address i in the list of addresses is defined as symbolic_addrs.base + (symbolic_addrs.stride * i).

Additional addresses are defined in the page_table_setup key, using the virtual, physical, and intermediate keywords. For example:

virtual x y z;
intermediate ipa1 ipa2;
physical pa1 pa2;

would define three virtual addresses x, y, and z; as well as two intermediate physical addresses ipa1 and ipa2; and two physical addresses pa1 and pa2. By default the addresses generated by each of these three lines are guaranteed to be disjoint, and aligned by symbolic_addrs.stride bytes from the architecture config. If it is required that the two addresses should not be disjoint, then they can be declared separately, for example the following will declare x and y, where both can have the same address. Note that they are not required to have the same address unless additional constraints are used.

virtual x;
virtual y;

Custom alignment requirements can be specified in the following way:

aligned 4096 physical pa1 pa2;

will require that pa1 and pa2 are disjoint and 4K aligned.

Constraints upon addresses can be specified using the assert keyword, for example

virtual x;
virtual y;
assert x == y;

will require that the the two addresses in the previous example are the same. The grammar for these constraints is as follows:

Exp: Exp = {
    <UnaryExp> "&&" <UnaryExp>, // boolean and
    <UnaryExp> "||" <UnaryExp>, // boolean or
    <UnaryExp> "&" <UnaryExp>,  // bitwise and
    <UnaryExp> "|" <UnaryExp>,  // bitwise or
    <UnaryExp> "^" <UnaryExp>,  // bitwise xor
    <UnaryExp> "==" <UnaryExp>,
    <UnaryExp> "!=" <UnaryExp>,
    <UnaryExp>,
}

UnaryExp: Exp = {
    "~" <AtomicExp>,
    <AtomicExp>,
}

AtomicExp: Exp = {
    <Id> "(" ")",
    <Id> "(" <Exp> <("," <Exp>)*> ")",
    <AtomicExp> "[" <Exp> ".." <Exp> "]",
    <Id>,
    <Nat>,
    <Hex>,
    "(" <Exp> ")",
}

Id = /[a-zA-Z_][0-9a-zA-Z_]*/
Hex = /[#0]x[0-9a-fA-F]+/
Nat = /[0-9]+/

Note that there is no operator precedence in the grammar, so all nested operators must be explicitly bracketed. This is both in the interests of simplicity, as well as ensuring that the address constraints remain as unambiguous as possible. The function identifiers in AtomicExp can be any primitive Sail operation known to Isla. These are the external functions specified in Sail type declarations, so using a definition such as the following from the Sail library

val sail_sign_extend = "sign_extend" : forall 'n 'm, 'm >= 'n. (bits('n), int('m)) -> bits('m)

we could write a constraint like sign_extend(0xF, 64) == x in our address constraint assertions. The x[7 .. 0] notation can be used to slice bitvectors. As in Sail, ranges are inclusive, so in this example the lower 8 bits of x would be sliced.

Custom functions can be declared using the let keyword within page_table_setup, for example:

let PAGE(x) = x[48 .. 12];

Declaring mappings

There are three ways to declare mappings in the page tables. These are the two mapping commands |-> and ?->, and the identity command. These commands are used in a mostly declarative way, so

virtual x;
physical pa;
x |-> pa

declares a virtual address x and a physical address pa, and then creates page table entries to map x to pa. Note that we only said mostly declarative above - what actually happens is that the addresses are determined first using the address declarations and any constraints, then the mappings are created sequentially in the order they appear. The mapping declarations do not influence the choice of addresses in any way. It is important therefore to take care that these mapping declarations do not clobber each other in unexpected ways for certain choices of addresses.

The exact behaviour of the |-> operator depends on the types of the addresses (virtual, physical, or intermediate) used for each operand, as well as whether a stage 2 table is in scope. The second operand can also be invalid, which causes an invalid mapping to be created.

intermediate

physical

physical (with stage 2)

invalid

virtual |-> ?

maps to intermediate physical address in stage 1 table

maps to physical address in stage 1 table

maps to physical address (as an intermediate physical address) in stage 1 table, and identity maps in stage 2 table

invalid mapping in stage 1 table

intermediate |-> ?

X

X

maps intermediate physical address to physical address in stage 2 table

invalid mapping in stage 2 table

Note
For completeness, there are actually two additional things we can use for the second operand: table physical addresses, and raw descriptors, but these will be discussed later.

The |-> operator defines how the page table is set up in the initial state of the test. The ?-> operator declares how the mappings can change over the course of the test. For example, if we update our previous example to:

virtual x;
physical pa pa2;
x |-> pa;
x ?-> invalid;
x ?-> pa2

Then x is initially mapped to pa, but can become either invalid or be mapped to pa2 over the course of running the test.

Note
The reason we need ?-> is to bound the non-determinism, ensuring we only create candidate executions for possible page table manipulations in each test, rather than any arbitrary change to the page table. Isla can detect (and will give an error) if we try to modify the page table in a way not permitted by the possible mappings defined by ?->. In future it may be possible to omit the ?-> declarations entirely.

Finally, we have the identity keyword, which is a shorthand way of creating identity mappings. The following shows for each type of address a use of the identity keyword, and the equivalent |-> operator usage on the next line.

virtual x;
identity x;
x |-> va_to_ipa(x);

intermediate y;
identity y;
y |-> ipa_to_pa(y);

physical z;
identity z;
pa_to_va(z) |-> z

Note the use of functions like va_to_ipa. These convert an address from one type to another without changing the bit representation. There are six such functions:

  • va_to_ipa converts virtual to intermediate

  • va_to_pa converts virtual to physical

  • ipa_to_va converts intermediate to virtual

  • ipa_to_pa converts intermediate to physical

  • pa_to_ipa converts physical to intermediate

  • pa_to_va converts physical to virtual

Page table entry locations

A mapping statement such as x |-> pa will create many descriptors in multiple tables in order to set up the overall mapping from x to pa (these will be the addresses walked by the translation table walk code). For some test descriptions it is useful to be able to access these addresses. We can use the as keyword to give the sequence of addresses used by a mapping command a name. For example:

x |-> pa as walk

We can then use the functions pteN where N is a number between zero and three, and tableN where N is a number between one and three. In ARMv8 pteN is equal to tableN plus the offset at that level given by the virtual address. For cases with both stage 1 and stage 2 translations, there are functions s2pteN and s2tableN which can be used to access addresses used in the stage 2 translations. These functions return a physical address.

Initial memory values

The initial values of memory locations can be set within the page table setup description. For example:

virtual x;
physical pa;
x |-> pa;
*x = 1;

Here the address x will be translated to the correct physical address using the initial page table setup.

Default page table setup

By default, we create both stage 1 and stage 2 page tables, with the address of each table being determined by the architecture configuration, in the [mmu] section. For example, for ARM we have:

[mmu]
page_table_base = "0x300000"
page_size = "4096"
s2_page_table_base = "0x200000"
s2_page_size = "4096"

default_setup = """
let PAGE(x) = x[48 .. 12];
let PAGEOFF(x) = x[11 .. 0];
"""

This declares the base address and default page size for the stage 1 and stage 2 tables. It also defines some default setup code which is prepended to the page_table_setup for each test using this configuration.

Note
Right now only 4K page sizes are supported

Advanced page table setup

The default page table setup above may not be suitable for all tests. In these cases we can disable the use of default tables by using the option keyword, and setting the default_tables option to false.

Note
Any options are processed first, and do not need to be at the start of the page_table_setup.

With the default tables turned off, we can declare our own tables using the s1table and s2table commands. These commands take the name of the page table to be created, and its base address in memory as arguments. They also introduce an optional scope which can contain mapping commands. Each of the mapping commands |->, ?->, and identity will use the closest enclosing tables in scope. The following shows an example of how this works:

option default_tables = false;
virtual x;
physical pa1;

s1table hyp_pgtable_new 0x280000 {
    x |-> invalid at level 3 as walk;
    x ?-> pa1 at level 3;
}

s1table hyp_pgtable 0x200000 {
    x |-> invalid at level 2;
    x ?-> table(table3(walk)) at level 2;
    identity 0x1000 with code;
    s1table hyp_pgtable_new;
}

*pa1 = 1;

The first x |-> invalid mapping will be created in hyp_pgtable_new as a level 3 mapping, while the second will be created in hyp_pgtable as a level 2 mapping. The use of at level <n> is also new in this example, which allows creating mappings at a specific level in the page tables (the default for ARM would be level 3).

We also use ?-> table(table3(walk)) to tell the ?-> mapping command that table3(walk) is the physical address of a table, so it needs to create a table descriptor rather than a regular descriptor.

Note
Raw hex numbers are by default treated as physical addresses. Functions like pa_to_va can be used to treat them as other types of addresses as required.

The third new feature seen in this example is the with code following the identity 0x1000 command. The with keyword is used to control the attributes used for the descriptors created by the mapping command. In this test 0x1000 is used for the address of an exception handler, so we need to ensure this memory is mapped with permissions suitable for executable code.

Finally we see that s1table hyp_pgtable_new appears nested within the s1table hyp_pgtable scope. Nesting table commands causes the tables to be mapped into each other. In this example, hyp_pgtable_new will be mapped into hyp_pgtable. If we wanted, we could insert more mappings into hyp_pgtable_new here, for example:

s1table hyp_pgtable 0x200000 {
    x |-> invalid at level 2;
    x ?-> table(0x283000) at level 2;
    identity 0x1000 with code;
    s1table hyp_pgtable_new {
        x ?-> invalid at level 1;
        s1table hyp_pgtable;
    }
}

By default each table is mapped into itself. To disable this, use:

option self_map = false;

each table can then be mapped into itself explicitly as follows:

s1table my_table 0x200000 {
  s1table my_table;
}

More attributes

Above we saw the use of with code to create a mapping with code permissions. We can also set custom mappings, for example:

virtual x;
intermediate pa;
x |-> ipa with [AP = 0x00]

If a mapping command can create both stage 1 and stage 2 descriptors, we can write with <stage 1 attributes> and <stage 2 attributes> to set the attributes for each type of descriptor separately. This is often required as stage 1 and stage 2 tables have different sets of descriptor attributes, so the attributes used by one will be invalid for the other. For example:

virtual x;
physical pa;
x |-> pa with [AP = 0x00] and default

Like how code is intended as a sensible default for mapping memory used for code, default represents sensible defaults for memory used for regular data.

The attributes supported by Isla for AArch64 are described in the ARM architecture reference manual. For stage 1 they are:

  • UXN

  • PXN

  • Contiguous

  • nG

  • AF

  • SH

  • AP

  • NS

  • AttrIndx

And for stage 2 they are:

  • XN

  • Contiguous

  • AF

  • SH

  • S2AP

  • MemAttr

In the case where setting the attributes isn’t quite enough, and we need absoutely full control over the format of the descriptor, we can use the raw function to tell the mapping commands to treat it’s second argument as a raw descriptor value and not an address. For example:

virtual va;
intermediate ipa;
va ?-> raw(0x0000000000000001);
ipa ?-> raw(0x0000000000000001) at level 2

Creating raw descriptors with virtual addresses will place them in the stage 1 table, whereas using intermediate physical addresses will cause them to be created in the stage 2 table. Parent descriptors at lower levels will still be created as usual.

Test initial state

Unlike for regular litmus tests, where the initial state for each thread usually does not go far beyond setting some general purpose registers, in systems litmus tests like the virtual memory tests, each thread may need to configure more registers in the model. To explain how this works, it is helpful to understand the initialisation flow in the Sail ARM model (and most other Sail models) that occurs for each thread in the litmus test. This is shown in the diagram below:

Initialisation sequence for the Sail ARM model

Blocks highlighted in yellow correspond to Sail primitive operations implemented in Isla. The first step is to call a Sail function, TakeReset in ARM, that defines programmatically how the model should initialised, ensuring any global invariants are set up correctly. The program counter _PC is then set to the entry point for the thread (using the same Sail primitives we use for loading ELF test files). Next, a special primitive reset_registers is called which sets registers based on values found in the litmus test file. Finally we tell Isla that the main fetch-execute-decode loop is starting by calling cycle_count for the first time.

The TOML litmus test format we use allows setting registers at two separate points in time.

  1. At the very start of the test before TakeReset is called.

  2. Whenever the reset_registers primitive is called by Sail.

Setting registers via the reset_registers primitive is more useful in general, so most of our virtual memory tests do this exclusively. The situation where we would need to set registers prior to TakeReset is if those registers are themselves used during TakeReset. Some registers in the ARM model are exclusively used in this way to configure the model via changing the behavior of TakeReset.

To assign registers during the reset_registers builtin we use the thread.N.reset key for each thread N. Below is an example from one of our tests:

[thread.0.reset]
R0 = "ttbr(asid=0x0000, base=vm2_stage1)"
R1 = "ttbr(vmid=0x0001, base=vm2_stage2)" # same VMID

TTBR0_EL2 = "ttbr(asid=0x0000, base=hyp_map)"
VTTBR_EL2 = "ttbr(vmid=0x0001, base=vm1_stage2)"
TTBR0_EL1 = "ttbr(asid=0x0000, base=vm1_stage1)"

R3 = "x"
VBAR_EL2 = "extz(0x1000, 64)"

R4 = "ttbr(vmid=0x0001, base=extz(0b0, 64))"
R5 = "ttbr(vmid=0x0000, base=extz(0b0, 64))"

"PSTATE.EL" = "0b10"

# return to EL1h at L0
SPSR_EL2 = "extz(0b00101, 64)"
ELR_EL2 = "L0:"
Note
We must assign these registers using the register names found in the Sail model (sometimes this differs from those used by the assembler for each thread’s code). Register subfields can be set individually as in PSTATE.N above.

Other that when it occurs, the main distinction between setting registers in a thread.N.reset section versus a thread.N.init section is that the reset section has access to the (symbolic) model state (whereas the init section is used to create this state in the first place). Having access to the model state has two main upsides:

  1. Registers can be set based on the contents of memory. In particular, the setup of the page tables.

  2. Registers can be set to symbolic values (The initial state of memory may itself be symbolic).

Various functions can be used in the reset section to help constructing values for virtual memory tests. These are as follows:

  • pteN — For N in {1, 2, 3}, takes a virtual or intermediate physical address and a page table base address and returns the level N page table entry (pte) for that address.

  • descN — For N in {1, 2, 3}, takes a virtual or intermediate address and a page table base address and returns the level N descriptor for that address.

  • pa — Translates a virtual or intermediate physical address, given that address and a page table base address to use for translation. Will only do one stage of translation using the table specified by the second argument.

  • page — Slices out the page bits of a descriptor

  • extz — Zero extension

  • exts — Sign extension

  • ttbr — Create a bitvector representing a translation table base register. Has keyword arguments:

    • base — 64-bit base address. Any bits in this value that go beyond architectural limts may be removed.

    • asid or vmid — optional 16-bit ASID or VMID value

    • CnP — optional CnP (common not private) bit

  • asid — Takes a 16-bit (or less) asid value and turns it into a 64-bit value suitable for storing in a general purpose register

  • vmid — Takes a 16-bit (or less) vmid value and turns it into a 64-bit value suitable for storing in a general purpose register.

  • mkdescN — For N in {1, 2, 3} Create a valid level N descriptor from a 64-bit value. Has two keyword arguments:

    • oa — Create a descriptor from an output address

    • table — Create a descriptor from a table address (only when N != 3)

  • bvand — Bitwise and two bitvectors

  • bvor — Bitwise or two bitvectors

  • bvxor — Bitwise xor two bitvectors

  • bvlshr — Logical right shift a bitvector

  • bvshl — Left shift a bitvector

  • offset — Get the offset within a level N table for a virtual or intermediate physical address. Has two keyword arguments:

    • va or ipa — The virtual or intermediate physical address

    • level — A value between 0 and 3

Running tests

To enable running tests with virtual memory we need to pass the --armv8-page-tables option to Isla. This will cause Isla to use the page_table_setup to create page tables suitable for AArch64. The other aspects of initializing the model to run virtual memory tests are handled via the usual configuration file passed via the -C/--config option. For running AArch64 virtual memory tests we provide the configuration file aarch64_mmu_on.toml in the configs subdirectory of the Isla repository.

Computing instruction footprints with address translation turned on is impracticle to the point of being impossible in any reasonable length of time, so a separate config can be provided for instruction footprint analysis using the --footprint-config option. For this, the standard aarch64.toml configuration file suffices.

Optimizations

There are two main options that affect how Isla generates candidate executions of translation tests, both of which are in some sense optimizations as they can make tests run considerably faster (by potentially several orders of magnitude). However neither are fully automatic, and require models to be written in specific ways to take full advantage.

  • --merge-translations Causes all the translation table reads for each walk to be merged into a single event. This has the following consequences:

    • Each translate event can have multiple trf edges

    • No translate events are marked as either Stage1 or Stage2 (because all of them are merged together)

    • To work around the lack of explicit Stage1 and Stage2 sets, this information is encoded into the edges, as via trf1 and trf2 relations, such that trf; [Stage1 & T] = trf1 in the unmerged case.

    • After merging translations we can’t distinguish between the order of translate writes by via trfN; iio; [T]; trfM^-1 where N, M in {1, 2}, so we introduce ternary relations between pairs of writes and translate events that capture this.

  • --merge-split-stages Modifes --merge-translations to generate two events for each walk, one for all the Stage1 and another for the Stage2 reads. This option shares the same consequences as the previous, except that we preserve the Stage1 and Stage2 event sets, so it tends to work for models written for unmerged translate events better, however models can still be made unsound depending on how they use trf; iio; [T]; trf^-1.

  • --remove-uninteresting removes 'uninteresting' events. There are two variants safe and all. An uninteresting translate event is defined as one that can only read from the initial state (i.e. there are no possible writes to that descriptor). The all option removes every such translate event, whereas the safe option removes all such events, provided they do not occur within the same walk as any interesting translate read.

    • The all variant is quite dangerous as it can affect things like rules for TLBI invalidation in the model.

    • Experimentally the safe option seems to work well, although it is possible to create counter-examples with our current models. Currently, they derive on edges from ERET events to translates, then from translates to other non-translate events. If the model was changed to derive the edges from ERET to non-translate events directly without going through uninteresting translate events then this would likely dissappear. This is just an example, any similar situation where edges are derived via any translate will cause this type of issue.

Web interface

The web interface can be used to run virtual memory tests. Enabling the page table setup, and toggling the various optimisations above is done via the options menu:

web vmsa options

The Use ARMv8 page tables checkbox is equivalent to the --armv8-page-tables flag on the command line. For running virtual memory tests, the AArch64 VMSA model should be selected in Sail architecture menu:

web vmsa arch

A suitable model is included in the memory model menu. The above options will be set automatically when choosing a sample virtual memory test using the litmus file menu, using the AArch64 VMSA examples.

web vmsa library
Warning
The virtual memory tests can be very memory and CPU intensive, so they may not work well when using the public web interface, as it has limited resources. The web interface can be run locally on Linux systems to avoid these limitations.

Checking additional properties (break-before-make)

Isla supports an option --extra-smt that can be used to check additional properties that are hard to express in the cat format used for the memory model. In the context of virtual memory tests, this can be used to detect things like break before make (BBM) violations in tests. In this subsection, we will show how this can be done to detect at least some such potential violations. Note that this section is mainly intended to demonstrate how this can be done, rather than perfectly describe possible break-before-make violations.

When changing an existing translation mapping, from one valid entry to another valid entry, Arm require in many cases the use of a break-before-make (BBM) sequence: breaking the old mapping with a write of an invalid entry, a DSB to ensure that is visible across the system, and a broadcast TLBI with additional synchronisation to invalidate any cached entries for all threads, then making the new mapping with a write of the new entry, and additional synchronisation to ensure that it is visible to translations (specifically, to translation-walk non-TLB reads). If this sequence is not used then any number of undesirable consequences may occur.

We start by identifying the events that write a level 3 descriptor and it’s parents (some details omitted for brevity).

(declare-const BBM_Wl0 Event)
(declare-const BBM_Wl1 Event)
(declare-const BBM_Wl2 Event)
(declare-const BBM_Wl3 Event)

(declare-const BBM_Wl0_pa (_ BitVec 64))
(declare-const BBM_Wl1_pa (_ BitVec 64))
(declare-const BBM_Wl2_pa (_ BitVec 64))
(declare-const BBM_Wl3_pa (_ BitVec 64))

(assert (not (= BBM_Wl0_pa BBM_Wl1_pa)))
(assert (not (= BBM_Wl1_pa BBM_Wl2_pa)))
(assert (not (= BBM_Wl2_pa BBM_Wl3_pa)))

(declare-const BBM_Wl0_data (_ BitVec 64))
(declare-const BBM_Wl1_data (_ BitVec 64))
(declare-const BBM_Wl2_data (_ BitVec 64))
(declare-const BBM_Wl3_data (_ BitVec 64))

; For each level, if its valid its parent must be a valid table entry
(assert
  (and
    (implies (valid_desc BBM_Wl3_data) (valid_table_desc BBM_Wl2_data))
    (implies (valid_desc BBM_Wl2_data) (valid_table_desc BBM_Wl1_data))
    (implies (valid_desc BBM_Wl1_data) (valid_table_desc BBM_Wl0_data))))

; If an entry is pointed to by its parent, then it must be actually
; represent a valid page table write at the correct location. The
; alternative is if the parent is invalid, in which case anything
; goes
(assert
  (implies (valid_table_desc BBM_Wl0_data)
    (and (tt_write BBM_Wl1 BBM_Wl1_pa BBM_Wl1_data)
         (= (table_address BBM_Wl0_data) (table_address BBM_Wl1_pa)))))

(assert
  (implies (valid_table_desc BBM_Wl1_data)
    (and (tt_write BBM_Wl2 BBM_Wl2_pa BBM_Wl2_data)
         (= (table_address BBM_Wl1_data) (table_address BBM_Wl2_pa)))))

(assert
  (implies (valid_table_desc BBM_Wl2_data)
    (and (tt_write BBM_Wl3 BBM_Wl3_pa BBM_Wl3_data)
         (= (table_address BBM_Wl2_data) (table_address BBM_Wl3_pa)))))

We then create constants for the two conflicting writes to a page table entry BBM_W1 and BBM_W2:

(declare-const BBM_W1 Event)
(declare-const BBM_W1_pa (_ BitVec 64))
(declare-const BBM_W1_data (_ BitVec 64))

(declare-const BBM_W2 Event)

; BBM_W1 must be one of the descriptor writes for the page table entry we described above
(assert (or
  (and (= BBM_W1 BBM_Wl3) (= BBM_W1_pa BBM_Wl3_pa) (= BBM_W1_data BBM_Wl3_data))
  (and (= BBM_W1 BBM_Wl2) (= BBM_W1_pa BBM_Wl2_pa) (= BBM_W1_data BBM_Wl2_data))
  (and (= BBM_W1 BBM_Wl1) (= BBM_W1_pa BBM_Wl1_pa) (= BBM_W1_data BBM_Wl1_data))
  (and (= BBM_W1 BBM_Wl0) (= BBM_W1_pa BBM_Wl0_pa) (= BBM_W1_data BBM_Wl0_data))))

; BBM_W1 and BBM_W2 conflict
(assert (and (tt_write BBM_W1 BBM_W1_pa BBM_W1_data) (valid_desc BBM_W1_data)))
(assert (W_valid BBM_W2))
(assert (not (= ((_ extract 47 12) BBM_W1_data) ((_ extract 47 12) (val_of_64 BBM_W2)))))
(assert (= BBM_W1_pa (addr_of BBM_W2)))
(assert (co BBM_W1 BBM_W2))

We can then define the sequence of events between BBM_W1 and BBM_W2 that would constitute a correct break-before-make sequence, namely writing an invalid entry to `BBM_W1’s descriptor or any parent descriptor, followed by a suitable TLBI. We then assert that there is no such sequence — meaning a satisfiable model is an example of a potential break before make failure.

(define-fun BBM_sequence1 ((S_Wp Event) (S_tlbi Event)) Bool
  (and
    (wco BBM_W1 S_Wp)
    (W_invalid S_Wp)
    (implies (= BBM_W1 BBM_Wl3) (or (= S_Wp BBM_Wl3) (= S_Wp BBM_Wl2) (= S_Wp BBM_Wl1) (= S_Wp BBM_Wl0)))
    (implies (= BBM_W1 BBM_Wl2) (or (= S_Wp BBM_Wl2) (= S_Wp BBM_Wl1) (= S_Wp BBM_Wl0)))
    (implies (= BBM_W1 BBM_Wl1) (or (= S_Wp BBM_Wl1) (= S_Wp BBM_Wl0)))
    (implies (= BBM_W1 BBM_Wl0) (= S_Wp BBM_Wl0))
    (wco S_Wp S_tlbi)
    (TLBI-VA S_tlbi)
    (= (tlbi_va (val_of_cache_op S_tlbi)) (concat #x0000 BBM_ia #x000))
    (wco S_tlbi BBM_W2)))

; If there are no valid BBM sequence between BBM_W1 and BBM_W2, we have a BBM violation
(assert (forall ((BBM_Wp Event) (BBM_tlbi Event))
  (not (BBM_sequence1 BBM_Wp BBM_tlbi))))

Debugging virtual memory tests

Figuring out how to get Isla to run some tests can be tricky, as the architectural state relevant for systems features such as address translation and virtual memory can be large and complex. This section aims to contain some useful tips that can help when writing these tests.

  • The Isla --probe option (with --debug p to enable) can be very useful to figure out what is going on during a test. This option lets us specify a Sail function, and Isla will output debug information whenever that function is called or returns. Some useful functions to use with this for litmus tests are:

    • __fetchA64 can be traced to see the control flow via the sequence of instructions that are executed. This is most useful when combined with --debug l to see the compiled litmus test.

    • __BranchTo can be used to track branches, and importantly for tests involving page faults, exception calls. An easy mistake is to miss setting the correct VBAR_ELn register (or set it incorrectly), which causes Isla to jump somewhere arbitrary in memory. Other functions involved in exceptions can be probed, such as AArch64_TakeException.

    • The fault records are constructed via specific functions, so probing something like AArch64_PermissionFault will tell you if a permission fault is occuring or not.

  • The --debug m flag will print information about the page table setup, in addition to information about every memory access that occurs during the test.

  • The --debug f flag can give useful information about control flow, specifically if an unexpected path is being taken, then often looking at the output of this flag will tell us exactly where in the model this is happening, and this often indicates the bit of architectural state which needs to be specifically set.

  • Running with -T 0 to use just a single thread can be useful when the output is hard to decipher due to debug information for multiple threads being interleaved.