Proven C BookEnglish GitHub

83 프로그램의 기억 배치 — 운영체제와 임베디드

먼저 알아야 할 것

44장 수명과 저장 기간 · 저장 기간
2장 기억의 구역들 · 기억의 구역들

돌아보기

44장에서 “C 표준에는 스택이라는 말도 그 크기에 대한 약속도 없다”고 했고, 자동 저장 기간만 정해 두고 나머지는 구현에 맡긴다고 했다. 그러면 실제 프로그램에서 그 자리는 누가 정하는가?

답. 세 층이 나눠 정한다. 컴파일러와 링커가 실행 파일 안에 구역을 나누고 (어느 변수가 어느 구역에 갈지), 운영체제가 그 파일을 읽어 주소 공간에 펼치며 스택과 힙의 자리를 마련하고, 실행 시작 코드가 남은 손질을 한다. 운영체제가 없으면 마지막 둘을 전부 시작 코드가 떠맡는다 — 임베디드의 세계가 그렇고, 이 장의 후반이 그 이야기다.

이 장의 필요성과 맥락

44장에서 스케치한 배치를 더 자세히 다시 그린다. 여기까지 미룬 이유는 이 장의 내용이 C 표준에 없는 것이기 때문이다 — 스택 크기도, data·bss 의 실제 배치도 운영체제의 사정이다. 표준이 무엇을 정하고 무엇을 안 정하는지 충분히 겪은 뒤라야, 이 장이 「예외」가 아니라 「경계 너머」로 읽힌다.

이 장이 끝나면

44장에서 스케치한 기억의 배치를 더 자세히 다시 그린다. C 표준에는 없고 운영체제에만 있는 것 — 스택과 그 한도 — 을 리눅스와 윈도우에서 각각 확인하고, data·bss·읽기 전용 구역이 실행 파일과 어떤 관계인지 본다. 그리고 운영체제가 없는 세계로 내려가 Arm Cortex-M, AVR, PIC의 기억이 어떻게 놓이는지 — 시작 코드가 무엇을 하는지 — 를 자세히 본다.

이 장에서 답할 질문

  1. 임베디드에서 스택 크기는 어떻게 정하는가? 리눅스처럼 넉넉히 잡으면 되지 않는가?

83.1 실행 파일 안의 구역, 주소 공간의 구역

44장의 예제가 보여 준 순서를 다시 적으면 이렇다. 다만 이번에는 실행 파일에 들어 있는 것실행할 때 생기는 것을 갈라 본다.

구역파일에 있나무엇이 사는가권한
.text있다기계 명령읽기·실행
.rodata있다문자열 리터럴, const 자료읽기
.data있다(값까지)초깃값이 0이 아닌 전역·static읽기·쓰기
.bss크기만 있다초깃값이 0이거나 없는 전역·static읽기·쓰기
없다malloc이 준 것읽기·쓰기
스택없다지역 변수·복귀 주소읽기·쓰기

표 83.1

.bss가 파일에 크기만 있다는 사실이 이 표의 핵심이다(44장에서 이름의 유래를 보았다). 0으로 채우는 일은 실행할 때 일어난다 — 운영체제가 있으면 커널이 0으로 채워진 페이지를 주고, 없으면 시작 코드가 직접 루프를 돈다.

여기서 실무의 오해 하나가 풀린다. 큰 전역 배열을 선언했는데 실행 파일 크기가 안 늘었다면 정상이다(.bss). 반대로 그 배열에 0이 아닌 초깃값을 하나라도 주면 — 예컨대 int t[1000000] = {1}; — 배열 전체가 .data로 가서 파일이 4 MB 커진다.

83.2 리눅스의 배치

리눅스에서 프로그램 하나의 주소 공간은 대략 이렇게 놓인다(64비트 기준).

