C*: Unifying Program and Proof in C

C* Extensions

EN | 中文

C* Extensions

The previous page described the syntax C* inherits unchanged from HOL Light. This page catalogues what C* adds on top of it: the type constants that make separation logic and C types expressible in the logic, the constants and notations built from them, and the naming conventions that connect logical variables back to the C program being verified.

Everything listed here lives inside the backticks of a logical expression. The entries form a syntactic and type-level catalogue — what a name is called, what type it has, how it is read. What the separation-logic constants mean is given in Memory Model and Assertions, and the memory predicates are developed in Typed Memory Predicates.

Types

C* introduces four type constants into HOL Light’s type language. They are ordinary members of the type syntax described on the previous page: they may appear wherever a type may, including in annotations such as (x:addr)(x:addr) and under postfix constructor application, as in (hprop)list(hprop)list.

Type Form Meaning
hprophprop 0-ary heap proposition — the type of separation-logic assertions
ctypectype inductive the logic-level representation of a C type
struct_namestruct_name 0-ary the logic-level representation of a struct name
fieldfield 0-ary the logic-level representation of a field name

struct_namestruct_name and fieldfield are declared as bare 0-ary type constants with no constructors at all: nothing inhabits them until a program introduces its own struct-name and field-name constants, as described under “Structs and Fields” below. ctypectype by contrast is an inductive type, whose constructors are listed in the section on C types. hprophprop is the type of assertions; in C*‘s shallow embedding it unfolds to a predicate over memory, but at the level of syntax it behaves as an opaque type constant.

Two type abbreviations name the roles that integers play in the memory model:

addr = :int
byte = :int
addr = :int
byte = :int

An abbreviation is transparent — addraddr and intint are the same type, and the type checker will not keep them apart. Their purpose is documentary: a signature written addr->ctype->int->hpropaddr->ctype->int->hprop says which argument is an address and which is a stored value, where int->ctype->int->hpropint->ctype->int->hprop would not.

Integer Literals

An unsuffixed numeral is a natural number; C* extends the lexer so that a numeral with the suffix ii is an integer.

Literal Type Note
123123 :num:num natural-number literal, HOL Light’s default
123i123i :int:int integer literal, C*‘s extension
0x1f0x1f, 0b10110b1011 :num:num hexadecimal and binary, also accepting the ii suffix

C*‘s modeling of C programs is integer-valued throughout — addresses, stored values, sizes, and offsets all have type intint — so essentially every numeral in a C* assertion carries the ii suffix. Dropping it is the most common type error when writing assertions:

data_at p Tint 42data_at p Tint 42

fails to type-check, because data_atdata_at wants an intint and 4242 is a numnum.

Negation of a literal is the prefix ----, not --: the additive inverse of 1i1i is --(1i)--(1i), since -- is reserved for binary subtraction.

Heap-Proposition Constants

The constants below are the separation-logic vocabulary. The first group builds assertions and has result type hprophprop; the last group states relations between assertions and therefore has result type boolboolp |-- qp |-- q is not itself an assertion, but a proposition about two assertions.

Notation Type Reads as
empemp hprophprop the heap is empty
truetrue hprophprop any heap at all (heap truth)
falsefalse hprophprop no heap at all (heap falsehood)
purepure bool->hpropbool->hprop the proposition holds; no constraint on the heap
factfact bool->hpropbool->hprop the proposition holds and the heap is empty
**** hprop->hprop->hprophprop->hprop->hprop separating conjunction
&&&& hprop->hprop->hprophprop->hprop->hprop heap conjunction
|||| hprop->hprop->hprophprop->hprop->hprop heap disjunction
-*-* hprop->hprop->hprophprop->hprop->hprop magic wand (separating implication)
-->--> hprop->hprop->hprophprop->hprop->hprop heap implication
existsexists (A->hprop)->hprop(A->hprop)->hprop existential quantifier over assertions
forallforall (A->hprop)->hprop(A->hprop)->hprop universal quantifier over assertions
|--|-- hprop->hprop->boolhprop->hprop->bool entailment
-|--|- hprop->hprop->boolhprop->hprop->bool assertion equality
-||--||- hprop->hprop->boolhprop->hprop->bool assertion equivalence (mutual entailment)

