Projeto

Geral

Perfil

Ações

Atividade #886

Aberta

Usar memoria flash para salvar ID dos robos

Adicionado por Onias Castelo Branco aproximadamente 6 anos atrás. Atualizado mais de 5 anos atrás.

Situação:
Em andamento
Prioridade:
Normal
Atribuído para:
Início:
16/09/2018
Data prevista:
Tempo gasto:

Descrição

Na placa mãe 2018 foi adicionado um botão para seleção de id dos robos para facilitar a leitura e a troca de ids. Podemos fazer todos os robôs todos começarem com id zero toda vez que ele seja ligado. Esta seria uma implementação provisória enquanto a de salvar o id na memória não fica pronta.

li algumas referencias sobre isso já. Pelo que vi, o ideal teria sido colocar uma eeprom pra salvar o id, já que podem ter vários detalhes na hora de programar isso. Pode ficar como sugestão de trabalho futuro.

https://electronics.stackexchange.com/questions/231920/stm32f4-flash-memory-programming

https://www.eevblog.com/forum/microcontrollers/stm32f4-saving-data-to-internal-flash-(at-runtime)/

https://stackoverflow.com/questions/36598898/read-write-data-storage-into-flash-memory-in-stm32f407-discovery-using-hal

https://www.programering.com/a/MjMxQTMwATg.html

https://www.st.com/content/ccc/resource/technical/document/application_note/ec/dd/8e/a8/39/49/4f/e5/DM00036065.pdf/files/DM00036065.pdf/jcr:content/translations/en.DM00036065.pdf


Arquivos

test.zip.zip (835 KB) test.zip.zip Onias Castelo Branco, 18/02/2019 00:58 h
flash_sector3.PNG (10,1 KB) flash_sector3.PNG Onias Castelo Branco, 28/02/2019 10:41 h
Ações #1

Atualizado por Onias Castelo Brancoquase 6 anos

https://www.st.com/en/embedded-software/stsw-stm32066.html

Aqui consta o projeto da st que simula uma eeprom na flash como mencionado no link que coloquei previamente.

Ações #2

Atualizado por Onias Castelo Brancoquase 6 anos

Mudei de ideia, tentando escrever direto na memória flash, sem simulação de eeprom. Há um exemplo na standard peripheral que é sobre isso:

https://github.com/mfauzi/STM32F4/tree/master/STM32F4%20Standard%20Peripheral%20Library/Project/STM32F4xx_StdPeriph_Examples/FLASH/FLASH_Program

Nele, há a tentativa de escrever uma mensagem pre-definida e então ele lê a mensagem naquele espaço de memória. Dependendo se a escrita ocorreu da maneira esperada ou não, ele acende um certo led.

Não estava conseguindo compilar no começo pois ele usava bibliotecas mais antigas na declaração dos tamanhos das seções de memória (deixava abstraído para mais de uma versão do stm32f4xx). Resolvi olhando no reference manual da f407 e setei manualmente as configurações.

Além disso, não modifiquei mais nada no projeto. Rodei o programa mas o led que acendeu era o que acusava erro. Não sei o que pode estar acontecendo de errado. A memória flash já está separada no linker, dentro do programa eu habilito escrever na memória flash e executo o procedimento padrão que ele coloca no código de exemplo.

Segue projeto anexo em zip.

Ações #3

Atualizado por Luiz Renault Leite Rodriguesquase 6 anos

É sempre mais fácil subir o projeto no Github para que eu possa ver.

O problema do uC da Discovery é a divisão da memória FLASH.

Coloque aqui a informação sobre setorização (tamanho e posição dos endereços que são apagados simultaneamente) e o endereçamento que está usando.

O problema deve estar aí.

Ações #4

Atualizado por Onias Castelo Brancoquase 6 anos

Criei o repositório como o sr sugeriu.

https://github.com/OniasC/stm32f4_flash

Achei meu erro no código. Havia um #if para avaliar qual era o chip usado. Como não havia essa declaração mais, ele pulou uma parte importante do código sobre os espaços de memória e deu erro.

