Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / IoT

Winduino: A Tool to Prototype Arduino Projects on the PC

5.00/5 (13 votes)
30 Sep 2023LGPL310 min read 34.3K   212  
Run and develop Arduino code, including a display, on PC using this Windows PC shim and emulator.
Speed up your prototyping process by avoiding having to upload your code to a device every time you change it. Now you can prototype your Arduino user interface w/ display and touch using this little code bridge.

Winduino

Introduction

Update: I have now implemented much of the Arduino framework on the PC now, but I was forced to change the license from MIT to Lesser GPL.

Update 2: I've now implement I2C, SPI and device emulation. Massive article and codebase update.

Update 3: Added the ability to map HardwareSerial instances to your PC's COM ports

This started on a lark. I just wanted to see if my graphics and user interface libraries would run on a Windows PC. To that end, I created a mechanism by which to draw to a window using DirectX and then I tied my user interface code and graphics code into it.

It wasn't very useful by itself, but I thought to myself, "hey, this could be a useful prototyping tool with a little work" and here we are.

What I've done is created a wrapper around a reasonable subset of Arduino functionality. It's enough to run a user interface using LVGL** or UIX and to write "serial" spew to a log window, emulate and manipulate virtual GPIO, SPI, and I2C devices.

** I should note that I have not tried this with LVGL, but in theory, it should work fine. It definitely will with an external virtual SPI screen, and something like htcw_ili9341 or TFT_eSPI.

Prerequisites

You'll need a Windows 10 or Windows 11 PC.

You'll need Git installed from git-scm.org.

You'll need MinGW installed to use GCC. I had good luck with Winlibs distro. Install it in your root of your system drive to avoid problems with path lengths.

You'll need VS Code with the CMake extensions from Microsoft installed.

Understanding this Mess

Winduino is a bunch of header files and a static library. Along with those, I've included an example project buildable with the included CMake file. I also included a batch file which can be used to fetch dependencies. Furthermore, I've included some virtual SPI and I2C devices as a DLL.

Once downloaded, make a "lib" folder if you don't already have one. Then run fetch_deps.cmd to pull in all the necessary dependencies. Now you can build it via VS Code by right clicking on CMakeLists.txt and clicking "build all projects". Finally, you can run the output with the run.cmd.

To use it, you #include "Arduino.h" in your main source file, and then implement setup() and loop(). A main.cpp is already included with the project, and the scripts are set up for my graphics and user interface libraries.

There's a special function you can implement called void winduino() that runs before setup() only when running under Winduino. This can be used to call Winduino specific code to set up the virtual hardware and such without cluttering setup(), but otherwise it performs basically the same as setup(). In any case, you need to wrap it with #ifdef WINDUINO/#endif in order to avoid compile errors outside Winduino.

This is what the top of the example main.cpp file looks like:

C++
#include <Arduino.h>
// include my graphics and ui
// iot libraries:
#include <gfx.hpp>
#include <uix.hpp>
using namespace gfx;
using namespace uix;

In addition to the Arduino compatibility, there are some other facilities for input and output.

  • read_mouse() gets mouse information
  • flush_bitmap() sends a bitmap to the display
  • winduino() can be implemented and runs before setup()
  • hardware_load() loads virtual hardware from a DLL.
  • hardware_set_pin() attaches virtual GPIOs to virtual hardware, like the DC line on a virtual display
  • hardware_configure() allows you to set device specific configuration properties
  • hardware_transfer_bits_spi() allows you to transfer bits to and from all virtual SPI devices on a port. However, you should use the SPIClass instances in SPI.h.
  • hardware_transfer_bytes_i2c() transfers bytes to and from all virtual I2C devices on a port. However, you should use the TwoWire instances in Wire.h.
  • hardware_attach_log() connects the hardware's log messages to the output window in Winduino
  • hardware_attach_spi() attaches a virtual SPI device to a particular port
  • hardware_attach_i2c() attaches a virtual I2C device to a particular port
  • hardware_attach_serial() attaches a PC serial COM port to a particular Serial instance
  • hardware_get_attached_serial() reports the COM port attached to a particular Serial instance
  • hardware_set_screen_size() sets the size of the integrated display. Defaults to 320x240.

flush_bitmap() takes the coordinates of a bitmap and the bitmap data and puts it onto the display. at the given coordinates. The bitmap is in BGRx8888 format, or RGBX8888 format if USE_RGB is defined. This is where your code would deviate from your Arduino device code. Particularly, the color format is 32 bit instead of 16 bit. You'll have to adjust accordingly. Fortunately, with my graphics and user interface libraries, this is trivial.

read_mouse() reports the coordinates of the mouse and true if the left button is pressed. If false, the coordinates aren't valid. This can be used to simulate touch. Note that coordinates can be outside the bounds of the window if the cursor is being dragged out of the window. In this way, read_mouse() works a bit differently than regular touch and you'll have to clamp the values yourself.

Note: The functions that follow must only be called inside the winduino() function. If they are called anywhere else, the results are undefined.

hardware_load() takes a DLL name and returns a hw_handle_t that can be used to refer to that hardware device. This function will create a new device instance each time it is called, returning a new handle. If the device could not be loaded, it returns false.

hardware_set_pin() makes a virtual GPIO connection between the virtual MCU and the virtual device. It takes a hardware handle, an MCU pin id, and a device pin id. You can find the pin ids for a device in the associated header it ships with.

hardware_configure() sets device specific configuration properties. It takes a hardware handle, a property id, some data, and the data size. The property ids are included with the header that ships with the device DLL. The format of the data is property specific.

hardware_transfer_bits_spi() and hardware_transfer_bytes_i2c() support the framework and should not be used directly.

