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
XMM0throughXMM7. - 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 acallinstruction.
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:
- Write the Assembly function according to the ABI.
- Assemble the Assembly code into an object file (
.o). - Compile your C code into an object file (
.o). - 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: Makesadd_two_asma global symbol, so the linker can find it.movl %edi, %eax:ediis the lower 32-bit part ofrdi.eaxis the lower 32-bit part ofrax. We move the first argumentaintoeax.addl %esi, %eax:esiis the lower 32-bit part ofrsi. We add the second argumentbtoeax.ret: Returns from the function. The sum is ineax(which is the lower 32 bits ofrax), andraxis 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):
Assemble the Assembly code:
gcc -c add_two.s -o add_two.o # or using 'as': as add_two.s -o add_two.oThe
-cflag tellsgccto compile (assemble) but not link.Compile the C code:
gcc -c main.c -o main.oLink both object files:
gcc add_two.o main.o -o myprogramRun 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 (likeeax), you need%%eaxbecause%has special meaning in the constraint string.\n\tfor multiple instructions: Newlines and tabs improve readability for multiple instructions.- Constraints:
rfor general-purpose register,mfor memory,ifor immediate, etc. - Clobbered Registers: Tell the compiler which registers your assembly code modifies so it can save/restore them if needed.
memorycan also be clobbered if your assembly modifies memory not explicitly listed as an output. volatile: Prevent the compiler from optimizing away theasmblock 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:
- Assembly
_startis the first code to run. - It sets up the stack, initializes crucial registers, and possibly global data.
- It then calls the C
mainfunction (or another C function). - After the C function returns, the Assembly
_starthandles 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:
- Create an
add_max.sfile. - Implement the
find_max_asmfunction following the x86-64 Linux ABI (arguments inRDI,RSI, return inRAX). - Use Assembly comparison and conditional jump instructions (e.g.,
cmp,jle,jg,mov) to determine the maximum. - Create a
main.cfile to call and test this function with different integer pairs. - Compile and link your C and Assembly files.
Hints:
cmpl %esi, %edicomparesRDIwithRSI.jge(jump if greater or equal),jl(jump if less).- Remember to use
movlfor 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:
- In
main, declare an integerxand initialize it. - Use a GCC/Clang
asmblock with extended assembly syntax. - Implement the multiplication
x * 7using a combination of shifts and additions. For example,x * 7 = (x * 8) - x, which translates to(x << 3) - x. - Use input/output constraints (
"+r"(x)) and declare any clobbered registers. - Print
xbefore 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.