Computing History · Mainframe Era

Packed and Zoned Decimal: Sign Nibbles and Decimal Arithmetic

How decimal numbers are laid out two digits to a byte with the sign folded into the last nibble, and why the format permits decimal addition without a binary round trip.

A commercial program adds money. The operands are amounts with two decimal places, held in fields whose widths were fixed when the record layout was agreed, and the result must equal what a clerk with a ledger would compute, digit for digit, because it is going to be printed on an invoice and compared against a customer’s own arithmetic. Binary floating point cannot make that promise. A value such as 0.10 has no exact representation in base two, so a chain of additions accumulates a residue that eventually shows up in the last printed place, and the rounding rule that hides it is a policy decision rather than a fact.

Decimal hardware exists to answer that requirement directly. The System/360 provides it in two storage formats, and both are built out of the same four-bit digit code that the character set uses. There is no conversion between the character encoding of a digit and its arithmetic value, which is the point: a field of digits can be read from a card image and added without a translation pass.

Zoned: one digit per byte, sign in the zone

Zoned decimal is the format that arrives from a card. Each byte holds one digit in its low nibble and a zone in its high nibble. For an unsigned or leading field the zone is 0xF on every byte, which makes the byte identical to the character encoding of that digit. The final byte is different: its zone nibble holds the sign instead of 0xF. 0xC means positive, 0xD means negative, and 0xF means unsigned, which the hardware treats as positive.

A three-digit field holding +123 therefore occupies three bytes: 0xF1, 0xF2, 0xC3. Reading it as a character string gives 123 except that the last byte is not the character 3. This is a real hazard: a program that prints a zoned field as text without clearing the sign nibble emits a byte that is not a digit, and a program that compares a zoned field against a literal '3' compares against the wrong byte.

Zoned fields are what a card reader produces when it interprets a numeric column, and what an editor produces when a person types digits into a fixed-width field. The format is chosen for compatibility with text, not for density.

Packed: two digits per byte, sign in the low nibble

Packed decimal removes the redundant zones. Each byte holds two digits, high nibble first, and the sign moves to the low nibble of the last byte. A field of odd digit count fills the first byte completely, because digits plus sign nibbles come to an even number; a field of even digit count needs one zero nibble of padding at the front. The field width in bytes is therefore floor(digits / 2) + 1: two digits per byte, plus one byte to hold the final digit and the sign. A three-digit field is two bytes, a four-digit field is three, a nine-digit field is five.

The same value, +123, packs into two bytes: 0x12, 0x3C. Negative 123 packs as 0x12, 0x3D. The sign codes are the same C, D, F used by the zoned format, which is why a conversion between the two is a nibble rearrangement rather than a table lookup. The pack instruction takes a zoned field and produces packed; the unpack instruction goes the other way. Both are single instructions, which is the practical argument for the format: a record read from tape as zoned text can be packed in one operation and then used arithmetically.

The density matters when a record holds many amounts, and it is the reason a blocked tape file can carry more records per reel than its character-encoded equivalent. A file with ten amounts of seven digits each occupies seventy bytes zoned and forty bytes packed, and the difference is file size, tape time, and buffer space. The arithmetic is the same either way, since the decimal instructions accept both formats.

/* Pack n zoned digits, one per byte with a 0xF zone, into n/2+1 bytes.
   The sign comes from the zone nibble of the last zoned byte. */
void zoned_to_packed(const unsigned char *z, int n, unsigned char *p)
{
    unsigned char nib[34];          /* 32 digits, one pad, one sign */
    int i, k = 0;

    if (n % 2 == 0)
        nib[k++] = 0;               /* even digit count: leading pad nibble */
    for (i = 0; i < n; i++)
        nib[k++] = z[i] & 0x0F;     /* digit is the low nibble */
    nib[k++] = z[n - 1] >> 4;       /* sign nibble: 0xC, 0xD, or 0xF */

    for (i = 0; i < k; i += 2)
        p[i / 2] = (unsigned char)(nib[i] << 4 | nib[i + 1]);
}

The parity case is where the layout is easy to get wrong, and it runs opposite to the intuition. Three digits plus a sign is four nibbles, which fills two bytes exactly, so an odd digit count needs no padding. Four digits plus a sign is five nibbles, which does not fill a whole number of bytes, so the field gains a leading zero nibble and becomes three bytes. A routine that packs from the first digit without inserting that pad produces a field one byte short, with the sign nibble in the high half of the last byte, and the error is invisible until the value is printed with a sign the hardware does not read.

