Sniffing the SMBus

Stock photo of a motherboard, from https://www.pexels.com/photo/black-and-gray-motherboard-2582937/

Out of curiosity, I wanted to find out if it was possible to capture the readings of the on-board temperature sensors on an older desktop PC. Scanning the ICs on the board for one that could execute the required analog-to-digital conversion, I came across the ALi M5879 “System Hardware Monitor”, which can capture up to 2 thermistor inputs, 4 voltage channels, and 2 fan speeds. There’s no full datasheet available online (at least none I could find), yet the product brief luckily features the pinout diagram, according to which the device communicates via I2C (which is technically compatible to the SMBus found on virtually all computer mainboards).

The M5879 system hardware monitor on the PC mainboard
M5879 pinout

So would it really be as simple as capturing some bus traffic in order to find out the temperatures, and maybe even other sensor readings?

What’s happening on the I2C/SMBus?

After hooking up the scope to the SDA and SCL pins, a periodic pattern of I2C traffic can be observed during regular system operation.

Multiple SMBus transactions, sniffed using an oscilloscope

By decoding the I2C traffic on a scope, the typical style of the transactions becomes clear: In the below diagram, a bus master prompts the device at slave address 0x2C for a sample of its 0x20 sensor channel. Then, a read operation to the same slave address follows, as a payload of which the slave transmits the corresponding sensor’s value.

One SMBus transaction, sniffed using an oscilloscope

Using a microcontroller to sniff the on-board SMBus, make sense of the data, and display it to the user shouldn’t be too complicated, right?

Finding a suitable attachment point

While I could locate at least three devices on the SMBus (a bus master, the M5879, and the real-time clock), it turned out to be rather difficult to find a suitable location from which the signals could be conveniently tapped. The bus is pulled up by a pair of 4k7 resistors, yet these are tucked away in-between two PCI slots and not easily accessible. The SMBus traces themselves are meandering across the board, but there are no openly exposed pads or connectors, and I didn’t feel like abusing one of the (rather tiny) vias for this purpose. This only left the slave devices as candidates from which the SMBus signal could be tapped.

Luckily, the M5879 comes in a 28-SOP package, with plenty of pin spacing to simply attach the connections (using enameled wire, in my case) there. As a further benefit, the device has VDD and (digital) GND connections readily available. This made it possible to buffer the signal using a 74HC14 (hex inverter with Schmitt-trigger inputs) on some SMD protoboard. This way, there’s no additional load on the bus and, more importantly, any (unintentional) write operations by the microcontroller are shielded from making it through to the bus.

A little prototype buffer board, wired up to the M5879
Final position of the bus tap, mounted on some SMD protoboard

The signals on the pin header on the right are SDA, SCL, VCC (to power the microcontroller), and GND. A small piece of double-sided tape was used to mount the buffer right on top of the M5879, with the pins conveniently available at the edge of the mainboard. So all that was left was getting the software right.

Capturing the signals using a microcontroller

On the way to successfully logging the SMBus traffic, there were a couple of dead-ends:

The only thing that turned out to be working was to continuously capture the signals on the SMBus pins, wait for the starting condition (SMBDAT undergoing a high-to-low transition while SMBCLK is high), writing them to a buffer, and parsing them once the buffer is full (or a timeout occurred). This was inspired by the i2c-sniffer-100kBaud-Arduino-Mega by Github user rricharz. Note that for this to work, both SMBDAT/SDA and SMBCLK/SCL must be connected to the same microcontroller port (port D in below code), such that their values can be captured using a single port read command.

The relevant snippet of Arduino-compatible code for the Teensy 2.0 is provided as follows.

// Pin mappings -- IMPORTANT: Both pins must be on the same port
const uint8_t pSDA(23), pSCL(22); // ATmega pins D4 and D5
volatile uint8_t *smbus_pin;
uint8_t pin_mask, idle_condition, start_condition;
byte buffer[1024];

// SMBus message definition (limited to 4 byte payload)
struct smbusframe {
  byte slave_adr;
  bool writing;
  bool acked;
  uint8_t payload_bytes;
  byte payload[4];
};
static smbusframe messages[50];

// prepare for sniffing traffic
void setup() {
  pinMode(pSDA, INPUT);
  pinMode(pSCL, INPUT);
  pin_mask = digitalPinToBitMask(pSDA) | digitalPinToBitMask(pSCL);
  smbus_pin = portInputRegister(digitalPinToPort(pSDA));
  idle_condition = pin_mask;
  start_condition = digitalPinToBitMask(pSCL);
}

inline uint8_t sample() {
  return (*smbus_pin & pin_mask);
}

