On the side, I’ve been wrapping up some improvements to the classic Unix stdio libraries in illumos. stdio contains the classic functions like fopen(), printf(), and the security nightmare gets(). While working on support for fmemopen() and friends I got to reacquaint myself with some of the joys of the stdio ABI and its history from 7th Edition Unix. With that in mind, let’s dive into this, history, and some mistakes not to repeat. While this is written from the perspective of the C programming language, aspects of it apply to many other languages.

APIs and ABIs

Before we dive into a discussion of the stdio ABI, let’s first talk about APIs and ABIs. In programming, many people talk about application programming interfaces (APIs). APIs define how a program can call into another piece of functionality. In C, APIs are often contained in header files and these are documented in manual pages. For example, here are some API declarations in C:

extern int fstat(int struct stat *);
extern void *(size_t);
extern int fputc(int, FILE *);

The API names a function and describes the types of the parameters and the return value of the function. These declarations (and dependent headers) are all that one needs to write a C program. While the API is enough to write a program, when the compiler and link-editor get to work and you actually run your program, you need to rely on something else entirely: the ABI.

The application binary interface (ABI) describes a lot of aspects of the program that are required to have it run. For example, when you call into libc, where are the arguments found? Are they found on the stack? Are they found in registers? The ABI also describes certain things like how many bytes comprise an int and how should one lay out a structure.

Now, a large amount of the ABI is standardized in different documents for a given platform. For example, many Unix-based systems follow the System V ABI. This ABI defined many aspects of systems such as ELF (executable and linkable format) and dynamic linking. Portions of the ABI were relegated to processor-specific parts. These describe the use of registers, the alignment of types, and more. Some of the more interesting documents include:

These documents don’t describe everything that one needs. For example, while Linux, illumos, and the various BSDs all use the same amd64 calling conventions and ELF-based binaries and libraries, there are many things that each system uniquely defines. For example, let’s compare the struct stat used in illumos and OpenBSD. I’ve placed the two side by side, though I’ve trimmed out a bunch of pre-processor macros:

illumos                                         | OpenBSD
                                                |
struct stat {                                   | struct stat {
        dev_t           st_dev;                 |         mode_t    st_mode;
        ino_t           st_ino;                 |         dev_t     st_dev;
        mode_t          st_mode;                |         ino_t     st_ino;
        nlink_t         st_nlink;               |         nlink_t   st_nlink;
        uid_t           st_uid;                 |         uid_t     st_uid;
        gid_t           st_gid;                 |         gid_t     st_gid;
        dev_t           st_rdev;                |         dev_t     st_rdev;
        off_t           st_size;                |         struct  timespec st_atim;
        timestruc_t     st_atim;                |         struct  timespec st_mtim;
        timestruc_t     st_mtim;                |         struct  timespec st_ctim;
        timestruc_t     st_ctim;                |         off_t     st_size;
        blksize_t       st_blksize;             |         blkcnt_t  st_blocks;
        blkcnt_t        st_blocks;              |         blksize_t st_blksize;
        char            st_fstype[_ST_FSTYPSZ]; |         u_int32_t st_flags;
};                                              |         u_int32_t st_gen;
                                                |         struct  timespec __st_birthtim;
                                                | };

Here, while there are a number of members which have the same types, the layout of these such as the order of the members is rather different. There’s nothing wrong with that; however, it’s pieces like this that are part of each system’s ABI. While these differences may seem a bit mundane at first, when you start to go further afield from a Unix-family system and say contrast with Windows, the differences are much larger. For example, the long data-type in 64-bit Windows is a 32-bit value, whereas it’s a 64-bit value in Unix-family systems.

Backwards Compatibility

You might reasonably ask, why do we care about these ABIs? Well, one of the areas where ABIs are quite important is in backwards compatibility. Backwards compatibility refers to the ability for today’s systems to run software that was built in the past. If that future system can run the older program, it is considered backwards compatibility. A common example is how you could play Gamecube games on the Nintendo Wii.

Different systems value backwards compatibility in different ways. Windows is famed (and cursed) for its backwards compatibility. One can run old software that targeted Windows 95 on Windows 10. Apple takes a different view entirely, and they generally drop compatibility after two or three major releases. Linus Torvalds long instilled the rule of "not breaking user space" in the Linux kernel, which is a similar edict about backwards compatibility for user applications, though the Linux kernel doesn’t have a stable API or ABI for drivers.

In illumos, we generally value maintaining a stable ABI over time so that way older software will continue to work. Maintaining backwards compatibility does come with some cost and sometimes requires that you put forth more effort to deal with it. However, if every major update meant you had to rebuild all of the software for your operating system, that can also be frustrating. illumos isn’t the only system with this property. A lot of other software is also known for valuing backwards compatibility. A notable example is glibc.

