Exemplo de baremetal mínimo executável da Intel x86
Exemplo de metal nu executável com todos os padrões exigidos . Todas as principais partes são abordadas abaixo.
Testado no Ubuntu 15.10 QEMU 2.3.0 e no convidado de hardware real Lenovo ThinkPad T400 .
O Guia de programação do sistema Intel Manual Volume 3 - 325384-056BR setembro de 2015 aborda o SMP nos capítulos 8, 9 e 10.
Tabela 8-1. "Transmitir sequência INIT-SIPI-SIPI e escolha de tempos limite" contém um exemplo que basicamente funciona:
MOV ESI, ICR_LOW ; Load address of ICR low dword into ESI.
MOV EAX, 000C4500H ; Load ICR encoding for broadcast INIT IPI
; to all APs into EAX.
MOV [ESI], EAX ; Broadcast INIT IPI to all APs
; 10-millisecond delay loop.
MOV EAX, 000C46XXH ; Load ICR encoding for broadcast SIPI IP
; to all APs into EAX, where xx is the vector computed in step 10.
MOV [ESI], EAX ; Broadcast SIPI IPI to all APs
; 200-microsecond delay loop
MOV [ESI], EAX ; Broadcast second SIPI IPI to all APs
; Waits for the timer interrupt until the timer expires
Nesse código:
A maioria dos sistemas operacionais impossibilitará a maioria dessas operações do anel 3 (programas do usuário).
Então você precisa escrever seu próprio kernel para brincar livremente com ele: um programa Linux do usuário não funcionará.
Inicialmente, um único processador é executado, chamado de processador de bootstrap (BSP).
Ele deve ativar os outros (chamados Application Processors (AP)) através de interrupções especiais chamadas Inter Processor Interrupts (IPI) .
Essas interrupções podem ser feitas através da programação de Controlador de interrupção programável avançado (APIC) por meio do registro de comando de interrupção (ICR)
O formato do ICR está documentado em: 10.6 "EMITIR INTERRUPTORES DE INTERPROCESSADORES"
O IPI acontece assim que escrevemos para o ICR.
ICR_LOW é definido em 8.4.4 "Exemplo de Inicialização MP" como:
ICR_LOW EQU 0FEE00300H
O valor mágico 0FEE00300
é o endereço de memória do ICR, conforme documentado na Tabela 10-1 "Mapa local de endereços de registro APIC"
O método mais simples possível é usado no exemplo: ele configura o ICR para enviar IPIs de difusão que são entregues a todos os outros processadores, exceto o atual.
Mas também é possível, e recomendado por alguns , obter informações sobre os processadores por meio de estruturas de dados especiais configuradas pelo BIOS, como tabelas ACPI ou tabela de configuração MP da Intel, e apenas ativar os que você precisa um por um.
XX
in 000C46XXH
codifica o endereço da primeira instrução que o processador executará como:
CS = XX * 0x100
IP = 0
Lembre-se de que o CS multiplica endereços por0x10
, portanto, o endereço de memória real da primeira instrução é:
XX * 0x1000
Portanto, se, por exemplo XX == 1
, o processador iniciar às 0x1000
.
Devemos garantir que haja um código de modo real de 16 bits para ser executado nesse local de memória, por exemplo, com:
cld
mov $init_len, %ecx
mov $init, %esi
mov 0x1000, %edi
rep movsb
.code16
init:
xor %ax, %ax
mov %ax, %ds
/* Do stuff. */
hlt
.equ init_len, . - init
Usar um script vinculador é outra possibilidade.
Os loops de atraso são uma parte chata para começar a trabalhar: não há uma maneira super simples de fazer essas dormidas com precisão.
Os métodos possíveis incluem:
- PIT (usado no meu exemplo)
- HPET
- calibrar o tempo de um loop ocupado com o descrito acima e usá-lo
Relacionado: Como exibir um número na tela e dormir por um segundo com a montagem do DOS x86?
Eu acho que o processador inicial precisa estar no modo protegido para que isso funcione enquanto escrevemos para o endereço 0FEE00300H
que é muito alto para 16 bits
Para se comunicar entre processadores, podemos usar um spinlock no processo principal e modificar o bloqueio a partir do segundo núcleo.
Devemos garantir que a gravação de memória seja feita, por exemplo, através wbinvd
.
Estado compartilhado entre processadores
8.7.1 "Estado dos processadores lógicos" diz:
Os seguintes recursos fazem parte do estado arquitetural dos processadores lógicos nos processadores Intel 64 ou IA-32 que oferecem suporte à tecnologia Intel Hyper-Threading. Os recursos podem ser subdivididos em três grupos:
- Duplicado para cada processador lógico
- Compartilhado por processadores lógicos em um processador físico
- Compartilhado ou duplicado, dependendo da implementação
Os seguintes recursos são duplicados para cada processador lógico:
- Registradores de uso geral (EAX, EBX, ECX, EDX, ESI, EDI, ESP e EBP)
- Registros de segmento (CS, DS, SS, ES, FS e GS)
- Registros EFLAGS e EIP. Observe que os registros CS e EIP / RIP para cada processador lógico apontam para o fluxo de instruções do encadeamento que está sendo executado pelo processador lógico.
- Registros FPU x87 (ST0 a ST7, palavra de status, palavra de controle, palavra de tag, ponteiro de operando de dados e ponteiro de instrução)
- Registradores MMX (MM0 a MM7)
- Registros XMM (XMM0 a XMM7) e o registro MXCSR
- Registradores de controle e registradores de ponteiros da tabela do sistema (GDTR, LDTR, IDTR, registro de tarefas)
- Registros de depuração (DR0, DR1, DR2, DR3, DR6, DR7) e os MSRs de controle de depuração
- MSRs de status global de verificação de máquina (IA32_MCG_STATUS) e de capacidade de verificação de máquina (IA32_MCG_CAP)
- Modulação de relógio térmico e controle de gerenciamento de energia ACPI MSRs
- MSRs de contador de carimbo de data / hora
- A maioria dos outros registros MSR, incluindo a tabela de atributos da página (PAT). Veja as exceções abaixo.
- Registros APIC locais.
- Registros de uso geral adicionais (R8-R15), registros XMM (XMM8-XMM15), registro de controle, IA32_EFER nos processadores Intel 64.
Os seguintes recursos são compartilhados por processadores lógicos:
- Registradores de intervalo de tipo de memória (MTRRs)
Se os seguintes recursos são compartilhados ou duplicados, é específico da implementação:
- IA32_MISC_ENABLE MSR (endereço 1A0H do MSR)
- MSRs da arquitetura de verificação da máquina (MCA) (exceto para os MSRs IA32_MCG_STATUS e IA32_MCG_CAP)
- Controle de monitoramento de desempenho e MSRs de contador
O compartilhamento de cache é discutido em:
Os hyperthreads da Intel têm maior compartilhamento de cache e pipeline do que núcleos separados: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Kernel Linux 4.2
A principal ação de inicialização parece estar em arch/x86/kernel/smpboot.c
.
Exemplo mínimo de baremetal executável do ARM
Aqui, forneço um exemplo mínimo de ARMv8 aarch64 executável para QEMU:
.global mystart
mystart:
/* Reset spinlock. */
mov x0, #0
ldr x1, =spinlock
str x0, [x1]
/* Read cpu id into x1.
* TODO: cores beyond 4th?
* Mnemonic: Main Processor ID Register
*/
mrs x1, mpidr_el1
ands x1, x1, 3
beq cpu0_only
cpu1_only:
/* Only CPU 1 reaches this point and sets the spinlock. */
mov x0, 1
ldr x1, =spinlock
str x0, [x1]
/* Ensure that CPU 0 sees the write right now.
* Optional, but could save some useless CPU 1 loops.
*/
dmb sy
/* Wake up CPU 0 if it is sleeping on wfe.
* Optional, but could save power on a real system.
*/
sev
cpu1_sleep_forever:
/* Hint CPU 1 to enter low power mode.
* Optional, but could save power on a real system.
*/
wfe
b cpu1_sleep_forever
cpu0_only:
/* Only CPU 0 reaches this point. */
/* Wake up CPU 1 from initial sleep!
* See:https://github.com/cirosantilli/linux-kernel-module-cheat#psci
*/
/* PCSI function identifier: CPU_ON. */
ldr w0, =0xc4000003
/* Argument 1: target_cpu */
mov x1, 1
/* Argument 2: entry_point_address */
ldr x2, =cpu1_only
/* Argument 3: context_id */
mov x3, 0
/* Unused hvc args: the Linux kernel zeroes them,
* but I don't think it is required.
*/
hvc 0
spinlock_start:
ldr x0, spinlock
/* Hint CPU 0 to enter low power mode. */
wfe
cbz x0, spinlock_start
/* Semihost exit. */
mov x1, 0x26
movk x1, 2, lsl 16
str x1, [sp, 0]
mov x0, 0
str x0, [sp, 8]
mov x1, sp
mov w0, 0x18
hlt 0xf000
spinlock:
.skip 8
GitHub upstream .
Montar e executar:
aarch64-linux-gnu-gcc \
-mcpu=cortex-a57 \
-nostdlib \
-nostartfiles \
-Wl,--section-start=.text=0x40000000 \
-Wl,-N \
-o aarch64.elf \
-T link.ld \
aarch64.S \
;
qemu-system-aarch64 \
-machine virt \
-cpu cortex-a57 \
-d in_asm \
-kernel aarch64.elf \
-nographic \
-semihosting \
-smp 2 \
;
Neste exemplo, colocamos a CPU 0 em um loop de spinlock, e ele só sai com a CPU 1 libera o spinlock.
Após o spinlock, a CPU 0 faz uma chamada de saída de semi - host que faz com que o QEMU saia.
Se você iniciar o QEMU com apenas uma CPU -smp 1
, a simulação ficará suspensa para sempre no spinlock.
A CPU 1 é acordada com a interface PSCI, mais detalhes em: ARM: Iniciar / Ativar / Recuperar os outros núcleos / APs da CPU e passar o endereço inicial de execução?
A versão upstream também possui alguns ajustes para fazê-lo funcionar no gem5, para que você também possa experimentar as características de desempenho.
Eu não o testei em hardware real, então não tenho certeza de como isso é portátil. A seguinte bibliografia do Raspberry Pi pode ser interessante:
Este documento fornece algumas orientações sobre o uso de primitivas de sincronização ARM, que você pode usar para fazer coisas divertidas com vários núcleos: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Testado no Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Próximas etapas para uma programação mais conveniente
Os exemplos anteriores ativam a CPU secundária e fazem a sincronização básica da memória com instruções dedicadas, o que é um bom começo.
Mas, para facilitar a programação de sistemas multicore, por exemplo, como o POSIX pthreads
, você também precisará entrar nos seguintes tópicos mais envolvidos:
A instalação interrompe e executa um cronômetro que decide periodicamente qual thread será executado agora. Isso é conhecido como multithreading preventivo .
Esse sistema também precisa salvar e restaurar registros de encadeamento à medida que são iniciados e parados.
Também é possível ter sistemas multitarefa não-preemptivos, mas isso pode exigir que você modifique seu código para que todos os encadeamentos produzam (por exemplo, com uma pthread_yield
implementação), e fica mais difícil equilibrar as cargas de trabalho.
Aqui estão alguns exemplos simplistas do temporizador bare metal:
lidar com conflitos de memória. Notavelmente, cada thread precisará de uma pilha exclusiva se você quiser codificar em C ou em outros idiomas de alto nível.
Você pode limitar os encadeamentos para ter um tamanho máximo fixo de pilha, mas a melhor maneira de lidar com isso é com a paginação, que permite pilhas eficientes de "tamanho ilimitado".
Aqui está um exemplo baremetal ingênuo do aarch64 que explodiria se a pilha crescesse muito fundo
Essas são algumas boas razões para usar o kernel do Linux ou algum outro sistema operacional :-)
Primitivas de sincronização de memória do Userland
Embora o início / parada / gerenciamento do encadeamento esteja geralmente fora do escopo da área do usuário, você pode, no entanto, usar instruções de montagem dos encadeamentos da área do usuário para sincronizar os acessos à memória sem chamadas de sistema potencialmente mais caras.
Obviamente, você deve preferir usar bibliotecas que agrupem essas primitivas de baixo nível. O padrão C ++ si fez grandes avanços nos <mutex>
e <atomic>
cabeçalhos, e em particular com std::memory_order
. Não tenho certeza se ele cobre todas as semânticas de memória possíveis, mas apenas pode.
A semântica mais sutil é particularmente relevante no contexto de estruturas de dados sem bloqueio , que podem oferecer benefícios de desempenho em certos casos. Para implementá-las, você provavelmente precisará aprender um pouco sobre os diferentes tipos de barreiras de memória: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
O Boost, por exemplo, tem algumas implementações de contêiner sem bloqueio em: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html
Essas instruções da terra do usuário também parecem ser usadas para implementar a futex
chamada do sistema Linux , que é uma das principais primitivas de sincronização no Linux. man futex
4,15 lê:
A chamada do sistema futex () fornece um método para aguardar até que uma determinada condição se torne verdadeira. É normalmente usado como uma construção de bloqueio no contexto da sincronização de memória compartilhada. Ao usar futexes, a maioria das operações de sincronização é realizada no espaço do usuário. Um programa de espaço do usuário emprega a chamada do sistema futex () somente quando é provável que o programa precise bloquear por um longo período de tempo até que a condição se torne verdadeira. Outras operações do futex () podem ser usadas para ativar quaisquer processos ou threads que aguardam uma condição específica.
O próprio nome do syscall significa "Fast Userspace XXX".
Aqui está um exemplo mínimo inútil de C ++ x86_64 / aarch64 com assembly embutido que ilustra o uso básico dessas instruções principalmente por diversão:
main.cpp
#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>
std::atomic_ulong my_atomic_ulong(0);
unsigned long my_non_atomic_ulong = 0;
#if defined(__x86_64__) || defined(__aarch64__)
unsigned long my_arch_atomic_ulong = 0;
unsigned long my_arch_non_atomic_ulong = 0;
#endif
size_t niters;
void threadMain() {
for (size_t i = 0; i < niters; ++i) {
my_atomic_ulong++;
my_non_atomic_ulong++;
#if defined(__x86_64__)
__asm__ __volatile__ (
"incq %0;"
: "+m" (my_arch_non_atomic_ulong)
:
:
);
// https://github.com/cirosantilli/linux-kernel-module-cheat#x86-lock-prefix
__asm__ __volatile__ (
"lock;"
"incq %0;"
: "+m" (my_arch_atomic_ulong)
:
:
);
#elif defined(__aarch64__)
__asm__ __volatile__ (
"add %0, %0, 1;"
: "+r" (my_arch_non_atomic_ulong)
:
:
);
// https://github.com/cirosantilli/linux-kernel-module-cheat#arm-lse
__asm__ __volatile__ (
"ldadd %[inc], xzr, [%[addr]];"
: "=m" (my_arch_atomic_ulong)
: [inc] "r" (1),
[addr] "r" (&my_arch_atomic_ulong)
:
);
#endif
}
}
int main(int argc, char **argv) {
size_t nthreads;
if (argc > 1) {
nthreads = std::stoull(argv[1], NULL, 0);
} else {
nthreads = 2;
}
if (argc > 2) {
niters = std::stoull(argv[2], NULL, 0);
} else {
niters = 10000;
}
std::vector<std::thread> threads(nthreads);
for (size_t i = 0; i < nthreads; ++i)
threads[i] = std::thread(threadMain);
for (size_t i = 0; i < nthreads; ++i)
threads[i].join();
assert(my_atomic_ulong.load() == nthreads * niters);
// We can also use the atomics direclty through `operator T` conversion.
assert(my_atomic_ulong == my_atomic_ulong.load());
std::cout << "my_non_atomic_ulong " << my_non_atomic_ulong << std::endl;
#if defined(__x86_64__) || defined(__aarch64__)
assert(my_arch_atomic_ulong == nthreads * niters);
std::cout << "my_arch_non_atomic_ulong " << my_arch_non_atomic_ulong << std::endl;
#endif
}
GitHub upstream .
Saída possível:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
A partir disso, vemos que a LDADD
instrução x86 LOCK prefix / aarch64 tornou a adição atômica: sem ela, temos condições de corrida em muitas das adições, e a contagem total no final é menor que a 20000 sincronizada.
Veja também:
Testado no Ubuntu 19.04 amd64 e com o modo de usuário QEMU aarch64.