A Simulator for Digital Circuits

SICP > Modularity, Objects, and State > Modeling with Mutable Data > A Simulator for Digital Circuits
Previous: Representing Tables Next: Propagation of Constraints

    Designing complex digital systems, such as computers, is an important engineering activity. Digital systems are constructed by interconnecting simple elements. Although the behavior of these individual elements is simple, networks of them can have very complex behavior. Computer simulation of proposed circuit designs is an important tool used by digital systems engineers. In this section we design a system for performing digital logic simulations. This system typifies a kind of program called an event-driven simulation, in which actions (``events'') trigger further events that happen at a later time, which in turn trigger more events, and so so.

    Our computational model of a circuit will be composed of objects that correspond to the elementary components from which the circuit is constructed. There are wires, which carry digital signals. A digital signal may at any moment have only one of two possible values, 0 and 1. There are also various types of digital function boxes, which connect wires carrying input signals to other output wires. Such boxes produce output signals computed from their input signals. The output signal is delayed by a time that depends on the type of the function box. For example, an inverter is a primitive function box that inverts its input. If the input signal to an inverter changes to 0, then one inverter-delay later the inverter will change its output signal to 1. If the input signal to an inverter changes to 1, then one inverter-delay later the inverter will change its output signal to 0. We draw an inverter symbolically as in figure [*]. An and-gate, also shown in figure [*], is a primitive function box with two inputs and one output. It drives its output signal to a value that is the logical and of the inputs. That is, if both of its input signals become 1, then one and-gate-delay time later the and-gate will force its output signal to be 1; otherwise the output will be 0. An or-gate is a similar two-input primitive function box that drives its output signal to a value that is the logical or of the inputs. That is, the output will become 1 if at least one of the input signals is 1; otherwise the output will become 0.


      \begin{figure}\par\figcaption {Primitive functions in the digital logic simulator.}\end{figure}

    We can connect primitive functions together to construct more complex functions. To accomplish this we wire the outputs of some function boxes to the inputs of other function boxes. For example, the half-adder circuit shown in figure [*] consists of an or-gate, two and-gates, and an inverter. It takes two input signals, A and B, and has two output signals, S and C. S will become 1 whenever precisely one of A and B is 1, and C will become 1 whenever A and B are both 1. We can see from the figure that, because of the delays involved, the outputs may be generated at different times. Many of the difficulties in the design of digital circuits arise from this fact.


      \begin{figure}\par\figcaption {A half-adder circuit.}\end{figure}

    We will now build a program for modeling the digital logic circuits we wish to study. The program will construct computational objects modeling the wires, which will ``hold'' the signals. Function boxes will be modeled by procedures that enforce the correct relationships among the signals.

    One basic element of our simulation will be a procedure make-wire, which constructs wires. For example, we can construct six wires as follows:

    (define a (make-wire))
    (define b (make-wire))
    (define c (make-wire))
    
    

    (define d (make-wire)) (define e (make-wire)) (define s (make-wire))

    We attach a function box to a set of wires by calling a procedure that constructs that kind of box. The arguments to the constructor procedure are the wires to be attached to the box. For example, given that we can construct and-gates, or-gates, and inverters, we can wire together the half-adder shown in figure [*]:

    (or-gate a b d)
    ok
    
    

    (and-gate a b c) ok

    (inverter c e) ok

    (and-gate d e s) ok

    Better yet, we can explicitly name this operation by defining a procedure half-adder that constructs this circuit, given the four external wires to be attached to the half-adder:

    (define (half-adder a b s c)
      (let ((d (make-wire)) (e (make-wire)))
        (or-gate a b d)
        (and-gate a b c)
        (inverter c e)
        (and-gate d e s)
        'ok))
    
    The advantage of making this definition is that we can use half-adder itself as a building block in creating more complex circuits. Figure [*], for example, shows a full-adder composed of two half-adders and an or-gate.[*] We can construct a full-adder as follows:

    (define (full-adder a b c-in sum c-out)
      (let ((s (make-wire))
            (c1 (make-wire))
            (c2 (make-wire)))
        (half-adder b c-in s c1)
        (half-adder a s sum c2)
        (or-gate c1 c2 c-out)
        'ok))
    
    Having defined full-adder as a procedure, we can now use it as a building block for creating still more complex circuits. (For example, see exercise [*].)


      \begin{figure}\par\figcaption {A full-adder circuit.}\end{figure}

    In essence, our simulator provides us with the tools to construct a language of circuits. If we adopt the general perspective on languages with which we approached the study of Lisp in section [*], we can say that the primitive function boxes form the primitive elements of the language, that wiring boxes together provides a means of combination, and that specifying wiring patterns as procedures serves as a means of abstraction.

    Primitive function boxes

    The primitive function boxes implement the ``forces'' by which a change in the signal on one wire influences the signals on other wires. To build function boxes, we use the following operations on wires:

    In addition, we will make use of a procedure after-delay that takes a time delay and a procedure to be run and executes the given procedure after the given delay.

    Using these procedures, we can define the primitive digital logic functions. To connect an input to an output through an inverter, we use add-action! to associate with the input wire a procedure that will be run whenever the signal on the input wire changes value. The procedure computes the logical-not of the input signal, and then, after one inverter-delay, sets the output signal to be this new value:

    (define (inverter input output)
      (define (invert-input)
        (let ((new-value (logical-not (get-signal input))))
          (after-delay inverter-delay
                       (lambda ()
                         (set-signal! output new-value)))))
      (add-action! input invert-input)
      'ok)
    
    (define (logical-not s)
      (cond ((= s 0) 1)
            ((= s 1) 0)
            (else (error "Invalid signal" s))))
    

    An and-gate is a little more complex. The action procedure must be run if either of the inputs to the gate changes. It computes the logicaland (using a procedure analogous to logical-not) of the values of the signals on the input wires and sets up a change to the new value to occur on the output wire after one and-gate-delay.

    (define (and-gate a1 a2 output)
      (define (and-action-procedure)
        (let ((new-value
               (logical-and (get-signal a1) (get-signal a2))))
          (after-delay and-gate-delay
                       (lambda ()
                         (set-signal! output new-value)))))
      (add-action! a1 and-action-procedure)
      (add-action! a2 and-action-procedure)
      'ok)
    

    Exercise. Define an or-gate as a primitive function box. Your or-gate constructor should be similar to and-gate.

    Exercise. Another way to construct an or-gate is as a compound digital logic device, built from and-gates and inverters. Define a procedure or-gate that accomplishes this. What is the delay time of the or-gate in terms of and-gate-delay and inverter-delay?

    Exercise. Figure [*] shows a ripple-carry adder formed by stringing together n full-adders. This is the simplest form of parallel adder for adding two n-bit binary numbers. The inputs A1, A2, A3, ..., An and B1, B2, B3, ..., Bn are the two binary numbers to be added (each Ak and Bk is a 0 or a 1). The circuit generates S1, S2, S3, ..., Sn, the n bits of the sum, and C, the carry from the addition. Write a procedure ripple-carry-adder that generates this circuit. The procedure should take as arguments three lists of n wires each--the Ak, the Bk, and the Sk--and also another wire C. The major drawback of the ripple-carry adder is the need to wait for the carry signals to propagate. What is the delay needed to obtain the complete output from an n-bit ripple-carry adder, expressed in terms of the delays for and-gates, or-gates, and inverters?  


      \begin{figure}\par\figcaption {A ripple-carry adder for $n$ -bit numbers.}\end{figure}

    Representing wires

    A wire in our simulation will be a computational object with two local state variables: a signal-value (initially taken to be 0) and a collection of action-procedures to be run when the signal changes value. We implement the wire, using message-passing style, as a collection of local procedures together with a dispatch procedure that selects the appropriate local operation, just as we did with the simple bank-account object in section  [*]:

    (define (make-wire)
      (let ((signal-value 0) (action-procedures '()))
        (define (set-my-signal! new-value)
          (if (not (= signal-value new-value))
              (begin (set! signal-value new-value)
                     (call-each action-procedures))
              'done))
    
        (define (accept-action-procedure! proc)
          (set! action-procedures (cons proc action-procedures))
          (proc))
    
        (define (dispatch m)
          (cond ((eq? m 'get-signal) signal-value)
                ((eq? m 'set-signal!) set-my-signal!)
                ((eq? m 'add-action!) accept-action-procedure!)
                (else (error "Unknown operation - WIRE" m))))
        dispatch))
    
    The local procedure set-my-signal! tests whether the new signal value changes the signal on the wire. If so, it runs each of the action procedures, using the following procedure call-each, which calls each of the items in a list of no-argument procedures:

    (define (call-each procedures)
      (if (null? procedures)
          'done
          (begin
            ((car procedures))
            (call-each (cdr procedures)))))
    
    The local procedure accept-action-procedure! adds the given procedure to the list of procedures to be run, and then runs the new procedure once. (See exercise [*].)

    With the local dispatch procedure set up as specified, we can provide the following procedures to access the local operations on wires:[*]

    (define (get-signal wire)
      (wire 'get-signal))
    
    (define (set-signal! wire new-value)
      ((wire 'set-signal!) new-value))
    
    (define (add-action! wire action-procedure)
      ((wire 'add-action!) action-procedure))
    

    Wires, which have time-varying signals and may be incrementally attached to devices, are typical of mutable objects. We have modeled them as procedures with local state variables that are modified by assignment. When a new wire is created, a new set of state variables is allocated (by the let expression in make-wire) and a new dispatch procedure is constructed and returned, capturing the environment with the new state variables.

    The wires are shared among the various devices that have been connected to them. Thus, a change made by an interaction with one device will affect all the other devices attached to the wire. The wire communicates the change to its neighbors by calling the action procedures provided to it when the connections were established.

    The agenda

    The only thing needed to complete the simulator is after-delay. The idea here is that we maintain a data structure, called an agenda, that contains a schedule of things to do. The following operations are defined for agendas:

    The particular agenda that we use is denoted by the-agenda. The procedure after-delay adds new elements to the-agenda:

    (define (after-delay delay action)
      (add-to-agenda! (+ delay (current-time the-agenda))
                      action
                      the-agenda))
    

    The simulation is driven by the procedure propagate, which operates on the-agenda, executing each procedure on the agenda in sequence. In general, as the simulation runs, new items will be added to the agenda, and propagate will continue the simulation as long as there are items on the agenda:

    (define (propagate)
      (if (empty-agenda? the-agenda)
          'done
          (let ((first-item (first-agenda-item the-agenda)))
            (first-item)
            (remove-first-agenda-item! the-agenda)
            (propagate))))
    

    A sample simulation

    The following procedure, which places a ``probe'' on a wire, shows the simulator in action. The probe tells the wire that, whenever its signal changes value, it should print the new signal value, together with the current time and a name that identifies the wire:

    (define (probe name wire)
      (add-action! wire
                   (lambda ()        
                     (newline)
                     (display name)
                     (display " ")
                     (display (current-time the-agenda))
                     (display "  New-value = ")
                     (display (get-signal wire)))))
    

    We begin by initializing the agenda and specifying delays for the primitive function boxes:

    (define the-agenda (make-agenda))
    (define inverter-delay 2)
    (define and-gate-delay 3)
    (define or-gate-delay 5)
    
    Now we define four wires, placing probes on two of them:

    (define input-1 (make-wire))
    (define input-2 (make-wire))
    (define sum (make-wire))
    (define carry (make-wire))
    
    (probe 'sum sum)
    sum 0  New-value = 0
    
    (probe 'carry carry)
    carry 0  New-value = 0
    
    Next we connect the wires in a half-adder circuit (as in figure [*]), set the signal on input-1 to 1, and run the simulation:

    (half-adder input-1 input-2 sum carry)
    ok
    
    (set-signal! input-1 1)
    done
    
    (propagate)
    sum 8  New-value = 1
    done
    
    The sum signal changes to 1 at time 8. We are now eight time units from the beginning of the simulation. At this point, we can set the signal on input-2 to 1 and allow the values to propagate:

    (set-signal! input-2 1)
    done
    
    (propagate)
    carry 11  New-value = 1
    sum 16  New-value = 0
    done
    
    The carry changes to 1 at time 11 and the sum changes to 0 at time 16.

    Exercise. 

    The internal procedure accept-action-procedure! defined in make-wire specifies that when a new action procedure is added to a wire, the procedure is immediately run. Explain why this initialization is necessary. In particular, trace through the half-adder example in the paragraphs above and say how the system's response would differ if we had defined accept-action-procedure! as

    (define (accept-action-procedure! proc)
      (set! action-procedures (cons proc action-procedures)))
    

    Implementing the agenda

    Finally, we give details of the agenda data structure, which holds the procedures that are scheduled for future execution.

    The agenda is made up of time segments. Each time segment is a pair consisting of a number (the time) and a queue (see exercise [*]) that holds the procedures that are scheduled to be run during that time segment.

    (define (make-time-segment time queue)
      (cons time queue))
    
    (define (segment-time s) (car s))
    
    (define (segment-queue s) (cdr s))
    
    We will operate on the time-segment queues using the queue operations described in section [*].

    The agenda itself is a one-dimensional table of time segments. It differs from the tables described in section [*] in that the segments will be sorted in order of increasing time. In addition, we store the current time (i.e., the time of the last action that was processed) at the head of the agenda. A newly constructed agenda has no time segments and has a current time of 0:[*]

    (define (make-agenda) (list 0))
    
    (define (current-time agenda) (car agenda))
    
    (define (set-current-time! agenda time)
      (set-car! agenda time))
    
    (define (segments agenda) (cdr agenda))
    
    (define (set-segments! agenda segments)
      (set-cdr! agenda segments))
    
    (define (first-segment agenda) (car (segments agenda)))
    
    (define (rest-segments agenda) (cdr (segments agenda)))
    

    An agenda is empty if it has no time segments:

    (define (empty-agenda? agenda)
      (null? (segments agenda)))
    

    To add an action to an agenda, we first check if the agenda is empty. If so, we create a time segment for the action and install this in the agenda. Otherwise, we scan the agenda, examining the time of each segment. If we find a segment for our appointed time, we add the action to the associated queue. If we reach a time later than the one to which we are appointed, we insert a new time segment into the agenda just before it. If we reach the end of the agenda, we must create a new time segment at the end.

    (define (add-to-agenda! time action agenda)
      (define (belongs-before? segments)
        (or (null? segments)
            (< time (segment-time (car segments)))))
      (define (make-new-time-segment time action)
        (let ((q (make-queue)))
          (insert-queue! q action)
          (make-time-segment time q)))
      (define (add-to-segments! segments)
        (if (= (segment-time (car segments)) time)
            (insert-queue! (segment-queue (car segments))
                           action)
            (let ((rest (cdr segments)))
              (if (belongs-before? rest)
                  (set-cdr!
                   segments
                   (cons (make-new-time-segment time action)
                         (cdr segments)))
                  (add-to-segments! rest)))))
      (let ((segments (segments agenda)))
        (if (belongs-before? segments)
            (set-segments!
             agenda
             (cons (make-new-time-segment time action)
                   segments))
            (add-to-segments! segments))))
    

    The procedure that removes the first item from the agenda deletes the item at the front of the queue in the first time segment. If this deletion makes the time segment empty, we remove it from the list of segments: [*]

    (define (remove-first-agenda-item! agenda)
      (let ((q (segment-queue (first-segment agenda))))
        (delete-queue! q)
        (if (empty-queue? q)
            (set-segments! agenda (rest-segments agenda)))))
    

    The first agenda item is found at the head of the queue in the first time segment. Whenever we extract an item, we also update the current time:[*]

    (define (first-agenda-item agenda)
      (if (empty-agenda? agenda)
          (error "Agenda is empty - FIRST-AGENDA-ITEM")
          (let ((first-seg (first-segment agenda)))
            (set-current-time! agenda (segment-time first-seg))
            (front-queue (segment-queue first-seg)))))
    

    Exercise. The procedures to be run during each time segment of the agenda are kept in a queue. Thus, the procedures for each segment are called in the order in which they were added to the agenda (first in, first out). Explain why this order must be used. In particular, trace the behavior of an and-gate whose inputs change from 0,1 to 1,0 in the same segment and say how the behavior would differ if we stored a segment's procedures in an ordinary list, adding and removing procedures only at the front (last in, first out).  

    Previous: Representing Tables Next: Propagation of Constraints

      webmaster@arsdigita.org