Отправляет email-рассылки с помощью сервиса Sendsay
  Все выпуски  

Пишем свою операционную систему. Основы Assembler


Отзывы подписчиков после предыдущего выпуска показали, что я ошибался, насчёт уровня знания Assembler моей аудиторией. Поэтому я немного нарушаю свои планы и в этом выпуске расскажу об основах программирования на Assembler, а о материал про загрузчик будет в следующем выпуске.

Те, кто уже и так не плохо умеет программировать на Assembler могут пропустить этот выпуск. Я не претендую я полное изложение всех аспектов, какой-то материал может быть намеренно упрощён или даже искажён для простоты понимания. Так что знающим людям не следует писать мне гневные письма "у тебя в рассылке ошибка!" - всё так и запланировано. Информации приведённой ниже должно быть достаточно для понимания работы загрузчика из второго выпуска. По мере разработки я буду дополнять выпуски новой информацией. Мы не планируем всей ОС целиком на Assembler, а того что я скажу вполне хватит.

Также, я не планировал рассказывать про языки программирования, а сконцентирироваться на разработке ОС, поэтому, возможно, выпуск получился немного странным по формулировкам и фразам. Приношу свои извинения. Как впрочем и за возможные орфографические и пунктуационные ошибки - текст получился просто огромным...

Assembler - достаточно сложный язык для новичков, но в деле осеписательства без него совсем обойтись нельзя. Не пугайтесь, потом будет проще :-) 

 Память. Сегменты. Регистры

 Сейчас мы будем рассматривать реальный режим работы процессора. То есть самый старый для процессоров x86. Начинаем с него, потому что загрузчик после BIOS попадаёт именно в него, а все остальные переходы совершаются уже специальными командами. Когда придёт время переводить процессор в защищённый режим, я дополню сведения об Assembler. 

Память представляет собой массив байт. Отсчёт ведётся от нуля. Доступ к памяти возможен на уровне байтов (8 бит), слов (16 бит), двойных слов (32 бита, доступно только на процессорах, поддерживающих защищённый режим). При этом доступ осуществляется быстрее, если переменная выровнена на свой размер. То есть, при обращении к слову лучше, если адрес кратен 2 байтам, к двойному слову - 4 байтам. В отличии от некоторых других архитектур, это лишь рекомендуемое, но не обязательное условие и к переменной любого размера можно обращаться, даже если её адрес не кратен ничему. Адреса принято записывать в 16-ричной системе счисления, потому что очень часто они выравнены на какую-нибудь степень двойки и выглядят более наглядно.

У процессора есть своя внутренняя память - регистры. Это небольшая, но очень быстрая память. Она предназначена для хранения состояния процессора (например, адрес выполняемой в данной момент инструкции, текущий режим процессора и т. д.) и для хранения программой промежуточных результатов вычислений. В отличии от обычной памяти, для обращения к регистрам используется не адрес, а имя. Да и обратиться к произвольной части регистра нельзя (например, нельзя взять 3-ий байт 32-битного регистра - только весь регистр целиком, а потом с помощью логических операций выделить часть).

У процессора 8086 (все его потомки унаследовали эти регистры, хотя добавили и свои, новые) все регистры делятся на регистры данных, сегментые регистры и указатель команд (IP - Instruction pointer), последний недоступен программно. Все регистры имеют размер 16 бит. Начнём с регистров данных - их 8 штук: AX, BX, CX, DX, SI, DI, SP, BP. Первые четыре регистра позволяют обращаться к своим 8-битным половинкам - для AX это AH (старшие 8 бит) и AL (младшие 8 бит), для BX - BH и BL, для CX - CH и CL, для DX - DH и DL. То есть, например, если мы поместим в AX число 0x55AA, то в AH будет 0x55, а в AL в 0xAA.

Регистры SI, DI ещё иногда называют "индексные", BX, SP и BP - "регистры-указатели".  4 регистра (кроме SP, но он тоже по сути используется для адресации памяти) обладают одной особенностью - они позволяют хранить не только данные, но и адреса. То есть, процессору можно сказать не только "прочитать слово из памяти по адресу 0x1234 и поместить в регистр AX", но и "прочитать слово из памяти по адресу из регистра BX и поместить в регистр AX". Во втором случае адрес не известен на этапе компиляции, а может вычисляться на ходу. SP - необычный регистр-указатель. Его нельзя использовать (в реальном режиме, в защищённом можно) для адресации в командах, но сам процессор использует его для поддержки аппаратного стека - в SP хранится указатель на его вершину.

Как я уже сказал выше, все регистры имеют размер 16 бит, то есть с их помощью получится адресовать лишь 64 КБ (2 ^ 16 байт) памяти. Это слишком мало, поэтому были введены сегментые регистры, которые расширяют адресуемое пространство до 1 МБ (этих проблем нет в защищённом режиме, где ширина адреса 32 или 64 бита на самых новых системах). Теперь адрес вычисляется путём сдвига значения сегментного регистра на 4 бита и прибавления значения адреса (смещения). Это можно записать в виде формулы: адрес = сегмент * 16 + смещение. В итоге получается ширина адреса 20 бит, что и даёт 1 МБ адресуемой памяти.

У 8086 есть 4 сегментные регистра (в последствии были добавлены ещё 2, но они нам не нужны) - CS, DS, ES, SS.

CS в паре с IP используется для выбора текущей инструкции для исполнения. Так же как и IP, он не доступен для программной записи (изменение значения этих регистров может происходить только в ходе естественного выполнения программы, либо команды перехода на другой адрес), хотя в отличии от него доступен для чтения. DS - регистр сегмента данных. Большинство команд подразумевают его использование для получения сегментной части адреса, если не указано иное. ES - альтернативный сегмент данных. Два сегментных регистра данных упрощают адресацию, позволяя реже изменять значения сегментов. SS в паре с SP служит для адресации вершины стека.

Ещё у процессора 8086 есть регистр флагов - FLAGS. Он недоступен для прямого чтения и записи, но может влиять на исполнение некоторых команд, также как и некоторые команды могут изменять его значение.

Несколько простых команд 

Команды ассемблера начинаются с кодового названия команды длиной от 2 до 5 букв. Затем может следовать от 0 до 3 (3 встречается очень редко) аргументов. Количество аргументов и допустимые значения (константа, адрес, регистр) зависят от команды.

Пожалуй, самая часто используемая команда - MOV (MOVe) - пересылки значения из одного места в другое. Можно провести аналогию с командой присваивания из языков высокого уровня. Пример использования:

mov ax, 0x1234      ; AX :=
0x1234
mov dx, ax          ; DX := AX
mov ax, [0x1234]    ; Поместить в AX слово из памяти по адресу DS:0x1234 (DS используется для данных по умолчанию)
mov [es:0x1234], dl ; Поместить младший байт DL в память по адресу ES:0x1234
mov word[0x1234], 1 ; Поместить единицу в слово памяти по адресу DS:0x1234
                    ; (явное указание размера byte или word нужно потому, что без регистра компилятор не может угадать какой размер мы имеем ввиду) 

Как можно заметить с помощью точки с запятой в Assembler отделяются комментарии. Прямая работа с сегментными регистрами (то есть, не для адресации в других командах) ограничена - с ними могут работать только команды работы с аппаратным стеком и mov. Причём последний в качестве второго аргумента может иметь только регистр (нельзя сохранять или восстанавливать сегментные регистр прямо из памяти или же присваивать ему непосредственное значение).

Перечислю ещё несколько простых команд:

