Exemplos executáveis
Vamos criar e executar alguns minúsculos programas hello world do bare metal que são executados sem um sistema operacional:
Também os testaremos no emulador QEMU, tanto quanto possível, pois é mais seguro e conveniente para o desenvolvimento. Os testes do QEMU foram realizados em um host Ubuntu 18.04 com o QEMU 2.11.1 pré-empacotado.
O código de todos os exemplos x86 abaixo e mais está presente neste repositório do GitHub .
Como executar os exemplos no hardware real x86
Lembre-se de que exemplos em execução em hardware real podem ser perigosos, por exemplo, você pode limpar seu disco ou bloquear o hardware por engano: faça isso apenas em máquinas antigas que não contêm dados críticos! Ou melhor ainda, use devboards semi-descartáveis baratos, como o Raspberry Pi, veja o exemplo do ARM abaixo.
Para um laptop x86 típico, é necessário fazer algo como:
Grave a imagem em um pen drive (destruirá seus dados!):
sudo dd if=main.img of=/dev/sdX
conecte o USB ao computador
ligue
diga para inicializar a partir do USB.
Isso significa fazer com que o firmware escolha USB antes do disco rígido.
Se esse não é o comportamento padrão da sua máquina, continue pressionando Enter, F12, ESC ou outras teclas estranhas após a inicialização, até que você obtenha um menu de inicialização onde você pode selecionar para inicializar a partir do USB.
Geralmente, é possível configurar a ordem de pesquisa nesses menus.
Por exemplo, no meu T430, vejo o seguinte.
Depois de ligar, é quando eu tenho que pressionar Enter para entrar no menu de inicialização:
Então, aqui eu tenho que pressionar F12 para selecionar o USB como o dispositivo de inicialização:
A partir daí, posso selecionar o USB como o dispositivo de inicialização assim:
Como alternativa, para alterar a ordem de inicialização e escolher o USB com maior precedência, para que eu não precise selecioná-lo manualmente todas as vezes, pressione F1 na tela "Menu de interrupção de inicialização" e navegue até:
Setor de inicialização
No x86, a coisa mais simples e de nível mais baixo que você pode fazer é criar um MBR (setor de inicialização mestre) , que é um tipo de setor de inicialização , e depois instalá-lo em um disco.
Aqui, criamos um com uma única printf
chamada:
printf '\364%509s\125\252' > main.img
sudo apt-get install qemu-system-x86
qemu-system-x86_64 -hda main.img
Resultado:
Observe que, mesmo sem fazer nada, alguns caracteres já estão impressos na tela. Esses são impressos pelo firmware e servem para identificar o sistema.
E no T430, obtemos uma tela em branco com um cursor piscando:
main.img
contém o seguinte:
\364
in octal == 0xf4
in hex: a codificação de uma hlt
instrução, que diz à CPU para parar de funcionar.
Portanto, nosso programa não fará nada: apenas inicie e pare.
Usamos octal porque os \x
números hexadecimais não são especificados pelo POSIX.
Poderíamos obter essa codificação facilmente com:
echo hlt > a.S
as -o a.o a.S
objdump -S a.o
quais saídas:
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <.text>:
0: f4 hlt
mas também está documentado no manual da Intel, é claro.
%509s
produzir 509 espaços. Necessário preencher o arquivo até o byte 510.
\125\252
em octal == 0x55
seguido por 0xaa
.
Estes são 2 bytes mágicos necessários, que devem ser os bytes 511 e 512.
O BIOS passa por todos os nossos discos procurando por inicializáveis, e considera apenas inicializáveis aqueles que possuem esses dois bytes mágicos.
Se não estiver presente, o hardware não tratará isso como um disco inicializável.
Se você não é um printf
mestre, pode confirmar o conteúdo main.img
com:
hd main.img
que mostra o esperado:
00000000 f4 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 |. |
00000010 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
*
000001f0 20 20 20 20 20 20 20 20 20 20 20 20 20 20 55 aa | U.|
00000200
onde 20
é um espaço em ASCII.
O firmware do BIOS lê esses 512 bytes do disco, coloca-os na memória e define o PC como o primeiro byte para começar a executá-los.
Olá, setor de inicialização mundial
Agora que criamos um programa mínimo, vamos para o mundo olá.
A pergunta óbvia é: como fazer IO? Algumas opções:
peça ao firmware, por exemplo, BIOS ou UEFI, que faça por nós
VGA: região de memória especial que é impressa na tela se gravada. Pode ser usado no modo protegido.
escreva um driver e fale diretamente com o hardware da tela. Esta é a maneira "correta" de fazê-lo: mais poderoso, mas mais complexo.
porta serial . Este é um protocolo padronizado muito simples que envia e recebe caracteres de um terminal host.
Nos desktops, fica assim:
Fonte .
Infelizmente, ele não está exposto na maioria dos laptops modernos, mas é o caminho mais comum para as placas de desenvolvimento, veja os exemplos de ARM abaixo.
É realmente uma pena, pois essas interfaces são realmente úteis para depurar o kernel do Linux, por exemplo .
use recursos de depuração de chips. O ARM chama de semi - hospedagem deles, por exemplo. Em hardware real, requer algum suporte extra de hardware e software, mas em emuladores pode ser uma opção conveniente e gratuita. Exemplo .
Aqui vamos fazer um exemplo de BIOS, pois é mais simples no x86. Mas observe que este não é o método mais robusto.
main.S
.code16
mov $msg, %si
mov $0x0e, %ah
loop:
lodsb
or %al, %al
jz halt
int $0x10
jmp loop
halt:
hlt
msg:
.asciz "hello world"
GitHub upstream .
link.ld
SECTIONS
{
/* The BIOS loads the code from the disk to this location.
* We must tell that to the linker so that it can properly
* calculate the addresses of symbols we might jump to.
*/
. = 0x7c00;
.text :
{
__start = .;
*(.text)
/* Place the magic boot bytes at the end of the first 512 sector. */
. = 0x1FE;
SHORT(0xAA55)
}
}
Monte e vincule com:
as -g -o main.o main.S
ld --oformat binary -o main.img -T link.ld main.o
qemu-system-x86_64 -hda main.img
Resultado:
E no T430:
Testado em: Lenovo Thinkpad T430, UEFI BIOS 1.16. Disco gerado em um host Ubuntu 18.04.
Além das instruções de montagem padrão da userland, temos:
.code16
: diz ao GAS para gerar código de 16 bits
cli
: desativar interrupções de software. Isso pode fazer com que o processador volte a funcionar após ohlt
int $0x10
: faz uma chamada do BIOS. É isso que imprime os caracteres um por um.
Os sinalizadores de link importantes são:
--oformat binary
: imprima o código de montagem binário bruto, não o envolva em um arquivo ELF, como é o caso dos executáveis regulares da terra do usuário.
Para entender melhor a parte do script do vinculador, familiarize-se com a etapa de realocação do link: O que os vinculadores fazem?
Programas cooler x86 bare metal
Aqui estão algumas configurações bare metal mais complexas que eu já consegui:
Use C em vez de montagem
Resumo: use a inicialização múltipla do GRUB, que resolverá muitos problemas irritantes em que você nunca pensou. Veja a seção abaixo.
A principal dificuldade do x86 é que o BIOS carrega apenas 512 bytes do disco na memória e é provável que você exploda esses 512 bytes ao usar o C!
Para resolver isso, podemos usar um carregador de inicialização de dois estágios . Isso faz outras chamadas do BIOS, que carregam mais bytes do disco na memória. Aqui está um exemplo mínimo de montagem do estágio 2 do zero usando as chamadas int 0x13 do BIOS :
Alternativamente:
- se você precisar apenas trabalhar no QEMU, mas não em hardware real, use a
-kernel
opção que carrega um arquivo ELF inteiro na memória. Aqui está um exemplo do ARM que eu criei com esse método .
- para o Raspberry Pi, o firmware padrão cuida da imagem carregada para nós a partir de um arquivo ELF chamado
kernel7.img
, assim como o QEMU -kernel
.
Apenas para fins educacionais, aqui está um exemplo C mínimo de um estágio :
main.c
void main(void) {
int i;
char s[] = {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'};
for (i = 0; i < sizeof(s); ++i) {
__asm__ (
"int $0x10" : : "a" ((0x0e << 8) | s[i])
);
}
while (1) {
__asm__ ("hlt");
};
}
entry.S
.code16
.text
.global mystart
mystart:
ljmp $0, $.setcs
.setcs:
xor %ax, %ax
mov %ax, %ds
mov %ax, %es
mov %ax, %ss
mov $__stack_top, %esp
cld
call main
linker.ld
ENTRY(mystart)
SECTIONS
{
. = 0x7c00;
.text : {
entry.o(.text)
*(.text)
*(.data)
*(.rodata)
__bss_start = .;
/* COMMON vs BSS: /programming/16835716/bss-vs-common-what-goes-where */
*(.bss)
*(COMMON)
__bss_end = .;
}
/* /programming/53584666/why-does-gnu-ld-include-a-section-that-does-not-appear-in-the-linker-script */
.sig : AT(ADDR(.text) + 512 - 2)
{
SHORT(0xaa55);
}
/DISCARD/ : {
*(.eh_frame)
}
__stack_bottom = .;
. = . + 0x1000;
__stack_top = .;
}
corre
set -eux
as -ggdb3 --32 -o entry.o entry.S
gcc -c -ggdb3 -m16 -ffreestanding -fno-PIE -nostartfiles -nostdlib -o main.o -std=c99 main.c
ld -m elf_i386 -o main.elf -T linker.ld entry.o main.o
objcopy -O binary main.elf main.img
qemu-system-x86_64 -drive file=main.img,format=raw
Biblioteca padrão C
As coisas ficam mais divertidas se você também quiser usar a biblioteca padrão C, já que não temos o kernel Linux, o que implementa grande parte da funcionalidade da biblioteca padrão C através do POSIX .
Algumas possibilidades, sem acessar um sistema operacional completo como o Linux, incluem:
Escreva o seu próprio. É apenas um monte de cabeçalhos e arquivos C no final, certo? Certo??
Newlib
Exemplo detalhado em: /electronics/223929/c-standard-libraries-on-bare-metal/223931
Implementos newlib todas as coisas chatas não-OS específicos para você, por exemplo memcmp
, memcpy
, etc.
Em seguida, fornece alguns stubs para você implementar os syscalls necessários.
Por exemplo, podemos implementar exit()
no ARM através de semi-hospedagem com:
void _exit(int status) {
__asm__ __volatile__ ("mov r0, #0x18; ldr r1, =#0x20026; svc 0x00123456");
}
como mostrado neste exemplo .
Por exemplo, você pode redirecionar printf
para os sistemas UART ou ARM ou implementar exit()
com semi - hospedagem .
sistemas operacionais incorporados como FreeRTOS e Zephyr .
Esses sistemas operacionais normalmente permitem desativar o agendamento preventivo, fornecendo controle total sobre o tempo de execução do programa.
Eles podem ser vistos como uma espécie de Newlib pré-implementado.
Inicialização múltipla GNU GRUB
Os setores de inicialização são simples, mas não são muito convenientes:
- você pode ter apenas um sistema operacional por disco
- o código de carregamento deve ser muito pequeno e caber em 512 bytes
- você tem que iniciar bastante, como mudar para o modo protegido
É por esses motivos que o GNU GRUB criou um formato de arquivo mais conveniente chamado de inicialização múltipla.
Exemplo de trabalho mínimo: https://github.com/cirosantilli/x86-bare-metal-examples/tree/d217b180be4220a0b4a453f31275d38e697a99e0/multiboot/hello-world
Também o uso no meu repositório de exemplos do GitHub para poder executar facilmente todos os exemplos em hardware real sem queimar o USB um milhão de vezes.
Resultado do QEMU:
T430:
Se você preparar seu sistema operacional como um arquivo de inicialização múltipla, o GRUB poderá encontrá-lo dentro de um sistema de arquivos comum.
É isso que a maioria das distribuições faz, colocando as imagens do SO em baixo /boot
.
Os arquivos de inicialização múltipla são basicamente um arquivo ELF com um cabeçalho especial. Eles são especificados pelo GRUB em: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html
Você pode transformar um arquivo de inicialização múltipla em um disco inicializável com grub-mkrescue
.
Firmware
Na verdade, seu setor de inicialização não é o primeiro software executado na CPU do sistema.
O que realmente roda primeiro é o chamado firmware , que é um software:
- feita pelos fabricantes de hardware
- fonte tipicamente fechada, mas provavelmente baseada em C
- armazenado na memória somente leitura e, portanto, mais difícil / impossível de modificar sem o consentimento do fornecedor.
Firmwares conhecidos incluem:
- BIOS : firmware x86 antigo completo. SeaBIOS é a implementação de código aberto padrão usada pelo QEMU.
- UEFI : sucessor do BIOS, melhor padronizado, mas mais capaz e incrivelmente inchado.
- Coreboot : a nobre tentativa de código aberto de arco cruzado
O firmware faz coisas como:
faça um loop sobre cada disco rígido, USB, rede etc. até encontrar algo inicializável.
Quando rodamos o QEMU, -hda
diz que main.img
é um disco rígido conectado ao hardware, e hda
é o primeiro a ser testado, e é usado.
carregue os primeiros 512 bytes no endereço de memória RAM 0x7c00
, coloque o RIP da CPU lá e deixe-o executar
mostrar coisas como o menu de inicialização ou as chamadas de impressão do BIOS no visor
O firmware oferece funcionalidade semelhante ao sistema operacional da qual a maioria dos sistemas operacionais depende. Por exemplo, um subconjunto Python foi portado para execução no BIOS / UEFI: https://www.youtube.com/watch?v=bYQ_lq5dcvM
Pode-se argumentar que os firmwares são indistinguíveis dos sistemas operacionais, e que o firmware é a única programação bare metal "verdadeira" que se pode fazer.
Como este desenvolvedor do CoreOS coloca :
A parte difícil
Quando você liga um PC, os chips que compõem o chipset (northbridge, southbridge e SuperIO) ainda não foram inicializados corretamente. Embora a ROM do BIOS esteja o mais longe possível da CPU, ela pode ser acessada pela CPU, porque precisa ser, caso contrário, a CPU não teria instruções para executar. Isso não significa que a ROM do BIOS esteja completamente mapeada, geralmente não. Mas apenas o suficiente é mapeado para iniciar o processo de inicialização. Quaisquer outros dispositivos, apenas esqueça.
Ao executar o Coreboot no QEMU, você pode experimentar as camadas mais altas do Coreboot e as cargas úteis, mas o QEMU oferece poucas oportunidades para experimentar o código de inicialização de baixo nível. Por um lado, a RAM funciona desde o início.
Estado inicial do BIOS pós
Como muitas coisas no hardware, a padronização é fraca, e uma das coisas em que você não deve confiar é o estado inicial dos registros quando o código começa a ser executado após o BIOS.
Então faça um favor a si mesmo e use algum código de inicialização como o seguinte: https://stackoverflow.com/a/32509555/895245
Os registros gostam %ds
e %es
têm efeitos colaterais importantes, portanto, você deve zerá-los, mesmo que não os esteja usando explicitamente.
Observe que alguns emuladores são mais agradáveis que o hardware real e fornecem um bom estado inicial. Então, quando você roda em hardware real, tudo quebra.
El Torito
Formato que pode ser gravado em CDs: https://en.wikipedia.org/wiki/El_Torito_%28CD-ROM_standard%29
Também é possível produzir uma imagem híbrida que funcione em ISO ou USB. Isso pode ser feito com grub-mkrescue
( exemplo ) e também é feito pelo kernel do Linux no make isoimage
uso isohybrid
.
BRAÇO
No ARM, as idéias gerais são as mesmas.
Não há firmware pré-instalado semi-padronizado amplamente disponível como o BIOS para usar no IO; portanto, os dois tipos mais simples de IO que podemos fazer são:
- serial, amplamente disponível em devboards
- pisque o LED
Eu enviei:
alguns exemplos simples de QEMU C + Newlib e assembly bruto aqui no GitHub .
O exemplo prompt.c, por exemplo, recebe entrada do terminal host e retorna a saída durante todo o UART simulado:
enter a character
got: a
new alloc of 1 bytes at address 0x0x4000a1c0
enter a character
got: b
new alloc of 2 bytes at address 0x0x4000a1c0
enter a character
Veja também: Como criar programas ARM bare metal e executá-los no QEMU?
uma configuração totalmente automatizada de blinker Raspberry Pi em: https://github.com/cirosantilli/raspberry-pi-bare-metal-blinker
Consulte também: Como executar um programa C sem SO no Raspberry Pi?
Para "ver" os LEDs no QEMU, é necessário compilar o QEMU da fonte com um sinalizador de depuração: /raspberrypi/56373/is-it-possible-to-get-the-state-of- the-leds-and-gpios-in-a-qemu-emulation-like-t
Em seguida, tente um olá mundo UART. Você pode começar pelo exemplo do pisca-pisca e substituir o kernel por este: https://github.com/dwelch67/raspberrypi/tree/bce377230c2cdd8ff1e40919fdedbc2533ef5a00/uart01
Primeiro, faça com que o UART trabalhe com o Raspbian, como expliquei em: /raspberrypi/38/prepare-for-ssh-without-a-screen/54394#54394 Será algo parecido com isto:
Certifique-se de usar os pinos corretos, ou você pode gravar o seu conversor UART para USB, já o fiz duas vezes em curto-circuito e 5V ...
Finalmente, conecte-se ao serial do host com:
screen /dev/ttyUSB0 115200
Para o Raspberry Pi, usamos um cartão Micro SD em vez de um pendrive para conter nosso executável, para o qual você normalmente precisa de um adaptador para conectar-se ao seu computador:
Não se esqueça de desbloquear o adaptador SD, como mostrado em: /ubuntu/213889/microsd-card-is-set-to-read-only-state-how-can-i-write-data # 814585 # 814585
https://github.com/dwelch67/raspberrypi parece o tutorial mais popular sobre Raspberry Pi bare metal disponível hoje.
Algumas diferenças do x86 incluem:
IO é feito escrevendo diretamente para endereços de mágica, não há in
e out
instruções.
Isso é chamado de E / S mapeada na memória .
para hardware real, como o Raspberry Pi, você pode adicionar o firmware (BIOS) à imagem do disco.
Isso é bom, pois torna a atualização desse firmware mais transparente.
Recursos