Skip to content
VLSI Mentor

C Part II · Module 14 · C Through a Hardware Engineer's Eyes

What Changes When C Talks to Hardware

A device returns the status byte 0x85, and bit 7 means ERROR. Read it into a plain char on this machine and the test for that bit returns false — the error is lost, silently, with every warning flag this curriculum recommends switched on. Add one compiler option and the same source on the same chip gives the opposite answer. Nothing about the hardware changed; what changed is that the bit pattern was decided by someone other than your compiler. This chapter names that shift exactly, re-examines five things Part I let you take for granted, and measures what each one actually is on this machine rather than what the language promises.

Intermediate20 min readCPart IIHardwareRepresentation

Part II · Module 14 · Chapter 14.1 · What Changes When C Talks to Hardware

1. One Byte, Two Answers

A peripheral returns an eight-bit status byte. The datasheet says bit 7 is the error flag. The byte comes back as 0x85 — binary 1000 0101 — so bit 7 is set and the device is reporting an error.

Here is the program that checks it.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <stdio.h>

/* A device returns a status byte. Bit 7 is the ERROR flag. */
static unsigned char device_status(void) { return 0x85; }

int main(void) {
    char           s_plain = (char)device_status();
    unsigned char  s_unsig = device_status();

    printf("plain char     : %d   (0x%02x)\n", s_plain, (unsigned)(unsigned char)s_plain);
    printf("unsigned char  : %d   (0x%02x)\n", s_unsig, s_unsig);
    printf("\n");
    printf("s_plain > 0x7F ? %s\n", s_plain > 0x7F ? "yes" : "no");
    printf("s_unsig > 0x7F ? %s\n", s_unsig > 0x7F ? "yes" : "no");
    printf("\n");
    printf("s_plain >> 4 = 0x%x\n", s_plain >> 4);
    printf("s_unsig >> 4 = 0x%x\n", s_unsig >> 4);
    return 0;
}

Compiled with -Wall -Wextra -Wpedantic, which produces no diagnostics at all:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plain char     : -123   (0x85)
unsigned char  : 133   (0x85)

s_plain > 0x7F ? no
s_unsig > 0x7F ? yes

s_plain >> 4 = 0xfffffff8
s_unsig >> 4 = 0x8

Three things happened, and all three are worth naming separately.

The byte is intact. Both variables hold 0x85. Nothing was corrupted. The storage is identical; it is the interpretation that differs.

The error test failed. s_plain > 0x7F is false, because s_plain is −123 and −123 is not greater than 127. The device reported an error and the program decided it had not. There is no crash, no warning, and no wrong-looking output — just a missed error, forever.

The field extraction was poisoned. Shifting right to get the top nibble gave 0xfffffff8 instead of 0x8. The value was promoted to int before the shift, and promoting a negative char sign-extends it, so the top twenty-eight bits arrived as ones. A mask would have been needed to clean up damage that a different declaration would have prevented entirely.

2. The Same Machine Gives the Other Answer

The obvious conclusion — this chip sign-extends bytes — is wrong, and it is worth demolishing immediately because it is the kind of belief that survives for years.

Same source file. Same compiler. Same processor. One extra option:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -funsigned-char -Wall -Wextra ch141.c -o ch141u && ./ch141u
plain char     : 133   (0x85)
unsigned char  : 133   (0x85)

s_plain > 0x7F ? yes
s_unsig > 0x7F ? yes

The answer reversed. Nothing about the hardware was consulted.

Whether a plain char is signed is implementation-defined — the category Chapter 13.3 established: the implementation must choose, must document its choice, and may choose differently from another implementation. It is settled by the platform's ABI and by compiler flags, not by the instruction set. On this target it is signed. On 64-bit ARM Linux, the same processor family with a different ABI, it is unsigned.

So the question "is char signed?" has no answer in C. It has an answer in this build, and that answer is a build input exactly like an include path.

3. Five Things Part I Let You Take for Granted

The status byte is one instance of a general shift. Here is the whole of it.

Five properties of a C value, with what Part I was entitled to assume about each and what Part II requires instead. Width: Part I said an int is at least sixteen bits and whatever this compiler chose; Part II says the width is in the specification, so use the exact-width types. Signedness of a plain char: Part I said it rarely mattered because a char held text; Part II says it is implementation-defined and measured signed here, where the byte 0x85 reads as negative 123. Byte order in memory: Part I said it is invisible because arithmetic never shows it; Part II says it becomes visible the moment the bytes leave the program. Structure layout: Part I said the compiler arranges it and padding is its business; Part II says the format is already fixed and the compiler must be made to match it. What a pointer denotes: Part I said an object the program made and owns; Part II says an address someone else chose, taken from an address map.The propertyPart I could assumePart II must establishWidth of an integerat least 16 bitswhatever the compiler choseexactly what the fieldisuint32_t, not intSignedness of plaincharrarely matteredtext, not datasigned here · measured0x85 reads as -123Byte order in memoryinvisiblearithmetic never shows itvisible on the wirethe moment bytes leaveStructure layoutthe compiler arrangesitpadding is its businessthe format is alreadyfixedthe compiler must matchWhat a pointer denotesan object you ownthe program made itan address someonechosefrom an address map12
Figure 1 — the five properties this module re-examines, with what a Part I program was entitled to assume about each and what a Part II program has to establish instead. Read the middle column as a set of assumptions that were not wrong — they were correct and sufficient for every program in Part I, because in those programs the implementation got to choose and any consistent choice worked. The right-hand column is what happens when something outside the program has already chosen. Every entry in it is measured on this machine later in this module, not asserted: the signedness of plain char in section 2 above, the byte order in Chapter 14.3, the structure layout in 14.5, and the address question in 14.6. The width entry is the only one with a complete answer available immediately, which is why Chapter 14.2 comes next.