uint16_t acquire_data(uint16_t duration) {
  unsigned long endtime;
  uint8_t data, lastData;
  uint16_t k = 0;
  
  // wait for start of transaction
  bool started = false;
  do { // wait for a starting condition to occur
    while ((lastData = sample()) ^ idle_condition); // wait for idle state
    while (!((data = sample()) ^ lastData));        // wait for state change
    started = (!(data ^ start_condition));          // start condition met?
  } while (!started);
  
  // re-insert previously eliminated start sequence into buffer
  buffer[k++] = idle_condition;   // 0: idle state
  buffer[k++] = start_condition;  // 1: start condition

  // set timeout
  endtime = millis() + duration;
  
  // read actual transactions
  lastData = start_condition;
  do {
    while (!((data = sample()) ^ lastData));  // wait until next state
    buffer[k++] = lastData = data;
  } while ((k < sizeof(buffer)) && (millis() < endtime));

  return k;
}

uint16_t findNextStartCondition(uint16_t startoffset) {
  while ((startoffset < sizeof(buffer) - 1) && 
         (buffer[startoffset-1] != idle_condition || 
          buffer[startoffset] != start_condition)) startoffset++;
  return startoffset;
}

uint8_t process_data(uint16_t points) {
  uint8_t message_ctr = 0;

  // collect data into these fields
  uint16_t streamoffset = 2; // offset in recorded data stream
  uint16_t nextStart = findNextStartCondition(streamoffset);
  uint8_t  smbyteoffset = 0; // byte offset in SMBus message

  smbusframe *msg = &messages[message_ctr];
  do {
    uint16_t dataword = 0;  // ATTENTION: 9 bit long data words!
    uint8_t  bitsread = 0;
    do { // read 9 consecutive bits from recorded stream
      uint8_t data = buffer[streamoffset++];
      if (!(data & digitalPinToBitMask(pSCL))) continue; // keep when SCL was high
      dataword = (dataword << 1) | ((data & digitalPinToBitMask(pSDA))?1:0);
      bitsread++;
    } while (bitsread < 9);
    
    if (smbyteoffset == 0) { // parse header
      msg->slave_adr = dataword >> 2;
      msg->writing = !(dataword & 2);
      msg->acked = !(dataword & 1);
      msg->payload_bytes = 0;
      smbyteoffset++;
    } else { // store data byte(s)
      if (msg->payload_bytes < sizeof(msg->payload)) {
        msg->payload[msg->payload_bytes++] = dataword >> 1;
      }
      smbyteoffset++;
      streamoffset--; // allow back-to-back frames
    }
    
    if (nextStart - streamoffset < 9) { // too close to the next start
      streamoffset = nextStart + 1;
      nextStart = findNextStartCondition(streamoffset);
      if (++message_ctr >= sizeof(messages)) return message_ctr;
      msg = &messages[message_ctr];
      smbyteoffset = 0;
    } 
  } while (streamoffset < points);
  return message_ctr;
}

uint16_t fan1 = 0, fan2 = 0;
uint8_t systemp = 0, cputemp = 0;
float voltageC = 0, voltage3 = 0, voltage5 = 0, voltage12 = 0;

void loop() {
  uint16_t data_points = acquire_data(500);
  uint8_t msgs = process_data(data_points);

  uint8_t type = 0;
  for (uint8_t c=0; c<msgs; ++c) {
    smbusframe *msg = &messages[c];

    if (msg->slave_adr == 0x2c && msg->writing && 
        msg->payload_bytes > 0) {
      type = msg->payload[0];
    } else if (msg->slave_adr == 0x2c && !msg->writing && 
               msg->payload_bytes > 0 && type != 0) {
      uint8_t data = msg->payload[0];

      switch(type) {
        case 0x28: // fan speed in RPM (4800 - 12 * value)
          fan1 = ((data == 0xff) ? 0 : 4800 - 12 * data);
          break;
        case 0x29: // fan speed in RPM (4800 - 12 * value)
          fan2 = ((data == 0xff) ? 0 : 4800 - 12 * data);
          break;

        case 0x27: // CPU temperature in Celsius
          cputemp = data;
          break;
        case 0x4e: // system temperature in Celsius
          systemp = data;
          break;
        
        case 0x23: // 5V rail
          voltage5 = -0.81 + 0.025 * data;
          break;
        case 0x21: // 12V rail
          voltage12 = 0.5 + 0.065 * data;
          break;
        case 0x22: // CPU voltage
          voltageC = 0.66 + 0.01 * data;
          break;
        case 0x20: // 3.3V rail
          voltage3 = -0.81 + 0.02 * data;
        }
        type = 0;
      }
    }
  delay(100);
}

The conversion functions in the switch(type) part of the loop() were determined empirically by monitoring the output of the BIOS screen during system startup and correlating fluctuating values with the corresponding SMBus traffic. They might be part of an initialization sequence (which I did not capture) and are likely different for other system hardware monitors.