From d33cdb8e828007c20fd2307433dce5da04a2e193 Mon Sep 17 00:00:00 2001 From: byun Date: Fri, 6 Mar 2026 16:10:13 +0000 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ESP32_CAN_Logger-a.ino | 275 +++++++++++++++++++++++++---------------- 1 file changed, 166 insertions(+), 109 deletions(-) diff --git a/ESP32_CAN_Logger-a.ino b/ESP32_CAN_Logger-a.ino index e27ebee..3517684 100644 --- a/ESP32_CAN_Logger-a.ino +++ b/ESP32_CAN_Logger-a.ino @@ -106,8 +106,9 @@ struct CANMessage { uint64_t timestamp_us; uint32_t id; uint8_t dlc; + uint8_t flags; // bit7=EFF(29bit확장), bit6=RTR, bit5=ERR ← ★ PCAP EFF 복원용 uint8_t data[8]; -} __attribute__((packed)); +} __attribute__((packed)); // 22 bytes // ======================================== // ★ PCAP 구조체 (Wireshark 호환) @@ -145,6 +146,12 @@ struct PcapRecord { SocketCANFrame frame; } __attribute__((packed)); // 32 bytes +// ★ 컴파일 타임 크기 검증 (틀리면 빌드 에러로 즉시 알 수 있음) +static_assert(sizeof(PcapGlobalHeader) == 24, "PcapGlobalHeader must be 24 bytes"); +static_assert(sizeof(PcapPacketHeader) == 16, "PcapPacketHeader must be 16 bytes"); +static_assert(sizeof(SocketCANFrame) == 16, "SocketCANFrame must be 16 bytes"); +static_assert(sizeof(PcapRecord) == 32, "PcapRecord must be 32 bytes"); + struct SerialMessage { uint64_t timestamp_us; uint16_t length; @@ -279,7 +286,8 @@ static inline bool IRAM_ATTR ring_can_push(CANRingBuffer* rb, const CANMessage* rb->dropped++; return false; // drop: 손실 카운트 후 즉시 반환 } - rb->buf[h & rb->mask] = *msg; // 데이터 기록 + // ★ packed struct(22bytes) → 비정렬 PSRAM 주소에 안전하게 memcpy + memcpy(&rb->buf[h & rb->mask], msg, sizeof(CANMessage)); __sync_synchronize(); // ★ 메모리 배리어: buf 쓰기 완료 후 head 갱신 rb->head = h + 1; return true; @@ -289,7 +297,8 @@ static inline bool IRAM_ATTR ring_can_push(CANRingBuffer* rb, const CANMessage* static inline bool ring_can_pop(CANRingBuffer* rb, CANMessage* msg) { uint32_t t = rb->tail; if (rb->head == t) return false; // 빈 버퍼 - *msg = rb->buf[t & rb->mask]; + // ★ packed struct(22bytes) → 비정렬 PSRAM 주소에서 안전하게 memcpy + memcpy(msg, &rb->buf[t & rb->mask], sizeof(CANMessage)); __sync_synchronize(); // ★ 메모리 배리어: 데이터 읽기 완료 후 tail 갱신 rb->tail = t + 1; return true; @@ -430,6 +439,7 @@ volatile uint32_t currentSerialFileSize = 0; volatile uint32_t currentSerial2FileSize= 0; volatile bool canLogFormatCSV = false; volatile bool canLogFormatPCAP = false; // ★ PCAP 추가 +uint32_t pcapDbgCount = 0; // ★ PCAP 시리얼 디버그 카운터 volatile bool serialLogFormatCSV = true; volatile bool serial2LogFormatCSV = true; volatile uint64_t canLogStartTime = 0; @@ -444,10 +454,10 @@ CAN_SPEED currentCanSpeed = CAN_1000KBPS; const char* canSpeedNames[] = {"125K", "250K", "500K", "1M"}; CAN_SPEED canSpeedValues[]= {CAN_125KBPS, CAN_250KBPS, CAN_500KBPS, CAN_1000KBPS}; -uint32_t totalMsgCount = 0; -uint32_t msgPerSecond = 0; -uint32_t lastMsgCountTime= 0; -uint32_t lastMsgCount = 0; +volatile uint32_t totalMsgCount = 0; // Core 1 write / Core 0 read → volatile 필수 +volatile uint32_t msgPerSecond = 0; // Core 0 webUpdateTask에서 계산 + uint32_t lastMsgCountTime= 0; + uint32_t lastMsgCount = 0; volatile uint32_t totalSerialRxCount = 0; volatile uint32_t totalSerialTxCount = 0; volatile uint32_t totalSerial2RxCount = 0; @@ -458,7 +468,7 @@ SequenceRuntime seqRuntime = {false, 0, 0, 0, -1}; int commentCount = 0; // Forward declarations -void IRAM_ATTR canISR(); +void IRAM_ATTR canISR(); // 초경량 ISR 전방선언 void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length); void resetMCP2515(); @@ -829,6 +839,7 @@ void checkAutoTriggers(struct can_frame &frame) { flushBufferSize = 0; flushInProgress = false; currentFileSize = logFile.size(); + pcapDbgCount = 0; // ★ PCAP 디버그 카운터 리셋 Serial.printf("🎯 Auto Trigger 시작: %s\n", currentFilename); } } @@ -1045,47 +1056,45 @@ bool setMCP2515Mode(MCP2515Mode mode) { } // ======================================== -// ★★★ CAN 인터럽트 & canRxTask (RingBuffer 버전) ★★★ +// ★★★ CAN 수신: 초경량 ISR + 레벨 폴링 하이브리드 ★★★ +// +// [문제 분석] +// 기존 ISR: vTaskNotifyGiveFromISR + portYIELD_FROM_ISR +// = FreeRTOS 컨텍스트 스위치 × 8000회/s → 시스템 부하 +// 순수 폴링: vTaskDelay(1~2ms) 공백 동안 MCP2515 RX버퍼(2개) 오버플로우 → 손실 +// +// [해결책: 하이브리드] +// ISR → volatile 플래그 세팅만 (FreeRTOS 호출 0, 컨텍스트 스위치 0) +// Task → 플래그 + INT 레벨 폴링으로 즉시 읽기, 빈 상태엔 taskYIELD()만 사용 +// ★ vTaskDelay 사용 금지: delay 중 HW 오버플로우 발생 가능 // ======================================== +volatile bool IRAM_DATA_ATTR canIntFlag = false; + void IRAM_ATTR canISR() { - BaseType_t xHigherPriorityTaskWoken = pdFALSE; - if (canRxTaskHandle != NULL) - vTaskNotifyGiveFromISR(canRxTaskHandle, &xHigherPriorityTaskWoken); - portYIELD_FROM_ISR(xHigherPriorityTaskWoken); + canIntFlag = true; // FreeRTOS 호출 없음 → 컨텍스트 스위치 없음 } void canRxTask(void *parameter) { struct can_frame frame; CANMessage msg; - uint32_t lastErrorCheck = 0; - uint32_t errorRecoveryCount = 0; + uint32_t lastErrorCheck = 0; + uint32_t errorRecoveryCount= 0; - Serial.println("✓ CAN RX Task 시작 (RingBuffer SPSC, Core 1, Pri 24)"); + Serial.println("✓ CAN RX Task 시작 (초경량ISR+레벨폴링 하이브리드, Core 1, Pri 24)"); + + // ★ setup() 완료 대기 (이중 방어: 태스크가 마지막 생성되더라도 안전하게) + vTaskDelay(pdMS_TO_TICKS(500)); // 초기 버퍼 클리어 - if (digitalRead(CAN_INT_PIN) == LOW) { - int readCount = 0; - while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK && readCount < 100) { - struct timeval tv; gettimeofday(&tv, NULL); - msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; - msg.id = frame.can_id & 0x1FFFFFFF; - msg.dlc = frame.can_dlc; - memcpy(msg.data, frame.data, 8); - // ★ RingBuffer push (non-blocking, no mutex) - if (ring_can_push(&canRing, &msg)) totalMsgCount++; - readCount++; - } - } + while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {} + canIntFlag = false; while (1) { - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100)); - - // 주기적 에러 체크 (1초마다) + // ── 주기적 에러 체크 (1초마다) ────────────────────────────────── uint32_t now = millis(); if (now - lastErrorCheck > 1000) { lastErrorCheck = now; uint8_t errorFlag = mcp2515.getErrorFlags(); - uint8_t txErr = mcp2515.errorCountTX(); uint8_t rxErr = mcp2515.errorCountRX(); if (errorFlag & 0xC0) { @@ -1101,9 +1110,9 @@ void canRxTask(void *parameter) { mcp2515.clearTXInterrupts(); mcp2515.clearMERR(); mcp2515.clearERRIF(); delay(10); - if (currentMcpMode == MCP_MODE_NORMAL) mcp2515.setNormalMode(); - else if (currentMcpMode == MCP_MODE_LOOPBACK) mcp2515.setLoopbackMode(); - else mcp2515.setListenOnlyMode(); + if (currentMcpMode == MCP_MODE_NORMAL) mcp2515.setNormalMode(); + else if (currentMcpMode == MCP_MODE_LOOPBACK) mcp2515.setLoopbackMode(); + else mcp2515.setListenOnlyMode(); delay(50); while (mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) {} mcp2515.clearRXnOVRFlags(); @@ -1114,26 +1123,30 @@ void canRxTask(void *parameter) { Serial.printf("⚠️ Error Passive! REC=%d\n", rxErr); } - // ★ 배치 읽기 → RingBuffer push (완전 non-blocking) - int batchCount = 0; - while (batchCount < 20 && mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { - checkAutoTriggers(frame); + // ── ★ 엣지(ISR 플래그) + 레벨(INT핀) 이중 감지 ───────────────── + if (canIntFlag || digitalRead(CAN_INT_PIN) == LOW) { + canIntFlag = false; - struct timeval tv; gettimeofday(&tv, NULL); - msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; - msg.id = frame.can_id & 0x1FFFFFFF; - msg.dlc = frame.can_dlc; - memcpy(msg.data, frame.data, 8); + // INT LOW 동안 RX 버퍼 완전히 비우기 (손실 없음) + while (digitalRead(CAN_INT_PIN) == LOW && + mcp2515.readMessage(&frame) == MCP2515::ERROR_OK) { + checkAutoTriggers(frame); - // ★ mutex 없음, blocking 없음 - 순수 메모리 쓰기 - if (ring_can_push(&canRing, &msg)) { - totalMsgCount++; + struct timeval tv; gettimeofday(&tv, NULL); + msg.timestamp_us = (uint64_t)tv.tv_sec * 1000000ULL + tv.tv_usec; + msg.id = frame.can_id & 0x1FFFFFFF; + msg.flags = (uint8_t)((frame.can_id >> 24) & 0xE0); + msg.dlc = frame.can_dlc; + memcpy(msg.data, frame.data, 8); + + if (ring_can_push(&canRing, &msg)) totalMsgCount++; } - // dropped 시에도 canRing.dropped에 자동 기록됨 - - batchCount++; + // 읽기 완료 후 즉시 다음 폴링 (loop 재진입) + } else { + // INT HIGH, 플래그도 없음 → 타 태스크에 CPU 양보 + // ★ vTaskDelay 사용 금지: delay 중 HW 오버플로우 가능 + taskYIELD(); } - if (batchCount == 0) vTaskDelay(pdMS_TO_TICKS(1)); } } @@ -1319,23 +1332,31 @@ void sdWriteTask(void *parameter) { } // PcapRecord 조립 후 버퍼에 기록 PcapRecord rec; + memset(&rec, 0, sizeof(PcapRecord)); // ★ 안전 초기화 uint32_t sec = (uint32_t)(canMsg.timestamp_us / 1000000ULL); uint32_t usec = (uint32_t)(canMsg.timestamp_us % 1000000ULL); rec.hdr.ts_sec = sec; rec.hdr.ts_usec = usec; rec.hdr.incl_len = sizeof(SocketCANFrame); // 16 rec.hdr.orig_len = sizeof(SocketCANFrame); // 16 - // SocketCAN ID: 확장 프레임이면 bit31 세트 - rec.frame.can_id = canMsg.id; - if (canMsg.id > 0x7FF) rec.frame.can_id |= 0x80000000U; // CAN_EFF_FLAG + // ★ flags 필드로 EFF/RTR 정확히 복원 (id>0x7FF 휴리스틱 불필요) + rec.frame.can_id = canMsg.id; + if (canMsg.flags & 0x80) rec.frame.can_id |= 0x80000000U; // CAN_EFF_FLAG + if (canMsg.flags & 0x40) rec.frame.can_id |= 0x40000000U; // CAN_RTR_FLAG rec.frame.can_dlc = canMsg.dlc; - rec.frame.pad = 0; - rec.frame.res0 = 0; - rec.frame.res1 = 0; - memcpy(rec.frame.data, canMsg.data, 8); + // data: dlc 바이트만 유효, 나머지는 memset으로 이미 0 + memcpy(rec.frame.data, canMsg.data, + (canMsg.dlc <= 8) ? canMsg.dlc : 8); memcpy(¤tWriteBuffer[writeBufferIndex], &rec, sizeof(PcapRecord)); writeBufferIndex += sizeof(PcapRecord); currentFileSize += sizeof(PcapRecord); + // ★ 최초 3패킷 시리얼 디버그 (PCAP ID=0 확인용) + if (pcapDbgCount < 3) { + Serial.printf("[PCAP DBG #%lu] id=0x%X flags=0x%02X dlc=%d data=%02X%02X...\n", + pcapDbgCount, canMsg.id, canMsg.flags, canMsg.dlc, + canMsg.data[0], canMsg.data[1]); + pcapDbgCount++; + } } else { // ── BIN: 더블 버퍼 (hot path, sdMutex 없음!) ───────────── // writeBuffer는 sdWriteTask만 쓰므로 mutex 불필요 @@ -1375,11 +1396,7 @@ void sdWriteTask(void *parameter) { void sdMonitorTask(void *parameter) { while (1) { uint32_t now = millis(); - if (now - lastMsgCountTime >= 1000) { - msgPerSecond = totalMsgCount - lastMsgCount; - lastMsgCount = totalMsgCount; - lastMsgCountTime= now; - } + // ★ msgPerSecond는 Core 0 webUpdateTask에서 계산 (Core 1 스타베이션 방지) if (now - powerStatus.lastCheck >= VOLTAGE_CHECK_INTERVAL_MS) { float rawV = analogRead(MONITORING_VOLT) * (3.3f / 4095.0f); powerStatus.voltage = rawV; @@ -1644,6 +1661,7 @@ void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) loggingEnabled = true; writeBufferIndex = 0; flushBufferSize = 0; flushInProgress = false; currentFileSize = logFile.size(); + pcapDbgCount = 0; // ★ PCAP 디버그 카운터 리셋 const char* fmtName = canLogFormatPCAP ? "PCAP" : (canLogFormatCSV ? "CSV" : "BIN"); Serial.printf("✅ 로깅 시작 [%s]: %s\n", fmtName, currentFilename); } @@ -2102,8 +2120,23 @@ void webUpdateTask(void *parameter) { vTaskDelay(pdMS_TO_TICKS(500)); while (1) { + // ★ server.handleClient()를 Core 0에서 호출 + // loop()(Core 1, Pri 1)는 canRxTask(Core 1, Pri 24)에 선점당해 실행 불가 + server.handleClient(); webSocket.loop(); + // ★ msgPerSecond를 Core 0에서 직접 계산 + // (sdMonitorTask가 Core 1에서 canRxTask(Pri24)에 스타베이션될 수 있으므로) + { + uint32_t now = millis(); + if (now - lastMsgCountTime >= 1000) { + uint32_t cur = totalMsgCount; // volatile 읽기 + msgPerSecond = cur - lastMsgCount; + lastMsgCount = cur; + lastMsgCountTime = now; + } + } + if (webSocket.connectedClients() > 0) { DynamicJsonDocument doc(16384); doc["type"] = "update"; @@ -2399,90 +2432,110 @@ void setup() { server.on("/serial2", HTTP_GET, [](){server.send_P(200,"text/html",serial2_terminal_html);}); server.on("/download", HTTP_GET, []() { - if (!server.hasArg("file")) { server.send(400,"text/plain","Bad request"); return; } + // ★★★ 주의: 이 핸들러는 webUpdateTask 컨텍스트에서 실행됨 + // → webTaskHandle을 vTaskSuspend 하면 자기 자신을 정지시켜 영원히 멈춤 + // → SD_MMC.end()/재마운트 불필요 (같은 SD 인스턴스 그대로 사용) + // → CHUNK를 충분히 크게 해서 Watchdog 타임아웃 방지 + + if (!server.hasArg("file")) { + server.send(400, "text/plain", "Bad request"); return; + } String filename = "/" + server.arg("file"); + + // 로깅 중이면 잠시 중단 (sdWriteTask와 SD 버스 충돌 방지) bool wasLogging = loggingEnabled; - if (wasLogging) { loggingEnabled = false; delay(200); } - if (sdWriteTaskHandle) { vTaskSuspend(sdWriteTaskHandle); delay(100); } - if (webTaskHandle) { vTaskSuspend(webTaskHandle); delay(50); } - if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(10000)) != pdTRUE) { - if (webTaskHandle) vTaskResume(webTaskHandle); - if (sdWriteTaskHandle) vTaskResume(sdWriteTaskHandle); - if (wasLogging) loggingEnabled = true; - server.send(503,"text/plain","SD card busy"); return; + if (wasLogging) { + loggingEnabled = false; + // sdWriteTask가 현재 flush 중이면 완료 대기 (최대 500ms) + int waitMs = 0; + while (flushInProgress && waitMs < 500) { delay(10); waitMs += 10; } } - SD_MMC.end(); delay(200); - if (!SD_MMC.setPins(SDIO_CLK, SDIO_CMD, SDIO_D0) || !SD_MMC.begin("/sdcard", true)) { - xSemaphoreGive(sdMutex); - if (webTaskHandle) vTaskResume(webTaskHandle); - if (sdWriteTaskHandle) vTaskResume(sdWriteTaskHandle); + + // sdMutex 획득 (sdFlushTask와 동시 접근 차단) + if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(3000)) != pdTRUE) { if (wasLogging) loggingEnabled = true; - server.send(500,"text/plain","SD remount failed"); return; + server.send(503, "text/plain", "SD busy"); return; } + if (!SD_MMC.exists(filename)) { - SD_MMC.end(); delay(100); - SD_MMC.setPins(SDIO_CLK,SDIO_CMD,SDIO_D0,SDIO_D1,SDIO_D2,SDIO_D3); - SD_MMC.begin("/sdcard", false); xSemaphoreGive(sdMutex); - if (webTaskHandle) vTaskResume(webTaskHandle); - if (sdWriteTaskHandle) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; - server.send(404,"text/plain","File not found"); return; + server.send(404, "text/plain", "File not found"); return; } + File file = SD_MMC.open(filename, FILE_READ); if (!file) { - SD_MMC.end(); delay(100); - SD_MMC.setPins(SDIO_CLK,SDIO_CMD,SDIO_D0,SDIO_D1,SDIO_D2,SDIO_D3); - SD_MMC.begin("/sdcard", false); xSemaphoreGive(sdMutex); - if (webTaskHandle) vTaskResume(webTaskHandle); - if (sdWriteTaskHandle) vTaskResume(sdWriteTaskHandle); if (wasLogging) loggingEnabled = true; - server.send(500,"text/plain","Failed to open file"); return; + server.send(500, "text/plain", "Failed to open file"); return; } + size_t fileSize = file.size(); String dispName = server.arg("file"); server.setContentLength(fileSize); - server.sendHeader("Content-Disposition","attachment; filename=\""+dispName+"\""); - server.sendHeader("Content-Type","application/octet-stream"); - server.sendHeader("Connection","close"); - server.send(200,"application/octet-stream",""); - const size_t CHUNK = 512; - uint8_t* buf = (uint8_t*)heap_caps_aligned_alloc(32, CHUNK, MALLOC_CAP_DMA); + server.sendHeader("Content-Disposition", "attachment; filename=\"" + dispName + "\""); + server.sendHeader("Content-Type", "application/octet-stream"); + server.sendHeader("Connection", "close"); + server.send(200, "application/octet-stream", ""); + + // ★ 청크 크기 8KB (512 → 8192): 전송 횟수 대폭 감소, Watchdog 여유 + const size_t CHUNK = 8192; + uint8_t* buf = (uint8_t*)heap_caps_malloc(CHUNK, MALLOC_CAP_DMA | MALLOC_CAP_8BIT); if (!buf) buf = (uint8_t*)malloc(CHUNK); + if (buf) { - size_t total=0; + size_t total = 0; WiFiClient client = server.client(); + uint32_t lastWdtFeed = millis(); + while (file.available() && total < fileSize && client.connected()) { size_t r = file.read(buf, CHUNK); if (!r) break; - size_t w=0; - while (w 0) { + w += wr; + } else { + // 클라이언트 버퍼 가득 찬 경우 짧게 대기 + if (millis() - chunkStart > 5000) break; // 5초 타임아웃 + delay(1); + } + } + total += r; + + // ★ Watchdog 피드 (매 500ms마다) + if (millis() - lastWdtFeed > 500) { + esp_task_wdt_reset(); + lastWdtFeed = millis(); + } + yield(); } free(buf); + Serial.printf("Download OK: %s (%u bytes)\n", filename.c_str(), total); } + file.close(); - SD_MMC.end(); delay(200); - SD_MMC.setPins(SDIO_CLK,SDIO_CMD,SDIO_D0,SDIO_D1,SDIO_D2,SDIO_D3); - SD_MMC.begin("/sdcard", false); xSemaphoreGive(sdMutex); - if (webTaskHandle) vTaskResume(webTaskHandle); - if (sdWriteTaskHandle) vTaskResume(sdWriteTaskHandle); + + // 로깅 재개 if (wasLogging) loggingEnabled = true; }); server.begin(); Serial.println("✓ 웹 서버 시작"); - // CAN 인터럽트 + // ★ 초경량 ISR 등록 (플래그 세팅만, 컨텍스트 스위치 없음) attachInterrupt(digitalPinToInterrupt(CAN_INT_PIN), canISR, FALLING); // ======================================== // Task 생성 // ======================================== Serial.println("\nTask 생성 중..."); - xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 12288, NULL, 24, &canRxTaskHandle, 1); + // ★ 중요: canRxTask(Core 1, Pri 24)는 반드시 마지막에 생성 + // → 먼저 생성하면 setup()이 Core 1에서 선점당해 이후 태스크 생성 불가 xTaskCreatePinnedToCore(webUpdateTask, "WEB_UPDATE", 12288, NULL, 8, &webTaskHandle, 0); xTaskCreatePinnedToCore(txTask, "TX", 4096, NULL, 3, NULL, 1); xTaskCreatePinnedToCore(sequenceTask, "SEQ", 4096, NULL, 2, NULL, 1); @@ -2493,6 +2546,8 @@ void setup() { xTaskCreatePinnedToCore(serial2RxTask, "SERIAL2_RX", 6144, NULL, 6, &serial2RxTaskHandle, 0); if (timeSyncStatus.rtcAvailable) xTaskCreatePinnedToCore(rtcSyncTask,"RTC_SYNC", 3072, NULL, 0, &rtcTaskHandle, 0); + // ★ canRxTask 마지막 생성 (Core 1 Pri 24 → 이 시점엔 setup() 완료 직전) + xTaskCreatePinnedToCore(canRxTask, "CAN_RX", 12288, NULL, 24, &canRxTaskHandle, 1); Serial.println("\n========================================"); Serial.println(" Task 구성 (RingBuffer v3.0)"); @@ -2516,7 +2571,9 @@ void setup() { // Loop // ======================================== void loop() { - server.handleClient(); + // ★ server.handleClient()는 webUpdateTask(Core 0)에서 처리 + // loop()는 Core 1, Pri 1 → canRxTask(Pri 24)에 선점당해 실질적으로 실행 안됨 + // 통계 출력만 여기서 하지 않고 vTaskDelay로 양보 vTaskDelay(pdMS_TO_TICKS(10)); // 30초마다 상태 출력 (RingBuffer 통계 포함)