C / TYPES AND REPRESENTATION
char as a small integer and signedness pitfalls
Treat char as an 8-bit integer, tell the three char types apart, and stop bytes above 0x7F or EOF from silently going negative.
What you will learn
- Read 'A' as the integer 65 and do arithmetic on char values deliberately
- Detect your compiler's plain-char signedness with CHAR_MIN < 0
- Keep getchar/fgetc results in int so EOF stays distinct from byte 0xFF
- Cast to unsigned char before using a byte as an index or ctype.h argument
Understanding char as a small integer and signedness pitfalls
A char is an integer type, not a separate text type. The constant 'A' is just the number the execution character set assigns to that letter, 65 in ASCII, and in C it even has type int, which is why 'A' + 1 is an ordinary integer expression that happens to name 'B'. char is also the unit C measures storage in: it is exactly CHAR_BIT bits wide, 8 on any machine you are likely to touch, and every other object is a whole number of chars.
The awkward part is signedness. C has three distinct char types, char, signed char and unsigned char, and the standard deliberately refuses to say which of the last two plain char behaves like, because architectures differed in whether their byte-load instruction sign-extended or zero-extended, and mandating one behaviour would have cost an extra instruction on half of them. x86-64 compilers on Linux, macOS and Windows make plain char signed; ARM and AArch64 Linux and many embedded toolchains make it unsigned. Values 0x00 to 0x7F read the same either way, so code looks fine until a byte with the top bit set arrives: 0xB5 is then either -75 or 181, from identical bits.
That fork is behind almost every char bug. The bits in memory never change; only the type you read them through does, so pick the type by role. Use char for text you hand to string functions, unsigned char for raw bytes you compare, sum, hash or use as a table index (it is also the type the standard guarantees has no padding bits, so it is what you inspect object bytes with), and signed char only when you really want a tiny signed number. Note too that getchar and fgetc return int on purpose: they must distinguish 256 possible byte values from EOF, and 257 distinct results cannot fit in 8 bits.
<stdio.h>
<limits.h>
/* Output below is from x86-64 gcc, where plain char happens to be signed. */
int main(void)
{
char c = 'A'; /* a character constant is just a small integer */
signed char s = -75;
unsigned char u = 181; /* -75 and 181 are the same eight bits */
char byte = (char)0xB5; /* the kind of byte a file hands you */
printf("'A' is %d, 'A' + 1 is %d, as text: %c\n", c, c + 1, c + 1);
printf("signed char %4d bits 0x%02X\n", s, (unsigned)(unsigned char)s);
printf("unsigned char %4d bits 0x%02X\n", u, (unsigned)u);
printf("CHAR_BIT = %d, plain char range %d .. %d\n",
CHAR_BIT, CHAR_MIN, CHAR_MAX);
printf("byte 0xB5 through a plain char prints as %d\n", byte);
return 0;
}
Plain char is a small integer whose signedness the implementation picks, so any byte above 0x7F only has a defined value once you say signed char or unsigned char.
Worked examples
Why fgetc returns int
Shows how narrowing an input value to char makes the byte 0xFF indistinguishable from EOF.
<stdio.h>
int main(void)
{
/* stand-ins for what fgetc() actually hands back */
int from_fgetc[3] = { 'A', 0xFF, EOF };
const char *label[3] = { "letter 'A'", "byte 0xFF", "real EOF" };
for (int i = 0; i < 3; i++) {
signed char narrow = (signed char)from_fgetc[i]; /* the bug */
printf("%-10s int value %4d narrow == EOF? %s\n",
label[i], from_fgetc[i], narrow == EOF ? "yes" : "no");
}
return 0;
}
Example explained
Line 1from_fgetc holds what the library really returns: 255 for the byte 0xFF and -1 for EOF, two different values.
Line 2(signed char)255 keeps the eight bits 0xFF, and read as a signed 8-bit number those bits are -1.
Line 3The comparison widens narrow back to int, so a data byte and true end-of-file both arrive as -1 and cannot be told apart.
Line 4There is no cast that fixes this: 257 outcomes do not fit in 8 bits, so the value has to stay in an int.
Bytes above 0x7F change value, not bits
Sums the same three bytes through signed char and unsigned char to show sign extension corrupting a checksum.
<stdio.h>
int main(void)
{
/* 0xC3 0xA9 is the UTF-8 encoding of e-acute, then plain 'A' */
const char data[] = { (char)0xC3, (char)0xA9, 0x41 };
int signed_sum = 0, unsigned_sum = 0;
for (int i = 0; i < 3; i++) {
signed char sv = (signed char)data[i];
unsigned char uv = (unsigned char)data[i];
printf("byte %02X -> signed %4d unsigned %4u\n",
(unsigned)uv, (int)sv, (unsigned)uv);
signed_sum += sv;
unsigned_sum += uv;
}
printf("checksum with signed char: %d\n", signed_sum);
printf("checksum with unsigned char: %d\n", unsigned_sum);
return 0;
}
Example explained
Line 1sv and uv hold identical bits, printed as C3 and A9, yet read out as -61 and 195, so the byte value itself is a matter of type.
Line 2The third byte 0x41 has its top bit clear and reads as 65 either way, which is why such bugs hide until non-ASCII data arrives.
Line 3-83 is not a truncated 429: each addend was sign-extended to int before the addition, so the sums diverge, not just their low bits.
Line 4Any checksum, hash or byte comparison therefore has to say unsigned char, or its result depends on which compiler built it.
A cast that keeps an index in range
Counts byte frequencies and shows the negative index a plain char would have produced.
<stdio.h>
int main(void)
{
unsigned counts[256] = {0};
const char msg[] = "caf\xC3\xA9"; /* cafe with an accent, in UTF-8 */
for (const char *p = msg; *p; p++)
counts[(unsigned char)*p]++; /* the cast is what makes this legal */
printf("index used for byte C3: %d\n", (int)(unsigned char)msg[3]);
printf("index without the cast: %d\n", (int)(signed char)msg[3]);
printf("counts['c'] = %u, counts[0xC3] = %u\n", counts['c'], counts[0xC3]);
return 0;
}
Example explained
Line 1counts[(unsigned char)*p] can only produce 0 through 255, exactly the valid index range of a 256-entry array.
Line 2The same byte read as a signed char gives -61, so counts[-61] would write 61 ints before the array: undefined behaviour, not a wrong count.
Line 3counts[0xC3] is 1 because the accented character occupies two separate bytes here; this table counts bytes, not characters.
Line 4The identical rule applies to ctype.h: isalpha and toupper accept unsigned char values or EOF, nothing else.
Important notes
char, signed char and unsigned char are three distinct types even where char has the same range as one of them, so unsigned char *p = str; is a type mismatch the compiler will warn about although the bits are identical.
A character constant such as 'A' has type int in C, unlike C++, and a non-ASCII character in a UTF-8 source file is not one char at all but a sequence of bytes you must handle as such.
Common mistakes
Writing char c; while ((c = getchar()) != EOF): on signed-char builds a legitimate 0xFF byte ends the loop early and truncates the input, and on unsigned-char builds c can never equal -1 so the loop never ends at all.
Comparing text bytes against literals, as in if (s[i] == 0xC3): s[i] widens to -61 on signed-char builds while 0xC3 is 195, so the test is never true and the branch is silently dead code.
Passing a plain char straight to isalpha(c) or using it as table[c]: a byte over 0x7F becomes a negative argument or index, which is undefined behaviour rather than merely a wrong answer, and often crashes only on some inputs.
Try it yourself
Change, predict, then run
In a browser editor, loop over the bytes of "\xC0\x80Z" and print each one twice with %d, once cast to signed char and once to unsigned char. Then print CHAR_MIN and state which of the two columns your compiler's plain char matches.
Open the C workspaceCheck your understanding
A file's next byte is 0xFF. The program does char c = fgetc(f); if (c == EOF) puts("done"); What happens across compilers?
- Where plain char is signed the 0xFF byte is wrongly reported as end of file; where plain char is unsigned, c can never equal -1 so real end of file is never detected
- It works everywhere, because 0xFF stored in a char is -1 and EOF is -1
- It fails to compile, because fgetc returns int and char is narrower
- It behaves identically everywhere, because C defines plain char to be signed
Show answer
fgetc must report 257 distinct outcomes, 256 byte values plus EOF, which is why it returns int; squeezing that into 8 bits destroys the distinction in one direction or the other depending on the compiler's choice. Option 1 is tempting because 0xFF really does become -1 in a signed char, but that is precisely the bug: a valid data byte then looks exactly like end of file. The assignment is a legal implicit conversion, so nothing stops it compiling.