Per-file or per-module compile flags

I am currently writing a little operating system kernel (for now only targetting x86(-64)) as a hobby, and to learn more about that kind of low-level programming. Up to now, I successfully used a "regular osdev" rust setup, with a custom target with all the juicy x86 vectorized ISA extensions disabled. Still, I felt that I still wanted to try to enable these features as an experiment (even though it adds some overhead to context-switches due to a bigger XSAVE region to copy, blah blah blah).
The problem is : I can't use these extensions without enabling them beforehand (by writing the right bits to the appropriate control registers), so I can't just compile my whole kernel with them enabled, with the risk of having the compiler use vectorized instructions where they still were not enabled.

So here is my question : is this possible to compile different files / modules from the same crate with different flags ? Since this is something feasible in C/C++, I think it would be a nice feature to add in the future, if it's not already the case.
Otherwise I suppose I should separate the init code and make it another crate of my project ?

I was also hoping that since #[target_feature(enable = ...)] exists, the opposite #[target_feature(disable = ...)] would also exist, but it's apparently not a thing for now.

EDIT : To clarify a bit, my initial goal was to compile initialization code (i.e. post bootloader code) without vectorized instructions enabled, and the rest of the kernel with them enabled

rust's analogy to C++ tranlation unit is a crate, not a module or file.

you can use multiple crates for you kernel just fine.

3 Likes

Alright, I see. It still feels a bit weird to me, but I suppose I'll just have to live with it.
Thanks for your answer !

For pretty much any compiled language, you can't have cyclic dependencies between separate translation units without introducing some extra level of indirection so that there is a valid sequence to compile them in, and the extra indirection usually makes more work for the developer, complicates the build process, slows down the build, or all three at once :slight_smile:

C solved this by requiring you to provide forward declarations for everything defined in another translation unit even when there isn't a cycle, and always compiling each translation unit totally independently of anything else.

This got more expensive and complex in C++ due to template expansion: a forward declaration of a template isn't sufficient, so you must either explicitly expand the template for each needed combination of parameters somewhere that does have access to the full definition, or just include the full definition into every translation unit that needs it via header files and then rely on the linker to remove duplicate expansions.

Rust just uses entire crates as translation units, so modules in a crate can freely have cyclic references, but separate crates cannot.

C++20 modules solve this in more or less the same way as Rust: each module is a single translation unit (even if it's composed of multiple files, called "module partitions" to distinguish them from non-module source files that are still standalone translation units) and cyclic references between modules aren't allowed. Cyclic references within a module are allowed, and no forward declarations are needed.

2 Likes

Yeah, okay, I clearly see the goal and advantages of having crate <==> TU.

Though for now it also has the drawback of not allowing for more fine-grained control over the target features used to compile two different related source files, or even two different functions in the same source file. At least not for disabling them.

Also, about the solution of splitting the code into two crates :

  • how do I specify one JSON target spec for one crate, and another one for the second crate ?
  • if the above is not possible, then how do I disable some target features for e.g. the initialization code ?
  • otherwise maybe it is possible to compile both the crates separately, and then tell cargo to link the initialization code (as a library) to the rest of the kernel ?
  • but then, how ? Especially since both the crates would depend on each other

Thanks a lot for your detailed answer !

You can specify different targets for different crates in .cargo/config.toml but this only gets applied when you run the build inside that specific crate's directory. When using a workspace and running cargo commands at the top level of the workspace it will only use the .cargo/config.toml at the top level and apply it to all crates in the workspace.

I've dealt with this in embedded projects where I want to be able to build some parts of the workspace for the host machine for testing/simulation/etc but I've not had any case where I've needed to build crates for different targets and then link them together, so I'm not sure if there is a well lit path to do that - it doesn't seem like a common use case.

Crates cannot have circular dependencies between them under any circumstances. You would need to break your project up into a set of crates that form a DAG - any shared code used by both the initialization and regular code would have to go into a third crate that they both depend on.

1 Like

Yes, the .cargo/config.toml solution isn't perfect either since you can't "easily" change target features / compile options (for example if you want to compile the kernel for a Skylake CPU, and then another one). In the end I opted for a project organisation similar to the one Theseus OS uses, with a highly modular architecture, plus the cargo xtask pattern.

And most importantly, for now I compile every Rust code with the same target features / compile flags, and have the kernel's entry point written in C, which is a lot easier since the C initialisation code is quite short (as it's just a mean of enabling the CPU to execute SIMD instructions without faulting) and which enables me to disable target features for one C file only.

Well in fact my problem is rather that I couldn't find a way to mix code with different target features (e.g. sse, avx2, ...) or compile options (e.g. redzone, ...), and this while keeping the ability to "configure" the kernel for different targets or CPUs without having to directly edit the kernel's Cargo.toml or any other config files. I realise this is indeed a quite "niche" need, but I still think I would be nice if Cargo permitted a bit more fine-grained tuning (or at least with an easier solution)

I think trying to mix target features in one project would lead to conflicts, and possible ABI issues.

What I would suggest is loading your kernel at runtime as an ELF (or PE, etc.) file, so that it can be as special as it likes, and have the only direct communication between the bootloader and kernel be when the bootloader enters the kernel.

If there are features both crates want to use, you could consider splitting them out into a shared lib crate that each one depends on separately.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.