Mixing Programming Languages in One Executable: How Compilers, Linkers, and ABIs Make It Work

Written by Massa Medi
Hi friends, I am George from mergesociety. Today we are going to explore low-level realities that most developers never think about unless they have to squeeze speed from metal or stitch together systems code from different worlds. If you have ever wondered how projects combine Rust, C, Fortran, assembly and more into a single program that runs as one process, this is your guide.
Why Some Projects Use Multiple Languages In One Process
Some multi-language projects are easy to picture. A typical Django stack uses Python for the backend and HTML, CSS, and JavaScript in the browser. That is two sides talking over the network. Two different processes. Clear mental model.
But what about projects where components written in different programming languages are meant to run together inside one process, sharing the same memory and the same address space? That is where people get confused. If languages have different compilers, runtimes, and memory models, how do they live inside one executable file without falling apart at runtime?
The short answer is: the toolchain and the linker make it possible. The longer answer is far more interesting, and it changes how you understand compilers entirely.
Prefer watching instead of reading? You can watch the full walkthrough below, or keep scrolling to read the complete article.
Compilers Are Not Magic Boxes - They Are Pipelines
A common myth says compilers turn source code straight into an executable file. That is the final outcome, but not the process. Real compilers are pipelines. They transform code through multiple stages, and at each stage the output is a different kind of artifact. Knowing these stages is the key to mixing languages safely.
A C Program Walkthrough On GNU Linux With GCC
On most GNU Linux systems, we reach for GCC when compiling C. Although people still call it the GNU C Compiler, it is actually the GNU Compiler Collection. That naming shift is important, and we will come back to it.
Imagine a tiny C program that prints a message. On Windows, it prints one string. On Linux, it prints another. We run:
gcc main.c -o hello
From our perspective: compile, run, done. Under the hood, GCC runs a pipeline with four big phases. Each phase is a tool with a job, and each job produces an output that the next tool consumes.
Phase 1 - Preprocessing
The preprocessor cleans and prepares C code before actual compilation. It removes comments, expands macros, and resolves conditional compilation with directives like #ifdef
. Most importantly, it resolves #include
lines by literally inserting the contents of header files into your source. The output is still C code, just expanded and ready for the next step.
Picture it like getting all the ingredients on the counter before cooking. You are not eating yet, but everything is chopped and ready to be turned into something hot and tasty.
Phase 2 - Compilation To Assembly
The compiler translates preprocessed C into assembly language. This is not machine code yet. Assembly is human readable, written in mnemonics like mov
, add
, and call
. It represents real CPU instructions and how the program will run.
Myth busted: compilers do not always go straight to machine code. Many produce an intermediate representation like assembly so developers can inspect the output and reason about performance and correctness.
Phase 3 - Assembling To Object Files
The assembler translates assembly into machine code, producing an object file, typically with a .o
extension. This file contains machine instructions and symbol tables, but it is not a runnable program yet. Think of an object file as a part of a puzzle that still needs to be snapped together.
Phase 4 - Linking Into An Executable
The linker takes one or more object files and stitches them into a single executable. It resolves where each function and global lives in memory, hooks up calls to the right addresses, and pulls in code from libraries that your program depends on, like the C standard library function printf
.
Here is the twist that matters for multi-language projects: the linker can combine object files produced by different compilers, and it can pull code from both static and dynamic libraries. This is the secret sauce that lets a C file call a Rust function or a Fortran routine, as long as they agree on how to talk.
Static Linking vs Dynamic Linking - What Actually Happens
With static linking, the linker copies the machine code for any needed library functions straight into your final executable. It is self contained. No dependencies to search for at runtime.
With dynamic linking, libraries live in separate shared objects loaded at runtime. On Unix-like systems they are .so
files. On Windows they are .dll
files. These files contain executable code but no entry point like main
. Your program references them, and the operating system loader maps them into memory when needed.
Why dynamic linking is so popular:
- Saves disk space and memory - thousands of programs can share one copy of libc or OpenSSL.
- Updates are easier - patch one library and every program that uses it benefits without recompilation.
- On-demand loading - only load what is needed when it is needed.
In practice, this looks like your final binary containing references to symbols that live in shared libraries. At runtime, the loader resolves those symbols and maps the code into your process. To a developer, it feels like everything is bundled, but under the hood, the OS is doing smart work.
Want to go deeper into linking later? We could write a full piece on PLT and GOT, symbol resolution, versioned symbols, and how the dynamic loader searches for libraries. If you want that, drop a comment.
Peeking Behind The Curtain With GCC Flags
Compilers often hide intermediate files by default. But they do not have to. With GCC, you can ask to keep them and even stop the process at a specific stage:
- Keep temps:
gcc -save-temps main.c -o main
- Stop after generating assembly:
gcc -S main.c
- Compile only to object:
gcc -c main.c
This is great for learning and for performance work. When you are tuning hot paths, you can inspect the assembly and verify the compiler produced the instructions you expect. I once had a hot loop that ran millions of times per second. Looking at the assembly showed an unexpected bounds check. One tiny refactor moved the check out of the loop and the function went from sluggish to snappy.
You can also start the pipeline from the middle. If you hand write an assembly routine, pass it to GCC to assemble and link with your C code. GCC is not just one program - it is a toolkit that plays nice with building blocks.
Real Example - Mixing C And Assembly For Speed
Suppose you need to count prime numbers between 0 and N and you want it fast. You could write it all in C. But maybe for the hottest inner loop, you do not trust the auto vectorization you are getting today. So you write a hand-tuned assembly routine for the sieve or the primality check and call it from C.
The flow looks like this:
- Write the core math function in assembly for your CPU target.
- Write the rest in C - parsing, timing, printing.
- Assemble the assembly file to an object file, compile the C file to an object, then link both into one executable.
That is exactly how many serious codebases work. The Linux kernel, FFmpeg, OpenSSL, and many embedded projects use a similar approach. Most logic is in C. When absolute speed matters, they drop to assembly for specific routines like crypto primitives or pixel operations.
Story time: the first time I swapped a C block for an assembly routine, I expected 2x. I got 15 percent. Not great. The win only came after I profiled, fixed cache misses, and aligned data for the CPU prefetcher. The lesson stuck - write the simple version first, measure, then pick your battles.
GCC Is A Toolchain, Not Just A C Compiler
This is why the name changed. GCC started life as the GNU C Compiler. It grew into the GNU Compiler Collection that supports C, C++, Objective-C, Fortran, Ada, D, and more, depending on configuration. Each language frontend feeds into a pipeline of tools. Parts of that pipeline are pluggable, which is exactly why you can stitch in your own assembly files or link in object files built by other compilers entirely.
Mixing High-Level Languages Too - C And Fortran
Assembly is already inside the C pipeline, so using it feels like a cheat code. What about mixing C with a different high-level language, like Fortran? Totally possible and common in math heavy code.
The flow is usually:
- Compile Fortran files with a Fortran compiler to object files.
- Compile C files with GCC or Clang to object files.
- Link all object files together, plus any runtime libraries that Fortran needs.
That last bit matters. Some languages have a runtime with support code that must be linked in. If you forget it, the linker will complain about missing symbols. Once you know the moving pieces, the process is smooth.
Example scenario: you inherit a finance model written in Fortran that traders have trusted for years. You want a modern C API in front of it for integration with services. You do not rewrite the math - you keep the Fortran core, build a thin C layer, and link them together. Now you get stability and a cleaner interface.
Rust And C - Calling Across The FFI Boundary
Rust has its own toolchain and build system. Different compiler, different philosophy. But when it comes time to produce a final binary, Rust relies on a linker too. That means C and Rust can talk to each other as long as they agree on how to talk.
Calling A Rust Function From C
- Write a Rust function with C ABI. In Rust you mark it with
#[no_mangle]
andextern "C"
so the name is predictable and the calling convention matches C. - Build a static or dynamic library from your Rust crate. In
Cargo.toml
, setcrate-type = ["staticlib", "cdylib"]
depending on your needs. - Declare the function in C with
extern
and link against the Rust built library.
Minimal example of the Rust side:
// src/lib.rs
#[no_mangle]
pub extern "C" fn add_fast(a: i32, b: i32) -> i32 {
a + b
}
And the C side:
// main.c
#include <stdio.h>
extern int add_fast(int a, int b);
int main(void) {
printf("%d\n", add_fast(40, 2));
return 0;
}
Build the Rust library with Cargo, compile the C file, and link with the Rust output. Now C calls Rust. Simple and strong.
Calling C From Rust
This is even more common since so many system APIs and libraries are written in C. In Rust, you declare external functions inside an extern "C"
block, and you can bind to headers with tools like bindgen. The same ABI rules apply.
Where This Matters Day To Day
- Graphics: Rust game loops calling C-based OpenGL or Vulkan loaders.
- Crypto: Rust apps using C libraries like OpenSSL or BoringSSL for FIPS compliance.
- OS APIs: Rust services calling POSIX or Win32 functions exposed in C headers.
Why Mix Languages At All? Performance And Leverage
You rarely need every part of a system to be blazing fast. Usually, 80-90 percent of the runtime sits in a small set of functions. So teams write most of the project in a language that makes them fast at building features, then write the hot code in a lower level language like C or hand-tuned assembly.
Another big reason is leverage. Mature C libraries exist for almost everything - graphics, networking, compression, crypto. Tapping into that saves months of work. Mixing languages lets you keep your modern language of choice while still using proven building blocks.
Critical Detail - ABIs And Calling Conventions
Here is the catch most newcomers miss. Even if two compilers target the same CPU and both produce valid machine code, they can disagree on how data is passed to functions and how results are returned. If they do not agree, you get garbage results or crashes.
What An ABI Actually Defines
ABI stands for Application Binary Interface. If an API is the set of functions and data types you call at the source level, the ABI is the contract at the binary level. It covers:
- Which registers carry which arguments.
- When arguments go on the stack and in what order.
- Where the return value lives.
- How the stack pointer is aligned.
- How names are mangled for the symbol table.
- How exceptions and unwinding should be handled, if applicable.
If two sides disagree on any of these, you can pass in a pointer and the callee treats it like a number, or you return a value in the wrong register and the caller reads from an empty one.
Concrete Mismatch Example - Registers And Values
Imagine Language A puts two integer arguments in registers 0 and 1, but Language B expects them in registers 1 and 2. Language A calls the function. Language B reads the wrong registers. Your math runs on junk. Then the function returns its result in register 1, but the caller reads from register 0. Now you have wrong input and a lost result.
Another twist: Language X passes by reference and places addresses in registers. Language Y passes by value and expects numbers. Y will happily add two memory addresses together. That is not what you wanted. It can even crash if those addresses are not readable or are misaligned for the read it tries to do.
How We Fix It - Make One Side Conform
The solution is to make at least one side speak the other side’s ABI. That is exactly what keywords and attributes like extern
and no_mangle
are for. They tell the compiler to generate assembly with specific calling conventions and symbol names.
- C: declare foreign functions with
extern
and match the signature. - Rust: use
extern "C"
so the ABI matches C and add#[no_mangle]
so the symbol name is not changed. - Fortran: use the
BIND(C)
attribute to match the C ABI and predictable naming. - Go: use cgo with a special comment block above
import "C"
to include headers and bind to C code.
After that, the linker can do its job safely because both sides agree on the binary level contract.
Seeing The Pipeline Yourself - A Simple Hands-On Flow
If you want to actually see this, try this mini project:
- Write a C file that declares an external function
extern int add_fast(int, int);
and calls it. - Write the same function in assembly for your platform or in Rust using
extern "C"
and#[no_mangle]
. - Build each to object files. Use
-S
to generate assembly and look at the calling code. Confirm which registers carry args and where the return value goes. - Link the objects and run the program. Then change the calling convention intentionally and watch it break. It is one of the best ways to truly understand ABIs.
On Linux, you can also use objdump -d
and readelf -Ws
to inspect symbols and assembly. On macOS, there is otool -tv
. On Windows, try dumpbin /symbols
.
Common Pitfalls When Mixing Languages
- Name mangling: C++ mangles names by default. Use
extern "C"
on exported functions to keep predictable names. - Struct layout: Different compilers or flags can pack structs differently. Use fixed-width types and alignment attributes when sharing data across languages.
- Calling convention attributes: On Windows, pay attention to
__stdcall
,__cdecl
, and friends when binding to system APIs. - Runtime dependencies: Languages like Fortran or Go may require extra runtime libraries. Make sure they are linked or present at runtime.
- Error handling: Exceptions do not cross language boundaries safely. Prefer error codes or result structs when crossing the FFI.
Key Takeaways - How Different Languages Live In One Executable
- Compilers are pipelines. Preprocess, compile to assembly, assemble to object files, then link.
- The linker is the meeting point. It stitches object files and libraries together, no matter which frontend produced them.
- Static linking copies code into your binary. Dynamic linking defers code loading to runtime and saves space.
- Mixing languages works only if both sides agree on the ABI and calling convention. Use language-specific keywords to make them match.
- Real projects do this all the time - from the Linux kernel to FFmpeg to Rust apps calling C libraries.
We have covered compiled language interop today. In the next part, we will cover how compiled languages mix with interpreted ones. Subscribe so you do not miss it. And big thanks again to Let’s Get Rusty for supporting this work. If this helped you, a quick like goes a long way.