Generically detecting changed fields in a custom form prior to saving a node
This, at long last, should work as a generic solution. Thanks to Clive and morbiD for all the input.

Pass both versions of the node to the following function. It will:


Pull all of the detected content type's editable fields and their editable columns (i.e. items that could possibly appear on the custom form) from the database in a single query.
Ignore fields and columns that are completely empty in both versions.
Treat a field that has a different number of values between the two versions as a change.
Iterate through every field, value, and column and compare the two versions.
Compare items non-identically (!=) if they are numeric and identically (!==) if they are anything else.
Immediately return TRUE on the first change it detects (since one change is enough to know we need to resave the node).
Return FALSE if no change is detected after all the values are compared.
Recursively compare field collections by loading them and their schema and passing the results to itself. This SHOULD even allow it to compare nested field collections. The code should NOT have any dependency on the Field Collection module.


Let me know if there are any more bugs or typos in this code.

/*
 * Pass both versions of the node to this function. Returns TRUE if it detects any changes and FALSE if not.
 * Pass field collections as an array keyed by field collection ID.
 *
 * @param object $old_entity
 *   The original (stored in the database) node object.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 * @param object $new_entity
 *   The prepared node object for comparison.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 */
function _fields_changed($old_entity, $new_entity) {
  // Check for node or field collection.
  $entity_is_field_collection = (get_class($old_entity) == 'FieldCollectionItemEntity');

  $bundle = ($entity_is_field_collection ? $old_entity-&gt;field_name : $old_entity-&gt;type);

  // Sanity check. Exit and throw an error if the content types don't match.
  if($bundle !== ($entity_is_field_collection ? $new_entity-&gt;field_name : $new_entity-&gt;type)) {
    drupal_set_message('Content type mismatch. Unable to save changes.', 'error');
    return FALSE;
  }

  // Get field info.
  $field_read_params = array(
    'entity_type' =&gt; ($entity_is_field_collection ? 'field_collection_item' : 'node'),
    'bundle' =&gt; $bundle
  );
  $fields_info = field_read_fields($field_read_params);

  foreach($fields_info as $field_name =&gt; $field_info) {
    $old_field = $old_entity-&gt;$field_name;
    $new_field = $new_entity-&gt;$field_name;

    // Check the number of values for each field, or if they are populated at all.
    $old_field_count = (isset($old_field[LANGUAGE_NONE]) ? count($old_field[LANGUAGE_NONE]) : 0);
    $new_field_count = (isset($new_field[LANGUAGE_NONE]) ? count($new_field[LANGUAGE_NONE]) : 0);

    if ($old_field_count != $new_field_count) {
      // The two versions have a different number of values. Something has changed.
      return TRUE;
    } elseif ($old_field_count &gt; 0 &amp;&amp; $new_field_count &gt; 0) {
      // Both versions have an equal number of values. Time to compare.

      // See if this field is a field collection.
      if ($field_info['type'] == 'field_collection') {

        foreach ($new_field[LANGUAGE_NONE] as $delta =&gt; $values) {
          $old_field_collection = entity_load_unchanged('field_collection_item', $values['entity']-&gt;item_id);
          $new_field_collection = $values['entity'];

          if (_fields_changed($old_field_collection, $new_field_collection)) {
            return TRUE;
          }
        }
        unset($delta, $values);

      } else {
        foreach($old_field[LANGUAGE_NONE] as $delta =&gt; $value) {
          foreach($field_info['columns'] as $field_column_name =&gt; $field_column_info) {
            $old_value = $old_field[LANGUAGE_NONE][$delta][$field_column_name];
            $new_value = $new_field[LANGUAGE_NONE][$delta][$field_column_name];
            $field_column_type = $field_column_info['type'];

            // As with the overall field, exit if one version has a value and the other doesn't.
            if (isset($old_value) != isset($new_value)) {
              return TRUE;
            } elseif (isset($old_value) &amp;&amp; isset($new_value)) {
              // The column stores numeric data so compare values non-identically.
              if (in_array($field_column_type, array('int', 'float', 'numeric'))) {
                if ($new_value != $old_value) {
                  return TRUE;
                }
              }
              // The column stores non-numeric data so compare values identically,
              elseif ($new_value !== $old_value) {
                return TRUE;
              }
            } else {
              // Included for clarity. Both values are empty so there was obviously no change.
            }
          } 
          unset($field_column_name, $field_column_info);
        }
        unset($delta, $value);
      }
    } else {
      // Included for clarity. Both values are empty so there was obviously no change.
    }
  }
  unset($field_name, $field_info);
  // End of field comparison loop.

  // We didn't find any changes. Don't resave the node.
  return FALSE;
}


Sometimes you are interested in knowing which fields have changed.
In order to know that, you can use this version of the function:

