인라인 어셈블리

인라인 어셈블리 (Inline Assembly)

Solidity 문장과 이더리움 가상 머신의 언어에 가까운 인라인 어셈블리를 섞을 수 있어요. 이는 더 세밀한 제어를 주며, 특히 언어를 향상시키기 위해 라이브러리를 작성하거나 가스 사용을 최적화할 때 유용해요. Solidity에서 인라인 어셈블리에 사용되는 언어는 Yul이라고 하며 자체 절에 문서화되어 있어요. 이 절은 인라인 어셈블리 코드가 주변 Solidity 코드와 어떻게 인터페이스하는지만 다루게 돼요.

출처: 문서

본문

Solidity 문장과 이더리움 가상 머신의 언어에 가까운 인라인 어셈블리를 섞을 수 있어요. 이는 더 세밀한 제어를 주며, 특히 언어를 향상시키기 위해 라이브러리를 작성하거나 가스 사용을 최적화할 때 유용해요.

Solidity에서 인라인 어셈블리에 사용되는 언어는 Yul이라고 하며 자체 절에 문서화되어 있어요. 이 절은 주변 Solidity 코드와 어떻게 인터페이스하는지만 다루게 돼요.

경고 (Warning)

인라인 어셈블리는 이더리움 가상 머신에 저수준으로 접근하는 방법이에요. 이것은 Solidity의 몇 가지 중요한 안전 기능과 검사를 우회해요. 필요한 작업에만, 그리고 그것을 자신 있게 사용할 수 있을 때만 사용해야 해요.

인라인 어셈블리 블록은 assembly { ... }로 표시되며, 중괄호 안의 코드는 Yul 언어의 코드예요. 인라인 어셈블리 코드는 아래에서 설명하듯 로컬 Solidity 변수에 접근할 수 있어요. 서로 다른 인라인 어셈블리 블록은 네임스페이스를 공유하지 않아요. 즉 다른 인라인 어셈블리 블록에 정의된 Yul 함수를 호출하거나 Yul 변수에 접근하는 것은 불가능해요.

예시 (Example)

다음 예시는 다른 컨트랙트의 코드에 접근해 그것을 bytes 변수로 로드하는 라이브러리 코드를 제공해요. 이것은 <address>.code를 사용해 "순수 Solidity"로도 가능해요. 그러나 여기서 요점은 재사용 가능한 어셈블리 라이브러리가 컴파일러 변경 없이 Solidity 언어를 향상시킬 수 있다는 것이에요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

library GetCode {
    // This will report a warning - `at` will be promoted to reserved keyword
    function at(address addr) public view returns (bytes memory code) {
        assembly {
            // retrieve the size of the code, this needs assembly
            let size := extcodesize(addr)
            // allocate output byte array - this could also be done without assembly
            // by using code = new bytes(size)
            code := mload(0x40)
            // new "memory end" including padding
            mstore(0x40, add(code, and(add(add(size, 0x20), 0x1f), not(0x1f))))
            // store length in memory
            mstore(code, size)
            // actually retrieve the code, this needs assembly
            extcodecopy(addr, add(code, 0x20), 0, size)
        }
    }
}

인라인 어셈블리는 최적화 프로그램이 효율적인 코드를 만들지 못하는 경우에도 유용해요. 예:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

library VectorSum {
    // This function is less efficient because the optimizer currently fails to
    // remove the bounds checks in array access.
    function sumSolidity(uint[] memory data) public pure returns (uint sum) {
        for (uint i = 0; i < data.length; ++i)
            sum += data[i];
    }

    // We know that we only access the array in bounds, so we can avoid the check.
    // 0x20 needs to be added to an array because the first slot contains the
    // array length.
    function sumAsm(uint[] memory data) public pure returns (uint sum) {
        for (uint i = 0; i < data.length; ++i) {
            assembly {
                sum := add(sum, mload(add(add(data, 0x20), mul(i, 0x20))))
            }
        }
    }

    // Same as above, but accomplish the entire code within inline assembly.
    function sumPureAsm(uint[] memory data) public pure returns (uint sum) {
        assembly {
            // Load the length (first 32 bytes)
            let len := mload(data)

            // Skip over the length field.
            //
            // Keep temporary variable so it can be incremented in place.
            //
            // NOTE: incrementing data would result in an unusable
            //       data variable after this assembly block
            let dataElementLocation := add(data, 0x20)

            // Iterate until the bound is not met.
            for
                { let end := add(dataElementLocation, mul(len, 0x20)) }
                lt(dataElementLocation, end)
                { dataElementLocation := add(dataElementLocation, 0x20) }
            {
                sum := add(sum, mload(dataElementLocation))
            }
        }
    }
}

외부 변수, 함수와 라이브러리에 대한 접근 (Access to External Variables, Functions and Libraries)

Solidity 변수와 다른 식별자에 그 이름을 사용해 접근할 수 있어요. 값 타입의 로컬 변수는 인라인 어셈블리에서 직접 사용할 수 있어요. 읽기와 할당 둘 다 가능해요.

메모리를 참조하는 로컬 변수는 메모리의 변수 주소로 평가되고, 값 자체가 아니에요. 그런 변수에도 할당할 수 있지만, 할당은 데이터가 아니라 포인터만 바꾸고, Solidity의 메모리 관리를 존중하는 것은 여러분의 책임이라는 점에 주의해요. Solidity에서의 규칙(Conventions in Solidity)을 참고해요.

마찬가지로, 정적 크기 calldata 배열이나 calldata 구조체를 참조하는 로컬 변수는 콜데이터의 변수 주소로 평가되고, 값 자체가 아니에요. 변수에 새 오프셋을 할당할 수도 있지만, 변수가 calldatasize()를 넘어가리라는 것을 확인하는 검증이 수행되지 않는다는 점에 주의해요.

외부 함수 포인터의 경우 주소와 함수 셀렉터는 x.address와 x.selector로 접근할 수 있어요. 셀렉터는 오른쪽 정렬된 4바이트로 구성돼요. 두 값 모두 할당될 수 있어요. 예:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.10 <0.9.0;

contract C {
    // Assigns a new selector and address to the return variable @fun
    function combineToFunctionPointer(address newAddress, uint newSelector) public pure returns (function() external fun) {
        assembly {
            fun.selector := newSelector
            fun.address  := newAddress
        }
    }
}

동적 calldata 배열의 경우, 그 calldata 오프셋(바이트)과 길이(요소 수)를 x.offset과 x.length로 접근할 수 있어요. 두 표현식 모두 할당될 수 있지만, 정적 경우와 마찬가지로 결과 데이터 영역이 calldatasize() 범위 안에 있다는 것을 확인하는 검증은 수행되지 않아요.

로컬 스토리지 변수나 상태 변수(트랜지언트 스토리지 포함)의 경우 단일 Yul 식별자로는 충분하지 않아요. 반드시 하나의 전체 스토리지 슬롯을 차지하지는 않기 때문이에요. 따라서 그것들의 "주소"는 슬롯과 그 슬롯 안의 바이트 오프셋으로 구성돼요. 변수 x가 가리키는 슬롯을 검색하려면 x.slot을, 바이트 오프셋을 검색하려면 x.offset을 사용해요. x 자체를 사용하면 에러가 나요. 로컬 스토리지 변수 포인터의 .slot 부분에는 할당할 수도 있어요. 이것들(구조체, 배열 또는 매핑)의 경우 .offset 부분은 항상 0이에요. 그러나 상태 변수의 .slot이나 .offset 부분에는 할당할 수 없어요.

로컬 Solidity 변수는 할당에 사용할 수 있어요. 예:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.28 <0.9.0;

