Devin.KR

DMA - CPU 없이 데이터 옮기기

개발자KR 조회 7

이 장에서 배우는 것

앞 장에서 인터럽트 서비스 루틴(ISR)과 메인 루프가 데이터를 주고받는 방법을 살펴보았다. 그러나 데이터를 안전하게 공유하는 일과 데이터를 효율적으로 옮기는 일은 서로 다른 문제다. 센서에서 값 하나가 도착할 때마다 CPU가 수신 레지스터를 읽고 배열에 저장한다면, 공유 규칙을 잘 지켜도 CPU가 반복 작업에 묶인다.

직접 메모리 접근(DMA)은 주변 장치와 메모리 사이의 반복 전송을 담당한다. CPU는 전송 위치와 길이를 설정하고, 일정한 양이 모였을 때 결과를 처리한다. 이 장에서는 컨베이어의 진동 센서 데이터를 두 버퍼로 수집하면서 전송 모드, 버퍼 소유권, 캐시 일관성을 함께 살펴본다. 제목의 ‘CPU 없이’는 개별 데이터의 이동을 뜻한다. 전송 준비와 결과 해석, 오류 처리는 여전히 CPU의 일이다.

  • 단발 전송, 순환 전송, 더블 버퍼의 차이를 설명한다.
  • DMA가 쓰는 버퍼와 CPU가 읽는 버퍼를 구분하고 재사용 시점을 정한다.
  • 캐시의 정리와 무효화가 각각 어떤 문제를 해결하는지 구분한다.
  • PC 모의 실행으로 블록 전송과 처리의 겹침을 관찰하고 실제 장치에 적용할 조건을 정리한다.

문제 상황

컨베이어 모터 옆에 진동 센서를 붙였다고 가정한다. 센서는 통신 주변 장치를 통해 일정한 간격으로 측정값을 보낸다. 처음에는 수신 인터럽트마다 값 하나를 배열에 넣었다. 이후 측정 주기가 짧아지고 다른 센서까지 추가되자 인터럽트 진입과 복귀, 배열 위치 갱신이 계속 반복된다. 각 작업은 짧지만 모두 합치면 제어 프로그램이 사용할 시간이 줄어든다.

여기서 DMA를 사용하면 주변 장치의 수신 요청에 맞춰 값을 배열에 저장할 수 있다. CPU는 값마다 개입하지 않고, 예를 들어 측정값 64개가 모였을 때 한 번씩 처리한다. 다만 블록을 크게 만들수록 첫 번째 값이 처리될 때까지 기다리는 시간도 길어진다. 인터럽트 횟수를 줄이는 목표와 측정 결과를 빨리 사용하는 목표를 함께 고려해야 한다.

수신 배열을 하나만 두면 다음 문제가 생긴다. CPU가 완성된 배열에서 평균을 계산하는 동안 DMA가 같은 배열의 앞부분부터 다시 쓴다. CPU가 읽은 앞부분은 새 측정값이고 뒷부분은 이전 측정값일 수 있다. 전송 완료 알림을 받았다는 사실만으로 그 배열이 계속 보존되는 것은 아니다.

캐시가 있는 MCU에서는 문제가 하나 더 생긴다. DMA가 메모리에 새 값을 써도 CPU가 캐시에 남은 이전 값을 읽을 수 있다. 배열 주소가 같고 전송 완료도 확인했는데 값은 오래된 상태다. 따라서 DMA 도입은 복사 코드를 지우는 작업으로 끝나지 않는다. 전송 단위, 버퍼 재사용 규칙, CPU가 값을 보는 경로까지 함께 설계해야 한다.

전송 모드는 주소와 종료 조건으로 이해한다

DMA 설정의 출발점은 출발지 주소, 목적지 주소, 전송 폭, 전송 횟수다. 여기에 요청을 내는 주변 장치와 주소 증가 여부가 붙는다. 센서 수신에서는 주변 장치 데이터 레지스터의 주소를 고정하고, 메모리 주소를 전송할 때마다 증가시키는 구성이 흔하다. 반대로 배열을 통신 장치로 송신할 때는 메모리 쪽 주소를 증가시키고 주변 장치 쪽 주소를 고정한다.

전송 횟수의 단위를 바이트라고 짐작해서는 안 된다. 어떤 DMA는 설정한 전송 폭을 기준으로 횟수를 센다. 전송 폭이 16비트이고 횟수가 64라면 메모리에서 차지하는 크기는 보통 128바이트가 된다. 출발지와 목적지의 폭이 다를 때 패킹을 지원하는지, 주소 정렬에 어떤 제한이 있는지는 해당 장치의 규칙을 확인해야 한다.

전송을 끝내거나 이어 가는 방식에 따른 차이
방식지정 길이 이후주요 용도주의점
단발 전송전송을 멈춘다정해진 길이의 명령 송신, 한 묶음 수집다음 전송을 다시 준비해야 한다
순환 전송설정한 시작 위치로 돌아간다연속 샘플 수집처리가 늦으면 읽지 않은 구간을 덮어쓴다
더블 버퍼다른 버퍼로 전송 대상을 바꾼다수집과 블록 처리를 겹친다지원 방식과 버퍼 교체 규칙이 장치마다 다르다

더블 버퍼(double buffer)는 두 저장 영역을 번갈아 사용하는 구조다. 모든 DMA가 이를 독립적인 하드웨어 모드로 제공하는 것은 아니다. 메모리 주소 두 개를 등록하는 장치도 있고, 전송 기술자를 이어 붙이는 장치도 있다. 두 블록 크기의 순환 배열을 만들고 절반 완료와 전체 완료 알림으로 각 구간을 처리하는 방법도 있다.

순환 배열의 절반 완료 알림은 앞쪽 절반이 채워졌다는 뜻이다. CPU가 그 구간을 처리하는 동안 DMA는 뒤쪽 절반을 채운다. 전체 완료 뒤에는 반대가 된다. 이 방식에서도 CPU에 주어진 시간은 다른 절반이 채워지는 동안이다. 완료 알림은 처리할 기회를 알려 줄 뿐, 해당 구간의 재사용을 자동으로 막지 않는다.

전송 폭과 버스트 전송(burst transfer)도 구분해야 한다. 전송 폭은 항목 하나를 옮기는 크기이고, 버스트는 버스 사용권을 얻었을 때 여러 전송을 묶어 진행하는 방식이다. 큰 버스트가 항상 좋은 설정은 아니다. 다른 버스 사용자의 대기 시간과 주변 장치의 요청 특성까지 영향을 받는다. 이 장의 모의 실행에서는 한 번의 진행 호출이 항목 하나를 옮긴다.

더블 버퍼는 두 배열보다 소유권 규칙이 중요하다

컨베이어 수집기는 A와 B라는 두 버퍼를 둔다. 처음에는 두 버퍼 모두 DMA에 제공한다. DMA가 A를 채우면 A를 처리 대기 상태로 넘기고 B에 쓰기 시작한다. CPU는 A를 읽은 다음 다시 DMA가 사용할 수 있게 준비한다. B가 채워질 때까지 A의 처리가 끝나야 다음 교대가 이어진다.

여기서 소유권은 지금 그 메모리를 누가 사용해도 되는지를 나타내는 규칙이다. DMA 소유 상태에는 실제 전송 중인 버퍼와 다음 전송을 위해 예약된 버퍼가 모두 포함된다. CPU는 두 경우 모두 내용을 바꾸지 않는다. 처리 대기 상태는 DMA 쓰기가 끝났지만 CPU가 아직 처리에 들어가지 않은 상태다.

DMA가 한 버퍼를 채우는 동안 CPU는 다른 버퍼를 처리하고 다음 교대 전에 반환한다

CPU가 처리를 마쳤다는 기준도 분명해야 한다. 합계 계산을 끝냈더라도 다른 함수가 그 버퍼의 포인터를 보관하고 나중에 읽는다면 반환할 수 없다. DMA에 돌려준 뒤에는 같은 주소에 다음 데이터가 들어온다. 장기간 보관해야 하는 결과는 별도 저장소로 옮기거나, 필요한 요약값만 남긴다.

더블 버퍼는 처리 지연을 무제한으로 흡수하지 않는다. 버퍼 하나가 채워지는 시간이 4밀리초라면, 직전에 완성된 버퍼를 다음 재사용 전에 반환해야 한다. 이 시간에는 작업 시작을 기다리는 시간과 실제 처리 시간이 모두 포함된다. 평균적으로 빠르다는 이유만으로 충분하다고 판단할 수 없다.

예제는 다음 버퍼가 반환되지 않았다면 오류를 보고하고 멈춘다. 이는 소유권 위반을 관찰하기 위한 정책이다. 실제 순환 DMA는 CPU의 소유권 변수를 읽지 않으므로 계속 덮어쓸 수 있다. 실제 제품에서는 블록 번호, 누락 횟수, 전송 오류 상태 등을 이용해 손실을 검출하고 센서 특성에 맞는 중단 또는 폐기 정책을 정해야 한다.

