34 điểm bởi GN⁺ 2025-09-13 | 1 bình luận | Chia sẻ qua WhatsApp
  • UTF-8 là một phương thức mã hóa độ dài biến thiên vừa biểu diễn được hàng triệu ký tự vừa duy trì tính tương thích ngược với ASCII
  • Vùng 7 bit giống hệt ASCII (U+0000~U+007F) được dùng nguyên vẹn dưới dạng 1 byte, nên tệp ASCII cũng chính là một tệp UTF-8 hợp lệ
  • Các ký tự còn lại được biểu diễn bằng chuỗi 2~4 byte, trong đó mẫu bit của byte đầu xác định độ dài còn các byte sau bắt đầu bằng 10 để phân biệt là byte tiếp nối
  • Nhờ thiết kế này, UTF-8 có thể xử lý tập ký tự phổ quát mà vẫn tương thích hoàn toàn với các hệ thống ASCII hiện có, nên đã trở thành bảng mã ký tự được dùng rộng rãi nhất
  • Các kiểu mã hóa Unicode khác như UTF-16, UTF-32 không cung cấp khả năng tương thích ASCII như vậy

Sự xuất sắc trong thiết kế của UTF-8

  • Khi lần đầu tìm hiểu về mã hóa UTF-8, tôi đã rất ấn tượng với cấu trúc tương thích với ASCII hiện có trong khi vẫn bao quát được hàng triệu ký tự của nhiều ngôn ngữ và hệ chữ khác nhau trong một hệ thống duy nhất
  • Về cơ bản UTF-8 có thể tận dụng tối đa 32 bit, nhưng ASCII chỉ dùng 7 bit
  • Các nguyên tắc thiết kế của UTF-8 như sau
    • Mọi tệp mã hóa ASCII đều là tệp UTF-8 hợp lệ
    • Mọi tệp UTF-8 chỉ chứa các ký tự ASCII đều là tệp ASCII hợp lệ
  • Ý tưởng kết nối một hệ thống cũ chỉ giới hạn ở 128 ký tự với một cơ chế bao quát hàng triệu ký tự thực sự rất đột phá

Khái niệm cơ bản của UTF-8

  • UTF-8 là một mã hóa ký tự độ dài biến thiên (variable-width encoding) được thiết kế để biểu diễn mọi ký tự trong bộ ký tự Unicode
  • Mỗi ký tự được mã hóa bằng 1~4 byte
  • 128 ký tự đầu tiên (U+0000~U+007F) được lưu bằng một byte duy nhất, nhờ đó đảm bảo tính tương thích ngược với ASCII
  • Các ký tự còn lại được mã hóa bằng hai, ba hoặc bốn byte
  • Các bit dẫn đầu của byte đầu tiên quyết định tổng số byte cần cho việc mã hóa
Mẫu 1 byte Số byte Mẫu toàn bộ chuỗi byte
0xxxxxxx 1 0xxxxxxx (ASCII thông thường)
110xxxxx 2 110xxxxx 10xxxxxx
1110xxxx 3 1110xxxx 10xxxxxx 10xxxxxx
11110xxx 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
  • Byte thứ 2, 3, 4 trong chuỗi nhiều byte luôn bắt đầu bằng 10, điều này đánh dấu rõ ràng rằng đó là byte tiếp nối
  • Các bit còn lại của byte chính và các byte tiếp nối được kết hợp để tạo thành một code point
    • Code point là mã định danh duy nhất của một ký tự Unicode, được biểu diễn bằng tiền tố "U+" và số hệ thập lục phân
    • Ví dụ: code point của "A" là U+0041
  • Quy trình diễn giải ký tự từ các byte mã hóa UTF-8 như sau
    • 1. Đọc một byte; nếu bắt đầu bằng 0 thì coi đó là ký tự một byte (ASCII), dùng 7 bit còn lại để biểu diễn ký tự rồi chuyển sang byte tiếp theo
    • 2. Nếu không phải 0 thì
      • 110 nghĩa là ký tự 2 byte, đọc thêm 1 byte tiếp theo
      • 1110 nghĩa là ký tự 3 byte, đọc thêm 2 byte tiếp theo
      • 11110 nghĩa là ký tự 4 byte, đọc thêm 3 byte tiếp theo
    • 3. Từ các byte đã xác định, ghép các bit còn lại sau khi bỏ bit đầu để dùng làm giá trị nhị phân của code point
    • 4. Tìm code point đó trong bộ ký tự Unicode và hiển thị lên màn hình
    • 5. Lặp lại với byte tiếp theo

Ví dụ: ký tự tiếng Hindi "अ"

  • Biểu diễn UTF-8: 11100000 10100100 10000101 (3 byte)
  • Byte đầu (11100000) → cho biết đây là ký tự 3 byte
  • Kết hợp các bit hữu hiệu của ba byte → 00001001 00000101 = hệ thập lục phân 0x0905
  • Code point U+0905 có nghĩa là ký tự Devanagari "अ"

Ví dụ về tệp

  • 1. Hey👋 Buddy

    • Gồm tổng cộng 13 byte
      • Ký tự ASCII (H, e, y, B, u, d, d, y, dấu cách) → mỗi ký tự 1 byte
      • 👋 (U+1F44B) → 4 byte 11110000 10011111 10010001 10001011
    • Tệp này là một tệp UTF-8 hợp lệ, nhưng vì chứa ký tự không phải ASCII (emoji) nên không còn tương thích ngược với ASCII
  • 2. Hey Buddy

    • Tổng cộng 9 byte, tất cả đều nằm trong phạm vi ASCII
    • Vì vậy tệp này đồng thời là tệp ASCII hợp lệtệp UTF-8 hợp lệ

So sánh với các kiểu mã hóa khác

  • Có một vài bảng mã cung cấp khả năng tương thích với ASCII, nhưng không được sử dụng rộng rãi như UTF-8
  • GB18030 (tiêu chuẩn của Trung Quốc) cũng hỗ trợ tương thích ASCII nhưng không phổ biến bằng
  • Dòng ISO/IEC 8859 là phần mở rộng một byte (tối đa 256 ký tự) nên có giới hạn
  • UTF-16/UTF-32 không có tính tương thích ASCII
    • 'A' (U+0041): UTF-16 là 00 41, UTF-32 là 00 00 00 41

Bonus: UTF-8 Playground

1 bình luận

 
GN⁺ 2025-09-13
Ý kiến trên Hacker News
  • Trong UTF-8, các byte tiếp nối luôn bắt đầu bằng 10, nên dù nhảy đến một byte bất kỳ vẫn có thể biết ngay đó là đầu ký tự hay byte tiếp nối, nhờ vậy dễ tìm điểm bắt đầu của ký tự tiếp theo hoặc trước đó. Nếu mã hóa theo kiểu số nguyên độ dài biến thiên của EBML (đảo 1/0 để giữ tính tương thích ASCII một byte), thì sẽ khó xác định ngay điểm bắt đầu ký tự từ một vị trí tùy ý. Xem thêm RFC8794 section 4.4

    • Đúng vậy, đây là một ưu điểm lớn của UTF-8. Có thể di chuyển tự do tiến lùi trong chuỗi UTF-8 mà không cần đọc từ đầu. Trong Python, để cho phép đánh chỉ mục chuỗi theo ký tự, CPython dùng wide characters. Trước đây từng có thể chọn ký tự 2 byte hoặc 4 byte, sau đó chuyển đổi tự động lúc chạy. Nhưng dù sao nó vẫn là wide character, không phải UTF-8. Ví dụ chỉ một emoji cũng có thể làm kích thước chuỗi tăng gấp bốn. Cá nhân tôi từng nghĩ đến cách dùng UTF-8 ở bên trong, rồi làm kiểu chỉ mục thành một đối tượng opaque, để khi cộng hoặc trừ một số nguyên nhỏ thì nó sẽ di chuyển qua lại trong chuỗi. Chỉ khi chuyển thật sang số nguyên hoặc subscript trực tiếp thì mới tính toán chỉ mục chuỗi. Với cách này, regex và những thứ tương tự cũng có thể dùng đối tượng chỉ mục opaque để hoạt động tốt trên biểu diễn UTF-8

    • Tôi nghĩ LEB128/VLQ tốt hơn kiểu số nguyên độ dài biến thiên của EBML. Nó phân biệt bằng MSB trong byte - 0 thì là hết chuỗi, byte kế tiếp là chuỗi mới; 1 thì lùi lại cho đến khi gặp MSB 0. Cũng có các hiện thực hiệu quả đã được tối ưu bằng SIMD. Khác biệt giữa LEB128 và VLQ chỉ là endianness. ASCII là 0xxxxxxx, ký tự mở rộng là 1xxxxxxx 0xxxxxxx, 1xxxxxxx 1xxxxxxx 0xxxxxxx v.v., nên 3 byte có thể mã hóa tối đa đến 0x1FFFFF, nhiều hơn mức Unicode cần. Nó không tự đồng bộ hóa (self-synchronizing) nhưng nén tốt hơn. ASCII vẫn là 1 byte, còn các code point dưới U+3FFF như ký hiệu toán học hay tiếng Nhật có thể biểu diễn bằng 2 byte, nên có lợi cho việc giảm kích thước mã

    • Tôi nghĩ điều đó chỉ đúng nếu giả định văn bản không bị hỏng hoặc không bị sửa đổi ác ý. Đã từng có rất nhiều lỗ hổng bảo mật phát sinh khi parse hoặc escape các chuỗi UTF-8 không hợp lệ. Có thể xem ví dụ ở vấn đề PostgreSQL CVE-2025-1094, và thêm tại danh sách CVE liên quan UTF-8

    • Không hẳn luôn đúng. Với UTF-8 không hợp lệ, một ký tự cũng có thể biến thành byte tiếp nối (continuation byte). Ví dụ nếu đầu vào là 0b01100001 0b10000000 0b01100001, thì sẽ ra ba ký tự a�a. Muốn biết một ký tự được xuất ra có phải bắt đầu tại vị trí đó hay không thì phải nhìn 1~3 byte ngay trước nó

    • Nếu kích thước multibyte tối đa là 4 byte, thì chỉ cần nhìn ngược nhiều nhất 3 byte là biết vị trí hiện tại có phải byte tiếp nối hay không. Nếu không thấy byte bắt đầu, thì biết đó là ký tự một byte. Tôi đoán đây là cách được thiết kế để phục vụ phục hồi dữ liệu: ngay cả khi thư viện không nhận biết UTF-8 đúng cách, vẫn có thể bỏ qua các byte sai ở đầu và cuối của slice đã cắt ra, rồi trích được một chuỗi tương đối hợp lý

  • Tôi thật sự nghĩ UTF-8 rất xuất sắc. Cốt lõi nằm ở quyết định ASCII chỉ dùng 7 bit. Ngay cả vào năm 1963, việc chọn 7 bit cũng hơi lạ. Tôi tò mò không biết đó chỉ là ngẫu nhiên lịch sử hay những người thiết kế ASCII đã từng tính đến việc dùng thêm 1 bit để thêm ký hiệu, hoặc đã nghĩ đến code page hay khả năng mở rộng rồi

    • Tôi không biết chính xác lý do, nhưng ngày xưa 8 bit không phải lúc nào cũng có sẵn. Mô hình 7 bit + 1 bit parity hoặc bit cờ là chuyện rất phổ biến (vì thế email đến giờ vẫn dùng quoted-printable để mã hóa 8 bit thành 7 bit). Việc có thể truyền nguyên vẹn cả 8 bit được gọi là 8-bit clean. Trong bối cảnh đó, UTF-8 rốt cuộc cũng là một ví dụ tận dụng tốt bit thứ 8 còn lại của ASCII. Có thể xem thêm giải thích về 8-bit clean

    • Tôi không phải chuyên gia, nhưng trước đây từng đọc về lịch sử ASCII. ASCII bắt nguồn từ mã teleprinter (vốn phát triển từ mã điện báo). Mã Morse có độ dài biến thiên nên khó hiện thực bằng máy. Vì vậy mới xuất hiện mã Baudot 5 bit. Đây là nỗ lực dùng mã độ dài cố định để đơn giản hóa máy móc, đồng thời giảm mệt mỏi cho người vận hành. Từ mã Baudot mà đến giờ tốc độ ký hiệu vẫn được gọi là baud. Sau đó, khi chuyển sang nhập liệu bằng băng giấy đục lỗ dùng máy đánh chữ, tính linh hoạt tăng lên và các ký hiệu đặc biệt như Carriage Return (về đầu dòng) và Line Feed (xuống dòng) được thêm vào. Ngành máy tính ban đầu dùng thẻ đục lỗ làm đầu vào, và IBM đã phát triển một hệ 8 bit mới để xử lý thẻ nhanh hơn, rồi từ đó hình thành nền tảng cho ASCII. Xét cho cùng, đây là quá trình mở rộng mã nhị phân theo tiến bộ kỹ thuật. ASCII cũng là một sản phẩm chuyển tiếp xuất hiện trước khi thông lệ byte 8 bit được định hình

    • Thực ra bit dư đó được tái sử dụng cho parity

    • Phần mở rộng 8 bit của ASCII (dòng ISO 8859-x) đã được dùng rất rộng rãi trong nhiều thập niên, và vẫn còn hiện diện trong code page chuẩn của Windows. Ngay cả nếu ASCII ngay từ đầu là 8 bit, tôi nghĩ các ký tự cốt lõi vẫn sẽ nằm trong 128 giá trị đầu, nên vẫn phù hợp với UTF-8. Nếu nói đến sự ngẫu nhiên lịch sử, thì không phải ASCII dùng 7 bit, mà là việc sự phát triển máy tính thời đó chủ yếu diễn ra ở thế giới nói tiếng Anh, và tiếng Anh có thể biểu diễn đầy đủ chỉ với 7 bit

    • Bản thân 7 bit không hẳn là điều gì quá kỳ lạ. Baudot là 5 bit, không đủ nên sinh ra mã 6 bit, rồi sau đó mới có ASCII 7 bit. IBM chuẩn hóa byte 8 bit (mã EBCDIC) trên System/360, nhưng các hãng máy tính khác khi ấy không có độ dài byte cố định. Nhìn thì 7 bit có vẻ kỳ, nhưng khi đó ký tự và word của hệ thống không nhất thiết phải khớp gọn gàng với nhau

  • Tôi đồng ý UTF-8 là một thiết kế vượt kỳ vọng. Nhưng Unicode lại có vấn đề là phạm vi (scppe) bị nới quá rộng. Người ta bắt đầu tự hỏi rốt cuộc Unicode nên bao gồm những gì. Trực giác thì có vẻ phải là “toàn bộ ký tự in được, phân biệt được, mà loài người dùng để giao tiếp”, nhưng thực tế không hẳn vậy.

    • Nó không phân định rõ ràng. Có các code point tồn tại chỉ để kết hợp (combining)

    • Nó không cụ thể. Một ký tự có thể được viết theo nhiều cách. Các ký tự nhìn giống nhau vẫn có code point và ý nghĩa khác nhau

    • Không phải tất cả đều printable. Có các ký tự điều khiển (control char). Chúng được đưa vào vì tương thích ASCII, nhưng các ký tự điều khiển riêng của Unicode cũng ngày càng tăng Có vẻ hiện chưa có Unicode point dạng hoạt ảnh. Ít nhất thứ gì có thể in được thì vẫn có thể in ra giấy. Nhưng tôi không chắc tính bất biến đó sẽ còn được giữ trong tương lai. Nhân tiện, trong các mã hóa utf còn có utf-7 mà tác giả chưa nhắc đến. Nó giống utf-8 nhưng được tạo ra dưới giả định rằng trong môi trường mạng thập niên 80, việc dùng bit cuối cùng chưa chắc an toàn. Tôi từng tình cờ nhận được email được mã hóa bằng utf-7. Đến giờ vẫn không hiểu họ đã gửi kiểu gì

    • UTF-7 chủ yếu được tạo ra cho môi trường truyền dẫn không 8-bit clean như email. Giờ nó đã lỗi thời, và cũng không mã hóa được supplementary plane (chỉ có thể qua UTF-16 surrogate pair). Cũng có UTF-9, nhưng đó là trò đùa trong một RFC Cá tháng Tư, kiểu nhại cho môi trường 36 bit như PDP-10

  • Có một điều tôi luôn thắc mắc: một code point Unicode có thể được mã hóa bằng chuỗi byte dài hơn mức cần thiết. UTF-8 cấm điều này và chỉ cho phép chuỗi ngắn nhất. Ví dụ 00000001 được, mà 11000000 10000001 cũng có thể biểu diễn cùng giá trị. Vậy tại sao không thiết kế hẳn theo cách khiến không thể có mã hóa bất hợp pháp kiểu khác? Chẳng hạn lấy phần đầu của chuỗi 2 byte làm giá trị hợp lệ cuối cùng, để 11000000 10000001 thành 128+1, còn 0-127 là 1 byte. Như vậy sẽ không còn mã bất hợp pháp, mà trong các trường hợp biên chuỗi còn hơi ngắn hơn, nên tôi tự hỏi có phải thời đó vì chi phí phần cứng mà không xét đến không. (Cập nhật: chuỗi bit thực ra phải là 10000001, tôi đã sửa)

    • Có nhiều câu trả lời nhắc đến synchronization indicator, nhưng câu hỏi cốt lõi là tại sao U+0080 lại là c2 80 chứ không phải c0 80 (giá trị đầu tiên sau 7f). Tôi nghĩ lý do là như sau a) Nếu cho phép overlong encoding, một số nơi chỉ kiểm tra chuỗi ngắn có thể trở thành lỗ hổng bảo mật b) Mã hóa/giải mã UTF-8 chuẩn có thể xử lý chỉ bằng masking (bitmask) và shifting (bitshift). Cách được đề xuất sẽ cần thêm phép trừ Đã có thảo luận về chuyện này trong email năm 1992, và FSS-UTF có bao gồm additive constants (xem bên dưới)

    Chuỗi 2 byte có thể mang 2^11 mã, nhưng 0-7f là bất hợp pháp. Có lẽ người ta thấy như vậy tốt hơn là dùng additive constants mà không có lợi ích bù lại rõ rệt
    Xem cuối utf-8-history.txt để biết thêm chi tiết

    • Điều cốt yếu là giữ được self-synchronicity của các mẫu byte. Nếu không giữ cấu trúc byte tiếp nối như 11000000 10000001, thì một luồng UTF-8 bị cắt cụt sẽ mất khả năng luôn tìm ra ranh giới code point. Nếu còn thêm phép cộng/trừ trong cơ chế này, hiệu năng decoder cũng giảm. Hiện tại chỉ cần phép toán bit là đủ

    • Như bình luận của quectophoton, các byte tiếp nối phải luôn bắt đầu bằng 10 thì parser mới có thể tìm ranh giới code point từ bất kỳ vị trí nào. Điều này thực sự đã được cân nhắc khi thiết kế UTF-8 vào đầu thập niên 90, khi môi trường truyền dẫn thiếu tin cậy còn rất phổ biến

    • Nếu dùng cách đề xuất, việc tính toán khi mã hóa/giải mã sẽ phức tạp hơn và chậm hơn. Hiện giờ chỉ vài lần bit shift là xong, nhưng trong bối cảnh máy tính chậm của thập niên 90 thì đó là yếu tố quan trọng

  • Nếu muốn đọc thêm về thiết kế của UTF-8, hãy xem one-pager của Russ Cox và bài tổng kết lịch sử của Rob Pike

  • UTF-8 rất tuyệt và sẽ rất tốt nếu được dùng ở mọi nơi (đang nhìn JavaScript đây). Nhưng nhược điểm duy nhất là tiêu chuẩn không nói thật rõ phải diễn giải các chuỗi byte không hợp lệ như thế nào. Một thiết kế “bắt buộc chỉ rõ cách diễn giải cho mọi chuỗi byte” có lẽ còn hoàn hảo hơn. Tôi nghĩ cách làm như trong đặc tả HTML5 cho thấy điều này hoàn toàn có thể vận hành thành công

    • Về mặt bảo mật, UTF-8 sai phải được xem là dữ liệu nguy hiểm và loại bỏ ngay kèm xử lý lỗi, chứ không nên cố xử lý. Nếu không, hệ thống sẽ dễ bị tấn công bằng cách lách qua cơ chế kiểm tra
  • Tôi có cảm xúc lẫn lộn với backward compatibility. Tôi ghét sự rối rắm, nhưng cũng thích tinh thần dám phá vỡ cái cũ để tiến lên. Đồng thời, tôi cũng rất thích những trường hợp như UTF-8 hay EAN, nơi khả năng tương thích được giữ lại mà vẫn thiết kế cực kỳ thông minh. Thành thật mà nói, có vẻ UTF-8 gần như không hy sinh gì cho tính tương thích

    • có vẻ UTF-8 gần như không hy sinh gì cho tính tương thích
      Nó chặn việc mã hóa quá 21 bit. Đây là vì tương thích với UTF-16 (cơ chế surrogate của UTF-16 chỉ lên đến 2^21-1). Có thể một ngày nào đó ta sẽ hối tiếc về giới hạn này. Có vẻ không có lý do thực tế nào khác ngoài chuyện đó để chặn code point trên 21 bit

    • thích kiểu người có quyền lực mạnh tay thay đổi mọi thứ nhân danh tiến bộ
      Nhưng việc hệ thống mình phụ thuộc bị hỏng chỉ vì ai đó đổi tên một tham số hoặc vì một phần thư viện chuẩn trông “bừa bộn” thì chẳng vui chút nào

    • Nếu buộc phải thay đổi gì đó, có lẽ tôi sẽ thay một vài ký tự điều khiển bằng những ký tự phổ biến hơn để tiết kiệm thêm chút không gian (nếu chấp nhận phá cả tương thích Unicode). Còn nếu xét như một định dạng mã hóa ký tự multibyte độc lập, thì nó gần như tối ưu rồi

  • Tôi rất thích liên kết UTF-8 playground này (utf8-playground.netlify.app). Sẽ hay hơn nếu UI cho nhập trực tiếp code point nữa (lúc đó hình như chỉ làm được qua URL). (Cập nhật: đã có PR được merge nên giờ làm được rồi)

    • Cảm ơn vì đã đóng góp, hiện đã merge và áp dụng ngay
  • Nếu muốn đào sâu hơn về chủ đề này và thích kiểu như Advent of Code, thì i18n-puzzles có nhiều câu đố về mã hóa văn bản. Nó rất hữu ích để thực sự nội tại hóa cách UTF-8, UTF-16 và các hệ tương tự hoạt động

  • Cảm ơn vì bài viết hay. Tôi cũng khuyến nghị UTF-8, nhưng chỉ khi dùng kèm BOM thì mới thực sự ổn. Nếu không thì ứng dụng sẽ không biết đó là UTF-8 và cũng dễ bỏ qua việc cần lưu ở UTF-8. Ví dụ trên Windows, khi tạo tài liệu văn bản mới, nếu file rỗng mà chỉ có BOM, thì bất kỳ ứng dụng nào sau đó mở để chỉnh sửa/lưu cũng sẽ tự nhận biết rằng phải lưu tiếp dưới dạng UTF-8. Nếu không có BOM, ứng dụng dù cố tự phát hiện encoding cũng không thể tin cậy hoàn toàn, và khi thêm ký tự đặc biệt như dấu phụ thì càng dễ rối hơn (editor có thể đoán sai ngôn ngữ, hoặc Notepad đổi encoding mặc định sau một bản cập nhật). Vì vậy tôi đồng ý nên dùng UTF-8, nhưng BOM bắt buộc phải là mặc định của OS/ứng dụng