A first preemptive kernel on ARM Cortex-M3

1. Introduction

When it comes to operating systems for embedded software, they are generally thought to be an overkill for most solutions. However, between a completely “hosted” multiapplication system (which uses a multi thread and general purpose OS) and a completely “standalone/monolithic” or “bare-metal” application specific system, there are many variations we can go for.

In previous publications I explored the notion of cooperative tasks, from a loop calling tasks from an array of function pointers, to something a little more complex with the processes being handled in a circular buffer, with explicit time criteria. This time I will show a minimal preemptive implementation, to also pave the way for something a little more complex, as in other publications.

2 Preemptive versus Cooperative

In a fully cooperative system, the processor does not interrupt any task to accept another, and the task itself needs to release the processor for the next one to use it. There is nothing wrong with this. A Run-To-Completion scheme is sufficient and/or necessary for many applications, and many embedded systems were deployed this way, including some very complex ones. In the past, even non-embedded systems used a cooperative kernel (Windows 3.x, NetWare 4.x, among others). If a task crashes, the entire system is compromised when we speak in a strictly cooperative way: it keeps the processor from going further (so in a server operating system like NetWare, this does not seem to be a good idea, because multiple clients are a must!).

In preemptive mode, tasks are interrupted and later resumed– i.e., a context (set of states in the processor registers) is saved and then retrieved. This leads to more complexity to the implementation but, if well done, it increases the robustness and the possibility of meeting narrower timing requirements, mainly if used with a priority and/or periodicity criteria to manage the queue.

3 Call stack

A processor is, in fact, a programmable finite-state machine. With some simplification, each state of our program can be defined within the set of core register values . This set dictates which program point is active. Therefore, activating a task means pushing values ​​to the call stack so that this task will be processed. This set of values is called context. To resume the task afterwards, it is necessary to save the call stack data at that point in the program. This “frozen” data represents a program state and is therefore called a stack frame. For every saved stackframe there is a context related. To resume a task, a previously saved stack frame is loaded back into the call stack.

In the ARM Cortex-M3, the 32-bit registers that define the active state of the processor are: R0-R12 for general use and R13-R15 registers for special use, in addition to the Program Status Register (xPSR) – its value is on the top of any stackframe, and it is not actually a single physical register, but a composition of three (Application, Interrupt and Execution: APSR, IPSR e EPSR).

Esta imagem possuí um atributo alt vazio; O nome do arquivo é armcortexm3arch-1.png

3.1. Load-store architectures

A load-store architecture is a processor architecture in which data from memory needs to be loaded to the core registers before being processed. Also, the result of this processing before being stored in memory must be in a register.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é registersm3-1.png

The two basic memory access operations on Cortex-M3:

// reads the data contained in the address indicated by Rn + offset and places it in Rn. 
LDR Rd, [Rn, #offset]
// stores data contained in Rn at the address pointed by Rd + offset
STR Rn, [Rd, #offset]

It is important to understand at least the Cortex-M3 instructions shown below. I suggest sources [1] and [2] as good references, in addition to this or this link.

MOV Rd, Rn // Rd = Rn
MOV Rd, #M // Rd = M, the immediate being a 32-bit value (here represented by M)
ADD Rd, Rn, Rm // Rd = Rn + Rm
ADD Rd, Rn, #M // Rd = Rn + M
SUB Rd, Rn, Rm // Rd = Rn - Rm
SUB Rd, Rn, #M // Rd = Rn - M
// pseudo-instruction to save Rn in a memory location.
// After a PUSH, the value of the stack pointer is decreased by 4 bytes
// POP increases SP by 4 bytes after loading data into Rn.
// this increase-decrease is based on the current address the SP is pointing to
POP {Rn}
B label // jump to routine label
BX Rm // jump to routine specified indirectly by Rm
BL label // jump to label and moves the caller address to LR

CPSID I // enable interrupts
CPSIE I // disable interrupts

We will operate the M3 in Thumb mode , where the instructions are actually 16 bits. According to ARM , this is done to improve code density while maintaining the benefits of a 32-bit architecture. Bit 24 of the PSR is always 1.

3.2. Stacks and stack pointer (SP)

Stack is a memory usage model [1]. It works in the Last In – First Out format (last to enter, first to leave). It is as if I organized a pile of documents to read. It is convenient that the first document to be read is at the top of the stack, and the last at the end.

We usually divide the memory between heap and stack . As said, the “call stack” will contain that temporary data that determines the next state of the processor. The heap contains data which nature is not temporary in the course of the program (this does not mean “non-volatile”). The stack pointer is a kind of pivot that keeps control of the program flow, by pointing to some position of the stack.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é stackusage.png
Figure 3. Model for using a stack. When saving data before processing (transforming) it saves the previous information. (Figure from [1])
Esta imagem possuí um atributo alt vazio; O nome do arquivo é annotation-2020-02-06-001056.png
Figure 4. Regions of memory mapped on a Cortex M3. The region available for the stack is confined between the addresses 0x20008000 and 0 0x20007C00. [1]

4 Multitasking on the ARM Cortex M3

The M3 offers two stack pointers (Main Stack Pointer and Process Stack Pointer) to isolate user processes from the kernel processes. Every interrupt service runs in kernel mode. It is not possible to go from user mode to kernel mode (actually called thread mode and privileged mode, respectively) without going through an interruption – but it is possible to go from privileged mode to user mode by changing the control register.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é so.png
Figure 5. Changing context on an OS that isolates the kernel application [1]

The core also has dedicated hardware for switching tasks. The SysTick interrupt service can be used to implement synchronous context switching. There are still other asynchronous software interruptions (traps) like PendSV and SVC. Thus, SysTick is used for synchronous tasks in the kernel, while SVC serves asynchronous interruptions, when the application makes a call to the system. The PendSV  is a software interruption which by default can only be triggered in privileged mode. It is usually suggested [1] to trigger it within SysTick service, because it is possible to keep track of the ticks to meet the time criteria. The interruption by SysTick is soon served, with no risk of losing any tick of the clock. A secure OS implementation would use both stack pointers to separate user and kernel threads and separate memory domains if an MPU (Memory Protection Unit) is available. At first, we will only use the MSP in privileged mode.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é 2sps.png
Figure 6. Memory layout on an OS with two stack pointers and protected memory [1]

5. Building the kernel

Kernel is a somewhat broad concept, but I believe that there is no OS which the kernel is not responsible for scheduling tasks. In addition, there must be IPC (inter-process communication) mechanisms. It is interesting to note the strong hardware-dependency of the scheduler that will be shown, due to its low-level nature .

5.1. Stackframes and context switching

When a SysTick is served, part of the call stack is saved by the hardware (R0, R1, R2, R3, R12, R14 (LR) and R15 (PC) and PSR). Let’s call this portion saved by the hardware stackframe. The remaining is the software stackframe [3], which we must explicitly save and retrieve with the PUSH and POP instructions .

To think about our system, we can outline a complete context switch depicting the key positions the stack pointer assumes during the operation (in the figure below the memory addresses increase, from bottom to top. When SP points to R4 it is aligned with an address lower than the PC on the stack)

Esta imagem possuí um atributo alt vazio; O nome do arquivo é ctxtswitch-1.png
Figure 7. Switching contexts. The active task is saved by the hardware and the kernel. The stackpointer is re-assigned, according to pre-established criteria, to the R4 of the next stackframe to be activated. The data is rescued. The task is performed. (Figure based on [3]) (“Salvo pelo hardware/kernel” translates to “Saved /pushed by hardware/kernel”; “Resgatado pelo hardware/kernel” translates to “retrieved/popped by hardware/kernel”)

When an interruption takes place, SP will be pointing to the top of the stack (SP (O)) to be saved. This is inevitable because this is how the M3 works. In an interruption the hardware will save the first 8 highest registers in the call stack at the 8 addresses below the stack pointer, stopping at (SP (1)). When we save the remaining registers, the SP will now be pointing to the R4 of the current stack (SP (2)). When we reassign the SP to the address that points to the R4 of the next stack (SP (3)), the POP throws the values of R4-R11 to the call stack and the stack pointer is now at (SP (4)). Finally, the return from the interrupt pops the remaining stackframe, and the SP (5) is at the top of the stack that has just been activated. (If you’re wondering where R13 is: it stores the value of the stack pointer)

The context switching routine is written in assembly and implements exactly what is described in Figure 7.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é systick-2.png
Figure 8. Context switcher

PS: When an interruption occurs, the LR takes on a special code. 0xFFFFFFF9 , if the interrupted thread was using MSP or 0xFFFFFFFD if the interrupted thread was using PSP.

5.1 Initializing the stacks for each task

For the above strategy to work, we need to initialize the stacks for each task accordingly. The sp starts by pointing to R4. This is by definition the starting stack pointer of a task, as it is the lowest address in a frame .

In addition, we need to create a data structure that correctly points to the stacks that will be activated for each SysTick service . We usually call this structure a TCB (thread control block). For the time being we do not use any selection criteria and therefore there are no control parameters other than next: when a task is interrupted, the next one in the queue will be resumed and executed.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é struct.png
Figure 9. Thread control block
Esta imagem possuí um atributo alt vazio; O nome do arquivo é stackinit-3.png
Figure 10. Initializing the stack (the values representing the registers, like 0x07070707 are for debugging purposes)

The kSetInitStack function initializes the stack for each “i” thread . The stack pointer in the associated TCB points to the data relating to R4. The stack data is initialized with the register number, to facilitate debugging. The PSR only needs to be initialized with bit 24 as 1, which is the bit that identifies Thumb mode . The tasks have the signature void Task (void * args) .

To add a task to the stack, we initially need the address of the main function of the task. In addition, we will also pass an argument. The first argument is in R0. If more arguments are needed, other registers can be used, according to AAPCS (ARM Application Procedure Call Standard).

Esta imagem possuí um atributo alt vazio; O nome do arquivo é addthreads-1.png
Figure 11. Routine for adding tasks and their arguments to the initial stackframe

5.3. Kernel start-up

It is not enough to initialize the stacks and wait for the SysTick. The TCB structure sp will only hold a valid stack pointer value when the task is interrupted. We have two types of threads running: background and foreground threads. The background includes the kernel routines, including the context switcher. At each SysTick, it is the kernel’s turn to use the processor. In the foreground are the applications.

In [2] a start-up routine is suggested:

Esta imagem possuí um atributo alt vazio; O nome do arquivo é kstart-1.png
Figure 13. Routine for booting the kernel

6. Putting it all together

To illustrate, we will perform, in Round-Robin 3 tasks that switch the output of 3 different pins and increment three different counters. The time between a change of context and another will be 1000 cycles of the main clock. Note that these functions run within a “while (1) {}”. It is like we have several main programs running on the foreground . Each stack has 64 x 4-byte elements (256 bytes).

Esta imagem possuí um atributo alt vazio; O nome do arquivo é tasks-1.png
Figure 14. System Tasks

Below the main function of the system. The hardware is initialized. Tasks are added and the stacks are initialized. The RunPtr receives the address of the thread.  After setting the SysTick to trigger every 1000 clock cycles, boot up the kernel . After executing the first task and being interrupted, the system is switching between one task and another, with the context switcher running in the background .

Esta imagem possuí um atributo alt vazio; O nome do arquivo é main.png
Figure 15. Main program

6.1. Debug

You will need at least a simulator to implement the system more easily, as you will need to access the core registers and see the data moving in the stacks. If the system is working, each time the debugger is paused, the counters should have almost the same value.

In the photo below, I use an Arduino Due board with an Atmel SAM3X8E processor and an Atmel ICE debugger connected to the board’s JTAG. On the oscilloscope you can see the waveforms of the outputs switching for each of the 3 tasks.

Esta imagem possuí um atributo alt vazio; O nome do arquivo é 20200209_163936.jpg
Figure 16. Debug bench
Esta imagem possuí um atributo alt vazio; O nome do arquivo é ds1z_quickprint1-1.png
Figure 17. Tasks 1, 2, 3 on the oscilloscope.

7 Conclusions

The implementation of a preemptive kernel requires reasonable knowledge of the processor architecture to be used. Loading the call stack registers and saving them in a “handmade” way allows us to have greater control of the system at the expense of the complexity of handling the stacks.

The example presented here is a minimal example where each task is given the same amount of time to be performed. After that time, the task is interrupted and suspended – the data from the call stack is saved. This saved set is called a stackframe – a “photograph” of the point at the program was. The next task to be performed is loaded at the point it was interrupted and resumed. The code was written in order to explain the concepts.

In the next publication we will split the threads between user mode and privileged mode – using two stack pointers – a fundamental requirement for a more secure system.


The text of this post as well as the non-referenced figures are from the author.
[1] The definitive guide to ARM Cortex-M3, Joseph Yiu
[2] Real-Time Operating Systems for the ARM Cortex-M, Jonathan Valvano
[3] https://www.embedded.com/taking-advantage-of-the-cortex-m3s-pre-emptive-context-switches/

Separando espaços de supervisor e usuário no ARM Cortex-M3

1. Introdução

Os processadores ARM Cortex-M estão integrados a SoCs para domínios diversos, principalmente em muito daquilo que chamamos de dispositivos smart. Esta publicação é uma continuação da anterior, em que demonstrei a implementação de um mecanismo preemptivo mínimo para um ARM Cortex-M3, aproveitando-se dos recursos de hardware especiais para a troca de contexto.

Alguns recursos importantes desta arquitetura serão agora demonstrados como a separação de threads de usuário e kernel – com 2 stack pointers: MSP e PSP e a utilização de Supervisor Calls para implementar chamadas ao sistema.

Apesar de alguns conceitos sobre sistemas operacionais serem abordados porque são inerentes ao assunto, o objetivo é a exploração do ARM Cortex-M3, um processador relativamente barato e de larga aplicabilidade, e como aproveitá-lo para desenvolver sistemas mais robustos.

2. Registradores especiais

São 3 os registradores especiais no ARM Cortex-M3. Você pode consultar a documentação da ARM para entender o papel de cada um deles no processador. O mais importante nesta publicação é o CONTROL.

  • Program Status Registers (APSR, IPSR, e EPSR)
  • Interrupt Mask Registers (PRIMASK, FAULTMASK e BASEPRI)
  • Control (CONTROL).

Registradores especiais somente podem ser acessado através das instruções privilegiadas MRS (ARM Read Special Register) e MSR (ARM Set Special Register):

//Carrega em R0 o atual valor contido no registrador especial
//Carrega no registrador especial o valor contido em R0)

O registrador CONTROL tem somente 2 bits configuráveis. Quando um serviço de excepção (e.g., SysTick_Handler) estiver a ser executado, o processador estará em modo privilegiado e utilizando o main stack pointer (MSP), e CONTROL[1] = 0, CONTROL[0] = 0. Em outras rotinas que não sejam handlers, este registrador pode assumir diferentes valores a depender da implementação do software (Tabela 1).

No pequeno kernel mostrado anteriormente, as tasks da aplicação (Task1, Task2 e Task3) também eram executadas em modo privilegiado e utilizando o stack pointer principal (MSP). Assim, um programa da aplicação poderia alterar os registradores especiais do core se quisesse.

3. Sistema com 2 stack pointers e diferentes privilégios

Na última publicação ressaltei o fato de o registrador R13 não fazer parte do stackframe, pois é ele justamente que guarda o endereço do stack pointer. O R13 é um registrador do tipo “banked” (não conheço uma boa tradução para português), significando que ele é fisicamente replicado, e assume um valor ou outro a depender do estado do core.

CTRL[1] (0=MSP/1=PSP)CTRL[0] (0=Priv, 1=Non priv)Estado
00Privileged handler* / Base mode
10Privileged thread
11User thread
Tabela 1. Estados possíveis do registrador CONTROL
*em rotinas que atendem às exceções este modo estará sempre ativo mesmo que o CTRL[0] = 1.

Com dois stack pointers, um para aplicação e outro para o kernel, significa que uma thread de usuário não poderá facilmente corromper o stack pointer do kernel por um erro de programação na aplicação. Os privilégios por sua vez evitam que a aplicação sobrescreva registradores especiais. De acordo com os manuais da ARM, um sistema operacional robusto tipicamente tem as seguintes características:

  • serviços de interrupção utilizam o MSP (por default da arquitetura)
  • rotinas do kernel são ativadas através do SysTick em intervalos regulares para executar, em modo privilegiado, o escalonamento das tarefas e gerenciamento do sistema
  • aplicações de usuário são executadas como threads, usam o PSP em modo não-privilegiado
  • a memória para as stacks do kernel e handlers são apontadas pelo MSP, sendo que os dados stack só podem ser acessados em modo privilegiado*
  • a memória para a stack das aplicações é apontada pelo PSP

*por enquanto não vamos isolar os espaços de memória

4. Chamadas ao sistema (System Calls)

Em uma perspectiva simples, uma chamada de sistema é um método no qual um software requisita um serviço/mecanismo do kernel ou SO sobre o qual está rodando: gerenciamento de processos (escalonamento, mecanismos de IPC), acesso a algum recurso de hardware, sistema de arquivos, entre outros, a depender da arquitetura do sistema operacional.

Se pretendemos separar nosso sistema em níveis de privilégio é inevitável que o nível da aplicação precise fazer chamadas ao kernel para ter acesso a, por exemplo, serviços de hardware ou o que mais julgarmos crítico para a segurança e estabilidade do sistema.

Uma maneira comum de implementar system calls no ARM Cortex-M3 (e em outros cores baseados em ARMv7) é a utilização da interrupção por software chamada Supervisor Call (SVC). O SVC funciona como um ponto de entrada para um serviço que necessita de privilégios para ser executado. O único parâmetro de entrada de um SVC é o seu número (instrução ASM: SVC #N), ao qual associamos a uma chamada de função (callback). Ao contrário da outra excepção disparada via software disponível, que é o PendSV (Pendable Supervisor Call), o SVC pode ser disparado em modo usuário. Apesar de serem destinadas a diferentes usos – o PendSV é tipicamente usado como forma de “agendar” tarefas do kernel menos críticas e não perder ticks do sistema, é possível configurar o core para que usuários também possam disparar o PendSV.

Figura 2. Diagrama de blocos de uma possível arquitetura de sistema operacional no M3. Supervisor Calls funcionam como ponto de entrada para serviços privilegiados. [2]

5. Design

5.1 Utilização de dois stack pointers

Para a utilizar os dois stack pointers disponíveis (MSP e PSP) é fundamental entender 2 coisas:

  • A manipulação do registrador de controle: só é possível escrever ou ler o registrador de controle em modo handler (na rotina que atende à uma exceção) ou em threads privilegiadas.
  • O mecanismo de exceções: quando uma interrupção ocorre, o processador guarda na stack o conteúdo dos registradores R0-R3, LR, PC e xPSR, como explicado na publicação anterior. O valor do LR quando entramos em uma exceção indica o modo que o processador estava a rodar, quando a thread foi interrompida. Podemos manipular este valor de LR juntamente com a manipulação dos stack pointer para controlar o fluxo do programa.
0xFFFFFFF9Volta para modo “base”, MSP privilegiado. (CONTROL=0b00)
0xFFFFFFFDVolta para user mode (PSP, com o nível de privilégio da entrada) (Control = 0b1x)
0xFFFFFFF1Volta para a interrupção anterior, no caso de uma interrupção de maior prioridade ocorrer durante uma de menor prioridade.
Tabela 2. Valores para retorno de exceção

5.1.1. Um kernel stack para cada user stack

Cada stack dedicada ao usuário terá uma stack de kernel correspondente (one kernel stack per thread). Assim, cada Task (thread principal) terá stacks de kernel e usuário associadas. Uma outra abordagem seria uma stack somente de kernel no sistema (one kernel stack per processor). A vantagem de utilizar a primeira abordagem é que além das threads do kernel poderem ser preemptivas, do ponto de vista de quem implementa o sistema, os programas que rodam no kernel seguem o mesmo padrão de desenvolvimento dos programas de aplicação. A vantagem da segunda abordagem é menor overhead de memória e menor latência nas trocas de contexto.

Figura 3. Cada user stack tem uma kernel stack associada

5.2 Mecanismos de entrada e saída do kernel

Na publicação anterior a interrupção feita pelo SysTick manejava a troca de contexto, i.e., interrompia a thread em execução, salvava seu stackframe, buscava a próxima thread apontada pelo campo next na estrutura TCB (thread control block) e a resumia.

Com a separação dos espaços de usuário e supervisor, teremos dois mecanismos para entrada e saída do kernel: as system calls, explicitamente chamadas em código, e a interrupção por SysTick que implementa a lógica de escalonamento (scheduling). Outro ponto a destacar, é que apesar de continuar utilizando um esquema round-robin simples, em que cada tarefa tem o mesmo tempo de execução, as threads do kernel também funcionarão de forma cooperativa com a a thread do usuário que a evocou, isto é: quando não houver mais nada a ser feito, o kernel pode explicitamente retornar. Se a thread do kernel demorar mais que o tempo entre um tick e outro, será interrompida e reagendada. As tarefas do usuário também poderiam utilizar-se de mecanismo similar, entretanto, por simplicidade para exposição, optei por deixar as tarefas de usuário somente em esquema round-robin fixo.

5.2.1. Escalonador (task scheduler)

O fluxograma do escalonador preemptivo a ser implementado está na Figura 4. O start-up do kernel e aplicação do usuário é também mostrado para maior clareza. O kernel é inicializado e voluntariamente inicia a primeira task de usuário. A cada interrupção por SysTick, a thread tem seu estado salvo e a próxima task agendada é resumida consoante ao estado em que foi interrompida: kernel ou user mode.

Figura 4. Fluxograma do escalonador

5.2.2 Chamadas ao sistema (System Calls)

As chamadas ao sistema ocorrem quando o usuário requisita acesso a um serviço privilegiado. Além disso, também utilizo o mesmo mecanismo para que tarefas privilegiadas do kernel retornem cooperativamente à tarefa do usuário.

Perceba que optei por não executar o as threads de kernel dentro do próprio handler, o que seria mais intuitivo, além de usual. As razões para isto são porque quis aproveitar o próprio mecanismo de interrupção do processador, que no seu retorno faz o POP dos registradores RO-R3, LR, PC e xPSR, e também para evitar o aninhamento de interrupções durante a preempção de tarefas do kernel. Caso eu tivesse optado por utilizar somente uma kernel stack para todas as threads, a implementação dentro do próprio handler julgo que seria melhor.

Figura 5. Fluxograma das system calls

6. Implementação

Abaixo explico os códigos criados para implementar as provas de conceito das funcionalidades anteriormente descritas. A maior parte do kernel propriamente dito é escrito em assembly, exceto para uma porção do handler de supervisor calls que é escrita em C com algum inline assembly. Na minha opinião, mais trabalhoso e suscetível a erros que escrever em assembly é embutir assembly no código C. A toolchain utilizada é a GNU ARM.

6.1. Stacks

Não há nada em especial aqui, exceto que agora além da stack de usuário, declaramos outro array de inteiros para a stack do kernel. Estes serão associados no Thread Control Block.

int32_t p_stacks[NTHREADS][STACK_SIZE]; // stack de usuário
int32_t k_stacks[NTHREADS][STACK_SIZE]; // stack do kernel

6.2. Task Scheduler

A principal diferença deste para o escalonador mostrado na última publicação é que agora manejaremos 2 stack pointers distintos: o MSP e o PSP. Assim, quando entramos em um handler de exceção, a porção do stackframe salvo automaticamente depende do stack pointer utilizado quando a exceção foi disparada. Entretanto na rotina de exceção o stack pointer ativo é sempre o MSP. Desta forma, para podermos manejar um stack pointer quando estamos a operar com outro, não poderemos utilizar as instruções PUSH e POP porque estas têm como endereço base o stack pointer ativo. Teremos de substituí-las pelas instruções LDMIA (load multiple and increment after, i.e, após o primeiro load incrementa-se o endereço base) para o POP, e STMDB (store multiple decrement before, antes do primeiro store decrementa-se o endereço base) para o PUSH, com o sinal de writeback “!” no endereço base [1].

// Exemplo de POP 
MRS R12, PSP // lê o valor do process stack pointer em R12
LDMIA R12!, {R4-R11} // R12 contém o endereço base (PSP)
/* o endereço contido em R12 agora armazena o valor de R4, [R12] + 0x4  
 contém o valor de R5, e assim por diante até que [R12] + 0x20 contém o 
 valor de R11.
 o valor inicial de R12 também é incrementado em 32 bytes 
MSR PSP, R12 // PSP é atualizado para o novo valor de R12

// Exemplo de PUSH
STMDB R12!, {R4-R11} 
/* [R12] - 0x20 contém R4, [R12] - 0x16 contém R5, ..., [R12] contém R4 
 o valor inicial de R12 é decrementado em 32 bytes */
MSR MSP, R12 // MSP é atualizado para o novo valor de R12 

Outra diferença é que agora a estrutura TCB precisa conter um ponteiro para cada um dos stack pointers da thread que controla, e também uma flag indicando se a tarefa a ser resumida estava utilizando o MSP ou o PSP quando foi interrompida.

// thread control block
struct tcb 
  int32_t*	psp; //psp salvo da ultima user thread interrompida
  int32_t*	ksp; //ksp salvo da ultima kernel thread interrompida	
  struct tcb	*next; //aponta para o proximo tcb
  int32_t	pid; //id da task (é o "i" do kSetInitStack(i))
  int32_t	kernel_flag; // 0=kernel, 1=user	
typedef struct tcb tcb_t;
tcb_t tcb[NTHREADS]; //array de tcbs
tcb_t* RunPtr; 

Abaixo a rotina que escrevi para implementação. O código foi escrito de forma a ficar claro em sua intenção, sem tentar economizar instruções. Perceba que na linha 5 o valor de LR assumido na entrada da excepção só é comparado com 0xFFFFFFFD, caso falso assume-se que ele é 0xFFFFFFFF9, isto porque garanto que não haverá interrupções aninhadas (o SysTick nunca interrompe um SVC, por exemplo), assim o LR nunca deve assumir 0xFFFFFFF1. Para propósitos que não uma prova de conceito, o teste deveria ser considerado.

.global SysTick_Handler
.type SysTick_Handler, %function
CPSID	I //atomica inicio
CMP	LR, #0xFFFFFFFD //verifica se retornou de uma user thread
BEQ	SaveUserCtxt	//se sim, branch para save user context
B	SaveKernelCtxt  //se nao

STMDB	R12!, {R4-R11}  //push R4-R11
LDR	R0,=RunPtr      //RunPtr aponta para a tcb atual
LDR	R1, [R0]
LDR	R2, [R1,#4]
STR	R12, [R2]  //salva stack pointer
B	Schedule

STMB	R12!, {R4-R11}
LDR	R0,=RunPtr
LDR	R1, [R0]
STR	R12, [R1]		
B	Schedule

LDR	R1, =RunPtr //R1 <- RunPtr
LDR	R2, [R1]	
LDR	R2, [R2,#8] //R2 <- RunPtr.next
STR	R2, [R1]    //atualiza valor de RunPtr
LDR	R0, =RunPtr
LDR	R1, [R0]
LDR	R2, [R1,#16]
CMP	R2, #1	     //verifica se kernel_flag=1
BEQ	ResumeUser   //sim, resume user thread
B	ResumeKernel //nao, resume kernel thread

LDR	R1, =RunPtr  //R1 <- RunPtr
LDR	R2, [R1]
LDR	R2, [R2]
LDMIA	R2!, {R4-R11} //Resgata sw stackframe 
MOV	LR, #0xFFFFFFFD	//LR=return to user thread
CPSIE	I		//atomica fim

LDR	R1, =RunPtr   //R1 <- RunPtr atualizado
LDR	R2, [R1]
LDR	R2, [R2, #4]
LDMIA	R2!, {R4-R11} //Resgata sw stackframe 
MOV	LR, #0xFFFFFFF9	 //LR=return to kernel thread
CPSIE	I		 //atomic fim

6.3 System Calls

A implementação dos system calls utiliza o SVC Handler. Como dito, o único parâmetro de entrada do SVC é o número que associamos a um callback. Mas então como passamos os argumentos para o callback? Eles precisam ser buscados na stack. O padrão AAPCS (ARM Application Procedure Call Standard), que é seguido pelos compiladores, diz que quando uma função (caller) chama outra função (callee), o callee espera que seus argumentos estejam em R0-R3. Da mesma forma, o caller espera que o retorno do callee esteja em R0. R4-R11 precisam ser preservados entre uma chamada e outra. R12 é o scratch register e pode ser usado livremente.

Não à toa, quando uma exceção ocorre o core salva (PUSH) na stack os registradores R0-R3, LR, PC e xPSR da função que foi interrompida, e no retorno os lança (POP) novamente nos registradores do core. Se trocarmos de contexto, isto é, após a interrupção não retornarmos ao mesmo ponto do programa que foi interrompido, precisamos explicitamente salvar o restante da stackframe para que a thread seja resumida de forma íntegra. É fundamental seguir o AAPCS se quisermos evocar funções escritas em assembly em código C e vice-versa.

Para executar chamadas ao sistema defini uma função macro em C que recebe o código do SVC e os argumentos para a callback (a sintaxe de assembly inline depende do compilador utilizado).

(Há uma razão de eu ter criado uma macro e não uma função comum: tem a ver com o ponto de retorno à user thread e o fato de os callbacks do kernel não serem executados dentro da exceção, o que exige a troca de contexto. Se criasse uma função comum para o system call, o stack pointer do usuário seria salvo dentro da chamada, e ao retornar do kernel, o SVC seria novamente executado.)

#define SysCall(svc_number, args) {					   				      
	__ASM volatile ("MOV R0, %0 "     :: "r"            (args) );     \
	__ASM volatile ("svc %[immediate]"::[immediate] "I" (svc_number) : );   \

O valor de args é armazenado em R0. A chamada do SVC é feita com o imediato “svc_number”. Quando o SVC é disparado R0-R3 serão automaticamente salvos na stack. O código foi escrito da seguinte forma, sem economizar instruções, para clareza:

.global SVC_Handler
.type	SVC_Handler, %function
 MRS R12, PSP		 //salva psp
 BEQ KernelEntry
 B   KernelExit

//salva contexto do usuário
STMDB	R3!, {R4-R11}
LDR	R1,=RunPtr
LDR	R2, [R1]
STR	R3, [R2]	
LDR	R3, =#0  
STR	R3, [R1, #16] //kernel flag = 0
MOV	R0, R12   //psp da chamada no r0 do CORE pra recuperar svc number
B svchandler_main //branch para rotina em C 
//recarrega frame do usuário
LDR	R0, =RunPtr
LDR	R1, [R0]
LDR	R2, [R1]
LDMIA	R2!, {R4-R11}
LDR	R12, =#1 //kernel flag = 1
STR	R12, [R1, #16]

O restante da rotina para entrada no kernel é escrito em C [2, 3]. Perceba que na rotina escrita em assembly um branch simples ocorre (linha 20) e portanto ainda não retornamos do handler de exceção.

#define SysCall_GPIO_Toggle  1 //codigo svc para gpio toggle
#define SysCall_Uart_PrintLn 2 //codigo svc para uart print line

void svchandler_main(uint32_t * svc_args)
    uint32_t svc_number;
    uint32_t svc_arg0;
    uint32_t svc_arg1;
    svc_number = ((char *) svc_args[6])[-2]; // recupera o imediato 
    svc_arg0 = svc_args[0];
    svc_arg1 = svc_args[1]; 
 case SysCall_GPIO_Toggle: 
	k_stacks[RunPtr->pid][STACK_SIZE-2] = (int32_t)SysCallGPIO_Toggle_; //PC
	k_stacks[RunPtr->pid][STACK_SIZE-8] = (int32_t)svc_arg0; //R0
	k_stacks[RunPtr->pid][STACK_SIZE-1] = (1 << 24); // T=1 (xPSR)
	__ASM volatile ("MSR MSP, %0" : : "r" (RunPtr->ksp) : );
	__ASM volatile ("POP {R4-R11}");
	__ASM volatile ("MOV LR, #0xFFFFFFF9");
	__ASM volatile ("BX LR"); //retorna da excecao
 case SysCall_Uart_PrintLn: 
	k_stacks[RunPtr->pid][STACK_SIZE-2] = (int32_t)SysCallUART_PrintLn_; 
	k_stacks[RunPtr->pid][STACK_SIZE-8] = (int32_t)svc_arg0;
	k_stacks[RunPtr->pid][STACK_SIZE-1] = (1 << 24); // T=1
	__ASM volatile ("MSR MSP, %0" : : "r" (RunPtr->ksp) : );
	__ASM volatile ("POP {R4-R11}");
	__ASM volatile ("MOV LR, #0xFFFFFFF9");
	__ASM volatile ("BX LR"); //retorna da excecao
	__ASM volatile("B SysCall_Dummy");

O svc_number, por sua vez, é recuperado ao andarmos dois bytes (por isso o cast para char) descrescentes a partir endereço do PC que está 6 posições acima de R0 na stack [1, 2, 3]. Note que foi preciso assinalar a R0 o valor contido em PSP logo após a entrada na interrupção, antes de salvarmos o restante da stack (linhas 4 e 19 do código assembly).

Após recuperar o número do SVC e os argumentos, inicializamos a stack do kernel, MSP é sobrescrito com o valor armazenado no TCB, mudamos o valor de LR para que a exceção ao retornar vá para o modo base, pois não iremos executar o callback dentro do handler. Quando a instrução BX LR é executada o restante do stackframe é automaticamente ativado nos registradores do core.

Um callback tem a seguinte cara:

static void SysCall_CallBack_(void* args)
	BSP_Function((int32_t) args); //funcao do BSB com unico argumento int32
	exitKernel_(); // //qualquer chamada ao svc aqui ira sair do kernel (Figura 5)

6.4. Start-up

O start-up é um ponto crítico. O sistema inicializa em modo base. As stacks são montadas. A primeira tarefa a ser executada pelo kernel após a inicialização do sistema é configurar o SysTick, mudar para o modo de usuário e disparar a primeira thread de usuário.

//inicializacao da primeira stack
tcb_t* RunPtrStart;
RunPtrStart = tcb[0];
void kFirstThreadInit(void)
	kSetInitStack(0); /* esta função segue o mesmo padrão da última postagem */
	k_stacks[0][STACK_SIZE-2] = (int32_t) UsrAppStart; // LR = UsrAppStart
// RunPtr e o restante dos tcbs é configurado da mesma forma da última publicacao 

As rotinas em assembly para o startup são as seguintes:

.equ SYSTICK_CTRL, 0xE000E010 
.equ TIME_SLICE,	999

.global kStart  // esta é a função principal de init 
.type kStart, %function
LDR	R0, =RunPtrStart
LDR	R1, [R0]
LDR	R2, [R1,#4]
MSR	MSP, R2	  // MSP <- RunPtr.ksp
POP	{R4-R11}  //carrega stackframe 0 na callstack
POP	{R0-R3}
POP	{R12}
ADD	SP, SP, #4
POP	{LR}	 //LR <- PC = UsrAppStart
ADD	SP, SP, #4
BX	LR // vai para UsrAppStart

//esta função prepara a stack para disparar a primeira user thread
.global UsrAppStart 
.type	UsrAppStart, %function
LDR	R1, =RunPtr //R1 <- RunPtr
LDR	R2, [R1]		
LDR	R2, [R2]
BL	SysTickConf //configura systick
MOV	R0, #0x3
MSR	CONTROL, R0 //habilita thread unprivileged mode
ISB		    /* inst sect barrier: garante que CONTROL 
                     estara atualizado nas proximas instrucoes*/
POP	{R4-R11}   //carrega stackframe 0 na callstack
POP	{R0-R3}
POP	{R12}
ADD	SP, SP, #4
POP	{LR}	   //LR <- PC
ADD	SP, SP, #4
MOV	R1, #0
STR	R1, [R0]  // zera contador
STR	R1, [R0,#8] // CURR_VALUE <- TIME_SLICE (mas tanto faz)
MOV	R1, #0x7   // 0b111:
		    // 1: Clock source = core clock 
		    // 1: Habilita irq
		    // 1: Habilita contador
STR	R1, [R0]		
BX	LR	    //volta pro caller

7. Teste

Para fazer um pequeno teste, vamos escrever na tela do PC através da UART. O callback para chamada de sistema foi escrito da seguinte forma:

static void SysCallUART_PrintLn_(const char* args)
	uart_write_line(UART, args);		
	while (uart_get_status(UART) != UART_SR_TXRDY); //espera fim da transmissão

É preciso tomar cuidado na hora de utilizar multithreading concorrendo para utilizar serviços de hardware, já que ainda não inserimos nenhum mecanismo de semáforo. Entretanto, fiz a operação atômica e não será interrompida pelo SysTick. O programa principal é o seguinte:

#include <commondefs.h> //board support package, libs padrão, etc.
#include <kernel.h>  
#include <tasks.h>

int main(void)
  kHardwareInit(); //configura clock, interrupcoes, uart, entre outros
  kAddThreads(Task1, (void*)"Task1\n\r", Task2, (void*)"Task2\n\r", Task3, (void*)"Task3\n\r");
  RunPtrStart = &tcbs[0]; 
  RunPtr = &tcbs[1];
  uart_write_line(UART, "Inicializando kernel...\n\r");
  delay_ms(500); //delay para dar tempo de tirar o printscreen da tela 😛

As tasks (threads principais) têm a seguinte cara:

void Task1(void* args)
	const char* string = (char*)args;
		SysCall(SysCall_Uart_PrintLn, string);

Na figura abaixo o sisteminha em execução:

8. Conclusões

A utilização de dois stack pointers, um para aplicação e outro para o kernel isola estes espaços não permitindo que a aplicação corrompa a stack do kernel. Os privilégios por sua vez evitam que a aplicação sobrescreva registradores especiais. Adicionar mais um stack pointer ao sistema exigiu mudanças na rotina do escalonamento porque agora manipulamos duas stacks no domínio de 2 stack pointers distintos, em que ambos estão sujeitos à preempção. Além disso, também foi adicionado um mecanismo cooperativo para que as tarefas do kernel liberem o processador para o usuário.

O mecanismo de system calls é utilizado como ponto de entrada a serviços de hardware, ou ao que mais julgarmos crítico para a segurança e estabilidade do sistema. Isto fará ainda mais sentido ao separarmos não só as stacks em níveis de privilégio mas também as regiões da memória com a MPU.

Para as próximas publicações ainda iremos: 1) incluir mecanismos de semáforos e IPCs, 2) configurar a MPU e 3) adicionar níveis de prioridades às tarefas que até então rodam em um esquema round-robin fixo.

9. Referências

[1] http://infocenter.arm.com/help/topic/com.arm.doc.ddi0337e/DDI0337E_cortex_m3_r1p1_trm.pdf

[2] The definitive Guide to ARM Cortex M3, Joseph Yiu

[3] https://developer.arm.com/docs/dui0471/j/handling-processor-exceptions/svc-handlers-in-c-and-assembly-language

Um primeiro kernel preemptivo no ARM Cortex-M3

1 Introdução

Quando se fala em sistemas operativos para software embarcado, em geral pensa-se que eles são um overkill (implementação cujo custo não compensa) para a maioria das soluções. No entanto entre uma aplicação completamente “hosted” (que faz uso de um SO multithread e de propósito geral) e outra completamente “standalone” ou “bare-metal” há muitas variações.

Em publicações anteriores eu explorei a noção de tarefas cooperativas, desde um loop fazendo chamadas a partir de um vetor de ponteiros para funções, até algo um pouco mais complexo com os processos sendo manejados em um buffer circular, com critérios temporais explícitos. Desta vez vou mostrar uma implementação preemptiva mínima, para também abrir o caminho para algo um pouco mais complexo, como nas outras publicações.

2 Preemptivo versus Cooperativo

Em um sistema cooperativo o processador não realiza a troca de contexto entre uma tarefa e outra, sendo necessário que a tarefa por si só libere o processador para a próxima ocupá-lo. Não há nada de errado nisto, este esquema Run-To-Completion é suficiente e/ou necessário para muitas aplicações, e muitos sistemas embarcados são assim executados, inclusive alguns muito complexos. Mais antigamente até sistemas não-embarcados utilizavam-se de kernel cooperativo (Windows 3.x, NetWare 4.x, entre outros). Se uma tarefa travar, o sistema todo fica comprometido quando falamos de um modo estritamente cooperativo (portanto em um sistema operacional para servidores como era o NetWare, isto não parece bom).

No modo preemptivo, tarefas são interrompidas e posteriormente resumidas – i.e, um contexto (conjunto de estados dos registradores do processador) é salvo e depois recuperado. Isto leva a maior complexidade na implementação do sistema mas, se bem implementado, aumenta a robustez e a possibilidade de atender a requisitos de tempo mais estreitos.

3 Call stack

Um processador é, para todos os efeitos, uma máquina de estados. Com alguma simplificação, cada estado do nosso programa pode ser definido como o conjunto de valores dos registradoresdo core. Este conjunto dita qual rotina do programa está ativa. Portanto, ativar uma tarefa significa lançar valores na call stack de forma que esta tarefa será processada. Para trocar de contexto é necessário salvar os dados da call stack naquele ponto do programa. Estes dados “congelados” representam um estado do programa e portanto são chamados de stack frame.

Para resumir uma tarefa, um stack frame previamente salvo é carregado novamente na call stack – os registradores reais do core.

No ARM Cortex-M3, registadores de 32-bit que definem o estado ativo do processador são: R0-R12 para uso geral e R13-R15 registos de uso especial.

3.1. Arquiteturas load-store

Uma arquitetura do tipo load-store é aquela em que um dado da memória precisa de ser carregado (load) aos registradores do core antes de ser processado. Também, o resultado deste processamento antes de ser armazenado (store) na memória deve estar em um registo. Na figura abaixo (retirada de[1]) a arquitetura do processador: pipeline de 3 estágios, barramentos de dados e controle distintos, e seu banco de registros representado abaixo.

As duas operações básicas de acesso a memória no Cortex-M3:

// lê o dado contido no endereço apontado por Rn + offset e o coloca em Rn.
LDR Rd, [Rn, #offset]
//armazena dado contido em Rn no endereço apontado por Rd + offset
STR Rn, [Rd, #offset]

É importante ainda compreender no mínimo as instruções assembly do Cortex-M3 mostradas abaixo. Sugiro as fontes [1] e [2] como boas referências, além deste ou este link.

MOV Rd, Rn // Rd = Rn
MOV Rd, #M// Rd = M, sendo o imediato um valor de 32 bit (aqui representado por M)
ADD Rd, Rn, Rm //Rd = Rn + Rm
ADD Rd, Rn, #M //Rd = Rn + M
SUB Rd, Rn, Rm //Rd = Rn - Rm
SUB Rd, Rn, #M //Rd = Rn - M
// pseudo-instrucao para salvar Rn em uma posicão da memória.
// Após um PUSH, o valor do stack pointer é decrementado em 4 bytes, e o
//Ao contrário do PUSH, o POP incrementa o SP após carregar o dado em Rn.
POP {Rn}
B label //pula para a rotina label
BX Rm //pula para a rotina especificada indiretamente por Rm
BL label //pula para label e passa endereco da prox. instrucao ao LR
//para retornar LR=Link Register
CPSID I //habilita interrupções
CPSIE I //desabilita interrupções

Vamos operar o M3 no modo Thumb, onde as instruções têm na verdade 16 bits. Segundo a ARM, isto é feito para melhorar a densidade de código mantendo os benefícios de uma arquitetura 32-bit. O bit 24 do PSR é sempre 1.

3.2. Stacks e o stack pointer (SP)

Stack é um modelo de uso da memória. Seu funcionamento se dá no formato Last InFirst Out (último a entrar, primeiro a sair). É como se eu organizasse uma pilha de documentos para ler. É conveniente que o primeiro documento a ser lido esteja no topo da pilha, e o último ao final.

Normalmente dividimos a memória entre heap e stack. Como dito, na “call stack” estarão contidos aqueles dados temporários que determinam o próximo estado do processador. No heap estão armazenados dados cuja natureza não é temporária no curso do programa (isto não significa “não-volátil”). O stack pointer é uma espécie de pivô que mantém o controle do fluxo do programa, ao apontar para alguma posição da stack.

Figura 3. Modelo de uso de uma stack. Ao salvarmos dados antes de processá-los (transformá-los) guardamos a informação anterior. (Figura de [1])
Figura 4. Regiões da memória mapeada em um Cortex M3. A região disponível para a stack está confinada entre os endereços 0x20008000 e 0 0x20007C00. [1]

4 Multitarefas no ARM Cortex M3

O M3 oferece dois stack pointers (Main Stack Pointer e Process Stack Pointer) para isolar os processos do usuário dos processos do kernel. Todo serviço de interrupção é executado no modo kernel. Não é possível ir do modo usuário ao modo kernel (na verdade chamados de thread mode e privileged mode) sem passar por uma interrupção – mas é possível ir do modo privilegiado ao modo usuário alterando o registro de controle.

Figura 5. Troca de contexto em um SO que isola a aplicação do kernel [1]

O core também tem hardware dedicado para a troca de tarefas. O serviço de interrupção SysTick pode ser usado para implementar a troca de contextos síncrona. Ainda existem outras interrupções assíncronas por software (traps) como o PendSV e o SVC. Assim, o SysTick é utilizado para as tarefas síncronas no kernel, enquanto o SVC serve às interrupções assíncronas, quando a aplicação faz uma chamada ao sistema. O PendSV é uma trap (interrupção via software) que por padrão só pode ser disparada também no modo privilegiado. Normalmente sugere-se [1] ativá-la no SysTick, porque assim é possível manter o controle dos ticks para atender aos critérios de tempo. A interrupção por SysTick é logo servida, não correndo-se riscos de perder algum tick do relógio. Uma implementação de S.O. seguro, utilizaria os dois stack pointers para separar threads de usuário e kernel, além de também separar os domínios de memória se houver uma MPU (Memory Protection Unit) disponível.

Figura 6. Leiaute da memória de dados em um SO com dois stack pointers e memória protegida [1]

Em um primeiro momento iremos utilizar somente o MSP em modo privilegiado.

5. Construção do kernel

Kernel é um conceito um tanto amplo, mas creio que não exista um S.O. cujo kernel não seja o responsável pelo escalonamento das tarefas. Além disso, minimamente deve haver também mecanismos de IPC (comunicação inter-processos). É interessante notar a forte hardware-dependência do escalonador que será mostrado, devido à sua natureza low-level.

5.1. Stackframes e troca de contexto

Lembre-se: call stack = valores armazenados no banco do core; stack ou stackframe = estado (valores) destes registradores salvos na memória.

Quando um SysTick é atendido, parte da call stack é salva pelo hardware (R0, R1, R2, R3, R12, R14 (LR) e R15 (PC) e PSR). Vamos chamar esta porção salva pelo hardware de hardware stackframe. O restante é o software stackframe [3], que devemos explicitamente salvar e recuperar com as instruções PUSH e POP.

Para pensar nosso sistema, podemos esquematizar uma troca de contexto completa, delineando as posições-chave que o stack pointer assume durante a operação (na figura abaixo os endereços de memória aumentam, de baixo pra cima. Quando SP aponta para R4 está alinhado com um endereço menor que o PC da sua stack)

Figura 7. Troca de contexto. A tarefa ativa é salva pelo hardware e pelo kernel. O stackpointer é reassinalado conforme critérios, apontado para o R4 do próximo stackframe a ser ativado. Os dados são resgatados. A tarefa é executada. (Figura baseada em [3])

Quando uma interrupção ocorre, SP estará apontando para o topo do stack (SP(O)) a ser salvo. É inevitável que seja assim porque é assim que o M3 funciona. Numa interrupção o hardware irá a salvar os primeiro 8 registradores mais altos da call stack nos 8 endereços abaixo do stack pointer, parando em (SP(1)). Quando salvarmos os registradores que restam, o SP agora estará apontando para o R4 da stack atual (SP(2)). Ao reassinalarmos o SP para o endereço que aponta ao R4 do próximo stack (SP(3)), o POP joga os valores de R4-R11 à call stack e o stack pointer agora está em (SP(4)). Finalmente, o retorno da interrupção resgata os valores do hardware stackframe à call stack, e o SP(5) está no topo da stack que acabou de ser ativada. (Se estiver se perguntando onde está o R13: ele armazena o valor do stack pointer )

A rotina para troca de contexto é escrita em assembly e implementa exatamente o que está descrito na Figura 7.

Figura 8. Troca de contexto

PS: Quando uma interrupção ocorre, o LR assume um código especial. 0xFFFFFFF9, se a thread interrompida estava a utilizar o MSP ou 0xFFFFFFFD se a thread interrompida utilizava o PSP.

5.1 Inicializando as stacks para cada tarefa

Para que a estratégia acima funcione, precisamos inicializar as stacks de cada tarefa, de acordo. O sp começa apontando para R4. Este é por definição o stack pointer inicial de uma task, pois é o endereço mais baixo de um frame.

Além disso, precisamos criar uma estrutura de dados que aponte corretamente para as stacks que serão ativadas a cada serviço de SysTick. Normalmente chamamos esta estrutura de TCB (thread control block). Por enquanto não utilizamos nenhum critério de escolha e portanto não há parâmetros de controle além do next: quando uma tarefa é interrompida, a próxima da fila será resumida e executada.

Figura 9. Estruturas para controle das threads

A função kSetInitStack inicializa a stack de cada thread i“. O stack pointer na TCB associada aponta para o dado relativo ao R4. Os dados da stack são inicializados com o número do registro a que devem ser carregados para facilitar o debug. O PSR só precisa ser inicializado com o bit 24 em 1, que é o bit que identifica o modo Thumb. As tarefas são do tipo void Task(void* args).

Figura 10. Inicializando a stack

Para adicionarmos uma tarefa à stack, precisamos inicialmente do endereço da função principal da task. Além disso, também passaremos um argumento. O primeiro argumento fica em R0. Se mais argumentos forem necessários outros registradores podem ser usados, conforme o AAPCS (ARM Application Procedure Call Standard).

Figura 11. Rotina para adicionar as tasks e seus argumentos no stackframe inicial

5.3. Inicializando o kernel

Não basta inicializar as stacks e esperar a interrupção do SysTick. O sp da estrutura TCB só guardará um valor válido de stack pointer quando a tarefa for interrompida.

Num sistema preemptivo, temos dois tipos de threads a rodar: background e foreground. O background comporta as rotinas do kernel, entre elas, a troca de contexto. A cada SysTick, é vez do kernel utilizar o processador. No foreground estarão as aplicações.

Se a tarefa já não tiver sido executada, o stack pointer guardado em sp não será válido. Assim precisamos fazer parecer que a tarefa foi executada, interrompida e guardada – para ser reativada depois. Eu usei a seguinte estratégia:

  1. Uma interrupção é forçada (PendSV). Hardware stackframe inicial é salvo.
  2. tcb[0].sp é carregado em SP. SP agora tem o endereço do dado R4 da stackframe
  3. O R4R11 do core são carregados com os valores da stackframe inicializada.
  4. ISR retorna, recupera o hardware stack frame e o SP estará no topo da stack. O PC agora está carregado com o endereço da primeira chamada a ser feita, e o programa segue o fluxo.
Figura 12. Serviço de interrupção do PendSV para inicializar kernel

Em [2] é sugerido uma outra forma de inicializar o kernel, bem mais didática:

Figura 13. Rotina para inicializar o kernel

Dispensa-se a interrupção e a call stack é carregada ativando o LR com o valor do PC da stack. Após finalmente levar o SP ao topo da stack, o BX LR executa a task e retorna.

Se utilizarmos o primeiro método apresentado, kStart fica simplesmente:

// com a lib CMSIS
void kStart(void) 

6. Juntando as peças

Para ilustrar vamos executar, em Round-Robin, 3 tarefas que chaveiam a saída de 3 diferentes pinos e incrementam um contador cada. O tempo entre uma troca de contexto e outro será de 1000 ciclos do relógio principal. Perceba que estas funções rodam dentro de um “while(1) { }”. É como se tivéssemos vários programas principais sendo executados no foreground. Cada stack tem 64 elementos de 4 bytes (256 bytes).

Figura 14. Tasks do sistema

Abaixo a função main do sistema. O hardware é inicializado. As tarefas são adicionadas e as stacks inicializadas com a função kAddThreads. O RunPtr recebe o endereço da thread 0. Após configurar o SysTick para disparar a cada 1000 ciclos de clock, inicializa-se o kernel. Depois de executar a primeira tarefa e ser interrompido, o sistema fica quicando entre uma tarefa e outra, com a troca de contexto a rodar no background.

Figura 15. Programa principal

6.1. Debug

Você vai precisar pelo menos de um simulador para implementar mais facilmente o sistema, pois precisará acessar os registradores do core (call stack) e ver os dados movimentando-se nas stacks. Se o sistema estiver a funcionar, a cada pausa do debugger os contadores devem ter praticamente o mesmo valor.

Na foto abaixo, utilizo uma placa Arduino Due com o processador Atmel SAM3X8E e um debugger Atmel ICE conectado na JTAG da placa. No osciloscópio pode-se ver as formas de onda das saídas chaveando de cada uma das 3 tasks.

Figura 16. Bancada para debug
Figura 17. Tasks 1, 2, 3 no osciloscópio.

7 Considerações finais

A implementação de um kernel preemptivo exige razoável conhecimento da arquitetura do processador a ser utilizado. Carregar os registradores da call stack e salvá-los de forma “artesanal” nos permite ter um maior controle do sistema às custas da complexidade de manejarmos as stacks.

O exemplo aqui apresentado é um exemplo mínimo onde cada tarefa recebe o mesmo tempo para ser executada. Após esse tempo, a tarefa é interrompida e suspensa – os dados da call stack são salvos. Este conjunto salvo chamamos de stackframe – uma “fotografia” da ponto do programa que estava a ser executado. A próxima tarefa a ser executada é carregada do ponto de onde parou e resumida. O código foi escrito de forma a explicitar os conceitos.

Na próxima publicação iremos adicionar prioridade às tarefas e separar as threads entre modo usuário e modo privilegiado – utilizando os dois stack pointers – requisito fundamental para um sistema mais seguro.


O texto desta postagem bem como as figuras não referenciadas são do autor.
[1] The definitive guide to ARM Cortex-M3 , Joseph Yiu
[2] Real-Time Operating Systems for the ARM Cortex-M, Jonathan Valvano
[3] https://www.embedded.com/taking-advantage-of-the-cortex-m3s-pre-emptive-context-switches/