..

Part I: C Fundamentals

Part I lays the groundwork for your C journey. You’ll set up your development environment, write and compile your first C program, and explore the fundamental syntax and semantics of the language. This section is designed to leverage your existing programming knowledge while introducing you to C’s unique characteristics.

Table of Contents

1. Getting Started (your first C program)

2. Language Primitives & Expressions (the building blocks)

3. Functions & Program Structure (organizing your code)


1. Getting Started (your first C program)

Welcome to the world of C! As an experienced C# developer, you’re used to a rich, managed environment where a lot of the low-level complexity is handled for you by the .NET runtime. This guide will help you build a new mental model for programming, one that gives you a deeper understanding of how computers and operating systems truly work. Think of this journey not as a step backward, but as a descent into the foundations of modern computing. C is the bedrock upon which many languages, including C#, were built.

1.1. C History, Standards, and Motivation

The C programming language was created by Dennis Ritchie at Bell Labs in the early 1970s. Its primary purpose was to develop the Unix operating system. This origin story is crucial, as it explains C’s core philosophy: it’s a systems programming language designed for direct access to hardware and memory, with minimal abstraction and a focus on efficiency.

Unlike C#, which is managed by a runtime that handles memory allocation and garbage collection, C gives you complete control. This is both its power and its primary challenge. Learning C will help you:

C is standardized by the ANSI C and later ISO/IEC committees. This guide will focus on modern C standards, primarily C99 and C11, which introduced features like inline functions and better support for variable-length arrays. The core language has remained remarkably stable for decades.

1.2. Installing a C Toolchain on Windows (MinGW/MSVC)

A C# developer uses the dotnet CLI, which includes a compiler and runtime. A C developer needs a toolchain, a suite of tools including a compiler, linker, and build system. For this guide, we will use a GCC-based toolchain, as it is the most common compiler suite for C and cross-platform development.

For Windows, the most straightforward option is MinGW-w64 (Minimalist GNU for Windows), a port of the GCC compiler.

  1. Download and Install MinGW-w64: Go to the official MinGW-w64 download page and download a suitable installer. The x86_64-posix-seh flavor is a good general-purpose choice for 64-bit systems.
  2. Add to Path: This is the most important step. Once installed, you need to add the bin directory of your MinGW installation to your system’s PATH environment variable. This allows you to run gcc from any command prompt.
    • Find the installed directory (e.g., C:\Program Files\mingw-w64\x86_64-8.1.0-posix-seh-rt_v6-rev0\mingw64\bin).
    • Search for “Edit the system environment variables” in the Windows Start menu.
    • In the System Properties dialog, click “Environment Variables…”.
    • Under “System variables,” find the Path variable, select it, and click “Edit…”.
    • Click “New” and paste the path to your bin directory. Click “OK” on all windows.
  3. Verify the Installation: Open a new command prompt or PowerShell window and type gcc --version. If the installation was successful, you’ll see version information.

Alternatively, you can use the Visual C++ toolchain provided with Visual Studio. It’s fully integrated and powerful but uses a different set of command-line tools. We will use GCC commands throughout this guide for consistency.

1.3. Installing a C Toolchain on Linux (GCC/Clang)

Linux distributions almost always come with a C compiler pre-installed or easily accessible through their package managers. We will focus on the GNU Compiler Collection (GCC).

To install GCC on a Debian/Ubuntu-based system, open a terminal and run: sudo apt update && sudo apt install build-essential

The build-essential package installs GCC, the GNU debugger (gdb), and other tools necessary for compiling software.

On a Fedora/Red Hat-based system, use dnf: sudo dnf install gcc

Clang is another excellent, modern C compiler often used as an alternative to GCC. You can install it with sudo apt install clang (Debian/Ubuntu) or sudo dnf install clang (Fedora). Both GCC and Clang will work for all examples in this guide.

1.4. Choosing an IDE: VS Code, CLion, Visual Studio

You don’t need a heavy IDE to write C, but a good editor with syntax highlighting and debugging support is a huge productivity booster.

For the purposes of this guide, we will stick to a text editor and the command line. This approach forces you to understand the fundamental compilation and linking process, which is the cornerstone of C development.

1.5. Compiling and Running “Hello, World!”

Let’s write our first C program. Open your text editor and create a file named hello.c.

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

Now, let’s break down this tiny program.

