Group A — Short Answer Questions (1 Mark Each)
Ans: Concise point-wise definition/formula for Define a Lexeme with an example. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is a Token in compiler design? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Pattern in lexical analysis. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is a Cross Compiler? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Bootstrapping in compilers. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is a Symbol Table? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Name the two parts of a compiler (Analysis and Synthesis). in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Left Recursion in context-free grammar? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Left Factoring. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is an Ambiguous Grammar? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Parse Tree. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is an Abstract Syntax Tree (AST)? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Shift-Reduce Conflict in LR parsing. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Reduce-Reduce Conflict in LR parsing. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is LL(1) parsing? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define FIRST set in top-down parsing. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define FOLLOW set in top-down parsing. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is $ symbol in parsing tables? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is ε-production in grammar? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Handle in bottom-up parsing. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Shift action in LR parsing? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Reduce action in LR parsing? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Accept action in parser? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Yacc tool. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Lex/Flex tool. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Synthesized Attribute? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Inherited Attribute? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define S-Attributed SDT. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define L-Attributed SDT. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Three-Address Code (3AC)? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Quadruple representation. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Triple representation. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Indirect Triple representation. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Backpatching? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Activation Record. --- Page 2 --- in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Display in run-time stack management? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is a Basic Block? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is a Control Flow Graph (CFG)? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Leader in basic block identification. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Directed Acyclic Graph (DAG) in optimization? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Common Subexpression Elimination? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Dead Code Elimination? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Constant Folding? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Copy Propagation? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Loop Invariant Code Motion? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Strength Reduction? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Peephole Optimization? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Register Allocation. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is Register Interference Graph? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Spooling/Spilling in register allocation. in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for What is a Static Single Assignment (SSA) form? in accordance with MAKAUT examination pattern.
Ans: Concise point-wise definition/formula for Define Dominator in control flow analysis. in accordance with MAKAUT examination pattern.
Group B — Medium / Descriptive Questions (5 Marks Each)
A compiler translates source code into target/machine code through a sequence of phases, each performing a well-defined transformation and passing its output to the next. The phases are grouped into an analysis part (Lexical, Syntax, Semantic Analyzer and Intermediate Code Generator, which build an intermediate representation) and a synthesis part (Code Optimizer and Code Generator, which build the target program). Two components run alongside every phase: the Symbol Table Manager, which records identifiers, their types, scopes and addresses so any phase can look them up, and the Error Handler, which detects and reports errors (lexical, syntax, semantic) without necessarily halting compilation.
- Lexical Analyzer: groups input characters into tokens (identifiers, keywords, operators).
- Syntax Analyzer: checks tokens against grammar rules, building a parse tree.
- Semantic Analyzer: checks type consistency and scope rules, builds an annotated tree.
- Intermediate Code Generator: produces a machine-independent representation (e.g. three-address code).
- Code Optimizer: improves the intermediate code for speed/space without changing meaning.
- Code Generator: maps optimized intermediate code to target machine instructions.
Thompson's Construction builds an NFA from a regular expression by recursively composing small NFA fragments, one per operator, where every fragment has exactly one start state and one accepting state.
- Base symbol a: start --a--> accept (a single edge).
- Union r1|r2: a new start state gets epsilon-edges to both sub-NFA starts; both sub-NFA accept states get epsilon-edges to one new common accept state.
- Concatenation r1r2: the accept state of N(r1) is epsilon-linked to the start state of N(r2).
- Closure r*: a new start/accept pair is added with epsilon edges: newstart→oldstart, oldaccept→oldstart (repeat loop), newstart→newaccept (bypass, zero repetitions), oldaccept→newaccept.
For (a|b)*a: build N(a) and N(b) as base fragments, combine them with the union rule to get N(a|b), apply the closure rule to obtain N((a|b)*), then concatenate a fresh N(a) fragment at the end. This yields exactly 10 states and 12 transitions (4 symbol transitions + 8 epsilon transitions), matching Thompson's rule of 2 states per operator/symbol occurrence.
Subset Construction (Rabin-Scott) converts an NFA to an equivalent DFA where each DFA state is the ε-closure of a set of NFA states. Starting from the ε-closure of the NFA start state, for every unmarked DFA state T and input symbol c, compute ε-closure(move(T,c)); if this set is new, add it as a fresh DFA state. This is repeated until no new sets appear.
For (a|b)*abb (using the standard 11-state Thompson NFA for this expression), applying subset construction produces exactly 5 distinct reachable DFA states:
| DFA State | on a | on b | Accepting? |
|---|---|---|---|
| A (start) | B | C | No |
| B | B | D | No |
| C | B | C | No |
| D | B | E | No |
| E | B | C | Yes |
Verification: tracing "abb" → A --a--> B --b--> D --b--> E (accept). Tracing "ababb" → A-a->B-b->D-a->B-b->D-b->E (accept) — both consistent with the language (a|b)*abb.
During lexical analysis, source text is scanned and grouped into meaningful units. Three related but distinct terms describe this process:
| Term | Meaning | Example |
|---|---|---|
| Lexeme | The actual sequence of characters in the source program that matches a pattern for a token. | total, count, 123 |
| Token | An abstract symbol/category returned to the parser, consisting of a token-name and an optional attribute value; represents a class of lexemes. | <id, ptr>, <num, 123> |
| Pattern | The rule (regular expression) that describes the set of strings a lexeme must match to be classified as a given token. | identifier pattern: letter(letter|digit)* |
For example, in the statement total = count + 10;, the lexeme total is recognized as belonging to the token class id because it matches the pattern for identifiers (a letter followed by letters or digits). Similarly 10 is a lexeme matching the pattern for numbers, classified as token num with attribute value 10. In short: the pattern is the rule, the lexeme is the actual matched text, and the token is the category the lexical analyzer reports to the parser, optionally carrying the lexeme as an attribute.
The symbol table is a data structure (commonly a hash table) that stores information about every identifier (variables, functions, constants, labels) used in the source program, such as name, type, scope, size, storage location/offset, and, for functions, parameter and return-type information. It is created during lexical analysis and consulted or updated throughout the compilation process, acting as a shared repository across phases.
| Phase | Use of Symbol Table |
|---|---|
| Lexical Analyzer | Inserts new identifiers as lexemes are recognized; returns a pointer to the entry as the token attribute. |
| Syntax Analyzer | Uses entries to help distinguish identifier classes where grammar is ambiguous (e.g. typedef names in C). |
| Semantic Analyzer | Fills in type and scope information; performs type checking and reports undeclared/redeclared identifiers using table entries. |
| Intermediate Code Generator | Uses type/address information to generate correctly typed three-address code. |
| Code Optimizer | Uses scope/usage information to decide safe optimizations (e.g. register allocation, dead-code elimination). |
| Code Generator | Uses storage/offset information from the table to generate correct memory addresses and instructions. |
Because every phase reads or writes it, the symbol table effectively acts as the compiler's shared memory, ensuring consistent identifier information from the first character scanned to the last instruction emitted.
When the lexical analyzer encounters a sequence of characters that cannot be matched to any pattern for a valid token (a lexical error, e.g. an illegal character or a malformed number), it cannot simply stop compilation, since only a small part of the input may be invalid. Instead it applies recovery strategies so scanning can continue and report as many genuine errors as possible in one pass.
- Panic mode recovery: successive characters are deleted from the remaining input one at a time until the lexical analyzer can find a well-formed token at the beginning of what input is left. This is the simplest and most commonly used strategy.
- Deletion: an extraneous/spurious character is removed from the input string.
- Insertion: a missing character required to complete a valid token is inserted into the input.
- Substitution/Replacement: an incorrect character is replaced by the correct one.
- Transposition: two adjacent characters that are interchanged (a common typing error) are swapped back.
More advanced analyzers choose the recovery action that requires the fewest character transformations (minimum edit distance) to convert the erroneous lexeme into a valid one, minimizing the chance of introducing further spurious errors.
A grammar is left recursive if it has a nonterminal A such that there is a derivation A => A alpha for some string alpha. Top-down predictive parsers (recursive descent, LL(1)) go into infinite loops on left-recursive grammars, so left recursion must be eliminated before such parsing.
Immediate left recursion elimination: for a production of the form
(where no betai begins with A), replace it with:
This removes the left recursion by pushing the repeated alpha parts to the right, using a new nonterminal A' and epsilon (empty string) as the base case.
Worked example: the grammar E -> E + T | T is left recursive on E. Applying the rule (A = E, alpha = + T, beta = T):
If left recursion is indirect (spread across several nonterminals, e.g. A calls B which calls A), the nonterminals are first ordered and substitution is used to convert it into immediate left recursion, which is then removed the same way.
Left factoring is a grammar transformation used when two or more productions of a nonterminal share a common prefix, which makes it impossible for a predictive (top-down) parser to decide which alternative to expand just by looking at the next input symbol. Left factoring rewrites the grammar to defer the decision until enough input has been seen.
For a production of the general form:
where alpha is the common prefix, it is rewritten as:
so the parser first matches the shared prefix alpha and only then, on seeing the next token, chooses between beta1 and beta2.
Worked example: the dangling-else style grammar
has the common prefix if expr then stmt. Left factoring gives:
Now a predictive parser can shift if expr then stmt unconditionally and decide, only on seeing the next token (else or not), which alternative of stmt' to take.
A context-free grammar is said to be LL(1) if a predictive top-down parser can always decide, by looking at only the current input symbol (1 token of lookahead), which production of the current nonterminal to use, with no backtracking. Formally, a grammar G is LL(1) if and only if, for every nonterminal A having two or more alternative productions A -> alpha1 | alpha2 | ... | alphan, the following conditions hold for every distinct pair of alternatives alphai and alphaj (i not equal to j):
- FIRST/FIRST disjointness: FIRST(alphai) and FIRST(alphaj) must be disjoint, i.e. FIRST(alphai) ∩ FIRST(alphaj) = empty set. No terminal can begin strings derivable from more than one alternative.
- FIRST/FOLLOW disjointness on epsilon: if some alphai is nullable (can derive epsilon), then FIRST(alphaj) for every other alternative must not intersect FOLLOW(A); more precisely FIRST(alphai) and FOLLOW(A) must be disjoint. This ensures the parser can correctly decide to use the epsilon-producing alternative only when the lookahead symbol is in FOLLOW(A).
- At most one alternative of A can derive epsilon.
If any nonterminal violates these conditions the LL(1) parsing table will have a multiply-defined (conflict) entry, and the grammar is not LL(1); it must then be left-factored and/or freed of left recursion, or an ambiguity removed, before predictive parsing can be applied.
The grammar E → E+T | T, T → T*F | F is left-recursive; assuming the usual F → (E) | id, eliminating immediate left recursion (A→Aα|β becomes A→βA', A'→αA'|ε) gives:
FIRST sets: FIRST(F)={(, id}, FIRST(T)={(, id}, FIRST(T')={*, ε}, FIRST(E)={(, id}, FIRST(E')={+, ε}.
FOLLOW sets: FOLLOW(E)={), $}, FOLLOW(E')={), $}, FOLLOW(T)={+, ), $}, FOLLOW(T')={+, ), $}, FOLLOW(F)={*, +, ), $}.
| Non-Terminal | id | + | * | ( | ) | $ |
|---|---|---|---|---|---|---|
| E | E→TE' | E→TE' | ||||
| E' | E'→+TE' | E'→ε | E'→ε | |||
| T | T→FT' | T→FT' | ||||
| T' | T'→ε | T'→*FT' | T'→ε | T'→ε | ||
| F | F→id | F→(E) |
No cell has more than one entry, so this grammar is LL(1).
Parsers construct a parse tree either starting from the root (start symbol) or from the leaves (input tokens), giving two broad parsing strategies.
| Aspect | Top-Down Parsing | Bottom-Up Parsing |
|---|---|---|
| Tree construction | Starts from the start symbol (root) and expands towards the leaves (input). | Starts from the input symbols (leaves) and reduces towards the start symbol (root). |
| Derivation produced | Leftmost derivation. | Reverse of a rightmost derivation. |
| Core operation | Prediction/expansion of a nonterminal using a production. | Shift input symbols, then reduce a handle (substring) to a nonterminal. |
| Grammar restriction | Cannot handle left recursion; needs left factoring; grammar must typically be LL(1). | Can handle left-recursive grammars; broader class (LR) accepted. |
| Examples | Recursive descent parser, LL(1) predictive parser. | Shift-reduce parser, LR(0), SLR(1), LALR(1), CLR(1) parsers. |
| Power/class | Weaker; LL(1) is a subset of LR(1) grammars. | Stronger; LR family accepts a larger class of unambiguous grammars. |
| Backtracking | May need backtracking (unless restricted, e.g. predictive parsing avoids it). | Generally no backtracking with a suitable parsing table. |
Both approaches ultimately verify that the input string belongs to the language defined by the grammar, but differ in the order in which the parse tree nodes are constructed.
Shift-reduce parsing is a bottom-up parsing technique that builds the parse tree from the leaves upward by repeatedly shifting input symbols onto a stack and, when the top of the stack matches the right-hand side of a production (a handle), reducing it to the corresponding left-hand nonterminal. Parsing succeeds if the stack is reduced to just the start symbol while all input is consumed.
It uses a stack (initially holding the end marker $) and a buffer of remaining input (terminated by $), and performs four possible actions at each step:
- Shift: push the next input symbol onto the stack.
- Reduce: replace a handle at the top of the stack with the nonterminal on the left side of the matching production.
- Accept: announce successful completion when the stack contains only the start symbol and input is empty.
- Error: report a syntax error when neither shift nor reduce can be applied.
Example: for grammar E -> E + E | E * E | id, parsing id + id * id proceeds as: shift id, reduce to E, shift +, shift id, reduce to E, stack is E + E, shift *, shift id, reduce to E, stack E + E * E, reduce E*E to E, stack E + E, finally reduce to E, then accept. The stack thus always holds a prefix of the right-sentential form (viable prefix) seen so far.
SLR(1), LALR(1) and Canonical LR(1) (CLR(1)) are three bottom-up table-driven parsers built by constructing sets of LR items, differing in how items are formed and how lookahead information is used, which gives them increasing parsing power at increasing table-construction/memory cost: SLR(1) < LALR(1) < CLR(1).
| Aspect | SLR(1) | LALR(1) | CLR(1) |
|---|---|---|---|
| Items used | LR(0) items (no lookahead in item itself). | LR(1) items with same core merged. | Full LR(1) items (production + lookahead). |
| Reduce decided using | FOLLOW(A) of the nonterminal being reduced. | Merged, more precise lookahead sets per state. | Item-specific lookahead symbol. |
| Number of states | Fewest (same as LR(0) automaton). | Same number of states as SLR (merges LR(1) states with identical cores). | Largest number of states. |
| Parsing power | Weakest; most reduce-reduce/shift-reduce conflicts. | Stronger than SLR; used by most practical parser generators (e.g. Yacc). | Strongest; accepts the largest class of unambiguous grammars. |
| Memory/table size | Smallest. | Same size as SLR, much smaller than CLR. | Largest, often impractically big for real languages. |
| Possible new conflicts | May introduce reduce-reduce conflicts CLR would not have. | Can occasionally introduce a reduce-reduce conflict not present in CLR due to merging. | None beyond genuine grammar ambiguity. |
An LR(0) item is a production with a dot marking how much of the RHS has been recognized so far, e.g. A → α . β. Two functions build the canonical collection of item sets (states of the LR(0) automaton):
- closure(I): repeatedly, for every item A → α . Bβ in I and every production B → γ, add item B → . γ to I (if not already present) until no more items can be added.
- goto(I, X): = closure of the set of items A → αX . β for every item A → α . Xβ in I. Applying goto for every grammar symbol X to every state, starting from I0 = closure({S'→.S}), generates the whole canonical collection (the LR(0) automaton).
Worked example — augmented grammar S' → S, S → (S) | a:
| State | Items |
|---|---|
| I0 | S'→.S, S→.(S), S→.a |
| I1 = goto(I0,S) | S'→S. (accept) |
| I2 = goto(I0,() | S→(.S), S→.(S), S→.a |
| I3 = goto(I0,a) | S→a. (reduce) |
| I4 = goto(I2,S) | S→(S.) |
| I5 = goto(I2,() | = I2 (self loop) |
| I6 = goto(I2,a) | = I3 (reused) |
| I7 = goto(I4,)) | S→(S). (reduce) |
Yacc (Yet Another Compiler-Compiler) takes a grammar specification and generates a bottom-up (LALR(1)) parser in C. A Yacc source file (.y) is divided into three sections separated by %%, mirroring the structure of a Lex file:
- Declarations: C code copied verbatim (between %{ %}) plus Yacc directives such as %token (terminal symbols), %left/%right/%nonassoc (precedence/associativity) and %start (start symbol).
- Rules: grammar productions in BNF-like form; each alternative can have an embedded C action, executed on reduction, using $$ for the result and $1, $2, ... for the values of right-hand-side symbols.
- User C Code: supporting routines, typically main(), yyerror() and, if not supplied by Lex, yylex().
Lex is a lexical analyzer generator that converts a specification of token patterns (regular expressions) into a C program (lex.yy.c) implementing a scanner. A Lex source file (.l) has three sections separated by %%:
- Definitions: C declarations/includes inside %{ %} (copied verbatim to the output), plus optional named regular-expression shorthands (e.g. DIGIT, LETTER) used later in the rules section.
- Rules: a list of pattern-action pairs; whenever the scanner matches a pattern against input, it executes the associated C action, typically returning a token code (often to a Yacc parser) with yylval/yytext carrying the matched lexeme.
- User Functions: auxiliary C functions such as yywrap() and, when Lex is used standalone, main().
Both Syntax Directed Definitions and Syntax Directed Translations attach semantics to a context-free grammar by associating attributes and rules with grammar symbols and productions, but they differ in their level of abstraction and how strictly they specify evaluation order.
| Aspect | Syntax Directed Definition (SDD) | Syntax Directed Translation (SDT) |
|---|---|---|
| Nature | High-level, declarative specification: a CFG with a set of semantic rules attached to each production. | Grammar with semantic actions ({...}) embedded at specific positions within the production body. |
| Evaluation order | Not specified; rules just define attribute values, order is left to the implementation (any order consistent with dependencies). | Implied by the position of the action in the right-hand side; actions execute exactly when parsing reaches that point. |
| Abstraction level | Conceptual/specification tool, closer to a formal definition of translation. | Implementation-oriented, closer to actual parser code. |
| Example | Production E -> E1 + T with rule E.val = E1.val + T.val (no ordering implied). | Production E -> E1 + T {print('+')} T with the action fixed between E1+T and T's parsing. |
| Typical use | Describing translation abstractly, reasoning about correctness, attribute grammars. | Directly guiding code generation in a single-pass parser (e.g. embedding in Yacc actions). |
In practice an SDT is obtained by deciding a concrete evaluation order for an SDD and inserting the corresponding actions at appropriate points in the productions.
Attributes carried by grammar symbols in a syntax directed definition are classified by the direction in which their values flow through the parse tree.
| Aspect | Synthesized Attribute | Inherited Attribute |
|---|---|---|
| Value computed from | Attribute values of its own children (and possibly itself) in the parse tree. | Attribute values of its parent and/or left siblings in the parse tree. |
| Flow direction | Bottom-up (leaves towards root). | Top-down / left-to-right (root and left siblings towards a node). |
| Defined at | The production where the nonterminal is on the left-hand side (head). | The production where the nonterminal appears on the right-hand side (body). |
| Evaluation order needed | Single bottom-up pass suffices; matches LR/bottom-up parsing naturally. | Needs a specific top-down or left-to-right order; matches LL/recursive-descent parsing. |
| Example | Production E -> E1 + T with E.val = E1.val + T.val (E.val synthesized from children E1, T). | Production D -> T L with L.inh = T.type (type declared in T is passed down to list L). |
A parse tree annotated only with synthesized attributes can be evaluated bottom-up in one post-order traversal, while inherited attributes require care to avoid circular dependencies and are typically evaluated left-to-right, depth-first.
SDDs are further classified by which evaluation orders they support, based on the mix of synthesized and inherited attributes they use.
- S-attributed SDD: uses only synthesized attributes. Every attribute is computed from the attributes of the children, so the whole tree can be evaluated bottom-up in a single post-order traversal. S-attributed definitions fit naturally with bottom-up (LR) parsers, which can evaluate attributes on each reduction using a parallel value stack.
- L-attributed SDD: allows both synthesized attributes and inherited attributes, with one restriction: each inherited attribute of a symbol on the right-hand side of a production may depend only on attributes of the production's head (parent) and on attributes of symbols to its left in the same production (never on symbols to its right). This restriction permits evaluation in a single left-to-right, depth-first traversal, so L-attributed definitions can be evaluated during both top-down (LL) parsing and, using a value stack, during bottom-up parsing as well.
Intermediate code represents a program in a form that is independent of both the source language and the target machine, easing optimization and retargeting. Two common tree-oriented/linear representations are syntax trees and postfix notation.
| Aspect | Syntax Tree (DAG-like) | Postfix Notation |
|---|---|---|
| Structure | Hierarchical tree; each interior node represents an operator, its children the operands. | Linear sequence of operators and operands, no parentheses needed. |
| Operator position | Operator at the (interior) node, operands as its children. | Operator written immediately after its operands (reverse Polish). |
| Evaluation | Requires tree traversal (e.g. post-order) to evaluate/generate code. | Directly evaluable left-to-right using a simple stack machine. |
| Sharing common subexpressions | A DAG variant can share identical subtrees, saving computation. | No structural sharing; each occurrence is written out. |
| Example for a + a * (b - c) | Tree with root +, left child leaf a, right child * with children a and (- with children b, c). | a b c - * a + |
A syntax tree makes the grammatical/hierarchical structure explicit and is convenient for semantic analysis and optimization (especially as a DAG, which exposes common subexpressions), while postfix notation is compact, parenthesis-free and directly executable by a simple stack-based evaluator, making it convenient for straightforward code generation.
A three-address code (TAC) statement has the general form x = y op z, using at most one operator and one assignment. It can be implemented in three ways that trade off memory versus ease of rearrangement during optimization.
| Representation | Fields stored | How the result is referenced | Effect of reordering statements |
|---|---|---|---|
| Quadruple | (op, arg1, arg2, result) | By an explicit temporary name in the result field | Easy - statements can be freely moved since results are named |
| Triple | (op, arg1, arg2) | By the position/index number of the triple itself | Hard - moving a triple changes its index, so every reference to it elsewhere must be updated |
| Indirect Triple | An ordered list of pointers into a triple array | By the pointer-list position, while the triple array stays fixed | Easy - only the pointer list is reordered; the triple structures themselves never move |
Example: for a = b * -c + b * -c, the TAC and its quadruple/triple forms are:
Quadruples are simplest for code generation and optimization since operands and results are named explicitly, at the cost of extra temporary-name storage. Triples save space but complicate optimizations that reorder code. Indirect triples recover this flexibility by adding one level of indirection, combining the compactness of triples with the reorderability of quadruples.
Boolean expressions used in flow-of-control statements (if, while) are usually translated using jumping code instead of computing a 0/1 value: control simply jumps to one of two program locations depending on whether the expression is true or false, giving short-circuit evaluation. Each non-terminal E carries two backpatch lists, E.truelist and E.falselist, holding jump instructions whose target label is not yet known.
The marker non-terminal M records the instruction number right after E1 so that or/and can backpatch E1's list to jump into E2's code, implementing short-circuiting without a separate fix-up pass over the whole expression.
Backpatching is a one-pass code generation technique for boolean and flow-of-control constructs: jump instructions are emitted with their target left blank, the instruction number is remembered in a list, and once the real target label becomes known the blank is filled in ("patched"). This avoids a second pass to resolve forward jumps.
Three helper functions manage the lists of incomplete jumps:
- makelist(i) - creates a new list containing only instruction number i (a single jump whose target is unknown).
- merge(p1, p2) - concatenates the lists pointed to by p1 and p2, returning a pointer to the merged list; used to combine truelists/falselists from sub-expressions.
- backpatch(list, target) - inserts the instruction number target into the target field of every jump instruction listed in list.
Because the actual target is patched only once the target statement's first instruction number is known, arbitrarily nested if/while/for statements with boolean conditions can be translated in a single left-to-right pass without backtracking.
Each call to a procedure creates an activation record (stack frame) holding all information needed for that single execution instance. Activation records are pushed onto a run-time stack on call and popped on return, which naturally supports recursion since each active call gets its own record.
The control link points to the caller's activation record (used to restore the caller's frame on return); the access link points to the activation record of the lexically enclosing procedure, used to resolve non-local variable references in languages with nested procedures. A stack pointer and frame pointer register track the top and base of the current record so offsets into locals/temporaries can be computed at compile time.
Data whose size or lifetime cannot be determined at compile time (e.g. objects created with new/malloc) is placed on the heap, a region of memory managed explicitly by allocation/deallocation routines rather than by the automatic call/return discipline of the stack.
The heap manager typically keeps a free list of unused blocks and services requests using one of these placement strategies:
- First-fit - scan the free list and allocate from the first block large enough; fast but can leave many small unusable fragments near the start of the list.
- Best-fit - scan the whole list and pick the smallest block that satisfies the request, minimizing leftover space but incurring the cost of a full scan and creating many tiny leftover slivers.
- Worst-fit - allocate from the largest available block, leaving a large usable remainder rather than a sliver.
- Buddy system - maintains free lists of blocks whose sizes are powers of two; a block is split into two equal "buddies" as needed and, on free, is merged back with its buddy if that buddy is also free, giving fast allocation/coalescing at the cost of internal fragmentation.
Two forms of fragmentation arise: internal fragmentation (allocated block larger than requested, wasting space inside it) and external fragmentation (enough total free memory exists but scattered in blocks too small individually to satisfy a request) - the latter is countered by coalescing adjacent free blocks and, where supported, by compaction that relocates live blocks together.
When a program uses heap allocation without explicit deallocation, the run-time system reclaims memory occupied by objects that are no longer reachable from the program's roots (globals, stack, registers) - this is garbage collection. Two classical algorithms are Mark-and-Sweep and Copying collection.
| Aspect | Mark-and-Sweep | Copying Collector |
|---|---|---|
| Working idea | Mark phase traverses from roots marking every reachable object; sweep phase scans all of heap memory reclaiming unmarked blocks | Heap is split into two semispaces (from-space, to-space); reachable objects are copied from from-space into to-space, then the roles of the spaces swap |
| Memory overhead | Uses the full heap for live data | Only half the heap is usable at any time |
| Fragmentation | Leaves free blocks scattered where dead objects were - can fragment memory | Compacts automatically - all live objects end up contiguous in to-space, so allocation afterward is a simple pointer bump |
| Cost driver | Sweep phase cost is proportional to total heap size (touches dead objects too) | Cost is proportional only to the volume of live data actually copied |
| Pointer updates | Not required - objects never move | All pointers to a moved object must be updated (forwarding pointers) |
Mark-and-sweep is attractive when most objects survive (little copying benefit), while copying collectors excel when most objects die young, as is common with short-lived temporaries - which is why many modern collectors use generational schemes combining both ideas.
Before local optimization, a program's three-address code must be partitioned into basic blocks: maximal sequences of consecutive instructions with a single entry point and single exit point, executed straight through with no jumps in or out except at the very start/end. This is done by first identifying leader statements.
Once basic blocks are identified, a flow graph is built by adding an edge from block B1 to B2 whenever B2's leader can be reached immediately after executing the last statement of B1 - either by falling through (B1's last statement is not an unconditional jump) or by an explicit jump/branch target. This flow graph is the structure over which local optimizations (within a block) and global/loop optimizations (across blocks) are performed.
A Directed Acyclic Graph (DAG) for a basic block represents the block's computation while automatically detecting common sub-expressions: leaves are labeled with identifiers/constants, interior nodes are labeled with operators, and a node is created only once per distinct computation - if the same expression is generated again, the existing node is reused rather than duplicated.
Construction algorithm: for each three-address statement x = y op z, look up nodes for y and z (creating leaf nodes if new); search for an existing interior node labeled op with those exact children - if found, reuse it, else create a new node; attach identifier x to that node's label list (removing x from any node it previously labeled). Applications of the DAG include eliminating common sub-expressions, detecting dead code (nodes with no live labels attached), and reordering statements that compute independent nodes for better register usage.
Local optimizations improve code within a single basic block without analyzing control flow across blocks. Three common techniques:
Constant folding - expressions with compile-time-known constant operands are evaluated at compile time and replaced by the result.
Dead code elimination - statements that compute a value never subsequently used (no live variable depends on it) are removed entirely.
Copy propagation - after a copy statement x = y, later uses of x (before x or y is redefined) are replaced directly by y, which often exposes further dead code once the copy itself becomes unused.
These passes are typically applied repeatedly (and combined with the DAG representation of the block) since each pass can expose new opportunities for the others - e.g. copy propagation feeding dead code elimination.
Since instructions inside loops execute many times, optimizing loop bodies gives the largest payoff per line of code improved. Three standard loop optimizations:
Code motion (loop-invariant code motion) - computations inside a loop whose operands do not change across iterations are hoisted out to a preheader block executed once before the loop, so they are computed a single time instead of every iteration.
Strength reduction - replaces an expensive operation inside the loop with a cheaper equivalent, typically converting a multiplication driven by an induction variable into repeated addition.
Loop unrolling - replicates the loop body multiple times per iteration test, reducing the relative overhead of the loop-control test/branch and enabling better instruction scheduling.
All three reduce dynamic instruction count in hot loops, which typically dominate a program's execution time.
Peephole optimization scans the generated (often target) code through a small sliding window ("peephole") of a few consecutive instructions and replaces recognized inefficient patterns with shorter or faster equivalents. It is applied repeatedly since one replacement can expose the next.
1. Redundant load/store elimination - a store immediately followed by a load of the same value from the same location is unnecessary.
2. Algebraic simplification - identity operations that don't change the value are removed.
3. Strength reduction - cheap machine instructions replace expensive ones for the same effect.
4. Unreachable code / jump-to-jump elimination - code after an unconditional jump/return (never reached) is deleted, and a jump to another jump is collapsed to jump straight to the final target.
Because it works on a small local window, peephole optimization is cheap to run yet consistently removes redundancy left behind by naive syntax-directed code generation.
The code generator turns intermediate representation into target machine code while trying to produce fast, compact code; several interacting issues make this a genuinely hard combinatorial problem:
- Instruction selection - choosing which machine instruction(s) implement a given IR operation; the same computation can often be done several ways (e.g. multiply-by-power-of-2 as a shift), and CISC-style machines add complexity through instructions that combine several IR operations (e.g. an addressing mode that does load+add).
- Register allocation and assignment - deciding which values are kept in the limited set of physical registers (allocation) and which specific register each gets (assignment); poor decisions force values to be spilled to memory, adding load/store overhead.
- Evaluation order - the order in which independent computations are scheduled affects the number of registers needed simultaneously and can affect pipeline/instruction-level parallelism on the target.
- Choice of addressing modes and memory management - exploiting indexed, indirect, or auto-increment addressing modes can shorten code, but requires matching IR access patterns to what the target supports.
- Target machine characteristics - instruction set, pipeline behavior, and cost/timing of each instruction all constrain what a "good" translation looks like, so the generator must be tailored to (or parameterized by) the target architecture.
These issues are interdependent - e.g. instruction selection affects how many registers are needed - which is why practical code generators (or separate optimization passes) address them together rather than in complete isolation.
Chaitin's algorithm allocates a fixed number k of physical registers to program values via graph coloring: build an interference graph whose nodes are live ranges (variables/temporaries) and whose edges connect any two live ranges simultaneously live at some point (so they cannot share a register); then find a k-coloring of this graph, where a color represents a register.
Algorithm outline:
- Build the interference graph from live-range analysis.
- Simplify - repeatedly remove any node with degree less than k (it is trivially colorable later) and push it onto a stack.
- Spill decision - if every remaining node has degree >= k, optimistically pick a node as a potential spill candidate (using a cost heuristic - low use/def frequency, high degree), remove it, and continue simplifying.
- Select - pop nodes off the stack one by one, assigning each a register color different from its already-colored neighbors; a node marked as a potential spill may still get a color if one happens to be free.
- Actual spill code - only if select genuinely fails to find a free color is the value spilled to memory, and the process (build/simplify/select) is repeated on the rewritten program.
When the parser encounters a token that cannot be legally derived at that point, an error-recovery strategy lets compilation continue (to catch further errors in one pass) instead of stopping at the first mistake. Two common strategies:
| Aspect | Panic Mode Recovery | Phrase-Level Recovery |
|---|---|---|
| Basic idea | Discard input tokens one at a time until a designated synchronizing token (e.g. ; or end) is found, then resume parsing | Perform a local, context-specific fix at the exact point of error - insert, delete, or replace a token |
| Implementation | Simple - just needs a set of synchronizing tokens per non-terminal | Requires filling every error entry in the parsing table with a specific corrective routine |
| Precision | Coarse - may skip over other real tokens/errors, sometimes causing cascading errors | Fine-grained - tailored fix for common mistakes (e.g. missing semicolon, unbalanced parenthesis) |
| Risk | Low risk of infinite loop but can lose significant context | Must be designed carefully to avoid infinite loops of insert/delete |
| Guarantee | Never causes an infinite loop; always terminates since tokens are only ever discarded | Cannot introduce infinite loops if corrective actions are chosen properly |
Compilers frequently combine both: phrase-level fixes for well-known common mistakes, falling back to panic mode synchronization for anything unrecognized, so error messages stay precise while parsing always terminates.
Scope rules determine which declaration a variable reference is bound to. The two disciplines differ in when that binding is decided.
| Aspect | Static (Lexical) Scope | Dynamic Scope |
|---|---|---|
| Binding time | Determined at compile time, from the program's textual nesting structure | Determined at run time, from the actual sequence of calls in execution |
| Resolution rule | A reference binds to the declaration in the nearest lexically enclosing block/procedure | A reference binds to the most recent still-active binding on the call stack, regardless of textual location |
| Implementation aid | Access links / a static scope chain built from the program's nesting | The dynamic call chain itself (control links), or a runtime association list |
| Predictability | Same variable reference always resolves the same way; easy to reason about and optimize at compile time | Meaning of a reference can change depending on the caller, harder to reason about statically |
| Used by | Most modern languages - C, Java, Pascal | Early Lisp, APL, and shell/macro-like environments |
A compiler's symbol table stores each identifier's attributes (type, scope, offset, etc.) and must support fast insert and lookup as well as correct scoping. A common efficient organization is a hash table with scope chaining: identifier names are hashed into buckets, and entries for identifiers declared in nested scopes are chained so that inner-scope declarations shadow outer ones.
On entering a new scope, a fresh scope marker/level is pushed; declarations insert new entries at the head of their name's chain, tagged with the current scope level. A lookup follows the hash bucket's chain and returns the first (innermost, most recently declared) matching entry, correctly implementing shadowing. On leaving a scope, all entries tagged with that scope level are removed (or a scope-boundary marker is popped), restoring visibility of the outer declarations - giving average O(1) insert/lookup with correct nested-scope resolution.
Type checking verifies that operations are applied to operands of compatible types, using the static or inferred types recorded in each expression's syntax tree/symbol table entries. When operand types don't match an operator's exact required types but are still compatible, a type conversion (coercion) is inserted.
| Aspect | Implicit Conversion | Explicit Conversion |
|---|---|---|
| Who triggers it | Inserted automatically by the compiler during type checking | Written directly by the programmer using a cast operator |
| Example | int i; float f = i; - i is widened to float automatically | float f; int i = (int) f; - explicit cast requested |
| Safety | Compiler only inserts safe, information-preserving widening conversions (e.g. int to float) | Can be narrowing/lossy (e.g. float to int truncates); programmer takes responsibility |
| Visibility | Invisible in source code, visible only in the annotated syntax tree/generated code | Visible in source code as an explicit cast expression |
During semantic analysis the compiler applies coercion rules bottom-up on the expression tree: if an operator expects type T and the actual operand has a type coercible to T, a conversion node is inserted around that operand before code generation, so the emitted code always operates on matching types.
Static Single Assignment (SSA) is an intermediate representation property in which every variable in the program is assigned a value exactly once (textually) - each new assignment to a source variable creates a fresh SSA name (e.g. x becomes x1, x2, x3...). Where control-flow paths carrying different definitions of the same original variable merge, a special phi (Φ) function is inserted at the join point to select the correct incoming value based on which predecessor block control arrived from.
Because every use in SSA has exactly one reaching definition, def-use chains become explicit and trivial to compute, which greatly simplifies several optimizations:
- Constant propagation - a use trivially inherits the single definition's value if it is constant.
- Dead code elimination - a definition with no uses can be deleted immediately, without a separate liveness pass.
- Value numbering / common sub-expression elimination - identical SSA expressions are guaranteed to compute identical values, so redundancy detection is direct.
This is why most modern optimizing compilers (LLVM, GCC's GIMPLE-SSA) build SSA as their central optimization IR before eventually converting back out of SSA for code generation.
Data flow analysis computes, for each program point, facts that hold on every possible execution path reaching (or leaving) that point, by solving equations over the control-flow graph. Two classic instances:
Reaching Definitions (a forward problem) - a definition d of variable v "reaches" a point p if there is some path from d to p along which v is not redefined. Used for constant propagation and to build def-use chains.
Live Variables Analysis (a backward problem) - a variable v is live at point p if there exists a path from p to some use of v along which v is not redefined first. Used for register allocation (a dead variable's register can be reused) and dead-code elimination.
Both are solved by iterating over the flow graph until the IN/OUT sets reach a fixed point - reaching definitions propagates information forward from entry, live variables propagates it backward from exit.
A retargetable compiler can generate code for a new target machine by replacing only its back end, without rewriting the front end or optimizer. This is achieved by funneling all source languages through a single, machine-independent intermediate representation (IR), keeping every machine-specific detail (instruction patterns, register classes, calling conventions) isolated behind a well-defined backend interface.
| Aspect | GCC | LLVM |
|---|---|---|
| Core IR | RTL (Register Transfer Language) - a low-level, machine-like IR close to actual instructions, plus the higher-level GIMPLE for optimization | LLVM IR - a single typed, SSA-based IR shared uniformly by every frontend and backend |
| Target description | Per-target machine-description (.md) files specifying instruction patterns, costs, and constraints, processed to build the backend | TableGen (.td) target-description files feeding a common backend framework (instruction selection DAG, register allocator, scheduler) |
| Pipeline shape | Frontend -> GIMPLE optimizations -> RTL -> target-specific RTL passes -> assembly | Frontend (e.g. Clang) -> LLVM IR -> shared "opt" optimization passes -> target-specific "llc" backend -> assembly |
| Adding a new target | Write a new machine-description file and target-specific RTL expanders | Write a new backend (TableGen descriptions + target-specific lowering) targeting the existing LLVM IR |
In both designs, retargetability comes from the same principle: a common, machine-independent representation and optimizer sit in the middle, and only a well-isolated backend module needs to change to support a new instruction set architecture.
Group C — Long / Numerical Questions (15 Marks Each)
Part (a): Canonical LR(1) construction. Augmented grammar and productions numbered:
LR(1) items carry a lookahead. Applying closure/goto (closure rule: for item [A→α.Bβ, a], add [B→.γ, b] for every b in FIRST(βa)) produces 10 canonical states:
| State | Items (production, lookahead) |
|---|---|
| I0 | [S'→.S,$] [S→.AA,$] [A→.aA,a/b] [A→.b,a/b] |
| I1=goto(I0,S) | [S'→S.,$] — accept |
| I2=goto(I0,A) | [S→A.A,$] [A→.aA,$] [A→.b,$] |
| I3=goto(I0,a) | [A→a.A,a/b] [A→.aA,a/b] [A→.b,a/b] |
| I4=goto(I0,b)=goto(I3,b) | [A→b.,a] [A→b.,b] |
| I5=goto(I2,A) | [S→AA.,$] |
| I6=goto(I2,a) | [A→a.A,$] [A→.aA,$] [A→.b,$] |
| I7=goto(I2,b)=goto(I6,b) | [A→b.,$] |
| I8=goto(I3,A) | [A→aA.,a] [A→aA.,b] |
| I9=goto(I6,A) | [A→aA.,$] |
goto(I3,a)=I3 and goto(I6,a)=I6 (self loops). The CLR(1) ACTION/GOTO table (r1: S→AA, r2: A→aA, r3: A→b):
| State | a | b | $ | S | A |
|---|---|---|---|---|---|
| 0 | s3 | s4 | 1 | 2 | |
| 1 | accept | ||||
| 2 | s6 | s7 | 5 | ||
| 3 | s3 | s4 | 8 | ||
| 4 | r3 | r3 | |||
| 5 | r1 | ||||
| 6 | s6 | s7 | 9 | ||
| 7 | r3 | ||||
| 8 | r2 | r2 | |||
| 9 | r2 |
No cell has more than one action — the grammar is CLR(1) with no shift-reduce or reduce-reduce conflicts.
Part (b): Trace of "aabb$".
| Step | Stack | Input | Action |
|---|---|---|---|
| 1 | 0 | aabb$ | shift, goto 3 |
| 2 | 0 a 3 | abb$ | shift, goto 3 |
| 3 | 0 a 3 a 3 | bb$ | shift, goto 4 |
| 4 | 0 a 3 a 3 b 4 | b$ | reduce A→b, goto(3,A)=8 |
| 5 | 0 a 3 a 3 A 8 | b$ | reduce A→aA, goto(3,A)=8 |
| 6 | 0 a 3 A 8 | b$ | reduce A→aA, goto(0,A)=2 |
| 7 | 0 A 2 | b$ | shift, goto 7 |
| 8 | 0 A 2 b 7 | $ | reduce A→b, goto(2,A)=5 |
| 9 | 0 A 2 A 5 | $ | reduce S→AA, goto(0,S)=1 |
| 10 | 0 S 1 | $ | accept |
The two reduced A's are "aab" and "b", concatenating to "aabb" = the input string, confirming the trace is correct.
Part (a): TAC and three-address representations. Three-address code for the statement (with statement numbers as labels):
| # | Quadruple (op,arg1,arg2,result) | Triple (op,arg1,arg2) |
|---|---|---|
| 0 | <, a, b, t1 | <, a, b |
| 1 | ifFalse, t1, _, 11 | ifFalse, (0), 11 |
| 2 | <, c, d, t2 | <, c, d |
| 3 | ifFalse, t2, _, 8 | ifFalse, (2), 8 |
| 4 | +, y, z, t3 | +, y, z |
| 5 | :=, t3, _, x | :=, x, (4) |
| 6 | goto, _, _, 0 | goto, (0), _ |
| 7 | -, y, z, t4 | -, y, z |
| 8 | :=, t4, _, x | :=, x, (7) |
| 9 | goto, _, _, 0 | goto, (0), _ |
Quadruples name a temporary explicitly in the result field, so operands can be reordered freely at optimization time. Triples instead refer to another triple's result by its position/index (i), so moving a triple invalidates every reference to it; Indirect Triples fix this by adding a separate list of pointers into the triple table representing execution order — code motion during optimization then only reorders the pointer list, leaving the triple table itself untouched.
Part (b): Control Flow Graph and Basic Blocks. Applying the leader rules (first statement; targets of any goto/conditional goto; statement immediately after a goto/conditional goto) to the 10 TAC statements gives leaders {1,3,5,8}, hence 4 basic blocks:
| Block | Statements | Content |
|---|---|---|
| B1 | 1-2 | t1=a<b; ifFalse t1 goto exit |
| B2 | 3-4 | t2=c<d; ifFalse t2 goto B4 |
| B3 | 5-7 | t3=y+z; x=t3; goto B1 |
| B4 | 8-10 | t4=y-z; x=t4; goto B1 |
Part (a): FIRST and FOLLOW sets.
| Symbol | FIRST | FOLLOW |
|---|---|---|
| E | { (, id } | { ), $ } |
| E' | { +, ε } | { ), $ } |
| T | { (, id } | { +, ), $ } |
| T' | { *, ε } | { +, ), $ } |
| F | { (, id } | { *, +, ), $ } |
Part (b): LL(1) table and trace of "id + id * id $".
| NT | id | + | * | ( | ) | $ |
|---|---|---|---|---|---|---|
| E | E→TE' | E→TE' | ||||
| E' | E'→+TE' | E'→ε | E'→ε | |||
| T | T→FT' | T→FT' | ||||
| T' | T'→ε | T'→*FT' | T'→ε | T'→ε | ||
| F | F→id | F→(E) |
| Step | Stack (bottom→top) | Remaining Input | Action |
|---|---|---|---|
| 1 | $E | id+id*id$ | E→TE' |
| 2 | $E'T | id+id*id$ | T→FT' |
| 3 | $E'T'F | id+id*id$ | F→id |
| 4 | $E'T'id | id+id*id$ | match id |
| 5 | $E'T' | +id*id$ | T'→ε |
| 6 | $E' | +id*id$ | E'→+TE' |
| 7 | $E'T+ | +id*id$ | match + |
| 8 | $E'T | id*id$ | T→FT' |
| 9 | $E'T'F | id*id$ | F→id |
| 10 | $E'T'id | id*id$ | match id |
| 11 | $E'T' | *id$ | T'→*FT' |
| 12 | $E'T'F* | *id$ | match * |
| 13 | $E'T'F | id$ | F→id |
| 14 | $E'T'id | id$ | match id |
| 15 | $E'T' | $ | T'→ε |
| 16 | $E' | $ | E'→ε |
| 17 | $ | $ | accept |
Part (a): DAG construction. Scanning the basic block, t1=4*i and t3=4*i compute the identical expression (same operator, same operand values, no intervening redefinition of i) so they collapse onto one shared DAG node with two labels:
Part (b): Optimized 3AC derived from the DAG. Reading the DAG in a valid topological (bottom-up) order and emitting one instruction per distinct node, using each node's surviving live label directly, eliminates the recomputation of t3 and the redundant copies via t6/t7:
This reduces the block from 9 three-address statements to 6, removing exactly the two dead temporaries (t3, and the extra copies via t6/t7) that the DAG's shared-node structure exposed.
Part (a): Chaitin's algorithm. (1) Build an interference graph: one node per live range/variable, an edge between two nodes if they are simultaneously live at some program point. (2) Simplify: repeatedly remove (push onto a stack) any node with degree < k (k = number of available registers) since it is trivially colorable once its neighbors are colored. (3) If stuck (all remaining nodes have degree ≥k), optimistically pick a spill candidate (lowest spill-cost/degree ratio), mark it and push it anyway. (4) Select: pop the stack, assigning each node a register/color different from its already-colored neighbors; if a marked spill candidate genuinely cannot get a color, generate spill code (store after definition, reload before each use) and rebuild the graph.
Part (b): Worked interference graph, k=3 registers. Consider: a=10; b=20; c=a+b; d=a-b; e=c+d; print(e). Live ranges: a:[I1-I4], b:[I2-I4], c:[I3-I5], d:[I4-I5], e:[I5-I6]. By the standard convention a variable does not interfere with the destination of the statement that consumes it as its very last use, giving interference edges (a,b), (a,c), (b,c), (c,d); d and e end up isolated from a/b and c/d respectively beyond the one edge shown.
Simplify order (remove lowest-degree first): e (deg 0), d (deg 1), a (deg 2), b (deg 1), c (deg 0) — pushed in that order onto the stack. Select (pop in reverse): c→R1 (no colored neighbor); b→R2 (avoids c=R1); a→R3 (avoids b=R2, c=R1); d→R2 (avoids c=R1 only); e→R1 (no neighbors).
| Variable | a | b | c | d | e |
|---|---|---|---|---|---|
| Register | R3 | R2 | R1 | R2 | R1 |
Since the graph's largest clique has size 3 = k, all 5 variables are colored with exactly 3 registers and no spill is required.
Part (c): Register spilling. If simplify gets stuck (every remaining node has degree ≥ k) a node is optimistically marked as a potential spill and pushed anyway; during select, if it truly cannot be given a free color, the variable is spilled: it is kept in memory, a store is emitted immediately after its definition and a fresh load is emitted immediately before each use, splitting its live range into many short ranges that are individually easy to color, at the cost of extra memory traffic.
Part (a): Syntax-Directed Translation (SDT) for type checking attaches semantic
type attributes to grammar symbols and computes them with rules tied to each production.
Every expression non-terminal carries a synthesized attribute type
which is computed bottom-up from the types of its children. A type checker uses two building
blocks: (1) a table of basic type compatibility (e.g. int op int -> int, real op real -> real)
and (2) a set of coercion (implicit conversion) rules that say which types may be
automatically widened to a common type when the operand types differ. In most languages, only
widening conversions (int -> real, char -> int) are performed implicitly; a
narrowing conversion (real -> int) requires an explicit cast because it can lose
information. When the type checker finds E1 op E2 with different types, instead of rejecting the
program it inserts a unary conversion operator such as inttoreal around
the narrower operand's AST node, producing a new synthesized type for the whole expression. This
is exactly how languages like C and Java silently promote int to double
in mixed arithmetic. Type checking and conversion insertion therefore happen together, in the
same pass, as the parse tree (or an equivalent syntax-directed evaluation during LR parsing) is
built or walked.
Part (b): The SDD below defines type and node (the AST pointer) synthesized attributes for arithmetic expressions, inserting explicit conversion nodes wherever the operand types differ.
Here coerce(Ei) denotes wrapping Ei.node in an inttoreal unary AST node only if Ei.type = int, otherwise returning Ei.node unchanged. Each conversion node is later translated by the code generator into an explicit CVTI2R (integer-to-real) machine instruction, so the widening that was implicit at the source level becomes explicit in the intermediate/target code.
Part (a): A Lex/Flex source file (conventionally .l) is divided into three
sections separated by the delimiter %%:
- Definitions section: everything before the first
%%. C declarations, global variables and header includes are written inside%{ ... %}markers and are copied verbatim into the generatedlex.yy.c. Below that, regular-expression name definitions (e.g.DIGIT [0-9]) may be given so later rules can reuse them as{DIGIT}. - Rules section: between the first and second
%%. Each line is a pairpattern actionwhere pattern is a regular expression and action is a fragment of C executed whenever the longest input text matching that pattern is found (maximal-munch / longest-match rule; ties broken by rule order). The matched text is available in the globalchar *yytextand its length inint yyleng. - User code section: everything after the second
%%, copied verbatim - normally containsmain()(which callsyylex()) and any helper functions such as a manualyywrap().
Flex compiles the rules into a single DFA-driven scanning loop; when run it repeatedly calls
the internal matcher, and for every token recognized it executes the associated action and then
resumes scanning, until end of input (yywrap() returning 1) is reached.
Part (b): A complete Lex program that scans C source and counts keywords, identifiers, integer constants, floating-point constants, and source lines:
Compiled with flex scanner.l && gcc lex.yy.c -lfl -o scanner. The order of
rules matters: {FLOAT} is placed before {INT} so a token like
3.14 is not first partially matched as the integer 3.
Part (a): A Yacc/Bison specification (.y) has the same three-section
layout as Lex: a declarations section (before the first %%) declares
terminal tokens with %token, non-terminal result types with %type, and,
crucially, precedence and associativity with lines such as %left '+' '-'
followed by %left '*' '/' - operators declared on a later line bind tighter, and
the keyword (%left, %right, %nonassoc) fixes how a chain
of same-precedence operators associates. This lets an otherwise ambiguous grammar such as
E : E '+' E | E '*' E be resolved automatically by the LALR table generator without
needing to rewrite the grammar into unambiguous non-terminals for every precedence level. The
grammar rules section (between the two %%) lists each production together
with a semantic action in braces, where $$ denotes the value (attribute) of the
left-hand non-terminal and $1, $2, ... denote the values of the right-hand symbols
in order; Yacc builds a push-down automaton with a state/action table (shift, reduce, accept,
error) from these rules, resolving shift-reduce conflicts using the declared precedence of the
token about to be shifted versus the precedence of the rule about to be reduced. The final
C code section supplies yylex() (usually generated by a companion Lex file),
main(), and yyerror().
Part (b): A complete Yacc specification for an infix arithmetic calculator:
Companion lexer (calc.l) that supplies NUMBER tokens:
Built with yacc -d calc.y && lex calc.l && gcc y.tab.c lex.yy.c -o calc.
The %left/%right declarations make *,/ bind
tighter than +,-, and %prec UMINUS gives unary minus the
highest precedence, correctly parsing expressions like 2+3*4 and -2*(3+4).
Part (a): Address calculation for A[i][j]. For an array with row bounds [low1..high1], column bounds [low2..high2], n2 = number of columns = high2-low2+1, and element width w:
- Row-major order (rows stored contiguously, used by C/C++/Java): addr(A[i][j]) = base + ((i-low1)*n2 + (j-low2)) * w. Row-major visits an entire row before moving to the next, so the rightmost index j varies fastest.
- Column-major order (used by FORTRAN): with n1 = number of rows, addr(A[i][j]) = base + ((j-low2)*n1 + (i-low1)) * w. Here the leftmost index i varies fastest.
Part (b): 3AC for x = A[i][j] (row-major, 0-based indices, so low1=low2=0). A compile-time constant c = base - (low1*n2+low2)*w is precomputed so no subtraction is needed at run time:
This is exactly the standard "address descriptor" pattern used by real compilers: the multiply by n2 and the final scale by w are the only run-time arithmetic operations, with the bounds-dependent constant folded away at compile time.
Part (a): Next-use / register descriptor algorithm. Scanning a basic block backward, for statement i: x = y op z: (1) attach to statement i the current liveness/next-use info of x, y, z (as known from statements below i); (2) in the symbol table, set x to "not live, no next-use" (it is about to be (re)defined going forward); (3) set y and z to "live, next-use = i". Forward code generation then uses getReg(): prefer an already-empty register; else reuse a register whose current value is dead (no next use and not live-out); else spill the value with the least-urgent next use to memory.
Part (b): Trace for x = (a-b) + (a-c) + (a-b). TAC: t1=a-b; t2=a-c; t3=t1+t2; t4=a-b; x=t3+t4.
| Stmt | Next-use attached (before update) |
|---|---|
| 1: t1=a-b | t1:(dead,-) a:(live,2) b:(live,4) |
| 2: t2=a-c | t2:(dead,-) a:(live,4) c:(live,-) |
| 3: t3=t1+t2 | t3:(dead,-) t1:(live,3) t2:(live,3) |
| 4: t4=a-b | t4:(dead,-) a:(live,-) b:(live,-) |
| 5: x=t3+t4 | x:(live,-) t3:(live,5) t4:(live,5) |
Using 2 registers R0,R1 (a,b,c stay in memory, never needing a permanent register since operations can read a memory operand directly):
| Stmt | Emitted code | R0 | R1 |
|---|---|---|---|
| 1 | MOV R0,a | SUB R0,b | t1 | - |
| 2 | MOV R1,a | SUB R1,c | t1 | t2 |
| 3 | ADD R0,R1 (t1,t2 both dead after use → reuse R0) | t3 | free |
| 4 | MOV R1,a | SUB R1,b | t3 | t4 |
| 5 | ADD R0,R1 | MOV x,R0 | x | free |
Because next-use correctly showed t1 and t2 die at statement 3, R0 was safely reused for t3 instead of spilling — the whole expression is evaluated with only 2 registers and zero spills.
Part (a): Global data flow analysis computes, for every basic block in a program's control flow graph (CFG), facts that depend on paths through the whole procedure rather than on a single block in isolation (e.g. "which assignments to a variable might still be live/valid at this point"). Because a CFG can contain cycles (loops), these facts cannot be computed in one top-down or bottom-up sweep; instead they are expressed as data flow equations relating each block's input set to its predecessors'/successors' output sets, and solved iteratively: initialize all sets (commonly to the empty set), then repeatedly recompute every block's IN/OUT using the equations until no set changes between two consecutive passes - this is the fixed point, guaranteed to exist and to be reached in a bounded number of iterations because the sets only grow (for "may" analyses) or shrink (for "must" analyses) monotonically over a finite universe of facts (a lattice with finite height).
Part (b): For Reaching Definitions (a forward, "may"/union analysis) the equations are:
Illustrative CFG (4 blocks): B1 (entry): d1: a=1; then B2: d2: b=2; then B3: d3: a=3 which branches back to B2 (loop) or falls through to B4 (exit). Edges: B1->B2, B2->B3, B3->B2, B3->B4.
The result shows d1 (a=1) reaches B2 but is killed before reaching B4 (since d3 overwrites
a on every path into B4), while d2 (b=2) and d3 (a=3) both reach the loop exit B4 - exactly the
information a compiler needs to detect that the initial value of a is dead by the
time the loop exits, enabling dead-code/constant-propagation optimizations.
Part (a): Live Variable Analysis determines, at each program point, the set of
variables whose currently held value may still be used along some path before being
overwritten - a variable is "live" going into a block if some path from there reads it before
redefining it. This is a backward analysis (information flows from a block's successors
back into it) and a "may" analysis (a variable is live if it is live along any one
outgoing path, so information sets are unioned). Available Expressions Analysis
determines, at each program point, the set of expressions (e.g. a+b) that have
already been computed on every path reaching that point and whose operands have not been
reassigned since - it is a forward analysis and a "must" analysis (an expression is
available only if it is available along every incoming path, so sets are intersected).
Part (b): Data flow equations:
Applications: live variable sets drive register allocation - a variable's live range (the span between definition and last use) determines which temporaries can share a physical register via graph coloring, and a variable that is dead immediately after assignment identifies dead code that can be deleted. Available expressions drive global common subexpression elimination - if an expression is available at a point, a later re-evaluation of the exact same expression can be replaced by a reference to the previously computed temporary, and also support loop-invariant code motion, hoisting an available, loop-independent expression out of a loop into its preheader.
Part (a): In an object-oriented language, scopes nest not only through blocks but through the class hierarchy: the global/file scope contains class declarations, each class scope contains its own members (fields, methods, nested/inner classes) and also implicitly re-exposes the members inherited from its superclass, and each method scope nested inside a class contains parameters and local variables, and additionally can see the enclosing class's fields/methods without qualification. The symbol table is therefore organized as a tree of scopes (a "scope tree") mirroring this nesting: each scope has its own hash table of symbol->attribute-record entries, plus a pointer to its parent scope (lexically enclosing scope) and, for a class, an additional pointer to its superclass's scope (which is not lexically enclosing but must still be searched for inherited members).
Part (b):
Lookup of an identifier used inside Derived.g() proceeds: (1) search
g()'s own local scope (parameters/locals); (2) if not found, search the enclosing
class scope Derived (its own fields/methods); (3) if still not found, follow the
superclass link to Base's scope and search it (repeating up the inheritance chain
for multi-level inheritance); (4) finally search the enclosing lexical scope, Global.
The first matching entry found, in this order, is bound to the reference; if a name exists in
both a class and its superclass, the class's own entry shadows (hides) the inherited one, which
is how method overriding and field shadowing are resolved at compile time.
Part (a): Runtime storage for a procedural language is split into three regions: (1) a
static area for global variables and constants, whose addresses are fixed at compile
time; (2) a stack, which holds one activation record (also called a stack frame)
per currently active procedure call, pushed on call and popped on return - this works because
procedure activations are naturally last-in-first-out for languages without coroutines/closures
that outlive their creator; and (3) a heap, for dynamically allocated data whose lifetime
is not tied to any single procedure call (via malloc/new). Stack-based
allocation is what makes recursion possible: every recursive call gets its own fresh activation
record with its own locals, even though the code is the same.
Part (b):
On a call, the caller pushes actual parameter values, then a control link (pointer to the caller's activation record, used to restore the caller's frame on return) and, for languages with nested procedures, an access link (pointer to the activation record of the lexically enclosing procedure, used to resolve non-local variable references); the caller also saves its own machine status (return address, register contents) before transferring control. The callee then allocates space for its local variables and temporaries on top of this. Return value handling: the callee writes its result into the reserved return-value slot (or a dedicated register, per the calling convention) before executing the return sequence, which pops the callee's frame, restores the saved machine status and jumps to the return address recorded via the control link, leaving the stack pointer back where the caller left it. Parameter passing may be by value (copy actual into the parameter slot), by reference (copy the address instead), or by value-result, all of which only change what is written into the "Actual Parameters" area, not the overall frame structure.
Part (a): Mark-and-Sweep runs in two phases: starting from a set of roots (globals, stack references), it recursively marks every reachable heap object, then sweeps the whole heap linearly, reclaiming any unmarked (unreachable) object back to a free list. It never moves objects, so pointers stay valid, but it leaves the heap fragmented and its sweep cost is proportional to heap size regardless of how much garbage exists. A Copying Collector divides the heap into two equal semispaces; it allocates only in the active (from-space) semispace and, when that fills, traces all reachable objects from the roots (Cheney's breadth-first algorithm using a work queue in the new space itself) and copies them compactly into the other (to-space) semispace, updating every pointer to the new location; the old semispace is then discarded wholesale. This automatically compacts memory and makes allocation a trivial pointer bump, at the cost of needing double the address space and copying live (not dead) objects. Reference Counting stores, in every object, a count of how many pointers reference it; incrementing on each new reference and decrementing (freeing at zero) on each reference removal, reclamation is immediate and spread out rather than a stop-the-world pause, but it cannot reclaim cyclic garbage (two objects referencing each other with no external reference) and imposes overhead on every pointer assignment.
| Algorithm | Reclaims cycles? | Compacts heap? | Pause behavior | Overhead |
|---|---|---|---|---|
| Mark-and-Sweep | Yes | No (fragments) | Stop-the-world, O(heap) | Marking + full sweep |
| Copying Collector | Yes | Yes | Stop-the-world, O(live) | 2x memory, copy cost |
| Reference Counting | No | No | Incremental, no big pause | Per-assignment inc/dec |
Part (b): The generational hypothesis observes empirically that "most objects die young" - the large majority of allocated objects become garbage very shortly after creation (e.g. short-lived temporaries), while a small minority survive for a long time. A generational collector exploits this by splitting the heap into a small young/nursery generation and one or more older generations; new objects are always allocated in the nursery, which is collected frequently using a cheap copying collector (a "minor GC") because it is small and most of its contents are dead, so scanning it is fast; objects that survive several minor collections are promoted (tenured) into the old generation, which is collected only rarely with a more expensive full/major GC. This gives large performance gains because it concentrates collection effort where garbage actually accumulates and avoids repeatedly re-scanning long-lived, mostly stable old-generation data. The one correctness subtlety is inter-generational pointers (an old object referencing a young one); collectors track these with a write barrier that records such pointers in a remembered set, so a minor GC can treat the remembered set as additional roots without having to scan the entire old generation.
Part (a): Every compiler phase detects and reports its own class of errors. The lexical analyzer flags illegal characters or malformed tokens (e.g. an unterminated string literal) and typically recovers by deleting the bad character(s) and resuming scanning. The syntax analyzer (parser) flags token sequences that do not match any grammar production (e.g. a missing semicolon or unbalanced parenthesis); since it has the most structural context it does the bulk of recovery work (panic mode / phrase-level / error productions, discussed below). The semantic analyzer flags type mismatches, undeclared identifiers, and redeclaration errors, typically continuing analysis by assuming an error type or inserting the identifier into the symbol table so that a single mistake does not trigger a cascade of false-positive errors downstream. The intermediate-code/optimizer and code-generator phases rarely see genuine source errors (a well-formed AST that passed semantic checks should always be generatable) but still validate invariants (e.g. detecting overflow of fixed-size constant folds) and report internal-compiler-errors if an invariant is violated. In all phases, good error reporting gives the line/column of the offending token, a human-readable message, and - crucially - continues compilation afterward (error recovery) so the user sees many real errors from one compile run instead of just the first.
Part (b):
| Aspect | Panic Mode Recovery | Phrase-Level Recovery |
|---|---|---|
| Strategy | Discard input tokens one at a time until a token in a predefined "synchronizing set" (e.g. ;, }) is found, then resume parsing | Perform a local, context-specific correction at the point of error - insert/delete/replace a token so parsing can continue exactly there |
| Precision | Coarse - skips potentially large chunks of input; may miss real errors inside the skipped region | Fine-grained - fixes only the specific malformed construct |
| Implementation cost | Simple - just needs sync-token sets per non-terminal | Complex - needs custom error-handling code/table entries for every anticipated mistake |
| Risk | May under-report (swallows some real errors) | Risk of infinite loops or cascading false errors if the "correction" is wrong |
| In LL(1) parsers | On seeing a token not in a non-terminal's predict set, pop/skip until a token in that non-terminal's FOLLOW set (or a designated sync set) is seen | Table cells left blank by construction are filled with pointers to specific error-handling routines instead of being blank/error |
| In LR parsers | On reaching an error state, pop states off the stack until one with a valid shift on a designated synchronizing (nonterminal) token is exposed, then shift it | Augment the grammar with explicit error-productions (e.g. stmt : error ';') so the parser generator builds recovery actions directly into the LR table |
Part (a): Peephole optimization examines the generated target/assembly code through a
small, sliding "peephole" window (typically 2-4 consecutive instructions) and replaces the
pattern currently in the window with a shorter or faster equivalent whenever a recognized
pattern matches, repeating (sliding the window and re-matching) until no further pattern fires.
It is applied as a late, machine-dependent cleanup pass after code generation. Common pattern
families include: redundant load/store elimination (a value stored to memory and
immediately reloaded into the same register); redundant push/pop elimination (a value
pushed and immediately popped back into the same register with no intervening use); algebraic
simplification (e.g. ADD reg, 0 or MUL reg, 1 removed entirely);
strength reduction (replacing a multiply by a power of two with a shift); unreachable
code elimination (code after an unconditional jump/return, before the next label, is dead);
and jump-to-jump / branch chaining (a jump whose target is itself an unconditional jump is
rewritten to jump straight to the final target, collapsing the chain). Because each rewrite is
purely local, peephole optimization is cheap to run and is typically applied repeatedly (a fixed
point) since one rewrite can expose a new pattern for the next.
Part (b): Unoptimized 8086-style assembly containing a redundant PUSH/POP pair, a useless add, and a jump chain:
Three independent transformations fired here: the PUSH/POP pair around AX was eliminated
because AX's value was unchanged and never used from the stack; ADD CX,0 was
deleted as an algebraic identity; and the two-instruction jump chain JMP L1 -> JMP L2
was collapsed into a single direct JMP L2, also making the now-unreferenced label
L1 eligible for removal.
Part (a): Loop unrolling replicates the body of a loop several times inside a single iteration (reducing the trip count by the same factor), cutting the relative overhead of loop-control instructions (increment, compare, branch) per unit of real work and exposing more independent instructions that the processor or compiler scheduler can overlap/pipeline. Loop jamming (loop fusion) merges two or more adjacent loops that iterate over the same index range into a single loop body, amortizing one loop's control overhead across both computations and improving data locality when both loops touch the same array (fewer separate passes over memory, better cache reuse) - it is legal only when no data dependence crosses from the second original loop back into an earlier iteration of the first. Software pipelining restructures a loop so that instructions from several different logical iterations are issued in an overlapping fashion within one physical loop iteration (a steady-state "kernel" preceded by a short prologue and followed by an epilogue), keeping every functional unit of the pipelined processor busy every cycle instead of stalling on a chain of true dependencies within one iteration.
Part (b): Original inner loop of a matrix-multiply kernel (accumulating one output
element c[i][j]):
Unrolled by a factor of 4, exposing four independent multiply-add chains per iteration
(with a cleanup loop for any remainder when n is not a multiple of 4):
The loop-control overhead (increment and bounds check on k) is now paid once per
four multiply-accumulate operations instead of once per one, and the four
a[i][k+m]*b[k+m][j] products have no dependence on each other, so an out-of-order or
VLIW/pipelined processor (or a vectorizing compiler) can issue and execute them concurrently
instead of serializing on the single accumulator - the accumulation into sum can also
be split into partial sums to break even that final dependency chain, a common further refinement.
Part (a): LLVM structures a compiler into three independent, replaceable stages
connected by a common intermediate representation. The front end (e.g. Clang for
C/C++/Objective-C, or any other language's front end) parses source into an AST and lowers it
into LLVM IR - a strongly typed, target-independent, Static Single Assignment (SSA) form
three-address-code-like representation with an infinite set of virtual registers, available in an
in-memory form, a human-readable textual .ll form, and a compact binary bitcode
.bc form. The optimizer (driven by the opt tool, or invoked
automatically at a chosen -O level) runs a pipeline of analysis and transformation
passes purely on LLVM IR, so the same optimizations work for every source language and every
target. The back end (per-target, driven by llc or the integrated code
generator) lowers optimized IR through instruction selection, register allocation, and
instruction scheduling into target-specific assembly or object code. Because only the back end
is target-specific (its machine description is generated from declarative TableGen files),
retargeting LLVM to a new CPU only requires writing a new back end, while every front end and
every IR-level optimization is reused unchanged - this front end/middle end/back end separation
around a single IR is LLVM's central architectural idea.
Part (b): The Pass Manager schedules and runs an ordered pipeline of analysis
and transformation passes over the IR. Passes are scoped at different granularities -
function passes (operate on one function at a time, e.g. -mem2reg which
promotes stack-allocated variables into SSA registers), loop passes (operate on individual
natural loops, e.g. loop-invariant code motion), and module passes (operate across the
whole translation unit, e.g. -inline, which needs the whole call graph). Newer LLVM
versions replaced the legacy Pass Manager with the "new Pass Manager", which represents passes
and their required/preserved analyses more explicitly, enabling better caching of analysis
results across passes and finer control over pipeline ordering. Representative passes include
-mem2reg (SSA construction), -gvn (Global Value Numbering, redundant
computation elimination), -dce/-adce (dead code elimination),
-inline (inline expansion), and -instcombine (peephole-style IR
simplification); the optimization level flags (-O1, -O2, -O3)
simply select a curated, ordered sequence of such passes.
Trace of a = b + c * 60 (assume a, b, c are declared real; the literal 60 is an integer constant, forcing a type coercion), through all six phases:
| Phase | Output |
|---|---|
| 1. Lexical Analysis | <id,a> <=> <id,b> <+> <id,c> <*> <num,60> ; symbol-table entries created/looked up for a,b,c |
| 2. Syntax Analysis | Parse tree / AST: assign-stmt(id a, expr(expr(id b) + term(term(id c) * factor(60)))) |
| 3. Semantic Analysis | Type checking finds 60 is integer but c is real; inserts an inttoreal conversion node so c * 60 becomes type-consistent real multiplication |
| 4. Intermediate Code Gen. | t1=inttoreal(60); t2=c*t1; t3=b+t2; a=t3 |
| 5. Code Optimization | 60 is a compile-time constant, so inttoreal(60) is folded at compile time: t1=c*60.0; t2=b+t1; a=t2 (one fewer temporary/instruction) |
| 6. Target Code Generation | MOVF c,R2 | MULF #60.0,R2 | MOVF b,R1 | ADDF R2,R1 | MOVF R1,a |
Part (a): SLR(1) construction. Augmented grammar (0: S'→S, 1: S→L=R, 2: S→R, 3: L→*R, 4: L→id, 5: R→L). FOLLOW sets (mutually recursive via L↔R): FOLLOW(S)={$}, FOLLOW(L)={=,$}, FOLLOW(R)={=,$}.
| State | Items |
|---|---|
| I0 | S'→.S, S→.L=R, S→.R, L→.*R, L→.id, R→.L |
| I1=goto(I0,S) | S'→S. (accept) |
| I2=goto(I0,L) | S→L.=R, R→L. |
| I3=goto(I0,R) | S→R. (reduce, FOLLOW(S)={$}) |
| I4=goto(I0,*) | L→*.R, R→.L, L→.*R, L→.id |
| I5=goto(I0,id) | L→id. (reduce, FOLLOW(L)={=,$}) |
| I6=goto(I2,=) | S→L=.R, R→.L, L→.*R, L→.id |
| I7=goto(I4,R) | L→*R. (reduce, FOLLOW(L)={=,$}) |
| I8=goto(I4,L)=goto(I6,L) | R→L. (reduce, FOLLOW(R)={=,$}) |
| I9=goto(I6,R) | S→L=R. (reduce, FOLLOW(S)={$}) |
Part (b): Conflict check. State I2 = {S→L.=R, R→L.}. This state has a shift action on '=' (from S→L.=R) and, because FOLLOW(R) contains '=' (via R→L and L's own FOLLOW set), also a reduce R→L action on '=' — a genuine shift-reduce conflict at I2 on the symbol '='. Every other reduce state (I3, I5, I7, I8, I9) is reduce-only with no competing shift, so no other conflicts exist.
Conclusion: this grammar is not SLR(1). The conflict is an artifact of SLR's imprecision — it uses the single global FOLLOW(R) set, which is forced to include '=' because R can derive L and L precedes '=' in S→L=R, even though in the specific context "after an L reached directly under S", R→L should only reduce on lookahead $. A canonical LR(1) or LALR(1) parser carries the more precise per-item lookahead and parses this grammar without conflict.
Part (a): Item-set generation compared.
| Method | Item form | Reduce decided by | States (typical) | Power |
|---|---|---|---|---|
| SLR(1) | LR(0) item [A→α.β] | global FOLLOW(A) | fewest | weakest |
| LALR(1) | LR(1) item [A→α.β, a] with same-core states merged | merged, per-state lookahead set | same as SLR's LR(0) core count | middle (used by yacc/bison) |
| CLR(1) | LR(1) item, no merging | precise per-item lookahead | most (can blow up) | strongest |
Part (b): Safe merging, demonstrated on S→AA, A→aA|b. The CLR(1) construction for this grammar (see the canonical LR(1) automaton worked out for this same grammar elsewhere in this bank) produces states I4={[A→b.,a],[A→b.,b]} and I7={[A→b.,$]} that share the identical core {A→b.} and differ only in lookahead; likewise I8={[A→aA.,a],[A→aA.,b]} and I9={[A→aA.,$]} share core {A→aA.}. LALR(1) merges each same-core pair: I4∪I7 → one state {[A→b., a/b/$]}, and I8∪I9 → {[A→aA., a/b/$]}.
Since I4/I7 and I8/I9 each contain only reduce items (no shift or other action to clash with), the merged states remain pure "reduce on {a,b,$}" states — merging introduces no new conflict here, so this grammar is LALR(1) with only 8 states instead of CLR(1)'s 10, at identical parsing power for this input language.
General risk: merging is only guaranteed conflict-free with respect to shift actions (a state's shift/goto transitions depend only on the core, so they are unaffected by merging). It can introduce new reduce-reduce conflicts when two states with the same core have two different complete items whose merged lookahead sets overlap (e.g. core {[A→γ.,a1],[B→δ.,a2]} in one state and {[A→γ.,a2],[B→δ.,a1]} in the other core-sharing state) — a case that never arises in this particular grammar but is the classical reason "some LR(1) grammars are not LALR(1)".
Part (a): Backpatching. Rather than fixing jump targets immediately (which is impossible for forward jumps whose target instruction doesn't exist yet), the code generator emits jump instructions with the target left blank and records each blank in a synthesized list (truelist/falselist) of instruction numbers still needing a target. makelist(i) creates a new one-element list {i}; merge(p1,p2) concatenates two lists; backpatch(list,target) fills every instruction in list with the address target once it becomes known. A marker non-terminal M → ε {M.instr = nextinstr} is inserted at the grammar position right where the next instruction's address must be captured (e.g. right after 'or'/'and').
Part (b): Backpatched 3AC for if (a<b or c<d) and e<f then x=1; using B→id1 relop id2 (emits a conditional-goto and a goto, both blank), B→B1 or M B2 (backpatch(B1.falselist,M.instr)), and B→B1 and M B2 (backpatch(B1.truelist,M.instr)):
Trace of how the jumps were resolved: (a<b).falselist={101} is backpatched to 102 (start of c<d, the other 'or' operand) once that code is generated. B1=(a<b or c<d) then has truelist={100,102} and falselist={103}. When "and e<f" is processed, B1.truelist={100,102} is backpatched to 104 (start of e<f) — so either a<b or c<d being true jumps straight to testing e<f. The whole expression's truelist={104} is backpatched to 106 (the x=1 statement) once it is generated, and falselist={103,105} both fall through to statement 107. Manually tracing all four truth combinations of the three relations confirms x=1 executes iff (a<b or c<d) and e<f is true.
Part (a): C++ uses largely manual memory management: new allocates
an object on the heap and invokes its constructor, delete invokes the destructor
and frees the storage; because there is no collector, C++ idiomatically relies on
RAII (Resource Acquisition Is Initialization) and smart pointers
(unique_ptr/shared_ptr) so that destructors deterministically release
memory when an owning object's scope ends, converting manual deallocation into automatic,
scope-driven deallocation. Java (and most managed OOP languages) instead allocates every object
on a garbage-collected heap; the programmer never frees memory explicitly, and the runtime
reclaims unreachable objects using one of the collection algorithms discussed in gc-15 (typically
a generational collector). The tradeoff is predictability versus safety: C++ gives precise,
immediate, low-overhead control at the cost of manual-memory bugs (leaks, dangling pointers,
double frees), while Java trades some throughput and pause-time unpredictability for the
elimination of that entire bug class.
Part (b):
Every object of a class with virtual methods carries a hidden pointer (vptr) as the
first field of its header, set by the constructor to point at that class's single shared
VTable - an array of function pointers with one fixed slot index per virtual method,
shared by all instances of the class. A virtual call obj->f() compiles to
obj->vptr[slot_of_f](): the slot index is resolved at compile time (from the
static declaration order), but which function pointer sits at that slot is resolved at run time
because each subclass's VTable stores its own overriding implementation at the same slot -
this indirection is exactly dynamic dispatch. Non-virtual methods need no such
indirection since their target is fixed at compile time. Java's JVM uses an analogous mechanism
(a per-class method table plus interface method tables/"itables" for interface dispatch). Single
inheritance keeps this a simple one-vptr, one-vtable, fixed-slot scheme; multiple inheritance
requires an object to carry several vptrs (one per base-class sub-object) and the compiler to
generate small "thunk" functions that adjust the this pointer before forwarding a
call through the correct base's vtable.
Part (a): Code generation must target the actual instruction set, and RISC and CISC
machines demand different strategies. RISC (Reduced Instruction Set Computer) machines
have fixed-length instructions, a strict load-store architecture (only explicit load/store
instructions touch memory; arithmetic operates only on registers), a large uniform register file,
and simple, single-cycle-friendly instructions - the code generator must therefore explicitly
schedule separate load instructions before any computation and explicitly manage a comparatively
large but still finite register set, relying heavily on the compiler (not the hardware) for
instruction scheduling and pipeline-hazard avoidance. CISC (Complex Instruction Set
Computer) machines allow variable-length instructions, rich addressing modes, and operands that
may reside directly in memory (memory-to-memory or memory-to-register operations in a single
instruction), with many complex operations implemented internally as microcode - the code
generator can therefore often emit fewer, denser instructions per source statement (e.g. one
ADD [mem], reg instead of separate load/add/store), shifting complexity from the
compiler into the hardware's microcode decoder.
| Aspect | RISC | CISC |
|---|---|---|
| Instruction length | Fixed | Variable |
| Memory access | Load-store only (registers for compute) | Memory-to-memory / memory operands allowed |
| Addressing modes | Few, simple | Many, complex |
| Registers | Many general-purpose | Fewer, often special-purpose |
| Execution model | Hardwired, single-cycle-friendly | Microcoded, multi-cycle |
| Compiler role | Heavy - scheduling, register allocation critical | Lighter - hardware absorbs some complexity |
Part (b): Instruction selection by tree rewriting represents each IR expression
as an expression tree and matches it against a library of tiles - small subtree patterns,
each pre-associated with a target instruction and a cost. A tiling covers the whole tree with
non-overlapping tiles, and a maximal-munch / dynamic-programming algorithm chooses, at
each node, the minimum-total-cost combination of tiles bottom-up (computing the cheapest tiling
of each subtree once and reusing it, which is what makes it dynamic programming rather than
brute-force search over all tilings). For x = a + b * c, the IR tree is
Assign(x, Add(a, Mul(b, c))). Tiling proceeds bottom-up: the subtree
Mul(b, c) is matched by a single "MUL-tile" (target instruction
MUL Rt, b, c), producing a temporary result; that temporary then combines with
a under an "ADD-tile" (ADD Rt2, a, Rt); finally a "STORE-tile" covers
the outer Assign node (MOV x, Rt2). Because the two-instruction tiling
(one MUL-tile, one ADD-tile, one STORE) has lower total cost than any decomposition using more,
smaller tiles, the dynamic-programming selector picks exactly this combination, directly
producing the instruction sequence MUL Rt,b,c ; ADD Rt2,a,Rt ; MOV x,Rt2.
Part (a): In a control flow graph, node d dominates node n (written d dom n) if every path from the entry node to n passes through d (every node trivially dominates itself). The dominance relation for a CFG is captured compactly as a dominator tree: each node's parent is its immediate dominator - the unique closest dominator that is itself dominated by all of the node's other dominators - so ancestry in the dominator tree exactly encodes the dominance relation. A natural loop is identified from a back edge n -> h, an edge whose target h dominates its source n (i.e. control flows from later in the traversal back "up" to a point that always executes first); h is the loop's unique header, and the loop body is h together with every node that can reach n without passing through h.
Part (b): To find all natural loops: (1) compute the dominator tree of the CFG (e.g. via the standard iterative dominator algorithm); (2) scan every CFG edge n -> h and classify it as a back edge whenever h dom n; (3) for each back edge, compute the loop body by a reverse-CFG traversal starting at n: initialize the body set to {h, n}, then repeatedly walk predecessors of nodes already in the set (excluding h itself, which is never re-entered) adding any newly reached predecessor, stopping when no new node is added - every node collected this way can reach n without passing through h, which is exactly the definition of loop membership. A loop pre-header is then constructed by inserting a new, empty basic block immediately before h and redirecting every edge that enters h from outside the loop to instead target this new block, which falls through to h; edges from inside the loop (including the back edge) still go directly to h. The pre-header gives optimizations like loop-invariant code motion a single, safe location - executed exactly once per loop entry - to hoist invariant computations into.
Part (a): Local Value Numbering (LVN) processes one basic block at a time,
maintaining a hash table that maps a canonicalized (operator, operand value
numbers) key to a value number. Each new variable/constant gets a fresh value
number the first time it is seen; for a computation like t = x op y, LVN looks up
(op, VN(x), VN(y)) (with operands of a commutative operator sorted into a canonical
order so that a+b and b+a hash identically) - if that key already
exists, the new computation is provably redundant (computes the same value) regardless of what
the source variable/temporary names were, and can be replaced by a copy of the earlier result;
otherwise a new value number is assigned. Because LVN only tracks facts within a single block, it
misses redundancies whose two occurrences are in different blocks. Global Value Numbering
(GVN) extends the same idea across the whole procedure/CFG, typically operating on SSA-form
IR (where each variable has exactly one definition, making value identity easier to reason about
across blocks and merge points via phi-functions); GVN therefore catches cross-block redundant
computations that LVN, restricted to one block, structurally cannot see.
| Aspect | LVN | GVN |
|---|---|---|
| Scope | Single basic block | Whole procedure / CFG |
| Typical representation | Plain three-address code | SSA form (phi-functions) |
| Implementation | Simple hash table, linear scan | More complex - partitioning / congruence-closure over the CFG |
| Redundancy caught | Only within one block | Across block boundaries too |
| Cost | Cheap, fast | More expensive but more powerful |
Part (b): Basic block with a redundant computation via commutative operands and a repeated expression:
Value-numbering trace: VN(a)=1, VN(b)=2. For t1 = a+b,
key (+, 1, 2) is new -> assign VN(t1)=3. For
t2 = b+a, canonicalizing commutative operands gives the same key (+, 1, 2)
-> already present, so VN(t2)=3 too and t2 is redundant
(equal to t1). For t3 = t1*t2, key (*, 3, 3) is new ->
VN(t3)=4. For t4 = a+b, key (+, 1, 2) is
already present -> VN(t4)=3, also redundant with t1.
For t5 = t3+t4, key (+, 4, 3) is new ->
VN(t5)=5.
Only one real addition (a+b) and one real multiplication are computed; the
second and third additions are eliminated as provably redundant, without any control-flow or
cross-block reasoning being required since all four statements live in one basic block.
Part (a): Interprocedural Optimization (IPO) is the family of optimizations that analyze and transform code across function/procedure boundaries rather than one function at a time, since purely intraprocedural (local) analysis must otherwise make worst-case conservative assumptions about what a called function might do to memory or globals. Typical interprocedural analyses include interprocedural constant propagation (propagating a constant actual argument into the callee's body), interprocedural alias/points-to analysis (determining whether two pointers in different functions can refer to the same memory), and call-graph construction (needed before any of the above, especially in the presence of function pointers/virtual calls). Inline expansion (inlining) is the most impactful and widely used IPO transformation: the compiler replaces a call site with a full copy of the callee's body, substituting actual arguments for formal parameters. This removes the call/return overhead (stack frame setup, parameter marshalling, jump) outright, and - more importantly - exposes the callee's statements to the caller's local optimizer, enabling further constant folding, dead code elimination, and common subexpression elimination across what used to be a call boundary, which is why inlining is often called an "enabling" optimization rather than just a speed optimization in itself.
Part (b):
| Effect | Benefit of inlining | Cost of inlining |
|---|---|---|
| Call overhead | Eliminated entirely (no push/jump/return) | - |
| Further optimization | Exposes callee body to constant propagation, CSE, dead-code elimination at the call site | - |
| Code size | - | Grows with every inlined copy; a function called from N sites can multiply its body size by N |
| Instruction cache | - | Larger code footprint increases i-cache misses, which can outweigh the saved call overhead, especially for hot but rarely-reused call sites |
| Compile time | - | Larger functions take longer to optimize/compile after inlining |
| When it wins | Small, frequently-called functions (accessors, small helpers) on hot paths | - |
| When it loses | - | Large functions, or functions called from many distinct sites (code bloat with little reuse benefit) |
Because of this tradeoff, real compilers use a cost/heuristic-based inliner: a callee is inlined only if its estimated size is below a threshold (or the call site is judged "hot" by profile data), and recursive or very large functions are excluded outright, balancing the execution-speed benefit against the code-size and instruction-cache cost.
Part (a): A generic target machine model used for code generation typically assumes a
small set of general-purpose registers, a byte-addressable memory, and a simple
load-store/two-address instruction set: MOV dst, src for moves/loads/stores,
and two-address arithmetic instructions of the form OP dst, src meaning
dst := dst OP src (the destination is also an implicit source operand). Each
instruction has an assumed cost (often uniform, e.g. 1 unit, possibly higher if either operand is
a memory location rather than a register), and the code generator's job is to produce a correct
instruction sequence for an expression while minimizing register usage and total instruction
cost - critically, computing which operand subtree needs more registers first (Sethi-Ullman
labeling) and evaluating that subtree first, so that if only a limited number of registers is
available, "spilling" an intermediate result to memory is avoided or minimized.
Part (b): For x = a + b * c using only two registers R0, R1:
the expression tree is Add(a, Mul(b,c)). By Sethi-Ullman reasoning, the
Mul(b,c) subtree needs one register to hold its result but must be evaluated first
and kept live while a is subsequently loaded, so it is computed into R0
before touching a; if the multiplication were evaluated last instead, its two leaf
operands plus the pending value of a would together need three registers
simultaneously, forcing a spill. Evaluating the heavier (multiply) subtree first keeps everything
within two registers:
Register allocation reasoning: R0 is dedicated to holding the (higher
register-need) multiplication result across the two multiply instructions; R1 is
only brought in afterward for a, once R0's value is stable, and is
immediately combined with R0 - at no point are more than two live values needed
simultaneously, so no spill to memory is required, and total code length is minimal (five
instructions, one per leaf load/store plus one per operator) for this two-register model.
Part (a): Static program analysis examines source or intermediate code without
executing it, computing conservative facts about all possible runs of the program. A
linter (e.g. the original Unix lint for C, or modern tools such as
Clang Static Analyzer, ESLint, or Pylint) is a lightweight static analysis tool built on top of a
compiler front end that flags suspicious-but-not-illegal constructs the compiler itself would
silently accept - unused variables, unreachable code, format-string mismatches, implicit
narrowing conversions, possible null-pointer dereferences - typically using the same data-flow
and type-checking machinery a compiler already builds (reaching definitions, live variables) but
applying stricter/heuristic rules aimed at catching likely bugs and style problems rather than
establishing outright language-legality. Because static analysis reasons about all possible
executions at once, without running the program, it can find bugs on rarely-exercised paths that
testing would miss, at the cost of possible false positives (over-conservative warnings on
paths that can never actually occur).
Part (b): Abstract Interpretation formalizes this "approximate all runs at once" idea: instead of executing a program over its real (concrete) domain of values, it executes a sound approximation of the program over an abstract domain - e.g. the "sign" domain {negative, zero, positive, unknown} instead of exact integers, or an interval domain [low, high] instead of exact values - with abstract versions of every operator (e.g. positive + positive = positive) defined so that the abstract result always safely over-approximates every concrete result that could actually occur. Because the abstract domain is small and has finite (or well-controlled infinite) height, fixed-point iteration over it (exactly the same iterative technique used for data-flow analyses like reaching definitions) terminates and yields provably sound bounds - e.g. proving an array index can never go negative - which is precisely how compilers and static analyzers prove properties that would be undecidable to check exactly. Model Checking takes a complementary, more expensive approach: it represents a program's (or protocol's, or hardware design's) behavior as a finite-state transition system and exhaustively explores its reachable state space to verify that a temporal-logic property (e.g. "no reachable state has a race" / "every request is eventually followed by a response") holds on every path, or produces a concrete counterexample trace if it does not. In compiler verification specifically, model checking and abstract interpretation are used to prove an optimization is behavior-preserving (the optimized and unoptimized programs are observationally equivalent) or to verify safety properties of generated code, complementing testing by offering exhaustive, mathematically certain coverage of the state space rather than sampled coverage.