diff --git a/klippy/fan.py b/klippy/fan.py
index 6a5766ef6..a46dc24e5 100644
--- a/klippy/fan.py
+++ b/klippy/fan.py
@@ -3,8 +3,7 @@
 # Copyright (C) 2016,2017  Kevin O'Connor <kevin@koconnor.net>
 #
 # This file may be distributed under the terms of the GNU GPLv3 license.
-
-import extruder
+import extruder, pins
 
 FAN_MIN_TIME = 0.1
 PWM_CYCLE_TIME = 0.010
@@ -15,9 +14,10 @@ class PrinterFan:
         self.last_fan_time = 0.
         self.max_power = config.getfloat('max_power', 1., above=0., maxval=1.)
         self.kick_start_time = config.getfloat('kick_start_time', 0.1, minval=0.)
-        pin = config.get('pin')
-        hard_pwm = config.getint('hard_pwm', 0)
-        self.mcu_fan = printer.mcu.create_pwm(pin, PWM_CYCLE_TIME, hard_pwm, 0.)
+        self.mcu_fan = pins.setup_pin(printer, 'pwm', config.get('pin'))
+        self.mcu_fan.setup_max_duration(0.)
+        self.mcu_fan.setup_cycle_time(PWM_CYCLE_TIME)
+        self.mcu_fan.setup_hard_pwm(config.getint('hard_pwm', 0))
     def set_pwm(self, mcu_time, value):
         value = max(0., min(self.max_power, value))
         if value == self.last_fan_value:
diff --git a/klippy/heater.py b/klippy/heater.py
index be8d72523..11f88107c 100644
--- a/klippy/heater.py
+++ b/klippy/heater.py
@@ -4,6 +4,7 @@
 #
 # This file may be distributed under the terms of the GNU GPLv3 license.
 import math, logging, threading
+import pins
 
 
 ######################################################################
@@ -121,19 +122,18 @@ class PrinterHeater:
         algos = {'watermark': ControlBangBang, 'pid': ControlPID}
         algo = config.getchoice('control', algos)
         heater_pin = config.get('heater_pin')
-        sensor_pin = config.get('sensor_pin')
         if algo is ControlBangBang and self.max_power == 1.:
-            self.mcu_pwm = printer.mcu.create_digital_out(
-                heater_pin, MAX_HEAT_TIME)
+            self.mcu_pwm = pins.setup_pin(printer, 'digital_out', heater_pin)
         else:
-            self.mcu_pwm = printer.mcu.create_pwm(
-                heater_pin, PWM_CYCLE_TIME, 0, MAX_HEAT_TIME)
-        self.mcu_adc = printer.mcu.create_adc(sensor_pin)
+            self.mcu_pwm = pins.setup_pin(printer, 'pwm', heater_pin)
+            self.mcu_pwm.setup_cycle_time(PWM_CYCLE_TIME)
+        self.mcu_pwm.setup_max_duration(MAX_HEAT_TIME)
+        self.mcu_adc = pins.setup_pin(printer, 'adc', config.get('sensor_pin'))
         adc_range = [self.sensor.calc_adc(self.min_temp),
                      self.sensor.calc_adc(self.max_temp)]
-        self.mcu_adc.set_minmax(SAMPLE_TIME, SAMPLE_COUNT,
-                                minval=min(adc_range), maxval=max(adc_range))
-        self.mcu_adc.set_adc_callback(REPORT_TIME, self.adc_callback)
+        self.mcu_adc.setup_minmax(SAMPLE_TIME, SAMPLE_COUNT,
+                                  minval=min(adc_range), maxval=max(adc_range))
+        self.mcu_adc.setup_adc_callback(REPORT_TIME, self.adc_callback)
         self.control = algo(self, config)
         # pwm caching
         self.next_pwm_time = 0.
diff --git a/klippy/klippy.py b/klippy/klippy.py
index 611686090..d733c3a8e 100644
--- a/klippy/klippy.py
+++ b/klippy/klippy.py
@@ -1,12 +1,12 @@
 #!/usr/bin/env python2
 # Main code for host side printer firmware
 #
