C's Flexible Integer Sizes Were Not a Design Mistake
Hacker News

C's Flexible Integer Sizes Were Not a Design Mistake

C's Flexible Integer Sizes Were Not a Design Mistake

Comments

Here at the school, we work with a lot of retro machines and old game consoles, so it's no surprise that we are often the first point of contact for many developers learning the C programming language. Many of our students come from languages like Python, JavaScript, Java, C#, Swift, and others. It does not take long for beginners to realize that many aspects they took for granted in their primary language are not necessarily a given in C. Fairly often, we find ourselves asking questions such as:

  • How many bits does this variable occupy in memory?
  • What is the padding in this structure?
  • Is this field properly aligned in RAM?

Integers Are Not 32-bits

Another rite of passage for C beginners is to learn how to use and to make sense of the operator sizeof. Observing their program output 4 when they ask for the sizeof(int) seems reasonable enough, given that in most modern machines, a signed integer occupies, indeed, 4 bytes in memory. Unfortunately, I must also tell my students that, when I first started learning how to code, if I asked for the sizeof(int), my old 386 machine would output 2 bytes! And right about here is where we start a discussion on how native C integer types do not have a fixed size.

Language types such as char, int, short, and long do not come with a guarantee of how many bytes they occupy in memory. Since we usually want fixed sizes in our code, we tend to suggest students to use the STDINT.H header to take advantage of actual fixed-size types, such as:

  • int8_t: signed integers of exactly 8 bits
  • uint8_t: unsigned integers of exactly 8 bits
  • int16_t: signed integers of exactly 16 bits
  • uint16_t: unsigned integers of exactly 16 bits
  • int32_t: signed integers of exactly 32 bits
  • uint32_t: unsigned integers of exactly 32 bits
  • etc.

The header was introduced as part of the C99 standard (published in 1999), and it helps programmers guarantee that their variables are declared to occupy an exact number of bytes, regardless of the compiler and regardless of the machine architecture we are compiling against.

Are Non-Fixed Integer Sizes a Design Mistake?

A fairly common take in programming circles is that C's platform-dependent integer types were a design mistake. To be fair, this is not wrong. Most programmers that use C with modern systems would argue exactly that. An int is 16 bits on one machine and 32 on another, long is 64 bits on Linux but 32 on 64-bit Windows, and all of this has caused decades of portability bugs. So, given that today almost every CPU is a 64-bit machine that handles 8, 16, 32, and 64-bit arithmetic... why didn't C just fix the sizes from the start?

This is basically us trying to judge a 1970s design decision by 2020s conditions! C was built to be a portable systems programming language that could map efficiently onto very different computer architectures. The flexible type sizes weren't an oversight; they were how C achieved that portability. Keep in mind that C's goal for "portability" meant something different than Java's portability promise of "write once, run anywhere." And to understand why, you have to remember what "a computer" meant when C was designed.

The World Before 8-bit Bytes

Today we take a lot for granted. Almost every machine you'll touch has:

  • 8-bit bytes
  • Byte-addressable memory
  • 32 or 64-bit registers
  • Two's-complement integers
  • A flat, conventional memory model

None of this was guaranteed in the 1960s and 70s. The industry had not converged, and machine word sizes were all over the map:

Machine Word size Notes
DEC PDP-8 12 bits Hugely popular minicomputer
DEC PDP-7 18 bits Where UNIX was born, in assembly
DEC PDP-11 16 bits Byte-addressed; where C grew up
DEC PDP-10 / DECSYSTEM-20 36 bits Characters were often packed 7 or 9 bits at a time
Honeywell 6000 series 36 bits 9-bit characters; an early C target
UNIVAC 1100 / Unisys 2200 36 bits Ones'-complement arithmetic, 9-bit chars; still has a C compiler today
IBM 7090 / 7094 36 bits 6-bit character codes
SDS 940, ICL 1900, Harris 24 bits ICL used 6-bit characters
Burroughs B5000 family 48 bits Tagged, stack-oriented architecture
CDC 6600 60 bits 6-bit characters, no byte addressing at all
Cray-1 64 bits Word-addressed; in C, short, int, and long could all be 64 bits
Data General Nova 16 bits Word-addressed; byte pointers had a different representation than word pointers
Intel 8086 16 bits Segmented memory; near and far pointers

Characters weren't consistent either. There were 6-bit character sets, 7-bit ASCII, 9-bit bytes on 36-bit machines, and EBCDIC on IBM mainframes. Negative numbers could be stored as two's complement, ones' complement, or sign-magnitude. Some machines could address individual bytes. Others could only address whole words, so a "pointer to a character" had to be a word address plus an offset.

