Boa tarde Alguns dias atrás, encontrei um pequeno problema em
nosso projeto - no manipulador de interrupção gdb, o rastreamento de pilha do Cortex-M foi exibido incorretamente. Portanto, mais uma vez, foi útil descobrir e de que maneiras posso obter um rastreamento de pilha para o ARM? Quais sinalizadores de compilação afetam a rastreabilidade da pilha no ARM? Como isso é implementado no kernel do Linux? Com base na pesquisa, decidi escrever este artigo.
Vejamos os dois métodos principais de rastreamento de pilha no kernel do Linux.
Empilhar desenrolar através de quadros
Vamos começar com uma abordagem simples que pode ser encontrada no kernel do Linux, mas que atualmente possui status obsoleto no GCC.
Imagine que um determinado programa esteja sendo executado na pilha na RAM e, em algum momento, nós o interrompemos e queremos exibir a pilha de chamadas. Suponha que tenhamos um ponteiro para a instrução atual que é executada pelo processador (PC), bem como o ponteiro atual para o topo da pilha (SP). Agora, para “pular” a pilha para a função anterior, você precisa entender que tipo de função era e onde devemos pular para essa função. O ARM usa o Link Register (LR) para essa finalidade.
O Link Register (LR) é o registro R14. Ele armazena as informações de retorno para sub-rotinas, chamadas de função e exceções. Na redefinição, o processador define o valor LR para 0xFFFFFFFF
Em seguida, precisamos subir a pilha e carregar os novos valores dos registros LR da pilha. A estrutura do quadro de pilha para o compilador é a seguinte:
/* The stack backtrace structure is as follows: fp points to here: | save code pointer | [fp] | return link value | [fp, #-4] | return sp value | [fp, #-8] | return fp value | [fp, #-12] [| saved r10 value |] [| saved r9 value |] [| saved r8 value |] ... [| saved r0 value |] r0-r3 are not normally saved in a C function. */
Esta descrição é obtida no arquivo de cabeçalho do GCC gcc / gcc / config / arm / arm.h.
I.e. o compilador (no nosso caso, GCC) pode, de alguma forma, ser informado de que queremos fazer um rastreamento de pilha. E então, no prólogo de cada função, o compilador preparará algum tipo de estrutura auxiliar. Você pode notar que nessa estrutura está o valor “próximo” do registro LR de que precisamos e, o mais importante, ele contém o endereço do próximo quadro
| return fp value | [fp, #-12]
| return fp value | [fp, #-12]
Este modo de compilador é especificado pela opção -mapcs-frame. Há uma menção na descrição da opção sobre "Especificar -fomit-frame-pointer com esta opção faz com que os quadros de pilha não sejam gerados para funções folha". Aqui, funções folha são entendidas como aquelas que não fazem chamadas para outras funções, para que possam ser um pouco mais fáceis.
Você também pode se perguntar o que fazer com as funções do assembler neste caso. Na verdade, nada complicado - você precisa inserir macros especiais. No
arquivo tools / objtool / Documentation / stack-validation.txt no kernel do Linux:
Cada função solicitável deve ser anotada como tal no ELF
tipo de função No código asm, isso geralmente é feito usando o
Macros ENTRY / ENDPROC.
Mas o mesmo documento discute que essa também é uma desvantagem óbvia dessa abordagem. O utilitário objtool verifica se todas as funções no kernel estão gravadas no formato correto para o rastreamento da pilha.
A seguir está a função de desenrolar uma pilha do kernel do Linux:
#if defined(CONFIG_FRAME_POINTER) && !defined(CONFIG_ARM_UNWIND) int notrace unwind_frame(struct stackframe *frame) { unsigned long high, low; unsigned long fp = frame->fp; frame->fp = *(unsigned long *)(fp - 12); frame->sp = *(unsigned long *)(fp - 8); frame->pc = *(unsigned long *)(fp - 4); return 0; } #endif
Mas aqui eu quero marcar a linha como
defined(CONFIG_ARM_UNWIND)
. Ela sugere que o kernel do Linux também usa outra implementação do unwind_frame, e falaremos sobre isso mais adiante.
A opção
-mapcs-frame é válida apenas para o conjunto de instruções ARM. Porém, sabe-se que os microcontroladores ARM possuem outro conjunto de instruções - Thumb (Thumb-1 e Thumb-2, para ser mais preciso), usado principalmente para a série Cortex-M. Para habilitar a geração de quadros no modo Thumb, use os
sinalizadores -mtpcs-frame e
-mtpcs-leaf-frame. Em essência, é um análogo do -mapcs-frame. Curiosamente, essas opções atualmente funcionam apenas para o Cortex-M0 / M1. Durante algum tempo, não consegui entender por que não consegui compilar a imagem desejada para o Cortex-M3 / M4 / .... Depois de reler todas as opções do gcc para o ARM e pesquisar na Internet, percebi que isso provavelmente era um bug. Portanto, subi diretamente no código-fonte do compilador
arm-none-eabi-gcc . Depois de estudar como o compilador gera quadros para ARM, Thumb-1 e Thumb-2, cheguei à conclusão de que eles ignoraram o Thumb-2, ou seja, no momento, os quadros são gerados apenas para Thumb-1 e ARM. Depois de criar os
bugs , os desenvolvedores do GCC explicaram que o padrão para ARM já foi alterado várias vezes e esses sinalizadores estão muito desatualizados, mas, por alguma razão, todos eles ainda existem no compilador. Abaixo está o desmontador da função para a qual o quadro é gerado.
static int my_func(int a) { my_func2(7); return 0; }
00008134 <my_func>: 8134: b084 sub sp, #16 8136: b580 push 8138: aa06 add r2, sp, #24 813a: 9203 str r2, [sp, #12] 813c: 467a mov r2, pc 813e: 9205 str r2, [sp, #20] 8140: 465a mov r2, fp 8142: 9202 str r2, [sp, #8] 8144: 4672 mov r2, lr 8146: 9204 str r2, [sp, #16] 8148: aa05 add r2, sp, #20 814a: 4693 mov fp, r2 814c: b082 sub sp, #8 814e: af00 add r7, sp, #0
Em comparação, um desmontador da mesma função para instruções ARM
000081f8 <my_func>: 81f8: e1a0c00d mov ip, sp 81fc: e92dd800 push {fp, ip, lr, pc} 8200: e24cb004 sub fp, ip, #4 8204: e24dd008 sub sp, sp, #8
À primeira vista, pode parecer que essas são coisas completamente diferentes. Mas, de fato, os quadros são exatamente os mesmos, o fato é que, no modo Thumb, a instrução push permite que apenas registros baixos (r0 - r7) e o registro lr sejam empilhados. Para todos os outros registros, isso deve ser feito em duas etapas, através das instruções mov e str, como no exemplo acima.
Desenrole a pilha através de exceções
Uma abordagem alternativa é o desenrolamento de pilha com base no
padrão Exception Handling ABI for ARM Architecture (
EHABI ). De fato, o principal exemplo de uso desse padrão é o tratamento de exceções em linguagens como C ++. As informações preparadas pelo compilador para manipulação de exceções também podem ser usadas para rastrear a pilha. Este modo é ativado com a opção GCC
-fexceptions (ou
-funwind-frames ).
Vamos dar uma olhada em como isso é feito. Para começar, este documento (EHABI) impõe certos requisitos no compilador para gerar tabelas auxiliares .ARM.exidx e .ARM.extab. É assim que esta seção .ARM.exidx é definida nas fontes do kernel do Linux. No arquivo
arch / arm / kernel / vmlinux.lds.h :
/* Stack unwinding tables */ #define ARM_UNWIND_SECTIONS \ . = ALIGN(8); \ .ARM.unwind_idx : { \ __start_unwind_idx = .; \ *(.ARM.exidx*) \ __stop_unwind_idx = .; \ } \
O padrão "Tratamento de exceções ABI para arquitetura ARM" define cada elemento da tabela .ARM.exidx como a seguinte estrutura:
struct unwind_idx { unsigned long addr_offset; unsigned long insn; };
O primeiro elemento é o deslocamento relativo ao início da função, e o segundo elemento é o endereço na tabela de instruções que precisa ser interpretado de uma maneira especial para girar ainda mais a pilha. Em outras palavras, cada elemento desta tabela é simplesmente uma sequência de palavras e meias palavras, que são uma sequência de instruções. A primeira palavra indica o número de instruções que devem ser concluídas para girar a pilha para o próximo quadro.
Estas instruções estão descritas no padrão EHABI já mencionado:

Além disso, a principal implementação desse intérprete no Linux está no arquivo
arch / arm / kernel / unwind.cImplementação da função unwind_frame int unwind_frame(struct stackframe *frame) { unsigned long low; const struct unwind_idx *idx; struct unwind_ctrl_block ctrl; idx = unwind_find_idx(frame->pc); if (!idx) { pr_warn("unwind: Index not found %08lx\n", frame->pc); return -URC_FAILURE; } ctrl.vrs[FP] = frame->fp; ctrl.vrs[SP] = frame->sp; ctrl.vrs[LR] = frame->lr; ctrl.vrs[PC] = 0; if (idx->insn == 1) return -URC_FAILURE; else if ((idx->insn & 0x80000000) == 0) ctrl.insn = (unsigned long *)prel31_to_addr(&idx->insn); else if ((idx->insn & 0xff000000) == 0x80000000) ctrl.insn = &idx->insn; else { pr_warn("unwind: Unsupported personality routine %08lx in the index at %p\n", idx->insn, idx); return -URC_FAILURE; } if ((*ctrl.insn & 0xff000000) == 0x80000000) { ctrl.byte = 2; ctrl.entries = 1; } else if ((*ctrl.insn & 0xff000000) == 0x81000000) { ctrl.byte = 1; ctrl.entries = 1 + ((*ctrl.insn & 0x00ff0000) >> 16); } else { pr_warn("unwind: Unsupported personality routine %08lx at %p\n", *ctrl.insn, ctrl.insn); return -URC_FAILURE; } ctrl.check_each_pop = 0; while (ctrl.entries > 0) { int urc; if ((ctrl.sp_high - ctrl.vrs[SP]) < sizeof(ctrl.vrs)) ctrl.check_each_pop = 1; urc = unwind_exec_insn(&ctrl); if (urc < 0) return urc; if (ctrl.vrs[SP] < low || ctrl.vrs[SP] >= ctrl.sp_high) return -URC_FAILURE; } frame->fp = ctrl.vrs[FP]; frame->sp = ctrl.vrs[SP]; frame->lr = ctrl.vrs[LR]; frame->pc = ctrl.vrs[PC]; return URC_OK; }
Esta é uma implementação da função unwind_frame, usada se a opção CONFIG_ARM_UNWIND estiver ativada. Eu inseri os comentários com explicações em russo diretamente no texto fonte.
A seguir, é apresentado um exemplo de como o elemento de tabela .ARM.exidx procura a função kernel_start no Embox:
$ arm-none-eabi-readelf -u build/base/bin/embox Unwind table index '.ARM.exidx' at offset 0xaa6d4 contains 2806 entries: <...> 0x1c3c <kernel_start>: @0xafe40 Compact model index: 1 0x9b vsp = r11 0x40 vsp = vsp - 4 0x84 0x80 pop {r11, r14} 0xb0 finish 0xb0 finish <...>
E aqui está o desmontador:
00001c3c <kernel_start>: void kernel_start(void) { 1c3c: e92d4800 push {fp, lr} 1c40: e28db004 add fp, sp, #4 <...>
Vamos seguir os passos. Vemos a atribuição
vps = r11
. (R11 é FP) e depois
vps = vps - 4
. Isso corresponde à instrução
add fp, sp, #4
. Em seguida, vem pop {r11, r14}, que corresponde à instrução
push {fp, lr}
. A última instrução de
finish
informa o final da execução (para ser sincero, ainda não entendo por que existem duas instruções de acabamento).
Agora vamos ver quanta memória o assembly com o
sinalizador -funwind-frames consome.Para o experimento, compilei a Embox para a plataforma STM32F4-Discovery. Aqui estão os resultados do objdump:
Com o sinalizador -funwind-frames:Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0005a600 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 .ARM.exidx 00003fd8 0805a600 0805a600 0005e600 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .ARM.extab 000049d0 0805e5d8 0805e5d8 000625d8 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .rodata 0003e380 08062fc0 08062fc0 00066fc0 2**5
Sem bandeira:Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00058b1c 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 .ARM.exidx 00000008 08058b1c 08058b1c 0005cb1c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .rodata 0003e380 08058b40 08058b40 0005cb40 2**5
É fácil calcular que as seções .ARM.exidx e .ARM.extab ocupam aproximadamente 1/10 do tamanho .text. Depois disso, coletei uma imagem maior - para o ARM Integrator CP com base no ARM9, e havia essas seções com 1/12 do tamanho da seção .text. Mas é claro que essa proporção pode variar de projeto para projeto. Também ocorreu que o tamanho da imagem que adiciona o sinalizador -macps-frame é menor que a opção de exceção (o que é esperado). Portanto, por exemplo, quando o tamanho da seção .text era 600 Kb, o tamanho total de .ARM.exidx + .ARM.extab era 50 Kb e o tamanho do código adicional com o sinalizador -mapcs-frame era de apenas 10 Kb. Mas se olharmos acima, que grande prólogo foi gerado para o Cortex-M1 (lembre-se, através de mov / str?), Então fica claro que, neste caso, praticamente não haverá diferença, o que significa que é improvável que você use
-mtpcs-frame no modo Thumb faz pelo menos algum sentido.
Esse rastreamento de pilha é necessário para o ARM agora? Quais são as alternativas?
Uma terceira abordagem é rastrear a pilha usando um depurador. Parece que muitos sistemas operacionais para trabalhar com microcontroladores FreeRTOS, NuttX atualmente
sugerem essa
opção de rastreamento específica ou se oferecem para assistir a um desmontador.
Como resultado, chegamos à conclusão de que o rastreamento de pilha para armas em tempo de execução não é realmente usado em nenhum lugar. Provavelmente, isso é uma consequência do desejo de criar o código mais eficiente durante o trabalho e executar as ações de depuração (que incluem a promoção da pilha) offline. Por outro lado, se o sistema operacional já usa código C ++, é bem possível usar a implementação do rastreamento por meio de .ARM.exidx.
Bem, sim, o problema com a saída incorreta da pilha na interrupção na
Embox foi resolvido com muita simplicidade; acabou sendo suficiente para salvar o registro LR na pilha.