Skip to content

C Programming

170 companion flashcards · AI-assisted study content · Open the deck →

This deck focuses on some of the most important intermediate concepts in C programming, with a strong emphasis on pointers and dynamic memory management. You'll work through questions about dereferencing pointers, the difference between malloc() and calloc(), and why free() is essential to prevent memory leaks. It also covers user-defined types like structs, unions, and enums, and finishes with the basics of file handling — opening, reading from, and writing to files.

It's a great fit if you already understand C syntax and basic data types but want to firm up the topics that often trip people up, especially around manual memory control. These concepts come up frequently in coursework, technical interviews, and real-world systems programming, so getting comfortable with them pays off across many areas.

Because the cards are deeply interconnected — for example, understanding pointers makes malloc() and dangling pointers much easier to grasp — try to study them in short, spaced-out sessions rather than cramming. After each review, take a moment to think about how one concept leads to the next, and try writing a tiny C program that uses what you just reviewed. Combining recall practice with hands-on coding tends to make these tricky ideas stick much faster than reading alone.

Pointers and Dynamic Memory

Pointers are central to C, serving as variables that store the memory address of another value. They are declared with the star operator, as in int *ptr, and the address-of operator & produces a variable's address to assign to a pointer. Dereferencing with * accesses the value at the address a pointer holds. A NULL pointer is one that points to no valid memory location, and it is essential to check for NULL before dereferencing to avoid segmentation faults. Beyond ordinary pointers, C supports void pointers, which are generic and can point to any data type but require a cast before dereferencing, and double pointers, which store the address of another pointer and are useful for modifying a pointer inside a function or for arrays of strings like char **argv.

Pointer arithmetic automatically scales by the size of the pointed-to type. Incrementing an int * advances by sizeof(int) bytes rather than by one, which is why expressions like *(p + i) work correctly for any array regardless of element size. Subtracting two pointers yields the number of elements between them, not the raw byte difference. The const keyword has different effects depending on placement: const int *p means the value cannot be changed through p, int *const p means p itself cannot be reassigned, and const int *const p locks down both. The sizeof operator returns the size in bytes of a type or variable at compile time, and the idiom sizeof(arr) / sizeof(arr[0]) recovers an array's length; for pointers, however, sizeof returns the pointer size rather than the size of the pointed-to data.

C manages memory in two main regions: the stack, which automatically holds local variables and is fast but limited, and the heap, which is larger but requires manual management. malloc(size) allocates uninitialized memory on the heap and returns NULL on failure, while calloc(count, size) zero-initializes all bytes and additionally checks for overflow in count * size, making it safer than computing that product yourself. realloc(ptr, new_size) resizes an existing allocation and may relocate it, so its result should always be assigned to a temporary pointer to avoid losing the original block on failure. Every dynamically allocated block must be released with free; forgetting causes a memory leak, and after freeing you should set the pointer to NULL to prevent a dangling pointer from causing undefined behavior on later use. Tools like Valgrind help detect leaks during development.

Data Types, Variables, and User-Defined Aggregates

C's basic data types are intentionally minimal: char (at least 8 bits), int (the natural integer size for the platform), float, double, and long double for floating-point values, plus _Bool for booleans. Modifiers like signed, unsigned, short, long, and long long adjust width and signedness. Because int is platform-dependent, <stdint.h> provides fixed-width types such as int32_t and uint64_t, while <inttypes.h> adds portable printf and scanf format macros like PRId32. The <stddef.h> header defines size_t (the unsigned type for sizes and counts) and ptrdiff_t (the signed type for pointer differences). Integer promotion converts small types like char and short to int or unsigned int before most operations, which is why sizeof('A') typically returns sizeof(int) rather than 1. The usual arithmetic conversions then resolve mixed-type expressions by promoting to the larger or higher-ranked type. For floating-point, float is typically IEEE-754 single precision, double is double precision and is the default for floating literals, and long double provides extended precision.

