8

I'm looking for a tiny libc (C runtime library) targeting small model DOS 8086, and providing (most of) the C89 library functions, including fread(...), printf(...) and scanf(...). The libc must work with at least one of these C compilers: Watcom C, Borland C++, Microsoft C or Zortech C++. The libc must seperate unrelated functions, so e.g. if printf(...) and scanf(...) are not used in the source code, then they shouldn't be linked into the final program. Also it should be possible to specify that I don't need floating point support in printf(...) and scanf(...), so they will become smaller.

My baseline is the output of the OpenWatcom 2 C compiler:

/* 8894 bytes: owcc -bcom -W -Wall -fsigned-char -fno-stack-check -march=i86 -s -Os -o hellopf.com hellopf.c */
#include <stdio.h>
int main(int argc, char **argv) {
  (void)argc; (void)argv;
  printf("Hello, World!\n");
  return 0;
}
/* 4046 bytes: owcc -bcom -W -Wall -fsigned-char -fno-stack-check -march=i86 -s -Os -o hellowr.com hellowr.c */
/* 2206 bytes: Zortech C++ 3.1 on DOS: ztc -mt -b -o+space hellowr.c */
#include <io.h>
int main(int argc, char **argv) {
  (void)argc; (void)argv;
  (void)!write(1, "Hello, World!\n", 14);
  return 0;
}

FYI here is the memory map of hellowr.com (showing the size of each function and variable): https://gist.github.com/pts/143db336182267eb4e306f6a3c5f354e

Surely the hello-world with write(...) can be implemented in less than 300 bytes, including the parsing of argv and calling the DOS int 21h functions corresponding to write(...) and exit(...). However, the OpenWatcom 2 C compiler generates 4046 bytes, most of which is coming from the OpenWatcom 2 libc. I'm looking for a libc which doesn't generate so much bloat by default, or at least it's configurable.

FYI Turbo Pascal 7.0 can generate a smaller program:

