Inicialização do kernel do Linux. Parte 1

Do gerenciador de inicialização para o kernel

Se você leu os artigos anteriores, conhece o meu novo hobby para programação de baixo nível. Escrevi vários artigos sobre programação de assembler para o x86_64 Linux e, ao mesmo tempo, comecei a mergulhar no código fonte do kernel do Linux.

Estou muito interessado em entender como as coisas de baixo nível funcionam: como os programas são executados no meu computador, como estão localizados na memória, como o kernel gerencia processos e memória, como a pilha de rede funciona em um nível baixo e muito mais. Então, decidi escrever outra série de artigos sobre o kernel do Linux para a arquitetura x86_64 .

Observe que eu não sou um desenvolvedor profissional de kernel e não escrevo código do kernel no trabalho. Este é apenas um hobby. Eu apenas gosto de coisas de baixo nível e é interessante mergulhar nelas. Portanto, se você perceber alguma confusão ou perguntas / comentários aparecerem, entre em contato comigo no Twitter , por correio ou apenas crie um ticket . Eu ficaria grato.

Todos os artigos são publicados no repositório GitHub e, se algo estiver errado com o meu inglês ou com o conteúdo do artigo, não hesite em enviar uma solicitação de recebimento.

Observe que isso não é documentação oficial, mas simplesmente treinamento e compartilhamento de conhecimento.

Conhecimento requerido

  • Noções básicas sobre código C
  • Compreendendo o código do assembler (sintaxe da AT&T)

De qualquer forma, se você está apenas começando a aprender essas ferramentas, tentarei explicar algo neste e em outros artigos. Ok, com a introdução concluída, é hora de mergulhar no kernel do Linux e coisas de baixo nível.

Comecei a escrever este livro nos dias do kernel do Linux 3.18 e muita coisa mudou desde então. Se houver alterações, atualizarei os artigos de acordo.

Botão de energia mágico, o que vem a seguir?


Embora estes sejam artigos sobre o kernel do Linux, ainda não o alcançamos - pelo menos nesta seção. Assim que você pressiona o botão liga / desliga mágico no laptop ou no computador, ele começa a funcionar. A placa-mãe envia um sinal para a fonte de alimentação . Após receber o sinal, ele fornece ao computador a quantidade necessária de eletricidade. Assim que a placa-mãe recebe o sinal "Power OK" , ela tenta iniciar a CPU. Ele despeja todos os dados restantes em seus registros e define valores predefinidos para cada um deles.

Os processadores 80386 e versões posteriores devem ter os seguintes valores nos registros da CPU após uma reinicialização:

  IP 0xfff0
 Seletor CS 0xf000
 CS base 0xffff0000 

O processador começa a funcionar em modo real . Vamos voltar um pouco e tentar entender a segmentação de memória nesse modo. O modo real é suportado em todos os processadores compatíveis com x86: de 8086 a modernos processadores Intel de 64 bits. O processador 8086 usa um barramento de endereços de 20 bits, ou seja, pode trabalhar com um espaço de endereço de 0-0xFFFFF ou 1 . Mas ele possui apenas registros de 16 bits com um endereço máximo de 2^16-1 ou 0xffff (64 kilobytes).

A segmentação de memória é necessária para usar todo o espaço de endereço disponível. Toda a memória é dividida em pequenos segmentos de tamanho fixo de 65536 bytes (64 KB). Como com registros de 16 bits, não podemos acessar a memória acima de 64 KB, um método alternativo foi desenvolvido.

O endereço consiste em duas partes: 1) um seletor de segmento com um endereço base; 2) deslocamento do endereço base. No modo real, o endereço base do * 16 segmentos * 16 . Portanto, para obter o endereço físico na memória, você precisa multiplicar parte do seletor de segmentos por 16 e adicionar o deslocamento a ele:

   =   * 16 +  

Por exemplo, se o registro CS:IP tiver o valor 0x2000:0x0010 , o endereço físico correspondente será o seguinte:

 >>> hex((0x2000 << 4) + 0x0010) '0x20010' 

Mas se você escolher o seletor do maior segmento e o deslocamento 0xffff:0xffff , obterá o endereço:

 >>> hex((0xffff << 4) + 0xffff) '0x10ffef' 

isto é, 65520 bytes após o primeiro megabyte. Como apenas um megabyte está disponível no modo real, 0x10ffef se torna 0x00ffef com a linha A20 desativada.

