C — Best Practices
C is unforgiving. There is no garbage collector, no bounds checking, no null safety. Every decision you make — from naming conventions to memory management to error handling — either builds resilience into your code or introduces a future vulnerability. Best practices in C are not style preferences; they are survival strategies.
This guide distills decades of hard-won knowledge from systems programming, security engineering, and large-scale C projects into actionable rules and patterns.
Consistency is more important than any specific convention. Choose a style and enforce it across the entire project. The Linux kernel, Chromium, and SQLite all have documented coding standards — yours should too.
| 1 | /* NAMING CONVENTIONS */ |
| 2 | |
| 3 | /* Functions: snake_case, verb_noun pattern */ |
| 4 | int process_order(const Order *order); /* good */ |
| 5 | int Order_Process(const Order *order); /* also valid (Windows style) */ |
| 6 | int prc_ord(Order *o); /* bad — cryptic abbreviation */ |
| 7 | |
| 8 | /* Variables: snake_case, descriptive names */ |
| 9 | int total_item_count; /* good */ |
| 10 | int tic; /* bad — too short */ |
| 11 | int theTotalNumberOfItems; /* bad — camelCase in C is non-standard */ |
| 12 | |
| 13 | /* Constants and macros: UPPER_CASE_SNAKE */ |
| 14 | #define MAX_BUFFER_SIZE 4096 |
| 15 | #define PI 3.14159265358979 |
| 16 | static const int default_timeout_sec = 30; /* const vars: snake_case */ |
| 17 | |
| 18 | /* Types: PascalCase or snake_case_t suffix (pick one) */ |
| 19 | typedef struct { |
| 20 | int x; |
| 21 | int y; |
| 22 | } Point; /* PascalCase — common in Linux kernel */ |
| 23 | |
| 24 | typedef struct { |
| 25 | double real; |
| 26 | double imag; |
| 27 | } complex_t; /* snake_case_t — also common */ |
| 28 | |
| 29 | /* Enums: PascalCase values */ |
| 30 | typedef enum { |
| 31 | COLOR_RED, |
| 32 | COLOR_GREEN, |
| 33 | COLOR_BLUE |
| 34 | } Color; |
Header file organization — the public interface of your module:
| 1 | /* ============================================ |
| 2 | * module_name.h — Public API for module_name |
| 3 | * ============================================ */ |
| 4 | #ifndef MODULE_NAME_H |
| 5 | #define MODULE_NAME_H |
| 6 | |
| 7 | /* 1. System includes first */ |
| 8 | #include <stddef.h> |
| 9 | #include <stdint.h> |
| 10 | |
| 11 | /* 2. Project-wide types and macros */ |
| 12 | #include "config.h" |
| 13 | |
| 14 | /* 3. Public macros */ |
| 15 | #define MODULE_VERSION "1.2.0" |
| 16 | #define MAX_ITEMS_PER_PAGE 50 |
| 17 | |
| 18 | /* 4. Type definitions */ |
| 19 | typedef struct module_context ModuleContext; |
| 20 | typedef struct item { |
| 21 | uint32_t id; |
| 22 | char name[128]; |
| 23 | double price; |
| 24 | } Item; |
| 25 | |
| 26 | /* 5. Function declarations */ |
| 27 | ModuleContext *module_create(const char *config_path); |
| 28 | void module_destroy(ModuleContext *ctx); |
| 29 | |
| 30 | int module_add_item(ModuleContext *ctx, const Item *item); |
| 31 | int module_get_item(const ModuleContext *ctx, uint32_t id, Item *out); |
| 32 | int module_remove_item(ModuleContext *ctx, uint32_t id); |
| 33 | |
| 34 | size_t module_count(const ModuleContext *ctx); |
| 35 | |
| 36 | #endif /* MODULE_NAME_H */ |
info
| Element | Convention | Example |
|---|---|---|
| Functions | snake_case, verb_noun | process_order() |
| Local variables | snake_case | item_count |
| Macros / constants | UPPER_SNAKE_CASE | MAX_BUFFER_SIZE |
| Types | PascalCase or _t suffix | OrderContext |
| Enums | PascalCase type, UPPER values | STATUS_OK |
| Files | snake_case.c / .h | order_handler.c |
Security in C is not an afterthought — it must be designed in from the start. The C standard library contains dozens of functions that are inherently unsafe. Replacing them with secure alternatives is non-negotiable.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* ========================================= |
| 6 | * NEVER USE THESE (unsafe functions) |
| 7 | * ========================================= */ |
| 8 | |
| 9 | /* gets() — reads unlimited input, guaranteed buffer overflow */ |
| 10 | /* char buf[64]; gets(buf); // DANGEROUS: banned in C11 */ |
| 11 | |
| 12 | /* sprintf() — no bounds checking */ |
| 13 | /* char buf[64]; sprintf(buf, "%s: %d", user_input, val); // DANGEROUS */ |
| 14 | |
| 15 | /* strcpy() — no bounds checking */ |
| 16 | /* char dst[32]; strcpy(dst, user_input); // DANGEROUS */ |
| 17 | |
| 18 | /* strcat() — no bounds checking */ |
| 19 | /* char buf[64]; strcat(buf, more_data); // DANGEROUS */ |
| 20 | |
| 21 | /* scanf("%s") — no length limit */ |
| 22 | /* char buf[32]; scanf("%s", buf); // DANGEROUS */ |
| 23 | |
| 24 | /* ========================================= |
| 25 | * ALWAYS USE THESE (safe alternatives) |
| 26 | * ========================================= */ |
| 27 | |
| 28 | /* fgets() instead of gets() */ |
| 29 | int safe_read_line(void) { |
| 30 | char buf[64]; |
| 31 | if (fgets(buf, sizeof(buf), stdin) == NULL) { |
| 32 | return -1; |
| 33 | } |
| 34 | /* fgets keeps the newline — strip it */ |
| 35 | buf[strcspn(buf, "\n")] = '\0'; |
| 36 | return 0; |
| 37 | } |
| 38 | |
| 39 | /* snprintf() instead of sprintf() */ |
| 40 | int safe_format(char *dst, size_t dst_size, const char *name, int val) { |
| 41 | int written = snprintf(dst, dst_size, "%s: %d", name, val); |
| 42 | if (written < 0) return -1; /* encoding error */ |
| 43 | if ((size_t)written >= dst_size) { |
| 44 | /* truncation occurred — handle it */ |
| 45 | return -1; |
| 46 | } |
| 47 | return 0; |
| 48 | } |
| 49 | |
| 50 | /* strncpy() with explicit null termination */ |
| 51 | int safe_str_copy(char *dst, size_t dst_size, const char *src) { |
| 52 | if (dst_size == 0) return -1; |
| 53 | strncpy(dst, src, dst_size - 1); |
| 54 | dst[dst_size - 1] = '\0'; /* always null-terminate */ |
| 55 | return 0; |
| 56 | } |
| 57 | |
| 58 | /* scanf with width specifier */ |
| 59 | int safe_scan_int(int *out) { |
| 60 | if (scanf("%31d", out) != 1) { |
| 61 | return -1; |
| 62 | } |
| 63 | return 0; |
| 64 | } |
Compile-time security hardening flags:
| 1 | # Stack protector — detects stack buffer overflows |
| 2 | gcc -fstack-protector-strong -o program main.c |
| 3 | |
| 4 | # FORTIFY_SOURCE — runtime checks for buffer overflows in libc calls |
| 5 | # Must use at least -O1 (optimization required) |
| 6 | gcc -O2 -D_FORTIFY_SOURCE=2 -o program main.c |
| 7 | |
| 8 | # Position-independent executable (required for ASLR) |
| 9 | gcc -fPIE -pie -o program main.c |
| 10 | |
| 11 | # Full hardened build (Linux) |
| 12 | gcc -Wall -Wextra -Werror \ |
| 13 | -fstack-protector-strong \ |
| 14 | -D_FORTIFY_SOURCE=2 \ |
| 15 | -fPIE -pie \ |
| 16 | -o program main.c |
| 17 | |
| 18 | # Disable common security issues |
| 19 | gcc -fno-strict-aliasing \ |
| 20 | -fno-common \ |
| 21 | -Wformat -Wformat-security \ |
| 22 | -o program main.c |
Input validation — trust nothing, validate everything:
| 1 | #include <errno.h> |
| 2 | #include <limits.h> |
| 3 | #include <stdlib.h> |
| 4 | |
| 5 | /* Validate and parse an integer from a string with range checking */ |
| 6 | int parse_port(const char *str, int *out_port) { |
| 7 | if (!str || !out_port) return -1; |
| 8 | |
| 9 | char *endptr; |
| 10 | errno = 0; |
| 11 | long val = strtol(str, &endptr, 10); |
| 12 | |
| 13 | /* Check for parsing errors */ |
| 14 | if (errno != 0 || endptr == str || *endptr != '\0') { |
| 15 | return -1; /* invalid input */ |
| 16 | } |
| 17 | |
| 18 | /* Check range */ |
| 19 | if (val < 1 || val > 65535) { |
| 20 | return -1; /* out of valid port range */ |
| 21 | } |
| 22 | |
| 23 | *out_port = (int)val; |
| 24 | return 0; |
| 25 | } |
| 26 | |
| 27 | /* Validate file path (basic sanitization) */ |
| 28 | int validate_path(const char *path) { |
| 29 | if (!path) return -1; |
| 30 | |
| 31 | /* Reject paths with directory traversal */ |
| 32 | if (strstr(path, "..") != NULL) return -1; |
| 33 | |
| 34 | /* Reject absolute paths (if serving relative only) */ |
| 35 | if (path[0] == '/') return -1; |
| 36 | |
| 37 | /* Reject paths with shell metacharacters */ |
| 38 | const char *dangerous = "|;&$`"; |
| 39 | for (const char *p = dangerous; *p; p++) { |
| 40 | if (strchr(path, *p) != NULL) return -1; |
| 41 | } |
| 42 | |
| 43 | /* Check max length */ |
| 44 | if (strlen(path) > PATH_MAX) return -1; |
| 45 | |
| 46 | return 0; |
| 47 | } |
warning
Every malloc() must have a corresponding free(). This sounds trivial but is the single largest source of bugs in C codebases. Discipline and patterns are your defense.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* RULE 1: Always initialize pointers to NULL */ |
| 6 | char *buffer = NULL; |
| 7 | |
| 8 | /* RULE 2: Check malloc return value */ |
| 9 | int init_buffer(size_t size) { |
| 10 | buffer = malloc(size); |
| 11 | if (buffer == NULL) { |
| 12 | fprintf(stderr, "malloc(%zu) failed\n", size); |
| 13 | return -1; |
| 14 | } |
| 15 | /* RULE 3: Zero-initialize sensitive allocations */ |
| 16 | memset(buffer, 0, size); |
| 17 | return 0; |
| 18 | } |
| 19 | |
| 20 | /* RULE 4: Free in reverse order of allocation */ |
| 21 | int complex_operation(void) { |
| 22 | char *a = malloc(100); |
| 23 | char *b = malloc(200); |
| 24 | char *c = malloc(300); |
| 25 | |
| 26 | if (!a || !b || !c) { |
| 27 | /* Reverse order cleanup on partial failure */ |
| 28 | free(c); |
| 29 | free(b); |
| 30 | free(a); |
| 31 | return -1; |
| 32 | } |
| 33 | |
| 34 | /* ... use a, b, c ... */ |
| 35 | |
| 36 | free(c); /* last allocated, first freed */ |
| 37 | free(b); |
| 38 | free(a); |
| 39 | return 0; |
| 40 | } |
| 41 | |
| 42 | /* RULE 5: Set pointer to NULL after freeing */ |
| 43 | void cleanup(void) { |
| 44 | free(buffer); |
| 45 | buffer = NULL; /* prevents use-after-free and double-free */ |
| 46 | } |
| 47 | |
| 48 | /* RULE 6: Use a cleanup pattern for complex functions */ |
| 49 | int process_data(const char *input) { |
| 50 | char *temp1 = NULL; |
| 51 | char *temp2 = NULL; |
| 52 | FILE *fp = NULL; |
| 53 | int result = -1; |
| 54 | |
| 55 | temp1 = malloc(1024); |
| 56 | if (!temp1) goto cleanup; |
| 57 | |
| 58 | temp2 = malloc(2048); |
| 59 | if (!temp2) goto cleanup; |
| 60 | |
| 61 | fp = fopen("output.txt", "w"); |
| 62 | if (!fp) goto cleanup; |
| 63 | |
| 64 | /* ... processing ... */ |
| 65 | result = 0; |
| 66 | |
| 67 | cleanup: |
| 68 | if (fp) fclose(fp); |
| 69 | free(temp2); /* free(NULL) is safe — it's a no-op */ |
| 70 | free(temp1); |
| 71 | return result; |
| 72 | } |
For many small allocations of the same size, a memory pool eliminates per-allocation overhead and makes cleanup trivial:
| 1 | #include <stdlib.h> |
| 2 | #include <string.h> |
| 3 | |
| 4 | /* Simple memory pool for fixed-size blocks */ |
| 5 | typedef struct pool { |
| 6 | char *memory; |
| 7 | size_t block_size; |
| 8 | size_t capacity; |
| 9 | size_t used; |
| 10 | } Pool; |
| 11 | |
| 12 | Pool *pool_create(size_t block_size, size_t count) { |
| 13 | Pool *pool = malloc(sizeof(Pool)); |
| 14 | if (!pool) return NULL; |
| 15 | |
| 16 | pool->memory = calloc(count, block_size); |
| 17 | if (!pool->memory) { |
| 18 | free(pool); |
| 19 | return NULL; |
| 20 | } |
| 21 | |
| 22 | pool->block_size = block_size; |
| 23 | pool->capacity = count; |
| 24 | pool->used = 0; |
| 25 | return pool; |
| 26 | } |
| 27 | |
| 28 | void *pool_alloc(Pool *pool) { |
| 29 | if (pool->used >= pool->capacity) return NULL; |
| 30 | void *ptr = pool->memory + (pool->used * pool->block_size); |
| 31 | pool->used++; |
| 32 | return ptr; |
| 33 | } |
| 34 | |
| 35 | void pool_destroy(Pool *pool) { |
| 36 | if (!pool) return; |
| 37 | free(pool->memory); /* single free for entire pool */ |
| 38 | free(pool); |
| 39 | } |
C has no exceptions. Errors are communicated through return values, and ignoring them is the fastest path to undefined behavior. Every function that can fail must signal failure, and every caller must check.
| 1 | #include <errno.h> |
| 2 | #include <stdio.h> |
| 3 | #include <stdlib.h> |
| 4 | #include <string.h> |
| 5 | |
| 6 | /* Return code convention: 0 = success, negative = error */ |
| 7 | typedef enum { |
| 8 | ERR_OK = 0, |
| 9 | ERR_NULL_PTR = -1, |
| 10 | ERR_OUT_OF_MEM = -2, |
| 11 | ERR_IO = -3, |
| 12 | ERR_INVALID = -4, |
| 13 | ERR_NOT_FOUND = -5 |
| 14 | } ErrorCode; |
| 15 | |
| 16 | const char *error_string(ErrorCode err) { |
| 17 | switch (err) { |
| 18 | case ERR_OK: return "success"; |
| 19 | case ERR_NULL_PTR: return "null pointer"; |
| 20 | case ERR_OUT_OF_MEM: return "out of memory"; |
| 21 | case ERR_IO: return "I/O error"; |
| 22 | case ERR_INVALID: return "invalid argument"; |
| 23 | case ERR_NOT_FOUND: return "not found"; |
| 24 | default: return "unknown error"; |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | /* Always check return values — never ignore errors */ |
| 29 | ErrorCode read_file(const char *path, char **out_buf, size_t *out_len) { |
| 30 | FILE *fp = fopen(path, "rb"); |
| 31 | if (!fp) { |
| 32 | fprintf(stderr, "ERROR [%s:%d]: fopen failed: %s\n", |
| 33 | __FILE__, __LINE__, strerror(errno)); |
| 34 | return ERR_IO; |
| 35 | } |
| 36 | |
| 37 | fseek(fp, 0, SEEK_END); |
| 38 | long file_size = ftell(fp); |
| 39 | fseek(fp, 0, SEEK_SET); |
| 40 | |
| 41 | if (file_size < 0) { |
| 42 | fclose(fp); |
| 43 | return ERR_IO; |
| 44 | } |
| 45 | |
| 46 | char *buf = malloc((size_t)file_size + 1); |
| 47 | if (!buf) { |
| 48 | fclose(fp); |
| 49 | return ERR_OUT_OF_MEM; |
| 50 | } |
| 51 | |
| 52 | size_t nread = fread(buf, 1, (size_t)file_size, fp); |
| 53 | fclose(fp); |
| 54 | |
| 55 | if ((long)nread != file_size) { |
| 56 | free(buf); |
| 57 | return ERR_IO; |
| 58 | } |
| 59 | |
| 60 | buf[nread] = '\0'; |
| 61 | *out_buf = buf; |
| 62 | *out_len = nread; |
| 63 | return ERR_OK; |
| 64 | } |
The goto cleanup pattern is the standard way to handle multiple cleanup requirements in C. Despite its reputation, goto is the correct tool for this:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* The goto cleanup pattern — recommended by Linux kernel style */ |
| 6 | int handle_request(const char *user, const char *action, const char *data) { |
| 7 | char *processed = NULL; |
| 8 | char *response = NULL; |
| 9 | FILE *log = NULL; |
| 10 | int status = -1; |
| 11 | |
| 12 | if (!user || !action || !data) { |
| 13 | status = -1; |
| 14 | goto done; |
| 15 | } |
| 16 | |
| 17 | processed = malloc(strlen(data) + 1); |
| 18 | if (!processed) { |
| 19 | fprintf(stderr, "malloc failed\n"); |
| 20 | goto done; |
| 21 | } |
| 22 | strcpy(processed, data); |
| 23 | |
| 24 | /* Simulate some processing */ |
| 25 | if (strcmp(action, "create") == 0) { |
| 26 | if (create_resource(processed) != 0) { |
| 27 | goto done; |
| 28 | } |
| 29 | } else if (strcmp(action, "delete") == 0) { |
| 30 | if (delete_resource(processed) != 0) { |
| 31 | goto done; |
| 32 | } |
| 33 | } else { |
| 34 | fprintf(stderr, "unknown action: %s\n", action); |
| 35 | goto done; |
| 36 | } |
| 37 | |
| 38 | log = fopen("audit.log", "a"); |
| 39 | if (log) { |
| 40 | fprintf(log, "user=%s action=%s\n", user, action); |
| 41 | fclose(log); |
| 42 | log = NULL; |
| 43 | } |
| 44 | |
| 45 | status = 0; /* success */ |
| 46 | |
| 47 | done: |
| 48 | /* Cleanup always runs — regardless of success or failure */ |
| 49 | if (log) fclose(log); |
| 50 | free(response); |
| 51 | free(processed); |
| 52 | return status; |
| 53 | } |
best practice
Premature optimization is the root of all evil — but profiling and applying targeted optimizations is engineering. Always measure before optimizing.
| 1 | #include <stddef.h> |
| 2 | |
| 3 | /* Compiler optimization flags (in Makefile): |
| 4 | * gcc -O2 -o program main.c (safe, recommended) |
| 5 | * gcc -O3 -o program main.c (aggressive, may increase binary size) |
| 6 | * gcc -Os -o program main.c (size-optimized, good for embedded) |
| 7 | * gcc -march=native -O2 -o program main.c (optimize for your CPU) |
| 8 | */ |
| 9 | |
| 10 | /* BRANCH PREDICTION HINTS — use __builtin_expect for hot paths */ |
| 11 | #define LIKELY(x) __builtin_expect(!!(x), 1) |
| 12 | #define UNLIKELY(x) __builtin_expect(!!(x), 0) |
| 13 | |
| 14 | int fast_path_example(int *data, size_t len) { |
| 15 | int sum = 0; |
| 16 | for (size_t i = 0; i < len; i++) { |
| 17 | /* Errors are rare — tell the compiler to optimize for the common case */ |
| 18 | if (LIKELY(data[i] > 0)) { |
| 19 | sum += data[i]; |
| 20 | } |
| 21 | } |
| 22 | return sum; |
| 23 | } |
| 24 | |
| 25 | /* RESTRICT POINTERS — promise the compiler no aliasing */ |
| 26 | void add_arrays(int *restrict dst, const int *restrict a, |
| 27 | const int *restrict b, size_t n) { |
| 28 | /* Compiler can vectorize this more aggressively because it knows |
| 29 | dst, a, and b do not overlap */ |
| 30 | for (size_t i = 0; i < n; i++) { |
| 31 | dst[i] = a[i] + b[i]; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /* CACHE-FRIENDLY ACCESS — iterate in memory order */ |
| 36 | /* BAD: column-major iteration of a row-major array */ |
| 37 | void sum_columns_bad(int *matrix, int rows, int cols) { |
| 38 | int sum = 0; |
| 39 | for (int c = 0; c < cols; c++) { |
| 40 | for (int r = 0; r < rows; r++) { |
| 41 | sum += matrix[r * cols + c]; /* jumps by cols elements each time */ |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /* GOOD: process one row at a time, accumulate in a separate array */ |
| 47 | void sum_columns_good(int *matrix, int rows, int cols, int *col_sums) { |
| 48 | memset(col_sums, 0, cols * sizeof(int)); |
| 49 | for (int r = 0; r < rows; r++) { |
| 50 | for (int c = 0; c < cols; c++) { |
| 51 | col_sums[c] += matrix[r * cols + c]; /* sequential access */ |
| 52 | } |
| 53 | } |
| 54 | } |
pro tip
| Optimization | Technique | Impact |
|---|---|---|
| Compiler flags | -O2 -march=native | 2-5x speedup over -O0 |
| Branch prediction | __builtin_expect | 10-20% on branch-heavy code |
| Restrict pointers | restrict keyword | Enables auto-vectorization |
| Cache access | Sequential memory access | 10-100x vs random access |
| Allocation | Avoid allocations in hot loops | malloc/free are expensive |
These are the subtle pitfalls that even experienced C developers fall into. Each one leads to bugs that can be extremely difficult to diagnose:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* MISTAKE 1: Array-pointer confusion */ |
| 6 | void array_decay_example(void) { |
| 7 | int arr[10]; |
| 8 | int *ptr = arr; /* arrays decay to pointers — sizeof differences */ |
| 9 | |
| 10 | printf("sizeof(arr) = %zu\n", sizeof(arr)); /* 40 (10 * 4) */ |
| 11 | printf("sizeof(ptr) = %zu\n", sizeof(ptr)); /* 8 (pointer size) */ |
| 12 | } |
| 13 | |
| 14 | /* MISTAKE 2: Signed vs unsigned comparison */ |
| 15 | void signed_unsigned_trap(void) { |
| 16 | size_t len = 5; /* unsigned */ |
| 17 | int i = -1; /* signed */ |
| 18 | |
| 19 | /* BUG: -1 is implicitly converted to a huge unsigned value */ |
| 20 | if (i < len) { |
| 21 | printf("This never prints!\n"); |
| 22 | } |
| 23 | |
| 24 | /* CORRECT: both operands should be the same signedness */ |
| 25 | if ((int)len >= 0 && i < (int)len) { |
| 26 | printf("This prints correctly\n"); |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | /* MISTAKE 3: sizeof on a pointer vs pointed-to type */ |
| 31 | void sizeof_misconception(int *arr, size_t n) { |
| 32 | /* sizeof(arr) is sizeof(int*), NOT sizeof the array */ |
| 33 | size_t total = n * sizeof(arr[0]); /* correct: use element size */ |
| 34 | int *copy = malloc(total); |
| 35 | if (copy) { |
| 36 | memcpy(copy, arr, total); |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /* MISTAKE 4: Struct padding in binary I/O */ |
| 41 | typedef struct { |
| 42 | char a; /* 1 byte + 3 bytes padding */ |
| 43 | int b; /* 4 bytes */ |
| 44 | char c; /* 1 byte + 3 bytes padding */ |
| 45 | } PackedTrick; /* sizeof = 12, NOT 6! */ |
| 46 | |
| 47 | void binary_io_mistake(void) { |
| 48 | PackedTrick val = {'x', 42, 'y'}; |
| 49 | /* fwrite(&val, sizeof(val), 1, fp) writes 12 bytes, not 6 */ |
| 50 | /* If you need exact layout, use __attribute__((packed)) or #pragma pack */ |
| 51 | } |
| 52 | |
| 53 | /* MISTAKE 5: Modifying string literals */ |
| 54 | void string_literal_trap(void) { |
| 55 | char *str = "hello"; /* str points to read-only memory */ |
| 56 | /* str[0] = 'H'; /* UNDEFINED BEHAVIOR: segfault */ |
| 57 | char mutable[] = "hello"; /* CORRECT: array copies to writable memory */ |
| 58 | mutable[0] = 'H'; /* This is fine */ |
| 59 | } |
| 60 | |
| 61 | /* MISTAKE 6: Implicit int assumptions */ |
| 62 | void int_size_mistake(void) { |
| 63 | /* int is NOT guaranteed to be 32 bits! */ |
| 64 | /* Use fixed-width types when size matters */ |
| 65 | int32_t guaranteed_32 = 1000000; |
| 66 | int64_t guaranteed_64 = 1000000000000LL; |
| 67 | |
| 68 | /* For sizes, use size_t. For file offsets, use off_t or int64_t */ |
| 69 | } |
| 70 | |
| 71 | /* MISTAKE 7: Missing break in switch */ |
| 72 | void switch_fallthrough_trap(int value) { |
| 73 | switch (value) { |
| 74 | case 1: |
| 75 | printf("one\n"); |
| 76 | /* missing break — falls through to case 2! */ |
| 77 | case 2: |
| 78 | printf("two\n"); |
| 79 | break; |
| 80 | default: |
| 81 | break; |
| 82 | } |
| 83 | } |
warning
A well-organized C project scales from a single-file prototype to a large multi-module codebase without requiring a rewrite. These conventions work for projects of any size.
| 1 | project/ |
| 2 | ├── include/ # Public headers (.h files) |
| 3 | │ └── project/ |
| 4 | │ ├── module_a.h |
| 5 | │ ├── module_b.h |
| 6 | │ └── project.h # Umbrella header |
| 7 | ├── src/ # Implementation files (.c files) |
| 8 | │ ├── module_a.c |
| 9 | │ ├── module_b.c |
| 10 | │ └── main.c |
| 11 | ├── tests/ # Unit and integration tests |
| 12 | │ ├── test_module_a.c |
| 13 | │ ├── test_module_b.c |
| 14 | │ └── test_main.c |
| 15 | ├── lib/ # Third-party libraries |
| 16 | │ └── some_lib/ |
| 17 | ├── docs/ # Documentation |
| 18 | │ └── api.md |
| 19 | ├── build/ # Build output (git-ignored) |
| 20 | │ ├── debug/ |
| 21 | │ └── release/ |
| 22 | ├── CMakeLists.txt # Build system (or Makefile) |
| 23 | ├── .gitignore |
| 24 | ├── README.md |
| 25 | └── LICENSE |
A minimal but complete CMakeLists.txt for a C project:
| 1 | cmake_minimum_required(VERSION 3.14) |
| 2 | project(myapp VERSION 1.0.0 LANGUAGES C) |
| 3 | |
| 4 | set(CMAKE_C_STANDARD 11) |
| 5 | set(CMAKE_C_STANDARD_REQUIRED ON) |
| 6 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) |
| 7 | |
| 8 | # Debug build: cmake -DCMAKE_BUILD_TYPE=Debug .. |
| 9 | # Release build: cmake -DCMAKE_BUILD_TYPE=Release .. |
| 10 | |
| 11 | add_compile_options(-Wall -Wextra -pedantic -Werror) |
| 12 | |
| 13 | # Library |
| 14 | add_library(mylib STATIC src/module_a.c src/module_b.c) |
| 15 | target_include_directories(mylib PUBLIC include) |
| 16 | |
| 17 | # Executable |
| 18 | add_executable(myapp src/main.c) |
| 19 | target_link_libraries(myapp PRIVATE mylib) |
| 20 | |
| 21 | # Tests |
| 22 | enable_testing() |
| 23 | add_executable(test_all tests/test_module_a.c tests/test_module_b.c) |
| 24 | target_link_libraries(test_all PRIVATE mylib) |
| 25 | add_test(NAME unit_tests COMMAND test_all) |
Essential .gitignore for C projects:
| 1 | # Build outputs |
| 2 | *.o |
| 3 | *.a |
| 4 | *.so |
| 5 | *.dylib |
| 6 | *.exe |
| 7 | build/ |
| 8 | out/ |
| 9 | |
| 10 | # Debug |
| 11 | core |
| 12 | core.* |
| 13 | *.dSYM/ |
| 14 | vgcore.* |
| 15 | |
| 16 | # IDE |
| 17 | .vscode/ |
| 18 | .idea/ |
| 19 | *.swp |
| 20 | *.swo |
| 21 | *~ |
| 22 | |
| 23 | # Coverage |
| 24 | *.gcda |
| 25 | *.gcno |
| 26 | *.gcov |
| 27 | coverage/ |
The right toolchain makes C development dramatically safer and more productive. Here is the essential stack:
| Category | Tool | Notes |
|---|---|---|
| Compilers | GCC, Clang, MSVC | Clang has best diagnostics; GCC most portable |
| Static Analyzers | cppcheck, clang-tidy | Run on every commit via CI |
| Dynamic Analyzers | Valgrind, ASan, UBSan | ASan for dev, Valgrind for deep analysis |
| Build Systems | CMake, Meson, Make | CMake dominates; Meson is modern alternative |
| Editors | VS Code + clangd | clangd gives IDE-quality completions in C |
| Formatters | clang-format | Enforce consistent style automatically |
| Documentation | Doxygen | Standard for C API documentation |
A practical development workflow that catches bugs at every stage:
| 1 | # 1. Format code automatically |
| 2 | clang-format -i src/*.c include/*.h |
| 3 | |
| 4 | # 2. Static analysis — catch bugs without running code |
| 5 | cppcheck --enable=all --error-exitcode=1 src/ |
| 6 | |
| 7 | # 3. Build with all warnings and sanitizers (debug) |
| 8 | cmake -DCMAKE_BUILD_TYPE=Debug \ |
| 9 | -DCMAKE_C_FLAGS="-fsanitize=address,undefined" .. |
| 10 | make |
| 11 | |
| 12 | # 4. Run tests |
| 13 | ctest --output-on-failure |
| 14 | |
| 15 | # 5. Run Valgrind on test suite |
| 16 | valgrind --leak-check=full --error-exitcode=1 ./test_all |
| 17 | |
| 18 | # 6. Build release binary |
| 19 | cmake -DCMAKE_BUILD_TYPE=Release .. |
| 20 | make |
best practice
These are the non-negotiable rules for production C code. Print this, tape it to your monitor, make it a team charter:
| # | Rule | Why |
|---|---|---|
| 1 | Always compile with -Wall -Wextra -Werror | Warnings are bugs in disguise |
| 2 | Never use gets(), sprintf(), strcpy() | Buffer overflow sources |
| 3 | Always check malloc() return value | NULL dereference on OOM |
| 4 | Free memory in reverse allocation order | Simplifies cleanup logic |
| 5 | Set pointers to NULL after freeing | Prevents use-after-free |
| 6 | Use snprintf() not sprintf() | Bounds checking on output |
| 7 | Validate all external input | Trust nothing, sanitize everything |
| 8 | Use goto cleanup for multi-resource functions | Clean, correct resource management |
| 9 | Run ASan + UBSan during development | Catch memory and UB bugs instantly |
| 10 | Write tests for every module | Regression prevention |