A couple of my Habr articles [one, two] on low-level programming for 8086 didn't cause much of a stir, but a poll at the end of the second part showed that only 5% of the victims readers wanted to discourage the author from tapping their fingers on the keyboard.

Feeling a sense of sincere gratitude to the remaining 95% of readers, the author decided to produce another, completely original, and extremely useful for educational purposes "low-level" article.

Today, we're in for a mind-blowing mix of 64/32-bit x86 assembly and good old C++.

Disclaimer

Everything below is the author's creation and comes with no guarantees. Use it at your own discretion, accepting the risks of any possible consequences. The author's goal is to provide an educational example, a template, or, if lucky, to explain the essence of the phenomenon.

Preface

I'll assume the reader is familiar with the concept of a "thread" (thread) in the context of software. In short, a thread, according to Wikipedia, is the smallest unit of execution that can be scheduled by the kernel. That is, the OS kernel manages the starting, stopping, termination, and other states of threads. And we, as programmers, command the kernel what to do with the thread. Of course, I mean a normal situation where the OS can do this at all.

So, a fiber (fiber) is an even smaller unit than a thread, one that the OS kernel doesn't even see. At all. Fibers are a way to implement cooperative execution. You can think of a fiber as a thread that decides for itself when to yield the processor to other fibers and calls a special API for this purpose. Moreover, many fibers can run within a single thread. Not simultaneously, but with the right programming skills, they can create the illusion of parallel execution just as well as multiple threads on a single processor core.

Modern C++ has coroutines. Their functionality largely overlaps with fibers, but a full-fledged implementation of coroutines requires compiler support. Fibers, on the other hand, are implemented without it.

The Win32 API provides functionality for working with fibers, and Boost::Fiber does too. But, as usual, we're going our own way and doing everything ourselves in order to:

  • Understand for ourselves and show others what's "under the hood"

  • Stop being afraid of assembly and love get along with it

  • Because we can

Our assembly will be as bare-bones as it gets: without any of that prologue, epilogue, invoke, stackframe. Only hardcore, only mov rbp, rsp and as direct as I am, call - just the way we like it.

The example was made with the free Microsoft Visual Studio 2022 Community, and tested on 2019. The example project itself can be downloaded from the author's GitHub repository. You can configure Visual Studio for Assembler+C++ projects using this recommendation, and there's also a video on this topic from Dr. Nina Javaher.

What a fiber-based program in C/C++ looks like

Almost normal:

void __stdcall fiber1(void* data)
{
	for (auto i = 5; i >= 0; --i)
	{
		std::cout << "+Fiber1:" << ::GetCurrentThreadId() << " " << i << std::endl;
	}
}

void __stdcall fiber2(void* data)
{
	for (auto i = 0; i < 10; i++)
	{
		std::cout << "-Fiber2:" << ::GetCurrentThreadId() << " " << i << std::endl;
	}
}

int main()
{
	// register our fibers
	FiberManager::addFiber(fiber1, nullptr);
	FiberManager::addFiber(fiber2, nullptr);
	// run
	FiberManager::start();
	// done
	std::cout << "***Exit***" << std::endl;
	return 0;
}

Two ordinary functions with loops in them. The first one goes from 5 down to 0 inclusive, the second from 0 up to 9 inclusive. Well, let's see...

Let's run it:

Рис. 1. Ничего интересного
Fig. 1. Nothing interesting

Found something to surprise us with! Author, what have you been eating?! It's obvious that first fiber1 runs, then fiber2. Dislike the article and a poop emoji to your karma!

Sorry, dear reader, I forgot something, one small step...

void __stdcall fiber1(void* data)
{
	for (auto i = 5; i >= 0; --i)
	{
		std::cout << "+Fiber1:" << ::GetCurrentThreadId() << " " << i << std::endl;
		yield(); // one small step for man...
	}
}

void __stdcall fiber2(void* data)
{
	for (auto i = 0; i < 10; i++)
	{
		std::cout << "-Fiber2:" << ::GetCurrentThreadId() << " " << i << std::endl;
		yield(); // ...one giant leap for mankind
	}
}

int main()
{
	// register our fibers
	FiberManager::addFiber(fiber1, nullptr);
	FiberManager::addFiber(fiber2, nullptr);
	// run
	FiberManager::start();
	// done
	std::cout << "***Exit***" << std::endl;
	return 0;
}

I added a couple of calls to yield():

Рис. 2. Наглядная демонстрация "кооперативности" волокон
Fig. 2. A clear demonstration of the "cooperative" nature of fibers

Let's take a closer look. Now the text output from the two functions is interleaved.

-Wait... these are just regular threads! Author, that's it, I'm unsubscribing!
-But they're not threads. It's not for nothing that I'm printing the result of the Win32 API function call GetCurrentThreadId(). The Thread ID of the current thread shows that it doesn't change...

Fiber cooperation

Cooperation in fibers is a mechanism for voluntarily transferring execution from one fiber to another by directly or indirectly calling a special function. Our special function, as you've already noticed, is called yield(). The entire fiber mechanism operates in user space and, unlike threads, does not require switching any system contexts.

The positive aspects of fibers include not only the absence of switching between user space and kernel space, but also the ability for the programmer to choose the points at which to "yield" control. This helps mitigate such an unpleasant issue as a race condition. However, a race condition can still be achieved if you start modifying a composite object before calling yield() and then continue the modifications after such a call. Inside yield(), control will be passed to another fiber, which will see the partially modified object and can make its own changes to it. To solve this problem, you can prepare an analog of a critical section for cooperative mode.

