Nonblocking I2C implementation on STM32
Sure.


Set up your drivers in terms of an upper and lower pair, separated by a shared buffer. The lower function is an interrupt driven bit of code that responds to interrupting events, takes care of the immediate work needed to serve the hardware, and adds data into the shared buffer (if it is a receiver) or extracts the next bit of data from the shared buffer to continue servicing the hardware (if it is a transmitter.) The upper function is called by your regular code and either accepts data to add to an outgoing buffer or else checks for and extracts data from the incoming buffer. By pairing things up like this, and providing adequate buffering for your needs, this arrangement can run all day long without trouble and it decouples your main code from your hardware servicing.
Use a state machine in your interrupt driver. Each interrupt reads the current state, processes one step, and then either transitions to a different state or stays in the same state, and then exits. This can be as complex or as simple as you need it to be. Often, this is driven off of a timer event. But it doesn't have to be so.
Create a co-operative operating system. This can be just as easy as setting up a few small stacks allocated using malloc() and allowing the functions to cooperatively call a switch() function when they are done with some immediate task for now. You set up a separate thread for your I2C function, but when it decides that there is nothing to do right now, it simply calls switch() to cause a stack change to another thread. This can be done round-robbin until it comes back and returns from the switch() call you made. Then you go back to to your while-condition, which checks again. If still nothing to do, the while calls switch() again. Etc. In this way, your code doesn't get much more complex to maintain and it's simple to insert switch() calls anywhere you feel the need. There is no need to worry about pre-emption of library functions, either, since you are only transitioning between stacks at a function call boundary so it is impossible for a library function to be interrupted. This makes implementation very easy.
Pre-emptive threads. This is much like #3 except there's no need for calling the switch() function. Pre-emption takes place based upon a timer, or a thread can also freely choose to release its time, as well. The difficulty here is dealing with pre-emption of library routines and/or specialized hardware where there may be specific instruction sequences generated by the compiler which cannot be interrupted (back-to-back I/O which must retrieve a high byte followed by a low byte from the same memory mapped address, for example.)


I think the first two options are probably your better bets, though. However, a lot depends on just how much you are depending on library code written by others. It's quite possible that their library code isn't designed to be split into upper and lower level components. And it's also quite possible you can't call them based upon timer events or other state machine based events.

I tend to assume that if I don't like the way the library does the work (busy wait loops only), then I'm stuck writing my own code to do the work. Which means I'm free to use any of the above methods.

But you need to take a close look at the library code and see if it already has features which allow you to avoid the busy wait behavior. It's possible the library has support for something different. So that's the first place to check, just in case. If not, then toy with the idea of using the existing functions as part of either an upper/lower driver split or else as a state machine driven process. It's possible you can work that out, as well.

I don't have any other suggestions that come quickly to mind, right now.