Целью данной работы является получение студентами навыков программирования аппаратных средств компьютера (на примере таймера i8253/4), а также знакомство с языком Ассемблера (синтаксис AT&T) для процессоров i386.
Микросхема таймера i8253 содержит 3 идентичных канала. Каждый из каналов имеет 2 входных сигнала (CLK - тактовые импульсы и GATE - разрешение работы) и один выходной сигнал. В ЭВМ i386 данная МС используется для следущих целей: канал 0 таймера используется для генерации прерываний IRQ0, которое, как правило, используется операционной системой; канал 1 используется для выработки сигнала для освежения содержимого оперативной памяти (memory refresh), а канал 2 используется для управления встроенным в эти ЭВМ динамиком.
На рисунке приведена схема включения канала 2 таймера в ЭВМ на базе процессоров i386:
port 0x61[0] | | port 0x61[1] __ PC speaker *--------->---| GATE2 | *----------| \ _ /| | | | & |_____| | | 1.193.180 Hz | |--OUT2->--------| | __|_| | *--------->---| CLK | |__ / | \| |_________| _|_
Элементарная операция по программированию таймера состоит из двух шагов:
1) запись управляющего слова (порт 0х43, см. ниже)
2) запись некоторого значения в счетчик (порты 0х40, 0х41, 0х42,
см. ниже)
Регистры таймера в ЭВМ i386 отображены в пространство портов ввода-вывода.
Имеется 4 регистра. 3 из них используются для задания константы счета
для 3-х каналов таймера, а четвертый используется для записи управляющих
слов, а также для обратного чтения и чтения на лету.
Таблица соответствия регистров таймера и портов ввода-вывода:
порт 0x40 - канал 0 (R/W), 8 бит
порт 0x41 - канал 1 (R/W), 8 бит
порт 0x42 - канал 2 (R/W), 8 бит
порт 0x43 - управление работой таймера
Рассмотрим формат регистра управления работой таймера (порт 0x43):
7 6 5 4 3 2 1 0 --------------------------------------------- | SC1 | SC0 | RW1 | RW0 | M2 | M1 | M0 | BCD | ----------------------------------------------
Регистр имеет размер 8 бит. Два старших бита (SC) используются для задания номера канала, именно: комбинация 00 означает канал 0, комбинация 01 - канал 1, комбинация 10 - канал 2. Комбинация 11 имеет особое значение, с ее помощью производится операция обратного чтения (при выполнения данной работы не используется).
Следующие 2 бита (4 и 5, RW) задают способ записи КОНСТАНТЫ СЧЕТА следующим
образом:
01 - будет записан только младший байт КОНСТАНТЫ СЧЕТА
10 - будет записан только старший байт КОНСТАНТЫ СЧЕТА
11 - будут записаны оба байта КОНСТАНТЫ СЧЕТА (сначала младший, потом старший)
00 - имеет особый смысл; означает, что будет производится чтение на лету.
Биты M (1, 2, 3) позволяют задать режим работы того или иного канала
таймера. Количество возможных режимов - 6. Функционирование
таймера в каждом из этих режимов описано ниже.
Режим 0, M = 000 (interrupt on terminal count, прерывание по конечному отсчету)
Режим 1, M = 001 (ждущий мультивибратор)
Режим 2, M = *10 (генератор импульсов)
Режим 3, M = *11 (генератор меандра)
Режим 4, M = 100 (программно-формируемый строб)
Режим 5, M = 101 (аппаратно-формируемый строб)
Наконец, самый младший бит регистра управления задает режим счета
счетчика таймера:
0 - двоичный счет
1 - двоично-десятичный счет
Предварительно заметим, что у каждого канала имеется внутренний регистр-счетчик (назовем его CE), который, собственно, и используется для подсчета тактовых импульсов. Отметим также, что счетчики всегда работают на декремент, то есть по каждому CLK значение их уменьшается на единицу. При этом в режимах 0, 1, 4 и 5 по достижении CE = 0 счетчик продолжает считать ( ... 3, 2, 1, 0, FFFF, FFFE, FFFD ....), а режимы 2 и 3 - периодические, то есть при CE = 0 константа счета вновь загружается в CE и цикл счета повторяется.
Режим 0, M = 000 (прерывание по конечному отсчету)
После записи управляющего слова OUT = 0. Запись счетчика не изменяет
состояние OUT. При GATE = 1 счет разрешен, в противном случае - запрещен.
После разрешения счета по первому CLK константа счета копируется в CE.
Сам счет начинается по второму CLK. Через (N+1)*T_CLK, когда CE = 0,
OUT становится равным 1 и остается таковым до записи новой константы
счета или нового управляющего слова.
Режим 1, M = 001 (ждущий мультивибратор)
Режим 2, M = *10 (генератор импульсов)
Режим 3, M = *11 (генератор меандра)
После записи управляющего слова и константы счета при GATE = 1
на выходе таймера генерируется меандр с полупериодами (N/2)*T_clk
при четном N (константе счета) и с ((N+1)/2)*T_clk и ((N-1)/2)*T_clk
при нечетном N. Если нужно иметь на выходе сигнал с частотой F Гц,
то константа счета определяется выражением N(F)=F_clk/F, где F_clk -
частота тактирующих импульсов, равная 1193180 Гц (1193182 ???).
Отметим, что данный режим не работает при N = 3.
Режим 4, M = 100 (программно-формируемый строб)
После записи управляющего слова OUT = 1, по окончании счета OUT = 0
в течение T_clk, затем снова 1.
Режим 5, M = 101 (аппаратно-формируемый строб)
Примеры алгоритмов программирования таймера.
Чтение счетчика канала 0 на лету:
записать в порт 0х43 число 0х00
(канал 0, чтение на лету, остальное значения не должно иметь)
прочитать из порта 0х40 байт, это будет младший байт счетчика.
прочитать из порта 0х40 еще байт, это будет старший байт счетчика.
Запрограммировать канал 0 на прерывание через 10 мс:
записать в порт 0х43 число 0x30
(канал 0, пишем оба байта счетчика, режим 0, счет двоичный)
рассчитать значение константы счета. Поскольку в этом режиме
OUT = 1 через (N+1)*T_clk, а период T_clk равен 1/1193180 c,
то для того, чтобы получить прерывание через требуемые десять
миллисекунд, нужно чтобы счетик отсчитал 0.010*1193180 = 11931.8
такта. С восемью десятыми, понятное дело, придется смириться,
то есть округлить до 11932.
Записать в порт 0х40 младший байт рассчитанной константы счета
Записать в порт 0х40 старший байт рассчитанной константы счета
Запрограммировать канал 2 на генерацию меандра с периодом 1 мс
(частота 1 КГц):
записать в порт 0х43 число 0xB6
(канал 2, пишем оба байта счетчика, режим 3, счет двоичный)
Рассчитать константу счета. Поскольку период меандра равен
N*T_clk = T, то искомая величина N равна T/T_clk = T*F_clk =
0.001*1193180 = 1193.
Записать в порт 0х42 младший байт рассчитанной константы счета
Записать в порт 0х42 старший байт рассчитанной константы счета
Разрешить счет, подав на GATE второго канала единицу.
(цитируется по: Зубков С.В. "Ассемблер для DOS, Window$ и Unix", 2-ое издание)
Допустимые символы
Допустимые символы в тексте программы - латинские буквы, цифры плюс к этому
символы
'%'(процент), '$'(доллар), '.'(точка), ','(запятая), '_'(подчеркивание),
'*'(звездочка)
':'(двоеточие), '"'(двойная кавычка), пробел и табуляция. Все прочие символы
допустимы только внутри комментария, который начинается с '//'(двойного слэша).
Впрочем, в разных ассемблерах символ начала комментария может быть разным.
Если последовательность символов не начинается со спец.символа или цифры и не заканчивается двоеточием, то она трактуется как мнемоника команды процессора. Если началом последовательности символов является знак процента, то это название регистра процессора, если знак доллара, то это непосредственный операнд, а если точка, то это директива ассемблера. Точка сама по себе имеет специальное значение, это метка, значение которой равно текущему адресу. Все прочие метки должны заканчиваться двоеточием. Существует специальный вид меток, локальные метки, они имеют вид 'цифра:'(0:, 1: ... 9:), при обращении к которым после имени метки надо указывать направление (2f - ближайшая метка 2 вперед, 5b - ближайшая метка 5 назад по тексту программы). Для команд jmp и call звездочка перед операндом-числом означает, что это абсолютный адрес, отсуствие звездочки - относительный.
Написание мнемоник команд
Мнемоники команд, не имеющих операндов, совпадают с мнемониками
ассемблера синтаксиса Intel. Мнемоники команд, имеющих операнды,
имеют суффикс, отражающий размер операнда:
b - байт
w - слово
l - двойное слово
q - учетверенное слово
s - 32-битное слово с плавающей точкой
s - 64-битное слово с плавающей точкой
t - 80-битное слово с плавающей точкой
Операнд-источник записывается первым (в синтаксисе Intel - наоборот).
Команды, имеющие операнды разных размеров, записываются с двумя
суффиксами, сначала для источника, затем для приемника. Команды передачи
управления (дальние) имеют префикс l (lcall, lret, ljmp)
Адресация
Как уже указывалось, регистровый операнд предваряется знаком процент.
Непосредственный операнд начинается с символа доллар. Косвенная
адресация использует немодифицированное имя переменной. Примеры
более сложных способов адресации:
//(Intel) mov eax, base_addr[ebx+edi*4]
movl base_addr(%ebx, %edi, 4), %eax
//(Intel) lea eax, [eax+eax*4]
leal (%eax, %eax, 4), %eax
//(Intel) mov ax, word ptr[bp-2]
movw -2(%ebp), %ax
//(Intel)mov edx, dword ptr[edi*2]
movl (,%edi,2), %edx
Как видно из примеров, для того чтобы содержимое регистра
интерпретировалось как адрес, нужно имя регистра заключить
в круглые скобки.
Операторы ассемблера
Программы-ассемблеры умеют производить вычисления во время компиляции.
Символы операций совпадают с принятыми в языке программирования C.
Директивы ассемблера
Как уже говорилось, директива ассемблера - это последовательность
допустимых символов, начинающаяся с точки. Директив существует масса.
Рассмотрим некоторые из них, сгруппировав их по классам.
1) Директивы определения данных
Перед каждой такой директивой обязательная метка (имя переменной).
Имеются следующие директивы для определения различных типов данных:
.byte - для определения байтов
.word, .hword, .short - слово
.int, .long - двойное слово
.quad - четвертное слово (8 байт)
.octa - 16-байтное слово
.float, .single - число с плавающей точкой одинарной точности (32 бита)
.double - число с плавающей точкой двойной точности (64 бита)
.tfloat - число с плавающей точкой повышенной точности (80 бит)
.ascii - строка
.asciz, .string - строка с добавляемым нулем в конце
.skip/.space [размер], [значение] - заполнить указанным значением указанное число байт
.fill [сколько], [размер], [значение] - заполнить память указанное
число раз блоками указанного размера с указанными значениями
.lcomm - [символ], [длина], [выравнивание] - зарезервировать
указанное число байт для локального символа в секции .bss
2) Директивы управления символами
.equ символ, выражение
.equiv символ, выражение
..................
Пример программы на языке ассемблера для OC Linux
Программа, исходный текст которой приведен ниже, выполняет два действия,
выводит на экран некоторую текстовую строку и завершается. Дадим некоторые
пояснения. Во-первых, для того, чтобы совершить то или иное действие
(за исключением вычислений), программа должна обратиться к ОС, то есть
выполнить системный вызов. Во-вторых, в ОС Linux интерфейс к системным
вызовам организован посредством программного прерывания по вектору 0x80
(в последних ядрах имеется также иной способ перехода в кольцо 0 -
при помощи инструкции процессора SYSENTER).
При этом параметры системному вызову передаются через регистры
%eax, %ebx, %ecx, %edx.
В регистре %eax всегда передается номер функции (собственно номер вызова).
Соответствие вызовов и их
номеров можно посмотреть в файле /usr/include/asm/unistd.h. Какие у того или
иного системного вызова параметры и какие у них типы, можно посмотреть
в руководстве по этому системному вызову (команда man, например,
man _exit, man write и т.п.)
.text .globl _start _start: // write(stdout, message, message_l); movl $4, %eax // syscall #4, write() xorl %ebx, %ebx // zero ebx incl %ebx // номер дескриптора (stdout) movl $message, %ecx movl $message_l, %edx int $0x80 // exit(0); xorl %eax, %eax incl %eax // syscall #1, exit() xorl %ebx, %ebx // exit code int $0x80 hlt .data message: .string "Hello, world!\012" message_l = .-message
Как откомпилировать программу
Процесс получения готовой запускаемой программы из исходного текста
на ассемблере происходит в два этапа. Сначала нужно получить объектный
модуль, что, собственно, и делает ассемблер (вызывается командой as):
as -o object_file_name.o source_file_name.s
После этого полученный модуль надо скомпоновать, для чего используется
компоновщик ld:
ld -s -o executable_file_name object_file_name.o
После этого программа готова к запуску.
Руководствуясь приведенными выше сведениями, разработайте и отладьте
программу, которая будет проигрывать на динамик мелодию по вашему вкусу.
Имейте при этом ввиду следующее:
0) Чтение из порта и запись в порт производится следующим образом:
// читаем из 8-ми битного порта с номером NN (шестнадцатеричное), // результат помещаем в регистр %al inb $0xNN, %al // записываем байт из регистра %al // в 8-ми битный порт с номером NN (шестнадцатеричное), outb %al, $0xNN
Замечание #1: в другие регистры (не %al или %ax) считать данные из порта
нельзя. Также нельзя записать что-то в порт, взяв данные не из %al(%ax).
Таковы процессоры от Intel.
Замечание #2: %eax означает ВЕСЬ регистр (32 бита), %ax - младшие 16 разрядов
%eax, %ah - старший байт %ax, %al - младший байт %ax.
Аналогично для регистров %ebx, %ecx, %edx.
1) Для того, чтобы ДИНАМИК начал выдавать звук, недостачно запрограммировать
и "включить" ТАЙМЕР ("включить" значит разрешить ему счет). Нужно еще "включить"
сам динамик, смотри схему в самом начале. Нужные для этого биты - в порту 0x61,
бит 0 - разрешение работы 2-ого канала таймера, бит 1 - включение динамика.
2) В ОС Linux, как и во многих других ОС, прикладным программам,
вообще то говоря, нельзя напрямую обращаться к аппаратуре. Исключение
из этого безусловно разумного правила делается тогда, когда программа
исполняется с UID = 0 (то есть от имени суперпользователя). Но и в этом
случае без некоторых предварительных действий обращение к портам ввода-вывода запрещено.
Программа ДОЛЖНА явно разрешить себе обращаться
к тем или иным портам. В ОС Linux для этого существует специальный системный
вызов, ioperm()(акроним от Input/Output PERMissions).
Вызов имеет три параметра. Первый задает начальный порт, второй - сколько портов,
а третий - флажок (включить/отключить доступ к указанной первыми двумя параметрами
группе портов, не 0 - включить, 0 - выключить).
По поводу передачи параметров смотри выше пример программы.
Номер вызова ioperm() - 101.
Подробности смотрите в руководстве по этому вызову:
man ioperm
3) Вам обязательно потребуется реализовать в программе задержки,
чтобы выдерживать длительности нот. Категорически не рекомендуется использовать
для этого циклы. Используйте системный вызов
nanosleep().
Пример (не очень хороший, так как подпрограмма задержки использует глобальную
переменную):
////////////// // секция кода ////////////// .text .globl _start _start: call delay call exit delay: // nanosleep(...); movl $162, %eax // nanosleep syscall # movl $tts, %ebx // addr 1 movl $0, %ecx // NULL as addr 2 int $0x80 ret // exit(0); exit: xorl %eax,%eax incl %eax // syscall #1 xorl %ebx,%ebx // exit code = 0 int $0x80 ///////////////// // секция данных ///////////////// .data // time to sleep = 3.5 sec tts: .int 3 // seconds .int 500000000 // nanoseconds
4) Вам будет нужно по требуемой частоте звука рассчитывать константу счета
для таймера. Для этого, как уже говорилось, нужно значение частоты импульсов,
тактирующих таймер, поделить на значение частоты звука. Деление 32-х битного слова (делимого)
на 16-ти битное (делитель) производится следующим образом: нужно поместить
старшие 16 разрядов делимого в регистр %dx, младшие 16 разрядов -
в регистр %ax, а делитель - в регистр %bx (например).
После этого использовать инструкцию
div %bx.
Частное помещается процессором в регистр %ax. Можно, конечно,
рассчитать константы счета для всех нужных частот заранее.
5) Частоты нот. Вопрос сей весьма сложен. Достаточно сказать, что
теоретическими исследованиями на эту тему люди занимаются со времен
Пифагора по наши дни. Мы же воспользуемся тем, чем пользуется
западно-европейская музыкальная культура с не очень древних времен, а
именно, равномерно-темперированным 12-ти полутоновым строем. Частоты
нот в этой системе рассчитываются следующим образом: начиная с
какой-нибудь ноты (скажем Ля первой октавы, 440 Гц), частоту следующей
получают путем умножения частоты предыдущей на корень 12-той степени из
двойки. В таблице приведены рассчитанные таким образом частоты нот в
диапазоне 4-х октав (округляйте до целого):
Обозначения нот стандартные, a = ля (а# = ля диез) h = си c = до (c# = до диез) d = ре (d# = ре диез) e = ми f = фа (f# = фа диез) g = соль (g# = соль диез) a: 110.000000 a#: 116.540940 h: 123.470825 "Малая" октава: c: 130.812783 c#: 138.591315 d: 146.832384 d#: 155.563492 e: 164.813778 f: 174.614116 f#: 184.997211 g: 195.997718 g#: 207.652349 a: 220.000000 a#: 233.081881 h: 246.941651 "Первая" октава: c: 261.625565 c#: 277.182631 d: 293.664768 d#: 311.126984 e: 329.627557 f: 349.228231 f#: 369.994423 g: 391.995436 g#: 415.304698 a: 440.000000 a#: 466.163762 h: 493.883301 "Вторая" октава: c: 523.251131 c#: 554.365262 d: 587.329536 d#: 622.253967 e: 659.255114 f: 698.456463 f#: 739.988845 g: 783.990872 g#: 830.609395 a: 880.000000 a#: 932.327523 h: 987.766603 "Третья" октава: c: 1046.502261 c#: 1108.730524 d: 1174.659072 d#: 1244.507935 e: 1318.510228 f: 1396.912926 f#: 1479.977691 g: 1567.981744 g#: 1661.218790 a: 1760.000000
Понимающие о чем идет речь эстеты могут использовать другой какой угодно
строй - пифагоров, чистый, китайский, арабский, индийский и т.д. :-)
6) Пример организации цикла (показано также использование косвенной
адресации для доступа к элементам массива):
.text .globl _start _start: // печатаем алфавит // поместить адрес массива с буквами в %edi movl $alphabet, %edi // поместить размер массива в %ecx movl alpha_size, %ecx 1: // сохранить счетчик в стеке pushl %ecx // берем из массива символ movb (%edi), %al // "обрабатываем" его incb %al // кладем обратно movb %al, (%edi) // системный вызов #4 - write() movl $4,%eax // файловый дескриптор - 1, stdout xorl %ebx,%ebx incl %ebx // адрес буфера movl %edi,%ecx // количество символов (1) movl $1,%edx // печатаем 1 символ int $0x80 // двигаем указатель массива incl %edi // восстанавливаем из стека счетчик цикла popl %ecx loop 1b // exit(0); xorl %eax,%eax incl %eax xorl %ebx,%ebx int $0x80 hlt .data // число букв alpha_size: .int 10 // буквы алфавита (a, b, c ...) alphabet: .byte 0x61 .byte 0x62 .byte 0x63 .byte 0x64 .byte 0x65 .byte 0x66 .byte 0x67 .byte 0x68 .byte 0x69 .byte 0x6A
Дата последней модификации: 2010-03-31