Bem, agora sabemos um pouco sobre o modo real e o endereçamento de memória nesse modo. Vamos voltar à discussão dos valores do registro após a redefinição.

O registro CS consiste em duas partes: um seletor de segmento visível e um endereço base oculto. Embora o endereço base seja geralmente formado pela multiplicação do valor do seletor de segmentos por 16, durante uma redefinição de hardware, o seletor de segmentos no registro CS é 0xf000 e o endereço base é 0xffff0000 . O processador usa esse endereço base especial até o CS mudar.

O endereço inicial é formado adicionando o endereço base ao valor no registro EIP:

 >>> 0xffff0000 + 0xfff0 '0xfffffff0' 

Temos 0xfffffff0 , que é 16 bytes abaixo de 4 GB. Esse ponto é chamado de vetor de redefinição . Este é o local na memória em que a CPU aguarda a execução da primeira instrução após uma redefinição: uma operação de salto ( jmp ), que geralmente indica o ponto de entrada do BIOS. Por exemplo, se você olhar o código fonte do coreboot ( src/cpu/x86/16bit/reset16.inc ), veremos:

  .section ".reset", "ax", %progbits .code16 .globl _start _start: .byte 0xe9 .int _start16bit - ( . + 2 ) ... 

Aqui vemos o código de operação ( opcode ) jmp , ou 0xe9 , 0xe9 , e o endereço de destino _start16bit - ( . + 2) .

Também vemos que a seção de reset é de 16 bytes e é compilada para ser executada a partir do endereço 0xfffff0 ( src/cpu/x86/16bit/reset16.ld ):

 SECTIONS { /* Trigger an error if I have an unuseable start address */ _bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report."); _ROMTOP = 0xfffffff0; . = _ROMTOP; .reset . : { *(.reset); . = 15; BYTE(0x00); } } 

O BIOS agora inicia; Depois de inicializar e verificar o hardware do BIOS, você precisa encontrar o dispositivo de inicialização. A ordem de inicialização é salva na configuração do BIOS. Ao tentar inicializar a partir do disco rígido, o BIOS tenta encontrar o setor de inicialização. Nos discos particionados MBR , o setor de inicialização é armazenado nos primeiros 446 bytes do primeiro setor, em que cada setor tem 512 bytes. Os dois últimos bytes do primeiro setor são 0x55 e 0xaa . Eles mostram ao BIOS que é um dispositivo de inicialização.

Por exemplo:

 ; ; :       Intel x86 ; [BITS 16] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa 

Coletamos e executamos:

nasm -f bin boot.nasm && qemu-system-x86_64 boot

O QEMU recebe um comando para usar o binário de boot que acabamos de criar como imagem de disco. Como o arquivo binário gerado acima atende aos requisitos do setor de inicialização (iniciando em 0x7c00 e terminando com uma sequência mágica), o QEMU considerará o binário como o registro mestre de inicialização (MBR) da imagem de disco.

Você verá:



Neste exemplo, vemos que o código é executado no modo real de 16 bits e inicia no endereço 0x7c00 na memória. Após iniciar, causa uma interrupção de 0x10 , que simplesmente imprime um personagem ! ; preenche os 510 bytes restantes com zeros e termina com dois bytes mágicos 0xaa e 0x55 .

Você pode ver o despejo binário com o utilitário objdump :

nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot


Obviamente, no setor de inicialização real, há código para continuar o processo de inicialização e uma tabela de partição em vez de um monte de zeros e um ponto de exclamação :). A partir deste momento, o BIOS transfere o controle para o carregador de inicialização.

Nota : como explicado acima, a CPU está no modo real; onde o cálculo do endereço físico na memória é o seguinte:

   =   * 16 +  

Temos apenas registradores de uso geral de 16 bits e o valor máximo do registrador de 16 bits é 0xffff ; portanto, nos maiores valores, o resultado será:

 >>> hex((0xffff * 16) + 0xffff) '0x10ffef' 

onde 0x10ffef é 1 + 64 - 16 . O processador 8086 (o primeiro processador no modo real) possui uma linha de endereço de 20 bits. Como 2^20 = 1048576 , a memória disponível real é de 1 MB.

