How Windows 3.11 Loads DLLs at Startup

This article provides an overview of how Microsoft Windows 3.11 handled dynamic link libraries (DLLs) during application startup. Running in 16-bit protected mode, Windows 3.11 relied on its core kernel to parse the New Executable (NE) file format, identify dependent modules, resolve exported functions, map shared memory segments, and execute library initialization code before passing control to the primary application.

Parsing the NE Header and Module References

When a user or process launched an application in Windows 3.11, the operating system's kernel (KRNL386.EXE) took control of the process initialization. Executables in Windows 3.11 adhered to the 16-bit New Executable (NE) format.

During the initial read of the application's executable file:

  1. The kernel parsed the NE header to locate the Module Reference Table.
  2. This table contained offsets pointing to the names of all external DLLs required by the application.
  3. The kernel read each library name to determine which external dependencies needed to be resolved before the program could execute.

Locating and Loading Modules

For each library identified in the Module Reference Table, Windows checked its internal module list to see if the DLL was already loaded into memory. Because Windows 3.11 utilized a single, shared address space for all running applications, a DLL only needed to be physically loaded once.

  • If already in memory: The kernel simply incremented the DLL's reference usage counter and reused the existing module handle.
  • If not in memory: The system searched for the DLL file on disk using a fixed sequence:
    1. The current working directory.
    2. The Windows directory (e.g., C:\WINDOWS).
    3. The Windows System directory (e.g., C:\WINDOWS\SYSTEM).
    4. The directory containing the executable.
    5. The directories listed in the DOS PATH environment variable.

If a required DLL could not be found, Windows halted the startup sequence immediately and displayed a fatal error indicating the missing file.

Segment Loading and Relocation

Once the DLL file was found, the kernel loaded its individual segments into system memory. Unlike flat 32-bit binaries, 16-bit NE files were divided into distinct code and data segments, each limited to 64 KB.

  1. Selector Allocation: The kernel allocated protected-mode descriptors (selectors) from the Global Descriptor Table (GDT) or Local Descriptor Table (LDT) for the library's segments.
  2. Applying Fixups: The loader read the relocation/fixup table at the end of each segment. Windows 3.11 patched memory addresses within the code and data to ensure pointers properly pointed to the assigned runtime selectors and offsets.
  3. Data Segment Allocation: Depending on whether the DLL was compiled with MOVEABLE, FIXED, PRELOAD, or DEMANDLOAD attributes, the kernel mapped the segments accordingly. By default, DLLs in Windows 3.11 shared a single data segment across all applications calling them, unless specifically engineered otherwise.

Import Resolution and Ordinal Binding

After the segments were positioned in memory, the loader resolved the application's function calls to the DLL's entry points.

The NE format supported two methods for exporting functions:

  • By Name: Slower and consumed more memory, as the loader had to perform string comparisons against the DLL's Non-Resident Name Table.
  • By Ordinal: A direct numeric index into the DLL's Entry Table. Windows 3.11 developers heavily favored ordinal binding because it bypassed string searches, allowing the kernel to rapidly patch the calling application's jump tables with the exact segment and offset of the target function.

Executing Initialization (LibMain)

Before returning control to the primary application's entry point (WinMain), the kernel verified if the loaded DLL was running for the first time.

If the DLL had just been loaded from disk:

  1. The kernel initialized the DLL's local heap if defined in its module definition file.
  2. The runtime called the DLL's initialization function, traditionally named LibMain (the 16-bit predecessor to DllMain).
  3. The DLL executed startup tasks, such as registering custom window classes, allocating shared resources, or setting up internal data structures.

If LibMain returned a success code (non-zero), the initialization continued. If it returned zero, Windows aborted the loading process and unloaded the dependent modules. Once all dynamic link libraries were mapped, relocated, bound, and initialized, the kernel transferred execution to the application's startup code.