The DAC can not be connected to the same SPI line as the SD card, because it is necessary to keep the chip select low while reading a whole block. Therefore the DAC is connected to pins 2, 3, 4 and 5 where I implement SPI by software.

This class works with the MCP4801/4811/4821 and with the MCP4901/4911/4921. If you are using the 10 or 8 bit version of these chips, the lower bits from the 12 bit value are ignored. In this case you could even speed up things, by just sending clocks for the ignored bits.

The Interface

There are a few methods in the interface and global instance of the class to use.

class DacPort
{
public:
  void initialize();
  void setValue(uint16_t value);
  void pushValue();
  void shutdown();
};
extern DacPort dacPort;
  • initialize()

    This method initialises the interface for the DAC. It sets the pin 2, 3, 4 and 5 to output and set this outputs to the initial values. You should call this method in the setup() function once.

  • setValue()

    The setValue() method sends the value into the DAC input register. The value has to be a 12 bit value, the highest 4 bits of 16 bit value are ignored. This will not change the output until pushValue() is called.

  • pushValue()

    Calling this method will push the value from the input register of the DAC into the output register. This will set the output of the DAC to the chosen value.

  • shutdown()

    This will shutdown the DAC. Shutting down the DAC will save power. The specs state a reduction from 330µA to 3.3µA.

The Implementation

First I declare a few macros to make the assignment of the four lines to the DAC more flexible. I define the port and the pin in this port. If you like to use other outputs for the DAC, you have to look into the schema and choose the correct port and pin for this macros. I have to use macros here, because the port is declared as some special kind of volatile variable.

// Chip Select: Pin 2
#define DAC_CS_PORT PORTD
#define DAC_CS PIND2

// Clock: Pin 3
#define DAC_CLK_PORT PORTD
#define DAC_CLK PIND3

// Data In: Pin 4
#define DAC_DI_PORT PORTD
#define DAC_DI PIND4

// Latch: Pin 5
#define DAC_LATCH_PORT PORTD
#define DAC_LATCH PIND5

After this, I declare more macros. I use macros in argument form xxx() to make them look like method calls. This way I can easily replace them with real method calls if required.

I create a macro for each single operation of the SPI and the latch line. I also create the combination dacClockPulse() to send a single clock.

// This are helper macros to create the actions from the ports and pins above.
#define dacSelect() DAC_CS_PORT &= ~_BV(DAC_CS);
#define dacUnselect() DAC_CS_PORT |= _BV(DAC_CS);
#define dacLatchUp() DAC_LATCH_PORT |= _BV(DAC_LATCH);
#define dacLatchDown() DAC_LATCH_PORT &= ~_BV(DAC_LATCH);
#define dacClockUp() DAC_CLK_PORT |= _BV(DAC_CLK);
#define dacClockDown() DAC_CLK_PORT &= ~_BV(DAC_CLK);
#define dacClockPulse() dacClockUp();dacClockDown();
#define dacDataUp() DAC_DI_PORT |= _BV(DAC_DI);
#define dacDataDown() DAC_DI_PORT &= ~_BV(DAC_DI);

At the end, I create a macro to send a single bit to the DAC. It checks for the bit in the value and changes the data line to high or low, depending on this bit and sends a clock.

// This will send one bit to the chip.
#define dacSendBit(bit) if (value & bit) { dacDataUp() } else { dacDataDown() }; dacClockPulse();

Initialize the Lines

In the initialize() method, I set the directions of the outputs and set the outputs to the initial states.

void DacPort::initialize()
{
  // Set all ports to output
  pinMode(2, OUTPUT); 
  pinMode(3, OUTPUT);
  pinMode(4, OUTPUT);
  pinMode(5, OUTPUT);
  
  // Set the outputs to the initial states
  dacUnselect();
  dacClockDown();
  dacDataDown();
  dacLatchUp();
}

Send a Single Value

Here I use all the macros I defined before to send a single value to the DAC. This method is quite easy to read and understand, because it hides all the technical details. In my opinion, this is a case where macros make the code more readable.

