Skip to content
VLSI Mentor

C Part I · Module 13 · Writing Correct and Reliable C

Signed and Unsigned Comparison Bugs

Before any binary operator runs, C may convert its operands — silently, by rules that depend on the types rather than the values. Most of the time this is invisible and harmless. Occasionally it takes a comparison whose truth is obvious to any reader, converts one side behind your back, and makes it evaluate false. The famous example is a one-liner, and it turns out to be the safe one: a flag this curriculum has used since Chapter 1 catches it. The two shapes that actually hang programs look nothing like it, involve no mixed signedness at all, and are caught by nothing you are currently running.

Advanced19 min readCPart IFundamentalsCorrectness

Part I · Module 13 · Chapter 13.5 · Signed and Unsigned Comparison Bugs

1. The One Everybody Knows

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    int      i = -1;
    unsigned u = 1;
    if (i < u) { /* ... */ }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  i < u  ->  0

False. Minus one is not less than one.

The mechanism is Chapter 3.12's usual arithmetic conversions, applied before < runs. The operands have different types, so C converts them to a common one — and when the types have the same rank and one is unsigned, the common type is the unsigned one. So i is converted to unsigned int:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  (unsigned)-1  ==  4294967295

and 4294967295 < 1 is false. Every step is defined, the arithmetic is correct, and the answer is wrong.

2. The Rule Is About Rank, Not Just Signedness

"Mixing signed and unsigned is wrong" is the usual summary and it is too crude to use, because the conversion depends on the rank of the types as well as their signedness. Three comparisons, same shape, three different answers:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int(-1) < unsigned int(1)     ->  0     unsigned wins — equal rank
int(-1) < unsigned long(1)    ->  0     unsigned long wins — higher rank
int(-1) < unsigned short(1)   ->  1     CORRECT

The third is the surprise and the one that makes the rule worth knowing properly.

Integer promotion runs first. Any type of lower rank than int — char, short, and their unsigned versions — is promoted to int if int can represent all its values. On this target unsigned short has 16 bits and int has 32, so an unsigned short becomes a signed int, and the comparison is signed throughout.

Then, for the types that remain, the usual arithmetic conversions pick the common type:

OperandsCommon type
same rank, one unsignedthe unsigned one
unsigned has higher rankthe unsigned one
signed has higher rank and can represent every value of the unsigned typethe signed one
otherwisethe unsigned version of the signed type

The third row is why long < unsigned int is signed on this target (a 64-bit long represents every 32-bit unsigned value) and would be unsigned on a target where both are 32 bits. The same expression changes meaning with the type sizes — which is Chapter 13.3's implementation-defined territory arriving in a comparison.

3. The Three Shapes

Three shapes of signed-unsigned bug and what catches each. The first is a direct mixed comparison, such as int minus one compared against unsigned one, which evaluated false; it is caught by -Wsign-compare, which -Wextra enables. The second is a bound computed by unsigned subtraction, such as strlen of a string minus one, which gave 18446744073709551615 for an empty string; every operand is unsigned so there is no sign mismatch and nothing warns. The third is a countdown loop testing i greater than or equal to zero on an unsigned counter, which is always true; it is caught only by the tautological unsigned zero compare flag, which neither -Wall nor -Wextra enables.The shapeWhat happensWhat catches itdirect mixed comparisonint i = -1; unsigned u = 1; i< uevaluates FALSEi converts to 4294967295-Wsign-compareenabled by -Wextra — you havethisunsigned bound thatwrapsi < strlen(s) - 1bound becomes 1.8e19only when the string is emptynothingevery operand is unsigned —no mismatch to seeunsigned countdownfor (size_t i = n; i >= 0;i--)never terminatesi >= 0 is always truea flag you must add-Wtautological-unsigned-zero-compare12
Figure 1 — the three ways this goes wrong, and what catches each. Read the right-hand column downwards, because it inverts the usual emphasis: the famous bug in the first row is the one that is already covered, and the two below it — which are the ones that cause a program to stop responding rather than to print a wrong answer — are covered by nothing you are likely to be running. Note also that neither of the lower two involves mixing signed and unsigned at all. Every operand in both is unsigned, which is precisely why the warning designed for sign mismatches has nothing to report.

4. Shape One Is Covered

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  flags            -Wsign-compare fires?
  (none)            no
  -Wall             no
  -Wextra           YES
  -Wall -Wextra     YES
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
warning: comparison of integers of different signs: 'int' and 'unsigned int'
         [-Wsign-compare]

-Wsign-compare comes from -Wextra, not from -Wall. That distinction matters more than it looks: a great many projects build with -Wall alone and therefore do not have this check at all.

This curriculum has used -Wall -Wextra in every example since Chapter 1, so you have been running it throughout — which is why the shape in §1, despite being the famous one, is the least dangerous of the three.

