The speed comparison with a laptop is just disingenuous. Is a device with Cheri integrated slower than one of the same class without?
The speed comparison with a laptop is just disingenuous. Is a device with Cheri integrated slower than one of the same class without?
Here's the difference with Fil-C: It's totally memory safe and fanatically compatible with C/C++, no B.S. The other approaches tend to either create a different language (not compatible), or tend to create a superset of C/C++ with a safe subset (not totally memory safe, only compatible in the sense that unsafe code continues to be unsafe), or just don't fully square the circle (for example SoftBound could have gotten to full compatibility and safety, but the implementation just didn't).
> The speed comparison with a laptop is just disingenuous. Is a device with Cheri integrated slower than one of the same class without?
Not disingenuous at all.
The issue is that:
- high volume silicon tends to outperform low volume silicon. Fil-C runs on x86_64 (and could run on ARM64 if I had the resources to regularly test on it). So, Fil-C runs on the high volume stuff that gets all of the best optimizations.
- silicon optimization is best done incrementally on top of an already fast chip, where the software being optimized for already runs on that chip. Then it's a matter of collecting traces on that software and tuning, rather than having to think through how to optimize a fast chip from first principles. CHERI means a new register file and new instructions so it doesn't lend itself well to the incremental optimization.
So, I suspect Fil-C will always be faster than CHERI. This is especially true if you consider that there are lots of possible optimizations to Fil-C that I just haven't had a chance to land yet.
That's exactly the kind of thing that the boosters of all those previous efforts said. But somehow it never quite worked out.
Language implementations get faster over time and young ones tend to be slow. The Fil-C implementation is young. So were all of the previous attempts at memory-safe C - usually an implementation that had years of at most a few person years of investment (because it was done in an academic setting). Young implementations tend to be slow because the optimization investment hasn't happened in anger. So, "past academic attempts were slow" is not a great reason to avoid investigating memory safe C.
Performance focus is not the reason why all of the world's C/C++ code gets written. Maybe that's even a minority reason. Lots of stuff uses C/C++ because of reasons like:
- It started out in C/C++ so it continues to be in C/C++. So many huge projects are in this boat.
- You're critically relying on a library whose only bindings are in C/C++, or the C/C++ bindings are the most mature, or the most easy to use.
- You're doing low-level systems stuff, and having pointers that you can pass to syscalls is a core part of your logic.
- You want to play nice with the dynamic linking situation on the OS you're targeting. (C/C++ get dynamic linking right in a way other languages don't.)
I'd guess less than half of the C/C++ code that's being written today is being written because the programmer was thinking "oh man, this'll be too slow in any other language".
Finally, Fil-C is already faster than a lot of memory safe languages. It's just not as fast as Yolo-C, but I don't think you can safely bet that this will be true forever.
No, they really didn't. Let's review some of the big cases.
- SafeC: not based on a mainstream C compiler, so can't handle gcc/clang extensions (Fil-C can). Had no story for threads or shared memory (Fil-C does). Hence, not really compatible.
- CCured: incompatible (cannot compile C code with it without making changes, or running their tool that tries to automate the changes - but even then, common C idioms like unions don't quite work). Didn't use a major C compiler,
- SoftBound: not totally memory safe (no attempt to provide safety for linking or function calls). But at least it's highly compatible.
I can list more examples. Fil-C is the first to get both compatibility and safety right.
Has any impartial third party reached that conclusion? Because honestly the way I remember it everyone says this kind of thing when it's their own project, a lot of the people behind these previous efforts were just as confident as you are.
I don't think this is true. - D, Swift, Rust, Zig: different languages, and while they do have FFI, using it means you're only as safe as your C code - CHERI: requires hardware support to be practical - Checked C, CCured, ?SAFECode IIRC?: too expensive - AddrSan@runtime, ARM MTE, SoftBound: mitigations with too many holes
I don't know of many (to be honest, can't think of any) other serious attempts at making a system that tries to cover all three of
A) lets you write normal C
B) covers all the gaps
C) doesn't kill performance
Maybe 3/3 depending on your workload and definition of "killing performance". It's less than 2x slower for some stuff.
The good news is Fil-C is getting faster all the time, and there are still so many obvious optimizations that I haven't gotten around to.
Here's one: even just switching from gcc or msvc to clang, in projects that really want to, takes years.
Here's another one: the Fil-C compiler is really young, so it almost certainly still has bugs. Those compilers that folks actually use in anger tend to get qualified on ~billions of lines of code before anyone other than the compiler devs touches them. The Fil-C compiler is too young to have that level of qualification.
So "immediately everywhere" isn't going to happen. At best it'll be "over a period of time and incrementally".
I’ll summarize: language implementations get faster over time. Young ones tend to be slow. Fil-C is a young implementation that still has lots of unoptimized things. Also, Fil-C being 2x slower than C means it’s already faster than many safe languages. And, for a lot of C use cases perf doesn’t matter as much as the hype suggests.
The fact that young implementations are slow is something that’s worth understanding even if you don’t care about fil-C. It suggests, for example, that if someone invents a new language and their initial implementation is slow, then you can’t use that fact to assume that it’ll be slow forever. I think that’s generally a useful lesson.
I care about performance a lot and Fil-C has gotten about 100x faster since the first prototype. It’ll keep getting faster.
Getting these things to mass deployment, ticking all those little boxes, is a lot of effort. Porting a distribution to a new CPU architecture is likely easier, especially after the early stages (toolchain bringup).
Apart from that, there could be technical issues with the proposed approach. Perhaps the memory overhead? Or it might turn out that the desired performance characteristics basically require a JIT that specializes code so that typed pointers can be used where the types are known to be correct.
> It's not widely known, and it seems to be still in the research stage.
Yes on both counts.
> A lot of things in this area never really get out of that.
I hope that doesn't happen to Fil-C, but it could!
> Getting these things to mass deployment, ticking all those little boxes, is a lot of effort.
100%
I think this is one area where I'm trying to make Fil-C different than what came before it. I'm trying to tick all those little boxes. It's a lot of work!
> Porting a distribution to a new CPU architecture is likely easier, especially after the early stages (toolchain bringup).
Not sure about this. It might be true today because Fil-C hasn't yet ticked all the boxes, but the aim is definitely to be close to the cost of porting to a new CPU. It's already like that for a lot of code.
> Perhaps the memory overhead?
Heh yeah. The invisicaps cost memory. And GC costs memory.
> Or it might turn out that the desired performance characteristics basically require a JIT that specializes code so that typed pointers can be used where the types are known to be correct.
I've thought about how a JIT might help. I don't think it would. (Most of my compiler experience is writing JITs and I wrote JavaScriptCore's JITs, so I'm biased towards seeing JIT opt opportunities - and I don't see any in Fil-C right now.)
https://docs.oracle.com/en/operating-systems/solaris/oracle-...
> Here's the difference with Fil-C: It's totally memory safe and fanatically compatible with C/C++, no B.S.
Is this true on both counts? If I'm reading your docs right, you're essentially adding hidden capabilities to pointers. This is a great technique that gives you almost perfect machine-level compatibility by default, but it comes with the standard caveats:
1. Your type safety/confusion guards are essentially tied to pointer "color," and colors are finite. In other words, in a sufficiently large program, an attacker can still perform type confusion by finding types with overlapping colors. Not an issue in small codebases, but maybe in browser- or kernel-sized ones.
2. In terms of compatibility, I'm pretty sure this doesn't allow a handful of pretty common pointer-integer roundtrip operations, at least not without having the user/programmer reassign the capability to the pointer that's been created out of "thin air." You could argue correctly that this is a bad thing that programmers shouldn't be doing, but it's well-defined and common enough IME.
(You also cited my blog's writeup of `totally_safe_transmute` as an example of something that Fil-C would prevent, but I'm not sure I agree: the I/O effect in that example means that the program could thwart the runtime checks themselves. Of course, it's fair to say that /proc/self/mem is a stunt anyways.)
Fil-C totally allows pointer to integer round tripping in many cases, if the compiler can see it’s safe.
I’m citing unsafe transmute as something that Fil-C doesn’t prevent. It’s not something that memory safety prevents.
I'm having trouble seeing where the type confusion protection properties come from, then. I read through your earlier (I think?) design that involved isoheaps and it made sense in that context, but the newer stuff (in `gimso_semantics.md` and `invisicap.txt`) seems to mostly be around bounds checking instead. Apologies if I'm missing something obvious.
> I’m citing unsafe transmute as something that Fil-C doesn’t prevent. It’s not something that memory safety prevents.
I think the phrasing is confusing, because this is what the manifesto says:
> No program accepted by the Fil-C compiler can possibly go on to escape out of the Fil-C type system.
This to me suggests that Fil-C's type system detects I/O effects, but I take it that wasn't the intended suggestion.
Long answer: let's assume 64-bit (8 byte pointers) without loss of generality. Each capability knows, for each 8 bytes in its allocation, whether those 8 bytes are a pointer, and if so, what that pointer's capability is.
Example:
char* p = malloc(64);
This will allocate 64 bytes. p's capability will know, for each of the 8 8-byte slots, if that slot is a pointer and if so, what it's capability is. Since you just allocated the object, none of them have capabilities.Then if you do:
*(int**)(p + 8) = malloc(sizeof(int));
Then p's capability will know that at offset 8, there is a pointer, and it will know that the capability is whatever came out of the malloc.Hence, each capability is dynamically tracking where the pointers are. So it's not a static type but rather something that can change over time.
There's a bunch of engineering that goes into this being safe under races (it is) and for supporting pointer atomics (they just work).
https://github.com/pizlonator/llvm-project-deluge/blob/delug...
One more thing: I assume the memory allocator has a somewhat efficient way to recover the pointer to the start of the allocation from a pointer inside it, for interoperability with the legacy C world. For recompiled code, this is not used because the capability is either tracked in registers, or loaded from the shadow memory.
The capability is the start of the allocation most of the time. It might have a bit in it saying that it's an aligned allocation (indicating alignment greater than 16 bytes), in which case there's an alignment hole between the start of what the GC thinks is the allocation and where the capability starts. Also capabilities might be global, meaning that there is no need to mark them. So, to mark a capability (which the runtime calls an "object"), the algorithm is something like:
if (object->is_global)
return;
void* thing_to_mark;
if (UNLIKELY(object->is_aligned))
thing_to_mark = object & -object->alignment;
else
thing_to_mark = object;
mark(thing_to_mark); int deref(uintptr_t p)
{
return *(int*)p;
}
Does this fail unconditionally? Or is there some trick by which it can succeed if p is valid? And, if the latter is the case, then how is memory safety preserved?edit: I found zptrtable and this construct:
https://github.com/pizlonator/pizlonated-quickjs/commit/258a...
The latter seems to indicate that:
(int*)((uintptr_t)p + 1)
is at least a semi-reliable way to increment p by one. I guess this is a decent way to look like C and to keep a widely-used pattern functional. Rust’s with_addr seems like a more explicit and less magical way to accomplish the same thing. If Fil-C really takes off, would you want to add something like with_addr? Is allowing the pair of conversions on the same line of code something that can be fully specified and can be guaranteed to compile correctly such that it never accidentally produces a pointer with no capability?The pair of conversions is guaranreed to always produce a pointer with a capability. That’s how I implemented it and it’s low-tech enough that it could be specified.
(int*)(f((uintptr_t)p))
Does it matter if f is inline?Could someone implement Rust’s with_addr as:
(int*)((uintptr_t)p, addr)
FWIW, I kind of like zptrtable, and I think Fil-C sounds awesome. And I’m impressed that you were able to port large code bases with as few changes as it seems to have taken.Not sure about the semantics of with_addr. But note that you can do this in Fil-C:
char* p = ...;
uintptr_t i = ...;
p -= (uintptr_t)p; // now p is NULL but still has its original capability
p += i; // now p points to whatever i's address was, but has p's original capability
I have a helper like that called `zmkptr` (it's just an inline function that does exactly the above).https://doc.rust-lang.org/std/primitive.pointer.html#method....
As I understand it, Rust added this in part for experiments with CHERI but mostly for miri.
Interestingly, the implementation of with_addr is very similar to your code.
How do you handle cases where there are multiple possible sources of the capability? For example:
int *a = …, *b = …;
(int*)(((uintptr_t)a + (uintptr_t)b) << 2)
I’m not sure I would allow this into any code I maintain, but still. There’s also the classic xor-list, and someone has probably done it like: struct node { struct node *p; };
struct node *next(struct node *here, struct node *prev)
{
return (struct node *)((uintptr_t)here ^ (uintptr_t)prev);
}
And this needs to either result in a compiler error or generate some kind of code.Rust’s with_addr wins points for being explicit and unambiguous. It obviously loses points for not being C. And Rust benefits here from all of this being in the standard library and from some of the widely-available tooling (miri) getting mad if code doesn’t use it. I can imagine a future Fil-Rust project doing essentially the same thing as Fil-C except starting with Rust code. It might be interesting to see how the GC part would interact with the rest of the language.
If you want to be explicit about where the capability comes from, use `zmkptr`.
It is true that memory-safe C compilers have existed for decades and have seen minimal adoption.
However, improvements to clang/llvm could yield wider impact and benefit than previous efforts, since they may be supported in a widely used C toolchain.
-fbounds-safety is another change that may see more adoption if it makes it into mainline clang/llvm