Agora ele já executa o código como programado. Pelo que entendi o código funciona da seguinte maneira:

Primeiro há a liberação dos registradores de controle da flash. Eu checo então os setores de início e do fim da flash e um por um vou delentando tudo deles.

Em seguida há a escrita, programada de palavra a palavra, setor por setor. Pelo que entendi ele escreve (uint32_t)0x12345678 em todas as palavras. Depois, ele trava de novo os registradores da flash.

Agora vem a checagem: Palavra a palavra ele checa se o valor escrito em (__IO uint32_t)(setor da memória) é o 0x12345678. Se houver algum diferente ele acusa o erro piscando leds diferentes.

A divisão da memória no stm32f4 é dividia da seguinte forma:

~~
#define ADDR_FLASH_SECTOR_0 ((uint32_t)0x08000000) /* Base of Sector 0, 16 Kbytes */
#define ADDR_FLASH_SECTOR_1 ((uint32_t)0x08004000) /* Base
of Sector 1, 16 Kbytes /
#define ADDR_FLASH_SECTOR_2 ((uint32_t)0x08008000) /
Base of Sector 2, 16 Kbytes */
#define ADDR_FLASH_SECTOR_3 ((uint32_t)0x0800C000) /* Base
of Sector 3, 16 Kbytes /
#define ADDR_FLASH_SECTOR_4 ((uint32_t)0x08010000) /
Base of Sector 4, 64 Kbytes */
#define ADDR_FLASH_SECTOR_5 ((uint32_t)0x08020000) /* Base
of Sector 5, 128 Kbytes /
#define ADDR_FLASH_SECTOR_6 ((uint32_t)0x08040000) /
Base of Sector 6, 128 Kbytes */
#define ADDR_FLASH_SECTOR_7 ((uint32_t)0x08060000) /* Base
of Sector 7, 128 Kbytes /
#define ADDR_FLASH_SECTOR_8 ((uint32_t)0x08080000) /
Base of Sector 8, 128 Kbytes */
#define ADDR_FLASH_SECTOR_9 ((uint32_t)0x080A0000) /* Base
of Sector 9, 128 Kbytes /
#define ADDR_FLASH_SECTOR_10 ((uint32_t)0x080C0000) /
Base of Sector 10, 128 Kbytes */
#define ADDR_FLASH_SECTOR_11 ((uint32_t)0x080E0000) /* Base
of Sector 11, 128 Kbytes */
~~

Ações #5

Atualizado por Luiz Renault Leite Rodriguesquase 6 anos

Exatamente isso.
O problema é que nesse uC os setores são muito grandes. Fica complicado de usar.
Uma sugestão é separar um setor para isso. Quando é apagado, assume o valor de 0xffffffff. Ao escrever, ele muda os bits desejados para 0. É possível escrever mais de uma vez em um mesmo endereço, prevalecendo sempre os valores que foram gravados como 0.

Assim, pode reservar um bit para marcar o início do endereço válido dos dados gravados. Se esse bit for 0, ele incrementa o endereço até que seja 1. Então ele carrega o conteúdo para uma struct dos dados. Quando for salvar uma nova configuração, zera os bits dos endereços com a configuração e salva uma nova, contendo os bits iguais a 1.

Ficou claro? É uma técnica bastante comum para salvar dados em memória flash com setores grandes.

Ações #6

Atualizado por Onias Castelo Brancoquase 6 anos

Luiz Renault Leite Rodrigues escreveu:

Exatamente isso.
O problema é que nesse uC os setores são muito grandes. Fica complicado de usar.
Uma sugestão é separar um setor para isso. Quando é apagado, assume o valor de 0xffffffff. Ao escrever, ele muda os bits desejados para 0. É possível escrever mais de uma vez em um mesmo endereço, prevalecendo sempre os valores que foram gravados como 0.

Assim, pode reservar um bit para marcar o início do endereço válido dos dados gravados. Se esse bit for 0, ele incrementa o endereço até que seja 1. Então ele carrega o conteúdo para uma struct dos dados. Quando for salvar uma nova configuração, zera os bits dos endereços com a configuração e salva uma nova, contendo os bits iguais a 1.

Ficou claro? É uma técnica bastante comum para salvar dados em memória flash com setores grandes.

Não entendi direito. Ao apagar uma seção da flash, todos os números apontadas pelos endereços daquele setor são levados a 0xFFFF..F. Isso eu entendi. Se eu escrever uma vez modificando só 1 bit dos 16kbytes que tem em uma seção (estou usando a 3 como exemplo) eu teria que reescrevê-lo como 1 depois para então escrever outra coisa, não?

O sr poderia me explicar essa técnica amanhã?

Mas como progresso, no meu commit mais recente eu modifiquei o código para acessar somente uma seção da memória, a 3, e escrevo uma palavra nela, no caso 0x00000001. Sempre que aperto o botão do usuário eu somo 1 bit e salvo novamente na flash (apagando e reescrevendo). Em seguida, dependendo do número que está salvo, eu acendo um dos leds da placa. Como é um condicional baseado em um valor que está na flash, ele "lembra" qual era o último estado antes de perder energia.

Ações #7

Atualizado por Onias Castelo Brancomais de 5 anos

Como consta no reference manual da stm32f40xx (pag 86):

~~~
The Flash memory programming sequence is as follows:
1. Check that no main Flash memory operation is ongoing by checking the BSY bit in the
FLASH_SR register.
2. Set the PG bit in the FLASH_CR register
3. Perform the data write operation(s) to the desired memory address (inside main
memory block or OTP area):
– Byte access in case of x8 parallelism
– Half-word access in case of x16 parallelism
– Word access in case of x32 parallelism
– Double word access in case of x64 parallelism
4. Wait for the BSY bit to be cleared.

note: Successive write operations are possible without the need of an erase operation when changing bits from ‘1’ to ‘0’. Writing ‘1’ requires a Flash memory erase operation.
~~~

Estou analisando o código exemplo para entender melhor como está sendo feita, especificamente, a parte de escrita.

Ações #8

Atualizado por Onias Castelo Brancomais de 5 anos

Sobre a função de escrita e leitura, entendi o seguinte:

função de escrita

~~~

uwAddress = FLASH_SECTOR;
while (uwAddress <= FLASH_SECTOR)
{
if (FLASH_ProgramWord(uwAddress, data) == FLASH_COMPLETE) {
uwAddress = uwAddress + 4;
}
else {
/* Error occurred while writing data in Flash memory.
User can add here some code to deal with this error */
while (1) {
}
}
}

~~~

Minha seção tem 16kbytes, isso dá 2^17 de tamanho total. Como o numero de endereços da seção é 0x4000, tenho então 4*16^3 = 2^14 endereços. Isso diz que cada endereço contem 8 bits, ou 1 byte. (0x00 até 0xFF). Como minha função é FLASH_ProgramWord, que programa uma palavra de 32 bits, então tenho que pular 4 endereços para escrever uma nova palavra. Como está mencionado acima, podemos até escolher somente escrever somente 1 byte por vez. Comentando as linhas que pulam o endereço e o while que controla o fim da seção é possível ainda escrever só uma única palavra na flash, e nao uma repetição da mesma por toda a seção.

Para leitura, é feito o mesmo procedimento, com o diferencial dele ler o valor apontado pelo endereço.

~~~
uwData32 = (__IO uint32_t)uwAddress;

~~~

Posso mascarar o valor uwData32 para descobrir se algum bit especifico dele é 0 como o sr sugeriu e depois transitar entre os endereços. E isso eu entendo como implementa. O que ainda não entendi como implementar é isso aqui:

Quando for salvar uma nova configuração, zera os bits dos endereços com a configuração e salva uma nova, contendo os bits iguais a 1.

Eu nao posso escrever 1 em um bit, só posso escrever 0. Se no meu endereço y eu tenho salvo 0b00000011 (id 3) e quiser gravar o id 4 (0b00000100) nesse endereço y como eu faria isso sem apagar toda a flash? Eu nao teria que ir para o endereço y+1 para gravar 0x04?

Ações #9

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Onias Castelo Branco escreveu:

Luiz Renault Leite Rodrigues escreveu:

Exatamente isso.
O problema é que nesse uC os setores são muito grandes. Fica complicado de usar.
Uma sugestão é separar um setor para isso. Quando é apagado, assume o valor de 0xffffffff. Ao escrever, ele muda os bits desejados para 0. É possível escrever mais de uma vez em um mesmo endereço, prevalecendo sempre os valores que foram gravados como 0.

Assim, pode reservar um bit para marcar o início do endereço válido dos dados gravados. Se esse bit for 0, ele incrementa o endereço até que seja 1. Então ele carrega o conteúdo para uma struct dos dados. Quando for salvar uma nova configuração, zera os bits dos endereços com a configuração e salva uma nova, contendo os bits iguais a 1.

Ficou claro? É uma técnica bastante comum para salvar dados em memória flash com setores grandes.

Não entendi direito. Ao apagar uma seção da flash, todos os números apontadas pelos endereços daquele setor são levados a 0xFFFF..F. Isso eu entendi. Se eu escrever uma vez modificando só 1 bit dos 16kbytes que tem em uma seção (estou usando a 3 como exemplo) eu teria que reescrevê-lo como 1 depois para então escrever outra coisa, não?

O sr poderia me explicar essa técnica amanhã?

Mas como progresso, no meu commit mais recente eu modifiquei o código para acessar somente uma seção da memória, a 3, e escrevo uma palavra nela, no caso 0x00000001. Sempre que aperto o botão do usuário eu somo 1 bit e salvo novamente na flash (apagando e reescrevendo). Em seguida, dependendo do número que está salvo, eu acendo um dos leds da placa. Como é um condicional baseado em um valor que está na flash, ele "lembra" qual era o último estado antes de perder energia.

Na memória Flash, escrever 1 é apagar. Não é possível transformá-la em 1 após ter escrito 0. Tem que apagar. E nesse caso, só é possível apagar o setor inteiro.

Vamos supor que você reserve o bit mais significativo para indicar que o endereço de memória flash é o utilizado. Assim, quando ele é 1, o dado contido é o atual. Quando ele é 0, está obsoleto.

A primeira vez que gravar a configuração do Id, vai colocar no endereço 0 do setor 3 a seguinte palavra: 0x80000001, para salvar o id=1.

Se quiser mudar o ID para 2, vai escrever no endereço 0: 0x00000000 e no endereço 1: 0x80000002.
Se quiser mudar o ID para 1 novamente, vai escrever no endereço 1: 0x00000000 e no endereço 2: 0x80000001.

Quando o uC inicializa, vai fazer um for até que ache algo que tenho o bit mais significativo igual a 1. Então você sabe que aquele é o dado atua.
Assim você pode apagar a memória apenas uma vez e salvar a configuração 16k/4 vezes.

Pode fazer a mesma coisa para dados mais complexos como uma Struct de configurações, usando o mesmo conceito.

Isso é gerenciamento de escrita em memória flash.

Ações #10

Atualizado por Onias Castelo Brancomais de 5 anos

Tendo em vista o que foi discutido, bolei o seguinte algoritmo:

~~~
write (id, flash_sector){
address = flash_sector
id_value = 0x80000000 or id

do{
valor = read(address)
if(address >= FLASH_SECTOR_LIMIT){
address = flash_sector
erase_flash()
}
else{
if(valor>>31){
program_word(address,0x0)
address += 4
program_word(address,id_value)
valor = read(address)
}
else{
address +=4
}
}
}while (!(valor>>31))
}
~~~

A função read(address) é da forma:

~~~
uint32_t ReadMemoryAddress(uint32_t FLASH_SECTOR, uint32_t address) {
uwAddress = address;

uwData32 = (__IO uint32_t)uwAddress;
return uwData32;
}
~~~

Porém, ao rodar o programa ele acusa um hard_fault e não consigo entender onde está o erro do meu programa. O código está no repositório que já coloquei aqui (https://github.com/OniasC/stm32f4_flash)

Ações #11

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Corrigido abaixo:

~~~
write (id, flash_sector){
address = flash_sector
id_value = 0x80000000 or id

while(1) {
if(address >= FLASH_SECTOR_LIMIT){
address = flash_sector
erase_flash()
}
valor = read(address)
if(valor & (1<&lt;31)){
program_word(address,0x0)
address = 4
program_word(address,id_value)
break;
}
address
=4;
}
}
~~~
Ações #12

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Qual a linha de código do hardfault? Qual a falha?

Ações #13

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Qual a linha de código do hardfault? Qual a falha?

Ele entra na função write, depois na função de leitura da flash e então há o hard fault. Ele não especifica.

Ações #14

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Corrigido abaixo:

~~~
write (id, flash_sector){
address = flash_sector
id_value = 0x80000000 or id

while(1) {
if(address >= FLASH_SECTOR_LIMIT){
address = flash_sector
erase_flash()
}
valor = read(address)
if(valor & (1<<31)){
program_word(address,0x0)
address = 4
program_word(address,id_value)
break;
}
address
=4;
}
}
~~~

O sr chegou a achar alguma falha do meu algoritmo?

Ações #15

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Não analisei teu código. Gostaria de saber primeiro onde está ocorrendo a falha. Pode usar o call stack e o fault analyzer para isso.

Ações #16

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Não analisei teu código. Gostaria de saber primeiro onde está ocorrendo a falha. Pode usar o call stack e o fault analyzer para isso.

Usei. Ele parava no

~~
do{
value=readmemory(...)
}
~~

ai dentro do readmemory dentro do

~~
uwData = (__IO uint32_t)uwAddress;
~~

Já o Fault analyzer acusa que houve uma hard fault, com problema na Bus, Memory management or usage fault (forced).

A bus fault:
Precise data access violation (PRECISERR). BUS fault address register: 0x10018000

o stack pointer estava apontando para 0x2001ffa8.

Ações #17

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Neste caso, quanto vale uwAddress?

É possível que esteja com um valor de endereço de memória que não existe.

Ações #18

Atualizado por Onias Castelo Brancomais de 5 anos

Testei a implementação do sr agr e aparentemente ele entra em um loop infinito dentro da função ReadMemoryAddress. Coloco um breakpoint antes do break do while na função de write que nunca para o codigo. Quando pauso o mesmo ele sempre está na função de ReadMemoryAddress.

Ações #19

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Neste caso, quanto vale uwAddress?

É possível que esteja com um valor de endereço de memória que não existe.

Não ia pra espaços inexistentes, ficava dentro do limite do setor da flash. Se não me engano não saia de 0x800C000 (inicio da flash)

Ações #20

Atualizado por Onias Castelo Brancomais de 5 anos

Agora apareceu outro erro bizarro de código: cannot open output file test.elf: Invalid argument.

Ações #21

Atualizado por Onias Castelo Brancomais de 5 anos

Onias Castelo Branco escreveu:

Agora apareceu outro erro bizarro de código: cannot open output file test.elf: Invalid argument.

Não sei a origem do erro. Mas consertei voltando para o commit mais atualizado e inserindo manualmente as novas mudanças.

Ações #22

Atualizado por Onias Castelo Brancomais de 5 anos

Usando a feature do atollic que o sr mostrou, vi que estou conseguindo modificar os endereços da maneira correta. O que estranhei foi o formato do número que ele mostrou, confere com a lógica do código. A variavel do id está como volátil para o debug, na aplicação no robo ela é opcional.

![](flash_sector3.PNG)

Ações #23

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

O ARM usa arquitetura Little Endian, ou seja, o byte menos significativo é armazenado no menor endereço.

Ações #24

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

O ARM usa arquitetura Little Endian, ou seja, o byte menos significativo é armazenado no menor endereço.

Ah, sim. Entendi, obrigado. Próxima parte da tarefa é aplicar esse algoritmo na memória flash do robô.

Ações #25

Atualizado por Onias Castelo Brancomais de 5 anos

O firmware atual da SSL usa 98,97 KB na flash. Então, posso usar somente do setor 5 em diante, pois até o setor 4 são ocupados ao todo 128KB de memória. O código que fiz pra flash já está pronto para ser adaptado a outro setor sem maiores problemas.

Ações #26

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Você pode reservar um setor inicial para flash. Basta mudar a organização da memória no Linker Script, reservando o espaço.

Ações #27

Atualizado por Onias Castelo Brancomais de 5 anos

Mudei o código para mudar o id quando for apertado o botao e entao escrever tal valor na flash. A placa que eu peguei para testar aparentemente não está com o botão funcionando. Quando estiver no pirf testo com mais cuidado.

Ações #28

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Chegou a ver aquele código de menu que configura ID, Freq do NRF, e mostra tensão da bateria?

Ações #29

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Chegou a ver aquele código de menu que configura ID, Freq do NRF, e mostra tensão da bateria?

Vi sim. Li também o "readme" que o sr colocou em uma tarefa. Estou tentando fazer funcionar no código que temos agora para depois trabalhar em um codigo com free-rtos e hal (fazer junto com a vss e o batalha)

Ações #30

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Você pode reservar um setor inicial para flash. Basta mudar a organização da memória no Linker Script, reservando o espaço.

Já pesquisei sobre isso mas nao achei como designar, por exemplo, o primeiro setor da flash (id_space), para o codigo em cpp.

Ações #31

Atualizado por Onias Castelo Brancomais de 5 anos

Onias Castelo Branco escreveu:

Mudei o código para mudar o id quando for apertado o botao e entao escrever tal valor na flash. A placa que eu peguei para testar aparentemente não está com o botão funcionando. Quando estiver no pirf testo com mais cuidado.

Testei agora independente do botão. Antes do while eu leio o valor escrito na memória, incremento 1, escrevo esse valor na flash de novo e mostro no display.

Ações #32

Atualizado por Luiz Renault Leite Rodriguesmais de 5 anos

Dê uma olhada no Linker Script.

O problema é que quando o uC é iniciado, ele pega o vetor de interrupções do endereço 0.
Então não pode usar o primeiro setor para gravar os dados do robô.

Teria que usar o segundo.

Pode criar diferentes regiões de memória no linker script e colocar o código nas regiões que criar.

Dê uma olhada no script e veja se consegue desembocar.

Ações #33

Atualizado por Onias Castelo Brancomais de 5 anos

Onias Castelo Branco escreveu:

Mudei o código para mudar o id quando for apertado o botao e entao escrever tal valor na flash. A placa que eu peguei para testar aparentemente não está com o botão funcionando. Quando estiver no pirf testo com mais cuidado.

Fomos hoje no pirf e testei em outra placa mãe. O problema estava de fato no botão, que não está funcionando - mal soldado na placa (apresenta curto quando eu aperto mas não apresenta sinal de pull-up quando não está apertado)

Commitei agora o código na branch NewBoard. Testei com o robo funcionando e não apresentou problemas. Agora é refino de separar um espaço na flash só para isso e melhorar o mecanismo da troca de id.

Ações #34

Atualizado por Onias Castelo Brancomais de 5 anos

Esse link fala sobre como designar uma região nova no linker e como explicitá-la no código em c: http://blog.atollic.com/using-gnu-gcc-on-arm-cortex-devices-placing-code-and-data-on-special-memory-addresses-using-the-gnu-ld-linker

Já esse parece ser um bom guia geral de linker script: http://www.scoberlin.de/content/media/http/informatik/gcc_docs/ld_3.html

Ações #35

Atualizado por Onias Castelo Brancomais de 5 anos

Luiz Renault Leite Rodrigues escreveu:

Dê uma olhada no Linker Script.

O problema é que quando o uC é iniciado, ele pega o vetor de interrupções do endereço 0.
Então não pode usar o primeiro setor para gravar os dados do robô.

Teria que usar o segundo.

Pode criar diferentes regiões de memória no linker script e colocar o código nas regiões que criar.

Dê uma olhada no script e veja se consegue desembocar.

Ainda não entendi como simplesmente dividir a parte que vai o código no meio e mostrar isso no linker. Vou continuar lendo.

Ações

Exportar para Atom PDF