5. Shape Two: A Wrapped Bound

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    for (size_t i = 0; i < strlen(s) - 1; i++) { ... }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  strlen("")    = 0,  bound = strlen - 1 = 18446744073709551615
  strlen("abc") = 3,  bound = strlen - 1 = 2

With a normal string the bound is 2 and the loop runs twice. With an empty string the bound is about 1.8 × 10¹⁹ and the loop runs until something else stops it.

Three things make this worse than §1.

No mixed signedness. strlen returns size_t, 1 converts to size_t, i is size_t. Every operand is unsigned, there is no conversion to complain about, and -Wsign-compare has nothing to report. Measured: no warning at all.

It is correct for almost every input. The bug appears only when the string is empty, which is exactly the case a hand-written test tends to omit. Chapter 12.6 §4 measured the parallel fact that a command-line argument can be an empty string.

The failure is a hang rather than a wrong answer. A loop running 18 quintillion times does not produce a result you can inspect; it produces a program that stops responding, which is harder to attribute to a line of code.

The cure is not to subtract:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    /* wrong when the length is 0 */
    for (size_t i = 0; i < strlen(s) - 1; i++) { ... }

    /* right: move the term to the other side */
    for (size_t i = 0; i + 1 < strlen(s); i++) { ... }

    /* or guard, and hoist the call out of the condition while you are there */
    size_t n = strlen(s);
    for (size_t i = 0; n > 0 && i < n - 1; i++) { ... }

Never subtract from an unsigned value that might be zero. Rearrange the comparison so the subtraction does not happen: i + 1 < n instead of i < n - 1.

6. Shape Three: A Countdown That Cannot End

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    for (size_t i = n; i >= 0; i--) { ... }

i >= 0 is always true for an unsigned type, because an unsigned value is never negative. When i is 0 and the loop decrements, it wraps to SIZE_MAX — defined behaviour, per Chapter 13.4 — and the loop continues.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -Wall -Wextra -Wpedantic                            0 warnings
  -Wall -Wextra -Wpedantic -Wtautological-compare     0 warnings
  -Wall -Wextra -Wtautological-unsigned-zero-compare  1 warning
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
warning: result of comparison of unsigned expression >= 0 is always true
         [-Wtautological-unsigned-zero-compare]

The flag exists and you have to name it. Neither -Wall nor -Wextra enables it, and even the obvious-sounding -Wtautological-compare does not.

Three correct ways to count down over an unsigned type:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    /* 1: offset the index — the loop variable stays strictly positive */
    for (size_t i = n; i > 0; i--) {
        process(a[i - 1]);
    }

    /* 2: test before decrementing */
    size_t i = n;
    while (i-- > 0) {
        process(a[i]);
    }

    /* 3: use a signed type, if the range allows it */
    for (ptrdiff_t i = (ptrdiff_t)n - 1; i >= 0; i--) {
        process(a[i]);
    }

The first is the one to reach for. The second is a recognised idiom and reads oddly the first time — i-- > 0 tests the old value and then decrements, so the body sees n-1 down to 0. The third works and reintroduces a conversion, so it needs n to be within ptrdiff_t's range.

7. Where These Types Meet in Practice

The reason this comes up constantly is that the standard library is unsigned and most arithmetic is signed.

Returns size_t (unsigned)Usually compared against
sizeofan int count
strlenan int index or length
fread / fwritean int expected count
array indices by conventionan int loop counter

So a loop like this mixes types by default:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    int n = get_count();
    for (size_t i = 0; i < n; i++) { ... }       /* -Wsign-compare fires */

Four ways to resolve it, in order of preference:

Make the counter match the bound. If the bound is size_t, so is the counter. This is the right answer when the thing being counted is a size.

Make the bound match the counter, with a cast you can justify: (size_t)n after checking n >= 0. The check is the part people omit — converting a negative int to size_t gives an enormous value, which is Chapter 13.4's defined-but-wrong.

Use ptrdiff_t when you genuinely need a signed index, such as a pointer difference (Chapter 7.8).

Never silence the warning with a cast you have not reasoned about. (int)strlen(s) is fine for a string you created and is a truncation waiting to happen for one you did not.

8. Reading Code for These Shapes

A checklist for review, since c.ts asks for recognition.

Look at every loop bound involving a subtraction. n - 1, len - 1, count - offset: ask what happens when the left operand is zero and the type is unsigned.

Look at every >= 0 test. If the left side is size_t, unsigned, or anything from sizeof/strlen, the test is always true.

Look at every comparison between a library return value and a local counter. That is where the types differ by default.

Look at every cast that silences a warning. A cast is an assertion that you have checked something; ask what was checked.

