“gcc will quietly break nearly half of all the packages that it compiles”
metzdowd.com
metzdowd.com
What's going on here is that the GCC developers feel free to change the generated code for behaviour undefined in the C spec. This means they might alter the behaviour of code since 40% of all packages in Debian (the "half of all packages" mentioned here) contain code whose behaviour is undefined.
But the poster seems to define any change in behaviour as "breaking" code, which is ridiculous. How much of that 40% is rare edge cases or similar, such that in reality the behaviour of the code doesn't change in practice?
The follow-up email raises this exact point, noting that in the specific example of pointer overflow:
In executions where overflow does not happen, the gcc produced binary will match the behavior of the abstract machine in the C spec.
Which means that the cited static analysis, while correctly identifying code containing UB, may not actually have an issue at runtime as the condition may never occur in practice.
The real problem is any software that truly relies on undefined behaviour for correct operation (which, I'll bet, is far less than the 40% cited here). That code is fundamentally non-portable to other compilers specifically because each compiler may produce semantically different output.
I'd bet it's well into double digits relying on undefined behaviour, and over 50% relying on unspecified behaviour. It's hard to write interesting code in C that doesn't, where interesting means code that couldn't just as profitably be written in a different language with stronger memory safety.
Programmers have a mental model of many things - in particular, 2's complement signed numbers, and the idea that pointers are just numbers indexing memory - that are not guaranteed by the C spec. Much of the reason for programming in a lower level language is to take advantage of lower level machine characteristics, like tagged pointers, unsafe unions, structs with blobs of data appended to avoid an indirection, custom memory allocators, etc., but the C abstract machine doesn't necessarily give all the guarantees required, without a lot of care and attention to details.
Deleted comment
A simple example is function argument evaluation order. If you have:
foo(bar(), baz())
A compiler is free to call these functions in either order. That means if baz relies on state mutated by bar, the program may behave differently if the compiler chooses to reorder evaluation.
If you wrote `foo(bar(), baz())` and `baz()` relies on state mutated by `bar()`, your code is bad, and you should feel bad, because experiencing those bad feelings is the way you learn to not write bad code. This code was wrong before the compiler reordered the calls, it just failed silently for a while. The compiler isn't responsible for fixing your bugs, you are.
People need to stop expecting other people to fix their problems.
That's it. Simple education.
In the context of this post I think that's a good thing because not everyone will understand the topic.
You, however, seen to have read some sort of agenda in the question, which I find a little baffling...
Certain things are unspecified. It can call in any order for example.
Other things are undefined. If you do them your program is no longer valid at all, and can crash or corrupt.
Theoretically "stop doing that" works... but history has shown that it really doesn't work in practice, in C, in programming more broadly, and, really, in any human endeavour ("planes don't need safety procedures, just stop making mistakes").
foo(baz(bar()))This is, of course, a rule of thumb, not a hard law. Some exceptions:
1. I think it's okay (and in fact, idiomatic in C) to mutate state and return some sort of information about what occurred (i.e. a success flag, a number of characters written, etc.).
2. Isolated mutations such as logging sometimes make sense in an otherwise pure function.
return foo().baz().bar();
With flags you start with success/fail, and end up with HRESULT. lock_two_widgets(widget *a, widget *b) {
if (a < b) {
lock(&a->lock);
lock(&b->lock);
} else {
lock(&b->lock);
lock(&a->lock);
}
}Edit: Your code has undefined behavior, unless the two pointers point to the same object, which is unlikely in a realistic example.
It is defined behavior to read the bytes of any object using unsigned char.
I have yet to see a thorough (source-to-compiler-intent-to-assembly) comparative description of undefined, unspecified and implementation-defined behaviors, though (not just a somewhat insightful blog, or techno gospel, which the C and C++ standards are).
But yes, authoring a book titled "Well-defined C (and C++)" one day would be awesome.
Thanks for the link.
- Some paper says that 40% of Debian packages have undefined behavior in them.
- gcc's optimizer is sometimes unforgiving w.r.t. undefined behavior (see also: strict aliasing), changing the intended meaning of the code.
- Therefore, it breaks 40% of packages.
And boom, there's your clickbait headline...
> I have worked on many programs, and whenever I found such a problem (typically called a "portability problem"), where the code was assuming something that the language did not guarantee, I fixed the program rather than bitching about the compiler.
A C compiler, meanwhile, has to aggressively take advantage of undefined behavior to get good performance, and the C specification has been keeping behavior undefined for the benefit of compilers.
You can hope that you find all such problems in C (which you might not) and "fix the program", but you can also "fix the program" by switching to a better language.
The real problem here is competition. Gcc is in a competition with clang to produce fast code which makes the gcc developers feel justified when they exploit undefined behaviours for marginal optimizations.
This is a case of following the letter of the law (in this case the C standard) while disregarding its spirit: all the undefined behaviour was so that C compilers could accomodate for odd architectures while remaining close to the metal, not so that compiler programmers could go out of their way to turn their compiler into a mine field.
For example, if I recall correctly the popular Opus codec overflows signed integers when decoding invalid data, and so long as this can be guaranteed to produce some (possibly implementation-specific) result this is perfectly safe. However, this is technically undefined behaviour - a particularly malevolent optimising C compiler could decide to give the sender of the data arbitrary code execution, because it's allowed to do whatever it likes. This might even make the code run faster, but it'd make decoding Opus correctly and safely slower because the decoder would have to do a bunch of gratuitous overflow checks on operations it could otherwise just let overflow. Fortunately, gcc hasn't reached that level of advanced malevolence yet.
That's true, but "producing the fasted code that actually achieves the intended goal" is not a problem which can be solved by a compiler--the compiler can't read your mind.
Attempting to represent a reasonable approximation of reading your mind is the responsibility of the specification. You can definitely do better than C, but I doubt you could have done better 4 decades ago when C was designed.
People are far too keen to assume that because the standard leaves it undefined, and "undefined" sounds a bit here-be-dragons, it's therefore inevitable that undefined behaviour has to be some nasty creepy ugly thing. That it renders your entire program instantly meaningless. That it's a perfect excuse for the compiler to look at your program and turn it into something completely different. But... it doesn't have to be.
I don't know why people don't treat handling of undefined behaviour as a quality of implementation issue, rather than just rolling over and letting gcc make their lives worse.
Mandatory links: http://blog.metaobject.com/2014/04/cc-osmartass.html, http://robertoconcerto.blogspot.co.uk/2010/10/strict-aliasin...
Computers don't have spirits; they work as you tell them to work, to the letter, and if you're remaining close to the metal, your language will indicate that fact. Optimizing undefined behaviors doesn't make C a minefield; low-level programming for different architectures just is inherently a minefield. C was a minefield before these optimizations were added.
Rust is extremely impressive because they've found so many ways to do high-level programming while maintaining low-level performance. But they can only do that because they have the benefit of the 4 decades of programming language research that have occurred since the basics of C were designed.
But standards committee do.
>Rust is extremely impressive because they've found so many ways to do high-level programming while maintaining low-level performance. But they can only do that because they have the benefit of the 4 decades of programming language research that have occurred since the basics of C were designed.
I doubt rust could be ported to a 8bit PIC microcontroller, or to a 6502 keeping reasonable performance characteristics or letting the programmer take advantage of the platform quirks. It's not just "4 decades of programming language research" it's also that it's intended to work only on "modern" processors.
> But standards committee do.
Agreed. Which is why you should choose a language which was standardized by a standards committee whose goals better align with your goals.
> I doubt rust could be ported to a 8bit PIC microcontroller, or to a 6502 keeping reasonable performance characteristics or letting the programmer take advantage of the platform quirks.
I don't think that's true; I think that the current state of Rust tools is such that this is true now, but it's nothing inherent to the design of the language, and I think you'll be able to do quite a bit with Rust in the situations you describe when the tools around Rust are more mature. I can't really speak to this more because I'm not sure why you think this can't be done.
I don't believe this is correct. Most of why Rust avoids UB is that it uses static types much more effectively than C does. Static types are an abstraction between the programmer and the compiler for conveying intent, that cease to exist at runtime. So the runtime processor architecture should be irrelevant.
For instance, in C, dereferencing a null pointer is UB. This allows a compiler to optimize out checks for null pointers if it "knows" that the pointer can't be null, and it "knows" that a pointer can't be null if the programmer previously dereferenced it. This is, itself, a form of communication between the programmer and the compiler, but an imperfect one. In Rust, safe pointers (references) cannot be null. A nullable pointer is represented with the Option<T> type, which has two variants, Some(T) and None. In order to extract an &Something from an Option<&Something>, a programmer has to explicitly check for these two cases. Once you have a &Something, both you and the compiler know it can't be null.
But at the output-code level, a documented compiler optimization allows Option<&Something> to be stored as just a single pointer -- since &Something cannot be null, a null-valued pointer must represent None, not Some(NULL). So the resulting code from the Rust compiler looks exactly like the resulting code from the C compiler, both in terms of memory usage and in terms of which null checks are present and which can be skipped. But the communication is much clearer, preventing miscommunications like the Linux kernel's
int flags = parameter->flags;
if (parameter == NULL)
return -EINVAL;
Here the compiler thinks that the first line is the programmer saying "Hey, parameter cannot be null". But the programmer did not actually intend that. In Rust, the type system requires that the programmer write the null check before using the value, so that miscommunication is not possible.There are similar stories for bounds checks and for loops, branches and indirect jumps and the match statement, etc. And none of this differs whether you're writing for a Core i7 or for a VAX.
> or letting the programmer take advantage of the platform quirks.
I'm not deeply familiar with that level of embedded systems, but at least on desktop-class processors, compilers are generally better than humans at writing stupidly-optimized code that's aware of particular opcode sequences that work better, etc.
(There are a few reasons why porting Rust to an older processor would be somewhat less than trivial, but they mostly involve assumptions made in the language definition about things like size_t and uintptr_t being the same, etc. You could write a language with a strong type system but C's portability assumptions, perhaps even a fork of Rust, if there were a use case / demand for it.)
Did rust find a way to defeat the halting problem and push all the array bound checks to compile time? How well does rust deal with memory bank switching where an instruction here makes that pointer there refer to a different area of memory?
I don't understand why the halting problem is relevant to this conversation. Just about all practical programs don't care about the halting problem in a final sense, anyway; see e.g. the calculus of inductive constructions for a Turing-incomplete language that lets you implement just about everything you actually care about implementing.
The halting problem merely prevents a program from evaluating a nontrivial property of another program with perfect accuracy. It does not prevent a program from evaluating a nontrivial property (bounds checks, type safety, whatever) with possible outputs "Yes" or "Either no, or you're trying to trick me, so cut that out and express what you mean more straightforwardly kthx."
This realization is at the heart of all modern language design.
> How well does rust deal with memory bank switching where an instruction here makes that pointer there refer to a different area of memory?
This problem boils down to shared mutable state, so the conceptual model of Rust deals with it very well. The current Rust language spec does not actually have a useful model of this sort of memory, but it would be a straightforward fork. As I said in my comment above, if there was interest and a use case for a safe language for these processors, it could be done easily.
for (int i=0; i <= N; i++) func();
Most processors have special support for looping a fixed number of times, e.g. "decrement then branch if zero." If overflow is UB, the compiler can use this support.But if overflow returns an implementation defined value, then it is possible that N is INT_MAX and the loop will not terminate. In this case the compiler cannot use the fixed-iteration form, and must emit a more expensive instruction sequence.
The real problem of course is that C requires the programmer to obscure their intent by messing with a counter variable. Is there really no "loop exactly N times" construct in the language?
I think choosing Rust might be a reasonable way to avoid the problem in the first place, so I agree with you there, but there are also reasons to choose C over Rust. Personally, I write a lot of C code because I prefer to build on a GPL stack. This is one of the reasons I'd like to see Rust added as a GCC language. Sadly I don't have the time to do it myself.
Go and Rust only have single implementations. The specification for both are very brief. Are you claiming that a clean box implementation of Go and Rust would always behave identically?
I have only one thing to say: I clicked through the Golang spec for 30 seconds and found this: https://golang.org/ref/spec#Run_time_panics
> The exact error values that represent distinct run-time error conditions are unspecified.
Oh, what's that? Undefined behavior? In the golang spec?!
Unspecified behaviour is usually intentional ambiguity either to give wiggle room for an optimizer, or to accommodate platform variance. Writing a program with that invokes unspecified behaviour isn't normally a problem, as long as you're not relying on a specific result. Order of argument evaluation is a common example.
Relying on undefined behaviour is almost always bad, and almost always avoidable. That's where the nasal demons come from. Dereferencing null, alias-creating pointer typecasting, etc.
For instance, if you have a bool in C++, the only defined behavior is for it to contain 0 or 1. If you somehow force the memory cell to contain 2 or 255 or anything else, you have triggered undefined behavior. This means the compiler never has to check for it. If it's faster for the compiler to implement, say, `if (b) x[1] = a; else x[0] = a;` as `x[b] = a`, it can do that. Even though the `b == 2` case might lead to a buffer overflow, the UB rules means that, as far as the compiler cares, the `b == 2` case cannot exist. If `x.operator[]` is a function that does a bounds check, the compiler can inline it and remove the bounds check. And so forth.
Hence, the rule that on encountering UB, the compiler may do anything. It is not so much that the compiler is intentionally doing anything, as that it is outputting code that assumes the UB can't happen. If the compiler chooses to implement an `if (b)` via a computed jump, the `b == 2` case may land on some completely ridiculous code to start executing. It is not that the compiler wants to land there to punish you, it's that the compiler's only responsibility is to make sure the `b == 0` and `b == 1` cases work.
What you're pointing to is unspecified values. This means that the implementation can return any value, but must actually return some coherent value. If a C++ function returning bool returns an "unspecified value", it is still only returning either 0 or 1. You can act with the resulting bool as if it is in fact a bool. There are no optimization gotchas to worry about. It's just that you don't know what it is.
For the particular case here, when you're inspecting a runtime error, the only useful thing to do with it is to call the Error() method from the error interface. The language guarantees you that it will work (i.e., you have defined behavior, that you have an object that indeed implements the error interface). It doesn't give you any particular guidance on exact error values or the resulting string. But that's no more "undefined behavior" than the result of reading from a file is "undefined".
The entire reason people care about undefined behavior is the fact that compilers can do arbitrarily-stupid(-seeming) things when it is present, which is solely because compilers want to optimize. In the case of unspecified values, there is nothing to optimize.
(Anyway, I am not a Go programmer at all. Maybe there is actually UB somewhere in safe Go. But what I have seen is not it.)
For example, consider an expression like (x*2)/2. clang and gcc will both optimize this to just x, but Go and Rust will actually perform the multiplication and division, as required by their overflow semantics. So the performance vs safety tradeoff is real.
In fact, there is "secret sauce" here. The secret sauce is that Rust treats integer overflow specially: in debug mode (the default compilation mode), integer overflow is checked and will result in a panic. In release mode, integer overflow is unchecked. It's not "undefined behavior" in the C sense, because of the fact that it always returns a value--there are no nasal demons possible here (Rust disallows UB in non-`unsafe` code entirely, because memory unsafety is a subset of nasal demons). The exact value that it returns is unspecified, and the language provides no guarantee of backward compatibility if you rely on it (it also provides explicit wrapping arithmetic types if you explicitly desire wrapping behavior). And though it's a potential correctness hazard if you don't do any testing in debug mode, it's not a memory safety hazard, even in the unchecked release mode, because Rust's other safety mechanisms prevent you from using an integer like this to cause memory errors.
pub fn test(x: i32) -> i32 {
let y = (x*2)/2;
y
}
and you'll see the difference (e.g. compare against a similar function on https://gcc.godbolt.org/ ).> The secret sauce is that Rust treats integer overflow specially
Debug vs. release mode is irrelevant: the panicking case is more expensive than any arithmetic, and, the RFC[0] was changed before it landed:
> The operations +, -, [multiplication], can underflow and overflow. When checking is enabled this will panic. When checking is disabled this will two's complement wrap.
The compiler still assumes that signed overflow may happen in release mode, and that the result needs to be computed as per two's complement, i.e. not unspecified.
[0]: https://github.com/rust-lang/rfcs/blob/master/text/0560-inte...
I don't understand the point of specifying wrapped behavior in unchecked mode rather than leaving the value unspecified. Surely we don't care about properly accommodating use cases that will panic in debug mode.
You're right that C requires UB to optimize things like (x2)/2. My argument is that Go and Rust's secret sauce is you wouldn't have to write things spiritually similar to that in the first place. (x2)/2 is a bad example, since nobody would write that on purpose, but loops are a great one. C's only interface to elements of an array is a pointer. So C has to say that accessing out-of-bounds pointers are UB, as is even having a pointer that's neither in-bounds or right at the end, because it simply has no way to distinguish "pointer" from "pointer that is in-bounds for this array". The type of an array iterator in Rust (and I think also Go) carries knowledge of what array it's iterating over, and how far it can iterate; since it's part of the type system, the compiler has access to that knowledge. So you can just say `for element in array`, and the compiler knows that you're only accessing in-bounds pointers, and generate the same code C would, without needing to define a concept of UB.
Of course if you do generate pointers in unsafe code, you are subject to UB, same as in C. Rust and Go simply give you a language where most of the time, you don't need to reach for constructs that require UB to be performant.
(There is, however, an actual bit of secret sauce, at least in Rust but I suspect Go has an analogue: Rust's ownership system allows it to do far stronger alias analysis than even C's -fstrict-aliasing, without the risk of false positives -- you cannot construct overlapping mutable pointers in safe code.)
(To be fair, I haven't written any Go because my personal use cases involve things like shared libraries and C-ABI compatibility, so I'm going off what I've heard about Go, not personal experience. But out of what I've heard about Go, it's a fine language for this purpose, because the requirement here is just portability to all Debian architectures and comparable performance, and whether GC is used is an implementation detail.)
Rather than rehashing the arguments for and against it, I'd really recommend anyone interested to read these articles:
http://blog.llvm.org/2011/05/what-every-c-programmer-should-...
The modern version of this seems to be:
"Mr. Babbage, I put the wrong figures into the machine and the wrong answers came out! Please fix it this, this has security implications!"
You can't reasonably expect the compiler to make your insecure code secure.
Calling them "language lawyers" is some entitled crap. GCC commits to implement the specification of the language. Expecting them to maintain some huge number of undefined behaviors is literally expecting them to do something they never said they would do and couldn't do even if they said they would.
int *b = &a->something; // pointer arithmetic, doesn't dereference a.
if(a == NULL) return 0;
else something_critical = a->somethingelse;
However, some non-Unix address models that Linux doesn't support don't permit pointer arithmetic on NULL pointers. So the ANSI C standards committee declared it undefined. Which means that gcc can - and eventually did - eliminate the NULL pointer check. This has resulted in privilege escalation vulnerabilities in Linux that didn't exist until gcc decided to optimise the code, some of them quite well-hidden.Also, I should note that a lot of code - particularly the Linux kernel - isn't actually using ANSI C anyway. They're using a superset of it with gcc extensions and they have a whole bunch of architecture-specific code too.
I'd quip that this is statistically impossible for a sufficiently large codebase.
> it's a criticism of ANSI C. If you don't like it, use a better language.
This is my basic stance. However, if I'm e.g. in a situation where I have a C or C++ codebase I can't afford to rewrite from scratch, I'd like to use a "Better C" compiler, where "Better C" is a slightly less bad version of "ANSI C" - some undefined behavior removed, for example.
As shorthand, I'll generally refer to compilers for "Better C" as "Good C Compilers".
GCC is not trying to be a Good C Compiler. They've decided these things aren't their problem. Which is... fair. That's their choice. I do not for one minute pretend to understand that choice however - and it gives me yet one more reason to switch to a Good C Compiler.
"POSIX says this is fine, so any application that expected this behaviour is already broken by definition. But this is rules lawyering. POSIX says that many things that are not useful are fine, but doesn't exist for the pleasure of sadistic OS implementors. POSIX exists to allow application writers to write useful applications. If you interpret POSIX in such a way that gains you some benefit but shafts a large number of application writers then people are going to be reluctant to use your code. You're no longer a general purpose filesystem - you're a filesystem that's only suitable for people who write code with the expectation that their OS developers are actively trying to fuck them over."
GCC doesn't, or at least shouldn't, exist to implement the ANSI standard. It exists to help people to write useful programs.
Also, this is not just GCC problem, all the existing C compilers have the issue to some extent. After all, STACK (the MIT tool to detect undefined behavior) is based on clang. And ICC exploits the same UB tricks AFAIK.
I'm not arguing that GCC should violate the ANSI standard; rather it should provide additional guarantees above the what ANSI requires (which was always the intent of the standard; the standard defines the absolute minimum that cross-platform programs can depend on, the reason so much is undefined is to allow compilers to have their own strategies for what should happen in those cases, not to require that compilers blow up in those cases). Honestly I think the ANSI side of things is a red herring; when given the option of some change that will slightly improve performance on some benchmarks, but make a lot of user code silently fail, a responsible developer should know to reject that change whether or not that change violates some standard.
> Also, this is not just GCC problem, all the existing C compilers have the issue to some extent.
The post is claiming that GCC is the worst of them. Certainly my impression is that clang is substantially less aggressive at exploiting UB; I don't know ICC well enough to comment.
The problem with that approach is that it introduces dependency on the compiler. The original code was ANSI C and thus should compile fine on all compilers compatible with ANSI C, the new code is not as each compiler will decide to handle undefined behavior differently. Either you'll make the exact compiler a hard dependency (i.e. it always has to be compiled with gcc and fails to build with everything else), or it will produce "correct" binaries on some compilers and "incorrect" binaries on others. That's hardly an improvement.
The only way out of this is either to abandon C and use a language with stronger guarantees, or make the ANSI C more strict by adding the guarantees to the standard. Which is not going to happen, I guess.
> The post is claiming that GCC is the worst of them. Certainly my impression is that clang is substantially less aggressive at exploiting UB; I don't know ICC well enough to comment.
GCC is also the most widely, so people tend to spot issues more often.
All this "problem" is a direct consequence of using C without really understanding what guarantees it does and does not provide, and instead driving by a simplified model of the environment. And then getting angry that the simplified model is not really correct.
Except 40% of the original code already wasn't ANSI C.
> Either you'll make the exact compiler a hard dependency (i.e. it always has to be compiled with gcc and fails to build with everything else), or it will produce "correct" binaries on some compilers and "incorrect" binaries on others. That's hardly an improvement.
Having code that was broken under GCC not be broken under GCC absolutely is an improvement, particularly since in fact this kind of code often works on every other extant compiler.
> make the ANSI C more strict by adding the guarantees to the standard. Which is not going to happen, I guess.
Standards tend to codify existing practice. There's no reason the standard couldn't be made stricter - but the way we get to there from here is if the major compilers implement stricter restrictions and can show that they can be implemented consistently and users find them useful. GCC has been willing to do that kind of innovation for other parts of the standard.
Then why complain that ANSI C compiler gets confused by it?
> Having code that was broken under GCC not be broken under GCC absolutely is an improvement, particularly since in fact this kind of code often works on every other extant compiler.
No, the code does not work on every other compiler. And if it is, there's no guarantee it will stay like that.
> Standards tend to codify existing practice. There's no reason the standard couldn't be made stricter - but the way we get to there from here is if the major compilers implement stricter restrictions and can show that they can be implemented consistently and users find them useful. GCC has been willing to do that kind of innovation for other parts of the standard.
AFAIK some of the limitations are there because of non-traditional platforms - some of them may be a thing of the past so removing them would be OK, but some are not (and thus won't be removed from the standard). And one of the points of ANSI C (and POSIX) is to define global guarantees, not per-platform ones.
Because I didn't ask for an ANSI C-and-not-a-penny-more compiler. Nobody wants that. Back in the day the GNU project made a point of going against standards when the standardized behaviour was user-unfriendly (POSIX_ME_HARDER etc.)
> No, the code does not work on every other compiler.
In many of these cases it does work on all other major compilers, or all other relevant platforms for that particular codebase.
> And if it is, there's no guarantee it will stay like that.
So what? That doesn't make it better to break it now.
> one of the points of ANSI C (and POSIX) is to define global guarantees, not per-platform ones.
Which is why it's GCC's (or any other compiler's) responsibility to define the per-platform guarantees.
I guess you could group this kind of thing into the category of "dead code elimination" which is useful, but results in parts of the code written not producing the specified executable. I have to think on this example more.
It's the same as inlining a function. The standard says that a function call is a function call. Compilers are still allowed to inline the call, even if it has not been specifically marked as "inline".
Most of us don't care about security issue too much when using C/C++. We do use it for performance, and use it mostly locally.
GCC is a very versatile code. It's ok that it makes secure code difficult to write because it's not what most of us is doing. Not being completely secure is ok, not being optimized is not.
There's also nothing wrong with writing a C program that rests on a base of POSIX, or the GNU system, and requires stronger guarantees than C alone provides.
That's not the purpose of writing in C. It's a goal you might be able to achieve with C, but I'm not sure why that's your goal, and it's certainly not the goal of everyone who writes C. I think more people who write C do so with the coal of producing programs that run fast or with a minimal memory fingerprint.
Compile your test suite with -fsanitize=undefined.
"40% of Debian packages might break if GCC changes the way it handles undefined behavior"
...always?
> "40% of Debian packages might break if GCC changes the way it handles undefined behavior"
A lot of them are silently broken today. That's the scary part.