// This will report a warning
contract C {
    bool transient a;
    uint b;
    function f(uint x) public returns (uint r) {
        assembly {
            // We ignore the storage slot offset, we know it is zero
            // in this special case.
            r := mul(x, sload(b.slot))
            tstore(a.slot, true)
        }
    }
}

경고 (Warning)

256비트보다 적게 걸치는 타입(예: uint64, address, bytes16)의 변수에 접근하면, 타입의 인코딩에 포함되지 않는 비트에 대해 어떤 가정도 할 수 없어요. 특히 그것들이 0이라고 가정하지 마세요. 안전하려면 중요한 맥락에서 사용하기 전에 항상 데이터를 적절히 지우세요: uint32 x = f(); assembly { x := and(x, 0xffffffff) /* now use x */ }. 부호 있는 타입을 정리하려면 signextend opcode를 사용할 수 있어요: assembly { signextend(<num_bytes_of_x_minus_one>, x) }

Solidity 0.6.0부터, 인라인 어셈블리 변수의 이름은 인라인 어셈블리 블록의 스코프에서 보이는 어떤 선언(변수, 컨트랙트, 함수 선언 포함)도 섀도잉해서는 안 돼요. Solidity 0.7.0부터, 인라인 어셈블리 블록 안에서 선언된 변수와 함수는 .을 포함할 수 없지만, 인라인 어셈블리 블록 밖에서 Solidity 변수에 접근하기 위해 .을 사용하는 것은 유효해요. 그러나 Yul-only 모드에서 Solidity를 사용하면 여전히 점을 사용하는 것이 유효해요.

피해야 할 것 (Things to Avoid)

인라인 어셈블리는 꽤 고수준으로 보일 수 있지만, 실제로는 극도로 저수준이에요. 함수 호출, 루프, if와 switch는 단순한 다시 쓰기 규칙으로 변환되고, 그 후에 어셈블러가 여러분을 위해 하는 유일한 일은 함수형 스타일 opcode를 재배열하고, 변수 접근을 위한 스택 높이를 세고, 어셈블리-로컬 변수의 스코프 끝에 도달하면 그 변수의 스택 슬롯을 제거하는 것이에요.

Solidity에서의 규칙 (Conventions in Solidity)

타입이 있는 변수의 값 (Values of Typed Variables)

EVM 어셈블리와 대조적으로, Solidity에는 256비트보다 좁은 타입(예: uint24)이 있어요. 효율성을 위해 대부분의 산술 연산은 타입이 256비트보다 짧을 수 있다는 사실을 무시하고, 상위 비트는 필요할 때, 즉 메모리에 쓰이기 직전이나 비교가 수행되기 직전에 정리돼요. 이는 그런 변수에 인라인 어셈블리 안에서 접근하면 상위 비트를 먼저 수동으로 정리해야 할 수도 있다는 뜻이에요.

메모리 관리 (Memory Management)

Solidity는 메모리를 다음과 같이 관리해요. 메모리의 위치 0x40에 "자유 메모리 포인터(free memory pointer)"가 있어요. 메모리를 할당하려면 이 포인터가 가리키는 곳부터 메모리를 사용하고 그것을 갱신해요. 메모리가 이전에 사용되지 않았다는 보장이 없으므로, 그 내용이 0 바이트라고 가정할 수 없어요. 할당된 메모리를 해제하거나 풀어주는 내장 메커니즘은 없어요. Solidity는 메모리의 값이 어떤 값의 배수에 정렬된 위치에 배치된다는 것을 보장하지도, 요구하지도 않아요.

위에서 설명한 과정을 따르는 메모리 할당에 사용할 수 있는 어셈블리 스니펫은 다음과 같아요:

open in Remix

function allocate(length) -> pos {
  pos := mload(0x40)
  mstore(0x40, add(pos, length))
}

