Chapter 15: Advanced Topics: Linking C with Assembly Language

Chapter 15: Advanced Topics: Linking C with Assembly Language

At the heart of low-level programming lies the ability to interact directly with the hardware and exploit the full potential of the CPU. While C provides excellent control, there are times when even C isn’t “low-level enough.” This is where Assembly Language comes in.

Assembly language is a human-readable representation of the machine code instructions that a processor executes. Linking C with Assembly allows you to:

  • Optimize Critical Code Sections: Achieve maximum performance for very tight loops or frequently executed routines where C compiler might not generate optimal code.
  • Direct Hardware Access: Interact with specific hardware features, registers, or instructions that might not be directly exposed or efficiently accessible through C.
  • Access System-Specific Features: Use certain CPU instructions or operating system system calls that are not easily invoked from C.
  • Bootstrapping: The initial code that sets up a system (e.g., bootloaders) is often written in Assembly.

This chapter will introduce the concepts and techniques for interfacing C code with Assembly language. Due to the architecture-specific nature of Assembly, we’ll focus on general principles and provide examples for a common architecture (x86-64, using AT&T syntax often used with GCC/Clang).

Prerequisites: Basic familiarity with an Assembly language (e.g., x86 or ARM) will be helpful, but we’ll explain the C-Assembly interface details.

15.1 Understanding the Calling Convention (ABI)

The most crucial aspect of interfacing C and Assembly is understanding the Application Binary Interface (ABI) for your specific system (OS and architecture). The ABI defines:

  • Function Calling Convention: How arguments are passed to a function (registers, stack), how return values are passed, and which registers are “caller-saved” (caller must save before call) vs. “callee-saved” (callee must save before use).
  • Stack Frame Layout: How local variables, return addresses, and saved registers are arranged on the stack.
  • Data Type Representation: How basic C data types are represented in memory and registers.

For x86-64 Linux (System V AMD64 ABI, used by GCC/Clang):

  • Integer/Pointer Arguments: Passed in registers RDI, RSI, RDX, RCX, R8, R9 (in order). Additional arguments are pushed onto the stack.
  • Floating-Point Arguments: Passed in XMM0 through XMM7.
  • Return Value (Integer/Pointer): Stored in RAX.
  • Return Value (Floating-Point): Stored in XMM0.
  • Caller-Saved Registers: RAX, RCX, RDX, RSI, RDI, R8-R11, XMM0-XMM15. The calling function (C code) must save these if it needs their values after the function call.
  • Callee-Saved Registers: RBX, RBP, R12-R15. The called function (Assembly code) must save these on the stack at the beginning and restore them before returning, if it modifies them.
  • Stack Alignment: The stack pointer (RSP) must be 16-byte aligned before a call instruction.

This ABI means your Assembly code must correctly read arguments from these registers (or the stack) and place the return value in the appropriate register.

15.2 Calling Assembly Functions from C

This is the most common way to link C and Assembly. You write a function in Assembly, compile it into an object file, and then link it with your C code.

Steps:

  1. Write the Assembly function according to the ABI.
  2. Assemble the Assembly code into an object file (.o).
  3. Compile your C code into an object file (.o).
  4. Link both object files together to create the executable.

Example 15.1: Simple Assembly Function add_two_asm

Let’s create an Assembly function that adds two integers.

add_two.s (Assembly code, x86-64 AT&T syntax for Linux):

.globl add_two_asm          # Make the function visible to the linker

# Function signature: int add_two_asm(int a, int b)
# Arguments:
#   a: RDI (first integer argument)
#   b: RSI (second integer argument)
# Return value:
#   RAX (integer return value)

add_two_asm:
    movl    %edi, %eax      # Move 'a' (RDI's lower 32 bits) to EAX
    addl    %esi, %eax      # Add 'b' (RSI's lower 32 bits) to EAX
    ret                     # Return. The result is already in RAX/EAX
  • .globl: Makes add_two_asm a global symbol, so the linker can find it.
  • movl %edi, %eax: edi is the lower 32-bit part of rdi. eax is the lower 32-bit part of rax. We move the first argument a into eax.
  • addl %esi, %eax: esi is the lower 32-bit part of rsi. We add the second argument b to eax.
  • ret: Returns from the function. The sum is in eax (which is the lower 32 bits of rax), and rax is the designated return register for integers.

main.c (C code):

#include <stdio.h>

// Declare the Assembly function prototype (tells C compiler it exists)
extern int add_two_asm(int a, int b);

int main() {
    int x = 10;
    int y = 5;
    int sum;

    // Call the Assembly function
    sum = add_two_asm(x, y);

    printf("Sum from Assembly function: %d\n", sum); // Expected: 15

    return 0;
}

Compilation and Linking (Linux/macOS with GCC):

  1. Assemble the Assembly code:

    gcc -c add_two.s -o add_two.o
    # or using 'as': as add_two.s -o add_two.o
    

    The -c flag tells gcc to compile (assemble) but not link.

  2. Compile the C code:

    gcc -c main.c -o main.o
    
  3. Link both object files:

    gcc add_two.o main.o -o myprogram
    
  4. Run the program:

    ./myprogram
    

15.3 Inline Assembly

Inline Assembly allows you to embed Assembly instructions directly within your C code. This is useful for very small, performance-critical sections, or to access specific CPU instructions that don’t have C equivalents.

Inline Assembly is compiler-specific. GCC and Clang use a powerful and flexible syntax with asm or __asm__ keywords.

Syntax (Basic GCC/Clang Inline Assembly):

asm("assembly code");

For more complex inline assembly, the general form is:

asm(
    "assembly code"
    : output_operands         // Optional: outputs from assembly to C variables
    : input_operands          // Optional: inputs from C variables to assembly
    : clobbered_registers     // Optional: registers modified by assembly
);

Example 15.2: Inline Assembly to Increment

Let’s increment an integer using inline assembly.

#include <stdio.h>

int main() {
    int a = 10;

    printf("Before inline assembly: a = %d\n", a); // Output: 10

    // Inline assembly to increment 'a'
    // "addl $1, %0" : Add 1 to the first operand. %0 refers to 'a'.
    // "+r"(a)       : 'a' is an input-output operand. 'r' means put in any general-purpose register.
    //                 '+' means it's both read and written.
    asm("addl $1, %0" : "+r"(a)); // 'l' suffix for long (32-bit in x86-64 context usually)

    printf("After inline assembly: a = %d\n", a); // Output: 11

    return 0;
}

To Compile and Run (Linux/macOS with GCC):

gcc inline_asm.c -o inline_asm
./inline_asm

Example 15.3: Inline Assembly to Swap Two Integers

This example demonstrates using multiple input/output operands and explicit registers.

#include <stdio.h>

int main() {
    int x = 10, y = 20;

    printf("Before swap: x = %d, y = %d\n", x, y);

    // Swap x and y using inline assembly
    // "movl %1, %eax" : Move y into EAX
    // "movl %0, %edx" : Move x into EDX
    // "movl %eax, %0" : Move EAX (original y) into x
    // "movl %edx, %1" : Move EDX (original x) into y
    //
    // "=r"(x) : x is an output operand, put result in a register ('r')
    // "=r"(y) : y is an output operand, put result in a register
    // "0"(y)  : y is an input operand, use the same register as the 0th output operand (x)
    // "1"(x)  : x is an input operand, use the same register as the 1st output operand (y)
    // "eax", "edx" : Clobbered registers. Compiler knows we modified these.
    asm volatile ( // 'volatile' keyword prevents GCC from optimizing away if it thinks the output is not used.
        "movl %1, %%eax\n\t"
        "movl %0, %%edx\n\t"
        "movl %%eax, %0\n\t"
        "movl %%edx, %1"
        : "=r"(x), "=r"(y)        // Output operands: x, y
        : "0"(y), "1"(x)          // Input operands: y is input for output 'x', x is input for output 'y'
        : "eax", "edx"            // Clobbered registers
    );

    printf("After swap: x = %d, y = %d\n", x, y);

    return 0;
}

