어셈블리 언어를 지원하는 NGINX Unit 앱 서버
어셈블리(Assembly) 언어는 복잡성에도 불구하고 모든 종류의 시스템 소프트웨어에서 널리 사용됩니다. 웹 개발에서 어셈블리 언어가 제공하는 이점을 활용할 수 있다고 생각합니다. 어셈블리는 컴퓨팅 시대 초기부터 사용되어 왔으며 여전히 매우 활발한 사용자 커뮤니티를 유지하고 있습니다. 지난 몇 달 동안, 어셈블리에 대한 지원을 추가해 달라는 요청을 많이 받았고 기능을 추가하고 그 결과 웹 개발이 아주 쉬워졌습니다.
목차
1. Background
2. 첫 번째 어셈블리 ‘Hello World’ 웹사이트
3. 애플리케이션 Build 및 실행
4. 결론
1. Background
웹 애플리케이션에 어셈블리 언어를 사용하면 다음과 같은 이점을 얻을 수 있습니다.
- 프로세서에 특정한 코드를 활용하여 다른 언어의 성능을 쉽게 능가할 수 있습니다.
- 고급 언어에서 생성되는 명령어 지침이 부족하면 여러 애플리케이션이 만들어집니다.
- 필요한 작업만 수행합니다. 기계는 사용자가 지시한 대로 정확하게 작동하며 getpid 호출은 일부 buggy cache에 의존하는 대신 syscall 결과를 보장합니다.
- 다른 프로그래밍 언어와의 인터페이스가 쉽게 됩니다.
- gets, strcpy, sprintf, strcmp, strcat 등과 같은 취약한 기능이 없습니다.
- 모든 사람이 sandbox 언어의 범위 내에서 안전하게 작업하는 동안 어셈블리에서 코딩하는 것은 안전 매트 없이 완벽한 트리플 공중제비를 하는 것과 같습니다.
위의 모든 것을 통해 어셈블리 언어는 웹 개발의 매우 유망한 옵션이 됩니다. 간결성을 위해 이 포스트는 AMD64(x86_64) 아키텍처에 중점을 두고 일반 이름 x64를 사용합니다. x64 어셈블리에 익숙하지 않은 경우 프로세서 소프트웨어 개발자 설명서 및 System V Application Binary Interface (ABI)를 참조하십시오. System V ABI는 NGINX Unit의 C 기반 API와의 인터페이스가 필요하므로 여기에서 매우 유용합니다.
x64 어셈블리에는 Intel과 AT&T라는 두 가지 공통 구문이 있습니다. 그들 사이의 차이점은 프로그래머 커뮤니티 내에서 엄청난 논쟁을 일으킵니다. 논란을 피하기 위해 두 버전을 모두 지원하기 위해 최선을 다했습니다.
2. 첫 번째 어셈블리 ‘Hello World’ 웹사이트
NGINX Unit의 모든 애플리케이션 모듈은 비밀(Secret)을 공유합니다. Core Process와의 통신을 위한 Primitives를 제공하는 libunit.a라는 정적 라이브러리에 의존합니다. 이 포스트에서는 NGINX Unit 설치 안내서의 단계를 따라 소스를 복제했다고 가정합니다.
NGINX Unit에서는 어셈블리 지원이 내장되어 있지만(외부 애플리케이션 유형과 매우 유사) 다음 명령을 실행하여 libunit.a를 직접 Build해야 합니다.
$ pwd
/home/user/unit
$ ./configure && make libunit-install
두 번째 명령은 build/libunit.a 파일을 만들며 그것이 유일한 의존성입니다. 이제 재미있는 부분이 나옵니다.
그러나 먼저 몇 가지 필요한 가정을 통해 진행해 봅시다.
- 다음 예제에서는 어셈블리 언어에 대한 약간의 지식이 필요합니다. 일부 세부 정보가 제공되지만 간결함을 위해 명확한 부분은 생략됩니다.
- x64 아키텍처는 각 함수의 시작 및 반환 시 기본 포인터(%rbp)를 저장하고 복원하기 위해 프롤로그 및 에필로그 매크로를 사용할 필요가 없지만 여기서는 어쨌든 가독성을 향상시키고 버그를 방지하기 위해 사용합니다.
- 아래 코드는 이미 짐작하셨겠지만 이식성이 없습니다.
- 여기서는 GNU 어셈블러(gas)의 기본 옵션인 AT&T 구문을 사용할 것입니다.
작업자(worker)가 NGINX Unit Daemon에 자신을 등록하는 기본 Workflow는 다음과 같습니다.
- nxt_unit_init 구조체를 할당하고 request_handler Callback을 설정합니다.
- nxt_unit_init(init)를 호출하여 클라이언트를 초기화하고 애플리케이션을 등록합니다.
- nxt_unit_run(ctx)를 호출하여 요청 처리 사이클을 시작합니다.
- 마지막에 nxt_unit_done(ctx)를 호출합니다.
어셈블리에는 구조체가 없으므로 불투명한 byte 덩어리를 할당하고 수동으로 채워야 합니다. C의 구조는 더 빠른 메모리 액세스를 가능하게 하는 정렬을 보장하기 위해 기본적으로 채워집니다. 이것이 어떻게 작동하는지에 대한 자세한 정보는 여기에서 얻을 수 있으며 여기에서 init 구조 선언을 볼 수 있습니다. 어셈블리 프로그래머는 구조체가 필요하지 않습니다. 대신 오프셋(Offset)을 수동으로 계산합니다.
더 이상의 작업 없이, 첫 번째 코드 snippet은 init 구조를 할당하고 메모리를 0으로 만듭니다.
.text
_start:
init_struct_size = 192 # sizeof(nxt_unit_init_t)
mov $0, %rbp # set first frame
sub $init_struct_size, %rsp # stack is already aligned
lea (%rsp), %rdi
mov $init_struct_size, %rsi
call memzero
init_struct_size 값은 C 단위의(nxt_unit_init_t)에서 가져왔습니다. 구조의 크기를 직접 확인할 수도 있습니다(거의 불가능한 GCC ABI 변경 또는 NGINX Unit의 헤더 파일 업데이트될 경우에 대비한 Sanity Check입니다).
약속한 대로, 우리는 가독성을 향상시키기 위해 각 함수에서 프롤로그와 에필로그를 사용하고 있지만, 일부 오래된 libc 버전과 gdb가 스택을 해제하는 동안 의존하기 때문에 첫 번째 프레임 포인터로 표시하기 위해 37행에서 %rbp 레지스터를 0으로 설정해야 합니다. Backtrace를 생성하는 스택. 최신 도구 체인은 CFI(Call Frame Information) 지시문을 사용하여 모든 기능에 대한 스택 정보로 ELF 섹션을 채우지만 여기에서는 사용하지 않습니다. 자세한 내용은 사용자 친화적인 DWARF 사양을 참조하십시오.
38행에서 init 구조에 대한 스택 포인터를 줄여 스택에 공간이 있는지 확인합니다. 호출 명령이 실행되기 전에 x64에서 스택이 16byte 정렬되어야 합니다. 이것은 필수이며 이를 놓치면 프로그램이 중단됩니다. System V ABI의 섹션 3.2.2에서는 제어를 _start로 전송할 때 스택을 적절하게 정렬해야 한다고 규정합니다. 사실상 이는 %rsp+8이 16의 배수임을 의미합니다. 호출 명령어가 다음 명령어의 반환 주소를 스택으로 Push하기 때문에 추가 8byte 가 필요합니다. 제어가 호출된 함수에 도달하면 이미 정렬된 것입니다. 운 좋게도 192는 16의 배수이므로 조정할 필요가 없습니다.
42행에서 memzero 함수를 호출하여 메모리를 0으로 만듭니다. 선언은 다음과 같습니다.
void memzero(ptr, size)
memzero를 호출하기 전에 40행에서 스택의 맨 위에 있는 주소(현재 로컬로 할당된 메모리의 베이스를 가리킴)를 %rdi로 이동합니다. 왜 %rdi이냐면 이것이 관례입니다(ABI의 섹션 3.2.3 참조). 매개 변수가 정수인 경우 레지스터 전달 시퀀스는 %rdi, %rsi, %rdx, %rcx, %r8 및 %r9입니다.
전체 memzero 함수는 다음과 같습니다.
memzero:
prologue
mov %rsi, %rcx
.again:
add $1, %rdi
movb $0, (%rdi)
loop .again
epilogue
ret
이 코드는 %rdi의 주소를 반복하고 132행에서 메모리를 0으로 만듭니다. 129행에서 두 번째 매개변수로 카운터(%rcx)를 초기화합니다. %rcx가 0에 도달하는 즉시 loop가 종료됩니다. 이것은 memset의 느린 버전이며 교육 목적으로 여기에 설명되어 있습니다. production 버전은 8byte 또는 16byte 사이클(movdqa, movups 등)로 영점 조정을 수행합니다.
프롤로그 및 에필로그 매크로는 다음과 같이 정의됩니다.
.macro prologue
push %rbp
mov %rsp, %rbp
.endm
.macro epilogue
mov %rbp, %rsp
pop %rbp
오래전 x86 어셈블리 프로그래밍을 기억하면 이러한 매크로가 컴파일러에서 오랫동안 사용되었기 때문에 인식할 수 있습니다. 모든 기능에 프롤로그와 에필로그를 포함함으로써 충돌이 발생하거나 프로그램을 반복적으로 디버깅하는 동안 Debugger가 스택을 탐색할 수 있도록 도와줍니다. 이제 %rbp 레지스터의 인덱스를 사용하여 모든 지역(Local) 변수를 참조할 수 있으므로 가독성도 향상됩니다. 또한 프롤로그와 에필로그를 사용하지 않고 여전히 %rsp에 의해 변수와 매개변수를 직접 인덱싱하는 경우 %rsp가 이동하거나 무언가를 Push하거나 Pop할 때마다 인덱스와 offset을 업데이트해야 하기 때문에 버그를 방지합니다.
-fomit-frame-pointer 옵션은 최신 컴파일러의 기본값이며, 이는 컴파일러가 %rsp에 상대적인 로컬 변수를 추적함을 의미합니다. 그러나 이 모드는 어셈블리 코드의 가독성을 위해 성능을 교환합니다.
이제 추진력을 얻고 다음과 같은 더 큰 구성 요소로 넘어가 보겠습니다.
lea request_handler(%rip), %rax
mov %rax, 0x20(%rsp) # init.callbacks.request_handler
lea (%rsp), %rdi
call nxt_unit_init
test %rax, %rax # ctx == NULL
jz .init_failed
이 코드는 init.callbacks.request_handler 함수를 request_handler 함수로 설정하고 nxt_unit_init 함수를 호출하여 init 구조를 매개 변수로 전달합니다.
또한 구조에서 유일한 필수 구성은 요청이 이 애플리케이션에 도착할 때마다 호출되는 request_handler callback입니다.
47행의 nxt_unit_init 함수는 context 포인터를 반환하거나 오류가 발생한 경우 NULL을 반환합니다. 호출 뒤에 오는 코드는 오류 상태를 확인하고 필요한 경우 처리로 분기합니다.
다음 코드는 요청 처리 기능을 호출합니다.
mov %rax, %rbx
mov %rbx, %rdi
call nxt_unit_run
test %rax, %rax
jnz .run_failed
먼저 ctx의 값을 %rbx에 저장합니다. 왜 구체적으로 %rbx이냐면 스택이나 전역 정적으로 할당된 저장소(BSS)에 저장할 수 있습니다. 그러나 이 코드는 프로그램의 최상위 함수 내에서 실행되므로 ABI를 활용하고 %rbx를 사용할 수 있습니다. %rbx는 호출 규칙에 포함되지만 일반적으로 선택 사항으로 작동하는 callee‑saved(비휘발성) 레지스터이기 때문입니다. 역사적인 이유로 i386 ABI는 GOT(Global Offset Table)의 기본 포인터로 ebx를 사용했지만 x64는 %rip을 기본으로 하는 상대 주소 지정을 사용하므로 %rbx는 사용되지 않습니다. 또한 해당 값을 유지하려면 호출된 함수가 필요합니다.
그런 다음 nxt_unit_run을 호출하여 입력 요청을 무한 loop에서 읽기를 시작하고 각 요청에 대한 사용자 지정 Callback을 호출합니다. 이 함수는 NGINX Unit 데몬(Daemon)이 애플리케이션 종료를 요청할 때만 반환됩니다. 이 경우 반환된 오류 코드를 확인합니다. 0이 아닌 경우(NXT_UNIT_OK) 코드는 다시 오류 처리로 분기됩니다.
nxt_unit_run 함수가 성공적으로 반환되면 nxt_unit_done을 호출하여 리소스를 해제하고 종료하여 정리를 진행합니다.
mov %rbx, %rdi
call nxt_unit_done
mov $1, %rdi
call exit
이제 흥미로운 부분인 request_handler가 나옵니다.
request_handler:
prologue
sub $0x10, %rsp # req + rc
mov %rdi, -0x10(%rbp)
mov $200, %rsi # 200 OK
mov $1, %rdx # 1 header
mov $req_total_len, %rcx
call nxt_unit_response_init
mov %eax, -0x8(%rbp)
cmpl $NXT_UNIT_OK, -0x8(%rbp)
jne .response_init_failed
입력 요청 및 오류 코드를 위해 스택에 16byte를 예약하는 것으로 시작합니다. 포인터는 8byte이고, 오류 코드는 정수(4byte)이지만, 앞에서 설명한 것처럼 정렬 목적으로 총 16byte를 할당합니다.
그런 다음 nxt_unit_response_init를 호출하여 응답 상태를 초기화합니다. 함수에는 다음 서명이 있습니다.
int nxt_unit_response_init(nxt_unit_request_info_t *req, uint16_t status,
uint32_t max_fields_count, uint32_t max_fields_size)
상태 코드, 반환할 최대 필드 수 및 필드의 최대 크기를 허용합니다. 이 경우 코드는 200 OK 상태, 최대 하나의 필드 및 .data 섹션의 콘텐츠 offset에서 파생된 상수인 $req_total_len 변수를 전달합니다.
mov -0x10(%rbp), %rdi
mov $req_cont_type, %rsi
mov $req_cont_type_len, %rdx
mov $req_text_plain, %rcx
mov $req_text_plain_len, %r8
call nxt_unit_response_add_field
mov %eax, -0x8(%rbp)
cmpl $NXT_UNIT_OK, -0x8(%rbp)
jne .response_add_field_failed
마지막으로 콘텐츠 본문을 추가하고 응답을 NGINX Unit으로 다시 보냅니다.
mov $req_body, %rsi
mov $req_body_len, %rdx
mov -0x10(%rbp), %rdi
call nxt_unit_response_add_content
mov %eax, -0x8(%rbp)
cmpl $NXT_UNIT_OK, -0x8(%rbp)
jne .response_add_content_failed
mov -0x10(%rbp), %rdi
call nxt_unit_response_send
mov %eax, -0x8(%rbp)
cmpl $NXT_UNIT_OK, -0x8(%rbp)
jne .request_error
nxt_unit_response_send에 대한 호출은 헤더와 내용을 클라이언트로 보내지만 애플리케이션은 nxt_unit_buf_send를 호출하여 추가 데이터를 계속 전송할 수 있습니다. 응답을 완료하고 할당된 리소스를 해제하려면 nxt_unit_request_done에 대한 호출이 필요합니다.
.request_ok:
mov -0x10(%rbp), %rdi
call nxt_unit_request_done
기본적으로 애플리케이션이 준비되었습니다. 오류 처리 및 데이터 변수와 같은 일부 코드에 대해서는 논의하지 않았습니다. 전체 코드는 이 파일에서 사용할 수 있습니다.
3. 어셈블리 애플리케이션 Build 및 실행
애플리케이션을 Build하고 연결하려면 다음 명령어를 실행하세요.
$ gcc -c -g hello.s -o hello.o
$ ld -o hello hello.o ./build/libunit.a -lc --dynamic-linker=/lib64/ld-linux-x86-64.so.2
NGINX Unit에서 첫 번째 어셈블리 애플리케이션을 실행할 준비가 되면 아래 구성을 적용합니다(실행 경로를 업데이트해야 합니다).
{
"listeners": {
"*:8081": {
"pass": "applications/hello-x64"
}
},
"applications": {
"hello-x64": {
"type": "asm",
"executable": "/path/to/hello"
}
}
}
구성이 Load되지 않으면 애플리케이션 유형을 asm에서 외부로 변경해 보십시오.
마지막으로 http://localhost:8081/로 이동합니다. 여기에서 애플리케이션에서 당신이 기대하는 인사말을 볼 수 있습니다.
4. 결론
글로벌 온라인 인프라에서 계속 증가하는 부하를 지원할 수 있는 매우 효과적인 웹 애플리케이션에 대한 필요성이 지금보다 더 분명한 적은 없었습니다. 기존 웹 애플리케이션은 여러 계층과 계층의 API Scaffolding 및 불분명한 라이브러리로 인해 비대해져 소중한 디스크 공간과 CPU 사이클을 낭비하고 있습니다. 중복 코드로 인한 디지털 클라우드 오염 및 Big‑Endian Footprint 문제는 또한 모든 주요 이해 관계자의 즉각적인 조치를 요구합니다. 여기서 우리는 변화를 위한 레시피를 제공합니다. 웹 개발을 위한 어셈블리 언어로의 전환은 새롭고 더 나은 웹의 초석이 되어야 합니다. 소프트웨어 개발에 대한 간결하고 정밀한 접근 방식인 어셈블리 언어는 IT 산업의 원동력 중 하나였습니다. 아마도 이처럼 어려운 시기에 우리를 구할 수도 있습니다.
NGINX Unit의 최신 소식을 빠르게 전달 받고 싶으시면 아래 뉴스레터를 구독하세요.