Skip to content
VLSI Mentor

C Part II · Module 15 · Bit-Level Programming

Sign Extension

The byte 0xD8 widened to 32 bits is either 216 or −40, and nothing about the byte decides which — the type of the thing you widened does. That is sign extension, and it is the one conversion in C that changes a value's bits rather than just its size. This chapter establishes exactly when it happens so you can predict it, then does the harder half: extending a 12-bit signed field out of a register deliberately, by two methods, with an honest account of which parts of the well-known trick are implementation-defined rather than guaranteed. It ends by measuring that a right shift is not a division.

Advanced21 min readCPart IIHardwareRepresentation

Part II · Module 15 · Chapter 15.6 · Sign Extension

1. The Same Byte, Two Values

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int8_t  s8  = -40;
int32_t s32 = s8;
printf("int8_t -40 -> int32_t  = %" PRId32 "  (0x%08" PRIx32 ")\n", s32, (uint32_t)s32);

uint8_t  u8  = 0xD8u;            /* the same bit pattern, unsigned */
uint32_t u32 = u8;
printf("uint8_t 0xD8 -> uint32_t = %" PRIu32 "  (0x%08" PRIx32 ")\n", u32, u32);
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int8_t -40 -> int32_t  = -40  (0xffffffd8)
uint8_t 0xD8 -> uint32_t = 216  (0x000000d8)

Both started from the eight bits 1101 1000. One became 0xFFFFFFD8, the other 0x000000D8. The twenty-four new bits are ones in the first case and zeros in the second.

That is the whole phenomenon:

  • Zero extension fills the new bits with zeros. Used when the source type is unsigned.
  • Sign extension fills the new bits with a copy of the source's top bit. Used when the source type is signed.

And the reason is that C's conversions preserve the value, not the bit pattern. −40 as an 8-bit two's complement number is 0xD8; −40 as a 32-bit two's complement number is 0xFFFFFFD8. Filling with ones is what keeps the value at −40. The bits changed precisely in order that the number would not.

2. Predicting It: When Does It Happen?

Sign extension occurs whenever a signed value is converted to a wider integer type. That covers more situations than people expect, because most of them are implicit.

Assignment and initialisation to a wider type. int32_t x = my_int8;

Integer promotion. Any int8_t or int16_t operand of an arithmetic operator is converted to int first — Chapter 15.5 §2's rule — and since those types are signed, the conversion sign-extends. This is the one that catches people, because no cast is visible:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int8_t a = -1;
uint32_t m = (uint32_t)a;      /* a is promoted to int (-1), then converted: 0xFFFFFFFF */

Function arguments and return values, which are assignments in disguise.

The usual arithmetic conversions, when a narrow signed value meets a wider type.

The complementary rule is just as important: an unsigned source never sign-extends, whatever the destination. uint8_t to int32_t is zero extension, because the value 216 is 216.

So the prediction rule is one line:

Look at the type of the operand being widened. Signed source, sign extension. Unsigned source, zero extension. The destination's signedness does not decide it.

That last clause is the part worth checking yourself on. uint8_t → int32_t zero-extends even though the destination is signed; int8_t → uint32_t sign-extends even though the destination is unsigned, which is how -1 becomes 0xFFFFFFFF rather than 0x000000FF.

3. Where It Is Correct

Sign extension has a reputation as a hazard, which is unfair — it is the right behaviour for the thing it was designed for.

A temperature offset field holds −40. A DAC correction holds −3. An I/Q sample holds a negative amplitude. These are genuinely signed quantities, and a datasheet that describes a field as "signed, two's complement" is telling you that widening it must sign-extend or the value is wrong.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int8_t  offset = -40;
int32_t raw    = 1000;
printf("raw %d, offset %d, sum %d\n", raw, offset, raw + offset);
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
raw 1000, offset -40, sum 960

The addition is correct because offset was promoted with sign extension. Had it been declared uint8_t, it would have held 216 and the sum would have been 1216.

So the decision is not "avoid sign extension". It is Chapter 14.2 §4's rule, which this chapter is the mechanism for: declare the field signed if and only if the specification says it can be negative. Get that right and the language's automatic behaviour is the behaviour you want.

