Кодирование Хаффмана. Часть 3
Вступление
В прошлой части мы рассмотрели программу на C++, сжимающую данные. Кроме этого, автор соизволил признаться, что написанная программа не является идеалом, и что при определённых наборах частот сжатие будет неверным.
Эта статья посвящена распаковке сжатого файла при помощи программы на ассемблере и дерева, построенного программой сжатия. Опять же следует оговориться: программа не рассчитана на распаковку гигантских файлов, но её в любой момент можно модифицировать так, чтобы подобных проблем не было. Что же касается самого алгоритма распаковки, то он предельно прост, что мы и обсудим в следующих разделах.
Быстрый старт
В качестве компилятора языка Ассемблера я решил выбрать всем полюбившийся FASM (его можно взять по адресу http://www.flatassembler.net/). Это очень удобный, маленький и шустрый компилятор, на нём даже пишут операционную систему MenuetOS (http://www.menuetos.net/). Сам дистрибутив весит немного, и, думаю, этот компилятор однозначно "must have".
Ладно, что-то я удалился от темы. Начнём проектировать. Первым делом нам надо распределить ресурсы программы, то есть определить необходимый ей набор данных. Итак, в данном случае программа должна быть:
1. Небольшой. Иначе выгоды от сжатия данных мы не получим.
2. Быстрой. Это сам собой разумеющийся факт.
3. Простой в использовании.
Пункт 1 достигается за счёт программ типа COM: в них не надо дополнительно описывать сегменты, резервировать место под стек, в них нет стандартного MZ-заголовка, что уже сокращает код, как минимум, на 0x40 байт. Помимо этого, 64 килобайта файла типа COM для подобной программы ― очень даже большая величина. Отсюда, соответственно, и вывод: программа будет иметь формат исполняемого файла типа COM.
Пункт 2 достигается за счёт того, что программа написана на ассемблере, а также подобраны более-менее оптимальные алгоритмы (автор, естественно, не претендует на сверхпроизводительный код).
Так как данная программа ― лишь демонстрационный вариант, то я решил её упростить, а именно: зафиксировать имена входного и выходного файла: входной файл будет называться "data.huf", а выходной ― "data.txt".
Нам будут необходимы файловые дескрипторы входного и выходного файла:Помимо этого, нам нужно построить алгоритм чтения так, чтобы данные из файла читались по-битно. Поэтому приходится объявлять следующие переменные (наподобие того, что мы делали в программе на С++):Код (ASM):
file_in: dw 0 file_out: dw 0Здесь in_byte ― текущий байт, из которого поочерёдно будут извлекаться биты, in_count ― количество ещё не извлечённых из байта бит, out_buf_bytes ― размер буфера, который следует записать в выходной файл, in_index ― номер текущего байта в буфере ввода.Код (ASM):
; Состояние чтения из файла in_byte: db 0 in_count: db 0 out_buf_bytes: dw 0 in_index: dw 0ffffh
Объявляем символьные константы имён файлов и сообщений об ошибках:Осталось только разрисовать структуру данных в памяти:Код (ASM):
; Соответствующие данные Message_in: db "Can't open file data.huf$" Message_out: db "Can't create file data.txt$" in_name: db "data.huf",0 out_name: db "data.txt",0
Верхние ячейки соответствуют младшим адресам, нижние ― старшим. Заметим, что буфер вывода и буфер ввода ― довольно-таки объёмные структуры, поэтому поместить их в файл ― значит проиграть в размере декомпрессора. Поэтому следует прибегнуть к стандартной уловке: "зарезервировать" пространство в конце файла:
Дерево кодирования Символьные константы
сообщений и имен
файловФайловые дескрипторы
и данные состояния
чтения из файлаБуфер вывода Буфер ввода Задать положение и размер буферов помогут следующие константы в начале файла:Код (ASM):
; Буферы данных align 10h ; Структура памяти: ; - Буфер вывода ; - Буфер ввода buffer:
ПримитивыКод (ASM):
BLOCK_SIZE = 8192 ; Размер буферов input_buffer = buffer+BLOCK_SIZE ; Адрес буфера ввода
Итак, как и в прошлой статье, пойдём от простого к сложному. И, на мой взгляд, следует начать с функции извлечения байта из буфера ввода в байтовый буфер:Разберёмся, что она выполняет. Первым делом она смотрит, есть ли ещё не считанные байты в буфере ввода (сравнивает содержимое in_index с BLOCK_SIZE). Если больше читать из буфера нечего, то она считывает входной буфер из файла и обнуляет счётчик байтов во входном буфере. После этого программа извлекает байт из буфера ввода, увеличивает счётчик текущего байта на единицу и заполняет данные байтового буфера: количество несчитанных бит (естественно, для байта их количество будет равно 8). Как оказалось, функция совсем несложная. Перейдём к следующей функции:Код (ASM):
; Функция: считать байт из буфера ввода в байтовый буфер read_byte: pusha ; Индекс байта в буфере ввода mov bx, word [in_index] cmp bx, BLOCK_SIZE jb no_block_load ; Если номер байта выходит за границы буфера ; Читаем блок размером BLOCK_SIZE из файла mov bx, word [file_in] mov cx, BLOCK_SIZE mov dx, input_buffer mov ah, 3Fh int 21h ; И обнуляем счётчик байтов в буфере xor bx, bx no_block_load: ; Извлекаем байт из буфера mov al, byte [input_buffer+bx] ; Увеличиваем счётчик на единицу inc bx ; И заполняем данные о байте mov byte [in_byte], al mov byte [in_count], 8 mov word [in_index], bx popa retКак уже видно из комментария к функции, она должна считать бит из байтового буфера и вернуть ax=1, если считанный бит равен единице и ax=0, если бит равен 0. Первым делом функция смотрит, есть ли ещё в байтовом буфере непрочитанные биты. Если такие биты не имеются, то вызывается функция чтения байта из буфера, уже рассмотренная ранее. После этого из байтового буфера считывается значение, берётся его младший бит и помещается в регистр ax, затем все биты сдвигаются на одну позицию вправо, и новое значение записывается в байтовый буфер. Также уменьшается информация о количестве несчитанных бит в байтовом буфере на единицу. Как видно отсюда, функция опять же не сложная.Код (ASM):
; Функция: считать бит из файла и вернуть его в регистре ax input_bit: push dx push cx push bx ; смотрим, все ли биты байта были считаны cmp byte [in_count], 0 jne input_get_bit ; если байт пуст, то надо считать новый call read_byte ; Получаем бит из байта input_get_bit: xor ax, ax mov dl, byte [in_byte] mov al, dl shr dl, 1 and al, 1 mov byte [in_byte], dl dec byte [in_count] ; Восстанавливаем сохранённые регистры и выходим pop bx pop cx pop dx ret
Теперь следует объявить функцию, которая записывает буфер вывода в файл. Собственно говоря, эта функция простая и является простой имплементацией функции DOS:Особого ничего интересного в этой функции нет. Просто в файл, описанный дескриптором file_out, записывается out_buf_bytes байт выходного буфера.Код (ASM):
; Функция: запись блока в файл put_to_file: pusha mov ah, 40h mov bx, word [file_out] mov cx, word [out_buf_bytes] mov dx, buffer int 21h popa ret
Шаги по дереву
Теперь мы имеем необходимый базис функций для того, чтобы организовать декодирование файла. И начнём с самой первой ― декодирование байта:
Что же делает эта функция? Первым делом она загружает в регистр bx корень дерева ― сгенерированную написанной в прошлой статье программой метку opcode00000000_0000. После этого осуществляет спуск по дереву от корня к листьям: если текущее положение не является конечным узлом дерева, то вызывается функция считывания бита ― input_bit, после чего, в зависимости от того, какое значение содержится в ax, осуществляется переход по соответствующей ветке в следующий узел, действие повторяется, пока мы не достигнем "финиша". В вершинах (конечных узлах) дерева содержится информация, какой символ соответствует этому узлу ― поэтому программа с лёгкостью извлекает необходимый декодированный символ и помещает его в выходной буфер. После этого увеличивает счётчик байтов в выходном буфере. Если счётчик превышает значение BLOCK_SIZE, то программа записывает буфер в выходной файл и сбрасывает счётчик в ноль.Код (ASM):
; Функция: пройтись по дереву, получить байт и поместить его в ; выходной буфер decode_byte_func: pusha ; Помещаем в bx адрес корня дерева mov bx, opcode00000000_0000 ; И спускаемся по дереву decode_byte_looping: ; Если мы достигли конца ветки - выходим cmp word [bx+2], -1 je loop_out ; Считываем бит call input_bit ; ax = считанный бит ; И направляемся по соответствующей ветке shl ax, 1 add bx, ax mov bx, word [bx] ; Шагаем дальше jmp decode_byte_looping loop_out: ; Мы нашли символ - он по адресу [bx] mov al, byte [bx] ; Помещаем символ в буфер mov bx, word [out_buf_bytes] mov byte [buffer+bx],al inc bx mov word [out_buf_bytes], bx ; Если мы не достигли конца буфера, осуществляем переход cmp bx, BLOCK_SIZE jb exit_decode_byte ; Скидываем буфер в файл call put_to_file mov word [out_buf_bytes], 0 exit_decode_byte: popa ret
Но эта функция реализует декодирование лишь одного байта. Рассмотрим теперь декодирование всего файла, чему и посвящена следующая функция:Эту функцию автор оставляет на рассмотрение читателя, так как совсем очевидно, что она делает.Код (ASM):
; Функция: декодировать файл decode_file: pusha decode_loop: cmp dword [coded_file_size],0 ; Проверяем, все ли байты декодированы je end_decode_loop dec dword [coded_file_size] ; Счётчик байтов - 1 call decode_byte_func ; И декодируем байт jmp decode_loop ; Повторяем, пока счётчик не 0 ; сюда приходим, когда файл полностью декодирован end_decode_loop: ; проверяем, не пуст ли выходной буфер mov cx, word [out_buf_bytes] test cx, cx jz exit_decode_file ; Если в буфере есть данные, то их надо записать в файл call put_to_file exit_decode_file: popa ret
int main(int argc, const char *argv[])
Ну что же, теперь мы во всеоружии: у нас есть функция, которая декодирует файл и записывает декодированные данные в выходной файл. Что же, осталось дело не за многим, а именно ― подготовить файловые дескрипторы и вызвать функцию декодирования файла. Это и делает наша функция start:В принципе, эта функция выполняет следующие действия:Код (ASM):
; Точка входа: отсюда начинается выполнение программы start: ; Устанавливаем сегментные регистры mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0FFFEh ; Открываем файл для чтения (data.huf) mov ax, 3D02h xor cx, cx mov dx, in_name int 21h jc not_open mov word [file_in], ax ; Создаём и открываем файл для записи (data.txt) mov ax, 3C00h xor cx, cx mov dx, out_name int 21h jc not_created mov word [file_out], ax ; Декодируем файл call decode_file ; Закрываем созданный файл mov ah, 3Eh mov bx, word [file_out] int 21h jmp ok_exit ; Выводим сообщение об ошибке not_created: mov ah, 9 mov dx, Message_out int 21h ; Закрываем открытый для чтения файл ok_exit: mov ah, 3Eh mov bx, word [file_in] int 21h ret ; Ошибка: файл не был открыт not_open: mov ah, 9 mov dx, Message_in int 21h ret
1. Установить сегментные регистры эквивалентно cs, установить указатель стека в конец сегмента.
2. Открыть файл для чтения с именем, содержащимся в строке in_name. Если открытие не удалось, то вывести сообщение об ошибке и выйти из программы.
3. Создать и открыть файл для записи с именем, содержащимся в строке out_name. Если создание файла не удалось, то вывести соответствующее сообщение об ошибке и выполнить пункт 6.
4. Вызвать функцию, декодирующую файл ― decode_file.
5. Закрыть выходной файл.
6. Закрыть входной файл и выйти из программы.
Компиляция
Компиляция ― одна из важнейших частей. Нужно сгенерировать файл, декодирующий сжатые данные, а для этого нужно генерировать таблицу декодирования и прилинковать к файлу. В принципе, это легко решается путём включения в исходник директивы:Здесь map.asm - созданная программой на С++ таблица декодирования файла.Код (ASM):
; Собственно говоря, сама таблица декодирования include "map.asm"
Таким образом, проглядывается необходимая последовательность действий:
1. Сжать исходный файл, получить сжатые данные и таблицу декодирования.
2. Скомпилировать программу-распаковщик с использованием сгенерированной таблицы.
3. Проверить, работает ли распаковщик, выполнив декомпрессию.
В таких случаях удобно писать bat-файлы. Собственно говоря, что мы и сделаем:
При запуске bat-файла получим следующие файлы:Код (Text):
xarch xarch.cpp data.huf map.asm fasm xdecode.asm xdecode.com xdecode
Теперь xdecode.asm и data.huf можно пускать в свободное плавание .
- data.huf ― сжатый алгоритмом Хафмана файл xarch.cpp.
- map.asm ― дерево декодирования сжатого файла.
- xdecode.com ― программа, декодирующая сжатые данные.
Постскриптум
Перед вами последняя статья из цикла "Кодирование Хафмана". Надеюсь, я смог вложить в неё всю необходимую информацию. Слово, как всегда, остаётся за читателем, потому, как только он сможет оценить, насколько данная статья полезна в повседневной деятельности. Конечно же, все перечисленные методы программирования не идеальны и не претендуют ни на какую абсолютную истину: это обычные примеры, которые всегда можно расширить и дополнить, получив тем самым необходимый результат.
P.P.S.
Здесь должно быть какое-нибудь заумное высказывание, тем или иным способом связанное с изложенным материалом. Но в этот раз, право слова я оставлю читателю. Так что можете писать мне письма с вашими вариантами.
Исходники к статье + FASM расположены в файле huffman.zip
© SadKo 2006
Кодирование Хаффмана (часть 3)
Дата публикации 24 авг 2017
| Редактировалось 10 янв 2018