You might ask what does all of this talk of backwards compatibility have to do with the ABI. The two are pretty closely related. Take the struct stat example above. When a C program is compiled, the offsets of the members of a structure become party of the generated binary. If a new member were inserted into the middle of the structure that would change the offsets of all subsequent members and would mean that older programs would access the wrong thing.

The struct stat also shows another challenge with the ABI. If you look at the way that a program often uses it, it looks something like:

void
foo(const char *path)
{
	struct stat st;

	if (stat(path, &st) == 0)
		printf("%x\n", st.st_mode);
}

Here, the application has declared the stat structure on the stack. This means that the size of it is known at compile time and used. If we increased the size of the structure, but ran a program compiled using the older, smaller size of the structure, the stat() call would write beyond the space allocated to the structure on the stack and wreak havoc.

With all this in mind, there’s a lot we can learn from stdio and in particular what not to do.

The History of stdio

Standard I/O (stdio) was introduced in 7th Edition Unix. Many aspects of this original version of stdio are the same today. The FILE *, fopen(), getc(), putc(), and even stdin, stdout, and stderr were all there. The now-common header file /usr/include/stdio.h was introduced as well.

From there, it soon entered into the land of BSD. The second BSD release in 1979 actually contains its own implementation of a small part of stdio. However, by the time of BSD 2.9, if not earlier, it ended up with a version of stdio that looked pretty similar to the one in 7th edition. Eventually though, the folks at Berkeley wrote their own version of it. You can see that most clearly in the 4.4BSD version.

illumos, due to its heritage from Solaris, was a combination of BSD and System V Release 4 (SVR4). An interesting side effect of this is that we can track most of the implementation back to the release of SVR4. In fact, you can even see all the original AT&T copyrights in the files. Many parts of the code look similar to the corresponding version in 7th Edition. Many of the file names are even the same!

As a result of this particular bit of history, a lot of the ABI decisions that had been made in 7th edition were carried into SVR4 and made their way into Solaris. If we look at some of the decisions with a modern eye, they’re all things that make it much harder to extend standard I/O without breaking the ABI.

The FILE Structure

At the heart of stdio is the FILE structure. We’ll look at a portion of stdio.h from 7th Edition. This comes from The Unix Tree:

#define	BUFSIZ	512
#define	_NFILE	20
# ifndef FILE
extern	struct	_iobuf {
	char	*_ptr;
	int	_cnt;
	char	*_base;
	char	_flag;
	char	_file;
} _iob[_NFILE];
# endif

...

#define	NULL	0
#define	FILE	struct _iobuf
#define	EOF	(-1)

...

Well, here we go. This struct _iobuf is what everyone knows and loves as the FILE structure. A lot of this structure is still with us today. If you look at different versions of the structure, you’ll see the same members. The _ptr, _cnt, and _base members are used to help implement the buffering policies. The _flag member is how the structure kept track of knowing if it was open for read, write, or had hit EOF or an error.

The _file member is particularly interesting. This represented the file descriptor that was backed by the FILE structure. If you consider a char was either a signed or unsigned byte value, this limited you from file descriptors 0 to 127 or 255. Most folks could quite reasonably have more than 256 file descriptors open. If you’re writing a tool like grep, this might not be true, but if you’re writing a network application and there’s a file descriptor per connection, having more than 256 connections is quite common.

Back in 7th Edition, the FILE structures were statically allocated. There was no dynamic allocation of them and if we look at the bit above there, we’ll see that all you could have were 20 different FILE structures. In that world, having a file descriptor limit probably made a lot more sense and when this was first written it was before there was much networking present.

Unfortunately, the fact that these structures and the array were declared this way leaked into many different applications. Let’s take a look at the 4.4BSD version of the structure:

typedef	struct __sFILE {
	unsigned char *_p;	/* current position in (some) buffer */
	int	_r;		/* read space left for getc() */
	int	_w;		/* write space left for putc() */
	short	_flags;		/* flags, below; this FILE is free if 0 */
	short	_file;		/* fileno, if Unix descriptor, else -1 */
	struct	__sbuf _bf;	/* the buffer (at least 1 byte, if !NULL) */
	int	_lbfsize;	/* 0 or -_bf._size, for inline putc */

	/* operations */
	void	*_cookie;	/* cookie passed to io functions */
	int	(*_close) __P((void *));
	int	(*_read)  __P((void *, char *, int));
	fpos_t	(*_seek)  __P((void *, fpos_t, int));
	int	(*_write) __P((void *, const char *, int));

	/* separate buffer for long sequences of ungetc() */
	struct	__sbuf _ub;	/* ungetc buffer */
	unsigned char *_up;	/* saved _p when _p is doing ungetc data */
	int	_ur;		/* saved _r when _r is counting ungetc data */

	/* tricks to meet minimum requirements even when malloc() fails */
	unsigned char _ubuf[3];	/* guarantee an ungetc() buffer */
	unsigned char _nbuf[1];	/* guarantee a getc() buffer */

	/* separate buffer for fgetline() when line crosses buffer boundary */
	struct	__sbuf _lb;	/* buffer for fgetline() */

	/* Unix stdio files get aligned to block boundaries on fseek() */
	int	_blksize;	/* stat.st_blksize (may be != _bf._size) */
	fpos_t	_offset;	/* current lseek offset (see WARNING) */
} FILE;