완료 알림과 데이터 저장소도 구별한다. 알림은 어떤 버퍼를 확인해야 하는지 알려 주며, 측정값 자체는 버퍼에 있다. 같은 완료 비트가 반복 설정되면 여러 사건이 하나로 합쳐질 수 있다. 따라서 알림 비트만으로 처리되지 않은 블록의 개수를 보존하려 해서는 안 된다.

캐시 일관성은 CPU와 DMA가 같은 값을 보게 하는 일이다

캐시(cache)가 있는 시스템에서 CPU의 읽기와 쓰기는 메모리에 곧바로 도달하지 않을 수 있다. 반면 DMA는 CPU의 데이터 캐시를 거치지 않고 메모리에 접근하는 구성이 많다. 이런 비일관성 시스템에서는 같은 주소를 사용해도 CPU가 보는 값과 DMA가 보는 값이 달라질 수 있다. 하드웨어가 일관성을 보장하거나 해당 영역이 캐시를 사용하지 않는 경우에는 조건이 달라진다.

CPU가 바꾼 내용이 아직 메모리에 반영되지 않은 캐시 라인을 더티 상태라고 한다. 정리(clean)는 그 변경 내용을 메모리로 내보낸다. 무효화(invalidate)는 캐시에 있는 복사본을 더 이상 유효하지 않다고 표시한다. 두 연산은 방향과 목적이 다르며 서로 대신할 수 없다.

정리는 CPU의 변경을 메모리로 내보내고 무효화는 DMA 전송 뒤 CPU가 메모리의 새 값을 다시 읽게 한다

송신에서는 CPU가 만든 배열을 DMA가 읽는다. CPU가 배열을 쓴 뒤 필요한 캐시 정리를 완료하고 DMA를 시작해야 한다. 수신에서는 DMA가 메모리를 쓰고 CPU가 읽는다. 전송 완료를 확인한 뒤 필요한 무효화를 거쳐야 CPU가 새 값을 읽을 수 있다. C의 volatile은 이 두 작업을 수행하지 않는다.

수신 준비 단계에도 주의가 필요하다. 이전 CPU 작업에서 남은 더티 라인이 DMA 수신 도중 메모리로 밀려나면 새 수신값을 덮을 수 있다. 이 장의 모델은 소유권을 넘기기 전에 정리하고 무효화하며, 전송 완료 뒤 읽기 전에도 무효화한다. 실제 장치에서는 버퍼 배치와 메모리 속성에 맞춰 제조사가 요구하는 연산 및 장벽 순서를 적용한다.

캐시 유지보수는 보통 캐시 라인 단위다. 수신 배열의 양 끝이 다른 변수와 한 라인을 공유하면 배열만 대상으로 삼으려던 무효화가 이웃 변수에 영향을 줄 수 있다. 따라서 버퍼 시작 주소뿐 아니라 할당 크기와 끝 경계도 고려한다. 유지보수 범위를 라인 경계로 넓힐 때 포함되는 메모리 전체를 안전하게 관리할 수 있어야 한다.

아래 모델은 버퍼 하나를 독립적인 캐시 관리 단위로 간주한다. cache 배열은 CPU의 복사본이고 ram 배열은 DMA가 접근하는 저장소다. 실제 PC 캐시를 제어하지 않으며, CPU가 메모리에 접근할 때 모델 함수를 거치도록 해서 차이를 드러낸다. 일반 PC에서 스레드 둘을 만들어 배열을 공유하는 것만으로는 이런 비일관성 DMA를 충실하게 재현할 수 없다.

완성 코드

파일 이름은 dma_demo.c다. hal_sim 계층은 주변 장치에서 온 값을 메모리에 쓰고 완료 사건을 발생시킨다. 간단한 협동형 스케줄러(cooperative scheduler) 모의 실행은 3틱마다 처리 함수를 호출한다. 틱(tick)은 여기서 논리적인 진행 단위이며 실제 밀리초를 뜻하지 않는다.

한 버퍼에는 값 네 개가 들어간다. DMA는 매 틱 한 값을 옮기므로 네 틱마다 한 블록이 완성된다. 네 블록을 수집한 뒤 전송을 끝내고 마지막 처리까지 진행한다. 이 프로그램은 실행 순서를 일정하게 유지하려고 단일 스레드에서 사건을 차례로 호출한다. 컴파일 옵션에 -pthread를 사용하지만 스레드는 만들지 않는다.

#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

enum {
    BUFFER_COUNT = 2,
    SAMPLE_COUNT = 4,
    BLOCK_LIMIT = 4,
    TASK_PERIOD = 3,
    RUN_TICKS = 18
};

typedef enum {
    OWNER_CPU,
    OWNER_DMA,
    OWNER_READY
} Owner;

typedef struct {
    unsigned ram[SAMPLE_COUNT];
    unsigned cache[SAMPLE_COUNT];
    bool valid;
    bool dirty;
    Owner owner;
} Buffer;

typedef struct {
    unsigned active;
    unsigned offset;
    unsigned completed;
    unsigned next_sample;
    bool running;
} DmaSim;

static Buffer buffers[BUFFER_COUNT];
static DmaSim dma;
static unsigned processed;
static unsigned total;

static void cache_clean(Buffer *buffer)
{
    if (buffer->dirty) {
        assert(buffer->valid);
        memcpy(buffer->ram, buffer->cache, sizeof buffer->ram);
        buffer->dirty = false;
    }
}

static void cache_invalidate(Buffer *buffer)
{
    assert(!buffer->dirty);
    buffer->valid = false;
}

static unsigned cpu_read(Buffer *buffer, unsigned index)
{
    assert(buffer->owner == OWNER_CPU);
    assert(index < SAMPLE_COUNT);

    if (!buffer->valid) {
        memcpy(buffer->cache, buffer->ram, sizeof buffer->cache);
        buffer->valid = true;
    }
    return buffer->cache[index];
}

static void prepare_rx(Buffer *buffer)
{
    assert(buffer->owner == OWNER_CPU);
    cache_clean(buffer);
    cache_invalidate(buffer);
    buffer->owner = OWNER_DMA;
}

static void hal_sim_init(void)
{
    for (unsigned i = 0; i < BUFFER_COUNT; ++i) {
        Buffer *buffer = &buffers[i];

        buffer->owner = OWNER_CPU;
        for (unsigned j = 0; j < SAMPLE_COUNT; ++j) {
            buffer->cache[j] = 0;
        }
        buffer->valid = true;
        buffer->dirty = true;
        prepare_rx(buffer);
    }

    dma.active = 0;
    dma.offset = 0;
    dma.completed = 0;
    dma.next_sample = 100;
    dma.running = true;
    processed = 0;
    total = 0;
}

static void dma_done_isr(unsigned index)
{
    assert(buffers[index].owner == OWNER_DMA);
    buffers[index].owner = OWNER_READY;
}

static bool hal_sim_dma_step(unsigned tick)
{
    if (!dma.running) {
        return true;
    }

    Buffer *buffer = &buffers[dma.active];
    assert(buffer->owner == OWNER_DMA);
    buffer->ram[dma.offset] = dma.next_sample;
    ++dma.next_sample;
    ++dma.offset;

    if (dma.offset != SAMPLE_COUNT) {
        return true;
    }

    unsigned finished = dma.active;
    ++dma.completed;
    dma_done_isr(finished);
    printf("DMA tick=%u done=%c\n", tick, 'A' + (int)finished);

    if (dma.completed == BLOCK_LIMIT) {
        dma.running = false;
        return true;
    }

    unsigned next = finished ^ 1u;
    if (buffers[next].owner != OWNER_DMA) {
        fprintf(stderr, "overrun tick=%u next=%c\n",
                tick, 'A' + (int)next);
        dma.running = false;
        return false;
    }

    dma.active = next;
    dma.offset = 0;
    return true;
}

static void process_ready(unsigned tick)
{
    for (unsigned i = 0; i < BUFFER_COUNT; ++i) {
        Buffer *buffer = &buffers[i];
        if (buffer->owner != OWNER_READY) {
            continue;
        }

        buffer->owner = OWNER_CPU;
        cache_invalidate(buffer);

        unsigned values[SAMPLE_COUNT];
        unsigned sum = 0;
        for (unsigned j = 0; j < SAMPLE_COUNT; ++j) {
            values[j] = cpu_read(buffer, j);
            sum += values[j];
        }

        printf("CPU tick=%u buffer=%c values=%u,%u,%u,%u sum=%u\n",
               tick, 'A' + (int)i,
               values[0], values[1], values[2], values[3], sum);

        total += sum;
        ++processed;
        prepare_rx(buffer);
    }
}