-# Copyright (C) 2016  Kevin O'Connor <kevin@koconnor.net>
+# Copyright (C) 2016,2017  Kevin O'Connor <kevin@koconnor.net>
 #
 # This file may be distributed under the terms of the GNU GPLv3 license.
 import sys, optparse, ConfigParser, logging, time, threading
-import gcode, toolhead, util, mcu, fan, heater, extruder, reactor, queuelogger
-import msgproto
+import util, reactor, queuelogger, msgproto, gcode
+import pins, mcu, extruder, fan, heater, toolhead
 
 message_ready = "Printer is ready"
 
@@ -170,11 +170,11 @@ class Printer:
                 config_file,))
         if self.bglogger is not None:
             ConfigLogger(self.fileconfig, self.bglogger)
-        self.mcu = mcu.MCU(self, ConfigWrapper(self, 'mcu'))
         # Create printer components
         config = ConfigWrapper(self, 'printer')
-        for m in [extruder, fan, heater, toolhead]:
+        for m in [pins, mcu, extruder, fan, heater, toolhead]:
             m.add_printer_objects(self, config)
+        self.mcu = self.objects['mcu']
         # Validate that there are no undefined parameters in the config file
         valid_sections = { s: 1 for s, o in self.all_config_options }
         for section in self.fileconfig.sections():
@@ -196,7 +196,7 @@ class Printer:
             self.mcu.connect()
             self.gcode.set_printer_ready(True)
             self.state_message = message_ready
-        except ConfigParser.Error as e:
+        except (ConfigParser.Error, pins.error) as e:
             logging.exception("Config error")
             self.state_message = "%s%s" % (str(e), message_restart)
             self.reactor.update_timer(self.stats_timer, self.reactor.NEVER)
diff --git a/klippy/mcu.py b/klippy/mcu.py
index aa35f76de..3190a3252 100644
--- a/klippy/mcu.py
+++ b/klippy/mcu.py
@@ -9,24 +9,15 @@ import serialhdl, pins, chelper
 class error(Exception):
     pass
 
-def parse_pin_extras(pin, can_pullup=False):
-    pullup = invert = 0
-    if can_pullup and pin.startswith('^'):
-        pullup = 1
-        pin = pin[1:].strip()
-    if pin.startswith('!'):
-        invert = 1
-        pin = pin[1:].strip()
-    return pin, pullup, invert
-
 STEPCOMPRESS_ERROR_RET = -989898989
 
 class MCU_stepper:
-    def __init__(self, mcu, step_pin, dir_pin):
+    def __init__(self, mcu, pin_params):
         self._mcu = mcu
         self._oid = mcu.create_oid(self)
-        self._step_pin, pullup, self._invert_step = parse_pin_extras(step_pin)
-        self._dir_pin, pullup, self._invert_dir = parse_pin_extras(dir_pin)
+        self._step_pin = pin_params['pin']
+        self._invert_step = pin_params['invert']
+        self._dir_pin = self._invert_dir = None
         self._commanded_pos = 0
         self._step_dist = self._inv_step_dist = 1.
         self._velocity_factor = self._accel_factor = 0.
@@ -36,9 +27,14 @@ class MCU_stepper:
         self._ffi_lib = self._stepqueue = None
         self.print_to_mcu_time = mcu.print_to_mcu_time
         self.system_to_mcu_time = mcu.system_to_mcu_time
-    def set_min_stop_interval(self, min_stop_interval):
+    def setup_dir_pin(self, pin_params):
+        if pin_params['chip'] is not self._mcu:
+            raise pins.error("Stepper dir pin must be on same mcu as step pin")
+        self._dir_pin = pin_params['pin']
+        self._invert_dir = pin_params['invert']
+    def setup_min_stop_interval(self, min_stop_interval):
         self._min_stop_interval = min_stop_interval
-    def set_step_distance(self, step_dist):
+    def setup_step_distance(self, step_dist):
         self._step_dist = step_dist
         self._inv_step_dist = 1. / step_dist
     def build_config(self):
@@ -144,12 +140,13 @@ class MCU_stepper:
 class MCU_endstop:
     error = error
     RETRY_QUERY = 1.000
-    def __init__(self, mcu, pin):
+    def __init__(self, mcu, pin_params):
         self._mcu = mcu
         self._oid = mcu.create_oid(self)
         self._steppers = []
-        self._pin, self._pullup, self._invert = parse_pin_extras(
-            pin, can_pullup=True)
+        self._pin = pin_params['pin']
+        self._pullup = pin_params['pullup']
+        self._invert = pin_params['invert']
         self._cmd_queue = mcu.alloc_command_queue()
         self._home_cmd = self._query_cmd = None
         self._homing = False
@@ -240,23 +237,27 @@ class MCU_endstop:
         return self._last_state.get('pin', self._invert) ^ self._invert
 
 class MCU_digital_out:
-    def __init__(self, mcu, pin, max_duration):
+    def __init__(self, mcu, pin_params):
         self._mcu = mcu
         self._oid = mcu.create_oid(self)
-        pin, pullup, self._invert = parse_pin_extras(pin)
+        self._pin = pin_params['pin']
+        self._invert = pin_params['invert']
+        self._max_duration = 2.
         self._last_clock = 0
         self._last_value = None
         self._mcu_freq = 0.
         self._cmd_queue = mcu.alloc_command_queue()
-        mcu.add_config_cmd(
-            "config_digital_out oid=%d pin=%s default_value=%d"
-            " max_duration=TICKS(%f)" % (
-                self._oid, pin, self._invert, max_duration))
         self._set_cmd = None
         self.print_to_mcu_time = mcu.print_to_mcu_time
         self.system_to_mcu_time = mcu.system_to_mcu_time
+    def setup_max_duration(self, max_duration):
+        self._max_duration = max_duration
     def build_config(self):
         self._mcu_freq = self._mcu.get_mcu_freq()
+        self._mcu.add_config_cmd(
+            "config_digital_out oid=%d pin=%s default_value=%d"
+            " max_duration=TICKS(%f)" % (
+                self._oid, self._pin, self._invert, self._max_duration))
         self._set_cmd = self._mcu.lookup_command(
             "schedule_digital_out oid=%c clock=%u value=%c")
     def set_digital(self, mcu_time, value):
@@ -275,37 +276,49 @@ class MCU_digital_out:
         self.set_digital(mcu_time, dval)
 
 class MCU_pwm:
-    def __init__(self, mcu, pin, cycle_time, hard_cycle_ticks, max_duration):
+    def __init__(self, mcu, pin_params):
         self._mcu = mcu
-        self._hard_cycle_ticks = hard_cycle_ticks
+        self._hard_pwm = False
+        self._cycle_time = 0.100
+        self._max_duration = 2.
         self._oid = mcu.create_oid(self)
-        pin, pullup, self._invert = parse_pin_extras(pin)
+        self._pin = pin_params['pin']
+        self._invert = pin_params['invert']
         self._last_clock = 0
         self._mcu_freq = 0.
         self._pwm_max = 0.
         self._cmd_queue = mcu.alloc_command_queue()
-        if hard_cycle_ticks:
-            mcu.add_config_cmd(
-                "config_pwm_out oid=%d pin=%s cycle_ticks=%d default_value=%d"
-                " max_duration=TICKS(%f)" % (
-                    self._oid, pin, hard_cycle_ticks, self._invert,
-                    max_duration))
-        else:
-            mcu.add_config_cmd(
-                "config_soft_pwm_out oid=%d pin=%s cycle_ticks=TICKS(%f)"
-                " default_value=%d max_duration=TICKS(%f)" % (
-                    self._oid, pin, cycle_time, self._invert, max_duration))
         self._set_cmd = None
         self.print_to_mcu_time = mcu.print_to_mcu_time
         self.system_to_mcu_time = mcu.system_to_mcu_time
+    def setup_max_duration(self, max_duration):
+        self._max_duration = max_duration
+    def setup_cycle_time(self, cycle_time):
+        self._cycle_time = cycle_time
+        self._hard_pwm = False
+    def setup_hard_pwm(self, hard_cycle_ticks):
+        if not hard_cycle_ticks:
+            return
+        self._cycle_time = hard_cycle_ticks
+        self._hard_pwm = True
     def build_config(self):
         self._mcu_freq = self._mcu.get_mcu_freq()
