7 ms·
I'd offer a different perspective: C is a simple language that obviously maps to a programmer's idea of the assembly they'd write. It biases towards lots of f
by yourbandsucks 8y ago
I'd offer a different perspective:
C is a simple language that obviously maps to a programmer's idea of the assembly they'd write.
It biases towards lots of fixed-size stack frames and fixed-layout structs. Writing C code really does feel very close to the "bounded turing machine" metaphor that the CPU gives us.
Other languages have their advantages, but C's proximity to assembler, with all the faults included, really is a great pedagogical tool.
- jcranmer 8y ago> C is a simple language that obviously maps to a programmer's idea of the assembly they'd write. It's not. That's my point. You don't need undefined behavior to get to that point, just consider what assembly you get from this code: unsigned prog_a(unsigned a) { return a * 2; } unsigned prog_b(unsigned a) { return a / 3; } What do you think you're going to get for that code? The "obvious mapping" principle for both codes suggest you're going to get a multiply and a division instruction, respectively. On x86, however, what you get is this: prog_a: leal (%rdi, %rdi), %eax ret prog_b: movl %edi, %eax movl $-1431655765, %edx mull %edx shrl %edx movl %edx, %eax ret In other words, the first program turned into a load-effective address instruction, and the latter turned into a multiply-hi instruction. Even something as simple as basic arithmetic gets turned into weird instructions, and which ones get this treatment is essentially a game of roulette based on compiler optimizations and particularly heuristics. We're not even bringing undefined behavior into play here, which is usually the major objection to C-obviously-maps-to-assembly; it's falling flat enough without it. The primary advantage C brings, what people are really touting when they tout its proximity to assembly, isn't that it's close to assembly but that it has a well-known, stable ABI for that mapping. Of course, it turns out that said ABI is actually a bad idea if you want performant modern code on most architectures (the infamous array-of-struct versus struct-of-array problem).
- nwmcsween 8y agoWho would have thought an optimizing compiler would optimize.
- fiedzia 8y agoTons of people repeating "C gets you close to hardware".
- nwmcsween 8y agoIt does though just not the way you planned, a litmus test on how close a languages is to 'the hardware' is to see how much crap a compiler frontend generates that the backend has to remove that doesn't have any positive effect on optimizations.
- fiedzia 8y agoNo, the litmus test would be "can I reason about generated native code produced by this line of C". And you can't do that today. This leaves you two options: either switch to something where you can, going lower than C to be able to control all the details, or stop worrying about, accept the fact that compilers take care of those details for you and use higher-level language.
- nwmcsween 8y agoThis is a feature of optimizing compilers and isn't what people mean when they say it maps to hardware.
- notacoward 8y agoWith optimization turned off, each line of C does map to a fairly obvious sequence of native instructions, including instructions that are actually poking at memory-mapped hardware registers. No hidden constructors/destructors, no operator overloading, no exceptions, no "built in" data types that require thousands of lines of hidden library code mapping to what the hardware actually offers. That doesn't cover things like memory ordering and barriers, but it's still a lot closer than any other language in which software is actually written (so e.g. assembler or LLVM IR don't count). I believe Rust has the same property, but only a tiny fraction as much software is written in it. Ditto for Zig and Nim. C++ has all of the paradigm-breakers mentioned above, Go has its own hidden magic mostly related to goroutines, Java doesn't even try, and anything functional doesn't even occupy the same conceptual space. As a mental exercise, try taking any random piece of generated assembler code and map it back to the corresponding higher-level-language code. I can usually do this pretty easily for C code, though switch/case statements present some challenges and heavy use of macros in any language can defeat the effort. Even calls to compiler-internal functions (e.g. memcpy-like functions for structure assignment) will be easy to interpret. Again, you can probably do the same for Rust/Nim/Zig. Attempting the same for C++ or Go will lead to all sorts of random places other than the original source line, and for just about anything else good luck even finding that generated assembler in the first place. When you can map easily back and forth between the language and CPU instructions, you're as close to the hardware as you're going to get. This is something few programmers - even in kernels and such - actually need, but C offers it and most other languages don't.
- enriquto 8y agoof course there are many semantically equivalent ways to translate from C to assembly, and the optimizer may chose in surprising ways. But the point is that when you write your functions, there is always an obvious assembly representation that should be in your head.
- pjmlp 8y agoExcept that representation is dependent on more than 200 possible scenarios of UB, which I doubt any human developer is able to keep on their head.
- jcranmer 8y ago> semantically equivalent Of course, the #1 biggest problem of C is that C's semantics are not what people think they are. Most notably, pointers are not integers in the C abstract machine, nor can you really enforce such a mapping without prohibiting nearly all optimizations [1]. And when you teach people to understand how processors work via C, you start to claim that C's semantics are processor semantics, which has not been the case for a very long time. [1] The underlying issue here is that the model you need for any sane optimization is that if you tell nobody that this box of data exists, then there is there no way for anyone else to refer to it. This basic fact opens up the ability to do things like move variables from stack allocations to registers, eliminate dead stores, etc. It also implies that you need some sort of pointer provenance in the semantic model to track who actually has the ability to refer to something. In practice, a semantic model for C needs to carry pointer provenance through integers as well, but that really means that C's integers also aren't mathematical integers (modulo some power of 2).
- enriquto 8y agopointers ARE ints if pointers are not integers how can you iterate through an arbitrary array using char* ?
- jcranmer 8y agoPointers are integers on most hardware, if you ignore the complexities of multiple address spaces (i.e., a feature present on virtually anything that's not a general-purpose CPU) or segmentation (i.e., the most common CPU architecture for desktop and server processors). In C, however, pointers are not integers. I've not fully built any kind of operational semantics for C, so I can't say what the full list of features you need to track for pointers is for sure, but I can take a stab at how you could describe it. A pointer consists of a tag of the object being stored in the object model (which needs to tell you what the current dynamic type of the object is and its size and alignment in addition to the actual memory contents of the object) as well as an offset of where it points inside that object. Iterating through an array would merely be incrementing (or decrementing, as the case may be) the offset field of the pointer, subject of course to the well-definedness characteristics that you can't exceed one past the end of the object or go before the beginning of the object.