Need help structuring project with multiple targets that share `main`

Hi all,

I’m struggling with the correct way to structure my project using CMake. I have a large chunk of shared code which includes the entrypoint for all targets. The main function, as well as other portions of the shared code, have their behavior modified by various compile definitions via #ifdef’s. The value of these compile definitions, as well as the implementation for a few functions that are called in the shared code, vary depending on which executable is being compiled.

At the moment the shared code is an interface library in CMake that’s linked by the executable targets, but I’m not really sure that’s correct, since it isn’t really a standalone library. I’ve tried a bunch of approaches and gone back and forth with CMake docs, ChatGPT and googling, but haven’t been able to get compilation to succeed without a ton of warnings about the compile definitions being redefined.

I’d really appreciate any help, whether just general guidance on setting up this type of project with CMake or notes specific to the repository below:

The current state of my CMake setup is here: https://github.com/ZadenRB/OpenGCC_Firmware/tree/zr/cmake-help. The opengcc folder is the shared code, with the different targets living under controllers subfolders.

Previously, instead of using compile definitions, I had a header file for each target that was included in the relevant shared files (with the path to include set via a compile definition). This seemed to work. However, I noticed that if I set these values via compile definitions, then I’d get a ton of warnings about redefinitions of those constants, which makes me suspect there was something wrong with that approach too. Plus, I’d prefer to use compile definitions, as it would simplify things for me. In any case, that approach is on the master branch of the repository linked above.

If you need multiple compilations of a single source file based on different preprocessor values, an interface library is likely your best option. I still recommend isolating this multi-compilation code as much as possible.

From quickly scanning through the provided code, it seems like there are a few sections which are not dependent on this behavior, those sections can be moved to a static library, and add that library to your target_link_libraries.

When you have code that varies at compile time, there are two ways you can attack it.

One way is with the build system, preprocessor macros and suitable flags. That seems to be the route you’re headed down. This can be made to work, but honestly I think it makes for very confusing code.

As Colossus said to Forbin: “There is another system.”

Static variation can be handled by template classes/functions. An advantage of this is that it makes the part that varies between targets clear – they are the (static) member functions called on the template argument to the generic implementation.

Suppose we want a platform to have the ability to parse command-line arguments from main. Different platforms will do different things, so we make the implementation of main a template function that accepts a type to expose the customization point:

template <typename T>
int custom_main(int argc, char *argv[])
{
    T::parse_arguments(argc, argv);
    // do whatever else we need to do
    return 0;
}

struct NullArgumentParser
{
    static void parse_arguments(int argc, char *argv[]) { } // do nothing
};

// the main.cpp for some platform that doesn't care about custom argument parsing
int main(int argc, char *argv[])
{
    return custom_main<NullArgumentParser>(argc, argv);
}

Now each platform that wants to parse arguments implements the static member function parse_arguments with the expected signature:

struct CustomArgumentParser
{
    static void parse_arguments(int argc, char *argv[]);
};

void CustomArgumentParser::parse_arguments(int argc, char *argv[])
{
    // do whatever is necessary here
}

This is just a simple example of injecting static polymorphism into main but these techniques can be applied wherever you need variation at compile-time.

You don’t need to stick to a single method on your customization template argument and you don’t need to stick to static methods. If you need run-time state that should have a lifetime corresponding to one of your generic algorithms, you can instantiate the template argument inside the algorithm or pass it as an argument, hold it as a member, etc. There are many specific design choices you can make.

The main point is to separate the things that change for each platform (implemented in the template argument) from the things that are constant for all platforms (the template functions/classes accepting a template argument).

Then you won’t be struggling to get the build system to compile a single source file in different ways using the preprocessor, you’ll just have small distinct source files for each platform that instantiate the generic algorithm with suitable customizations. It’s even possible to use configure_file to generate such specializations at build time, if necessary.

The bottom line is that whenever you find yourself reaching for the C preprocessor in order to accomplish something, there’s almost always a native C++ way of doing it that is better at expressing the intent of what you’re really trying to do.