C · lesson 4 of 25
Data Types in C
Explore C's built-in data types including integers, floating-point numbers, characters, and how to use sizeof and type limits.
By SkillByExample · Updated
Integer Types
C has several integer types of increasing size. The standard sets only minimums, so <limits.h> tells you the real range on the machine you compile for:
#include <stdio.h>
#include <limits.h> /* INT_MAX, CHAR_MAX, etc. */
int main(void) {
printf("char: %zu bytes, max = %d\n", sizeof(char), CHAR_MAX);
printf("short: %zu bytes, max = %d\n", sizeof(short), SHRT_MAX);
printf("int: %zu bytes, max = %d\n", sizeof(int), INT_MAX);
printf("long: %zu bytes, max = %ld\n", sizeof(long), LONG_MAX);
printf("long long: %zu bytes, max = %lld\n", sizeof(long long), LLONG_MAX);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o ints ints.c
$ ./ints
char: 1 bytes, max = 127
short: 2 bytes, max = 32767
int: 4 bytes, max = 2147483647
long: 8 bytes, max = 9223372036854775807
long long: 8 bytes, max = 9223372036854775807
On 64-bit Linux, long and long long are both 8 bytes. Compile the same file on 64-bit Windows and long drops to 4 bytes with a max of 2147483647. This is why code that stores a file size or a timestamp in long can break when it’s ported. %zu is the correct format for sizeof’s result type, size_t.
Unsigned Variants and Their Traps
unsigned removes negative values and doubles the positive range. Unsigned arithmetic wraps around instead of overflowing, and mixing signed with unsigned has a nasty surprise:
#include <stdio.h>
#include <limits.h>
int main(void) {
unsigned int x = 0;
x--; /* wraps around: well-defined for unsigned */
printf("0u - 1 = %u\n", x);
printf("UINT_MAX = %u\n", UINT_MAX);
unsigned char uc = 255;
uc++; /* wraps to 0 */
printf("255 + 1 as unsigned char = %d\n", uc);
int n = -1;
unsigned int len = 3;
if (n < len) printf("-1 < 3\n");
else printf("-1 is NOT less than 3?!\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o unsigned unsigned.c
unsigned.c: In function 'main':
unsigned.c:16:11: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
16 | if (n < len) printf("-1 < 3\n");
| ^
$ ./unsigned
0u - 1 = 4294967295
UINT_MAX = 4294967295
255 + 1 as unsigned char = 0
-1 is NOT less than 3?!
The last line is the real bug. When an int meets an unsigned int in a comparison, the int is converted to unsigned, so -1 becomes 4294967295, which is not less than 3. -Wextra enables the warning that catches it. This is why loop counters compared against strlen() or sizeof (both unsigned) should be size_t, not int. Note also that only unsigned overflow is defined to wrap. Overflowing a signed int is undefined behavior.
Fixed-Width Integer Types (stdint.h)
When the exact size matters (network protocols, file formats, hardware registers), use the fixed-width types from <stdint.h>. <inttypes.h> supplies matching printf macros:
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h> /* PRId32 etc. for printf */
int main(void) {
int8_t a = INT8_MIN;
uint8_t b = UINT8_MAX;
int32_t e = INT32_MIN;
uint64_t h = UINT64_MAX;
printf("int8_t min: %" PRId8 "\n", a);
printf("uint8_t max: %" PRIu8 "\n", b);
printf("int32_t min: %" PRId32 "\n", e);
printf("uint64_t max: %" PRIu64 "\n", h);
printf("sizeof(size_t) = %zu, sizeof(intptr_t) = %zu\n",
sizeof(size_t), sizeof(intptr_t));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o stdint stdint.c
$ ./stdint
int8_t min: -128
uint8_t max: 255
int32_t min: -2147483648
uint64_t max: 18446744073709551615
sizeof(size_t) = 8, sizeof(intptr_t) = 8
PRIu64 expands to the right conversion for the platform ("lu" on this Linux build, something different on Windows), and string-literal concatenation glues it into the format. Also worth knowing:
size_t: the unsigned typesizeofreturns, used for sizes and indicesintptr_t/uintptr_t: wide enough to hold a pointerptrdiff_t: the result of subtracting two pointers
Floating-Point Types
The same value of pi stored three ways shows how much precision each type keeps:
#include <stdio.h>
#include <float.h> /* FLT_MAX, DBL_DIG, etc. */
int main(void) {
float f = 3.14159265358979f;
double d = 3.14159265358979;
long double ld = 3.14159265358979323846L;
printf("float: %.15f (%zu bytes, %d digits)\n", f, sizeof f, FLT_DIG);
printf("double: %.15f (%zu bytes, %d digits)\n", d, sizeof d, DBL_DIG);
printf("long double: %.18Lf (%zu bytes, %d digits)\n", ld, sizeof ld, LDBL_DIG);
printf("float max: %e\n", FLT_MAX);
printf("double max: %e\n", DBL_MAX);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o floats floats.c
$ ./floats
float: 3.141592741012573 (4 bytes, 6 digits)
double: 3.141592653589790 (8 bytes, 15 digits)
long double: 3.141592653589793239 (16 bytes, 18 digits)
float max: 3.402823e+38
double max: 1.797693e+308
The float goes wrong after 3.141592, its seventh significant digit (FLT_DIG guarantees 6). Everything after that is rounding noise, printed faithfully because we asked for 15 decimals. long double is 16 bytes on x86-64 Linux (80-bit extended precision plus padding). On MSVC it is the same as double, so check LDBL_DIG before relying on it.
Never Compare Floats with ==
#include <stdio.h>
#include <math.h>
int main(void) {
double a = 0.1 + 0.2;
double b = 0.3;
printf("a = %.17f\n", a);
printf("b = %.17f\n", b);
printf("a == b: %s\n", a == b ? "yes" : "no");
printf("close enough: %s\n", fabs(a - b) < 1e-9 ? "yes" : "no");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o fcompare fcompare.c -lm
$ ./fcompare
a = 0.30000000000000004
b = 0.29999999999999999
a == b: no
close enough: yes
Neither 0.1, 0.2 nor 0.3 exists exactly in binary. Printing 17 digits shows the two values land on opposite sides of 0.3. Compare within a tolerance instead. -lm links the math library that provides fabs (needed on Linux, harmless elsewhere).
The char Type
char holds one character, and it is also a small integer. 'A' is just the number 65 in ASCII:
#include <stdio.h>
int main(void) {
char letter = 'A';
printf("%c = %d\n", letter, letter);
for (char c = 'a'; c <= 'z'; c++) {
printf("%c", c);
}
printf("\n");
char upper = 'q' - 32; /* ASCII: lowercase is 32 above uppercase */
int digit = '7' - '0'; /* digit characters are consecutive */
printf("upper=%c, digit=%d\n", upper, digit);
printf("'7' as a number: %d\n", '7');
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o chars chars.c
$ ./chars
A = 65
abcdefghijklmnopqrstuvwxyz
upper=Q, digit=7
'7' as a number: 55
'7' - '0' works on every platform because the standard guarantees the digit characters are consecutive. The - 32 trick for uppercase is ASCII-specific, so real code uses toupper() from <ctype.h>. Whether plain char is signed or unsigned is implementation-defined (signed on x86, unsigned on ARM Linux). Write signed char or unsigned char when it matters.
The sizeof Operator
sizeof gives the size in bytes of a type or expression, worked out at compile time:
#include <stdio.h>
int main(void) {
printf("sizeof(short) = %zu\n", sizeof(short));
printf("sizeof(long long) = %zu\n", sizeof(long long));
printf("sizeof(float) = %zu\n", sizeof(float));
printf("sizeof(void *) = %zu\n", sizeof(void *));
int arr[10];
printf("array: %zu bytes, %zu elements\n",
sizeof arr, sizeof arr / sizeof arr[0]);
int n = 5;
size_t s = sizeof(n++); /* operand is NOT evaluated */
printf("s = %zu, n = %d\n", s, n);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o sizes sizes.c
$ ./sizes
sizeof(short) = 2
sizeof(long long) = 8
sizeof(float) = 4
sizeof(void *) = 8
array: 40 bytes, 10 elements
s = 4, n = 5
n is still 5. sizeof only looks at the type of n++, so the increment never runs. sizeof arr / sizeof arr[0] is the standard element-count idiom. It only works on a real array, not on a pointer to one (the arrays lesson shows why).
Type Conversion
C converts operands to a common type in arithmetic. Whether the division is integer or floating-point depends on the operand types, not on the variable you store the result in:
#include <stdio.h>
int main(void) {
int i = 7;
float f = 2.5f;
float result = i + f; /* i is converted to float */
printf("7 + 2.5f = %.1f\n", result);
int quotient = 7 / 2; /* both int: integer division */
printf("7 / 2 = %d\n", quotient);
double exact = (double)7 / 2; /* one double operand: real division */
printf("(double)7 / 2 = %.1f\n", exact);
printf("(int)3.99 = %d\n", (int)3.99); /* truncates */
printf("(int)-3.99 = %d\n", (int)-3.99); /* toward zero */
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o convert convert.c
$ ./convert
7 + 2.5f = 9.5
7 / 2 = 3
(double)7 / 2 = 3.5
(int)3.99 = 3
(int)-3.99 = -3
Converting to int truncates toward zero. It never rounds, so -3.99 becomes -3, not -4. The cast in (double)7 / 2 applies to 7 alone, before the division. (double)(7 / 2) would do the integer division first and give 3.0.
Type Qualifiers
const
const makes a variable read-only after initialization, and the compiler enforces it:
#include <stdio.h>
int main(void) {
const double PI = 3.14159265358979;
const int DAYS_IN_WEEK = 7;
printf("PI = %.5f, days = %d\n", PI, DAYS_IN_WEEK);
PI = 3.0; /* not allowed */
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o const const.c
const.c: In function 'main':
const.c:8:8: error: assignment of read-only variable 'PI'
8 | PI = 3.0; /* not allowed */
| ^
The error happens at compile time. No executable is produced, so the bad write can never run. Delete the last assignment and the program prints PI = 3.14159, days = 7.
volatile
volatile tells the compiler a variable can change in ways it can’t see, such as from a signal handler, so every read must really happen. Here a signal handler sets a flag after one second while main waits for it:
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
volatile sig_atomic_t stop = 0;
void on_alarm(int sig) {
(void)sig;
stop = 1; /* changed "outside" the normal flow */
}
int main(void) {
signal(SIGALRM, on_alarm);
alarm(1); /* deliver SIGALRM in one second */
while (!stop) {
/* busy-wait for the flag */
}
printf("flag seen, exiting\n");
return 0;
}
This uses POSIX alarm(), so it runs on Linux or macOS. flag_plain.c is the same file with the flag declared as plain int stop = 0;. timeout 3 kills anything still running after three seconds and exits with status 124:
$ gcc -Wall -Wextra -std=c17 -o flag_plain flag_plain.c
$ timeout 3 ./flag_plain
flag seen, exiting
$ gcc -Wall -Wextra -std=c17 -O2 -o flag_plain flag_plain.c
$ timeout 3 ./flag_plain
$ echo $?
124
$ gcc -Wall -Wextra -std=c17 -O2 -o flag flag.c
$ timeout 3 ./flag
flag seen, exiting
The plain-int version works at -O0 and hangs forever at -O2, with no warning either way. The optimizer saw that nothing inside the loop changes stop, read it once, and turned the loop into while (1). The volatile version forces a real memory read on every iteration. sig_atomic_t is the integer type the standard guarantees a signal handler can write safely. volatile does not make code thread-safe. Between threads, use _Atomic or a mutex (see the concurrency lesson).
restrict (C99)
restrict on a pointer promises that, while the function runs, the memory it points to is accessed only through that pointer. The compiler can then vectorize loops without guarding against overlap:
#include <stdio.h>
void add_arrays(int *restrict dst, const int *restrict src, int n) {
for (int i = 0; i < n; i++) {
dst[i] += src[i]; /* compiler may assume dst and src never overlap */
}
}
int main(void) {
int totals[4] = {1, 2, 3, 4};
int extra[4] = {10, 20, 30, 40};
add_arrays(totals, extra, 4);
for (int i = 0; i < 4; i++) printf("%d ", totals[i]);
printf("\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -O2 -o restrict restrict.c
$ ./restrict
11 22 33 44
The output is the same with or without restrict. The keyword changes what the optimizer is allowed to assume, not what correct code computes. The compiler cannot check the promise. Calling add_arrays(totals, totals + 1, 3) breaks it, and the result is undefined behavior. memcpy is declared with restrict for this reason, and overlapping copies must use memmove.
Practice
- A
uint8_t counterstarts at250and is incremented 10 times. Predict its final value, then check.
Solution
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
int main(void) {
uint8_t counter = 250;
for (int i = 0; i < 10; i++) {
counter++;
}
printf("counter = %" PRIu8 "\n", counter);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
counter = 4
250 + 10 = 260, and a uint8_t wraps at 256, so 260 − 256 = 4. Unsigned wraparound is well-defined, so this is guaranteed on every platform.
- This average prints
4.00for17 / 4. Fix it so it prints4.25.
int total = 17, count = 4;
double wrong = total / count;
Solution
#include <stdio.h>
int main(void) {
int total = 17, count = 4;
double wrong = total / count;
double right = (double)total / count;
printf("wrong average = %.2f\n", wrong);
printf("right average = %.2f\n", right);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
wrong average = 4.00
right average = 4.25
Storing into a double doesn’t help. total / count is fully evaluated as integer division first, giving 4, and only that result is converted. Casting one operand before the division makes it floating-point division.
- Add
0.1fto afloatten times. Is the sum exactly1.0f?
Solution
#include <stdio.h>
int main(void) {
float sum = 0.0f;
for (int i = 0; i < 10; i++) {
sum += 0.1f;
}
printf("sum = %.10f\n", sum);
printf("sum == 1.0f: %s\n", sum == 1.0f ? "yes" : "no");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
sum = 1.0000001192
sum == 1.0f: no
Each addition rounds to the nearest float, and the ten small errors add up to 0.0000001192. That is visible, even though %.1f would still print 1.0.
- Print every character of
"Hello"next to its integer code, e.g.H=72.
Solution
#include <stdio.h>
int main(void) {
const char *word = "Hello";
for (int i = 0; word[i] != '\0'; i++) {
printf("%c=%d ", word[i], word[i]);
}
printf("\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
H=72 e=101 l=108 l=108 o=111
The same char value prints as a letter with %c and as a number with %d. The type is identical; only the format changes. The loop stops at '\0', the terminator every C string ends with.