Miscellaneous Real-World C

Not every practical C topic fits neatly into "macros" or "pointers" - this guide is the catch-all for the things every real command-line program ends up needing: parsing argc/argv properly, reading environment variables, choosing sensible exit codes, cleaning up on shutdown, timing and randomness, and the build/debug tooling around all of it. These are the topics that turn a program that "runs" into one that behaves like a real, well-mannered piece of software.


0. Why this stuff is needed, why industry treats it as non-optional, and why your syllabus never got here

Why it's needed at all

A homework assignment usually just runs, reads a fixed input, and prints a result. A real program has to be launched - with arguments, flags, an environment, a working directory - and has to end in a way other software can react to (an exit code), and often has to survive being interrupted, respond to timing, or generate believable randomness. None of this comes up in a single self-contained "compute the answer" program, which is exactly why a course built around solving individual problems rarely reaches it.

Why real projects treat it as non-optional

Why your syllabus (probably) never got here

This is the guide of things nobody assigned you to learn, but that every working C programmer just knows - because at some point their program had to actually ship, get piped into another tool, get interrupted mid-run, or get built alongside twenty other files.


1. argc and argv - the real basics

Every C program can receive command-line arguments through main's parameters: argc (argument count) and argv (an array of C strings). argv[0] is always the program's own invocation name - the real arguments start at index 1.

#include <stdio.h>

int main(int argc, char *argv[]) {
    printf("Program name (argv[0]): %s\n", argv[0]);
    printf("Number of REAL arguments: %d\n", argc - 1);

    for (int i = 1; i < argc; i++) {
        printf("  argv[%d] = %s\n", i, argv[i]);
    }

    return 0;
}

Running ./myprogram hello world gives argc == 3, with argv[1] == "hello" and argv[2] == "world" - argv[argc] is always guaranteed to be NULL, a useful sentinel if you ever iterate argv without relying on argc directly.


2. Manual flag and option parsing

For a handful of simple flags, many real tools just loop over argv manually with strcmp - no library needed for something this small.

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

int main(int argc, char *argv[]) {
    bool verbose = false;
    const char *outputFile = NULL;

    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) {
            verbose = true;
        } else if (strcmp(argv[i], "-o") == 0) {
            if (i + 1 >= argc) {   // always check there's actually a NEXT argument
                fprintf(stderr, "Error: -o requires a filename\n");
                return 1;
            }
            outputFile = argv[++i];   // consume the NEXT argument as this flag's value
        } else if (argv[i][0] == '-') {
            fprintf(stderr, "Unknown option: %s\n", argv[i]);
            return 1;
        } else {
            printf("Positional argument: %s\n", argv[i]);
        }
    }

    printf("verbose = %d, outputFile = %s\n", verbose, outputFile ? outputFile : "(none)");
    return 0;
}

This scales fine up to maybe half a dozen flags; beyond that, real projects reach for getopt (section 3) instead, since manual parsing gets error-prone fast (missing value checks, ambiguous combined flags, inconsistent error messages).


3. getopt - the standard POSIX way to parse short options

getopt (from <unistd.h>, POSIX - not standard C, but nearly universal on Unix-like systems) handles single-character flags, flags with required values, and produces consistent error messages automatically.

#include <stdio.h>
#include <unistd.h>   // POSIX

int main(int argc, char *argv[]) {
    int opt;
    int verbose = 0;
    const char *outputFile = NULL;

    // "vo:" means: -v takes no value, -o REQUIRES a value (the trailing ':')
    while ((opt = getopt(argc, argv, "vo:")) != -1) {
        switch (opt) {
            case 'v':
                verbose = 1;
                break;
            case 'o':
                outputFile = optarg;   // getopt sets this automatically for "o:" options
                break;
            case '?':                  // getopt itself reports unknown options / missing values
                fprintf(stderr, "Usage: %s [-v] [-o file] [args...]\n", argv[0]);
                return 1;
            default:
                break;
        }
    }

    printf("verbose = %d, outputFile = %s\n", verbose, outputFile ? outputFile : "(none)");

    // optind is the index of the first NON-option argument, set by getopt
    for (int i = optind; i < argc; i++) {
        printf("positional: %s\n", argv[i]);
    }

    return 0;
}

getopt also transparently handles combined short flags (-vo file is equivalent to -v -o file), which manual parsing would need extra code to support correctly.


4. getopt_long - long-form --option flags

getopt_long (a GNU extension, widely available on Linux, also on macOS and via common libraries elsewhere) adds support for --verbose-style long options alongside the short ones, exactly matching what most modern CLI tools support.

