Refactor SDL/FreeRTOS implementation to support macOS simulator properly (#653)

- Run SDL from main() and place FreeRTOS in separate thread. This fixes macOS support.
- Updated GitHub Actions to publish macOS simulator build for testing, updated amd64 to x86_64 for consistent naming.
This commit is contained in:
Ken Van Hoeylandt
2026-09-20 23:12:42 +02:00
committed by Adolfo Reyna
parent 89e8baf517
commit 72089f74f3
25 changed files with 748 additions and 373 deletions
+2 -2
View File
@@ -10,13 +10,13 @@ constexpr auto* TAG = "FreeRTOS";
namespace simulator {
MainFunction mainFunction = nullptr;
static MainFunction mainFunction = nullptr;
void setMain(MainFunction newMainFunction) {
mainFunction = newMainFunction;
}
static void freertosMainTask(void* parameter) {
static void freertosMainTask(void*) {
LOG_I(TAG, "starting app_main()");
assert(simulator::mainFunction);
mainFunction();
+22 -2
View File
@@ -1,6 +1,11 @@
#pragma once
#include "Main.h"
#include "drivers/sdl_bridge.h"
#include <csignal>
#include <pthread.h>
#include <thread>
namespace simulator {
/** Set the function pointer of the real app_main() */
@@ -14,8 +19,23 @@ void app_main(); // ESP-IDF's main function, implemented in the application
}
int main() {
// Actual main function that passes on app_main() (to be executed in a FreeRTOS task) and bootstraps FreeRTOS
// The FreeRTOS POSIX port arms a process-wide SIGALRM timer for its tick and expects every one
// of its task pthreads to have all signals but SIGINT blocked.
// (see prvSetupSignalsAndSchedulerPolicy() in FreeRTOS-Kernel's Posix port.c)
// A signal-generated SIGALRM can land on any thread in the process that doesn't block it.
// This thread stays a plain OS thread (running the SDL loop below, never a FreeRTOS task),
// so without this it's eligible to catch a tick SIGALRM and freeze inside the scheduler's handler.
// Block the same set here, before anything else, so it never can.
sigset_t all_signals_except_sigint;
sigfillset(&all_signals_except_sigint);
sigdelset(&all_signals_except_sigint, SIGINT);
pthread_sigmask(SIG_SETMASK, &all_signals_except_sigint, nullptr);
// FreeRTOS and app_main() run on a separate thread: macOS requires SDL/Cocoa window creation,
// event pumping and rendering to happen on the real OS main thread, which sdl_bridge_run_main_loop()
// below takes over. freertosMain() never returns, so this thread is detached rather than joined.
simulator::setMain(app_main);
simulator::freertosMain();
std::thread(simulator::freertosMain).detach();
sdl_bridge_run_main_loop();
return 0;
}
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_bridge.h"
#include "sdl_input.h"
#include <tactility/error.h>
#include <chrono>
#include <condition_variable>
#include <mutex>
namespace {
struct PresentJob {
Device* device;
void* internal;
int32_t x_start;
int32_t y_start;
int32_t x_end;
int32_t y_end;
const void* color_data;
error_t result;
};
std::mutex job_mutex;
std::condition_variable job_ready_cv;
std::condition_variable job_done_cv;
bool job_pending = false;
bool job_done = false;
PresentJob pending_job;
}
error_t sdl_bridge_present(Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
std::unique_lock<std::mutex> lock(job_mutex);
pending_job = { device, internal, x_start, y_start, x_end, y_end, color_data, ERROR_NONE };
job_pending = true;
job_done = false;
job_ready_cv.notify_one();
job_done_cv.wait(lock, [] { return job_done; });
return pending_job.result;
}
void sdl_bridge_run_main_loop() {
while (true) {
sdl_input_pump();
std::unique_lock<std::mutex> lock(job_mutex);
if (job_ready_cv.wait_for(lock, std::chrono::milliseconds(1), [] { return job_pending; })) {
PresentJob job = pending_job;
lock.unlock();
job.result = sdl_display_execute_draw_bitmap(job.device, job.internal, job.x_start, job.y_start, job.x_end, job.y_end, job.color_data);
lock.lock();
pending_job.result = job.result;
job_pending = false;
job_done = true;
lock.unlock();
job_done_cv.notify_one();
}
}
}
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/error.h>
#include <stdint.h>
struct Device;
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Runs forever, pumping SDL input and executing display present jobs submitted via
* sdl_bridge_present(). Must be called exactly once, from the real OS main thread: macOS requires
* SDL/Cocoa window creation, event pumping and rendering to happen there, but FreeRTOS tasks
* (including the lvgl task that owns display flush and indev polling) run on separate pthreads
* spawned by the FreeRTOS POSIX port, not on that thread.
*/
void sdl_bridge_run_main_loop(void);
/**
* @brief Hands a display flush off to the main thread and blocks until it has finished copying
* the pixel data out (see sdl_display_execute_draw_bitmap() in sdl_display.cpp). Called from the
* lvgl task. Must block: the caller's pixel buffer is single-buffered and gets reused as soon as
* this returns.
*/
error_t sdl_bridge_present(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
/**
* @brief Implemented in sdl_display.cpp: the actual SDL work behind a display flush (lazy window
* init on first call, SDL_UpdateTexture, present). Only ever called from sdl_bridge_run_main_loop()
* on the main thread.
*/
error_t sdl_display_execute_draw_bitmap(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
#ifdef __cplusplus
}
#endif
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_display.h"
#include "sdl_bridge.h"
#include <tactility/device.h>
#include <tactility/driver.h>
@@ -141,8 +142,10 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
return true;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
// Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for
// SDL/Cocoa window creation and rendering on macOS.
error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(internal_ptr);
if (internal->init_failed) {
return ERROR_RESOURCE;
@@ -166,6 +169,16 @@ static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t
return ERROR_NONE;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
if (internal->init_failed) {
return ERROR_RESOURCE;
}
return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data);
}
static enum DisplayColorFormat sdl_display_get_color_format(Device*) {
return DISPLAY_COLOR_FORMAT_RGB565;
}
+70 -46
View File
@@ -7,11 +7,16 @@
#include <SDL2/SDL.h>
#include <cstdlib>
#include <mutex>
namespace {
constexpr size_t KEY_QUEUE_CAPACITY = 32;
// Written by sdl_input_pump() on the real main thread, read by sdl_input_get_pointer_state()/
// sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task.
std::mutex state_mutex;
SdlPointerState pointer_state = { 0, 0, false };
} // namespace
@@ -26,6 +31,7 @@ uint32_t touch_override_until_tick = 0;
#define SIM_TOUCH_HOLD_MS 1500
extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed) {
std::lock_guard<std::mutex> lock(state_mutex);
touch_override.x = x;
touch_override.y = y;
touch_override.pressed = pressed;
@@ -36,6 +42,7 @@ extern "C" void sdl_input_set_touch_override(int32_t x, int32_t y, bool pressed)
}
extern "C" void sdl_input_clear_touch_override(void) {
std::lock_guard<std::mutex> lock(state_mutex);
touch_override_active = false;
touch_override.pressed = false;
}
@@ -101,58 +108,73 @@ uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
} // namespace
void sdl_input_pump() {
if (!text_input_started) {
SDL_StartTextInput();
text_input_started = true;
// exit() must run with state_mutex unlocked: it never returns, so a lock_guard held across it
// would never release the mutex, hanging any other thread that later calls into this file's
// other functions (all of which lock state_mutex) while exit() tears the process down.
bool quit_requested = false;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (!text_input_started) {
SDL_StartTextInput();
text_input_started = true;
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.pressed = false;
}
break;
case SDL_KEYDOWN:
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
break;
case SDL_TEXTINPUT:
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
quit_requested = true;
break;
default:
break;
}
}
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.pressed = false;
}
break;
case SDL_KEYDOWN:
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
break;
case SDL_TEXTINPUT:
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
exit(0);
default:
break;
}
if (quit_requested) {
exit(0);
}
}
void sdl_input_get_pointer_state(SdlPointerState* out_state) {
std::lock_guard<std::mutex> lock(state_mutex);
if (touch_override_active) {
// Auto-release: viewer sends press only; LVGL needs press then release
// to register a click. Hold long enough for several indev polls.
@@ -168,6 +190,7 @@ void sdl_input_get_pointer_state(SdlPointerState* out_state) {
}
bool sdl_input_pop_key(uint32_t* out_key) {
std::lock_guard<std::mutex> lock(state_mutex);
if (key_queue_count == 0) {
return false;
}
@@ -178,5 +201,6 @@ bool sdl_input_pop_key(uint32_t* out_key) {
}
bool sdl_input_has_queued_key() {
std::lock_guard<std::mutex> lock(state_mutex);
return key_queue_count > 0;
}
+3 -3
View File
@@ -19,9 +19,9 @@ struct SdlPointerState {
/**
* @brief Drains all pending SDL events exactly once, updating the pointer state and key queue
* below. Safe to call from both the sdl-pointer and sdl-keyboard drivers' polling functions:
* SDL_PollEvent() drains a single global queue, so whichever driver is polled first on a given
* LVGL indev tick pumps events for both.
* below. Must be called only from the real OS main thread (sdl_bridge_run_main_loop()): SDL
* requires event pumping to happen there on macOS. The getters below are safe to call from a
* different thread (the lvgl task, via sdl-pointer/sdl-keyboard's polling functions).
*/
void sdl_input_pump(void);
@@ -16,8 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
// region KeyboardApi
static error_t sdl_keyboard_read_key(Device*, KeyboardKeyData* data) {
sdl_input_pump();
uint32_t key = 0;
if (sdl_input_pop_key(&key)) {
data->key = key;
@@ -16,7 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
// region PointerApi
static error_t sdl_pointer_read_data(Device*, TickType_t) {
sdl_input_pump();
return ERROR_NONE;
}