existsexists and forallforall are binders: they are written

exists x. pexists x. p

and

forall x y. pforall x y. p

, which is sugar for applying the constant to a lambda abstraction. The type variable AA in their signatures is unconstrained — one may quantify over an intint, an addraddr, a list, or anything else.

Six of these names are overloaded rather than exclusive to hprophprop: truetrue, falsefalse, existsexists, forallforall, &&&&, and |||| each also denote a boolean counterpart, and type inference picks between them from context. In

fact(x > 0i && y > 0i)fact(x > 0i && y > 0i)

the &&&& is boolean conjunction (HOL Light’s /\/\); in

fact(x > 0i) && empfact(x > 0i) && emp

it is heap conjunction. By contrast |--|--, -|--|-, -||--||-, -*-*, -->-->, ****, empemp, purepure, and factfact are C*-specific names with no boolean reading. C*‘s convention also keeps the two quantifier layers apart notationally: the boolbool layer uses the symbols !! and ??, the hprophprop layer the words forallforall and existsexists.

The three relational notations differ in a way worth stating precisely. -|--|- is HOL Light’s equality at type hprophprop, that is (=):hprop->hprop->bool(=):hprop->hprop->bool under another name; it is what a printed theorem about assertions displays, which is why a definition of a representation predicate is written with == yet prints with -|--|-. -||--||- is a separately defined constant, logic_equivlogic_equiv, meaning entailment in both directions. The two agree extensionally, but they are distinct terms, and the library’s rules distinguish them.

For the precedence and associativity of all these infixes see the operator table in HOL Light Syntax for their definitions and the laws they satisfy see Memory Model and Assertions.

Memory Representation Predicates

These constants are the assertions that describe C memory. Each takes an address, a ctypectype saying how that memory is to be read, and a description of the contents.

Signature Meaning
data_at : addr->ctype->int->hpropdata_at : addr->ctype->int->hprop the address holds the given value, stored at the given C type
undef_data_at : addr->ctype->hpropundef_data_at : addr->ctype->hprop the address holds an allocated but uninitialized location of the given C type
array_at : addr->ctype->int->(int)list->hproparray_at : addr->ctype->int->(int)list->hprop nn consecutive elements from the address on, with the list as their contents
array_at_rec : addr->ctype->int->int->(int)list->hproparray_at_rec : addr->ctype->int->int->(int)list->hprop the half-open index segment lolohihi of an array, with the list as its contents
array_at_missing_i_rec : addr->ctype->int->int->int->(int)list->hproparray_at_missing_i_rec : addr->ctype->int->int->int->(int)list->hprop that same segment minus one index — an array with a single element split out
undef_array_at : addr->ctype->int->hpropundef_array_at : addr->ctype->int->hprop nn consecutive uninitialized elements from the address on
undef_array_at_rec : addr->ctype->int->int->int->hpropundef_array_at_rec : addr->ctype->int->int->int->hprop an uninitialized half-open index segment
undef_array_at_missing_i_rec : addr->ctype->int->int->int->int->hpropundef_array_at_missing_i_rec : addr->ctype->int->int->int->int->hprop an uninitialized segment with one index removed

Argument order for the segment forms is: address, C type, then the indices, then (for the initialized family only) the contents list. The missing_imissing_i forms take the removed index first among the indices, as in array_at_missing_i_rec x ty i lo hi larray_at_missing_i_rec x ty i lo hi l. The undef_undef_ family carries no contents list, since there is nothing to say about the value of uninitialized memory; the index arguments are otherwise parallel, with a trailing length argument in place of the list.

Element number ii of an array based at xx sits at address x + i * sizeof(ty)x + i * sizeof(ty), which is exactly the address C* produces when it elaborates the C subscript expression a[i]a[i]. The rules for splitting, merging, and rebasing these predicates — and the fact that the user-facing ones are supplied as axioms rather than proved from the byte-level model — are the subject of Typed Memory Predicates.