#include <stdio.h>
#include <getopt.h>   // GNU extension

int main(int argc, char *argv[]) {
    int verbose = 0;
    const char *outputFile = NULL;

    static struct option longOptions[] = {
        {"verbose", no_argument,       0, 'v'},
        {"output",  required_argument, 0, 'o'},
        {"help",    no_argument,       0, 'h'},
        {0, 0, 0, 0}   // terminator -- required
    };

    int opt;
    while ((opt = getopt_long(argc, argv, "vo:h", longOptions, NULL)) != -1) {
        switch (opt) {
            case 'v': verbose = 1; break;
            case 'o': outputFile = optarg; break;
            case 'h':
                printf("Usage: %s [-v|--verbose] [-o|--output file]\n", argv[0]);
                return 0;
            case '?':
                return 1;   // getopt_long already printed an error message
        }
    }

    printf("verbose = %d, outputFile = %s\n", verbose, outputFile ? outputFile : "(none)");
    return 0;
}

This is exactly the pattern behind real tools' --help, --verbose, and --output=file style flags - getopt_long even supports the --output=file (single-token, equals-sign) form automatically.


5. Environment variables: getenv, setenv, and envp

A program can read (and, on POSIX systems, set) environment variables - key/value pairs inherited from the shell or parent process, commonly used for configuration that shouldn't be hardcoded or passed as a visible command-line argument.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    const char *home = getenv("HOME");   // returns NULL if the variable isn't set
    if (home != NULL) {
        printf("HOME = %s\n", home);
    } else {
        printf("HOME is not set\n");
    }

    const char *apiKey = getenv("API_KEY");
    if (apiKey == NULL) {
        fprintf(stderr, "Error: API_KEY environment variable is required\n");
        return 1;
    }
    printf("Using API key: %s\n", apiKey);

    // setenv is POSIX (not standard C) -- sets/overwrites a variable for
    // THIS process and any children it spawns, not the parent shell
    setenv("MY_VAR", "hello", 1);   // 1 = overwrite if it already exists
    printf("MY_VAR = %s\n", getenv("MY_VAR"));

    return 0;
}

A lesser-known third parameter to main - int main(int argc, char *argv[], char *envp[]) - gives direct array access to every environment variable at once, but getenv is almost always preferred since it's simpler and doesn't depend on this non-standard (though widely supported) third parameter.


6. Exit codes: the contract with the rest of the system

A program's return value from main (or the argument to exit()) becomes its exit status - by convention, 0 means success and any non-zero value means some kind of failure, and other programs/scripts routinely branch on this.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <filename>\n", argv[0]);
        return 1;          // non-zero: signals failure to the calling shell/script
    }

    FILE *fp = fopen(argv[1], "r");
    if (fp == NULL) {
        perror("fopen");
        return 2;          // a DIFFERENT non-zero code -- some tools use distinct
                             // codes to distinguish different failure REASONS
    }

    fclose(fp);
    return EXIT_SUCCESS;   // == 0, from <stdlib.h> -- more descriptive than a bare 0
    // EXIT_FAILURE (also from <stdlib.h>) == 1 on virtually every real platform
}

In a shell, $? (or %errorlevel% on Windows) holds the last program's exit code - this is exactly what shell scripts and CI pipelines check with if ./myprogram; then ... to decide whether to proceed or abort.


7. atexit() - registering cleanup to run automatically

atexit() registers a function to run automatically when the program exits normally (via return from main or a call to exit()) - useful for guaranteed cleanup regardless of where in the program execution finally ends.

#include <stdio.h>
#include <stdlib.h>

void cleanup1(void) {
    printf("cleanup1 running\n");
}

void cleanup2(void) {
    printf("cleanup2 running\n");
}

int main(void) {
    atexit(cleanup1);
    atexit(cleanup2);

    printf("main doing work...\n");

    return 0;   // cleanup2 runs FIRST, then cleanup1 -- registered functions
                 // run in REVERSE order of registration, like a stack
}

atexit-registered functions do not run if the program terminates abnormally (a crash, or abort()) - for cleanup that must happen even in those cases, you generally need OS-level mechanisms (signal handlers, section 10) instead.


8. assert() and NDEBUG

assert(condition) (from <assert.h>) checks a condition during development and aborts with a clear message if it's false - a fast way to catch "this should never happen" bugs early, that's automatically compiled out entirely in release builds.

#include <stdio.h>
#include <assert.h>

int divide(int a, int b) {
    assert(b != 0);   // documents AND enforces a precondition during development
    return a / b;
}

