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:
-
Rust (https://rustup.rs/).
-
Cross-compiler toolchains for Arm and RISC-V
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:
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 ast.0
, witht.1
,t.2
and so on for the subsequent elements. -
A bitvector
bv
can be sliced asbv[n .. m]
wheren
andm
are inclusive indices wheren >= m
. -
A bitvector
bv
can be zero-extended to a lengthn
asbv.extz(n)
, and sign-extended usingbv.exts(n)
. -
bv.length(n)
will filter any bitvectors that do not have a length of exactlyn
. -
A struct
s
can have its fields accessed using thes.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:
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.
|
|
|
|
|
|
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 |
|
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:
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.
-
At the very start of the test before
TakeReset
is called. -
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:
-
Registers can be set based on the contents of memory. In particular, the setup of the page tables.
-
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
— ForN in {1, 2, 3}
, takes a virtual or intermediate physical address and a page table base address and returns the levelN
page table entry (pte) for that address. -
descN
— ForN in {1, 2, 3}
, takes a virtual or intermediate address and a page table base address and returns the levelN
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
orvmid
— 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
— ForN in {1, 2, 3}
Create a valid levelN
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 whenN != 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 levelN
table for a virtual or intermediate physical address. Has two keyword arguments:-
va
oripa
— 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
andtrf2
relations, such thattrf; [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
whereN, 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 theStage1
and another for theStage2
reads. This option shares the same consequences as the previous, except that we preserve theStage1
andStage2
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 usetrf; iio; [T]; trf^-1
. -
--remove-uninteresting
removes 'uninteresting' events. There are two variantssafe
andall
. 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). Theall
option removes every such translate event, whereas thesafe
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 fromERET
events to translates, then from translates to other non-translate events. If the model was changed to derive the edges fromERET
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:
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:
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.
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 correctVBAR_ELn
register (or set it incorrectly), which causes Isla to jump somewhere arbitrary in memory. Other functions involved in exceptions can be probed, such asAArch64_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.