COMP1521 2,101 words·11 min read

16. Unicode and UTF-8

Computers store bytes, not letters. If we want a byte sequence to represent text, we need an agreement which maps between numbers and the characters a human intends to read. That agreement is a character encoding.

Start with the euro sign printed on a screen. Several different layers are involved:

Layer Plain-language meaning Example for €
character the abstract textual idea, independent of any font or file format the euro sign
code point Unicode's number for that idea U+20AC
encoding rules which turn that number into stored units UTF-8
code unit one basic storage unit used by an encoding one byte in UTF-8
encoded bytes the actual values stored for this example E2 82 AC

A code point is therefore not “the bytes”, just as a house's street number is not the physical ink used to print it. The same code point can be represented using UTF-8, UTF-16 or UTF-32, producing different code units in memory or a file. Keep the fixed-width representations from Integers in mind: an encoding supplies the context which gives each bit pattern meaning.

ASCII#

ASCII is a fixed-width 7-bit encoding with 128 values, 0x00 through 0x7F. It includes:

  • control characters such as NUL ('\0') and line feed ('\n');
  • punctuation;
  • digits '0' through '9';
  • uppercase 'A' through 'Z';
  • lowercase 'a' through 'z'.

The useful blocks are sequential:

C
int digit = c - '0';          // if c is known to be '0' ... '9'
char next = letter + 1;       // 'a' becomes 'b'

The digit trick works because the code points for the ten digits are consecutive. Do not infer that arbitrary character arithmetic is meaningful: punctuation also occupies gaps between the alphabetic blocks.

Hex Range Category Key Characters / Range Bitwise Pattern / Notes
0x00 – 0x1F Control characters 0x00 (NUL), 0x0A (LF / \n), 0x0D (CR / \r), 0x09 (TAB) Non-printable commands
0x20 – 0x2F Punctuation & Space 0x20 (Space ' '), ! " # $ % & ' ( ) * + , - . / First printable character is space (0x20)
0x30 – 0x39 Digits ('0'–'9') '0' (0x30) through '9' (0x39) High nibble is 0x3; '5' - '0' == 5
0x41 – 0x5A Uppercase letters 'A' (0x41 = 0100 0001₂) through 'Z' (0x5A) Bit 5 is 0
0x61 – 0x7A Lowercase letters 'a' (0x61 = 0110 0001₂) through 'z' (0x7A) Bit 5 is 1 (differs from uppercase by 0x20)
0x7F Control character DEL (Delete) 0111 1111₂ (all 7 bits set)

Tip

Notice that uppercase 'A' (0x41 = 01000001₂) and lowercase 'a' (0x61 = 01100001₂) differ only at bit 5 (1 << 5 = 0x20). Toggling bit 5 with XOR (c ^ 0x20) flips the case of an ASCII alphabetic character.

Unicode#

Unicode provides a common coded character set for writing systems, symbols and control characters. Its codespace is simply the full range of numbers reserved for possible code points: U+0000 through U+10FFFF, giving 1,114,112 positions. Not every position is assigned to a character.

A Unicode scalar value is a code point which an encoding such as UTF-8 is allowed to represent: any value in that codespace except U+D800 through U+DFFF. That excluded surrogate range exists for UTF-16's internal design; it is not a set of standalone characters.

Code points are conventionally written as U+ followed by at least four hexadecimal digits:

Character Code point
A U+0041
€ U+20AC
字 U+5B57
😀 U+1F600

Unicode is not merely “one giant font”. It standardises character identities and properties. A glyph is the visible shape eventually drawn by a particular font and rendering system. One character can have different glyphs in different fonts, and one displayed glyph can sometimes depend on several code points.

Note

- Characters are more complicated than code points
A user-perceived character can contain multiple code points. For example, é may be one precomposed code point, or an e followed by a combining acute-accent code point. Emoji can join several code points into one displayed symbol. A grapheme cluster is the group a user would usually treat as one character when moving the cursor or pressing Backspace. COMP1521 mainly asks you to count and decode UTF-8 code points, but real text editors need this higher-level unit too.

UTF-32#

UTF-32 uses a 32-bit code unit for each Unicode scalar value. Conceptually, the value stored is the code point:

A   U+0041   -> 00000041
€   U+20AC   -> 000020AC
字  U+5B57   -> 00005B57
😀  U+1F600  -> 0001F600

The fixed width makes indexing by code point simple, but wastes space for ASCII-heavy text. It also still does not guarantee that one code unit equals one user-perceived character.

UTF-8#