While it’s different, many things are still the same. The flags and file descriptor grew to a short, but everything is still public, which means the layout of the structure is in the ABI. This all leads to a few critical issues that are all intertwined.

Lack of Opacity

If we look at the APIs that exist around stdio, all of them take a FILE *. This is a pointer to the FILE structure. This means that the use of the APIs doesn’t actually require someone to know the size. Even from the beginning, the APIs that gave you access to stdio, fopen(), and fdopen() returned a FILE * and stdin, stdout, and stderr all referred to a FILE * pointer.

All of this tells us that a consumer didn’t actually need to know the layout of the structure. If you have a consumer where you don’t actually know the layout of a structure, then we call that an opaque structure. There are a couple implications of this. Most notably it means that an application cannot allocate the structure itself, it must ask a library to, and that you need to use functions to access the members of the structure.

While in the early days of Unix, things like binary compatibility weren’t top of mind, by the 90s, some systems started caring about backwards compatibility and it was a bit too late. An existing body of software was using those fields directly.

Many of the things that we know of as functions that were standardized in C89 such as getc() or fileno(), actually were macros and just dereferenced the structure members. Here’s an example of another part of V7’s stdio.h:

#define getc(p)         (--(p)->_cnt>=0? *(p)->_ptr++&0377:_filbuf(p))
#define getchar()       getc(stdin)
#define putc(x,p) (--(p)->_cnt>=0? ((int)(*(p)->_ptr++=(unsigned)(x))):_flsbuf((unsigned)(x),p))
#define putchar(x)      putc(x,stdout)
#define feof(p)         (((p)->_flag&_IOEOF)!=0)
#define ferror(p)       (((p)->_flag&_IOERR)!=0)
#define fileno(p)       p->_file

What we think of today as functions were actually all macros dereferenced the FILE structure directly. Every consumer had to know the implementation. Take fileno() for example. It returns the corresponding file descriptor for a FILE *. Here, it just dereferenced the field, which means that programs that called fileno() encoded the actual size and offset of the _file member in their programs. The same is true of all the other members referenced

If we turn to modern implementations, the stdio functions aren’t actually implemented as macros for the most part. Folks will just pay the cost of a function for that opacity. However, that doesn’t actually mean it’s safe to modify and change the FILE structure around. The problem isn’t actually just software that people compile on their own that could have encoded it in the past. No, some modern software refuses to let go of the encoding and accept that these structures may now be opaque.

A great example of this is Gnulib which is a portability library. Gnulib has actually gone back and encoded the now-opaque structures that exist into itself! It will happily reach into the structures and modify them. Here’s a header file that encodes all of the structures for a number of different operating systems from Windows, to Android, to OS X, and even Minix. At this point, an operating system maintainer can’t do too much, without risking arbitrary corruption in user programs. While it’s tempting to say who cares about someone that encodes the private interface of a once-public structure, when software breaks we all lose. Users generally don’t care about who was responsible for breakage, just that it broke and that’s not necessarily a bad thing.

For better or for worse, the stdio ship has sailed here. It doesn’t matter if Android made their version private or if the Solaris 64-bit structure was never even in a public header, because stdio once visible, some software will still use it and encode the private implementation. Even when functions were added to get and set these private members.

An important take away here is that if you are concerned about backwards compatibility for a long time, a structure that users allocate themselves is probably not the right answer. Opaque structures do give library authors the ability to really change this over time. While not all software has the same constraints, when building a new interface, think twice about whether or not the structure should actually be exposed.

Member Sizing and Padding

A related challenge with the FILE structure is the size of its original members. Several implementations stuck with using a char for both the flags and the file descriptor (or in some cases a short). In the context of 7th Edition, this is a reasonable choice. However, as we consider what the impacts of sizing here are on the ABI, it makes things more difficult.

Because of these choices, there was a fundamental limit on the file descriptors that can be used. While 32-bit Solaris and illumos inherited a limit of 255 (!), FreeBSD, NetBSD, and OpenBSD today are all still limited to a short, which on current mainstay processors is a signed 16-bit quantity or 32767. Other systems such as musl, DragonFlyBSD, and 64-bit illumos don’t currently have the same constraints. In some of these cases that’s due to a useful application of hindsight or a lot of careful engineering.

