코드의 배열 크기와 실제 스택 사용량을 구분합니다
스택(stack)은 함수가 돌아갈 위치와 실행 중 필요한 값을 잠시 쌓아 두는 메모리 영역입니다. 함수가 한 번 호출될 때 차지하는 공간을 스택 프레임(stack frame)이라고 합니다. 스택 오버플로(stack overflow)는 실행 중 필요한 스택 공간이 해당 스택에 마련된 경계를 넘는 상태입니다. 경계 밖에 다른 데이터가 있으면 값이 바뀌거나 프로그램이 멈출 수 있지만, 어떤 데이터가 먼저 손상되는지는 메모리 배치와 보호 설정에 따라 달라집니다.
예를 들어 실행 중 데이터를 보관하는 RAM(Random Access Memory)이 총 2 KB인 장치의 재귀 함수에 char buf[120]가 적혀 있다고 해도, 호출 한 번에 정확히 120바이트가 스택에 더해진다고 계산해서는 안 됩니다. 컴파일러가 배열을 없애거나 호출 방식을 바꿀 수도 있고, 반대로 복귀 정보·저장 레지스터·정렬 때문에 프레임이 더 커질 수도 있습니다. GCC(GNU Compiler Collection)는 일부 조건에서 꼬리 재귀 호출(tail-recursive call)을 최적화합니다. 따라서 소스 코드의 120바이트, 빌드 결과의 프레임 크기, 실제 실행에서 도달한 최대 깊이를 서로 다른 값으로 취급합니다. 그림의 스택 포인터(SP, Stack Pointer)는 현재 사용 중인 스택 위치를 가리키는 중앙처리장치(CPU, Central Processing Unit)의 레지스터입니다.
스택 프레임이 전역 변수 자리까지 내려온다는 가상의 배치를 생각해 보겠습니다. 프레임에 저장한 복귀 주소(return address)가 전역 변수 speed를 덮으면, 먼저 변수 값이 복귀 주소의 비트 패턴으로 바뀔 수 있습니다. 이어 코드가 speed = 100을 실행하면 같은 저장 위치의 복귀 주소가 100(16진수 0x64)으로 바뀔 수도 있습니다. 프로그램 카운터(PC, Program Counter)는 다음에 실행할 위치를 가리킵니다. 함수가 돌아갈 때 PC가 잘못된 주소로 이동하려 하면 오류가 납니다. 이 순서는 메모리가 겹칠 때 일어날 수 있는 손상을 설명하는 예시이지, 특정 장치에서 실제로 발생했다는 측정 결과가 아닙니다. 어느 값이 손상됐는지는 아래의 빌드 배치와 오류 직전 레지스터 기록으로 확인해야 합니다.
먼저 실패가 시작되는 입력과 호출 깊이를 기록합니다
같은 펌웨어와 빌드 설정으로 정상 입력과 실패 입력을 각각 반복 실행합니다. 재귀가 몇 단계까지 들어갔는지, 어느 분기에서 돌아오는지, 멈춤·재부팅·변수 변경 중 무엇이 일어났는지를 기록합니다. 디버거(debugger)가 있다면 오류 직전의 SP, PC, 오류 상태를 저장합니다. 값 변경이 보인다고 특정 전역 변수가 스택에 덮였다고 바로 결론 내리지 마세요. 버퍼 범위 초과, 잘못된 포인터, 경쟁 상태도 별도로 확인해야 합니다.
입력에 따라 재귀 깊이가 달라지는 함수라면 종료 조건을 먼저 찾고 최대 허용 깊이를 정합니다. 종료 조건이 빠진 경로가 있는지, 깊이 제한을 넘는 입력이 거부되는지 살펴봅니다. 실제 장치에서 최악 입력을 만들기 어렵다면 테스트용 입력으로 경계 바로 아래와 바로 위를 재현합니다. 이때 오류가 난 장치를 계속 운전하지 말고 디버그 환경에서 상태를 수집합니다.
실제 빌드의 메모리 경계와 프레임을 대조합니다
링커 스크립트(linker script)는 프로그램의 코드와 데이터가 어느 메모리 영역에 놓일지 정하는 빌드 파일입니다. 링크 맵(link map)은 그 배치 결과를 보여 주는 보고서입니다. 현재 배포할 빌드의 스크립트와 맵에서 RAM의 시작·끝, .data와 .bss 같은 전역·정적 데이터 구역, 힙(heap)의 배치를 확인합니다. 힙은 실행 중 동적으로 할당하는 메모리 영역입니다. 스택의 시작·끝과 예약 크기는 시작 코드(startup code)와 운영체제 설정까지 대조합니다. 빌드에 따라 스택 예약이 맵에 직접 표시되지 않을 수도 있습니다. 총 RAM 2 KB를 스택 2 KB로 읽으면 안 됩니다. 스택과 힙이 서로 자라는 배치라면 둘 사이의 여유도 따로 봅니다.
GCC를 쓰는 빌드라면 -fstack-usage로 함수별 스택 사용량을 담은 .su 파일을 만듭니다. 이 파일의 숫자는 함수 하나에 관한 빌드 분석이며, 재귀 호출 전체가 쓴 최대량을 자동으로 뜻하지 않습니다. static, dynamic, bounded 표시를 읽고, 동적 사용량이 상한 없이 표시되거나 라이브러리 함수의 크기를 알 수 없으면 숫자만 더해 안전하다고 판단하지 않습니다. 컴파일러 버전·최적화 수준·링크 시 최적화(LTO, Link-Time Optimization)를 실제 배포 빌드와 맞춥니다. char buf[120]가 최종 코드에 남았는지도 이 결과와 생성 코드에서 확인합니다.
그다음 종료 조건을 기준으로 아직 반환되지 않고 남아 있는 재귀 호출의 최대 수를 세고, 각 단계의 프레임과 그 안에서 부르는 다른 함수의 가장 깊은 경로를 함께 계산합니다. 같은 스택을 쓰는 인터럽트 서비스 루틴(ISR, Interrupt Service Routine)이 중첩될 수 있다면 그때 저장되는 상태와 ISR 자체의 사용량도 더해야 합니다. 실시간 운영체제(RTOS, Real-Time Operating System)를 쓰면 메인 스택과 작업별 스택이 분리될 수 있으므로, 어느 실행 경로가 어느 스택을 쓰는지 먼저 확인합니다. Cortex-M에서 작업이 프로세스 스택을 쓰는 구성이라면 인터럽트 진입 때 저장되는 작업 상태는 작업 스택에, ISR의 실행 프레임은 메인 스택에 놓일 수 있습니다. 작업 스택과 메인 스택의 필요량을 각각 계산해야 합니다.
계산 방법을 설명하기 위한 가상의 빌드에서 스택 예약이 768바이트이고, 재귀에 들어오기 전 사용량이 96바이트라고 해 보겠습니다. 실제 생성 코드에서 확인한 재귀 프레임이 단계마다 144바이트, 동시에 살아 있는 호출이 최대 세 단계, 가장 깊은 단계에서 부르는 함수가 64바이트, 같은 스택에서 중첩되는 인터럽트의 최대 추가량이 64바이트라면 상한은 96 + 3 × 144 + 64 + 64 = 656바이트입니다. 예약 공간에서 뺀 112바이트는 이 가정에 따른 여유일 뿐입니다. 실제 빌드에서 프레임이나 호출 경로가 달라지거나 미확인 라이브러리 호출·인터럽트 중첩이 있으면 다시 계산해야 합니다. 인터럽트가 별도 스택을 쓰면 그 사용량은 해당 스택에서 따로 검증합니다. 이 숫자를 앞서 본 2 KB RAM 그림의 실측값으로 읽어서는 안 됩니다.
실행 중 가장 깊이 쓴 지점을 측정하고 다시 검증합니다
정적 분석 뒤에는 실제 빌드의 디버그 시험에서 스택 최대 사용 흔적(high-water mark)을 확인합니다. 이는 시험 중 가장 적게 남았던 스택 여유를 기록하는 방법입니다. 사용하는 RTOS가 제공하는 함수나 디버거 기능이 있다면 해당 버전과 포트의 단위를 확인해 읽습니다. 별도 기능이 없다면, 시작 전에 사용하지 않는 스택 영역을 일정한 바이트 값으로 채우고 시험 뒤 어느 지점까지 바뀌었는지 볼 수 있습니다. 이 값을 현재 사용 중인 프레임에 써서는 안 되며, 시작 코드와 스택 경계를 확인한 디버그 빌드에서만 적용합니다.
이 검사는 시험 중 바이트 값이 실제로 바뀐 지점을 찾습니다. 함수가 큰 공간을 예약하고도 그 일부에 값을 쓰지 않으면 실제로 내려간 스택 포인터보다 여유가 커 보일 수 있습니다. 깊은 호출 지점의 스택 포인터와 함수별 보고서, 가능한 보호 오류 기록을 함께 대조합니다. 관찰한 최고 사용량도 실행한 입력·인터럽트 조합에 대한 기록일 뿐, 시험하지 않은 경로의 안전 증명은 아닙니다.
지원되는 MCU와 실행 환경에서는 스택 끝에 접근 금지 구역(guard region)을 설정하거나 하드웨어 스택 한계 레지스터를 사용할 수 있습니다. MPU(Memory Protection Unit)는 메모리 구역의 접근 권한을 검사하는 하드웨어입니다. 다만 MPU 유무, 보호 구역의 정렬·크기, 메인·작업 스택의 분리, 오류가 감지되는 시점은 코어와 RTOS마다 다릅니다. 예컨대 Cortex-M33에는 메인·프로세스 스택의 하한을 지정하는 MSPLIM·PSPLIM이 있지만, 같은 방식이 모든 Cortex-M에 있는 것은 아닙니다. 보호 오류가 발생하면 원인을 남기고 멈춰 조사해야 하며, 데이터가 전혀 손상되지 않았다고 가정해 바로 실행을 재개해서는 안 됩니다.
최대 깊이를 제한하거나 재귀를 반복문으로 바꾸는 수정은 호출이 계속 쌓이는 원인을 줄입니다. 큰 지역 배열을 함수 밖으로 옮길 때는 동시 호출이 같은 저장 공간을 덮지 않는지 확인해야 합니다. 정적 배열로 옮겨도 RAM 총사용량이 저절로 줄지는 않습니다. 수정 뒤에는 정상 입력·경계 입력·최대 입력에 인터럽트 중첩과 다른 작업 부하를 더해 재시험하고, 맵의 예약 크기·함수별 보고서·고수위 기록이 서로 맞는지 확인합니다.
String의 힙 상태와 재귀 스택은 별도 가설로 조사합니다
장시간 동작 뒤 멈추었다는 현상만으로 재귀가 원인인지 동적 문자열 할당이 원인인지 정할 수 없습니다. 같은 빌드·입력에서 재귀 깊이·스택 여유와 문자열의 생성·확장·수명, 할당 실패 기록을 나눠 관측합니다. 플랫폼이 제공하는 계측 수단의 의미도 확인하며, 총 빈 힙 크기 하나를 연속 할당 가능한 최대 블록과 혼동하지 않습니다.
Arduino AVR 코어 1.8.6의 WString.cpp에서 reserve는 기존 용량이 충분하면 버퍼를 유지하고, 일반 문자열 대입은 copy를 거칩니다. 따라서 myString = ""만으로 기존 버퍼가 반드시 해제된다고 말할 수 없습니다. 다른 코어·버전은 해당 구현을 다시 확인합니다.
재현 가능한 작은 시험에서는 문자열 최대 길이와 반복 작업을 고정하고, 같은 입력의 재귀·비재귀 경로 및 동적 할당 유무를 비교합니다. reserve의 반환값처럼 확인 가능한 실패를 기록하되 관측 코드 자체의 메모리 사용도 고려합니다. 이는 조사 계획이며 특정 원본에서 누수나 힙 고갈을 재현했다는 결과가 아닙니다.