4. Doing It on Purpose: The Problem

The difficulty arrives when a signed field is narrower than any C type. A 12-bit signed field in a 32-bit register is common — ADC results, position encoders, correction offsets — and there is no int12_t to extract it into.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
uint32_t reg = 0x00000F38u;      /* bits 11:0 = 0xF38 */
uint32_t raw = reg & 0xFFFu;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
raw 12-bit field = 0xf38 (3896 unsigned)

Chapter 15.4's GET gave 3896 and 3896 is wrong. As a 12-bit two's complement number, 0xF38 has its top bit — bit 11 — set, so it is negative, and its value is 3896 − 4096 = −200.

The extraction was correct; it simply produced an unsigned 12-bit pattern, and nothing in the mask-and-shift knows the field was signed. The missing step is to widen 12 bits to 32 as a signed quantity, which means copying bit 11 into bits 31:12.

5. Two Ways to Do It

The shift method. Push the field up so its sign bit lands on the destination's sign bit, then shift back down with an arithmetic shift that replicates it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int32_t via_shift = (int32_t)(raw << 20) >> 20;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
via shift trick = -200

32 − 12 = 20. The left shift puts bit 11 at bit 31; the right shift brings it back, and if the right shift is arithmetic it drags copies of the sign bit down behind it.

The mask-and-or method. Test the sign bit explicitly and set the upper bits yourself:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int32_t via_mask;
if (raw & 0x800u) via_mask = (int32_t)(raw | ~(uint32_t)0xFFFu);
else              via_mask = (int32_t)raw;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
via mask+or (portable) = -200

0x800 is bit 11, the field's sign bit. ~0xFFFu is 0xFFFFF000 — every bit above the field. If the sign bit is set, OR them in; otherwise the value is already correct.

Both give −200. They are not equivalent in status, and the difference is the point of this section.

The mask-and-or method has neither implementation-defined step: it uses only unsigned operations and one conversion that is in range. It is slightly longer and it is the one to reach for in code that must be portable by construction rather than by assertion.

Either way, write it once:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static inline int32_t sign_extend32(uint32_t value, unsigned width) {
    assert(width > 0u && width <= 32u);
    if (width == 32u) return (int32_t)value;
    uint32_t mask = (UINT32_C(1) << width) - 1u;
    uint32_t v    = value & mask;
    uint32_t sign = UINT32_C(1) << (width - 1u);
    return (int32_t)((v ^ sign) - sign);      /* no implementation-defined step */
}

The body is a third formulation worth knowing: XOR with the sign bit flips it, subtracting the sign bit then produces the correct negative value, and every operation is unsigned arithmetic that cannot overflow. The width == 32 guard is Chapter 15.2 §5's edge, still present wherever a width becomes a shift count.

6. The Misconception: Shifting Right Is Dividing

"x >> n is x / 2ⁿ, so I can use whichever is clearer."

For unsigned values that is exactly true, which is why the belief is so durable. For signed negative values it is false, and measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  16 >> 2 =    4      16 / 4 =    4    same
   7 >> 2 =    1       7 / 4 =    1    same
 -16 >> 2 =   -4     -16 / 4 =   -4    same
  -7 >> 2 =   -2      -7 / 4 =   -1    DIFFER
  -1 >> 2 =   -1      -1 / 4 =    0    DIFFER
  -4 >> 2 =   -1      -4 / 4 =   -1    same
  -5 >> 2 =   -2      -5 / 4 =   -1    DIFFER

Three of seven differ, and the pattern is exact:

Arithmetic right shift floors — it rounds toward negative infinity. −7 >> 2 is ⌊−1.75⌋ = −2.

Integer division truncates toward zero — a rule C fixed in C99. −7 / 4 is −1.

They agree for non-negative values and for exact multiples, and disagree for every negative value that is not an exact multiple. −1 >> 2 is −1 and −1 / 4 is 0, which is the case most likely to appear in a loop bound or an index.

The practical consequences in bit code:

Never substitute one for the other on signed values. If the datasheet says "divide the raw count by 4", write / 4. If it says "shift right by 2", write >> 2. On negative inputs they are different operations and the document meant one of them.

