1. Conservation of mass: the amount of C code you put in will be pretty close to the amount of machine code you get out. Aside from the preprocessor, which is very obviously expanding macros, there are almost no features of C that will take a small amount of code and expand it to a large amount of output. This makes some things annoyingly verbose to code in C (eg. string manipulation), but that annoyance is reflecting a true fact of machine code, which is that it cannot handle strings very easily.
2. Conservation of energy: the only work that will be performed is the code that you put into your program. There is no "supervisor" performing work on the side (garbage collection, stack checking, context switching), on your behalf. From a practical perspective, this means that the machine code produced by a C compiler is standalone, and can be called from any runtime without needing a special environment to be set up. This is what makes C such a good language for implementing garbage collection, stack checking, context switching, etc.
There are some exceptions to both of these principles. Auto-vectorizing compilers can produce large amounts of output from small amounts of input. Some C compilers do support stack checking (eg. `-fstack-check`). Some implementations of C will perform garbage collection (eg. Boehm, Fil-C). For dynamically linked executables, the PLT stubs will perform hash table lookups the first time you call a function. The point is that C makes it very possible to avoid all of these things, which has made it a great technology for programming close to the machine.
Some languages excel at one but not the other. Byte-code oriented languages generally do well at (1): for example, Java .class files are usually pretty lean, as the byte-code semantics are pretty close to the Java langauge. Go is also pretty good at (1). Languages like C++ or Rust are generally good at (2), but have much larger binaries on average than C thanks to generics, exceptions/panics, and other features. C is one of the few languages I've seen that does both (1) and (2) well.
Haven't seen C's allure quite explained that way.
In a nutshell, the useful fiction of computer-as-Von-Neumann-meaning doesn’t adequately reflect the reality of modern hardware. Not only does the CPU itself not fit that model (with things like speculative execution, sophisticated power and load management…), but the system as a whole is increasingly an amalgamation of different processors and address spaces.
Take the "wobbly types" for example. It would have been more "minimal" to have types tied directly to their sizes instead of having short, int, long, etc.
There isn't any reason that compilers on the same platform have to disagree on the layout of the same basic type, but they do.
The complaints about parsing header files could potentially be solved by an IDL that could compile to c header files and ffi definitions for other languages. It could even be a subset of c that is easier to parse. But nothing like that has ever caught on.
This hasn't been true for decades. x86 assembly is now itself an abstraction over what the CPU is actually doing.
Microcode, speculative execution, etc.
Exactly.
Everything in assembly is still one-to-one in terms of functional/stateful behavior to actual execution. Runtime hardware optimization (pinhole instruction decomposition and reordering, speculative branching, automated caching, etc.) give a performance boost but do not change the model. Doing so would mean it didn't work!
And C is still very close to the assembly, in terms of basic operations. Even if a compiler is able to map the same C operations to different instructions (i.e. regular, SIMD, etc.)
If language extensions to ISO C are allowed, then same goes for my selection on competing systems languages.
Java with only primitive values, arrays, and classes only with fields and static methods.
But that wouldn’t be idiomatic Java, so typically non-explicit abstractions such as polymorphism have code generated for them that you don’t have explicit control over.
C is consistently low level because that’s all you get. Down to direct access to addressing and RAM, the stack frame, etc. as with assembly.
Being idiomatic or not doesn't matter, what counts are what language features are available.
Note here “close” being used in the injective, not bijective, sense. (Scratch out “one-to-one” in my earlier comment.)
And “closer” lowers the bar here too. C isn’t simply decorated assembly. But closer to it.
And “close” being used informally. Arguments for closeness are several and strong (I think), but a bit of a hodgepodge.
In terms of non-bijectivity, for systems programming and performance choices C makes it easy to drop into assembly. But the former are uniquely application specific. And the latter doesn’t make the C version less like the assembly it maps onto - whether the compiler uses the more performant instructions for the context or not.
C’s convenient assembly inlining, and the handoff in both directions being smoothed by an assembly friendly model of the C code around it, are both a part of the “closeness”
But C is generally “close” to assembly, because its data types emphasize types handled natively, compound types reflect RAM layout, and pointers are explicit addresses to data and code. And those address values can be constructed and operated on just like any other data.
C is objectively closer to assembly than languages with strongly required abstractions. (E.g., Java classes, Lisp S-exp's/cons cells, etc.)
C is more “strictly closer” to assembly than languages with more optional abstractions, even if they also allow for relatively low level coding.
Functions could be viewed as a preferred abstraction, but they have a clear assembly level model accessible directly with pointer arithmetic. And they don’t get in the way of directly encoding custom argument passing schemes, and using goto’s and zero argument functions and tail calls as atomic assembly calls for function and jumps for continuations.
Types are a significant non-assembly abstraction, but are zero-cost in that they don't separate C from assembly, but C from C, as a code safety mechanism that is easily escaped.
It is often easy to add abstractions, via regular C, or macros, but you have to provide an explicit implementation for them in the source or complied library.
(However, if macros, with their mixed logical, symbol, text and file “data” model, are viewed as C source instead of as a C source construction language, then C becomes a very wacky abstraction language with behavior and rules that look nothing like simple assembly.)
Did anyone say that? I think the point is not that it is not "close", but that C is not equivalent to ASM: C has its own abstractions, and there are things you can do on assembly that you can't express in C.
The other low level languages such as C++, Rust, Zig, ... are equally close since you can express the same things. In some respect they are even closer since they got more features builtins that modern assembly can now do that was not part of the design in C. (SIMD, threading, ...)
Modern languages also have extra abstractions that makes programming easier without compromising on the cost. There are more abstractions than in C, but they also are optional. (Just like you could use goto instead of while or for loop, but you're happy this abstractions exist. We could also use functions pointer in C++ instead of virtual functions, but why would we if the language provide tools that make programming easier, for the same result)
> The other low level languages such as C++, Rust, Zig, ... are equally close since you can express the same things.
C is not just low level friendly, but low level out of the box. That is the level that all C must be written in, even when creating higher abstractions.
Some higher level languages are also low level friendly, not low level strict. Which is a kind dual.
I would argue that what makes C lower level, is that it comes in at, or under, the low levels of other languages, and its high bar comes in much lower than the abstractions built into other languages.
Forth is a good candidate for being even lower level.
But if someone else doesn't see things that way, that is fine. It is just one lens for comparing languages.
No, it is not:
- People use for/while loop, for example, instead of the "low level" 'goto'
- C compiler compute pointer aliasing, assume operations don't overflow, etc., in order to optimise your code: What you write doesn't translate directly to assembly.
- Some low level operations cannot even be represented in pure C (without using __asm__ extension escape hatch)
The manipulation of memory by C programs is close semantically to the manipulation of memory by assembly programs. Memory accessed through pointers is similarly "external" to both assembly language and C programs.
The evaluation of C program code is not close to assembly language. C programs cannot reflect on themselves portably; features like parameter passing, returning, and allocating local storage during procedure activation, are not in the programming model.
C loses access to detailed machine state. Errors that machine language can catch, like overflows, division by zero and whatnot, are "undefined behavior". An assembly language program can easily add two integers together and then two more integers which include the carry out from the previous addition. Not so in C.
Assembly language instruction set designs (with some exceptions) tend to bend over backwards to preserve the functioning of existing binary programs, by maintaining the illusion that instructions are executed in sequence as if there were no pipelining or speculative execution, or register renaming, etc.
Meanwhile, C compiler vendors bend over backwards to prove that code you wrote 17 years ago was wrong and make it fail. C is full of unspecified evaluation orders and various kinds of undefined behavior in just the basic evaluation model of its syntactic, built-in constructs; and then some more in the use of libraries.
In assembly language, you would never have doubt about the order of evaluation of arguments for a procedure.
Even when it comes to memory, where C and asasembly language agree in many points, there are some subtle ways C can screw you. In assembly language, you would never wonder whether copying a structure from one memory location to another included the alignment padding bits. In C you also don't have to wonder, if you use memcpy. Oh, but if you use memset to clear some memory which you don't touch afterward and which goes out of scope, the compiler can optimize that away, oops!
See my comment here for some references - https://news.ycombinator.com/item?id=46930253
The gap between the "C Abstract Machine" and the actual Hardware underneath is smaller than most other high-level languages. This comment by user haberman puts it very nicely - https://news.ycombinator.com/item?id=46910015
For example, C has pointer provenance, so pointers arent just addresses. Thats why type punning is such a mess. If a lang claims to be super close to the hardware this seems like a very weird thing.
What makes C feel free for programming is that instead of prescribing an implementation paradigm, it instead exposes a computing model and then lets the programmer write whatever is possible with that (and also what is not -- UB). And a lot of higher level abstractions are quickly implemented in C, e.g. inheritance and polymorphism, but then they still allow to be used in ways you like, so you can not just do pure class inheritance, but get creative with a vtable, or just use another vtable with the same object. These are things you can't do when the classes are a language construct.
My gripe is only with people acting like the C abstract machine doesn't exist and C is just syntax sugar for a bit of assembly. It's a bit more involved than that.
Most people have no understanding of an abstract machine though the very idea of a high-level programming language is based on it.
The C Language Standard itself specifies "Program Execution" only on a "Abstract Machine". Mapping that abstract machine to an ISA/Memory on real hardware is the task of the C compiler. It can do this in any manner as long as the observable behaviour of the program is "as-if" it ran on the abstract machine.
Relevant quote:
A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input.
Further Resources;
Wikipedia Abstract machine - https://en.wikipedia.org/wiki/Abstract_machine
Abstract machines for programming language implementation (pdf) - https://www.rw.cdl.uni-saarland.de/people/diehl/private/pubs...
The Abstract Machine: A Pattern for Designing Abstract Machines (pdf) - https://www.plopcon.org/pastplops/plop99/proceedings/garcia/...