Proven C Book한국어 GitHub

72 In practice — handling Unicode and multibyte encodings

What to know first

chapter 71, Wide characters ② · UTF-16 and the platform split
chapter 70, Wide characters ① · conversion functions and mbstate_t
chapter 9, Characters and text · code points and encodings

Looking back

Through chapters 70 and 71 the advice “handle UTF-8 byte strings as they are” kept returning. But if you handle bytes as they are, how do you do something like “delete the third character”?

A. Because most programs never do that. Count what they actually do: read, store, compare, concatenate, search, and hand back out. All six work on UTF-8 byte strings with no decoding at all.

The work that needs characters — moving a cursor, finding a line-break point, counting letters — belongs to an editor or a renderer, and at that layer even code points are not enough; you need grapheme clusters. This chapter draws that line: how far to go on bytes, where decoding begins, and what to watch for there.

The need for this chapter, and its context

The last of three chapters (70, 71, 72) and the one that prescribes. The prescription comes last because the conclusion “UTF-8 first” only persuades after living through the previous two. Said up front with no grounds, it sounds like following a fashion.

By the end of this chapter

The prescriptions of practice. Why a UTF-8-first design wins, the three layers of “how many characters” and grapheme clusters, normalisation, case that depends on language, a UTF-8 validator written by hand, and the traps of the legacy two-byte encodings still in use.

The questions this chapter answers

  1. How do you count grapheme clusters?

72.1 The principle — UTF-8 inside, conversion only at the boundary

The conclusion first.

PlaceWhat to use
Inside the programUTF-8 byte strings (char *, length in bytes)
Files, networks, databasesUTF-8
Calling the Windows APIConvert to UTF-16 at the boundary only
Character-level editing and renderingDecode to code points or graphemes only where needed

Table 73.1

Why this design wins follows straight from UTF-8′s properties.

Property of UTF-8What it buys
Fully compatible with ASCIIExisting code, protocols and file formats keep working
Trail bytes are always 0x80 or aboveThey never collide with separators like '/', '\\' or ','
It is self-synchronisingA character boundary can be found from any position
Byte order equals code-point orderstrcmp gives a code-point-ordered sort
It has no endiannessNo byte order mark is needed

Table 73.2

examples-en/ch72/utf8_scan.c

/* Reading UTF-8 by hand — the rule, and what must be rejected (RFC 3629). */
#include <stdio.h>
#include <stddef.h>
#include <string.h>

/* The whole rule of UTF-8 fits in one table.
     0xxxxxxx                            -> 1 byte,  U+0000..U+007F
     110xxxxx 10xxxxxx                   -> 2 bytes, U+0080..U+07FF
     1110xxxx 10xxxxxx 10xxxxxx          -> 3 bytes, U+0800..U+FFFF
     11110xxx 10xxxxxx 10xxxxxx 10xxxxxx -> 4 bytes, U+10000..U+10FFFF
   A trail byte is always 10xxxxxx. So from any byte you can tell whether you
   are at the start of a character or inside one — that is self-synchronisation. */

typedef enum { OK, BAD_LEAD, BAD_TRAIL, TRUNCATED, OVERLONG, SURROGATE, TOO_BIG } verdict;

static const char *why(verdict v)
{
    switch (v) {
    case OK:        return "fine";
    case BAD_LEAD:  return "not a value that can lead a character";
    case BAD_TRAIL: return "a trail byte is not 10xxxxxx";
    case TRUNCATED: return "not enough bytes";
    case OVERLONG:  return "overlong — this could be written shorter";
    case SURROGATE: return "surrogates (U+D800..DFFF) cannot be encoded";
    case TOO_BIG:   return "beyond U+10FFFF";
    }
    return "?";
}

/* Read one character. On success the byte count goes into *len. */
static verdict decode(const unsigned char *s, size_t n,
                      unsigned long *cp, size_t *len)
{
    if (n == 0) return TRUNCATED;
    unsigned char c = s[0];
    size_t need;
    unsigned long v;

    if (c < 0x80)                  { need = 1; v = c; }
    else if ((c & 0xE0) == 0xC0)   { need = 2; v = c & 0x1Fu; }
    else if ((c & 0xF0) == 0xE0)   { need = 3; v = c & 0x0Fu; }
    else if ((c & 0xF8) == 0xF0)   { need = 4; v = c & 0x07u; }
    else return BAD_LEAD;          /* starts 10xxxxxx, or 11111xxx */

    if (n < need) return TRUNCATED;
    for (size_t i = 1; i < need; i++) {
        if ((s[i] & 0xC0) != 0x80) return BAD_TRAIL;
        v = (v << 6) | (unsigned long)(s[i] & 0x3Fu);
    }

    /* From here on: shapes that parse but must not be accepted */
    static const unsigned long lowest[5] = { 0, 0, 0x80, 0x800, 0x10000 };
    if (v < lowest[need])                return OVERLONG;
    if (v >= 0xD800UL && v <= 0xDFFFUL)  return SURROGATE;
    if (v > 0x10FFFFUL)                  return TOO_BIG;

    *cp = v; *len = need;
    return OK;
}

static void scan(const char *label, const unsigned char *s, size_t n)
{
    printf("\n[%s]", label);
    for (size_t i = 0; i < n; i++) printf(" %02X", s[i]);
    puts("");

    for (size_t pos = 0; pos < n; ) {
        unsigned long cp = 0;
        size_t len = 0;
        verdict v = decode(s + pos, n - pos, &cp, &len);
        if (v == OK) {
            printf("  %zu byte%s -> U+%04lX\n", len, len == 1 ? "" : "s", cp);
            pos += len;
        } else {
            printf("  rejected: %s\n", why(v));
            pos += 1;                /* drop one byte and resynchronise */
            break;
        }
    }
}

int main(void)
{
    /* the good ones */
    scan("ASCII", (const unsigned char *)"Hi", 2);
    scan("Hangul U+D55C", (const unsigned char *)"\xED\x95\x9C", 3);
    scan("emoji", (const unsigned char *)"\xF0\x9F\x98\x80", 4);

    /* the ones that must be rejected */
    scan("starts on a trail byte", (const unsigned char *)"\x9C", 1);
    scan("a truncated three-byte", (const unsigned char *)"\xED\x95", 2);
    scan("a bad trail byte", (const unsigned char *)"\xED\x41\x9C", 3);
    scan("overlong C0 80", (const unsigned char *)"\xC0\x80", 2);
    scan("surrogate ED A0 80", (const unsigned char *)"\xED\xA0\x80", 3);
    scan("out of range F5 80 80 80", (const unsigned char *)"\xF5\x80\x80\x80", 4);

    puts("\nSelf-synchronisation — point at any byte and you know where you are:");
    const unsigned char *s = (const unsigned char *)"a한글b";
    size_t n = strlen((const char *)s);
    for (size_t i = 0; i < n; i++)
        printf("  byte %zu (%02X): %s\n", i, s[i],
               (s[i] & 0xC0) == 0x80 ? "inside a character" : "start of a character");
    return 0;
}

Output


[ASCII] 48 69
  1 byte -> U+0048
  1 byte -> U+0069

[Hangul U+D55C] ED 95 9C
  3 bytes -> U+D55C

[emoji] F0 9F 98 80
  4 bytes -> U+1F600

[starts on a trail byte] 9C
  rejected: not a value that can lead a character

[a truncated three-byte] ED 95
  rejected: not enough bytes

[a bad trail byte] ED 41 9C
  rejected: a trail byte is not 10xxxxxx

[overlong C0 80] C0 80
  rejected: overlong — this could be written shorter

[surrogate ED A0 80] ED A0 80
  rejected: surrogates (U+D800..DFFF) cannot be encoded

[out of range F5 80 80 80] F5 80 80 80
  rejected: beyond U+10FFFF