메모리의 첫 64바이트는 단기 할당을 위한 "스크래치 공간"으로 사용될 수 있어요. 자유 메모리 포인터 뒤의 32바이트(즉 0x60에서 시작하는)는 영구적으로 0이 되도록 의도되고 빈 동적 메모리 배열의 초기 값으로 사용돼요. 이는 할당 가능한 메모리가 자유 메모리 포인터의 초기 값인 0x80에서 시작한다는 뜻이에요.

Solidity 메모리 배열의 요소는 항상 32바이트의 배수를 차지해요(bytes1[]의 경우에도 마찬가지지만, bytes와 string은 예외예요). 다차원 메모리 배열은 메모리 배열에 대한 포인터예요. 동적 배열의 길이는 배열의 첫 슬롯에 저장되고 뒤이어 배열 요소들이 와요.

경고 (Warning)

정적 크기 메모리 배열에는 길이 필드가 없지만, 정적·동적 크기 배열 사이의 더 나은 변환을 허용하기 위해 나중에 추가될 수 있어요. 그러니 이에 의존하지 마세요.

메모리 안전 (Memory Safety)

인라인 어셈블리를 사용하지 않으면, 컴파일러는 메모리가 항상 잘 정의된 상태를 유지한다고 신뢰할 수 있어요. 이는 Yul IR을 통한 새 코드 생성 파이프라인에 특히 중요해요. 이 코드 생성 경로는 스택-심도-과다 오류를 피하기 위해 로컬 변수를 스택에서 메모리로 옮기고, 메모리 사용에 대한 특정 가정을 신뢰할 수 있다면 추가 메모리 최적화를 수행할 수 있어요.

항상 Solidity의 메모리 모델을 존중할 것을 권장하지만, 인라인 어셈블리를 사용하면 메모리를 호환되지 않는 방식으로 사용할 수 있어요. 따라서 스택 변수를 메모리로 옮기는 것과 추가 메모리 최적화는, 메모리 연산을 포함하거나 메모리의 Solidity 변수에 할당하는 인라인 어셈블리 블록이 있으면 기본으로 전역적으로 비활성화돼요.

그러나 다음과 같이 어셈블리 블록이 실제로 Solidity의 메모리 모델을 존중한다는 것을 구체적으로 주석할 수 있어요:

open in Remix

assembly ("memory-safe") {
    ...
}

특히, memory-safe 어셈블리 블록은 다음 메모리 범위에만 접근할 수 있어요:

  • 위에서 설명한 allocate 함수 같은 메커니즘으로 여러분이 직접 할당한 메모리.
  • Solidity가 할당한 메모리, 예: 참조하는 메모리 배열의 범위 안의 메모리.
  • 위에서 언급한 메모리 오프셋 0과 64 사이의 스크래치 공간.
  • 어셈블리 블록 시작 시 자유 메모리 포인터의 값 뒤에 있는 임시 메모리, 즉 자유 메모리 포인터를 갱신하지 않고 자유 메모리 포인터에 "할당"되는 메모리.

게다가 어셈블리 블록이 메모리의 Solidity 변수에 할당하면, Solidity 변수에 대한 접근이 이런 메모리 범위만 접근하도록 보장해야 해요.

이것은 주로 최적화 프로그램에 관한 것이므로, 어셈블리 블록이 revert하거나 종료되어도 이런 제한은 여전히 따라야 해요. 예를 들어 다음 어셈블리 스니펫은 returndatasize()의 값이 64바이트 스크래치 공간을 초과할 수 있으므로 memory-safe가 아니에요:

open in Remix

assembly {
  returndatacopy(0, 0, returndatasize())
  revert(0, returndatasize())
}

반면 다음 코드는 memory-safe예요. 자유 메모리 포인터가 가리키는 위치 너머의 메모리를 임시 스크래치 공간으로 안전하게 사용할 수 있기 때문이에요:

open in Remix