UTF-8 is a variable-width encoding which represents one Unicode scalar value using one to four bytes:

Bytes Payload bits Pattern
1 7 0xxxxxxx
2 11 110xxxxx 10xxxxxx
3 16 1110xxxx 10xxxxxx 10xxxxxx
4 21 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Explanation and table showing one-byte ASCII and the leading-byte and continuation-byte prefixes used for two-, three- and four-byte UTF-8 sequences

The x bits carry the code point; the fixed 0, 10, 110, 1110 and 11110 bits describe the structure. A continuation byte reserves its first two bits as 10, leaving six of its eight bits available for code-point data. Prefixes make the structure self-identifying:

  • 0xxxxxxx is an ASCII byte and a complete one-byte character;
  • 110xxxxx, 1110xxxx and 11110xxx begin two-, three- and four-byte sequences;
  • 10xxxxxx is a continuation byte.

Use the leading-byte table and image above as a decision procedure: 0xxxxxxx means one byte total, 110xxxxx means two, 1110xxxx means three, and 11110xxx means four. A byte beginning 10 is a continuation byte, so it cannot begin a new character by itself.

ASCII is therefore a byte-for-byte subset of UTF-8. Bytes 0x00 through 0x7F never appear inside the multibyte encoding of another code point. In particular, a continuation byte cannot be zero or /, which lets UTF-8 fit safely into the existing Unix conventions for NUL-terminated pathnames.

Practice: Quick Checks#

  1. Distinguish a character, a code point, a Unicode scalar value and a UTF-8 byte sequence.
  2. What prefix identifies a continuation byte, and which masks can test its two high bits?

Note

- Quick Check Answers

  1. A character is an abstract textual element; a code point is a numbered position in the Unicode codespace; a scalar value excludes surrogate code points; UTF-8 is a variable-width byte encoding of those scalar values. A user-perceived grapheme may contain several code points.
  2. A continuation byte has prefix 10xxxxxx. Test (byte & 0xC0) == 0x80.

Encoding a Code Point#

Take €, code point U+20AC:

  1. Write the value in binary: 10 000010 101100.
  2. Split it from the right into six-bit groups.
  3. It needs three groups, so choose the three-byte template.
  4. Insert the payload bits and zero-fill unused leading payload positions.
template: 1110xxxx 10xxxxxx 10xxxxxx
payload:         10   000010   101100
result:   11100010 10000010 10101100
hex:         E2       82       AC

More examples:

Character Code point UTF-8 bytes
A U+0041 41
€ U+20AC E2 82 AC
字 U+5B57 E5 AD 97
😀 U+1F600 F0 9F 98 80

Decoding UTF-8#

To recover the code point:

  1. determine the length from the leading-byte prefix;
  2. verify that the required following bytes begin with 10;
  3. remove all structural prefix bits;
  4. concatenate the payload bits.

For E2 82 AC:

11100010 10000010 10101100
    0010   000010   101100
       10 000010 101100
       0x20AC

Validating real UTF-8 requires more than checking prefixes. A decoder must reject:

  • truncated sequences;
  • a continuation byte where a leading byte is expected;
  • a non-continuation byte inside a multibyte sequence;
  • overlong encodings, where a value uses more bytes than necessary;
  • surrogate code points U+D800 through U+DFFF;
  • values above U+10FFFF.

Warning

- A common exam shortcut
If a question only asks how many structurally indicated UTF-8 characters occur, prefix patterns may be sufficient. If it asks whether the sequence is valid UTF-8, check the entire sequence and the decoded value.

Practice: Encoding, Decoding and Validation#

  1. Decode F0 9F 98 80 by removing the structural prefix bits and concatenating the payload. Which scalar value is it?
  2. Explain why each sequence is invalid UTF-8: E2 82, C0 AF, ED A0 80, and F4 90 80 80.

Note

- Encoding, Decoding and Validation Answers
3. The payload bits form hexadecimal 1F600, so the scalar value is U+1F600 (😀).
4. E2 82 is truncated; C0 AF is an overlong encoding; ED A0 80 decodes into the surrogate range; F4 90 80 80 represents a value above U+10FFFF.

Working with UTF-8 in C#

A C string is still a zero-terminated array of char. The library sees bytes; it does not automatically see Unicode code points.

C
char message[] = "A€字😀";
printf("%zu bytes\n", strlen(message));

In UTF-8 this contains 1 + 3 + 3 + 4 = 11 bytes before the terminating zero. strlen(message) therefore returns 11, not 4.

