Один из разработчиков нашей операционной системы реального времени МАКС написал серию статей о ее создании и особенностях. Получилась своеобразная «Книга знаний», неформальное руководство программиста.
В первой статье мы рассказали об отличии ОС от ОСРВ, представили архитектуру ОСРВ МАКС и ее специфику.
Вторая статья была посвящена ядру системы и приоритету задач, третья— структуре простейшей программы, а в четвертой мы представляем необходимую теорию для последующего ее применения. Также статья размещена на Хабре.
Из всего этого следует, что выделять память на куче следует с крайней осторожностью. В идеале, это следует делать на этапе инициализации программы. Если же требуется выделять память по ходу работы, то лучше это делать как можно реже. Не стоит увлекаться постоянным выделением и освобождением. Также стоит опасаться операций, которые выделяют память неявно, внутри себя. Меня коробит от кода подобного вида, особенно если учесть, что он исполняется в системе, где на всё про всё выделено 50 килобайт ОЗУ:
String output;
if (cnt > 0)
output = ',';
output += "{\"type\":\"";
output += (entry.isDirectory()) ? "dir" : "file";
output += "\",\"name\":\"";
output += entry.name();
output += "\"";
output += "}";
char xml [768];
...
xml[0] = 0;
if (cnt > 0)
{
strcat (xml,",");
}
strcat (xml,"{\"type\":\"");
if (entry.isSubDir())
{
strcat (xml,"dir");
} else
{
strcat (xml,"file");
}
strcat (xml,"\",\"name\":\"");
entry.getName(xml+strlen(xml),255);
strcat (xml,"\"}");
Этот код специально сделан «в лоб», чтобы чётко показать, что после его добавления, не стало опасности фрагментации адресного пространства, а также было бы точно видно, что на что заменено. Но, до совершенства ему ещё далеко. Начнём с того, что в нём в угоду наглядности попраны принципы ООП, а продолжим тем, что функция strcat каждый раз перебирает строку-приёмник с начала, что отрицательно сказывается на быстродействии. Чисто теоретически, строка-приёмник также может переполниться (хотя в данном конкретном примере, защита от переполнения находится в функции entry.getName).
Приведём вариант, предложенный @comdiv, лишённый указанных недостатков.
Опишем класс для работы со статической строкой, содержащий, среди прочего, указание текущей длины, что позволит не начинать осмотр строки каждый раз с начала. Для простоты, реализуем в этом классе только оператор "+=". Именно на этот класс ляжет смысловая нагрузка нового варианта примера.
class StaticString
{
protected:
char* m_buf; // Указатель на строку
int m_size; // Максимальный размер строки
int m_len; // Текущий размер строки
public:
StaticString (char* buf,int size)
{
_ASSERT(NULL != buf);
_ASSERT(size > 0);
m_buf = buf; // Указатель на статический буфер
buf[0] = '\0'; // Терминатор
m_size = size; // Размер буфера
m_len = 0; // Пока длина строки - нулевая
}
StaticString& operator+=(const char *str)
{
int i = 0;
// Пока есть, куда складывать и пока в источнике не терминатор
while ((m_len < m_size - 1) && (str[i] != '\0'))
{
// Скопировали очередной символ
m_buf[m_len++] = str[i++];
}
// Терминировали, ведь у нас всё-таки сишная строка
m_buf[m_len] = '\0';
return *this;
}
};
char xml [768];
...
StaticString output (xml,sizeof(xml));
if (cnt > 0)
output += ',';
output += "{\"type\":\"";
output += (entry.isDirectory()) ? "dir" : "file";
output += "\",\"name\":\"";
Но за счёт применения другого класса, опасность фатальной фрагментации адресного пространства — миновала. И в отличие от «лобового» решения — оптимизировано быстродействие и устранена опасность переполнения буфера строки.
Совершенствовать класс можно долго (сейчас в нём перекрыт только один вариант оператора "+="), но это уже скорее относится к руководствам по программированию вообще, а не к руководству по ОСРВ МАКС. А пока — просто отмечу, что какой бы из вариантов замены («на скорую руку, но наглядный» или «правильный, но более сложный») ни был бы выбран, они иллюстрируют одну и ту же идею:
Мне часто приходилось встречаться с программистами, которые не знают, как именно реализуются локальные переменные в языках Си и С++. При этом, те программисты прекрасно осведомлены, что такое стек, а также — о том, как в него сохраняется содержимое регистров (которые будут испорчены) и адреса возврата из подпрограмм (правда, в архитектуре ARM адрес возврата попадает в регистр LR). Возможно, это связано с тем, что все эти программисты закончили один и тот же ВУЗ (чего уж греха таить, я сам закончил его же, и ещё лет 10 назад тоже до конца не представлял себе, что такое стековый кадр). Тем не менее, будет полезным кратко обрисовать, как же эти загадочные локальные переменные хранятся. В конце раздела, будет раскрыта интрига, каким образом это относится к ОСРВ МАКС.
Итак. Оказывается, стек используется не только для хранения адресов возврата (правда, не у ARM) и временного сохранения содержимого регистров процессора. Стек используется также для хранения локальных переменных.
Посмотрим, как выглядит типичная преамбула функции, у которой этих локальных переменных настолько много, что они не помещаются в регистрах
Первая инструкция PUSH — с нею всё ясно. Она как раз сохраняет регистры в стеке, чтобы перед выходом их восстановить. А что это за вычитание константы 0x1C из SP? А это как раз выделение стекового кадра. Из курса информатики известно, что стек — это такая вещь, которая адресуется не непосредственно, а относительно указателя на вершину стека. Рассмотрим графически, что сделают эти две строки.
Рис. 2. Влияние преамбулы функции на стек
Что это за стековый кадр? Всё просто. Его размер таков, чтобы в нём уместились все локальные переменные функции (кроме тех, которые оптимизатор положит в регистры). Размер стекового кадра вычисляется компилятором. Каждая переменная получает своё смещение относительно начала кадра, а обращение к ним идёт примерно так:
Понятно, почему локальные переменные видны только внутри функции. Они адресуются относительно регистра SP, а во вложенных функциях (как и в функциях более верхнего уровня) SP будет другой.
Понятно, почему отладчик среды разработки KEIL не отображает некоторые переменные — почему-то разработчики не умеют показывать содержимое переменных, размещённых в регистрах.
Понятно, почему выход за границы массива, размещённого в локальных переменных может привести к полной неработоспособности программы — адрес возврата из функции хранится в том же стеке и вполне может быть испорчен.
Понятно, что рекурсивные функции тратят стек не только на адреса возврата, но и на стековые кадры. Чем больше локальных переменных, тем больше стека расходуется при рекурсивных вызовах. Этого лучше избегать при работе в условиях ограниченной памяти.
Самый главный вывод — после некоторых тренировок, программист сможет начать прикидывать, какой объём стека будет необходим задаче (исходя из прикидок о самой глубокой вложенности взаимного вызова функций и набора их локальных переменных). ОС Windows по умолчанию выделяет каждому потоку по мегабайту стека. При работе с микроконтроллерами, речь идёт о килобайтах. Причём эти килобайты выделяются на куче, поэтому уменьшают объёмы свободной динамической памяти и глобальных переменных, так что знание физики не просто полезно, а часто жизненно необходимо.
Защита стека задачи от переполнения
При создании задачи, определяется размер стека для неё. После этого, размер не может быть динамически изменён. Если он был выбран неудачно (по ходу работы образовалась большая вложенность вызовов, либо число локальных переменных оказалось высоко, что могло произойти уже при сопровождении программы), данные могут выскочить за выделенные пределы, повредив данные в стеках других задач, в куче, либо иные данные и производя прочие непредсказуемые действия. Такую ситуацию желательно выявить и сообщить разработчику, что она требует устранения.
Идеальным методом предотвращения такой ситуации была бы проверка на уровне компилятора, без участия ОС, но к сожалению, такой механизм как минимум, создаёт большие накладные расходы. Основная задача для контроллеров — не проверять программиста, а производить управление. При тактовой частоте в районе ста-двухсот мегагерц (а иногда — и десятков мегагерц), такой метод контроля уже неприемлем.
На уровне ОС также можно производить контроль стека на переполнение. В ОСРВ МАКС используются следующие методы защиты:
Проверка текущего положения указателя стека при переключении задач. Почти не влияет на производительность, но обладает низкой надежностью. Во-первых, разрушение стека уже произошло, а во-вторых – за время системного такта программа могла не только войти в функцию, вызвавшую переполнение, но и выйти из неё, а значит — указатель мог успеть вернуться обратно в разрешенный диапазон.
Если установлен размер стека больше минимального, то к нему автоматически добавляется одно слово на вершине, куда записывается «magic number» — 32 разрядное число случайного вида, которое вряд ли встретится при работе программы. При переполнении стека это число будет затерто данными приложения, что почти наверняка позволит зафиксировать факт переполнения стека даже после возвращения указателя в рабочую область.
В том случае, когда процессор содержит блок MPU (Memory Protection Unit), сразу за границей стека помещается область памяти минимально допустимого размера с защитой от доступа. Это самый совершенный способ контроля, так как при любом обращении к защищённой области, произойдет аппаратное прерывание. Следует, однако, помнить, что в некоторых случаях, защитная зона может оказаться не тронутой. Например, если часть локальных переменных, которые попали именно в эту зону, зарезервированы, но не используются. Защита сделана для самоконтроля и не должна идти в ущерб основным задачам.
Детали для работы с защитой стека можно найти среди констант, заданы в классе Task (в файле MaksTask.h). Изучая комментарии к этим константам, можно понять конкретные величины параметров «минимальный стек», «защищаемая область» и т.п. При желании, этим параметры можно и изменить. Следует только помнить, что размер защищаемой области должен быть степенью двойки.
Всё, наконец-то необходимый минимум теории, без которой невозможно начинать практические опыты, закончен.
Вообще, обычно авторы сначала описывают всё про предметную область, а уже в последней главе (или вообще, в приложении) приводят сведения о практической работе. Видимо, они считают, что читатели сначала всё запомнят, а уже потом — начнут эксперименты. Наивно предполагать, что средний человек запомнит всю массу вываленных на него знаний. Удобнее пробовать все знания постепенно.
Поэтому перед тем, как начать пичкать читателя дальнейшей теорией, стоит немного попрактиковаться. Но для этого следует рассмотреть, как же правильно начать работу с ОСРВ МАКС. Давайте будем считать, что читатель знаком с тем, как скомпилировать и запустить программу под имеющийся у него микроконтроллер, иначе текст будет сильно перегружен.
Если это не так, то крайне рекомендую ознакомиться с замечательными руководствами от среды разработки Keil по работе с отладочными платами ST (к сожалению, на английском языке, но многое понятно и из рисунков):
http://www.keil.com/appnotes/files/apnt_253.pdf
http://www.keil.com/appnotes/files/apnt_261.pdf
И в следующей статье мы приступим к первому практическому опыту.