You can really tell, though, that the language wasn't meant to be used this way. Some common annoyances are:
1) C# is unable to declare fixed-size arrays in structs. There are ways around it, but they are all more unsafe, require code generation or hit the GC. If you've ever written kernel code, you know that fixed-size data members are really common, so this is more annoying that you might think.
2) C# types are either always references (classes) or always values (everything else). There are reference types, but they are restricted so that, for example, the array subscription operator cannot return them. This means that you can't directly operate on values in an array. Once again, there are ways around this, but they involve pointers, which pull in a bunch of the "unsafe" language.
3) The standard library makes a lot of choices that, in hindsight, are extremely unfortunate. Data structures carry generation numbers that increment on various operations and invalidate iterators. This makes a lot of operations that are sublinear in C++ take linear time in C#. You pretty much can't use the C# stdlib for any low-level code.
The projects that successfully use C# in low-level code pretty much all roll their own containers, stdlib and a module that implements black-magic stuff, usually called something like UnsafeUtil. On balance, having written a bunch of lower-level C# code, I'd still slightly prefer it to C++ for (e.g.) talking to the GPU driver, but there's no denying that that's a very low bar to clear. For general kernel code I wouldn't use it.
The 4 languages I would consider for low-level code these days are, in order:
- C/C++ (footguns galore, but lots of existing literature)
- Go (surprisingly easy to control the GC and get it to do what you want)
- Rust (the Rick & Morty of programming languages - not a compliment)
- C# (See above)