C++ CPE study materials
C++ CPE study resources

Signed Char, Negative Index: A Checksum That Only Fails on x86

C++ CPE · Put your knowledge to work in context.

All case studies

Signed Char, Negative Index: A Checksum That Only Fails on x86

A firmware team ships a telemetry gateway that validates every incoming frame with an 8-bit table-driven checksum. Frames are 64 bytes, and the gateway handles about 1,200 frames per hour. The same source file is compiled with GCC on an ARM board, where char is unsigned, and with MSVC on an x86_64 test host, where char is signed. The core routine is:

static unsigned char tbl[256];
unsigned char crc8(const char* buf, int len) {
    unsigned char crc = 0;
    for (int i = 0; i < len; ++i)
        crc = tbl[crc ^ buf[i]];
    return crc;
}

The ARM build accepts every frame. The x86_64 build rejects roughly 30% of frames, precisely those containing at least one byte in the range 0x80 to 0xFF. On the test host, an assert-enabled build aborts inside the loop, and AddressSanitizer reports a global-buffer-overflow read of size 1 immediately before tbl. The team first proposes adding -funsigned-char to the x86 build flags.

1. Explain the exact mechanism that makes `crc8` behave differently on the two targets, and state which input byte values trigger the difference. Include the intermediate types after integral promotion.

Read the worked answer

Integral promotion is the whole story. crc is unsigned char, so it promotes to int in the range 0 to 255. buf[i] is plain char; on x86_64/MSVC it is signed, so a byte 0x80 to 0xFF becomes an int in -128 to -1. The XOR therefore produces a negative int (for example, 0 ^ -128 == -128). That expression is the array index; the built-in subscript converts it to ptrdiff_t, keeping the sign, so the code reads tbl + (-128), which is before the array. On ARM, char is unsigned, the index is 0 to 255, and the read stays in bounds. Any frame containing at least one byte >= 0x80 triggers the difference, which matches the observed 30%. Because the read is out of bounds, the result is undefined behavior, not merely a wrong value: the ARM checksum may be correct while the x86 build returns garbage or traps. Note that the checksum arithmetic itself would have been fine modulo 256; it is the table subscript that breaks. The assert build catches the negative index, and ASan reports the global-buffer-overflow.

2. Evaluate the proposal to add `-funsigned-char` to the x86 build. Does it fix the defect? What would you do instead, and why is the flag inadequate even if it makes the tests pass?

Read the worked answer

-funsigned-char makes plain char unsigned, so the x86 build would reproduce ARM behavior for this function. It is still the wrong fix. Plain char, signed char, and unsigned char are three distinct types, and the signedness of plain char is implementation-defined. A build flag changes the behavior of every translation unit, including third-party headers and code that relies on char being signed, such as comparisons like c > 127 or passing a char to <cctype> functions, which require EOF or an unsigned char value. It also leaves the function interface ambiguous and does not prevent a future caller from passing data through a signed char*. The durable fix is to make the byte type explicit at the boundary: accept const unsigned char* (or std::uint8_t*), or convert once per element with static_cast<unsigned char>(buf[i]). Converting a negative char to unsigned char is well-defined modulo 256, so the XOR is in 0 to 255 and the index is always valid. The table should also be std::array<unsigned char,256> with a bounds-checked accessor in debug builds.

3. Rewrite `crc8` so it is correct on both targets, and describe a concrete test that would have caught the bug on either platform.

Read the worked answer

Rewrite with explicit types and single evaluation per element:

#include <array>
#include <cstddef>
#include <cstdint>

static std::array<std::uint8_t, 256> tbl{};

std::uint8_t crc8(const std::uint8_t* buf, std::size_t len) {
    std::uint8_t crc = 0;
    for (std::size_t i = 0; i < len; ++i)
        crc = tbl[static_cast<std::uint8_t>(crc ^ buf[i])];
    return crc;
}

The cast is redundant when buf is already std::uint8_t* but documents intent and keeps the function safe if it is later called through a char* wrapper. A test that catches the original bug on any target uses bytes that cross the sign boundary: {0x00}, {0x7F}, {0x80}, {0xFF}, and a mixed frame with at least one high byte, each with an independently computed expected CRC. Run the suite on both targets and under -fsanitize=address,undefined; the original assert or ASan report fails immediately on 0x80. Add -Wsign-conversion or a debug-only assert(idx < 256) in the indexing helper, and compare ARM and x86 outputs for a corpus of 1,000 random frames.