Низкоуровневое программирование на Ассемблере под DOS


Предисловие (от редакции)

Эта статья впервые была опубликована еще в конце 2002-го года. В то время были актуальны совсем другие операционные системы. Однако в теоретической своей части может быть интересна тем программистам (или просто интересующихся тематикой), которые изучают более низкоуровневое программирование. На сайте проекта WASM.RU эта статья входила в рубрику «Низкоуровневое программирование для дZенствующих». Если коротко — эта тематика тех, кого интересует чуть больше, чем уровень «Hello World».

Содержание

  • 2.1. Проба молотка
  • 2.2. Несколько «тупых» процедурок
  • 2.3. Печать «шестнадцатеричных циферек»
  • 2.4. Печать десятичных циферек
  • 2.5. «Hello, world!» или Изврат-2
  • Диагноз

2.1. Проба молотка

#1. Теоретически гвозди можно забивать и голыми руками. Но намного быстрее и безболезненнее делать это с помощью молотка. Пользоваться им, как известно, каждый дурак умеет. Чего там сложного? Взял оный в руки — и молоти: раз по гвоздю, два раза по пальцам (понимание приходит с опытом).   Молотки бывают разные: большие и маленькие, с длинной ручкой и с короткой ручкой, железные и деревянные, приспособленные для забивания гвоздей и приспособленные для пробивания черепов… Да и гвоздей разнообразие еще то! И разной длины, и разной толщины, и со шляпками разной формы, из разного материала сделанные… есть даже специализированные для прибивания рук к кресту! :(.
Так вот: виды молотков и особенности их использования мы пока что оставим в покое. Для начала просто возьмем гвоздь, который уже не раз забивали голыми руками (больно было?) и забьем его при помощи молотка! Помните, как мы программу, выводящую окошки, без компилятора одними мнемониками писали?
А теперь ее же, дуру, «под компилятор» перелопатим… В общем, настало время, братья, тайну нервную и страшную узнать. Сегодня мы познакомимся с компилятором.

#2. Нижеследующий текст набираем в текстовом редакторе:

  

;-[блок 2]--------------------------  
CODESG segment 
assume CS:CODESG 
org 100

;-[блок 3]--------------------------  
MAIN proc 
  xor AL,AL 
  mov BH,10h
  mov CH,5
  mov CL,10h 
  mov DH,10h
  mov DL,3Eh
  mov AH,6
  int 10h
  call WINDOW 
  call WINDOW 
  call WINDOW
  call WINDOW
  int 20h
MAIN endp 

WINDOW proc
  ADD BH,10h
  ADD CH,1 
  ADD CL,1 
  SUB DH,1 
  SUB DL,1 
  INT 10h
  RET 
WINDOW endp

;-[блок 4]--------------------------  
CODESG ends 

end MAIN

Сохраняем получившуюся дуру под именем proga_1.asm.
Далее запускаем файл tasm.exe и в качестве параметра передаем ему имя файла с исходным текстом программы…То есть командная строка у вас (в том же Windows Commander’е) вот какая должна быть:

tasm proga_1.asm

Если вы правильно набрали текст программы, то TASM должен выплюнуть вам вот что:

Turbo Assembler Version 4.1 Copyright (c) 1988, 1996 Borland International 
Assembling file: proga_1.asm 
Error messages: None 
Warning messages: None 
Passes: 1 
Remaining memory: 406k 

Самое тут главное — это чтоб «Error messages» был «None». Это значит, что ошибок в программе нет.
Поехали дальше… Если ошибок у вас действительно никаких не было, то в том же каталоге, что и proga_1.asm ищите файл proga_1.OBJ. Можете даже по F3 попробовать просмотреть его содержимое… Что-нибудь поняли? Если нет — отсылаю вас к «Введению в машинный код».
А теперь запускаем хорошую программу TLINK следующим образом:

tlink /t proga_1.obj

Обратите внимание: линкуем мы именно файл с типом OBJ, а не ASM.
Что получилось? А получился файл proga_1.COM!! И этот .COM работает!
Посмотрите на его содержимое в DZEBUG’е :). Отличется ли оно чем-то от той проги, что мы делали ранее?
Нет, не отличается!
Медитируем…

#3. А теперь несколько слов о том, почему не стоит писать программы так, как мы это делали раньше:
1. Эти проклятые адреса! Пойди рассчитай на какой адрес прыгнуть нужно, с какого смещения подпрограмма начинается, с какого блок данных… В принципе, как мы уже убедились, в этом нет ничего сложного… просто занятие это очень уж муторное и неинтересное…
Как абсолютно верно заметил некто Евгений Олейников в RTFM_Helpers: «Когда я пишу программу, я не знаю точного адреса начала будущей подпрограммы»
2. Очень сложно было в том случае, когда возникала необходимость ВСТАВИТЬ ту или иную команду в середину кода. Приходилось перебивать код по новой… по новой пересчитывать адреса… ужас, в общем!
Так вот: основная и самая главная функция «ассемблерного компилятора» — это как раз и есть «просчитывание адресов»!
Смотрите, как хорошо и приятно: мы готовим исходный текст в обыкновенном текстовом редакторе :). Просто набиваем строчка за строчкой — нам не важны адреса (мы их вообще не видим!), не важны «точки входа» в процедуру… Спокойненько вставляем какую надо команду, спокойненько удаляем… Без лишнего напряга!
Да вы посмотрите на блок 3 программы :). Там все те же хорошо знакомые команды :).
Особое внимание обратите на команду CALL, которая у нас, как известно, вызывает процедуру. После нее идет не привычный адрес начала процедуры, а всего лишь ее «имя собственное»! А сама процедура находится между строчками WINDOW proс (начало процедуры) и WINDOW endp (конец процедуры).
«WINDOW» — понятно, это «имя собственное». «Proc» — потому что процедура. «Endp» — потому что конец процедуры…
Тут еще один момент… подобные «словеса» КОМАНДАМИ АССЕМБЛЕРА НЕ ЯВЛЯЮТСЯ. Они называются иначе — «директивами». Это не ПРИКАЗ делать то-то и то-то, а ЦЕННОЕ УКАЗАНИЕ компилятору (не процессору!), что и как ему делать с данным куском кода…
Процедуры — вещь хорошая! Все процедуры хороши, и в большой программе их чертовски много! Это вам не языки высокого уровня, где можно длиннющие простыни кода лабать и все будет работать! («Дельфи-компилятор не даст вам выстрелить себе в ногу, но будет так удивлен попыткой сделать это, что через некоторое время сделает это сам.» (С) DZ WhiteUnicorn).
Это ASM! Тут запутаться легче простого! Исходники неимоверно длинны и сложны! Все делается ручками (хоть и с помощью компилятора), а ошибки довольно трудно отслеживаются (для не-дZенствующих это вообще занятие безнадежное)! Вот и выкручиваются низкоуровневые программеры таким вот образом: всю программу (даже в тех случаях, когда это не обоснованно!) делят на меленькие кусочки-процедурки… Напишут процедурку, протестируют ее так и сяк… если работает — за следующую берутся…
Сии «методы проектирования» мы с вами еще не раз рассмотрим :). Пока что знайте вот что: любую прогу можно/нужно рассматривать как КУЧУ ПРОЦЕДУР, которые все между собой повязаны…
Но есть среди этих процедур САМАЯ ГЛАВНАЯ! Это та, С КОТОРОЙ НАЧИНАЕТСЯ ВЫПОЛНЕНИЕ ПРОГРАММЫ! Ее никто не вызывает. Она — босс! Она всеми командует, все гребет под себя… Описывают ее те же самые директивы, что и прочие «подчиненные процедуры»… Но есть у нас еще одна директивка, которая указывает, КАКАЯ ИМЕННО процедура ИЗ ВСЕХ вроде бы «равноправных» является ГЛАВНОЙ.
Видите строчку end MAIN в конце исходного текста программы? Именно она и указывает ГЛАВНУЮ ПРОЦЕДУРУ («MAIN» — ее имя собственное). Если бы мы написали end WINDOW, то выполнение программы у нас началось бы с первой строчки процедуры WINDOW, и ни одна строчка из MAIN выполнена не была бы…
Уф… в общем, долго и упорно медитируйте…