int main(void) {
    printf("%d\n", divide(10, 2));   // 5, fine

    // printf("%d\n", divide(10, 0));   // aborts immediately with a message like:
                                          // "Assertion failed: b != 0, file ..., line ..."
                                          // rather than silently doing undefined division

    return 0;
}

Defining the macro NDEBUG (typically via a compiler flag, e.g. gcc -DNDEBUG ...) makes every assert() in the program compile to nothing - the standard reason release/production builds disable asserts (for performance), while debug builds keep them enabled to catch bugs early. Because of this, never put code with real side effects inside an assert - assert(doImportantWork()) silently stops doing that work entirely in an NDEBUG build.


9. errno - a quick real-world reference

errno (covered in depth in the standard I/O and file handling guides) is worth a quick standalone reminder here since it comes up constantly across the whole standard library, not just file functions.

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

int main(void) {
    errno = 0;   // reset first -- errno isn't automatically cleared before a call
    long value = strtol("not a number", NULL, 10);

    if (errno != 0) {
        printf("strtol reported an error: %s\n", strerror(errno));
    }

    FILE *fp = fopen("/nonexistent/path.txt", "r");
    if (fp == NULL) {
        printf("fopen failed: %s (errno %d)\n", strerror(errno), errno);
    }

    return 0;
}

The pattern is always the same across the standard library: reset errno to 0 right before a call whose failure you care about distinguishing, make the call, then check errno immediately afterward (before any other call has a chance to overwrite it).


10. Signal handling - responding to Ctrl+C and shutdown requests

Signals are how the OS (or another process, via kill) asynchronously notifies a running program of events like an interrupt request (Ctrl+C sends SIGINT) or a termination request. signal() lets you register a handler instead of accepting the default behavior (which is usually "terminate immediately").

#include <stdio.h>
#include <signal.h>
#include <stdbool.h>
#include <unistd.h>

// volatile sig_atomic_t is the standard, safe type for a flag shared
// between a signal handler and the main program flow
volatile sig_atomic_t shutdownRequested = 0;

void handleSigint(int sig) {
    (void)sig;   // unused parameter -- required by signal()'s handler signature
    shutdownRequested = 1;   // ONLY do minimal, signal-safe work inside a handler --
                               // no printf, no malloc, nothing that isn't guaranteed
                               // async-signal-safe
}

int main(void) {
    signal(SIGINT, handleSigint);   // register our handler for Ctrl+C

    printf("Running... press Ctrl+C to request a graceful shutdown.\n");

    int iterations = 0;
    while (!shutdownRequested) {
        printf("working... (%d)\n", iterations++);
        sleep(1);

        if (iterations > 100) break;   // safety limit for this example
    }

    printf("Shutdown requested -- cleaning up gracefully.\n");
    return 0;
}

The volatile sig_atomic_t flag pattern (set a flag in the handler, check it in the main loop) is the standard, safe way to respond to signals - doing real work (printing, allocating memory, file I/O) directly inside a signal handler is unsafe, since a signal can arrive in the middle of another, unrelated operation.


11. Time and date: time(), difftime(), strftime()

<time.h> provides wall-clock time (for timestamps and dates) - distinct from clock() (section 11 continued below), which measures CPU time instead and is meant for performance measurement, not real-world dates.

#include <stdio.h>
#include <time.h>

int main(void) {
    time_t now = time(NULL);   // current time, as seconds since the Unix epoch

    struct tm *localTime = localtime(&now);   // break it into year/month/day/etc.

    char buffer[64];
    strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", localTime);
    printf("Current time: %s\n", buffer);

    time_t later = now + 3600;   // one hour later, in raw seconds
    double secondsElapsed = difftime(later, now);
    printf("Difference: %.0f seconds\n", secondsElapsed);

    return 0;
}

For measuring how long code takes to run (performance timing, not wall-clock dates), use clock() instead, which measures CPU time consumed by the program:

#include <stdio.h>
#include <time.h>

int main(void) {
    clock_t start = clock();

    long sum = 0;
    for (long i = 0; i < 100000000; i++) sum += i;   // some work to time

    clock_t end = clock();
    double elapsedSeconds = (double)(end - start) / CLOCKS_PER_SEC;
    printf("sum = %ld, took %f seconds of CPU time\n", sum, elapsedSeconds);

    return 0;
}

time() and clock() measure genuinely different things - time() is real-world wall-clock time (affected by system clock changes, useful for timestamps); clock() is CPU time consumed (unaffected by the process being paused/descheduled, useful for benchmarking).


12. Random numbers: rand(), srand(), and avoiding modulo bias

