x86-64 배열·문자열·구조체 루프 읽기
포인터와 길이 계약, stride, sentinel, 경계 검사를 실제 루프로 추적하기
x86-64 배열·문자열·구조체 루프 읽기
본 블로그 작성 글들은 강의를 목표로 제작되었습니다. 예제는 Intel 문법을 사용하며 포인터 유효성, 객체 수명, aliasing과 overflow 계약은 C/C++ 호출 경계에서 별도로 확인해야 합니다.
실제 프로그램의 어셈블리에서 가장 자주 만나는 형태는 “시작 주소에서 일정 간격으로 이동하며 값을 검사하거나 누적하는 루프”다. 배열, 문자열, 구조체 배열은 모두 이 형태로 내려오지만 종료 조건과 stride가 다르다.
이 글은 index 기반, end-pointer 기반, sentinel 기반 루프를 구분하고, 구조체 offset, 길이 단위, 범위 검사와 compiler 최적화를 실제 코드로 추적한다.
루프를 네 가지 상태로 나눈다
데이터 루프를 볼 때 다음 상태부터 찾는다.
- base 또는 current pointer
- index, end pointer, remaining count 중 하나
- accumulator나 검색 결과
- 종료 조건과 그 조건의 signedness
; rcx = int32_t* values, rdx = count
xor eax, eax
xor r8d, r8d
loop_test:
cmp r8, rdx
jae loop_done
add eax, dword ptr [rcx + r8*4]
inc r8
jmp loop_test
loop_done:
ret
여기서 rcx는 base, r8은 index, eax는 accumulator, r8 >= rdx가 종료 조건이다. jae는 unsigned 비교이므로 size_t 계열 길이와 자연스럽게 연결된다.
index 루프와 end-pointer 루프는 같은 범위를 표현한다
compiler는 다음처럼 마지막 주소를 미리 계산할 수 있다.
; rdi = begin, rsi = count
lea rdx, [rdi + rsi*4] ; end = begin + count
xor eax, eax
cmp rdi, rdx
je done
loop_begin:
add eax, dword ptr [rdi]
add rdi, 4
cmp rdi, rdx
jne loop_begin
done:
ret
이 형태에는 명시적인 index가 없다. current pointer가 end pointer에 도달하는지를 검사한다. 역으로 복원할 때 rdi의 초기값, 증가량 4, rdx 계산을 함께 봐야 원소 개수 계약이 보인다.
count * 4가 주소 범위를 넘을 수 있는지, begin + count가 유효한 one-past-end 포인터인지도 상위 언어의 전제다. 기계어 덧셈은 wrap할 수 있지만 C/C++ 포인터 산술 계약은 더 좁다.
문자열은 길이 또는 sentinel로 끝난다
길이가 따로 있는 바이트 span은 배열과 같다.
; rcx = bytes, rdx = length, r8b = target
xor eax, eax
test rdx, rdx
jz done
loop_begin:
cmp byte ptr [rcx], r8b
sete r9b
movzx r9d, r9b
add rax, r9
inc rcx
dec rdx
jne loop_begin
done:
ret
반면 C 문자열은 0 바이트를 sentinel로 사용한다.
strlen_like:
mov rax, rcx
scan:
cmp byte ptr [rax], 0
je found
inc rax
jmp scan
found:
sub rax, rcx
ret
이 코드는 포인터만 받고 최대 길이를 받지 않는다. 접근 가능한 메모리 안에 반드시 NUL terminator가 있다는 강한 전제가 필요하다. terminator가 없으면 객체 경계를 넘어 계속 읽는다.
문자열 처리에서는 다음 단위를 섞지 않는다.
- 바이트 수
- UTF-8 code unit 수
- Unicode code point 수
- 사용자가 보는 grapheme cluster 수
strlen과 같은 바이트 sentinel 스캔은 글자 수를 세는 함수가 아니다.
sentinel 검색 결과는 명확한 계약이 필요하다
; 없으면 -1을 반환하는 바이트 검색 예시
mov rax, -1
xor r9d, r9d
loop_test:
cmp r9, rdx
jae not_found
cmp byte ptr [rcx + r9], r8b
je found
inc r9
jmp loop_test
found:
mov rax, r9
not_found:
ret
반환형이 size_t라면 -1은 SIZE_MAX 비트 패턴이 된다. C++의 std::string_view::npos 같은 계약일 수 있지만, signed -1과 unsigned 최대값을 호출자와 피호출자가 다르게 해석하면 버그가 된다.
구조체 배열은 stride와 field offset을 함께 본다
struct Particle {
float x; // 0
float y; // 4
float z; // 8
uint32_t live; // 12
};
live인 입자의 x 합계를 구하는 스칼라 루프는 다음처럼 보일 수 있다.
; rcx = Particle*, rdx = count
xorps xmm0, xmm0
test rdx, rdx
jz done
loop_begin:
cmp dword ptr [rcx + 12], 0
je skip
addss xmm0, dword ptr [rcx]
skip:
add rcx, 16
dec rdx
jne loop_begin
done:
ret
[rcx+12]는 field offset 후보[rcx]는x후보add rcx,16은sizeof(Particle)후보xmm0은 float accumulator이자 FP 반환 레지스터
이 숫자는 소스가 아니라 현재 binary layout의 증거다. static_assert와 compiler layout 출력으로 확인한다.
AoS와 SoA는 주소 패턴이 다르다
Array of Structures는 한 객체의 필드가 가까이 있고 다음 객체로 stride만큼 이동한다.
x y z live | x y z live | x y z live
Structure of Arrays는 필드별 배열을 따로 둔다.
x: x x x ...
y: y y y ...
z: z z z ...
특정 필드만 대량 처리하면 SoA가 연속 load와 SIMD에 유리할 수 있다. 반대로 한 객체의 여러 필드를 함께 쓰면 AoS가 locality에 맞을 수 있다. 어셈블리에서 큰 stride와 여러 고정 offset이 반복되면 AoS, 독립된 base pointer들이 보이면 SoA의 단서다.
bounds check 제거는 증명 결과다
for (size_t i = 0; i < n; ++i)
out[i] = in[i] * 2;
루프 조건 i < n이 매 반복의 접근을 지배하므로 별도의 원소별 검사가 없을 수 있다. compiler가 n과 pointer 관계를 증명하지 못하면 aliasing 때문에 vectorization이나 load 이동을 포기할 수 있다.
void scale(int* out, const int* in, size_t n);
out과 in이 겹칠 수 있는 계약인지가 중요하다. C의 restrict, compiler alias 분석, C++의 실제 객체 관계에 따라 생성 코드가 달라진다. 손으로 ASM을 작성하면서 “두 포인터는 안 겹칠 것”이라고 추측하면 overlapping 입력에서 조용히 잘못될 수 있다.
loop unrolling을 원래 반복으로 복원한다
loop4:
add eax, [rcx]
add eax, [rcx + 4]
add eax, [rcx + 8]
add eax, [rcx + 12]
add rcx, 16
sub rdx, 4
ja loop4 ; CF=0 && ZF=0 — 뺄셈 결과가 아직 양수일 때만 계속
한 번의 machine loop가 소스 원소 네 개를 처리한다. 보통 뒤에는 0~3개를 처리하는 remainder loop가 있다. 큰 본문만 보고 길이가 4의 배수라는 전제를 단정하지 않는다.
종료 조건도 같이 봐야 한다. sub가 세우는 CF는 "뺄셈 전 피연산자가 더 작았는가"를 뜻하므로, jae(CF=0)는 직전 블록이 합법이었다는 사실만 확인해 항상 한 블록을 더 돌게 된다. 남은 개수를 보려면 ja(CF=0 && ZF=0)를 쓰고, 길이가 4의 배수임이 보장될 때는 jne를 쓴다.
unrolling은 branch 수를 줄이고 instruction-level parallelism을 늘릴 수 있지만 코드 크기와 register pressure도 키운다.
vectorized loop는 prologue·main·tail로 나뉜다
자동 vectorization 결과는 대개 세 구간이다.
- 작은 입력 또는 정렬을 처리하는 prologue
- 여러 원소를 한 번에 처리하는 SIMD main loop
- 남은 원소를 처리하는 scalar tail
vector_loop:
movdqu xmm0, [rcx]
paddd xmm1, xmm0
add rcx, 16
sub rdx, 4
ja vector_loop ; CF=0 && ZF=0 — 뺄셈 결과가 아직 양수일 때만 계속
movdqu는 16바이트를, paddd는 32비트 lane 네 개를 처리한다. SIMD register 하나를 “큰 정수 한 개”로만 해석하면 안 된다. packed element 폭을 opcode에서 확인한다.
memcpy, memmove, 직접 루프는 계약이 다르다
memcpy: source와 destination이 겹치지 않아야 한다.memmove: 겹쳐도 올바르게 복사해야 한다.- typed loop: 객체 수명, 정렬, aliasing과 타입 의미가 추가된다.
compiler는 고정 크기 memcpy를 load/store로 inline하거나, 큰 복사를 runtime 함수에 맡기거나, rep movsb 같은 구현을 선택할 수 있다. rep movsb 한 줄이 보인다고 언제나 느리거나 빠르다고 결론 내리지 않는다. CPU와 크기 분포에 따라 구현 선택이 달라진다.
overflow와 길이 단위
bytes = count * sizeof(Item);
이 곱셈이 size_t를 넘으면 작은 값으로 wrap한다. 그 작은 크기로 할당한 뒤 count만큼 루프를 돌면 heap overflow로 이어질 수 있다. 어셈블리에서 allocation 크기 계산과 loop bound가 서로 다른 경로라면 두 계약을 함께 감사한다.
안전한 경계에서는 count > SIZE_MAX / sizeof(Item)를 먼저 검사하거나 검증된 checked multiplication을 사용한다.
루프 디버깅 체크리스트
- 각 pointer가 가리키는 최소 유효 범위는?
- count 단위는 바이트인가 원소인가?
- stride와 field offset은 무엇인가?
- 종료 조건은 index, end pointer, sentinel 중 무엇인가?
- 0개 입력에서 본문을 실행하지 않는가?
- 마지막 유효 원소와 one-past-end를 구분하는가?
- unrolled/vector main loop 뒤 tail이 있는가?
- source와 destination overlap 계약은?
- 길이 곱셈과 pointer addition이 overflow하지 않는가?
정리
배열·문자열·구조체 루프는 base/current pointer, stride, accumulator, 종료 조건으로 분해할 수 있다. index와 end pointer는 같은 범위를 다른 상태로 표현하며, sentinel 루프는 최대 길이가 없는 대신 terminator 존재라는 강한 전제를 가진다.
반복 본문의 주소식만 보지 말고 0개 입력, 마지막 원소, remainder, overlap, byte/element 단위, 크기 overflow까지 연결해야 실제 binary의 메모리 계약을 복원할 수 있다.