[modbus] CRC scan all unknown function codes (#18483)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bonne Eggleston
2026-08-18 11:09:12 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 92f55f721f
commit 285a508e09
5 changed files with 232 additions and 12 deletions
+22 -11
View File
@@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() {
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const {
// Custom functions could be any length - we have to rely on the CRC to determine completeness.
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
// could be any length - we have to rely on the CRC to determine completeness.
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size();
for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) {
if (crc16(raw, len) == 0)
return len;
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
if (min_length > max_len)
return 0;
// The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value,
// so we seed once over the first min_length bytes and extend one byte at a time instead of
// recomputing the whole prefix for every candidate length.
uint16_t crc = crc16(raw, min_length);
if (crc == 0)
return min_length;
for (uint16_t len = min_length; len < max_len; len++) {
crc = crc16(&raw[len], 1, crc);
if (crc == 0)
return len + 1;
}
return 0;
}
@@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() {
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (helpers::is_function_code_unknown_length(function_code)) {
frame_length = this->find_frame_end_by_crc_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
@@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (helpers::is_function_code_unknown_length(function_code)) {
frame_length = this->find_frame_end_by_crc_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
+1 -1
View File
@@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component {
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_custom_frame_end_(uint16_t min_length) const;
uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
uint32_t last_modbus_byte_{0};
uint32_t last_receive_check_{0};
@@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) {
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
}
/// True for any function code whose frame length the parsers cannot predict - everything the
/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code -
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
/// pays (recovery by timeout instead of an immediate CRC failure).
inline bool is_function_code_unknown_length(uint8_t function_code) {
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
case FunctionCode::READ_FILE_RECORD:
case FunctionCode::WRITE_FILE_RECORD:
case FunctionCode::MASK_WRITE_REGISTER:
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
case FunctionCode::READ_FIFO_QUEUE:
return false;
default:
return true;
}
}
// Returns the expected length of a server response PDU based on the function code.
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC