Do gerenciador de inicialização para o kernelSe 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 { _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:
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:
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