Linking, Loading, and Runtime Environment
Learning Objectives
- You know of the work that needs to be done after compilation to create an executable program.
- You know on a high level what happens when loading an executable for execution.
Static and dynamic linking
After code generation, the produced code needs to be linked with other code and libraries to form an executable. The linking can be done statically or dynamically.
During static linking, files and their associated libraries are combined into a single executable that contains all the necessary code and data. This approach simplifies deployment, as the executable is self-contained and can be run without any external dependencies. However, static linking can lead to code duplication if multiple executables use the same libraries, increasing the size of the final binary. Additionally, static linking can make it harder to update shared libraries, as each executable must be recompiled to incorporate the changes.
In contrast, with dynamic linking the executable is linked to shared libraries at runtime, allowing multiple programs to share the same code and data in memory. This approach reduces the size of the executable and simplifies library updates, as changes to shared libraries are automatically reflected in all programs that use them. However, dynamic linking introduces additional complexity, as the operating system must locate and load the shared libraries when the program starts. Furthermore, dynamic linking can lead to versioning issues if different programs require different versions of the same library.
For example, Rust uses static linking by default, although it also supports dynamic linking through the use of shared libraries. Dart, on the other hand, runs on a virtual machine that dynamically loads libraries as needed; on production environments, though, Dart can be compiled to native code with static linking.
Loading
When the program has been linked, it can be executed. The loading process involves taking the executable produced by the linker and placing it in memory for execution. The operating system is responsible for loading the program into memory, setting up the necessary data structures, and preparing the environment for execution. Loading typically involves several steps, including memory allocation, address relocation, and initialization of program resources.
When a program is loaded into memory, the operating system allocates memory for the program’s code and data segments. The code segment contains the machine instructions generated by the compiler, while the data segment holds global variables and other static data. The operating system also sets up a stack for the program’s execution, which is used to store local variables, function parameters, and return addresses during function calls.
The loading can also include relocation and symbol resolution. Relocation involves adjusting memory addresses in the program to account for the actual memory layout at runtime. This process is necessary when the program is loaded at an address different from the one it was compiled for. Symbol resolution, on the other hand, involves mapping symbolic references in the program to their actual memory addresses.
Runtime Support
The concrete execution of a program is supported by a set of routines and services that are provided by the runtime environment. The extent of the support depends on the language, where some language runtime environments are minimal and rely on the operating system for most services, while others are more complex and provide a wide range of services to the extent of creating an abstraction layer on top of the operating system (e.g. virtual machines).
The runtime environment is often responsible for initializing the program’s resources, such as file handles, network connections, and other system resources. This step ensures that the program is ready to execute and can interact with the operating system and other programs. Similarly, the runtime environment can also be responsible for managing memory, handling exceptions, and interfacing with the operating system. In addition, it may also provide services such as dynamic type checking, reflection, and debugging support.
As an example, Rust has a light-weight runtime environment that supports unwinding and panicking, but relies on the operating system for memory management and other services. In contrast, Dart has a more complex runtime environment that includes a garbage collector, a task scheduler, and a set of libraries for working with low-level system resources.
Further, the runtime environments in modern languages are often providing support that depends on the execution mode. For example, the Dart runtime environment can be divided into two parts: one for development and one for production. The development environment includes a virtual machine that dynamically loads changes to the code and provides debugging support, while the production environment compiles the code into native code. Both environments include a garbage collector and a set of libraries for working with the Dart language and the underlying platform.