Basics of C
Rough notes from learning C: compiling, types, memory, pointers, structs, and the preprocessor.
Why this exists
This post is for developers who already know how to program and want a fast, practical entry point into C.
It is not a complete textbook. It is a compact guide to the concepts that matter early: compilation, memory, pointers, structs, and the parts of the language that shape how C code actually behaves.
For a deeper or more formal treatment, the original article and the language documentation are better sources. This post is meant to be the useful first pass.
What is C?
C is a general-purpose programming language that is extremely popular, simple, and flexible.
At a practical level, C is worth learning because it exposes the mechanics that higher-level languages often hide. Data layout, memory lifetime, compilation, and undefined behavior are all much more visible here.
Why C?
For developers coming from JavaScript, Python, or Go, C is useful partly because it forces a stricter mental model. The language does not offer much protection from bad assumptions, which makes it a strong place to learn what values, pointers, arrays, and memory are really doing.
The video How I program C by Eskil Steenberg is a strong companion piece for anyone trying to learn the language with more intention.
With that out of the way, here we go!
What matters most
A useful foundation in C is not just syntax. The real goal is the mental model:
- how a
.cfile becomes a program - what the compiler checks and what it does not check
- how values live in memory
- how pointers point at memory
- how arrays and strings are represented
- how headers and source files work together
- how allocation, ownership, and cleanup work
- what undefined behavior is and why it matters
That is the stuff that makes C feel different from higher-level languages.
Basic hello world
A basic C program looks like this:
#include <stdio.h>
int main(void)
{
printf("Hello, world!\n");
}This program starts with #include <stdio.h> because it uses printf.
Every C program has a main function, which is int main(void).
The main() function is the entry point for a C program.
Sometimes a C program returns 0 at the end, but it is not necessary in this tiny example.
Every statement in C ends with a semicolon ;.
Compiling
To compile a C program, you need a C compiler installed on your computer.
You can use gcc:
gcc hello.c -o helloInstructions for downloading and installing gcc can be found here.
When learning, compile with warnings turned on:
gcc -Wall -Wextra -pedantic hello.c -o hello-Wallturns on a useful set of warnings-Wextraturns on more warnings-pedanticasks the compiler to complain about non-standard C
Warnings are not decoration in C. Treat them like bugs until you know exactly why they are safe.
The build pipeline
A C program goes through a few steps before it becomes an executable:
- preprocessing: handles
#include,#define, and other preprocessor directives - compiling: turns C source into object code
- linking: combines object files and libraries into one executable
That is why this works:
gcc main.c helper.c -o appmain.c and helper.c are compiled, then linked together into app.
Variables and Types
There are a bunch of types in C. Their exact sizes can depend on the platform and compiler, so do not blindly memorize the numbers. Use sizeof when it matters.
Common sizes on modern 64-bit systems look something like this:
char: character, usually 1 byteint: signed integer, commonly 4 bytesshort: signed short integer, commonly 2 byteslong: signed long integer, commonly 8 bytesfloat: floating-point number, commonly 4 bytesdouble: double-precision floating-point number, commonly 8 byteslong double: extended-precision floating-point number, commonly 16 bytesbool: boolean, requiresstdbool.h, commonly 1 bytevoid: no value
#include <stdio.h>
int main(void)
{
printf("char size: %zu bytes\n", sizeof(char)); // 1 byte
printf("int size: %zu bytes\n", sizeof(int)); // often 4 bytes
printf("short size: %zu bytes\n", sizeof(short)); // often 2 bytes
printf("long size: %zu bytes\n", sizeof(long)); // often 8 bytes on 64-bit Unix
printf("float size: %zu bytes\n", sizeof(float)); // often 4 bytes
printf("double size: %zu bytes\n", sizeof(double)); // often 8 bytes
printf("long double size: %zu bytes\n", sizeof(long double)); // platform-dependent
}The sizeof operator returns the size of a type or variable in bytes.
Its result has type size_t, so %zu is the right printf format for it.
Constants
There are two common ways to define constants in C:
const: creates a typed read-only variable#define: creates a preprocessor macro
#include <stdio.h>
#define B 20
int main(void)
{
const int a = 10;
printf("a: %d\n", a); // 10
printf("B: %d\n", B); // 20
}Put macros at file scope, not inside functions. For ordinary values, prefer const when a typed variable works.
Operators
C has a bunch of operators.
Math operators:
+: addition-: subtraction*: multiplication/: division++: increment by 1--: decrement by 1%: modulus
Logical operators:
&&: and||: or!: not
Bitwise operators:
&: bitwise and|: bitwise or^: bitwise xor~: bitwise not>>: right shift<<: left shift
Comparison operators:
==: equal!=: not equal>: greater than<: less than>=: greater than or equal<=: less than or equal
Assignment operators:
=: assignment+=: addition assignment-=: subtraction assignment*=: multiplication assignment/=: division assignment%=: modulus assignment&=: bitwise and assignment|=: bitwise or assignment^=: bitwise xor assignment>>=: right shift assignment<<=: left shift assignment
Conditionals
There are two types of conditionals in c:
if: if statementswitch: switch statement
Ternary operator
?:: ternary, examplea > b ? a : b
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 20;
if (a > b)
{
printf("a is greater than b\n");
}
else if (a < b)
{
printf("a is less than b\n");
}
else
{
printf("a is equal to b\n");
}
switch (a)
{
case 10:
printf("a is 10\n");
break;
case 20:
printf("a is 20\n");
break;
default:
printf("a is not 10 or 20\n");
break;
}
}
Loops
There are three main kinds of loops in C:
for: for loopwhile: while loopdo while: like awhileloop, but it runs at least once
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 10; i++)
{
printf("i: %d\n", i);
}
int i = 0;
while (i < 10)
{
printf("i: %d\n", i);
i++;
}
i = 0;
do
{
printf("i: %d\n", i);
i++;
} while (i < 10);
}If you want to break out of a loop you can use break and if you want to skip the rest of the loop you can use continue.
Arrays
Arrays are a collection of variables of the same type.
#include <stdio.h>
int main(void)
{
int arr[10];
for (int i = 0; i < 10; i++)
{
arr[i] = i;
}
for (int i = 0; i < 10; i++)
{
printf("arr[%d]: %d\n", i, arr[i]);
}
}
Strings
Strings can be represented as an array of characters.
#include <stdio.h>
int main(void)
{
char a[10] = "hello";
char b[10] = {'h', 'e', 'l', 'l', 'o', '\0'};
printf("a: %s\n", a); // hello
printf("b: %s\n", b); // hello
}
A string is an array of characters that ends with a null character \0.
The last character is called a terminator.
To manipulate strings you can use the string.h library, which contains functions like:
strlen(): returns the length of a stringstrcpy(): copies a string to another string, but can overflow if the destination is too smallstrcat(): concatenates two strings, but can overflow if the destination is too smallstrcmp(): compares two strings
For real code, prefer patterns that track the destination buffer size, such as snprintf, or write a helper that checks lengths before copying.
Pointers
A pointer is a variable that stores the address of another variable. Probably one of the most confusing things in C. An address is a location in memory. Memory is an array of bytes.
Pointers are declared by adding a * after the type.
To get the address of a variable you can use the & operator.
To get the value of a pointer you can use the * operator.
#include <stdio.h>
int main(void)
{
int a = 10;
int *b = &a;
printf("a: %d\n", a); // 10
printf("b: %p\n", (void *) b); // 0x7ffeeb0b4a3c
printf("*b: %d\n", *b); // 10
printf("&a: %p\n", (void *) &a); // 0x7ffeeb0b4a3c
printf("&b: %p\n", (void *) &b); // 0x7ffeeb0b4a30
}In most expressions, an array decays to a pointer to its first element.
#include <stdio.h>
int main(void)
{
int a[10] = {0,1,2,3,4,5,6,7,8,9};
int *b = a;
printf("a: %p\n", (void *) a); // 0x7ffeeb0b4a30
printf("b: %p\n", (void *) b); // 0x7ffeeb0b4a30
printf("a[0]: %d\n", a[0]); // 0
printf("b[0]: %d\n", b[0]); // 0
printf("*a: %d\n", *a); // 0
printf("*b: %d\n", *b); // 0
}You can iterate over an array using pointer arithmetic.
#include <stdio.h>
int main(void)
{
int a[10];
for (int i = 0; i < 10; i++)
{
a[i] = i;
}
for (int i = 0; i < 10; i++)
{
printf("a[%d]: %d\n", i, *(a + i));
}
for(int *p = a; p < a + 10; p++)
{
printf("p: %d\n", *p);
}
}Memory lifetime
C forces careful thinking about where data lives and how long it stays valid:
- automatic storage: local variables inside a function, often on the stack
- static storage: globals and
staticvariables, alive for the whole program - allocated storage: memory requested with
malloc, alive untilfree
Example of automatic storage:
int add(int a, int b)
{
int result = a + b;
return result;
}result only exists while add is running. Returning its value is fine. Returning a pointer to result would be wrong because the local variable stops existing after the function returns.
Dynamic memory
When memory size is not known until runtime, malloc is the standard tool.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
size_t count = 5;
int *numbers = malloc(count * sizeof *numbers);
if (numbers == NULL)
{
perror("malloc");
return 1;
}
for (size_t i = 0; i < count; i++)
{
numbers[i] = (int) i * 2;
}
for (size_t i = 0; i < count; i++)
{
printf("%d\n", numbers[i]);
}
free(numbers);
}The important rules:
- check whether
mallocreturnedNULL - call
freeexactly once when done - do not use the pointer after
free - do not lose the pointer before freeing it
Undefined behavior
C allows code that compiles but has no valid meaning. That is called undefined behavior.
Examples:
- reading past the end of an array
- using an uninitialized variable
- dereferencing a null pointer
- using memory after
free - returning a pointer to a local variable
- overflowing a signed integer
Undefined behavior is not just "maybe wrong output." The compiler is allowed to assume it never happens, which can make bugs extremely weird.
Functions
Functions are blocks of code that can be called after the compiler has seen a declaration for them.
#include <stdio.h>
// a and b are parameters
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int a = 10;
int b = 20;
// a and b are arguments
int c = add(a, b);
printf("c: %d\n", c); // 30
}
Functions have a return type and may or may not take parameters. Functions should be declared before they are called. The full definition can come later if a prototype appears first. Parameters can be pointers.
#include <stdio.h>
// swap the values of two variables
void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
int main(void)
{
int a = 10;
int b = 20;
printf("a: %d | b: %d\n", a, b); // a: 10 | b: 20
swap(&a, &b);
printf("a: %d | b: %d\n", a, b); // a: 20 | b: 10
}Scope
There is global scope and local scope.
#include <stdio.h>
int a = 10; // global scope
int main(void)
{
int b = 20; // local scope
printf("a: %d\n", a); // 10
printf("b: %d\n", b); // 20
}
Type Definitions
You can create your own types using typedef.
#include <stdio.h>
typedef int NUMBER;
int main(void)
{
NUMBER a = 10;
printf("a: %d\n", a); // 10
}
Enumerations
Enumerations are a list of named integer constants.
#include <stdio.h>
enum Weekday
{
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
};
int main(void)
{
enum Weekday today = Monday;
printf("today: %d\n", today); // 0
}Enumerated Types
This is how to create a named enum type. For actual booleans, stdbool.h is usually the better choice.
#include <stdio.h>
typedef enum
{
BOOL_FALSE,
BOOL_TRUE
} Bool;
int main(void)
{
Bool is_true = BOOL_TRUE;
printf("is_true: %d\n", is_true); // 1
}Structures
Structures are a collection of variables of different types.
Structures use the word struct to define them.
#include <stdio.h>
struct Person
{
const char *name; // pointer to a string literal
int age; // integer
};
// name points at the first character of a string literal.
int main(void)
{
struct Person person = {"John", 20};
printf("name: %s\n", person.name); // John
printf("age: %d\n", person.age); // 20
}
Structures can be declared and defined at the same time.
#include <stdio.h>
struct Person
{
const char *name;
int age;
} john, people[10], jim = {"Jim", 30}; // john is a struct Person, people is an array of struct Person and jim is a struct Person with the name Jim and age 30
int main(void)
{
john.name = "John";
john.age = 20;
printf("name: %s\n", john.name); // John
printf("age: %d\n", john.age); // 20
people[0].name = "John";
people[0].age = 20;
people[1].name = "Jane";
people[1].age = 30;
}
Structures are passed by copy.
Typedef Structures
You can create your own types using typedef.
#include <stdio.h>
typedef struct
{
const char *name;
int age;
} Person;
int main(void)
{
Person person = {"John", 20};
printf("name: %s\n", person.name); // John
printf("age: %d\n", person.age); // 20
}
Pointers to Structures
#include <stdio.h>
typedef struct
{
const char *name;
int age;
} Person; // Person is a type
int main(void)
{
Person person = {"John", 20};
Person *p = &person;
printf("name: %s\n", p->name); // John
printf("age: %d\n", p->age); // 20
}
Unions
Unions are a collection of variables that share the same memory location.
#include <stdio.h>
union Number
{
int i;
float f;
};
int main(void)
{
union Number number;
number.i = 10;
printf("i: %d\n", number.i); // 10
number.f = 10.5;
printf("f: %f\n", number.f); // 10.500000
}
The difference between a union and a structure is that a union can only hold one active value at a time. Reading a different member than the one most recently written is not a portable way to convert values.
Bit Fields
Bit fields are used to pack small integer fields into implementation-chosen storage units.
#include <stdio.h>
struct Date
{
unsigned int day : 5; // 1 - 31
unsigned int month : 4; // 1 - 12
unsigned int year; // 1900 - 2020
};
int main(void)
{
struct Date today = {26, 4, 2020};
printf("size of today: %zu\n", sizeof(today)); // platform-dependent
printf("day: %u\n", today.day); // 26
printf("month: %u\n", today.month); // 4
printf("year: %u\n", today.year); // 2020
}
Command line parameters
Sometimes you want to pass parameters to your program when you run it.
In this case, instead of using int main(void), you can use int main(int argc, char *argv[]).
argcis the number of arguments passed to the programargvis an array of strings containing the arguments passed to the program
#include <stdio.h>
// ./hello hello world
int main(int argc, char *argv[]) {
printf("argc: %d\n", argc); // 3
for (int i = 0; i < argc; i++) {
printf("%s\n", argv[i]);
}
}./hello hello world is an example of a command line argument.
Another example is gcc -o hello hello.c where -o is an argument to the gcc program.
Header files
Header files are used to share declarations between multiple files.
Header files have the .h extension.
They can help to make your code more modular by separating the interface from the implementation.
// hello.h
#ifndef HELLO_H
#define HELLO_H
void hello(void);
#endif// hello.c
#include <stdio.h>
#include "hello.h"
void hello(void) {
printf("Hello, World!\n");
}// main.c
#include "hello.h"
int main(void) {
hello();
}When you compile your program you need to pass all the source files to the compiler.
gcc -Wall -Wextra -pedantic main.c hello.c -o mainPreprocessor
The preprocessor is a program that runs before the compiler. There are a few directives that the preprocessor uses to modify the source code before the compiler runs.
#include- includes a header file#define- defines a macro#undef- undefines a macro#ifdef- checks if a macro is defined#ifndef- checks if a macro is not defined#if- checks if a condition is true#else- alternative for#if#elif- alternative for#if#endif- ends a conditional block#error- prints an error message#pragma- implementation-defined directive
And more...
#include <stdio.h>
#define PI 3.141592653589793
int main(void)
{
printf("PI: %f\n", PI); // 3.140000
}
The preprocessor replaces all occurrences of PI with 3.14.
In the case below, the preprocessor will replace DEBUG with 0 and the compiler will see this code.
#include <stdio.h>
#define DEBUG 0
int main(void) {
#if DEBUG == 0
printf("I am NOT debugging\n");
#else
printf("I am debugging\n");
#endif
}Macros and Symbolic Constants
Symbolic constants are used to define constants.
Macros are used to define constants and functions, which means they can take arguments.
They are defined using the #define directive.
#include <stdio.h>
#define PI 3.141592653589793
#define AREA(r) (PI * (r) * (r))
int main(void)
{
printf("PI: %f\n", PI); // 3.140000
printf("AREA: %f\n", AREA(2)); // 12.560000
}
Function-like macros are text substitution, so arguments can be evaluated more than once. Avoid passing expressions with side effects, such as AREA(i++).
If defined
The #if defined directive checks if a macro is defined.
#include <stdio.h>
#define VALUE 1
int main(void) {
#ifdef VALUE
printf("Value is defined\n");
#else
printf("Value is not defined\n");
#endif
}Predefined symbolic constants
The C language defines a few predefined symbolic constants.
__DATE__- the current date as a string literal in the format "Mmm dd yyyy"__TIME__- the current time as a string literal in the format "hh:mm:ss"__FILE__- the current filename as a string literal__LINE__- the current line number as a decimal constant
#include <stdio.h>
int main(void) {
printf("Date: %s\n", __DATE__);
printf("Time: %s\n", __TIME__);
printf("File: %s\n", __FILE__);
printf("Line: %d\n", __LINE__);
}What to practice next
This note covers a lot of surface area, but reading is not enough. The fastest way to get solid at C is to write small programs that force contact with memory, files, and error handling.
Good practice projects:
- write a calculator that parses command line arguments
- write a dynamic array for integers with
push,get, andfree - write a string helper that copies, trims, and compares strings
- read a text file line by line and count words
- implement a linked list
- implement a hash table
- write tests for tiny functions
- compile every program with
-Wall -Wextra -pedantic - run programs with sanitizers when available
Sanitizers can catch memory bugs while learning:
gcc -Wall -Wextra -pedantic -fsanitize=address,undefined main.c -o mainThat is not a replacement for understanding memory, but it gives much better feedback when something goes wrong.
The main things worth burning into memory:
- know who owns memory
- know when memory stops being valid
- check return values
- keep headers and source files organized
- avoid undefined behavior
- let compiler warnings teach me