Feed the program currently published on this page the string zz and ask it to read it as octal, and it answers 666. Ask it to convert the decimal number 0 to binary and it prints nothing at all — not 0, not an error, just an empty line. Neither failure produces a warning, and neither is unusual: they are what happens when a converter validates nothing and uses the wrong loop.
This rewrite replaces that program. You get the two algorithms that underlie every base conversion, a single pair of functions that handles every base from 2 to 36 instead of six near-duplicates, the standard-library call that already does half the job, and a captured record of exactly what the old code did wrong. If you are new to C, the Hello World walkthrough is the better starting point. Every program on this page was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 (-std=c11 -Wall -Wextra, zero warnings), with a clean AddressSanitizer and UndefinedBehaviorSanitizer run on the corrected version; the C23 %b examples were checked on GCC 13.3 and GCC 14.2. Every output block, warning and sanitizer report is captured verbatim.
What Is Number Base Conversion?
Number base conversion changes how a number is written, not what it is. The value 156 in base 10, 234 in base 8, and 10011100 in base 2 are the same quantity in three notations. Converting between them needs only two operations: repeated division by the target base to produce digits, and repeated multiplication by the source base to consume them.
That second sentence is the part most tutorials skip. A program offering “binary to decimal”, “decimal to binary”, “octal to decimal”, “decimal to octal”, “octal to binary” and “binary to octal” looks like six problems. It is two, applied in different orders — and once you see that, the six functions collapse into a pair that also handles hexadecimal, base 36 and anything in between.
The Two Algorithms
Decimal to any base: divide and collect remainders
Divide by the target base. The remainder is a digit. Replace the number with the quotient and repeat until the quotient is zero. The digits come out least-significant first, so you reverse them — or, as below, write them into a buffer backwards and copy them out forwards.
Converting 156 to base 8:
156 / 8 = 19 remainder 4
19 / 8 = 2 remainder 3
2 / 8 = 0 remainder 2
Read the remainders bottom to top: 234.
The loop must be a do/while, not a while. A while (value > 0) loop never executes when the value is already zero, which is precisely the bug in the published version — it emits an empty string for input 0. A do/while runs its body once before testing, so zero produces the single digit 0. That one-keyword difference is the whole fix.
Any base to decimal: multiply and add
Start with an accumulator of zero. For each digit left to right, multiply the accumulator by the base and add the digit’s value. This is Horner’s method, and it needs no exponents, no pow() and no floating point:
start = 0
0 x 8 + 2 = 2
2 x 8 + 3 = 19
19 x 8 + 4 = 156
Notice that 2 and 19 appear in both traces. That is not a coincidence — the multiply-and-add chain is the divide-and-collect chain run backwards, which is why one algorithm undoes the other.
A Converter for Every Base From 2 to 36
Here is the whole thing. Two functions replace the original six, and they handle hex and base 36 for free.
/* baseconv.c - convert between any bases 2..36 */
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char DIGITS[] = "0123456789abcdefghijklmnopqrstuvwxyz";
/* Format `value` in `base`, writing into out[0..outsz-1].
Returns out on success, NULL if the base is invalid or the buffer is too small. */
char *to_base(unsigned long value, int base, char *out, size_t outsz)
{
if (base < 2 || base > 36 || out == NULL || outsz == 0) {
return NULL;
}
/* Worst case is base 2: one character per bit, plus the terminator. */
char tmp[sizeof(unsigned long) * CHAR_BIT + 1];
size_t n = 0;
/* do-while, not while: a do-while runs once even when value is 0,
which is what makes to_base(0, ...) produce "0" instead of "". */
do {
tmp[n++] = DIGITS[value % (unsigned long)base];
value /= (unsigned long)base;
} while (value != 0);
if (n + 1 > outsz) {
return NULL;
}
for (size_t i = 0; i < n; i++) {
out[i] = tmp[n - 1 - i]; /* tmp holds the digits backwards */
}
out[n] = '\0';
return out;
}
/* Parse `s` as a number in `base`. Returns false and leaves *out untouched
if the string is empty, contains a character that is not a digit in that
base, has trailing text, is negative, or overflows. */
bool from_base(const char *s, int base, unsigned long *out)
{
if (s == NULL || *s == '\0' || base < 2 || base > 36 || out == NULL) {
return false;
}
/* strtoul accepts a leading sign and skips leading whitespace. Reject both, so that " 1010" and "1010 " are treated the same way. */
if (s[0] == '-' || s[0] == '+' || isspace((unsigned char)s[0])) {
return false;
}
errno = 0;
char *end;
unsigned long value = strtoul(s, &end, base);
if (end == s) return false; /* no digits at all */
if (*end != '\0') return false; /* trailing garbage: "1012" in base 2 */
if (errno == ERANGE) return false; /* too large for unsigned long */
*out = value;
return true;
}
Driving it through the six original conversions plus four the old program could not do:
Output:
base 2 -> base 10: 1010 -> 10
base 10 -> base 2: 10 -> 1010
base 8 -> base 10: 777 -> 511
base 10 -> base 8: 511 -> 777
base 8 -> base 2: 777 -> 111111111
base 2 -> base 8: 1010 -> 12
base 10 -> base 2: 0 -> 0
base 10 -> base 8: 0 -> 0
base 10 -> base 16: 255 -> ff
base 16 -> base 10: ff -> 255
base 36 -> base 10: zz -> 1295
And the inputs that used to slip through:
Output:
Inputs the old program accepted silently:
from_base("zz", 8) -> rejected
from_base("99", 8) -> rejected
from_base("hello", 2) -> rejected
from_base("2222", 2) -> rejected
from_base("", 2) -> rejected
from_base("-5", 2) -> rejected
from_base("1010 ", 2) -> rejected
zz is rejected as octal and accepted as base 36, where it legitimately means 1295. That is the difference between validating against the base and not validating at all.
Three details in from_base are worth calling out, because each one closes a hole the original left open. Checking *end != '\0' catches trailing garbage — without it, "1012" parsed as binary would quietly return 2, having stopped at the 2. Checking errno == ERANGE catches overflow. And rejecting a leading - is necessary because strtoul does not: it negates the result using unsigned wraparound, so "-1" comes back as 18446744073709551615 rather than an error.
What from_base accepts. Digits valid in the given base, in either case — "FF" and "ff" are both 255 in base 16. Nothing else: no sign, no surrounding whitespace, no trailing text, no value larger than unsigned long. One inherited behaviour is worth knowing about: strtoul consumes a 0x prefix when the base is 16, so "0x10" is accepted and returns 16. That is the C library’s documented behaviour rather than a bug, but if your input format forbids the prefix you have to reject it yourself. Output from to_base is always lowercase, because the DIGITS table is.
C Already Does Half of This For You
Before writing any conversion code, it is worth knowing how much of the job the standard library already does.
Parsing from any base is solved. strtol and strtoul take a base argument from 2 to 36 and have since C89. The from_base function above is a thin validation wrapper around one, not a reimplementation.
Three output bases are built into printf.
#include <stdio.h>
int main(void) { int v = 255; printf("dec=%d oct=%o hex=%x HEX=%X\n", v, v, v, v); return 0; }
Output:
dec=255 oct=377 hex=ff HEX=FF
That is decimal-to-octal and decimal-to-hex done — two of the original program’s six menu options, in one line each.
C23 adds binary. The %b conversion specifier is new in C23 and makes decimal-to-binary a printf call too:
#include <stdio.h>
int main(void) { printf("%b\n", 42u); printf("%#b\n", 42u); return 0; }
Output:
101010
0b101010
Compiled with gcc -std=c2x on GCC 13.3 and gcc-14 -std=c23 on GCC 14.2 — both accepted it and both produced the output above, against glibc 2.39. That pairing matters: %b is interpreted by your C library’s printf, not by the compiler, which only checks the format string. A new compiler in front of an older C library will compile the call and print the wrong thing. Check both before relying on it.
| Conversion | Standard-library answer | Since |
|---|---|---|
| Any base to integer | strtol / strtoul with a base argument | C89 |
| Integer to octal | printf("%o", v) | C89 |
| Integer to hex | printf("%x", v) | C89 |
| Integer to binary | printf("%b", v) | C23 |
| Integer to base 3, 7, 36… | Nothing — write to_base | — |
So the only conversion genuinely worth hand-writing is integer-to-arbitrary-base. Everything else already exists, and itoa() — which many tutorials reach for — is not in any C standard and is not portable.
What the Published Program Actually Did
Every result below is a captured run of the code as it appeared on this page, compiled with GCC 13.3.
It does not build at all with the command most beginners try, because pow() lives in the math library:
/usr/bin/ld: /tmp/ccxLQsEt.o: in function `binaryToDecimal':
old.c:(.text+0x3b1): undefined reference to `pow'
/usr/bin/ld: /tmp/ccxLQsEt.o: in function `octalToDecimal':
old.c:(.text+0x527): undefined reference to `pow'
collect2: error: ld returned 1 exit status
The fix is to add -lm, and the article never mentioned it. (Our math.h reference covers the linking rule in more detail.) With -lm supplied, it compiles cleanly under -Wall -Wextra — which is the problem. Every failure below is silent.
| Input | Menu option | What it printed | What it should have done |
|---|---|---|---|
0 | 2 (decimal to binary) | (empty line) | print 0 |
0 | 4 (decimal to octal) | (empty line) | print 0 |
-5 | 2 (decimal to binary) | (empty line) | reject the input |
hello | 1 (binary to decimal) | 0 | reject the input |
2222 | 1 (binary to decimal) | 0 | reject the input |
99 | 3 (octal to decimal) | 81 | reject the input |
zz | 3 (octal to decimal) | 666 | reject the input |
32 × 1 | 1 (binary to decimal) | -2147483648 | 4294967295 |
The zero case in full, exactly as the terminal shows it:
Number Base Conversion Program
1. Binary to Decimal
2. Decimal to Binary
3. Octal to Decimal
4. Decimal to Octal
5. Octal to Binary
6. Binary to Octal
Enter your choice: Enter decimal number: Binary equivalent:
The zz result is the most instructive. octalToDecimal computes (octal[i] - '0') * pow(8, j) without ever asking whether the character is an octal digit. For 'z', 'z' - '0' is 74, so the function faithfully computes 74 × 8 + 74 = 666 and returns it as though it meant something.
The last row is a different failure. binaryToDecimal accumulates into an int, so 32 binary ones overflow signed arithmetic. Signed overflow is undefined behaviour in C; this machine produced -2147483648, and another compiler or optimisation level is entitled to produce something else. Using unsigned long, as the rewrite does, makes the arithmetic well defined and raises the ceiling.
Finally, the input buffer. scanf("%s", input) against char input[50] has no bound, so AddressSanitizer catches an 80-character input immediately:
==153==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f6715300092
WRITE of size 81 at 0x7f6715300092 thread T0
#0 0x7f6717491acf in scanf_common
Address 0x7f6715300092 is located in stack of thread T0 at offset 146 in frame
#0 0x5620c95c82f8 in main /home/claude/base/old.c:13
SUMMARY: AddressSanitizer: stack-buffer-overflow in scanf_common
The fix is a field width: scanf("%49s", input), or better, fgets. The same sanitizer run against the rewritten program produced zero output.
Why pow() Does Not Belong in a Base Converter
The original uses pow(2, j) and pow(8, j) to weight each digit. The common objection is that floating-point pow returns inexact results for integer powers. I tested that claim and it did not hold up here.
Comparing (long)pow(2, j) against 1L << j for every j from 0 to 30, and pow(8, j) against an integer product for j from 0 to 10, glibc’s pow was exact in every case — zero mismatches. The rounding argument is real in principle and was not reproducible on this toolchain, so it is not the reason to avoid pow here.
The two real reasons are range and speed.
Range fails first. pow(2, 31) returns 2147483648.0, which does not fit in an int; assigning it produced 2147483647 on this machine, and the conversion is undefined behaviour rather than a defined saturation. That happens at a 32-digit binary string — not an exotic input, and exactly the case the failure table above ends on.
Speed is the larger surprise.
How this was measured
| Setting | Value |
|---|---|
| Date tested | September 2026 |
| Machine | Single-core Linux VM, Ubuntu 24.04 |
| Compiler | GCC 13.3, -O2 -std=c11 |
| Input | One fixed 31-character binary string |
| Iterations | 200,000 warm-up discarded, then mean over 5,000,000 calls |
| Statistic | Mean wall-clock nanoseconds per call |
| Correctness check | Both versions asserted to return 1450748586 before timing |
| Not captured | No median, no percentiles, no CPU-frequency control |
Both functions are marked __attribute__((noinline)) and their results accumulate into a volatile sink, because without that -O2 hoists the whole computation out of the loop and the benchmark measures nothing.
| Implementation | Per call | Relative |
|---|---|---|
pow(2, j) per digit | 232 ns | 18× slower |
Horner (d = d * 2 + digit) | 13 ns | baseline |
Reproduce it. The two figures come from these commands:
# build with the optimiser on; -lm is only needed by the pow() version
gcc -O2 -std=c11 -D_POSIX_C_SOURCE=199309L bench.c -o bench -lm
# confirm both implementations agree before timing anything
./bench # prints the shared result, then the per-call means
The harness marks both functions __attribute__((noinline)), accumulates into a volatile sink, discards 200,000 warm-up calls and averages the next 5,000,000. Drop either the noinline or the sink and -O2 hoists the whole loop, at which point the benchmark measures nothing and reports it confidently.
Across three runs the ratio held between 17.8× and 18.4×. A floating-point library call per digit, plus a double-to-int conversion per digit, to do work that is one integer multiply and one add. The Horner version is also shorter, has no -lm dependency and cannot overflow a double. For base-2 specifically, the multiply is a shift — see our guide to bitwise and shift operators in C for why the compiler will make that substitution for you.
A quick review question that catches more bugs than it should: what does this function do with zero, and what does it do with garbage? Run it on both before you run it on anything else. The converter this page used to carry passed every test its author tried and failed both of these — it printed an empty line for zero and returned 666 for the string zz. Neither is a crash, so neither shows up in testing that only checks whether the program ran. A function that cannot say no is a function that will eventually say something wrong.
Key Takeaways
- Six conversion functions are really two algorithms. Divide-and-collect-remainders produces digits; multiply-and-add consumes them. Write them once and every base from 2 to 36 works.
- Use
do/while, notwhile, in the division loop. Awhileloop emits an empty string for input 0 — the exact bug in the version this replaces. - Validate against the base, or you will convert nonsense silently. Reading
zzas octal returned 666 rather than an error, because nothing checked whether the characters were octal digits. strtolandstrtoulalready parse any base from 2 to 36, and have since C89. Wrap them for validation rather than reimplementing them.printfalready outputs octal and hex with%oand%x, and C23 adds binary with%bwhere your C library implements it — three of the original six menu options need no code at all.- Skip
pow()for digit weights. On this machine it was 18× slower than Horner’s method, it drags in-lm, and it breaks at 2³¹ where integer arithmetic keeps working. Integer types overflow too — Horner’s method removes the floating-point step, not the need to size your result type and check the range. - Bound your input.
scanf("%s", buf)against a fixed array is a buffer overflow waiting for a long line; usescanf("%49s", buf)orfgets.
Frequently Asked Questions
Conclusion
The program this page used to carry was not badly written so much as unvalidated. It handled the inputs its author tried and produced confident nonsense for everything else, which is the failure mode that survives longest in tutorial code — it never crashes, so nobody investigates. Converting zz to 666 is funny; converting a mistyped serial number to a plausible-looking integer in production is not.
The wider lesson is worth more than the conversion routine. Before hand-writing a standard operation, check whether the standard library already does it, and when you do write it, decide explicitly what your function should refuse. The rest of our C programming articles follow the same approach: working code, captured output, and the failure cases spelled out.
What I Could Not Verify
Everything on this page was produced on one machine: a single-core Ubuntu 24.04 VM with GCC 13.3, plus GCC 14.2 for the second C23 %b check. No other compiler or platform was tested — the pow exactness result in particular is a property of this glibc build and could differ elsewhere, which is why it is reported as a negative result rather than a general rule. The signed-overflow output of -2147483648 is undefined behaviour and should not be relied on; a different compiler or optimisation level may print something else. The timing ratio held across three runs on one machine; the ordering should transfer, the nanosecond figures will not.



