Koroutinen Kontextwechsel

Nach meinen Experimenten mit Koroutinen mit OS-Funktionen und meinem Fehlschlag mit setjmp/longjump, ist es an der Zeit, das Thema “neu zu denken”.

Denn jetzt, wo Assembler Patches im GATE Projekt leicht möglich sind und mit UEFI eine zur Zeit thread-lose Implementierung vor mir liegt, bekommen Koroutinen plötzlich eine neue Bedeutung.


Für Windows und Linux habe ich mit Boardmitteln akzeptable Lösungen gefunden, um innerhalb eines Threads zwischen Callstacks hin und her zu hüpfen.

In EFI-Apps gibt es keine Threads, doch wenn man mehrere Callstacks anlegen könnte und zwischen denen hin- und herhüpfen könnte, dann wäre eine einfache Art von kooperativem Multitasking geboren, mit der man z.B. einen Webserver dazu bringen könnte, mehrere Anfragen zu bearbeiten, oder man könnte UIs mit Background-Tasks aufwerten.

Callstacks erzeugen

Normaler Datenspeicher, wie er von malloc() zurückkommt, ist nicht automatisch Stack-tauglich. Oft muss man dem OS erst mitteilen, dass man auch “Code-Adressen” darauf nutzen möchte.

EFI nutzt offenbar keinen Speicherschutz womit ein
efi_sys_table->BootServices->AllocatePool(EfiLoaderData, mem_size, &ptr_mem); ausreicht um Speicher für einen Callstack zu allokieren.

Unter Windows braucht man APIs wie VirtualAlloc(), VirtualProtect() und VirtualFree() um Speicher mit den Protection-Flags PAGE_EXECUTE_READWRITE auf die Nutzung als Stack vorzubereiten.

Zu einem neuen Callstack springen

Hat man einen neuen Stack allokiert, muss man eigentlich nur noch das Stack-Register auf dessen Ende setzen und kann dann eine weitere Funktion aufrufen, die in dem neuen Kontext laufen soll.
Die Funktion kann ganz ähnlich wie eine Thread-Entry-Funktion aussehen.

Damit man beim Rücksprung aus der Funktion wieder zum alten Stack zurückfinden kann, wird die Adresse des alten Stacks auf den neuen Stack gepusht, um am Ende wieder zurückfinden zu können.

X86-32 Funktion auf neuem Stack ausführen

 1; typedef void* (enty_function_t)(void* entry_param);
 2
 3mov eax, [stack_top_ptr]  ; stack-alloc-ptr + stack-size
 4mov ecx, [entry_param]
 5mov edx, [entry_function_ptr]
 6
 7push ebp      ; backup old EBP on current stack
 8mov ebp, esp  ; backup of current stack
 9mov esp, eax  ; switch to new stack
10push ebp      ; save old stack-ptr on new stack
11
12push ecx      ; param for entry-function
13call edx      ; call entry-function
14add esp, 4    ; param cleanup
15
16pop ebp       ; restore old stack-ptr
17mov esp, ebp  ; switch back to old stack
18pop ebp       ; restore old EBP

X86-64 Funktion auf neuem Stack ausführen

 1; typedef void* (enty_function_t)(void* entry_param);
 2mov rax, [stack_top_ptr]
 3mov rcx, [entry_param]
 4mov rdx, [entry_function_ptr]
 5
 6push rbp      ; backup old RBP on current stack
 7mov rbp, rsp  ; backup of current stack
 8mov rsp, rax  ; switch to new stack
 9and rsp, 0fffffffffffffff0h  ; align(16) new stack
10sub rsp, 8    ; prepare call-alignment
11push rbp      ; save old stack-ptr on new stack
12
13sub rsp, 32   ; allocate x64 home space
14call rdx      ; call function on foreign stack
15add rsp, 32   ; deallocate x64 home space
16
17pop rbp       ; restore old stack-ptr
18mov rsp, rbp  ; switch back to old stack
19pop rbp       ; restore old RBP

Callstack wechseln

Um dynamisch zwischen Callstacks wechseln zu können, braucht eine “Callstack-Snapshot” - Struktur, die einige intptr_t bzw. void* speichern kann, um Register des aktuellen Callstacks zu speichern. Und eben genau diese gesicherten Register werden dann von einem anderen Callstack-Snapshot geladen.

X86-32 Callstack wechseln

 1;void* gate_win32_callstack_switch_x86(
 2;        gate_callstack_context_t* old_to_save, 
 3;        gate_callstack_context_t* new_to_load);
 4
 5gate_win32_callstack_switch_x86 PROC
 6  push ebp
 7  mov ebp, esp
 8
 9  mov ecx, [ebp + 8]  ; store-context
10  mov edx, [ebp + 12] ; load-context
11
12  ;store current context to ptr in ECX
13  mov DWORD PTR [ecx +  0], esp
14  mov DWORD PTR [ecx +  4], ebp
15  mov DWORD PTR [ecx +  8], ebx
16  mov DWORD PTR [ecx + 12], esi
17  mov DWORD PTR [ecx + 16], edi
18
19  ;load new context from ptr in EDX
20  mov edi, DWORD PTR[edx + 16]
21  mov esi, DWORD PTR[edx + 12]
22  mov ebx, DWORD PTR[edx +  8]
23  mov ebp, DWORD PTR[edx +  4]
24  mov esp, DWORD PTR[edx +  0]
25
26  mov eax, 0    ; default-return: no switch
27  cmp ecx, edx
28  jz callstack_switch_x86_exit
29
30  ;if ecx != edx, we have switched, 
31  ;otherwise current context is only saved
32  mov eax, edx  ; return: pointer to switched context
33
34callstack_switch_x86_exit:
35  mov esp, ebp
36  pop ebp
37  ret
38  ;EAX tells, if switch was performed
39gate_win32_callstack_switch_x86 ENDP

X86-64 Callstack wechseln

 1;void* gate_win32_callstack_switch_x64(
 2;        gate_callstack_context_t* old_to_save, 
 3;        gate_callstack_context_t* new_to_load);
 4gate_win32_callstack_switch_x64 PROC
 5  push rbp
 6  mov rbp, rsp
 7
 8  ; store current stack frame to ptr in RCX
 9  mov qword ptr [rcx +  0], rsp
10  mov qword ptr [rcx +  8], rbp
11  mov qword ptr [rcx + 16], rbx
12  mov qword ptr [rcx + 24], rsi
13  mov qword ptr [rcx + 32], rdi
14  mov qword ptr [rcx + 40], r12
15  mov qword ptr [rcx + 48], r13
16  mov qword ptr [rcx + 56], r14
17  mov qword ptr [rcx + 64], r15
18
19  ; load next stack frame from stored struct at ptr in RDX
20  mov r15, qword ptr [rdx + 64]
21  mov r14, qword ptr [rdx + 56]
22  mov r13, qword ptr [rdx + 48]
23  mov r12, qword ptr [rdx + 40]
24  mov rdi, qword ptr [rdx + 32]
25  mov rsi, qword ptr [rdx + 24]
26  mov rbx, qword ptr [rdx + 16]
27  mov rbp, qword ptr [rdx +  8]
28  mov rsp, qword ptr [rdx +  0]
29
30  mov rax, 0    ; default-return: no switch
31  cmp rcx, rdx
32  jz callstack_switch_x64_exit
33
34  ;if ecx != edx, we have switched, 
35  ;otherwise the context was only saved without a switch  
36  mov rax, rdx  ; return: pointer to switched context
37
38callstack_switch_x64_exit:
39  mov rsp, rbp
40  pop rbp
41  ret
42  ;RAX tells, if switch was performed
43gate_win32_callstack_switch_x64 ENDP

Fazit

Nachdem x64-EFI das gleiche ABI wie Windows X64 hat, konnte ich den Stack-Wechsel einfach unter Windows im MSVC Debugger testen und war fast erstaunt, dass das auch genau so funktioniert, bevor ich die ersten Tests direkt als EFI-App erfolgreich ausführen konnte.
Interessant sind jetzt die noch nicht entdeckten Seiteneffekte.

Meine aktuelle Koroutinen-Implementierung ist noch ein bisschen detailreicher als hier beschrieben, da eine Routine beim create() zu einem Dispatcher springt, der gleich wieder zum Aufrufer zurückwechselt und zur finale Routine wird erst später wieder per yield() hingewechselt.
Und obwohl oben im ASM-Code vorgesehen ist, dass man aus einem neuen Callstack auch “einfach so” wieder zum Aufrufer zurückwechseln kann, ist das im Endeffekt nicht vorgesehen, da der Aufrufer dann vielleicht nicht mehr existiert.
Man wechselt dann immer zu einer internen Scheduler-Routine, die alle anderen verwaltet und eine “beendete” Routine deallokiert.

Wie auch immer … hier geht es ja vorerst nur um das Konzept.
Und das scheint zumindest auf den ersten Blick zu funktionieren.