Em geral, o endereçamento de memória em modo real é o seguinte:

  0x00000000 - 0x000003FF - tabela de vetores de interrupção do modo real
 0x00000400 - 0x000004FF - área de dados do BIOS
 0x00000500 - 0x00007BFF - não usado
 0x00007C00 - 0x00007DFF - nosso gerenciador de inicialização
 0x00007E00 - 0x0009FFFF - não usado
 0x000A0000 - 0x000BFFFF - RAM de vídeo (VRAM) 
 0x000B0000 - 0x000B7777 - memória de vídeo monocromática
 0x000B8000 - 0x000BFFFF - memória de vídeo em modo de cor
 0x000C0000 - 0x000C7FFF - BIOS de ROM de vídeo
 0x000C8000 - 0x000EFFFF - área de sombra (BIOS Shadow)
 0x000F0000 - 0x000FFFFF - BIOS do sistema 

No início do artigo, está escrito que a primeira instrução do processador está localizada em 0xFFFFFFF0 , que é muito mais que 0xFFFFF (1 MB). Como a CPU pode acessar esse endereço no modo real? Resposta na documentação do coreboot :

0xFFFE_0000 - 0xFFFF_FFFF: 128 ROM

No início da execução, o BIOS não está na RAM, mas na ROM.

Carregador de inicialização


O kernel do Linux pode ser carregado com diferentes gerenciadores de inicialização, como GRUB 2 e syslinux . O kernel possui um protocolo de inicialização que define os requisitos do carregador de inicialização para implementar o suporte ao Linux. Neste exemplo, estamos trabalhando com o GRUB 2.

Continuando o processo de inicialização, o BIOS selecionou o dispositivo de inicialização e transferiu o controle para o setor de inicialização, a execução começa com boot.img . Devido ao seu tamanho limitado, este é um código muito simples. Ele contém um ponteiro para acessar a imagem principal do GRUB 2. Ele começa com diskboot.img e geralmente é armazenado imediatamente após o primeiro setor no espaço não utilizado antes da primeira partição. O código acima carrega na memória o restante da imagem que contém o kernel do GRUB 2 e os drivers para o processamento de sistemas de arquivos. Depois disso, a função grub_main é executada .

A função grub_main inicializa o console, retorna o endereço base dos módulos, define o dispositivo raiz, carrega / analisa o arquivo de configuração do grub, carrega os módulos, etc. No final da execução, coloca o grub no modo normal. A função grub_normal_execute (do arquivo de origem grub-core/normal/main.c ) conclui as últimas preparações e exibe um menu para escolher o sistema operacional. Quando selecionamos um dos itens de menu do grub, a função grub_menu_execute_entry é grub_menu_execute_entry , que executa o comando de boot do grub e carrega o sistema operacional selecionado.

Conforme indicado no protocolo de inicialização do kernel, o carregador de inicialização deve ler e preencher alguns campos do cabeçalho de instalação do kernel, que inicia no deslocamento 0x01f1 do código de instalação do kernel. Esse deslocamento é indicado no script do vinculador . O cabeçalho do kernel arch / x86 / boot / header.S começa com:

  .globl hdr hdr: setup_sects: .byte 0 root_flags: .word ROOT_RDONLY syssize: .long 0 ram_size: .word 0 vid_mode: .word SVGA_MODE root_dev: .word 0 boot_flag: .word 0xAA55 

O carregador de inicialização deve preencher este e outros cabeçalhos (que são marcados apenas como tipo write no protocolo de inicialização do Linux, como neste exemplo) com valores recebidos da linha de comando ou calculados no momento da inicialização. Agora não vamos nos debruçar nas descrições e explicações para todos os campos de cabeçalho. Discutiremos mais tarde como o kernel os usa. Para uma descrição de todos os campos, consulte o protocolo de download .

Como você pode ver no protocolo de inicialização do kernel, a memória será exibida da seguinte maneira:

  |  Modo protegido do kernel |
 100000 + ------------------------ +
          |  Mapeamento de E / S |
 0A0000 + ------------------------ +
          |  Reserva  para BIOS |  Deixe o máximo possível livre
          ~ ~
          |  Linha de comando |  (também pode estar abaixo de X + 10000)
 X + 10000 + ------------------------ +
          |  Pilha / pilha |  Para usar o código real do modo kernel
 X + 08000 + ------------------------ +
          |  Instalação do Kernel |  Código de modo real do kernel
          |  Setor de inicialização do kernel |  Setor de inicialização do kernel herdado
        X + ------------------------ +
          |  Carregador |  <- Ponto de entrada do setor de inicialização 0x7C00
 001000 + ------------------------ +
          |  Reserva  para MBR / BIOS |
 000800 + ------------------------ +
          |  Costuma usar  MBR
 000600 + ------------------------ +
          |  Usado  Apenas BIOS |
 000000 + ------------------------ +

