JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

From C to Assembly: Expressions and Branches

A C program is full of arithmetic, ifs, and braces — but the chip only knows a flat list of tiny instructions. Watch a compiler flatten an expression and turn an if/else into a conditional branch, and meet the assembler that turns readable mnemonics into the bits the hardware runs.

The bottom of the language stack

By now you know the ISA as a contract: a fixed list of instructions, some registers, and a flat array of bytes. Assembly language is simply that contract written for human eyes. Each line is one machine instruction spelled out with a readable name — add, lw, beq — instead of a raw pattern of bits. There is almost no abstraction here: one line of assembly is one instruction the hardware actually runs. The friendly names are a courtesy to us; the chip never sees them.

A high-level language like C sits one floor up. When you write y = (a + b) * c;, you are describing a result, not a sequence of machine steps. The job of bridging the two floors belongs to the compiler, the tool that reads C and emits assembly. The compiler knows the target ISA intimately — that is what it means to say a compiler targets an ISA — and it must take your nested, parenthesised expression and lay it out as a strict, single-file line of instructions the machine can march through one at a time.

Flattening an expression

An expression like (a + b) * c is a little tree in your head: a multiply whose left branch is itself an add. The hardware cannot hold a tree; it can only do one operation at a time and stash each partial result somewhere. So the compiler walks the tree from the leaves inward, computing each sub-result into a register and feeding it to the next step. This is the heart of code generation: a tree becomes a straight, ordered line. Recall the load-store rule from the ISA rung — arithmetic touches only registers — so any variable in memory must first be loaded.

  1. Say a, b, c already sit in registers x10, x11, x12, and the answer y should land in x13. (If they lived in memory, each would need a load first — arithmetic touches only registers.)
  2. Compute the inner add first, because it is the deepest leaf: add x13, x10, x11 makes x13 hold a + b.
  3. Feed that partial result straight into the multiply: mul x13, x13, x12 reuses x13 as a running accumulator, giving (a + b) times c.
  4. The nested tree is now a flat, ordered pair of instructions the machine can march through one at a time — that straightening is exactly what code generation does.

Notice that subtraction needs no separate machinery from the compiler's point of view. Because integers are stored in two's complement, the hardware subtracts a - b by adding a to the negation of b, and one adder circuit handles both. That single design choice — which you met in the data-representation rung — is exactly why an ISA can offer add and sub as near-twins. The compiler simply picks the right mnemonic; two's complement makes the silicon underneath reuse one adder for both jobs.

Turning if/else into a branch

The chip runs instructions in address order — fetch one, then the very next, then the next — by quietly stepping the program counter forward. An if has to break that march: depending on a test, the machine must skip one block of code and run another. The instruction that can do this is a conditional branch: it inspects a condition and, only if the condition holds, redirects the program counter to a different address instead of falling through to the next line. A control-flow instruction is the only thing that can make a program take a fork in the road.

Here is the trick that surprises newcomers: assembly inverts your test. Where C says "if the condition is true, run the then-block," the natural assembly says "if the condition is false, branch away to skip the then-block." Falling through stays cheap and common; the branch is the exception. So if (a < b) { ... } compiles to a single conditional branch that jumps past the body when a is not less than b. Read assembly long enough and this flipped logic becomes second nature.

# C:   if (a < b)  m = a;   else  m = b;        // m = min(a,b)
#      a in x10, b in x11, m to land in x12

       blt   x10, x11, then    # if a <  b, jump to 'then'   (branch taken)
       add   x12, x11, x0      # else-block: m = b           (x0 is always 0)
       j     done              # skip over the then-block
  then:
       add   x12, x10, x0      # then-block: m = a
  done:
       # ... m is now in x12
An if/else lowered to one conditional branch (blt = branch-if-less-than) plus an unconditional jump to skip the other arm. Labels like 'then' are just names for addresses.

Conditions, flags, and comparisons

How does a branch actually decide? There are two great styles, and knowing both keeps you from confusion when you read different machines. RISC-V folds the comparison into the branch itself: blt takes two registers, compares them on the spot, and jumps if the first is less. Many older ISAs — x86, ARM — instead split it in two. A compare (or any arithmetic) instruction first sets a few condition flags — tiny one-bit results recording whether the last operation was zero, negative, or produced a carry — and a separate branch instruction then reads those flags to decide.

Picture flags as sticky notes the ALU leaves behind after every operation: "the result was zero," "the result was negative," "there was a carry out of the top bit." A branch-if-equal really means branch-if-the-zero-flag-is-set, because a == b is tested by computing a - b and checking whether the result was zero. The flag-based design lets one comparison feed several different branches, but it adds a hidden dependency: the flags must survive untouched between the compare and the branch. RISC-V's fused style sidesteps that, which is one reason its assembly reads so cleanly.

The assembler: mnemonics into bits

Assembly is for us, not the chip, so one last tool must translate it down to the actual bit patterns the ISA defined: the assembler. Its job is mostly mechanical bookkeeping. For each line it looks up the opcode, packs the register numbers and any immediate into the instruction's fixed-format fields, and emits the resulting machine word. Crucially, it also resolves the labels you saw above — then, done — into real numeric addresses, computing the offset each branch needs. The assembler is what frees you from counting addresses by hand.

The assembler also offers a small convenience that trips up beginners: the pseudo-instruction. Some lines you write are not real machine instructions at all — they are friendly shorthands the assembler expands into one or more genuine instructions. RISC-V's mv x12, x10 (copy a register) is not an opcode; the assembler turns it into addi x12, x10, 0. Likewise li (load a constant) may become one or two real instructions. A pseudo-instruction is a kindness in the assembly source, not something the hardware knows — which is exactly why disassembled binaries can look subtly different from the source you wrote.

Once the assembler finishes, it produces an object file — machine instructions plus a table of unfinished references to things living in other files. Stitching those files together and placing the program in memory to run is the work of the linker and the loader, which we will meet at the end of this rung. For now you have the whole front of the pipeline: C becomes assembly through the compiler, and assembly becomes runnable bits through the assembler.