A C program consists of typed objects, expressions, control flow, and functions. An implementation translates source files and links the required definitions into a program.
C’s type rules do not automatically protect bounds or lifetimes. Uninitialized reads, signed overflow, and wrong format specifiers can all cause undefined behavior.
Make types and ranges explicit, check library results, and verify generated code with strict warnings and sanitizers.
What it is and why it exists
C is a statically typed, compiled language. Declarations describe the types of objects and functions, expressions compute values, and statements control execution. The language offers a small set of mechanisms while letting code express memory layouts, bit operations, and external interfaces directly, so it remains common in operating systems, embedded software, runtimes, and foreign-function interfaces.
“Close to the hardware” does not mean that every statement maps to one machine instruction. The C standard describes an abstract machine and its observable behavior; an implementation maps a valid program to a target platform. A compiler may reorder or remove work that cannot change observable results, provided the program does not invoke undefined behavior.
Basic C code answers four questions: what type the data has, how an expression is evaluated, which statement executes next, and where a name is visible. Pointers, arrays, dynamic memory, and file I/O all build on these rules. This topic covers only the boundaries you need before moving to those subjects.
You encounter these concepts in .c source files, header declarations, build logs, and compiler diagnostics. A small runnable program usually includes a header, a main function, declarations and expressions, and a return value that reports success or failure.
| Need | C mechanism | Contract you maintain |
|---|---|---|
| Represent data | Objects and types | Range and initialization state |
| Compute a result | Operators and expressions | Conversions, overflow, evaluation order |
| Select a path | if, switch, and loops | Bounds and termination conditions |
| Reuse behavior | Functions and declarations | Parameter meaning, result, and error convention |
How it works
From source file to program
After preprocessing, one source file becomes a translation unit . Preprocessing handles #include, conditional compilation, and macro replacement; the compiler then analyzes declarations, types, and statements and produces information needed for object code. A linker combines object files with required library definitions into an executable program. Exact commands and intermediate files belong to the toolchain, not to a fixed C language requirement.
A declaration tells the compiler what a name denotes. A definition also supplies a function body or reserves storage for an object. Headers usually contain shared declarations and source files contain definitions. If several translation units need one function, they must see compatible declarations, and the complete program must provide the required definition.
With GCC, gcc -std=c2x -Wall -Wextra -Wconversion -Wpedantic program.c -o program requests a language mode close to C23 and enables a useful set of warnings. GCC 13 calls that mode c2x, so this article uses that spelling to verify its examples. Warning options are not language semantics, and other compilers may use different names.
A typical build path can be described in four steps:
- Preprocessing expands headers, macros, and conditional branches.
- Compilation checks syntax and types and produces information needed for object code.
- Assembly creates an object file containing machine code and relocation information.
- Linking resolves cross-file names and library references and creates the final program.
Objects, types, and initialization
An object is data storage that holds a value during execution. The declaration int retries = 3; creates and initializes an int object named retries. A later assignment changes its value, not its type. The const qualifier prevents modification through that lvalue; it does not require the compiler to place the object in physically read-only storage.
C specifies type capabilities and minimum ranges, but an implementation chooses many exact widths. sizeof(char) is always 1, and that unit is a byte; one C byte has at least 8 bits but need not have exactly 8. Portable code must not assume fixed sizes for short, int, long, or pointers. Use sizeof, limits.h, or an exact-width integer type when the requirement is real.
The basic arithmetic types include integer and floating types. Integer types have signed and unsigned variants, while an implementation chooses the signedness of plain char. void represents no value or an incomplete object type. Arrays, pointers, functions, structures, unions, and enumerations are developed in their own topics.
An object needs a value appropriate for its type before its first read. An ordinary uninitialized local integer with automatic storage duration does not have a value you may simply read. File-scope objects and static local objects, by contrast, undergo zero initialization during program startup. The reliable habit is to give a local object a meaningful initializer at its declaration.
| Spelling | Meaning | Watch for |
|---|---|---|
int count = 0; | Create and initialize a modifiable integer | The implementation determines the int range |
const double rate = 0.2; | Prevent modification through rate | Floating representation is usually not decimal-exact |
unsigned mask = 1u; | Create an unsigned integer | Mixing it with signed values triggers conversions |
int pending; | Create without explicit initialization | Do not read it before assignment |
Expressions and conversions
An expression combines operands and operators to produce a value or a side effect. Multiplication, division, and remainder bind more tightly than addition and subtraction; comparisons bind more tightly than logical AND and OR. Precedence determines grouping, not the evaluation order of every subexpression. Split the work into statements when order changes the result.
Dividing two integers still produces an integer, with any fractional part truncated toward zero. Thus, 7 / 2 is 3, while 7 / 2.0 converts the integer to double first and produces 3.5. A cast can request a conversion explicitly, but it cannot recover precision already lost or automatically make an out-of-range value safe.
Narrow integer types undergo integer promotion before most arithmetic. The usual arithmetic conversions then find a common type for the two operands of a binary arithmetic operation. Mixing signed and unsigned integers is especially risky: a negative value may first become a large unsigned value and only then participate in the comparison or calculation.
Signed integer overflow is undefined behavior. Unsigned arithmetic wraps modulo one more than the type’s maximum value, which is defined but may still violate the application’s intent. For counts, sizes, and money, determine the allowed range and check the boundary before performing the operation.
A condition treats scalar zero as false and nonzero as true. && and || evaluate left to right and short-circuit, so they can check a precondition before performing a potentially unsafe operation. Short-circuiting cannot repair an out-of-bounds access, invalid read, or overflow that already occurred on the left.
Control flow, functions, and scope
Use if to choose by a Boolean condition and switch to choose among discrete integer or enumeration values. A switch starts at the matching case and continues into later cases unless it reaches break, return, or another jump. Comment deliberate fallthrough; otherwise, write the break.
A for statement groups initialization, continuation test, and per-iteration update, making it suitable for a clear iteration variable. while tests before each iteration, while do while executes at least once. Loop bounds are often easiest to maintain as half-open ranges, starting at 0 and continuing while index < count.
A function declaration gives a name, return type, and parameter types; a function definition adds the body. Writing int main(void) explicitly says that main takes no arguments, and ordinary no-argument functions should also use a void parameter list. C always passes arguments by value. When an argument is a pointer, the copied value is still the pointer itself.
A name declared inside a block is visible from its declaration through the end of that block. An inner block may shadow an outer name. A file-scope name can be referred to by later declarations in the same translation unit, while linkage determines whether names across translation units correspond to one entity. Scope says where a name is visible; storage duration says how long an object’s storage exists.
Program entry and exit status
In a hosted environment, execution starts at main. Use int main(void) when the program does not read command-line arguments; int main(int argc, char *argv[]) receives an argument count and argument strings. Reaching the closing brace of main is equivalent to returning 0, but an explicit return 0; makes the successful path in an example easy to see.
A zero status returned to the host reports success, while a nonzero status reports some failure; <stdlib.h> also provides EXIT_SUCCESS and EXIT_FAILURE. The meaning of a particular nonzero value belongs to the interface between the program and its caller. Do not assume that every platform preserves any large status unchanged.
Examples
These four programs progressively add types and arithmetic, functions and branches, loops, and switch. Each is an independent file. Every output shown here came from compiling locally with GCC 13.3.0 using -std=c2x -Wall -Wextra -Wconversion -Wpedantic -Werror and then running the result.
Money with an explicit unit
The calculation stores money as integer cents until the final conversion to euros. This keeps quantities and units clear and avoids repeated binary floating-point rounding in intermediate steps.
#include <stdio.h>
int main(void) {
const int unit_price_cents = 725;
const int quantity = 3;
const int subtotal_cents = unit_price_cents * quantity;
const double tax_rate = 0.20;
const double tax_euros = subtotal_cents * tax_rate / 100.0;
const double total_euros = subtotal_cents / 100.0 + tax_euros;
printf("items: %d\n", quantity);
printf("subtotal: %.2f EUR\n", subtotal_cents / 100.0);
printf("tax: %.2f EUR\n", tax_euros);
printf("total: %.2f EUR\n", total_euros);
return 0;
}items: 3
subtotal: 21.75 EUR
tax: 4.35 EUR
total: 26.10 EURThe 100.0 makes the division use a floating type. With subtotal_cents / 100, integer division would produce 21 first. Assigning that result to double later could not restore the lost 75 cents.
Put a branch behind a function
The shipping function accepts grams and returns cents; -1 represents invalid input. The caller must check that convention before treating the result as a price.
#include <stdio.h>
static int shipping_cents(int weight_grams) {
if (weight_grams <= 0) {
return -1;
}
if (weight_grams <= 500) {
return 499;
}
if (weight_grams <= 2000) {
return 799;
}
return 1299;
}
int main(void) {
const int weight_grams = 1200;
const int price = shipping_cents(weight_grams);
if (price < 0) {
puts("invalid weight");
return 1;
}
printf("weight: %d g\n", weight_grams);
printf("shipping: %.2f EUR\n", price / 100.0);
return 0;
}weight: 1200 g
shipping: 7.99 EURPlacing static on a file-scope function declaration limits that function’s linkage to the current translation unit. The two consecutive if statements already cover lighter ranges. Each later condition handles only inputs that have not returned, so the branch boundaries do not overlap.
Accumulate a range with a loop
The loop uses the closed interval [first, last], so its condition is current <= last. The function handles a reversed range first instead of carrying invalid bounds into the loop.
#include <stdio.h>
static long long range_total(int first, int last) {
long long total = 0;
if (first > last) {
return 0;
}
for (int current = first; current <= last; ++current) {
total += current;
}
return total;
}
int main(void) {
for (int end = 3; end <= 6; ++end) {
printf("1..%d = %lld\n", end, range_total(1, end));
}
printf("empty range = %lld\n", range_total(5, 2));
return 0;
}1..3 = 6
1..4 = 10
1..5 = 15
1..6 = 21
empty range = 0The sample inputs are small, so long long holds each total. The function itself has not proved that arbitrary int boundaries cannot overflow. A production interface should also constrain the range or check against LLONG_MAX - current before adding.
Handle discrete states with switch
A switch displays the mapping from status code to handling path. Every known branch ends with break, and default provides a defensive path for an unknown value.
#include <stdio.h>
static void print_state(int state) {
printf("state %d: ", state);
switch (state) {
case 0:
puts("queued");
break;
case 1:
puts("running");
break;
case 2:
puts("complete");
break;
default:
puts("unknown");
break;
}
}
int main(void) {
print_state(0);
print_state(2);
print_state(9);
return 0;
}state 0: queued
state 2: complete
state 9: unknownWhen several case labels genuinely share behavior, place them consecutively and write one break after the shared code. Do not remove default merely because current callers make it look unreachable; external input and future states may still reach it.
Pitfalls
Reading an uninitialized automatic object
Fix: initialize at the declaration, or prove that every path reaching the read performs an assignment first. Enable -Wuninitialized and test error branches, empty inputs, and early returns. Never treat the zero observed in one debug run as a guarantee.
Assuming integer widths and signedness
Fix: use a type from <stdint.h> that meets a protocol field’s requirement, with the matching <inttypes.h> formatting macro. Ordinary counts can still use int or size_t, but design their bounds from the type’s real range rather than a familiar platform.
Mixing signed and unsigned values
Fix: use compatible types in one calculation domain. If an API returns a signed error code, check the error before converting a valid result. When conversion is necessary, prove that the source is nonnegative and representable in the destination, and let -Wconversion locate implicit cases.
Mismatching a printf format
Fix: match every specifier to the promoted argument type and keep -Wformat=2 enabled. For fixed-width integers, use the PRI... macros from <inttypes.h> instead of guessing between %ld and %lld from a byte count.
Modifying one object repeatedly in an expression
Fix: perform one visible state change per statement: store the element, then increment the index separately. Review compound expressions containing ++, --, assignment, and function calls. Do not ask a model to predict a result for an expression that has no guaranteed C meaning.
The C abstract machine and portability boundaries
Four different kinds of behavior
C portability depends on describing specification boundaries precisely. The language fully specifies some results and leaves other choices to an implementation or a particular execution. Only undefined behavior means that the standard imposes no requirements on the result. Calling all these categories “random” hides which choices can be documented, tested, or constrained.
| Category | Meaning | Response |
|---|---|---|
| Defined behavior | The standard requires a result | Depend on it directly |
| Implementation-defined behavior | The implementation chooses and documents one behavior | Read compiler and target documentation |
| Unspecified behavior | The implementation chooses from an allowed set without documenting the choice | Keep correctness independent of the selection |
| Undefined behavior | The standard imposes no requirements | Exclude it through design, diagnostics, and tests |
The signedness of plain char is an implementation choice, function-argument evaluation order often involves an unspecified choice, and out-of-bounds access and signed overflow invoke undefined behavior. Their consequences differ: the first two still have a language-defined set of candidates, while the last has no promise to crash or to continue.
An optimizer may assume that an executing program has no undefined behavior. For example, because signed addition cannot overflow in a valid execution, an optimizer can derive conditions from that assumption. Sanitizers and warnings find many violations, but cannot prove that an arbitrary program is free of undefined behavior. Interface constraints and code review remain the first defense.
Scope, storage duration, and linkage
Scope controls where a name is visible in source code. Block scope applies to names in function bodies and nested blocks, file scope applies to declarations outside all functions, and parameter names in a function prototype matter only inside that prototype. When an inner declaration shadows an outer name, both objects may still exist; the outer name is merely hidden.
Storage duration controls the minimum lifetime of an object’s storage. Ordinary local objects usually have automatic storage duration: their storage begins on entry to the corresponding block and ends on exit. File-scope objects and static local objects have static storage duration and last for the program’s execution. Allocation and deallocation functions control dynamically allocated storage; its ownership rules belong in the memory-management topic.
Linkage answers whether declarations in different scopes or translation units denote the same entity. A file-scope static name has internal linkage and corresponds only within that translation unit. An ordinary external function definition usually has external linkage. Local variables generally have no linkage. Keeping these dimensions separate clarifies “visible here,” “still alive,” and “the same definition.”
Evaluation order and side effects
Operator precedence and associativity determine syntactic grouping during translation. For example, a + b * c groups as a + (b * c). They usually do not determine which operand runs first. Nor may consume(first(), second()) assume that first() always runs before second().
The language uses sequencing relationships to constrain selected evaluations. &&, ||, the comma operator, and the chosen path of the conditional operator establish ordering at important points, but function arguments do not become ordered as a result. If side effects on one scalar object are unsequenced relative to each other, or relative to a value computation that does not determine the value being stored, behavior can be undefined.
The safest code separates stateful calls and increment operations. A temporary variable is not inherently a performance cost. An optimizer can usually remove storage used only to express ordering, while a reviewer can see exactly where each read and modification occurs in a full expression.
Integer models and boundary checks
Integer conversion considers conversion rank, signedness, and representable range, not merely which type name looks “larger” in source. If one type represents every value of the other, the value can be preserved; otherwise, the rules may take both operands to an unsigned type. This explains why -1 < sizeof object can be false: the right side is unsigned size_t, and the left side may be converted.
A boundary check must run before the operation that might overflow. Testing a + b <= LIMIT may already evaluate an overflowing a + b. For known nonnegative signed integers, test a <= LIMIT - b after establishing that the subtraction is valid. Multiplication checks must also handle zero, signs, and the minimum negative value; complex cases merit a reviewed helper or a compiler overflow intrinsic.
An exact-width type such as uint32_t exists only when the implementation supplies an integer type with exactly 32 bits and no padding. When the requirement is “at least 32 bits,” uint_least32_t expresses a looser constraint. Standard-library APIs normally use size_t for object sizes and indices. Choose a type from the data contract, not merely to silence a warning.
Diagnostics and dynamic checking
Strict warnings catch suspicious conversions, missing prototypes, unreachable branches, and format-string defects. Treating warnings as errors keeps new diagnostics from disappearing in logs, although third-party headers or multiple compilers may require layered settings. When suppressing a warning, record the proved precondition instead of hiding it with a cast.
AddressSanitizer mainly helps find out-of-bounds and use-after-free memory defects. UndefinedBehaviorSanitizer detects some overflow, invalid shifts, and alignment problems. They check only paths that actually execute and change program layout and timing, so they neither replace test design nor prove the absence of defects.
A reliable baseline includes a strict-warning build, a test build with sanitizers, and a build near the release optimization level. For code that depends on implementation choices, record the compiler, version, target architecture, and relevant options so a later verification can tell whether the environment changed.
Further reading
Use the GCC manuals for toolchain diagnostics and the language references for types, conversions, and expression rules.
4 questions · 1 predict-the-output · 1 spot-the-bug