All programming languages are effectively on a spectrum of abstraction. Some are more abstract than others. So, with this in mind, within the set of high-level languages, you’re going to see some that abstract the inner workings of the machine more or less than others. And even within the set of low-level languages, you see some amount of variation in levels of abstraction as well, although it’s a much smaller range.
That said, if you’re going to categorize every programming language as either high-level or low-level, you need to draw the line between the two somewhere. And to draw that line, you need to define exactly what you mean by a high-level language. The definition of the term, coined in the 1960s, hasn’t really changed:
“A problem-oriented programming language that uses English-like statements and symbols to create sequences of computer instructions and identify memory locations, rather than the machine-specific individual instruction codes and numerical addresses employed by assembly language or machine language.”
With this definition of high-level languages in mind, if you’ve ever developed non-trivial software in assembly language, and also developed non-trivial software in C, the distinction between low-level languages and high-level languages becomes crystal clear. C is a high-level language, and the following are the only low-level languages:
- Assembly languages (and macro assembly languages), in which mnemonic statements map to binary machine language instructions. Programs are written as text using mnemonics for instructions and directives.
- Machine languages, in which sequences of bits represent machine instructions. Programs are written as sequences of binary digits (often expressed in shorthand, using octal or hexadecimal notation).
- Microcode languages (in microcoded architectures), in which sequences of bits represent very low-level microinstructions that are used to implement machine instructions. (Few people, outside of CPU designers, ever have an opportunity to work at this level.)
These low-level languages are tethered to the specific CPU architecture you’re working with.
Everything else, including the C programming language, is a high-level language.
Now, even K&R says that C is “not a very” high-level language. In the set of high-level languages, C lets you do some pretty low-level things. For example, you deal directly with memory addresses, dynamic memory management, and can even insert assembly language instructions directly into your code (although it makes the code non-portable, as soon as you do).
But C has all the characteristics of a high-level language. Sure, you can write code in C that is tied to a specific architecture. But with discipline, you can write C code that is completely independent of a specific architecture.
This has prompted some would-be helpful souls to come up with the term “middle-level language” for C. This ill-defined term just serves to muddy the waters, and is perpetuated by many books, instructors, and other sources. There is no such thing as a middle-level language.
Consider C++ for a moment. C++, a multi-paradigm hybrid language, supports object-oriented programming and functional programming paradigms. And yet, you can do all the lower-level stuff in C++ that you can do in C. Anyone truly familiar with C++ would have a tough time arguing that C++ is a low-level language. C++ is clearly a high-level language.
So, C is a high-level language, but within the set of high-level languages, it’s "not very" high-level. It’s low in the spectrum of high-level languages, but it doesn’t cross the clearly-defined line into the realm of low-level languages.