Topic 2 · Reference · Weeks 2–5

Two Symbol Tables, One Compiler

Your textbook describes one symbol table. This compiler has two, in different files, with different shapes and opposite lifetimes. What each one is for, and why the split is forced rather than chosen.

Read alongside this

1The question

Your textbook describes the symbol table: one structure, threaded through the phases, accumulating what the compiler learns about each name. Dragon §2.7.

Now grep this compiler. There are two, in two files, and they never exchange a single byte of data:

semantic.c   static Scope       scopes[MAX_SCOPE_DEPTH];  a stack of name lists
symtab.c     static SymbolTable locals, globals;         names with addresses

The natural conclusion is that one of them is redundant. It is not. They answer different questions, they are alive at different times, and — the part worth the rest of this page — neither one could do the other's job, even if you wanted it to.

Why this page exists

When Activity 5 asks you to predict “what the symbol table will contain”, it is asking about the storage map: names and storage locations. When the compiler rejects an undeclared variable, that was the scope stack. Keeping those two straight is most of what makes the Topic 2 traces readable.

2The two tables at a glance

scope stack (semantic.c)storage map (symtab.c)
Question it answersIs this name visible here?What address does this name live at?
Per-entry datathe name, and nothing elsename, type, offset, isArray, arraySize, isGlobal, isParamArray
Structurea stack of scopes, nestedtwo flat arrays: locals and globals
Filled fromdeclaration nodes in the ASTTAC instructions, after optimization
Holds temporaries?no — only names a programmer wroteyes — t0, t1 need frame slots too
Are entries removed?yes, on every exitScope()no, until the next initSymTab()
Lifetimephase 3 onlyphase 6 only; globals spans all functions
A failure meansa user error, reported with a line numberan internal error — exit(1)
The one-sentence version

Both tables map names to facts. The scope stack maps a name to whether you may use it. The storage map maps a name to where it lives. The first question can be answered from the AST. The second cannot be answered until the TAC is final.

3Where each one lives

1 Lex + Syntaxscanner.l2 ASTast.c3 Semanticsemantic.c4 TACtac.c5 Optimizetac.c6 CodeGencodegen.ctokensASTASTTACTAC'what crosses phasesASTTACthe two tablesscope stackstorage mapphases 4 and 5: neither table holds anything
Neither table is populated while the other is. The scope stack is built and emptied inside phase 3; the storage map gets its first entry in phase 6. What crosses a phase boundary is the AST and the TAC — the tables are local to one phase each. The compiler even times them separately: 0.02 ms in phase 3, 0.18 ms in phase 6.

The ordering is fixed in main.c: yyparse() at line 124, then semantic analysis, then generateTAC() at 167, optimizeTAC() at 176, and generateMIPSFromTAC() at 183.

Read that ordering again with the storage map in mind. Every entry in it is a byte offset. At the moment semantic analysis runs there is nothing to take an offset into: no TAC, no list of temporaries, no frame, no idea how big the frame will be. The storage map is not merely unpopulated during phase 3. It is unpopulatable.

4Table 1 — the scope stack in semantic.c

What one entry holds

typedef struct {
    char* names[MAX_VARS];   the entire entry: a string
    int   count;
} Scope;

static Scope scopes[MAX_SCOPE_DEPTH];
static int   scopeDepth = 0;

That really is the whole entry. No type, no size, no address — because none of those are needed to answer the only question this table is ever asked.

How it is searched

static int isVarDeclaredInScope(char* name) {
    for (int depth = scopeDepth - 1; depth >= 0; depth--)   innermost first
        for (int i = 0; i < scopes[depth].count; i++)
            if (strcmp(scopes[depth].names[i], name) == 0)
                return 1;
    return 0;
}

Innermost outward — the most-closely-nested rule from Dragon §2.7, written out in six lines. Compare it with addVarToScope(), which checks for duplicates in the current scope only, because shadowing an outer name is legal and redeclaring in the same block is not.

What it looks like running

Compile any test program without -q and phase 3 narrates itself. Each line is one query against the scope stack:

  ✓ Variable 'x' declared (line 3)
  ✓ Variable 'y' declared (line 4)
  ✓ Variable 'total' declared (line 5)
  ✓ Assignment to 'x' is valid (line 7)
  ✓ Assignment to 'y' is valid (line 8)
  ✓ Assignment to 'total' is valid (line 9)

Nothing is being allocated there. Names are only being accepted or rejected.

When it says no

$ ./minicompiler tests/t2_04_errors_undeclared.cm out.s

║ SEMANTIC ERROR - Undeclared Variable                      ║
  📍 Location: Line 8
  ❌ Error: Variable 'ghost' is used before being declared
  💡 Suggestion: Add a declaration before using this variable:
     → int ghost;  (add this before line 8)
Notice what makes that message possible

A line number. ASTNode carries one (ast.h line 43); TACInstr carries no line number at all — look at the struct in tac.h. So source positions are available in phase 3 and simply gone by phase 6. That is a second, independent reason this check cannot be deferred to the storage map: by then the compiler could tell you that a name has no storage, but not where you wrote it.

5Table 2 — the storage map in symtab.c