rand() generates pseudo-random numbers - but without seeding it with srand() first, it produces the exact same sequence every single run, and the common rand() % n pattern has a subtle statistical bias worth knowing about.

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void) {
    srand((unsigned int)time(NULL));   // seed ONCE, typically with the current time,
                                          // so each run produces a different sequence --
                                          // NEVER call srand() repeatedly in a loop

    int diceRoll = rand() % 6 + 1;   // simple, common, but has a SLIGHT statistical bias:
                                       // if RAND_MAX+1 isn't evenly divisible by 6, low
                                       // values become very slightly more likely than high ones

    printf("dice roll: %d\n", diceRoll);

    // A more statistically correct approach for ranges where the bias
    // actually matters (e.g. cryptography-adjacent code, though rand()
    // itself is NEVER appropriate for real cryptographic randomness):
    int range = 6;
    int limit = RAND_MAX - (RAND_MAX % range);
    int r;
    do {
        r = rand();
    } while (r >= limit);   // reject values that would introduce bias
    int fairDiceRoll = r % range + 1;
    printf("fair dice roll: %d\n", fairDiceRoll);

    return 0;
}

For anything security-sensitive - tokens, passwords, cryptographic keys - rand() is never appropriate, seeded or not; it's not cryptographically secure. Real security-sensitive code uses a CSPRNG: platform-specific APIs like /dev/urandom (POSIX) or dedicated cryptographic libraries, never <stdlib.h>'s rand().


13. qsort and bsearch from <stdlib.h>

qsort (introduced in the functions guide as the canonical callback example) sorts any array given a comparator; bsearch performs a binary search on an already-sorted array using the same comparator convention - together they cover most of what you'd otherwise hand-write a sort/search algorithm for.

#include <stdio.h>
#include <stdlib.h>

int compareInts(const void *a, const void *b) {
    int intA = *(const int *)a;
    int intB = *(const int *)b;
    return intA - intB;
}

int main(void) {
    int numbers[] = {5, 2, 8, 1, 9, 3};
    int count = 6;

    qsort(numbers, count, sizeof(int), compareInts);

    printf("sorted: ");
    for (int i = 0; i < count; i++) printf("%d ", numbers[i]);
    printf("\n");

    int target = 8;
    // bsearch REQUIRES the array to already be sorted (as it now is) --
    // it returns a pointer to a matching element, or NULL if not found
    int *found = bsearch(&target, numbers, count, sizeof(int), compareInts);

    if (found != NULL) {
        printf("found %d at index %ld\n", *found, found - numbers);
    } else {
        printf("not found\n");
    }

    return 0;
}

Both functions take the same "comparator" convention (return negative, zero, or positive), which is why the same compareInts function works for both - a pattern worth reusing anywhere else you need consistent ordering logic in one place.


14. Useful utility macros: MIN/MAX, ARRAY_SIZE

A small set of utility macros shows up in an enormous fraction of real-world C codebases - worth knowing both for use and for recognizing them when reading other people's code.

#include <stdio.h>

#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
#define CLAMP(value, low, high) (MAX((low), MIN((value), (high))))

int main(void) {
    printf("%d\n", MAX(3, 7));          // 7
    printf("%d\n", MIN(3, 7));          // 3
    printf("%d\n", CLAMP(15, 0, 10));   // 10 -- clamped to the upper bound

    int numbers[] = {1, 2, 3, 4, 5};
    printf("%zu\n", ARRAY_SIZE(numbers));   // 5

    // Remember the macro pitfalls from the macros guide: these all
    // duplicate their arguments, so MAX(i++, j) would increment i TWICE.
    // Prefer 'static inline' functions over these where the language
    // allows it (they can't be as generic across types, but avoid the
    // duplication problem entirely) -- these macros are still extremely
    // common in real C because of their type-generic convenience.

    return 0;
}

ARRAY_SIZE only works correctly on a real, in-scope array - exactly like the raw sizeof(arr) / sizeof(arr[0]) idiom from the pointers guide, it silently computes the wrong thing if arr has already decayed to a pointer (e.g. inside a function that received it as a parameter).


15. Compiling multi-file projects, and a minimal Makefile

Real C projects split code across multiple .c/.h files (as covered in the functions guide, section 19) and are almost never compiled by typing the full gcc command by hand every time - a Makefile automates it.

/* main.c */
#include <stdio.h>
#include "math_utils.h"

int main(void) {
    printf("%d\n", add(2, 3));
    return 0;
}
/* math_utils.h */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
#endif
/* math_utils.c */
#include "math_utils.h"
int add(int a, int b) { return a + b; }
# Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c11

myprogram: main.o math_utils.o
    $(CC) $(CFLAGS) -o myprogram main.o math_utils.o

