Programming Principles: Comprehensive Course Notes for C Development and Computer Systems Architecture and C Language Fundamentals

Educational Objective and Competencies of the Programming Principles Course

The Programming Principles course, integrated within the academic frameworks of UTEC, UTU - DGETP, and the Facultad de Ingeniería at UdelaR, is structured as an exhaustive and professional introduction to C programming. The primary objective is to equip students with the ability to read, write, debug, and reason regarding C programs designed to solve real-world problems. This foundation is explicitly described as a prerequisite for mastering more high-level languages such as Java, Python, C++, and Rust. The course is divided into seven strategic axes: understanding computer hardware from a non-magical perspective, professional environment setup (VS Code, GCC, Docker, Git), fundamentals of C syntax and types, control flow (selection and iteration), modular function design, complex data structures (arrays, strings, and structs), and classic algorithms including search, sort, and recursion. By the end of the course, students are expected to implement programs from scratch using conditional and iterative logic, design reusable modular functions with prototypes, and handle multi-dimensional arrays and custom types with typedef. Beyond syntax, the course emphasizes transversal skills such as algorithmic thinking (breaking problems into finite execution steps), systematic debugging by interpreting compiler warnings and testing edge cases, and the responsible use of Artificial Intelligence aids like Claude.ai and GitHub Copilot as educational assistants rather than replacements for fundamental learning.

Computer Architecture and the Memory Hierarchy

To ensure that programming is not perceived as modern alchemy, the course begins with a fundamental leveler regarding hardware architecture. Computers utilize three primary locations for data storage, each characterized by vastly different trade-offs in speed, capacity, and cost. The most immediate storage consists of CPU Registers. A typical CPU may have approximately 3232 registers of 88 bytes each, totaling roughly 256256 bytes. These operate at speeds faster than 11 nanosecond but incur costs in the thousands of USD per GB. The second level is RAM (Random Access Memory), typically ranging from 88 GB to 3232 GB in modern workstations. RAM operates at approximately 100100 nanoseconds, making it 100×100 \times slower than the CPU, with a price point of roughly 55 USD per GB. The final level is the Disk (SSD or HDD), providing massive storage capacities from 256256 GB to several TB. Disk access speed is roughly 0.10.1 milliseconds for SSDs, which is 100,000×100,000 \times slower than RAM, but it is highly economical at approximately 0.050.05 USD per GB. This hierarchy follows a strict rule: smaller and faster storage is located closer to the CPU, while larger and slower storage is further away. The CPU can only perform operations using its registers; therefore, all data stored in RAM or Disk must be moved into registers before processing. An analogy used to describe this interaction is that the CPU is a chef, the registers are the immediate countertop workspace, the RAM is the restaurant's refrigerator, and the disk is the storage warehouse in the basement.

CPU Operations and the Concept of the Byte

The CPU performs calculations through specific cycles involving LOAD and STORE operations. For an operation such as the sum of two integers (r=a+br = a + b), the CPU follows a three-step process: first, it executes a LOAD instruction to move the value from a specific RAM address (e.g., 0x10000x1000) into a register like EAX. Second, it loads the second variable from another address (0x10040x1004) into register EBX. Finally, it performs the ADD instruction, placing the result in directory ECX, which is subsequently STORED back into the RAM address allocated for the result (0x100C0x100C). This mechanical movement of data carries a performance cost. Understanding the data itself requires defining the byte, the minimum unit handled by the computer. One byte consists of 88 bits, which allows for 28=2562^8 = 256 distinct combinations. In decimal terms, an unsigned byte ranges from 00 to 255255, while a signed byte ranges from 128-128 to 127127. A byte such as 0100000101000001 is interpreted based on its context: as an unsigned char it represents the value 6565 (the sum of bits 26=642^6 = 64 and 20=12^0 = 1); as a char it represents the ASCII letter 'A'; as an x86 instruction it represents INC ECX (incrementing a register); and as a color channel, it represents a specific dark shade of red. The data type assigned in C, such as int or char, tells the compiler specifically how to interpret these raw bits.

Memory Management and the Swapping Process

When the fixed capacity of the RAM is exhausted by too many active programs, the Operating System employs a technique known as Swapping. This involves moving "pages" of memory that are not currently in active use from the RAM into a dedicated section of the Disk. This area is known as the swap partition in Linux or pagefile.sys in Windows. While this prevents the system from crashing due to memory depletion, it introduces a massive performance penalty because the disk is 100,000×100,000 \times slower than RAM. Constant movement between RAM and Disk is known as "thrashing," which makes the entire system extremely sluggish. Consequently, for modern computational tasks, having sufficient RAM is often more critical for perceived speed than having a faster CPU clock rate.

Professional Development Environment and Toolchain

The professional C programming environment required for this course is built upon Visual Studio Code (VS Code) as the primary multi-platform editor. Essential extensions include the Microsoft C/C++ tools for IntelliSense and debugging. The core of the environment is the GCC (GNU Compiler Collection). On Windows, it is recommended to use MSYS2 with the MinGW-w64 toolchain, installed via the command pacman -S mingw-w64-ucrt-x86_64-gcc and then adding the bin path to the system PATH environment variable. On Ubuntu or Linux systems, the build-essential package is mandatory, installed via sudo apt install build-essential gdb. macOS users utilize the Xcode Command Line Tools, where gcc acts as a wrapper for Clang, though true GCC can be installed via Homebrew. Verification of a successful installation is performed by running gcc --version in the terminal. Additionally, Docker Desktop is recommended for containerized, uniform environments, and GitHub accounts are required for version control and accessing GitHub Education benefits.

Foundations of C Syntax, Identifiers, and Data Types

Identifies in C must adhere to strict naming conventions: they can only contain letters, digits, and underscores, they cannot begin with a digit, they cannot contain spaces, and they cannot use reserved keywords like int or while. C is case-sensitive, meaning total, Total, and TOTAL are three distinct variables. Data types are categorized by their memory footprint and precision. A char occupies 11 byte and stores characters or small integers. An int typically uses 44 bytes with a range from 2,147,483,648-2,147,483,648 to 2,147,483,6472,147,483,647. For floating-point numbers, float uses 44 bytes (approximately 77 digits of precision) and requires the f suffix (e.g., 3.14f), while double uses 88 bytes (approximately 1515 digits of precision) and is the preferred type for decimal calculations. long is used for larger integers up to 9.2×10189.2 \times 10^{18} and requires the L suffix. Modifiers like unsigned can double the positive range of integer types by removing the sign bit. A critical error in C is using the equality operator == with floating-point numbers due to precision errors (e.g., 0.1+0.20.30.1 + 0.2 \neq 0.3); instead, developers should check if the absolute difference is less than a small constant, EPSILON (fabs(ab)<109\text{fabs}(a-b) < 10^{-9}).

Operator Precedence and Program Lifecycle

The program lifecycle in C involves four distinct stages: 1. Editing (creating the .c source file), 2. Compiling (using gcc -c to create a .o object file), 3. Linking (using gcc -o to bundle object files into an executable), and 4. Executing (running the binary on the OS). During the execution of code, operator precedence determines the evaluation order. The highest precedence is held by parentheses and array access () [], followed by unary operators ! ++ --, then multiplicative operators * / %, additive operators + -, relational operators < <= > >=, equality == !=, and logical AND/OR && ||. The lowest precedence belongs to assignment operators = += -=. A common pitfall is integer division; in the expression double r = 5 / 2;, the result is 2.0 because the division of two integers truncates the decimal. To achieve 2.5, one must use a literal double 5 / 2.0 or cast an operand as (double)5 / 2. The compiler flag -Wall is strongly recommended to detect accidental assignments inside conditionals, such as if (x = 0), which always evaluates to false.

Control Flow: Selection and Iteration Mechanics

Control flow is dictated by selection statements and iterative loops. The if/else if/else structure handles branching, while the switch statement handles multi-way branching based on discrete integer or character constants. Every case in a switch must conclude with a break to prevent "fall-through" behavior, where subsequent cases are executed erroneously. For repetition, while is used when the number of iterations is unknown (e.g., reading until a sentinel value), and do-while ensures the code block runs at least once (e.g., displaying a menu). The for loop is ideal for known iteration counts, such as traversing arrays. Standard loop errors include the "off-by-one" error (looping $n+1$ times instead of $n$) and placing a semicolon immediately after the for or while condition, which results in an empty loop body. The break keyword can exit a loop prematurely, while continue skips the current iteration and jumps to the next evaluation.

Arrays, Multi-dimensional Matrices, and Strings

Arrays in C are fixed-size collections of homogeneous data stored in contiguous memory. Indexing always starts at 00 and ends at n1n-1. Accessing an index outside this range, such as arr[n] for an array of size nn, leads to "Undefined Behavior" (UB) and potential buffer overflows, as C does not perform runtime bounds checking. The name of an array acts as a pointer to its first element. Memory addresses for an index $k$ are calculated as base+k×sizeof(type)\text{base} + k \times \text{sizeof(type)}. Multi-dimensional arrays, or matrices, are stored in row-major order: int m[3][4] creates 33 rows and 44 columns, with the entire first row stored followed by the second. When passing matrices to functions, all dimensions except the first must be specified in the prototype (e.g., void f(int m[][4], int rows)). Strings in C are essentially character arrays terminated by a null character  (0\setminus 0). This terminator is vital; functions like strlen (which returns the count of characters excluding the null) and printf rely on it to identify the end of the text. Strings must never be compared with == or assigned with =; instead, library functions from <string.h> like strcmp and strcpy are required.

Subprograms and Function Design

C utilizes functions to create modular, reusable code. A function consists of a prototype (declaration), a definition (implementation), and a call. Variables declared within a function have local scope and are invisible to other functions, including main. Global variables are visible throughout the file but should be used sparingly. By default, C uses "Pass-by-Value," meaning the function receives a local copy of the argument. Modifications made to the parameter inside the function do not affect the original variable in the calling function. To modify the original, pointers must be used (to be covered in depth later). A function can return at most one value. A function typed as void returns nothing, and a function with void as a parameter receives no arguments.

User-Defined Structures and Type Aliasing

The struct keyword allows programmers to group variables of different types into a single unit. For example, a struct Point could contain two doubles, x and y. Members are accessed using the dot operator . for direct variables or the arrow operator -> for pointers to structs. The typedef keyword is frequently used to create an alias for a struct, removing the need to repeatedly type the struct keyword (e.g., typedef struct {...} Student;). While arrays are passed "by reference" in effect because their name decays to a pointer, structs are passed "by value." This means a complete copy of the internal data is made, which can be computationally expensive for large structures; therefore, passing pointers to structs is generally preferred for performance.

Classic Algorithms: Searching and Sorting

The course covers fundamental algorithms and their efficiency, often described through Big O notation. Linear Search (O(n)O(n)) iterates from start to finish and is the only option for unsorted arrays. Binary Search (O(log n)O(\text{log } n)) is significantly faster but strictly requires the array to be sorted. For an array of 1,000,0001,000,000 elements, the worst-case for linear search is 1,000,0001,000,000 comparisons, whereas binary search requires only 2020. Sorting algorithms include Bubble Sort (O(n2)O(n^2)), which repeatedly swaps adjacent elements if they are in the wrong order, and Selection Sort (O(n2)O(n^2)), which finds the minimum element and moves it to the front. While both have the same complexity, selection sort involves exactly n(n1)2\frac{n(n-1)}{2} comparisons. Bubble sort can be optimized with a "swapped" flag that allows it to terminate early if no swaps occur in a given pass, indicating the array is already sorted.

Recursion and the Call Stack

Recursion is a technique where a function calls itself to solve smaller instances of the same problem. Every recursive function must contain two parts: a Case Base, which stops the recursion, and a Recursive Call, which moves towards the base case. Without a base case, the program enters infinite recursion, leading to a "Stack Overflow" as the limited memory allocated for the call stack (typically 11 to 88 MB) is exhausted. The course highlights the calculation of factorials (n!=n×(n1)!n! = n \times (n-1)!) and Fibonacci sequences as key examples. While recursive Fibonacci is mathematically elegant, its complexity is O(2n)O(2^n), making it exponentially slower than an iterative approach (O(n)O(n)). Other advanced recursive applications mentioned include the Towers of Hanoi and efficient power calculations (O(log n)O(\text{log } n)) using divide and conquer.

Practical Exercises and Course Methodology

The course follows a cycle of 22 hours of theory followed by 22 hours of practice over a 1616-week semester. Practical exercises are divided into two blocks. The first block involves "Skeleton Exercises" where students fill in // TODO markers in provided code snippets. The second block requires students to develop programs "From Scratch" based on a problem statement and specific acceptance criteria. Notable exercises include building an IMC (Body Mass Index) classifier using WHO standards, calculating leap years with composite logic, generating numerical pyramids, approximating π\pi using the Leibniz series (4×(113+1517...)4 \times (1 - \frac{1}{3} + \frac{1}{5} - \frac{1}{7} \text{...})), and implementing Mergesort as a final algorithmic challenge. Assessment occurs through two partial exams (Parcial 1 in week 7/8 and Parcial 2 in week 15/16).