7 ms·
UndefinedBehaviorSanitizer's Unexpected Behavior
- pistoleer 2y agoMan rants about not expecting weird type system abuse that works on his machine to be undefined behavior
- kleiba 2y agoThis is not a rant, but a well laid-out description of something a widely used software ran into as a result of a change in the compiler they use. The #ifdef they had in place was a bit hackish, but I wouldn't call it abuse. It is a typical construction for C to do stuff like that which in general isn't without risk, but they have used it without any problems for years. The whole point of the blog post is to start a discussion whether sth. like this should rightfully be flagged as "unexpected behavior" or not.
- pistoleer 2y agoIt's not well laid out. The examples are malformed/illegal and the ifdef thing is stupid. The author admits to not being a C undefined behavior expert and yet acts like they might know better than a tool made by such experts. Looking up the rules and verifying the shown snippets takes at most 30 minutes at a leisurely pace, the author could have saved themselves the embarrassment. I'm not going to write a blog post about how I didn't expect a color spectrometer pointed at the sky to say "BLUE" because I thought it might have been purple, "although I'm not an expert in wave lengths".
- mort96 2y agoThe ifdef thing certainly isn't "stpuid". It's not good design, you wouldn't design it that way if you made libcurl from scratch today, but it makes sense as a solution to the problem of, "we can't change the type of CURL* in the public API, but internally, it ought to be defined as a pointer to a struct". If it was well-defined behavior, it would probably have been the best solution possible given the constraints.
- mnw21cam 2y ago> The author admits to not being a C undefined behavior expert At this stage, I would seriously doubt the credentials of anyone who claims to be a C undefined behaviour expert. Saying "I'm not a C UB expert" is just a realistic acknowledgement that UB is hard and we will get it wrong at some point without realising. The approach of having an automated tool tell you when UB is present is very sensible.
- aragilar 2y agoAnd apparently worked the last 28 or so years on loads of other machines (I think the question would be where doesn't curl run)?
- nwellnhof 2y agoCasting function pointers like this can break Emscripten [1]. In libxml2, I fixed all these issues 7 years ago. It can be painful, but "it worked for 28 years" is not an excuse. [1] https://emscripten.org/docs/porting/guidelines/function_pointer_issues.html https://emscripten.org/docs/porting/guidelines/function_poin...
- ctz 2y agoI believe AIX C++ name mangling includes function argument type information (with CV qualifiers!) so this is a real-world case where this does actually break. I suspect curl does not compile with the C++ compiler though.
- School-Cotton 2y agoSo does the Itanium ABI (which is what most people would think of as the normal/standard/usual C++ ABI): $ c++filt _Z1fPFvPcE f(void (*)(char*)) But I'm struggling to understand how this would cause things to break.
- flohofwoe 2y agoI guess only if you directly expose C++ APIs in DLLs, which is a bad idea anyway.
- flohofwoe 2y agoIt's literally the opposite of "works on my machine" because it's in curl (which is most likely the single most widely deployed code base in the world).
- gtaena 2y agoHow would you implement objects and inheritance in C without function casts? CPython certainly uses these casts. dlysm() even relies on a (void *) cast that is not C standard compliant. C is useless for certain applications with this "undefined behavior".
- pjmlp 2y agoThat is the thing, people keep treating it as a portable macro assembler, when it stopped being so decades ago, and those folks haven't yet got the memo.
- pistoleer 2y agoUse a generic function signature that takes in a `void*`. Inside the specialized function bodies, cast the void* to an `actual_type*`, then dereference.
- rom1v 2y ago> This construct works perfectly fine in C Intuitively, I would say that this is actually undefined behavior (it would probably be difficult to expose a wrong behavior in practice though). In C specs, I found 6.5.2.2, paragraph 9: > If the function is defined with a type that is not compatible with the type (of the expression) pointed to by the expression that denotes the called function, the behavior is undefined. We might discuss whether void (*)(char *) is "compatible" with void (*)(void *) but I think it isn't, since: void target(void *ptr) {} void (*name)(char *ptr) = target; fails to compile with the error message: initialization of ‘void (*)(void *)’ from incompatible pointer type ‘void (*)(char *)’ The compiler explicitly says "incompatible pointer type". Same for: void target(char *ptr) {} void (*name)(void *ptr) = target;
- pistoleer 2y agoIt's worse than that. This guy takes a void* function and casts it to a char* function, then passes it a char**. void (*name)(char *ptr); typedef void (*name_func)(char *ptr); void target(void *ptr) { printf("Input %p\n", ptr); } char *data = "string"; name = (name_func)target; // Illegal: casting fn that takes void* to a fn that takes char* name(&data); // Illegal: passing a char** into a function that takes char* Before someone mentions qsort(): the comparator function really is supposed to take a void*, and inside the function, you re-cast the void* argument to a pointer type of your desire. If you don't do it in that order, you're using it wrong.
- rom1v 2y ago> name(&data); // Illegal: passing a char* into a function that takes char* I assume this is just a typo in the article. He probably meant `name(data)`.
- trealira 2y agoIronically, in K&R, they did exactly this for casting comparator functions for their own version of qsort. /* declarations */ void qsort(void *lineptr[], int left, int right, int (*comp)(void *, void *)); int numcmp(char *, char *); /* the offending line */ qsort((void **) lineptr, 0, nlines-1, (int (*)(void*,void*)(numeric ? numcmp : strcmp));
- simonask 2y agoAwesome writeup. Always interesting to read what Daniel has to say. I think the fact that it turned out that he was wrong (and UBsan was right, as usual) is a great testament to the shortcomings of C. Lots of people - both inexperienced and very experienced - celebrate it for being "simple" and "close to the hardware", but the truth of the matter is that it is precisely not close enough to the hardware for people who _know_ what the hardware is doing to be able to do what they expect, and it's too close to the hardware to be able to be able to ignore it. Lots of experienced C programmers (and - guilt by association - C++ programmers as well) run into UB because they have clear expectations of the compiler. I.e., they know what the compiler should generate, more or less, and C is just a convenient notation. But compilers don't live up to those expectations, because they don't actually compile your code for the hardware. They compile it to the virtual machine abstraction defined by the standard, which very often works differently from any real architecture, and then translate that into machine code. Even though there is basically a single set of semantics that every single "relevant" (mainstream) architecture implements. This is a holdover from when C had to target architectures that are 100% irrelevant today. Everybody's favorite example is signed integer overflow. In both x86-64 and ARM64, that just works - two's complement is the only relevant implementation, so there's no issue. But `int` in C and C++ is not that. Almost every single common UB pitfall has reasonable behavior at the assembler level for every mainstream architecture, and almost every single niche architecture. C gives you the illusion of being close to the hardware, but in actual reality the hardware is several steps removed, so if you want to leverage your knowledge of the hardware, calling conventions, assembly, or other low-level details, you have to go out of your way to work around the C standard. (Aside: We need new languages to tackle this, and I coincidentally happen to like Rust. Lots of people coming from C or C++ are irritated and frustrated by Rust, but 99% of the time it's because Rust gives you a compile error where C would give you UB. This is one example of that out of thousands.)
- veltas 2y agoDoes anyone know of languages that achieve this? I'm interested, I'm currently implementing a project in x86 assembly for this reason, and am happy to try a higher level language.
- baq 2y agoThey say Rust is much harder than C for... disallowing these kinds of things?
- pistoleer 2y agoFor the same reason python is seen as easier: guardrails and checks are just an impediment right?
- account42 2y ago> In 2016 I wanted to change the type universally to just typedef struct Curl_easy CURL; … as I thought we could do that without breaking neither API nor ABI. This seems to be the obvious solution and how most libraries define their opaque handles. It doesn't break a guaranteed API and it doesn't break the ABI any more than than only using it when building the library - and you can check that it doesn't break the ABI on any platform where you want to guarantee ABI stability.
- mort96 2y agoIn the real world, we often want to avoid breaking people's code even when people rely on something that's not guaranteed by the API docs. It seems like Daniel's goal isn't to only be API-compatible in a technical sense (namely that perfectly written code which carefully avoids using anything in a way that's not explicitly guaranteed to work), but rather to avoid breaking people's existing code. I can respect that.
- account42 2y agoYes but that is always a balancing act if you want to make any change at all since users can theoretically depend on any possible implementation detail or even on outright bugs.
- mort96 2y agoCorrect. It's always a balancing act. That means hard rules like "we will do any change which doesn't technically break any promises explicitly made in the documentation in a patch release" aren't appropriate, it's always a value judgement (and sometimes you get it wrong and revert the change once you realize that people were affected more than expected, as happened in the case of curl).
- badmintonbaseba 2y agoYes, it's very much undefined behavior. As I recall, GTK's glib does this all over the place for signals. edit: I'm not advocating that this being UB is fine. I don't expect compilers to exploit this for optimization, because so many projects rely on this working. There might be room to extend compatible function types to make this defined.
- quelsolaar 2y agoThis is one of the very rare cases in C where something is technically Undefined Behaviour, but in practice works and is recommended. The typedef struct trick, is very common idiom that creates _more_ safety, not less. All reasonable compilers should (and do) support this. It is sad that the ISO standard is not in line with reality at all times. (I say that as a member of the wg14 and the UB study group) I Recommend Daniel keep his typedef struct definition, and then have an ifdef to revert to the void definition for when Clang does its UB sanitizer. While checking for prototype discrepancies is a very good thing to automate, Clang should add an exception for this.
- im3w1l 2y ago> very rare cases in C where something is technically Undefined Behaviour, but in practice works and is recommended. In the bad old days, such cases were very common. I think complaining about it is a part of the process that slowly resolves it.
- quelsolaar 2y agoLots of code has been broken because people have written UB code and then optimzers become smarter and vreak the code. This category of we call ”usless UB” are things that wont help optimizeayions, and all major compiler have to support beacause if they dont, their users complain too much, can be relied on. Unfortunately its very hard for the average use to know what UB can be relied on (there isnt much).
- School-Cotton 2y ago> I say that as a member of the wg14 and the UB study group Since you have experience in this area, do you know how likely it is that something like this could be resolved? I.e., if someone proposed "just make this defined", how likely would the C standards body be to agree and do so?
- quelsolaar 2y agoThese things can be fixed and often are. Some one just needs to write a paper proposing a change. I might in fact do so on this issue. It should be resolvable.
- Someone 2y agoI would think the proper way to do this would be #if defined(BUILDING_LIBCURL) struct Curl_easy { … } #else struct Curl_easy; #endif typedef struct Curl_easy CURL; If BUILDING_LIBCURL isn’t defined that tells code “CURL is identical to a struct named Curl_easy”. If it is defined, that also tells code what fields it has.
- gpderetta 2y agoInteresting problem. The typical solution in C++ to deal with type erasing function pointer types is to go through a trampoline function: struct X {}; void use_x(X*); using F = void(void*); void bar(F* fn, void* y) { fn(y); } template<class T, auto fn> void trampoline(void*arg) { return fn(reinterpret_cast<T*>(arg)); } X x; bar(&trampoline<X, use_x>, &x); In plain C there is no way to generate the trampoline at the point of use in the same way template instantiation works, but it can be generated by a macro at global scope.
- olliej 2y agoOk, this is UB, calling a function pointer through a different type than its definition is a pretty clear example of UB. The problem here is that there's a confusion between "void * and char * are implicitly convertible in C" and "void * and char * are the same". The latter is true for many platforms (especially older ones) but not all (I think there were platforms where functionally they had `typedef char void`). There's a side note of conflating "this has defined behavior on my platform that is stable and works for me" and "it's not UB if it's stable and works on a platform", just like integer overflow is UB despite being entirely defined behavior on every platform under the sun. Anyway, if folk are curious there are many platforms where not only can the representation of `void()(void)` and `void()(char)` be different - even if pointing to the same function - but the representation of even just the data pointers void* and char* may not be the same, again while pointing to the same memory. For example, on platforms with pointer authentication function pointers are generally (I would say "always" but in principle it can be avoided) signed, and in some configuration the type of the function is incorporated into the function. Calling the function pointer requires authenticating the pointer, and authenticating the pointer requires that the call site agrees on the type of the pointer because otherwise the signature fails. Absent actual pointer auth hardware you could imagine someone implementing this as some kind of monstrosity like this (very hypothetical, unpleasant, and footgun heavy) horror: #define SIGN_FPTR(fptr) (typeof(fptr))(((uintptr_t)fptr)|(MAGIC_HASH(stringify(typeof(fptr)) << some_number_of_bits)) #define AUTH_FPTR(fptr) (typeof(fptr))(((uintptr_t)fptr)^(MAGIC_HASH(stringify(typeof(fptr)) << some_number_of_bits)) and you can immediately see that if you had code that used these but disagreed on the type of the function you'd have a bad time. With compiler+hardware pointer auth this is just handled transparently. In principle a pointer auth environment could apply this type discrimination logic to data pointers as well, but I'm unaware of any that do so implicitly. But if a platform did do so, then the incorrect type of the parameter would mean you would fail inside the function, if you were able to call it (say if you weren't using a function pointer, but had mistyped the prototype). Similarly, in other environments, the pointer may be directly aware the type being referenced, and I believe that CHERI supports this, in which case even if you could call the function pointer when attempting to read the pointer I believe it would fail. Having got here, you might be saying "but hang on C says void* and char* are the same", and we go all the way back to my first sentence where I said "are implicitly convertible" :D On plenty of systems casting from one pointer type to another is an entirely source level feature and once lowered is completely invisible. But in the environments we're discussing (Type1*)pointerToType2 Under pointer auth it requires re-signing the pointer (you have to auth the original value to verify it, and then sign the result according to the new schema), and under CHERI I believe there are instructions for controlling how a pointer is tagged. But the important thing is the C does not say they are the same thing, just that the conversion is automatic, just like numbers and bools, or numbers and bools in JS, or numbers and strings in JS, or objects and strings in JS, or nothing and strings in JS, or .... :D