Why C still matters for Reverse Engineering
Most of us spend hours looking at a binary in Ghidra, bouncing between the disassembly and the decompiler. Eventually, you notice that no matter what language the program was originally written in, Ghidra keeps showing C-like code.
Not Rust, not Go, not python, C.
The answer isn’t complicated. It explains a lot about how reverse engineering actually works.
What Reverse Engineering actually looks like
Very few people read thousands of lines of assembly instruction by instruction unless they have to. Most of the time you’re looking for patterns.
A chunk of assembly that looks like a loop. A sequence that resembles a function call. A block that behaves like a string comparison.
The translation lands in a familiar place. For most of us, that place is C.
C and assembly share a relationship that most other languages don’t.
The “Portable Assembler” Idea
When Dennis Richie designed C in the early 1970s, he wasn’t trying to build a high-level language in the modern sense. He wanted something that could generate efficient machine code across different hardware platforms while still being readable. The language was often described as a “portable assembler.”
In practice, this means that the mapping from C to assembly is thing. One line of C usually turns into a small handful of instructions. There isn’t a lot of hidden machinery between what you write and what the CPU executes.
Here’s a simple demo:
int x = 10;int y = x + 5;the generated assembly is roughly:
mov eax, 10mov [x], eaxmov eax, [x]add eax, 5mov [y], eaxThere’s very little hidden work between the source code and the generated instructions.
Now compare that to Python:
x = 10y = x + 5Behind the scenes, Python is doing allocation, reference counting, type checking and dynamic dispatch. The gap between what you write and what the CPU does is enormous.
When reversing a binary, you don’t want the gap. You want the direct line from instruction to concept. That’s why decompilers output C code. It’s the closest thing to assembly that’s comfortably readable by us.
Recognizing the standard library
This has made reversing much faster for me instead of re-analyzing them each and every time.
Most binaries, even those written in C++ or other languages call into the C standard library. Functions like memcpy, strcmp, printf and malloc show up constantly.
The calling conventions for these functions are consistent. On x86-64, the first argument goes in rdi, second rsi and third rdx. Once you’ve seen a strcmp call a few times, you recognize the pattern immediately.
So when you see this in assembly:
mov rdi, raxmov rsi, rbxcall 0x401234You can label that as a string comparison without tracing through the function. Instead of spending 20 minutes analyzing the implementation, just move on.
This is the kind of shortcut that builds up over time. The more C you know, the more of these patterns you recognize. It’s not about writing C well, it’s about reading C well enough to see the underlying structure.
Memory as a linear space
One idea that took me a while to appreciate is that, to the CPU, memory is just a long sequence of bytes. A linear address space. Every reliable, structure and object lives somewhere inside that space.
C programmers tend to think this way naturally. They work with pointers, offsets and sizes. They know that a structure is just a block of memory with fields at specific offsets. The first field is at offset 0, the second at offset 4 or 8 depending on alignment and so on.
Reverse engineers think the exact same way. When you’re analyzing a binary, you’re looking at memory dumps and trying to figure out what the bytes represent. If you don’t understand how C lays out data in memory, you’re looking at noise.
For example, suppose you find a region of memory that seems to represent some kind of object. In C, a struct is just a contract about how bytes are arranged. If you can figure out the layout, you can name the fields and understand what the program is doing with them.
If you’re used to higher-level languages where objects are handled through runtime systems and garbage collectors, this mindset doesn’t come naturally. But it’s essential for reversing.
Vulnerability research and memory safety
Most of the serious security bugs we deal with; use-after-free, buffer overflows, type confusion, come from C and C++ code. These languages give programmers direct access to memory and humans make mistakes.
When you’re hunting for these bugs in a binary, you need to track memory allocations, understand where the heap and stack are and follow pointers through their lifetimes. You’re asking questions like:
- where was this buffer allocated?
- when is it freed?
- could a pointer still be used after free?
- are there any paths that write past the allocated space?
You trace these directly in the binary. If you don’t understand C’s memory model, this kind of analysis becomes almost impossible.
The bugs live in C’s domain. You have to be comfortable in that domain to find them.
The systems that are still with us
There’s also a practical reality worth acknowledging.
Embedded devices, operating system kernels, industrial control systems, medical devices, satellite software, all of these systems are overwhelmingly written in C. Billions of lines of code running on critical infrastructure and much of it was written decades ago.
These systems are going to be around for a long time. They’re not being rewritten in Rust or Go any time soon.
If you reverse engineer these systems, you’re working with C. There’s no way around it.
Rust, Go and other languages
Rust and Go introduce their own challs for reverse engineering.
Rust’s compiler aggressively inlines functions and monomorphizes generics. This can make decompiled output harder to follow because boundaries disappear. The names are also mangled in ways that are less straightforward than C++.
Go brings its own runtime, scheduler and calling conventions. Goroutines and channels create control flow that doesn’t map cleanly to traditional function calls.
Even then, many of the concepts you’re reasoning about: memory layout, function calls, pointers, control flow - are still easiest to understand through C. It’s like the default language.
A simple example
Here’s a short C program that checks a password:
#include <string.h>
int check_password(char* input) { char correct[] = "secret"; return strcmp(input, correct) == 0;}compile this without stack protection and look at the assembly
gcc -S -fno-stack-protector check.c
The function call
leaq -15(%rbp), %rdx # Address of "secret" stringmovq -24(%rbp), %rax # Address of inputmovq %rdx, %rsi # Second argument: "secret"movq %rax, %rdi # First argument: inputcall strcmp@PLTThis is the actual comparison. The compiler:
- gets the address of the string at
-15(%rbp)intordx - gets the input pointer from
-24(%rbp)intorax - sets up the arguments for
strcmp(first inrdi, second inrsi) - calls
strcmp
This pattern is exactly what you’d look for when reversing. The arguments being prepared before the call instruction are dead giveaways.
The difference between a C programmer and someone who only knows Python or JS isn’t about intelligence. It’s about familiarity with how the language maps into the machine.
What you need to know
Do not make the mistake of trying to memorize assembly instructions. It doesn’t help much. What will actually improve your reversing is learning to recognize higher-level patterns such as loops, switch statements, stack frames, string handling and common library calls. That’s when assembly starts to look less like random instructions and more like a program.
You don’t need to be a professional C developer to reverse engineer effectively. You don’t need to write production-quality code or know every detail of the language standard.
But you need to understand:
- how pointers work and why they’re used
- how memory is organized (stack, heap, static data)
- how structures are laid out in memory
- how strings are represented
- how function calls work(stack frames, calling conventions)
Once you have these concepts, the decompiler’s output stops looking like foreign language

