Немного про ARM ассемблер. Пишем многопоточную программу.

Пишем простую многопоточную программку на ARM ассемблере.

Ассемблер не плохой язык программирования. Одновременно и мощный и сложный, но не такой сложный как можно подумать. Предлагаю развенчать мифы об этом. Вспомнить наши корни и попробовать написать программу на ассемблере под ARM Cortex-M3, в качестве подопытного будем использовать отладку с алиэкспрес для микроконтроллера stm32f103c8.

Если заинтересовал - читайте дальше.

Проект будем делать в Keil. Создаем пустой проект и не добавляем ни одного пакета.

Код простейшей программы

Окунёмся же сразу в пучину ассемблерных команд. И по ходу пьессы буду объяснять некоторые тонкости.

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   0x00000100
__initial_sp

                PRESERVE8
                THUMB

                AREA    RESET, DATA, READONLY
__Vectors       DCD     __initial_sp    ; Указатель вершины основного стека
                DCD     Start           ; Указатель на начало программы
                SPACE   (12 * 4)        ; Пропуск 12-и ненужных векторов
                DCD     PendSV_Handler  ; 
                DCD     SysTick_Handler ; 
                                        ; тут ещё могут быть прерывания
                PRESERVE8

                AREA    |.text|, CODE, READONLY
Start           PROC
                BL      INIT_MCU        ; Инициализация микроконтроллера
INF_LOOP        B       .               ; Вечный цикл
                ENDP

Этот код ничего не делает.

Читайте сверху вниз.

Сначала размечается область для стэка в оперативной памяти, за подробностями обращайтесь к документации (ссылки оставлю в конце заметки). В области STACK резервируются 0x100 байт памяти. __initial_sp - это метка(указатель) на вершину стэка. Стек здесь растёт в сторону уменьшения адреса.

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

PRESERVE8 - необходимо для выравнивания.

Далее идёт область с кодом, что и понятно исходя из описания области. В ней как раз располагаются все алгоритмы, подпрограммы и функции. И наша функция не исключения, в ней вызывается функция для инициализации микроконтроллера, а затем переходим в бесконечный цикл. Метка INF_LOOP здесь не обвязательно, так как переход осуществляется командой B ., она нужна будет в будущём при работе с несколькими потоками.

Разобрали простейщий код - пора приступать к более значительной задаче, будем управлять 4-я потоками, два из них будут выводить в USART1 разный текст, и два будут мигать светодиодами с разной частотой.

Сразу говорю, полный код я не дам, я лишь расскажу как всё это делается.

Потоки (задачи)

И так, как же будет выглядеть подпрограмма реализующая наш поток:

TASK_LED        PROC
                ; Для BitBand под рукой всегда должны быть нолик и единичка
                MOV     R11, #0
                MOV     R12, #1
                ; BitBand для PC13.ODR
                MOV32   R0, (GPIOC_ODR & 0x00FFFFFF) * 0x20  + 0x42000000 + 13 * 4      

                ; Цикл с мигалкой
SCL             STR     R12, [R0]           ; Выключаем светодиод
                BL      Delay               ; Вызов функции задержки

                STR     R11, [R0]       ; Включаем светодиод
                BL      Delay           ; Вызов функции задержки
                B       SCL

                BX      LR
                ENDP

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

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

А именно, при возникновении прерывания в стек автоматически сохраняются некоторые регистры, в таком порядке: xPSR, 'PC', LR, R12, R3, R2, R1, R0. Следовательно стек задачи нужно стразу инициализировать нужными значениями:

FILL_STACK_TASK PROC
                PUSH    {LR}

                ; Значения по-умолчанию для заполнения стэков
                MOV32   R1, #0x01000000 ; Значение для xPSR
                MOV32   R3, #0x0 ; 
                LDR     R4, =INF_LOOP   ; Возврат из задачи в бесконечный цикл

                ; Регистры сохраняемые при возникновении прерывания
                STR     R1,  [R0, #-0x04] !     ;   xPSR    = 0x01000000
                STR     R2,  [R0, #-0x04] !     ;   PC      = &TASK & 0xfffffffe
                STR     R4,  [R0, #-0x04] !     ;   LR      = INF_LOOP
                STR     R3,  [R0, #-0x04] !     ;   R12     = 0
                STR     R3,  [R0, #-0x04] !     ;   R3      = 0
                STR     R3,  [R0, #-0x04] !     ;   R2      = 0
                STR     R3,  [R0, #-0x04] !     ;   R1      = 0
                STR     R3,  [R0, #-0x04] !     ;   R0      = 0
                ; Дополнительные регистры тоде можно сохранить

                POP     {PC}
                ENDP

Здесь в R0 хранится указатель на стек этой задачи, в ресистре R2 - указатель на процедуру задачи, с наложеной маской 0xfffffffe (Это магия возврата из прерываний, в документации на ядро можно почитать более подробно), как уже заметили ригистр связи LR инициализируем указателем на наш бесконечный цикл, на случай если выйдем из процедуры, если этого не сделать можно словить фатальную ошибку, а так нет, просто будем вхолостую гонять вечный цикл вместо выполнения этой задачи.

Переключение контекста

Идём дальше, мы реализовали одну из задач, реализовали процедуру инициализации стека, осталось научиться переключать контекст. Первое что приходит на ум, это переключать его в прерывании с определенным интервалом времени, пусть будет милисекунда. Системный таймер? И да и нет. Если просто переключить контекст в прерывании системного таймера, то можно также словить фатальную ошибку. Необходимо использовать специальное прерывание, которое есть в ядре ARM Cortex-M3 - PendSV.

PendSV прерывание должно иметь самый низкий приоритет, что бы оно гарантированно было выполнено после всех остальных прерываний. И ещё это программное прерывание, это означает что его нужно принудительно вызывать:

                MOV32   R0, ICSR
                MOV32   R1, SCB_ICSR_PENDSVSET
                STR     R1, [R0]

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

Переключение контекста будет выполняться по следующему алгоритму:

  • Если мы первый раз вошли в планировщик, то мы просто меняем значение регистра SP на указатель стека первой задачи и завершаем прерывание, всё, теперь мы выполняем первую задачу до возникновения следующего прерывания переключения контекста.
  • Теперь нам необходимо сохранить адрес стэка из регистра SP, взять адрес следующего стека и применить его, выйти из прерывания. Теперь мы выполняем следующую задачу и так далее.

Код реализующий это может выглядить примерно так:

PendSV_Handler  PROC
                PUSH    {r4-r11}            ; Сохраняем дополнительные регистры

                ; Сбрасываем флаг прерывания
                MOV32   R0, ICSR        
                MOV32   R1, SCB_ICSR_PENDSVCLR
                STR     R1, [R0]
                ; Получаем ячейку с нужным стэком
                LDR     R0, =Task_Runing
                LDR     R1, [R0]            ; Номер текущего стэка
                AND     R1, R1, #0x3        ; Очищаем лишнее по маске 
                ; Получаем указатель на указатель на текущий стек
                LDR     R2, =Task_SP        ; Указатель на массив указателей стеков
                MOV     R12, #4             ; Количество байт в слове
                MLA     R12, R12, R1, R2    ; ADR = 4* N + ADRSP[]
                LDR     R3, [R12]           ; Адрес стека для нужной задачи

                ; Проверяем первый ли это запуск планировщика
                LDR     R4, =Task_IsRuning
                LDR     R5, [R4]
                CMP     R5, #1              
                BEQ     IS_NOFIRST  

                ; Это первый запуск планировщика
                MOV     R5, #1
                STR     R5, [R4]
                MSR     MSP, R3             ; Переключаем стэк на первую задачу
                B       EXIT_PSV

IS_NOFIRST      ; Не первый раз мы здесь               
                MRS     R6, MSP             ; получаем SP
                STR     R6, [R12]

                ; Номер следующего стэка
                ADD     R1, R1, #1
                AND     R1, R1, #0x3        
                STR     R1, [R0]
                MOV     R12, #4
                MLA     R12, R12, R1, R2
                ; Адрес стека для задачи
                LDR     R3, [R12]           
                ; Переключаем стэк на первую задачу
                MSR     MSP, R3

EXIT_PSV
                POP     {r4-r11}            ; Восстанавливаем дополнительные регистры
                BX      LR
                ALIGN   4
                ENDP

                PRESERVE8

Да и кстати нужно не забыть выделить память для всех наших переменных, что-то вроде этого:

;-----------------------СЕКЦИЯ СО СТЭКАМИ ЗАДАЧ----------------------------------------------------
                AREA    STACK_TASK, DATA, READWRITE, ALIGN=3    ; Секция со стеками задач
Stack_A_Mem     SPACE   0x00000100                              ; 
Stack_A                                                         ; указатель на вершину A
Stack_B_Mem     SPACE   0x00000100                              ; 
Stack_B                                                         ; указатель на вершину B
Stack_C_Mem     SPACE   0x00000100                              ; 
Stack_C                                                         ; указатель на вершину C
Stack_D_Mem     SPACE   0x00000100                              ; 
Stack_D                                                         ; указатель на вершину D

;-----------------------СЕКЦИЯ С ПЕРЕМЕННЫМИ-------------------------------------------------------
                AREA    VAR, DATA, READWRITE, ALIGN=3
Task_IsRuning   DCD     0                                       ; Номер текущей задачи
Task_Runing     DCD     0                                       ; Номер текущей задачи
Task_SP         DCD     Stack_A, Stack_B, Stack_C, Stack_D      ; Указатели стека
                ALIGN

Литература:

  1. RM0008 Reference manual, - Описание микроконтроллера
  2. PM0056 Programming manual - Описание ядра Cortex®-M3 и его команд.
  3. ARM and Thumb instruction

PS

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

Как обычно, хорошего кодинга и поменьше багов.