Where C's Types Came From

C's integer philosophy makes more sense once you look at its ancestors. BCPL (Martin Richards, 1967) and B (Ken Thompson, around 1969) were typeless languages. There was only one kind of value: the machine word. A variable held a word. You could treat it as an integer, an address, or a bit pattern depending on the operator you applied. On the word-addressed machines these languages targeted, that was elegant and efficient.

Then Bell Labs got a PDP-11. The PDP-11 was byte-addressed, with 16-bit words, and was about to get floating-point hardware. Dennis Ritchie describes in The Development of the C Language (1993) how badly B's "everything is a word" model fit that machine. Handling characters was clumsy, pointers had to be scaled between word and byte addresses, and floating-point values didn't fit in a word. C's type system was created to fix that mismatch. char gave you the byte. int kept the spirit of BCPL's word: the natural integer of the machine. That idea became part of the language.

Even today, the C standard (C11 §6.2.5) says: "int object has the natural size suggested by the architecture of the execution environment." This is a deliberate design statement. int was never meant to be "32 bits". It meant "whatever this machine is fastest and most comfortable with".

C Escapes the PDP-11

The real test came in 1977 and 1978, when Ritchie and Steve Johnson ported UNIX and C to the Interdata 8/32, a 32-bit machine that was quite different from the PDP-11. They wrote about it in Portability of C Programs and the UNIX System (Bell System Technical Journal, 1978). Around the same time, Johnson's Portable C Compiler (pcc) made retargeting C to new architectures practical, and C spread to a wide range of hardware.

The first edition of The C Programming Language (Kernighan & Ritchie, 1978) includes a table I like to point people to. It lists the type sizes on four machines C already ran on:

Type DEC PDP-11 Honeywell 6000 IBM 370 Interdata 8/32
char 8 bits 9 bits 8 bits 8 bits
short 16 36 16 16
int 16 36 32 32
long 32 36 32 32
float 32 36 32 32
double 64 72 64 64

In 1978 C already ran on machines with 16-bit ints, 32-bit ints, and 36-bit ints, with 8-bit and 9-bit chars. The flexible sizes were there from the start, and they worked. The same language, and largely the same programs, ran natively and efficiently on all of these machines.

Why Flexible Sizes Were Useful

Suppose C had required int to be exactly 32 bits, two's complement, wrapping on overflow, the way many modern languages do. Here is what that would have cost.

  1. On a 16-bit Machine (PDP-11, 8086)
    Every int operation would need two machine instructions instead of one. Adding two 32-bit values on a PDP-11 means an ADD on the low words followed by an ADC (add with carry) on the high words. Comparisons, shifts, and multiplications all get worse. Every array index and every loop counter would cost twice the registers and twice the instructions.

  2. On a 36-bit Machine (Honeywell, UNIVAC, IBM)
    A 32-bit int would waste bits and complicate alignment. On a 36-bit word, a 32-bit field leaves 4 bits unused or requires awkward packing. Meanwhile, char can be 9 bits on a 36-bit machine, packing four characters per word with nothing left over. This is also why C never promised that all pointers have the same representation. On the Data General Nova, a char * and an int * pointing to the same place held different bit patterns. C's rules on pointer conversion (and the need for void * and explicit casts) exist because of machines like that.

  3. On Small 8-bit Micros (Z80, 6502, 8080)
    C doesn't let int be as small as the machine's 8-bit registers. The standard sets a minimum range of -32767 to 32767, so int must be at least 16 bits. This shows the balance the design struck: the size adapts to the machine, but there is a floor so programs can rely on something.

The Design Pattern: Minimums, Not Exact Sizes

When ANSI standardized C in 1989, it wrote this philosophy down. You don't get exact sizes, you get guaranteed minimum ranges, exposed through <limits.h>:

Type Minimum guaranteed
char 8 bits (CHAR_BIT >= 8)
short 16 bits
int 16 bits
long 32 bits
long long (C99) 64 bits

A portable C program asks "what's the smallest type guaranteed to hold my range?", not "what type is exactly N bits?". If you need values up to 100,000, you use long, because long is guaranteed to hold them everywhere. If your values fit in ±32767, int is fine and will be the fastest choice on every machine.

The ANSI C Rationale summed up the "spirit of C" in a few principles, and two of them apply directly here:

  • Trust the programmer
  • Make it fast, even if it is not guaranteed to be portable