Programmers build richer types by combining the basic ones. A struct groups related variables of different types under one name, and members are accessed with the dot operator for values or the arrow operator for pointers, since p->field is equivalent to (*p).field. Dynamically allocated structs use malloc with sizeof and the arrow operator, and the original must be freed when done. A union is similar to a struct but all members share the same memory, so its size equals its largest member and only one member holds a meaningful value at a time. An enum defines named integer constants that start at 0 by default but can be assigned custom values. The typedef keyword creates an alias for an existing type, improving readability so you can write Point p; instead of struct Point p;.

C99 introduced designated initializers for setting specific struct or array members by name and compound literals like (struct Point){.x = 1, .y = 2} for unnamed objects. C99 also allows flexible array members, where a struct's final element is an unsized array that shares its allocation with the header. Bit-fields let you specify the exact number of bits a struct member occupies, which is useful for compact storage or matching hardware registers, though you cannot take their address. Each type has an alignment requirement, and compilers insert padding bytes so members start at properly aligned addresses; _Alignas overrides this to enforce stronger alignment, while #pragma pack controls padding globally. Endianness determines whether multibyte values are stored least-significant byte first (little-endian, common on x86 and ARM) or most-significant first (big-endian, used in network byte order). The volatile qualifier tells the compiler that a variable's value may change unexpectedly, such as from hardware or another thread, preventing the compiler from optimizing away reads, and the register keyword is a now-largely-ignored hint to keep a variable in a CPU register.

Arrays, Strings, and Memory Operations

Arrays in C hold a fixed number of elements of the same type, declared with a compile-time size like int arr[5]. Indexing starts at 0, and unlike some languages C performs no automatic bounds checking, so out-of-bounds access is undefined behavior. An array name acts as a pointer to its first element, so *(p + i) and arr[i] are equivalent and pointer arithmetic naturally scales by element size. However, the array name itself is not a modifiable lvalue and cannot be reassigned. Strings in C are arrays of characters terminated by a null character '\0', and the <string.h> library provides functions for working with them; for example, char name[] = "Hello"; reserves six bytes for five characters plus the terminator.

