Dữ liệu & CPU/Văn bản: Unicode và UTF-8 — vì sao tiếng Việt thành ô vuông
5/23
Bài 5 / 23~17 phútBiểu diễn dữ liệuMiễn phí lượt xem

Văn bản: Unicode và UTF-8 — vì sao tiếng Việt thành ô vuông

ASCII, Unicode code point và cách UTF-8 mã hoá một ký tự thành 1–4 byte. Vì sao chuỗi tiếng Việt hiện thành ký tự lạ (mojibake) và cách tránh lỗi encoding.

TL;DR: Máy tính lưu văn bản là dãy số; bảng mã quyết định số nào ứng ký tự nào. ASCII chỉ có 128 ký tự — không đủ cho tiếng Việt. Unicode mở rộng lên hơn 1 triệu ký tự bằng cách gán mỗi ký tự một số gọi là code point. UTF-8 là cách ghi code point ra byte: ASCII vẫn 1 byte (tương thích ngược), ký tự Việt thường dùng 3 byte. Hệ quả quan trọng: một ký tự không còn là một bytelen() theo byte cho kết quả khác theo ký tự. Khi đọc file UTF-8 bằng Latin-1 (hoặc ngược lại), bạn nhận được chuỗi "vô nghĩa" gọi là mojibake — lỗi encoding cổ điển nhất trong lập trình đa ngôn ngữ.

Bạn nhận file CSV từ đối tác: mở ra thấy "Tiêng Việt" hiện thành "Tiếng Việt". Hoặc bạn gọi len("café") trong Python và nhận về 5 thay vì 4. Hoặc cột VARCHAR(50) trong database từ chối lưu 50 ký tự tiếng Việt mà bạn nghĩ là đủ.

Tất cả những tình huống đó có chung một gốc: bạn (hoặc thư viện bạn dùng) đang nhầm lẫn giữa byteký tự. Bài này giải thích từng tầng của vấn đề — từ ASCII, Unicode code point, đến cách UTF-8 mã hoá byte — để bạn chẩn đoán và tránh được lỗi encoding trong mọi ngôn ngữ lập trình.

1. Analogy — bảng mã như từ điển song ngữ

Hình dung bạn và đối tác cần giao tiếp qua các con số. Hai bên thống nhất một "từ điển": số 65 nghĩa là chữ A, số 66B, số 233é... Khi bạn gửi dãy số 72 101 108 108 111, đối tác tra từ điển và đọc ra Hello.

Bảng mã (character encoding) chính là từ điển đó. Vấn đề xảy ra khi hai bên dùng hai từ điển khác nhau: số 233 trong Latin-1 là é, nhưng trong Windows-1258 (bảng mã tiếng Việt phổ biến trước kia) lại là ký tự khác hoàn toàn. Người viết dùng bảng mã A, người đọc dùng bảng mã B → văn bản ra sai.

Đời thườngKhái niệm mã hoá
Từ điển song ngữBảng mã (charset)
Từ → số trangKý tự → code point
In sách theo số trangEncode: code point → byte
Dịch số trang ra chữDecode: byte → code point
Hai bên dùng hai từ điển khácMojibake: encode/decode lệch bảng mã
💡 Cách nhớ

Encode = ký tự → byte (ghi ra đĩa/gửi qua mạng). Decode = byte → ký tự (đọc vào). Lỗi encoding = encode bằng A, decode bằng B.

flowchart LR
  A["Ki tu: A"] -->|"Encode UTF-8"| B["Byte: 65"]
  C["Ki tu: e-voi-dau"] -->|"Encode UTF-8"| D["Byte: E1 BA BF (3 byte)"]
  B -->|"Decode UTF-8"| E["Ki tu: A"]
  D -->|"Decode Latin-1 (NHAP SAI)"| F["Ki tu: ??? mojibake"]

2. Vì sao ASCII không đủ cho tiếng Việt?

ASCII (American Standard Code for Information Interchange, 1963) là bảng mã đầu tiên phổ biến. Nó dùng 7 bit để biểu diễn 128 ký tự: 26 chữ cái thường, 26 chữ hoa, 10 chữ số, dấu câu thông dụng, và 33 ký tự điều khiển (newline, tab, null...).

Dec  Hex  Char
 65  0x41  A
 97  0x61  a
 48  0x30  0
 10  0x0A  LF (newline)

Giới hạn cứng: với 7 bit chỉ có 2^7 = 128 ký tự — không có chỗ cho bất kỳ ký tự nào ngoài tiếng Anh. Không có é, không có ü, không có ê, , . Mỗi quốc gia tự mở rộng byte thứ 8 theo cách riêng → sinh ra hàng chục bảng mã Latin-1, Latin-2, Windows-1252, TCVN3, VNI... không tương thích nhau.

📚 Lịch sử — extended ASCII là ngộ nhận phổ biến

Không có chuẩn "extended ASCII" duy nhất. Byte từ 128–255 (bit thứ 8) được từng vùng/công ty định nghĩa riêng: Latin-1 (ISO-8859-1) dùng cho Tây Âu, Windows-1258 cho tiếng Việt trên Windows... Khi ai đó nói "extended ASCII", họ thường muốn nói đến một bảng mã 8-bit cụ thể — hỏi lại xem là bảng nào.

3. Unicode — tách khái niệm "ký tự" khỏi "byte"

Giải pháp căn bản: thay vì mỗi quốc gia một bảng mã, tạo một bảng mã duy nhất cho mọi ký tự của mọi ngôn ngữ trên thế giới. Đó là dự án Unicode (bắt đầu 1987, phiên bản đầu 1991).

3.1 Code point — định danh ký tự

Unicode tách rời hai khái niệm:

  1. Code point: số định danh duy nhất của một ký tự. Viết dạng U+XXXX (hex). Ví dụ: U+0041 = A, U+1EBF = ế, U+1F600 = 😀.
  2. Encoding: cách ghi code point ra byte khi lưu file hoặc truyền qua mạng.

Unicode hiện có hơn 1.1 triệu code point (dải từ U+0000 đến U+10FFFF), trong đó hơn 154.000 đã được gán ký tự (Unicode 16.0, 2024). Tiếng Việt nằm trong khối Latin Extended Additional (U+1E00–U+1EFF) — tất cả 134 ký tự Việt có dấu đều có code point ổn định.

U+0041  →  A
U+00E9  →  é
U+1EBF  →  ế
U+1EB9  →  ẹ
U+1F4BB →  💻

Biết code point chưa đủ — bạn còn cần biết cách ghi nó ra byte. Đó là lúc UTF-8 vào cuộc.

3.2 UTF-8 — mã hoá biến độ dài 1–4 byte

UTF-8 (Unicode Transformation Format — 8-bit) là encoding phổ biến nhất hiện nay (hơn 98% trang web, theo W3Techs 2024). Quy tắc mã hoá:

Dải code pointSố byteMẫu byte (b = bit dữ liệu)
U+0000 – U+007F10bbbbbbb
U+0080 – U+07FF2110bbbbb 10bbbbbb
U+0800 – U+FFFF31110bbbb 10bbbbbb 10bbbbbb
U+10000 – U+10FFFF411110bbb 10bbbbbb 10bbbbbb 10bbbbbb

Hai điểm then chốt:

Tương thích ngược ASCII: code point U+0000–U+007F mã hoá thành đúng 1 byte với bit đầu là 0 — trùng khớp hoàn toàn với ASCII. File tiếng Anh thuần ASCII và file UTF-8 byte-for-byte giống hệt nhau.

Ký tự Việt tốn 3 byte: code point U+1E00–U+1EFF nằm trong dải U+0800–U+FFFF → cần 3 byte. Ví dụ chữ ế (U+1EBF):

U+1EBF = 0001 1110 1011 1111  (binary)

Template 3 byte: 1110xxxx 10xxxxxx 10xxxxxx
                      ^^^^ ^^^^^^ ^^^^^^
Fill bits:        1110 0001  10 111010  10 111111
Hex:               0xE1        0xBA        0xBF

"ế" → byte: E1 BA BF
# Minh hoa byte representation
char = "ế"        # chu "e" voi dau sac mui
encoded = char.encode("utf-8")
print(list(encoded))   # [225, 186, 191]
print(hex(225), hex(186), hex(191))  # 0xe1 0xba 0xbf
[225, 186, 191]
0xe1 0xba 0xbf

4. Hệ quả lớn — một ký tự không còn là một byte

Đây là điểm gây nhầm lẫn nhiều nhất. Khi mọi ký tự đều là ASCII, len(s) (theo byte) = số ký tự. Nhưng với UTF-8 đa ngôn ngữ, điều đó không còn đúng.

s1 = "cafe"          # 4 ky tu ASCII
s2 = "café"     # 4 ky tu Unicode: c, a, f, e-voi-dau-sac

print(len(s1))                     # 4
print(len(s2))                     # 4  (Python dem code point)
print(len(s1.encode("utf-8")))     # 4  byte
print(len(s2.encode("utf-8")))     # 5  byte (e-voi-dau-sac = 2 byte)
4
4
4
5

Python 3 và JavaScript đếm theo code point (Java/JS dùng UTF-16 nội bộ, phức tạp hơn một chút — xem Đào sâu). Nhưng khi ghi ra file hoặc gửi qua mạng, hệ thống đếm byte. Ba cách đo "độ dài" một chuỗi:

Cách đoÝ nghĩaVí dụ với "café"
Byte countKích thước thực tế lưu trữ/truyền5 byte (UTF-8)
Code point countSố ký tự Unicode4
Grapheme clusterSố "ký tự nhìn thấy"4 (xem Đào sâu)
// Java: internal UTF-16, length() tra ve UTF-16 code unit
String s = "café";
System.out.println(s.length());              // 4
System.out.println(s.getBytes("UTF-8").length); // 5

5. Mojibake xảy ra như thế nào?

Mojibake (文字化け, tiếng Nhật) là hiện tượng văn bản bị hiển thị sai ký tự do đọc bằng bảng mã khác bảng mã đã dùng khi ghi.

Kịch bản kinh điển: bạn ghi file UTF-8 chứa "Tiếng Việt". Phần mềm khác đọc bằng Latin-1 (ISO-8859-1). Latin-1 dùng đúng 1 byte/ký tự, nên 3 byte của chữ T + i + byte đầu ế bị đọc thành 3 ký tự Latin-1 khác nhau → ra chuỗi vô nghĩa.

original = "Tiếng Việt"
print(original)  # Tieng Viet (voi dau)

# Ghi UTF-8, doc lai bang Latin-1 -> mojibake
utf8_bytes = original.encode("utf-8")
mojibake = utf8_bytes.decode("latin-1")  # doc sai charset
print(mojibake)  # Ti\xe1\xba\xbfng Vi\xe1\xbb\x87t - chuoi vo nghia
Tiếng Việt
Tiếng Việt

Ngược lại cũng xảy ra: file Latin-1 đọc bằng UTF-8. Vì một số byte đơn của Latin-1 không hợp lệ trong UTF-8 (byte 0x80–0xBF không được đứng đơn lẻ), bạn nhận UnicodeDecodeError hoặc ký tự thay thế (ký tự hỏi).

latin1_bytes = "caf\xe9".encode("latin-1")  # e voi dau sac theo Latin-1
try:
    result = latin1_bytes.decode("utf-8")   # doc sai
except UnicodeDecodeError as e:
    print(f"Decode error: {e}")
Decode error: 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte

6. Đào sâu (tuỳ chọn) — grapheme, normalization, UTF-16

📚 Đào sâu (tuỳ chọn) — ba tầng phức tạp hơn nữa

Grapheme cluster: một "ký tự nhìn thấy" có thể là nhiều code point ghép lại qua Zero-Width Joiner (ZWJ). Emoji gia đình 👨‍👩‍👧 là 3 emoji riêng nối bằng ZWJ — tổng 5 code point, nhưng chỉ trông như 1 ký tự. len("👨‍👩‍👧") trong Python trả về 5, không phải 1. Để đếm theo "ký tự nhìn thấy", dùng thư viện Unicode grapheme.

Unicode Normalization (NFC vs NFD): chữ ế có thể biểu diễn bằng 2 cách: (1) code point duy nhất U+1EBF (NFC — precomposed), hoặc (2) hai code point U+0065 e + U+0301 dấu sắc + U+0302 dấu mũ (NFD — decomposed). Cả hai trông giống nhau, nhưng so sánh chuỗi bằng == sẽ trả về False vì byte khác nhau! Normalize về cùng dạng trước khi so sánh: unicodedata.normalize("NFC", s) trong Python.

UTF-16 và surrogate pair: Java, JavaScript, C# dùng UTF-16 nội bộ. Code point từ U+D800–U+DFFF bị dự trữ cho "surrogate pair" — cơ chế ghép 2 unit UTF-16 để biểu diễn code point ngoài Basic Multilingual Plane (U+10000 trở lên, gồm nhiều emoji). "😀".length trong JavaScript trả về 2 chứ không phải 1 — vì emoji này cần surrogate pair. Đây là lý do codePointAt() được giới thiệu thay cho charCodeAt() trong ES6.

7. Áp dụng vào code của bạn

Luôn khai báo encoding rõ khi đọc/ghi file

Mọi ngôn ngữ đều có giá trị encoding mặc định phụ thuộc OS — trên macOS/Linux thường là UTF-8, trên Windows cũ có thể là Windows-1252 hay CP936. Đừng dựa vào default.

# SAI: encoding theo default OS, co the khac nhau giua may
with open("data.csv", "r") as f:
    content = f.read()

# DUNG: khai bao ro UTF-8
with open("data.csv", "r", encoding="utf-8") as f:
    content = f.read()

# Neu file co the co BOM (Byte Order Mark) dau file, dung utf-8-sig
with open("data.csv", "r", encoding="utf-8-sig") as f:
    content = f.read()
// SAI: dung default charset cua JVM (phu thuoc OS)
BufferedReader reader = new BufferedReader(new FileReader("data.csv"));

// DUNG: khai bao ro UTF-8
BufferedReader reader = new BufferedReader(
    new InputStreamReader(new FileInputStream("data.csv"), StandardCharsets.UTF_8));

// Hoac voi Files API (Java 11+)
List<String> lines = Files.readAllLines(Path.of("data.csv"), StandardCharsets.UTF_8);

Phân biệt giới hạn theo byte hay ký tự khi làm việc với database

Cột VARCHAR(n) trong SQL có nghĩa khác nhau tuỳ database engine và charset:

  • MySQL với utf8mb4: VARCHAR(50) giới hạn 50 ký tự (code point), không phải 50 byte. Ký tự Việt 3 byte vẫn được tính là 1.
  • PostgreSQL với UTF8: tương tự — VARCHAR(50) giới hạn 50 ký tự.
  • Nhưng: nếu engine dùng byte-length semantics (một số DB cũ, hoặc BINARY type), 50 byte chỉ chứa được khoảng 16 ký tự Việt.
-- PostgreSQL: kiem tra do dai theo ky tu va byte
SELECT
    char_length('Tiếng Việt')  AS char_len,   -- 10 ky tu
    octet_length('Tiếng Việt') AS byte_len;   -- 16 byte

Khi validate input phía backend, quyết định giới hạn theo đơn vị phù hợp với storage layer. Đừng validate "tối đa 100 ký tự" theo len(s) Python nếu DB đang giới hạn theo byte.

Xác định encoding khi nhận file từ bên ngoài

Khi nhận file CSV/text không biết encoding, dùng thư viện detect trước:

# Detect encoding voi chardet
import chardet

with open("unknown.csv", "rb") as f:
    raw = f.read(10000)  # doc 10KB dau de detect

result = chardet.detect(raw)
encoding = result["encoding"]   # vd: "utf-8", "windows-1258", "latin-1"
confidence = result["confidence"]  # 0.0 - 1.0

print(f"Detected: {encoding} ({confidence:.0%} confidence)")

Khi không chắc và không có công cụ detect: thử UTF-8 trước (errors="replace" để không crash), nếu thấy ký tự lạ thì thử UTF-16 rồi Windows-1258/CP1258 (bảng mã tiếng Việt phổ biến trên Windows).

8. Liên hệ các bài khác

9. Tóm tắt

  • ASCII dùng 7 bit, 128 ký tự, chỉ đủ tiếng Anh. Mỗi quốc gia tự mở rộng byte thứ 8 → hàng chục bảng mã không tương thích.
  • Unicode gán cho mọi ký tự một code point định danh duy nhất (dạng U+XXXX). Hơn 1 triệu code point, bao phủ mọi ngôn ngữ.
  • UTF-8 mã hoá code point thành 1–4 byte: ASCII vẫn 1 byte (tương thích ngược), ký tự Việt thường 3 byte.
  • Một ký tự KHÔNG còn là một byte: cần phân biệt đếm theo byte, code point, hay grapheme cluster.
  • Mojibake xảy ra khi encode bằng bảng mã A nhưng decode bằng bảng mã B — lỗi encoding kinh điển.
  • Luôn khai báo encoding rõ khi đọc/ghi file và I/O — đừng dựa vào default OS.
  • Khi giới hạn độ dài (DB, input validation), xác định rõ giới hạn theo byte hay ký tự — chúng khác nhau với văn bản đa ngôn ngữ.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao ASCII không đủ để lưu tiếng Việt, và người ta đã giải quyết vấn đề đó thế nào trước khi có Unicode?
ASCII dùng 7 bit nên chỉ có 128 ký tự — không có chỗ cho bất kỳ ký tự có dấu nào ngoài tiếng Anh. Trước Unicode, mỗi quốc gia tự định nghĩa ý nghĩa của 128 byte còn lại (byte 128–255), sinh ra hàng chục bảng mã như TCVN3, VNI, Windows-1258 cho tiếng Việt. Các bảng mã này không tương thích nhau — văn bản ghi bằng TCVN3 đọc bằng Windows-1258 sẽ bị lỗi ký tự.
Q2
Unicode code point và UTF-8 encoding khác nhau thế nào? Tại sao lại cần tách hai khái niệm này?
Code point là số định danh của ký tự (ví dụ U+1EBF = ế) — nó trả lời câu hỏi "ký tự này là gì". UTF-8 là cách ghi code point ra byte — trả lời câu hỏi "lưu thế nào". Tách hai khái niệm giúp Unicode có nhiều encoding khác nhau (UTF-8, UTF-16, UTF-32) phù hợp các mục đích khác nhau mà vẫn dùng chung bảng ký tự. Ví dụ Java dùng UTF-16 nội bộ nhưng đọc/ghi file vẫn có thể dùng UTF-8.
Q3
Chữ ế (U+1EBF) cần bao nhiêu byte trong UTF-8? Suy luận từ quy tắc mã hoá thế nào?
U+1EBF = 7871 trong decimal, nằm trong dải U+0800–U+FFFF nên cần 3 byte. Quy tắc: dải này dùng template 1110xxxx 10xxxxxx 10xxxxxx với 16 bit dữ liệu. Đổi 1EBF sang nhị phân: 0001 1110 1011 1111, điền vào template ra E1 BA BF. Đây là lý do một chuỗi 10 ký tự tiếng Việt có thể chiếm tới 30 byte khi encode UTF-8.
Q4
Mojibake xảy ra như thế nào? Cho ví dụ cụ thể với tiếng Việt.
Mojibake xảy ra khi encode bằng bảng mã A nhưng decode bằng bảng mã B khác. Ví dụ: chuỗi "Tiếng Việt" encode thành UTF-8 ra 16 byte. Nếu phần mềm đọc bằng Latin-1 (ISO-8859-1), nó đọc mỗi byte độc lập theo bảng Latin-1, cho ra chuỗi vô nghĩa như "Tiếng Việt". Latin-1 và UTF-8 chỉ tương đồng cho 128 ký tự ASCII đầu; với byte trên 127, chúng có nghĩa hoàn toàn khác nhau.
Q5
Cho s = "ốc" trong Python 3. len(s)len(s.encode("utf-8")) trả về bao nhiêu? Vì sao chúng khác nhau?
len(s) trả về 2 (2 code point: chữ c). len(s.encode("utf-8")) trả về 4 (chữ cần 3 byte UTF-8, chữ c cần 1 byte ASCII). Python 3 đếm len() theo Unicode code point, không phải byte. Khi cần kích thước thực tế để ghi file hay gửi mạng, phải encode trước rồi đếm byte.
Q6
Vì sao cần luôn khai báo encoding rõ khi mở file, thay vì dùng default của ngôn ngữ/OS?
Default encoding phụ thuộc OS và locale của máy: macOS/Linux thường mặc định UTF-8, nhưng Windows cũ có thể mặc định Windows-1252 hoặc CP936. Code chạy đúng trên máy dev (macOS, UTF-8) có thể crash hoặc ra mojibake trên server production (Windows, khác encoding). Khai báo tường minh encoding="utf-8" làm code portable và behaviour xác định — không phụ thuộc môi trường chạy.
Q7
Cột VARCHAR(50) trong database có nghĩa là tối đa 50 ký tự tiếng Việt không? Khi nào thì không?
Tuỳ engine. PostgreSQL và MySQL với utf8mb4 giới hạn 50 ký tự (code point), không phải byte — nên 50 ký tự Việt hoàn toàn vừa. Nhưng nếu storage layer dùng byte-length semantics (một số DB cũ, hoặc column type BINARY/VARBINARY), 50 byte chỉ chứa được khoảng 16 ký tự Việt (vì mỗi ký tự Việt 3 byte). Khi thiết kế schema, đọc tài liệu engine về ý nghĩa của tham số n trong VARCHAR(n) và đặt giới hạn validate phía app tương ứng.

Bài tiếp theo: Byte order và thao tác bit

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên