/

AF_MCTP는 리눅스 커널이 MCTP(Management Component Transport Protocol)를 소켓 인터페이스로 노출할 때 사용하는 주소 패밀리입니다. Linux kernel docs, Management Component Transport Protocol (MCTP) 문서를 기준으로 리눅스 MCTP 소켓 구조와 사용자 공간 도구인 mctp 명령·mctpd 데몬, 그리고 PLDM 인코딩·디코딩 라이브러리인 libpldm과 OpenBMC pldmd 데몬의 역할을 정리합니다.

핵심 요약

리눅스 커널은 MCTP를 네트워크 스택처럼 제공하여, 프로그램이 socket(AF_MCTP, SOCK_DGRAM, 0)으로 소켓을 열고 EID와 메시지 타입을 지정해 메시지를 주고받게 합니다. 인터페이스·주소·경로는 mctp 명령으로 조회·설정하고, 버스 오너로서 원격 엔드포인트에 EID를 할당하며 경로·이웃 테이블을 관리하는 일은 mctpd 데몬이 맡습니다. PLDM 메시지를 인코딩·디코딩하는 라이브러리는 libpldm이며, OpenBMC에서 이를 서비스로 실행하는 데몬은 pldmd입니다. 이 글의 테스트 환경에서는 MCTP가 없는 커널에서 Errno 97을 확인하고, 사용자 공간 도구(mctp, libpldm) 빌드와 PLDM Base GetPLDMTypes 요청 바이트 인코딩까지 검증했습니다.

리눅스는 AF_MCTP로 MCTP를 어떻게 제공합니까?

한 줄 답: 리눅스 커널은 AF_MCTP(PF_MCTP) 주소 패밀리와 SOCK_DGRAM 소켓을 통해 MCTP를 제공하며, struct sockaddr_mctp 구조체로 네트워크·EID·메시지 타입·태그를 지정합니다.

리눅스 커널의 MCTP 문서에 따르면, MCTP를 켠 커널에서는 AF_MCTP(또는 PF_MCTP) 주소 패밀리를 사용하며 소켓 타입은 SOCK_DGRAM만 사용 가능합니다. 프로토콜 인자는 0을 전달하여 int sd = socket(AF_MCTP, SOCK_DGRAM, 0); 형태로 소켓을 생성합니다. 물리 인터페이스 드라이버는 DSP0236의 바인딩(binding)에 해당하며, 개별 인터페이스(link)는 바인딩의 인스턴스입니다.

소켓 주소 구조체인 struct sockaddr_mctp는 smctp_family, smctp_network, smctp_addr, smctp_type, smctp_tag 필드를 가지며, 전체 네트워크와 전체 주소를 뜻하는 상수로 MCTP_NET_ANY(0x0)와 MCTP_ADDR_ANY(0xff)가 정의되어 있습니다. 요청을 수신해 처리하는 소켓은 bind()를 호출합니다. 아래는 커널 문서에 수록된 bind() 예시 코드입니다(커널 문서 예시이며 이 글에서 실행하지 않았습니다).

struct sockaddr_mctp addr;

addr.smctp_family = AF_MCTP;
addr.smctp_network = MCTP_NET_ANY;
addr.smctp_addr.s_addr = MCTP_ADDR_ANY;
addr.smctp_type = MCTP_TYPE_PLDM;
addr.smctp_tag = MCTP_TAG_OWNER;

int rc = bind(sd, (struct sockaddr *)&addr, sizeof(addr));

바인드된 소켓은 TO(Tag Owner) 비트가 설정된 메시지, 즉 들어오는 요청만 수신합니다. bind() 호출 시 smctp_tag 필드에 사용할 수 있는 유일한 유효 값은 MCTP_TAG_OWNER입니다. 메시지를 보낼 때는 sendto(), sendmsg(), send()를 사용하며, 네트워크와 주소 필드로 원격지 주소를 지정합니다. 송신 시 MCTP_TAG_OWNER를 지정하면 커널이 태그를 생성하며, 할당 가능한 태그가 없으면 호출은 EAGAIN을 보고합니다. 응용 프로그램은 송신 버퍼의 첫 번째 바이트로 메시지 타입 바이트를 넣습니다.

MCTP 패킷 헤더 필드와 EID·바인딩의 기본 개념은 「MCTP·PLDM이란? 서버 장치 관리 구조와 MCTP 패킷·EID·바인딩 동작 원리」(시리즈 이전 편)에서 다룹니다.

mctp 명령과 mctpd는 무엇이 다릅니까?

한 줄 답: mctp 명령은 iproute2의 ip처럼 커널 MCTP 스택의 상태를 조회·관리하는 명령행 유틸리티이고, mctpd는 버스 오너로서 원격 엔드포인트에 EID를 할당하고 경로와 이웃 테이블을 관리하는 MCTP Control 프로토콜 데몬입니다.

CodeConstruct/mctp 저장소 README에 따르면, mctp는 커널 MCTP 스택의 상태를 조회하고 관리하는 작은 명령행 유틸리티로 iproute2의 ip 명령과 비슷합니다. README 사용법에 나와 있는 하위 명령은 link, address, route, neigh, monitor입니다. 다음은 README에 적힌 명령 구문입니다(README 사용법 구문이며 실행 출력이 아닙니다).

mctp link set <ifname> [up|down] [mtu <mtu>] [network <net>] [bus-owner <physaddr>]
mctp address add <eid> dev <ifname>
mctp route add <eid>[-<eid>] via <ifname> [mtu <mtu>]

반면 mctpd는 MCTP Control 프로토콜을 구현한 데몬으로, 로컬 호스트가 버스 오너(bus owner) 역할을 수행할 때 필요합니다. 주요 역할은 원격 엔드포인트에 EID를 할당하고 그에 따른 경로(route)와 이웃 테이블(neighbour-table) 항목을 관리하는 것입니다. mctpd는 인터페이스를 활성화하고 로컬 주소를 할당한 뒤 시스템 서비스로 실행해야 하며, D-Bus 인터페이스를 노출하고 기본적으로 /etc/mctpd.conf에서 설정을 읽습니다. 이 저장소는 Meson으로 빌드하며, -Dtests=false 옵션을 주면 테스트 의존성을 건너뜁니다.

PLDM은 어디서 처리합니까?

한 줄 답: PLDM 메시지의 인코딩과 디코딩은 libpldm 라이브러리가 담당하고, OpenBMC에서 PLDM을 서비스로 처리하는 데몬은 openbmc/pldm 저장소의 pldmd입니다.

openbmc/libpldm은 PLDM 메시지를 인코딩하고 디코딩하는 라이브러리입니다. 이 라이브러리의 base.h 헤더에는 GetPLDMTypes 명령 코드가 0x4로, 요청 페이로드 길이 상수인 PLDM_GET_TYPES_REQ_BYTES가 0으로 정의되어 있습니다.

openbmc/pldm 저장소는 PLDM을 서비스로 처리하는 OpenBMC 데몬인 pldmd 실행 파일을 meson.build로 빌드하며, README에는 systemctl restart pldmd 명령이 안내되어 있습니다. PLDM 타입별 역할과 메시지 흐름 상세는 「PLDM 타입이란? Base·모니터링(PDR·센서)·펌웨어 업데이트·RDE 정리」(시리즈 이전 편)에서 정리합니다.

이 글의 테스트 환경에서는 어디까지 확인했습니까?

한 줄 답: 테스트 환경의 커널은 AF_MCTP를 제공하지 않아 소켓 생성 시 Errno 97이 반환되었으므로 패킷 송수신은 수행하지 않았으며, 사용자 공간 도구(mctp, libpldm) 빌드와 GetPLDMTypes 요청 바이트 인코딩까지 확인했습니다.

이 글의 테스트 환경에서는 커널 설정을 변경하지 않고 현재 커널이 AF_MCTP를 제공하는지 먼저 확인했습니다. python3에서 AF_MCTP 상수 값인 45로 SOCK_DGRAM 소켓 생성을 시도한 결과는 다음과 같습니다(출력의 ... 줄은 생략된 트레이스백 줄을 뜻합니다).

$ python3 -c 'import socket; socket.socket(45, socket.SOCK_DGRAM)'
...
OSError: [Errno 97] Address family not supported by protocol

위와 같이 Errno 97(Address family not supported by protocol)이 발생하여 테스트 머신의 커널이 AF_MCTP를 제공하지 않는 것을 확인했습니다. 따라서 실제 MCTP 패킷 송수신이나 링크·주소·경로 설정, mctpd 및 pldmd 실행은 수행하지 않았습니다.

이어서 사용자 공간 도구인 CodeConstruct/mctp(커밋 f3b843d)를 클론하고 -Dtests=false 옵션으로 빌드했습니다(meson setup 출력은 버리고 ninja 출력은 tail -1로 마지막 줄만 남겼습니다).