Several string functions require careful use. strlen returns the number of characters before the null terminator and does not count '\0' itself; if the string is not properly terminated, strlen reads past the buffer and triggers undefined behavior. strcpy copies a string with no bounds checking and risks buffer overflow, while strncpy copies at most n characters but may not null-terminate when the source is longer than n, requiring manual termination. For formatted output, snprintf writes at most n-1 characters and always null-terminates, making it far safer than sprintf, which has no length limit; its return value indicates the length that would have been written. Comparison and search helpers include strcmp and strncmp (for prefix checks like http://), strchr and strrchr (for first and last occurrence of a character), strstr for substring search, and strtok for splitting on delimiters, though strtok modifies its input and is not thread-safe, so strtok_r is preferred for reentrancy. For input, fgets(buf, n, stdin) is always preferable to gets, which was removed in C11 because it had no size limit.

For raw memory rather than null-terminated strings, memcpy copies exactly n bytes but requires non-overlapping regions, memmove handles overlapping regions safely through a temporary buffer, and memset fills a buffer with a byte value, which is the standard idiom for zeroing a struct or buffer. Numeric parsing benefits from strtol and strtod, which report errors through errno and an end pointer and accept a base for strtol, unlike atoi, which silently returns 0 on failure and has undefined behavior on overflow. printf and its relatives accept width and precision specifiers like %5d for right-aligned padding, %-5d for left alignment, and %.3f to control digits after the decimal, while the length modifiers %zu and %zd print size_t and ptrdiff_t portably, and the PRIu64-style macros from <inttypes.h> print fixed-width integers without bugs on 64-bit systems. Always pass user-controlled data as an argument, never as the format string itself, because a format string vulnerability allows attackers to read or write arbitrary memory through specifiers like %s and %n.

Functions and Control Flow

C functions consist of a return type, name, and parameter list. A function prototype declares these without a body, allowing the compiler to check call-site argument types and forward-reference functions defined later in the file or in another translation unit. Functions declared void perform actions without producing a value and use a bare return statement, while a non-void function that reaches its closing brace without returning yields undefined behavior if the caller uses the result. Variadic functions, declared with ... like printf, accept a variable number of arguments and use va_list, va_start, va_arg, and va_end from <stdarg.h> to traverse them; default argument promotions convert float to double and small integers to int, so va_arg must use double when extracting floating-point arguments. The restrict qualifier on a pointer tells the compiler that no other pointer aliases the same object, which permits aggressive optimizations but is undefined behavior to violate.

Recursion occurs when a function calls itself, with each call creating a new stack frame holding its own parameters and locals. Every recursive function needs a base case that terminates the calls, otherwise the stack eventually overflows. Iteration through loops uses constant memory and can express anything recursion can, though recursive code is often clearer for tree and graph traversals. Function pointers store the address of a function and are declared like int (*fptr)(int, int), then assigned and called through the pointer; they are commonly used as callbacks, where one function is passed to another to be invoked later, enabling flexible, reusable designs. The inline keyword hints that a function should be expanded at the call site rather than emitted as a separate function, though modern compilers decide freely and a non-inline external definition is still typically provided so the linker can find a callable symbol.

C provides several control-flow constructs. The while loop tests the condition before each iteration and may execute zero times, while do-while tests afterward and always runs at least once. The switch statement dispatches on an integer or enum expression, with cases falling through unless terminated by break and a default branch handling unmatched values. The ternary operator ?: is C's only three-operand operator and returns one of two expressions based on a condition, while the comma operator evaluates its left operand, discards it, and returns the right operand, having the lowest precedence of any operator. The increment forms ++i (prefix) and i++ (postfix) differ in whether they increment before or after returning the value, which matters in expressions like arr[++i] = x versus arr[i++] = x. Operators follow defined precedence and associativity rules, with parentheses the safest way to make intent explicit. Sequence points, introduced by ;, &&, ||, ?:, and ,, define when all side effects of prior evaluations are complete, so expressions like a = i++ + i++ invoke undefined behavior. The goto statement is generally discouraged but is accepted for breaking out of nested loops to a centralized cleanup label. Functions that never return can be marked _Noreturn, allowing the compiler to warn about unreachable code after the call.

File I/O and Program Termination

File operations in C begin with fopen, which takes a filename and a mode string. The standard text modes are r, w, and a, while binary variants rb, wb, and so on are required for images, archives, or structured records because text mode on Windows translates newlines to and from the carriage-return-plus-linefeed sequence. Always check that the returned FILE pointer is not NULL before proceeding. For reading, fgetc reads one character at a time, fgets reads a line into a buffer with a size limit, fscanf does formatted input, and fread reads binary records; their writing counterparts are fputc, fputs, fprintf, and fwrite. The pair fprintf and fscanf mirror printf and scanf but take a FILE pointer as their first argument, so fprintf(stdout, ...) behaves like printf and fprintf(stderr, ...) writes to the usually unbuffered standard error stream. The return value of printf indicates the number of characters written, which is worth checking for output errors.

Always close files with fclose to flush buffered output and release the underlying file descriptor; failing to do so risks data loss from unflushed buffers and resource leaks because file descriptors are limited. Use fseek with SEEK_SET, SEEK_CUR, or SEEK_END to reposition the file pointer, ftell to query the current offset, rewind as shorthand for fseek to the start, and fflush to push buffered output even without a newline; fflush(NULL) flushes every output stream. The feof function only returns true after a read has attempted to pass the end of the file, so the idiomatic loop reads first and then checks the return value rather than predicting EOF ahead of time.

Programs terminate in several ways. Returning a value from main causes the runtime to call exit, which runs atexit handlers in LIFO order, flushes and closes stdio buffers, and returns the status to the host; exit(EXIT_SUCCESS) and exit(EXIT_FAILURE) are portable. abort terminates abnormally by raising SIGABRT without flushing buffers or running handlers, often producing a core dump. assert(expr) from <assert.h> checks an internal invariant at runtime, printing file and line and calling abort if false; defining NDEBUG before including the header disables all asserts at compile time, so asserts are for internal invariants rather than validating external input. The errno variable, set by many library functions on failure, can be displayed with perror("prefix") or strerror(errno) and should be set to zero before a call and checked afterward. Beyond these, <stdlib.h> offers qsort for in-place sorting via a comparator, bsearch for binary search on a sorted array, rand and srand for pseudo-random numbers (note that rand() % N is biased and prefers scaling), and getopt for parsing command-line options with short flags and optarg for option arguments. POSIX clock_gettime measures elapsed time at nanosecond resolution, signal and sigaction install handlers for events like SIGINT (though only async-signal-safe functions may be called inside a handler), and setjmp with longjmp provides non-local jumps out of deeply nested calls.

The Preprocessor and Macros

The C preprocessor runs before compilation and handles directives such as #include, #define, #ifdef, #ifndef, #if, #elif, #else, #endif, and #pragma. Its output is a single translation unit with all directives resolved. #include <file> searches system directories first and is used for standard headers like <stdio.h>, while #include "file" searches the current source directory first and is the convention for project headers. The preprocessor evaluates only integer literals, defined macros, and a small set of operators inside #if expressions, making it a distinct mini-language focused on textual transformation. Header files containing declarations are a clean way to share interfaces across source files without exposing implementations.

#define creates macros that perform textual substitution before compilation. Constants like #define PI 3.14159 substitute the literal everywhere, while function-like macros like #define MAX(a,b) ((a) > (b) ? (a) : (b)) take parameters; always wrap parameters and the whole expression in parentheses to avoid precedence bugs. The token-pasting operator ## joins two tokens, useful for generating repetitive declarations like DECLARE(counter) expanding to int my_counter = 0;. Compared to functions, macros lack type checking, may evaluate arguments multiple times, can produce code bloat, and are harder to debug because errors point at the expansion site rather than the source; for instance, MAX(a++, b++) would increment one operand twice. Prefer static inline functions or enum and const variables where possible, reserving #define for conditional compilation, token pasting, or features that must work before the compiler runs.

Conditional compilation controls which code is compiled. #ifdef MACRO includes code when the macro is defined, #ifndef when it is not, and #if evaluates a more general preprocessor expression; #elif chains branches and #else catches everything else. The most common application is the include guard, which prevents a header from being included more than once in a translation unit and avoids duplicate-definition errors during compilation. The non-standard but widely supported #pragma once serves the same purpose with simpler syntax, and #pragma more generally provides compiler-specific instructions like #pragma pack(1) for tight struct alignment; unrecognized pragmas are silently ignored. Compared with macros, const variables are real objects with types, scoped lifetimes, and visibility in debuggers, so they are usually the better choice for named constants.

Compilation, Linking, and Program Structure

A C program is built in four stages. The preprocessor resolves directives, expands macros, and includes headers to produce a translation unit. The compiler parses that unit, performs type checking, and emits assembly code. The assembler converts assembly into relocatable object code in .o files. Finally, the linker combines object files and libraries, resolves external symbols, and produces an executable. The compiler handles one source file at a time, while the linker ties multiple files together. This separation is why declarations and definitions are distinct: extern int counter; declares a variable that exists elsewhere, while int counter = 0; actually allocates storage, and multiple definitions of the same symbol cause a linker error.

Linkage governs whether a symbol is visible across translation units. By default, non-static globals and functions have external linkage and are visible everywhere; applying static at file scope gives internal linkage, hiding the symbol within its own source file. Inside functions, static means the local variable retains its value across calls. Header files with a .h suffix contain declarations that provide an interface, so other source files can use features without seeing the implementation. Each .c file is compiled independently, and the linker matches each declaration to its single definition, errors on undefined or multiply-defined symbols, and produces the executable. The C standard itself has evolved through versions including C89, C99, C11, C17, and C23, with each adding features such as fixed-width integers, designated initializers, threads, atomics, and refined attribute syntax; C and C++ also remain distinct languages with different rules for constructs like character literals.

Libraries come in two forms. A static library, with .a on Unix and .lib on Windows, is a bundle of object files copied into the executable at build time, producing a self-contained binary that does not depend on the library at runtime. A shared library, with .so on Linux, .dylib on macOS, and .dll on Windows, is loaded by the dynamic linker at runtime and shared between processes, yielding smaller binaries and easier patching at the cost of runtime symbol resolution. Storage durations in C are automatic (block scope), static (program duration), allocated (malloc through free), and thread (_Thread_local, one copy per thread). A typical process address space is divided into read-only text, initialized data, zero-initialized BSS, the heap (which grows up through malloc), and the stack (which grows down with each function call). The main function has two standard signatures, int main(void) and int main(int argc, char *argv[]), where argc is the argument count and argv is an array of argument strings terminated by a NULL pointer; the value returned from main becomes the process exit status. Low-level helpers like offsetof, which returns a struct member's byte offset, and the container_of pattern, which recovers a containing struct from a pointer to an embedded member, rely on these layout guarantees and are widely used in systems code.

Frequently asked questions

What is a pointer in C?

A pointer is a variable that stores the memory address of another variable.
Declared using the * operator: int *ptr;
The address-of operator & retrieves a variable's address: ptr = &x;

What is the #include preprocessor directive?

#include inserts the contents of another file before compilation.
#include <stdio.h> — searches system directories
#include "myfile.h" — searches the current directory first, then system directories.

What are the stages of the C compilation process?

The C compilation has four stages:
  • Preprocessing: handles #include, #define, macros
  • Compilation: translates C code to assembly
  • Assembly: converts assembly to object code (.o files)
  • Linking: combines object files and libraries into an executable

What is a memory leak in C?

A memory leak occurs when dynamically allocated memory is never freed.
Example:
int *p = malloc(100);
p = malloc(200); // first block is leaked!

Over time, leaked memory accumulates and can exhaust available memory. Use tools like Valgrind to detect leaks.

What is the lifetime of a variable?

Lifetime is the runtime interval during which storage exists.
  • Automatic: from block entry to exit
  • Static: entire program duration
  • Dynamic: from malloc() to free()
  • Thread: thread duration (_Thread_local)

What does strtok() do and why is it tricky?

strtok(str, delim) splits str into tokens separated by any character in delim.
On the first call, pass the string; subsequent calls pass NULL to continue.
It modifies the input by writing '\0' where delimiters were, and is not thread-safe; use strtok_r() for reentrancy.

What does fflush() do?

fflush(stdout) writes any buffered output to the underlying file or terminal, even without a newline.
fflush(NULL) flushes all output streams.
It is defined only for output streams (or update streams in write mode); behavior on input streams is implementation-defined.

What is the difference between declaration and definition for variables?

Definition: int counter = 0; — creates the object and allocates storage.
Declaration: extern int counter; — asserts that the variable exists elsewhere.
At file scope you may have many declarations but exactly one definition; otherwise the linker reports multiple-definition errors.

What does setjmp() and longjmp() do?

setjmp(env) saves the current execution state (including registers, stack pointer) into env and returns 0.
longjmp(env, val) later restores that state, making setjmp return val (non-zero).
This is C's non-local goto: it can jump out of deeply nested calls but skips destructors/cleanup, so it interacts poorly with RAII-like idioms.

How does C's operator evaluation order work for function arguments?

Until C11 the order in which arguments to a function are evaluated was unspecified; foo(f(), g()) could call f or g first.
Since C11, every expression is sequenced: full expressions are sequenced after their operands, and operators like &&, ||, ,, and ?: introduce sequence points, so the left side is fully evaluated before the right.

Drill this topic

170 flashcards on C Programming — free, no signup needed to start.

Study C Programming flashcards

LearnWiki pages are generated with AI assistance from LearnCoachAssist's reviewed study catalog and may contain errors — verify anything critical against your course materials.