Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

C Syntax Speed Run

You know how to program. Let’s map your existing knowledge to C in record time. If you have written Python, JavaScript, Java, or Go, most of the control flow will look familiar. The key differences: C gives you no garbage collection (you manage memory yourself), no built-in strings (just null-terminated character arrays), no exceptions (you check return values), and no runtime safety checks (the program trusts that you know what you are doing). These differences are not limitations — they are the reason C code can run as fast as the hardware allows.
Time: 4-6 hours
Goal: Write C code confidently
Assumed: You understand variables, functions, loops, arrays from another language

Types & Variables

Primitive Types

#include <stdio.h>
#include <stdint.h>  // Fixed-width integers (C99)
#include <stdbool.h> // bool type (C99)
#include <limits.h>  // Type limits

int main(void) {
    // Character types
    char c = 'A';           // At least 8 bits, may be signed or unsigned
    signed char sc = -128;  // Guaranteed signed
    unsigned char uc = 255; // Guaranteed unsigned (0-255)

    // Integer types (sizes are MINIMUM, vary by platform)
    short s = 32767;              // At least 16 bits
    int i = 2147483647;           // At least 16 bits (usually 32)
    long l = 2147483647L;         // At least 32 bits
    long long ll = 9223372036854775807LL; // At least 64 bits (C99)

    // Unsigned variants
    unsigned int ui = 4294967295U;
    unsigned long ul = 4294967295UL;

    // Fixed-width integers (USE THESE for portable code)
    int8_t   i8  = -128;
    int16_t  i16 = -32768;
    int32_t  i32 = -2147483648;
    int64_t  i64 = -9223372036854775807LL;
    uint8_t  u8  = 255;
    uint16_t u16 = 65535;
    uint32_t u32 = 4294967295U;
    uint64_t u64 = 18446744073709551615ULL;

    // Floating point
    float f = 3.14f;           // Usually 32 bits
    double d = 3.14159265359;  // Usually 64 bits
    long double ld = 3.14159265358979323846L; // At least 64 bits

    // Boolean (C99)
    bool flag = true;  // Actually just int, 0 = false, non-zero = true

    // Size type (for array indices, sizes)
    size_t size = sizeof(int);  // Unsigned, platform-specific

    // Pointer difference type
    ptrdiff_t diff = &i - &i;  // Signed, for pointer arithmetic

    return 0;
}
Critical: int is NOT always 32 bits. On embedded systems it might be 16 bits. Use int32_t when you need exactly 32 bits.

Type Sizes

#include <stdio.h>
#include <stdint.h>

int main(void) {
    printf("char:        %zu bytes\n", sizeof(char));      // Always 1
    printf("short:       %zu bytes\n", sizeof(short));     // >= 2
    printf("int:         %zu bytes\n", sizeof(int));       // >= 2
    printf("long:        %zu bytes\n", sizeof(long));      // >= 4
    printf("long long:   %zu bytes\n", sizeof(long long)); // >= 8
    printf("float:       %zu bytes\n", sizeof(float));     // Usually 4
    printf("double:      %zu bytes\n", sizeof(double));    // Usually 8
    printf("void*:       %zu bytes\n", sizeof(void*));     // 4 or 8 (32/64-bit)
    printf("size_t:      %zu bytes\n", sizeof(size_t));    // 4 or 8

    return 0;
}

Integer Promotion & Conversions

C’s implicit type conversions are one of the most common sources of subtle bugs. The rules are well-defined but counterintuitive. The general principle: when you mix types in an expression, the compiler “promotes” smaller types to larger ones. But when you mix signed and unsigned, the signed value gets converted to unsigned — and that conversion can turn a negative number into a huge positive number.
#include <stdio.h>
#include <stdint.h>

