When we left off last time, our machine was capable of executing a wide variety of programs! However, the way it runs these programs is still a bit weird… To clearly explain the process by which we execute programs currently, I’ll refer to the physical computer I’m using right now as the “Host”; and the software-implemented computer I built myself—from the CPU down to the runtime environment—as the “Client”.
To run a program on the Client, I must:
- Write the program on the Host: Currently, my Client has no storage functionality, so any program meant to run on it must be stored on the Host.
- Compile on the Host: Again, due to the lack of storage, we must compile the binaries capable of running on the Client within the Host environment.
- Execute the Client on the Host, simultaneously writing the program binary into the Client’s memory: Right now, our Client automatically reads instructions sequentially from memory straight after booting up, and terminates when the program finishes. (The process from compilation to execution is detailed here).
The essence of the Client is evident here: The Client is a program, but one capable of running other programs!
A sharp reader will likely have noticed an incredibly inconvenient aspect: Every time we boot up, we can only execute one program!
In fact, this is exactly how early computers operated: System administrators would load a specific program into the computer (actually punch cards in ancient times), and the machine would execute that program continuously until it finished or the admin manually terminated it. The admin then had to manually load the subsequent program.
How do we solve this? Let’s recall the von Neumann architecture; one of its characteristics is that once a computer finishes executing one instruction, it automatically proceeds to the next. Similarly, is there a way for an administrator to prep an array of programs, so that when the computer finishes running one program, it automatically proceeds to execute the next?
Batch processing systems provide a method for running software programs in batches. After users submit multiple tasks to a batch processing system, it runs in the background and executes these tasks in order. Such a software system running in the background is essentially the simplest form of an operating system.
Thus, our current task is to start with a batch processing system and gradually construct a matured operating system.
New Requirements Imposed by an Operating System
If we think carefully, we’ll realize that the two features mentioned above encompass a hidden requirement: Control flow switching between programs. We know that function calls typically occur within a single program (excluding dynamic linking libraries)—representing an intra-program control flow switch—which can be achieved using a jal (jump and link) instruction. However, the requirement above necessitates control flow switching between the OS and user programs. Yet, fundamentally, control flow switching is merely changing the PC (Program Counter) from one value to another (at least, that’s how hackers see it). Could we simply use a jal instruction to manage execution switching between programs?
Perhaps during the era when the GM-NAA I/O was born, they did exactly that: The OS was merely a library function; when a user program was about to exit, it simply invoked this special library function. Over time, however, people realized an OS is vastly different from other user programs: If a user program faults, the OS can just run the next one; but if the OS crashes, the entire computer system stops working. Therefore, there’s a strong desire to protect the OS, ensuring its robust functionality as much as possible.
Given this context, using jal instructions to context-switch between the OS and user processes appears wildly sloppy. Fundamentally, an OS is still a program comprising functions. Whether a user program acts maliciously or makes an innocent mistake, we do not want it to be able to switch control flow into any arbitrary OS function. What we want is a control flow switch mechanism with restricted entry points. Naturally, this restrictive behavior cannot be implemented via software program code.
Event Handling Mechanism
To prevent programs from switching execution flow to arbitrary locations within the OS, apart from the event handling mechanism, the hardware implements a multitude of other protection-related features. More information on this can be found here.
With robust hardware protection mechanisms in place, user programs can no longer arbitrarily switch control flow into any OS code. However, to construct the simplest OS, hardware must explicitly provide a restricted-entry switching method. This instrument takes the shape of a trap instruction. Upon executing a trap instruction, the program faults directly to a predetermined jump target set up by the OS. This jump target is also referred to as the event entry address.
Concurrently, while the CPU is executing a program, various events might occur. These also mandate routing to a unified handler entry point configured by the OS. ISA manuals normally do not differentiate between explicit traps and CPU-generated events, so we will cumulatively refer to them as “events”.
When an event occurs:
- We must save: the cause of the event (used to handle the event), the location where the event transpired (used to resume execution post-handling), and the CPU’s state at the moment the event occurred.
- We assert the CPU’s PC (a specific register marking the execution location) to a pre-agreed event handler entry address.
- The CPU proceeds horizontally to sequentially perform the instructions within the pre-established event handler program.
- After resolving the event, the PC is redirected back to the location where the event fired, and the CPU state is restored to its pre-event state, permitting the program to resume normal execution.
Thereafter, our machine resumes normal program execution! As far as it’s concerned, the event didn’t even happen (aww, what an adorable little beast!)
This ends PA3.1.