The CPU
What is important to understand is that the CPU comes with a predefined instruction set that it can handle. For example, x86-64 CPUs have a defined set of instructions that they can operate on.
The CPU has a data bus and an address bus. The buses are only pins under the processor and they are connected to metallic lines that are directed towards the pins of the DIMM (Dual Inline Memory Module) or RAM which contains DRAM chips.
The CPU pins (under the CPU) are detailed here https://www.intel.com.br/content/dam/doc/datasheet/core-i5-600-i3-500-pentium-6000-datasheet-vol-1.pdf in the last chapter. Below is an image which shows the upper-left quadrant.
In the upper-left quadrant, you have some SA_DQ pins which are the DDR3 data pins. These pins will be connected by the data bus to the data pins of the RAM memory module's pins. You have something similar for the address pins.
On the address pins is placed an address (0 or 1 on each line). This address will be decoded by the DIMM which will open a path to the DRAM memory cells containing the content at that address. A DRAM memory cell looks like below.
The DRAM cell consists of one transistor controlling the flow of charge and one capacitor containing the charge. Each DRAM cell is one bit of information. You could think of it as if the charge would flow from the capacitor to the CPU register and then the CPU would do operations on the register and then charge back some other capacitors. In reality, it is a bit more complex but you get the point.
For example, you could have the Intel syntax assembly instruction
mov rax, [0x1234]
This instruction will have an encoding in binary (stored as an array of charged/not charged capacitors). When the CPU fetches that instruction, it will place the address of the instruction on the address bus and expect RAM to answer with the instruction on the data bus. Then the CPU will decode the instruction. It will do basic operations on it to determine what it asks. Since the instruction tells the CPU to place the content of address 0x1234 in RAX then the CPU will place that address on the address bus and wait for RAM to answer with the content of that address. It will then charge/discharge the SRAM (Static RAM) cells of the register to now hold the new data. Static RAM is faster and more reliable then DRAM (Dynamic RAM). It uses six transistors instead of one and no capacitor. Registers are made of it (every bit of the register is one SRAM cell).
To understand better how the computer works, you should look at booting because a lot happens there.
Booting
CPUs have reset pins. The reset pins are used by the BIOS to initialize the CPU when you start the computer. The CPU is manufactured to put a certain address in its RIP register when you reset it (the reset vector). The RIP register is the instruction pointer. It contains the address of the next instruction to be fetched, decoded and executed. The CPU will thus execute its first instruction at the reset vector. It is the job of the motherboard to make sure that, at this address, is hardwired the BIOS's first instruction. The BIOS will thus be executed as the first program (software) of the computer. It will start sending instructions to the CPU and tell it to write some stuff in RAM like the ACPI tables.
The ACPI tables are located in predefined conventional locations in RAM that the OS will look for. The OS will then interpret these tables to determine what is plugged in the computer. Like what PCI slots there is, what USB controller and what IOAPIC.
Today most computers boot with UEFI. The BIOS will initialize everything and then jmp to the first instruction of the UEFI program. The UEFI program will set up long mode and paging then execute a UEFI app in a predefined directory on a FAT32 partition. The hardware/firmware manufacturer provides the UEFI program while the OS/software manufacturer provides the UEFI app that the UEFI program will launch. The hard-disk needs to be partitioned with GPT so that the UEFI program embedded in the motherboard can interpret the partitions and jmp to a UEFI app in a certain directory. The OS manufacturers often provide an ISO file which is basically an image of the hard-disk byte by byte. When you burn an ISO to disk, you simply use software to write that ISO byte by byte to the disk as an exact copy.
USB
I think that to understand computers better, you should look at how USB works because most of what happens comes from there (keyboard/mouse). Today, USB is controlled by an Intel's xHCI (https://www.intel.com/content/dam/www/public/us/en/documents/technical-specifications/extensible-host-controler-interface-usb-xhci.pdf). The xHCI is responsible to trigger interrupts when an event happens like when you press a keyboard key. At the hardware level, one (or some) pin of the xHCI is hardwired to the IOAPIC(s) of the processor. The IOAPIC is responsible to trigger an interrupt at the local APIC of one specific CPU core. The IOAPIC can be programmed to trigger certain local APICs depending on where the interrupt happened (what interrupt pin). The OS will determine what pin of the IOAPIC is connected to what by looking at the ACPI tables in RAM at boot. For example, it may find that the xHCI controller is connected to pin 12 of the IOAPIC. It will thus program that pin to fire a certain interrupt number in a specific local APIC.
The CPU has an IDT (interrupt descriptor table). The IDT is a table present in RAM which indicates some information to the processor on what to do with what interrupt number. For example, if the xHCI on pin 12 triggers an interrupt, the OS could decide to map it to interrupt number 10. The processor will thus look in its LIDT register, which stores the physical address of the IDT in RAM, and will jump to the handler of interrupt 10. The handler of interrupt 10 will do something depending on what was connected to this USB plug which has been determined at boot by querying this information from the xHCI.
For a keyboard keypress in hardware, the steps are the following:
The keyboard key is pressed.
The 5 volts provided by the USB port to the keyboard is used as the voltage to trigger a pin on the xHCI.
The xHCI then triggers a pin on the IOAPIC to interrupt the CPU.
The CPU looks in its LIDT register for the address of the IDT.
It fetches the information of the IDT from RAM (or from its cache most probably).
It jumps to the handler of the interrupt number that was programmed by the OS for that interrupt pin.
The handler uses a GetReport request to the HID USB device (https://wiki.osdev.org/USB_Human_Interface_Devices) to gather the info on what happened.
The HID device responds with a serial packet containing the information.
The OS interprets that info and sends to the application, which currently has focus, a message stating that a key was pressed. For example, on Windows 10, every application running is polling a message queue in a loop. When a message arrives (in the form of an int or a macro in C/C++), the message is switched for several messages until the right message is found and then handled by the application. The minimal Windows 10 window application looks like below (in C++).
#include <windows.h>
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam){
switch (uMsg)
{
case WM_DESTROY: //The user closed the application
PostQuitMessage(0);
return 0;
case WM_PAINT: //Message sent when the window is painted at app launch
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
FillRect(hdc, &ps.rcPaint, (HBRUSH) (COLOR_WINDOW+1));
EndPaint(hwnd, &ps);
}
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow){
// Register the window class.
const wchar_t CLASS_NAME[] = L"Window";
WNDCLASS wc = { };
wc.lpfnWndProc = WindowProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;
RegisterClass(&wc);
// Create the window.
HWND hwnd = CreateWindowEx(0, CLASS_NAME, L"Window", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, hInstance, NULL);
ShowWindow(hwnd, nCmdShow);
// Run the message loop.
MSG msg = { };
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
Every application in any language will be translated to something similar after being compiled. It doesn't matter if it is C# or C++ it will use the same API provided by Microsoft to create the window and show controls like buttons. As you can see the application goes into an infinite loop until GetMessage() returns a certain value which tells the app to close (when you close the app). In the meantime, it is receiving messages probably from the USB keyboard/mouse that the interrupt handler will translate to a message and then place the message at the end of the message queue of the application which currently has focus. Even a console application is a window application. The window is the console and your application is just running in a background thread of the console application which itself has a message queue.
Screen
Where you get things wrong I think is when you look at an abstract screen and think it is all magical. In the end, it's the other way around. From boot time to shutdown of the computer, everything is just electrical signals in the computer. The screen is just a representation of what is happening in the computer that software/OS developers determined.
At the hardware level, the screen is often controlled today by a graphics card. Even one which is embedded in the CPU. The graphics card can be accessed using the PCI bus and certain memory mapped registers. Memory mapped registers are hardwired to pins on the graphics card. When you set the address bus to the addresses of memory mapped IO (MMIO), what you output on the data pins (data bus) will be written to the graphics card registers. This way, you can send commands to the different hardware or read data from the hardware connected to PCI. It works the same way for the IOAPIC and the xHCI USB controller.
Using these registers, you can set the address of the framebuffer (Video RAM). The graphics card often has memory within itself to store video data. It is pretty complex and often graphics cards are provided along with their drivers. For example, NVIDIA graphics cards are closed source and there have been attempts to support them on Linux with the Nouveau driver which have been a complete failure. In the end, NVIDIA provided graphics card drivers for Ubuntu Linux and maybe some other distributions (since a driver for Linux should work on Linux). I don't know about the specifics.
The framebuffer will be read at certain rates by the graphics card and the screen refreshed. VGA works similarly but outside the PCI bus. It has separate controllers which is even emulated with today's more recent hardware. The framebuffer works by writing to some addresses in RAM, but writes the data to Video RAM instead which is separate memory.
Compilation
When you write a program in C/C++ (high level languages). You use a lot of abstract human/machine interfaces like the screen, keyboard and mouse. What you write is simply encoded as ASCII/UTF-8 in the machine at all time and stored in a text file. A .c/.cpp file is just a text file with a different extension. The .c/.cpp file will be iterated by the compiler and translated to machine code following the convention that the CPU provides for instruction encoding. It will then be placed in a file with a certain extension and a certain conventional format. For example, on Windows 10 the .exe files are executables. These executables store the virtual addresses at which the code should be placed in memory so that the different execution paths reference each other properly. By execution paths I mean the different functions, ifs, loops etc. In assembly it takes the form of relative jmps to certain virtual addresses. For example, you could have the main function executed at first from virtual address 0x401000. Then the foo() function will be placed at virtual address 0x400000. When you call that function, the CPU will execute something like jmp 0x400000
but it will be relative to the current position in the code (a short jump). In reality it will be more like jmp -0x1000
as depicted in the bochs emulator. Again assembly is just an abstract representation of machine code which has a more direct relationship to it than C/C++. What I mean by that is that you can easily go from assembly to machine code while compiling C/C++ to machine code takes more steps.
Launching an executable on Windows has many steps which could look like the following in hardware:
The user presses a mouse button while having the file explorer opened and with the mouse on the executable's icon.
The xHCI receives an electrical signal and triggers its interrupt line to the IOAPIC.
The IOAPIC fires a certain interrupt number to the local APIC it was programmed to.
The local APIC checks the IDT for the address of the handler for that interrupt number.
The local APIC changes the RIP register to that address which makes the CPU jump to the handler.
The CPU keeps doing its fetch, decode and execute cycle as usual and executes the handler.
The handler will send a message to the message queue of the file explorer.
The file explorer will eventually handle that message which will prompt it to determine what what pressed (what file).
The file explorer will then launch that application using system calls. The kernel will load some parts of that file (or the whole file) to RAM. The kernel will use IO port registers of the DMA controller. The DMA controller will load the data from disk placing the data in RAM. Once done, the hardware (hard-disk) will fire an interrupt.
The OS will then know it's time to launch that application. The OS will interpret the .exe file which is now in RAM and set up the page tables and other structures for the new process taking place.
The OS will put that application in its processes queue and eventually, will make the CPU jump to the first instruction of that process.
The CPU will then be executing the new process instruction by instruction.