Como se proteger contra o estouro de pilha (no Cortex M)?

Se você programa em um computador "grande", provavelmente não tem essa pergunta. Há muita pilha para transbordar, você precisa tentar. Na pior das hipóteses, você clica em OK em uma janela como essa e descobre.

imagem

Mas se você programa microcontroladores, o problema parece um pouco diferente. Primeiro você precisa observar que a pilha está cheia.

Neste artigo, falarei sobre minha própria pesquisa sobre esse tópico. Como eu programa principalmente no STM32 e no Milander 1986 - eu me concentrei neles.

1. Introdução


Vamos imaginar o caso mais simples: escrevemos código simples de thread único sem nenhum sistema operacional, ou seja, nós temos apenas uma pilha. E se você, como eu, programa no uVision Keil, a memória é distribuída de alguma forma assim:



E se você, como eu, considera a memória dinâmica nos microcontroladores um mal, faça o seguinte:



By the way
Se você deseja proibir o uso de heap, faça o seguinte:
#pragma import(__use_no_heap_region) 

Detalhes aqui

OK, qual é o problema? O problema é que Keil coloca a pilha imediatamente atrás da área de dados estáticos. E a pilha no Cortex-M está crescendo na direção da diminuição de endereços. E quando transborda, simplesmente rasteja para fora da parte de memória alocada. E substitui quaisquer variáveis ​​estáticas ou globais.

Especialmente bom se a pilha estourar apenas ao entrar na interrupção. Ou, melhor ainda, em uma interrupção aninhada! E estraga discretamente alguma variável usada em uma seção de código completamente diferente. E o programa trava na declaração. Se você tiver sorte. Heisenbag esférico, pode-se procurar uma semana inteira com uma lanterna.

Faça imediatamente uma reserva de que, se você usar um heap, o problema não vai a lugar nenhum, apenas em vez de variáveis ​​globais o heap estraga. Não muito melhor.

Ok, o problema está claro. O que fazer

MPU


O mais simples e mais óbvio é usar o MPU (em outras palavras, Unidade de proteção de memória). Permite atribuir diferentes atributos a diferentes partes da memória; em particular, você pode cercar a pilha com regiões somente leitura e capturar o MemFault ao escrever nela.

Por exemplo, em stm32f407 MPU é. Infelizmente, em muitos outros "junior" stm não é. E no Milandrovsky 1986VE1 também não é.

I.e. A solução é boa, mas nem sempre acessível.

Controle manual


Ao compilar, o Keil pode gerar (e faz isso por padrão) um relatório html com um gráfico de chamada (opção do vinculador "--info = stack"). E este relatório também fornece informações sobre a pilha usada. O Gcc também pode fazer isso (opção -fstack-use). Portanto, às vezes você pode examinar este relatório (ou escrever um script que faça isso por você e chamá-lo antes de cada compilação).

Além disso, no início do relatório, é escrito um caminho que leva ao uso máximo da pilha:



O problema é que, se seu código possui chamadas de função por ponteiros ou métodos virtuais (e eu os tenho), esse relatório pode subestimar bastante a profundidade máxima da pilha. Bem, é claro que as interrupções não são levadas em consideração. Não é uma maneira muito confiável.

Posicionamento complicado da pilha


Eu aprendi sobre esse método neste artigo . O artigo é sobre ferrugem, mas a idéia principal é a seguinte:



Ao usar o gcc, isso pode ser feito usando o " link duplo ".

E no Keil, a localização das áreas pode ser alterada usando seu próprio script para o vinculador (arquivo de dispersão na terminologia do Keil). Para fazer isso, abra as opções do projeto e desmarque a opção “Usar layout da memória na caixa de diálogo de destino”. Em seguida, o arquivo padrão aparecerá no campo "Arquivo de dispersão". Parece algo como isto:

 ; ************************************************************* ; *** Scatter-Loading Description File generated by uVision *** ; ************************************************************* LR_IROM1 0x08000000 0x00020000 { ; load region size_region ER_IROM1 0x08000000 0x00020000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 { ; RW data .ANY (+RW +ZI) } } 

O que fazer depois? Possíveis opções. A documentação oficial sugere a definição de seções com nomes reservados - ARM_LIB_HEAP e ARM_LIB_STACK. Mas isso implica conseqüências desagradáveis, pelo menos para mim - o tamanho da pilha e da pilha terá que ser definido no arquivo de dispersão.

Em todos os projetos que eu uso, os tamanhos de pilha e heap são definidos no arquivo de inicialização do assembler (que Keil gera ao criar o projeto). Eu realmente não quero mudar isso. Eu só quero incluir um novo arquivo de dispersão no projeto, e tudo ficará bem. Então eu fui um pouco diferente:

Spoiler
 #! armcc -E ; with that we can use C preprocessor #define RAM_BEGIN 0x20000000 #define RAM_SIZE_BYTES (4*1024) #define FLASH_BEGIN 0x8000000 #define FLASH_SIZE_BYTES (32*1024) ; This scatter file places stack before .bss region, so on stack overflow ; we get HardFault exception immediately LR_IROM1 FLASH_BEGIN FLASH_SIZE_BYTES { ; load region size_region ER_IROM1 FLASH_BEGIN FLASH_SIZE_BYTES { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } ; Stack region growing down REGION_STACK RAM_BEGIN { *(STACK) } ; We have to define heap region, even if we don't actually use heap REGION_HEAP ImageLimit(REGION_STACK) { *(HEAP) } ; this will place .bss region above the stack and heap and allocate RAM that is left for it RW_IRAM1 ImageLimit(REGION_HEAP) (RAM_SIZE_BYTES - ImageLength(REGION_STACK) - ImageLength(REGION_HEAP)) { *(+RW +ZI) } } 


Então eu disse que todos os objetos denominados STACK devem estar localizados na região REGION_STACK e todos os objetos HEAP devem estar localizados na região REGION_HEAP. E tudo o resto está na região RW_IRAM1. E ele organizou as regiões nesta ordem - o começo do operativo, a pilha, a pilha, tudo mais. O cálculo é que, no arquivo de inicialização do assembler, a pilha e a pilha são definidas usando este código (ou seja, como matrizes com os nomes STACK e HEAP):

Spoiler
 Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 __heap_base Heap_Mem SPACE Heap_Size __heap_limit PRESERVE8 THUMB 


Ok, você pode perguntar, mas o que isso nos dá? E aqui está o que. Agora, ao sair da pilha, o processador tenta gravar (ou ler) a memória que não existe. E no STM32, ocorre uma interrupção devido a uma exceção - HardFault.

Isso não é tão conveniente quanto o MemFault por causa da MPU, porque o HardFault pode ocorrer devido a vários motivos, mas pelo menos o erro é alto e não é silencioso. I.e. ocorre imediatamente, e não após um período de tempo desconhecido, como era antes.

O melhor de tudo é que não pagamos nada por isso, sem tempo de execução. Uau. Mas há um problema.

Isso não funciona em Milander.

Sim Obviamente, no Milandra (estou interessado principalmente em 1986BE1 e BE91), o cartão de memória parece diferente. No STM32, antes do início da operação, não há nada, e na Milandra, antes da operação, fica a área do barramento externo.

Mas mesmo se você não usar um barramento externo, não receberá nenhum HardFault. Ou talvez entenda. Ou talvez entenda, mas não imediatamente. Não consegui encontrar nenhuma informação sobre esse assunto (o que não é surpreendente para Milander), e os experimentos não deram nenhum resultado inteligível. Às vezes, o HardFault ocorria se o tamanho da pilha era múltiplo de 256. Às vezes, o HardFault ocorria se a pilha fosse muito longe na memória inexistente.

Mas isso nem importa. Se o HardFault não ocorrer todas as vezes, simplesmente mover a pilha para o início da RAM não nos salvará mais. E para ser completamente honesto, o STM também não é obrigado a lançar uma exceção ao mesmo tempo, a especificação principal do Cortex-M parece não dizer nada concreto sobre isso.

Portanto, mesmo no STM, é mais um truque, mas não muito sujo.

Então, você precisa procurar outra maneira.

Acessar o ponto de interrupção no registro


Se movermos a pilha para o início da RAM, o valor limite da pilha será sempre o mesmo - 0x20000000. E podemos simplesmente colocar um ponto de interrupção no registro nesta célula. Isso pode ser feito com o comando e até registrado na execução automática usando o arquivo .ini:

 // breakpoint on stackoverflow BS Write 0x20000000, 1 

Mas essa não é uma maneira muito confiável. Esse ponto de interrupção será acionado toda vez que a pilha for inicializada. É fácil vencê-lo acidentalmente clicando em "Matar todos os pontos de interrupção". E ele irá protegê-lo apenas na presença de um depurador. Não é bom

Proteção dinâmica contra transbordamento


Uma pesquisa rápida sobre esse assunto me levou às opções de Keil --protect_stack e --protect_stack_all. Infelizmente, as opções úteis protegem não transbordando a pilha inteira, mas inserindo outra função no quadro da pilha. Por exemplo, se o seu código ultrapassar os limites de uma matriz ou falhar com um número variável de parâmetros. O Gcc, é claro, também pode fazer isso (-fstack-protector).

A essência desta opção é a seguinte: “variável de proteção” é adicionada a cada quadro de pilha, ou seja, um número de proteção. Se esse número foi alterado após a saída da função, a função manipulador de erros é chamada. Detalhes aqui .

Uma coisa útil, mas não exatamente o que eu preciso. Eu preciso de uma verificação muito mais simples - para que, ao inserir cada função, o valor do registro SP (Stack Pointer) seja verificado em relação a um valor mínimo previamente conhecido. Mas não escreva este teste com as mãos na entrada de cada função?

Controle SP dinâmico


Felizmente, o gcc tem a maravilhosa opção "-finstrument-functions", que permite chamar uma função definida pelo usuário quando você entra em cada função e quando sai de cada função. Isso geralmente é usado para gerar informações de depuração, mas qual é a diferença?

Ainda mais felizmente, o Keil copia deliberadamente a funcionalidade gcc, e a mesma opção está disponível sob o nome "--gnu_instrument" ( detalhes ).

Depois disso, você só precisa escrever este código:

Spoiler
 //   ,    //   ,         scatter- extern unsigned int Image$$REGION_STACK$$RW$$Base; //    ,   static const uint32_t stack_lower_address = (uint32_t) &( Image$$REGION_STACK$$RW$$Base ); //         extern "C" __attribute__((no_instrument_function)) void __cyg_profile_func_enter( void * current_func, void * callsite ) { (void)current_func; (void)callsite; ASSERT( __current_sp() >= stack_lower_address ); } //   -   extern "C" __attribute__((no_instrument_function)) void __cyg_profile_func_exit( void * current_func, void * callsite ) { (void)current_func; (void)callsite; } 


E pronto! Agora, ao inserir cada função (incluindo manipuladores de interrupção), uma verificação será realizada para o estouro da pilha. E se a pilha exceder, haverá uma afirmação.

Uma pequena explicação:
  • Sim, é claro, você precisa verificar se há um estouro com alguma margem, caso contrário, existe o risco de "pular" por cima da pilha.
  • Imagem $$ REGION_STACK $$ RW $$ Base é uma mágica especial para obter informações sobre áreas de memória usando as constantes geradas pelo vinculador. Detalhes (embora não muito inteligíveis em alguns lugares) aqui .


A solução é perfeita? Claro que não.

Em primeiro lugar, essa verificação está longe de ser gratuita, o código aumenta 10%, pois o código funcionará mais lentamente (embora eu não tenha medido). Se é crítico ou não, depende de você; na minha opinião, este é um preço razoável para a segurança.

Em segundo lugar, isso provavelmente não funcionará ao usar bibliotecas pré-compiladas (mas como eu não as uso, não verifiquei).

Mas essa solução é potencialmente adequada para programas multithread, já que nós próprios fazemos a verificação. Mas eu realmente não pensei nessa idéia, então vou mantê-la por enquanto.

Resumir


Acabou por encontrar soluções de trabalho para o stm32 e para o Milander, embora, no último, eu tivesse que pagar com alguma sobrecarga.

Para mim, o mais importante foi uma pequena mudança no paradigma do pensamento. Antes do artigo mencionado acima, eu não pensava que você pudesse, de alguma forma, se proteger do estouro de pilha. Eu não percebi isso como um problema que precisa ser resolvido, mas como um certo fenômeno natural - às vezes chove e às vezes a pilha transborda, bem, não há nada a ser feito, é preciso morder a bala e tolerar.

E geralmente percebo por mim mesmo (e por outras pessoas) isso - em vez de passar 5 minutos no Google e encontrar uma solução trivial - vivo com meus problemas há anos.

Isso é tudo para mim. Entendo que não descobri nada fundamentalmente novo, mas não encontrei artigos prontos com essa decisão (pelo menos, o próprio Joseph Yu não oferece isso diretamente em um artigo sobre esse assunto). Espero que, nos comentários, eles me digam se estou certo ou não e quais são as armadilhas dessa abordagem.

UPD: Se, ao adicionar um arquivo de dispersão, o Keil começar a emitir um aviso incompreensível ala "AppData \ Local \ Temp \ p17af8-2 (33): aviso: # 1-D: a última linha do arquivo termina sem uma nova linha" - mas esse arquivo em si não é abre, porque é temporário, basta adicionar a quebra de linha com o último caractere no arquivo de dispersão.

Source: https://habr.com/ru/post/pt425071/


All Articles