Self-synchronisation — point at any byte and you know where you are:
  byte 0 (61): start of a character
  byte 1 (ED): start of a character
  byte 2 (95): inside a character
  byte 3 (9C): inside a character
  byte 4 (EA): start of a character
  byte 5 (B8): inside a character
  byte 6 (80): inside a character
  byte 7 (62): start of a character

The last part of the demonstration shows self-synchronisation. Point at any byte and testing (b & 0xC0) == 0x80 alone tells you whether you are inside a character or at its start — which is why a buffer can be cut anywhere and recovered, and why a scanner can move on to the next character in damaged data.

72.2 Validation — what to reject even when the shape is right

Code that reads UTF-8 must validate. The rules are RFC 3629 (STD 63), and three things are not caught by the shape alone.

What to rejectExampleWhy
Overlong encodingsC0 80U+0000 written in two bytes — a classic way past a check
Encoded surrogatesED A0 80U+D800 is not a character (chapter 71)
Out of rangeF5 80 80 80Beyond U+10FFFF

Table 73.3

The demonstration rejects each of the three. The first row matters most — overlong encodings are a security problem. There really were attacks that wrote .. or / in several bytes to slip past a path check, so that a later layer would interpret them again. That is why “only the shortest form is valid” became a requirement of the specification.

A common misconception. “UTF-8 is just bytes, so it can be passed through without validation”

Passing it through the middle untouched is mostly safe. The problem is the moment of interpretation.

An unvalidated byte string can be interpreted differently by different layers, and that mismatch becomes a security hole — the first layer sees “odd bytes” and lets them by, the next reads them as /. This is especially so where a web server, a file system and a database meet.

One discipline — validate once at the input boundary and trust it afterwards. Marking clearly in the code where that happened is part of the discipline. Part XII’s u8 family in proven is an example of enforcing it through the type.

72.3 The three layers of “how many characters”, and the fourth

Chapter 71 said length has three answers. Counting what a reader sees makes four.

examples-en/ch72/graphemes.c

/* "How many characters" has three answers — bytes, code points, and what a reader sees. */
#include <locale.h>
#include <stdio.h>
#include <string.h>
#include <wchar.h>

/* The full rule for "one character as a reader sees it" (a grapheme cluster)
   is Unicode UAX #29, and a proper implementation needs tables. Here we count
   with a *simplified rule* covering the four cases you meet most — the point is
   to see the concept, not to be complete.
     (1) combining marks (U+0300..U+036F and friends) join the previous one
     (2) Hangul conjoining jamo (U+1100..U+11FF) join the previous one
     (3) whatever follows a ZWJ (U+200D) joins the previous one
     (4) regional indicators (U+1F1E6..U+1F1FF) pair up into a flag */
static int is_combining(unsigned long c)
{
    return (c >= 0x0300 && c <= 0x036F)     /* combining diacritics */
        || (c >= 0x1160 && c <= 0x11FF)     /* Hangul vowels and final consonants */
        || (c >= 0xFE00 && c <= 0xFE0F)     /* variation selectors */
        || (c >= 0x1F3FB && c <= 0x1F3FF);  /* skin-tone modifiers */
}
static int is_regional(unsigned long c) { return c >= 0x1F1E6 && c <= 0x1F1FF; }

static void measure(const char *label, const char *s)
{
    size_t bytes = strlen(s);
    size_t cps = 0, clusters = 0;

    mbstate_t st;
    memset(&st, 0, sizeof st);

    unsigned long prev = 0;
    int prev_was_zwj = 0, prev_regional = 0;

    for (size_t pos = 0; pos < bytes; ) {
        wchar_t wc = 0;
        size_t r = mbrtowc(&wc, s + pos, bytes - pos, &st);
        if (r == (size_t)-1 || r == (size_t)-2) break;
        if (r == 0) r = 1;
        pos += r;
        cps++;

        unsigned long c = (unsigned long)wc;
        int joins = (cps > 1) && (is_combining(c) || prev_was_zwj || c == 0x200D
                                  || (is_regional(c) && prev_regional));
        if (!joins) clusters++;

        prev_was_zwj = (c == 0x200D);
        prev_regional = is_regional(c) && !prev_regional;
        prev = c;
    }
    (void)prev;

    printf("  bytes %2zu  code points %2zu  visible characters %2zu   %s\n",
           bytes, cps, clusters, label);
}