assembly ("memory-safe") {
  let p := mload(0x40)
  returndatacopy(p, 0, returndatasize())
  revert(p, returndatasize())
}

뒤따르는 할당이 없으면 자유 메모리 포인터를 갱신할 필요는 없지만, 자유 메모리 포인터가 주는 현재 오프셋에서 시작하는 메모리만 사용할 수 있다는 점에 주의해요. 메모리 연산이 길이 0을 사용하면, 어떤 오프셋을 사용해도(스크래치 공간에 들어가지 않아도) 괜찮아요:

open in Remix

assembly ("memory-safe") {
  revert(0, 0)
}

인라인 어셈블리 자체의 메모리 연산뿐 아니라 메모리의 참조 타입 Solidity 변수에 대한 할당도 memory-unsafe일 수 있다는 점에 주의해요. 예를 들어 다음은 memory-safe가 아니에요:

bytes memory x;
assembly {
  x := 0x40
}
x[0x20] = 0x42;

메모리에 접근하는 연산도 포함하지 않고 메모리의 Solidity 변수에도 할당하지 않는 인라인 어셈블리는 자동으로 memory-safe로 간주되고 주석이 필요 없어요.

경고 (Warning)

어셈블리가 실제로 메모리 모델을 충족하는지 확인하는 것은 여러분의 책임이에요. 어셈블리 블록을 memory-safe로 주석하지만 메모리 가정 중 하나를 위반하면, 테스트로 쉽게 발견할 수 없는 잘못되고 정의되지 않은 동작으로 이어져요.

여러 Solidity 버전에서 호환되도록 의도된 라이브러리를 개발하는 경우, 특별한 주석으로 어셈블리 블록을 memory-safe로 표시할 수 있어요:

open in Remix

/// @solidity memory-safe-assembly
assembly {
    ...
}

경고 (Warning)

memory-safe-assembly 특별 주석은 비권장이며 제거될 예정이에요. 최근 컴파일러를 대상으로 하는 새 코드에서는 어셈블리 블록 주석을 사용하세요.

메모리의 고급 안전 사용 (Advanced Safe Use of Memory)

위에서 주어진 memory-safety의 엄격한 정의를 넘어, 메모리 오프셋 0에서 시작하는 64바이트보다 많은 스크래치 공간을 사용하고 싶은 경우가 있을 수 있어요. 조심한다면, 오프셋 0x80까지(포함하지 않음) 메모리를 사용하고 여전히 어셈블리 블록을 memory-safe로 안전하게 선언하는 것이 허용될 수 있어요.

이것은 다음 조건 중 하나 아래에서 허용돼요:

  • 어셈블리 블록이 끝날 때까지, 오프셋 0x40의 자유 메모리 포인터가 제정신 값으로 복원되고(즉 원래 값으로 복원되거나 수동 메모리 할당 때문에 그 증가분으로 복원됨), 오프셋 0x60의 메모리 워드가 0의 값으로 복원돼요.
  • 어셈블리 블록이 종료돼요. 즉 실행이 고수준 Solidity 코드로 결코 돌아올 수 없어요. 예를 들어 어셈블리 블록이 무조건 revert opcode 호출로 끝나는 경우가 그렇죠.

게다가 Solidity에서 동적 배열의 기본 값이 메모리 오프셋 0x60을 가리킨다는 것을 알아야 해요. 그래서 메모리 오프셋 0x60의 값을 일시적으로 바꾸는 동안에는, 0x60에서 0 값을 복원할 때까지 동적 배열을 읽을 때 정확한 길이 값을 얻을 수 있다고 더 이상 신뢰할 수 없어요. 더 정확히 말하면, 나머지 어셈블리 스니펫이 고수준 Solidity 객체의 메모리와 상호작용하지(이전에 변수에 저장된 오프셋에서 읽는 것도 포함) 않을 때만 제로 포인터를 덮어쓰는 안전성을 보장해요.

더 알아보기 (Learn more)