If you need actual C-style, including compatibility with C libraries, and the attached potential unsafety pitfalls from C (especially if you get the signatures mismatching) Rust does have extern blocks (Unsafe Rust - The Rust Programming Language) (External blocks - The Rust Reference).
From rust code to rust code, you wouldn't necessarily use any of those. Instead, trait based solutions would be used: those would generally involve adding a generic argument to your API such as
fn some_func<S: SendToI2c>(s: &MyStruct) {
S::send_to_i2c(s);
}
this means that different downstream users can provide different send_to_i2c implementation without conflict. Rust does not (yet?) support, at least natively, any way of allowing for similarly downstream provided functionality while restricting to only one implementation globally.
If you go this way, the trait could be defined like
pub trait SendToI2c {
fn send_to_i2c(&MyStruct);
}
pub struct MyStruct {
i2c_address: u8,
}
and if the whole library depends on several such functions at once, you can bundle them up into a single trait if you like (feel free to choose a more fitting name in that case).
The downstream user than implements the trait for some marker type (common choices might be a empty unit struct, or maybe even an empty enum)
struct SendI2cImpl;
or
enum SendI2ClcImpl {}
(feel free to choose a mor descriptive name relating more to what your implementation actually does, not just what trait it implements) and then an implementation
impl upstream_crate::SendToI2c for SendI2cImpl {
fn send_to_i2c(&upstream_crate::MyStruct) {
// code goes here
}
}
then the code can call your API such as this:
fn foo() {
let xyz: MyStruct = //...
some_func::<SendI2cImpl>(&xyz);
}
For more ad-hoc use cases when only a single function is involved, directly expecting some impl Fn(&MyStruct) argument passed to some_func may also be a possible alternative API design choice.
If the choice of implementation of the interface is logically coupled to the lifetime of a specific instance of the MyStruct value (in particular if you're not supposed to switch out that send_to_i2c logic between multiple calls to some_func on the same MyStruct, then it might also make sense to psrametrize the MyStruct type with your trait, like
struct MyStuct<S: SendToI2c> {
i2c_address: u8,
_marker: PhantomData<fn(S) -> S>, // trick for phantom marker invariant in `S`
}
// I'm skipping the details of adjusting the definitions of the trait and your function signature
// since it's a lot to type on mobile rn ;-)
This way, users done have to manually specify their parameter on every call to some_func anymore, but only once when initializing the struct, and then type inference can help afterwards.
It's hard to give more concrete advice without a better feel of the overall shape of the API surface though.