Skip to content
← Prism
Prism
Overview
Features
defer orelse zeroinit raw bounds-check auto-unreachable auto-static check
C in practice
goto skips init tag namespace struct padding strict aliasing char signedness signed overflow VLAs
Spec Draft Releases Blog
GitHub ↗

Is char signed or unsigned?a third type, and the conversions that follow it

Whichever your target picked. Plain char is a third type, distinct from both signed char and unsigned char, and the implementation decides which of the two it behaves like. On x86_64 Linux it is signed. On aarch64 Linux it is unsigned. The same source reads a byte as -1 on one and 255 on the other.

#char is a third type

C23 6.2.5p20 calls char, signed char and unsigned char "the character types", and they are three types, not two spellings of two. _Generic proves it:

#define NAME(x) _Generic((x), char: "char", \
                              signed char: "signed char", \
                              unsigned char: "unsigned char")

char a; signed char b; unsigned char c;
printf("%s / %s / %s\n", NAME(a), NAME(b), NAME(c));
char / signed char / unsigned char

If char were an alias for one of the others the _Generic association list would be a constraint violation, because two associations would have compatible types. It compiles, so they are three.

The rest of 6.2.5p20 says the implementation "shall define char to have the same range, representation, and behavior as either signed char or unsigned char", and Annex J.3 lists which one as implementation-defined behaviour the implementation must document. What the standard does guarantee is narrower than people assume: by 6.2.5p3, a member of the basic execution character set stored in a char is nonnegative. Anything else, including a byte read from a file or a socket, is implementation-defined.

#What your target picked

Same program, same compiler, three builds:

BuildCHAR_MINCHAR_MAXPlain char is
default, aarch64 Linux0255unsigned
-fsigned-char-128127signed
-funsigned-char0255unsigned

The default is the target ABI's choice, not GCC's. Its manual is explicit about this: "Each target supported by GCC has a default for what char should be, which is typically specified in the processor-specific ABI document for that target." ARM and PowerPC picked unsigned; x86, x86_64 and most others picked signed. That split is why a checksum routine that has worked for a decade returns different numbers the first time someone cross-compiles it for a Raspberry Pi, with no warning at any level.

Test both on one machine with -fsigned-char and -funsigned-char rather than waiting for the other architecture to tell you.

#The getchar bug

The canonical failure. getchar returns an int so it can express 256 byte values plus EOF, which is negative. Store that in a char and you have thrown away the distinction:

char c;                              /* wrong */
while ((c = getchar()) != EOF) { ... }

On a signed-char target, the byte 0xFF reads back as -1, compares equal to EOF, and the loop stops at the first such byte. On an unsigned-char target it reads back as 255, never equals EOF, and the loop never stops, because the real EOF got truncated to 255 too. Measured, with the same source:

-fsigned-char
char 0xFF == EOF ? yes, loop ends early
-funsigned-char
char 0xFF == EOF ? no

Two different bugs from one line, decided by your target. The fix is to declare the variable int, which is what the function returns.

This one has a CVE history. Bash 1.14.6 and earlier read the command line through a char * in yy_string_get(), so byte 255 sign-extended to -1 and became indistinguishable from EOF; that is CERT Advisory CA-1996-22. Linux-PAM up to 1.0.3 used a possibly-negative char as an array index in its strtok(), giving CVE-2009-0887. Both are the same three lines.

#Integer promotion

Every char and short operand of an arithmetic operator is converted to int before the operation, if int can hold all its values. C23 6.3.1.1p3 calls these the integer promotions. Two consequences people trip on:

unsigned char a = 200, b = 100;
a - b                    /* 100, computed as int, not as unsigned char */

short x = 30000, y = 30000;
x + y                    /* 60000: int arithmetic, no overflow  */
(short)(x + y)           /* -5536: the truncation happens on assignment */

Narrow types do not have narrow arithmetic. x + y above cannot overflow a short, because there is no short arithmetic; there is int arithmetic followed by a conversion you wrote. And char arithmetic on a signed-char target promotes a byte of 0xFF to -1, so str[i] >> 4 on high-bit bytes shifts a negative value, which is its own undefined behaviour.

#Why -1 < 1u is false

After promotion, the usual arithmetic conversions (C23 6.3.1.8) pick a common type for the two operands. When one is int and the other unsigned int, and both have the same rank, the signed one converts to unsigned:

int s = -1; unsigned u = 1;
s < u        /* false: -1 converts to 4294967295 */

sizeof(int) > -1   /* false: -1 converts to SIZE_MAX */

Both measured on GCC 11.4.0. The second is the one that bites in real code, because sizeof and strlen return unsigned types and any comparison against a signed length or a negative sentinel silently flips.

GCC will tell you, but not under -Wall:

$ gcc -std=c11 -Wall -c cmp.c        # silent
$ gcc -std=c11 -Wextra -c cmp.c
cmp.c:2:36: warning: comparison of integer expressions of different signedness:
            'int' and 'unsigned int' [-Wsign-compare]

In C, -Wsign-compare is enabled by -Wextra only. In C++ it is in -Wall. If your build stops at -Wall, you have never seen this warning.

#What to write instead

Use unsigned char for bytes. Anything that is data rather than text: buffers, hashes, checksums, protocol fields, anything you will shift or mask. It has no implementation-defined signedness and no negative values to promote.

Use int for anything holding getchar, fgetc or getc. Not because it is bigger, but because EOF is not a byte value and needs somewhere outside the byte range to live.

Use plain char for text. String literals are arrays of char, and the string functions take char *. Mixing unsigned char * into that means casts everywhere. Keep plain char for characters and switch to unsigned char at the point where you start treating them as numbers, which is exactly what <ctype.h> requires: passing a negative char to isalpha is undefined unless it is EOF.

Build with -Wextra, or at least -Wsign-compare and -Wsign-conversion. The second is noisy and not in -Wextra; run it once over a codebase rather than leaving it on.

Pin it if you must. -funsigned-char makes the choice explicit across a project. It also makes your code different from every library you link against that assumed the default, so prefer fixing the declarations.

#How Prism handles it

None of this is something Prism changes. Signedness of plain char is an ABI property of the target, promotion is a language rule, and a transpiler that altered either would be emitting C that means something different from what you wrote.

The one place it shows up is zero-initialization: char c; becomes char c = 0;, which is a valid value on either signedness and removes the separate question of what an uninitialized byte held. That is the members problem, not the conversions problem.

#Further reading

Measured on GCC 11.4.0, Ubuntu 22.04, aarch64, where plain char is unsigned by default. The signed-char rows come from -fsigned-char on the same machine.