C Types in the Logic

A value of type ctypectype is the logical avatar of a C type: it is what tells data_atdata_at how many bytes a location occupies and which values it may hold. Nine constants cover the scalar types C* models.

Constant C type Constant C type
TcharTchar charchar TucharTuchar unsigned charunsigned char
TshortTshort shortshort TushortTushort unsigned shortunsigned short
TintTint intint TuintTuint unsigned intunsigned int
Tint64Tint64 64-bit signed Tuint64Tuint64 64-bit unsigned
TptrTptr any pointer type

TptrTptr is uniform across pointer types — the logic does not record what a pointer points to, so int *int * and struct list *struct list * are both TptrTptr. (The ctypectype datatype carries one further constructor, TstructTstruct, used inside the library’s byte-level development; hand-written assertions use the nine scalar constants and reach struct memory through field_addrfield_addr instead.)

Three functions read information out of a ctypectype:

Signature Meaning
sizeof : ctype->intsizeof : ctype->int size in bytes: 1i1i for the char types, 2i2i for the short types, 4i4i for the intint types, 8i8i for the 64-bit types and for TptrTptr
min_of : ctype->intmin_of : ctype->int smallest representable value (&0&0 for every unsigned type)
max_of : ctype->intmax_of : ctype->int largest representable value

Structs and Fields

Struct and field names enter the logic as constants of the dedicated types struct_namestruct_name and fieldfield. The library declares no inhabitants of either type in advance; a program introduces the ones it needs, typically with new_constnew_const in a global proof declaration. The naming convention is that a struct-name constant is the C struct name prefixed with TT, and a field-name constant is the C field name prefixed with FFstruct liststruct list becomes TlistTlist, and its fields headhead and tailtail become FheadFhead and FtailFtail. C* matches the C program against the logic by these names, so the prefixes are load-bearing, not decorative.

Field addresses are computed by two constants:

field_offset : struct_name->field->int
field_addr : addr->struct_name->field->addr
field_offset : struct_name->field->int
field_addr : addr->struct_name->field->addr

field_offsetfield_offset is primitive, declared with no definition: the actual byte offsets of a given struct are left uninterpreted and supplied to the verifier per program. field_addrfield_addr by contrast is defined, and its defining theorem, registered in the theorem database as field_addr_propfield_addr_prop, is

field_addr x s f = x + field_offset s f
field_addr x s f = x + field_offset s f

So field_addr pt Tlist Ftailfield_addr pt Tlist Ftail denotes the address of the tailtail field of the struct liststruct list at ptpt, and nested access composes: the C expression &it->node.next&it->node.next becomes field_addr (field_addr it Titem Fnode) Tlink Fnextfield_addr (field_addr it Titem Fnode) Tlink Fnext.

Bitwise Operations

C’s bitwise operators are not primitive in the logic. C* defines four families of them — signed and unsigned, at 32 and 64 bits — over intint, taking their meaning from the machine-word semantics of HOL Light’s word library.

Signed Unsigned Type and reading
i32_andi32_and, i64_andi64_and u32_andu32_and, u64_andu64_and int->int->intint->int->int, bitwise and
i32_ori32_or, i64_ori64_or u32_oru32_or, u64_oru64_or int->int->intint->int->int, bitwise or
i32_xori32_xor, i64_xori64_xor u32_xoru32_xor, u64_xoru64_xor int->int->intint->int->int, bitwise exclusive or
i32_noti32_not, i64_noti64_not u32_notu32_not, u64_notu64_not int->intint->int, bitwise complement
i32_shli32_shl, i64_shli64_shl u32_shlu32_shl, u64_shlu64_shl int->int->intint->int->int, left shift
i32_shri32_shr, i64_shri64_shr u32_shru32_shr, u64_shru64_shr int->int->intint->int->int, right shift

