This markdown uses the high-level language of R to investigate the lowest levels of computing: logic gates. If you can make logic gates, and wire them up together in any way you please, then you can build a general-purpose computer like the one you’re probably reading this document on.

We start by creating the basic logic gates an examining their truth tables. We then show that each of these logic gates can be created from NAND (Not AND), and then put these circuits to work by doing bitwise arithmetic.

Basic operations

The gates we will focus on are:
- NOT
- AND
- OR
- XOR
- NAND
- NOR

We will start out with the concept of truth. For the sake of this markdown, we will focus on the values TRUE and FALSE. These will be represented by the computer as 1 and 0, respectively. A circuit is investigated using a truth table. In a circuit that has two inputs, a and b, and one output, “out.” We can make what is called a truth table. This is a table where the columns are, in this case, a, b, and out. Then we have rows that represent all the combinations of a and b (a = 0, b = 1; a = 1, b = 1; a = 1, b = 0; a = 0, b = 0), and then the final column telling us what the output is given the values of inputs a and b.

Below is a truth table initialized with columns a, b, and out. We have not put a and b through any circuit yet, so out is currently NA.

library(tidyverse)
## ── Attaching packages ─────────────────────────────────────── tidyverse 1.3.2 ──
## ✔ ggplot2 3.3.6     ✔ purrr   0.3.4
## ✔ tibble  3.1.8     ✔ dplyr   1.0.9
## ✔ tidyr   1.2.0     ✔ stringr 1.4.1
## ✔ readr   2.1.2     ✔ forcats 0.5.2
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
a <- c(0, 0, 1, 1)
b <- c(0, 1, 0, 1)
truth_table <- tibble(a = a, b = b, out = NA)
truth_table
## # A tibble: 4 × 3
##       a     b out  
##   <dbl> <dbl> <lgl>
## 1     0     0 NA   
## 2     0     1 NA   
## 3     1     0 NA   
## 4     1     1 NA

Now we begin with the basic circuits. We start with NOT, which flips a bit from 1 to 0 or 0 to 1.

NOT <- function(a) {
  return(as.numeric(!a))
}

c(1, 0)
## [1] 1 0
NOT(c(1, 0))
## [1] 0 1

Now we move to AND, which only outputs 1 if a = 1 and b = 1.

AND <- function(a, b) {
  return(as.numeric(a & b))
}

truth_table$out <- AND(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     0
## 3     1     0     0
## 4     1     1     1

Now we go to or, which outputs 1 if a = 1 or b = 1, or both equal 1.

OR <- function(a, b) {
  return(as.numeric(a | b))
}

truth_table$out <- OR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     1
## 3     1     0     1
## 4     1     1     1

Now we move to exclusive or, or XOR, which only outputs 1 if a = 1 or b = 1, but not if both equal 1.

XOR <- function(a, b) {
  return(as.numeric(xor(a, b)))
}

truth_table$out <- XOR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     1
## 3     1     0     1
## 4     1     1     0

Now we move to NAND, or Not AND. This is literally the opposite of AND. So the output is 1 unless a = 1 and b = 1.

NAND <- function(a, b) {
  return(as.numeric(!(a & b)))
}

truth_table$out <- NAND(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     1
## 2     0     1     1
## 3     1     0     1
## 4     1     1     0

Below is NOR, or Not OR. It is literally the opposite of OR. If only a = 1 or only b = 1, then the output is 0. Else the output is 1.

NOR <- function(a, b) {
  return(as.numeric(!(a | b)))
}

truth_table$out <- NOR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     1
## 2     0     1     0
## 3     1     0     0
## 4     1     1     0

Basic operations from NAND

A key insight in computing is that you can make any logic gate from NAND gates. I will illustrate that here. We will go through all the logic gates we created in the previous section, and re-make them with NAND. We will compare the NAND-made circuits’ truth tables with the truth tables of the original circuits.

We start with NOT.

c(1, 0)
## [1] 1 0
NOT(c(1, 0))
## [1] 0 1
NAND_NOT <- function(a) {
  return(as.numeric(NAND(a, a)))
}

NAND_NOT(c(1, 0))
## [1] 0 1

Here is AND.

truth_table$out <- AND(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     0
## 3     1     0     0
## 4     1     1     1
NAND_AND <- function(a, b) {
  return(as.numeric(NAND_NOT(NAND(a, b))))
}

truth_table$out <- NAND_AND(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     0
## 3     1     0     0
## 4     1     1     1

Next is OR.

truth_table$out <- OR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     1
## 3     1     0     1
## 4     1     1     1
NAND_OR <- function(a, b) {
  nand_a <- NAND(a, a)
  nand_b <- NAND(b, b)
  nand_out <- NAND(nand_a, nand_b)
  return(nand_out)
}

truth_table$out <- NAND_OR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     1
## 3     1     0     1
## 4     1     1     1

And then XOR.

truth_table$out <- XOR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     1
## 3     1     0     1
## 4     1     1     0
NAND_XOR <- function(a, b) {
  nand_1 <- NAND(a, b)
  nand_2a <- NAND(a, nand_1)
  nand_2b <- NAND(b, nand_1)
  nand_out <- NAND(nand_2a, nand_2b)
  return(nand_out)
}

truth_table$out <- NAND_XOR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     0
## 2     0     1     1
## 3     1     0     1
## 4     1     1     0

And now NOR.

truth_table$out <- NOR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     1
## 2     0     1     0
## 3     1     0     0
## 4     1     1     0
NAND_NOR <- function(a, b) {
  nand_a <- NAND(a, a)
  nand_b <- NAND(b, b)
  nand_c <- NAND(nand_a, nand_b)
  nand_out <- NAND(nand_c, nand_c)
  return(nand_out)
}

truth_table$out <- NAND_NOR(truth_table$a, truth_table$b)
truth_table
## # A tibble: 4 × 3
##       a     b   out
##   <dbl> <dbl> <dbl>
## 1     0     0     1
## 2     0     1     0
## 3     1     0     0
## 4     1     1     0

Half-adder from NAND

A quick refresher on binary. We count in base 10, or decimal. 111 is 1*10^2 + 1*10^1 + 1*10^0. If we use base 2, we only have 0 and 1. Thus, in binary, 111 would be 1*2^2 + 1*2^1 + 1*10^0. Which is 7.

A half-adder is a basic circuit that takes in two boolean values as input, and outputs a sum and a carry. Think of it like when you would talk through addition by hand in elementary school. 9 + 1 is 0 carry the 1, is 10. But now we’re in binary. So we have 0. 0 + 1 is 1. 1 + 1 is “0 carry the 1” is 10. This allows the computer to do basic arithmetic. Oh, and we can code it up using only NAND gates.

Note that these wiring diagrams can get pretty complicated, so we’re going to utilize the fact that we’ve already made all the basic gates from NAND in the previous section, and we’ll bring them in here. This is called going up a level of abstraction, and it is another fundamental concept in computer science.

half_add_table <- tibble(a = truth_table$a, b = truth_table$b)
half_add_table
## # A tibble: 4 × 2
##       a     b
##   <dbl> <dbl>
## 1     0     0
## 2     0     1
## 3     1     0
## 4     1     1
Half_adder <- function(a, b) {
  carry <- NAND_AND(a, b)
  sum_out <- NAND_XOR(a, b)
  result <- tibble(carry = carry, sum = sum_out)
  return(result)
}

half_add_table <- bind_cols(half_add_table, Half_adder(half_add_table$a, half_add_table$b))
half_add_table
## # A tibble: 4 × 4
##       a     b carry   sum
##   <dbl> <dbl> <dbl> <dbl>
## 1     0     0     0     0
## 2     0     1     0     1
## 3     1     0     0     1
## 4     1     1     1     0

Full-adder from NAND

Now we make a one-bit full-adder. The best way to think of this is adding a and b as we did in the half-adder, but then adding in the carry if there was one, thereby “completing” the operation. Again, we will utilize the abstractions we made from NAND gates already to make the wiring a bit easier.

full_add_table <- tibble(a = c(0, 0, 0, 0, 1, 1, 1, 1),
                         b = c(0, 0, 1, 1, 0, 0, 1, 1),
                         carry_in = c(0, 1, 0, 1, 0, 1, 0, 1))

Full_adder <- function(a, b, carry_in = 0) {
  half_1 <- Half_adder(a, b)
  half_2 <- Half_adder(half_1$sum, carry_in)
  or_carry <- NAND_OR(a = half_1$carry, b = half_2$carry)
  result <- tibble(carry_out = or_carry, sum = half_2$sum)
  return(result)
}

full_add_result <- Full_adder(a = full_add_table$a, b = full_add_table$b, carry_in = full_add_table$carry_in)
bind_cols(full_add_table, full_add_result)
## # A tibble: 8 × 5
##       a     b carry_in carry_out   sum
##   <dbl> <dbl>    <dbl>     <dbl> <dbl>
## 1     0     0        0         0     0
## 2     0     0        1         0     1
## 3     0     1        0         0     1
## 4     0     1        1         1     0
## 5     1     0        0         0     1
## 6     1     0        1         1     0
## 7     1     1        0         1     0
## 8     1     1        1         1     1

Bitwise arithmetic

In order to do bitwise arithmetic the way computers do, we have to string together full-adders, such that the carry from the previous operation and be moved to the next bit. We’re going to go to 4 bits in this example. We will then count up in binary from 0000 until we get to 17, which will be outputted as 0000 with a carry of 1.

Four_bit_adder <- function(b1, b2, carry_in = 0) {
  a4 <- Full_adder(b1[4], b2[4], carry_in)
  a3 <- Full_adder(b1[3], b2[3], a4$carry_out)
  a2 <- Full_adder(b1[2], b2[2], a3$carry_out)
  a1 <- Full_adder(b1[1], b2[1], a2$carry_out)

  result <- list(carry = a1$carry_out, sum = c(a1$sum, a2$sum, a3$sum, a4$sum))
  return(result)
}

# Now we will count upward from 1 until we max out the 4 bits. Then we will have all 0s and then a carry. 
count <- c(0, 0, 0, 0)
carry <- 0
for(i in 1:17) {
  print(count)
  if(carry != 0) {
    print(paste("carry = ", carry))
  }

  curr <- Four_bit_adder(c(0, 0, 0, 1), count) # Adding 1 to count
  count <- curr$sum
  carry <- curr$carry
}
## [1] 0 0 0 0
## [1] 0 0 0 1
## [1] 0 0 1 0
## [1] 0 0 1 1
## [1] 0 1 0 0
## [1] 0 1 0 1
## [1] 0 1 1 0
## [1] 0 1 1 1
## [1] 1 0 0 0
## [1] 1 0 0 1
## [1] 1 0 1 0
## [1] 1 0 1 1
## [1] 1 1 0 0
## [1] 1 1 0 1
## [1] 1 1 1 0
## [1] 1 1 1 1
## [1] 0 0 0 0
## [1] "carry =  1"

We’ve just counted upwards in binary. To keep track of more complex operations, we’re going to write a function that converts binary output into decimal so we can understand it better.

ToDecimal <- function(b) {
  result <- lapply(seq(length(b)), function(i) {
    return(b[i]*2**(length(b) - i))
  }) %>% unlist() %>% sum()
  return(result)
}

ToBinary <- function(d) {
  if(d == 0) {
    return(0)
  }
  result <- NULL
  while(d > 1) {
    r <- d %% 2
    d <- d %/% 2
    result <- c(r, result)
  }
  result <- c(1, result)
  return(result)
}

test <- lapply(0:16, function(i) {
  b <- ToBinary(i)
  d <- ToDecimal(ToBinary(i))
  result <- list(input = i, binary = paste(b,collapse = ""), decimal = d)
}) %>% bind_rows()

test
## # A tibble: 17 × 3
##    input binary decimal
##    <int> <chr>    <dbl>
##  1     0 0            0
##  2     1 1            1
##  3     2 10           2
##  4     3 11           3
##  5     4 100          4
##  6     5 101          5
##  7     6 110          6
##  8     7 111          7
##  9     8 1000         8
## 10     9 1001         9
## 11    10 1010        10
## 12    11 1011        11
## 13    12 1100        12
## 14    13 1101        13
## 15    14 1110        14
## 16    15 1111        15
## 17    16 10000       16

Ok, now we’re going to make a 16 bit adder by stringing together 4 4-bit adders that we made previously. That will allow us to add numbers that sum up to 2^16 - 1, or 65535. You can see by the way this is set up, that we could in turn string together 4 16-bit adders to create a 64-bit adder, and so on for very large numbers. I’ll leave that as an exercise for the reader. Note that below, we have an extra function that converts a binary number to a sixteen bit representation for the adder In other words, the binary number 101 would become 0000000000000101.

ToSixteen <- function(b) {
  while(length(b) < 16) {
    b <- c(0, b)
  }
  return(b)
}

Sixteen_bit_adder <- function(b1, b2) {
  # Convert to sixteen bits
  b1 <- ToSixteen(b1)
  b2 <- ToSixteen(b2)
  
  a4 <- Four_bit_adder(b1[13:16], b2[13:16], 0)
  a3 <- Four_bit_adder(b1[9:12], b2[9:12], a4$carry)
  a2 <- Four_bit_adder(b1[5:8], b2[5:8], a3$carry)
  a1 <- Four_bit_adder(b1[1:4], b2[1:4], a2$carry)

  result <- list(carry = a1$carry, sum = c(a1$sum, a2$sum, a3$sum, a4$sum))
  return(result)
}

Now we’re going to test this by pitting it against R’s adder. To do that, we’re going to make a function that takes decimal values as input, converts them to binary, puts them into the sixteen bit adder, converts the result back to decimal, and outputs the decimal result. This makes the testing easier to read.

Add <- function(a, b) {
  a_bin <- ToBinary(a)
  b_bin <- ToBinary(b)
  result <- Sixteen_bit_adder(a_bin, b_bin)
  if(result$carry == 1) {
    stop("sum is greater than this adder can handle")
  }
  result <- ToDecimal(result$sum)
  return(result)
}

Now we run some tests on random numbers. Note that we’re using R’s internal random number generator here, not one from NAND gates. At least right now.

set.seed(1)
n1 <- runif(20, 0, 32768) %/% 1
n2 <- runif(20, 0, 32767) %/% 1
sum_nand <- lapply(seq(length(n1)), function(i) Add(n1[i], n2[i])) %>% unlist()
sum_r <- lapply(seq(length(n1)), function(i) n1[i] + n2[i]) %>% unlist()

out <- tibble(n1 = n1, n2 = n2, sum_nand = sum_nand, sum_r = sum_r)
out
## # A tibble: 20 × 4
##       n1    n2 sum_nand sum_r
##    <dbl> <dbl>    <dbl> <dbl>
##  1  8700 30627    39327 39327
##  2 12193  6951    19144 19144
##  3 18771 21353    40124 40124
##  4 29760  4114    33874 33874
##  5  6608  8756    15364 15364
##  6 29438 12651    42089 42089
##  7 30955   438    31393 31393
##  8 21653 12529    34182 34182
##  9 20614 28497    49111 49111
## 10  2024 11152    13176 13176
## 11  6749 15796    22545 22545
## 12  5785 19645    25430 25430
## 13 22512 16171    38683 38683
## 14 12586  6101    18687 18687
## 15 25226 27110    52336 52336
## 16 16308 21903    38211 38211
## 17 23514 26024    49538 49538
## 18 32502  3536    36038 36038
## 19 12452 23713    36165 36165
## 20 25475 13476    38951 38951

From here, there are many different directions you can go. You can use the adder to do subtraction. You can create an arithmetic logic unit that does a number of things. You can also use feedback loops to create RAM. From there, you can start thinking at the level of operating systems.

The key points from this exercise I hope you got are:
- It doesn’t take much to get to arithmetic from simple logic gates.
- NAND gates can make all the logic gates we covered, and in turn do arithmetic.
- Abstraction. We started at the level of logic gates, and moved to arithmetic. Once we made a circuit we needed, we could re-use it to make the next thing without having to worry about which NAND gate hooks up to which. This can almost be thought of as the first principles of innovation.