Addition without leaving decimal

The reason the format exists as hardware rather than as a software convention is that the arithmetic unit can add decimal digits directly. Decimal addition works on digit pairs with a carry that is generated per digit position rather than per bit position. Adding 6 and 7 in a position produces 3 and a carry, because the hardware detects that the sum exceeded 9 and adds the correction of 6 to skip the six invalid codes between 9 and 0x10. The result is that a decimal add of two packed fields produces a correct decimal result without converting to binary, adding, and converting back, and without the rounding question that a binary round trip raises.

The cost is throughput. A decimal add touches one digit position per step where a binary add handles a full word at a time, so decimal arithmetic is slower per operation by roughly the ratio of digits to word width. The design accepts that cost because the alternative is a correctness argument that cannot be made. An invoice total must be the sum of the printed lines, not the sum of approximations to them.

Fixed-point decimal in this form also has a property that floating point does not: the scale is fixed by the field layout and never changes. Adding two fields with two decimal places gives a result with two decimal places, exactly. Multiplying two fields gives a product whose scale is the sum of the operand scales, which the programmer must account for by declaring the result field wide enough. If it is not wide enough, the high-order digits are lost and the low-order digits remain, which is a silent truncation of magnitude rather than a rounding. Overflow detection exists, but only if the program asks for it by using a field size the hardware can check; a field that is simply too short for the value loses the top digits and reports nothing.

The sign nibble as a place where bugs live

Three defects recur in code that handles these formats, and all three come from treating a numeric field as text.

The first is comparison of packed fields as byte strings. Packed bytes are not ordered: 0x12 0x3C is 123 and 0x11 0x9D is -119, and byte comparison puts the negative value first. Sorting a packed field requires unpacking to zoned or converting to a numeric type, and no shortcut through byte order exists. The sign nibble sits in the last byte, so its position in a comparison depends on the length of the field, and two fields of different lengths cannot be compared without alignment.

The second is truncation of the sign nibble during a move. A field moved by a character-move instruction loses nothing, but a field edited into an output line by a routine that assumes digits will produce a line whose last character is a non-digit. This is why output routines use a specific edit instruction or a picture clause that names the numeric field as numeric: the sign has to be removed or repositioned deliberately.

The third is the unsigned 0xF sign code being confused with a data digit. Because 0xF is also the zone used for every zoned digit, a zoned field that has been partially overwritten by text can hold a valid-looking digit in the sign position, and the value silently becomes positive when it was meant to be negative. There is no redundancy in the format to catch it.

Why the format survived the move to binary

A machine with a fast binary integer unit and eight-bit bytes can do fixed-point arithmetic on scaled integers, and most modern code does exactly that: store cents, add integers, divide at the end. That technique reproduces the arithmetic but loses two things the packed format provides.

The first is the variable field width. A record layout declared with a five-digit amount and a nine-digit amount packs them into four and six bytes respectively, and the hardware reads the width from the instruction operand rather than from a type. There is no alignment requirement and no padding, so a record of mixed widths has no wasted space and no alignment arithmetic. Software fixed-point with a single word type cannot express that.

The second is the interoperability of the format itself. Data written by one program is read by another that was compiled decades later against a different machine, and the field layout is the contract. A format that is defined by a specification rather than by a language’s type system survives a change of language.

The same concern drives later decimal work outside the mainframe. The general decimal arithmetic specification, and the decimal formats in IEEE 754, define decimal numbers with an explicit exponent and a fixed number of significant digits, and they exist because binary floating point cannot represent a decimal quantity exactly. The difference is that the mainframe formats fix the scale in the field layout while the later formats carry it in the value. The older design pushes the decision to the record definition, where a change is expensive; the newer one pushes it into every value, where the cost is space. Neither is free, and the trade is between a schema that is hard to change and a representation that is larger.

For records that are also read by the sorting and collating machinery, the interaction with the character set matters: zone bits in EBCDIC explains why the digit nibble and the zone nibble are separate in the first place, and why the same four-bit code serves both text and arithmetic.

References

  1. IBM System/360 Principles of Operation — IBM, GA22-6821
  2. General Decimal Arithmetic Specification — Mike Cowlishaw, IBM
  3. IEEE 754-2019: Standard for Floating-Point Arithmetic — IEEE
  4. IBM z/Architecture Principles of Operation — IBM, SA22-7832