And one habit that removes most of this at source: pick the type from what the value is, not from what is convenient. A count of bytes is a size_t. A difference between two pointers is a ptrdiff_t. A value that can legitimately be negative is signed. Most of these bugs come from a value being stored in a type that cannot express what it needs to.

9. What You Should Be Able to Say Now

  1. The usual arithmetic conversions run before the operator, on the types, not the values.
  2. Measured: -1 < 1u is false, because -1 converts to 4294967295.
  3. Integer promotion runs first: measured, -1 < (unsigned short)1 is true, because unsigned short promotes to int.
  4. So the rule is about rank as well as signedness — and the same expression can change meaning on a different target (13.3).
  5. -Wsign-compare is enabled by -Wextra, not -Wall — measured across four flag sets.
  6. Shape one is the covered one. The famous example is the least dangerous.
  7. Shape two: i < strlen(s) - 1. Measured: strlen("") - 1 is 18446744073709551615.
  8. It involves no mixed signedness, so nothing warns — and it is correct for every non-empty input.
  9. Never subtract from an unsigned value that might be zero — write i + 1 < n.
  10. Shape three: i >= 0 on an unsigned counter is always true. -Wall -Wextra gave 0 warnings; -Wtautological-unsigned-zero-compare gave 1.
  11. Count down with i > 0 and index a[i-1], or with while (i-- > 0).
  12. These types meet constantly because the library is unsigned and arithmetic is signed — pick the type from what the value is.

10. Exercises

Exercise 1 — evaluate each. Give the result and say which conversion applied, on a target where int is 32 bits and long is 64.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-1 < 1u
-1 < 1
-1L < 1u
-1 < (unsigned short)1
(unsigned)-1 == UINT_MAX
-1 < 1UL

Exercise 2 — find the hang. Each either hangs or misbehaves for one input. Give that input and the fix.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for (size_t i = 0; i < n - 1; i++) { }
for (size_t i = n; i >= 0; i--) { }
for (size_t i = n - 1; i < n; i--) { }
while (len-- >= 0) { }

The third is subtler than the others — say what it does and whether it is correct.

Exercise 3 — which flag. For each, say whether -Wall, -Wextra, or a named flag is required, using §4 and §6's measurements.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int i = -1; unsigned u = 1; if (i < u) { }
size_t i = 0; if (i >= 0) { }
for (size_t i = 0; i < strlen(s) - 1; i++) { }

Exercise 4 — rewrite the loop. Give three correct versions of a loop that visits a[n-1] down to a[0] with n of type size_t, and say which you would ship.

Exercise 5 — justify the cast. For each, say what must be checked before the cast is safe.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for (size_t i = 0; i < (size_t)n; i++) { }       /* n is int        */
int len = (int)strlen(s);
if ((int)count > limit) { }                      /* count is size_t */

Exercise 6 — explain the promotion. Explain why -1 < (unsigned short)1 is true while -1 < (unsigned int)1 is false, naming both rules involved.

Exercise 7 — the portable question. On a target where int and long are both 32 bits, does -1L < 1u give the same answer as on this one? Explain, and say which chapter covers the category this question falls into.

Exercise 8 — review a function. Using §8's checklist, list everything you would question.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void trim(char *s, int max) {
    size_t n = strlen(s);
    for (size_t i = n - 1; i >= 0 && s[i] == ' '; i--) { s[i] = '\0'; }
    if (n > max) { s[max] = '\0'; }
}

11. Summary

  • Conversions run before the operator, chosen by the types.
  • Measured: -1 < 1u is false (-1 becomes 4294967295); -1 < (unsigned short)1 is true because of promotion.
  • The rule is about rank and signedness, and can differ by target.
  • -Wsign-compare is a -Wextra flag — measured — so the famous bug is already covered.
  • The dangerous shapes contain no mixed signedness.
  • strlen("") - 1 is 18446744073709551615 — no warning, and correct for every non-empty input.
  • i >= 0 on unsigned is always true — -Wall -Wextra says nothing; -Wtautological-unsigned-zero-compare does.
  • Never subtract from an unsigned value that could be zero; never test unsigned >= 0.
  • Count down with i > 0 and a[i-1], or while (i-- > 0).
  • Pick the type from what the value is.

Both of this chapter's dangerous shapes end the same way: a loop whose bound is wrong, running past the data it was meant to visit. What that loop then does is the subject the module has been circling since Chapter 6.4 and has never treated head-on. An index one past the end is not an error C reports; it is an ordinary read or write of whatever is at that address, which belongs to something else — a different variable, a saved register, part of the allocator's own bookkeeping. The program keeps running and the damage shows up later, in code that is correct, with no connection to the line that caused it. The next chapter traces a single out-of-range write through to the thing it corrupts, and makes the case for a design in which the bound is checked once and trusted everywhere after.

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.