int main(void) {
    // Integer promotion: smaller types promoted to int in expressions
    char a = 100, b = 100;
    int result = a + b;  // a and b promoted to int before addition: result = 200

    // This is why char arithmetic is safe (no overflow in expression)
    char c = a + b;  // Computed as int (200), then truncated to char
                     // On systems where char is signed: 200 wraps to -56

    // DANGER: Implicit signed/unsigned conversion
    // This is the #1 "gotcha" in C integer arithmetic.
    int negative = -1;
    unsigned int positive = 1;
    
    // When comparing signed vs unsigned, the signed value is converted to unsigned.
    // -1 in two's complement is all 1-bits, which as unsigned is UINT_MAX (4294967295)!
    if (negative < positive) {
        printf("Expected\n");
    } else {
        printf("Surprise! -1 > 1 in unsigned comparison\n"); // This prints!
    }
    // Compile with -Wsign-compare to catch this at compile time.

    // DANGER: Truncation
    int32_t big = 100000;
    int16_t small = big;  // Truncated! small = -31072 (undefined if out of range)

    return 0;
}

Control Flow (Quick Reference)

// If-else
if (condition) {
    // ...
} else if (other_condition) {
    // ...
} else {
    // ...
}

// Switch (MUST use break, fall-through is default)
switch (value) {
    case 1:
        // ...
        break;
    case 2:
    case 3:
        // handles both 2 and 3 (intentional fall-through)
        break;
    default:
        // ...
}

// Loops
for (int i = 0; i < n; i++) { }
while (condition) { }
do { } while (condition);

// Loop control
break;     // Exit loop
continue;  // Skip to next iteration
goto label; // Jump (use sparingly, but valid for error cleanup)

label:
    // ...

The Comma Operator

// Less common, but you'll see it
for (int i = 0, j = n; i < j; i++, j--) {
    // i increases, j decreases
}

// Evaluates left-to-right, returns rightmost value
int x = (a = 5, b = 6, a + b);  // x = 11

Functions

Declaration vs Definition

// Declaration (prototype) - tells compiler function exists
int add(int a, int b);

// Definition - actual implementation
int add(int a, int b) {
    return a + b;
}

// Declaration can omit parameter names
int multiply(int, int);

// Old-style declarations (avoid, but you'll see in legacy code)
int old_style();  // Accepts ANY number of arguments!
int modern_style(void);  // Accepts NO arguments

Pass by Value (Always!)

void swap_broken(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
    // Original values unchanged - we modified copies!
}

void swap_working(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
    // Works because we passed addresses
}

int main(void) {
    int x = 5, y = 10;
    swap_broken(x, y);   // x=5, y=10 still
    swap_working(&x, &y); // x=10, y=5 now
    return 0;
}

Static Functions

// Only visible within this file (internal linkage)
static int helper_function(int x) {
    return x * 2;
}

// Can be called from other files (external linkage, default)
int public_function(int x) {
    return helper_function(x) + 1;
}

Inline Functions (C99)

// Hint to compiler: inline the code
static inline int square(int x) {
    return x * x;
}

Arrays

Basic Arrays

#include <stdio.h>
#include <string.h>

int main(void) {
    // Stack-allocated arrays (size must be constant in C89)
    int arr[5] = {1, 2, 3, 4, 5};
    int zeros[100] = {0};           // All elements zero
    int partial[5] = {1, 2};        // Rest are zero
    int inferred[] = {1, 2, 3};     // Size inferred as 3

    // Variable-length arrays (C99, optional in C11)
    int n = 10;
    int vla[n];  // Size determined at runtime (on stack!)

    // Array size
    size_t size = sizeof(arr) / sizeof(arr[0]); // 5

    // Array decay: arrays decay to pointers when passed
    void process(int *arr, size_t size);  // Receives pointer, not array

    return 0;
}
Array Decay: When you pass an array to a function, it decays to a pointer. You LOSE size information. Always pass size as a separate parameter.

Multi-dimensional Arrays

int matrix[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// Memory layout: row-major (rows are contiguous)
// matrix[0][0], matrix[0][1], ..., matrix[0][3], matrix[1][0], ...

// Passing to functions requires all dimensions except first
void process_matrix(int mat[][4], size_t rows);

Strings

Strings in C are just null-terminated character arrays. There is no String type, no .length() method, no bounds checking. A “string” is a char* pointing to the first byte of a sequence that ends with a '\0' byte. Every string operation must manually account for that null terminator, and every buffer that holds a string must have room for it. Forgetting the null terminator or writing past the buffer end is the root cause of the majority of C security vulnerabilities in the wild. If you come from Python where s = "hello" + " world" just works, C strings will feel primitive. They are. That is the price of zero-overhead abstractions — and the reason safe string handling is a core skill, not an afterthought.
#include <stdio.h>
#include <string.h>

int main(void) {
    // String literal (stored in read-only memory)
    const char *literal = "Hello";  // 6 bytes including '\0'

    // Mutable string (array, not pointer)
    char mutable[] = "Hello";       // Can modify
    mutable[0] = 'J';               // Now "Jello"

    // DANGER: This is undefined behavior
    // char *bad = "Hello";
    // bad[0] = 'J';  // UB! Modifying string literal

    // String functions
    size_t len = strlen("Hello");           // 5 (not counting '\0')
    char dest[20];
    strcpy(dest, "Hello");                  // Copy (DANGER: no bounds check)
    strncpy(dest, "Hello", sizeof(dest));   // Safer, but may not null-terminate!
    dest[sizeof(dest) - 1] = '\0';          // Ensure null termination

    strcat(dest, " World");                 // Concatenate (DANGER)
    strncat(dest, " World", sizeof(dest) - strlen(dest) - 1); // Safer

    int cmp = strcmp("abc", "abd");         // < 0 (a < a, b < b, c < d)
    char *found = strstr("Hello World", "World");  // Pointer to "World"

    // Safe string formatting
    char buffer[100];
    int written = snprintf(buffer, sizeof(buffer), "Value: %d", 42);
    // Returns number of chars that WOULD be written (for truncation detection)

    return 0;
}

Structs

Basic Structs

#include <stdio.h>
#include <string.h>

// Definition
struct Point {
    int x;
    int y;
};

// Typedef for cleaner syntax
typedef struct {
    char name[50];
    int age;
    float salary;
} Employee;

int main(void) {
    // Initialization
    struct Point p1 = {10, 20};
    struct Point p2 = {.y = 20, .x = 10};  // Designated initializers (C99)
    struct Point p3 = {0};                  // All fields zero

    Employee emp = {"John Doe", 30, 50000.0f};

    // Access
    p1.x = 15;
    printf("%s is %d years old\n", emp.name, emp.age);

    // Pointer access
    struct Point *ptr = &p1;
    ptr->x = 25;  // Equivalent to (*ptr).x = 25

    // Copy (entire struct, not pointer)
    struct Point p4 = p1;  // Deep copy of struct

    return 0;
}

Struct Memory Layout & Padding

The compiler inserts invisible “padding” bytes between struct members to ensure each field starts at a memory address that is a multiple of its size. Why? Because most CPUs can only read an int from an address that is a multiple of 4. An unaligned access is either slow (x86, which silently handles it in hardware) or a crash (ARM, which raises a bus error). The compiler pads to prevent this. This means the order of struct fields affects the struct’s total size. Same fields, different order, different memory footprint.
#include <stdio.h>
#include <stddef.h>

// BAD ORDER: char-int-char creates padding gaps on both sides of the int
struct Padded {
    char a;      // 1 byte at offset 0
                 // 3 bytes padding (compiler pads to align b to offset 4)
    int b;       // 4 bytes at offset 4
    char c;      // 1 byte at offset 8
                 // 3 bytes padding (struct must be padded to a multiple of its largest member)
};  // Total: 12 bytes, but only 6 bytes of actual data!

// GOOD ORDER: group same-sized fields together (largest first)
struct Packed {
    int b;       // 4 bytes at offset 0 (naturally aligned)
    char a;      // 1 byte at offset 4 (no padding needed before a char)
    char c;      // 1 byte at offset 5
                 // 2 bytes padding (to align struct size to multiple of 4)
};  // Total: 8 bytes -- saved 33% just by reordering!

// Check offsets
int main(void) {
    printf("Padded size: %zu\n", sizeof(struct Padded));
    printf("a offset: %zu\n", offsetof(struct Padded, a));
    printf("b offset: %zu\n", offsetof(struct Padded, b));
    printf("c offset: %zu\n", offsetof(struct Padded, c));
    return 0;
}
Optimization: Order struct members from largest to smallest to minimize padding. This is especially important in hot paths and large arrays.

Bit Fields

struct Flags {
    unsigned int active : 1;     // 1 bit
    unsigned int ready : 1;      // 1 bit
    unsigned int error_code : 4; // 4 bits (0-15)
    unsigned int : 2;            // 2 bits padding (unnamed)
    unsigned int mode : 3;       // 3 bits
};  // Typically 2-4 bytes depending on compiler

int main(void) {
    struct Flags f = {0};
    f.active = 1;
    f.error_code = 15;  // Max value for 4 bits
    // f.error_code = 16;  // UB! Overflow
    return 0;
}

Unions

Unions share memory between members—only one is valid at a time.
#include <stdio.h>
#include <stdint.h>

union Value {
    int32_t i;
    float f;
    char bytes[4];
};  // Size = max member size (4 bytes)

// Type-punning for byte inspection
void print_float_bytes(float f) {
    union {
        float f;
        uint8_t bytes[4];
    } u = {.f = f};

    printf("Float %f as bytes: ", f);
    for (int i = 0; i < 4; i++) {
        printf("%02x ", u.bytes[i]);
    }
    printf("\n");
}

// Tagged union (common pattern)
typedef enum { INT, FLOAT, STRING } ValueType;

typedef struct {
    ValueType type;
    union {
        int i;
        float f;
        char *s;
    } data;
} TaggedValue;

void print_value(TaggedValue *v) {
    switch (v->type) {
        case INT:    printf("%d\n", v->data.i); break;
        case FLOAT:  printf("%f\n", v->data.f); break;
        case STRING: printf("%s\n", v->data.s); break;
    }
}

Enums

typedef enum {
    RED,      // 0
    GREEN,    // 1
    BLUE      // 2
} Color;

typedef enum {
    SUCCESS = 0,
    ERROR_FILE_NOT_FOUND = -1,
    ERROR_PERMISSION_DENIED = -2,
    ERROR_OUT_OF_MEMORY = -3
} ErrorCode;

// Underlying type is int (can use flags)
typedef enum {
    FLAG_NONE = 0,
    FLAG_READ = 1 << 0,   // 1
    FLAG_WRITE = 1 << 1,  // 2
    FLAG_EXEC = 1 << 2    // 4
} Permissions;

int main(void) {
    Permissions p = FLAG_READ | FLAG_WRITE;  // 3
    if (p & FLAG_READ) {
        printf("Has read permission\n");
    }
    return 0;
}

Compilation Model

# The four stages:
# 1. Preprocessing (-E): Handle #include, #define, #ifdef
# 2. Compilation (-S): C code → Assembly
# 3. Assembly (-c): Assembly → Object file (.o)
# 4. Linking: Object files → Executable

# Full pipeline
gcc -E main.c -o main.i     # Preprocessor output
gcc -S main.c -o main.s     # Assembly output
gcc -c main.c -o main.o     # Object file
gcc main.o -o main          # Link to executable

# Or all at once
gcc main.c -o main

# Important flags
gcc -Wall -Wextra -Werror -std=c11 -pedantic main.c -o main
# -Wall: Enable common warnings
# -Wextra: Enable extra warnings
# -Werror: Treat warnings as errors
# -std=c11: Use C11 standard
# -pedantic: Strict ISO C compliance

Quick Exercises

1

Type Sizes

Write a program that prints the size of all basic types on your system. Note which are different from what you expected.
2

Struct Padding

Create a struct with 3 members of different sizes. Use sizeof and offsetof to visualize the padding.
3

String Manipulation

Implement a safe string_concat function that takes a destination buffer, its size, and two source strings, returning true if concatenation succeeded.
4

Tagged Union

Implement a JSON-like value type using a tagged union that can hold null, bool, int, double, string, or error.

Next Up

Build Systems & Toolchain

Master GCC, Make, CMake, and the compilation process

Interview Deep-Dive

Strong Answer:
  • When a signed and unsigned integer appear in the same expression, the signed value is implicitly converted to unsigned before the comparison. If the signed value is negative, it wraps around to a very large unsigned value. For example, (int)-1 < (unsigned int)1 evaluates to false because -1 is converted to UINT_MAX (4294967295 on a 32-bit system), and 4294967295 > 1.
  • This is a real security vulnerability. In code like if (user_length < buffer_size) where user_length is a signed int from untrusted input, an attacker can pass -1, which passes the check (because it becomes a huge unsigned value), and then the subsequent memcpy(buf, src, user_length) interprets -1 as SIZE_MAX bytes, causing a massive buffer overflow.
  • The mitigation is to always check for negative values before mixed comparisons, use -Wsign-compare and -Wconversion compiler flags, and prefer size_t (unsigned) for all sizes and indices.
Follow-up: A junior developer argues that -Wall would catch this. Is that correct?Follow-up Answer:
  • Not entirely. -Wall includes -Wsign-compare on GCC, which catches the comparison case, but it does not catch all implicit signed-to-unsigned conversions in arithmetic. You need -Wconversion and -Wsign-conversion for broader coverage. Even then, some cases slip through. The belt-and-suspenders approach is compiler warnings plus explicit validation of all untrusted signed values before they participate in unsigned operations.
Strong Answer:
  • The CPU accesses memory most efficiently when data is aligned to its natural boundary: a 4-byte int must sit at an address divisible by 4, an 8-byte double at an address divisible by 8. The compiler inserts invisible padding bytes between struct members to satisfy these constraints. The struct itself is padded at the end to a multiple of its largest member’s alignment.
  • For example, struct {'{'}char a; int b; char c;{'}'} on a typical 64-bit system is 12 bytes (1 + 3 padding + 4 + 1 + 3 padding), not 6. Reordering to struct {'{'}int b; char a; char c;{'}'} yields 8 bytes (4 + 1 + 1 + 2 padding) — a 33% reduction.
  • The general rule: order members from largest to smallest alignment. Use sizeof and offsetof to verify. For arrays of millions of structs (particle systems, database rows), this padding waste multiplies — saving 4 bytes per struct across 10 million structs saves 40 MB of memory and dramatically improves cache utilization.
  • When you need exact byte-level control (network protocols, file formats), use __attribute__((packed)) or #pragma pack(1), but be aware that packed structs cause unaligned memory access, which is slow on x86 and crashes on ARM.
Follow-up: How do you use _Static_assert to enforce struct layout assumptions at compile time?Follow-up Answer:
  • _Static_assert(sizeof(struct NetworkPacket) == 8, "NetworkPacket must be exactly 8 bytes"); will fail compilation if the struct is not the expected size. This is essential for binary formats and network protocols where the on-wire representation must match exactly. You can also assert offsets: _Static_assert(offsetof(struct Packet, flags) == 4, "flags must be at offset 4");. This catches breakage immediately when someone adds a field or changes a type, rather than producing silent data corruption at runtime.
Strong Answer:
  • int old_style() is an old-style (K&R) function declaration that says nothing about the parameters. It accepts any number of arguments of any type without a compiler warning. This is a holdover from pre-ANSI C and is a source of subtle bugs — the compiler will not catch old_style(1, 2, 3) even if the function expects zero arguments.
  • int modern_style(void) explicitly declares that the function takes no arguments. Calling modern_style(42) produces a compile-time error, which is what you want.
  • In C23, the empty parentheses () in a function declaration finally means “no parameters” (matching C++ behavior), but until your codebase targets C23 exclusively, always use (void) for parameterless functions. This is a common interview signal: candidates who write int main() instead of int main(void) often have not internalized the distinction.
Follow-up: What are calling conventions, and why do they matter when linking C code with assembly or other languages?Follow-up Answer:
  • A calling convention specifies how arguments are passed (registers vs. stack, which registers, what order), who cleans up the stack (caller vs. callee), and which registers must be preserved across calls. On x86-64 Linux (System V ABI), the first six integer arguments go in rdi, rsi, rdx, rcx, r8, r9; floating-point in xmm0-xmm7; return value in rax. On Windows x64, the convention is different (rcx, rdx, r8, r9). If you link C code with hand-written assembly or a library compiled with a different convention, arguments land in the wrong registers and you get silent data corruption or crashes. This is also why extern "C" exists in C++ — it tells the compiler to use C’s calling convention and name mangling rules so C++ code can interoperate with C libraries.