#4. Наличие многочисленных директив — это своего рода плата за то, что компилятор избавляет нас от необходимости просчитывать адреса. Как говорит дZенская программерская мудрость «любишь кататься, люби и саночки возить»…
Как и в DZEBUG’е, «в TASM’e» мы также должны четко инструктировать компилятор (дабы он в свою очередь также четко проинструктировал процессор), что у нас является кодом, а что — данными…
Посмотрите на исходник. Весь текст программы у нас хранится между директивами CODESG segment и CODESG ends. Где CODESG — это «имя собственное», «segment» — потому что «CODESG» он, собственно, и есть сегмент :), и «ends» — потому что конец сегмента… (сравните с процедурными директивами).
Но тут такой вопрос:
Директива ASSUME производит связывание сегментного решистра CS с «именем собственным».
Далее у нас следует директива org 100h. Нужна она нам для того, чтобы компилятор понял, что мы хотим получить именно COM-файл, который, как известно, помещается в сегмент памяти начиная со смещения 100 (в общем-то, это необходимое, но вовсе не достаточное условие для получения COM-файл). Директивка очень интересная, о ней мы, надеюсь, еще поговорим подробнее, когда коснемся вирмейкерства.

#5. Ладно… с директивами более-менее разобрались, исходник приготовили, пора разбираться че там дальше происходит…
Дальше происходит так называемое «ассемблирование», т.е. перевод команд в соответствующие машинные коды. При этом просчитываются адреса меток, адреса начала подпрограмм, адреса начала/конца сегментов… и многое другое…
Причем ассемблирование происходит как минимум в два приема. Посудите сами: откуда компилятору знать, с какого адреса начнется процедура WINDOW, если не известно, какая еще простыня команд ПОСЛЕ этого CALL’а будет? В DZEBUG’е мы это «в уме на листике» считали…
TASM это тоже аналогичным образом делает :).
При первом проходе он подсчитывает, сколько какая команда занимает места, с каких адресов начинаются процедуры и т. д., и только при втором проходе подставляет в call’ы КОНКРЕТНЫЕ АДРЕСА начала этих процедур… всего лишь… ну еще и ваши «d» и «b» в машинные «h» (которые на самом деле «b») переводит… (во завернул!)…
А вообще, TASM много еще чего делает… программеры — народ ленивый…
В результате ассемблирования мы получаем так называемый «объектный файл».
«И что это за дрянь?» — спросите вы, и правильно спросите…
А вы сравните шестнадцатеричное содержимое OBJ и COM файлов. В OBJ присутствует та же последовательность байтов, что и в OBJ. Но помимо этого и еще какая-то шестнадцатеричная ерунда присутствует: имя сассемблированного файла, версия ассемблера, «имя собственное» сегмента и т. д.
Это своего рода «служебная» информация, предназначенная для тех случаев, когда ваш исполнимый файл вы хотите собрать из нескольких. При разработке больших приложений исходный текст, как правило, хранится в нескольких файлах; в каждом из них прописаны свои сегменты кода/данных/стека. А исполнимый получить нам нужно только один — с единым сегментом кода/данных/стека. Именно это TLINK и делает: завершает определение адресных ссылок и объединяет, если это требуется, несколько программных модулей в один…
И этот один у нас и является исполнимым… УРА!

#6. Итак, первую прогу с использованием компилятора мы написали. Теперь напишем вторую. Набиваем ее исходный текст и компилим:

assume CS:SUXXX, ES:SUXXX

SUXXX segment
org 100h

MAIN proc
  lea bp,ABC
  mov AH,13h
  mov AL,3
  xor bh,bh
  mov bl,07h
  mov cx,16d
  xor DX,DX
  int 10h 
  int 20h
MAIN endp

ABC db 'H',0Ah,'e',0Bh,'l',0Dh,'l',0Ch
    db 'o',0Bh,',',0Ah,' ',0Ah,'W',09h
    db 'o',08h,'r',07h,'l',06h,'d',05h
    db '!',02h,'!',02h,'!',02h

SUXXX ends

end MAIN

Итак, в этой программе мы использовали функцию 13h прерывания 10h (INT 10h, AH=13h).
Вот ее описание:

