Skip to content

Casting a negative float to an unsigned int

Never cast a negative float to an unsigned int. The result of the cast differs depending on whether the cast is executed on a device with Intel or ARM architecture. The C++ Standard decrees that the result of such a cast is undefined.

Introduction

I wrote the first version of this post in August 2013. It has been in the top 10 of my most popular posts ever since with 1550+ views per year. In November 2022, after 9 years, I decided to share the post on LinkedIn again. It garnered more than 100K impressions, 850 reactions, 65 comments and 25 reposts within a week. The blog post got its yearly number of views within a week.

It seems that the post hasn’t lost its usefulness over the years and can still save people a lot of time. From some comments, I infer that I should give some context how the cast became undefined, a new section. The cast was perfectly fine, until someone violated the rules. The sections Why the Cast Fails and How to Fix the Problem are taken from the original post with some minor changes. The post ends with the new section Earlier Feedback Needed. The tooling support for detecting undefined behaviour is wanting.

How the Cast Became Undefined

The ECUs (electronic control units) in harvesters, tractors, excavators, cranes and trucks communicate over CAN bus using the J1939 protocol. The J1939 standard demands that all numbers – integer or floating point numbers – are converted into unsigned integers for transmission over CAN. The ranges of the transformed values must fit into a 32-bit unsigned integer.

Let me illustrate this with an example. We assume that the outside temperature is in the range from -45°C to +85°C, because most electronics stops working outside this range. We measure the temperature with an accuracy of 0.1°C. We apply an affine transformation to convert the temperature -27.4°C into a non-negative floating-point number 176.0. The type of fValue and hence of tValue could also be double. It doesn’t make a difference for the cast.

float fValue;
auto tValue = (fValue + 45.0) * (1.0 / 0.1);
# With fValue = -27.4: 
# tValue = (-27.4 + 45.0) * (1.0 / 0.1) = 17.6 * 10.0 = 176.0

We could now round tValue, take the ceiling or floor of it, or cast it to turn it into an unsigned integer. As this conversion may be executed hundreds or even thousands of times per second, casting seemed to be the fasted method. The other methods must execute some extra steps. The code looks like this now. tValue is a 16-bit unsigned integer.

float fValue;
auto tValue = static_cast<uint16_t>((fValue + 45.0) * 10.0);

The behaviour of the code is well defined. The range for the transformed values runs from 0 to 1300. J1939 messages would pack the values into 11 bits of its 8-byte payload. This saves 21 bits over a 4-byte float and 53 bits over an 8-byte double. Saving space is paramount, because CAN buses typically run at 256Kbps.

Now the inevitable happens. Someone flouts the J1939 standard, drops the offset from the parameter specification (Note: Conversion code is generated!), and “just” uses a signed integer.

float fValue;
auto tValue = static_cast<uint16_t>(fValue * 10.0);

The unit tests for negative values pass on the PCs, which are still typically powered by Intel processors. Disaster strikes on the ARM-based ECUs. All negative values are cast to 0.

Why the Cast Fails

When we run the code

auto fValue = -176.0;
auto tValue = static_cast<uint16_t>(fValue);
// On Intel: tValue = 65360
// On ARM:   tValue = 0

on a device with Intel architecture, tValue has the value 65360, which is equal to 2 ^ 16 – 176. 65360 is the two’s complement representation of -176 for 16 bits and is the expected result.
When we run the code on a device with ARM architecture, however, tValue has the value 0. Actually, every negative floating point number less than or equal to -1 comes out as 0. By the way, the result is the same if we remove the static_cast. Then, the conversion is done implicitly.
A post at StackOverflow points us to Section 6.3.1.4 Real floating and integer of the C Standard for an explanation to our problem:

The remaindering operation performed when a value of integer type is converted to unsigned type need not be performed when a value of real floating type is converted to unsigned type. Thus, the range of portable real floating values is (−1, Utype_MAX+1).

In other words, the two’s complement of a negative float need not be computed. The safe range for casting a negative float to an unsigned int is from -1 to the maximum number that can be represented by this unsigned integer type.

How to Fix the Problem

A possible solution of our problem is to cast the floating point number to a signed integer number first and then to an unsigned integer number.

auto fValue = -176.0;
auto tValue = static_cast<uint16_t>(static_cast<int16_t>(fValue));
// On Intel: tValue = 65360
// On ARM:   tValue = 65360

Another possible solution is to round the floating point number or to compute the floor or ceiling of the floating point number. Qt provides the functions qRound, qCeil and qFloor for this purpose. The right solution depends on the concrete problem at hand.
Unfortunately, the compiler cannot help us with finding the problem, because we use an explicit cast here. We basically tell the compiler that we know better what to do. If we had used an implicit conversion and had used the warning option -Wconversion, the compiler would have warned about the problem sheepishly.

auto fValue = -176.0;
uint16_t tValue = fValue;
warning: conversion from ‘float’ to ‘quint16’ {aka ‘short unsigned int’} may change value [-Wfloat-conversion]

Earlier Feedback Needed

How can we detect the problem automatically?

  • The Continuous Delivery (CD) pipeline runs the unit tests not only on the Intel-based workstation but also on the ARM-based target device.
  • The CD pipeline runs the undefined behaviour sanitizer UBSan.

The first method tells us that there is a problem and roughly where it is. We must still figure out that the problem is caused by the undefined cast. We may do this by searching the Web or by running several sanitizers. When we understand the problem, we add UBSan to the CD pipeline to avoid a regression.

So far, we have been talking about an ideal world. It’s December 2022 and I haven’t encountered an embedded software project yet that runs a CD pipeline. At least, I have helped a customer this year to set up a CD pipeline.

So, we must rely on people’s expertise (knowing the problem or finding it quickly) and diligence (running tests manually on an ARM-based device). We get feedback very late, days or weeks after we wrote the problematic lines of code. The CD pipeline is a big improvement. We get feedback within hours if not minutes. But the C++ tooling could do much better than this!

My favourite solution would be for the compiler to stop with an error when encountering undefined behaviour. That would be the earliest feedback possible.

error: Casting from a negative 'float' to an unsigned integer 'uint16_t' is undefined behavior.

The compiler writers know that this cast is undefined, because the C++ standard says so. They know, because they implemented it differently for the same compiler, g++, on different processor architectures. The compiler behaves inconsistent.

Being inconsistent is one of the big no-gos in user experience (UX) design. Stopping with an error when encountering undefined behaviour would improve the UX of C++ compilers tremendously and save C++ developers lots of time.

Interpreting “undefined behaviour” as “the compiler can do whatever it wants” leads to a major waste of time for C++ developers. The C++ standard and compilers must treat undefined behaviour consistently as errors.

1 thought on “Casting a negative float to an unsigned int”

Leave a Reply to Mmanu Cancel reply

Your email address will not be published. Required fields are marked *