스위프트 문자열 메모리 할당 분석

Jung Kim
6 min readOct 23, 2021

--

Swift 5.5 기준으로 문자열 처리하는 String 타입에 대한 SIL 수준 분석

지난 메모리 분석 실험 글에서 살펴봤던 코드를 SIL 수준에서 살펴보고 이해해보려고 한다. 이전 글을 안 읽어봤다면 스위프트 타입별 메모리 분석 실험 글 링크를 먼저 읽어보길 권한다.

이전 글에서 명확하게 이해하지 못해서 들었던 의문점은 다음 두 가지였다.

  • String은 스택 영역에서 어떻게 힙 영역에 있는 값을 참조하는가?
  • 왜 String 이나 Array는 포인터 변수에 접근이 가능하고 Int는 안되는가?

String은 어떤 식으로 Heap 영역과 값을 참조하는 지부터 살펴보자. 메모리 dump 하는 동작을 제거하고 다음과 같이 문자열 생성하고, 복사하는 코드를 SIL 수준에서 살펴보자. SIL은 스위프트 컴파일러가 중간 단계로 생성하는 저수준 표현을 의미한다. 필요한 경우에 swiftc 컴파일 옵션으로 확인할 수 있다.

var str1 = “abcd” 코드가 SIL로 바뀌는 다음과 같다.

%0 에서 str1 이름으로 var 문자열 변수를 스택 영역에 생성한다. %1에서 상수 “abcd”를 선언하고 %2, %3, %4까지 길이와 ASCII 타입인지, 인스턴스 생성을 위한 메타 타입 추가 정보를 지정한다.

%5에서 Literal 문자열을 생성하는 String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:) 함수를 지정하고 %0부터 %4까지 매개변수로 전달하고 호출해서 %6에 지정한다. 이 부분이 바로 힙 (MALLOC_TINY)영역에 문자열 인스턴스가 생기는 부분이다. 바로 다음 %7에서 %6에 값을 %0 스택 영역 포인터에 지정한다.

이전 글에서 Xcode에서 스택 포인터를 찍어봤을 때 문자열 값이 그대로 보였던 것은 Store %6 to %0 이 명령을 최적화하면서 값을 그대로 복사하는 것으로 같다.

다음으로 스택 포인터 str1에 새로운 문자열을 바꾸는 코드를 살펴보자.

str1 = “abcd5678901234567890” 코드는 다음과 같이 SIL로 변환된다.

%13까지는 위에서 살펴본 것과 동일하게 리터럴 문자열을 인스턴스로 생성하는 SIL 표현이 동일하다. %14는 위에서 만든 %0 포인터 변수(str1)에 begin_access로 접근하고 %14에 담는다(결과를 레지스터가 처리하기 때문에 이렇게 표현하는 것 같다). %13에서 생성한 문자열 객체를 retain하고, 기존 문자열을 참조하는 %14 포인터를 %16에 임시로 보관하고 %13에 생성한 문자열을 %14로 지정(store)한다. 마지막으로 임시로 저장한 %16에 문자열 인스턴스를 release하고 end_access로 포인터 사용을 끝낸다.

위에 “abcd”를 넣는 것과 다른 부분은 바로 리터럴 문자열로 생성한 객체를 retain한다는 점이다. 16바이트보다 작은 경우는 힙 영역에 생성하고 스택 영역으로 복사하고 retain하지 않아서 곧 바로 사라지고, 16바이트보다 큰 경우는 retain해서 힙 영역에 남아있는 것으로 보인다.

str2에 str1 을 할당하는 var str2 = str1 코드는 다음과 같이 간단하다. 스택 영역에 str2 var변수를 %20으로 생성하고 %13에서 생성한 힙 영역 메모리 주소를 %20에 지정한다.

다음으로 str2에 “zzzz”를 지정해서 값을 바꾸는 경우를 살펴보자. 리터럴 문자열을 생성하고 %27 할당하는 부분까지는 동일하다.

%20 스택 영역에 저장된 포인터 주소를 %28로 가져와서 %29에 임시로 보관하고, %28에는 새로 생성한 문자열 %27주소를 지정한다. %29는 release하고 끝난다.

마지막으로 str1에 긴 문자열을 할당하는 코드를 SIL로 변환하면 다음과 같다.

위에서 str1에 넣은 긴 문자열에는 retain이 있었는데 여기는 없다. 이 부분은 ARC가 동작해서 아래 해당 변수를 참조하는 경우가 있느냐 없느냐로 retain처리를 넣거나 생략하는 것 같다. 그래서 함수를 끝내기 전에 str1 변수를 참조하는 코드를 추가했더니 retain이 추가되는 것을 확인할 수 있었다.

이제 String 타입은 포인터 변수에 접근이 가능하고 Int는 안되는 지 살펴보자. 메모리 분석을 할 때 dump(with: UnsafeRawPointer) 함수를 사용해서 String이나 Array 타입은 매개변수로 넘길 수 있었지만, Int는 되지 않았다.

dump()에 선언한 문법을 보면 매개변수로 UnsafeRawPointer 타입이 넘어간다. String도 struct 타입이고 UnsafeRawPointer도 struct 타입인데 컴파일 에러도 없고 실제로 동작하는 것이다. Int나 Double 같은 struct 타입은 안된다. 그 이유를 확인하기 위해서 dump(with: str1) 함수 호출이 바뀌는 SIL을 살펴보자.

매개변수로 str1을 넘기면 스위프트 컴파일러는 타입 추론을 통해서 변환 가능한 경우를 찾아낸다. 이 경우는 UTF8 C문자열 형식인 String.utf8CString으로 바꾸고, 다시 연속 배열 ContiguousArray<Int8>로 바꾸고, Array._baseAddressIfContiguous로 배열의 주소를 가져온다. 이 때 넘어오는 타입이 UnsafeMutablePointer<Int8>이라서 매개변수로 넘길 수 있는 것이다. 문자열이지만 힙 영역에 할당한 연속 배열로 판단하고 메모리 주소를 넘겨주는 것이다.

이제야 스위프트 String에 대한 궁금증이 조금 풀렸다. UnsafeRawPointer가 프로토콜이 아니라 구체 타입이지만 처리하는 것은 친절한 컴파일러 덕분(?)이었다. 이러니 컴파일이 느려질 수 밖에 없는 것인가 싶다.

스위프트 String 타입은 스택 값처럼 다루기도 하고, 힙 영역에 생기기도 한다. 그리고 컴퍼일러는 연속 배열처럼 다루기도 한다. 4년전 <코코아 인터널스> 작업할 당시에, 스위프트로 긴 문자열을 생성하는 과정을 반복하면 오히려 더 느렸던 기억이 난다. 다음에는 NSString과 성능 비교도 해봐야할 것 같다.

--

--