Chapter 06: 산술 표현식 - 연산자와 비교
소개
Chapter 05에서 정수 리터럴 하나만 컴파일하는 최소한의 컴파일러를 구축했다. 42를 입력으로 받아 네이티브 바이너리로 출력하는 전체 파이프라인이 작동한다. 하지만 실제 프로그램을 작성하려면 산술 연산자가 필요하다.
이 장에서는 다음을 추가한다:
- 이진 연산자:
+,-,*,/(정수 산술) - 비교 연산자:
<,>,<=,>=,=,<>(i1 boolean 반환) - 단항 연산자:
-(부정) - 출력 기능:
print함수로 결과를 stdout에 출력
이 장을 마치면 10 + 3 * 4와 같은 표현식을 컴파일하고, 비교를 수행하고, 결과를 화면에 출력하는 완전한 계산기 컴파일러를 갖게 된다.
중요: 산술 연산은 MLIR의
arithdialect를 사용한다 (Chapter 01의 primer에서 배웠다). 이 dialect는 SSA 형태의 연산을 제공하며 LLVM dialect로 깔끔하게 낮춰진다.
확장된 AST 정의
Chapter 05의 AST는 IntLiteral 하나만 가졌다. 이제 표현식을 확장한다:
namespace FunLangCompiler
/// 이진 연산자
type Operator =
| Add // +
| Subtract // -
| Multiply // *
| Divide // /
/// 비교 연산자
type CompareOp =
| LessThan // <
| GreaterThan // >
| LessEqual // <=
| GreaterEqual // >=
| Equal // =
| NotEqual // <>
/// 단항 연산자
type UnaryOp =
| Negate // -
/// FunLang 표현식 AST
type Expr =
| IntLiteral of int
| BinaryOp of Operator * Expr * Expr // 예: Add(IntLiteral 10, IntLiteral 20)
| UnaryOp of UnaryOp * Expr // 예: Negate(IntLiteral 42)
| Comparison of CompareOp * Expr * Expr // 예: LessThan(IntLiteral 5, IntLiteral 10)
/// 최상위 프로그램
type Program =
{ expr: Expr }
설계 결정:
- Operator와 CompareOp 분리: 산술 연산은 i32를 반환하지만, 비교는 i1 (boolean)을 반환한다. 타입 시스템이 다르므로 별도의 타입으로 구분한다.
- UnaryOp은 확장 가능: 지금은 Negate만 있지만 나중에 논리 부정 (
not) 등을 추가할 수 있다.
AST 예시:
// Source: 10 + 3 * 4
BinaryOp(Add,
IntLiteral 10,
BinaryOp(Multiply,
IntLiteral 3,
IntLiteral 4))
// Source: -(5 + 10)
UnaryOp(Negate,
BinaryOp(Add,
IntLiteral 5,
IntLiteral 10))
// Source: 5 < 10
Comparison(LessThan,
IntLiteral 5,
IntLiteral 10)
파서 노트: 실제 파서는 연산자 우선순위를 처리해야 한다 (
*가+보다 높음). 이 장에서는 코드 생성에 집중하므로 파서 구현은 생략한다. LangTutorial의 파서를 재사용하거나 간단한 재귀 하강 파서를 작성하면 된다.
arith Dialect 연산 생성
Chapter 03-04에서 구축한 OpBuilder.CreateOperation 패턴을 사용하여 arith dialect 연산을 생성한다. 개별 P/Invoke 대신 generic operation builder를 사용하는 것이 더 유연하고 유지보수가 쉽다.
CodeGen.fs에서 연산 생성 헬퍼:
/// Create operation, append to block, return result value
let private emitOp (ctx: CompileContext) name resultTypes operands attrs regions =
let op = ctx.Builder.CreateOperation(name, ctx.Location, resultTypes, operands, attrs, regions)
ctx.Builder.AppendOperationToBlock(ctx.Block, op)
op
산술 연산 생성 예시:
// arith.addi: 정수 덧셈
| Add(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let i32Type = builder.I32Type()
let op = emitOp ctx "arith.addi" [| i32Type |] [| leftVal; rightVal |] [||] [||]
builder.GetResult(op, 0)
// arith.subi: 정수 뺄셈
| Subtract(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let op = emitOp ctx "arith.subi" [| i32Type |] [| leftVal; rightVal |] [||] [||]
builder.GetResult(op, 0)
// arith.muli: 정수 곱셈
| Multiply(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let op = emitOp ctx "arith.muli" [| i32Type |] [| leftVal; rightVal |] [||] [||]
builder.GetResult(op, 0)
// arith.divsi: 부호 있는 정수 나눗셈
| Divide(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let op = emitOp ctx "arith.divsi" [| i32Type |] [| leftVal; rightVal |] [||] [||]
builder.GetResult(op, 0)
비교 연산 - arith.cmpi:
비교 연산은 predicate 속성이 필요하다. 중요: predicate는 반드시 i64 타입의 IntegerAttr로 전달해야 한다:
// arith.cmpi predicate 값:
// 0 = eq (equal)
// 1 = ne (not equal)
// 2 = slt (signed less than)
// 3 = sle (signed less or equal)
// 4 = sgt (signed greater than)
// 5 = sge (signed greater or equal)
| Equal(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let i64Type = builder.I64Type() // 주의: i64 타입!
let predicateAttr = builder.IntegerAttr(0L, i64Type) // eq = 0
let i1Type = builder.I1Type() // 결과는 i1 (boolean)
let op = emitOp ctx "arith.cmpi" [| i1Type |]
[| leftVal; rightVal |]
[| builder.NamedAttr("predicate", predicateAttr) |]
[||]
builder.GetResult(op, 0)
핵심 발견: MLIR의 ArithOps.td 정의에 따르면 predicate 속성은 i64 타입이어야 한다. i32를 사용하면 “attribute ‘predicate’ expected integer type of width 64” 에러가 발생한다.
연산자 매핑 표:
| FunLang Operator | MLIR Operation | 타입 시그니처 |
|---|---|---|
+ | arith.addi | (i32, i32) -> i32 |
- | arith.subi | (i32, i32) -> i32 |
* | arith.muli | (i32, i32) -> i32 |
/ | arith.divsi | (i32, i32) -> i32 (부호 있는 나눗셈) |
< | arith.cmpi slt | (i32, i32) -> i1 |
> | arith.cmpi sgt | (i32, i32) -> i1 |
<= | arith.cmpi sle | (i32, i32) -> i1 |
>= | arith.cmpi sge | (i32, i32) -> i1 |
= | arith.cmpi eq | (i32, i32) -> i1 |
<> | arith.cmpi ne | (i32, i32) -> i1 |
C API 노트: MLIR C API는
mlir-c/Dialect/Arith.h에서 arith dialect 연산을 노출한다. 실제 함수 이름은 위와 다를 수 있다 (예:mlirArithAddiOpCreatevsmlirArithAddiCreate). MLIR 설치의 헤더 파일을 확인하여 정확한 시그니처를 사용한다.
arith.cmpi predicate 값:
/// arith.cmpi predicate enum
module ArithCmpIPredicate =
let eq = 0 // equal
let ne = 1 // not equal
let slt = 2 // signed less than
let sle = 3 // signed less or equal
let sgt = 4 // signed greater than
let sge = 5 // signed greater or equal
let ult = 6 // unsigned less than (나중에 사용)
let ule = 7 // unsigned less or equal
let ugt = 8 // unsigned greater than
let uge = 9 // unsigned greater or equal
Boolean 리터럴과 논리 연산자
비교 연산 외에도 boolean 리터럴 (true, false)과 논리 연산자 (&&, ||)를 지원해야 한다.
Boolean 리터럴 컴파일
Boolean 값은 i1 타입 (1-bit integer)으로 표현된다:
| Bool(b, _) ->
let i1Type = builder.I1Type()
let value = if b then 1L else 0L
let valueAttr = builder.IntegerAttr(value, i1Type)
let op = emitOp ctx "arith.constant" [| i1Type |] [||]
[| builder.NamedAttr("value", valueAttr) |] [||]
builder.GetResult(op, 0)
생성된 MLIR IR:
%true = arith.constant true // 또는 arith.constant 1 : i1
%false = arith.constant false // 또는 arith.constant 0 : i1
논리 AND/OR 연산자
논리 연산자는 arith.andi와 arith.ori를 사용한다:
| And(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let i1Type = builder.I1Type()
let op = emitOp ctx "arith.andi" [| i1Type |] [| leftVal; rightVal |] [||] [||]
builder.GetResult(op, 0)
| Or(left, right, _) ->
let leftVal = compileExpr ctx left
let rightVal = compileExpr ctx right
let i1Type = builder.I1Type()
let op = emitOp ctx "arith.ori" [| i1Type |] [| leftVal; rightVal |] [||] [||]
builder.GetResult(op, 0)
주의: 이 구현은 **비단락 평가 (non-short-circuit evaluation)**이다. 양쪽 피연산자가 항상 평가된다. 진정한 단락 평가를 위해서는
scf.if를 사용해야 한다 (Chapter 08 참조).
생성된 MLIR IR:
// true && false
%a = arith.constant true
%b = arith.constant false
%result = arith.andi %a, %b : i1 // 결과: false
// true || false
%result = arith.ori %a, %b : i1 // 결과: true
코드 생성 패턴
실제 구현에서는 개별 P/Invoke 대신 generic CreateOperation 패턴을 사용한다. 이것이 더 유지보수하기 쉽고 확장성이 좋다.
설계 결정:
- Generic 패턴:
CreateOperation(name, resultTypes, operands, attrs, regions)형식으로 모든 연산을 생성할 수 있다 - emitOp 헬퍼: CompileContext를 받아 operation 생성, block에 추가, operation 반환을 하나로 묶는다
- 부정 구현:
-expr은0 - expr로 변환한다. 별도의 arith.negate 연산이 없으므로 이것이 표준 방법이다 - 타입 일관성: 모든 정수는 i32, 모든 boolean은 i1로 컴파일한다
공통 에러 (1부)
에러 1: 잘못된 정수 타입 사용 (i64 vs i32)
증상:
MLIR verification failed:
Type mismatch: expected i32, got i64
원인: MLIR은 타입이 엄격하다. 상수를 i64로 생성했지만 함수 시그니처는 i32를 요구하는 경우.
해결:
// WRONG: i64 타입 사용
let i64Type = builder.Context.GetIntegerType(64)
let attr = builder.Context.GetIntegerAttr(i64Type, 42L)
// CORRECT: i32 타입 사용
let i32Type = builder.Context.GetIntegerType(32)
let attr = builder.Context.GetIntegerAttr(i32Type, 42L)
규칙: 모든 FunLang 정수는 i32로 컴파일한다. 타입을 일관되게 유지한다.
에러 2: 연산자 우선순위를 파서에서 처리하지 않음
증상:
Source: 10 + 3 * 4
Expected: 22
Actual: 52 (잘못된 결과)
원인:
파서가 우선순위를 무시하고 왼쪽에서 오른쪽으로 파싱하여 (10 + 3) * 4 = 52가 됨.
해결: 파서에서 연산자 우선순위를 구현한다:
- 곱셈/나눗셈 (
*,/)이 덧셈/뺄셈 (+,-)보다 우선순위가 높다. - 비교 연산자는 산술 연산보다 우선순위가 낮다.
재귀 하강 파서 예시:
// Precedence climbing algorithm
// additive := multiplicative (('+' | '-') multiplicative)*
// multiplicative := primary (('*' | '/') primary)*
// primary := number | '(' additive ')'
파서 구현은 이 장의 범위를 벗어난다. LangTutorial의 기존 파서를 사용하거나 FParsec 같은 파서 라이브러리를 사용한다.
산술 표현식을 위한 코드 생성
이제 Chapter 05의 compileExpr을 확장하여 모든 산술 표현식을 처리한다.
CodeGen.fs 수정:
namespace FunLangCompiler
open System
open MlirWrapper
open MlirBindings
module CodeGen =
/// 표현식을 MLIR value로 컴파일 (재귀적)
let rec compileExpr
(builder: OpBuilder)
(block: MlirBlock)
(location: Location)
(expr: Expr)
: MlirValue =
match expr with
| IntLiteral value ->
// arith.constant operation 생성
let i32Type = builder.I32Type()
let attr = builder.Context.GetIntegerAttr(i32Type, int64 value)
let constOp = builder.CreateConstant(attr, location)
MlirNative.mlirBlockAppendOwnedOperation(block, constOp)
builder.GetResult(constOp, 0)
| BinaryOp(op, lhs, rhs) ->
// 왼쪽 피연산자 컴파일 (재귀)
let lhsVal = compileExpr builder block location lhs
// 오른쪽 피연산자 컴파일 (재귀)
let rhsVal = compileExpr builder block location rhs
// 이진 연산 생성
let binOp = builder.CreateArithBinaryOp(op, lhsVal, rhsVal, location)
MlirNative.mlirBlockAppendOwnedOperation(block, binOp)
builder.GetResult(binOp, 0)
| UnaryOp(Negate, expr) ->
// 피연산자 컴파일
let val = compileExpr builder block location expr
// 부정 연산 생성 (0 - val)
let negOp = builder.CreateArithNegate(val, location)
MlirNative.mlirBlockAppendOwnedOperation(block, negOp)
builder.GetResult(negOp, 0)
| Comparison(compareOp, lhs, rhs) ->
// 피연산자 컴파일
let lhsVal = compileExpr builder block location lhs
let rhsVal = compileExpr builder block location rhs
// 비교 연산 생성 (i1 반환)
let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
builder.GetResult(cmpOp, 0)
/// 프로그램을 MLIR module로 컴파일
let translateToMlir (program: Program) : Module =
let ctx = new Context()
ctx.LoadDialect("arith")
ctx.LoadDialect("func")
let loc = Location.Unknown(ctx)
let mlirMod = new Module(ctx, loc)
let builder = OpBuilder(ctx)
let i32Type = builder.I32Type()
// main 함수 생성: () -> i32
let funcType = builder.FunctionType([||], [| i32Type |])
let funcOp = builder.CreateFunction("main", funcType, loc)
// 함수 body에 entry block 생성
let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
let entryBlock = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)
// 표현식 컴파일 (재귀적으로 모든 연산 처리)
let resultValue = compileExpr builder entryBlock loc program.expr
// return operation 생성
let returnOp = builder.CreateReturn([| resultValue |], loc)
MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)
// 함수를 module에 추가
MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)
mlirMod
/// MLIR module을 검증
let verify (mlirMod: Module) =
if not (mlirMod.Verify()) then
eprintfn "MLIR verification failed:"
eprintfn "%s" (mlirMod.Print())
failwith "MLIR IR is invalid"
SSA 형태 유지:
재귀 호출이 SSA 형태를 자연스럽게 유지한다는 것을 주목한다:
- 각
compileExpr호출은 새로운 SSA value를 반환한다. - 중복 계산이 없다 (각 표현식은 정확히 한 번만 평가된다).
- 지배 관계가 자동으로 유지된다 (하위 표현식이 먼저 평가된다).
예시: 복잡한 표현식 컴파일
// Source: 10 + 3 * 4
let ast = BinaryOp(Add,
IntLiteral 10,
BinaryOp(Multiply,
IntLiteral 3,
IntLiteral 4))
let mlirMod = CodeGen.translateToMlir { expr = ast }
printfn "%s" (mlirMod.Print())
생성된 MLIR IR:
module {
func.func @main() -> i32 {
%c10 = arith.constant 10 : i32
%c3 = arith.constant 3 : i32
%c4 = arith.constant 4 : i32
%0 = arith.muli %c3, %c4 : i32 // 3 * 4 = 12
%1 = arith.addi %c10, %0 : i32 // 10 + 12 = 22
func.return %1 : i32
}
}
동작 분석:
- 상수 10, 3, 4가 생성된다 (
arith.constant) - 먼저 곱셈 계산:
%0 = 3 * 4(하위 표현식이 먼저) - 그 다음 덧셈:
%1 = 10 + %0 - 결과 반환:
return %1
중요: 연산 순서는 AST 구조가 결정한다. 파서가 올바른 우선순위로 AST를 구축하면 코드 생성이 자동으로 올바른 평가 순서를 생성한다.
비교 예시:
// Source: 5 < 10
let ast = Comparison(LessThan, IntLiteral 5, IntLiteral 10)
let mlirMod = CodeGen.translateToMlir { expr = ast }
printfn "%s" (mlirMod.Print())
생성된 MLIR IR:
module {
func.func @main() -> i32 {
%c5 = arith.constant 5 : i32
%c10 = arith.constant 10 : i32
%0 = arith.cmpi slt, %c5, %c10 : i32 // returns i1
// 문제: %0은 i1이지만 함수는 i32를 반환해야 함!
func.return %0 : i32 // TYPE ERROR!
}
}
타입 불일치 문제: 비교는 i1 (boolean)을 반환하지만 main 함수는 i32를 기대한다. 이를 해결하려면 boolean을 정수로 확장해야 한다:
// compileExpr 수정 (Comparison 케이스)
| Comparison(compareOp, lhs, rhs) ->
let lhsVal = compileExpr builder block location lhs
let rhsVal = compileExpr builder block location rhs
// 비교 연산 (i1 반환)
let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
let cmpVal = builder.GetResult(cmpOp, 0)
// i1 -> i32 확장 (zero extend)
let i32Type = builder.I32Type()
let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location) // unsigned extend
MlirNative.mlirBlockAppendOwnedOperation(block, extOp)
builder.GetResult(extOp, 0)
생성된 MLIR IR (수정 후):
module {
func.func @main() -> i32 {
%c5 = arith.constant 5 : i32
%c10 = arith.constant 10 : i32
%0 = arith.cmpi slt, %c5, %c10 : i32 // returns i1
%1 = arith.extui %0 : i1 to i32 // i1 -> i32 (0 or 1)
func.return %1 : i32
}
}
이제 비교 결과가 정수로 반환된다 (true = 1, false = 0).
단항 부정 예시:
// Source: -(10 + 5)
let ast = UnaryOp(Negate, BinaryOp(Add, IntLiteral 10, IntLiteral 5))
let mlirMod = CodeGen.translateToMlir { expr = ast }
printfn "%s" (mlirMod.Print())
생성된 MLIR IR:
module {
func.func @main() -> i32 {
%c10 = arith.constant 10 : i32
%c5 = arith.constant 5 : i32
%0 = arith.addi %c10, %c5 : i32 // 10 + 5 = 15
%c0 = arith.constant 0 : i32
%1 = arith.subi %c0, %0 : i32 // 0 - 15 = -15
func.return %1 : i32
}
}
출력 기능 추가: printf로 결과 출력
지금까지 프로그램은 결과를 종료 코드로만 반환했다. 이제 printf를 사용하여 stdout에 출력하는 기능을 추가한다.
llvm.call 연산을 위한 P/Invoke 바인딩
LLVM dialect는 외부 함수를 호출하는 llvm.call 연산을 제공한다.
MlirBindings.fs에 추가:
// ===== LLVM dialect operations =====
/// llvm.call: 외부 함수 호출
[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation mlirLLVMCallCreate(
MlirContext context,
MlirLocation location,
MlirValue callee,
MlirValue[] args,
int numArgs)
/// llvm.mlir.global: 전역 문자열 상수
[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation mlirLLVMGlobalCreate(
MlirContext context,
MlirLocation location,
MlirType type,
MlirAttribute initializer,
MlirStringRef name)
/// llvm.mlir.addressof: 전역 변수의 주소 가져오기
[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation mlirLLVMAddressOfCreate(
MlirContext context,
MlirLocation location,
MlirStringRef globalName)
C API 경고: 실제 MLIR C API는 LLVM dialect에 대한 직접 지원이 제한적일 수 있다. 필요한 경우 Chapter 05의 Appendix 패턴 (C++ wrapper)을 사용한다.
printf 함수 선언 생성
printf를 호출하려면 먼저 함수 선언과 전역 포맷 문자열이 필요하다.
CodeGen.fs에 헬퍼 함수 추가:
/// printf 함수 선언 생성 (module 레벨)
let createPrintfDeclaration (builder: OpBuilder) (mlirMod: Module) (location: Location) =
// printf 시그니처: (i8*, ...) -> i32
let i8Type = builder.Context.GetIntegerType(8)
let i8PtrType = builder.Context.GetPointerType(i8Type)
let i32Type = builder.I32Type()
// func.func @printf(%fmt: !llvm.ptr<i8>, ...) -> i32 attributes { sym_visibility = "private" }
let printfType = builder.FunctionType([| i8PtrType |], [| i32Type |])
let printfOp = builder.CreateFunction("printf", printfType, location)
// 가변 인자 속성 추가 (실제 구현에서는 속성 API 필요)
// 여기서는 단순화를 위해 생략
MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, printfOp)
/// 전역 포맷 문자열 생성: "%d\n\0"
let createFormatString (builder: OpBuilder) (mlirMod: Module) (location: Location) : string =
let formatStrName = ".str.fmt"
let formatStrValue = "%d\n\0"
// LLVM global 생성
let i8Type = builder.Context.GetIntegerType(8)
let arrayType = builder.Context.GetArrayType(i8Type, formatStrValue.Length)
let strAttr = builder.Context.GetStringAttr(formatStrValue)
let globalOp = builder.CreateLLVMGlobal(arrayType, strAttr, formatStrName, location)
MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, globalOp)
formatStrName
/// print_int 헬퍼 함수 생성: 정수를 출력
let createPrintIntHelper
(builder: OpBuilder)
(mlirMod: Module)
(location: Location)
(formatStrName: string)
=
// func.func @print_int(%arg: i32) -> i32
let i32Type = builder.I32Type()
let funcType = builder.FunctionType([| i32Type |], [| i32Type |])
let funcOp = builder.CreateFunction("print_int", funcType, location)
// 함수 body
let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
let entryBlock = MlirNative.mlirBlockCreate(1n, &i32Type, nativeint 0) // 1 argument
MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)
// 인자 가져오기
let arg = MlirNative.mlirBlockGetArgument(entryBlock, 0n)
// 포맷 문자열 주소 가져오기
let formatStrOp = builder.CreateLLVMAddressOf(formatStrName, location)
MlirNative.mlirBlockAppendOwnedOperation(entryBlock, formatStrOp)
let formatStrPtr = builder.GetResult(formatStrOp, 0)
// printf 호출
let printfCallOp = builder.CreateLLVMCall("printf", [| formatStrPtr; arg |], location)
MlirNative.mlirBlockAppendOwnedOperation(entryBlock, printfCallOp)
// 인자를 그대로 반환 (print는 부수 효과)
let returnOp = builder.CreateReturn([| arg |], location)
MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)
MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)
main 함수에서 print_int 호출
이제 main 함수를 수정하여 결과를 출력하도록 한다:
/// 프로그램을 MLIR module로 컴파일 (print 지원)
let translateToMlirWithPrint (program: Program) : Module =
let ctx = new Context()
ctx.LoadDialect("arith")
ctx.LoadDialect("func")
ctx.LoadDialect("llvm")
let loc = Location.Unknown(ctx)
let mlirMod = new Module(ctx, loc)
let builder = OpBuilder(ctx)
let i32Type = builder.I32Type()
// printf 선언과 print_int 헬퍼 생성
createPrintfDeclaration builder mlirMod loc
let formatStrName = createFormatString builder mlirMod loc
createPrintIntHelper builder mlirMod loc formatStrName
// main 함수 생성
let funcType = builder.FunctionType([||], [| i32Type |])
let funcOp = builder.CreateFunction("main", funcType, loc)
let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
let entryBlock = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)
// 표현식 컴파일
let resultValue = compileExpr builder entryBlock loc program.expr
// print_int 호출
let printOp = builder.CreateFunctionCall("print_int", [| resultValue |], loc)
MlirNative.mlirBlockAppendOwnedOperation(entryBlock, printOp)
let printedVal = builder.GetResult(printOp, 0)
// 결과 반환
let returnOp = builder.CreateReturn([| printedVal |], loc)
MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)
MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)
mlirMod
생성된 MLIR IR (전체):
module {
// printf 선언
func.func private @printf(!llvm.ptr<i8>, ...) -> i32
// 포맷 문자열
llvm.mlir.global private constant @.str.fmt("%d\n\00")
// print_int 헬퍼
func.func @print_int(%arg0: i32) -> i32 {
%fmt = llvm.mlir.addressof @.str.fmt : !llvm.ptr<array<4 x i8>>
%fmt_ptr = llvm.bitcast %fmt : !llvm.ptr<array<4 x i8>> to !llvm.ptr<i8>
%result = llvm.call @printf(%fmt_ptr, %arg0) : (!llvm.ptr<i8>, i32) -> i32
func.return %arg0 : i32
}
// main 함수
func.func @main() -> i32 {
%c10 = arith.constant 10 : i32
%c3 = arith.constant 3 : i32
%c4 = arith.constant 4 : i32
%0 = arith.muli %c3, %c4 : i32
%1 = arith.addi %c10, %0 : i32
%2 = func.call @print_int(%1) : (i32) -> i32
func.return %2 : i32
}
}
실행 결과:
$ ./program
22
$ echo $?
22
결과가 stdout에 출력되고 종료 코드로도 반환된다!
완전한 컴파일러 드라이버
Chapter 05의 컴파일러 드라이버를 업데이트하여 새로운 기능을 지원한다:
Compiler.fs 업데이트:
namespace FunLangCompiler
open System
open System.IO
module Compiler =
/// 소스 파일을 네이티브 실행 파일로 컴파일
let compile (sourceFile: string) (outputFile: string) (withPrint: bool) =
printfn "=== FunLang Compiler ==="
printfn "Source: %s" sourceFile
printfn "Output: %s" outputFile
printfn ""
// 1단계: 파싱
printfn "[1/7] Parsing..."
let source = File.ReadAllText(sourceFile)
let program = Parser.parse source // 실제 파서 사용 (LangTutorial 재사용)
printfn " AST: %A" program
// 2단계: MLIR로 변환
printfn "[2/7] Translating to MLIR..."
let mlirMod =
if withPrint then
CodeGen.translateToMlirWithPrint program
else
CodeGen.translateToMlir program
printfn " MLIR (high-level):"
printfn "%s" (mlirMod.Print())
// 3단계: 검증
printfn "[3/7] Verifying MLIR..."
CodeGen.verify mlirMod
printfn " ✓ Verification passed"
// 4-7단계: Lowering, LLVM IR, object file, linking (Chapter 05와 동일)
Lowering.lowerToLLVMDialect mlirMod
let llvmIR = Lowering.translateToLLVMIR mlirMod
let objectFile = outputFile + ".o"
NativeCodeGen.emitObjectFile llvmIR objectFile
NativeCodeGen.linkExecutable objectFile outputFile
mlirMod.Dispose()
printfn ""
printfn "=== Compilation successful ==="
printfn "Run: ./%s" outputFile
[<EntryPoint>]
let main args =
if args.Length < 2 then
eprintfn "Usage: compiler <source.fun> <output> [--print]"
exit 1
let sourceFile = args.[0]
let outputFile = args.[1]
let withPrint = args.Length > 2 && args.[2] = "--print"
Compiler.compile sourceFile outputFile withPrint
0
사용 예시:
# 결과를 출력하지 않음 (종료 코드만)
$ dotnet run test.fun program
# 결과를 출력함 (stdout + 종료 코드)
$ dotnet run test.fun program --print
$ ./program
22
공통 에러 (2부)
에러 3: 비교가 i1을 반환하지만 i32가 필요한 곳에서 사용
증상:
MLIR verification failed:
Type mismatch in func.return: expected i32, got i1
원인: 비교 연산은 i1 (boolean)을 반환하지만 main 함수는 i32를 반환해야 한다.
해결: i1을 i32로 확장한다:
// arith.extui 사용 (zero extend)
let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location)
또는 main 함수가 i1을 반환하도록 변경 (덜 일반적):
// main 함수 시그니처를 i1으로 변경 (비권장)
let funcType = builder.FunctionType([||], [| builder.Context.GetIntegerType(1) |])
권장 방법: 항상 i32로 확장한다. Unix 종료 코드는 8비트 정수이므로 boolean을 정수로 표현하는 것이 자연스럽다.
에러 4: 0으로 나누기 (런타임 vs 컴파일 타임)
증상:
$ ./program
Floating point exception (core dumped)
원인:
10 / 0과 같은 표현식이 런타임에 0으로 나누기를 시도한다.
컴파일 타임 해결: AST를 분석하여 상수 0으로 나누기를 감지한다:
| BinaryOp(Divide, lhs, IntLiteral 0) ->
failwith "Compile error: division by zero"
런타임 해결 (더 일반적): 동적 검사 코드를 삽입한다:
| BinaryOp(Divide, lhs, rhs) ->
let lhsVal = compileExpr builder block location lhs
let rhsVal = compileExpr builder block location rhs
// rhsVal == 0 검사
let zero = builder.CreateConstant(0, builder.I32Type(), location)
let isZero = builder.CreateArithCmpi(ArithCmpIPredicate.eq, rhsVal, zero, location)
// if (rhsVal == 0) abort() else lhs / rhs
// scf.if 사용 (Chapter 08에서 다룸)
// 지금은 단순화를 위해 생략
실용적 접근: 대부분의 컴파일러는 0으로 나누기를 런타임 에러로 남긴다. 프로그램이 SIGFPE로 종료되는 것이 예상 동작이다.
에러 5: printf 포맷 문자열에 null terminator 누락
증상:
$ ./program
22ݠ�(garbage characters)
원인:
C 문자열은 null terminator (\0)가 필요하다. "%d\n" 대신 "%d\n\0"를 사용해야 한다.
해결:
// WRONG: null terminator 없음
let formatStrValue = "%d\n"
// CORRECT: null terminator 포함
let formatStrValue = "%d\n\0"
MLIR IR:
// CORRECT
llvm.mlir.global private constant @.str.fmt("%d\0A\00") : !llvm.array<4 x i8>
에러 6: arith 연산 후 LLVM dialect로 낮추기 잊음
증상:
Translation error: Unhandled operation 'arith.addi'
원인: arith dialect를 LLVM IR로 변환하려면 먼저 LLVM dialect로 낮춰야 한다.
해결:
Lowering 단계에서 convert-arith-to-llvm pass를 실행한다:
// Pass manager에 추가
MlirStringRef.WithString "convert-arith-to-llvm" (fun passName ->
let pass = MlirNative.mlirCreateConversionPass(passName)
MlirNative.mlirPassManagerAddOwnedPass(pm, pass))
Pass 순서:
convert-func-to-llvmconvert-arith-to-llvmreconcile-unrealized-casts- 그 다음
mlirTranslateModuleToLLVMIR
구현 시 주의사항 (Common Pitfalls)
실제 구현에서 발견된 중요한 주의사항들:
1. arith.cmpi predicate는 i64 타입이어야 한다
// WRONG: i32 타입 predicate
let predicateAttr = builder.IntegerAttr(0L, builder.I32Type())
// CORRECT: i64 타입 predicate
let predicateAttr = builder.IntegerAttr(0L, builder.I64Type())
MLIR ArithOps.td 정의에서 predicate는 64비트 정수 속성으로 정의되어 있다. i32를 사용하면 검증 에러가 발생한다.
2. 비교 연산 결과는 i1, 정수 연산 결과는 i32
// 비교 연산: i1 결과
let op = emitOp ctx "arith.cmpi" [| builder.I1Type() |] ...
// 산술 연산: i32 결과
let op = emitOp ctx "arith.addi" [| builder.I32Type() |] ...
3. Boolean 리터럴은 i1 타입의 arith.constant
// Boolean true/false
let i1Type = builder.I1Type()
let value = if b then 1L else 0L
let valueAttr = builder.IntegerAttr(value, i1Type) // i1 타입으로 생성
4. 비단락 평가에 주의
arith.andi와 arith.ori는 양쪽 피연산자를 모두 평가한다. 부수 효과가 있는 표현식에서 문제가 될 수 있다. 진정한 단락 평가가 필요하면 scf.if를 사용한다.
장 요약
이 장에서 다음을 성취했다:
- 확장된 AST: 이진 연산자, 비교, 단항 부정, boolean 리터럴, 논리 연산자를 지원하는 표현식 타입
- Generic 연산 생성:
CreateOperation패턴으로 arith dialect 연산 생성 - 비교 연산: arith.cmpi와 i64 predicate 속성
- Boolean 지원: i1 타입, arith.andi/ori 논리 연산자
- 재귀 코드 생성: SSA 형태를 유지하며 복잡한 표현식 컴파일
- 출력 기능: printf를 통한 결과 출력
- 완전한 예제: MLIR IR 출력을 보여주는 실행 가능한 코드
독자가 할 수 있는 것:
10 + 3 * 4컴파일 → 네이티브 바이너리 → 실행 → 결과: 22 ✓5 < 10컴파일 → boolean 반환 (1 = true) ✓-42컴파일 → 부정 연산 ✓print(10 + 20)컴파일 → stdout 출력: 30 ✓
다음 장 미리보기:
Chapter 07에서는 let 바인딩을 추가한다:
let x = 10 in
let y = 20 in
x + y
이것은 다음을 도입한다:
- 변수 이름과 SSA value 간의 환경 (symbol table)
- 중첩된 스코프 (nested scopes)
- 변수 섀도잉 (shadowing) vs 뮤테이션 (mutation)
Phase 2는 계속된다!
이제 독자는 산술 표현식을 컴파일하고 결과를 출력할 수 있다!