add ax, bx ; AX := AX + BX
sub dx, cx ; DX := DX - CX
inc si     ; SI := SI + 1
dec di     ; DI := DI - 1
or ax, bx  ; AX := AX or BX
and ax, bx ; AX := AX and BX
xor ax, bx ; AX := AX xor BX
xor cx, cx ; CX := 0 - одна из простейших оптимизаций. В отличии от mov cx, 0 эта инструкция занимает лишь 1 байт.
           ; Очевидно, что результат исключающего ИЛИ числа с самим собой равен нулю.
not dx     ; DX := not DX 

В качестве операндов каждой из этих инструкций могут выступать 16- и 8-битные регистры, непосредственные значения (например, add ax, 10), адреса памяти (например, add [0x1234], 10 или add ax, [es:0x4321]) с соблюдением одного условия - в команде может быть только одно явное обращение к памяти. То есть нельзя, например, складывать значение из памяти со значением из памяти и т. д. Это ограничение распространяется на подавляющее большинство команд процессора x86 (включая и вышеописанный MOV), поэтому я не буду о нём больше специально напоминать при описании команд. Если действие с двумя аргументами из памяти всё же необходимо, один из них придётся временно скопировать в какой-нибудь регистр.

Адрес памяти не обязательно должен быть жёстко задан, его можно вычислять во время работы программы. Допустимо сложение непосредственного значения, одного регистра-указателя и одного индексного регистра. То есть в самой длинной форме адрес может выглядеть как-то так:

mov byte[ds:0x1234
+ bx + si], 0
mov word[es:0x1234 + bx + di], 1
mov [ss:0x1234 + bx + bp], ax

Любая из частей адреса может отсутствовать, то есть для адресации можно использовать лишь какой-нибудь один из регистров, а то и вообще ничего к его значению не прибавлять.

Переходы. Ветвления. Циклы

Для безусловного перехода есть специальная команда - jmp (JuMP). Она принимает один аргумент - адрес, куда надо перейти. Адрес может быть без указания сегмента - в таком случае CS не меняется, так и с указанием, тогда этот переход называется "дальним" и меняется вся пара CS:IP. В качестве адреса перехода может использоваться непосредственное значение, регистр-указатель, индексный регистр или переменная в памяти. В случае использования значения из регистра, переход возможен только ближний (в пределах сегмента), потому что размер регистра лишь 16 бит.

jmp 0x1234   ; Переход к адресу CS:0x1234
jmp bx       ; Переход к адресу CS:BX
jmp 0:0x7C00 ; CS := 0, IP := 0x7C00
jmp word[es:0x7E00 + bx + si] ; Адрес куда следует прыгать хранится в 16-битной переменной

Переходы бывают не только безусловные, но и условные. То есть, выполняющиеся лишь в определённом состоянии процессора. Это позволяет реализовывать ветвления и циклы. Команды условного перехода анализируют значение регистра FLAGS. Обычно команды перехода используются после инструкции CMP (CoMPare - сравнить), которая сравнивает два числа. Эта инструкция принимает два аргумента и производит вычитание одного из другого. После этого некоторые биты регистра флагов (флаг нулевого результата, флаг отрицательного результата, флаг переноса, флаг переполнения) изменяют своё значение. Анализируя их можно сделать вывод об отношении чисел, которые мы сравнили.

jz, je - Установлен флаг нулевого результата, в результате арифметической операции получился ноль. В случае cmp это значит, что числа равны.
jb - При беззнаковом сравнении означает, что второй аргумент больше первого.
ja - При беззнаковом сравнении означает, что второе аргумент меньше первого.
jl - При знаковом сравнении означает, что второй аргумент больше первого.
jg - При знаковом сравнении означает, что второй аргумент меньше первого.
jc - Установлен флаг переноса.
jo - Установлен флаг переполнения.
js - Установлен флаг отрицательного результата. 

Действие операции можно изменить на противоположное добавив N после J - jne (переход, если не равно), jnz, jnb, ja, jnl, jng, jnc, jno. Также можно комбинировать 2 условия - jae (переход, если больше или равно), jbe, jle и т. д.