Now, let’s compile and run the program from your terminal or command prompt. Navigate to the directory where you saved hello.c.

The Compilation Process

The compilation process for C is a multi-step journey. Unlike C#, where the dotnet build command abstracts the process, you must be more explicit in C.

  1. Preprocessing: The preprocessor handles directives like #include and #define. It expands the source code, including the contents of header files.
  2. Compilation: The compiler translates the preprocessed C source code into assembly language, a low-level language specific to your computer’s architecture.
  3. Assembly: The assembler converts the assembly code into machine code, creating an object file (.o or .obj).
  4. Linking: The linker combines your object file with other object files (e.g., the pre-compiled code for printf from the C Standard Library) to produce a single, executable file (.exe on Windows, or no extension on Linux/macOS).

Running the Program

To compile and link with GCC, type the following command:

gcc hello.c -o hello

If you don’t get any output, that’s a good sign—it means the compilation was successful. Now, to run the program, type:

You should see the output: Hello, World!

Understanding Compiler Flags

For a more in-depth understanding of GCC and its myriad options, refer to the GCC documentation. Key flags you’ll frequently use include:

1.6. Basic Coding Style and Formatting

While C is a very flexible language, adopting a consistent coding style early on is a great habit.

// A good example of C coding style
#include <stdio.h> // Include standard I/O library

int calculate_sum(int a, int b) {
    // This function returns the sum of two integers.
    int sum = a + b;
    return sum;
}

Key Takeaways