static void scheduler_step(unsigned tick)
{
    if (tick % TASK_PERIOD == 0) {
        process_ready(tick);
    }
}

int main(void)
{
    hal_sim_init();

    for (unsigned tick = 1; tick <= RUN_TICKS; ++tick) {
        if (!hal_sim_dma_step(tick)) {
            return 1;
        }
        scheduler_step(tick);
    }

    if (dma.completed != BLOCK_LIMIT || processed != BLOCK_LIMIT) {
        fprintf(stderr, "incomplete transfer\n");
        return 1;
    }

    printf("END blocks=%u total=%u\n", processed, total);
    return 0;
}

줄별 해설

상수 선언은 버퍼 개수, 블록 길이, 처리 주기를 한곳에 모은다. 이 예제의 버퍼 개수는 두 개로 고정되어 있다. 출력문도 값 네 개를 출력하도록 작성했으므로 SAMPLE_COUNT만 바꾸면 모든 부분이 함께 확장되는 범용 구현은 아니다.

Owner는 접근 권한을 나타낸다. OWNER_DMA는 DMA가 사용 중이거나 사용하도록 예약된 상태이고, OWNER_READY는 CPU 처리를 기다리는 상태다. Buffer의 두 배열은 실제 캐시 하드웨어를 흉내 내기 위한 것이다. MCU에 그대로 옮기면서 수신 배열을 두 벌씩 만들라는 뜻은 아니다.

cache_clean()의 조건문은 CPU의 변경이 있을 때만 메모리로 복사한다. 복사 뒤에는 dirty를 해제한다. cache_invalidate()는 복사하지 않고 유효 표시만 지운다. 더티 상태에서 이 함수를 호출하면 단언문이 실패한다. 더티 데이터를 어떻게 처리할지 결정하지 않은 채 버리는 실수를 모델 단계에서 드러내기 위한 제한이다.

cpu_read()는 CPU 소유권과 인덱스 범위를 확인한다. 유효한 복사본이 없으면 메모리에서 버퍼 전체를 가져온다. 그 뒤의 읽기는 캐시 배열을 사용한다. 따라서 DMA가 ram만 바꾸고 캐시 유효 표시가 그대로 남아 있다면 이 함수는 이전 값을 반환할 수 있다.

prepare_rx()는 소유권을 넘기는 경계다. 정리, 무효화, 소유권 변경 순서로 진행한다. 초기화에서는 CPU가 캐시 배열에 0을 썼다고 모델링하므로 첫 호출의 정리가 실제로 복사를 수행한다. 블록 처리에서는 값만 읽으므로 이후 호출의 정리는 대개 아무것도 복사하지 않는다.

hal_sim_init()는 두 버퍼를 미리 제공한 뒤 A부터 시작하도록 설정한다. next_sample의 시작값은 100이다. 실제 센서의 통신 형식이나 측정 단위를 끌어들이지 않고, 블록 경계와 값의 연속성을 쉽게 확인하기 위한 입력이다.

dma_done_isr()는 해당 버퍼를 처리 대기로 바꾸는 일만 한다. 실제 장치에서는 완료 원인 확인과 인터럽트 상태 해제도 필요하다. 여기서는 완료 사건을 소프트웨어가 직접 만들기 때문에 그 레지스터 동작을 생략한다. 출력문은 이 함수 밖에 있으며 실제 ISR에서 콘솔 출력을 하도록 제안하는 코드가 아니다.

hal_sim_dma_step()의 첫 쓰기는 캐시를 거치지 않고 ram을 바꾼다. 전송 위치가 네 칸에 도달하면 완료 함수를 호출한다. 네 블록을 모두 채웠으면 멈추고, 그렇지 않으면 다음 버퍼의 소유권을 확인한다. finished ^ 1u는 두 인덱스 0과 1을 교환한다.

다음 버퍼 확인은 이 모델이 제공하는 오류 검출 장치다. 하드웨어 DMA가 C 열거형을 보고 기다려 준다는 뜻은 아니다. 또한 마지막 완료 뒤에는 이미 반환된 다른 버퍼가 DMA 예약 상태로 남을 수 있지만, running이 거짓이므로 더 이상 쓰지 않는다.

process_ready()는 처리 대기 버퍼만 골라 CPU 소유로 바꾼다. 무효화 후 네 값을 읽어 합계를 구하고, 출력과 누적을 마친 뒤 반환한다. 지역 배열 values에는 출력할 값이 복사되어 있다. 반환 이후 수신 버퍼의 포인터를 보관해 사용하는 경로는 없다.

scheduler_step()는 3틱마다 처리 함수를 호출한다. 같은 틱에서는 DMA 진행이 먼저이고 CPU 처리가 나중이다. 따라서 12틱에 완성된 A는 같은 틱에 처리되지만, 4틱에 완성된 A는 6틱까지 기다린다. 이 호출 순서가 예상 출력을 결정한다.

모든 호출이 단일 스레드에서 직렬로 실행되므로 공유 변수에 경쟁이 없다. 이 사실 때문에 일반 변수를 사용한 것이다. 실제 ISR과 태스크가 동시에 접근하는 코드로 옮길 때는 앞 장에서 다룬 공유 규칙을 적용해야 한다. 캐시 유지보수는 그 공유 규칙을 대신하지 않으며, 공유 규칙도 DMA의 캐시 문제를 해결하지 않는다.

PC 모의 실행의 역할과 실제 FreeRTOS 환경의 대응
모의 실행FreeRTOS API 또는 장치 기능옮길 때의 의미
scheduler_step()xTaskCreate()로 만든 처리 태스크실제 실행 시점은 커널의 스케줄링에 따른다
완료 시 처리 대기 표시xTaskNotifyFromISR()eSetBits로 버퍼별 완료 비트를 알릴 수 있다
처리 대기 버퍼 확인xTaskNotifyWait()알림을 기다린 뒤 해당 버퍼의 처리 조건을 확인한다
hal_sim_dma_step(), 캐시 함수MCU의 DMA 및 캐시 제어 기능이에 직접 대응하는 FreeRTOS DMA·캐시 API는 없다

실제 알림 방식에서는 필요한 경우 ISR 종료 시 포트가 제공하는 전환 요청 절차를 따른다. 커널 API를 호출할 수 있는 인터럽트 우선순위 제한도 적용된다. 표의 대응은 역할을 비교한 것이며, 알림만 추가하면 소유권 관리까지 끝난다는 뜻은 아니다. 같은 버퍼의 완료 비트가 다시 오기 전에 이전 데이터를 처리한다는 조건은 별도로 지켜야 한다.

실행 결과

macOS와 Linux에서 다음 명령으로 빌드하고 실행한다. 프로그램은 C11 표준 기능만 사용하며, 이 구성에서 경고를 유발하는 사용하지 않는 변수나 형식 지정자 불일치는 없다.

cc -std=c11 -Wall -Wextra -pthread dma_demo.c -o dma_demo
./dma_demo

예상 표준 출력은 다음과 같다.

DMA tick=4 done=A
CPU tick=6 buffer=A values=100,101,102,103 sum=406
DMA tick=8 done=B
CPU tick=9 buffer=B values=104,105,106,107 sum=422
DMA tick=12 done=A
CPU tick=12 buffer=A values=108,109,110,111 sum=438
DMA tick=16 done=B
CPU tick=18 buffer=B values=112,113,114,115 sum=454
END blocks=4 total=1720

첫 A 블록을 처리하는 6틱에는 B에 이미 두 값이 들어가 있다. 첫 B 블록을 처리하는 9틱에는 다음 A 블록의 첫 값이 저장되어 있다. 한 블록의 수집 구간과 다른 블록의 처리 시점이 겹친다는 것을 확인할 수 있다. 다만 처리 함수 자체가 소비하는 시간은 이 모델에 들어 있지 않다. 이 출력으로 실제 MCU의 처리 성능이나 응답 시간을 보장할 수는 없다.

표준 오류 출력 없이 네 블록이 처리되고 총합이 1720이면 계획한 경로가 끝난다. 값의 연속성과 합계는 예제 입력의 누락이나 중복을 살펴보는 확인 수단이다. 실제 제품에서는 센서 데이터에 이런 규칙이 없을 수 있으므로 전송 길이, 블록 순번, 주변 장치 오류 상태 등 별도 관찰 정보를 둔다.

실무에서 자주 틀리는 것

전송 중인 버퍼를 먼저 읽는다

활성 버퍼 인덱스는 현재 쓰는 위치를 뜻한다. 이를 처리할 버퍼 인덱스로 사용하면 일부만 갱신된 블록을 읽을 수 있다. 다음 조각은 완성 코드 안에서 대체 관계를 설명하기 위한 것으로, 각각 독립 프로그램은 아니다.

틀린 코드는 현재 활성 버퍼의 메모리를 직접 읽는다.