[INT 10h, ФУНКЦИЯ 13h] — записывает на экран символьную строку, начиная от указанной позиции.
ВХОДНЫЕ ПАРАМЕТРЫ:
AH = 13h;
AL — код формата(0-3):
AL=0, формат строки{симв., симв.,…, симв.} и курсор не перемещается,
AL=1, формат строки{симв., симв.,…, симв.} и курсор перемещается,
AL=2, формат строки{симв., атр.,…,симв., атр.} и курсор не перемещается,
AL=3, формат строки{симв., атр.,…,симв., атр.} и курсор перемещается;
BH — страница дисплея;
BL — атрибут (для режимов AL=0, AL=1);
CX — длина строки;
DX — позиция курсора для записи строки;
ES:BP — указатель строки.
ВЫХОДНЫЕ ПАРАМЕТРЫ: отсутствуют.

А еще мы использовали команду LEA, которая загружает в регистр адрес (смещение), с которого у нас начинается блок данных.

2.2. Несколько «тупых» процедурок

#1. Дабы сходу «обломать» нездоровую критику, сразу предупреждаем, что нами сознательно допущена некоторая излишняя «процедуризация» исходного кода. А по сему, прежде чем взяться за изучение нижеследующего материала, твердо уясните: в данном случае (как есть сейчас) дробление кода на процедуры — дурь полная. А обосновываем мы эту дурь только тем, что впоследствии будем совершенствовать эти процедуры до уровня, когда, собственно, сие «дробление» и будет обосновано.
И еще. Подобный модульный подход (это понятие немножко шире, чем просто «использование процедур») в некоторых случаях снижает быстродействие и увеличивает размер исполнимого файла, однако он же и значительно увеличивает «читабельность» исходника, что вполне обоснованно с точки зрения обучения. И с точки зрения «командной» разработки программ — тоже. (В общем, критика по этому поводу не принимается!).
Итак, для начала набиваем вот какой текст (исходник это!):

 
assume CS:PROGA

PROGA segment
org 100h

;-[TESTING]---------------------------
;Здесь мы будем тестировать процедуры
;---------------------------------------
TESTING proc
  call EXIT_COM
TESTING endp

;-[EXIT_COM, V1]--------------------
;Завершение работы программы
;На входе: пофиг
;На выходе: нихрена
;Прерывания: INT 20h
;Процедуры: ан нэту
;-----------------------------------
EXIT_COM proc
  int 20h
EXIT_COM endp

PROGA ends

end TESTING 

Здесь вам все должно быть понятно. INT 20h вынесен в отдельную процедуру, и только. Плюс еще какие-то нездоровые заголовки добавлены, которые и весят-то больше, чем сам код. Это нормально. После точки с запятой в исходнике вы можете писать все, что угодно. Все равно при компиляции это будет проигнорированно и, следовательно, на размер исполнимого файла не повлияет. (Точно так, как и длина «имен собственных» процедур и меток).
Мы предлагаем использовать именно такую «шапку» комментария к каждой из ваших процедур. Все очень просто. Первая строчка — это «что делает процедура». Вторая — какие ей необходимо передать параметры. Третья — какие она возвращает параметры. Четвертая и пятая, соответственно, — какие в процедуре использовались прерывания и хм… ранее написанные процедуры :). Это намного облегчит понимание вашего исходника как вами самими, так и теми, кому вы его предоставите на поругание…
Для тех, кто в танке: последующие процедуры вставляйте между процедурами TESTING и EXIT_COM — не ошибетесь :-p.

#2. Следующая процедура также основана на одном-единственном прерывании. Все, что она делает — это возвращает текущие координаты курсора.

;-[CURSOR_READ, V1]-------------------
;Возвращает координаты курсора
;На входе: пофиг
;На выходе: DH - строка, DL - столбец
;Прерывания: INT 10h, AH=03h
;Процедуры: ан нэту
;------------------------------------------
CURSOR_READ proc
  push AX
  push BX
  push CX
  mov AH,3
  xor BH,BH
  int 10h
  pop CX
  pop BX
  pop AX
  ret
CURSOR_READ endp 

Прежде всего обратите внимание на цепочку push’ей и pop’ов (далее — просто «поп»), на очередность записи в стек (AX, BX, CX) и извлечения (CX, BX, AX — обратное то есть). Все регистры, которые мы собираемся изменять внутри процедуры, должны обязательно сохраняться в ее начале и восстанавливаться в ее конце. В важности соблюдения этого правила вы еще не раз убедитесь на своем горьком опыте. Те, кто медитировал над заданием из главы 1.10 #4, уже знают, о чем тут идет речь (а кто не пытался — самое время!).
Давайте посмотрим на нашу процедуру с точки зрения пушей и поп. AH мы изменяли для указания функции прерывания, которую мы хотим использовать. BH обнуляли для указания видеостраницы (пока будем только одну-единственную, нулевую, юзать). «А CX зачем» — спросите. — «Вроде мы его не трогали…». И точно, мы — не трогали. А посмотрите в описании, что в этот регистр нам засунуло прерывание в «результате» своего выполнения… Посмотрели? Оно вам надо?? То, что нам надо — координаты — засунуты в DX (DH, DL), поэтому их значения мы не сохраняем. Если бы нам нужно было получить информацию о типе курсора — мы бы запушили DX, а CX бы оставили в покое…
Что? Без пива не разобраться?
Так в чем, черт подери, дело? Разбирайтесь под пиво!!
Вот вам еще одна аналогичная процедура, которая не определяет, а УСТАНАВЛИВАЕТ курсор в заданные координаты…

;-[CURSOR_SET, V1]---------------------------------------
;Устанавливает курсор в заданные координаты
;На входе: DH - строка, DL - столбец
;На выходе: нихрена
;Прерывания: INT 10h, AH=02h
;Процедуры: ан нэту
;--------------------------------------------------------
CURSOR_SET proc
  push AX
  push BX
  push CX
  mov AH,2
  xor BH,BH
  int 10h
  pop CX
  pop BX
  pop AX
  ret
CURSOR_SET endp

Если кто чего не понимает — смотрите комментарии к предыдущей процедуре (+ описание прерываний и команд! это обязательно!). Если кто не понял и предыдущую — снова отсылаю к главе 1.10

#3. На основе двух предыдущих процедур мы напишем третью «курсорную» :), которая будет сдвигать курсор на одну позицию вправо.

;-[CURSOR_RIGHT, V1]-------------------------------------
;Перемещает курсор на одну позицию вправо
;На входе: пофиг
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: CURSOR_READ, CURSOR_SET
;--------------------------------------------------------
CURSOR_RIGHT proc
  push DX
  call CURSOR_READ
  inc DL
  call CURSOR_SET
  pop DX
  ret
