Topic 3 · Lecture notes · Weeks 6–8

Storage, Scope, and the Run-Time Environment

Where a name lives at run time — in the data area, in a stack frame, or behind a pointer — and the calling conventions that make functions and recursion possible at all.

Read alongside this

0Where this topic sits

1. Lexicalchars → tokensscanner.l2. Syntaxtokens → ASTparser.y3. SemanticAST → checked ASTsemantic.c4. IRAST → TACtac.c5. OptimizeTAC → better TACtac.c6. CodeGenTAC → MIPScodegen.c

Topic 2 gave every variable a slot in one flat region, because there was only ever one thing running. This topic breaks that assumption three times over. Arrays mean a name can occupy many slots. Functions mean several activations can be alive at once, each needing its own storage. Scope means one name can refer to different storage in different places.

The theory that handles all three is the run-time environment: the set of conventions about where data lives and who is responsible for it. Dragon devotes a whole chapter to it, and it is the single largest conceptual step in the course.

1Types, declarations, and storage layout

A declaration does two jobs (Dragon §6.3): it records a name's type in the symbol table, and it reserves storage. The type determines how much.

Types are built inductively, exactly like regular expressions were. A type expression is either a basic type, or a type constructor applied to type expressions (§6.3.1):

basic types        int   float   char   boolean   void
constructors       array(n, T)          n elements of type T
                   record( ... )        named fields
                   pointer(T)
                   T₁ × T₂ → T          a function

    int scores[10]     is     array(10, int)
    int sum(int a[])   is     array(?, int) → int

Storage follows from the type. Dragon §6.3.2 computes it with two attributes carried through the declaration list — a type and a width — and a running offset that starts at zero and advances by the width of each declared name. That is precisely what our symbol table does, and it is a textbook example of an inherited attribute doing useful work.

Alignment is real, even though we ignore it

Dragon §6.3.2 notes that many machines require a multi-byte object to sit at an address divisible by its size, so compilers insert padding between fields. Our language has only int, all four bytes wide, so every offset is naturally aligned and the issue never arises. It arises immediately if you add char as an optional extension — and it arises in the .data section regardless, which is why the generated assembly contains .align 2 directives.

2Arrays: indexing is address arithmetic

Dragon §6.4.3 derives array addressing from first principles, and the derivation is worth following because everything else about arrays follows from it.

int scores[5]; base at offset 8 in the framescores[0]8($sp)scores[1]12($sp)scores[2]16($sp)scores[3]20($sp)scores[4]24($sp)5 elements × 4 bytes = 20 bytes, contiguousaddress of scores[i] = base + i × ww is the width of one element — 4 bytes for an int on MIPS
A one-dimensional array is contiguous storage, and indexing is arithmetic (Dragon §6.4.3). There is nothing else to it: the whole of array support in the back end is computing base + i×w and then loading or storing. The only genuinely interesting question is where base comes from, and that has three different answers.

For one dimension the formula is base + i × w. Note that i × w is a multiplication in a loop, and that w is a power of two — so a compiler emits a shift instead. That is strength reduction (Dragon §9.1.8) appearing already, and it is the first optimization in your back end even though phase 5 has not run.

int m[3][4]; laid out in ROW-MAJOR order0,00,10,20,3row 01,01,11,21,3row 12,02,12,22,3row 2address of m[i][j] = base + ( i × n₂ + j ) × wn₂ = the number of columns. Note the compiler must REMEMBER n₂ — the row length ispart of the type, not part of the value, and it must live in the symbol table.
Two dimensions, and why the type matters (Dragon §6.4.3, Fig. 6.22). Row-major stores each row contiguously; column-major (Fortran) stores each column. The formula generalizes by a recurrence, and for k dimensions it needs k−1 of the bounds — which is exactly why C requires every dimension but the first in a parameter declaration. We support one dimension, but knowing the general shape is what makes that limitation a decision rather than an omission.

