Programming the Basic Computer
Machine Language
Machine language is the lowest-level programming language, consisting of binary or hexadecimal instructions that a computer's central processing unit (CPU) can directly understand and execute. Each instruction typically represents a specific operation, such as moving data, performing an arithmetic calculation, or changing the flow of control. Because machine language is specific to a particular CPU architecture (e.g., x86, ARM), programs written in machine language for one type of processor will not run on another.
The structure of a machine language instruction usually includes an opcode (operation code) that specifies the action to be performed, and one or more operands that specify the data or memory locations to be used. For example, an instruction might look like `10001011 11000011`, where the first part is the opcode for "add" and the second part specifies the registers or memory addresses involved.
Writing programs directly in machine language is extremely tedious, error-prone, and time-consuming. It requires a deep understanding of the CPU's architecture and is impractical for developing complex software. However, it is the fundamental language that all other programming languages are eventually translated into before execution.
Assembly Language
Assembly language is a low-level programming language that acts as a symbolic representation of machine language. Instead of using binary or hexadecimal codes, assembly language uses mnemonics (short, easily remembered abbreviations) for instructions and symbolic names for memory addresses and data. This makes assembly language much more human-readable and easier to work with than machine language.
For example, instead of a binary opcode for "add," assembly language might use the mnemonic `ADD`. Instead of a memory address like `0x1A3F`, it might use a symbolic label like `BUFFER_SIZE`. An assembler program is used to translate assembly language code into machine language code that the CPU can execute.
Key features of assembly language include:
- Mnemonic codes for operations (e.g., `MOV` for move, `ADD` for add, `JMP` for jump).
- Symbolic names for memory locations and variables.
- Direct control over hardware and memory.
- Processor-specific instructions.
While significantly easier than machine language, assembly language is still considered low-level and requires an understanding of the underlying hardware architecture. It is often used for performance-critical tasks, device drivers, operating system kernels, and embedded systems where direct hardware manipulation or maximum efficiency is required.
Shortcut: Think of assembly language as a direct, one-to-one translation of machine code, but using English-like words (mnemonics) instead of numbers. An 'assembler' is the translator program.
Assembler
An assembler is a utility program that translates assembly language code into machine language code. It reads the source code written in assembly language and converts each mnemonic instruction and symbolic address into its corresponding binary or hexadecimal representation.
The process of assembly typically involves several steps:
- Lexical Analysis: The assembler scans the source code, breaking it down into tokens (keywords, identifiers, operators, etc.).
- Syntax Analysis: It checks if the tokens form valid assembly language instructions according to the defined grammar.
- Semantic Analysis: It verifies that the instructions are meaningful in the context of the target architecture.
- Code Generation: It generates the equivalent machine code instructions and resolves symbolic addresses into actual memory locations.
There are generally two types of assemblers:
- One-Pass Assemblers: These assemblers process the source code once. They typically require that symbols be defined before they are used. If a symbol is used before its definition, the assembler might use a placeholder and update it later.
- Two-Pass Assemblers: These assemblers process the source code twice. The first pass builds a symbol table containing all defined symbols and their addresses. The second pass uses this table to generate the machine code. This approach handles forward references (using a symbol before its definition) more cleanly.
The output of an assembler is an object file, which contains machine code and information needed to link it with other object files or libraries to create an executable program.
Program Loops
A program loop is a fundamental control flow structure that allows a sequence of instructions to be executed repeatedly. Loops are essential for automating repetitive tasks, processing collections of data, and implementing algorithms that involve iteration.
Loops are typically controlled by a condition. The loop continues to execute as long as the condition is true (or false, depending on the loop type) and terminates when the condition is no longer met.
Common types of loops include:
- Count-Controlled Loops: These loops execute a specific number of times. A counter variable is usually incremented or decremented in each iteration until it reaches a predetermined limit.
- Condition-Controlled Loops: These loops continue to execute as long as a certain condition remains true (e.g., `while` loop) or until a certain condition becomes true (e.g., `do-while` loop, `repeat-until` loop).
In assembly language, loops are typically implemented using conditional jump instructions. A counter is initialized, a block of code is executed, the counter is updated, and then a conditional jump instruction checks if the loop should continue.
Example of a simple count-controlled loop in pseudocode:
COUNTER = 5
LOOP_START:
; Code to be repeated
PRINT "Hello"
COUNTER = COUNTER - 1
IF COUNTER > 0 GOTO LOOP_START
LOOP_END:
; Code after the loop
This pseudocode illustrates initializing a counter, executing a block of code, decrementing the counter, and then jumping back to the start if the counter is still positive.
Key Concept: Loops automate repetition. In assembly, they rely on counters and conditional jumps (like `JNE` - Jump if Not Equal, or `JC` - Jump if Carry).
Subroutines
A subroutine (also known as a function or procedure) is a block of code designed to perform a specific task. Subroutines allow programmers to break down complex programs into smaller, manageable, and reusable modules. This promotes modularity, readability, and reduces code duplication.
When a subroutine is called, the program's execution flow is transferred to the beginning of the subroutine. The subroutine performs its task, and then returns control back to the point in the main program where it was called.
Key operations involved with subroutines:
- Call: An instruction that transfers control to the subroutine and typically saves the return address (the instruction to execute after the subroutine finishes) on the stack.
- Return: An instruction that retrieves the return address from the stack and transfers control back to the main program.
- Parameters: Data passed from the calling program to the subroutine to be used in its operations. This can be done via registers or memory locations.
- Return Value: Data returned by the subroutine to the calling program, indicating the result of its operation. This is also typically passed via registers or memory.
The use of a stack is crucial for managing subroutine calls and returns, especially when subroutines call other subroutines (nested calls). The stack ensures that the correct return address is preserved for each level of call.
Example scenario: Imagine you need to calculate the area of a rectangle multiple times in your program. Instead of writing the multiplication and addition logic each time, you can create a `CALCULATE_AREA` subroutine. You pass the length and width as parameters, the subroutine calculates the area, and returns the result.
In assembly language, instructions like `CALL` and `RET` (or `JSR` - Jump to Subroutine, `RTS` - Return from Subroutine) are used. The stack pointer register (`SP`) is heavily involved in managing the stack for return addresses and local variables.
Analogy: Think of a subroutine like a specific tool in a toolbox. You 'call' for the screwdriver when you need to screw something, it does its job, and then you 'return' it to its place, ready for the next time.
Input-Output Programming
Input-Output (I/O) programming deals with how a computer interacts with external devices, such as keyboards, displays, printers, disk drives, and network interfaces. This interaction involves transferring data between the main memory (or CPU registers) and these peripheral devices.
There are several common methods for handling I/O operations:
- Programmed I/O (PIO): In PIO, the CPU is directly involved in every I/O operation. It must execute instructions to transfer data to or from the device. The CPU continuously checks the status of the I/O device (polling) to see if it's ready for data transfer. This method can be inefficient as the CPU spends a lot of time waiting for slow I/O devices.
- Interrupt-Driven I/O: With this method, the I/O device can signal the CPU when it's ready for a data transfer or when an event occurs (like a key press). The CPU is interrupted from its current task, handles the I/O request, and then resumes its original task. This is more efficient than polling because the CPU doesn't waste time checking device status.
- Direct Memory Access (DMA): DMA allows certain hardware subsystems (like disk controllers or network cards) to transfer data directly to or from main memory without involving the CPU. The CPU initiates the transfer by setting up the DMA controller, and then the controller handles the data movement independently. This is the most efficient method for large data transfers, freeing up the CPU for other tasks.
In the context of basic computer programming, especially in assembly language, I/O is often handled through specific I/O instructions or by accessing special memory-mapped I/O addresses.
Memory-Mapped I/O: Some architectures treat I/O devices as if they were memory locations. Specific memory addresses are assigned to device registers (e.g., a data register, a status register). The CPU uses standard memory access instructions (like `LOAD` and `STORE`) to read from or write to these addresses, interacting with the device.
Port-Mapped I/O: Other architectures use dedicated I/O instructions (like `IN` and `OUT`) that operate on I/O ports. These ports are distinct from memory addresses.
Example: Reading a character from a keyboard (conceptual):
- Check the keyboard's status register to see if a key has been pressed.
- If a key has been pressed, read the character's data from the keyboard's data register.
- If no key has been pressed, wait (or perform other tasks) and check again.
This interaction requires understanding device controllers, status flags, and data buffers associated with each peripheral.