What one entry holds

typedef struct {
    char* name;
    char* type;
    int   offset;       locals: byte offset from $sp
    int   isArray;
    int   arraySize;
    int   isGlobal;     1 = lives in .data instead
    int   isParamArray; slot holds a base address, not the data
} Symbol;

Two tables inside one file, because MIPS has two storage classes

GLOBALS  →  .data, addressed by label     la   $t0, counter
LOCALS   →  the current frame, by offset  lw   $t0, 8($sp)

locals is cleared at the start of every function by initSymTab(), because each function gets a fresh activation record. globals is built once by initGlobalScope() and persists for the whole program.

Where its functions are called

All of them, from one file. This is the complete list:

FunctionCall siteWhat it is for
initGlobalScope()codegen.c:371reset the global table, once, before any function
addGlobalVar()codegen.c:387each file-scope TAC_DECL, paired with its .word
addGlobalArray()codegen.c:392each file-scope TAC_ARRAY_DECL, paired with its .space
initSymTab()codegen.c:304start a fresh frame for each function
addVar()codegen.c:311, :314, :298parameters, declarations, and every temporary
addArrayParam()codegen.c:310one word holding an incoming base address
addArray()codegen.c:317reserve size × 4 bytes
getLocalBytes()codegen.c:328frame size = locals + 8, rounded up to a multiple of 8
lookupSymbol()codegen.c:261, :273 and 3 morethe hot path: decides lw $t0, counter vs lw $t0, 8($sp)
printSymTab()codegen.c:424the trace box below

Nothing outside codegen.c calls into symtab.c — not the parser, not tac.c, and not semantic.c. That holds in all six topics. semantic.c does #include "symtab.h", but only to borrow the MAX_VARS constant.

Five declared functions with no callers

getVarOffset, getArraySize, isVarDeclared, isArray and isGlobalSymbol are described in symtab.h as “convenience wrappers kept for readability at the call sites”. There are no such call sites, in any of the six topics: codegen.c calls lookupSymbol() and reads the Symbol fields directly. Proposing that they be deleted is a perfectly good answer to “what would you change about this code?”

What it looks like running

  ┌─ SYMBOL TABLE ─────────────────────────────────────────┐
  │ GLOBALS (.data)                                            │
    (none)
  │ LOCALS (current activation record,  12 bytes)              │
    x              int        0($sp)
    y              int        4($sp)
    total          int        8($sp)
  └──────────────────────────────────────────────────────┘
Why GLOBALS is empty in Topic 2

The TAC generator wraps the whole program in FUNC_BEGIN main … FUNC_END, so every declaration lands inside a function and becomes a local. The global table is initialized and then stays empty for the entire topic. It earns its keep in Topic 3, when real functions and file-scope declarations arrive — which is also when addArrayParam() stops being theoretical.

6Why the shapes differ

semantic.c — scope stacknested · pops · names onlyScope[1]i, tmpScope[0]x, y, totalsearchenterScope() pushes · exitScope() popsthe pop calls free() — entries cease to existstores names, and nothing elsesymtab.c — storage mapflat · persists · names + addresseslocalsx0($sp)y4($sp)total8($sp)t012($sp)t116($sp)globals(empty in Topic 2)searchone flat table per functioncleared only by the next initSymTab()nothing is removed mid-function
The two tables need opposite structures. Scoping is deletion: exitScope() frees the names so they stop resolving, and a lookup walks outward from the innermost box. Storage is permanence: a spill near the end of a function must still find the home a declaration gave it at the top, so the table stays flat and keeps every entry until the next function starts. Then note what the right-hand table contains that the left-hand one never could: t0 and t1, which nobody declared.

Scoping requires deletion. exitScope() frees the names and decrements the depth, because leaving a block has to make those names stop resolving. That is what scoping means. A table that never forgot anything could not implement it.

Storage requires permanence. A register spill near the bottom of a function has to find the home a declaration was given at the top. If the storage map popped entries the way a scope stack does, emitStoreHome() would fail on any name whose block had already ended.

And by phase 6 there is no nesting left to represent. TAC is a flat list; blocks have dissolved into labels and jumps. So the visibility question is meaningless in phase 6, and the storage question is unanswerable in phase 3. Each table has the only structure that fits its own question.

7One program through both tables

tests/t2_01_basics.cm — the whole starter language in four statements:

int x;
int y;
int total;

x = 5;
y = 10;
total = x + y + 3;

print(x);
print(y);
print(total);

Follow one name, total, and watch what each phase knows about it:

PhaseWhat the compiler knows about total
3 · semanticdeclared at line 5, visible at line 9 — and nothing else
4 · TACt0 = x + y, t1 = t0 + 3, total = t1  (14 instructions)
5 · optimizetotal = 18 — t0 and t1 no longer exist  (12 instructions)
6 · storage maptotal lives at offset 8; the frame is 24 bytes
6 · MIPSli $t4, 18  ·  sw to 8($sp) when the register is needed elsewhere