For k dimensions the addresses come from a recurrence, and computing them requires the compiler to remember the array's bounds — which are part of the type, not part of the value. This is the reason C makes you write int m[][4] rather than int m[][] in a parameter list: without the column count the callee cannot compute an address.

Where does base come from?

This is the only part that is not arithmetic, and it has three answers depending on where the array lives:

Kind of arrayWhere the elements areHow the base is obtained
globalthe static data areathe address of a label — known at link time
localthis activation recordstack pointer plus a compile-time offset
parametersomebody else's frame, or the data arearead out of the parameter slot — it is a pointer
An array parameter is a pointer, and that is a language decision

Dragon §1.6.6 distinguishes call-by-value from call-by-reference. Ours passes integers by value and arrays by reference — as C does, and for the same reason: copying an array on every call is expensive, and the copy is almost never wanted. The consequences are worth stating explicitly. The callee's slot holds one word regardless of the array's size. The callee cannot know the length, so every function taking an array also takes a count. And a write through the parameter modifies the caller's array — which is either a feature or a hazard, depending on the day.

3Scope

The scope of a declaration is the region of program text in which it applies (Dragon §1.6.3). When several declarations of the same name are in scope, the most closely nested rule decides which one a use refers to.

int n; globalint show(int n) { parameter n shadows the global return n;}int main() { print(n); the global print(show(2)); the parameter { int n; n = 9; } a block-local n}[2] blockn[1] function main(none)[0] globaln, show, mainlookup searches TOP DOWN and stops at the first hit
Scope is a stack, and lookup is innermost-first — the most-closely-nested rule (Dragon §1.6.3, §2.7.1). Entering a block pushes a table; leaving pops it. Shadowing is not a special case that has to be programmed: it is what searching from the top down already does.

Implementing this needs no cleverness: a stack of tables, pushed on entering a block and popped on leaving, with lookup searching from the top. Shadowing then falls out of the search order rather than needing to be programmed (Dragon §2.7.1).

Static scope and dynamic scope

Ours is statically (lexically) scoped: which declaration a use refers to is determined by the program text, and can be settled at compile time. The alternative is dynamic scoping, where a use refers to the most recent declaration made by any currently active procedure — a run-time question (Dragon §1.6.5, §7.3). Almost every modern language chose static scoping, because it lets a reader determine what a name means without simulating execution.

Access to non-local data

Dragon §7.3 covers the harder case: a procedure nested inside another, referring to a variable of its enclosing procedure. Solving it needs access links or a display. Our language has no nested procedures, so the problem does not arise — a name is local, a parameter, or global, and nothing else. Knowing what the access link is for is enough; you will meet it again in any language with closures.

semantic.c — the SCOPE STACKanswers: "is this name visible here?"lives: only during phase 3shape: a stack of scopes, pushed and poppedholds: names, and enough to check a usethrown away when analysis finishessymtab.c — the STORAGE MAPanswers: "what ADDRESS is this name?"lives: during phase 6shape: one table per function, plus globalsholds: names, offsets, sizes, is-it-an-arrayrebuilt for each function generated
There are two symbol tables and they answer different questions. Conflating them is the most common conceptual error in this topic. Dragon §2.7 treats the first; §7.2 and §8.3 treat the second. They may share an implementation in some compilers, but they never share a purpose.

4Run-time storage organization

Codethe generated instructionsfixed at compile timeStaticglobals — size and lifetime known staticallyfixed at compile timeHeapdynamic data, freed explicitly or collectedgrows downward↓  free space  ↑Stackone activation record per live callgrows upwardlow addresseshigh addresses
Run-time storage organization (Dragon §7.1, Fig. 7.1). Code and static data have addresses fixed at compile time. The heap and the stack grow toward each other so that whichever needs more space can take it. Everything in this topic is about the stack: it is where parameters, locals and return addresses live, and it is the reason recursion works.

Dragon §7.1 divides run-time memory into four areas, and which area a name lands in is decided entirely by its lifetime:

LifetimeAreaAllocated byIn our language
the whole program runstaticthe compiler, at compile timeglobals — emitted into .data
one activation of a procedurestackthe calling sequence, at run timeparameters, locals, temporaries
arbitrary — outlives its creatorheapan explicit allocatornone; we have no malloc

This is the whole reason global and local variables are generated so differently. It is not an implementation quirk — it is the lifetime of the name deciding which storage discipline can serve it.

5Activation records

Every execution of a procedure is an activation, and every activation needs somewhere to keep its parameters, locals and bookkeeping. That somewhere is an activation record, or frame (Dragon §7.2).

mainreadArraysortquicksort(1,9)quicksort(1,3)q(1,0)q(2,3)quicksort(5,9)
An activation tree (Dragon §7.2.1, Fig. 7.4). Each node is one activation — one execution of a procedure. A child's activation begins after its parent's begins and ends before its parent's ends, which is exactly the discipline a stack enforces. That correspondence is the whole justification for stack allocation: at any moment, the live activations are precisely the path from the root to the node currently executing.

The key observation (Dragon §7.2.1) is that activations nest: if activation b begins while a is running, then b finishes before a does. That is exactly the discipline of a stack — which is why activation records go on a stack, and why the design generalizes to recursion without any extra machinery.

returned valuewhere the callee leaves its resultactual parametersthe arguments, as the callee sees themcontrol linka pointer to the caller's recordaccess linkfor reaching non-local data (not needed in our language)saved machine statusthe return address, and any registers to preservelocal datathe callee's own variables and arraystemporariescompiler-generated intermediate valueshighlighted rows are the ones our activation record actually contains
A general activation record (Dragon §7.2.2, Fig. 7.5). Not every language needs every field. Ours has no nested procedures, so no access link; it returns in a register, so no returned-value slot; and it uses the stack pointer alone, so no control link. What remains — parameters, saved return address, locals, temporaries — is the minimum that makes calls and recursion work.
main is runningmain$spmain calls sum(a, 4)summain$spsum calls total(...)totalsummain$sptotal returnssummain$spsum returnsmain$sp
The stack of activation records, over time. Each call pushes a record; each return pops one. The highlighted frame is the one currently executing, and $sp always points at its base. Read left to right, this is a depth-first traversal of the activation tree above — which is the correspondence that makes a stack the right data structure.
The three failure modes, and what each looks like

6The calling sequence

Allocating and freeing a frame is a joint operation. Dragon §7.2.3 calls the code that does it the calling sequence, split between the caller and the callee.

CALLER doesCALLEE does1. evaluate the arguments2. place them where the callee expects ($a0–$a3)3. write back anything live in caller-saved registers4. jal — which stores the return address in $ra5. …6. collect the result from $v01. push a frame: addi $sp, $sp, −frameSize2. save $ra into the frame3. copy parameters out of $a0–$a3 into the frame4. run the body5. put the result in $v06. restore $ra, pop the frame, jr $ra
The calling sequence, divided between caller and callee (Dragon §7.2.3). The division is a convention, not a law — what matters is that both sides agree. The general principle is to push work onto the callee wherever possible, because the callee's half of the sequence appears once in the program while the caller's half appears at every call site.

Two design principles govern the split (Dragon §7.2.3), and both are worth being able to state:

  1. Put as much as possible in the callee. The callee's half of the sequence occurs once in the program; the caller's half occurs at every call site. Moving work into the callee shrinks the program.
  2. Fields whose size is not known to the caller go near the top of the frame. Then the caller can compute offsets to the fields it does know about, without knowing the callee's local storage requirements at all.

Register conventions

A calling convention must also say what happens to registers, and there are only two possible answers per register. Caller-saved: the callee may destroy it, so the caller writes back anything it still needs. Callee-saved: the callee must preserve it, saving and restoring it if it wants to use it. MIPS convention makes $t0–$t9 caller-saved and $s0–$s7 callee-saved. Ours uses only the caller-saved set, which is why every live value is written back before a jal.

Why does the calling convention put the return address in a register ($ra) and then require the callee to save it to memory, rather than having jal push it onto the stack directly?

7Intermediate code for procedures

Dragon §6.9 shows what a call becomes in three-address code — a short, deliberately machine-independent sequence:

source                     three-address code
                           ─────────────────────────────
n = f(a[i]);               t1 = i * 4
                           t2 = a [ t1 ]
                           param t2
                           t3 = call f, 1
                           n = t3

Note what the IR does not say: nothing about registers, nothing about the stack, nothing about who saves what. All of that is the back end's business. param means 'here is an argument', and call f, 1 means 'transfer control to f, which takes one argument'. That is the front end's entire contribution to the calling convention, and it is why retargeting does not touch phase 4.

8Two passes, and why

This program is legal, and forcing it to be legal is a decision:

int main() {
    print(helper(3));       <- used here …
    return 0;
}
int helper(int x) {         <- … defined here
    return x + 1;
}

A single traversal would reach the call before ever seeing the definition, so it could not check the argument count. Two passes fix it: the first records every function's signature, the second checks the bodies. This is a small instance of a general pattern — when a check needs information from later in the program, either make a preliminary pass to collect it, or require the programmer to supply it in advance.

C chose the second: it required forward declarations precisely so that one-pass compilation stayed possible on 1970s hardware. That constraint is gone, and modern languages generally do not impose it. Either answer is defensible in your compiler; not knowing which you implemented is not.

9Type checking, briefly

Dragon §6.5 builds a type checker as a syntax-directed definition: a synthesized attribute type on every expression node, and rules that combine children's types and report a mismatch. With only int in the language there is nothing yet to mismatch — but the shape is worth seeing, because it is where an optional extension would go:

E → E₁ + E₂    { E.type = (E₁.type == integer and E₂.type == integer)
                              ? integer : type_error }

E → E₁ [ E₂ ]  { E.type = (E₁.type == array(s, t) and E₂.type == integer)
                              ? t : type_error }

Two ideas from that section are worth carrying even without a type system. Type conversion: when operand types differ, a compiler may insert a coercion — the inttofloat you saw in Topic 1's running example. And type equivalence: does a name for a type mean the same as its definition? Structural versus name equivalence is a real language decision and a good interview question.

10The approach for this milestone

Do arithmetic and arrays first, functions second

The first two are additive — new cases in switch statements you already wrote. Functions change what the code generator has to promise, and will take longer than the other two combined. Budget accordingly.

Draw the frame before you generate it

On paper, for one small function: every slot, its offset, and the total size. Then compare with what the compiler reports. This takes ten minutes and saves an evening of reading generated assembly hoping something jumps out.

Decide the frame layout once, and write it down

Where does $ra go? Do offsets grow up or down from the stack pointer? Where do temporaries live? These are arbitrary choices, but they must be made once and honored by the prologue, the epilogue and every load and store. A comment diagram at the top of the code generator is worth more than any amount of debugging later.

Keep the two symbol tables separate in your head, and in your files

Visibility is a phase-3 question; storage is a phase-6 question. They have different lifetimes, different shapes, and different contents. Students who conflate them cannot debug either one.

Test recursion early

fact(5) returning 120 exercises the entire calling convention in one line. If it returns anything else, stop and fix the convention before writing another feature — everything downstream depends on it.

11Self-check

Why are activation records kept on a stack rather than in a list or a table?

int a[5]; is a local at frame offset 12. What is the address of a[3], and how many bytes does the array occupy?

A function takes int a[]. How many bytes does that parameter occupy in the callee's frame?

Which task belongs to the CALLER rather than the callee, and why?

Your compiler has a scope stack in semantic.c and a symbol table in symtab.c. Why not merge them?

12References