Every one of these is an ordinary HOL definition, not an axiom, and each definition theorem is fetchable from the theorem database under the name <op>_def<op>_def. The pattern is: wrap both operands into an N-bit word with iwordiword, apply the corresponding word operation, and read the result back as an integer — through ivalival (signed interpretation) for the i32_i32_/i64_i64_ family, and through &(val ...)&(val ...) (unsigned interpretation) for the u32_u32_/u64_u64_ family.

i32_and x y = ival (word_and ((iword x):(32)word) ((iword y):(32)word))
u32_and x y = &(val (word_and ((iword x):(32)word) ((iword y):(32)word)))
i64_shr x y = ival (word_ishr ((iword x):(64)word) (num_of_int y))
u64_shr x y = &(val (word_ushr ((iword x):(64)word) (num_of_int y)))
i32_and x y = ival (word_and ((iword x):(32)word) ((iword y):(32)word))
u32_and x y = &(val (word_and ((iword x):(32)word) ((iword y):(32)word)))
i64_shr x y = ival (word_ishr ((iword x):(64)word) (num_of_int y))
u64_shr x y = &(val (word_ushr ((iword x):(64)word) (num_of_int y)))

Two details of this encoding matter when reasoning about these operators.

  • Value operands wrap totally. iwordiword is defined on every integer, negatives included, so u32_and (a - b) cu32_and (a - b) c is well specified even when b > ab > a. This is deliberate: unsigned C arithmetic keeps intermediates as mathematical integers in this model — there is no u32_subu32_sub — and a partial encoding would leave such terms unsimplifiable.
  • Shift counts do not. The second operand of shlshl and shrshr passes through num_of_intnum_of_int, which is unspecified on negative inputs. A negative shift count is undefined behaviour in C, so nothing is lost; but a proof that must evaluate a shift has to establish that the count is non-negative.

Right shift also splits by signedness at the word level: the signed family uses the arithmetic shift word_ishrword_ishr, the unsigned family the logical shift word_ushrword_ushr.

Width Conversions

Two further operators model C’s conversion of a value to a given bit width:

signed_last_nbits x y = cast_signed y x
unsigned_last_nbits x y = cast_unsigned y x
signed_last_nbits x y = cast_signed y x
unsigned_last_nbits x y = cast_unsigned y x

Both have type int->int->intint->int->int, where xx is the value and yy the target width in bits — note that the arguments come in the opposite order to the underlying cast_signedcast_signed and cast_unsignedcast_unsigned of the C-semantics layer. cast_unsigned n zcast_unsigned n z is z rem exp_2 nz rem exp_2 n (HOL’s remrem is Euclidean, so the result is never negative), and cast_signedcast_signed re-centres that residue into the signed window. Both are total and width-generic: they behave correctly for negative xx, and for widths of 64 bits and beyond.

Three facts about them are proved, and are available from the theorem database:

Theorem Statement
unsigned_last_nbits_rangeunsigned_last_nbits_range !x y. &0 <= unsigned_last_nbits x y /\ unsigned_last_nbits x y <= max_unsigned y!x y. &0 <= unsigned_last_nbits x y /\ unsigned_last_nbits x y <= max_unsigned y
unsigned_last_nbits_idunsigned_last_nbits_id !x y. &0 <= x /\ x < exp_2 y ==> unsigned_last_nbits x y = x!x y. &0 <= x /\ x < exp_2 y ==> unsigned_last_nbits x y = x
signed_last_nbits_rangesigned_last_nbits_range !x y. &0 < y ==> min_signed y <= signed_last_nbits x y /\ signed_last_nbits x y <= max_signed y!x y. &0 < y ==> min_signed y <= signed_last_nbits x y /\ signed_last_nbits x y <= max_signed y

The _range_range lemmas give the result window, which is what discharges a data_atdata_at range obligation after a conversion. unsigned_last_nbits_idunsigned_last_nbits_id is the “already in range, so the conversion is the identity” law, matching exactly the condition under which the C* frontend strips a conversion from a verification condition. The definitions of cast_signedcast_signed and cast_unsignedcast_unsigned are themselves exported, under the names cast_signed_defcast_signed_def and cast_unsigned_defcast_unsigned_def, so a proof block can unfold all the way down when it must.