If the input is already known to be well-formed UTF-8, a simple code-point count can count bytes which are not continuation bytes:

C
#include <stddef.h>

size_t utf8_code_points(const unsigned char *s) {
	size_t count = 0;

	for (size_t i = 0; s[i] != '\0'; i++) {
		if ((s[i] & 0xC0) != 0x80) {
			count++;
		}
	}

	return count;
}

0xC0 is 11000000. Masking with it preserves the first two bits. Continuation bytes have the pattern 10xxxxxx, so they produce 0x80 and are not counted. Bitwise Operations develops the mask-and-compare technique in detail.

This is not a validator. Invalid leading bytes would also be counted. A safer routine validates and advances according to the leading byte:

C
int utf8_sequence_length(unsigned char byte) {
	if ((byte & 0x80) == 0x00) return 1; // 0xxxxxxx
	if ((byte & 0xE0) == 0xC0) return 2; // 110xxxxx
	if ((byte & 0xF0) == 0xE0) return 3; // 1110xxxx
	if ((byte & 0xF8) == 0xF0) return 4; // 11110xxx
	return -1;
}

The full decoder must then check each continuation byte and reject illegal values.

UTF-8 Across Read Boundaries#

A file or pipe returns chunks of bytes, not whole Unicode characters. A valid multibyte sequence can therefore be split between two successful reads:

Suppose the stream is 41 E2 82 AC 0A, which encodes A, €, then a newline. A first read might return only 41 E2 82; the second then returns AC 0A.

After the first read, 41 is a complete ASCII code point, but E2 82 is only the beginning of the three-byte encoding E2 82 AC. It is not invalid merely because the current buffer ended. A streaming decoder retains that incomplete prefix and completes it when the next chunk arrives. It reports truncation only if the overall input ends before the required continuation bytes arrive.

Chunk Safe action
41 E2 82 decode 41 as U+0041 (A); retain the incomplete E2 82 prefix
append AC 0A decode E2 82 AC as U+20AC (€), then decode 0A as U+000A (newline)

This separates two different events: end of the current buffer means “obtain more bytes”, while end of the entire input means an unfinished sequence is truly truncated.

Important

- Chunked decoding
A pipe first returns F0 9F and later returns 98 80 21. What should a streaming UTF-8 decoder do after each read?

Note

- Reasoned answer
After the first read it recognises the start of a four-byte sequence but has only two bytes, so it retains F0 9F without accepting or rejecting a code point yet. It prepends those bytes to the next chunk, validates F0 9F 98 80 as U+1F600, then decodes 21 as ASCII !. If EOF arrived before 98 80, the retained prefix would be a truncated sequence.

Practice: Writing and Debugging#

  1. The simple utf8_sequence_length() helper accepts leading byte 0xC0 as a two-byte start. Why is that insufficient validation, and what later check is required?
  2. A loop counts every byte for which (byte & 0xC0) != 0x80. Give one kind of malformed input it would still count as a code-point start.

Note

- Writing and Debugging Answers
5. Prefix shape alone accepts illegal starts such as C0 and C1, which can only form overlong encodings. A full decoder must assemble the value and reject overlong forms, surrogates, out-of-range values, bad continuations and truncation.
6. An invalid leading byte such as 0xFF, or an isolated legal-looking start followed by missing/invalid continuation bytes, is still counted.

Bytes, Code Points and Display Width#

These counts answer different questions:

Question Relevant unit
How much memory does this UTF-8 string occupy? bytes
How many encoded Unicode scalar values are present? code points
How many characters does a user perceive? grapheme clusters
How many terminal columns will it occupy? display width

For plain ASCII, all four often coincide. Unicode makes the distinction visible.

Practice: Challenge#

  1. The UTF-8 string A€😀 is followed by a terminating zero. What do strlen() and a valid code-point counter return, and how many bytes of array storage are occupied including the terminator?
  2. Why can neither the UTF-8 byte count nor the code-point count reliably predict terminal columns?

Note

- Challenge Answers
7. The payload uses 1 + 3 + 4 = 8 bytes, so strlen() returns 8; the code-point count is 3; the array occupies 9 bytes including \0.
8. Grapheme clusters may combine several code points, while glyphs can occupy zero, one, two or context-dependent display columns. Display width belongs to a higher rendering layer.

Note

- Further reading
The course's official Unicode topic notes include the historical path from telegraphs to variable-width encodings. The Unicode Consortium's technical introduction gives the larger model, while the current standard's encoding-forms chapter defines UTF-8, UTF-16 and UTF-32 precisely.