Machine-Level Representation of Programs - Conditional Branches and Loops & Avoiding Undefined Behavior

📚 Notes

  • This blog content is for learning reference only
  • It is recommended to understand the concepts and implement them independently
  • Welcome to exchange and discussion

task: Conditional Branches and Loops & Avoiding Undefined BehaviorConditional Branches and Loops

if-else

int f(int x) {
  int y = 0;
  if (x > 500) y = 150;
  else if (x > 300) y = 100;
  else if (x > 100) y = 75;
  return y;
}


Conditional jump instructions determine whether to enter a code block.

beq, bne, blt, bge, bltu, bgeuThere are four pseudo-instructions: bgt, ble, bgtu, bleu, which achieve their purpose by swapping operands. For example, bgt r1, r2, offset is equivalent to blt r2, r1, offset. These instructions cover all comparison operations for signed and unsigned numbers.

Tip: A single unsigned comparison instruction can be used to check a range for signed numbers.

When comparing unsigned, negative numbers are greater than all non-negative numbers.

void f(int x) { if (x >= 300 && x <= 550) printf("A"); }


switch-case

int f(int x) {
  int y = 0;
  switch (x) {
    case 1: y = 2; break;
    case 2: case 3: y = 5; break;
    case 4: case 5: case 6: case 7: y = 8; break;
    case 8: case 9: case 10: case 11: y = 10; break;
    case 12: y = 15; break;
    default: y = -1; break;
  }
  return y;
}


With -O0 or -O1 compilation, gcc generates a jump table (x -> branch entry offset). It uses lw to read the offset from the jump table, calculates the branch address, and then jumps with jr.

If compiled with -O2, gcc directly generates a result lookup table (x -> y). It reads the y value directly, skipping the jump entirely.

If cases are sparse like case 1, case 10, case 100, gcc generates if-else style code. The space cosst of building a jump table becomes too high.

while, do-while & for

int f(int n) {
  int y = 0;
  for (int i = 0; i < n; i ++) {
    y += i;
  }
  return y;
}


The machine-level representation of a loop is a conditional jump instruction that branches back.

Jumping means continuing the loop. Not jumping means exiting the loop.

Avoiding Undefined Behavior

Integer Overflow

#include <stdio.h>
#include <limits.h>
int foo(int x) { return (x + 1) > x; }
int main() {
  printf("INT_MAX=%d, cmp: %d%d\n", INT_MAX, (INT_MAX + 1) > INT_MAX, foo(INT_MAX));
  return 0;
}


gcc -w a.c && ./a.out
gcc -w -O2 a.c && ./a.out
clang -w a.c && ./a.out
clang -w -O2 a.c && ./a.out


Code that appears semantically equivalent may produce different results.

Reason: Signed integer overflow is undefined behavior.

Different machines may use different representations for signed integers (original code, inverse code, two's complement...)

The C standard cannot unify the exact behavior of signed integer overflow. For example, 32-bit original code and inverse code cannot represent -2147483648.

This leads to undefined behavior, allowing the compiler to handle it arbitrarily.

Overflow semantics of addition instructions

An ISA will use a specific encoding method to represent signed integers (most commonly two's complement).

Therefore, for an ISA, the overflow result of addition enstructions is well-defined.

RISC-V: wraps around, i.e., maximum positive number + 1 = minimum negative number.

x86: wraps around and sets the overflow flag OF (overflow flag).

MIPS: addu/addiu - wraps around, add/addi - throws an exception.

The 'u' here is misleading. Most instructors assume that the presence or absence of 'u' corresponds to signed or unsigned addition in C language. Even if not misled, it's likely unclear why 😂.

Fact: Most programming languages don't require exceptions on signed overflow.

Thus, MIPS's add/addi seems very redundant. Compilers do not use it. To save valuable opcode space, MIPSr6 finally removed the exception-throwing addi.

Shifts

#include <stdio.h>
int main() {
  int i = 30;
  printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
  printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
  printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
  printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
  return 0;
}


Whether using -O2 compilation yields different results.

The behavior of shift instructions is well-defined. However, C standards state that shifting beyond the representable range is undefined behavior.

Division by Zero

#include <stdio.h>
int f(int a, int b) { return a / b; }
__attribute__((noinline)) int g() { return 1 / 0; }
__attribute__((noinline)) int h(int a, int b) { return a / b; }
int main() {
  printf("1 / 0 = %d\n", 1 / 0);
  printf("f(1, 0) = %d\n", f(1, 0));
  printf("g(1, 0) = %d\n", g());
  printf("h(1, 0) = %d\n", h(1, 0));
  return 0;
}


clang -O2 a.c && ./a.out


C language defines division by zero as undefined behavior, so the compiler can handle it arbitrarily.

0000000000401140 <g>:
  401140: c3                    retq


clang simply removes the entire division calculation 😂.

The function's return value depends on the value in the eax register at the time of the call.

Semantics of division by zero

x86: throws an exception RISC-V: the result is defined as -1

rv64gcc a.c && ./a.out


MIPS: does not throw an exception, but the result is unpredictable. In QEMU, it might directly return the dividend as the result.

mips-linux-gnu-gcc -mno-check-zero-division -static a.c && ./a.out


But since it is undefined behavior at the language level, adding -O2 allows gcc to ignore it.

It generates a self-trap instruction for the runtime enviroment to handle. x86 - ud2RISC-V - ebreakMIPS - teq

Undefined Behavior and Mitigation

Assuming int is represented in two's complement with 32 bits:

Expression Result Expression Result
UINT_MAX+1 0 1<<-1 undefined
LONG_MAX+1 undefined 1<<0 1
INT_MAX+1 undefined 1<<31 undefined
SHRT_MAX+1 INT_MAX == SHRT_MAX then undefined 1<<32 undefined
char c=CHAR_MAX; c++ ??? 1/0 undefined
-INT_MIN undefined INT_MIN/-1 undefined

Reference reading: Understanding integer overflow in C/C++, ICSE 2012

Scientific approach: sanitizer - compiler automatically inserts assert()

gcc -fsanitize=undefined a.c && ./a.out man gcc # Learn more about the -fsanitize option

Scientific approach: sanitizer - compiler automatically inserts assert()

gcc -fsanitize=undefined a.c && ./a.out
man gcc  # Learn more about the -fsanitize option


Summary

C is a high-level assembly language

The result of converting C code to binary is more understandable than expected Constants, variables, operations, conditional branches, loops Looking at C code, you can basically "manually compile" into machine instructions

Undefined behavior: the dark side of the program Can be addressed using sanitizer

Significance: writing C code allows predicting how it will run on the computer

Eliminate fear of software-hardware collaboration, and achieve precise control over machine-level behavior through software

Tags: machine-level code undefined behavior c programming Compiler Optimization RISC-V

Posted on Mon, 26 Oct 2026 16:21:57 +0000 by freebie