The negative aspects include the extreme undesirability of using blocking calls. For example, the standard Win32 Sleep(milliseconds) will "hang" the entire cooperative system for the specified number of milliseconds. Therefore, all such things should be replaced with non-blocking analogs. For example, a version mySleep using the Win32 GetTickCount64():

void mySleep(uint32_t milliseconds) {
	// Retrieve the number of milliseconds that have elapsed since the system was started.
	// See https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-gettickcount64
	ULONGLONG t = GetTickCount64();
	while (GetTickCount64() < t + milliseconds) {
		yield();
	}
}

We see that the function mySleep(uint32_t milliseconds) blocks only the fiber that called it; the other fibers will continue to run, because the "paused" fiber calls yield() in a loop, yielding the processor to its neighbors. Here's an example:

void __stdcall fiber1(void* data)
{
	for (auto i = 5; i >= 0; --i)
	{
		std::cout << "+Fiber1:" << ::GetCurrentThreadId() << " " << i << std::endl;
		mySleep(300);
	}
}

void __stdcall fiber2(void* data)
{
	for (auto i = 0; i < 10; i++)
	{
		std::cout << "-Fiber2:" << ::GetCurrentThreadId() << " " << i << std::endl;
		mySleep(100);
	}
}

Since mySleep() already calls yield(), there's no need to do it again, unless we explicitly want to yield control to another fiber at some point. Here's what we get in the console:

+Fiber1:10140 5
-Fiber2:10140 0
-Fiber2:10140 1
-Fiber2:10140 2
+Fiber1:10140 4
-Fiber2:10140 3
-Fiber2:10140 4
-Fiber2:10140 5
+Fiber1:10140 3
-Fiber2:10140 6
+Fiber1:10140 2
-Fiber2:10140 7
-Fiber2:10140 8
-Fiber2:10140 9
+Fiber1:10140 1
+Fiber1:10140 0
***Exit***

Of course, data input, for example, from the keyboard, can also be implemented in a way suitable for use in fibers:

std::string myReadString()
{
	std::string result;
	for (;;)
	{
		if (_kbhit())
		{
			char c = static_cast<char>(_getch_nolock());
			if (c == '\r')
			{
				break;
			}
			_putch_nolock(c);
			result += c;
		}
		yield();
	}
	return result;
}

Here, _getch_nolock() and _putch nolock() are library functions defined in conio.h.

The very need to decide where to yield control to other threads can also be considered a negative point. But it shouldn't be considered particularly serious: in I/O tasks, for example, when working with a network, the call to yield() can be completely hidden in wrappers around non-blocking poll()/select() and then work with the network from fibers through these wrapper functions as if the I/O were blocking.

Inside me... ahem... a neon light

Long story short...

- Long story short, this printing contraption, unfortunately, contains nothing new. It only contains very old things...

- Inside! - the old man rustled. - Look inside, where its analyzer and thinker are...

- Analyzer... - I said. - There's no analyzer here. A standard rectifier - yes, also an old one. An ordinary neon bulb. A toggle switch. A good toggle switch, new. Riiight... There's also a cord. A very good cord, brand new... Well, I guess that's all.

-- Arkady Strugatsky, Boris Strugatsky.
"Tale of the Troika"

We've loved pictures since childhood. Here is a sequence diagram (Fig. 3):

Рис. 3. Диаграмма последовательности работы волокон.
Fig. 3. Sequence diagram of fiber operation.

For readers familiar with my previous publication, this diagram is very reminiscent of a thread switcher based on timer interrupts. The main difference here is that we don't use interrupts; instead, the fiber calls yield() on its own initiative.

A call to yield() transfers control to the Fiber manager, which selects the next fiber in order from the list of registered fibers, switches the stack pointer register esp/rsp to the selected fiber's stack, and returns with the RET instruction to that fiber, popping the return address from the stack.

In this other fiber, it looks as if the yield() it previously called has just unblocked and returned control. Meanwhile, the first fiber remains blocked in its call to yield() until the second one decides to call this function again.

We've discussed the state when everything is already running. The situation of entering a fiber is a special case. For this, when registering a fiber function, the Fiber Manager creates a separate stack for it and places, among other things, the address of the fiber termination handler onFiberFinished() and the entry address of the fiber function onto this stack. The very first call to yield() is made from the Main code - the main program code. This causes control to be transferred from the main code to the Fiber manager, which selects the very first fiber from the prepared list, writes the address of the selected fiber's stack top to esp/rsp and executes the RET instruction. As a result, a jump to the fiber's code occurs - it starts. From then on, everything proceeds as described above: a subsequent call to yield() from the fiber function, entering the Fiber Manager, selecting the next fiber from the list, and so on.

The fibers in Fig. 3 are shown in green and pink (okay, let's say pink, not magenta). The discontinuity of the control fragments means that the fiber executes intermittently, in fragments on a dotted lifeline. This differs from the situation of a simple call to some local function and waiting for its return, because there is a switch of the fiber's local context, which is stored on its own stack. At this moment, the logical continuity of the fiber function temporarily disappears. The Fiber manager, in principle, can choose not to return control to a suspended fiber, while continuing to manage other fibers.

When a fiber function completes its work through a standard C-style return, it actually executes the familiar RET instruction. This pops the address of the termination handler - the function onFiberFinished() belonging to the Fiber manager - from the fiber's stack. This handler removes the fiber from the list of fibers managed by the manager. If the list becomes empty, the manager switches the esp/rsp register to the main code's stack (Main code in Fig. 3) and, by popping the saved registers and return address, returns control to it.

The Guts

Fiber Registration

A rather trivial thing. The function addFiber creates a new fiber descriptor FiberDescriptor based on the passed pointer to the fiber function fiber and an optional block of private data data. The created descriptor is placed in the list of fibers _fibers:

namespace FiberManager {
	typedef std::unique_ptr<FiberDescriptor> FiberDescritporPtr;
	typedef std::list<FiberDescritporPtr> Descriptors;

	Descriptors _fibers;
	FiberDescritporPtr _finishedFiber;
	Descriptors::iterator _itFiber; // points to a current fiber

	void addFiber(void(__stdcall* fiber)(void*), void* data)
	{
		_fibers.emplace_back(std::make_unique<FiberDescriptor>(fiber, data));
	}
. . . .

Fiber Descriptor

The state of a fiber is saved in the fiber descriptor FiberDescriptor:

#ifdef _M_X64
typedef uint64_t MemAddr;
#else
typedef uint32_t MemAddr;
#endif
class FiberDescriptor
{
public:
	static constexpr size_t nStackEntries = 16384;
	FiberDescriptor(const FiberDescriptor&) = delete;
	FiberDescriptor& operator=(const FiberDescriptor&) = delete;
	FiberDescriptor(void(__stdcall* fiber)(void*), void* data);
	bool isOwnerOfStack(const MemAddr* sp) { return (sp >= &_stack[0]) && (sp < &_stack[0] + nStackEntries); }
	void saveStackPointer(MemAddr* sp) { _stackPointer = sp; }
	MemAddr* getStackPointer() const { return _stackPointer; }
private:
	MemAddr _stack[nStackEntries];
	MemAddr* _stackPointer;
};

Data fields:

  • in the array _stack lives the fiber's own stack with a size of 16384 addresses, each address being 4 bytes wide for 32-bit mode or 8 bytes for 64-bit mode. The stack is needed for local variables of called functions, passing parameters, storing the call history itself (call) and ensuring returns (ret) from them. Everything here is exactly the same as with threads, which also have their own stack. The array size was chosen empirically.

  • in the field _stackPointer the value of the stack top pointer register esp/rsp is saved for the time while control is transferred to another fiber.

Methods:

  • The constructor FiberDescriptor(void(__stdcall* fiber)(void*), void* data) receives the address of the fiber function fiber and a pointer to a data block, which can be nullptr, if no starting data is needed for the function.

  • bool isOwnerOfStack(const MemAddr* sp) checks the stack top pointer, passed in the parameter sp, for belonging to the fiber's stack. The method is needed to detect whether yield() was called from the Main code and to save the main code's stack pointer in the global variable _mainSp for a subsequent correct return from yield() back to the main code after all fibers have completed.

Let's pay attention to the fiber descriptor's constructor:

#include "FiberDescriptor.h"

extern "C" MemAddr* lowLevelEnqueueFiber(void(__stdcall*)(void*), void*, MemAddr*); // defined in .asm

FiberDescriptor::FiberDescriptor(void(__stdcall* fiber)(void*), void* data)
{
	// fill the stack with pre-defined pattern
	for (auto& elem : _stack)
	{
#ifdef _M_X64
		elem = 0xdeadbeeff00da011ULL; // DeadBeefFoodA0ll for debug
#else
		elem = 0xdeadbeefU; // DeadBeef for debug
#endif
	}
	_stackPointer = lowLevelEnqueueFiber(fiber, data, &_stack[0] + nStackEntries);
}
  • We fill the stack array with a specified numerical pattern. This helps to understand how much stack space was actually used, including calls to Win32 API system functions. Part of the pattern is overwritten by calls, and one can analyze what stack depth is needed for the fiber to work.

  • the function MemAddr* lowLevelEnqueueFiber(void(__stdcall*)(void*), void*, MemAddr*) is implemented in assembly. It initializes the new fiber's stack. We will now look at this function in more detail.

The author knew you were dreaming of modern assembly and brutal, 64-bit wide, general-purpose registers. There will be 64-bit, but we'll start with 32-bit. Here is the x86-32 version of lowLevelEnqueueFiber:

;----------------------------------------------------------------------------
; Should be used from MAIN context to add a new fiber to fiber dispatcher.
; returns new stack pointer in eax
; extern "C" MemAddr* lowLevelEnqueueFiber(void(__stdcall*)(void*), void*, MemAddr*);
lowLevelEnqueueFiber PROC    ;pFunc:PTR, pData:PTR, pStack:PTR
    push    ebp
    mov     ebp, esp
    mov     esp, [ebp + 10h] ; pStack - prepare the top of stack for a new fiber
    push    [ebp + 0Ch]      ; pData - points to void* parameter will passed to the fiber via stack
    push    onFiberFinished  ; the handler which is called at the fiber completion stage
    push    [ebp + 08h]      ; pFunc = pointer to a fiber function
    ; allocate stack space to popping edi, esi, ebx, ebp in lowLevelResume()
    push    0
    push    0
    push    0
    push    0
    mov     eax, esp    ; the result is the address of the new fiber's stack pointer in eax.
    mov     esp, ebp    ; restore esp
    pop     ebp         ; restore ebp
    ret
lowLevelEnqueueFiber ENDP

As promised, no special macro tricks from MASM. Here, even the enter/leave instructions are not used for the sake of a clearer example.

Briefly about enter/leave

ENTER op1,op2

The ENTER instruction creates a stack frame required by most high-level languages. The first operand specifies the number of bytes of memory to be allocated on the stack upon entering the procedure. The second operand specifies the nesting level of the procedure in the high-level language source code. It determines the number of stack frame pointers to be copied into the new stack frame from the previous one. If the current bitness is 16 bits, the processor uses the BP register as the frame pointer and the SP register as the stack pointer. If the bitness is 32 bits, the processor uses the EBP and ESP registers, respectively, and in the 64-bit case, RBP and RSP. Source: https://sysprog.ru/post/komandy-enter-leave

In plain language, ENTER X, 0 can be written like this:

push   ebp
mov    ebp, esp
sub    esp, X

For the particularly curious, here they explain the second parameter of ENTER.

LEAVE

The LEAVE instruction has the opposite effect of the ENTER instruction. In fact, the LEAVE instruction only copies the contents of EBP to ESP, thereby discarding the entire stack frame created by the ENTER instruction, and reads the value of the EBP register for the previous procedure. Source: https://sysprog.ru/post/komandy-enter-leave

In a language we're used to, LEAVE can be implemented like this:

mov    esp, ebp
pop    ebp

At the moment of calling lowLevelEnqueueFiber, on the stack, according to the ABI for 32-bit __cdecl, are the parameters pFunc (aka fiber), pData, pStack in increasing order of offset from the address in esp. At the moment of entry into lowLevelEnqueueFiber, the esp register points to the stack element with the return address to the calling code. We consider this to be offset 0. At offset 4 is the pointer to the fiber pFunc, at offset 8 is the pData pointer, and at offset 12dec is the pointer pStack.

But in the code, you see something different: to address data on the stack, we use ebp, into which we placed a copy of esp, but for some reason, all offsets are increased by 4. What's the reason? The reason is that we first pushed the original value from ebp onto the stack, then did mov ebp, esp and then started working with the parameters. And pushing onto the stack always decrements esp by 4.

Why did we put the ebp register on the stack? So that the calling side wouldn't have problems accessing local variables after returning from lowLevelEnqueueFiber.

A few words about local variables

The typical place for local variables in a function is the stack. And they are accessed via the ebp/rbp:

Рис. 5. Локальные переменные в программе на C в отладчике Visual Studio 2022.
Fig. 5. Local variables in a C program in the Visual Studio 2022 debugger.

You can find more about local variables here.

We must either restore ebp before exiting our function, or not touch it at all. In our case, ebp is used to access the function's parameters and to store a copy of the esp register, which we lowLevelEnqueueFiber change later in the code:

mov     esp, [ebp + 10h] ; pStack - prepare the top of stack for a new fiber

and at the end of the function, we restore it back from ebp to return normally to the code that called lowLevelEnqueueFiber.

So, for what purpose do we change esp? Our goal is purely practical and noble! We put a pointer to the newly created empty fiber stack into the esp register.

Let's go back to what the call to lowLevelEnqueueFiber from C++ looks like:

_stackPointer = lowLevelEnqueueFiber(fiber, data, &_stack[0] + nStackEntries);

We see the third argument &_stack[0] + nStackEntries. This is the pointer to the top of the new stack.

The stack grows downwards...

...from larger addresses to smaller ones. When a value is placed on the stack, first a decrement of esp by 4 (in the 64-bit case, rsp is decremented by 8) occurs, and then the value to be saved is written to the resulting address. So, everything is correct here.*

*) It's more correct to say not "by 4" or "by 8", but "by the size of the object being placed on the stack". However, most often, general-purpose registers are placed there, and their size is exactly 4 and 8 bytes for 32- and 64-bit modes, respectively.

Onto this very stack, we sequentially push the address of the fiber termination handler onFiberFinished, the address of the fiber function itself, passed as the first argument when calling lowLevelEnqueueFiber, and now located at the address ebp + 08h, and another 4 zeros. Here's that piece:

    push    onFiberFinished  ; the handler which is called at the fiber completion stage
    push    [ebp + 08h]      ; pFunc = pointer to a fiber function
    ; allocate stack space to popping edi, esi, ebx, ebp in lowLevelResume()
    push    0
    push    0
    push    0
    push    0

As a result, we get a stack prepared for launching the fiber. We will soon fully reveal the meaning of all these manipulations.

For now, let's return for a second to the code of lowLevelEnqueueFiber and notice that the new value of esp, after all the necessary addresses and zeros have been placed on the stack, is copied to eax:

mov     eax, esp    ; the result is the address of the new fiber's stack pointer in

In the C/C++ cdecl convention, functions return their result through the eax/rax register.

So, we return the new top of the fiber's stack to the calling code, not forgetting to restore esp and pop the original value of ebp before leaving, so that no one gets offended nothing crashes:

    mov     eax, esp    ; the result is the address of the new fiber's stack pointer in eax.
    mov     esp, ebp    ; restore esp
    pop     ebp         ; restore ebp
    ret

Phew... I think I forgot something... oh yes, the four zeros on the stack! The thing is, starting a fiber function and its suspend-resume operations are performed in exactly the same way: by calling yield(). Inside a fiber, this call is no different from any other call, and therefore must follow certain restrictions imposed by conventions we didn't create.

Among them is the requirement that the values of so-called non-volatile registers remain unchanged after returning from a function. In other words, the called function must either save and restore such registers, or not use them. The second option is definitely not suitable for us, because we don't know which registers a particular fiber will want to use, and yield() is precisely what transfers control between fibers.

Here is a list of non-volatile registers for 32-bit mode: EBX, EBP, ESP, EDI, ESI, CS, DS.

Given that the program uses the standard Flat memory model for Windows (and not only), in which all segments are combined and coincide with each other, we can rely on the fact that CS and DS will not be changed by anyone, and we don't touch them ourselves. The ESP register is under our close control and we strictly watch over its safety, but EBX, ESI, EDI, EBP we are obliged to restore to their original state upon exiting yield(). And this must be done independently for different fibers; each fiber forms its own states and register values. It's most convenient to save registers on the stack with PUSH instructions, and retrieve them from the stack with POP instructions. In order for the fiber's stack to be correctly formed with space for these four registers, we placed four zero values in it, since the specific register values are not important at the start of the fiber.

Start!

Let's take a closer look at the code of the void start() function in the Fiber manager:

// Pointer to MAIN stack
static MemAddr* _mainSp;

namespace FiberManager {
	typedef std::unique_ptr<FiberDescriptor> FiberDescritporPtr;
	typedef std::list<FiberDescritporPtr> Descriptors;

	Descriptors _fibers;
	FiberDescritporPtr _finishedFiber;
	Descriptors::iterator _itFiber; // points to a current fiber

	void addFiber(void(__stdcall* fiber)(void*), void* data)
	{
		_fibers.emplace_back(std::make_unique<FiberDescriptor>(fiber, data));
	}

	void start()
	{
		_mainSp = nullptr;
		_itFiber = _fibers.begin(); // Select the first fiber
		// Run the first fiber from MAIN stack context.
		// The stack will be switched to a local fiber-related stack.
		yield(); // it returns back to start() when all the fibers will finish.
		_finishedFiber.reset();
	}
}

The most important line there is the call to yield(). This call will start the fiber mechanics and will only return control to the main code after all fiber functions have completed their work.

The implementation of the 32-bit version of yield() is very simple:

; void yield() is used to switch the fiber. Should be called from running fiber.
; It is also used to run the initial fiber from main context
yield PROC
    pushall             ; save non-volatile registers
    push    esp         ; pass a stack pointer to fiberManagerYield as argument
    ; fiberManagerYield(sp) switches the execution to another fiber
    call    fiberManagerYield
    add     esp, 4      ; release one stacked parameter passed to fiberManagerYield
    popall              ; restore non-volatile registers
    ret
yield ENDP
The pushall and popall macro-instructions save/retrieve non-volatile registers to/from the stack

32-bit version:

;----------------------------------------------------------------------------
; See https://www.agner.org/optimize/calling_conventions.pdf and
; https://learn.microsoft.com/en-us/cpp/cpp/cdecl?view=msvc-160
; Non-vloatile registers are EBX, EBP, ESP, EDI, ESI, CS and DS
pushall macro
    push    ebx
    push    esi
    push    edi
    push    ebp
    endm
;----------------------------------------------------------------------------
; See https://www.agner.org/optimize/calling_conventions.pdf and
; https://learn.microsoft.com/en-us/cpp/cpp/cdecl?view=msvc-160
; Non-vloatile registers are EBX, EBP, ESP, EDI, ESI, CS and DS
popall  macro
    pop     ebp
    pop     edi
    pop     esi
    pop     ebx
    endm
;----------------------------------------------------------------------------

When called, the yield() function saves the non-volatile registers on the current stack at the time of the call, then passes the current top address via the same stack to the C function void fiberManagerYield(MemAddr*) .

Let's look at what fiberManagerYield does:

// This function is called from ASM code yield().
// @param sp - current stack pointer after all the required CPU 
// registers have been pushed to stack.
void fiberManagerYield(MemAddr* sp)
{
	using namespace FiberManager;
	if (_fibers.empty()) // No fibers in the list?
	{
		// No fibers to switch to, just return back to yield().
		return;
	}
	// Does the current fiber own the stack pointed by sp?
	if ((*_itFiber)->isOwnerOfStack(sp))
	{
		// Save current stack pointer to the fiber descriptor
		(*_itFiber)->saveStackPointer(sp);
		if (_fibers.size() > 1)
		{
			// Select the next fiber.
			if (++_itFiber == _fibers.end()) // Is the last fiber?
			{
				// Go to first.
				_itFiber = _fibers.begin();
			}
		}
	}
	else
	{
		// Execution goes here ONCE, when yield() is first called from the
		// MAIN stack context. We have only one such place in the start() function.
		assert(_mainSp == nullptr);
		// Save MAIN stack pointer to use it in the final completion.
		_mainSp = sp;
	}
	// Switch to the selected fiber using its own stack.
	lowLevelResume((*_itFiber)->getStackPointer());
	assert(false); // The execution must never go here!
}

It's clear that fiberManagerYield does nothing if the fiber list is empty. The check

if ((*_itFiber)->isOwnerOfStack(sp)) ...

is needed to find out if fiberManagerYield and, consequently, yield(), was called from a fiber (result true) or from the main code (result false). For this, the method

bool FiberDescriptor::isOwnerOfStack(const MemAddr* sp)

compares the pointer to the top of the current stack passed to it with the range of addresses occupied by the array FiberDescriptor::_stack allocated for the fiber's stack.

If the current stack corresponds to the current fiber's stack, we select the next fiber in order for switching (returning control). If the current stack is different, it means that yield() was called from the main code during the start of the Fiber manager. In this case, the pointer to the top of the current, and in this situation, the main, stack is saved in _mainSp to be used for a correct return of control to the main code upon completion of all fibers. The return will be made precisely to where yield() was called.

The next interesting point is the call to lowLevelResume

lowLevelResume((*_itFiber)->getStackPointer());

with a pointer to the top of the selected fiber's stack. The source for the 32-bit version of lowLevelResume is:

;----------------------------------------------------------------------------
; Get a new stack pointer from passed argument and switch the stack
; to return into a different fiber
; extern "C" void lowLevelResume(MemAddr*);
lowLevelResume PROC     ; pSP:PTR
    ; update esp with a new address taken from pSP parameter passed via stack
    mov     esp, [esp + 4] ; pSP
    ; extract previously saved non-volatile registers using the passed stack pointer
    popall
    ret
lowLevelResume ENDP

No frills here: we assign the new pointer value, passed via the same stack when calling esp, to the lowLevelResume register. Then we pop the non-volatile registers from the new stack, and then ret pops the return address from there as well. And control is transferred (it's more correct to say "returned", since this happens with the ret instruction) to another fiber according to the new stack.

Do you still remember those suspicious

push   0
push   0
push   0
push   0

in lowLevelEnqueueFiber? Well, during the first launch of a fiber, which is also executed by the chain of calls
yield() --> fiberManagerYield() --> lowLevelResume(),
, there must be something on its stack that popall at the end of lowLevelResume will pop as values for the four registers ebp, edi, esi, ebx, and that the ret instruction will use to transfer execution.

This is why those zeros were initially placed on the stack after the starting address of the fiber function itself. The mystery is solved!

-Author, wait! - an attentive reader will say, -You just mentioned four zeros for the initial values of non-volatile registers, plus one more address for the jump to the beginning of the fiber function, but... your code pushes a whole six values onto the stack!

When it's time to stop

At the end of the previous chapter, the author was exposed by a meticulous reader who remembered a piece of code from the lowLevelEnqueueFiber function, used in the process of creating a new fiber descriptor to form the initial content of its own fiber stack:

. . . . .
    push    onFiberFinished  ; the handler which is called at the fiber completion stage
    push    [ebp + 08h]      ; pFunc = pointer to a fiber function
    ; allocate stack space to popping edi, esi, ebx, ebp in lowLevelResume()
    push    0
    push    0
    push    0
    push    0
. . . . .

Yes, reader, you are right. Right about everything. It's time to turn on our imagination to the fullest and think about what will happen when a fiber function decides to quietly, in a C-like manner, finish its work via return, or even without it, since a void function has nothing to return but control.

The C/C++ compiler didn't come up with anything better for exiting a function than the RET instruction. And this means that our code has reached the last element of its own stack - the address of the fiber termination handler, named onFiberFinished.

I'm sure you know, but still...

...a C/C++ function name is translated by the compiler into its address. For this reason, we can use a function name as an entry point or as an argument where a function pointer is expected. This rule also works for assembly.

The function onFiberFinished() will be automatically called immediately after exiting the fiber, as it is the address of onFiberFinished that falls into the hands of that very RET instruction.

Let's take a closer look at this creation of a twilight genius:

// This function is called from ASM code as a fiber completion.
// It should ALWAYS call lowLevelResume()
void onFiberFinished()
{
	using namespace FiberManager;
	// Currently, completing fiber is ALWAYS the owner of the current stack. But we mus
	assert((*_itFiber)->isOwnerOfStack(lowLevelGetCurrentStack()));
	// Avoid of auto-destruction the FiberDescriptor by saving it to
	// finishedTask shared pointer. We need this stack to be allocated
	// because it is current stack we are working with right now.
	_finishedFiber.reset(_itFiber->release());
	// Remove completed fiber from the list.
	_itFiber = _fibers.erase(_itFiber);

	MemAddr* sp;
	if (_fibers.empty())
	{
		// Prepare the final completion, we will return the control
		// from yield() to start() function. See yield() call in start().
		sp = _mainSp;
	}
	else
	{
		// Switch to the next fiber.
		if (_itFiber == _fibers.end())
		{
			_itFiber = _fibers.begin();
		}
		sp = (*_itFiber)->getStackPointer();
	}
	lowLevelResume(sp); // it doesn't return control!
	assert(false);
}

We immediately verify that the call to onFiberFinished() occurred using the stack of the fiber that last received control:

assert((*_itFiber)->isOwnerOfStack(lowLevelGetCurrentStack()));

If the Fiber manager's algorithm is not broken, this is ensured automatically. Next, we remove the completed fiber from the list _fibers and, if the list becomes empty after this, we select the main code's stack to return control to:

sp = _mainSp;

If other incomplete fibers exist, we select the next descriptor in order and get the fiber's stack:

sp = (*_itFiber)->getStackPointer();

And we yield control to the code at the address stored on the selected stack:

lowLevelResume(sp); // it doesn't return control!

And that's the entire implementation of fiber switching.

The Mysterious World of 64-bit Code

The C/C++ code is common for both platforms, but the tastiest part - the assembly - differs significantly. Let's focus on this difference. Gourmets have prepared their forks, sharpened their knives, moved their like-throwers closer, and aimed them at the author's text.

64 bits is not just 2 times 32. It's much worsebetter. The general-purpose registers became 64-bit, and, in addition to the ones we're used to, 8 new 64-bit registers were added: R8-R15 ,

The differences also concern function calling conventions (ABI). All those cdecl and stdcall turn into one elegant fastcall variant:

The first 4 parameters, if they are pointers or integers, are passed through the registers RCX, RDX, R8, and R9, and if we have floating-point numbers, then through the registers XMM0L, XMM1L, XMM2L, and XMM3L.

Details at this link.

Registers that must be restored upon exiting a function (non-volatile registers):

RBX, RBP, RDI, RSI, RSP, R12, R13, R14, R15, and XMM6-XMM15

Details at this link.

In this regard, the code for the pushall/popall macro-instructions has undergone significant changes

The 64-bit version does not include the rbp register; we work with it in a special way in the 64-bit version of the code:

;----------------------------------------------------------------------------
pushmmx macro mmxreg
    sub     rsp, 16
    movdqu  [rsp], mmxreg
    endm
;----------------------------------------------------------------------------
popmmx macro mmxreg
    movdqu  mmxreg, [rsp]
    add     rsp, 16
    endm
;----------------------------------------------------------------------------
; See https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170#callercallee-saved-registers
pushall macro
    push    rbx
    push    rsi
    push    rdi
    push    r12
    push    r13
    push    r14
    push    r15
    pushmmx xmm6
    pushmmx xmm7
    pushmmx xmm8
    pushmmx xmm9
    pushmmx xmm10
    pushmmx xmm11
    pushmmx xmm12
    pushmmx xmm13
    pushmmx xmm14
    pushmmx xmm15
    endm
;----------------------------------------------------------------------------
; See https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170#callercallee-saved-registers
popall  macro
    popmmx  xmm15
    popmmx  xmm14
    popmmx  xmm13
    popmmx  xmm12
    popmmx  xmm11
    popmmx  xmm10
    popmmx  xmm9
    popmmx  xmm8
    popmmx  xmm7
    popmmx  xmm6
    pop     r15
    pop     r14
    pop     r13
    pop     r12
    pop     rdi
    pop     rsi
    pop     rbx
    endm
;----------------------------------------------------------------------------

Before calling a function, the stack must be aligned to an address that is a multiple of 16 bytes.

For this, the author has prepared a small macro-instruction alignstack:

;----------------------------------------------------------------------------
; Align stack at 16 (see https://docs.microsoft.com/en-us/cpp/build/stack-usage?view=msvc-160 ) 
alignstack macro
    and     spl, 0f0h
    endm
;----------------------------------------------------------------------------
What is this SPL and why is this stub needed?

spl is the lower 8-bit (1 byte) part of the stack pointer register rsp. A meticulous reader will ask: why doesn't the author want to use rsp there instead of its stub spl? You can't use rsp, even if you really want to depict something like this:

and     rsp, 0fffffffffffffff0h

ml64.exe (this is the MASM macro assembler) will write to us during program translation:

LowLevel_x86-64.asm(124) : error A2084:constant value too large

It's strange, but true: There is no AND instruction for a 64-bit constant. And it's not just AND that has this issue. There is an option for a 32-bit constant* which is automatically sign-extended to 64 bits if the first operand is 64-bit:

Рис. 6. Странность x86-64 инструкций
Fig. 6. The strangeness of x86-64 instructions

*) Constants in CPU instruction diagrams are denoted by the abbreviation imm from the word immediate - direct.

Another feature is the requirement to have at least 32 bytes of reserved space on the stack before a function call, called the Shadow space. The called function can use this space to store a copy of the parameters passed through registers and to implement, for example, a mechanism for handling a variable number of arguments.

Details at this link.

To avoid writing the number 32 everywhere space needs to be allocated on the stack, the constant SHADOWSIZE has been added to the code:

;----------------------------------------------------------------------------
; Shadow Space (see https://docs.microsoft.com/en-us/cpp/build/stack-usage?view=msvc-160 ) 
SHADOWSIZE equ 32
;----------------------------------------------------------------------------

Microsoft has graciously drawn the stack in the brave new world for the situation where function A has called function B:

Рис. 7. Функция A вызвала функцию B.
Fig. 7. Function A called function B.

Let's move on to considering the differences between the 32- and 64-bit versions of the assembly functions

The 64-bit version of lowLevelEnqueueFiber:

;----------------------------------------------------------------------------
; Should be used from MAIN context to add a new fiber to fiber dispatcher
; rcx - pointer to a fiber function
; rdx - void* data
; r8  - pointer to a function stack
; returns  rax - address of a new host's stack pointer
; extern "C" MemAddr* lowLevelEnqueueFiber(void(__stdcall*)(void*), void*, MemAddr*);
lowLevelEnqueueFiber PROC
    push    rbp
    mov     rbp, rsp

    mov     rsp, r8         ; prepare the top of stack for a new fiber
    sub     rsp, SHADOWSIZE ; THIS SPACE IN TASK STACK IS REQUIRED BY ABI!
    alignstack
    ; onFiberFinished is handler which is called at the fiber completion stage.
    mov     r8, onFiberFinished
    push    r8
    push    rdx
    push    rcx
    ; prepare fiber entry proxy function
    mov     r8, fiberEntry
    push    r8
    pushall                 ; allocate stack space to popping non-volatile registers in lowLevelResume() 
    push    0               ; 0 is a value for RBP when it will be popped in lowLevelResume().
    mov     rax, rsp

    mov     rsp, rbp
    pop     rbp
    ret
lowLevelEnqueueFiber ENDP
;----------------------------------------------------------------------------

The code logic is identical to that in the 32-bit version, but there are also noticeable differences:

  • Space for the Shadow space is allocated on the fiber's own stack.

  • This stack is aligned to a 16-byte boundary.

  • Instead of push onFiberFinished, a strange combination of instructions is used

    ; onFiberFinished is handler which is called at the fiber completion stage.
    mov     r8, onFiberFinished
    push    r8

-As if they couldn't have left the old version... - the reader will say. -It's obvious that onFiberFinished is just the address of the entry point to the function of the same name!

Unfortunately, push does not work with 64-bit constants...

...maximum 32 bits, which are sign-extended to 64 bits when written to the stack. For example, the instruction push with the 32-bit operand 80000000hex

push   80000000h

will ultimately place the following bytes into the stack memory (shown from least significant to most significant):

00 00 00 80 ff ff ff ff

This happens because the most significant bit in the 32-bit 80000000hex is 1, which means this number is negative and equal to -2 147 483 648dec. So it gets extended to the exact same negative value with the full power of 64 bits!

Now live with that :-D

  • Instead of pushing the fiber function's entry address onto the stack, in this case, this parameter comes into lowLevelEnqueueFiber via rcx, and the address of some strange function fiberEntry is pushed onto the stack in the familiar way. And before it, the rdx and rcx registers are pushed. Let's note this feature for ourselves and move on for now.

  • Remember, in the 32-bit version, we put four 32-bit zeros on the stack as values for the four non-volatile registers at the start of the fiber? They took up a total of 16 bytes. This time, there are many more registers, and not all of them are the same size. I decided to just call pushall. Yes, I had to accept that instead of zeros, about 216 bytes of garbage from the actual registers would end up on the stack, but it won't prevent us from starting the thread in any way.

  • A separate instruction push 0 places the value for rbp. This non-volatile register is not included in pushall/popall intentionally. We work with it in a special way.

These are the differences.

Let's return to the strange function mentioned earlier, fiberEntry:

;----------------------------------------------------------------------------
; Fiber entry code is used to prepare an input parameter in RCX
fiberEntry PROC
    pop     rdx             ; Target fiber function address
    pop     rcx             ; Fiber argument pointer
    enter   SHADOWSIZE, 0   ; it pushes RBP to current stack and sets RBP=RSP and then RSP -= SHADOWSIZE
    alignstack        

    call    rdx             ; call fiber entry point

    leave                   ; Restore stack (rsp) & frame pointer (rbp)
    ret
fiberEntry ENDP
;----------------------------------------------------------------------------

With our current knowledge, there's no mystery there. fiberEntry rewrites the parameters passed via the stack into the necessary registers according to the ABI and calls the fiber's entry point.

The implementation of yield() practically repeats that for the 32-bit mode, with the differences related to stack alignment requirements:

;----------------------------------------------------------------------------
; void yield() is used to switch fiber. Should be called from running fiber.
; It is also used to run the initial fiber from main context
yield PROC
    pushall
    enter   0, 0            ; it pushes RBP to current stack and sets RBP=RSP
    mov     rcx, rsp        ; rcx is passed as parameter to fiberManagerYield
    sub     rsp, SHADOWSIZE
    alignstack
    ; fiberManagerYield(sp) switches the execution to another fiber
    call    fiberManagerYield

    leave                   ; Restore stack (rsp) & frame pointer (rbp)
    popall
    ret
yield ENDP
;----------------------------------------------------------------------------

In the 64-bit versions, I decided to use enter/leave for variety. How they work was described earlier in this article.

Completely unchanged is lowLevelResume, except that the input parameter is passed via the rcx register instead of the stack:

;----------------------------------------------------------------------------
; Get a new stack pointer from passed argument (rcx) and switch the stack
; to return into a different fiber
; rcx - target stack pointer
; extern "C" void lowLevelResume(MemAddr*);
lowLevelResume PROC
    mov     rsp, rcx
    ; extract previously saved non-volatile registers
    pop     rbp
    popall
    ret
lowLevelResume ENDP
;----------------------------------------------------------------------------

I almost forgot! There's one more assembly function I haven't mentioned. It's simple and is needed to get the top of the currently used stack in C code:

64-bit version

;----------------------------------------------------------------------------
; Get current stack pointer to provide it to C++ code
; extern "C" MemAddr* lowLevelGetCurrentStack();
lowLevelGetCurrentStack PROC
    mov     rax, rsp
    ret
lowLevelGetCurrentStack ENDP
;----------------------------------------------------------------------------

And the same for 32-bit

;----------------------------------------------------------------------------
; Get current stack pointer to provide it in C++ code
; extern "C" MemAddr* lowLevelGetCurrentStack();
lowLevelGetCurrentStack PROC
    mov     eax, esp
    ret
lowLevelGetCurrentStack ENDP
;----------------------------------------------------------------------------

As you can see, they are absolutely identical and primitive, but they are used in C code and, therefore, are worthy of mention.

Conclusion

I hope the article turned out to be moderately boring and at least a little informative. Many hours and mental effort were spent writing this text and the code example. The author would be very grateful for feedback in the comments, likes, and karma boosts.

Source code on GitHub