Перейти к основному содержимому

Использование языка Си

В статье процедура сборки проекта были рассмотрены базовые принципы работы с периферийными устройствами при разработке ПО для процессора серии «Мультикор», а также проведено первое знакомство со средой разработки MCStudio 4. В этой главе будут рассмотрены преимущества, предоставляемые языком Си, а также реализована аналогичная задача с использованием входящего в состав среды разработки MCStudio 4 компилятора Си для CPU-ядра. Ознакомление весьма поверхностное, так как невозможно в одной главе охватить хотя бы мало-мальски значимую часть серьезной книги по языку Си. Рекомендуется дополнительное обращение к литературе по данному языку программирования.

Главный недостаток ассемблера — тот факт, что программист вынужден слишком много задумываться о вещах, которые не имеют никакого отношения к функционалу разрабатываемого устройства. Какой регистр сейчас использовать, какой использовать нельзя, поставить NOP после команды перехода, так, а вот тут цикл организовать — всего-то пять строчек… Языки высокого уровня позволяют не задумываться о таких нюансах. Скажем, ту же запись в периферийный регистр можно сделать одной строкой. А уж как это будет на языке машинных кодов — дело компилятора. А о регистрах самого процессора программисту на Си и вовсе задумываться неуместно — такие тонкости его могут волновать только в крайних случаях. На этом с теоретической частью можно закончить и приступить к созданию проекта, реализующего то же самое моргание светодиодов, но на языке Си.

Разобьем процесс на три этапа:

  1. Написание самого кода;
  2. Создание проекта в MCStudio 4;
  3. Демонстрацию дополнительных возможностей при отладке в MCStudio 4.

Написание кода

Какая б ни была программа на языке Си, она обязана содержать в себе функцию main(). То есть, в базовом варианте она выглядит как:

void main() {
}

Теперь вернемся к функционалу. Как мы помним, наша программа на ассемблере состояла из двух частей — инициализации и рабочего цикла. В инициализации мы записывали ряд значений в ряд периферийные регистры, а в основном цикле — сначала читали регистр сопроцессора CP0.Count, а потом опять же читали и записывали в периферийные регистры. Значит, нам надо научиться делать на Си две вещи:

  • оперировать с периферийными регистрами;
  • оперировать с регистрами сопроцессора CP0.

Операции с периферийными регистрами

Запись в периферийный регистр — это запись по определенному адресу памяти. Для таких операций в языке Си предусмотрено понятие «указатель». Вот, скажем, такое объявление:

unsigned int *ptr;

Это значит, что объявлен символ ptr, который из себя представляет значение адреса памяти, для MIPS32 — это 32-разрядный адрес. То есть, ptr — это 32-разрядное число. И если произвести чтение по адресу, равному этому числу — мы прочитаем значение, которое лежит в этом адресе памяти. unsigned int обозначает, что по данному адресу лежит число шириной 32 разряда. Ширина числа определена ключевым словом int. Слово unsigned дает компилятору понять, что с этим числом надо оперировать как с беззнаковым, то есть, могущим принимать значения от нуля до (232-1).

С указателем в языке Си можно делать следующие операции:

  • менять значение самого указателя (то есть, менять адрес, на который он указывает);
  • менять значение того числа, которое лежит по этому адресу;
примечание

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

Чтобы записать что-то по адресу, на который показывает наш указатель, нужно написать такую конструкцию:

*ptr = 0x12345678;

Здесь звездочка обозначает, что операция производится не со значением указателя, а с той ячейкой памяти, на которую он указывает. Если написать:

  ptr = 0x12345678;

Это будет попыткой изменить значение самого указателя. В силу особенностей языка Си, последний вариант он назовет ошибочным и не соберет. Логика простая — слева от знака равенства указан тип (unsigned int *), а справа — число, которое компилятором воспринимается как тип (unsigned int). Чтобы напрямую присвоить указателю значение 0x12345678, необходимо использовать так называемое преобразование типов:

  ptr = (unsigned int *) 0x12345678;

Вот здесь компилятор поймет, что указатель должен указывать на адрес 0x12345678, и соберет все без проблем. Учитывая вышесказанное, рассмотрим, как будет выглядеть запись значения 0xFFFF_FFFF в регистр CLK_EN на языке Си:

void main() {
unsigned int *CLK_EN;
CLK_EN = (unsigned int *) 0xB82F4004; // адрес регистра
*CLK_EN = 0xFFFFFFFF;
}

Здесь, однако, есть ряд излишеств. С точки зрения генерируемого кода, все это не так страшно, тем более, если у компилятора будет включена оптимизация. А вот с точки зрения громоздкости программы — все плохо, потому что мы делаем в три строчки то, что можно сделать в одну. Без излишних действий, можно было бы записать так:

void main() {
*((unsigned int *) 0xB82F4004) = 0xFFFFFFFF;
}

И это приведет ровно к такому же эффекту. При этом, первый вариант кода — это объявление переменной. А переменная нам не нужна — ведь мы не собираемся менять значение этого указателя. Второй вариант кода компактнее, но неудобно каждый раз выписывать вот эти вот звездочки и скобки. Здесь нам на помощь приходит такой механизм языка Си, как макроопределения:

#define CLK_EN *((unsigned int *) 0xB82F4004)

void main() {
CLK_EN = 0xFFFFFFFF;
}

Компилятор, проходя по коду, видит директиву #define. Обрабатывая ее, он понимает, что если где-то далее в коде он увидит макроопределение CLK_EN - ему необходимо подставить вместо этого макроопределения *((unsigned int*) 0xB82F4004). Что он успешно и делает. В итоге, наш код приобретает читабельный и удобный вид.

По факту, для каждого процессора серии «Мультикор» формируется заголовочный файл, содержащий такие макроопределения для каждого периферийного регистра. Эти файлы включаются в коде директивой #include, после чего становится возможно обращаться к периферийным регистрам по именам. Поэтому, указанная запись в регистр CLK_EN, в совсем уж финальном варианте, будет выглядеть так:

#include "memory_nvcom_02t.h"

void main() {
CLK_EN = 0xFFFFFFFF;
}

Однако это еще не все. Пока мы говорили все сплошь о записи в периферийные регистры. Но нам также требуется производить и чтение оттуда, и дальнейшую обработку полученного значения. Вспомним главный цикл программы: считать значение регистра GPIO_DR, осуществить XOR, записать значение регистра. Чтобы считать и сохранить значение периферийного регистра, мы объявим переменную. Не указатель, а именно переменную. Вот так будет выглядеть объявление:

unsigned int tmp;

Это 32-разрядная беззнаковая переменная. А код главного цикла будет выглядеть так:

#include "multicore/nvcom02t.h"

void main() {
unsigned int tmp;
tmp = GPIO_DR;
tmp ^= 0xC0;
GPIO_DR = tmp;
}

Значок «^» — операция побитового исключающего ИЛИ (XOR).

Оператор «^=» равнозначен такому: tmp = tmp^0xC0.

Операции с регистрами сопроцессора CP0

В отличие от периферийных регистров, доступ к регистрам сопроцессора CP0 осуществляется только с помощью специальных инструкций MFC0/MTC0. Это значит, что вся мощь языка высокого уровня не спасет нас от применения ассемблера. Итак, как прочитать регистр CP0.Count на Си? Это делается с помощью кода такого вида:

unsigned int GetCP0_Count() {
unsigned int result;
asm volatile ("mfc0 %0, $9" :"=r"(result));
return result;
}

void main() {
unsigned int count;
count = GetCP0_Count();
}

Разберем этот код подробнее. От первого unsigned int и до первой закрывающей фигурной скобки идет объявление функции GetCP0_Count. В отличии от функции main(), код который выполняется сразу после загрузки программы в ОЗУ (после небольшого кода стартовой инициализации — но об этом позже), другие функции в программе не будут выполняться, если их не вызывают в main() или в других функциях, в свою очередь вызванных из main(). Почему unsigned int, а не void? Потому что функция может ничего не возвращать, а просто выполнять какие-то действия — тогда она имеет тип void. А может возвращать какое-то значение — тогда мы задаем тип, а при вызове можем использовать конструкцию value = function();.

В данном случае функция как раз возвращает нам значение регистра Count сопроцессора CP0. В функции GetCP0_Count() мы объявляем переменную result. Дело в том, что возврат значения осуществляется ключевым словом return (см. предпоследнюю строчку данной функции). И чтобы вернуть значение регистра CP0 — это значение надо сначала куда-то сохранить. Для этого и объявляется данная переменная. Дальше идет самая главная строка во всей функции. Слово asm обозначает, что предстоит ассемблерная вставка. Модификатор volatile говорит компилятору, что данная строчка должна быть транслирована в машинный код точно так, как написана, без малейшей оптимизации и прочего. Дело в том, что компилятор в режиме оптимизации может «выкидывать» ненужный, по его мнению, код. Вот слово volatile дает ему понять, что данный участок кода оптимизации не подлежит.

Ну и конечно, синтаксис ассемблерного кода:

"mfc0 %0, $9" :"=r"(result)

Это несколько отличается от использованного в ассемблерном коде варианта. Дело в том, что когда мы пишем на языке Си, всеми регистрами CPU-ядра распоряжается компилятор. Он сам определяет, для чего и какой регистр используются. И если мы в ассемблерной вставке напишем mfc0 $5,$9 - то нет никаких гарантий того, что компилятор не хранил чрезвычайно важную переменную в регистре $5, и что мы, соответственно, не испортили это значение (и что программа далее будет работать корректно). А использованная конструкция дает компилятору команду поместить результат в переменную result. Как при этом будут использованы регистры CPU — дело компилятора. Запись в регистры CP0 осуществляется аналогично:

void SetCP0_Count(unsigned int value) {
asm volatile ("mtc0 %0, $9" ::"r"(value));
}

void main() {
SetCP0_Count(0);
}

Здесь мы видим, что в функцию SetCP0_Count() передается также параметр — то значение, которое должно быть записано в регистр CP0.Count. Соответствующим образом меняется запись ассемблерной вставки. Подробнее про подобную запись можно узнать в описании компилятора GCC (http://gcc.gnu.org).

Окончательный текст программы

Учитывая вышеизложенное, наш код на языке Си будет выглядеть так:

// файл со ссылками на макроопределения регистров, в том числе нужных нам CLK_EN, DIR_MFBSP1,
// GPIO_DR1

#include "multicore/nvcom02t.h"
// установка длительности паузы в тактах частоты CPU, 1 секунда
#define TIMEOUT 5000000

unsigned int GetCP0_Count() {
unsigned int result;
asm volatile ("mfc0 %0, $9" :"=r"(result));
return result;
}

void SetCP0_Count(unsigned int value) {
asm volatile ("mtc0 %0, $9" ::"r"(value));
}

void main() {
SYS_REG.CLK_EN.data = 0xFFFFFFFF;// запись единицы в системный регистр CLK_EN
MFBSP1.DIR.data = 0xC0; // переключаем выводы LDAT[5:4] в режим выхода
MFBSP1.GPIO_DR.data = 0x80; // устанавливаем противоположные значения разрядов
//GPIO_DR1[7] и GPIO_DR1[6]

while (1) { // бесконечный цикл
MFBSP1.GPIO_DR.data ^= 0xC0; // инвертируем биты GPIO_DR1[7:6] //GPIO_DR1[7:6]
SetCP0_Count(0); // обнуляем CP0.Count
while (GetCP0_Count()<TIMEOUT) ; // ждем 1 секунду
}
}

Названия регистров DIR и GPIO_DR мы определили по правилам встроенных заголовочных файлов, пример использования имен можно посмотреть в файле nvcom02_dma_mem_ch_regs.h , располагающемся в директории с заголовочными файлами: "%MCS4%\ToolsMGCC\mipsel-elf32\include\multicore\nvcom02t". В коде также видно новое кодовое слово while. С его помощью реализуются циклы. Участок кода после while и условия в обычных скобках (одна строчка, если не используются фигурные скобки, или участок кода, обрамленный фигурными скобками сразу после while) выполняется до тех пор, пока справедливо выражение, указанное в обычных скобках. В первом while условия нет — этот цикл выполняется бесконечно. Во втором while условие продолжения цикла — если значение регистра CP0.Count меньше, чем 5000000. Значение 5000000 мы объявили с помощью макроопределения. Это, в частности, удобно, если мы решим его поменять. Не надо искать в коде, где оно используется — достаточно изменить его в начале файла. Особенно это удобно, если данное число используется не в одном участке кода.

Создание проекта в MCStudio 4

Механизм создания проекта аналогичен тому, создавался проект с ассемблерным кодом, за исключением одного момента. В проекте на языке Си перед входом в функцию main() обязательно должен выполняться предварительный стартовый код, который устанавливает стек и выполняет еще ряд необходимых действий. Стартовый код можно задать либо отдельным StartUp.s файлом, либо он присоединятся в составе стандартной библиотеки.

Стандартная библиотека Си реализует стартовый и финишный код, работу с динамическим выделением памяти, а также математические функции, работу со строками, ввод-вывод и так далее. Для работы с процессорами “Мультикор” используется стандартная библиотека newlib. Если мы планируем использовать функции стандартной Си-библиотеки (например, операции с динамическим выделением памяти, операции с числами с плавающей точкой, и т.д.) — в поле «File → New → Project.. → Multicore Project → Project Type» нам необходимо выбрать вариант «NewLib Project — NVCom-02T».

Если же мы не планируем использовать стандартные Си-функции, надо выбирать вариант «Simple Project — NVCom-02T», проект будет создан уже с включенным файлом StartUp.asm, в котором будет производиться минимально необходимая стартовая инициализация перед main(). Разумеется, в случае необходимости можно не добавлять стандартный стартап-код, а написать свой. Но это редкий случай, и в данный момент рассматриваться не будет. Поскольку мы не используем стандартных функций Си, выберем вариант «Simple Project — NVCom-02T»:

Рисунок 1. Выбор типа проекта.

Проект будет создан уже с файлами StartUp.asm и main.c, так что добавлять файлы в проект не потребуется. Копируем в файл main.c наш код и собираем проект: Выделяем имя проекта → Build Project

**** Build of configuration MultiCore_Configuration_Debug for project GPIO_NVCOM02TEM3U_C ****
make all
Building file: ../main.c
Invoking: RISC C Compiler - mipsel-elf32-gcc
"c:\\elvees\\MCStudio4_17_FULL28_10_2014"/ToolsMGCC/bin/mipsel-elf32-gcc -O0 -g -Wall -DNVCOM -EL -m
hard-float -c -fmessage-length=0 -G0 -mips32 -ffixed-k0 -ffixed-k1 -mno-check-zero-division -fno-del
ayed-branch -Xassembler --mc24r2 -o "main.o" "../main.c"
Finished building: ../main.c
Building target: GPIO_NVCOM02TEM3U_C.elf
Invoking: MultiCore Linker - mipsel-elf32-gcc
"c:\\elvees\\MCStudio4_17_FULL28_10_2014"/ToolsMGCC/bin/mipsel-elf32-gcc -nostartfiles -nodefaultlib
s -nostdlib -T Project.xl -EL -mhard-float -o "GPIO_NVCOM02TEM3U_C.elf" ./StartUp.o ./main.o __DS
P0__.o __DSP1__.o __DSP2__.o __DSP3__.o
Finished building target: GPIO_NVCOM02TEM3U_C.elf
**** Build Finished ****

Проект успешно собирается, что дает уверенность в правильности синтаксиса кода. Теперь надо выполнить настройку самого проекта, аналогично тому, как это делалось для проекта на ассемблере (см. главу 1). Однако есть важные нюансы: Расположение секций проекта в памяти. Сейчас в проекте два файла, каждый из которых имеет секцию text и секцию data. Если мы хотим, чтобы весь наш код располагался в памяти непрерывным «куском» - для всех секций всех файлов необходимо указать один и тот же адрес (0xB800_0000, если мы хотим расположить его во внутреннем ОЗУ микросхемы). При сборке линковщик сам определит расположение каждого участка кода.

Во вкладке «Options → Project» есть параметры «Heap size» и «Stack size». Heap size — размер «кучи» - это объем памяти, выделяемой под динамическое ее выделение — по функциям malloc() и им подобным (см. подробное описание стандартной библиотеки Си). В данном случае мы эти функции не используем, поэтому данный параметр нам безразличен. А вот параметр «Stack size» - размер стека - нам важен, так как это размер области памяти, выделяемый под наши переменные. И в стартап-коде есть участок, который очищает участок памяти, зарезервированный под стек. Нередкий случай, когда размер стека указывается больше, чем размер физически имеющейся памяти. В этом случае стартап-код, перейдя границы физически имеющейся памяти, начинает затирать уже саму программу или нужные нам данные. Для нашей программы его размер лучше установить в районе 0x800 байт. Теперь, когда проект собран успешно, надо попробовать запустить его в симуляторе. Методика контроля правильности алгоритма полностью аналогична таковой для программы на ассемблере. Только точку останова, соответственно, надо располагать на строке MFBSP1.GPIO_DR.data ^= 0xC0;.

Рисунок 2. Запуск проекта.

Запуск программы в эмуляторе (на отладочном модуле) также полностью аналогичен тому, как это делалось с проектом на ассемблере.

Дополнительные возможности при отладке

Данный раздел приведен вкратце в силу стандартности данного функционала. Дополнительные возможности при отладке сконцентрированы в закладках правого верхнего

Рисунок 3. Возможность просмотра точек останова.

и нижнего левого окна перспективы «Debug», речь ниже идет именно об этих вкладках.

Рисунок 4. Просмотр MCRegisters.

Просмотр глобальных и локальных переменных

Пункт «Variables» позволяет просматривать состояние всех локальных и глобальных переменных (то есть, объявленных внутри функции) в той функции, которая сейчас исполняется. Для ознакомления с этой функцией на текущем проекте рекомендуется поставить точку останова внутри функции GetCP0_Count() и открыть данную вкладку. В ней будет отображаться состояние переменной result. В случае выхода из функции — отображение данной переменной не прекращается. Кроме того, в этом окне отображается состояние параметров, переданных в исполняемую функцию. На вкладке «Disassembly» отображается дизассемблированный вид области памяти, где располагается программа.

Окно «Registers» позволяет выборочно смотреть состояние нужных регистров. Например, в нашем приложении нам совершенно не было необходимости смотреть весь набор регистров CP0 или порта MFBSP1 — можно было вывести только нужный нам набор регистров. Пункт «Call stack» дает возможность увидеть последовательность вызовов функций. «Breakpoints» откроет окно с перечнем всех установленных в проекте точек останова, а также предложит установить точки останова не только по строчке исходного кода, но и также по конкретному адресу в памяти — как по исполнению инструкции с этого адреса, так и по попытке записи/чтения в этот адрес. Вкладка «Memory» позволяет отображать содержимое ячеек памяти. Чтобы задать диапазон адресов области памяти выберите пункт Add Memory Monitor в контекстном меню, которое вызывается по нажатию правой кнопки мыши в области Monitor.

В результате откроется диалоговое окно Monitor Memory, которое позволяет ввести начальный адрес области памяти в десятичном, либо шестнадцатеричном (через 0x…) представлении числа.

Рисунок 5. Добавление адреса для просмотра.

После нажатия OK в правой части вкладки Memory появится структурированная таблица с содержимым ячеек памяти.

Рисунок 6. Просмотр адресов памяти.