Naming Conventions from Symbolic Execution

C*‘s memory model folds the store into the heap: an assertion has no notion of “program variable”, so a C variable must appear in the logic as an ordinary logical variable. The symbolic execution engine generates those variables by a fixed naming scheme, and assertions in contracts and invariants are written using exactly these names.

Logical variable Type Denotes
x__addrx__addr addraddr the address of the program variable xx — parameter, local, or global
x__prex__pre the value type of xx the value of the parameter xx on entry to the function
__return__return the return type the function’s return value, usable in a postcondition

The separator is a double underscore, and these are ordinary identifiers as far as the lexer is concerned: __ is an alphanumeric character in HOL Light, so x__addrx__addr is a single token, and nothing prevents a program from colliding with the convention by declaring a variable of its own by that name.

An intint parameter xx therefore contributes the resource data_at x__addr Tint x__predata_at x__addr Tint x__pre to the symbolic state at function entry, and an uninitialized local int rint r contributes undef_data_at r__addr Tintundef_data_at r__addr Tint. The value range of x__prex__pre is not a standalone pure fact in that state: it is built into the data_atdata_at resource, and is extracted with the range rule when a proof needs it.

Example 1 (A contract in these names).

For int abs_diff(int a, int b)int abs_diff(int a, int b), “the result is the absolute difference of the two arguments” is stated over a__prea__pre, b__preb__pre, and __return__return:

[[cst::require]] `emp`
[[cst::ensure]] `fact(__return == a__pre - b__pre || __return == b__pre - a__pre) **
fact(__return >= 0i)`
int abs_diff(int a, int b);
[[cst::require]] `emp`
[[cst::ensure]] `fact(__return == a__pre - b__pre || __return == b__pre - a__pre) **
fact(__return >= 0i)`
int abs_diff(int a, int b);

The parameters’ own storage — data_at a__addr Tint a__predata_at a__addr Tint a__pre and data_at b__addr Tint b__predata_at b__addr Tint b__pre — need not be mentioned: symbolic execution adds it to the state on entry and discards it on exit, so a contract speaks only about memory the caller owns.

How to write contracts and invariants over these names is developed in Function Contracts, Loop Invariants, and Assertions how they appear in the conditions the engine emits is developed in Verification Condition Generation.

Summary

  • C* adds the type constants hprophprop, ctypectype, struct_namestruct_name, fieldfield to HOL Light’s type language, plus the transparent abbreviations addr = :intaddr = :int and byte = :intbyte = :int.
  • A numeral is a numnum unless it carries the ii suffix, which makes it an intint; theorem statements from the library use the equivalent &n&n injection notation instead.
  • The heap-proposition constants — ****, &&&&, ||||, -*-*, -->-->, empemp, purepure, factfact, truetrue, falsefalse, existsexists, forallforall — build assertions; |--|--, -|--|-, -||--||- relate assertions and have result type boolbool. Six of the names are overloaded with boolean counterparts.
  • data_atdata_at/undef_data_atundef_data_at and the array_atarray_at/undef_array_atundef_array_at families describe C memory; every signature takes a ctypectype argument, and array element ii lives at x + i * sizeof(ty)x + i * sizeof(ty).
  • Nine ctypectype constants cover the scalar C types; sizeofsizeof, min_ofmin_of, max_ofmax_of read information out of them; structs enter through TT-prefixed and FF-prefixed constants together with field_addr x s f = x + field_offset s ffield_addr x s f = x + field_offset s f.
  • The bitwise and width-conversion operators are defined over HOL’s machine words, not axiomatized: value operands wrap totally via iwordiword, shift counts pass through num_of_intnum_of_int (a negative count being C undefined behaviour), and the range lemmas for signed_last_nbitssigned_last_nbits and unsigned_last_nbitsunsigned_last_nbits are proved.
  • Program variables reach the logic as x__addrx__addr, x__prex__pre, and __return__return.