Làm việc với binary file trong Ruby

Binary file là gì?

Về mặt kỹ thuật mà nói thì tất cả các file trong máy tính đều là binary file, hay nói cách khác chỉ là một chuỗi các byte (có giá trị từ 0-255) được lưu trữ trong bộ nhớ. Nhưng nếu một file chỉ chứa các ký tự ASII (từ 0-127) thì ta có thể gọi nó là một text file.

Text file có thể là file XML, CSV hoặt các file chứa thông tin config. Ta có thể ở một text file bằng bất cứ text editor nào là có thể hiểu được nội dụng bên trong file.

Mặt khác binary file có thể là file JPEG hoặc file ZIP. Ta vẫn có thể sử dụng text editor để mở file, nhưng sẽ chỉ thấy các ký hiệu khó hiểu. Trong file ta có thể thấy một vài ký tự ASII, nhưng về tổng thể thì nó chẳng mang ý nghĩa gì cả.

Để đọc hiểu được các file này, ta cần phải biết được mô tả chi tiết cho format của từng file. Đó là một tài liệu mô tả ý nghĩa của mỗi byte trong đó. Ví dụ, trong tài liệu mô tả của file PNG có nói rằng 8 byte đầu tiên luôn là chuỗi giá trị 137 80 78 71 13 10 26 10. Từ đó ta có thể viết được chương trình kiểm tra file PNG bằng việc kiểm tra chuỗi byte này.

Bài viết này sẽ giới thiệu cách làm việc với binary file với Ruby, mà cụ thể ở đây là file BMP. BMP là kiểu file ảnh đơn giản và dễ làm việc nhất, các kiểu file khác có thể được xử lý tương tự cùng với mô tả riêng của từng file.

Đọc data dạng binary

Đầu tiên, ta đọc data từ file. Ruby hỗ trợ method File.read có thể thực hiện việc này dễ dàng:

data = File.read "lena512.bmp"

Hoặc nếu muốn thực hiện thêm các tác vụ trên file ta có thể thực hiện mở file và đọc data từ object của file:

data = nil

File.open("lena512.bmp", "r") do |file|
  data = file.read
end

Trong đó tham số thứ hai “r” có tác dụng thông báo cho Ruby biết rằng file được mở để đọc. Ruby sẽ mặc định mở file như một file text. Để mở file dưới dạng binary, ta truyền vào “rb”:

data = nil

File.open("lena512.bmp", "rb") do |file|
  data = file.read
end

Nếu mở file bằng File.read ta không thể chuyển sang đọc kiểu binary, nhưng thay vào đó ta có method File.binread:

data = File.binread "lena512.bmp"

Trên các hệ thống dựa trên UNIX thì không có sự phân biệt giữa text và binary file, nhưng cũng có một sự khác biệt nhỏ giữa cách Ruby xử lý với binary và text data:

data = File.read "lena512.bmp"
data.encoding
=> #<Encoding:UTF-8>

data = File.binread "lena512.bmp"
data.encoding
=> #<Encoding:ASCII-8BIT>

Điểm khác biệt chính là encoding của data. Với các file binary, data được encode bằng ASCII-8BIT, đại diện cho chuỗi các byte. Khi làm việc với binary data ta sẽ dùng encoding này. Nếu sử dụng UTF-8 thì sẽ gặp lỗi không đọc được ký tự.

Ta có thể convert binary data sang encoding khác như sau:

data = File.read "lena512.bmp"
data.force_encoding "ASCII-8BIT"
data.encoding
=> #<Encoding:ASCII-8BIT>

Decoding binary data

Ruby cung cấp method String#unpack có thể được dùng để decode data từ một binary string (hoặc một string bình thường). Ta có thể chọn để decode số integer 1, 2, 4 hoặc 8 byte, ta có thể chọn số nguyên có dấu hoặc không dấu mà format Little hoặc Big-Endian. Dưới đây là một vài ví dụ:

# Đây là một chuỗi binary thể hiện ngày tháng, trong đó ngày là 1 byte, tháng là 1 byte và năm là 1 byte
data = "\x14\a\xB1\a"
data.unpack "CCS"
=> [20, 7, 1969]

Như ta thấy chuỗi binary trên được decode thành ngày tháng. Trong tham số truyền vào String#unpack, C đại diện cho số nguyên không dấu 1 byte và S là số nguyên không dấu 2 byte. Tài liệu đầy đủ được đăng tại đây. Nếu sử dụng chuỗi binary với format khác ta sẽ decode được dữ liệu khác:

data = "\x14\a\xB1\a"
data.unpack "L"
=> [129042196]

L đại diện cho một số nguyên 4 byte không dấu. Ta có kết quả là một số nguyên khác.

Đọc một file BMP

Bây giờ ta sẽ thử đọc data từ một binary file thực sự. Ta có thể đọc mô tả của file BMP.

File BMP gồm có 4 phần:

  • Header của file, chứa các thông tin chung về file
  • Header của ảnh, chứa các thông tin chung về ảnh
  • Bảng màu sử dụng
  • Dữ liệu của từng pixel

File header luôn là một chuỗi 14 byte gồm 5 trường

  • bfType, 2 byte, chữ ký xác định file BMP "BM"
  • bfSize, 4 byte, dung lượng của file
  • bfReserved1, 2 byte, không sử dụng, để dự trữ, có giá trị 0
  • bfReserved2, 2 byte, không sử dụng, để dự trữ, có giá trị 0
  • bfOffBits, 4 byte, khoảng cách đến dữ liệu pixel

Image header có cấu trúc phức tạp hơn. Có tất cả 7 cấu trúc khác nhau, tùy thuộc vào phiên bản và hệ điều hành. Các cấu trúc có số trường và dung lượng khác nhau, nên ta phải kiểm tra xem file đang sử dụng phiên bản nào. Dưới đây là image header của các file BMP sử dụng 256 màu và 40 byte header:

  • biSize, 4 byte, dung lượng của header, ở phiên bản này là 40
  • biWidth, 4 byte, chiều rộng của ảnh tính theo pixel
  • biHeight, 4 byte, chiều cao của ảnh tính theo pixel
  • biPlanes, 2 byte, có giá trị 1
  • biBitCount, 2 byte, số bit cho mỗi pixel
  • biCompression, 4 byte, phương pháp nén
  • biSizeImage, 4 byte, độ lớn của ảnh
  • biXPelsPerMeter, 4 byte, số pixel mỗi met theo chiều rộng
  • biYPelsPerMeter, 4 byte, số pixel mỗi met theo chiều cao
  • biClrUsed, 4 byte, số màu sử dụng
  • biClrImportant, 4 byte, số màu quan trọng

Bảng màu là định nghĩa các màu được sử dụng trên ảnh. Trong file ảnh 256 màu, dung lượng của bảng màu là 1024 byte, mỗi màu được mô tả bằng 4 byte theo format lam – lục – đỏ. Byte thứ 4 không được sử dụng và để bằng 0.

Sau bảng màu là dữ liệu về các pixel. Mỗi pixel là 1 byte chứa index của màu của nó trên bảng màu.

require "pp"

# define file header structure
FileHeader = Struct.new(
  :bfType,
  :bfSize,
  :bfReserved1,
  :bfReserved2,
  :bfOffbits
)

# define image header structure
ImageHeader = Struct.new(
  :biSize,
  :biWidth,
  :biHeight,
  :biPlanes,
  :biBitCount,
  :biCompression,
  :biSizeImage,
  :biXPelsPerMeter,
  :biYPelsPerMeter,
  :biClrUsed,
  :biClrImportant
)

File.open("lena512.bmp", "rb") do |file|
  # đọc 14 byte file header
  binary = file.read 14
  data = binary.unpack "A2 L S S L"
  file_header = FileHeader.new *data

  # đọc 40 byte image header
  binary = file.read 40
  data = binary.unpack "L L L S S L L L L L L"
  image_header = ImageHeader.new *data

  pp file_header
  pp image_header
end

Giá trị trả về của chương trình trên có dạng:

#<struct FileHeader
  bfType="BM",
  bfSize=263222,
  bfReserved1=0,
  bfReserved2=0,
  bfOffbits=1078>
#<struct ImageHeader
  biSize=40,
  biWidth=512,
  biHeight=512,
  biPlanes=1,
  biBitCount=8,
  biCompression=0,
  biSizeImage=262144,
  biXPelsPerMeter=0,
  biYPelsPerMeter=0,
  biClrUsed=256,
  biClrImportant=0>

Từ file header ta có thể thấy tổng dung lượng của file là 263222 byte và khoảng cách đến pixel data là 1078 byte. Điều này là hợp lý vì dung lượng của file header là 14 byte, image header là 40 byte là bảng màu 1024 byte, tổng cộng 14 + 40 + 1024 = 1078.

Từ image header ta biết được bức ảnh có kích thước 512x512, với mỗi pixel 8 bit ta có tổng dung lượng của file là 1078 + 512 * 512 = 263222, phù hợp với thông số trên file header.

Vị trí con trỏ trong binary data

Đôi khi ta muốn đọc data từ giữa file hoặc đọc từ cuối lên. Ruby cung cấp method File#seek. Từ đó ta có thể chỉ đọc bảng màu của file BMP:

File.open("lena512.bmp", "rb") do |file|
  # 54 byte đầu tiên là file header và image header
  file.seek 54

  # Đọc bảng màu 1024 byte
  color_table = file.read 1024
end

Encoding data dưới dạng binary

