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 bitsuint8_t: unsigned integers of exactly 8 bitsint16_t: signed integers of exactly 16 bitsuint16_t: unsigned integers of exactly 16 bitsint32_t: signed integers of exactly 32 bitsuint32_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.
-
On a 16-bit Machine (PDP-11, 8086)
Everyintoperation 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. -
On a 36-bit Machine (Honeywell, UNIVAC, IBM)
A 32-bitintwould waste bits and complicate alignment. On a 36-bit word, a 32-bit field leaves 4 bits unused or requires awkward packing. Meanwhile,charcan 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, achar *and anint *pointing to the same place held different bit patterns. C's rules on pointer conversion (and the need forvoid *and explicit casts) exist because of machines like that. -
On Small 8-bit Micros (Z80, 6502, 8080)
C doesn't letintbe as small as the machine's 8-bit registers. The standard sets a minimum range of -32767 to 32767, sointmust 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
intwas 16 bits (in the DOS era) or 32 bits (later), assumedsizeof(int) == sizeof(void *), or stored pointers inints. 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 (
longand pointers are 64 bits), while 64-bit Windows chose LLP64 (longstays 32 bits and onlylong longand pointers are 64). Code that usedlongas "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 ownu32/INT32typedefs with#ifdefforests. 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
Comments
No comments yet. Start the discussion.