Do not use >> as an optimisation. Any compiler from this century turns x / 4 into the correct shift-and-fixup sequence for signed types and a plain shift for unsigned ones. Writing the shift by hand buys nothing and changes the semantics for negatives.

Prefer unsigned for pure bit manipulation, where the question of which rounding applies does not arise at all. That is Chapter 15.5 §8's habit, and this is another reason for it.

7. What You Should Be Able to Say Now

  1. Conversions preserve the value, not the bit pattern. 0xD8 widens to 0xFFFFFFD8 from int8_t and 0x000000D8 from uint8_t — both correctly, because −40 and 216 are different numbers.
  2. The source's signedness decides, not the destination's. uint8_t → int32_t zero-extends; int8_t → uint32_t sign-extends, which is how −1 becomes 0xFFFFFFFF.
  3. Promotion sign-extends silently. Any int8_t or int16_t operand becomes int before any operator sees it, and that conversion sign-extends — no cast appears in the source.
  4. Chapter 14.1's 0x85 bug was this, and the cure — declare it uint8_t — works because an unsigned source cannot sign-extend.
  5. Sign extension is correct for genuinely signed fields. Declare signed iff the specification permits negatives (14.2 §4), and the automatic behaviour is the right one.
  6. A signed field narrower than any C type must be extended by hand. 0xF38 as 12-bit two's complement is −200, not 3896.
  7. The shift method is implementation-defined in two places — unsigned-to-signed conversion out of range, and right shift of a negative — and not undefined. Measured arithmetic here, with UBSan silent. C23 defines both. Pin it with _Static_assert((-1 >> 1) == -1, …).
  8. The (v ^ sign) - sign form has no implementation-defined step and is the portable one.
  9. >> is not /. Shift floors, division truncates toward zero. Measured: -7 >> 2 = −2, -7 / 4 = −1.

8. Exercises

  1. Widen 0xD8 to 32 bits from int8_t, uint8_t, char and signed char, and print all four in hex. Explain any pair that differs, naming the platform fact from Chapter 14.1 that decides the char case.
  2. int8_t a = -1; uint32_t m = (uint32_t)a; — predict m before running, then explain the two conversions that occurred, in order.
  3. Extract the 12-bit field from 0x00000F38 three ways: raw, shift method, and sign_extend32. Confirm all three agree on the sign-extended answer and that the raw one does not.
  4. Run sign_extend32 over every value from 0 to 4095 with width = 12 and confirm it produces exactly the range −2048 to 2047. Run it under UBSan.
  5. Add _Static_assert((-1 >> 1) == -1, …) to a file and confirm it passes. Then explain what a compiler that failed it would have done to the shift method.
  6. Reproduce §6's table. Add -8, -9 and -1000, and predict each before running.
  7. A datasheet describes a 10-bit signed ADC result in bits 13:4 of a register. Write the complete accessor and say what its return type should be and why.

9. Summary

  • Conversions preserve the value. 0xD8 → 0xFFFFFFD8 (from signed) or 0x000000D8 (from unsigned); both keep the number.
  • The source's signedness decides, never the destination's.
  • Promotion sign-extends with no cast in sight — the mechanism behind Chapter 14.1's 0x85 → −123.
  • It is correct behaviour for genuinely signed fields. Declare signed iff the spec permits negatives.
  • A sub-byte signed field must be extended by hand: 0xF38 as 12-bit two's complement is −200.
  • The shift method is implementation-defined twice over — unsigned→signed out of range, and >> on a negative — and not undefined. Measured arithmetic, UBSan silent, C23 guarantees both. Pin it with a _Static_assert.
  • (v ^ sign) - sign is the formulation with no implementation-defined step.
  • >> floors; / truncates toward zero. Measured -7 >> 2 = −2 against -7 / 4 = −1. Never substitute one for the other on signed values.

That covers the operations a field forces on you. There is a separate, much smaller body of bit expressions that are not forced on you by any datasheet — the handful of idioms that turn up in driver and model code so often that failing to recognise one costs more time than writing it would have.

Standards & specifications

Governing standard
ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)

Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C Programming curriculum.