One trick is to avoid smart marshaling (for C# strings, arrays etc), and use "unsafe" and raw pointers. This way, all your types are what CLR calls blittable - i.e. their representation is the same in native and managed - and the behavior is straightforward and predictable, as no copying or implicit conversions occur. This is particularly useful for cases when you have something like a struct containing a pointer to a struct containing a string, and when ownership of memory is unclear.
Of course, the downside is that you have to do all the marshaling yourself then. E.g. for strings, you use the appropriate System.String constructor to create one from a raw pointer - it has overloads for null-terminated string pointers as well as pointer + length, and you can specify encoding too. Or, to pass the string to native, you pin it using "fixed" to obtain a pointer.