int main(void)
{
    const char *loc = setlocale(LC_CTYPE, "C.UTF-8");
    if (!loc) loc = setlocale(LC_CTYPE, "en_US.UTF-8");
    if (!loc) { puts("no UTF-8 locale — skipping this demonstration"); return 0; }

    puts("[the same letter, written two ways]");
    measure("\"\" precomposed, U+AC00",       "\uAC00");
    measure("\"\" composed, U+1100 U+1161",  "\u1100\u1161");
    measure("\"e\\u0301\", combining accent",  "e\u0301");
    measure("\"\\u00E9\", precomposed",         "\u00E9");

    puts("\n[emoji: several code points that look like one character]");
    measure("grinning face",          "\U0001F600");
    measure("family, joined by ZWJ",  "\U0001F468\u200D\U0001F469\u200D\U0001F467");
    measure("a flag, two indicators", "\U0001F1F0\U0001F1F7");
    measure("wave + skin tone",       "\U0001F44B\U0001F3FD");

    puts("\n[one sentence]");
    measure("\"Hello, 세계!\"", "Hello, 세계!");

    puts("\nSo: before asking \"how many characters\", settle *which layer*.");
    puts("  bytes = what you store and send,  code points = Unicode's unit,");
    puts("  grapheme clusters = what the cursor steps over (UAX #29).");
    puts("The count above uses four simplified rules — a complete decision");
    puts("belongs to a dedicated library such as ICU.");
    return 0;
}

Output

[the same letter, written two ways]
  bytes  3  code points  1  visible characters  1   "가" precomposed, U+AC00
  bytes  6  code points  2  visible characters  1   "가" composed, U+1100 U+1161
  bytes  3  code points  2  visible characters  1   "e\u0301", combining accent
  bytes  2  code points  1  visible characters  1   "\u00E9", precomposed

[emoji: several code points that look like one character]
  bytes  4  code points  1  visible characters  1   grinning face
  bytes 18  code points  5  visible characters  1   family, joined by ZWJ
  bytes  8  code points  2  visible characters  1   a flag, two indicators
  bytes  8  code points  2  visible characters  1   wave + skin tone

[one sentence]
  bytes 14  code points 10  visible characters 10   "Hello, 세계!"

