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
int i = -1;
unsigned u = 1;
if (i < u) { /* ... */ } i < u -> 0False. 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:
(unsigned)-1 == 4294967295and 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:
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 CORRECTThe 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 tointifintcan represent all its values. On this targetunsigned shorthas 16 bits andinthas 32, so anunsigned shortbecomes a signedint, and the comparison is signed throughout.
Then, for the types that remain, the usual arithmetic conversions pick the common type:
| Operands | Common type |
|---|---|
| same rank, one unsigned | the unsigned one |
| unsigned has higher rank | the unsigned one |
| signed has higher rank and can represent every value of the unsigned type | the signed one |
| otherwise | the 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
4. Shape One Is Covered
flags -Wsign-compare fires?
(none) no
-Wall no
-Wextra YES
-Wall -Wextra YESwarning: 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
for (size_t i = 0; i < strlen(s) - 1; i++) { ... } strlen("") = 0, bound = strlen - 1 = 18446744073709551615
strlen("abc") = 3, bound = strlen - 1 = 2With 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:
/* 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 < ninstead ofi < n - 1.
6. Shape Three: A Countdown That Cannot End
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.
-Wall -Wextra -Wpedantic 0 warnings
-Wall -Wextra -Wpedantic -Wtautological-compare 0 warnings
-Wall -Wextra -Wtautological-unsigned-zero-compare 1 warningwarning: 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:
/* 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 |
|---|---|
sizeof | an int count |
strlen | an int index or length |
fread / fwrite | an int expected count |
| array indices by convention | an int loop counter |
So a loop like this mixes types by default:
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
- The usual arithmetic conversions run before the operator, on the types, not the values.
- Measured:
-1 < 1uis false, because-1converts to 4294967295. - Integer promotion runs first: measured,
-1 < (unsigned short)1is true, becauseunsigned shortpromotes toint. - So the rule is about rank as well as signedness — and the same expression can change meaning on a different target (13.3).
-Wsign-compareis enabled by-Wextra, not-Wall— measured across four flag sets.- Shape one is the covered one. The famous example is the least dangerous.
- Shape two:
i < strlen(s) - 1. Measured:strlen("") - 1is 18446744073709551615. - It involves no mixed signedness, so nothing warns — and it is correct for every non-empty input.
- Never subtract from an unsigned value that might be zero — write
i + 1 < n. - Shape three:
i >= 0on an unsigned counter is always true.-Wall -Wextragave 0 warnings;-Wtautological-unsigned-zero-comparegave 1. - Count down with
i > 0and indexa[i-1], or withwhile (i-- > 0). - 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.
-1 < 1u
-1 < 1
-1L < 1u
-1 < (unsigned short)1
(unsigned)-1 == UINT_MAX
-1 < 1ULExercise 2 — find the hang. Each either hangs or misbehaves for one input. Give that input and the fix.
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.
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.
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.
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 < 1uis false (-1becomes 4294967295);-1 < (unsigned short)1is true because of promotion. - The rule is about rank and signedness, and can differ by target.
-Wsign-compareis a-Wextraflag — measured — so the famous bug is already covered.- The dangerous shapes contain no mixed signedness.
strlen("") - 1is 18446744073709551615 — no warning, and correct for every non-empty input.i >= 0on unsigned is always true —-Wall -Wextrasays nothing;-Wtautological-unsigned-zero-comparedoes.- Never subtract from an unsigned value that could be zero; never test unsigned
>= 0. - Count down with
i > 0anda[i-1], orwhile (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.