CURSOR_RIGHT endp

А здесь очень простая идеология :)). Из двух процедур мы собрали третью :)). Вызвали CURSOR_READ, получили в DX текущие координаты курсора. Ту координату, что столбец (DL, младшая часть DX), увеличили на единицу. А потом вызвали процедуру CURSOR_SET, которая у нас устанавливает координаты курсора. Новые координаты в нее передаются опять таки через тот же DX. Улавливаете?
Естественно, мы запросто можем отказаться от процедур CURSOR_SET и CURSOR_READ и решить данную задачу внутри одной процедуры… В общем, свой выбор вы сделаете сами. Страшна Сцилла оптимизации по быстродействию, еще страшнее — Харибда оптимизации по размеру, но тварь самая страшная — это Программер, который оптимизирует свой код по собственной «удобноваримости»… (Хм… интересно, что бы сказали по этому поводу программеры Мелкософта…)
Обратите также внимание на push/pop DX внутри процедуры. Мы просто сдвигаем курсор вправо. ПРОСТО СДВИГАЕМ на одну позицию. То что в DX нам надо? Сто лет оно нам не надо… херим… А остальные регистры — еще процедурами CURSOR_SET и CURSOR_READ неоднократно «похерены». В каком смысле «похерены»?? А в таком, что состояние регистров ПОСЛЕ вызова CURSOR_RIGHT в точности такое же, как и было ДО. Хотя, сами помните, что всю четверку регистров мы еще как юзали…
Вот теперь можно сделать паузу и (это американцы пускай свой пластмассовый твикс кушают) ПОМЕДИТИРОВАТЬ…

#4. Следующая процедура ну вааще элементарна:

;-[WRITE_CHAR, V1]---------------------------------------
;Печатает символ и переводит курсор на позицию вправо
;На входе: DL - код символа.
;На выходе: нихрена
;Прерывания: INT 10h, AH=09h
;Процедуры: CURSOR_RIGHT
;--------------------------------------------------------
WRITE_CHAR proc
  push AX
  push BX
  push CX
  mov AH,9
  xor BH,BH
  mov BL,00000111b
  mov CX,1
  mov AL,DL
  int 10h
  call CURSOR_RIGHT
  pop CX
  pop BX
  pop AX
  ret
WRITE_CHAR endp

Она символ на монитор выводит. Через 9-ю функцию 10-го прерывания. А потом (после вывода) курсор на позицию вправо перемещает. Догадайтесь сами, «путем вызова» какой процедуры… Ага, правильно :)), CURSOR_RIGHT.
Посмотрите на описание этого прерывания. Код символа должен быть в AL. А у нас в комментариях он в DL прописан. А перед INT 10h mov AL,DL нездоровый стоит. Нахрена он тут?? И правильно!! Этот mov можно удалить, и передавать значение через AL. Но я тварь вредная. Привык я, понимаете-ли, через DX передавать… Привычка — сила страшная!! Лень с ней бороться… Лень, а поэтому и не буду… Кто-то в подобном мове может и более глубокий смысл найдет — наверняка найдет!! В общем — ищите сами. На блюдечке с голубой каемочкой вам это не преподнесу :)). Вредный.
mov BL,00000111b (не 07h) написано специально. Это чтоб вы посмотрели, как кодируется атрибут (фон, цвет) энтого символа. В одном из предыдущих номеров даже табличка есть, из справочника содранная…

#5. В языке «командного интерпретатора DOS» есть хорошая команда — CLS (то бишь очистка дисплея). Хорошая команда! Кто не поленится заглянуть внутрь command.com’а, увидят приблизительно следующее (на самом деле все чуть-чуть навороченнее, но прерывание то же):

 
;-[CLS, V1]-----------------------------------------
;Oчистка дисплея
;На входе: пофиг
;На выходе: нихрена
;Прерывания: INT 10h, AH=06h
;Процедуры: ан нэту
;--------------------------------------------------------
CLS proc
  push AX
  push BX
  push CX
  push DX
  mov AH,6
  xor AL,AL
  mov BH,00000111b
  xor CX,CX
  mov DH,24d
  mov DL,79d
  int 10h
  pop DX
  pop CX
  pop BX
  pop AX
  ret
CLS endp

Короче, элементарный скроллинг, только заданы максимально возможные координаты скроллируемого окошка и CX=0… в общем, окошки рисовали, помните…

#6. Тестируем, штоль?? Дописываем процедуру TESTING:

TESTING proc
  mov DL,'*'
  call WRITE_CHAR
  call WRITE_CHAR
  call WRITE_CHAR
  call EXIT_COM
TESTING endp 

Компилим… CLS тож тестируем… Все работает??

А теперь самое интересное :)) и благоприятно влияющее на нервную систему :). Прелесть модульного подхода вот в чем: написали процедуру, протестировали успешно — и МОЖЕТЕ ЗАБЫТЬ нафиг, как она у вас работает, какие функции там используются, какие хитроПОПые алгоритмы там применены…
Просто смотрите на заголовок, че она делает, чего ей надобно на входе, чего возвращает… а ее «внутренности» вам глубоко фиолетовы. Работает — и ладно. Ааааaaa?? Круто?
Уф… медитируйте!!

2.3. Печать «шестнадцатеричных циферек»

#1. Мы уже неоднократно юзали хорошую мнемоническую (aka ассемблерную) команду ADD :). Напомню, что в результате выполнения команд

mov AX,2
mov BX,3
add AX,BX

в регистр AX у нас помещалась сумма (AX=AX+BX).

Мы смотрели на это дело под отладчиком, и, к своей неописуемой радости, убеждались в том, что эта дрянь действительно работает. Но толку нам знать, что она работает?? Программа ведь не только работать должна, но еще и диалог какой-нить между юзверем и компутером обеспечивать! Например, спрашивать у него эти два числа и выплевавать на монитор результат их сложения.
Вот именно — выводить на монитор, а не заносить в какой-то абстрактный регистр.
С клавиатурным вводом пока обождем, а вот с выводом (на монитор) разберемся прямо сейчас.

Как мы это сделаем? Вы уже неоднократно слышали, что «в ассемблере» все делается «ручками» :). Сейчас вы лишний раз убедитесь в том (некоторые замрут в ужасе), что это утверждение истинно. Для вывода значения регистра мы вовсе не «познакомимся с новым прерыванием». Даже такая простейшая операция, как «вывод на дисплей значения регистра (переменной)» — это целая процедура. И не одна, как вы скоро в этом убедитесь. Страшно?