$ git clone --depth 1 https://github.com/CodeConstruct/mctp src-mctp
$ (cd src-mctp && rm -rf build && meson setup build -Dtests=false >/dev/null && ninja -C build | tail -1)
[22/22] Linking target mctp
$ ls src-mctp/build | grep -E "^mctp"
mctp
mctp-bench
mctp-bench.p
mctp-client
mctp-client.p
mctp-echo
mctp-echo.p
mctp-req
mctp-req.p
mctp.p

빌드 결과물 목록에 mctpd는 없습니다. Meson 로그에 Run-time dependency libsystemd found: NO (tried pkg-config and cmake)가 기록되어 있으며, 프로젝트의 meson.build가 libsystemd를 찾았을 때만 mctpd를 빌드하기 때문입니다.

다음으로 openbmc/libpldm(커밋 be4f63e)을 클론하고 같은 방식으로 빌드했습니다.

$ git clone --depth 1 https://github.com/openbmc/libpldm src-libpldm
$ (cd src-libpldm && rm -rf build && meson setup build -Dtests=false >/dev/null && ninja -C build | tail -1)
[33/33] Linking target bindings/cpp/libpldm++.so.0.18.0

빌드한 libpldm을 사용해 PLDM Base(Type 0)의 GetPLDMTypes 요청 메시지를 바이트로 인코딩만 하는 C 프로그램(pldm_encode.c)을 작성했습니다.

/* libpldm로 PLDM Base(Type 0) "GetPLDMTypes" 요청 메시지를 인코딩해 바이트를 출력 (MCTP 전송 없음, 인코딩만) */
#include <libpldm/base.h>
#include <stdio.h>
#include <stdint.h>
int main(void) {
    uint8_t buf[sizeof(struct pldm_msg_hdr) + PLDM_GET_TYPES_REQ_BYTES];
    struct pldm_msg *msg = (struct pldm_msg *)buf;
    int rc = encode_get_types_req(1 /* instance id */, msg);
    printf("encode_get_types_req rc=%d len=%zu bytes:", rc, sizeof(buf));
    for (size_t i = 0; i < sizeof(buf); i++) printf(" %02x", buf[i]);
    printf("\n");
    printf("request=%u instance_id=%u header_ver=%u pldm_type=%u command=0x%02x\n",
           msg->hdr.request, msg->hdr.instance_id, msg->hdr.header_ver, msg->hdr.type, msg->hdr.command);
    return rc;
}

이 프로그램을 컴파일하고 실행한 결과는 다음과 같습니다.

$ gcc -std=c11 -Wall -Isrc-libpldm/include -Isrc-libpldm/build/include pldm_encode.c -Lsrc-libpldm/build/src -lpldm -o pldm_encode
$ env LD_LIBRARY_PATH=src-libpldm/build/src ./pldm_encode
encode_get_types_req rc=0 len=3 bytes: 81 00 04
request=1 instance_id=1 header_ver=0 pldm_type=0 command=0x04

두 번째 출력 줄이 보여 주듯, 인코딩된 3바이트(81 00 04) 헤더의 필드는 request=1, instance_id=1, header_ver=0, pldm_type=0, command=0x04입니다. 이 메시지는 메모리 버퍼에 인코딩만 한 것이며 MCTP 소켓으로 전송하지 않았습니다.

실제 MCTP 장치와 통신하려면 무엇이 더 필요합니까?

한 줄 답: MCTP를 켠 커널과 바인딩 드라이버가 준비된 환경이라면 mctp 명령으로 링크 활성화·로컬 EID·경로를 설정하고 mctpd를 시스템 서비스로 실행한 뒤, AF_MCTP 소켓으로 PLDM 요청 바이트를 보냅니다.

실제 MCTP 장치가 연결된 환경이라면 먼저 MCTP를 켠 커널과 물리 버스용 바인딩 드라이버(예: I2C 등)가 필요합니다. MCTP를 쓸 수 있는 커널에서 인터페이스가 준비되면 mctp 명령으로 링크를 올리고 로컬 EID와 경로를 설정합니다. 아래는 예시 명령(이 글에서 실행하지 않음, 출력 없음)입니다.

mctp link set <ifname> up
mctp address add <eid> dev <ifname>
mctp route add <eid> via <ifname>

그다음 mctpd를 시스템 서비스로 실행하여 버스 오너로서 원격 엔드포인트에 EID를 할당하고 경로와 이웃 테이블을 관리하게 합니다. 이후 응용 프로그램이 AF_MCTP 소켓을 열고 송신 버퍼의 첫 바이트에 메시지 타입 바이트를 넣은 뒤, 앞서 인코딩한 GetPLDMTypes 바이트 같은 PLDM 요청을 전송하는 순서입니다. 이 흐름은 MCTP를 켠 커널을 전제로 한 설명이며, 이 글의 테스트 환경에서는 실행하지 않았습니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 커널의 MCTP 제공 여부 확인 방법, 링크·주소 설정 시 필요한 권한, 그리고 Python에서의 AF_MCTP 소켓 생성 확인 범위를 정리했습니다.

커널에 MCTP가 있는지 어떻게 확인합니까?

socket(AF_MCTP, SOCK_DGRAM, 0) 호출이 Errno 97(Address family not supported by protocol)로 실패하면 실행 중인 커널이 MCTP를 제공하지 않는 상태이며, 이 글의 테스트 환경에서도 동일하게 확인했습니다.

root 권한이 필요합니까?

일반적으로 네트워크 링크·주소·경로 설정을 변경하는 작업에는 관리자 권한이 필요합니다.

Python으로도 해 볼 수 있습니까?

이 글에서는 AF_MCTP에 해당하는 상수 45를 사용해 소켓 생성만 시도해 보았으며, 이 커널에서는 Errno 97로 실패했습니다. 그 이후 동작은 이 글에서 테스트하지 않았습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: MCTP 패킷 헤더·EID·바인딩 원리와 PLDM 타입별 구조를 다룬 시리즈 이전 편입니다.

출처는 어디입니까?

한 줄 답: 리눅스 커널 MCTP 문서와 CodeConstruct/mctp, openbmc/libpldm, openbmc/pldm 저장소입니다.

PLDM(Platform Level Data Model)은 메시지 헤더의 PLDM 타입 필드로 기본 조회, 모니터링(PDR·센서), PLDM 펌웨어 업데이트, RDE 같은 관리 기능 묶음을 구분하는 DMTF 표준입니다. 이 글에서는 대표 출처인 DMTF DSP0240 PLDM Base Specification 1.2.1과 각 타입별 표준 문서를 바탕으로 PLDM 공통 메시지 구조와 Type 0·2·5·6의 역할 및 펌웨어 업데이트 순서를 정리합니다.

핵심 요약

PLDM은 메시지 헤더의 "PLDM Type"으로 기능 묶음을 나눕니다. Type 0 Base는 어떤 타입·버전을 다룰 수 있는지 묻는 기본 명령, Type 2 모니터링·제어는 PDR(장치 설명 레코드)과 센서 읽기, Type 5는 펌웨어 업데이트, Type 6 RDE는 장치를 Redfish 방식으로 관리하게 해 줍니다. 펌웨어 업데이트는 업데이트 에이전트(UA)가 RequestUpdate로 시작하고, 장치(FD)가 RequestFirmwareData로 이미지를 조금씩 가져간 뒤 ActivateFirmware로 마칩니다.

PLDM 메시지는 어떻게 생겼습니까?

한 줄 답: PLDM 메시지는 요청 여부와 인스턴스를 구분하는 필드, 기능 묶음을 뜻하는 PLDM Type, 세부 명령을 가리키는 PLDM Command Code(응답에는 PLDM Completion Code 추가)로 이루어진 공통 헤더를 가집니다.

DSP0240 1.2.1에 정의된 PLDM 공통 헤더 필드는 다음과 같습니다.

필드 이름의미
Rq메시지가 요청(request)인지 응답(response)인지 구분합니다.
D데이터그램(datagram) 여부를 나타냅니다.
Instance ID요청과 응답 또는 비동기 메시지를 구분하기 위한 인스턴스 식별자입니다.
Hdr VerPLDM 헤더 버전을 나타냅니다.
PLDM Type메시지가 속한 PLDM 기능 묶음(타입)을 나타냅니다.
PLDM Command Code해당 PLDM 타입 안에서 수행할 세부 명령 코드입니다.
PLDM Completion Code응답 메시지에만 포함되며 명령 처리 결과를 나타냅니다.

DSP0240에서는 PLDM 타입 값 자체는 DMTF DSP0245에 정의된다고 설명합니다. 또한 PLDM은 MCTP 위에 MCTP 메시지 타입의 하나로 실리며, 아래쪽의 MCTP 전송 헤더와 EID 동작 원리는 「MCTP·PLDM이란? 서버 장치 관리 구조와 MCTP 패킷·EID·바인딩 동작 원리」(시리즈 이전 편)에서 다룹니다.

Type 0 Base는 무엇을 합니까?

한 줄 답: 엔드포인트가 어떤 PLDM 타입과 버전을 다룰 수 있는지 확인하고 TID를 조회하는 기본 명령을 제공합니다.

Type 0(PLDM_BASE, 0x00)은 PLDM 통신의 가장 기본이 되는 발견·조회 명령을 담고 있습니다. 상대 엔드포인트가 다룰 수 있는 PLDM 타입 목록을 묻는 GetPLDMTypes(libpldm의 base.h 기준 명령 코드 0x4)를 비롯해 버전 정보를 확인하는 GetPLDMVersion, 해당 타입에서 다룰 수 있는 명령을 확인하는 GetPLDMCommands, 그리고 엔드포인트의 TID를 조회하는 GetTID가 여기에 속합니다.

「리눅스에서 MCTP·PLDM은 어떻게 다루나? AF_MCTP 소켓·mctp·mctpd·libpldm·pldmd」(시리즈 다음 편)에서는 libpldm으로 GetPLDMTypes 요청 메시지를 MCTP로 보내지 않고 인코딩만 해 보며, 그때 출력된 바이트와 필드 값은 다음과 같습니다.

encode_get_types_req rc=0 len=3 bytes: 81 00 04
request=1 instance_id=1 header_ver=0 pldm_type=0 command=0x04

둘째 줄의 출력 그대로, 인코딩된 3바이트(81 00 04)는 요청 메시지(request=1), 인스턴스 ID 1(instance_id=1), 헤더 버전 0(header_ver=0), PLDM 타입 0(pldm_type=0), 명령 코드 0x04(command=0x04)를 나타냅니다.

Type 2 모니터링에서 PDR과 센서는 무엇입니까?

한 줄 답: PDR(Platform Descriptor Record)은 플랫폼의 엔티티와 센서를 설명하는 레코드이며, Type 2는 이 레코드와 센서 값 및 이벤트를 읽는 데 쓰입니다.

DSP0248 1.4.0(PLDM for Platform Monitoring and Control)에서 다루는 Type 2(PLDM_PLATFORM, 0x02)의 핵심 개념은 다음과 같습니다.

  • PDR(Platform Descriptor Record)과 PDR 저장소: 플랫폼 안의 엔티티와 센서 구성을 설명하는 레코드가 PDR이며, 이 레코드들은 PDR repository(Primary PDR Repository)에 보관됩니다. 저장소에서 레코드를 읽어 올 때는 GetPDR 명령을 사용합니다.
  • 센서 종류와 값 읽기: 센서를 설명하는 PDR에는 수치 값을 다루는 Numeric Sensor PDR과 상태를 다루는 State Sensor PDR이 있으며, 실제 센서 값을 읽을 때는 GetSensorReading 명령을 사용합니다.
  • 이벤트 전달: 플랫폼에서 발생한 이벤트를 알릴 때는 PlatformEventMessage 명령을 사용합니다.

Type 5 펌웨어 업데이트는 어떤 순서로 진행됩니까?

한 줄 답: 업데이트 에이전트(UA)가 업데이트를 시작하면 펌웨어 장치(FD)가 필요한 이미지 조각을 요청해 받아 검증·적용하고, 마지막에 활성화 단계로 마무리합니다.

DSP0267 1.3.0(PLDM for Firmware Update)의 Figure 1에 나온 Type 5(PLDM_FWUP, 0x05) PLDM 펌웨어 업데이트 흐름은 업데이트 에이전트(UA)와 펌웨어 장치(FD) 사이에서 다음 순서로 진행됩니다.

  1. UA가 FD에 RequestUpdate를 보내면 FD가 업데이트 모드로 들어갑니다.
  2. (선택) 패키지 데이터와 메타데이터를 주고받습니다.
  3. UA가 PassComponentTable로 구성요소(컴포넌트) 정보를 전달합니다.
  4. 각 구성요소마다 UA가 UpdateComponent를 보내면, FD가 RequestFirmwareData로 이미지 조각을 조금씩 요청해 받아 가고, 전송이 끝나면 FD가 TransferComplete, 검증이 끝나면 FD가 VerifyComplete, 적용이 끝나면 FD가 ApplyComplete를 차례로 보냅니다.
  5. 모든 구성요소 처리가 끝난 뒤 UA가 FD에 ActivateFirmware를 보내고, UA는 GetStatus로 완료 상태를 확인합니다. 스스로 바로 활성화할 수 없는 구성요소는 외부 리셋 등이 일어날 때까지 보류(pending) 상태가 됩니다.
  6. 진행 중 언제든 CancelUpdate로 취소할 수 있으며, 만약 통신이 끊기면 FD는 자체 내부 타이머에 따라 업데이트 모드를 종료합니다.

Type 6 RDE는 무엇을 합니까?

한 줄 답: 장치가 Redfish 데이터 모델을 직접 다룰 수 있게 해 주며, 데이터를 간결하게 표현하기 위해 BEJ(Binary Encoded JSON)와 사전(dictionary)을 사용합니다.

DSP0218 1.2.0(PLDM for Redfish Device Enablement)에 정의된 Type 6(PLDM_RDE, 0x06)은 Redfish 데이터를 장치 쪽에서 다룰 수 있게 해 주는 PLDM 타입입니다. 이때 JSON 형태의 Redfish 데이터를 이진 형태로 다루기 위해 BEJ(Binary Encoded JSON)와 사전(dictionary)이라는 개념을 함께 사용합니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: PLDM 타입 번호가 정의된 문서와 헤더, 펌웨어 업데이트 중단 시 동작, 그리고 그 외 정의된 PLDM 타입 이름을 정리합니다.

PLDM 타입 번호는 어디에 정의됩니까?

DSP0240에서는 PLDM 타입 값이 DMTF DSP0245에 정의된다고 안내합니다. 이 글에서 표기한 타입 번호 0·2·5·6은 OpenBMC libpldm의 base.h 열거형(PLDM_BASE 0x00, PLDM_PLATFORM 0x02, PLDM_FWUP 0x05, PLDM_RDE 0x06)에서 확인한 값입니다.

펌웨어 업데이트 중 실패하면 어떻게 됩니까?

DSP0267에 따르면 진행 중 언제든 CancelUpdate를 사용할 수 있으며, 통신이 끊기면 펌웨어 장치(FD)가 자체 내부 타이머로 업데이트 모드를 종료합니다.

SMBIOS·BIOS·FRU 타입은 무엇입니까?

libpldm의 base.h에는 이 글에서 다룬 타입 외에도 0x01 SMBIOS, 0x03 BIOS, 0x04 FRU(그리고 0x07 FILE) 타입이 함께 나열되어 있습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: MCTP 패킷·EID·바인딩 기초를 다룬 시리즈 이전 편과 리눅스 소켓·도구·라이브러리를 다룬 다음 편입니다.

출처는 어디입니까?

한 줄 답: DMTF의 PLDM 표준 문서(DSP0240·DSP0248·DSP0267·DSP0218)와 OpenBMC libpldm 헤더 및 DMTF PMCI 표준 페이지입니다.

MCTP(Management Component Transport Protocol)와 PLDM(Platform Level Data Model)은 서버 보드 내부에서 관리 컨트롤러(BMC)와 여러 장치가 상태 정보나 제어 명령을 주고받을 때 사용하는 DMTF(Distributed Management Task Force) PMCI 표준입니다. 이 글에서는 DSP0236 MCTP Base Specification 1.4.0과 관련 바인딩 규격을 바탕으로 서버 장치 관리 구조, MCTP 전송 헤더, EID(엔드포인트 ID)와 메시지 태그, 버스 오너의 동작 원리를 정리합니다.

핵심 요약

MCTP는 서버 보드 안에서 BMC 같은 관리 컨트롤러와 장치들이 관리 메시지를 주고받게 하는 DMTF 전송 프로토콜이고, PLDM은 그 위에 실리는 "무엇을 말할지"를 정한 데이터 모델입니다. MCTP는 SMBus/I2C, PCIe, I3C 같은 서로 다른 버스 위에서 같은 형식의 패킷을 쓰고, 장치를 물리 주소가 아니라 EID(엔드포인트 ID)로 구분합니다. EID는 버스 오너가 MCTP Control 메시지(Set Endpoint ID 등)로 나눠 줍니다.

MCTP·PLDM이란 무엇이고 서버 장치 관리에서 어디에 쓰입니까?

한 줄 답: MCTP는 서버 보드 내부의 관리 컨트롤러(BMC)와 장치 사이에서 물리 버스와 무관하게 메시지를 나르는 전송 계층이고, PLDM은 그 위에서 센서 조회나 펌웨어 업데이트 같은 관리 동작을 표현하는 상위 데이터 모델입니다.

서버 보드에는 호스트 CPU·운영체제(OS)와 별개로 보드 위 장치를 감시하고 제어하는 관리 컨트롤러인 BMC(Baseboard Management Controller)가 들어갑니다. 호스트 OS가 동작하는 경로(in-band)와 구분되는 별도 관리 경로(out-of-band)를 두면, 호스트 OS의 상태와 상관없이 BMC가 보드 내부 장치의 상태를 살피고 관리 작업을 수행할 수 있습니다.

관리자나 외부 관리 시스템이 네트워크를 통해 BMC에 접근할 때는 IPMI나 Redfish 같은 외부 관리 인터페이스를 사용합니다. 반면 보드 안쪽에서 BMC와 개별 장치들이 실제로 데이터를 주고받을 때는 DMTF의 PMCI(Platform Management Components Intercommunication) 스택이 쓰입니다.

PMCI 스택은 역할을 두 층으로 나눕니다. 아래층의 MCTP는 SMBus/I2C, PCIe, I3C 등 물리 버스가 달라져도 동일한 패킷 구조와 주소 체계로 메시지를 전달하는 전송 프로토콜입니다. 위층의 PLDM은 그 전송 계층 위에 실려 "무엇을 말할지"를 정의하는 데이터 모델이며, PLDM 외에도 다른 관리 메시지 타입이 MCTP 위에 실릴 수 있습니다. PLDM의 세부 타입 구성은 「PLDM 타입이란? Base·모니터링(PDR·센서)·펌웨어 업데이트·RDE 정리」(시리즈 다음 편)에서 다룹니다.

MCTP 패킷 헤더에는 무엇이 들어갑니까?

한 줄 답: 32비트 MCTP 전송 헤더에는 헤더 버전, 목적지·출발지 EID, 패킷 분할·조립 플래그(SOM, EOM, Pkt Seq #), 메시지 식별용 TO와 Msg tag가 들어가며, 메시지의 첫 패킷 본문 첫 바이트에는 무결성 검사 비트(IC)와 Message type이 위치합니다.

DSP0236 1.4.0 표 1(Table 1)에 정의된 MCTP 전송 헤더(MCTP transport header)는 총 32비트이며, 아래 필드들로 구성됩니다. 규격 주석에 명시된 대로 서브필드의 구체적인 배치 위치는 사용하는 물리 바인딩에 따라 달라질 수 있습니다.

필드 이름비트 수설명 (DSP0236 1.4.0 Table 1)
RSVD4 bits예약 필드
Hdr version4 bits헤더 버전 (이 버전의 규격에서는 0001b)
Destination endpoint ID8 bits목적지 엔드포인트 ID (EID)
Source endpoint ID8 bits출발지 엔드포인트 ID (EID)
SOM1 bitStart of message (메시지의 첫 패킷 표시)
EOM1 bitEnd of message (메시지의 마지막 패킷 표시)
Pkt Seq #2 bits패킷 순번 (modulo 4로 증가)
TO1 bitTag Owner (메시지 태그를 할당한 주체 표시)
Msg tag3 bits메시지 태그

전송 헤더 바로 뒤에 이어지는 메시지 본문(message body)의 첫 번째 바이트는 IC(1비트, 메시지 무결성 검사 존재 여부)와 Message type(7비트, 상위 메시지 종류 식별)으로 이루어집니다. 이 첫 바이트는 하나의 메시지를 구성하는 패킷들 가운데 첫 번째 패킷(SOM이 설정된 패킷)에만 필요합니다.

EID와 메시지 태그는 왜 필요합니까?

한 줄 답: EID는 물리 버스 주소 체계와 분리된 논리 주소로 장치를 식별하기 위해 필요하며, 메시지 태그(Msg tag)와 TO 비트는 여러 패킷으로 쪼개진 메시지나 동시에 오가는 요청·응답을 구분하기 위해 필요합니다.

SMBus/I2C, PCIe, I3C는 저마다 물리적인 장치 주소 형식이 다릅니다. MCTP는 물리 주소 대신 8비트 논리 주소인 EID(Endpoint ID)로 통신 대상을 지정합니다. DSP0236 표 2(Table 2)에 정리된 특수 EID 값은 다음과 같습니다.

  • 0 (Null EID): 논리 주소를 쓰지 않고 물리 주소만 사용할 때 지정합니다.
  • 1~7: 예약(reserved)된 값입니다.
  • 0xFF: 해당 버스 내 브로드캐스트(broadcast within the bus)에 쓰입니다. 네트워크 전체 브로드캐스트는 제공하지 않습니다.

또한 한 장치가 여러 메시지를 주고받거나 긴 메시지를 여러 패킷으로 나누어 보낼 때는 패킷들이 어떤 메시지에 속하는지 구분해야 합니다. MCTP에서는 Msg tag(3비트)와 TO(Tag Owner, 1비트), 그리고 Source endpoint ID의 조합으로 개별 메시지를 식별합니다.

DSP0236은 기본(최소) 전송 단위를 64바이트로 정합니다. 메시지가 한 패킷에 다 들어가지 않을 때는 여러 패킷으로 분할해 전송하며, 첫 패킷에는 SOM을 켜고 마지막 패킷에는 EOM을 켜며 패킷마다 Pkt Seq #를 modulo 4로 1씩 증가시켜 순서대로 재조립합니다.

MCTP는 어떤 버스 위에서 동작합니까?

한 줄 답: MCTP는 바인딩 규격을 통해 물리 계층 프레이밍과 물리 헤더만 버스별로 정의하는 방식으로 SMBus/I2C, PCIe VDM, I3C 등 서로 다른 물리 버스 위에서 동일한 전송 헤더를 실어 나릅니다.

MCTP 기본 규격(DSP0236)은 버스에 독립적인 전송 헤더와 제어 절차만 정의하고, 각 물리 매체의 헤더와 프레이밍 안에 MCTP 헤더를 어떻게 담아 보낼지는 별도의 전송 바인딩(transport binding) 규격에서 정합니다.

  • SMBus/I2C 바인딩 (DSP0237): SMBus 위에서 오가는 MCTP 메시지는 명령 코드(command code) 0x0F를 사용합니다. I2C 버스 자체의 기초는 I2C·SPI·UART 차이와 기초 글에서 다뤘습니다.
  • PCIe VDM 바인딩 (DSP0238): PCIe의 Vendor Defined Messages(VDM)를 이용해 MCTP 패킷을 전달하며, DMTF Vendor ID 1AB4h를 사용합니다.
  • I3C 바인딩 (DSP0233): I3C 버스 위에서 MCTP를 전달하기 위한 DMTF 전송 바인딩 규격(DSP0233)이 정의되어 있습니다.

버스 오너는 장치를 어떻게 찾고 EID를 줍니까?

한 줄 답: 해당 버스의 EID 할당을 관리하는 버스 오너(bus owner)가 MCTP Control 메시지(Set Endpoint ID, Get Message Type Support 등)를 보내 장치를 식별하고 EID와 메시지 타입 구성을 확인합니다.

DSP0236에서 버스 오너(bus owner)는 해당 버스의 EID 할당을 관리하는 쪽을 뜻합니다. 버스 오너는 MCTP Control 메시지를 보내 버스 위의 엔드포인트에 EID를 부여하고, 각 엔드포인트가 어떤 버전과 메시지 타입을 다룰 수 있는지 조회합니다. DSP0236에 정의된 주요 Control 명령 코드는 아래 표와 같습니다.

명령 코드명령 이름역할
0x01Set Endpoint ID엔드포인트에 EID를 할당하거나 갱신
0x02Get Endpoint ID엔드포인트의 현재 EID 조회
0x03Get Endpoint UUID엔드포인트의 고유 UUID 조회
0x04Get MCTP Version Support대응 가능한 MCTP 기본 규격 및 메시지 타입 버전 조회
0x05Get Message Type Support엔드포인트에서 사용 가능한 상위 메시지 타입 목록 조회

이 밖에도 버스 위 장치를 탐색하고 라우팅 정보를 전달하기 위한 Control 명령으로 Prepare for Endpoint Discovery, Endpoint Discovery, Routing Information Update가 있습니다. 여러 버스가 브리지(bridge)로 연결된 환경에서는 브리지와 라우팅 구성을 통해 이 경로 정보를 주고받으며 버스 간 메시지를 전달합니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: MCTP에 실리는 다른 메시지 타입(NVMe-MI, SPDM 등), 원시 I2C 레지스터 접근 대신 MCTP를 쓰는 이유, 리눅스 환경에서의 실습 방법을 정리했습니다.

PLDM 말고 MCTP에 실리는 메시지는 무엇이 있습니까?

DMTF의 ID 및 코드 문서인 DSP0239 1.12.0에 따르면, 메시지 타입 코드로 0x00 MCTP Control, 0x01 PLDM(DSP0241), 0x04 NVMe-MI(DSP0235)가 정의되어 있으며, 보안 통신 등을 위한 SPDM과 Secured Messages 메시지 타입도 존재합니다.

I2C로 그냥 레지스터를 읽으면 안 됩니까?

장치마다 고유한 I2C 레지스터 맵을 직접 읽는 방식은 버스나 장치가 바뀔 때마다 관리 소프트웨어를 새로 맞춰야 합니다. MCTP를 사용하면 물리 버스와 독립된 주소 체계(EID)와 공통 메시지 형식을 쓸 수 있어, SMBus/I2C와 PCIe VDM, I3C처럼 서로 다른 버스 위에서도 동일한 상위 관리 소프트웨어(PLDM 등)를 그대로 사용할 수 있습니다.

리눅스에서 직접 써 보려면 어떻게 합니까?

리눅스 커널의 AF_MCTP 소켓과 사용자 공간 도구, PLDM 라이브러리를 다루는 방법은 「리눅스에서 MCTP·PLDM은 어떻게 다루나? AF_MCTP 소켓·mctp·mctpd·libpldm·pldmd」(시리즈 다음 편)에서 확인하실 수 있습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 이어지는 시리즈 글에서 PLDM 세부 타입과 리눅스 AF_MCTP·libpldm 사용법을 다룹니다.

출처는 어디입니까?

한 줄 답: DMTF PMCI 작업 그룹의 MCTP 기본 규격과 버스별 바인딩 규격, 메시지 타입 코드 문서를 참고했습니다.

undefined reference는 개별 소스 파일의 컴파일 단계(g++ -c)는 통과했지만, 목적 파일과 라이브러리를 합치는 링크 단계에서 링커(collect2를 통한 /usr/bin/ld)가 선언된 심볼(U)의 실제 정의(T)를 찾지 못할 때 발생하는 C++ 링크 에러입니다. 이 글에서는 2026-10-28(KST)에 g++/gcc (Debian 14.2.0-19) 14.2.0, x86_64, /usr/bin/ld 환경에서 재현한 네 가지 사례와 nm 출력을 바탕으로 원인과 해결 방법을 정리합니다(출력의 +0x5 같은 오프셋 값은 이 실행 환경의 값이며, 임시 목적 파일 경로는 /tmp/ccXXXXXX.o로 표기했습니다).

핵심 요약

undefined reference는 컴파일러가 아니라 링커가 내는 에러로, 함수를 쓰겠다는 선언은 확인했으나 실제 정의(본문)를 링크 대상 어디에서도 찾지 못했다는 뜻입니다. 대표적인 원인은 정의 자체를 작성하지 않았거나, 정의가 담긴 .cpp·라이브러리를 링크 명령에서 빠뜨렸거나, C 함수를 extern "C" 없이 호출했거나, 정적 라이브러리 -l 옵션을 호출하는 목적 파일보다 앞에 둔 경우입니다. nm 및 nm -C로 U(이 파일에 정의 없음)와 T(코드 섹션에 정의됨)를 비교하면 원인을 바로 좁힐 수 있습니다.

undefined reference 에러란 무엇입니까?

한 줄 답: undefined reference는 선언만 보고 넘어간 컴파일 단계(g++ -c) 뒤, 링커(ld)가 실제 함수 정의(T)를 찾지 못해 ld returned 1 exit status와 함께 중단되는 링크 에러입니다.

C++ 분리 컴파일에서는 함수 선언만 있으면 개별 소스 파일을 목적 파일(.o)로 컴파일할 수 있습니다. 하지만 실행 파일을 만들 때 링커가 모든 목적 파일과 라이브러리를 뒤져도 그 함수의 정의를 찾지 못하면 링크 에러가 발생합니다.

아래 c1_main.cpp는 함수 선언만 있고 정의가 없는 코드입니다.

void helper();   // 선언만 있음
int main() { helper(); }

이 파일을 $ g++ c1_main.cpp -o c1 명령으로 실행 파일까지 빌드하면 종료 코드 1(exit=1)로 실패하며 다음 출력이 나옵니다.

/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
c1_main.cpp:(.text+0x5): undefined reference to `helper()'
collect2: error: ld returned 1 exit status

반면 링크를 생략하고 컴파일만 수행하는 $ g++ -c c1_main.cpp -o c1_main.o 명령은 종료 코드 0(exit=0)으로 성공합니다. 생성된 목적 파일을 $ nm -C c1_main.o로 확인하면 결과는 다음과 같습니다.

                 U helper()
0000000000000000 T main

여기서 T는 이 목적 파일의 텍스트(코드) 섹션에 정의된 심볼을 뜻하고, U는 이 파일에서 참조하지만 정의는 외부에 있어 아직 해결되지 않은 심볼을 뜻합니다. 이는 동일한 함수 정의가 두 번 이상 들어가 충돌하는 C++ multiple definition 에러란? 헤더에 함수를 정의할 때 inline이 필요한 이유(정의 두 번 vs 0번)와 정반대 상황이며, 전처리기·문법 단계에서 발생하는 expected unqualified-id before numeric constant 에러란? 매크로와 이름이 겹칠 때 C++ 해결 방법 같은 컴파일 에러와도 메시지 형태가 다릅니다. 분리 컴파일의 기본 구조는 「A Tour of C++ 3.2 분리 컴파일(Separate Compilation)」에서도 다룹니다.

정의 파일을 빠뜨리면 어떻게 됩니까?

한 줄 답: 헤더 파일만 #include하고 함수 본문이 담긴 .cpp 파일을 빌드 명령에서 빼먹으면, 컴파일은 통과해도 링커가 정의를 찾지 못합니다.

선언과 정의를 헤더(math.h)와 구현 파일(math.cpp)로 나눈 뒤, 호출하는 쪽(c2_main.cpp)만 빌드 명령에 넣는 경우입니다.

// math.h
#pragma once
int add(int a, int b);
// math.cpp
#include "math.h"
int add(int a, int b) { return a + b; }
// c2_main.cpp
#include "math.h"
#include <cstdio>
int main() { std::printf("add=%d\n", add(2, 3)); }

math.cpp를 빼고 $ g++ c2_main.cpp -o c2를 실행하면 종료 코드 1(exit=1)과 함께 다음 링크 에러가 출력됩니다.

/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
c2_main.cpp:(.text+0xf): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status

구현 파일까지 함께 지정해 $ g++ c2_main.cpp math.cpp -o c2를 실행하면 종료 코드 0(exit=0)으로 링크가 완료되며, $ ./c2 실행 결과는 다음과 같습니다.

add=5

CMake를 사용하는 프로젝트라면 add_executable(또는 라이브러리 타깃)의 소스 파일 목록에 구현 .cpp 파일이 빠지지 않았는지 확인합니다.

C 함수를 부를 때는 왜 생깁니까?

한 줄 답: C++ 컴파일러는 오버로딩 지원을 위해 함수 이름을 맹글링(_Z8c_squarei)하지만 C 컴파일러는 원래 이름(c_square)을 그대로 내보내므로, extern "C"가 없으면 심볼 이름이 어긋납니다.

cppreference의 언어 연결(Language linkage) 문서에 따르면, extern "C"는 C 언어 연결을 지정하여 C++ 이름 맹글링(name mangling)을 적용하지 않고 C로 컴파일된 함수를 C++ 프로그램에서 호출할 수 있게 해 줍니다.

먼저 C 컴파일러(gcc -c csq.c -o csq.o)로 컴파일한 csq.c입니다.

int c_square(int x) { return x * x; }

extern "C" 없이 선언만 적은 csq_noextern.h(int c_square(int x);)를 포함한 c3_main_bad.cpp는 다음과 같습니다.

#include "csq_noextern.h"
#include <cstdio>
int main() { std::printf("sq=%d\n", c_square(4)); }

두 파일을 합치는 $ g++ c3_main_bad.cpp csq.o -o c3bad 명령은 csq.o를 함께 넘겼는데도 종료 코드 1(exit=1)로 실패합니다.

/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
c3_main_bad.cpp:(.text+0xa): undefined reference to `c_square(int)'
collect2: error: ld returned 1 exit status

각 목적 파일의 심볼 테이블을 nm으로 확인하면 원인이 드러납니다. 먼저 $ nm csq.o 출력입니다.

0000000000000000 T c_square

반면 C++로 컴파일된 c3_main_bad.o를 $ nm c3_main_bad.o로 확인하면 맹글링된 이름을 찾고 있습니다.

                 U _Z8c_squarei
0000000000000000 T main
                 U printf

디맹글링 옵션인 $ nm -C c3_main_bad.o를 주면 사람이 읽는 시그니처로 보입니다.

                 U c_square(int)
0000000000000000 T main
                 U printf

즉 csq.o는 c_square를 제공하고 c3_main_bad.o는 _Z8c_squarei를 찾고 있어 이름이 맞지 않았습니다.

이를 해결하려면 C와 C++ 양쪽에서 포함할 수 있도록 #ifdef __cplusplus와 extern "C" 패턴을 쓴 csq.h를 사용합니다.

#ifdef __cplusplus
extern "C" {
#endif
int c_square(int x);
#ifdef __cplusplus
}
#endif

c3_main_bad.cpp에서 포함 헤더만 #include "csq.h"로 바꾼 c3_main_ok.cpp를 $ g++ c3_main_ok.cpp csq.o -o c3ok로 빌드하면 종료 코드 0(exit=0)으로 성공하며, $ ./c3ok 실행 결과는 다음과 같습니다.

sq=16

라이브러리 순서는 왜 중요합니까?

한 줄 답: 링커는 명령줄에 지정된 순서대로 목적 파일과 라이브러리를 검색·처리하므로, 정적 라이브러리를 참조하는 목적 파일보다 -l 옵션을 뒤에 두어야 합니다.

GCC 공식 문서(Link Options)는 링커가 라이브러리와 목적 파일을 지정된 순서대로 검색하고 처리한다고 설명합니다. 따라서 -lz보다 뒤에 나오는 bar.o가 라이브러리 z의 함수를 참조하면 해당 함수들이 적재되지 않을 수 있으므로, -l 옵션은 이를 참조하는 목적 파일 뒤에 놓아야 합니다.

앞서 작성한 math.cpp를 g++ -c math.cpp -o math.o && ar rcs libmymath.a math.o 명령으로 정적 라이브러리 libmymath.a로 묶은 뒤, -lmymath를 소스 파일보다 앞에 둔 $ g++ -L. -lmymath c2_main.cpp -o c4bad를 실행하면 종료 코드 1(exit=1)로 실패합니다.

/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
c2_main.cpp:(.text+0xf): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status

호출하는 c2_main.cpp를 앞에 두고 정적 라이브러리 옵션을 뒤에 배치한 $ g++ c2_main.cpp -L. -lmymath -o c4ok는 종료 코드 0(exit=0)으로 성공하며, $ ./c4ok 실행 시 정상 출력됩니다.

add=5

nm으로 원인을 어떻게 찾습니까?

한 줄 답: 호출하는 목적 파일의 미해결 심볼(U)과 정의가 들어 있어야 할 파일의 정의 심볼(T)을 nm 및 nm -C로 대조하는 3단계 순서로 원인을 찾습니다.

  1. 호출하는 목적 파일에 nm -C를 실행해 링크 에러가 난 심볼이 U(미정의 참조)로 표시되는지 확인합니다.
  2. 정의를 제공해야 하는 목적 파일(.o)이나 정적 라이브러리(.a)에 nm -C를 실행해 같은 이름의 T(텍스트 섹션 정의) 심볼이 있는지 확인합니다.
  3. -C를 뺀 기본 nm에서 한쪽은 C 이름(c_square)이고 다른 쪽은 맹글링된 이름(_Z8c_squarei)이면 헤더에 extern "C"를 추가하고, 아예 T가 없거나 무시되었다면 누락된 소스·라이브러리를 추가하거나 -l 순서를 호출 파일 뒤로 옮깁니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 함수 템플릿의 분리 작성, 가상 함수 본문 누락, 크로스 컴파일 시 타깃 아키텍처 라이브러리 불일치에서도 undefined reference가 발생할 수 있습니다.

템플릿 함수를 .cpp 파일에 정의하면 왜 링크 에러가 납니까?

템플릿은 호출되는 타입에 맞춰 인스턴스화할 때 컴파일러가 전체 정의를 봐야 하므로, 명시적 인스턴스화(explicit instantiation)를 따로 하지 않는 한 템플릿 정의는 헤더 파일에 두는 것이 원칙입니다.

가상 함수를 선언만 하고 정의하지 않으면 어떻게 됩니까?

순수 가상 함수(= 0)가 아닌 일반 가상 함수의 정의를 빠뜨리면 링커 메시지에 vtable이 보일 수 있습니다. 가상 함수와 소멸자의 동작 원리는 C++ 가상 소멸자란? 부모 포인터로 delete할 때 virtual이 필요한 이유 글에서 확인할 수 있습니다.

크로스 컴파일할 때 라이브러리를 지정했는데도 왜 에러가 납니까?

호스트 아키텍처용 라이브러리나 잘못된 sysroot 경로의 라이브러리를 참조하면 링커가 호환되는 심볼 정의를 찾지 못할 수 있으며, 타깃용 gcc 빌드와 실행 순서는 크로스 컴파일이란? ARM용 gcc로 빌드하고 QEMU에서 실행할 때 순서 글을 참고하시기 바랍니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: C++ 링크·컴파일 에러 해결, 가상 소멸자, 크로스 컴파일 및 CMake 설정을 다룬 관련 글입니다.

출처는 어디입니까?

한 줄 답: GCC 링커 옵션 공식 문서와 cppreference의 언어 연결(Language linkage) 문서를 참고했습니다.

추측 디코딩(speculative decoding)은 작은 초안 모델(draft model)이 다음에 올 토큰 여러 개를 먼저 추측해 두고, 큰 모델이 이를 한 번에 검증하여 LLM 답변 생성 속도를 높이는 추론 기법입니다. 대규모 언어 모델이 토큰을 순서대로 만들 때는 메모리에서 가중치를 읽어 오는 동안 연산 장치가 놀고 있게 되는데, 이 글에서는 전체 계산량이 늘어나는데도 왜 답변이 빨라지는지 그 원리와 한계를 정리합니다.

핵심 요약

추측 디코딩은 작은 초안 모델이 다음 토큰 여러 개를 먼저 추측하고, 큰 모델이 그 초안을 한 번에 검증해 자기가 냈을 토큰만 받아들이는 방법입니다. 전체 계산량은 오히려 늘지만, 한 토큰씩 만들 때 GPU는 가중치를 읽어 오느라 연산 장치가 놀고 있기 때문에 그 남는 연산을 검증에 쓰면 더 빨라집니다. 그래서 요청이 적을 때 효과가 크고, 배치가 크면 이득이 줄어듭니다. 결과 품질은 큰 모델 기준을 따르도록 설계된 방법입니다.

추측 디코딩이란 무엇입니까?

한 줄 답: 추측 디코딩은 작은 초안 모델이 다음 토큰 여러 개를 먼저 제안하고, 큰 모델이 한 번의 전달(forward pass)로 이를 검증해 맞는 토큰만 수락하는 추론 가속 기법입니다.

일반적인 디코딩은 큰 모델이 한 번에 토큰을 하나씩만 순서대로 만들어 냅니다. 반면 추측 디코딩(speculative decoding)은 초안 작성, 검증, 수락 및 거절의 흐름으로 동작합니다.

  • 초안 작성(draft): 작고 빠른 초안 모델(draft model)이 다음에 올 토큰 여러 개를 빠르게 제안합니다.
  • 검증(verify): 큰 모델(target model)이 초안 모델이 제안한 토큰들을 단 한 번의 전달(forward pass)로 동시에 확인합니다.
  • 수락 및 거절(accept/reject): 큰 모델은 자신이 직접 생성했을 토큰들을 그대로 수락하고, 처음으로 거절되는 지점에서는 큰 모델 자신의 토큰으로 바꾼 뒤 그 위치부터 다시 초안 작성을 이어 갑니다.

Hacker News에서 화제가 된 글 "Why Is Speculative Decoding Fast?"와 그 원문인 Joe Barrow의 Why Is Speculative Decoding Fast?에서도 이 초안 및 검증 구조를 바탕으로 왜 실제 응답이 빨라지는지를 설명하고 있습니다.

계산이 늘어나는데 왜 빨라집니까?

한 줄 답: 토큰을 하나씩 만들 때는 GPU가 메모리에서 가중치를 읽어 오느라 연산 장치가 놀고 있는데(memory bound), 남는 연산 자원으로 초안 토큰 여러 개를 한 번에 검증하기 때문입니다.

초안 모델을 따로 실행하고 거절될 수도 있는 토큰까지 검증하므로 전체 계산량은 오히려 늘어납니다. 그럼에도 전체 시간이 줄어드는 이유는 단일 토큰 생성 단계가 연산 능력 부족(compute bound)이 아니라 메모리 읽기 대기(memory bound) 상태에 묶여 있기 때문입니다.

큰 모델이 토큰을 하나 만들 때마다 GPU는 모델 가중치를 메모리에서 읽어 와야 합니다. 이때 대부분의 시간은 가중치를 옮기는 데 쓰이고 정작 연산 장치는 할 일이 없어 놀게 됩니다. 비유하자면 배송 트럭이 상자 하나만 싣고 가든 상자 여러 개를 함께 싣고 가든 이동 시간은 거의 같은 것과 비슷합니다. 가중치를 메모리에서 읽어 오는 과정이 트럭의 이동이라면, 추측 디코딩은 비어 있는 트럭 적재 공간에 초안 토큰 여러 개를 실어 한 번의 이동 동안 함께 검증하는 셈입니다.

한편 추측 디코딩은 이전 토큰의 연산 결과를 재사용하는 KV 캐시를 대체하는 기법이 아니며 함께 동작하는데, 기본 추론 과정과 캐시 구조는 「Day3: LLM 추론 구조와 KV Cache」 글에서 확인할 수 있습니다.

언제 효과가 없습니까?

한 줄 답: 동시 요청 묶음(배치)이 커서 이미 연산 장치가 가득 차 있거나(compute bound), 초안 모델이 자주 틀려 수락률이 낮을 때는 효과가 줄어듭니다.

추측 디코딩이 항상 빠른 것은 아니며, 다음과 같은 상황에서는 이득이 줄어듭니다.

  • 배치가 클 때(compute bound): 동시에 처리하는 요청 묶음이 커지면 GPU 연산 장치가 이미 가득 차서 돌아가므로, 검증에 쓸 남는 연산 여유가 사라져 속도 이득이 줄어듭니다.
  • 초안이 자주 틀릴 때(낮은 수락률): 초안 모델이 제안한 토큰이 큰 모델의 검증에서 자주 거절되면, 거절된 토큰을 만들고 검증한 계산이 그대로 낭비가 됩니다.

이러한 특성 때문에 서빙 엔진에는 배치 크기에 따라 추측 디코딩을 끄는 설정이 존재합니다.

결과 품질은 바뀝니까?

한 줄 답: 원래 추측 디코딩 기법의 설계 기준으로는 큰 모델의 출력 분포를 그대로 따르도록 검증 규칙이 구성되어 있어 결과 품질이 유지됩니다.

작은 초안 모델이 먼저 단어를 고른다고 해서 최종 답변 수준이 작은 모델 수준으로 떨어지지는 않습니다. 원래 추측 디코딩 기법의 설계에서는 큰 모델이 모든 초안 토큰을 직접 검증하고 거절된 위치에서도 큰 모델의 분포를 따라 토큰을 채우도록 검증·수락 규칙이 짜여 있기 때문에, 모든 개별 구현체에 대해 절대적인 보장을 단정할 수는 없으나 원래 설계 기준으로는 큰 모델이 직접 생성했을 출력 분포를 따르게 됩니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 샘플링 설정, 모델 양자화, 로컬 추론 프레임워크와 추측 디코딩의 관계를 정리했습니다.

temperature·top-p와는 어떤 관계입니까?

샘플링 방식과 설정은 그대로 유지되며 검증 단계에서도 해당 분포를 반영합니다. 샘플링 개념 자체는 「Temperature와 Top-p란? LLM 답변이 매번 달라지는 이유」 글에서 다룹니다.

양자화와는 무엇이 다릅니까?

양자화는 모델 가중치 자체를 작게 줄이는 기법이고 추측 디코딩은 모델을 실행하는 방식을 바꾸는 기법이므로 일반적으로 두 기법을 함께 조합해 쓸 수 있습니다. 양자화의 개념은 「양자화(Quantization)란?」 글을 참고할 수 있습니다.

로컬에서도 쓸 수 있습니까?

일부 추론 프레임워크에서 추측 디코딩 기능을 지원하므로 로컬 환경에서도 사용할 수 있습니다. 추론 프레임워크의 기본 개념은 「LLM 추론 프레임워크란?」 글에 정리되어 있습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: LLM 추론 구조, 샘플링, 모델 경량화 및 하드웨어 차이를 다룬 관련 글입니다.

출처는 어디입니까?

한 줄 답: 본문 작성에 참고한 원문 글과 커뮤니티 토론 출처입니다.

링 버퍼(원형 버퍼, circular buffer)는 임베디드 면접에서 UART 수신 버퍼를 어떻게 구현할지 물을 때 단골로 등장하는 자료구조입니다. 이 글에서는 리눅스 커널 공식 문서의 원형 버퍼 설명과 gcc (Debian 14.2.0-19) 14.2.0으로 확인한 C 예제 코드를 바탕으로 head·tail 이동 원리, 가득 참 판정과 빔 판정 방법, 인터럽트 서비스 루틴(ISR)과 함께 사용할 때의 주의점을 정리합니다.

핵심 요약

링 버퍼는 고정 크기 배열의 끝과 처음을 이어 붙인 것처럼 쓰는 큐입니다. 쓰는 위치(head)와 읽는 위치(tail) 두 개만 움직이고, 끝에 닿으면 0으로 돌아갑니다. head == tail이면 비어 있고, 한 칸을 비워 두는 방식에서는 head 다음 칸이 tail이면 가득 찬 것이라 크기 8이면 7개까지 들어갑니다. UART 수신 인터럽트(생산자)와 메인 루프(소비자)가 하나씩일 때 락 없이 쓰기 좋습니다.

링 버퍼란 무엇입니까?

한 줄 답: 링 버퍼는 고정 크기 배열의 끝과 시작을 이어 붙여, 데이터를 앞으로 당기지 않고 쓰기 위치(head)와 읽기 위치(tail) 인덱스만 이동하는 큐 구조입니다.

일반적인 선형 배열 큐에서 앞쪽 데이터를 꺼낸 뒤 나머지 원소들을 매번 앞으로 한 칸씩 당기면 불필요한 복사 작업이 발생합니다. 반면 링 버퍼(원형 버퍼, circular buffer)는 배열의 원소를 제자리에 그대로 둔 채 두 개의 인덱스만 전진시킵니다.

  • head: 생산자(producer)가 새 항목을 넣는(쓰는) 위치입니다.
  • tail: 소비자(consumer)가 항목을 꺼내는(읽는) 위치입니다.

면접에서 한 문장으로 답할 때는 "고정 크기 배열의 끝을 지날 때 0번 인덱스로 되돌아가며, 데이터 이동 없이 head와 tail 인덱스만 갱신하는 FIFO 큐입니다"라고 정리할 수 있습니다.

head와 tail은 어떻게 움직입니까?

한 줄 답: 데이터를 넣을 때 head를, 꺼낼 때 tail을 한 칸씩 전진시키며, 버퍼 크기가 2의 거듭제곱이면 나머지 연산 대신 비트 AND 연산(& (SIZE - 1))으로 끝에서 0번 칸으로 되돌립니다.

리눅스 커널의 원형 버퍼 문서에 따르면, 버퍼 크기를 2의 거듭제곱으로 잡으면 인덱스가 배열 끝을 넘어갈 때 나눗셈이 수반되는 나머지 연산 대신 비트 AND 연산(& (SIZE - 1))으로 감싸기(wrap)를 처리할 수 있습니다.

아래 ringbuf.c 예제는 동시성 처리가 전혀 없는 순수 개념 확인용 코드입니다. x86_64 환경의 gcc (Debian 14.2.0-19) 14.2.0에서 gcc -std=c11 -Wall -Wextra ringbuf.c -o ringbuf 명령으로 경고 없이 컴파일하고 실행한 결과입니다.

ringbuf.c:

/* 링 버퍼: 크기 8(2의 거듭제곱), "한 칸 비워 두기" 방식. 단일 생산자/단일 소비자 가정, 동시성 처리 없음(개념 확인용) */
#include <stdio.h>
#include <stdint.h>
#define SIZE 8u
#define MASK (SIZE - 1u)
static uint8_t buf[SIZE];
static unsigned head = 0, tail = 0;            /* head: 쓰기 위치, tail: 읽기 위치 */
static int is_empty(void) { return head == tail; }
static int is_full(void)  { return ((head + 1u) & MASK) == tail; }
static int put(uint8_t c) { if (is_full()) return 0; buf[head] = c; head = (head + 1u) & MASK; return 1; }
static int get(uint8_t *c) { if (is_empty()) return 0; *c = buf[tail]; tail = (tail + 1u) & MASK; return 1; }
int main(void) {
    int n = 0;
    for (uint8_t c = 'A'; c <= 'J'; c++) { if (put(c)) n++; else { printf("put('%c') failed: full\n", c); break; } }
    printf("stored=%d (SIZE=%u, one slot always empty)\n", n, SIZE);
    uint8_t c; printf("read 3: ");
    for (int i = 0; i < 3 && get(&c); i++) printf("%c", c);
    printf("\n");
    put('X'); put('Y'); put('Z');                 /* 끝을 지나 0번 칸으로 돌아감 */
    printf("head=%u tail=%u (head wrapped)\n", head, tail);
    printf("read all: ");
    while (get(&c)) printf("%c", c);
    printf("\nempty=%d\n", is_empty());
    return 0;
}

실행 출력:

put('H') failed: full
stored=7 (SIZE=8, one slot always empty)
read 3: ABC
head=2 tail=3 (head wrapped)
read all: DEFGXYZ
empty=1

코드와 출력의 흐름을 단계별로 따라가면 다음과 같습니다.

  1. 'A'부터 'G'까지 7개의 문자가 들어가고, 'H'를 넣으려 할 때는 head 다음 칸이 tail과 같아져 가득 참으로 판정되므로 거절됩니다(stored=7).
  2. 3개를 읽으면 ABC가 순서대로 나오고 tail은 3으로 이동합니다.
  3. 이어서 'X', 'Y', 'Z'를 넣으면 head가 배열 끝을 지나 다시 0, 1, 2번 칸으로 돌아가므로 head=2가 되어 tail=3보다 작은 값을 가리킵니다(head wrapped).
  4. 남은 데이터를 모두 읽으면 넣은 순서 그대로 DEFGXYZ가 출력되고, 다시 head == tail 상태가 되어 버퍼가 비게 됩니다(empty=1).

가득 참과 빔은 어떻게 구분합니까?

한 줄 답: head == tail을 비어 있음으로 두고 한 칸을 항상 비워 두어 ((head + 1) & MASK) == tail로 가득 참 판정을 하거나, 별도의 count 변수를 두어 구분합니다.

만약 배열의 모든 칸을 끝까지 다 채우면 가득 찬 상태에서도 head == tail이 되어 비어 있는 상태와 구분할 수 없게 됩니다. 이를 해결할 때는 대표적으로 두 가지 방식을 사용합니다.

  • 한 칸 비워 두기 방식: 리눅스 커널 원형 버퍼 문서와 위 예제에서 사용한 방식으로, head == tail이면 비어 있음(empty)이고 head가 tail 바로 한 칸 뒤(즉 head의 다음 칸이 tail)이면 가득 참(full)으로 판정합니다. 항상 한 칸을 비워 두므로 크기 8인 버퍼에는 최대 7개까지 저장됩니다.
  • 별도의 count 변수 두기 방식: 현재 저장된 원소 개수를 세는 변수를 따로 두어 배열의 모든 칸을 다 쓰는 방식입니다. 단, 생산자와 소비자가 모두 동일한 count 변수를 수정하므로 ISR과 함께 쓸 때는 별도의 동기화 주의가 필요합니다.
구분한 칸 비워 두기 방식별도 count 변수 방식
비어 있음(Empty) 판정head == tailcount == 0
가득 참(Full) 판정((head + 1) & MASK) == tail (다음 칸이 tail)count == SIZE
사용 가능한 슬롯항상 한 칸을 비워 둠 (SIZE - 1칸 사용)모든 슬롯 사용 가능 (SIZE칸 모두 사용)
ISR 동시성 특성생산자는 head만, 소비자는 tail만 수정함count 변수를 양쪽이 함께 수정하므로 추가 주의가 필요함

UART 인터럽트와 같이 쓸 때 무엇을 조심합니까?

한 줄 답: 단일 생산자(ISR)와 단일 소비자(메인 루프) 조건을 지키면서 ISR은 head만, 메인 루프는 tail만 수정하고, 데이터를 먼저 쓴 뒤 인덱스를 갱신하며, 버퍼가 가득 찼을 때의 정책을 정해야 합니다.

임베디드 시스템에서 수신 대기 방식을 고를 때의 기준은 인터럽트와 폴링, 임베디드 면접에서 어떻게 고르나? 글에서 다룬 것처럼 상황마다 다르며, 직렬 통신 자체의 특징 비교는 I2C vs SPI vs UART 차이: 임베디드 면접에서 어떻게 비교하나? 글에서 확인할 수 있습니다. UART 수신 인터럽트(ISR)가 바이트를 받아 링 버퍼에 넣고 메인 루프가 꺼내 가는 구조를 설계할 때는 다음 원칙을 지켜야 합니다.

  • 단일 생산자·단일 소비자 조건 명시: 리눅스 커널 문서에 따르면 생산자와 소비자가 각각 정확히 하나씩일 때만 메모리 배리어와 순서가 보장된 인덱스 접근(커널 문서의 smp_store_release / READ_ONCE 형태 접근)을 통해 락 없이 사용할 수 있습니다. 따라서 면접에서는 "언제나 락 없이 안전하다"고 단정하지 말고 단일 생산자·단일 소비자 조건을 분명히 밝혀야 합니다.
  • 수정하는 인덱스 분리: 생산자인 ISR은 오직 head만 수정하고, 소비자인 메인 루프는 오직 tail만 수정합니다.
  • 데이터를 먼저 쓰고 인덱스는 나중에 갱신: 생산자는 버퍼 슬롯에 데이터를 먼저 기록한 뒤에 head를 갱신해야 소비자가 아직 쓰이지 않은 쓰레기 값을 읽지 않습니다.
  • 상대방 인덱스 읽기 시 컴파일러 캐싱 방지: 한쪽이 다른 쪽의 인덱스를 반복해서 읽을 때 컴파일러가 레지스터에 값을 캐시해 두지 않도록 C에서는 volatile이나 원자적 접근을 사용하고, C++에서는 원자적 타입을 활용합니다(C++의 원자적 연산과 데이터 레이스 방지는 C++ std::atomic, 데이터 레이스를 막는 법 글을 참고할 수 있습니다).
  • 가득 찼을 때의 처리 정책 결정: 소비자가 제때 읽지 못해 버퍼가 가득 찼을 때 새로 들어온 데이터를 버릴지(drop), 아니면 가장 오래된 데이터를 덮어쓸지(overwrite) 시스템 요구사항에 맞춰 정해야 합니다. 수신 처리 지연으로 하드웨어 수신 버퍼 자체가 넘치는 현상은 mcp251xfd FIFO overflow, can0에서 RX가 떨어질 때 먼저 볼 것 글의 사례에서도 볼 수 있습니다.

면접 꼬리 질문은 무엇입니까?

한 줄 답: 생산자가 여럿일 때의 동기화 방법, 버퍼 크기를 2의 거듭제곱으로 맞추는 이유, 그리고 DMA 수신과 결합할 때의 인덱스 관리 차이를 자주 묻습니다.

생산자가 여럿이면 어떻게 합니까?

생산자가 둘 이상이면 여러 실행 흐름이 동시에 head를 읽고 수정하려다 충돌하므로, 락을 사용해 생산자들의 접근을 직렬화(serialize)해야 합니다. 임베디드·커널 환경에서 스핀락과 뮤텍스를 구분하는 기준은 스핀락은 뮤텍스 대신 언제 쓰나? 임베디드·커널 면접 기준 글에서 확인할 수 있습니다.

크기를 2의 거듭제곱으로 하는 이유는 무엇입니까?

버퍼 크기가 2의 거듭제곱이면 인덱스가 끝에 도달해 0번으로 돌아갈 때 나눗셈이 수반되는 나머지 연산 대신 비트 AND 연산(& (SIZE - 1))으로 감싸기(wrap)를 처리할 수 있기 때문입니다.

DMA와 같이 쓸 때는 무엇이 다릅니까?

ISR이 바이트마다 깨어나 head를 직접 올리는 대신 DMA 컨트롤러가 생산자가 되어 메모리의 원형 버퍼 영역에 직접 데이터를 기록하며, 소프트웨어는 하드웨어가 가리키는 전송 위치(또는 남은 카운트)를 읽어 어디까지 새 데이터가 들어왔는지 파악합니다. CPU 복사와 DMA의 기본 차이는 DMA란? CPU 복사와 차이, 임베디드·펌웨어 면접에서 어떻게 답하나? 글에서 다룹니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 인터럽트와 폴링, UART 통신 비교, DMA, 스핀락과 뮤텍스, 하드웨어 FIFO 오버플로, C++ 원자적 연산을 다룬 관련 글입니다.

출처는 어디입니까?

한 줄 답: 리눅스 커널 공식 문서의 원형 버퍼 가이드와 직접 실행한 GCC 결과입니다.

+ Recent posts