Прототип UART загрузчика для STM8.

Bootloader позволяет прошить контроллер, без участия специальных программаторов (ну это вы и сами знаете). И все всегда говорят, что он полезен, когда надо обновить прошивку "на объекте у заказчика".

Я же готовлю свою версию UART загрузчика для STM8 с несколько иной целью (хотя и сильно пересекающейся). Для начала написал на C, что бы примерно представлять что нужно, и как это будет выглядеть. Как обычно ничего нового, просто вырезанные куски из разных аппноутов/проектов.

Главное - зачем?

В некотором роде это бесполезный проект. На самом деле, зачем мне загрузчик, когда у меня есть STM8S-Discovery? С её (дискавери) помощью я могу спокойно прошить любой stm8 контроллер, не занимая в нем (МК) ресурсов. Кроме того, в старших моделях МК имеется встроенный bootloader по интерфейсам UART/CAN/I2C. Так зачем же мне свой велосипед?

Все достаточно просто:

  • у меня имеется кучка STM8S003F3P, к которым загрузчик не прилагается;
  • отладочная плата не вечна (новую купить можно, но скажем не в данный момент), да и дрова на компе могут слететь и новые не скачать (эти провайдеры/начала месяца), а ещё и отсутствие винды может сыграть свою роль (Linux ни кто не отменял ещё);
  • протокол SWD достаточно скоростной и "требовательный ко времени", что бы реализовать самопальный программатор на коленке (на самом деле допуски достаточно широкие, но не хочется делать абы-как);
Вот и было решено в несколько контроллеров зашить bootloader "не требующий ни каких дополнительных средств" (только терминал и любой конвертор в UART).

Требования

Bootloader должен удовлетворять следующим требованиям:

  1. использование UART;
  2. не требовать применения специализированного ПО;
  3. занимать как можно меньше места;
  4. исключить необходимость создания специальной прошивки "с учетом загрузчика"

Первое требование очевидно. Интерфейс UART "самый распространенный". Достать/собрать преобразователь RS232-UART или USB-UART не составляет большой проблемы. Более того его желательно иметь всем, кто работает с МК.

Из второго требования следует, что мы не можем реализовывать интерфейс загрузчиков из старших МК. Двоичные данные, да с расчетами контрольных сумм, да без ПО - практически невозможно. Позволим МК понимать текстовый формат Intel HEX либо Motorola SREC. Но только один из них, не оба сразу, ибо требование третье.

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

Программирование памяти

Как обычно начинаем эксперименты с самого простого. ST предоставляет готовый пример в аппноуте AN2659 - STM8 in-application programming c исходниками. Данный проект удовлетворяет только первому требованию, по всем остальным в пролете. Но мы можем позаимствовать интересующие нас части.

Начнем с функции записи. Аппноут предлагает нам полную верную версию. Запись производится блоками максимально-возможного размера. При возможность записи страницы - пишется страница, если можем записать слово - пишем слово, если ни то ни другое не возможно - пишем по байтам (см. исходники аппноута). При этом корректно обрабатываются границы записей. Такой способ полезен с точки зрения сохранности ресурсов flash-памяти и времени выполнения. Но за это приходится расплачиваться объемом кода. Для начала не будем усложнять пример (а заодно сэкономим несколько десятков байт памяти) и воспользуемся только частью побайтовой записи:

int WriteMemory(unsigned int base, const unsigned char* data, int len)
{
  //program remaining bytes (after words)
  while(len)
  {
    *((volatile unsigned char*) base) = *data;
    while( (FLASH_IAPSR & (MASK_FLASH_IAPSR_EOP | MASK_FLASH_IAPSR_WR_PG_DIS)) == 0);
    base++;
    data++;
    len--;
  }
  return len;
}
Важным моментом в прототипе является исполнение данной функции из Flash, а не RAM-памяти (как того советует/требует мануал). Код работает, но так делать не стоит.

Что бы запись удалась, надо предварительно снять защиту от записи (зачем нужно защита - читайте в мануале). Берём следующий набор функций:

void unlock_PROG(void)
{
  //Unlock PROG memory
  FLASH_PUKR = 0x56;
  FLASH_PUKR = 0xAE;
}
	
void unlock_DATA(void)
{
 //Unlock DATA memory
  FLASH_DUKR = 0xAE; // Warning: keys are reversed on data memory !!!
  FLASH_DUKR = 0x56;
}

void lock_memory(void){
  // Lock program memory
  FLASH_IAPSR_bit.PUL = 0;
  // Lock data memory
  FLASH_IAPSR_bit.DUL = 0;
}

Имея описанные функции мы можем писать во Flash/EEPROM память МК любые байты данных. Но эти байты надо где-то взять.

Прием данных

А брать данные мы собрались по UART. К счастью работать с ним достаточно просто. Я уже приводил пример приема данных по прерываниям в статье Шаг назад. Делаем дисплей для LCD4Linux. Если бы не одно но:

The application stops for the duration of the byte program operation.
Выполнение программы приостанавливается до окончания записи. Мы не можем использовать прерывания во время программирования Flash, так как эта самая флэш заблокирована.

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

Есть несколько обходов данной проблемы:

  1. снизить скорость обмена в 10 раз до 900бод;
  2. во время программирования выполнять код из RAM-памяти, в котором "слушать UART" и копировать принятые данные во временный буфер;
  3. запрещать обмен на время программирования флеш.

Первый вариант просто абсурден. Время передачи 1кБ прошивки составит порядка 24 секунд (сами данные плюс накладные расходы). Оно нам надо?

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

Задействуем программный контроль передачи (XON/XOFF). Суть в том, что приняв блок данных для записи, мы отправляем специальный байт XOFF, говоря тем самым, что не готовы пока принимать данные. А после обработки данных и их записи во flash отправляем байт XON, извещая о готовности принять очередную порцию данных. И как обычно всплывает ещё одно но.

Реакции на наш байт XOFF не мгновенна. Как минимум пройдет время на передачу самого байта запрета, и ещё на обработку его удаленной стороной. После отправки запрета мы должны ещё некоторое время продолжать прием данных и их сохранение во временный буфер. А поскольку мы не знаем сколько нам придет байт после запрета обмена, будем использовать "таймаут приёма" - если данных нет в течении некоторого интервала времени (большего времени приёма одного байта), считаем что прием завершен.

Так мы получаем следующие функции:

static char save_buffer[GET_SAVEBUF_SIZE];
static int save_count = 0;
static int save_total = 0;

char UART_getc()
{
  while(!(UART1_SR & MASK_UART1_SR_RXNE));
  return UART1_DR;
}

char getc()
{
  if(save_count < save_total)
  {
    // Есть данные в кеше, возвращаем их
    return save_buffer[save_count++];
  }
  else
  {
    // Данных в кэше нет
    if(XonXoff)
    {
    // разрешаем прием, если был запрещён
      XonXoff = 0;
      putc(XON);
      save_total = save_count = 0;
    }
    // возвращаем принятый байт
    return UART_getc();
  }
}

void saveInput()
{
  int delay;
  if(!XonXoff)
  {
    // Запрещаем прием, если он был разрешен
    XonXoff = 1;
    putc(XOFF);
  }
  // Дочитываем в кеш, все что идет после запрета приема
  while(save_total < GET_SAVEBUF_SIZE)
  {
    for(delay = GET_SAVETIMEOUT; delay; --delay)
    {
      if(UART1_SR & MASK_UART1_SR_RXNE)
      {
        // есть байт на приемнике, сохраняем его в кеш
        save_buffer[save_total++] = UART1_DR;
    	break;
      }
    }
    // проверяем на таймаут приёма
    if(!delay) break;
  }
}

Функцию saveInput мы должны вызвать при завершении приёма. Она производит отправку XOFF (при необходимости) и дочитывает во временный буфер данные, пришедшие после запрета.

Функция getc вызывается для получения очередного принятого байта. Она извлекает данные из временного буфера, при их наличии, либо разрешает прием и получает данные напрямую из UART.

На основе данных функций строится gets (получение одной строки, заверенной символом перевода строки), которую мы вызываем для получения очередной порции данных, ...

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

Из текста в данные

Так как места у нас мало (третье требование), не будем гнаться за универсальностью. Обрабатываем только один формат, и этим форматом будет Intel HEX (хотя логичнее для stm8 выбрать srec).

Для преобразования и проверки служат функции ProcessWrite, inplaceHex2Array, hex2int. Их реализацию смотрите в приложенном архиве.

Обрабатываем только записи с кодами 00 (данные) и 01 (конец файла), на все остальное отвечаем ошибкой. Позже мы поймаем ошибку на строке с кодом 04 (адрес запуска) - не учел немного, но не смертельно.

Выбираем место для загрузчика

У контроллера есть область памяти, называемая User Boot Area, специально предназначенная для размещения кода загрузчика. Она дополнительно защищена от записи. Она используется в аппноуте, но как вы уже догадались, нам она не подходит. Потому что расположена она в начале флэш-памяти, там, где находится таблица векторов прерывания и выше. Следовательно, при её использовании будет нарушено четвертое требование.

Свой загрузчик мы расположим в меньше всего используемой области flash-памяти - в её вершине, начиная с адреса 0x9800. Делаем это при помощи *.icf файла линкера (об этом я писал в статье Преодолевая пределы. Часть вторая. Ресурсы в IAR.). Приведу только изменения в базовом файле:

define region VectorRegion = [from 0x8000 to 0x80FF];
define region NearFuncCode = [from 0x9800 to 0x9EFB];
define region FarFuncCode = [from 0x9800 to 0x9EFB];
define region HugeFuncCode = [from 0x9800 to 0x9EFB];

define block INTVEC with size = 0x80 { ro section .intvec };

place at start of VectorRegion  { block INTVEC };
Таблицу векторов прерываний мы "отрываем" от остального кода и помещаем в её обычное место, что бы правильный вектор перехода был записан при прошивке самого загрузчика. А адреса 9EFC - 9EFF оставляем для сохранения адреса перехода "исполняемой прошивки".

Теперь большинство прошивок (объемом < 6кБ) сможет быть использовано без изменения. Однако загрузчик более не будет защищен, и если исполняемая прошивка пожелает что-либо записать в область памяти загрузчика (естественно предварительно сняв блокировку с flash-памяти), то загрузчик будет безвозвратно испорчен. И с этим ничего не поделаешь. Немного смягчить ситуацию может свободная страница памяти в самом конце flash-памяти (потому что обычно используют именно последние адреса, если собираются что-либо во flash-памяти - так проще настроить проект и меньше ошибок), но надеяться на это тоже не сильно стоит.

Тогда возникает проблема с передачей управления загрузчику. Просто так при сбросе МК мы попадаем на адрес 0x8000, где будет располагается исполняемая прошивка, а не наш загрузчик. И следовательно, после прошивки любой программы, загрузчик потеряет управление. Но мы можем это дело обойти.

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

  // Проверяем допустимость адреса
  if(addr >= FLASH_START && addr + count - 1 <= FLASH_END)
  {
    // Данные во флеш
    if(!(addr >= BOOTV_END || addr + count - 1 <= BOOTV_START) )
    {
      // Есть пересечение с вектором начала программы
      // Пишем вектор в свою область
      int nn, ret;
      nn = (BOOTV_END - addr + 1);	// определяем, сколько данных надо для записи в область вектора перехода
      if(nn > count) nn = count;	// в наличие данных меньше, чем поместится в область вектора перехода
      ret = WriteMemory(RESET_VECTOR_ADDR + (addr - BOOTV_START), data, nn);
      if(ret > 0) return ret;
      // Пишем оставшуюся часть данных
      if(count > nn)
      {
        ret = WriteMemory(addr + nn, data + nn, count - nn);
        if(ret > 0) return ret;
        return 0;
      }
    }
    else
    {
      // Пишем одним блоком, разбивка не требуется
      return WriteMemory(addr, data, count);
    }
FLASH_END - в данном случае 0x97FF конец доступной памяти для пользовательской прошивки, а не конец всей памяти 0x9FFF. Так же отмечу, что мы перемещаем только вектор сброса, в отличие от аппноута, где мы вынуждены перенаправлять все прерывания.

Передача управления основной программе

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

Собственно передача управления подсмотрена в аппноуте:

    asm("LDW X,  SP ");
    asm("LD  A,  $FF");
    asm("LD  XL, A  ");
    asm("LDW SP, X  ");
    asm("JPF " RESET_VECTOR_ASM);
RESET_VECTOR_ASM - адрес памяти, куда мы сохранили оригинальную инструкцию перехода.

Выбором режима "загрузчик"/"основная программа" в прототипе является соответственно низкий/высокий уровень на выводе PD2. Это сделано только для удобства отладки (гораздо лучше будет использовать вывод PD1, который является выводом отладочного интерфейса SWIM). Так же режим загрузчика включается, если вектор сброса не содержит "0x82" в первом байте, что расценивается как отсутствию пришивки.

В целях отладки так же добавлены команду вывода содержимого флэш/рам/еепром. Подробнее смотрите в исходниках.

Кто хочет проверить

Прошивка и исходники для stm8s103f3 - тут.

Подключение:

// PD5 -  2 in TSSOP20 - tx
// PD6 -  3 in TSSOP20 - rx
// PD4 -  1 in TSSOP20 - beeper
// PD3 - 20 in TSSOP20 - led
// PD2 - 19 in TSSOP20 - button

Прототип занимает порядка 1.7кБайт памяти, что очень много. Придется реализовывать на ассемблере, и желательно уложиться в 1кБайт. Иначе смысла в проекте не будет совершенно.

Файлы: stm8boot.zip