Поехали!!

#2.Задача: вывести на монитор значение регистра DL.
Народ! Давайте сразу расставим границы между КОДОМ СИМВОЛА и его НАЧЕРТАНИЕМ.
Например, у нас F3h в DL. Как мы хотим это вывести? Как символы ‘F3’ или же как ASCII символ, соответствующий коду F3h? Определяемся. Если в DL у нас F3h — то надо чтоб именно ‘F3’ у нас на монитор и выводилась. Не ‘э перевернутое’, не ’46 33′, а именно ‘F3’. Помедитируйте. Уловите разницу между ‘э’, ’46 33′ и ‘F3’.

Эту задачу мы немножко упростим :). Для начала напишем процедуру, которая выводит только младшую тетраду регистра DL (цифру «3» в нашем примере). Для этого мы обратимся к процедуре WRITE_CHAR из прошлого номера. Именно она печатает нам на монитор символ, ASCII-код которого находится в DL.
Но тут загвоздка: в DL-код, а печатается-то символ :). А нам, собственно, именно две циферки шестнадцатеричного кода, как два символа, и нужно напечатать. Ну, или хотя бы младшую циферку этого кода…
Решается эта задача элементарно :). Главное — это правильно ее сформулировать!
Вот что я тут «нарисовал»:

   ЕСТЬ         НУЖНО
код символ    символ код
-----------  ------------
00h  '?'       '0'   30h
01h  '?'       '1'   31h
02h  '?'       '2'   32h
03h  '?'       '3'   33h
04h  '?'       '4'   34h
05h  '?'       '5'   35h
06h  '?'       '6'   36h
07h  '?'       '7'   37h
08h  '?'       '8'   38h
09h  '?'       '9'   39h
0Ah  '?'       'A'   41h
0Bh  '?'       'B'   42h
OCh  '?'       'C'   43h
ODh  '?'       'D'   44h
OEh  '?'       'E'   45h
0Fh  '?'       'F'   46h

Только во второй колонке вместо вопросительных знаков должны быть соответствующие всякие символы (посмотрите в ASCII-таблице, какие они на вид страшные!).

Процедурка наша вот что должна делать — Всего-навсего перевести (конвертировать) тетраду в код соответствующего ей символа… Завернуто? Если разобраться, то не очень-то и завернуто.
Смотрите: в DL у нас 03h. Хотим мы эту ‘3’ на монитор вывести. Если вызовем WRITE_CHAR, то у нас символ «сердечко» выплюнется. А надо, чтоб символ ‘3’ вывелся, код которого 33h.
Соответственно и для остальных смотри по табличке.

А теперь обратите внимание, насколько «шестнадцатеричная циферка» (тетрада) отличается от ASCII-кода, этой «циферке» соответствующего. Сам скажу: на 30h для цифр от ‘1’ до ‘9’, и на 37h для цифр от ‘A’ до ‘F’. То есть «переконвертацию» мы запросто можем сделать командами add DL,30h (если тетрада в диапазоне 0…9) и add DL,37h (если тетрада в диапазоне A…F).

Короче, вот код (пропиваю!):

;-[WRITE_HEX_DIGIT, V1]----------------------------------
;Печатает одну шестнадцатеричную цифру (младшую тетраду DL)
;(старшая тетрада должна быть равна 0)
;На входе: DL - цифра
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: WRITE_CHAR
;--------------------------------------------------------
WRITE_HEX_DIGIT proc
  push DX   
  cmp  DL,0Ah       
  jae  HEX_LETTER
  add  DL,30h
  JMP  WRITE_DIGIT
 HEX_LETTER:
  add  DL,37h
 WRITE_DIGIT:
  call WRITE_CHAR
  pop  DX   
  ret
WRITE_HEX_DIGIT endp

Сначала, ессно, изменяемые регистры сохраняем (мы ж их изменяем!). (Ну, и восстанавливаем в конце процедуры (PUSH и POP соответственно)).
Потом у нас логическое ветвление организовано. Сравниваем значение DL с «общей границей» наших двух диапазонов (команда — CMP, «граница» — 0Ah). Если это значение больше или равно 0Ah, то прыжок на метку HEX_LETTER, прибавление к DL 37h и печать цифры (WRITE_CHAR). Иначе добавляем 30h и безо всяких условий перепрыгиваем на вызов WRITE_CHAR (минуя add DL,37h то бишь).
Все. Тестируем.

#3. Про тестирование — базар отдельный. В данном случае мы можем запросто проверить нашу процедуру на абсолютно всех возможных значениях этой тетрады (всего-то ничего- 16 вариантов). Но намного правильнее, проанализировав алгоритм, установить своего рода «критические» значения, на которых целесообразно проводить проверку. Плюс, естественно, минимальное и максимальное значения.

TESTING proc
  mov DL,00h
  call WRITE_HEX_DIGIT
  mov DL,01h
  call WRITE_HEX_DIGIT
  mov DL,09h
  call WRITE_HEX_DIGIT
  mov DL,0Ah
  call WRITE_HEX_DIGIT
  mov DL,0fh
  call WRITE_HEX_DIGIT
  call EXIT_COM
TESTING endp

Если сия «тестовая» (она же — главная) процедура выведет на монитор

019AF

, значит мы с высокой долей вероятности можем быть уверенными, что процедура WRITE_HEX_DIGIT работает правильно на всех значениях младшей тетрады DL.
Кто не просто скопировал процедуру из буфера обмена, а действительно разобрался с тем, как она работает — сами знают, что значение старшей тетрады нашей процедуре НЕбезразлично. Оно должно быть равным 0.

#4. Что мы имеем? Процедуру для вывода на дисплей одной шестнадцатеричной циферки — младшей тетрады (в DL). Но нам-то нужно две вывести! Сначала старшую циферку-тетраду, и только потом — младшую!
Таким образом очередная задача разбивается на две части: печать старшей тетрады DL и печать младшей тетрады DL.
Первая «подзадача» решается легко: нужно просто старшую тетраду переместить на место младшей и вызывать процедуру (основательно протестированную и 99,9%-но работающую) WRITE_HEX_DIGIT.
А вторая подзадача — хм… заключается в восстановлении предыдущего (мы ж тетраду перенесли) значения DL и снова — вызове WRITE_HEX_DIGIT.
(Хе! Вот теперь-то вы уж точно почувствуете всю прелесть «дробления кода на процедуры»!)