В качестве аргумента команды условного перехода можно использовать только непосредственное значение.

Изменяет значение регистров флагов не только cmp, но и практически все арифметические и логические операции (add, sub, inc, dec, and, or, xor и т. д.; а вот mov, кстати, не изменяет, как и операции работы со стеком). Это следует учитывать как с положительной (можно проанализировать результат выполнения операции по флагам), так и с отрицательной (между cmp и условным переходом нельзя выполнять многие операции, потому что они затрут результат сравнения).

Существует команда test, которая выполняет проверку битов. По сути это and, только без сохранения результатов. Например, test ax, 0xF проверит установлен ли в регистре AX хоть один из 4 младших битов. Его же используют для очередной оптимизации - вместо cmp ax, 0, можно написать test ax, ax. Если AX = 0, то флаг нулевого результата будет установлен и сработает переход jz/je, а такая команда занимает лишь 1 байт.

Метки. Описание данных

Разумеется, вычислять все адреса вручную было бы слишком сложно, поэтому это вполне естественно переложить на компилятор.

Для создания метки надо записать сначала её идентификатор, а потом символ двоеточия. После этого может сразу же следовать команда, а может и не следовать (для читабельности).

Метки можно использовать в любом месте, где ожидается адрес в памяти. Ассемблер вычисляет все адреса и заменяет имена меток на непосредственные значения на этапе компиляции - процессор ничего о метках не знает.

        mov di, 0x100
mov ax, 0x55AA fill_memory: mov [di], ax add di, 2 cmp di, 0x200 jb fill_memory

Пример выше заполняет память от адреса DS:0x100 до адреса DS:0x200 словами 0x55AA. Таким образом это простейший цикл. С помощью меток и условных переходов можно проверять условия любой сложности.

Для описания данных служат псевдоинструкции db, dw, dd и dq (define byte, define word, define dword, define qword). Эти команды не обозначают никаких инструкций процессора, а лишь говорят компилятору поместить в этом месте без какой-либо обработки указанные через запятую значения. Инструкция может претворятся меткой, чтобы задать имя для переменной. При этом двоеточие не обязательно и даже не желательно (если его не использовать, то flat assembler сможет запомнить размер переменной и при использовании в других командах не придётся его явно указывать). Количество аргументов может быть от 1 до бесконечности (это не обычная инструкция). Все они будут просто скопированы в выходной файл. В случае объявления байтовой переменной можно указать в качестве значения строку в кавычках, вся она будет записана в выходной файл. Примеры:

number dw 0xB800
string db "Hello world!",13,10,0
byte_array db 1,2,3,4,5,6
word_array dw 1,2,3,4
 ...
        mov ax, [number]
        mov [number], 0 ; Размер переменной известен. Указывать word не обязательно.
        mov cl, byte[number] ; Но если мы захотим выполнить приведение типов, то можно указать другой размер.
        mov si, string ; А тут мы записываем в SI не значение переменной, а её адрес
        mov al, [byte_array + bx] ; Получаем из массива элемент с индексом BX

Есть специальные псевдо-инструкции, служащие лишь для резервирования места, но не описания самих данных. В некоторых ситуациях они позволяют сьэкономить место в исполняемом файле. По умолчанию значения этих переменных равных нулю. Это инструкции rb (reserve byte), rw (reserve word), rd (reserve double word) и rq (reserve quad word). В качестве аргумента они принимают количество элементов, место для которых надо зарезервировать. Например, rw 100 создаст пустой массив из 100 слов. Для одиночных переменных такого же поведения можно добиться, если вместо значения указать знак вопроса. 

Все метки, которые мы описывали выше были глобальные, а бывают ещё и локальные. Их имя начинается с точки. В этом случае к ним можно обращаться напрямую во всём блоке до описания следующей глобальной метки, а вне блока надо писать ИмяГлобальнойМетки.ИмяЛокальнойМетки. Например:

global_label:
       .local_label db 1
       .addr dw .local_label
next_global_label:
       dw global_label.local_label

Все переменные видимы как после их описания, так и до.

Существует специальный вид меток, которые являются глобальными, но никак не влияют на локальные. Имена этих меток начинаются с двух точек. 

Чтобы вычислить адреса меток необходимо знать, где в памяти будет располагаться код программы. Часто программа располагается в памяти далеко не с нулевого адреса. Чтобы задать базовый адрес программы служит псевдоинструкция org.

label1: ; label1 = 0
org 0x1000
label2: ; label2 = 0x1000
dw 0
label3: ; label3 = 0x1002

Менять базовый адрес можно несколько раз, если того требует особенности выполнения приложения, но важно помнить - инструкция org меняет лишь базовый адрес для вычисления адресов меток, при этом данные в файле никуда не перемещаются. Хотя в памяти между label1 и label2 должен быть пробел в 4 килобайта, в файле эти метки будут расположены вплотную. Правильно разместить код в памяти - задача загрузчика приложения (например, BIOS загружает наш код загрузчика по адресу 0:0x7C00 и мы можем на это рассчитывать).

Кстати, команды условных и коротких безусловных переходов являются относительными (типа "прыгнуть на 10 байт вперёд"), поэтому переходы в рамках одного сегмента будут работать вне зависимости от базового адреса (главное, чтобы метки располагались в пределах одного org), по которому он действительно загружен.

Также, flat assembler объявляет пару псевдометок (их значение зависит от того в каком месте они используются) - $ и $$. Первая всегда равна текущему адресу (то есть jmp $ - бесконечный цикл, каждый раз прыжок будет совершаться на эту же команду), вторая - последнему заданному с помощью org базовому. 

Стек

Процессор предоставляет доступ к аппаратному стеку. Для работы с ним служат две команды - push и pop. Обе они имеют один аргумент, который может быть чем-угодно (константа, регистр, сегментные регистр, адрес памяти). Первая команда уменьшает значение SP на 2 и записывает аргумент по адресу SS:SP. Вторая команда наоборот, копирует значение из SS:SP в свой аргумент и увеличивает значение SP на 2. Помимо прочего эти команды позволяют менять значения сегментных регистров без использования основных.

push ax
mov ax, 10
pop ax
 ...
push ds
pop es ; ES := DS
 ...
push 0xB800
pop es ; ES := 0xB800
 ...
push [variable]
pop [other_variable]

Как можно заметить, указатель стека всегда выровнен на 2 байта. Поэтому нет смысла только ради экономии памяти помещать туда байт место слова. Это имеет смысл, только если приёмник имеет однобайтовый размер. 

Подпрограммы

Процессор на аппаратном уровне поддерживает использование подпрограмм. Для этого есть две команды - call и ret. Первая помещает в стек адрес следующей инструкции и выполняет безусловный переход по адресу из аргумента. Вторая извлекает из стека адрес возврата и выполняет переход по нему. Таким образом откуда бы не был произведён вызов подпрограммы выполнение вернётся в вызывающий код.

procedure:
        ...
        ret
 ...
        call procedure
        call procedure

Если аргументом call является адрес с сегментной частью, то в стек запихивается не 1 слово, а 2 (не только IP, но и CS). Это называется "дальним вызовом". Подпрограмма, которую так вызывают должна использовать не ret, а retf, который умеет возвращаться по такому длинному адресу возврата. Таким образом "дальние" и "ближние" подпрограммы несовместимы между собой и программист должен помнить на какой тип вызова рассчитана каждая его процедура или функция.

Ближние вызовы как и переходы могут быть относительными и корректно работать даже если базовый адрес кода в памяти неизвестен.

Строковые операции

 Процессор x86 обладает достаточно широким набором команд. На мой взгляд стоит рассмотреть одно из их подмножества - "строковые". Эти команды предназначены для работы с массивами данных.

Опишу несколько команд:

lods - Загрузка байта/слова из памяти по адресу DS:SI в AL/AX. Увеличение SI на размер элемента
stos - Сохранение байта/слова из AL/AX в память по адресу ES:DI. Увеличение DI на размер элемента.
movs - Копирование байта/слова из DS:SI в ES:DI. Увеличение SI и DI на размер элемента.
cmps - Сравнение байта/слова из DS:SI и ES:DI. Увеличение SI и DI на размер элемента. Регистр флагов изменяется аналогично команде cmp.
scas - Сравнение байта/слова из ES:DI с AL/AX. Увеличение DI на размер элемента. Регистр флагов изменяется аналогично команде cmp.

Каждая строковая команда имеет постфикс размера - b (byte), w (word). Например, lodsb, movsw и т. п.

Совместно с этими командами часто применяют префиксы повтора операции - rep, repe, repne. Эти префиксы позволяют повторить следующую за ними строковую операцию CX раз (каждую итерацию CX будет уменьшаться на единицу, пока не достигнет нуля. если CX равен нулю изначально, то команда не выполнится ни разу). repe помимо основного условия может прервать выполнение, если после выполнения флаг нулевого результата не будет установлен ("выполнять пока равно"), а repne в противоположной ситуации (выполнять пока не равно).

Например, с помощью rep movsw можно быстро скопировать CX слов из DS:SI в ES:DI. С помощью других префиксов можно также выполнять сравнение строк (repe cmpsb), поиск символа в строке (repne scasb). Эти операции будут выполняться значительно быстрее, чем простой цикл с помощью mov, dec, cmp и jmp, да и выглядят очень наглядно.

Разбор фрагмента загрузчика и заключение 

Рассмотрим, подпрограмму нашего загрузчика из предыдущего выпуска:

write_str:
	push ax si
	mov ah, 0x0E
 @:
	lodsb
	test al, al
	jz @f
	int 0x10
	jmp @b
 @:
	pop si ax
	ret

Перед разбором, стоит обратить внимание на @@ (в предыдущем выпуске Subscribe покорёжил код и заменил две "собаки" на одну) - это так называемая анонимная метка. Их можно описывать сколько угодно в одной программе (если описать две обычных метки с одинаковом именем, то будет ошибка). @f ссылается на ближайшую анонимную метку дальше по коду, @b - на ближайшую предыдущую. Глобальные метки никак не влияют на видимость анонимных, а анонимные не ограничивают видимость локальных, как глобальные.

Первая строка объявляет глобальную метку для подпрограммы, которая позволяет обратиться к ней из любой точки программы. Вторая и предпоследняя строка обеспечивает сохранение и восстановление регистров AX и SI, которые будут затронуты кодом процедуры. flat assembler позволяет записать сколько угодно аргументов для push и pop через пробел - в итоге будет сгенерировано по одному push/pop на каждый аргумент. Это позволяет сэкономить строчки программы. Заметьте, что при восстановлении регистров из стека я указываю их в обратном порядке, потому что действует правило стека "первым пришёл - последним ушёл".

В третьей строке я  записываю значение 0x0E в старший байт AX. Далее между двумя анонимными метками выполняется цикл по всем символам строки. Первая команда цикла подгружает очередной символ из строки. Вторая проверяет не равен ли он нулю (ноль у нас - признак конца строки). Если равен, то третья выведет нас из цикла. И наконец команда безусловного перехода повторяет цикл вновь.

Последняя команда выполняет возврат из процедуры в вызывающую программу. Поскольку мы используем ret, а не retf нашу подпрограмму можно вызывать только из того же сегмента кода (но нам большего и не нужно в загрузчике, а ближние вызовы занимают меньше места).

Пусть разбор остальных частей загрузчика будет вашим домашним заданием. В случае возникновения трудностей задавайте вопросы ;-)

На этом этот выпуск рассылки можно считать завершённым. До встречи! 


В избранное