void DacPort::setValue(uint16_t value)
{
  dacSelect(); // select the chip
  // Send the header
  dacDataDown(); // bit 15, 0 = Write to DAC register  
  dacClockPulse();
  dacClockPulse(); // bit 14, don't care.
  dacClockPulse();
  dacDataUp(); // bit 13, 1 = 1x Gain, 0 = 2x Gain
  dacClockPulse(); // bit 12, 1 = Enabled.
  // Send the data bits.
  dacSendBit(_BV(11));
  dacSendBit(_BV(10));
  dacSendBit(_BV(9));
  dacSendBit(_BV(8));
  dacSendBit(_BV(7));
  dacSendBit(_BV(6));
  dacSendBit(_BV(5));
  dacSendBit(_BV(4));
  dacSendBit(_BV(3));
  dacSendBit(_BV(2));
  dacSendBit(_BV(1));
  dacSendBit(_BV(0));
  dacUnselect(); // unselect the chip.
}

The compiler heavily optimizes this code, into slick machine code:

    cbi 0xb,2        // dacSelect()  cbi = clear bit in i/o register
    cbi 0xb,4        // dacDataDown()
    sbi 0xb,3        // dacClockPulse()  sbi = set bit in i/o register
    cbi 0xb,3
    sbi 0xb,3        // dacClockPulse()
    cbi 0xb,3
    sbi 0xb,3        // dacClockPulse()
    cbi 0xb,3
    sbi 0xb,4        // dacDataUp()
    sbi 0xb,3        // dacClockPulse()
    cbi 0xb,3
    sbrs r23,3       // sbrs = Skip if bit (3) in register (r23) is set.
    rjmp .L3         // jump to .L3 (this jump is "skipped" if bit 3 is set)
    sbi 0xb,4        // dacDataUp()
    rjmp .L4         // jump to .L4
.L3:
    cbi 0xb,4        // dacDataDown()
.L4:
    sbi 0xb,3        // dacClockPulse()
    cbi 0xb,3
    sbrs r23,2       // sbrs = Skip if bit (3) in register (r23) is set.
    rjmp .L5         // ... etc.
    sbi 0xb,4
    rjmp .L6
.L5:
    cbi 0xb,4
.L6:
    sbi 0xb,3
    cbi 0xb,3
    sbrs r23,1
    rjmp .L7
    sbi 0xb,4
    rjmp .L8
.L7:
    cbi 0xb,4
.L8:
    ... repeated with all other bits ...
    ret

The cbi and sbi commands, set or clear a bit in an i/o register. They only need one cycle to complete. The actual test using sbrs is much faster as the implementation using the left or right shift operations which are often seen. Using the left and right ship operands, the compiler almost every time generates code which shifts two registers and then checks for a bit. Because the ATmega328 is an 8 bit processor, it actually gains speed if you check for the bits directly, because it operates on the individual bytes, and not on a whole word or even larger register.

Push the Value to the Output

The DAC MCP4821 has a “latch” input. This input can be used to push the transmitted value at a given time to the output of the DAC. It is a two step process: First you transmit the value which is internally stored, then in a second step you lower the latch line to make this value “active” on the output.

void DacPort::pushValue()
{
  // Push the value into the DAC
  dacLatchDown();
  dacLatchUp();
}

I use the latch to have really accurate control when to put a new value to the output of the DAC. This is critical to get a usable sound quality. How closer the samples are put to the output at 22.1kHz, how fewer are the distortions.

The shutdown

There is also a shutdown method to “shutdown” the DAC. If the DAC is shutdown, it takes less energy. Using the software shutdown, it goes down from 330µA to 3.3µA.

void DacPort::shutdown()
{
  dacSelect(); // select the chip
  // Send the header
  dacDataDown(); // bit 15, 0 = Write to DAC register  
  dacClockPulse();
  dacClockPulse(); // bit 14, don't care.
  dacClockPulse();
  dacClockPulse(); // bit 12, 0 = Disabled.
  // Send the data bits.
  dacClockPulse(); // bit 11
  dacClockPulse(); // 10
  dacClockPulse(); // 9
  dacClockPulse(); // 8
  dacClockPulse(); // 7
  dacClockPulse(); // 6
  dacClockPulse(); // 5
  dacClockPulse(); // 4
  dacClockPulse(); // 3
  dacClockPulse(); // 2
  dacClockPulse(); // 1
  dacClockPulse(); // 0
  dacUnselect(); // unselect the chip.
  // Push the value into the DAC
  dacLatchDown();
  dacLatchUp();
}

Conclusion

This is a very simple way to control the MCP4821 or similar chips. The methods are fast enough to play samples, but working well to use the DAC in a slower fashion.

Continue here: Play Audio