Перенос тетрады мы осуществим при помощи команды SHR, которую в умных книжках обзывают как «логический сдвига разрядов операнда вправо». Объясню.
Представьте себе деревянную доску, длинной в 8 бутылок пива и шириной в одну. (В принципе, доску эту можно и в два раза длиннее представить, но тогда на ней надо «DX» написать, мы же пока только «DL» напишем). А еще дурня, у которого на лбу SHR написано. Так вот, если этому придурку стукнуть по хребту, то он слева от доски поставит ПУСТУЮ бутылку, а остальные сдвинет на одну позицию вправо, в результате чего самая правая бутылка, ессно, с доски упадет.
Бутылки, которые сразу стояли, могут быть пустыми или полными, а вот дурень SHR — только пустые ставит. И только слева.

"исходное" 11110011
   SHR     01111001
   SHR     00111100
   SHR     00011110
   SHR     00001111

Ну тут и ежу все понятно. Четыре раза дурню по хребту надо дать, чтоб старшая тетрада на место младшей переместилась.
(На самом деле самая правая бутылка перед тем как об землю разбиться, на некоторое время в воздухе зависает, но вы пока этим голову не забивайте).
Реализовывается этот сдвиг вот как:

mov DL,11110011b
mov CL,4
shr DL,CL

В DL — наша цепочка битов. (11110011b = F3h, естественно).
В CL заносим «на сколько позиций» нам нашу цепочку сдвинуть.
Ну и SHR — это дурень, который сдвигает вправо, а слева нули дописывает.

#5. Думаете, это все?? Разогнались!! Не все так просто :).
WRITE_HEX_DIGIT у нас требует, чтобы первой тетрадой были только одни нули. Я заострял на этом ваше внимание.
При печати первой тетрады это условие соблюдается. SHR слева нули дописывает.
А вот при печати второй цифры нужно вот что: ничего никуда не сдвигая, обнулить старшую тетраду, а младшую (которая, собственно, и есть «цифра») оставить в покое.
Решим мы эту команду при помощи логической операции «и» (and по-аглицкому).

0 0 1 1
0 1 0 1
-------
0 0 0 1

А теперь и для особо одаренных:

0 and 0 = 0
0 and 1 = 0
1 and 0 = 0
1 and 1 = 1

Смотрите, интересно как получается:
Если мы AND чего-либо (нуля или единички) с 0 делаем, то у нас в результате 0, и только 0 получается.
А если AND с единичкой — то ЧТО БЫЛО, ТО И ОСТАЕТСЯ.
(Это и есть потаенный дZенский смысл команды AND)

Решение нашей проблемы (обнулить старшую тетраду, а младшую оставить без изменений) таким образом сводится к тому, что старшую тетраду нужно «AND 0», а младшую — «AND 1».
То есть значению DL с 00001111b (оно же — 0Fh) «AND» сделать.

На ассемблере это вот как выглядеть будет:

and  DL,00001111b

Естественно, 00001111b = 0Fh

Аминь!!

#6. Уфф… Вот что получиться в итоге должно:

;-[WRITE_HEX, V1]----------------------------------------
;Печатает две шестнадцатеричные цифры
;На входе: DL - типа цифры две :))
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: WRITE_HEX_DIGIT
;--------------------------------------------------------
WRITE_HEX proc
  push CX
  push DX
  mov  DH,DL                
  mov  CL,4
  shr  DL,CL
  call WRITE_HEX_DIGIT
  mov  DL,DH
  and  DL,0Fh
  call WRITE_HEX_DIGIT
  pop  DX
  pop  CX
  ret
WRITE_HEX endp

Ну че тут объяснять?? Я уже объяснил все!! Единственное, что могу добавить — это про mov DH,DL. Этой командой мы значение регистра копируем перед тем как биты «ему» сдвинуть. А потом статус-кво mov DL,DH восстанавливаем, чтоб и младшую цифру напечатать.
Все. Тестируем. Вроде должно работать.

#7. Так сказать «к вопросу о шаблонах мышления»…
Мы тут доооолго трахались с тетрадами. Вроде успешно.
Когда при тестировании понимаемости материала мы предложили пяти «подопытным» самостоятельно написать процедуру для вывода на монитор «большого» регистра (DX), они все как один начали сдвигать байты… 🙁
Народ!! Это не есть правильно!!

;-[WRITE_HEX_WORD, V1]-----------------------------------
;Печатает шестнадцатеричное слово
;На входе: DX - слово
;На выходе: нихрена
;Прерывания: ан нэту
;Процедуры: WRITE_HEX
;--------------------------------------------------------
WRITE_HEX_WORD proc
  push  DX
  xchg  DL,DH
  call  WRITE_HEX
  xchg  DL,DH
  call  WRITE_HEX
  pop   DX
  ret
WRITE_HEX_WORD endp

Команды xchg DL,DH и xchg DH,DL, кстати, работают абсолютно одинаково. Операнды просто меняются между собой значениями. В качестве одного из операндов может выступать память.

#8. Ну, и напоследок, — информация к размышлению:

Команду shr можно использовать для деления целочисленных операндов без знака на степени 2 :)).

mov     cl,4
shr     ax,cl

Думаете эти «фокусники» который в уме офигенны уравнения считать умеют, шибко умные?? Нифига!! Они просто люди ЗНАЮЩИЕ. А делить на десять и вы умеете…
Над этим я настоятельно рекомендую дооооолго помедитировать. А еще над тем, что девушки весьма и весьма любят, когда им фокусы показывают. Впрочем, это (вычисления в уме) — тема отдельная. Мы ее тоже когда-нить коснемся :). MATRIX MUST DIE!!

2.4. Печать десятичных циферек

#1. Для тех, кто медитировал над главой 1.1, алгоритм должен быть понятен как 2+2=(вписать желаемое). Кто не въедет в алгоритм WRITE_DECIMAL — научитесь сначала переводить числа между радиксами на листике в клеточку.
Итак, ставим новую задачу. В DX у нас шестнадцатеричное число. Нам нужно напечатать его на монитор в «десятичном формате». Еще раз обращаю ваше внимание на то, что существует множество способов ее решения. Мы же выбрали способ, который:
а). Вам легче всего будет понять.
б). Требует минимального количества «новых» команд.
Кто скажет, что мы не правы — пусть первый кинет камень в эхо-конференцию RTFM_Helpers.

