Perfil de memória mais simples no STM32 e outros microcontroladores

"Com a experiência, surge uma abordagem científica padrão para calcular o tamanho correto da pilha: pegue um número aleatório e espere o melhor."
- Jack Ganssle, “A arte de projetar sistemas embarcados”

Olá Habr!

Por mais estranho que pareça, na grande maioria dos "primers STM32" que eu já vi em particular e nos microcontroladores em geral, geralmente não há nada sobre alocação de memória, colocação de pilha e, mais importante, prevenção de excesso de memória - como resultado disso. uma área desgasta a outra e tudo desmorona, geralmente com efeitos encantadores.

Isso se deve em parte à simplicidade dos projetos de treinamento realizados em placas de depuração com microcontroladores relativamente oleosos, onde é difícil atingir uma falta de memória piscando um LED - no entanto, recentemente, mesmo para amadores iniciantes, as referências a, por exemplo, controladores do tipo STM32F030F4P6 são cada vez mais comuns. , fácil de instalar, vale um centavo, mas também com uma unidade de memória de kilobytes.

Esses controladores permitem que você faça coisas bastante sérias por si mesmo (bem, aqui, por exemplo, uma medida completamente adequada foi feita para nós no STM32F042K6T6 com 6 KB de RAM, da qual um pouco mais de 100 bytes permanecem livres), mas ao lidar com memória, você precisa de uma certa quantidade de memória limpeza.

Eu quero falar sobre essa precisão. O artigo será breve, os profissionais não aprenderão nada de novo - mas para iniciantes esse conhecimento é altamente recomendado.

Em um projeto típico em um microcontrolador baseado em um núcleo Cortex-M, a RAM possui uma divisão condicional em quatro seções:

  • data - dados inicializados por um valor específico
  • bss - dados inicializados em zero
  • heap - heap (área dinâmica a partir da qual a memória é alocada explicitamente usando malloc)
  • stack - a pilha (a região dinâmica a partir da qual a memória é alocada implicitamente pelo compilador)

A área noinit também pode ocorrer ocasionalmente (variáveis ​​não inicializadas - elas são convenientes na medida em que retêm o valor entre as reinicializações), e com menos frequência, algumas outras áreas alocadas para tarefas específicas.

Eles estão localizados na memória física de uma maneira bastante específica - o fato é que a pilha em microcontroladores nos núcleos ARM cresce de cima para baixo. Portanto, ele está localizado separadamente dos blocos de memória restantes, no final da RAM:



Por padrão, seu endereço geralmente é igual ao endereço de RAM mais recente e, a partir daí, diminui à medida que cresce - e um recurso extremamente desagradável da pilha cresce a partir dela: pode atingir bss e reescrever sua parte superior, e você não saberá sobre isso de nenhuma maneira explícita.

Áreas de memória estática e dinâmica


Toda a memória é dividida em duas categorias - alocadas estaticamente, ou seja, memória, cuja quantidade total é óbvia no texto do programa e não depende da ordem de sua execução e é alocada dinamicamente, cujo volume necessário depende do andamento do programa.

O último inclui um monte (do qual pegamos pedaços usando malloc e retornamos usando grátis) e uma pilha que cresce e diminui por si só.

De um modo geral, o uso de malloc em microcontroladores é altamente desencorajado, a menos que você saiba exatamente o que está fazendo. O principal problema que eles trazem é a fragmentação da memória - se você alocar 10 partes de 10 bytes e liberar a cada segundo, não receberá 50 bytes gratuitamente. Você receberá 5 peças gratuitas de 10 bytes cada.

Além disso, no estágio de compilação do programa, o compilador não poderá determinar automaticamente quanta memória o seu malloc precisará (principalmente levando em consideração a fragmentação, que depende não apenas do tamanho das peças solicitadas, mas da sequência de sua alocação e liberação) e, portanto, não poderá avisá-lo se no final não houver memória suficiente.

Existem métodos para solucionar esse problema - implementações especiais de malloc que funcionam dentro de uma área alocada estaticamente, e não toda a RAM, uso cuidadoso do malloc, levando em consideração a possível fragmentação no nível lógico do programa, etc. - mas em geral malloc é melhor não tocar .

Todas as áreas de memória com limites e endereços são registradas em um arquivo com a extensão .LD, na qual o vinculador é orientado ao criar o projeto.

Memória alocada estaticamente


Portanto, a partir da memória alocada estaticamente, temos duas áreas - bss e dados, que diferem apenas formalmente. Quando o sistema é inicializado, o bloco de dados é copiado do flash, onde são armazenados os valores de inicialização necessários, o bloco bss é simplesmente preenchido com zeros (pelo menos, preenchê-lo com zeros é considerado uma boa forma).

As duas coisas - copiar de um flash e preencher zeros - são feitas no código do programa de forma explícita , mas não no seu main (), mas em um arquivo separado que é executado primeiro, ele é gravado uma vez e simplesmente arrastado de um projeto para outro.

No entanto, não é isso que nos interessa agora - mas como entenderemos se nossos dados se encaixam na RAM do nosso controlador.

É reconhecido de maneira muito simples - pelo utilitário arm-none-eabi-size com um único parâmetro - o arquivo ELF compilado do nosso programa (geralmente sua chamada é inserida no final do Makefile, porque é conveniente):



Aqui, o texto é a quantidade de dados do programa repentinamente, e bss e dados são nossas áreas alocadas estaticamente na RAM. As duas últimas colunas não nos incomodam - esta é a soma das três primeiras, não tem significado prático.

Totalmente, estaticamente, na RAM, precisamos de bss + bytes de dados, neste caso - 5324 bytes. O controlador possui 6144 bytes de RAM, não usamos malloc, 820 bytes permanecem.

O que deve ser suficiente para nós na pilha.

Mas chega? Porque, se não, nossa pilha aumentará para nossos próprios dados, e primeiro substituirá os dados, depois os dados sobrescreverão, e então tudo falhará. Além disso, entre o primeiro e o segundo ponto, o programa pode continuar funcionando sem perceber que há lixo nos dados que processa. Na pior das hipóteses, serão os dados que você anotou quando tudo estava em ordem com a pilha, e agora você acabou de ler - por exemplo, os parâmetros de calibração de algum sensor - e então você não tem uma maneira óbvia de entender que tudo está errado com eles, Este programa continuará sendo executado, como se nada tivesse acontecido, fornecendo lixo na saída.

Memória alocada dinamicamente


E aqui começa a parte mais interessante - se você reduzir o conto a uma frase, é quase impossível determinar o tamanho da pilha com antecedência .

Teoricamente , você pode pedir ao compilador que lhe forneça o tamanho da pilha usada por cada função individual, depois solicitar que ele retorne a árvore de execução do seu programa e, para cada ramificação, calcule a soma das pilhas de todas as funções presentes nessa árvore. Isso por si só, para qualquer programa mais ou menos complexo, levará um tempo considerável.

Lembre-se de que a qualquer momento pode ocorrer uma interrupção, cujo processador também precisa de memória.

Então - que duas ou três interrupções aninhadas podem acontecer, cujos manipuladores ...

Em geral, você entende. Tentar contar a pilha de um programa específico é uma atividade interessante e geralmente útil, mas geralmente você não o faz.

Portanto, na prática, é utilizada uma técnica que permite que você pelo menos entenda de alguma forma se tudo em nossa vida se desenvolve bem - a chamada “pintura da memória” (pintura da memória).

O que é conveniente nesse método é que ele não depende das ferramentas de depuração usadas e, se o sistema tiver pelo menos algum meio de gerar informações, você poderá fazê-lo sem as ferramentas de depuração.

Sua essência é que preenchemos toda a matriz desde o final do bss até o início da pilha em algum lugar no estágio inicial da execução do programa, quando a pilha ainda é exatamente pequena, com o mesmo valor.

Além disso, verificando em qual endereço esse valor já desapareceu, entendemos onde a pilha caiu. Como uma vez que a cor apagada não será restaurada, a verificação poderá ser feita esporadicamente - mostrará o tamanho máximo da pilha atingido.

Defina a cor da tinta - o valor específico não importa, abaixo eu apenas toquei com dois dedos da mão esquerda. O principal é não escolher 0 e FF:

#define STACK_CANARY_WORD (0xCACACACAUL)

- , -, :

volatile unsigned *top, *start;
__asm__ volatile ("mov %[top], sp" : [top] "=r" (top) : : );
start = &_ebss;
while (start < top) {
    *(start++) = STACK_CANARY_WORD;
}

? top , —  ; start —  bss (, , *.ld — libopencm3). bss .

:

unsigned check_stack_size(void) {
    /* top of data section */
    unsigned *addr = &_ebss;

    /* look for the canary word till the end of RAM */
    while ((addr < &_stack) && (*addr == STACK_CANARY_WORD)) {
        addr++;
    }
    
    return ((unsigned)&_stack - (unsigned)addr);
}

_ebss , _stack —  , , , , .

.

— - check_stack_size() , , , .

.

712 — 6 108 .

Word of caution


— , , 100-% . , , , , , . , , -, 10-20 %, 108 .

, , , .

P.S. RTOS — MSP, , PSP. , — .

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


All Articles