unsigned value = buffers[dma.active].ram[0];
printf("%u\n", value);

고친 코드는 완료되어 CPU 소유로 넘어온 버퍼만 읽는다. 읽은 뒤 더 사용할 곳이 없을 때 반환한다.

Buffer *buffer = &buffers[0];
if (buffer->owner == OWNER_READY) {
    buffer->owner = OWNER_CPU;
    cache_invalidate(buffer);
    unsigned value = cpu_read(buffer, 0);
    printf("%u\n", value);
    prepare_rx(buffer);
}

완성 코드의 처리 함수는 이 규칙을 두 버퍼 모두에 적용한다. 단지 전송 위치를 잠깐 읽는 것으로는 충분하지 않다. 위치를 확인한 직후 DMA가 다음 위치로 진행할 수 있기 때문이다.

수신 뒤 정리와 무효화를 혼동한다

틀린 코드는 DMA가 새 값을 쓴 뒤 CPU 캐시를 메모리로 내보내려고 한다.

buffer->owner = OWNER_CPU;
cache_clean(buffer);
unsigned value = cpu_read(buffer, 0);

캐시가 깨끗하지만 오래된 상태라면 정리는 아무 효과가 없다. 더티 상태라면 이전 내용이 새 수신값을 덮을 수도 있다. 고친 코드는 수신 준비에서 더티 상태를 해소했다는 전제 아래 완료 뒤 무효화한다.

buffer->owner = OWNER_CPU;
cache_invalidate(buffer);
unsigned value = cpu_read(buffer, 0);

송신에서는 반대로 DMA가 읽기 전에 정리가 필요할 수 있다. ‘DMA를 썼으니 캐시 함수를 하나 호출한다’는 식으로 고르면 방향을 놓치기 쉽다. 누가 마지막으로 썼고 누가 다음에 읽는지를 먼저 적는다.

CPU 처리를 마치기 전에 버퍼를 반환한다

틀린 코드는 DMA 재사용을 허용한 다음 합계를 계산한다. 실제 장치에서는 반환 직후 쓰기가 시작될 수 있다.

prepare_rx(buffer);
unsigned sum = 0;
for (unsigned j = 0; j < SAMPLE_COUNT; ++j) {
    sum += cpu_read(buffer, j);
}

이 모델에서는 cpu_read()의 소유권 단언문이 실패한다. 고친 코드는 모든 읽기를 끝낸 후 반환한다.

unsigned sum = 0;
for (unsigned j = 0; j < SAMPLE_COUNT; ++j) {
    sum += cpu_read(buffer, j);
}
prepare_rx(buffer);

반환 후에는 지역 변수인 sum을 사용해도 된다. 반면 수신 버퍼 내부를 가리키는 포인터를 후속 작업에 넘겼다면 그 작업의 읽기까지 끝나야 반환할 수 있다.

읽지 않은 블록을 다음 전송으로 덮는다

틀린 코드는 다음 버퍼가 준비되었는지 확인하지 않고 전송 대상을 바꾼다.

unsigned next = finished ^ 1u;
dma.active = next;
dma.offset = 0;

고친 코드는 다음 버퍼의 반환 여부를 확인하고, 조건을 지키지 못하면 실패를 호출자에게 전달한다.

unsigned next = finished ^ 1u;
if (buffers[next].owner != OWNER_DMA) {
    fprintf(stderr, "overrun tick=%u next=%c\n",
            tick, 'A' + (int)next);
    dma.running = false;
    return false;
}
dma.active = next;
dma.offset = 0;

이 수정은 모의 실행의 검출 규칙이다. 실제 하드웨어에서 같은 정책을 구현하려면 버퍼 전환 전에 중단할 수 있는지, 중단 동안 주변 장치가 데이터를 유지할 수 있는지까지 확인해야 한다. DMA 정지가 센서 입력 정지를 뜻하지는 않는다.

한눈에 보기

DMA 설계에서 확인할 경계와 책임
항목확인할 질문이 장의 선택
전송 단위횟수는 바이트인가, 설정 폭의 항목인가논리적인 측정값 네 개를 한 블록으로 사용한다
전송 모드끝나면 멈추는가, 다른 영역으로 이어지는가두 버퍼를 교대하고 네 블록 뒤 멈춘다
소유권현재 누가 읽거나 쓸 수 있는가DMA, 처리 대기, CPU로 구분한다
반환 시점이 버퍼를 읽는 작업이 모두 끝났는가출력과 합계 계산을 마친 뒤 반환한다
캐시다음 사용자가 최신 값을 보게 했는가수신 전 정리·무효화, 완료 후 무효화를 모델링한다
지연과 손실다음 재사용 전에 처리가 끝나는가반환되지 않은 버퍼를 발견하면 실패한다

실제 MCU로 옮길 때는 DMA가 접근할 수 있는 메모리 영역인지도 확인한다. CPU가 읽고 쓸 수 있는 메모리가 모두 DMA에 연결되어 있는 것은 아니다. 버퍼의 수명은 전송 종료와 마지막 CPU 사용보다 길어야 한다. 함수가 반환하면서 사라지는 자동 저장 기간의 배열을 진행 중인 DMA에 남겨 두면 안 된다.

DMA는 데이터를 옮기지만 데이터의 의미까지 판단하지 않는다. 컨베이어 수집기에서는 이제 블록이 완성되는 시점과 처리 가능한 저장소가 분명해졌다. 이후 제어 동작을 설계할 때는 이렇게 확보한 입력을 바탕으로 장치의 현재 상황과 다음 행동을 구분할 수 있다.

연습 문제

  1. TASK_PERIOD를 7로 바꾸고 나머지는 유지한다. 처음 소유권 위반을 검출하는 틱과 반환되지 않은 버퍼를 구하라. 같은 틱에서 DMA 진행이 먼저라는 규칙을 적용하라.
  2. 실제 장치에서 16비트 측정값 32개를 한 버퍼에 저장한다. 측정값은 50마이크로초마다 하나씩 도착한다. 버퍼의 데이터 크기와 한 버퍼가 채워지는 시간을 구하라. 두 버퍼를 교대할 때 CPU의 대기 및 처리 시간과 어떤 관계가 필요한지 설명하라.
  3. 송신 배열의 CPU 캐시에는 새 명령이 있고 메모리에는 이전 명령이 있다. DMA 시작 전에 무효화만 하면 어떤 문제가 생기는가. 필요한 작업 순서를 적어라.
  4. 완성 코드의 처리 함수에서 값을 읽은 직후 캐시의 첫 값을 999로 바꾸고 더티 표시를 설정했다. 반환할 때 cache_clean()만 생략한다면 어떤 단언문이 실패하는가. 이 검사가 실제 수신 준비의 어떤 위험을 드러내는지 설명하라.

정답과 해설

  1. 12틱에 다음 버퍼 B가 반환되지 않았음을 검출한다. A는 4틱에 완성되고 7틱에 처리되어 반환된다. B는 8틱에 완성되지만 다음 처리 시점은 14틱이다. DMA는 12틱에 두 번째 A를 완성한 뒤 B로 전환하려 한다. 이때 B가 아직 처리 대기 상태이므로 실패한다. A 완료 로그를 출력한 다음 오류를 보고하며, 메인 함수는 14틱까지 진행하지 않는다.

  2. 데이터 크기는 64바이트이고 채우는 시간은 1600마이크로초, 즉 1.6밀리초다. 한 버퍼가 완성된 뒤 다른 버퍼가 채워지는 동안 해당 버퍼를 반환해야 한다. CPU의 작업 시작 대기, 처리, 필요한 캐시 유지보수를 합친 시간이 이 재사용 간격 안에 들어가야 한다. 실제 설계에서는 경계에 맞추기보다 여유를 둔다. 캐시 라인 정렬 때문에 실제 할당 크기는 데이터 크기보다 커질 수 있다.

  3. 새 명령이 메모리에 반영되지 않은 채 캐시 복사본만 버려지면 DMA는 이전 명령을 읽을 수 있다. CPU가 명령 작성을 끝내고, 해당 범위를 정리한 뒤, 장치가 요구하는 완료 보장 및 순서 제어를 거쳐 DMA를 시작한다. DMA가 읽는 동안에는 그 배열을 바꾸지 않는다. 더티 라인 무효화의 세부 동작에 기대어 송신 준비를 대신해서는 안 된다.

  4. prepare_rx() 안에서 호출하는 cache_invalidate()의 assert(!buffer->dirty)가 실패한다. CPU가 수정한 복사본을 어떻게 처리할지 결정하지 않은 채 수신 버퍼를 넘기려 했기 때문이다. 실제 비일관성 시스템에서는 남아 있는 더티 라인의 나중 쓰기가 DMA 수신값을 덮을 수 있다. 이 모델은 그 조건을 허용하지 않고 전송 준비 단계에서 드러낸다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.