#2. Прежде всего посмотрите на процедуру WRITE_HEX_DIGIT и вспомните, какой алгоритм положен в основу ее работы. Вспомнили? Рад за вас!!
А теперь мы познакомимся к командой деления. Что будем делить?? Ну естественно, «регистры» :).

Новая команда называется «деление беззнаковое» (DIVide unsigned).
Что такое делимое/делитель/частное, я вас грузить не буду, это в учебнике арифметики для младших классов более чем понятно расжевано. Ну во всяком случае детишки это понимают.
(Кто скажет, что 10/3=3 а остаток 333 в периоде — тот дурак. Кто скажет, что остаток будет равен 1, скажет правильно.)

Следующий кусок кода демонстрирует деление значения регистра AX на значение регистра BL.

mov ax,10d
mov bl,3d
div bl

Обратите внимание: ДЕЛИМОЕ у нас может располагаться только в регистре AX (другими словами, делимое задается неявно), а ДЕЛИТЕЛЬ — в любом регистре.
Кто не верит — посмотрите под отладчиком…

1. Если делитель размером в байт, то после операции частное помещается в AL, а остаток — в AH.
2. Если делитель размером в слово, то делимое должно быть расположено в паре регистров DX:AX (младшая часть делимого в AX). После операции частное помещается в AX, а остаток — в DX.
2. Если делитель размером в двойное слово, то делимое должно быть расположено в паре регистров EDX:EAX (младшая часть делимого находится в EAX.) После операции частное помещается в EAX, а остаток — в EDX.

Внимание, подводный камень! В следующем примере:

mov ax,10d
mov bx,3d
div bx

у вас вовсе не AX на BX делиться будет, а парочка DX:AX на BX.
Помедитируйте над этим :))

#3. А теперь, собственно, пропиваю саму процедуру вместе с традиционным расжевыванием оной:

;-[write_decimal, v1]------------------------------------
;печатает десятичное беззнаковое число
;на входе: dx - типа число
;на выходе: нихрена
;прерывания: ан нэту
;процедуры: write_hex_digit
;--------------------------------------------------------
write_decimal proc
  push ax
  push cx
  push dx
  push bx
  mov  ax,dx  ;(1)
  mov  bx,10d ;(2)
  xor  cx,cx  ;(3)
 non_zero:
  xor  dx,dx  ;(4)
  div  bx     ;(5)
  push dx     ;(6)
  inc  cx     ;(7)
  cmp  ax,0   ;(8)
  jne  non_zero
 write_digit_loop:
  pop  dx     ;(9)
  call write_hex_digit ;(10)
  loop write_digit_loop
  pop bx
  pop dx
  pop cx
  pop ax
  ret
write_decimal endp

Алгоритм простой: пока частное не равно 0, делим его, делим и еще раз делим на 10d, запихивая остатки в стек. Потом — извлекаем из стека. Вот и вся «конвертация» из HEX в BIN :). Это если в двух словах.
А если подробно, то вот что получается:
Бряк 1 — подготавливаем делимое. Как уже говорилось, оно у нас задается неявно — обязательно через AX. А параметр у нас — через DX процедуре передается. Вот и перемещаем.
Бряк 2 — это, собственно, делитель.
Бряк 3 — очищаем CX. Он у нас будет в качестве счетчика. О нем мы еще поговорим.
Бряк 4 — очищаем DX. Если не очистим, то мы не 1234h какое-нить на 10 делить будем, а 12341234h. Первое 1234 нам надо? Вот и я говорю — очищаем!
Бряк 5 — делим! Частное — в AX, остаток — в DX.
Бряк 6 — заносим остаток (DX) в стек ;).
Бряк 7 — CX=CX+1. Это мы считаем сколько раз «щемили остаток», который «щемится», по кругу (прыжок на метку non_zero), пока AX не равно 0 (бряк 8). То есть делим, делим AX, пока он не окажется таким, что делить, собственно, нечего.
Так-с… Деление закончено, число раз, которое мы поделили AX до его полного обнуления, хранится в CX.
Дальше все просто. Нам нужно такое же количество раз (CX) извлечь значение (DX) из стека. И это будет «HEX», переведенный в DEC. (Оно же: число в двоичном коде, разобранное на последовательность десятичных цифр).
Помните, как у нас организуется цикл? Через loop и CX?
Если бы в качестве счетчика мы использовали какой-нибудь другой регистр, то пришлось бы извращаться со всякими метками и прыжками… а так все просто, все продуманно :). Цикл, в теле которого ИЗВЛЕЧЬ ЦИФЕРКУ (бряк 9) и НАПЕЧАТАТЬ ЦИФЕРКУ (бряк 10). Столько же раз, сколько мы и делили наше исходное шестнадцатеричное число.

Для тех, кто не понял: бряк — это брекпоинт. Для тех, кто еще не знает, что такое брекпоинт — ищите объяснение в ‘DZebug. Руководство юZверя’

#4.Тестируем!!

testing proc
  mov  dx,12345d
  call write_decimal
  call exit_com
testing endp

Двое из десяти «подопытных» чайников (есть такие) возмутились:
В DX же только четыре циферки влазят!
Ага! Аж два раза четыре!

mov  dx,'DZ'

Как видите, туда еще и две буковки «влазят» 😉
А то и все четыре, если кавычки считать…

Медитируйте!!

2.5. Hello, world! или Изврат-2

#1. Мы уже писали программу «Hello, World!» с использованием 13-й функции 10 прерывания. Посмотрите на ее исходник…
Сегодня мы слабаем еще одно «Hello, World!», но уже несолько другим способом. Какой из этих способов более дZенский — решайте сами ;).
Для начала мы создадим блок данных после всех-всех-всех процедур но между директивами начала и конца сегмента.
Блок данных будет выглядеть следующим образом:

abc db 'Hello, World-2$'

Вы должны спросить «А почему мы хотим напечатать Hello, World-2, а в конце строки у нас 2$? $ — это что? World за баксы продавать, штоль?? Да ну вас…
Эту строчку мы будем выводить на монитор особым дZенским способом — посимвольно. То есть: возьмем первый символ из блока данных, выведем, потом второй и т. д. аналогично пока не встретим символ ‘$’.