There is always a trade-off and tension between how large to size something and the corresponding memory impact. If you’re going to implement a structure whose implementation you need to maintain in a backwards compatible fashion for a while, you’ll want to think through what those sizes should be. The size that was right in the 1980s may not be right in the 2020s and the size that’s right today maybe seen as small in the future as well.

A related thing to think is structure padding. Padding is a way to add extra members that aren’t used today but may be in the future. API designers often add a bunch of arbitrary bytes to the end of a structure so they can change it in the future without having to break ABIs. This is a technique that’s used beyond operating systems. Most structures in the NVMe specification use this technique as well. Padding is also used to make sure that members in a structure are aligned at a certain granularity such as a cache line. This is done to avoid false sharing.

The nice thing about structure padding is that software will always allocate a structure of the right size, but will ignore the unused fields. This lets older software still work with newer versions of the structure, even if they don’t understand what all the fields mean.

Arrays and ABIs

A related challenge to structure opacity is the use of arrays in ABIs. Wait, where have we really used ABIs? While systems have managed to get around the _iob[_NFILE] declaration with fopen(), there’s actually a more interesting case: the definitions of stdin, stdout, and stderr. Let’s look at a couple examples side by side including 7th Edition and 4.4BSD (which can still be found in some of the BSDs today):

7th Edition                | 4.4BSD
                           |
#define	stdin	(&_iob[0]) | #define	stdin	(&__sF[0])
#define	stdout	(&_iob[1]) | #define	stdout	(&__sF[1])
#define	stderr	(&_iob[2]) | #define	stderr	(&__sF[2])

Here, we can see that we’ve made an array an explicit part of the ABI. This is another way that enforces the size of the existing structures. Using this technique forces the structures actually to have a known size, even if the actual member layout is private. This can be seen on various systems, such as 64-bit illumos.

If one were to do this today, they’d instead define these macros as pointers to a symbol that existed in libc. Instead the program has a base reference to the _iob or __sF symbol and then knows the memory offset to find the next values at. If we were to increase the structure size, suddenly a program that used to refer to stdout would actually be finding a bit of the memory at the end of stdin! Trust me, having made the mistake in development, that this doesn’t end well at all.

If you’re designing something new, think twice before an array becomes part of an ABI. And if not for this, then because of copy relocations.

ABI Lessons

To summarize, if you’re working on something where backwards compatibility is important, here are some lessons we can take from stdio:

  • Keep your structures opaque if you can! Folks can and will encode public things even if you later make them private.

  • If your structure will be public, think carefully about the sizing of members.

  • Make sure to add room for expansion in public structures.

  • Think twice before exposing an array!

Further Reading

If you want to learn more about the implementation of stdio in illumos, as part of my recent work I wrote up a design document. Parts of this will generalize to other systems.

Here are historical versions of different implementations of stdio:

Release

stdio.h

Implementation

7th Edition

stdio.h

implementation

2BSD

stdio.h

implementation

2.9BSD

stdio.h

implementation

4.4BSD

stdio.h

implementation

System III

stdio.h

pdp11 and vax

Final Thoughts

Reading this may lead to some despair: 'Ugh, parts of this ABI are terrible.', 'Really, some things are limited to 256 file structures?', 'Growing the structure requires jumping through hoops?', 'Why do you still live with it?'.

Trying to answer the question of when you should break an API or an ABI is a tough one. The answers will vary depending on the project and what its values are. In some areas of software, the API and ABI are broken regularly. When colleagues of mine have experienced that in different frameworks, it was often quite frustrating and demotivating. Not everything has the same trade offs as an operating system and there are times where breaking the API and ABI are important and necessary.

While the constraints of working within an existing API or ABI can occasionally be infuriating, figuring out how to enable new functionality without breaking existing functionality is an important part of engineering. Studying and dealing with episodes like the legacy of stdio helps teach us what not to do. Hopefully that means that when we look ten or twenty years in the future, some of the things that we’re putting together today will have stood the test of time.

I’m sure that this won’t be the last time that I’m dealing with the design decisions whose history stretches back to 7th Edition Unix. While older code bases with 40+ years of history have a lot of rocks you may not want to turn over, there’s a lot that you can learn from them and a large number of interesting problems as well. And, if we don’t learn from them, people in 4o years will view us the same way.

Still, I find it helpful to remember what Roger Faulkner, a man who had to suffer with more of this than anyone else, was fond of saying: 'That code came from New Jersey'.


Previous Entry: Joining Oxide| All Entries | Next Entry: None