Portanto, quando o carregador transfere o controle para o kernel, ele começa com o endereço:

 X + sizeof (KernelBootSector) + 1 

onde X é o endereço do setor de inicialização do kernel. No nosso caso, X é 0x10000 , como visto no despejo de memória:



O carregador de inicialização moveu o kernel do Linux para a memória, preencheu os campos do cabeçalho e depois mudou-se para o endereço de memória correspondente. Agora podemos ir diretamente para o código de instalação do kernel.

Início da fase de instalação do kernel


Finalmente, estamos no centro! Embora tecnicamente ainda não esteja em execução. Primeiro, a parte de instalação do kernel precisa configurar algo, incluindo um descompactador e algumas coisas com o gerenciamento de memória. Depois de tudo isso, ela vai descompactar o núcleo real e seguir em frente. A instalação inicia em arch / x86 / boot / header.S com o caractere _start .

À primeira vista, isso pode parecer um pouco estranho, pois há várias instruções à sua frente. Mas há muito tempo, o kernel do Linux tinha seu próprio gerenciador de inicialização. Agora, se você executar, por exemplo,

qemu-system-x86_64 vmlinuz-3.18-generic

você verá:



Na verdade, o arquivo header.S começa com o número mágico MZ (veja a captura de tela do despejo acima), o texto da mensagem de erro e o cabeçalho PE :

 #ifdef CONFIG_EFI_STUB # "MZ", MS-DOS header .byte 0x4d .byte 0x5a #endif ... ... ... pe_header: .ascii "PE" .word 0 

É necessário carregar um sistema operacional com suporte UEFI . Vamos considerar seu dispositivo nos próximos capítulos.

Ponto de entrada real para instalar o kernel:

 // header.S line 292 .globl _start _start: 

O gerenciador de inicialização (grub2 e outros) conhece esse ponto (deslocamento 0x200 do MZ ) e vai diretamente para ele, embora o header.S comece na seção .bstext , onde o texto da mensagem de erro está localizado:

 // // arch/x86/boot/setup.ld // . = 0; // current position .bstext : { *(.bstext) } // put .bstext section to position 0 .bsdata : { *(.bsdata) } 

Ponto de entrada da instalação do kernel:

  .globl _start _start: .byte 0xeb .byte start_of_setup-1f 1: // // rest of the header // 

Aqui vemos o código de operação jmp ( 0xeb ), que vai para o ponto start_of_setup-1f . Na notação Nf , por exemplo, 2f refere-se ao rótulo local 2: No nosso caso, esse é o rótulo 1 , que está presente imediatamente após a transição e contém o restante do cabeçalho da instalação. Imediatamente após o cabeçalho da instalação, vemos a seção .entrytext , que começa com o rótulo start_of_setup .

Este é o primeiro código realmente executado (além das instruções de salto anteriores, é claro). Depois que parte da instalação do kernel recebe controle do carregador, a primeira instrução jmp fica localizada no deslocamento 0x200 desde o início do modo real do kernel, ou seja, após os primeiros 512 bytes. Isso pode ser visto no protocolo de inicialização do kernel Linux e no código-fonte grub2:

 segment = grub_linux_real_target >> 4; state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20; 

No nosso caso, o kernel é inicializado no endereço 0x10000 . Isso significa que, após iniciar a instalação do kernel, os registradores de segmento terão os seguintes valores:

gs = fs = es = ds = ss = 0x10000
cs = 0x10200


Depois de start_of_setup kernel deve fazer o seguinte:

  • Verifique se todos os valores de registro de segmento são os mesmos
  • Se necessário, configure a pilha correta
  • Configurar bss
  • Vá para o código C em arch / x86 / boot / main.c

Vamos ver como isso é implementado.

Alinhamento de caso por segmento


Primeiro, o kernel verifica se os registros do segmento ds e es apontam para o mesmo endereço. Em seguida, ele limpa o sinalizador de direção usando a cld :

  movw %ds, %ax movw %ax, %es cld 

Como escrevi anteriormente, o grub2, por padrão, carrega o código de instalação do kernel em 0x10000 e cs em 0x10200 , porque a execução não inicia no início do arquivo, mas na transição aqui:

 _start: .byte 0xeb .byte start_of_setup-1f 

Este é um deslocamento de 512 bytes de 4d 5a . Também é necessário alinhar cs de 0x10200 a 0x10000 , como todos os outros registradores de segmento. Depois disso, instale a pilha:

  pushw %ds pushw $6f lretw 

Esta instrução coloca o valor ds na pilha, seguido pelo endereço da etiqueta 6 e a instrução lretw , que carrega o endereço da etiqueta 6 no registro do contador de comandos e carrega cs com o valor ds . Depois disso, ds e cs terão os mesmos valores.

Configuração da pilha


Quase todo esse código faz parte do processo de preparação do ambiente C em modo real. A próxima etapa é verificar o valor do registro ss e criar a pilha correta se o valor ss estiver incorreto:

  movw %ss, %dx cmpw %ax, %dx movw %sp, %dx je 2f 

Isso pode desencadear três cenários diferentes:

  • ss valor válido de 0x1000 (como em todos os outros registradores, exceto cs )
  • ss valor inválido e o sinalizador CAN_USE_HEAP definido (veja abaixo)
  • ss valor inválido e o sinalizador CAN_USE_HEAP não CAN_USE_HEAP definido (veja abaixo)

Considere todos os cenários em ordem:

  • ss valor válido ( 0x1000 ). Nesse caso, vamos rotular 2:

 2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp sti 

Aqui, definimos o alinhamento do registro dx (que contém o valor sp especificado pelo carregador) como 4 bytes e verificamos o zero. Se for zero, colocamos o valor 0xfffc dx (endereço alinhado a 4 bytes antes do tamanho máximo do segmento de 64 KB). Se não for igual a zero, continuamos a usar o valor sp especificado pelo carregador de inicialização ( 0xf7f4 no nosso caso). Em seguida, colocamos o valor do ax em ss , que salva o endereço de segmento correto 0x1000 e define o sp correto. Agora temos a pilha correta:



  • No segundo cenário, ss != ds . Primeiro, colocamos o valor _end (o endereço do final do código de instalação) em dx e verificamos o campo de cabeçalho loadflags , usando a instrução testb para verificar se o heap pode ser usado. loadflags é um cabeçalho de máscara de bit que é definido da seguinte maneira:

 #define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7) 

e conforme indicado no protocolo de inicialização:

: loadflags

.

7 (): CAN_USE_HEAP
1, ,
heap_end_ptr . ,
.


Se o bit CAN_USE_HEAP estiver CAN_USE_HEAP , em dx , definimos o valor heap_end_ptr (que aponta para _end ) e adicionamos STACK_SIZE a ele (o tamanho mínimo da pilha é 1024 bytes). Depois disso, vá para o rótulo 2 (como no caso anterior) e faça a pilha correta.



  • Se CAN_USE_HEAP não CAN_USE_HEAP definido, use a pilha mínima de _end a _end + STACK_SIZE :



Configuração do BSS


São necessárias mais duas etapas antes de passar para o código C principal: isso é configurar a área BSS e verificar a assinatura “mágica”. Verificação de assinatura primeiro:

  cmpl $0x5a5aaa55, setup_sig jne setup_bad 

A instrução simplesmente compara setup_sig com o número mágico 0x5a5aaa55. Se eles não forem iguais, um erro fatal é relatado.

Se o número mágico corresponder e tivermos um conjunto correto de registradores de segmentos e uma pilha, tudo o que resta é configurar a seção BSS antes de prosseguir para o código C.

A seção BSS é usada para armazenar dados não inicializados estaticamente alocados. O Linux verifica cuidadosamente se essa área de memória foi redefinida:

  movw $__bss_start, %di movw $_end+3, %cx xorl %eax, %eax subw %di, %cx shrw $2, %cx rep; stosl 

Primeiro, o endereço inicial de __bss_start é movido para di . Em seguida, o endereço _end + 3 (+3 para alinhamento por 4 bytes) é movido para cx . O registro eax é limpo (usando a instrução xor ), o tamanho da seção bss ( cx-di ) é calculado e colocado em cx . Então cx é dividido em quatro (o tamanho da “palavra”) e a instrução stosl é usada stosl , armazenando o valor (zero) no endereço apontando para di , aumentando automaticamente di em quatro e repetindo isso até que chegue a zero). O efeito __bss_start desse código é que zeros são gravados em todas as palavras na memória, de __bss_start a _end :



Vá para o principal


É isso: temos uma pilha e BSS, para que você possa ir para a função main() C:

  calll main 

A função main() está localizada em arch / x86 / boot / main.c. Falaremos sobre ela na próxima parte.

Conclusão


Este é o fim da primeira parte do dispositivo do kernel Linux. , , . C, Linux, , memset , memcpy , earlyprintk , .

Referências


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


All Articles