Урок ОК03

Начальная страница курса

Урок ОК03 основан на уроке ОК02 и рассматривает как использовать функции в ассемблере чтобы использовать код повторно и придать ему большую читабельность. Предполагается, что у Вас есть код Урока 2 ОК02.

1. Повторное использование кода

До сих пор мы писали код нашей операционной системы вводя, то что мы хотим в нужном порядке. Это нормально для крошечных программ, но для больших программ код станет совершенно не читаемым. Вместо этого мы будем использовать функции.


Функция — это кусочек кода, который может быть использован несколько раз для получения какого либо результата или проведения какого либо действия. Вы могли слышать, что их также называют процедурами (procedures, routines) или подпрограммами (subroutins). Хотя все это разные вещи, люди редко используют правильный термин.

Вы наверное уже знакомы с понятием функции в математике. Например функция косинуса дает число в диапазоне от -1 до 1 — значение косинуса входного угла. Мы используем нотацию cos(x) для обозначения функции косинуса примененной к значению x.

В коде, функции могут принимать множество входных параметров (а также ни одного) и возвращать множество выходных (а также ни одного). Например функция может создавать файл в файловой системе с именем на основе первого параметра и длиной на основе второго.


В высокоуровневом коде (например на С или С++), функции — часть языка. В ассемблере, функции — это просто идея (подход) который у нас есть.

В идеале мы хотим иметь возможность установить в регистры значения входных параметров, перейти по адресу функции и ожидаем, что в некотором месте кода произойдет переход обратно после того как будут установлены значения выходных параметров в регистрах. Это то чем является функция в коде ассемблера. Трудности начинаются при выборе системы установки регистров. Если мы будем использовать любую систему, каждый программист может использовать другую систему и таким образом работу другого программисту будет сложно понять. Кроме того компиляторы не могут работать с кодом на ассемблере если они не будут знать как работать с функциями. Чтобы избежать путаницы был предложен стандарт Application Binary Interface (ABI, бинарный интерфейс приложения), который является договоренность как должны быть запущены функции. Если все делают функции с одинаковым интерфейсом, то каждый сможет использовать функции друг друга. Я буду преподавать этот стандарт в курсе и с текущего момента весь код моих функций будет соответствовать стандарту.

Стандарт говорит нам, что r0,r1,r2 и r3 будут использованы как входные параметры функции. Если функция не требует входных параметров, значит их значения не имеют значения. Если нужен только один параметр, он всегда передается через r0, если два — первый передается в r0, а второй в r1 и так далее. Выходной параметр всегда передается в r0. Если функция не имеет выходного параметра значение r0 не важно.

Кроме того, требуется чтобы после выполнения функции значения регистров r4-r12 были такими же как до выполнения. Это значит, что когда вы вызываете функцию, вы можете быть уверены в том, что после её выполнения значения r4-r12 не изменятся, но Вы не можете ждать того же от r0-r3.

Когда функция завершается она должна выполнить переход назад к коду, который её вызвал. Это значит, что она должна знать адрес кода, который её вызвал. Для этого предназначен регистр lr (link register), который всегда содержит адрес инструкции следующей после той, что вызвала данную функцию.

Использование регистров согласно ARM ABI
r0 и r1 используются для передачи первых двух аргументов в функцию и возврата результата из неё. Если функция не использует их для возврата результата они могут принимать любое значение.
r2 и r3 используются для передачи второй пары аргументов. После выполнения функции в этих регистрах может быть что угодно.
r4 – r12 используются для хранения промежуточных значений в программе. Их значения после выполнения функции должны быть такими же как и до этого.
lr – адрес возврата после выполнения функции.
sp – указатель на вершину стека. Значение должно быть тем же после выполнения функции.

Часто функции требуют больше регистров чем r0-r3. Из-за того что r4-r12 должны сохранить значение после выполнения функции они должны быть где-то сохранены. Мы сохраняем их в место под названием стек.


Стек (stack – пачка, стопка англ.) — метафора, которую мы используем для метода хранения значений. Также как и в стопке тарелок, вы можете выбирать/добавлять значения только из/в вершину.
Стек превосходная идея для хранения значений регистров при выполнении функции. К примеру если у меня есть функция, которой необходимо использовать регистры r4 и r5, она может поместить текущее значение этих регистров в стек. А в конце выполнения функции она может получить их обратно из стека. Самое приятное, что если из моей функции должна быть выполнена другая функция и она таким же образом сохранит значения регистров в стек, а по окончании выполнения восстановить значения регистров. Т.е. это не повлияет на значения регистров r4 и r5 которые сохранила моя функция. Ведь новые значения будут помещены в вершину стека, а потом извлечены из неё.

Из-за того что стек так полезен он был реализован непосредственно в наборе инструкций ARMv6. Специальный регистр sp (stack pointer) хранит адрес вершины стека. Когда в стек добавляется значение, регистр sp обновляется, таким образом, что он всегда хранит адрес первого значения в стеке. push {r4, r5} поместит значения из регистров r4 и r5 в стек, а pop {r4, r5} извлечет значения обратно в регистры (в правильном порядке).


2. Наша первая функция

Теперь мы имеем некоторое представления о том, как работают функции. Давайте попробуем сделать одну. Для примера сделаем функцию, которая не принимает параметров, а возвращает адрес GPIO. В последнем уроке мы просто записали это значение, но было бы лучше получать его из функции, т. к. это значение нам скорее всего будет часто необходимо в настоящей операционной системе, а мы не всегда сможем его помнить.
Скопируйте следующий код в файл с названием gpio.s. Просто создайте новый файл в директории с main.s. Мы поместим все функции связанные с GPIO в один файл, чтобы их можно было легко найти.

.globl GetGpioAddress
GetGpioAddress:
ldr r0,=0x20200000
mov pc,lr

Это простейшая завершенная функция. Команда .globl GetGpioAddress — это сообщение ассемблеру, сделать метку GetGpioAddress доступной из всех файлов. Это значит что в нашем main.s мы можем сделать ветвление к этой метке даже если её нет в данном файле.
Вы должны понимать что команда ldr r0,=0x20200000 загружает адрес GPIO контроллера в регистр r0. Т.к. это функция мы должны поместить значение в регистр r0, т.е. мы уже не так свободны как ранее.
mov pc,lr копирует значение в lr в pc. Как было отмечено ранее lr всегда содержит адрес кода к которому мы должны вернуться по окончании функции. pc — это специальный регистр, он содержит адрес следующей инструкции, которая будет выполнена. Команда ветвления просто меняет значение в этом регистре. Копируя значение lr в pc мы изменяем следующую строчку которая будет выполнена на ту к которой мы должны вернуться.

Разумным вопросом сейчас будет: как собственно запустить этот код? Специальная инструкция bl делает все что нам нужно. Она переходит к метке как и обычная команда ветвления, но перед тем как сделать это она обновляет значение lr. Это значит что после окончания выполнения функции будет выполнена строка после инструкции bl. Получается что функция работает также как и любая другая команда, выполняется и переходит к следующей строке. Это действительно полезный способ думать о функциях. Мы рассматриваем их как «черный ящик», затем мы выполняем их и нам не нужно думать о том как они работают. Мы только должны знать какие входные параметры им нужны и какие выходные они вернут.

Сейчас не переживаете об использовании функций. Мы будем использовать их в следующем разделе.

3. Большая функция

Сейчас мы создадим функцию побольше. Нашей первой задачей было включить выход GPIO 16. Было бы неплохо чтобы это была функция. Мы можем указать ножку и функцию как входные параметры функции и она установит нужную функцию для нужной ножки. Таким методом мы можем управлять любым GPIO пином, а не только светодиодом.

Скопируйте этот код в файл gpio.s под код функции GetGpioAddress.

.globl SetGpioFunction
SetGpioFunction:
cmp r0,#53
cmpls r1,#7
movhi pc,lr

Первым делом при создании функции нужно думать о её параметрах. Что делать если они не верны? В данной функции один входной параметр — номер ножки GPIO, таким образом он должен принимать значение от 0 до 53, т.к. есть 54 ножки. У каждой ножки есть 8 функций, пронумерованные от 0 до 7, т.е. код функции должен быть таким же. Мы можем просто предположить что входные параметры верны, но это очень опасно при работе с железом, т.к. некорректные значения могут привести к очень плохим побочным эффектам. Поэтому, в данном случае, мы хотим быть уверены, что входные параметры имеют нужные пределы.

Для этого нужно проверить что r0 <= 53, а r1 <= 7. Прежде всего мы используем сравнение, которые мы уже видели ранее, для сравнения r0 с 53. Следующая инструкция cmpls это инструкция сравнения, которая будет выполнена если r0 был меньше или равен 53. Если это так то сравнивается r1 и 7, иначе результат сравнения такой же как и ранее. В итоге мы возвращаемся к коду вызвавшему функцию если результат сравнения говорит, что в регистрах значения больше указанных чисел.

Результат этих действий — это именно то, чего мы хотим. Если значение r0 больше 53, то команда cmpls не выполниться, а movhi выполнится. Если же значение меньше или равно 53, то команда cmpls выполниться, т.е. произойдет сравнение r1 и 7. Если r1 больше 7 инструкция movhi будет выполнена и функция будет завершена. В противном случаем мы наверняка будем знать, что r0 <= 53 и r1 <= 7.

Есть небольшая разница между суффиксами ls (lower or same) и le (less or equal) также как между hi (higher) и gt (greater), но мы рассмотрим это позже.

Скопируйте эти сроки под предыдущие.

push {lr}
mov r2,r0
bl GetGpioAddress

Эти строки вызывают нашу первую функцию. push {lr} копирует значение lr в вершину стека, откуда мы сможем получить их позже. Мы должны проделать это перед вызовом GetGpioAddress, т.к. нам нужно будет использовать регистр lr для того чтобы вернуться в эту функцию.

Если мы не знаем ничего о функции GetGpioAddress, то должны предполагаем что она использует регистры r0, r1, r2 и r3 и должны переместить наши значения в регистры r4, r5 чтобы сохранить их после выполнения функции. К счастью мы знаем эту функцию. Мы знаем что она только меняет r0, записывает в него адрес, и не меняет остальные регистры. Тем не менее необходимо переместить значение пина из регистра r0, чтобы избежать его перезаписи. Мы также знаем что можем безопасно сохранить его в регистре r2, т.к. функция GetGpioAddress не перезаписывает значение в нем.

Наконец мы используем инструкцию bl для запуска GetGpioAddress. Обычно мы используем термин call (вызов) для запуска функции, и я теперь буду. Как обсуждалось ранее bl вызывает функцию обновляет значение lr.

Когда функция завершается мы говорим она «вернула результат» (returned). По завершении функция GetGpioAddress возвращает адрес GPIO. Таким образом по завершении в регистре r0 адрес GPIO, r1 — код функции, r2 — номер GPIO вывода. Я упоминал ранее, что функции GPIO размещены блоками по 10 пинов, итак сперва нам нужно проверить в каком именно блоке нужная нам функция. Звучит как будто это задача для которой нужно использовать деление. Но это очень медленная операция, для маленьких чисел лучше использовать вычитание.

Скопируйте этот код.

functionLoop$:
    cmp r2,#9
    subhi r2,#10
    addhi r0,#4
    bhi functionLoop$

Этот простой цикл сравнивает номер пина с 9. Если он больше 9 вычитает из него 10 и добавляет 4 к адресу GPIO контроллера, после чего повторяет проверку.

В итоге в регистре r2 содержится остаток от деления номера пина на 10. r0 содержит адрес выбора функции пина. Это значение тоже что и адрес GPIO контроллера + 4 × (номер вывода GPIO ÷ 10).

Наконец, скопируйте этот код.

add r2, r2,lsl #1
lsl r1,r2
str r1,[r0]
pop {pc}

Этот код завершает метод. Первая строка на самом деле — умножение на 3. Умножение сложная инструкция. Электрической цепи может понадобится много времени для того чтобы предоставить результат. Иногда намного быстрее использовать более быстрые инструкции. В данном случае я знаю что r2 x 2 + r2 это тоже самое что r2 x 3. Очень легко умножить регистр на 2, т.к. это тоже самое что и сдвиг числа влево на 1.

Одной из очень удобных возможностей ассемблера ARMv6 является возможность сдвинуть значение перед его использованием. В данном случае я добавляю r2 к результату сдвига r2 на 1. В кодах ассемблера вы всегда можете использовать трюки вроде этого для вычисления результат более простым путем. Но если Вам не нравится этот метод Вы всегда можете использовать такой код: mov r3,r2; add r2,r3; add r2,r3.

Теперь мы сдвигаем значение функции влево на значение в регистре r2. Большинство инструкций вроде add и sub имеют вариант, который использует регистр вместо значения. Мы выполняем этот сдвиг для того чтобы установить биты отвечающие за необходимый номер пина, это 3 бита на каждый пин.

Далее мы загружаем полученное значение в ячейку памяти GPIO контроллера. Мы уже получили значение адреса в цикле. Теперь нам не нужно использовать смещение как это было в OK01 и OK02.

Теперь мы можем вернуться из этой функции. Т.к. мы сохранили значение lr в стек, если мы выполним pop {pc}, то значение будет извлечено из стека в pc. Это будет тоже что и mov pc,lr. Когда это команда будет выполнена произойдет возврат из функции.

Вы могли заметить, что эта функция работает не совсем корректно. В то время как она устанавливает значение функции указанного вывода, она устанавливает функции других выводов в блоке в 0. Это вероятно будет весьма раздражать в системе, которая активно использует GPIO. Я оставляю задачу исправления этой функции для заинтересовавшихся. Решение этой задачи может быть найдено на странице загрузок для данного урока. Инструкция которые могут вам понадобиться: and — находит булево И двух регистров, mvns — вычисляет булево НЕ и orr — вычисляет булево ИЛИ.

4. Еще одна функция

Итак, у нас есть функция, которая заботится об установке функции GPIO. Теперь нам нужна функция для включения и отключения ножки. Лучше иметь одну функцию для включения/отключения ножки.

Мы сделаем функцию SetGpio которая принимает номер GPIO вывода как первый аргумент в r0 и его значение как второй — r1. Если значение 0 пин будет выключен, если не 0 — включен.

Скопируйте этот код в конец gpio.s.

.globl SetGpio
SetGpio:
pinNum .req r0
pinVal .req r1

Нам опять нужена команда .globl чтобы сделать метку видимой из других файлов. В этот раз мы будем использовать псевдонимы (alias) для регистров. Они позволяют использовать любые имена для регистров, а не только r0,r1 и т.д. Сейчас это не так уж и важно, но это неоценимо важно при написании больших методов и Вы должны попробовать их использовать сейчас. pinNum .req r0 значит что с текущей строки pinNum будет значить r0.

Скопируйте этот код и вставьте после предыдущего блока.

cmp pinNum,#53
movhi pc,lr
push {lr}
mov r2,pinNum
.unreq pinNum
pinNum .req r2
bl GetGpioAddress
gpioAddr .req r0

Как и в SetGpioFunction сперва нужно удостовериться, что нам передали валидный номер пина. Мы делаем этот также как и ранее, путем сравнения pinNum (r0) с 53. Выходим из фунции если значение больше 53. Снова мы хотим вызвать GetGpioAddress, поэтому нужно сохранить lr, поместив его в стек и переместить pinNum в r2. Затем используем .unreq для удаления псевдонима для r0. Т.к. номер пина теперь в r2 нужно создать новый псевдоним для него. Вы должны всегда использовать .unreq как можно раньше, сразу после того как он перестает быть нужным, чтобы избежать ошибок при использовании его в коде, в то время как он уже не существует.

После этого вызываем GetGpioAddress и создаем псевдоним для r0.

Скопируйте этот код.

pinBank .req r3
lsr pinBank,pinNum,#5
lsl pinBank,#2
add gpioAddr,pinBank
.unreq pinBank

GPIO контроллер имеет два набора по 4 байта каждый для включения и отключения ножек. Первый набор контролирует первые 32 ножки, второй — остальные 22. Для того чтобы определить какой набор нам необходим нужно разделить номер пина на 32. К счастью это очень просто, это тоже что и сдвинуть номер пина на 5 вправо. Таким образом я назвал r3 как pinBank и вычислил pinNum ÷ 32. Т.к. набор состоит из 4 байт нужно умножить результат на 4. Это тоже самое что и сдвиг влево на 2. Вы можете удивиться, ведь можно просто сдвинуть на 3 вправо, а не делать сдвиг вправо, а затем влево. Это не сработает т.к. результат не будет округлен при делении на 8 так как при делении на 32.

В результате этого в gpioAddr будет содержаться 0x20200000 если номер пина 0-31 и 0x20200004 если номер пина в диапазоне 32-53. Это значит, что если мы добавить 28, то получим адрес для включения пина, а если 40, то для выключения. Мы закончили работу с pinBank, поэтому сразу же после этого используем .unreq.

Копируем следующий кусок кода.

and pinNum,#31
setBit .req r3
mov setBit,#1
lsl setBit,pinNum
.unreq pinNum

Эта часть необходима для генерирования числа с необходимым набором бит. Чтобы дать команду контроллеру GPIO включить/выключить пин мы передаем ему число с установленным битом, номер которого равен остатку от деления номера пина на 32. К примеру если нужно включить пин 16, нам нужно число с 16-ым битом равным 1. Для 45 пина необходимо число с установленным 13-ым битом (т.к. 45 ÷ 32 = 1 остаток от деления 13).

Команда and вычисляет остаток. Результатом этой операции является число, которое имеет единицы в каждом разряде в котором оба операнда имели единицы и нули во всех остальных. Это фундаментальная бинарная операция и она очень быстрая. Мы передали ей pinNum и 31 = 0b11111. Это значит что результат может иметь 1 только в 5 младших разрядах, т.е. он определенно в пределах от 0 до 31. Конкретно, результат будет иметь единицы только в младших 5 разрядах где pinNum имел 1. Это тоже что и остаток от деления на 32. Не совпадение, что 31 = 32 — 1.

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

Скопируйте этот код и вставьте после предыдущего блока.

teq pinVal,#0
.unreq pinVal
streq setBit,[gpioAddr,#40]
strne setBit,[gpioAddr,#28]
.unreq setBit
.unreq gpioAddr
pop {pc}

Этот код завершает нашу функцию. Как было отмечено мы выключаем пин если pinVal равен нулю, иначе — включаем. teq (test equal) — еще одна операция сравнения, которая может быть использована только для проверки на равенство. Она похожа на cmp но не понимает какое из чисел больше. Если нужно только проверить числа на равенство можно использовать teq.

Ксли pinVal равен нулю, то мы загружаем setBit со смещением 40 от gpioAddr, этот адрес как мы уже знаем выключает ножку. Иначе мы используем смещение 28, в таком случае мы включаем ножку. Наконец мы возвращаемся при помощи pop {pc}, которая восстанавливает значение lr сохраненное ранее в стек.

5. Новое начало

Теперь мы создали все необходимые функции для работы с GPIO. Нужно изменить main.s для их использования. Т.к. main.s теперь стал намного больше и сложнее, лучше разбить его на две секции. Секцию ‘.init’ которую мы использовали до сих пор лучше оставлять как можно меньшей. Мы можем легко изменить код для этого.

Вставьте эти строки сразу после _start в main.s

b main

.section .text
main:
mov sp,#0x8000

Ключевым изменением здесь является указание секции .text. Я спроектировал makefile и скрипт компоновщика таким образом чтобы секция .text (которая используется по умолчанию) была размещена сразу же после секции .init, которая размешена по адресу 0x8000. Такой адрес загрузки даеи нам немного памяти для стека. Так как стек находится в памяти он должен иметь адрес. Стек растет вниз в памяти. Т.е. каждое новое значение имеет меньший адрес. Таким образом вершина стека имеет наименьший адрес.

Замените код, который устанавливает функцию ножки этим.

pinNum .req r0
pinFunc .req r1
mov pinNum,#16
mov pinFunc,#1
bl SetGpioFunction
.unreq pinNum
.unreq pinFunc

Этот код вызывает SetGpioFunction с номером ножки 16 и кодом функции 1. Это включает светодиод OK (он же ACT).

Замените код, который устанавливал ножку этим.

pinNum .req r0
pinVal .req r1
mov pinNum,#16
mov pinVal,#0
bl SetGpio
.unreq pinNum
.unreq pinVal

Этот код использует SetGpio для выключения пина 16, т.е. включении светодиода. Если же использовать mov pinVal,#1, то эта функция включить ножку, т.е. выключить светодиод. Замените ваш старый код этим.

6. Вперед

Надеюсь сейчас Вы сможете проверить все что Вы сделали для Raspberry Pi. Мы написали много кода в этот раз, таким образом многое может пойти не так. Если так обратитесь в раздел решения проблем.

Поздравляю с работающим кодом! Тем не менее наша операционная система не делает ничего большего чем в уроке OK02. Мы узнали многое о функциях и форматировании, теперь мы можем закодировать новые функции намного быстрее. Теперь будет очень просто сделать операционную систему, которая управляет любым GPIO регистром, который используется для управления железом.

В уроке OK04 мы рассмотрим нашу функцию задержки, которая сейчас неточна. Таким образом мы получим более точный контроль над GPIO ножками и светодиодом.

 

Похожий код:

Фото аватара
Алексей Петров

Программист, разработчик с 5 летним опытом работы. Учусь на разработчика игр на Unity и разработчика VR&AR реальности (виртуальной реальности). Основные языки программирования: C#, C++.

Оцените автора
Бла, бла код
Добавить комментарий