- Compilers: Principles, Techniques and Tools §2.7 — symbol tables per scope, and the most-closely-nested rule.
- §7.2 — stack allocation of space: the activation record that every offset in this handout is an offset into.
- §8.3 — addresses in the target code: what has to be known before a name can become an operand.
- The source, read side by side:
compiler/semantic.c(323 lines) andcompiler/symtab.c(226 lines). Together they are smaller thancodegen.c.
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.
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 answers | Is this name visible here? | What address does this name live at? |
| Per-entry data | the name, and nothing else | name, type, offset, isArray, arraySize, isGlobal, isParamArray |
| Structure | a stack of scopes, nested | two flat arrays: locals and globals |
| Filled from | declaration nodes in the AST | TAC instructions, after optimization |
| Holds temporaries? | no — only names a programmer wrote | yes — t0, t1 need frame slots too |
| Are entries removed? | yes, on every exitScope() | no, until the next initSymTab() |
| Lifetime | phase 3 only | phase 6 only; globals spans all functions |
| A failure means | a user error, reported with a line number | an internal error — exit(1) |
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
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)
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:
| Function | Call site | What it is for |
|---|---|---|
initGlobalScope() | codegen.c:371 | reset the global table, once, before any function |
addGlobalVar() | codegen.c:387 | each file-scope TAC_DECL, paired with its .word |
addGlobalArray() | codegen.c:392 | each file-scope TAC_ARRAY_DECL, paired with its .space |
initSymTab() | codegen.c:304 | start a fresh frame for each function |
addVar() | codegen.c:311, :314, :298 | parameters, declarations, and every temporary |
addArrayParam() | codegen.c:310 | one word holding an incoming base address |
addArray() | codegen.c:317 | reserve size × 4 bytes |
getLocalBytes() | codegen.c:328 | frame size = locals + 8, rounded up to a multiple of 8 |
lookupSymbol() | codegen.c:261, :273 and 3 more | the hot path: decides lw $t0, counter vs lw $t0, 8($sp) |
printSymTab() | codegen.c:424 | the 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.
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)
└──────────────────────────────────────────────────────┘
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
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:
| Phase | What the compiler knows about total |
|---|---|
| 3 · semantic | declared at line 5, visible at line 9 — and nothing else |
| 4 · TAC | t0 = x + y, t1 = t0 + 3, total = t1 (14 instructions) |
| 5 · optimize | total = 18 — t0 and t1 no longer exist (12 instructions) |
| 6 · storage map | total lives at offset 8; the frame is 24 bytes |
| 6 · MIPS | li $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
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:
- Ordering. Semantic analysis runs two phases before any TAC exists. There is no frame to measure offsets against.
- Temporaries.
t0andt1are invented bytac.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. - 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.
- 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.