The middle column is not a column of mistakes. Every assumption in it is sound for every program in Part I, for one reason: in those programs, the implementation got to choose. If int was 32 bits here and 64 bits elsewhere, a correct Part I program worked on both, because it never depended on the number. If the compiler inserted three padding bytes, nothing cared, because nothing but that compiler ever read the structure.

The right-hand column is what happens when the choice has already been made by someone else — a register map, a bus protocol, a file format, a reference model written in another language. Your compiler still chooses. It just no longer gets to be right by definition.

4. What the Standard Actually Promises

It is worth being exact about how little is fixed, because the gap between "C is close to the hardware" and what C guarantees is where these bugs live.

ISO C fixes minimum ranges, not widths. A char is at least 8 bits, a short and an int at least 16, a long at least 32, a long long at least 64. It also fixes the ordering: sizeof(char) == 1 <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long). Everything else — the exact width, whether plain char is signed, the alignment of each type, the order of bytes within an object, how much padding a structure gets — is left to the implementation.

Measured on this machine:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <stdio.h>
#include <limits.h>
#include <stdint.h>
#include <stddef.h>

int main(void) {
    printf("sizeof: char %zu short %zu int %zu long %zu longlong %zu ptr %zu\n",
           sizeof(char), sizeof(short), sizeof(int), sizeof(long),
           sizeof(long long), sizeof(void *));
    printf("CHAR_BIT %d  CHAR_MIN %d  CHAR_MAX %d\n", CHAR_BIT, CHAR_MIN, CHAR_MAX);
    printf("plain char is %s\n", (char)-1 < 0 ? "SIGNED" : "UNSIGNED");
    printf("_Alignof: int %zu long %zu double %zu\n",
           _Alignof(int), _Alignof(long), _Alignof(double));
    printf("uintptr_t %zu  size_t %zu  ptrdiff_t %zu\n",
           sizeof(uintptr_t), sizeof(size_t), sizeof(ptrdiff_t));
    return 0;
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof: char 1 short 2 int 4 long 8 longlong 8 ptr 8
CHAR_BIT 8  CHAR_MIN -128  CHAR_MAX 127
plain char is SIGNED
_Alignof: int 4 long 8 double 8
uintptr_t 8  size_t 8  ptrdiff_t 8

Every number in that output is this implementation's answer to a question the standard deliberately left open. Not one of them is a property of C, and none may be assumed in code that has to agree with a document.

5. The Rule That Replaces the Assumption

The five rows of Figure 1 reduce to a single working rule, and the rest of this module is its application.

If a value's representation is specified somewhere outside your program, your declaration must state that representation explicitly rather than inherit whatever the compiler would have picked.

"Specified somewhere outside your program" is the test, and it is narrower than it sounds. A loop counter is not specified outside your program; int remains the right type for it, and will still be the right type in Module 17. A field in a register, a header on a bus, a record in a file, a value a SystemVerilog testbench will read — those are specified outside, and for those the declaration has to carry the specification.

Applying the rule to the status byte of §1:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <stdint.h>

/* The datasheet says: 8 bits, unsigned, bit 7 is ERROR. Say exactly that. */
static uint8_t device_status(void);

#define STATUS_ERROR  ((uint8_t)0x80)

static int device_failed(void) {
    return (device_status() & STATUS_ERROR) != 0;
}

Three changes, each one removing a decision the compiler would otherwise have made for you. The width is stated, so it cannot follow the platform. The signedness is stated, so §1's sign extension cannot occur. And the test is a mask against a named bit rather than a comparison against a threshold, so it says what the datasheet says — is bit 7 set — instead of a numeric accident that happens to coincide with it when the signedness goes your way.

That last point is the one people skip. status > 0x7F was not merely fragile; it was never the right question. The document never said "greater than 127". It said "bit 7". Module 15 is entirely about writing the second thing.

6. The Misconception: C Maps Onto the Hardware

"C is a low-level language — basically portable assembler — so its types correspond to the machine's types."

This is the most widely repeated claim about C, and it is the direct cause of every bug in this chapter. It is false in a specific and useful way.

C types correspond to the machine's types as the implementation chose to map them. The mapping is real, it is stable for a given compiler and target, and it is written down — but it is a property of the implementation, not of C and not of the chip. The measurements in §4 are that mapping, for this build. Change the ABI and they change; change the compiler flags, as §2 did, and some of them change on the same chip.

The reason the folklore persists is that it is almost harmless as long as the implementation is the only party involved. A Part I program that assumed int was 32 bits ran correctly on every machine where it was, and was never tested anywhere else. The assumption was never challenged, so it hardened into a belief.

What is true, and is the useful half of the claim, is that C gives you access to the representation — you can take the address of any object, read its bytes through a character pointer, and reason about its layout. No managed language lets you do that. But access to the representation is not a guarantee about the representation. C lets you see the bytes; it does not promise what they will be.

The practical form of the correction is the rule in §5, and the rest of this module is six answers to it: state the width (14.2), state the byte order (14.3), respect the alignment (14.4), control the layout (14.5), be precise about what a pointer denotes (14.6), and carry a length with every buffer (14.7).

7. What You Should Be Able to Say Now

  1. Name the shift. In Part I the implementation chose the representation and any consistent choice worked. In Part II something outside the program — a register map, a bus, a file format, another language — has already chosen, and the compiler's choice is now either in agreement with it or not.
  2. Plain char has no portable signedness. It is implementation-defined. Measured signed here; -funsigned-char reverses it on the same machine with the same compiler. Never use plain char for a byte of data.
  3. The measured consequence. 0x85 read through a plain char is −123, so s > 0x7F is false and the error flag is lost; >> 4 gives 0xfffffff8 because promotion sign-extends. No diagnostic at any warning level.
  4. The standard fixes minima, not widths. char ≥ 8 bits, short and int ≥ 16, long ≥ 32, long long ≥ 64, in non-decreasing size order. Width, char signedness, alignment, byte order and padding are all left to the implementation.
  5. sizeof(char) == 1 by definition; CHAR_BIT says how many bits that is. Assert CHAR_BIT == 8 if you depend on it.
  6. The rule. If a representation is specified outside your program, declare it explicitly instead of inheriting the compiler's default.
  7. Ask the document's question. The datasheet said "bit 7", not "greater than 127". A mask against a named bit is the correct translation; a threshold comparison is a coincidence.
  8. "C maps onto the hardware" is false as stated. C gives you access to the representation; it does not promise what the representation is. The mapping belongs to the implementation and is a build input.

8. Exercises

  1. Run §4's program on your own machine and record all seven lines. If any number differs from the output above, say which standard guarantee still holds despite the difference.
  2. Reproduce §1 and §2. Then write the version from §5 and confirm that -funsigned-char no longer changes its behaviour. Explain in one sentence why not.
  3. s_plain >> 4 gave 0xfffffff8. Work out, on paper, each step of the conversion that produced the leading fs, naming the Chapter 3 rule at each step. Then check your answer by printing the intermediate value.
  4. Find one declaration in code you have written in Part I that would be wrong if a datasheet owned its representation, and one that would still be correct. Say what distinguishes them. (If every declaration you check is in the second group, that is the expected result — say so.)
  5. Add _Static_assert lines to a small file asserting 8-bit bytes, a 4-byte int, and that unsigned char is exactly 8 bits wide. Make one of them fail deliberately and read the message.
  6. The datasheet in §1 defines bit 7 as ERROR. Write the test three ways — threshold comparison, mask against a literal, mask against a named constant — and argue for one of them. Then say which of the three would survive the field being moved to bit 6.

9. Summary

  • Part I: the implementation chooses the representation, and any consistent choice works. Part II: something outside the program has already chosen, and your declaration either agrees or does not.
  • Plain char signedness is implementation-defined — measured signed here; -funsigned-char flips it on the same chip. Never a data byte.
  • Measured: 0x85 as a plain char is −123, so the bit-7 test returns no, and >> 4 yields 0xfffffff8. Silent at every warning level.
  • The standard fixes minimum ranges (char ≥ 8 bits, short/int ≥ 16, long ≥ 32, long long ≥ 64) and the size ordering. Width, signedness, alignment, byte order and padding are the implementation's.
  • sizeof(char) == 1 by definition; CHAR_BIT carries the bit count. Assert it if you rely on it.
  • The rule: a representation specified outside your program must be stated in the declaration, never inherited.
  • Translate the document's question, not a numeric coincidence that currently matches it.
  • "C maps onto the hardware" confuses access to the representation with a guarantee about it.

Width is the one entry in Figure 1 with a complete, portable answer already standardised — a set of types that say exactly how many bits they occupy and whether they are signed, and that refuse to exist at all on a target that cannot provide them. That is where the next chapter starts, and it is the single highest-value change you can make to code that touches hardware.

Standards & specifications

Governing standard
ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)

Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C Programming curriculum.