So: before asking "how many characters", settle *which layer*.
  bytes = what you store and send,  code points = Unicode's unit,
  grapheme clusters = what the cursor steps over (UAX #29).
The count above uses four simplified rules — a complete decision
belongs to a dedicated library such as ICU.
LayerUnitWhere it is used
BytescharStorage, transmission, buffer sizes
Code unitsA UTF-16 unit, …The length of Java and JavaScript
Code pointsOne Unicode valueNormalisation, classification, conversion
Grapheme clustersWhat a reader sees as one characterCursor movement, counting, truncation

Table 73.4

The demonstration measures the difference. One emoji family is 18 bytes, 5 code points, 1 visible character. An invisible character, ZWJ (U+200D), joined three people into one. A flag is two regional indicators gathered into one.

The same happens in Hangul. “가” can be written as the precomposed U+AC00 or as U+1100 (ㄱ) + U+1161 (ㅏ) — what appears on screen is the same letter.

Q. How do you count grapheme clusters?

A. The rules are Unicode Annex UAX #29. It defines “where a character breaks” by combinations of character properties, and implementing it properly needs the Unicode database.

The demonstration’s count is a simplified version covering the four cases you meet most — combining marks, Hangul conjoining jamo, ZWJ joins and regional indicator pairs. That is enough to count most emoji and Hangul correctly, but it is not complete.

The practical conclusion: do not implement it yourself. Where grapheme units are genuinely needed (an editor’s cursor, display width), use ICU or its like. The purpose of this section is to know when they are needed: not for storage, comparison or transmission, but only when handling what a person sees.

72.4 Normalisation — the same letter, different bytes

The two ways of writing “가” become a problem at once in practice, because different bytes make strcmp say different. To the user it is the same letter, yet the search misses, file names collide, and a login name is treated as another.

Unicode handles this with normalisation (UAX #15). There are four forms.

FormWhatExample
NFCCompose as far as possible (the usual recommendation)+
NFDDecompose as far as possible+
NFKC, NFKDAlso unify compatibility characters that look different(주), fullwidth A

Table 73.5

In practice. macOS file names and NFD

macOS’s HFS+ file system stored file names normalised into something close to NFD. So a Korean file name created on macOS and moved to Linux often appeared with its jamo pulled apart.

For the same reason archives broke, web servers returned 404, and git status reported perfectly good files as modified. Git ended up adding a core.precomposeunicode setting to put names back into NFC on macOS.

Two lessons. One, normalise before comparing strings — especially when using something a person typed (a file name, a user name, a search term) as a key. Two, settle on one form across the whole system — usually NFC.

Standard C has no normalisation function. ICU or its like is required.

72.5 Case and sorting depend on the language

The assumption that toupper turns one character into one character also collapses under Unicode.

ExampleWhat happensSo
Turkish iIts capital is İ (dotted I)Without a locale it cannot be done right
Turkish IIts lower case is ı (dotless i)The exact opposite of English
German ßIts capital is SS, two lettersNot a one-to-one mapping
Greek ΣAt the end of a word it is ςIt depends on position

Table 73.6

In practice. The Turkish i — code that really did break

The common idiom “to compare case-insensitively, upper-case both and compare” breaks in a Turkish locale. Upper-casing "file" gives "FİLE", which is not "FILE".

Because of this, extension checks failed, HTTP header names did not match and configuration keys went unrecognised in real software. Java’s toUpperCase(Locale.ROOT) exists as a separate call because of this problem.

The remedy is to separate the layers. What a machine compares — protocols, identifiers, file extensions — is folded by ASCII rules only, independent of the locale. The locale is used only for names shown to people.

/* for machines — ASCII only, independent of the locale */
static char ascii_lower(char c)
{ return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; }

72.6 Still alive — the legacy two-byte encodings

The world has not all become UTF-8. Old files, old databases and the protocols of old equipment still carry regional two-byte encodings.

EncodingRegionUnderlying standard
EUC-KR / CP949 (UHC)KoreaKS X 1001 (formerly KS C 5601)
Shift_JIS / CP932JapanJIS X 0208
Big5Taiwan, Hong Kong— (a de facto standard)
GBK / GB 18030ChinaGB 18030-2022 (a mandatory Chinese national standard)

Table 73.7

Their common structure is “lead byte + trail byte”. And in some of them the trail byte reaches into ASCII — which is where the famous accident happens.

examples-en/ch72/legacy_lead.c

/* Legacy two-byte encodings — what happens when trail bytes overlap ASCII. */
#include <stdio.h>
#include <string.h>

/* Each encoding has its own lead-byte and trail-byte ranges.
   The dangerous ones are those whose trail bytes reach into ASCII. */
static void show_table(void)
{
    puts("byte ranges per encoding (two-byte characters)");
    puts("  encoding    lead bytes         trail bytes           0x5C('\\') as trail?");
    puts("  EUC-KR      A1-FE              A1-FE                 no");
    puts("  CP949(UHC)  81-FE              41-5A,61-7A,81-FE     no");
    puts("  Shift_JIS   81-9F,E0-FC        40-7E,80-FC           * yes");
    puts("  Big5        81-FE              40-7E,A1-FE           * yes");
    puts("  GBK         81-FE              40-FE(not 7F)         * yes");
}

/* A path written in Shift_JIS: C:\表\ソ.txt
   表 = 95 5C, ソ = 83 5C — both have 0x5C as their trail byte (checked with iconv). */
static const unsigned char sjis_path[] = {
    'C', ':', 0x5C, 0x95, 0x5C, 0x5C, 0x83, 0x5C, '.', 't', 'x', 't', 0
};

/* The same in EUC-KR: C:\한글.txt  (한 = C7 D1, 글 = B1 DB) */
static const unsigned char euckr_path[] = {
    'C', ':', 0x5C, 0xC7, 0xD1, 0xB1, 0xDB, '.', 't', 'x', 't', 0
};

static int sjis_is_lead(unsigned char c)
{ return (c >= 0x81 && c <= 0x9F) || (c >= 0xE0 && c <= 0xFC); }

static int euckr_is_lead(unsigned char c) { return c >= 0xA1 && c <= 0xFE; }

/* Find the last separator, stepping over lead bytes — the correct way */
static long safe_last_sep(const unsigned char *s, int (*is_lead)(unsigned char))
{
    long last = -1;
    for (size_t i = 0; s[i]; ) {
        if (is_lead(s[i]) && s[i + 1]) { i += 2; continue; }  /* a two-byte character */
        if (s[i] == 0x5C) last = (long)i;
        i += 1;
    }
    return last;
}

static void dump(const char *label, const unsigned char *s)
{
    printf("  %-10s", label);
    for (size_t i = 0; s[i]; i++) printf(" %02X", s[i]);
    puts("");
}

static void test(const char *name, const unsigned char *path,
                 int (*is_lead)(unsigned char))
{
    printf("\n[%s]\n", name);
    dump("bytes", path);

    /* The usual way — look at bytes only, find the last '\\' */
    const char *hit = strrchr((const char *)path, 0x5C);
    long naive = hit ? (long)(hit - (const char *)path) : -1;
    long safe  = safe_last_sep(path, is_lead);

    printf("  last '\\' found by strrchr:   at %ld\n", naive);
    printf("  a lead-byte-aware scan:      at %ld\n", safe);
    if (naive == safe) puts("  -> the same. This encoding is safe here.");
    else {
        puts("  -> different! strrchr took a *trail byte* for a separator.");
        printf("     Cut there and the file name becomes \"");
        for (size_t i = (size_t)naive + 1; path[i]; i++) printf("%02X ", path[i]);
        puts("\" — a broken fragment.");
    }
}

int main(void)
{
    show_table();
    test("Shift_JIS: C:\\\\ソ.txt", sjis_path, sjis_is_lead);
    test("EUC-KR: C:\\한글.txt", euckr_path, euckr_is_lead);

    puts("\nThis accident even has a name in Japan — dame-moji (ダメ文字),");
    puts("with 表, ソ, 十 and ダ as the usual victims. UTF-8 does not have it:");
    puts("its trail bytes are always 0x80 or above, so they never touch ASCII.");

    const unsigned char utf8_path[] = "C:\\한글.txt";
    dump("UTF-8", utf8_path);
    const char *h = strrchr((const char *)utf8_path, 0x5C);
    printf("  last '\\' found by strrchr:   at %ld — always right\n",
           h ? (long)(h - (const char *)utf8_path) : -1);
    return 0;
}

Output

byte ranges per encoding (two-byte characters)
  encoding    lead bytes         trail bytes           0x5C('\') as trail?
  EUC-KR      A1-FE              A1-FE                 no
  CP949(UHC)  81-FE              41-5A,61-7A,81-FE     no
  Shift_JIS   81-9F,E0-FC        40-7E,80-FC           * yes
  Big5        81-FE              40-7E,A1-FE           * yes
  GBK         81-FE              40-FE(not 7F)         * yes

[Shift_JIS: C:\表\ソ.txt]
  bytes      43 3A 5C 95 5C 5C 83 5C 2E 74 78 74
  last '\' found by strrchr:   at 7
  a lead-byte-aware scan:      at 5
  -> different! strrchr took a *trail byte* for a separator.
     Cut there and the file name becomes "2E 74 78 74 " — a broken fragment.

[EUC-KR: C:\한글.txt]
  bytes      43 3A 5C C7 D1 B1 DB 2E 74 78 74
  last '\' found by strrchr:   at 2
  a lead-byte-aware scan:      at 2
  -> the same. This encoding is safe here.

This accident even has a name in Japan — dame-moji (ダメ文字),
with 表, ソ, 十 and ダ as the usual victims. UTF-8 does not have it:
its trail bytes are always 0x80 or above, so they never touch ASCII.
  UTF-8      43 3A 5C ED 95 9C EA B8 80 2E 74 78 74
  last '\' found by strrchr:   at 2 — always right

The demonstration reproduces it. In Shift_JIS, 表 is 95 5C and ソ is 83 5C, and that second byte 0x5C is the ASCII backslash. So strrchr(path, '\\') mistakes a character’s trail byte for a path separator. In the demonstration it points at 7 instead of the correct 5, and cutting there breaks the file name.

In Japan these characters even have a name — dame-moji (ダメ文字). 表, ソ, 十 and ダ caused accidents over and over in path handling, escaping and SQL.

EncodingTrail byte range0x5C as a trail?
EUC-KRA1~FENo — safe
CP94941~5A, 61~7A, 81~FENo — safe
Shift_JIS40~7E, 80~FC★ Yes
Big540~7E, A1~FE★ Yes
GBK40~FE (not 7F)★ Yes
UTF-880~BFNo — structurally impossible

Table 73.8

The Korean encodings escaped this accident by luck. EUC-KR’s trail bytes are all 0xA1 or above and so never touch ASCII. Code handling Japanese or Chinese, by contrast, must use a lead-byte-aware scan.

Counter-example. Scanning a legacy-encoded string byte by byte

char *ext = strrchr(filename, '.');    /* can misbehave in Shift_JIS */
for (char *p = s; *p; p++)             /* mistakes a trail byte for a character */
    if (*p == ',') split(p);

On meeting a lead byte you must step over two. The safe_last_sep of the demonstration is that shape. With standard functions, advance one character at a time with mblen or mbrtowc — but only if the locale is that encoding (chapter 70).

The better prescription is not to create the problem — convert to UTF-8 at the input boundary and handle only UTF-8 inside.

72.7 What does the converting

MeansWhereCharacter
iconvPOSIXNamed by encoding. Outside standard C, but on every Unix
The MultiByteToWideChar familyWindowsNamed by code page number
ICUPortableThe most complete — normalisation, collation, graphemes
The mbrtowc familyStandard COnly the locale’s encoding (chapter 70)
Writing it yourselfUTF-8 ↔ UTF-16/32 is short. The demonstrations are the example

Table 73.9

Let us restate that standard C alone cannot handle an arbitrary encoding. mbrtowc knows only “the encoding of the current locale”. So a task like “read a EUC-KR file and save it as UTF-8” is outside standard C and needs iconv or a conversion table of your own.

Recap

What to rememberThe point
DesignUTF-8 byte strings inside, conversion at the boundary only
ValidationOnce, at the input boundary. Reject overlong, surrogate and out-of-range
LengthBytes, code units, code points, graphemes — settle which layer first
NormalisationTo NFC before comparing. Not in standard C
CaseASCII rules for machines. Remember the Turkish i
LegacyShift_JIS, Big5 and GBK have trail bytes that reach into ASCII
ScanningIn legacy encodings, know the lead bytes and step over
Conversioniconv, ICU, platform APIs. Standard C only does the locale’s encoding

Table 73.10

The story of characters that began in chapter 67 closes after six chapters — from the judgement of one byte through locales and wchar_t to the prescriptions of practice. The next chapter is what recent standards added, and the long argument over “safe” functions.