/*
 * Pass both versions of the node to this function. Returns an array of
 * fields that were changed or an empty array if none were changed.
 * Pass field collections as an array keyed by field collection ID.
 *
 * @param object $old_entity
 *   The original (stored in the database) node object.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 * @param object $new_entity
 *   The prepared node object for comparison.
 *   This function may also pass itself a FieldCollectionItemEntity object to compare field collections.
 */
function _fields_changed($old_entity, $new_entity) {
  // Check for node or field collection.
  $entity_is_field_collection = (get_class($old_entity) == 'FieldCollectionItemEntity');

  $bundle = ($entity_is_field_collection ? $old_entity-&gt;field_name : $old_entity-&gt;type);

  // Sanity check. Exit and throw an error if the content types don't match.
  if ($bundle !== ($entity_is_field_collection ? $new_entity-&gt;field_name : $new_entity-&gt;type)) {
    drupal_set_message('Content type mismatch. Unable to save changes.', 'error');
    return FALSE;
  }

  // Get field info.
  $field_read_params = array(
    'entity_type' =&gt; ($entity_is_field_collection ? 'field_collection_item' : 'node'),
    'bundle' =&gt; $bundle
  );
  $fields_info = field_read_fields($field_read_params);

  $fields_changed = array();

  foreach ($fields_info as $field_name =&gt; $field_info) {
    $old_field = $old_entity-&gt;$field_name;
    $new_field = $new_entity-&gt;$field_name;

    // Check the number of values for each field, or if they are populated at all.
    $old_field_count = (isset($old_field[LANGUAGE_NONE]) ? count($old_field[LANGUAGE_NONE]) : 0);
    $new_field_count = (isset($new_field[LANGUAGE_NONE]) ? count($new_field[LANGUAGE_NONE]) : 0);

    if ($old_field_count != $new_field_count) {
      // The two versions have a different number of values. Something has changed.
      $fields_changed[] = $field_name;
    }
    elseif ($old_field_count &gt; 0 &amp;&amp; $new_field_count &gt; 0) {
      // Both versions have an equal number of values. Time to compare.

      // See if this field is a field collection.
      if ($field_info['type'] == 'field_collection') {

        foreach ($new_field[LANGUAGE_NONE] as $delta =&gt; $values) {
          $old_field_collection = entity_load_unchanged('field_collection_item', $values['entity']-&gt;item_id);
          $new_field_collection = $values['entity'];

          $fields_changed = array_merge($fields_changed, _fields_changed($old_field_collection, $new_field_collection));
        }
        unset($delta, $values);

      }
      else {
        foreach ($old_field[LANGUAGE_NONE] as $delta =&gt; $value) {
          foreach ($field_info['columns'] as $field_column_name =&gt; $field_column_info) {
            $old_value = $old_field[LANGUAGE_NONE][$delta][$field_column_name];
            $new_value = $new_field[LANGUAGE_NONE][$delta][$field_column_name];
            $field_column_type = $field_column_info['type'];

            // As with the overall field, exit if one version has a value and the other doesn't.
            if (isset($old_value) != isset($new_value)) {
              $fields_changed[] = $old_field;
            }
            elseif (isset($old_value) &amp;&amp; isset($new_value)) {
              // The column stores numeric data so compare values non-identically.
              if (in_array($field_column_type, array(
                'int',
                'float',
                'numeric'
              ))) {
                if ($new_value != $old_value) {
                  $fields_changed[] = $field_name;
                }
              }
              // The column stores non-numeric data so compare values identically,
              elseif ($new_value !== $old_value) {
                $fields_changed[] = $field_name;
              }
            }
            else {
              // Included for clarity. Both values are empty so there was obviously no change.
            }
          }
          unset($field_column_name, $field_column_info);
        }
        unset($delta, $value);
      }
    }
    else {
      // Included for clarity. Both values are empty so there was obviously no change.
    }
  }
  unset($field_name, $field_info);
  // End of field comparison loop.

  return $fields_changed;
}


Sometimes you may want to make it so that changing certain fields of a node do not cause that node's "changed" timestamp to be updated. This could be implemented like as follows:

/**
 * Implements hook_node_presave().
 */
function mymodule_node_presave($node) {
  $fields_changed = _fields_changed($node-&gt;original, $node);
  $no_update_timestamp_fields = array('field_subject', 'field_keywords');
  if (!empty($fields_changed) &amp;&amp;
    empty(array_diff($fields_changed, $no_update_timestamp_fields))) {
    // Don't change the $node-&gt;changed timestamp if one of the fields has
    // been changed that should not affect the timestamp.
    $node-&gt;changed = $node-&gt;original-&gt;changed;
  }
}


EDIT (7/30/2013) Tightened up field collection support. Added support for fields with multiple values.

EDIT (7/31/2015) Added version of function which returns which fields have changed, and example use case.