{ 2208 bytes: DOS: tpc hellopas }
{$I-} {$S-} begin write('Hello, World!'#13#10); end.

Example of unnecessary bloat: putchar(...) calls a flush function, which allocates a buffer (if it hasn't existed yet) with malloc(...), which may be in the same object file as free(...) and realloc(...), so all 3 get included. Solution: if the program uses only stdin, stdout and stderr, then preallocate these buffers in bss, and link in a version of a flush function which doesn't call malloc(...).

I've taken a look at the libc as UTILPROG on SAC. Freelib contains functions which can be called from assembly, not C. J.R.Ferguson's C Library uses printf from the libc, it doesn't define one. I haven't found anything else there.

FYI For 32-bit targets (not only x86) there are musl libc and Picolibc and others.

I'm not aiming for the smallest possible program size (because then I would write the program, or at least a custom libc in assembly, see e.g. dosmc), but I'm loooking for a better compromise (i.e. smaller by default) than what the OpenWatcom 2 libc provides, but still providing printf(...), scanf(...) and malloc(...) (and most other C89 functions) if needed by the program.

7
  • Comments are not for extended discussion; this conversation has been moved to chat.
    – Chenmunka
    Jan 23, 2023 at 10:33
  • From experience with embedded devices, if possible, you want to recompile libc without floating point support in printf(), scanf() and friends - it tends to take up a lot of space.
    – jaskij
    Jan 24, 2023 at 22:14
  • @jaskij: Sure, it takes some extra space in the .lib file, but that's not a problem. If it's really huge for you, please share some numbers.
    – pts
    Jan 24, 2023 at 23:43
  • 1
    elixir.bootlin.com/linux/v6.2-rc4/source/tools/include/nolibc There is also a minimal libc in the linux kernel. Jan 28, 2023 at 5:31
  • 1
    "if the program uses only stdin, stdout and stderr, then preallocate these buffers on the heap, and link in a version of a flush function which doesn't call malloc" -- its been a while since I looked at them, but I don't think any of the common DOS linkers would have supported being able to decide between different versions of a function like that. There's no way of describing how to choose which version in the standard file formats, so you'd have to have entirely separate .LIB files for this...
    – occipita
    Jan 28, 2023 at 19:18

2 Answers 2

7

In my experiments, a void main(void) {}, compiled to a .com with @hello.lnk in the wcl arguments, OPTION ELIMINATE, VFREMOVAL inside hello.lnk, and something along the lines of -ei -obehiklnrs -ms -q -wx -zm -zpw for other wcl flags took up 996 bytes, so that'll be the size of Open Watcom's C runtime itself.

Start with Making Watcom C(++) Programs Tiny (written by a demo-coder in 1997), which has various bits of advice.

Unfortunately, since it's from 1997 and by demo-coders, they didn't think about licensing so the smaller Watcom C runtime and math routines they offer aren't suitable for my own projects. (They appear to have blindly copy-pasted "All Rights Reserved" from someone else's copyright declaration without understanding what it meant and then vaguely implied 'use it for whatever' permissions in how they offer it to you.)

I find that, for things where I need to replace libc, it's not particularly difficult to write things like minimal wrappers around the IBM video BIOS APIs. (graph.h is particularly inefficient. In my tests, the base size jumped 30-40KiB with a single call to do a positioned "Hello World!")

... something I later implemented, and so much more, in a 1602-byte "exercise all my int 10h wrappers" .com file that looked like this:

Fancy "Hello World!"

Props to anyone who gets what the style is referencing. Here's a hint:

It's a fictional installer for the CD-ROM release of "Tragic Pachinko".

For example, feel free to use this piece of my wrapper set however you please. (To avoid potential hypocrisy, I'll explicitly put this fragment of my otherwise Zlib-licensed codebase under CC0, though a thanks would still be nice.)

I think it's one of the ones where I already spent my time staring at the wdis output and tweaking it for size... though I'm very much not an assembly programmer. I'm barely a C programmer. (As you might be able to tell from my having reinvented Rust's string slices and used typedefs so splint can enforce a newtype pattern.)

/** Combine foreground and background colors into what `int 10h` expects */
#define MAKE_ATTR(bg, fg) (((uint8_t)bg << 4) + (uint8_t)fg)

#pragma aux draw_str_asm =                                              \
    "push bp"        /* Retain BP and ES to uphold Watcom invariants */ \
    "push es"                                                           \
    "push ss"        /* Set ES to SS when there's no MOV SS, ES */      \
    "pop es"                                                            \
    "mov bp, ax"     /* Set up and call INT 10h/AH=13h */               \
    "mov ax, 1300h"                                                     \
    "mov bh, 0"                                                         \
    "int 10h"                                                           \
    "pop es"         /* Restore original BP and ES */                   \
    "pop bp"                                                            \
    parm [ax] [cx] [dh] [dl] [bl]                                       \
    modify [bh];

void draw_str(str_slice text, position pos,
              ibm_color bg_color, ibm_color fg_color) {
    ASSERT_STR_SLICE(text);
    ASSERT_COORDS(pos);
    ASSERT_COLORS(bg_color, fg_color);

    draw_str_asm(text.data, text.len, pos.row, pos.col,
                 MAKE_ATTR(bg_color, fg_color));
}

(See http://www.ctyme.com/intr/rb-0210.htm or https://fd.lod.bz/rbil/interrup/video/1013.html for details on INT 10h/AH=13h in an online copy of RBIL.)

4
  • Your screen could be one of the installer/compilation CDs from this company... (well I think they did only one, haha) : imgur.com/a/MquJXBJ
    – knol
    Jan 22, 2023 at 14:53
  • Thank you for these awesome tips and tricks for making programs generated by wcc smaller! I've been using similar tricks, but I didn't know all of them. However, I'm not able to accept your answer, because in this question I'm looking for a libc with printf(...) and scanf(...) (and preferably all other C89 functions).
    – pts
    Jan 22, 2023 at 15:08
  • The page in your answer (cubic.org/docs/tiny.htm) has i386 code for Watcom C, but the general ideas there can also be used when targeting the 8086.
    – pts
    Jan 22, 2023 at 15:40
  • @knol You're close. I was copying the CD-ROM release of Epic Pinball. Thus the name of the fictional product in the hint.
    – ssokolow
    Jan 23, 2023 at 5:06
5

It looks like such a tiny libc doesn't exist, and the existing full libcs (implementing most C89 functions) for DOS 8086 have too much bloat. Compared to those, the bloat of the dosmc libc is tiny, but that libc is way too incomplete: it doesn't have printf(...) or scanf(...) (and most other C89 functions).

Plan to write a libc

It would be possible to create such a libc (complete C89, tiny bloat) by starting with the dosmc libc, and adding functions from newlib one-by-one, carefully checking and ensuring that unused functions mustn't contribute any initialization code, and malloc(...) isn't a dependency of the initilization code. Given enough time, some of the newlib functions (e.g. printf(...)) could be rewritten in hand-optimized 8086 assembly, or replaced with simiplified, shorter implementation (available to be chosen by the application programmer at compile time, e.g. printf(...) without floating point support, malloc(...) without free(...)).

There is a tradeoff between execution speed and code size. In the ideal case, the libc has multiple implementations, and the application programmer could choose either on the libc level or on a subsystem (e.g. malloc, stdio) level.

Sometimes the linker is not smart enough to eliminate all unused code and data. Asking the programmer for hints (by macros in the .c source) is one option to mitigate this. Another option is preparing a few alternative object files in the libc, and adding a smart linking step which takes a list of undefined symbols, and runs some custom logic to figure out which of the alternative object files to include.

Minimum libc code size

These best effort estimates if the libc targets the small model (maximum 64 KiB code, maximum 64 KiB data) and is optimized for size (rather than execution speed) are based on prior experience writing on writing tiny libcs:

  • DOS .exe header: 30 bytes; mandatory for DOS .exe programs; for DOS .com files, the header is 0 bytes
  • _start(...) and exit(...): 20 bytes; mandatory
  • argc and argv initialization: 120 bytes: optional, the programmer can turn it off if they don't need argc or argv
  • environ initialization: 100 bytes; optional, the programmer can turn it off if they don't need environ (or getenv(...))
  • initialization of the BSS and the stack with NUL (0) bytes: 20 bytes; needed for reproducibility; the programmer can turn it off for their own risk; some small programs have a very short BSS, whose NUL bytes can be harcoded if the programmer turns off stack initialization
  • write(...), read(...), .close(...) etc.: <20 bytes each
  • population of errno: 20 bytes + 5 bytes for each system call; optional, the programmer can turn it off if they don't need errno
  • errno translation table: <100 bytes; optional, the programmer can turn it off if DOS-specific error codes are good for the programmer (rather than system-agnostic, e.g. ENOENT with the same numerical value on all systems)
  • isxdigit(...), toupper(...) etc.: <20 bytes for each; only if the program uses it
  • memcpy(...), strcmp(...) etc.: <30 bytes each
  • malloc(...) + free(...) + realloc(...) total: >400 bytes; only if the program uses it
  • stdio (e.g. fputs(...)) total except for *printf(...) and *scanf(...): ~1000 bytes; only if the program uses it
  • *printf(...): 700 bytes for minimal version without floating point support for each variant, 1000 bytes for the full version without floating point support for each variant, some of the code can be unified; only if the program uses it
  • *scanf(...): >1000 bytes for each variant without floating point support, some of the code can be unified; only if the program uses it
  • there are some other C89 functions (e.g. qsort(...)) not covered above

Bloat size of existing libcs

Some promising newcomers in addition to the classic libcs (OpenWatcom 2, Borland C++, Microsoft C, Zortech C++):

  • libc of the Amsterdam Compiler Kit. The libc is tied to that compiler by default. Maybe OpenWatcom could compile the ACK libc to fewer bytes.

  • libc of the BCC 0.16.17 C compiler. Command-line on Linux to generate a DOS .com program: bcc -ansi -Md -o hellowc.com hellowr.c. This version of BCC is used to build early versions of ELKS (e.g. 0.2.0), both kernel and userland. Earlier versions of BCC ran natively on DOS.

  • newlib libc (i16newli.zip) for the 8086 accompanying the gcc-ia16 compiler, containing printf(...), scanf(...) and malloc(...).

  • SubC libc from 2018. Most of it is implemented in C, the DOS system-specific parts (crt0-dos-8086.s) are in 8086 assembly. It's not optimized for size, but it's simple and relatively complete. The assembly parts have to be split to separate source files to reduce the unused bloat. It can be used as inspiration and as a baseline when implementing size-optimized libcs.

  • PDOS SuBC libc stage 38 from 2021. A fork of the SubC libc. Most of it is implemented in C, the DOS system-specific parts are in src/lib/crt0.s.

  • PDOS PDPCLIB 4.00 from 2019. A full C89 libc with speed optimizations. Most of it is implemented in C, the DOS system-specific parts (dosstart.asm and dossupa.asm) are in 8086 assembly (in syntax common to TASM, WASM nd MASM). It can be compiled with Turbo C++ (and Borland C++), and OpenWatcom 2. The assembly parts have to be split to separate source files to reduce the unused bloat. It can be used as inspiration and as a baseline when implementing size-optimized libcs.

Unix ports for the 8086 had their own tiny libc supporting a large fraction of the C89 libc functions. (They also had a C compiler as well, which didn't support C89 and generated code bigger than necessary.) It may be worth to port those libcs to DOS, most of the functions (e.g. printf(...) and fwrite(...)) can be left intact. These operating systems are:

  • ELKS 0.6.0: ELKS libc sources
  • ELKS 0.2.0: Both kernel and userland are built with the BCC C compiler, which includes a libc. See the BCC C compiler and libc sources. Newer versions of ELKS are built with gcc-ia16.
  • Minix 1.x and 2.x: Minix 2.0.4 libc sources
  • Coherent 3.x: libc sources and compiled library are included, no free license
  • Xenix 86 2.x: libc sources are not included, .h files and compiled library are included
  • PC/IX 1.0: libc sources are not included, .h files and compiled library are included
  • Venix/86 2.1: libc sources are not included, .h files and compiled library are included

Even when the libc sources are not included, but the compiled library files are (e.g libc.a, libm.a), the library can be disassembled, and the system-specific parts (e.g. write(...)) can be ported to DOS. However, the licensing situation is unclear.

Here are the program file (DOS .com or .exe) sizes for hellowr.c (the .c source with write(...) in the question):

  • Borland C++ 2.0: 4140 bytes, including 448 bytes of unnecessary .exe header padding
  • OpenWatcom 2 with option eliminate: 4034 bytes
  • Microsoft C 6.00a: 3697 bytes, including 448 bytes of unnecessary .exe header padding. The libc is not open source.
  • Pacific C 7.51: 3532 bytes, 224 bytes of unnecessary .exe header padding. The libc is not open source.
  • Zortech C++ 3.1: 2266 bytes, including 454 bytes of unnecessary .exe header padding
  • ACK 2023-01-23: 1184 bytes
  • gcc-ia16 2022-01-09 with newlib: 934 bytes
  • BCC 0.16.17: 864 bytes
  • ELKS 0.6.0 libc ported to DOS, compiled with gcc-ia16: 348 bytes, including argv parsing. Please note that only the bare minimum was ported to DOS (e.g. _start, write, exit, argv parsing) just for this size measurement, and the port is not released.
  • Venix/86 libc ported to DOS: 287 bytes, including argv parsing. Please note that only the bare minimum was ported to DOS (_start, write, exit, _exit, just-ret __cleanu, argv parsing) just for this size measurement, and the port is not released.
  • dosmc with argv parsing: 164 bytes, implementated according to the section about minimums above
  • dosmc without argv parsing: 48 bytes. By using a DOS-specific function instead of write(...), it becomes even smaller (30 bytes).

All of these C compliers have a libc bundled. The size of the actual program code (main(...) calling write(...)) is negligible, most of the bytes are libc bloat.

11
  • 1
    Give Pacific C (grifo.com/SOFT/Pacific/pacific.html , archive.org/details/pacific_c-751-msdos ) a look too. It got freeware'd for inclusion in FreeDOS... 1.1, I think?
    – ssokolow
    Jan 23, 2023 at 5:11
  • 1
    Is there a reason why the size matters so much? You need to fit tons of tools on a single floppy? Jan 23, 2023 at 6:16
  • 1
    Busybody project? You've got me curious but that's a very common word and I'm having trouble searching it up.
    – ssokolow
    Jan 23, 2023 at 18:28
  • 2
    I think it's the BusyBox project.
    – pts
    Jan 23, 2023 at 20:21
  • 1
    @pts First, I'm not sure what you're replying to. That doesn't seem like a natural reply to anything I see myself having said when I scroll back up. Second, that's true, but I've generally found that, by the time that becomes an issue, I'll either have already needed to switch to a different memory models for heap space or be working on something where there's nothing preventing me from using a DPMI extender and, probably, Free Pascal for the Python-esque rich library of included batteries in its DPMI target.
    – ssokolow
    Jan 26, 2023 at 6:18

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .