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 byte — len() 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 byte và ký 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ố 66 là B, số 233 là é... 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ường | Khái niệm mã hoá |
|---|---|
| Từ điển song ngữ | Bảng mã (charset) |
| Từ → số trang | Ký tự → code point |
| In sách theo số trang | Encode: code point → byte |
| Dịch số trang ra chữ | Decode: byte → code point |
| Hai bên dùng hai từ điển khác | Mojibake: encode/decode lệch bảng mã |
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.
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:
- 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=😀. - 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 point | Số byte | Mẫu byte (b = bit dữ liệu) |
|---|---|---|
| U+0000 – U+007F | 1 | 0bbbbbbb |
| U+0080 – U+07FF | 2 | 110bbbbb 10bbbbbb |
| U+0800 – U+FFFF | 3 | 1110bbbb 10bbbbbb 10bbbbbb |
| U+10000 – U+10FFFF | 4 | 11110bbb 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ĩa | Ví dụ với "café" |
|---|---|---|
| Byte count | Kích thước thực tế lưu trữ/truyền | 5 byte (UTF-8) |
| Code point count | Số ký tự Unicode | 4 |
| Grapheme cluster | Số "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
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
BINARYtype), 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
- Bài 01 — Bit, byte và hệ cơ số: hex là cách đọc các byte UTF-8 như
E1 BA BF; kiến thức hex ở bài 01 áp dụng trực tiếp ở đây. - Bài 05 — Byte order và thao tác bit: BOM (Byte Order Mark) là dấu hiệu endianness cho UTF-16 — liên hệ trực tiếp giữa encoding và thứ tự byte.
- Bài 03 — Số thực IEEE 754: cùng tư duy "không gian bit hữu hạn buộc phải chọn cách mã hoá".
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
Q1Vì 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?▸
Q2Unicode 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?▸
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.Q3Chữ ế (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) cần bao nhiêu byte trong UTF-8? Suy luận từ quy tắc mã hoá thế nào?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.Q4Mojibake xảy ra như thế nào? Cho ví dụ cụ thể với tiếng Việt.▸
Q5Cho s = "ốc" trong Python 3. len(s) và len(s.encode("utf-8")) trả về bao nhiêu? Vì sao chúng khác nhau?▸
s = "ốc" trong Python 3. len(s) và 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ữ ố và 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.Q6Vì sao cần luôn khai báo encoding rõ khi mở file, thay vì dùng default của ngôn ngữ/OS?▸
encoding="utf-8" làm code portable và behaviour xác định — không phụ thuộc môi trường chạy.Q7Cộ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?▸
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
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