Để lưu dữ liệu vào binary file ta cần encode lại thành một binary stream. Để làm được điều này ta dùng method Array#pack, là method ngược lại của String#unpack và sử dụng chung xâu template làm tham số đầu vào.

Ví dụ như ta muốn encode một dãy số nguyên không dấu 2 byte, đầu tiên ta đặt chúng vào một array. Nếu muốn encode thành dãy các số nguyên không dấu 1 byte ta làm như sau:

input = [8, 557, 912, 818, 376, 887, 148, 725, 366]
data = input.pack "CS*"
=> "\b-\x02\x90\x032\x03x\x01w\x03\x94\x00\xD5\x02n\x01"

Ghi data vào file

Tương tự như đọc data. Chỉ cần nhớ rằng ta phải ghi file bằng binary mode hoặc dùng hàm binwrite:

data = "\b-\x02\x90\x032\x03x\x01w\x03\x94\x00\xD5\x02n\x01"

File.binwrite "data.bin", data

# hoặc với hàm open

File.open("data.bin", "wb") do |file|
  file.write data
end

Little và Big Endian

Khi làm việc với binary data, ta sẽ thường xuyên bắt gặp khái niệm Little và Big-Endian. Vậy các khái niệm này mang ý nghĩa gì, hãy cùng tìm hiểu ở phần này.

Ví dụ với số 1024 ở hệ thập phân để biểu diễn sang nhị phân cần 2 byte được và được viết là 0000010000000000 viết gọn lại theo hệ hexa là 04 00. Byte phía bên trái, 00000100 ở hệ nhị phân hay 04 ở hệ hexa được gọi là có ảnh hưởng lớn hơn vì nó biểu diễn giá trị lớn hơn. Trong khoa học máy tính nó được gọi là most significant byte. Bình thường thì các byte phía bên trái sẽ có ảnh hưởng lớn hơn các byte phía bên phải. Quy ước này được dựa trên cách viết của số thập phân. Như với số 1024, chữ số 1 biểu diễn giá trị 1000 và có ảnh hưởng lớn nhất, chữ số 4 có ảnh hưởng thấp nhất.

Little và Big-Endian là quy ước mà các byte được lưu trữ. Big-Endian có nghĩa là các byte có ảnh hưởng lớn nhất sẽ được viết trước, ngược lại Little-Endian sẽ viết các byte có ảnh hưởng nhỏ nhất trước.Như với số 1024 trên sẽ được ghi vào bộ nhớ là 04 00 với Big-Endian và 00 04 với Little-Endian. Cả hai chuẩn trên đều đang được sử dụng rộng rãi. Big-Endian giúp cho việc xử lý dữ liệu dễ dàng hơn vì nó gần giống với cách viết tự nhiên trong hệ thập phân. Big-Endian được ứng dụng rộng rãi trong data networking. Little-Endian lại gần với cách đọc ghi của máy tính hơn, nên được sử dụng để thiết kế các vi xử lý.

Thử nghiệm với giá trị 1024:

[1024].pack "S"
=> "\x00\x04"

Nhìn vào giá trị trả về ta có thể thấy đây là chuẩn Little-Endian. Byte ít quan trọng nhất 00 ra trước sau đó mới đến byte quan trọng nhất 04. Nếu muốn encode theo chuẩn Big-Endian ta phải thêm > vào format:

[1024].pack "S>"
=> "\x04\x00"

Một điều quan trọng nữa cần lưu ý là format khi decode phải giống với lúc encode. Nếu không giá trị trả về sẽ không đúng:

[1024].pack("S>").unpack("S")
=> [4]

[1024].pack("S>").unpack("S>")
=> [1024]

Số nguyên không dấu và có dấu

Đây là một lưu ý nữa với binary data. Số nguyên không dấu chỉ có thể biểu diễn các số nguyên lớn hơn 0, vậy nên 1 byte có thể biểu diễn các giá trị từ 0 đến 255, 2 byte có thể biểu diễn từ 0 đến 65535… Số nguyên có dấu có thể biểu diễn được các số nguyên âm bằng cách sử dụng most significant bit để lưu trữ dấu cho số đó. Vì thế nên số nguyên có dấu có ít hơn 1 bit dùng để biểu diễn giá trị, nên 1 byte có thể biểu diễn các giá trị từ -128 đến 127, 2 byte có thể biểu diễn các giá trị từ -32768 đến 32767.

Thử nghiệm với số 1024, và lưu ý lại lần nữa là format khi encode và decode phải giống nhau:

[-1024].pack("s").unpack("S")
=> [64512]

[-1024].pack("s").unpack("s")
=> [-1024]

Quy luật ở đây là nếu format sử dụng các chữ cái viết hoa (Q, L, S hoặc C) thì Ruby sẽ hiểu là sử dụng các số nguyên không dấu và các chữ các viết thường (q, l, s và c) là đại diện cho số nguyên có dấu.