Ejemplo de metal desnudo ejecutable mínimo x86 de Intel
Ejemplo de metal desnudo ejecutable con todas las repeticiones requeridas . Todas las partes principales se cubren a continuación.
Probado en Ubuntu 15.10 QEMU 2.3.0 y en el invitado de hardware real Lenovo ThinkPad T400 .
La Guía de programación del sistema Intel Manual Volumen 3 - 325384-056US Septiembre 2015 cubre SMP en los capítulos 8, 9 y 10.
Tabla 8-1. "Broadcast INIT-SIPI-SIPI Sequence and Choice of Timeouts" contiene un ejemplo que básicamente 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
En ese código:
La mayoría de los sistemas operativos harán que la mayoría de esas operaciones sean imposibles desde el anillo 3 (programas de usuario).
Por lo tanto, debe escribir su propio kernel para jugar libremente con él: un programa Linux de usuario no funcionará.
Al principio, se ejecuta un único procesador, llamado procesador de arranque (BSP).
Debe despertar a los otros (llamados procesadores de aplicaciones (AP)) a través de interrupciones especiales llamadas interrupciones entre procesadores (IPI) .
Esas interrupciones pueden realizarse programando el Controlador de interrupción programable avanzado (APIC) a través del registro de comando de interrupción (ICR)
El formato del ICR se documenta en: 10.6 "EMISIÓN DE INTERRUPCIONES INTERPROCESADORAS"
El IPI ocurre tan pronto como escribimos al ICR.
ICR_LOW se define en 8.4.4 "Ejemplo de inicialización MP" como:
ICR_LOW EQU 0FEE00300H
El valor mágico 0FEE00300
es la dirección de memoria del ICR, como se documenta en la Tabla 10-1 "Mapa de dirección de registro APIC local"
El método más simple posible se utiliza en el ejemplo: configura el ICR para enviar IPI de difusión que se entregan a todos los demás procesadores, excepto el actual.
Pero también es posible, y recomendado por algunos , obtener información sobre los procesadores a través de estructuras de datos especiales configuradas por el BIOS como las tablas ACPI o la tabla de configuración MP de Intel y solo despertar las que necesita una por una.
XX
En 000C46XXH
codifica la dirección de la primera instrucción que el procesador ejecutará como:
CS = XX * 0x100
IP = 0
Recuerde que CS multiplica las direcciones por0x10
, por lo que la dirección de memoria real de la primera instrucción es:
XX * 0x1000
Entonces, por ejemplo XX == 1
, el procesador comenzará a las 0x1000
.
Luego debemos asegurarnos de que haya un código de modo real de 16 bits para ejecutar en esa ubicación de memoria, por ejemplo, con:
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 un script vinculador es otra posibilidad.
Los bucles de retardo son una parte molesta para comenzar a trabajar: no hay una forma súper simple de hacer tales duermas con precisión.
Los posibles métodos incluyen:
- PIT (usado en mi ejemplo)
- HPET
- calibre el tiempo de un bucle ocupado con lo anterior y úselo en su lugar
Relacionado: ¿Cómo mostrar un número en la pantalla y dormir durante un segundo con el ensamblaje DOS x86?
Creo que el procesador inicial debe estar en modo protegido para que esto funcione mientras escribimos en una dirección 0FEE00300H
que es demasiado alta para 16 bits
Para comunicarnos entre procesadores, podemos usar un spinlock en el proceso principal y modificar el bloqueo desde el segundo núcleo.
Deberíamos asegurarnos de que se realiza la escritura de la memoria, por ejemplo, a través de wbinvd
.
Estado compartido entre procesadores
8.7.1 "Estado de los procesadores lógicos" dice:
Las siguientes características son parte del estado arquitectónico de los procesadores lógicos dentro de los procesadores Intel 64 o IA-32 que admiten la tecnología Intel Hyper-Threading. Las características se pueden subdividir en tres grupos:
- Duplicado para cada procesador lógico
- Compartido por procesadores lógicos en un procesador físico
- Compartido o duplicado, dependiendo de la implementación
Las siguientes características están duplicadas para cada procesador lógico:
- Registros de uso general (EAX, EBX, ECX, EDX, ESI, EDI, ESP y EBP)
- Registros de segmento (CS, DS, SS, ES, FS y GS)
- EFLAGS y registros EIP. Tenga en cuenta que los registros CS y EIP / RIP para cada procesador lógico apuntan a la secuencia de instrucciones para el subproceso que ejecuta el procesador lógico.
- Registros FPU x87 (ST0 a ST7, palabra de estado, palabra de control, palabra de etiqueta, puntero de operando de datos y puntero de instrucción)
- Registros MMX (MM0 a MM7)
- Registros XMM (XMM0 a XMM7) y el registro MXCSR
- Registros de control y registros de puntero de tabla del sistema (GDTR, LDTR, IDTR, registro de tareas)
- Registros de depuración (DR0, DR1, DR2, DR3, DR6, DR7) y los MSR de control de depuración
- Estado global de verificación de máquina (IA32_MCG_STATUS) y capacidad de verificación de máquina (IA32_MCG_CAP) MSR
- Modulación de reloj térmico y control de gestión de energía ACPI MSR
- Contador de sello de tiempo MSR
- La mayoría de los otros registros MSR, incluida la tabla de atributos de página (PAT). Ver las excepciones a continuación.
- Registros locales de APIC.
- Registros adicionales de propósito general (R8-R15), registros XMM (XMM8-XMM15), registro de control, IA32_EFER en procesadores Intel 64.
Los procesadores lógicos comparten las siguientes características:
- Registros de rango de tipo de memoria (MTRR)
Si las siguientes características son compartidas o duplicadas es específico de la implementación:
- IA32_MISC_ENABLE MSR (dirección MSR 1A0H)
- MSR de arquitectura de verificación de máquina (MCA) (excepto los MSR IA32_MCG_STATUS e IA32_MCG_CAP)
- Control de supervisión del rendimiento y contador de MSR
El intercambio de caché se discute en:
Los hyperthreads de Intel tienen un mayor intercambio de caché y canalización que los núcleos separados: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Kernel de Linux 4.2
La principal acción de inicialización parece estar en arch/x86/kernel/smpboot.c
.
Ejemplo de metal desnudo ejecutable mínimo ARM
Aquí proporciono un ejemplo ARMv8 aarch64 mínimo ejecutable 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 aguas arriba .
Montar y ejecutar:
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 \
;
En este ejemplo, colocamos la CPU 0 en un bucle de spinlock, y solo sale cuando la CPU 1 libera el spinlock.
Después del spinlock, la CPU 0 realiza una llamada de salida de semihost que hace que QEMU se cierre.
Si inicia QEMU con solo una CPU -smp 1
, entonces la simulación simplemente se bloquea para siempre en el spinlock.
La CPU 1 se ha despertado con la interfaz PSCI, más detalles en: ARM: ¿Iniciar / Activar / Activar los otros núcleos / AP de la CPU y pasar la dirección de inicio de ejecución?
La versión ascendente también tiene algunos ajustes para que funcione en gem5, por lo que también puede experimentar con las características de rendimiento.
No lo he probado en hardware real, así que no estoy seguro de lo portátil que es. La siguiente bibliografía de Raspberry Pi puede ser de interés:
Este documento proporciona una guía sobre el uso de primitivas de sincronización ARM que luego puede usar para hacer cosas divertidas con múltiples núcleos: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Probado en Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Próximos pasos para una programabilidad más conveniente
Los ejemplos anteriores despiertan la CPU secundaria y sincronizan la memoria básica con instrucciones dedicadas, lo cual es un buen comienzo.
Pero para hacer que los sistemas multinúcleo sean fáciles de programar, por ejemplo, como POSIX pthreads
, también deberá abordar los siguientes temas más involucrados:
la configuración interrumpe y ejecuta un temporizador que periódicamente decide qué hilo se ejecutará ahora. Esto se conoce como subprocesamiento múltiple preventivo .
Dicho sistema también necesita guardar y restaurar registros de subprocesos a medida que se inician y se detienen.
También es posible tener sistemas multitarea no preventivos, pero estos pueden requerir que modifique su código para que todos los hilos rindan (por ejemplo, con una pthread_yield
implementación), y se hace más difícil equilibrar las cargas de trabajo.
Aquí hay algunos ejemplos simplistas de temporizadores de metal desnudo:
lidiar con conflictos de memoria. En particular, cada hilo necesitará una pila única si desea codificar en C u otros lenguajes de alto nivel.
Podrías limitar los hilos para que tengan un tamaño de pila máximo fijo, pero la mejor manera de lidiar con esto es con paginación que permite pilas eficientes de "tamaño ilimitado".
Aquí hay un ejemplo ingenuo de aarch64 baremetal que explotaría si la pila crece demasiado
Esas son algunas buenas razones para usar el kernel de Linux o algún otro sistema operativo :-)
Userland primitivas de sincronización de memoria
Aunque el inicio / detención / administración de subprocesos generalmente está más allá del alcance del usuario, sin embargo, puede usar las instrucciones de ensamblaje de los subprocesos del usuario para sincronizar los accesos a la memoria sin llamadas al sistema potencialmente más costosas.
Por supuesto, debería preferir el uso de bibliotecas que envuelvan de forma portátil estas primitivas de bajo nivel. El estándar de C ++ se ha hecho grandes avances en los <mutex>
y <atomic>
las cabeceras, y en particular con std::memory_order
. No estoy seguro de si cubre todas las semánticas de memoria posibles, pero podría serlo.
La semántica más sutil es particularmente relevante en el contexto de estructuras de datos sin bloqueo , que pueden ofrecer beneficios de rendimiento en ciertos casos. Para implementarlos, es probable que tenga que aprender un poco sobre los diferentes tipos de barreras de memoria: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
Boost, por ejemplo, tiene algunas implementaciones de contenedores sin bloqueo en: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html
Dichas instrucciones de usuario también parecen usarse para implementar la futex
llamada al sistema Linux , que es una de las primitivas de sincronización principales en Linux. man futex
4.15 lee:
La llamada al sistema futex () proporciona un método para esperar hasta que una determinada condición se vuelva verdadera. Por lo general, se usa como una construcción de bloqueo en el contexto de la sincronización de memoria compartida. Cuando se usan futexes, la mayoría de las operaciones de sincronización se realizan en el espacio del usuario. Un programa de espacio de usuario emplea la llamada al sistema futex () solo cuando es probable que el programa tenga que bloquear durante más tiempo hasta que la condición se vuelva verdadera. Se pueden usar otras operaciones futex () para activar cualquier proceso o subproceso que espere una condición particular.
El nombre de syscall en sí significa "Fast Userspace XXX".
Aquí hay un ejemplo mínimo inútil de C ++ x86_64 / aarch64 con ensamblaje en línea que ilustra el uso básico de tales instrucciones principalmente para divertirse:
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 aguas arriba .
Salida posible:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
De esto vemos que el prefijo x86 LOCK / aarch64 LDADD
instrucción hizo que la suma fuera atómica: sin ella tenemos condiciones de carrera en muchas de las adiciones, y el recuento total al final es menor que el 20000 sincronizado.
Ver también:
Probado en Ubuntu 19.04 amd64 y con el modo de usuario QEMU aarch64.