April 11, 20257 min

C Gives You Control, But at What Cost?

m
mayo

With C, there's no runtime, no GC. Just raw speed and control.

char* msg = malloc(100);
strcpy(msg, "hello");
free(msg);
printf("%s", msg); // ❌ Use after free
malloc(100) memory allocated msg → valid pointer strcpy(msg, ..) writes "hello" into allocated block free(msg) memory released msg is now dangling printf(msg) reads freed memory undefined behavior Nothing in C stops step 4 from reading memory that no longer belongs to it

Common Pitfalls

Problem Code Risk
Use-after-free printf("%s", msg); Undefined behavior
Double free free(msg); free(msg); Heap corruption
Buffer overflow char buf[4]; strcpy(buf, "long"); Memory corruption
Memory leak malloc(...) with no free Slow crashes

Manual Memory Model

You must:

  • Allocate memory
  • Track ownership
  • Free it manually
  • Avoid accessing freed or invalid memory

Real-World Consequences

Heartbleed (OpenSSL)

// Simplified version of the bug
char* buffer = malloc(payload_length);
memcpy(buffer, payload, payload_length); // No bounds check!
// Attacker could read past buffer end

Impact: 500,000+ servers exposed private keys and passwords.

CVE-2021-44228 (Log4Shell equivalent in C)

char* user_input = get_user_data();
sprintf(log_buffer, "User: %s", user_input); // Buffer overflow possible

The Problem: No automatic bounds checking means attackers can:

  • Crash your program
  • Execute arbitrary code
  • Steal sensitive data

Memory Safety Statistics

Security vulnerabilities by :

  • 70% of Microsoft security bugs: memory safety issues
  • 65% of Chrome vulnerabilities: memory corruption
  • ~50% of Android security patches: memory-related

The Developer Burden

Every Allocation Needs Tracking

typedef struct {
    char* data;
    size_t size;
} Buffer;

Buffer* create_buffer(size_t size) {
    Buffer* buf = malloc(sizeof(Buffer));
    if (!buf) return NULL;
    
    buf->data = malloc(size);
    if (!buf->data) {
        free(buf);  // Must remember to cleanup!
        return NULL;
    }
    
    buf->size = size;
    return buf;
}

void destroy_buffer(Buffer* buf) {
    if (buf) {
        free(buf->data);  // Must free in correct order
        free(buf);
    }
}

Mental overhead: Every function must consider:

  • Who owns this pointer?
  • When should it be freed?
  • Is it still valid?

Debugging Memory Issues

$ valgrind ./my_program
==12345== Invalid read of size 4
==12345==    at 0x40084B: main (test.c:10)
==12345==  Address 0x5204044 is 0 bytes after a block of size 4 alloc'd
==12345==    at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)

The problem: Bugs found at runtime, not compile time.

Performance vs Safety Trade-off

C Performance Characteristics

// Zero overhead - direct memory access
int sum = 0;
for (int i = 0; i < 1000000; i++) {
    sum += array[i];  // No bounds checking
}

Speed: ✅ Maximum performance
Safety: ❌ One mistake = security vulnerability

Memory Layout Control

// Precise control over memory layout
struct Point {
    float x, y, z;     // Exactly 12 bytes
} __attribute__((packed));

Point* points = malloc(1000 * sizeof(Point)); // Predictable allocation

Control: ✅ Complete memory layout control
Risk: ❌ Manual lifetime management

Tools Help, But Aren't Enough

Static Analysis

// clang-static-analyzer can catch some issues
char* ptr = malloc(10);
free(ptr);
*ptr = 'x';  // ⚠️ Warning: use after free

Runtime Detection

// AddressSanitizer (ASan) catches bugs at runtime
$ gcc -fsanitize=address program.c
$ ./a.out
=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free

The Limitation

  • Static tools: Miss complex cases, false positives
  • Runtime tools: Only catch bugs that execute during testing
  • Code review: Human error, time-consuming
Every C safety net has a hole in a different place Static analysis reads code, never runs it misses pointers whose path it cannot prove — plus noise ASan / Valgrind runs the real program but only sees the lines your tests actually execute Code review understands intent but a freed pointer can be files away from its use the overlap is not the whole space what none of the three caught ships to production Rust deletes the category instead of hunting instances — none of these bugs compile

Why C Persists Despite Risks

Systems Programming Requirements

  • Operating systems: Need direct hardware access
  • Embedded systems: Memory constraints, no room for runtime
  • Performance-critical code: Every nanosecond matters

Legacy and Ecosystem

  • Massive codebases: Decades of C code in production
  • Library ecosystem: Most system libraries written in C
  • Developer knowledge: Generations of C programmers

The Fundamental Problem

C gives you two bad choices:

Option 1: Manual Memory Management

char* data = malloc(size);
// ... complex logic ...
if (error) {
    free(data);  // Must remember cleanup in ALL paths
    return -1;
}
// ... more logic ...
free(data);  // Easy to forget or double-free

Option 2: Garbage Collection

  • Add GC library like Boehm GC
  • Lose performance predictability
  • Still possible to have memory leaks

Key Takeaways

Predictable performance - no GC pauses
Complete control over memory layout
Minimal runtime overhead
Unsafe by default - one mistake = vulnerability
High mental burden for developers
Most security bugs stem from memory issues
Tools catch bugs after they're written, not before


The Challenge: We want C's performance without its danger.

The Question: What if the compiler could prevent memory bugs at compile time?

➡️ Next: "Rust's Ownership: Memory Safety Without Garbage Collection"