The two TAC listings, side by side, are the whole argument of this handout:

 unoptimized — 14 instructions          optimized — 12 instructions 
  FUNC_BEGIN main                        FUNC_BEGIN main
      DECL int x                             DECL int x
      DECL int y                             DECL int y
      DECL int total                         DECL int total
      x = 5                                  x = 5
      y = 10                                 y = 10
      t0 = x + y                             total = 18
      t1 = t0 + 3                            PRINT 5
      total = t1                             PRINT 10
      PRINT x                                PRINT 18
      PRINT y                                RETURN 0
      PRINT total                        FUNC_END main
      RETURN 0
  FUNC_END main

Three things changed between phase 4 and phase 6, and every one of them would have invalidated a storage map built during phase 3: two temporaries appeared, both were then deleted, and the frame size changed as a result.

8Why they cannot be merged

laid out from the unoptimized TAC5 named slots · 32 bytesx0y4total8t012t116padding (8 B)20saved $ra28offset 0 sits at $spwhat codegen actually emits3 named slots · 24 bytesx0y4total8padding (8 B)12saved $ra20offset 0 sits at $spconstant foldingremoves t0 and t18 B
The storage map cannot be filled before the optimizer runs. Both frames are drawn to the same scale, so that height difference is the eight bytes. Fold x + y + 3 into 18 and the temporaries t0 and t1 stop existing, which takes the frame from 32 bytes to 24. layoutFrame() walks the optimized TAC for exactly this reason, and the prologue it emits really does say addi $sp, $sp, -24.

Four independent reasons, in increasing order of how hard they are to work around:

  1. Ordering. Semantic analysis runs two phases before any TAC exists. There is no frame to measure offsets against.
  2. Temporaries. t0 and t1 are invented by tac.c. They need frame slots — reserveTemp() hands them out — and in this program the temporaries are the only things that get spilled (the trace reports 3 spills, 0 reloads). A phase-3 table could not have named them.
  3. Optimization. The frame size is a function of the optimized TAC, as the figure above shows. Any table filled earlier would reserve slots for names that no longer exist.
  4. Structure. One question needs a stack that pops; the other needs a flat map that persists. Section 6.

9Where the two tables touch

They share no data structure. They coordinate through exactly one rule:

static int isReservedName(const char* name) {
    if (strncmp(name, "__sw", 4) == 0) return 1;   switch temporaries
    if (name[0] != 't' && name[0] != 'L') return 0;
    for (int i = 1; name[i]; i++)
        if (name[i] < '0' || name[i] > '9') return 0;
    return 1;
}

The storage map puts user names and generated temporaries into one flat namespace, where lookupSymbol() could not tell them apart. So the semantic analyzer, three phases earlier, refuses to let you declare a variable named t0:

  ❌ Error: 't0' is reserved for the compiler's own use
  📖 Note: names of the form t0, t1, ... are three-address-code
           temporaries and L0, L1, ... are generated labels.

One of phase 3's jobs, then, is protecting phase 6's key space. That is the entire interface between the two tables: a naming convention, enforced early, with a decent error message attached.

10What the split costs

Declarations get recorded twice, and there are two independent duplicate checks that know nothing about each other:

 semantic.c — reported to the user, with a line number 
if (addVarToScope(node->data.decl.name) == -1) { ...SEMANTIC ERROR... }

 symtab.c — the same question, asked again in phase 6 
int addVar(char* name, char* type) {
    if (findIn(&locals, name)) return -1;      duplicate declaration
    ...
}

layoutFrame() discards that -1 without looking at it, and is entitled to: phase 3 already guaranteed there are no duplicates. But it is a genuine redundancy, and the kind of thing worth noticing — it is the seam where two tables that should arguably be one rub against each other.

What a production compiler does instead

Keeps one table whose entries accumulate attributes across phases: the parser inserts the name, the semantic pass annotates type and scope, the back end annotates the address. That requires the table to model scopes as a persistent tree rather than a stack that frees as it pops, and to survive every phase rather than one. It also means every phase can see fields that are meaningless to it.

What the split buys

symtab.c does not know what a scope is. semantic.c does not know what MIPS is. Each file is 300-odd lines and readable on its own, which is why this compiler is a teaching compiler and GCC is not.

11Check yourself

A classmate proposes filling in the offsets during semantic analysis, so the compiler only needs one table. What breaks first?

Why does the scope stack free() its names on exitScope(), when the storage map never removes anything?

t2_01_basics.cm declares three ints and the frame comes out at 24 bytes. Where are the other 12?

Why can't semantic.c tell you that x is at offset 8?

Because in phase 3 there is no frame. Offsets are handed out by layoutFrame(), which walks the optimized TAC and must also find room for every temporary the intermediate-code generator invented. Neither the temporaries nor the final instruction list exist yet when semantic.c runs.

Both tables are static arrays that exist for the whole process. In what sense is it true that their lifetimes do not overlap?

Fair challenge — and the precise claim matters. Both are statically allocated, so as memory they coexist from start to finish. The claim is about contents: scopeDepth is back to 0 before generateMIPSFromTAC() is called, and locals.count is 0 until layoutFrame() runs. So the scope stack is empty before the storage map holds a single entry. If you wanted to enforce that rather than merely observe it, you would move each table behind an init/destroy pair and assert on use-after-destroy.