-        if self._hard_cycle_ticks:
+        if self._hard_pwm:
+            self._mcu.add_config_cmd(
+                "config_pwm_out oid=%d pin=%s cycle_ticks=%d default_value=%d"
+                " max_duration=TICKS(%f)" % (
+                    self._oid, self._pin, self._cycle_time, self._invert,
+                    self._max_duration))
             self._pwm_max = self._mcu.serial.msgparser.get_constant_float(
                 "PWM_MAX")
             self._set_cmd = self._mcu.lookup_command(
                 "schedule_pwm_out oid=%c clock=%u value=%hu")
         else:
+            self._mcu.add_config_cmd(
+                "config_soft_pwm_out oid=%d pin=%s cycle_ticks=TICKS(%f)"
+                " default_value=%d max_duration=TICKS(%f)" % (
+                    self._oid, self._pin, self._cycle_time, self._invert,
+                    self._max_duration))
             self._pwm_max = self._mcu.serial.msgparser.get_constant_float(
                 "SOFT_PWM_MAX")
             self._set_cmd = self._mcu.lookup_command(
@@ -321,8 +334,9 @@ class MCU_pwm:
         self._last_clock = clock
 
 class MCU_adc:
-    def __init__(self, mcu, pin):
+    def __init__(self, mcu, pin_params):
         self._mcu = mcu
+        self._pin = pin_params['pin']
         self._oid = mcu.create_oid(self)
         self._min_sample = self._max_sample = 0.
         self._sample_time = self._report_time = 0.
@@ -332,20 +346,23 @@ class MCU_adc:
         self._inv_max_adc = 0.
         self._mcu_freq = 0.
         self._cmd_queue = mcu.alloc_command_queue()
-        mcu.add_config_cmd("config_analog_in oid=%d pin=%s" % (self._oid, pin))
         self._query_cmd = None
         mcu.add_init_callback(self._init_callback)
-        self._query_cmd = None
-    def build_config(self):
-        self._mcu_freq = self._mcu.get_mcu_freq()
-        self._query_cmd = self._mcu.lookup_command(
-            "query_analog_in oid=%c clock=%u sample_ticks=%u sample_count=%c"
-            " rest_ticks=%u min_value=%hu max_value=%hu")
-    def set_minmax(self, sample_time, sample_count, minval=0., maxval=1.):
+    def setup_minmax(self, sample_time, sample_count, minval=0., maxval=1.):
         self._sample_time = sample_time
         self._sample_count = sample_count
         self._min_sample = minval
         self._max_sample = maxval
+    def setup_adc_callback(self, report_time, callback):
+        self._report_time = report_time
+        self._callback = callback
+    def build_config(self):
+        self._mcu_freq = self._mcu.get_mcu_freq()
+        self._mcu.add_config_cmd("config_analog_in oid=%d pin=%s" % (
+            self._oid, self._pin))
+        self._query_cmd = self._mcu.lookup_command(
+            "query_analog_in oid=%c clock=%u sample_ticks=%u sample_count=%c"
+            " rest_ticks=%u min_value=%hu max_value=%hu")
     def _init_callback(self):
         if not self._sample_count:
             return
@@ -370,9 +387,6 @@ class MCU_adc:
         last_read_time = (next_clock - self._report_clock) / self._mcu_freq
         if self._callback is not None:
             self._callback(last_read_time, last_value)
-    def set_adc_callback(self, report_time, callback):
-        self._report_time = report_time
-        self._callback = callback
 
 class MCU:
     error = error
@@ -398,7 +412,7 @@ class MCU:
         # Config building
         if printer.bglogger is not None:
             printer.bglogger.set_rollover_info("mcu", None)
-        self._config_error = config.error
+        pins.get_printer_pins(printer).register_chip("mcu", self)
         self._emergency_stop_cmd = self._reset_cmd = None
         self._oids = []
         self._config_cmds = []
@@ -564,8 +578,7 @@ class MCU:
                 updated_cmds.append(pins.update_command(
                     cmd, self._mcu_freq, pnames))
             except:
-                raise self._config_error("Unable to translate pin name: %s" % (
-                    cmd,))
+                raise pins.error("Unable to translate pin name: %s" % (cmd,))
         self._config_cmds = updated_cmds
 
         # Calculate config CRC
@@ -617,6 +630,13 @@ class MCU:
         for cb in self._init_callbacks:
             cb()
     # Config creation helpers
+    def setup_pin(self, pin_params):
+        pcs = {'stepper': MCU_stepper, 'endstop': MCU_endstop,
+               'digital_out': MCU_digital_out, 'pwm': MCU_pwm, 'adc': MCU_adc}
+        pin_type = pin_params['type']
+        if pin_type not in pcs:
+            raise pins.error("pin type %s not supported on mcu" % (pin_type,))
+        return pcs[pin_type](self, pin_params)
     def create_oid(self, oid):
         self._oids.append(oid)
         return len(self._oids) - 1
@@ -634,19 +654,6 @@ class MCU:
         return self.serial.msgparser.lookup_command(msgformat)
     def create_command(self, msg):
         return self.serial.msgparser.create_command(msg)
-    # Wrappers for mcu object creation
-    def create_stepper(self, step_pin, dir_pin):
-        return MCU_stepper(self, step_pin, dir_pin)
-    def create_endstop(self, pin):
-        return MCU_endstop(self, pin)
-    def create_digital_out(self, pin, max_duration=2.):
-        return MCU_digital_out(self, pin, max_duration)
-    def create_pwm(self, pin, cycle_time, hard_cycle_ticks=0, max_duration=2.):
-        if hard_cycle_ticks < 0:
-            return MCU_digital_out(self, pin, max_duration)
-        return MCU_pwm(self, pin, cycle_time, hard_cycle_ticks, max_duration)
-    def create_adc(self, pin):
-        return MCU_adc(self, pin)
     # Clock syncing
     def set_print_start_time(self, eventtime):
         clock = self.serial.get_clock(eventtime)
@@ -687,3 +694,6 @@ class MCU:
         return self._printer.reactor.monotonic()
     def __del__(self):
         self.disconnect()
+
+def add_printer_objects(printer, config):
+    printer.add_object('mcu', MCU(printer, config.getsection('mcu')))
diff --git a/klippy/pins.py b/klippy/pins.py
index 9d3da5e6c..cdfdede02 100644
--- a/klippy/pins.py
+++ b/klippy/pins.py
@@ -1,11 +1,16 @@
 # Pin name to pin number definitions
 #
-# Copyright (C) 2016  Kevin O'Connor <kevin@koconnor.net>
+# Copyright (C) 2016,2017  Kevin O'Connor <kevin@koconnor.net>
 #
 # This file may be distributed under the terms of the GNU GPLv3 license.
 
 import re
 
+
+######################################################################
+# Hardware pin names
+######################################################################
+
 def port_pins(port_count, bit_count=8):
     pins = {}
     for port in range(port_count):
@@ -142,12 +147,12 @@ def update_map_beaglebone(pins, mcu):
 
 
 ######################################################################
-# External commands
+# Command translation
 ######################################################################
 
 # Obtains the pin mappings
 def get_pin_map(mcu, mapping_name=None):
-    pins = MCU_PINS.get(mcu, {})
+    pins = dict(MCU_PINS.get(mcu, {}))
     if mapping_name == 'arduino':
         update_map_arduino(pins, mcu)
     elif mapping_name == 'beaglebone':
@@ -163,3 +168,51 @@ def update_command(cmd, mcu_freq, pmap):
     def ticks_fixup(m):
         return str(int(mcu_freq * float(m.group('ticks'))))
     return re_ticks.sub(ticks_fixup, re_pin.sub(pin_fixup, cmd))
+
+
+######################################################################
+# Pin to chip mapping
+######################################################################
+
+class error(Exception):
+    pass
+
+class PrinterPins:
+    error = error
+    def __init__(self):
+        self.chips = {}
+    def parse_pin_desc(self, pin_desc, can_invert=False, can_pullup=False):
+        pullup = invert = 0
+        if can_pullup and pin_desc.startswith('^'):
+            pullup = 1
+            pin_desc = pin_desc[1:].strip()
+        if can_invert and pin_desc.startswith('!'):
+            invert = 1
+            pin_desc = pin_desc[1:].strip()
+        if ':' not in pin_desc:
+            chip_name, pin = 'mcu', pin_desc
+        else:
+            chip_name, pin = [s.strip() for s in pin_desc.split(':', 1)]
+        if chip_name not in self.chips:
+            raise error("Unknown pin chip name '%s'" % (chip_name,))
+        return {'chip': self.chips[chip_name], 'pin': pin,
+                'invert': invert, 'pullup': pullup}
+    def register_chip(self, chip_name, chip):
+        chip_name = chip_name.strip()
+        if chip_name in self.chips:
+            raise error("Duplicate chip name '%s'" % (chip_name,))
+        self.chips[chip_name] = chip
+
+def add_printer_objects(printer, config):
+    printer.add_object('pins', PrinterPins())
+
+def get_printer_pins(printer):
+    return printer.objects['pins']
+
+def setup_pin(printer, pin_type, pin_desc):
+    ppins = get_printer_pins(printer)
+    can_invert = pin_type in ['stepper', 'endstop', 'digital_out', 'pwm']
+    can_pullup = pin_type == 'endstop'
+    pin_params = ppins.parse_pin_desc(pin_desc, can_invert, can_pullup)
+    pin_params['type'] = pin_type
+    return pin_params['chip'].setup_pin(pin_params)
diff --git a/klippy/stepper.py b/klippy/stepper.py
index f5085bc0f..c97c8e4d7 100644
--- a/klippy/stepper.py
+++ b/klippy/stepper.py
@@ -1,10 +1,10 @@
 # Printer stepper support
 #
-# Copyright (C) 2016  Kevin O'Connor <kevin@koconnor.net>
+# Copyright (C) 2016,2017  Kevin O'Connor <kevin@koconnor.net>
 #
 # This file may be distributed under the terms of the GNU GPLv3 license.
 import math, logging
-import homing
+import homing, pins
 
 class PrinterStepper:
     def __init__(self, printer, config, name):
@@ -13,14 +13,17 @@ class PrinterStepper:
         self.step_dist = config.getfloat('step_distance', above=0.)
         self.inv_step_dist = 1. / self.step_dist
         self.min_stop_interval = 0.
-        step_pin = config.get('step_pin')
-        dir_pin = config.get('dir_pin')
-        self.mcu_stepper = printer.mcu.create_stepper(step_pin, dir_pin)
-        self.mcu_stepper.set_step_distance(self.step_dist)
+        self.mcu_stepper = pins.setup_pin(
+            printer, 'stepper', config.get('step_pin'))
+        dir_pin_params = pins.get_printer_pins(printer).parse_pin_desc(
+            config.get('dir_pin'), can_invert=True)
+        self.mcu_stepper.setup_dir_pin(dir_pin_params)
+        self.mcu_stepper.setup_step_distance(self.step_dist)
 
         enable_pin = config.get('enable_pin', None)
         if enable_pin is not None:
-            self.mcu_enable = printer.mcu.create_digital_out(enable_pin, 0)
+            self.mcu_enable = pins.setup_pin(printer, 'digital_out', enable_pin)
+            self.mcu_enable.setup_max_duration(0.)
         self.need_motor_enable = True
     def _dist_to_time(self, dist, start_velocity, accel):
         # Calculate the time it takes to travel a distance with constant accel
@@ -33,7 +36,7 @@ class PrinterStepper:
         second_last_step_time = self._dist_to_time(
             2. * self.step_dist, max_halt_velocity, max_accel)
         min_stop_interval = second_last_step_time - last_step_time
-        self.mcu_stepper.set_min_stop_interval(min_stop_interval)
+        self.mcu_stepper.setup_min_stop_interval(min_stop_interval)
     def motor_enable(self, move_time, enable=0):
         if enable and self.need_motor_enable:
             mcu_time = self.mcu_stepper.print_to_mcu_time(move_time)
@@ -48,8 +51,8 @@ class PrinterHomingStepper(PrinterStepper):
     def __init__(self, printer, config, name):
         PrinterStepper.__init__(self, printer, config, name)
 
-        endstop_pin = config.get('endstop_pin', None)
-        self.mcu_endstop = printer.mcu.create_endstop(endstop_pin)
+        self.mcu_endstop = pins.setup_pin(
+            printer, 'endstop', config.get('endstop_pin'))
         self.mcu_endstop.add_stepper(self.mcu_stepper)
         self.position_min = config.getfloat('position_min', 0.)
         self.position_max = config.getfloat(