Exercises

  1. Modify and Recompile: Modify your hello.c program to print your name instead of “Hello, World!”. Compile and run the new program.

    • Hint: The compilation command does not need to change.
  2. Add a Comment: Add a comment to your hello.c program that explains what the printf function does. Compile and run it again.

    • Hint: C supports both single-line (//) and multi-line (/* ... */) comments, just like C#.
  3. Explore Compiler Warnings: Intentionally create a compile-time error by removing the semicolon at the end of the printf statement. Try to compile the program. What error message does the compiler give you?

    • Hint: The compiler will point you to the line and character where it encountered the error, often suggesting what it expected to see. This is your first introduction to a core C skill: reading and interpreting compiler output.

2. Language Primitives & Expressions (the building blocks)

In C#, you work with a well-defined set of language primitives like int, long, double, and char, all of which have a fixed size guaranteed by the .NET specification. In C, things are a bit more flexible—and consequently, more complex. This flexibility is a direct consequence of C’s design for a wide range of hardware platforms. This chapter will demystify C’s basic types, literals, and operators, constantly comparing them to their C# counterparts to help you build a correct mental model.

2.1. Fundamental Integer and Floating-Point Types

Unlike C#, where int is always 32 bits, the size of C’s primitive types is implementation-defined. The C standard only guarantees a minimum size and that certain types are at least as large as others. This requires you to think about the underlying hardware.

The sizeof operator is your most valuable tool for discovering a type’s size on your current system. It returns the size in bytes.

#include <stdio.h>

int main() {
    printf("Size of char:        %zu bytes\n", sizeof(char));
    printf("Size of short:       %zu bytes\n", sizeof(short));
    printf("Size of int:         %zu bytes\n", sizeof(int));
    printf("Size of long:        %zu bytes\n", sizeof(long));
    printf("Size of long long:   %zu bytes\n", sizeof(long long));
    printf("Size of float:       %zu bytes\n", sizeof(float));
    printf("Size of double:      %zu bytes\n", sizeof(double));
    printf("Size of long double: %zu bytes\n", sizeof(long double));
    return 0;
}

Note: The %zu format specifier is used for size_t types, which is what sizeof returns.

The output on a typical 64-bit system might look like this:

Size of char:        1 bytes
Size of short:       2 bytes
Size of int:         4 bytes
Size of long:        8 bytes
Size of long long:   8 bytes
Size of float:       4 bytes
Size of double:      8 bytes
Size of long double: 16 bytes

Minimum Sizes Guaranteed by the C Standard

For more details see this wikipedia article.

Integer Types

Floating-Point Types

Fixed Width Integer Types

C99 introduced fixed-width integer types in the <stdint.h> header, allowing you to specify integers with exact sizes (e.g., 8, 16, 32, or 64 bits). This is especially useful for cross-platform code, binary protocols, and low-level programming where you need precise control over data size.

Common fixed-width types include:

There are also “least” and “fast” types (e.g., int_least16_t, int_fast32_t) for minimum or fastest types of at least a given width, and intptr_t/uintptr_t for pointer-sized integers.

Example: Using fixed-width integer types

#include <stdio.h>
#include <stdint.h>

int main() {
    int8_t a = -100;
    uint16_t b = 50000;
    int32_t c = 123456789;
    uint64_t d = 1234567890123456789ULL;   // ULL means unsigned long long

    printf("int8_t a = %d\n", a);
    printf("uint16_t b = %u\n", b);
    printf("int32_t c = %d\n", c);
    printf("uint64_t d = %llu\n", d);

    return 0;
}

For printing these types, use the correct format specifiers (%d, %u, %lld, %llu, etc.) and consider including <inttypes.h> for portable macros like PRId64.

#include <inttypes.h>

printf("Using inttypes.h macros:\n");
printf("int8_t a = %" PRId8 "\n", a);
printf("uint16_t b = %" PRIu16 "\n", b);
printf("int32_t c = %" PRId32 "\n", c);
printf("uint64_t d = %" PRIu64 "\n", d);

Fixed-width types help ensure your code behaves consistently across different platforms, unlike the basic types whose sizes may vary.

The size_t Type: What It Is and Why It Matters

One of the most common types you’ll encounter in C, especially when working with memory, arrays, and the standard library, is size_t.

Example: Using size_t with sizeof and strlen

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

int main() {
    char message[] = "Hello, C!";
    size_t length = strlen(message); // Returns the length as size_t

    printf("The message is %zu characters long.\n", length);
    printf("The size of the array is %zu bytes.\n", sizeof(message));

    return 0;
}

Note: Use the %zu format specifier with printf to print size_t values portably.

Why not just use int or unsigned int?
Using size_t ensures your code is portable and can handle the full range of possible object sizes on any platform. Mixing size_t with signed types can lead to compiler warnings or subtle bugs, so always use size_t for sizes and counts.

2.2. Literals: char, int, double, and Suffixes

A literal is a constant value written directly in your code. While C# has similar concepts, C’s type system requires careful use of suffixes to prevent unexpected behavior.

2.3. Operators, Precedence, and Expressions

C’s operators are remarkably similar to those in C#, which is no surprise given that C# borrowed much of its syntax from C.

The rules for operator precedence and associativity are virtually the same as C#.

The bool Problem: Integers as Booleans

This is a critical point for C# developers. C does not have a native bool type until the C99 standard, and even then, it’s just a macro for an integer type. In C, any non-zero integer is considered true, and 0 is considered false.

#include <stdio.h>

int main() {
    int x = 5;
    int y = 0;

    if (x) { // This is true because x is non-zero
        printf("x is true\n");
    }

    if (y) { // This is false because y is zero
        printf("y is true\n");
    }

    // Logical operators return 0 or 1
    printf("!5 is %d\n", !x); // Prints 0
    printf("!0 is %d\n", !y); // Prints 1

    return 0;
}

The standard library header <stdbool.h> (part of C99) defines bool, true, and false as a convenience. The size of bool is typically 1 byte, but it can vary by implementation.

2.4. The Ternary Conditional Operator (? :)

The ternary operator is a familiar friend from C#. It provides a concise way to write a conditional expression.

Syntax: condition ? value_if_true : value_if_false;

#include <stdio.h>

int main() {
    int temperature = 25;
    const char* weather = (temperature > 20) ? "Warm" : "Cool";

    printf("The weather is %s.\n", weather); // Prints "The weather is Warm."

    return 0;
}

This operator works identically to its C# counterpart.

2.5. Type Conversions: C# (int)x vs C’s Type Casting

Casting in C has the exact same syntax as in C#.

#include <stdio.h>

int main() {
    double pi = 3.14159;
    int rounded_pi = (int)pi; // Explicit cast, just like C#

    printf("Original double: %.4f\n", pi);
    printf("Rounded int: %d\n", rounded_pi);

    return 0;
}

However, C also has complex implicit conversion rules (also known as “type promotion”) that can be a source of subtle bugs. For example, when an operation involves a float and a double, the float is automatically promoted to a double. The compiler will often handle this for you, but it’s crucial to be aware of what’s happening.

A common pitfall is integer division:

#include <stdio.h>

int main() {
    int a = 10;
    int b = 4;
    double result_incorrect = a / b; // Integer division, result is 2.000000
    double result_correct = (double)a / b; // Cast before division, result is 2.500000

    printf("Incorrect result: %f\n", result_incorrect);
    printf("Correct result: %f\n", result_correct);

    return 0;
}

The expression a / b is evaluated using integer arithmetic before the result is assigned to the double. This is a classic C bug. You must cast one of the operands to a floating-point type before the division occurs.

2.6. Negative Numbers: Two’s Complement Representation

In C#, you rarely have to think about the binary representation of numbers. In C, a deeper understanding is essential. All modern computers use a system called two’s complement to represent signed integers.

Let’s use an 8-bit char for our example. An 8-bit number has a range of $2^8 = 256$ possible values. In a signed char, this range is from -128 to 127.

How to find the two’s complement of a negative number:

  1. Find the binary representation of the positive number. For example, 5 is 0000 0101 in 8-bit binary.
  2. Invert all the bits. Change all 0s to 1s and 1s to 0s. This is the one’s complement. 0000 0101 becomes 1111 1010.
  3. Add 1 to the result. 1111 1010 + 1 = 1111 1011.

Therefore, 1111 1011 is the two’s complement representation of -5.

The highest bit (the leftmost one) is the sign bit. If it’s 0, the number is positive. If it’s 1, the number is negative. This representation makes arithmetic simple for the CPU. Integer overflow, for example, is simply a matter of the sign bit “flipping” when the number exceeds its maximum range.

Key Takeaways

Exercises

  1. Size Exploration: Write a program that prints the minimum and maximum values for signed int, unsigned int, and long long using the macros defined in <limits.h> (e.g., INT_MAX, INT_MIN). Compare these values to the ranges you’d expect from their sizes on your system.

    • Hint: You’ll need to #include <limits.h>.
  2. Casting Challenge: Write a program that calculates the average of two integers, a = 5 and b = 2, and stores the result in a float variable. Demonstrate both the incorrect and correct ways to do this using type casting.

    • Hint: The incorrect way will perform integer division and then convert the result. The correct way will cast one of the operands before the division.
  3. Two’s Complement Proof: Write a program that initializes a char variable with a value of -1 and prints its value as an int using printf("%d\n", my_char). Then, use bitwise operators (which will be covered in a later chapter) to print its binary representation. See if the binary output matches the two’s complement representation of -1 (which is all 1s for an 8-bit number).

    • Hint: This exercise is an excellent bridge to future chapters. For the binary printing, you can loop through the bits and use a logical right shift (>>).

3. Functions & Program Structure (organizing your code)

In C#, the compiler and IDE handle most of the program structure for you. You don’t typically need to worry about the order in which you define classes or methods within a file. C, however, requires a more deliberate approach to organizing your code, particularly with functions. Understanding the concepts of function declaration, definition, scope, and the main entry point is fundamental to writing correct and maintainable C programs.

3.1. Function Declaration vs. Definition

This distinction is perhaps the most important new concept for a C# developer. In C, a function must be declared (its prototype must be known) before it can be called. The function’s definition (its implementation) can appear later. This is different from C#, where the compiler can generally find any method definition within a project, regardless of where it’s located.

A function declaration (or prototype) provides the compiler with the function’s signature: its name, return type, and the number and types of its parameters.

A function definition provides the actual body of the function.

Let’s see what happens without a declaration:

#include <stdio.h>

int main() {
    // This will cause a compiler error because square() has not been declared yet.
    int result = square(5);
    printf("Result: %d\n", result);
    return 0;
}

int square(int x) {
    return x * x;
}

When you try to compile this code, gcc will produce an error like “implicit declaration of function ‘square’”. The compiler doesn’t know what square is when it encounters the call in main.

To fix this, we provide a declaration (or prototype) for square before main:

#include <stdio.h>

// Function Declaration (Prototype)
int square(int x);

int main() {
    int result = square(5);
    printf("Result: %d\n", result);
    return 0;
}

// Function Definition
int square(int x) {
    return x * x;
}

Now the code compiles and runs correctly. The compiler sees the declaration, knows that square exists and what its signature is, and can then safely proceed to compile main. The linker will later find the actual function body and link everything together.

3.2. Header Files (.h) vs. Source Files (.c)

For multi-file projects, putting all function prototypes at the top of every file is unmanageable. The C solution is to use header files (.h) to centralize all function declarations. This is C’s form of an API contract.

Let’s refactor our square example into a multi-file project.

utils.h (the header file)

#ifndef UTILS_H
#define UTILS_H

// Function declaration
int square(int x);

#endif

Note: The #ifndef, #define, and #endif lines are include guards. They prevent the compiler from including the same header file multiple times in a single compilation unit, which would cause errors. This is standard practice in C.

utils.c (the source file)

// We include our own header file to check for consistency
#include "utils.h"

// The function definition (implementation)
int square(int x) {
    return x * x;
}

main.c (our main program)

#include <stdio.h>
#include "utils.h" // Include our custom header file

int main() {
    int num = 5;
    int result = square(num); // This call works because we included utils.h
    printf("The square of %d is %d.\n", num, result);
    return 0;
}

To compile this project, you must tell the compiler about both source files.

gcc main.c utils.c -o app

The compiler will compile main.c and utils.c into temporary object files, then the linker will combine them into a single executable named app.

3.3. Scope and Lifetime of Local Variables

In C, variables have different storage durations and scopes.

  1. Automatic Lifetime (Stack): The default for local variables inside a function. They are created when the function is called and destroyed when the function returns. Their scope is limited to the function or block they are declared in. This is similar to C#’s value types on the stack.

  2. Static Lifetime (Static Data Segment): A variable declared with the static keyword inside a function retains its value between function calls. Its lifetime extends for the entire duration of the program, but its scope remains local to the function.

#include <stdio.h>

void counter() {
    static int count = 0; // 'static' retains its value between calls
    count++;
    printf("Static count: %d\n", count);
}

void normal_counter() {
    int count = 0; // 'count' is re-initialized to 0 on every call
    count++;
    printf("Normal count: %d\n", count);
}

int main() {
    counter();        // Prints "Static count: 1"
    counter();        // Prints "Static count: 2"
    normal_counter(); // Prints "Normal count: 1"
    normal_counter(); // Prints "Normal count: 1"
    return 0;
}

This is a key difference from C#’s static keyword, which typically relates to class-level members shared across all instances. In C, static has multiple meanings based on context, and its use for local variables is an important pattern.

3.4. The main Function: argc and argv

In C#, your program can receive command-line arguments through the string[] args parameter of the Main method. C uses a similar, but more explicit, mechanism via the main function’s parameters.

The full signature of main is: int main(int argc, char *argv[])

The argv array always contains at least one element: argv[0], which is the name of the executable itself. The actual arguments start at argv[1].

#include <stdio.h>

int main(int argc, char *argv[]) {
    printf("Number of arguments: %d\n", argc);

    printf("Program name: %s\n", argv[0]);

    if (argc > 1) {
        printf("Arguments passed:\n");
        // Loop through the arguments, starting from the second one
        for (int i = 1; i < argc; i++) {
            printf("  - %s\n", argv[i]);
        }
    }

    return 0;
}

Note: We use the %s format specifier in printf to print a C string. We’ll dive into what C strings are in the next chapter.

If you compile this program as args_example and run it from your terminal:

./args_example hello world

The output would be:

Number of arguments: 3
Program name: ./args_example
Arguments passed:
  - hello
  - world

Key Takeaways

Exercises

  1. Multi-File Project: Create a new project with two files: math_utils.h and main.c. In math_utils.h, declare a function int add(int a, int b);. In main.c, provide a definition for add and call it from main to calculate 10 + 5. Compile and run the project.

    • Hint: The compilation command will be gcc main.c -o my_program. You do not need a separate .c file for the definition.
  2. Stateful Function: Write a function get_id() that uses a static int to return a unique, incrementing ID on each call. The first call should return 1, the second 2, and so on. Call the function three times from main to prove it works.

    • Hint: Initialize the static variable to 0 and increment it before returning its value.
  3. Argument Counter: Write a program that takes a variable number of command-line arguments and prints out the total number of characters in all arguments combined (excluding the program name).

    • Hint: You can use a for loop from i = 1 to i < argc. For the length of each string, you can use the standard library function strlen from the <string.h> header. The next chapter will cover this more formally, but feel free to use it now.

Where to Go Next