C tried to be portable and fast by letting each implementation map the language onto its hardware in the cheapest way possible. That's a trade-off... not a blunder.

A Case Study in Portable C: Lua

If you want to see this philosophy used well in a modern repository, you should check out the Lua source code. Lua runs on everything from 64-bit servers to microcontrollers and old/unusual toolchains. Lua is written in what its authors call "Clean C", which is the common subset of ANSI C (C89). For a long time, Lua avoided depending on <stdint.h> entirely, and it still follows the older approach:

Here are a few examples from Lua 5.4:

Detecting Whether int is Big Enough, Without int32_t

In luaconf.h:

/* @@ LUAI_IS32INT is true iff 'int' has (at least) 32 bits. */
#define LUAI_IS32INT ((UINT_MAX >> 30) >= 3)

This is pure C89. It doesn't ask "is int exactly 32 bits?", because on a 36-bit machine that would be the wrong question. It asks "does unsigned int hold at least 32 bits of range?". It works on 16-bit, 32-bit, 36-bit, and 64-bit ints alike.

VM Instructions: "At Least 4 Bytes", Not "Exactly 4 Bytes"

In llimits.h:

/*
** type for virtual-machine instructions;
** must be an unsigned with (at least) 4 bytes (see details in lopcodes.h)
*/
#if LUAI_IS32INT
typedef unsigned int l_uint32;
#else
typedef unsigned long l_uint32;
#endif
typedef l_uint32 Instruction;

The type is named l_uint32, but look at the comment: it only needs to be at least 32 bits. On a 16-bit-int machine it falls back to long, which the standard guarantees is at least 32 bits. The instruction decoder in lopcodes.h extracts fields with shifts and masks, so any extra high bits on wider machines are simply ignored.

Small Numbers and Memory Counts

/* chars used as small naturals (so that 'char' is reserved for characters) */
typedef unsigned char lu_byte;
typedef signed char ls_byte;

Lua uses unsigned char where a modern codebase would write uint8_t. That's correct everywhere, including on machines where a byte is 9 or 16 bits. uint8_t wouldn't even exist on those machines. The memory accounting types follow the same pattern: size_t / ptrdiff_t when int is 32 bits or more, and unsigned long / long on 16-bit machines where size_t might be too small to count total memory usage.

Choosing lua_Integer From What the Platform Offers

Lua's integer type is configurable in luaconf.h (LUA_INT_INT, LUA_INT_LONG, LUA_INT_LONGLONG), with a LUA_32BITS option for small targets:

#if defined(LUA_32BITS)
/* { */
/*
** 32-bit integers and 'float'
*/
#if LUAI_IS32INT
/* use 'int' if big enough */
#define LUA_INT_TYPE LUA_INT_INT
#else
/* otherwise use 'long' */
#define LUA_INT_TYPE LUA_INT_LONG
#endif
#define LUA_FLOAT_TYPE LUA_FLOAT_FLOAT

The default uses long long when the compiler provides it (checked via LLONG_MAX) and falls back to long for strict C89 compilers that don't.

Binary Serialization With CHAR_BIT

The most instructive example may be string.pack and string.unpack in lstrlib.c. They read and write binary integers byte by byte, and they don't assume a byte is 8 bits:

/* number of bits in a character */
#define NB CHAR_BIT
/* mask for one character (NB 1's) */
#define MC ((1 << NB) - 1)

And let the implementation choose the most efficient type that satisfies it. This is the discipline C's design expected from programmers.

Being Fair: What Hurt

I don't want to pretend flexible sizes came free. Some pain points are real:

  • People didn't write code the way Lua does. Much C code assumed int was 16 bits (in the DOS era) or 32 bits (later), assumed sizeof(int) == sizeof(void *), or stored pointers in ints. Moving to 32-bit and then 64-bit machines broke that code. The language allowed portability but didn't enforce it.
  • The 64-bit data model split. Unix-like systems chose LP64 (long and pointers are 64 bits), while 64-bit Windows chose LLP64 (long stays 32 bits and only long long and pointers are 64). Code that used long as "the big integer" or "the pointer-sized integer" behaves differently on the two.
  • Standard fixed-width types arrived late. <stdint.h> only came with C99, so for two decades every project wrote its own u32 / INT32 typedefs with #ifdef forests. That's exactly the "use ifdefs" workaround people suggest today, and the standard eventually made it official.

Even <stdint.h> kept C's original philosophy. Th

Read on Hacker News ↗ ← Back to News

Comments

No comments yet. Start the discussion.