Опять-таки, это если в двух словах. Но ведь наверняка вам этого покажется мало ;).

«Щемить» символы мы будем двумя способами. (Тут я хотел было написать «неправильным» и «правильным», но потом передумал и решил обозвать их «первым» и «вторым»).
В алгоритм первого способа вы и сами без труда въедете (я только «рабочую часть» приведу, пуши с попами сами проставляйте):

...
next:
 mov dl,[BX]
 cmp dl,'$'
 je finish
 call write_char
 inc BX
 jmp next
finish:
...

#2. А вот второй способ немножко навороченнее :). Чтобы в нем разобраться, мы сначала познакомимся со следующей группой команд: LODSB, LODSW, LODSD
Их назначение — это загрузка элемента из последовательности (строки, цепочки) в регистр-аккумулятор al/ax/eax.
Для тех, кто не понял — наша строчка «Hello, World-2$» как раз и является «последовательностью/строкой/цепочкой» из элементов размером в байт.
Адрес цепочки передается через ds:esi/si, сами «элементы» (фиксированной ширины) возвращаются в al (байт, команда LODSB), ax (слово, команда LODSW) или eax (двойное слово, команда LODSD). В общем, последние буковки команд как раз и указывают на размерность элемента: [B]yte, [W]ord, [D]ouble word. Т. е. размер мы определяем неявно.
После выполнения одной из этих команд значение регистра si изменяется на величину, равную длине элемента, но… хм…

Пришло время еще одну большую тайну познать, братья. В справочнике Юрова написано, «знак этой величины зависит от состояния флага df:
df=0 — значение положительное, то есть просмотр от начала цепочки к ее концу;
df=1 — значение отрицательное, то есть просмотр от конца цепочки к ее началу.»
Обидно, да? Флаги-то мы с вами еще не расколупали… Чего-ж дальше-то делать, а?

Как что? «Пропивать» стандартную процедуру и колупать ее, колупать, колупать, ногами ее, ногами, и по морде, по морде, по морде…

;-[write_string, v1]-------------------------------------
;печать строки символов на мониторе.
;(Строчка оканчивается символом '$'
;на входе: ds:dx - адрес строки
;на выходе: нихрена
;прерывания: ан нату
;процедуры: write_char
;--------------------------------------------------------
write_string proc
  push ax
  push dx
  push si
  pushf              ;(1)
  cld                ;(2)
  mov  si,dx         ;(3)
 string_loop:
  lodsb              ;(4)
  cmp  al,'$'        ;(5)
  jz   end_of_string ;(6)
  mov  dl,al         ;(7)
  call write_char    ;(8)
  jmp  string_loop   ;(9)
 end_of_string:
  popf               ;(10)
  pop  si
  pop  dx
  pop  ax
  ret
write_string endp

Итак, что делает команда lodsb, вы уже поняли. А вот с df=0/df=1 пока что непонятки.
Будем разбираться.

Нам нужно, чтобы «просмотр цепочки» осуществлялся командой lodsb слева направа, для этого нужно установить значение df=0. Делаем мы это при помощи команды cld (бряк 2).
Если нам нужно, чтобы «просмотр цепочки» осуществлялся справа налево, мы используем команду std. (Можете попробовать. Ерунда получится.)

А теперь вспомним «золотое правило». Все регистры, которые мы изменяли ВНУТРИ процедуры, «на выходе» должны восстанавливать свои ПРЕДЫДУЩИЕ значения (кроме тех регистров, через которые мы возвращаем РЕЗУЛЬТАТ).
Так вот: то же самое касается и флагов, которые мы изменяем.
Для регистров мы использовали команды PUSH и POP. Для регистра флагов (изменять у которого мы можем только БИТЫ) используются команды pushf (записать в стек значения флагов, бряк 1) и popf (извлечь из стека, бряк 10).

ПРИМЕЧАНИЕ: Это несколько вольное положение (хотя, вобщем-то, верное). Видимо, здесь следует хотя бы добавить, что сохранять/восстанавливать флаги нужно при изменении специальных флагов (if, df). (С) Хемуль

Теперь колупаем дальше… (С) Serrgio

Бряк 3. Мне удобнее передавать данные «в процедуру» через регистр DX, о чем и написано в заголовке. А lodsb (бряк 4) хотит, чтоб адрес ему в SI подавали. Удовлетворим его желание 🙂
Бряк 4. После выполнения этой команды ASCII-код первого символа «цепочки» «Hello, World-2$» помещается в AL, а значение регистра SI увеличивается на 1 и указывает теперь на второй элемент цепочки (символ ‘e’).
Бряк 7. Удовлетворяем «пожелания» процедуры write_char. Из AL переносим в DL и печатаем (бряк 9) write_char’ом.

Тэкс… Мы пропустили бряки 5 и 6-й…
Смотрите: на бряке 9 мы «безусловно» зацикливаем «извлечение» и печать элементов цепочки. Безусловно — это значит до потери пульса. Чтоб этого не произошло, каждый из элементов цепочки мы сравниваем с символом-конца-цепочки (в нашем случае это ‘$’, но можно использовать и любой другой). Если текущий символ равен символу-конца-цепочки, то выпрыгиваем (бряк 6) из этого безусловного цикла, восстанавливаем статус-кво (бряк 10) и все на этом…

#3. Тестируем!!

testing proc
  lea dx,abc
  call write_string
  call exit_com
testing endp

Напечаталось то, что надо?
Если true — читайте дальше.
Если false — расколупывайте и медитируйте до полного просветления…

Диагноз

Когда-то автор этого текста хотел осветить все вопросы низкоуровневого программирования, начиная от процессора 8086 до p3, от DOS’а до W2000 и линуха. Однако, практика показала, что при таких черепашьих темпах написания… к тому времени когда дойдет очередь хотя бы до w3.11 — windows-2000 уже станет вчерашним днем :(. Поэтому этот туториал, мягко говоря, не совсем завершен ;).

[C] Serrgio / HI-TECH

Источник WASM.RU


Поделиться в соц сетях

Подписаться
Уведомить о
0 комментариев
Старые
Новые
Межтекстовые Отзывы
Посмотреть все комментарии

Есть идеи, замечания, предложения? Воспользуйтесь формой Обратная связь или отправьте сообщение по адресу replay@sciencestory.ru
© 2017 Истории науки. Информация на сайте опубликована в ознакомительных целях может иметь ограничение 18+