main.o: main.c math_utils.h
    $(CC) $(CFLAGS) -c main.c

math_utils.o: math_utils.c math_utils.h
    $(CC) $(CFLAGS) -c math_utils.c

clean:
    rm -f *.o myprogram

Running make builds only what's changed since the last build (based on file timestamps), and make clean removes generated files - this incremental-rebuild behavior is the whole reason Makefiles (or modern equivalents like CMake) exist instead of just re-running one big gcc command every time.


16. Compiler warnings: -Wall -Wextra as free bug-catching

Enabling warning flags costs nothing and catches a surprising fraction of the bugs covered across this entire guide series - an uninitialized variable, a signed/unsigned comparison, a format string mismatch - before the program ever runs.

#include <stdio.h>

int main(void) {
    int x;             // uninitialized
    printf("%d\n", x);  // -Wall would warn: "'x' is used uninitialized"

    int a = 5;
    unsigned int b = 3;
    if (a < b) { }       // -Wextra would warn: signed/unsigned comparison

    printf("%d\n", 3.14);  // -Wall would warn: format '%d' expects 'int', got 'double'

    return 0;
}

Compiling this with gcc -Wall -Wextra example.c surfaces all three issues before the program even runs - compare that to compiling with no flags at all, where every one of these compiles silently and becomes a runtime bug waiting to happen. Real-world C projects near-universally build with at least -Wall -Wextra enabled, and many add -Werror to turn every warning into a hard compile failure, forcing issues to be fixed before code can even be committed.


17. Debugging tools: a quick map of what's used for what

Beyond printf-debugging (inserting print statements to trace program state), real-world C development leans on a small set of standard tools - worth knowing what each one is actually for, even without covering their full usage here.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof(int) * 5);
    p[5] = 100;   // BUG: writes one element PAST the end of the allocated array
                    // (a classic off-by-one buffer overflow)

    free(p);
    // p[0] = 1;  // would ALSO be a bug: use-after-free

    return 0;
}

The bug above (p[5] = 100; on a 5-element array) is exactly the kind of thing that might not crash immediately, or ever, in casual testing - and exactly the kind of thing Valgrind or AddressSanitizer catches instantly and precisely, which is why real projects run these tools routinely rather than relying on bugs to announce themselves.


18. Common pitfalls checklist


Quick reference

TaskFunction(s) / ToolPurposeSection
Command-line argumentsargc, argv[]Access arguments passed at program launch1
Manual flag parsingstrcmp loop over argvSimple, no-dependency option parsing2
POSIX short optionsgetopt (<unistd.h>)Standard -v, -o value style parsing3
Long optionsgetopt_long (<getopt.h>)--verbose, --output=file style parsing4
Environment variablesgetenv, setenvRead/set configuration outside the command line5
Exit statusreturn/exit() value, EXIT_SUCCESS/EXIT_FAILUREReport success/failure to the calling process6
Cleanup on exitatexit(fn)Register a function to run automatically at normal exit7
Development-time checksassert(cond), NDEBUGCatch broken invariants early; compiled out in release builds8
Error diagnosiserrno, strerror(errno)Discover why a standard library call failed9
Graceful shutdownsignal(SIGINT, handler)Respond to Ctrl+C / termination requests safely10
Wall-clock timetime, localtime, strftimeTimestamps and human-readable dates11
CPU timingclock(), CLOCKS_PER_SECMeasure how much CPU time code consumed11
Pseudo-random numbersrand, srandNon-cryptographic randomness12
Sorting/searchingqsort, bsearchGeneric sort and binary search via a comparator13
Utility macrosMAX, MIN, ARRAY_SIZE, CLAMPCommon small, type-generic helper patterns14
Multi-file buildsMakefile, makeAutomated, incremental compilation15
Bug-catching at compile time-Wall -Wextra -WerrorFree, automatic detection of common bug classes16
Interactive debugginggdbStep through a running program, inspect state17
Memory bug detectionValgrind, AddressSanitizerFind leaks, overflows, use-after-free at runtime17
Static bug detectionclang --analyze, cppcheckFind potential bugs without running the program17

Coverage note

This guide rounds out the Realworld C series with the practical, OS-facing glue that real command-line programs depend on: proper argc/argv and environment handling, exit codes and cleanup, signals, timing, randomness, and the build/debugging tooling every real project is built and verified with. Combined with the other guides in this folder - macros, structs/unions, functions, pointers, file handling, and standard I/O - this collection covers the practical foundation for reading, writing, and shipping real-world C code.

Previous:
The Kid Who Discovered Machine Learning
Next:
File Handling in C