class 10 · hour ten of fifty
What happens
at the boundary?
You now have four ways to hold a number. Real programs move values between them constantly, and that is where this arc's last open story ends.
Seven programs. Ariane closes at the end, nineteen classes after it went on the board.
Every program you write crosses these lines all day.
An int index into an array of float. A double sensor reading written into a short field. A count compared against strlen.
Every one of those is a value leaving a representation that could hold it and entering one that might not.
the four things that can go wrong
- The new type has less precision, so digits are lost.
- The new type has less range, so the value wraps.
- The new type reads the same bits differently, so the value changes meaning.
- The value does not fit at all, and the standard stops defining what happens.
Keep this list on the board. Each of the next five programs is one row of it.
Float to int does not round.
/* 01-truncate.c */
#include <stdio.h>
int main(void)
{
double v[] = { 3.9, 3.99999, -3.9, 0.6, -0.6 };
printf("value (int) gives what you probably wanted\n");
for (int i = 0; i < 5; i++)
printf("%-10.5f %-13d %.0f\n",
v[i], (int)v[i],
v[i] < 0 ? v[i] - 0.5 : v[i] + 0.5);
return 0;
}
value (int) gives what you probably wanted
3.90000 3 4
3.99999 3 4
-3.90000 -3 -4
0.60000 0 1
-0.60000 0 -1
remember
- The cast throws away the fraction. It does not round to nearest.
- It truncates toward zero, so 3.9 becomes 3 and minus 3.9 becomes minus 3. Not a floor, not a ceiling.
- 3.99999 also becomes 3. A value one hundred thousandth away from 4 lands on 3.
- If you want rounding, ask for it:
lround, or add half before casting.
Ask what happens to minus 0.6. Most say minus 1. It is zero, and there are two of those from Class 7.
Int to float can lose digits, going the safe direction.
/* 02-precision.c */
#include <stdio.h>
int main(void)
{
int v[] = { 1000, 16777216, 16777217,
16777219, 2147483647 };
printf("integer through a float same?\n");
for (int i = 0; i < 5; i++) {
int back = (int)(float)v[i];
printf("%-14d %-18d %s\n",
v[i], back, v[i] == back ? "yes" : "no");
}
return 0;
}
integer through a float same?
1000 1000 yes
16777216 16777216 yes
16777217 16777216 no
16777219 16777220 no
2147483647 -2147483648 no
remember
- Both types are 32 bits, so this feels safe. It is not. A float spends 8 of its bits on the exponent.
- Above 224 a float cannot hold every integer. This is the gap of 2 from Class 6, in a different costume.
- 16777219 came back as 16777220. It rounded up, and it rounded to the even neighbour.
- The last row is worse than wrong. The float is 2147483648, which no int can hold, so the conversion back is undefined.
This is the one that surprises people. Same width, and the int does not survive the round trip.
Into a smaller integer, it simply wraps.
/* 03-narrow.c */
#include <stdio.h>
int main(void)
{
int v[] = { 300, 32767, 32768, 70000, -70000 };
printf("int as short as char\n");
for (int i = 0; i < 5; i++)
printf("%-11d %-11d %d\n",
v[i], (short)v[i], (char)v[i]);
return 0;
}
int as short as char
300 300 44
32767 32767 -1
32768 -32768 0
70000 4464 112
-70000 -4464 -112
remember
- Narrowing keeps the low bits and discards the rest. That is the circle from Class 5, cut smaller.
- 32768 as a short is minus 32768. One past the top and you are at the bottom.
- Row one is the wallpaper crash from Class 1. 300 into a char is 44, because 300 minus 256 is 44.
- No warning at run time, and by default no warning at compile time either.
Compile with -Wconversion and this program lights up. Show that. Most students have never turned it on.
And the same bits can change sides.
/* 04-unsigned.c */
#include <stdio.h>
#include <string.h>
int main(void)
{
int i = -1;
char *s = "hello";
printf("(unsigned)-1 = %u\n", (unsigned)i);
printf("(size_t)-1 = %zu\n\n", (size_t)i);
printf("i is %d and strlen is %zu, "
"so i < strlen(s) must be true.\n", i, strlen(s));
printf("i < strlen(s) %s\n\n",
i < strlen(s) ? "true" : "false");
size_t z = 0;
printf("z >= 0 %s\n",
z >= 0 ? "always true" : "sometimes false");
printf("z - 1 %zu\n", z - 1);
return 0;
}
(unsigned)-1 = 4294967295
(size_t)-1 = 18446744073709551615
i is -1 and strlen is 5, so i < strlen(s) must be true.
i < strlen(s) false
z >= 0 always true
z - 1 18446744073709551615
remember
- Nothing was converted here in the sense of being changed. The bits stayed put and the reading changed.
- Comparing a signed value with an unsigned one turns the signed one unsigned first, so minus one becomes enormous.
i < strlen(s) is false when i is minus one. That is a real bug in real code, and it compiles silently.
- A loop counting down with an unsigned index never ends, because unsigned values are never below zero.
This is the most common of the five in ordinary software. It is also the one that never looks like a conversion.
And when it does not fit at all, the standard gives up.
/* 05-outofrange.c */
#include <stdio.h>
int main(void)
{
double a = 1e10;
double b = 40000.0;
double c = -50000.0;
printf("all three are out of range "
"for the type they go into\n\n");
printf("(int) 1e10 = %d\n", (int)a);
printf("(short) 40000.0 = %d\n", (short)b);
printf("(short) -50000.0 = %d\n", (short)c);
printf("\nthe C standard says this is undefined behaviour.\n");
printf("the compiler is allowed to produce anything at all,\n");
printf("and as you can see it did not even pick one answer.\n");
return 0;
}
(int) 1e10 = -2147483648
(short) 40000.0 = -25536
(short) -50000.0 = 15536
remember
- Converting a float to an integer type that cannot hold it is undefined behaviour, not merely wrong.
- Undefined means the compiler owes you nothing. It may wrap, clamp, produce rubbish, or delete the surrounding code.
- Three conversions in one program gave three unrelated answers. There is no rule here to learn.
- Which means you cannot test your way out of it. The only fix is to check the range before converting.
Rebuild this with -O2 or with clang. The numbers change. That is the lesson, not the numbers.
Five conversions, five different failures.
| conversion | what it does | what you lose |
| float to int | drops the fraction, toward zero | everything after the point |
| int to float | rounds to the nearest float | low digits above 224 |
| int to smaller int | keeps the low bits | the high bits, silently |
| signed to unsigned | changes nothing at all | the meaning of the top bit |
| out of range | undefined behaviour | any guarantee whatsoever |
| int to bigger int | sign extends | nothing. this one is safe |
Only the last row is free. Everything else costs something, and none of them announce it.
Worth copying down. This table is the practical output of the whole arc.
kourou, french guiana
4 June 1996.
Ariane 5, flight 501.
First flight of a new launcher. Ten years of development, and a cluster of four scientific satellites on board.
Thirty seven seconds after ignition the guidance system fails. At about forty seconds the vehicle breaks up and is destroyed.
The rocket was fine. The engines were fine.
The guidance software had flown successfully on Ariane 4 for years. It was reused, deliberately, because it was proven.
This has been on the board since Class 1. Say so. They have been waiting nine sessions for it.
The line that did it.
/* 06-ariane.c */
#include <stdio.h>
#include <stdint.h>
int main(void)
{
/* the guidance unit held a horizontal value as a 64 bit
float and wrote it into a 16 bit signed integer,
with no check */
double v[] = { 5000, 20000, 32767,
32768, 60000, 100000 };
printf("64 bit float into int16 still correct?\n");
for (int i = 0; i < 6; i++) {
int16_t stored = (int16_t)(int32_t)v[i];
printf("%-14.0f %-14d %s\n",
v[i], stored, stored == v[i] ? "yes" : "NO");
}
return 0;
}
64 bit float into int16 still correct?
5000 5000 yes
20000 20000 yes
32767 32767 yes
32768 -32768 NO
60000 -5536 NO
100000 -31072 NO
remember
- A 64 bit float can hold a value that a 16 bit integer cannot. Nothing checked whether this one could.
- Ariane 4 never produced a value past the third row, because it flew a shallower, slower trajectory.
- Ariane 5 was faster. Its horizontal velocity was several times larger, and the value went past the limit.
- The conversion was one of seven. Four had range checks. Three did not, on the argument that they could never overflow, based on Ariane 4 flight data.
The reasoning behind leaving three unchecked was not laziness. It was a documented analysis, using the wrong rocket's numbers.
What one unchecked conversion set off.
1
The conversion raises an operand error. Nothing catches it.
2
The inertial reference unit shuts itself down and puts a diagnostic pattern on its output.
3
The backup unit is running identical software on identical data. It had already failed, moments earlier, for the same reason.
4
The on board computer reads the diagnostic pattern as though it were flight data.
5
It commands a full deflection of the nozzles to correct an attitude error that does not exist.
6
The vehicle turns sharply into the airflow, begins to break up, and the self destruct fires.
Step three is the one worth dwelling on. Redundancy protects against a part failing, not against a decision being wrong.
And the piece of code that failed had no job to do.
The function computing that value was part of the alignment routine, which prepares the guidance system before launch.
On Ariane 4 it kept running for some seconds after lift off, so that a late hold in the countdown would not require a full realignment.
remember
- Ariane 5 had no such requirement. The routine served no purpose after lift off at all.
- It was left in because it worked on Ariane 4, and removing working code felt like the larger risk.
- The value it was computing was never used by anything. It was calculated, converted, and thrown away.
Roughly 370 million dollars of launcher and payload, over a number nobody read.
What it would have taken.
/* 07-guard.c */
#include <stdio.h>
#include <stdint.h>
int to_int16(double v, int16_t *out)
{
if (v < -32768.0 || v > 32767.0) return 0;
*out = (int16_t)v;
return 1;
}
int main(void)
{
double v[] = { 20000, 32768, 100000 };
int16_t out;
for (int i = 0; i < 3; i++)
if (to_int16(v[i], &out))
printf("%-8.0f stored as %d\n", v[i], out);
else
printf("%-8.0f refused, out of range\n", v[i]);
return 0;
}
20000 stored as 20000
32768 refused, out of range
100000 refused, out of range
remember
- One comparison before the cast. That is the entire difference.
- The point is not the check. It is that the function now has to say whether it worked, so the caller has to decide what to do.
- A conversion that cannot fail needs no return value. A conversion that can fail and does not report it is the pattern behind most of this arc.
- Newer languages make this the default. Rust makes you handle a failed conversion, and Python simply refuses to have a fixed width.
Ask what the caller should do when it refuses. There is no good answer in flight software, and that is the real engineering problem.
Four stories went on the board in Class 1.
| failure | mechanism | status |
| 0.1 + 0.2 | one tenth has no exact binary form | closed, Class 6 |
| Ariane 5 | 64 bit float into a 16 bit integer, unchecked | closed, today |
| the wallpaper crash | 271 into 8 bits, and nothing read the flag | half open |
| the 2038 problem | a 32 bit signed counter reaching its top | half open |
You know the mechanism of all four now. What you still cannot explain is why the last two said nothing. That needs a flag, in hardware you have not built yet.
Class 20. They build the adder, feed it an overflow, and find the flag sitting there unread.
A conversion is a promise that
a value fits somewhere else.
Nobody checks the promise unless you write the check. The language will not, the compiler will not by default, and the hardware cannot.
Next session: what else a pattern can mean, when it is not a number at all.
Send off: rebuild programs 3 and 5 with -Wconversion and -fsanitize=undefined, and come back with what each one told you.