Introduction
Chapter 2 revisits several concepts already seen previously, such as types and type conversions, variables, constants, expressions, and operator precedence. The difference is that these topics are now explored at a deeper level, as if we were investigating the details of how they really work.
In this post, I will focus on the parts I consider most interesting and specific to the reading: some exercises, certain implementation insights, and the bitwise operators.
Type conversions
Types in C are not just declarations — they determine how you interpret data and how you reconstruct values from them.
To the computer, a number typed on the keyboard is not born as a number.
When we write 123, the program receives three characters: '1', '2', and '3'. Transforming that sequence into a single integer value is the program's responsibility.
The algorithms atoi and lower, presented by K&R, show how conversions can be used in programs.
Example: atoi (simplified)
ASCII to integer (atoi) is a classic example in computer science. Honestly, I found the algorithm fascinating — from it, a sequence of ideas about data conversion started to emerge in my head, showing how computers are essentially machines for transforming representations.
Although the algorithm is simple, it is important to recognize that the idea behind it is not obvious.
/* atoi: convert s to integer */
int atoi(char s[])
{
int i, n;
n = 0;
for (i = 0; s[i] >= '0' && s[i] <= '9'; ++i)
n = 10 * n + (s[i] - '0');
return n;
}
The algorithm repeats the idea from chapter 1 of converting a value from the ASCII table to the corresponding integer by subtracting '0'. At the same time, it brings an important arithmetic insight: the number is not converted all at once, but built incrementally in base 10 at each iteration. That is why the line n = 10 * n + (s[i] - '0') is necessary.
This construction may seem strange at first glance to those not yet familiar with this type of arithmetic manipulation, especially involving ASCII and strings. What happens is that several layers are occurring at the same time: reading characters, converting each digit, and reconstructing the final number in base 10.
Example: lower
The goal of the example is not to show that a character becomes a number, but that, in C, characters can be treated as integer values during expressions. Before performing comparisons and arithmetic operations, the compiler automatically converts values of type char to int, allowing them to be manipulated through their ASCII codes.
/* lower: convert c to lower case; ASCII only */
int lower(int c)
{
if (c >= 'A' && c <= 'Z')
return c + 'a' - 'A';
else
return c;
}
The most important line is: return c + 'a' - 'A';.
Before applying this expression, the function checks whether the character is between 'A' and 'Z'. This ensures that only uppercase letters are converted. Any other character is returned unchanged.
In the ASCII table, uppercase and lowercase letters occupy different positions. The letter 'A' has the code 65, while 'a' has the code 97. The difference between them is 32 positions, and that same difference repeats for all letters of the alphabet.
'A' = 65 'a' = 97 → difference = 32
'B' = 66 'b' = 98 → difference = 32
...
'Z' = 90 'z' = 122 → difference = 32
As an example, suppose the input is the letter 'C', whose ASCII code is 67:
67 + (97 - 65)
67 + 32
99
The code 99 corresponds to the character 'c'. That is how the function converts an uppercase letter to its lowercase equivalent using only arithmetic operations on ASCII codes.
Exercise 2-3. htoi(s)...
Write a function htoi(s), which converts a string of hexadecimal digits (including an optional 0x or 0X) into its equivalent integer value. The allowable digits are 0 through 9, a through f, and A through F.
The exercise asks for something similar to the previous example, atoi, but now instead of converting a decimal string (numeric ASCII) to integer, the function must convert a hexadecimal string to its corresponding integer value.
The logic is essentially the same, but with an extra layer of numeric base interpretation. For me, this exercise works almost like a natural extension of atoi, and gives the feeling that we are starting to understand how different data representations can be converted between each other in a systematic way.
It is worth saying that, although the logic is similar to atoi, this exercise proved to be more challenging, mainly due to the nuances that appear along the way — from recognizing the hexadecimal prefix to converting characters beyond '0' to '9'.
Example: if the input is 0x1A3 - the output should be 419.
Solution
/*Exercise 2-3. Write a function htoi(s), which converts a string of hexadecimal digits
(including an optional 0x or 0X) into its equivalent integer value. The allowable digits are 0
through 9, a through f, and A through F.*/
#include <stdio.h>
int htoi(char s[]);
int main() {
printf("%d\n", htoi("0x1A3")); /* should print 419 */
printf("%d\n", htoi("ff")); /* should print 255 */
printf("%d\n", htoi("0xFF")); /* should print 255 */
return 0;
}
int htoi(char s[]) {
int i;
int n = 0;
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
i = 2;
}
else {
i = 0;
}
for (; (s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f') || (s[i] >= 'A' && s[i] <= 'F'); i++) {
if (s[i] >= '0' && s[i] <= '9') {
n = n * 16 + (s[i] - '0');
}
else if (s[i] >= 'a' && s[i] <= 'f') {
n = n * 16 + (s[i] - 'a' + 10);
}
else if (s[i] >= 'A' && s[i] <= 'F') {
n = n * 16 + (s[i] - 'A' + 10);
}
}
return n;
}
What caught my attention the most in this exercise was the line (and yes, this is a single line):
for (; (s[i] >= '0' && s[i] <= '9') ||
(s[i] >= 'a' && s[i] <= 'f') ||
(s[i] >= 'A' && s[i] <= 'F'); i++)
From it, I started to see how expressions in C are more powerful than they appear at first glance: they can be combined to describe quite complex conditions in a direct way.
This is probably the most "loaded" part of the exercise solution. The rest is basically an extension of the atoi idea, or the use of the same conversion trick between ASCII values and their numeric equivalents, as in the lower function.
Above it we have:
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
i = 2;
}
else {
i = 0;
}
The idea is simple, and meets a condition of the exercise:
including an optional 0x or 0X
Basically, we are checking whether the string starts with the hexadecimal prefix. If the first two characters are '0' and 'x' (or 'X'), then the index i is adjusted to 2, skipping those characters. Otherwise, we start from index 0.
The final lines n = n * 16 + (s[i] - '0'); and n = n * 16 + (s[i] - 'a' + 10); follow the same logic as atoi. The variable n is multiplied by 16 because we are working in hexadecimal base, that is, a base with 16 possible symbols (0–9 and A–F).
Next, the current character is converted to its numeric value. In the case of '0' to '9', this is done with (s[i] - '0'), which transforms the ASCII character into its corresponding integer value.
For letters, the idea is similar: (s[i] - 'a' + 10) transforms 'a' into 10, 'b' into 11, and so on. This works because we subtract 'a', normalizing the range to start at 0, and then add 10 to fit into the hexadecimal representation.
Considering the example input 0x1A3, the construction of the number occurs as follows:
i = 2: s[2] = '1' → n = 16 * 0 + 1 = 1
i = 3: s[3] = 'A' → n = 16 * 1 + 10 = 26
i = 4: s[4] = '3' → n = 16 * 26 + 3 = 419
Bitwise operators 💀
When I read the bitwise operators section in K&R, I found the content dense, compact, and distant from everyday programming reality. After all, manipulating data bit by bit is not something most programmers do frequently.
To summarize quite a bit, there are six bitwise operators; they operate on integer values. While operators like +, -, and * work with the numeric value of an integer, the bitwise operators work with its binary representation, manipulating or comparing each bit individually.
&(AND) compares two numbers bit by bit and keeps only the bits that are 1 in both.|(OR) compares bit by bit and sets 1 where at least one of the bits is 1.^(XOR) sets 1 only where the bits differ from each other.~(NOT) inverts all bits of the number.<<(shift left) shifts the bits to the left, filling with zeros on the right, equivalent to multiplying by powers of 2.>>(shift right) shifts the bits to the right, discarding final bits, equivalent to dividing by powers of 2 (in many cases).
Of the entire chapter, this was, for me, the most difficult part. Understanding the behavior of each operator is not the biggest obstacle; the real challenge is developing the intuition necessary to think of solutions using bit manipulation. This requires familiarity with binary representation, practice, and often a great deal of patience and creativity.
In the book, they are presented in an extremely direct way. K&R seems to assume that the reader already has some familiarity with bit-level operations, which makes this section particularly challenging for those having their first contact with the subject. At the same time, many programmers spend a good part of their career without needing to use bitwise operators directly.
After reviewing binary operations, I went over how each operator works and observed how they act on the binary representation of numbers. Then I asked myself, sincerely: what is possible to do with this? To my surprise, I discovered that these operators have several important applications in computing.
To start the examples, the simplest one to understand is checking whether a number is even or odd using bitwise, instead of the classic remainder operator %. Odd numbers share a characteristic when represented in binary: the last bit is always 1. The goal here is precisely to check whether that last bit is 1 or 0.
The number 1 in binary is 00000001. We then perform an AND (&) operation with the number x we want to test. If the result is different from zero, the number is odd; if it is zero, it is even. Example:
5 = 0101
1 = 0001
------
& = 0001 → different from 0 → odd
In network protocols, for example, a single byte can carry several pieces of information at the same time. Some bits can indicate that a packet is an ACK, others can represent SYN or FIN. Imagine:
10010110
||||||||
|||||||└─ ACK
||||||└── SYN
|||||└── FIN
...
In this case, the bitwise operators allow extracting or modifying each of these pieces of information individually. This is incredibly efficient.
Finally, another common example is the use of flags: instead of storing several separate boolean variables, a single integer can hold dozens of different states, where each bit represents a configuration turned on or off. An example of prototype code for Unix-style permissions:
int perms = 0; /* 0000 nothing */
perms = perms | READ; /* 0001 add read */
perms = perms | WRITE; /* 0011 add write */
/* ... later ... */
if (perms & EXECUTE) { ... } /* check: false, 0000 */
if (perms & READ) { ... } /* check: true, 0001 */
/* ... later ... */
perms = perms & ~WRITE; /* 0001 remove write */
Below, I include two small party tricks involving bitwise operators that I asked Claude to generate. They do not represent real applications, but help show how operations that, at first glance, seem strange can produce interesting results.
Party tricks
/* ===========================================================================
* bitwise_tricks.c
*
* Two of the most fun/impressive "party tricks" you can pull off with
* bitwise operators in C. Companion to K&R Chapter 2 (2.9, Bitwise Ops).
*
* Refresher:
* & AND -> 1 only if BOTH bits are 1 (clear / test bits)
* | OR -> 1 if EITHER bit is 1 (set bits)
* ^ XOR -> 1 if bits DIFFER (toggle bits)
* ~ NOT -> flips every bit
* << / >> -> shift left/right (multiply / divide by powers of 2)
*
* Both tricks below rely on one single property of XOR:
* x ^ x = 0 (a value XORed with itself cancels to zero)
* x ^ 0 = x (XOR with zero changes nothing)
* XOR is commutative and associative (order doesn't matter)
*
* Compile: cc -Wall -o bitwise_tricks bitwise_tricks.c
* Run: ./bitwise_tricks
* ===========================================================================
*/
#include <stdio.h>
/* ===========================================================================
* TRICK 1: Swap two variables with no temp variable, using XOR
*
* XOR is its own inverse: a ^ b ^ b == a and a ^ b ^ a == b
*
* a = a ^ b; // a now holds "the difference" between original a and b
* b = a ^ b; // b becomes (orig_a ^ orig_b) ^ orig_b = orig_a
* a = a ^ b; // a becomes (orig_a ^ orig_b) ^ orig_a = orig_b
*
* Net effect: a and b swap, with zero extra memory.
*
* NOTE: fun to know, but use a temp variable in real code -- it's clearer,
* and this trick FAILS if a and b are the same memory location (XOR-ing a
* value with itself zeroes it out, so you'd lose the value entirely).
* ===========================================================================
*/
void demo_xor_swap(void)
{
int a = 7, b = 42;
printf("Before swap: a = %d, b = %d\n", a, b);
a = a ^ b;
b = a ^ b;
a = a ^ b;
printf("After swap: a = %d, b = %d\n", a, b);
}
/* ===========================================================================
* TRICK 2: Find the ONE number that appears once in an array where every
* other number appears exactly twice -- using only XOR, one pass, no
* extra memory
*
* Recall: x ^ x = 0, x ^ 0 = x, and XOR is commutative/associative -- so
* the order you XOR things in doesn't matter.
*
* If you XOR every element of the array together, every PAIR of
* duplicates cancels itself out to 0 (since x ^ x = 0), and whatever
* survives at the end is the lone, unpaired number.
*
* Example: {4, 1, 2, 1, 2}
* 4 ^ 1 ^ 2 ^ 1 ^ 2
* = 4 ^ (1 ^ 1) ^ (2 ^ 2) <- regroup, order doesn't matter
* = 4 ^ 0 ^ 0
* = 4
* ===========================================================================
*/
void demo_find_unique(void)
{
int arr[] = {4, 1, 2, 1, 2}; /* 4 is the only one without a pair */
int n = sizeof(arr) / sizeof(arr[0]);
int i, result = 0;
for (i = 0; i < n; i++) {
result ^= arr[i]; /* duplicates cancel out as we go */
}
printf("Array: {4, 1, 2, 1, 2} -> unique element = %d\n", result);
}
/* ===========================================================================
* main -- runs each trick with a header, so the output reads like a
* short guided demo.
* ===========================================================================
*/
int main(void)
{
printf("=== 1. XOR swap (no temp variable) ===\n");
demo_xor_swap();
printf("\n");
printf("=== 2. Find the unique element via XOR ===\n");
demo_find_unique();
printf("\n");
return 0;
}
Conclusion
After this chapter, I started to see numbers, characters, and even bits as different representations of the same information. Perhaps that is the greatest contribution of K&R: showing that, before learning new tools, it is necessary to understand how the computer sees data.
I confess that the bitwise section was particularly mind blowing. Until then, a byte was just a number in my head. Discovering that it can represent eight completely independent states changed quite a bit how I came to see data. The Unix permissions example made that very clear: instead of creating several variables to store true or false information, a single byte can store all of them at the same time. I found it very elegant to realize that, in low-level programming, practically no bit is wasted. Each one can carry different information, and that is precisely what makes this type of representation so intelligent and efficient.