Notes on Inline Assembly Syntax:

  • %% for registers: When specifying register names directly (like eax), you need %%eax because % has special meaning in the constraint string.
  • \n\t for multiple instructions: Newlines and tabs improve readability for multiple instructions.
  • Constraints: r for general-purpose register, m for memory, i for immediate, etc.
  • Clobbered Registers: Tell the compiler which registers your assembly code modifies so it can save/restore them if needed. memory can also be clobbered if your assembly modifies memory not explicitly listed as an output.
  • volatile: Prevent the compiler from optimizing away the asm block if it seems to have no effect on observable C variables (e.g., if you don’t use the output variables).

15.4 C calling Assembly using _start

In embedded systems or bootloaders, you might want to call C code from an Assembly _start routine, or even replace the default C runtime entry point. This is an advanced topic that goes beyond typical application development. The general idea is:

  1. Assembly _start is the first code to run.
  2. It sets up the stack, initializes crucial registers, and possibly global data.
  3. It then calls the C main function (or another C function).
  4. After the C function returns, the Assembly _start handles program termination (e.g., calling an exit system call).

This requires a deep understanding of your system’s startup process and linking without the standard C runtime library.

15.5 System Calls with Assembly

Operating systems expose their services through system calls (e.g., write to print to console, exit to terminate the program). While C library functions (like printf, exit) wrap these system calls, you can invoke them directly from Assembly.

Example (Linux x86-64 System Call for exit):

.globl _start

_start:
    # exit(0)
    movl $60, %eax     # syscall number for exit (60)
    xorl %edi, %edi    # argument 1: exit code (0). XORing with self sets to 0.
    syscall            # Invoke the system call

To compile and link this, you would typically use ld directly without the C runtime:

as exit.s -o exit.o
ld exit.o -o exit_program
./exit_program
echo $? # Check exit code (should be 0)

This direct interaction with system calls from Assembly is critical for operating system development and low-level utilities.

Exercise 15.1: Assembly Function to Find Max

Write an Assembly function int find_max_asm(int a, int b) that takes two integers and returns the larger one.

Instructions:

  1. Create an add_max.s file.
  2. Implement the find_max_asm function following the x86-64 Linux ABI (arguments in RDI, RSI, return in RAX).
  3. Use Assembly comparison and conditional jump instructions (e.g., cmp, jle, jg, mov) to determine the maximum.
  4. Create a main.c file to call and test this function with different integer pairs.
  5. Compile and link your C and Assembly files.

Hints:

  • cmpl %esi, %edi compares RDI with RSI.
  • jge (jump if greater or equal), jl (jump if less).
  • Remember to use movl for 32-bit integer operations.

Exercise 15.2: Inline Assembly to Multiply by 7 (Mini-Challenge)

Write a C program that uses inline assembly to multiply an integer variable x by 7 using only bitwise shifts and additions/subtractions. Avoid direct multiplication instruction in assembly.

Instructions:

  1. In main, declare an integer x and initialize it.
  2. Use a GCC/Clang asm block with extended assembly syntax.
  3. Implement the multiplication x * 7 using a combination of shifts and additions. For example, x * 7 = (x * 8) - x, which translates to (x << 3) - x.
  4. Use input/output constraints ("+r"(x)) and declare any clobbered registers.
  5. Print x before and after the inline assembly.

Example C-like logic:

result = (x << 3) - x; // x * 8 - x = x * 7

Assembly equivalent to achieve result = (input << 3) - input; (high-level concept):

    movl %0, %%eax       ; eax = input (x)
    shll $3, %%eax       ; eax = input << 3 (x * 8)
    subl %0, %%eax       ; eax = eax - input (x * 8 - x = x * 7)
    movl %%eax, %0       ; x = eax

You need to correctly map %0 to the C variable x using constraints.


You’ve now explored the fascinating frontier of linking C with Assembly language. Whether through external Assembly functions or inline blocks, this capability provides unparalleled control over performance and hardware interaction, solidifying your understanding of low-level programming. In the next chapter, we’ll consolidate your knowledge with a guided project: building a simple command-line calculator.