It becomes a rough draft of what the program might have looked like in the first place.
I don’t write C everyday, but I read the output of decompilers almost every day. That’s where the payoff has been.
Reverse engineers use it because it’s the closest common ground between the machine and human understanding.
In the next article, we’ll stop talking why C matters and start using it. We’ll compile a small program, look at the generated assembly and walk through how Ghidra reconstructs it back into C. Once you see that round trip, decompiler output starts making a lot more sense.
Next reads
View all →9 Sept
Mini-Readelf: Gluing It All Together
The capstone. Four parts of pieces, headers, sections, symbols, relocations, joined into one tool that reads any ELF. The only new mechanic is the sh_link chain: offset into a table that holds offsets into a table that holds strings.
5 Sept
Relocations: How PIE Binaries Fix Their Addresses
A PIE binary can't write final addresses because ASLR moves it. The linker leaves placeholders and the loader patches them after mapping. That's a relocation: R_X86_64_RELATIVE, GLOB_DAT and JUMP_SLOT.
4 Sept
Symbol Tables: What Function Names Actually Are
Function names in a binary are just entries in a table. Two tables actually: .symtab and .dynsym. Here's what each is for, how the struct works, and how to resolve a name from an address.
Get posts by email
One email when I publish, not a drip, not weekly. Sign up and I'll only write when there's something new.
You won't get mail just for signing up. Unsubscribe any time.