Visual C++ 6 linking for NT 3.1

27 May 2017

For years I naievely trusted that Visual C++ 6 would not target anything older than NT 3.51, because the linker defaulted to subsystem 4.0. However, the linker will target an older subsystem if it's explicitly specified. It's important to remember that the subsystem for NT 3.1 is 3.10, not 3.1; the linker will rightly reject an attempt to target 3.1.

Having discovered this, and tried to use it, I noticed that the executable was larger when targeting NT 3.1. This seemed odd to me since it's possible to support older OSes by just changing the subsystem version field. Just to test my sanity, I manually edited the subsystem version field of a 4.0 binary, then took both back to NT 3.1, and they both ran fine. So why the size difference?

The short answer is that the NT 3.1 version contains two more segments than the 4.0 version:

Dump of file sdir31.exe

File Type: EXECUTABLE IMAGE

  Summary

        1000 .bss
        1000 .data
        1000 .idata
        4000 .rdata
        8000 .text

Dump of file sdir40.exe

File Type: EXECUTABLE IMAGE

  Summary

        1000 .data
        4000 .rdata
        8000 .text

There are two new segments: .bss and .idata. However, this raises more questions: what are they, and why are they there?

The what part is straightforward. .bss is a segment that means, "here are symbols that should be initialized to zero. Don't bother recording the zeroes in the file, just make them when the program starts." As an optimization, that makes complete sense; if a program had a lot of zero data, this would be a big win. In sdir's case though, it's not a win at all, because there's just not much data like that. Remember, all segments with data need to be aligned to some value, in this case 512 bytes; so if a program has a small amount of data to be zero initialized and another segment has free space for it, it's more efficient to just store the zeroes and eliminate this section. That seems to be true for .bss in sdir, and it seems to be what the 4.0 linker is doing:

SECTION HEADER #2
    .bss name
      34 virtual size

SECTION HEADER #4
   .data name
      C8 virtual size
    E000 virtual address
     200 size of raw data

The other new section is .idata. .idata means, "this is writable data that's initialized to some value, and by convention, describes the import table." The convention part is interesting, because there's already a segment that contains writable data, and it's just as capable of containing an import table as anything else, so it makes sense to just combine the two.

SECTION HEADER #4
   .data name
      C8 virtual size
...
C0000040 flags
         Initialized Data
         Read Write

SECTION HEADER #5
  .idata name
     6E6 virtual size
...
C0000040 flags
         Initialized Data
         Read Write

So the NT 3.1 version contains 0x34 bytes in a .bss segment, 0xC8 bytes in a .data segment, and 0x6E6 bytes in a .idata segment. These need 0x200 + 0x800 bytes on disk (for .data and .idata) plus the headers for the three. Combining these means 0x7E2 bytes, which needs 0x800 bytes on disk, eliminating 0x200 bytes, as well as two sets of headers.

Edit: One other consequence of this is that each section needs to be aligned on its own 4Kb page, so adding two extra sections increases the amount of writable memory in the process by 8Kb. Not huge, but also not necessary; the same optimizations that decrease disk footprint also decrease memory footprint.

Going back to why though, I don't think I'll ever know. It really looks like asking for a 3.1 binary just disabled a pile of link time optimizations. Those were just as effective on NT 3.1 as 4.0, as evidenced by the fact that the 4.0 program runs just fine on 3.1. It's possible that there is some environment out there (Win32s perhaps?) that really needs a seperate .idata, but I haven't seen it yet. .idata appeared to go away when moving from Visual C++ 5 to Visual C++ 6 but Visual C++ 6 was still supporting Windows 95 and NT 3.51, so the .idata requirement can't have existed on those platforms, and Visual C++ 5 didn't support anything else. The .bss elimination optimization is a total no-brainer: it's inconceivable to imagine a system that is capable of initializing data to values through .data but has a hard dependency on any initialization to zero to be performed through .bss.

My best guess is that in order to maintain compatibility with older OSes the linker team just forked a pile of logic for "new" platforms and enhanced that logic while leaving the old logic untouched. That's most unsatisfying because it implies there's a set of things that older OSes really do need to be performed differently, which I just haven't found yet.