hardware_attach_log() takes a hardware handle, a string prefix, and log level. The prefix is appended to any log coming from that device. The log level indicates the verbosity of the spew, with 255 including all messages, including debug. 1 is errors, 2 is warnings, and 3 is informational. The rest are various levels of information or debug, depending on the hardware implementation.

hardware_attach_spi() takes a hardware handle and a port number. The virtual hardware is attached to the given SPI port.

hardware_attach_i2c() takes a hardware handle and a port number. The virtual hardware is attached to the given I2C port.

hardware_attach_serial() takes a uart number and a port number. The COM port is attached to the given serial instance.

hardware_get_attached_serial() takes a uart number and a port number out parameter. The COM port associated with the uart is retrieved.

hardware_set_screen_size() takes a width and a height and sets the integrated screen dimensions accordingly.

Using this Mess

Basically, you import your Arduino ino or your PlatformIO code and declare the screen size. Remember to "cpp-ize" your ino file. Add the new cpp file to CMakeLists.txt, or just use the provided main.cpp.

In fact, you'll need to modify CMakeLists.txt for any new C or CPP implementation files you add.

It should be noted that .ino files don't actually work with my graphics library due to it requiring a newer version of the C++ standard than the Arduino IDE toolchain provides. They should work with LVGL.

If you need RGBA8888 instead of BGRA8888 (DirectX native), use this define before including the Arduino.h header:

C++
#define USE_RGB

This dictates the expected format of the bitmap passed to flush_bitmap().

After that, #include <Arduino.h>.

C++
#include <Arduino.h>

You can use Serial to print to the log window. You really should call begin() before you use them even though in the current codebase, it is not necessary. It may be in the future.

To build with VS Code, right click on the CMakeLists.txt and click "Build All Projects". If you don't have that option, you need to install the CMake extensions from Microsoft. The first time you build, you'll be asked which compiler to use. You'll want to use one of the GCC offerings because that's what your Arduino toolchain uses when coding for IoT.

Once you've done that, you can drop to the terminal in VS Code and type .\run to run the program.

main.cpp

This file contains a bunch of example code for running a demo on the native screen plus loading and attaching external hardware. This file is where your Arduino code goes. Just remove the example code and implement your own winduino()/setup()/loop() routines.

Implementing Virtual Devices

Virtual devices are DLLs that expose one or more instances of a hardware device. It's possible that the hardware may expose a composite device like a screen with a touch screen on it. That said, there is only one hardware device (even if composite) exposed from each DLL, in order to keep things simple. Otherwise, I would have used COM.

Your device should be a class in order to support multiple instances. Each time the DLL function CreateHardware() is called, a new instance of the class should be created, cast to the hardware interface, and then returned.

The class must implement the hardware interface, which includes all the core functions needed to interact with the device. Your class may not implement all of them, but must provide stubs for those where the implementation is not otherwise provided:

C++
class hardware_interface {
public:
    virtual int __cdecl CanConfigure() =0;
    virtual int __cdecl Configure(int prop, 
                            void* data, 
                            size_t size) =0;
    virtual int __cdecl CanConnect() =0;
    virtual int __cdecl Connect(uint8_t pin, 
                            gpio_get_callback getter, 
                            gpio_set_callback setter, 
                            void* state) =0;
    virtual int __cdecl CanUpdate() =0;
    virtual int __cdecl Update() =0;
    virtual int __cdecl CanPinChange() =0;
    virtual int __cdecl PinChange(uint8_t pin, 
                            uint32_t value) =0;
    virtual int __cdecl CanTransferBitsSPI() =0;
    virtual int __cdecl TransferBitsSPI(uint8_t* data, 
                                    size_t size_bits) =0;
    virtual int __cdecl CanTransferBytesI2C() =0;
    virtual int __cdecl TransferBytesI2C(const uint8_t* in, 
                                    size_t in_size, 
                                    uint8_t* out, 
                                    size_t* in_out_out_size) =0;
    virtual int __cdecl CanAttachLog() =0;
    virtual int __cdecl AttachLog(log_callback logger, 
                            const char* prefix, 
                            uint8_t level) =0;
    virtual int __cdecl Destroy() =0;
};

There are four ways to drive your device: Through GPIO, through SPI, through I2C, and through an Update() function. That function is not quite like the other mechanisms in that it does not intrinsically need a bus or GPIO connection to function, but by itself the function can't communicate with the virtual MCU. It simply gets run periodically as part of the application loop.

You can use one or more of these mechanisms to make the device work. Each facility has a corresponding CanXXXX() function that indicates whether the base function is supported or not, like CanConfigure()/Configure().

Typically with your GPIO, you'd simply respond to changes and/or get and set values on the GPIO through the callbacks.

With SPI and I2C, you'll probably need state machines. SPI and I2C work a little differently due to protocol differences.

With SPI, you transmit a number of bits, and the same number of bits is returned. This may be called multiple per transaction. As the bits come in, they should be processed, usually using a state machine, and resulting values should overwrite the read values passed to the function. If the function is half duplex, then the non-op end should be non-destructive, for example, for a half-duplex write operation the data argument should remain unchanged. Be sure to ignore incoming data if your CS pin is high.

With I2C, you process an entire transaction in one operation. This is because I2C holds the line for an entire transaction rather than allowing you to split them like you can with SPI. You most likely need a state machine, but it can be local to the processing routine rather than being able to retain between calls, like you must with SPI.

See the windiuno_hardware/spi_screen files for an example that uses GPIO, I2C and SPI to implement a virtual touch screen with capacitive touch.

History

  • 17th September, 2023 - Initial submission
  • 20th September, 2023 - Massive compatibility improvement
  • 26th September, 2023 - Added virtual hardware
  • 30th September, 2023 - Added serial mapping

License

This article, along with any associated source code and files, is licensed under The GNU Lesser General Public License (LGPLv3)