높은 주소  ┌──────────────────────────┐
           │ 커널 영역 (접근 불가)     │
           ├──────────────────────────┤
           │ 스택      ↓ (아래로 자람) │  기본 한도 8 MiB
           │   ...  (아주 넓은 빈 곳)  │
           │ mmap 구역 ↓               │  공유 라이브러리, 큰 malloc
           │   ...                     │
           │ 힙        ↑ (위로 자람)   │  brk 로 늘린다
           ├──────────────────────────┤
           │ .bss / .data / .rodata    │
           │ .text                     │
낮은 주소  └──────────────────────────┘  0번지 부근은 접근 금지(6장)

챙길 것이 넷이다.

① 스택 한도는 8 MiB가 기본이다. ulimit -s로 보고 바꾼다(44장). 이 한도는 예약이 아니라 상한이어서, 실제로는 쓰는 만큼 페이지가 붙는다.

② 넘치면 즉시 죽는다 — 가드 페이지. 스택 아래에는 접근 금지로 표시된 페이지가 있다. 스택이 그 자리를 건드리면 그 즉시 신호(SIGSEGV)가 나서 프로그램이 멈춘다. “조용히 남의 자리를 덮어쓰는” 최악의 사고를 막아 주는 장치다.

③ 큰 할당은 힙이 아니라 mmap으로 간다. 할당자는 작은 요청은 힙을 잘라 주고, 큰 요청(glibc의 기본 문턱은 128 KiB 근처다)은 커널에서 새 구역을 통째로 받아 준다. 그래서 큰 블록은 free할 때 운영체제로 실제 반납되기도 한다 — 45장의 “free는 대개 OS에 안 돌려준다”의 예외다.

④ 주소는 실행할 때마다 바뀐다 — ASLR. 44장의 예제를 두 번 돌리면 주소가 다르다. 공격자가 주소를 미리 알 수 없게 하려는 보안 장치(주소 공간 배치 무작위화)다. 그래서 주소 값을 기록해 두었다가 다음 실행에서 쓰는 코드는 성립하지 않는다.

플랫폼 노트. 스레드마다 스택이 따로 있다

갈래(스레드)를 만들면 스택도 하나씩 더 생긴다. 리눅스에서 기본 크기는 대개 주 스레드의 한도를 따라 8 MiB이고, pthread_attr_setstacksize로 정한다. 이 사실이 실무에 두 가지를 남긴다 — 갈래를 수천 개 만드는 설계는 주소 공간을 그만큼 예약하게 되고(64비트에서는 견딜 만하지만 32비트에서는 금세 바닥난다), 갈래 안에서의 깊은 재귀는 주 스레드보다 먼저 넘친다.

83.3 윈도우의 배치

큰 그림은 비슷하지만 숫자와 이름이 다르다.

리눅스윈도우
기본 스택8 MiB(한도)1 MiB(예약), 처음 커밋은 4 KiB
스택 크기 지정ulimit -s, pthread_attr_setstacksize링커 /STACK:예약[,커밋], CreateThread 인자
실행 파일 형식ELFPE
큰 할당mmapVirtualAlloc(힙 관리자가 호출)
힙 APImallocbrk/mmapmallocHeapAllocVirtualAlloc
주소 무작위화ASLRASLR(같은 개념)

표 83.2

윈도우의 1 MiB가 실무에서 자주 문제를 만든다. 리눅스에서 멀쩡하던 코드가 윈도우에서 스택 넘침으로 죽는 전형적인 경우가 셋이다 — 큰 지역 배열, 깊은 재귀, 그리고 큰 구조체를 값으로 넘기는 호출(47장). 예약과 커밋을 나눈 구조도 알아 둘 만하다: 1 MiB는 주소를 잡아 둔 것일 뿐이고 물리 메모리는 쓰는 만큼 붙는다. 그래서 스택을 크게 예약해도 실제 사용량이 적으면 손해가 크지 않다.

실제 사례. “리눅스에서 되는데 윈도우에서 죽어요”

이식 작업에서 반복해서 나오는 사고다. 원인은 대개 두 줄 안에 있다.

void process(void) {
    char buffer[4 * 1024 * 1024];   /* 4 MiB — 리눅스 8 MiB 안에서는 통과 */
    ...                             /* 윈도우 1 MiB 에서는 즉사 */
}

고치는 길은 셋이다. 링커 옵션으로 스택을 키우거나(/STACK:8388608), 버퍼를 static으로 돌리거나(다만 재진입·스레드 안전을 잃는다), malloc으로 옮기는 것이다. 셋 중 무엇이 옳은지는 그 버퍼의 성격이 정한다 — 크기가 크고 수명이 함수보다 길면 힙, 크기가 크지만 함수 안에서 끝나면 힙 또는 스택 확대, 작고 뜨거우면 스택 그대로.

83.4 운영체제가 없는 세계 — 자유 사용 구현

이제 커널도, 가상 메모리도, ASLR도 없는 쪽으로 내려간다. 61장에서 본 자유 사용 구현(freestanding)의 세계다. 여기서는 앞의 그림 대부분이 사라지고, 대신 물리 주소가 그대로 드러난다.

공통 골격은 이렇다. 칩에는 대개 두 종류의 기억이 있다 — 전원을 꺼도 남는 플래시(코드와 상수)와, 꺼지면 사라지는 RAM(변수와 스택). 그리고 프로그램이 시작될 때 시작 코드(startup, 흔히 crt0Reset_Handler)가 세 가지 일을 한다.

  1. .data를 플래시에서 RAM으로 복사한다. 초깃값은 전원이 꺼져도 남아야 하므로 플래시에 저장되고, 변수는 RAM에 있어야 하므로 시작할 때 옮긴다.
  2. .bss를 0으로 채운다. 리눅스에서는 커널이 해 주던 일이다.
  3. 스택 포인터를 세우고 main을 부른다.

이 세 줄이 “초기화하지 않은 전역은 0”이라는 C의 약속이 공짜가 아니라 누군가 해 주는 일임을 보여 준다. 그리고 이 코드는 대개 링커 스크립트와 짝을 이룬다 — 어느 구역이 어느 주소에 놓일지 사람이 직접 적는 파일이다.

83.4.1 Arm Cortex-M — 벡터 테이블의 첫 워드가 스택이다

마이크로컨트롤러에서 가장 널리 쓰이는 계열이다. 배치는 대개 이렇다.

0x0800_0000  플래시  ┌──────────────┐
                     │ 벡터 테이블   │  [0] 초기 스택 포인터 값
                     │              │  [1] Reset_Handler 주소
                     │ .text        │
                     │ .rodata      │
                     │ .data 의 원본 │ → 시작 때 RAM 으로 복사
                     └──────────────┘
0x2000_0000  RAM     ┌──────────────┐
                     │ .data        │
                     │ .bss         │
                     │ 힙      ↑    │ (쓴다면)
                     │   ...        │
                     │ 스택    ↓    │ ← 초기 SP = RAM 의 끝
                     └──────────────┘

특징 셋을 챙긴다.

① 스택의 시작 주소가 벡터 테이블에 박혀 있다. Cortex-M은 리셋되면 주소 0의 첫 워드를 읽어 그것을 스택 포인터로 삼고, 두 번째 워드를 읽어 그리로 점프한다. 스택의 자리를 하드웨어가 파일에서 읽어 세우는 셈이다.

② 스택 포인터가 둘이다. MSP(메인)와 PSP(프로세스). 운영체제(RTOS) 없이 쓰면 MSP 하나만 쓰지만, RTOS를 얹으면 커널은 MSP를, 각 태스크는 PSP를 쓰는 식으로 나눈다. 태스크마다 스택이 따로 있고, 그 크기는 사람이 태스크를 만들 때 숫자로 적는다 — 리눅스의 8 MiB 같은 넉넉함은 없고, 보통 수백 바이트에서 수 KiB다.

③ 넘침을 알려 주는 장치가 있거나, 없다. 상위 칩에는 스택 한계 레지스터 (MSPLIM/PSPLIM)나 MPU가 있어 넘침을 예외로 잡을 수 있다. 없는 칩에서는 스택이 조용히 .bss를 덮어쓴다 — 그래서 관행이 생겼다: 스택 영역을 특정 값(예: 0xAA)으로 미리 칠해 두고, 나중에 얼마나 지워졌는지 세어 최대 사용량을 재는 것이다(스택 페인팅). 정적 분석 도구가 호출 그래프에서 최악의 깊이를 계산해 주기도 한다.

83.4.2 AVR — 하버드 구조라 상수도 복사한다

Arduino로 널리 알려진 8비트 계열이다. 59장에서 본 하버드 구조 — 코드와 데이터의 주소 공간이 아예 다른 — 가 여기서 실물이 된다.

플래시(프로그램 공간)      RAM(데이터 공간)
┌───────────────────┐     ┌──────────────────┐
│ 벡터 테이블        │     │ .data            │ ← 플래시에서 복사
│ .text             │     │ .bss             │
│ .data 의 초깃값    │──▶ │ 힙 ↑ (쓴다면)     │
│ (그리고 상수들)    │     │  ...             │
└───────────────────┘     │ 스택 ↓            │ ← RAMEND 에서 시작
                          └──────────────────┘

특징 둘이 실무를 지배한다.

① 문자열 상수가 RAM을 먹는다. 주소 공간이 나뉘어 있어 평범한 포인터로는 플래시를 읽을 수 없다. 그래서 컴파일러는 안전한 쪽을 택한다 — 문자열 리터럴까지 RAM으로 복사해 둔다. RAM이 2 KiB뿐인 칩에서 로그 문자열 몇 줄이 RAM을 절반 먹는 사태가 여기서 나온다. 해결책이 PROGMEMpgm_read_byte, 그리고 F("...") 같은 매크로다 — “이 상수는 플래시에 두고, 읽을 때 특별한 명령으로 읽으라”는 지시다. 이것들은 표준 C가 아니라 AVR 도구의 확장이다.

② 스택과 힙이 같은 RAM에서 마주 보고 자란다. 스택은 RAM의 끝에서 아래로, 힙은 .bss 다음에서 위로 자란다. 둘이 만나면 — 아무 경고 없이 서로를 덮어쓴다. 가드 페이지 같은 것은 없다. 그래서 AVR 세계의 오랜 관행은 동적 할당을 아예 쓰지 않는 것이다.

83.4.3 PIC — 뱅크와 “컴파일된 스택”

마이크로칩의 8비트 계열(PIC10/12/16/18)은 구조가 또 다르다.

① 데이터 메모리가 뱅크로 나뉜다. 한 번에 볼 수 있는 창이 작아서, 다른 뱅크의 변수를 만지려면 뱅크 선택 레지스터를 바꿔야 한다. 컴파일러가 알아서 넣어 주지만, 변수 배치가 나쁘면 뱅크 전환 명령이 늘어 코드가 커지고 느려진다.

② 데이터 스택이 하드웨어에 없다. PIC10/12/16에는 호출 스택(복귀 주소를 쌓는 하드웨어 스택)만 있고 그 깊이도 고정이다(예: 여덟 단계, PIC18은 서른한 단계). 지역 변수를 담을 스택은 없다. 그래서 XC8 컴파일러는 컴파일된 스택이라는 방식을 쓴다 — 함수마다 지역 변수를 위한 자리를 정적으로 할당해 두고, 동시에 살아 있을 수 없는 함수들끼리 그 자리를 겹쳐 쓴다.

③ 그래서 재귀가 기본적으로 금지된다. 지역 변수의 자리가 정적이면 같은 함수를 겹쳐 부를 수 없다. 호출 깊이도 하드웨어 한계를 넘을 수 없다. “이 함수에서 저 함수를 부를 수 있는가”를 컴파일러가 호출 그래프로 계산해 자리를 나누므로, 함수 포인터(59장)를 남발하면 컴파일러가 그래프를 알 수 없어 배치가 나빠진다. PIC24·dsPIC·PIC32는 사정이 다르다 — 이들은 진짜 데이터 스택이 있어 일반적인 C에 가깝다.

Arm Cortex-MAVRPIC(8비트)
구조폰 노이만(통합 주소)하버드하버드 + 뱅크
스택RAM 끝에서 아래로, SP 둘RAMEND 에서 아래로하드웨어 호출 스택만
지역 변수스택스택정적으로 배치(컴파일된 스택)
재귀된다된다(위험)사실상 안 된다
상수평범하게 읽는다PROGMEM 필요특수 접근 필요
쓸 수 있다(권하지 않음)거의 안 쓴다거의 안 쓴다
넘침 감지MPU·SPLIM(있으면)없음없음

표 83.3

흔한 오해. “임베디드에서도 malloc을 쓰면 되지 않나”

쓸 수는 있지만, 여기서는 45장의 이유들이 훨씬 무겁다. 첫째, 실패했을 때 갈 곳이 없다. 데스크톱이라면 널을 받고 종료하면 되지만, 제동 장치나 심박 조율기는 “종료”가 답이 아니다. 둘째, 단편화가 회복되지 않는다. 몇 달·몇 년을 재부팅 없이 도는 기기에서 조각난 힙은 결국 할당 실패로 이어진다. 셋째, 시간이 일정하지 않다. 할당 시간이 요청과 힙 상태에 따라 달라지므로, 정해진 시간 안에 끝나야 하는 실시간 제어와 맞지 않는다.

그래서 이 세계의 규범은 정적 할당이다. 필요한 만큼을 컴파일 시간에 잡아 두고 재사용한다. 그다음으로 흔한 것이 (같은 크기 조각을 미리 만들어 놓고 빌려 주기)과 아레나(한 덩어리를 잘라 쓰고 통째로 버리기)다 — 둘 다 시간이 일정하고 단편화가 없다. MISRA와 안전 규격들이 동적 할당을 금지하거나 강하게 제한하는 이유가 이것이고, 다음 장과 제12부가 그 대안을 다룬다.

문. 임베디드에서 스택 크기는 어떻게 정하는가? 리눅스처럼 넉넉히 잡으면 되지 않는가?

답. RAM 전체가 수 KiB인 세계에서는 “넉넉히”가 없다. 실무의 방법은 세 가지를 겹쳐 쓴다. 계산 — 정적 분석 도구가 호출 그래프에서 최악의 스택 깊이를 구해 준다(재귀와 함수 포인터가 있으면 계산이 불가능해진다). 측정 — 위에서 본 스택 페인팅으로 실제 최대 사용량을 재고 여유를 얹는다. 보호 — MPU나 스택 한계 레지스터로 넘침을 예외로 잡는다.

그리고 설계 규칙 하나가 따라온다: 깊은 재귀와 큰 지역 배열을 쓰지 않는다. 47장에서 본 “구조체를 값으로 넘기면 스택이 그만큼 뛴다”는 사실이 여기서는 곧바로 제품의 실패로 이어진다.

복습 정리

기억할 것요지
.bss파일에는 크기만. 0으로 채우는 것은 실행할 때
리눅스 스택기본 8 MiB, ulimit -s. 가드 페이지로 즉시 잡힌다
윈도우 스택기본 1 MiB(커밋 4 KiB), 링커 /STACK
이식 사고큰 지역 배열·깊은 재귀는 윈도우에서 먼저 죽는다
큰 할당mmap/VirtualAlloc 으로 따로 간다
자유 사용시작 코드가 .data 복사 + .bss 0채움 + SP 설정
Cortex-M벡터 테이블 첫 워드가 초기 SP. MSP/PSP
AVR하버드 — 상수도 RAM 을 먹는다(PROGMEM). 스택↔힙 충돌
PIC 8비트컴파일된 스택. 재귀 사실상 불가
임베디드 할당정적 > 풀 > 아레나. malloc은 마지막

표 83.4

배치를 그렸다. 다음 장은 그 배치의 한 구역 — 힙 — 을 열어, 할당자가 실제로 무슨 일을 하는지, 그리고 표준 malloc 말고 어떤 선택지가 있는지 본다.