Course Homepage

Compiler Basics

Goal: Implement a programming language

Compiling and running C code

gcc -o hello hello.c
./hello

What does GCC do?

Keep in mind that gcc itself is also a program! Bash creates a process to exec() gcc.

Two ways to implement a programming language

  1. Compilation: Translate to machine code to run on the CPU.
  2. Interpretation: Simulate the program’s instructions.

With both approaches, we have another program that takes our C code and makes it run on our machine.

These are often blended for real-world compiled or interpreted languages.

Compilation

A compiler translates your source code to machine code.

Compiler correctness

Compiled machine code should have the same outputs that the source program would.

Functional equivalence is possible when programs are deterministic. How can programs, such as random number generators, create apparent non-determinism with a deterministic program? They can by using random (or hard to guess) inputs.

Probabilistic and quantum programming languages have probability distributions as outputs.

Interpretation

An interpreter simulates the actions of source code.

Interpreter correctness

The interpreted program should have the same outputs that the source program would.

Illustrating language implementation

Getting “1+2” to run on our machine.

We assume a syntax and semantics for arithmetic operations that is similar to what C-like languages have.

Compiler approach

Translate the expression “1+2” to machine code.

// read the source code
char left = getchar();
char operator = getchar();
char right = getchar();

// generate assembly code that performs the addition
if ('+' == operator) {
  printf("  mov $%c, %%rax\n", left);
  printf("  mov $%c, %%rbx\n", right);
  // print the addition instruction
  printf("  add %%rbx, %%rax\n");
}

Interpreter approach

Interpret the result of “1+2.”

// read the source code
char left = getchar();
char operator = getchar();
char right = getchar();

// compute the result of the addition
if ('+' == operator) {
  int leftnum = left - '0';
  int rightnum = right - '0';
  int result = leftnum + rightnum; // perform addition
  printf("%d\n", result);
}

Observe that it’s pretty straightforward to interpret addition, because we have the same representation for it in C.

Key difference between compilation and interpretation

Extending the interpreter

Interpreting exponents, e.g., “2^8”?

No machine instruction or C instruction for exponentiation.

Interpreter pseudocode

read left operand
read right operand
read operator

if operator == "^":
  result = 1
  for i = 1 to right operand:
    result = result * left operand
  print result

Full example for + and ^

File: language.c

/**
  gcc -o language language.c
  echo "1+2" | ./language
  echo "2^8" | ./language
*/

#include <stdio.h>
#include <stdlib.h>

int ascii_to_int(char c) { return c - '0'; }

int main() {
  char left = getchar();
  char operator = getchar();
  char right = getchar();

  printf("input: %c%c%c\n", left, operator, right);

  printf("compiler\n");
  switch (operator) {
  case '+':
    printf("  mov $%c, %%rax\n", left);
    printf("  mov $%c, %%rbx\n", right);
    printf("  add %%rbx, %%rax\n");
    break;
  case '^':
    printf("  mov $%c, %%rbx\n", left);
    printf("  mov $%c, %%rcx\n", right);
    printf("  mov $1, %%rax\n");
    printf("loop:\n");
    printf("  cmp $0, %%rcx\n");
    printf("  jle end\n");
    printf("  imul %%rbx, %%rax\n");
    printf("  sub $1, %%rcx\n");
    printf("  jmp loop\n");
    printf("end:\n");
    break;
  }
  printf("\n");

  printf("interpreter\n");
  int leftnum = ascii_to_int(left);
  int rightnum = ascii_to_int(right);
  int result;
  switch (operator) {
  case '+':
    result = leftnum + rightnum;
    printf("%d\n", result);
    break;
  case '^':
    result = 1;
    for (; rightnum > 0; rightnum--) {
      result *= leftnum;
    }
    printf("%d\n", result);
    break;
  }
}

COP-5621 Compiler Construction

Take this course if you want to learn more about how programming languages are defined and implemented.

Compiler Internals

The “middle”-end

Many compilers use an intermediate language.

Why an intermediate representation?

Multiple front-ends and back-ends

The LLVM compiler infrastructure.

Classic phases of a compiler

The classic phases of compilation, as illustrated in the Dragon book.

Symbols versus meaning

1 + 2 3

6 / 2(1 + 2)

一加二乘三

What gives symbols meaning?

ASCII versus machine code

Recall interpreter last time.

Syntax trees

Human language

“The boy went to the store”

Syntax: groups of words

Syntactic structure captures relationships between words

“The boy leaving behind his friend went to the store.”

What does “store” mean?

“The store is closed.”

“Squirrels store acorns for the winter”.

The meaning of the word “store” depends on how it is used with other words: - Store as a noun: a shop that sells products. - Store as a verb: to place for later use.

Meaning also depends on structure

Arithmetic expressions

9 + 3 - 2

Compiling arithmetic expression

What is the assembly code version of “9 + 3 - 2”?

Evaluating the tree

Ambiguity

1 + 2 * 3

Parsing

How is source code represented?

Parsing infers structure

Formal grammars

Part of a grammar
Terminals
Non-terminals
Productions
Starting symbol

Terminals

Non-terminals

Productions

Strictly speaking, context-free grammars are restricted to having a single non-terminal on the left-hand side of the arrow, but context-sensitive grammars can have more symbols on the left-hand side.

Starting symbol

Constructs can be nested

Example: arithmetic expressions

E -> E + E
E -> E - E
E -> E E
=
E -> E / E
E -> N
N -> 0
N -> 1
N -> 2
N -> 3
N -> 4
N -> 5
N -> 6
N -> 7
N -> 8
N -> 9

The terminals, i.e., uttered words in the language, are the operators (+, -, *, /) and the digits (0, 1, 2, 3, 4, 5, 6, 7, 8, 9).

The non-terminals are the names of the grammar constructs, specifically E for expression, num for number, and op for operator.

The pipe symbol here means alternative, i.e., num -> 0 | 1 is equivalent to two rules, num -> 0 and num -> 1.

The starting symbol, by convection, is the non-terminal on the left-hand side of the first rule.

Deriving a tree

Defining an interpreter

Expression interpreter rules

Specifying the interpreter

Define the computation for each grammar construct:

E -> E + E  { E.value = add(E1.value, E2.value) }
E -> E - E  { E.value = sub(E1.value, E2.value) }
E -> E * E  { E.value = mult(E1.value, E2.value) }
E -> E / E  { E.value = div(E1.value, E2.value) }
E -> N      { E.value = ascii_to_int(N.value) }
N -> 0      { N.value = "0" }
N -> 1      { N.value = "1" }
N -> 2      { N.value = "2" }
...
N -> 9      { N.value = "9" }

Defining a compiler

Compile infix to postfix

Assume we have a function concat() for joining any number of strings.

E -> E + E  { E.value = concat(E1.value, E2.value, "+") }
E -> E - E  { E.value = concat(E1.value, E2.value, "-") }
E -> E * E  { E.value = concat(E1.value, E2.value, "*") }
E -> E / E  { E.value = concat(E1.value, E2.value, "/") }
E -> N      { E.value = N.value }
N -> 0      { N.value = "0" }
N -> 1      { N.value = "1" }
N -> 2      { N.value = "2" }
...
N -> 9      { N.value = "9" }

Note that we no longer need to convert ASCII to a machine integer, since we can just print the output program in ASCII as well.