Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)
(시리즈 글이 16개 있습니다.)
.NET Framework: 112. How to Interop DISPPARAMS
; https://www.sysnet.pe.kr/2/0/617

.NET Framework: 137. C#에서 Union 구조체 다루기
; https://www.sysnet.pe.kr/2/0/728

.NET Framework: 141. Win32 Interop - 크기가 정해지지 않은 배열을 C++에서 C#으로 전달하는 경우
; https://www.sysnet.pe.kr/2/0/737

.NET Framework: 168. [in,out] 배열을 C#에서 C/C++로 넘기는 방법
; https://www.sysnet.pe.kr/2/0/810

.NET Framework: 169. [in, out] 배열을 C#에서 C/C++로 넘기는 방법 - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/811

.NET Framework: 183. 구조체 포인터 인자에 대한 P/Invoke 정의
; https://www.sysnet.pe.kr/2/0/912

.NET Framework: 472. C/C++과 C# 사이의 메모리 할당/해제 방법
; https://www.sysnet.pe.kr/2/0/1784

.NET Framework: 620. C#에서 C/C++ 함수로 콜백 함수를 전달하는 예제 코드
; https://www.sysnet.pe.kr/2/0/11099

.NET Framework: 627. C++로 만든 DLL을 C#에서 사용하기
; https://www.sysnet.pe.kr/2/0/11111

.NET Framework: 686. C# - string 배열을 담은 구조체를 직렬화하는 방법
; https://www.sysnet.pe.kr/2/0/11319

.NET Framework: 757. 포인터 형 매개 변수를 갖는 C++ DLL의 함수를 C#에서 호출하는 방법
; https://www.sysnet.pe.kr/2/0/11533

.NET Framework: 978. C# - GUID 타입 전용의 UnmanagedType.LPStruct
; https://www.sysnet.pe.kr/2/0/12444

C/C++: 158. Visual C++ - IDL 구문 중 "unsigned long"을 인식하지 못하는 #import
; https://www.sysnet.pe.kr/2/0/13128

.NET Framework: 2058. [in,out] 배열을 C#에서 C/C++로 넘기는 방법 - 세 번째 이야기
; https://www.sysnet.pe.kr/2/0/13141

.NET Framework: 2083. C# - C++과의 연동을 위한 구조체의 fixed 배열 필드 사용 (2)
; https://www.sysnet.pe.kr/2/0/13205

닷넷: 2152. Win32 Interop - C/C++ DLL로부터 이중 포인터 버퍼를 C#으로 받는 예제
; https://www.sysnet.pe.kr/2/0/13429




Visual C++ - IDL 구문 중 "unsigned long"을 인식하지 못하는 #import

아래의 질문이 있었는데요,

C# dll 과 C++ 간 배열 전달. SafeArray
; https://www.sysnet.pe.kr/3/0/5726

문제를 정리해 보면, C# 측에서 다음과 같은 메서드를 하나 정의했고,

using System;
using System.Runtime.InteropServices;

namespace CsClassLibrary
{
    [ComVisible(true)]
    [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
    [Guid("EF9B11F3-AAF7-47B3-95BA-5D842F376E8E")]
    public interface IDevice
    {
        UInt32[] GetFoo();
    }

    [ComVisible(true)]
    [ClassInterface(ClassInterfaceType.None)]
    [Guid("E0F14B0E-7749-4BA5-8304-FA61F8056D24")]
    public class CDevice : IDevice
    {
        public UInt32[] GetFoo()
        {
            UInt32[] retArray = new UInt32[] { 0, 1, 2, 3, 4 };
            return retArray;
        }
    }
}

"Register for COM interop" 옵션을 통해 COM 개체로 노출시킨 상태입니다. 그리고 이것을 Visual C++에서 다음과 같이 사용하는데,

#include <atlstr.h>
#include <atlconv.h>
#include <atlbase.h>
#include <atlsafe.h>

#import "..\\CsClassLibrary\\bin\\Debug\\CsClassLibrary.tlb" no_namespace named_guids

int main()
{
    CoInitialize(NULL);

    IDevice* mp_device;
    HRESULT hr = CoCreateInstance(CLSID_CDevice, NULL, CLSCTX_INPROC_SERVER, DIID_IDevice, reinterpret_cast<void**>(&mp_device));
    if (hr == S_OK)
    {
        SAFEARRAY* presult = mp_device->GetFoo();
        mp_device->Release();
    }

    CoUninitialize();
}

GetFoo 함수 호출에서 런타임 오류가 발생합니다.

Unhandled exception at 0x00007FFE9AB6474C in CppClient.exe: Microsoft C++ exception: _com_error at memory location 0x000000CABF78F360.

>    vcruntime140d.dll!_CxxThrowException(void * pExceptionObject, const _s__ThrowInfo * pThrowInfo) Line 75 C++ Non-user code. Symbols loaded.
    CppClient.exe!_com_raise_error(HRESULT hr, IErrorInfo * perrinfo) Line 19   C++ Symbols loaded.
    CppClient.exe!_com_dispatch_method(IDispatch * pDispatch, long dwDispID, unsigned short wFlags, unsigned short vtRet, void * pvRet, const wchar_t * pwParamInfo, ...) Line 96   C++ Symbols loaded.
    CppClient.exe!IDevice::GetFoo() Line 17 C++ Symbols loaded.
    CppClient.exe!main() Line 18    C++ Symbols loaded.
    CppClient.exe!invoke_main() Line 79 C++ Non-user code. Symbols loaded.
    CppClient.exe!__scrt_common_main_seh() Line 288 C++ Non-user code. Symbols loaded.
    CppClient.exe!__scrt_common_main() Line 331 C++ Non-user code. Symbols loaded.
    CppClient.exe!mainCRTStartup(void * __formal) Line 17   C++ Non-user code. Symbols loaded.
    kernel32.dll!BaseThreadInitThunk() Unknown Non-user code. Symbols loaded.
    ntdll.dll!RtlUserThreadStart() Unknown Non-user code. Symbols loaded.

도대체 무슨 오류가 있는 것일까요? ^^




Visual C++가 "#import ..." 구문을 이용해 tlb 파일로부터 생성한 GetFoo 함수의 호출 코드를 보면 이렇습니다.

inline SAFEARRAY * IDevice::GetFoo ( ) {
    SAFEARRAY * _result = 0;
    _com_dispatch_method(this, 0x60020000, DISPATCH_METHOD, VT_ARRAY|VT_I4, (void*)&_result, NULL);
    return _result;
}

뭔가.. 좀 이상하지 않나요? ^^ 우리가 만든 C# 메서드는 uint[]를 반환하는데 위에서 명시한 "VT_ARRAY|VT_I4" 플래그는 int[]에 해당합니다. 이 가정이 맞는지, 테스트를 위해 위의 함수를 우리가 직접 다음과 같이 만들어,

inline SAFEARRAY* GetFoo(IDevice *pDevice) {
    SAFEARRAY* _result = 0;
    _com_dispatch_method(pDevice, 0x60020000, DISPATCH_METHOD, VT_ARRAY | VT_UI4, (void*)&_result, NULL);
    return _result;
}

이렇게 호출할 수 있습니다.

SAFEARRAY* presult = GetFoo(mp_device);

호출 결과,

cs_cpp_interop_ui4_1.png

presult에도 값이 정상적으로 들어 있으니 이것으로 문제가 파악되었습니다.




그렇다면, 혹시 어느 쪽에서 잘못하고 있는 것인지 알 수 있을까요?

우선, C# 프로젝트의 "Register for COM interop" 옵션으로 생성된 TLB 파일을 oleview를 통해 확인해 보면,

[
  uuid(63722C41-D463-41DB-BCE4-8A497077448B),
  version(1.0),
  custom(90883F05-3D28-11D2-8F17-00A0C9A6186D, "CsClassLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")

]
library CsClassLibrary
{
    // TLib : mscorlib.dll : {BED7F4EA-1A96-11D2-8F08-00A0C9A6186D}
    importlib("mscorlib.tlb");
    // TLib : OLE Automation : {00020430-0000-0000-C000-000000000046}
    importlib("stdole2.tlb");

    // Forward declare all types defined in this typelib
    dispinterface IDevice;

    [
      uuid(EF9B11F3-AAF7-47B3-95BA-5D842F376E8E),
      version(1.0),
      custom(0F21F359-AB84-41E8-9A78-36D110E6D2F9, "CsClassLibrary.IDevice")    

    ]
    dispinterface IDevice {
        properties:
        methods:
            [id(0x60020000)]
            SAFEARRAY(unsigned long) GetFoo();
    };

    [
      uuid(E0F14B0E-7749-4BA5-8304-FA61F8056D24),
      version(1.0),
      custom(0F21F359-AB84-41E8-9A78-36D110E6D2F9, "CsClassLibrary.CDevice")
    ]
    coclass CDevice {
        interface _Object;
        [default] dispinterface IDevice;
    };
};

정상적으로 "SAFEARRAY(unsigned long)"라고 출력을 하고 있습니다. 이것으로 C# 측에서는 정상적으로 COM 규약에 따라 메타데이터를 생성하고 있는 것입니다. 그럼, 남은 범인은 Visual C++ 측이 되는군요. ^^

그러니까, #import 구문(혹은 아마도 이 처리를 수행하는 하위 프로세스)은 IDL의 SAFEARRAY(unsigned long) 규약을 "VT_ARRAY|VT_ UI4"가 아닌 "VT_ARRAY|VT_I4"로 출력하는 버그(?)가 있는 것입니다.

일단, 어쩔 수 없습니다. 현상이 저러하니, 마이크로소프트 측에 이슈 제기를 하거나, 이 글에서처럼 별도의 함수를 만들어 직접 마샬링 타입을 지정하는 수밖에는 없습니다. (물론, 이렇게 되면 C#에서 메서드 시그니처를 변경했을 때 주의 깊게 해당 메서드의 마샬링 플래그도 직접 수정해야 하는 단점이 있습니다.)

이렇게 해결 방법은 알게 되었지만, 그나저나 런타임 오류가 발생하는 것이 좀 신기하긴 합니다. UI4나 I4나 어차피 4바이트인데... 너무 빡빡하게 ^^; 타입 체크를 해서 문제가 되는 듯합니다.




자, 그럼 이번에는 반환값이 아닌 out 인자로 들어온 경우를 테스트해볼까요?

// C# 코드

public void GetFoo2(out uint[] result)
{
    result = new UInt32[] { 0, 1, 2, 3, 4 };
}

위의 메서드를 C++에서 #import하면 이런 함수가 자동생성됩니다.

// .tli 파일에 자동생성된 코드
inline HRESULT IDevice::GetFoo2 ( SAFEARRAY * * result ) {
    return _com_dispatch_method(this, 0x60020001, DISPATCH_METHOD, VT_EMPTY, NULL, 
        L"\x6003", result);
}

그리고 이번엔 해당 메서드를 호출하면 아예 Access Violation 예외가 발생합니다.

Exception thrown at 0x00007FFE851509A2 (clr.dll) in CppClient.exe: 0xC0000005: Access violation reading location 0xFFFFFFFFFFFFFFFF.

    clr.dll!ClrSafeArrayGetVartype(struct tagSAFEARRAY *,unsigned short *)  Unknown Symbols loaded.
    clr.dll!OleVariant::CreateArrayRefForSafeArray(struct tagSAFEARRAY *,unsigned short,class MethodTable *)    Unknown Symbols loaded.
    clr.dll!DispParamArrayMarshaler::MarshalNativeToManaged(struct tagVARIANT *,class Object * *)   Unknown Symbols loaded.
    clr.dll!DispatchMemberInfo::MarshalParamNativeToManaged(int,struct tagVARIANT *,class Object * *)   Unknown Symbols loaded.
    clr.dll!DispatchInfo::InvokeMemberWorker(struct DispatchMemberInfo *,struct InvokeObjects *,int,int,int,int &,int &,long,struct tagDISPPARAMS *,struct tagVARIANT *,unsigned short,unsigned long,long *,struct tagVARIANT *,struct OBJECTHANDLE__ * *,int *,struct tagVARIANT * *)  Unknown Symbols loaded.
    clr.dll!DispatchInfo::InvokeMemberDebuggerWrapper(struct DispatchMemberInfo *,struct InvokeObjects *,int,int,int,int &,int &,long,struct tagDISPPARAMS *,struct tagVARIANT *,unsigned short,unsigned long,long *,struct tagVARIANT *,struct OBJECTHANDLE__ * *,int *,struct tagVARIANT * *,class Frame *)   Unknown Symbols loaded.
    clr.dll!DispatchInfo::InvokeMember(struct SimpleComCallWrapper *,long,unsigned long,unsigned short,struct tagDISPPARAMS *,struct tagVARIANT *,struct tagEXCEPINFO *,struct IServiceProvider *,unsigned int *)   Unknown Symbols loaded.
    clr.dll!InternalDispatchImpl_Invoke(struct IDispatch *,long,struct _GUID const &,unsigned long,unsigned short,struct tagDISPPARAMS *,struct tagVARIANT *,struct tagEXCEPINFO *,unsigned int *)  Unknown Symbols loaded.
    clr.dll!InternalDispatchImpl_Invoke_CallBack(void *)    Unknown Symbols loaded.
    clr.dll!InternalDispatchImpl_Invoke_Wrapper(struct IDispatch *,long,struct _GUID const &,unsigned long,unsigned short,struct tagDISPPARAMS *,struct tagVARIANT *,struct tagEXCEPINFO *,unsigned int *)  Unknown Symbols loaded.
    CppClient.exe!_com_invoke_helper(IDispatch * pDispatch, long dwDispID, unsigned short wFlags, unsigned short vtRet, void * pvRet, const wchar_t * pwParamInfo, char * argList, IErrorInfo * * pperrinfo) Line 324   C++ Symbols loaded.
    CppClient.exe!_com_dispatch_method(IDispatch * pDispatch, long dwDispID, unsigned short wFlags, unsigned short vtRet, void * pvRet, const wchar_t * pwParamInfo, ...) Line 84   C++ Symbols loaded.
>    CppClient.exe!GetFoo2(IDevice * pDevice, tagSAFEARRAY * * result) Line 18   C++ Symbols loaded.
    CppClient.exe!main() Line 35    C++ Symbols loaded.
    CppClient.exe!invoke_main() Line 79 C++ Non-user code. Symbols loaded.
    CppClient.exe!__scrt_common_main_seh() Line 288 C++ Non-user code. Symbols loaded.
    CppClient.exe!__scrt_common_main() Line 331 C++ Non-user code. Symbols loaded.
    CppClient.exe!mainCRTStartup(void * __formal) Line 17   C++ Non-user code. Symbols loaded.
    kernel32.dll!BaseThreadInitThunk() Unknown Non-user code. Symbols loaded.
    ntdll.dll!RtlUserThreadStart() Unknown Non-user code. Symbols loaded.

사실, 이번에도 원인은 같을 것입니다. 반환 값에 대해서 VT_ARRAY|VT_I4 조합을 했던 것처럼 인자에 대해서도 역시 VT_I4를 했을 테니까요.

실제로 인자 형식을 지정한 L"\x6003" 값은 VARENUM의 정의 중,

// wtypes.h

enum VARENUM
    {
        //...[생략]...
        VT_I4   = 3,
        //...[생략]...
        VT_UI4  = 19,
        //...[생략]...
        VT_ARRAY    = 0x2000,
        VT_BYREF    = 0x4000,
        //...[생략]...
    } ;

아래의 3개 값이 조합한 것임을 유추할 수 있습니다.

VT_ARRAY    = 0x2000,
VT_BYREF    = 0x4000,
VT_I4   = 3,

따라서, VT_I4만 VT_UI4로 바꿔서 다음과 같이 맞춰주면,

inline HRESULT GetFoo2(IDevice* pDevice, SAFEARRAY** result) {
    return _com_dispatch_method(pDevice, 0x60020001, DISPATCH_METHOD, VT_EMPTY, NULL, 
        L"\x6013", result);
}

이제 정상적으로 호출이 됩니다. ^^

SAFEARRAY *pArrayResult = nullptr;
GetFoo2(mp_device, &pArrayResult);

cs_cpp_interop_ui4_2.png




참고로, 이런 식의 문제가 발생했을 때 IDispatch를 이용한 원시적인 호출을 하면서 접근하는 것도 원인 파악에 도움이 됩니다. 가령, 위에서 구현한 GetFoo, GetFoo2에 대해서는 다음과 같이 GetIDsOfNames, Invoke를 직접 호출하는 식으로 접근할 수 있습니다.

// ...[생략]...

{
    DISPID dispId;
    LPOLESTR lpDesc = (LPOLESTR)L"GetFoo";
    HRESULT hr = mp_device->GetIDsOfNames(IID_NULL, &lpDesc, 1, 0, &dispId);
            
    CComVariant varResult;
    DISPPARAMS params = { nullptr, nullptr, 0, 0 };
    hr = mp_device->Invoke(dispId, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_METHOD, &params, &varResult, nullptr, nullptr);
    if (hr != S_OK)
    {
        throw 1;
    }
}


{
    DISPID dispId;
    LPOLESTR lpDesc = (LPOLESTR)L"GetFoo2";
    HRESULT hr = mp_device->GetIDsOfNames(IID_NULL, &lpDesc, 1, 0, &dispId);

    CComVariant* varParams = new CComVariant[1];

    SAFEARRAY* pArray = nullptr;
    varParams[0].pparray = &pArray;
    varParams[0].vt = VT_ARRAY | VT_BYREF | VT_UI4;
    DISPPARAMS params = { varParams, nullptr, 1, 0 };

    hr = mp_device->Invoke(dispId, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_METHOD, &params, nullptr, nullptr, nullptr);
    if (hr != S_OK)
    {
        throw 1;
    }
}

이 정도면.. 대충 모두 설명한 것 같군요. ^^

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 11/13/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2024-08-30 08시37분
On the strange status of wchar_t in classic MIDL
; https://devblogs.microsoft.com/oldnewthing/20240828-00/?p=110188

-----------------------------------------

The MIDL compiler still has trouble with double greater-than signs, sadly
; https://devblogs.microsoft.com/oldnewthing/20250623-00/?p=111295

C++ 11에서는 template의 이중 화살 괄호(>>)에 대한 해석을 하는 지원하는데, MIDL 컴파일러는 여전히 그 문자의 해석을 하지 못한다고 합니다. 그래서 "> >"로 중간에 공백을 넣어야 합니다.

재미있는 점은, 마이크로소프트 내에서 몇몇 개발자들이 이에 대한 지원을 추가하려고 했지만 번번이 실패했다고 합니다.

이와 함께 재미있는 주석 사건을 하나 소개해줍니다. ^^

What is the best comment in source code you have ever encountered? [closed]
; https://stackoverflow.com/questions/184618/what-is-the-best-comment-in-source-code-you-have-ever-encountered/482129#482129

-----------------------------------------

Unintended yet somehow entirely expected consequences of marking a COM interface as local
; https://devblogs.microsoft.com/oldnewthing/20250702-00/?p=111332

[local] 특성은 마샬링이 필요 없는 인터페이스에 대해서만 지정합니다. 간단하게 설명하면, 마치 인터페이스를 가리키는 포인터와 다를 바 없는, 결국 vtable을 가진 C++ 클래스의 인스턴스에 대한 포인터처럼 동작하는 것입니다. 그래서 해당 인터페이스에 대해서는 IDL 컴파일러가 어떠한 마샬링 코드도 생성하지 않습니다. 따라서 그런 인터페이스를 외부에서 접근하려고 하면 E_NOINTERFACE (0x80004002) 오류가 발생합니다.
정성태

... 61  62  63  64  65  66  67  68  69  70  71  72  73  74  [75]  ...
NoWriterDateCnt.TitleFile(s)
12153정성태2/23/202024452.NET Framework: 898. Trampoline을 이용한 후킹의 한계파일 다운로드1
12152정성태2/23/202021443.NET Framework: 897. 실행 시에 메서드 가로채기 - CLR Injection: Runtime Method Replacer 개선 - 세 번째 이야기(Trampoline 후킹)파일 다운로드1
12151정성태2/22/202024077.NET Framework: 896. C# - Win32 API를 Trampoline 기법을 이용해 C# 메서드로 가로채는 방법 - 두 번째 이야기 (원본 함수 호출)파일 다운로드1
12150정성태2/21/202024192.NET Framework: 895. C# - Win32 API를 Trampoline 기법을 이용해 C# 메서드로 가로채는 방법 [1]파일 다운로드1
12149정성태2/20/202021084.NET Framework: 894. eBEST C# XingAPI 래퍼 - 연속 조회 처리 방법 [1]
12148정성태2/19/202025776디버깅 기술: 163. x64 환경에서 구현하는 다양한 Trampoline 기법 [1]
12147정성태2/19/202021067디버깅 기술: 162. x86/x64의 기계어 코드 최대 길이
12146정성태2/18/202022265.NET Framework: 893. eBEST C# XingAPI 래퍼 - 로그인 처리파일 다운로드1
12145정성태2/18/202023872.NET Framework: 892. eBEST C# XingAPI 래퍼 - Sqlite 지원 추가파일 다운로드1
12144정성태2/13/202024063.NET Framework: 891. 실행 시에 메서드 가로채기 - CLR Injection: Runtime Method Replacer 개선 - 두 번째 이야기파일 다운로드1
12143정성태2/13/202018489.NET Framework: 890. 상황별 GetFunctionPointer 반환값 정리 - x64파일 다운로드1
12142정성태2/12/202022442.NET Framework: 889. C# 코드로 접근하는 MethodDesc, MethodTable파일 다운로드1
12141정성태2/10/202021411.NET Framework: 888. C# - ASP.NET Core 웹 응용 프로그램의 출력 가로채기 [2]파일 다운로드1
12140정성태2/10/202022745.NET Framework: 887. C# - ASP.NET 웹 응용 프로그램의 출력 가로채기파일 다운로드1
12139정성태2/9/202022433.NET Framework: 886. C# - Console 응용 프로그램에서 UI 스레드 구현 방법
12138정성태2/9/202028643.NET Framework: 885. C# - 닷넷 응용 프로그램에서 SQLite 사용 [6]파일 다운로드1
12137정성태2/9/202020306오류 유형: 592. [AhnLab] 경고 - 디버거 실행을 탐지했습니다.
12136정성태2/6/202021971Windows: 168. Windows + S(또는 Q)로 뜨는 작업 표시줄의 검색 바가 동작하지 않는 경우
12135정성태2/6/202027745개발 환경 구성: 468. Nuget 패키지의 로컬 보관 폴더를 옮기는 방법 [2]
12134정성태2/5/202024990.NET Framework: 884. eBEST XingAPI의 C# 래퍼 버전 - XingAPINet Nuget 패키지 [5]파일 다운로드1
12133정성태2/5/202022777디버깅 기술: 161. Windbg 환경에서 확인해 본 .NET 메서드 JIT 컴파일 전과 후 - 두 번째 이야기
12132정성태1/28/202025876.NET Framework: 883. C#으로 구현하는 Win32 API 후킹(예: Sleep 호출 가로채기) [1]파일 다운로드1
12131정성태1/27/202024515개발 환경 구성: 467. LocaleEmulator를 이용해 유니코드를 지원하지 않는(한글이 깨지는) 프로그램을 실행하는 방법 [1]
12130정성태1/26/202022061VS.NET IDE: 142. Visual Studio에서 windbg의 "Open Executable..."처럼 EXE를 직접 열어 디버깅을 시작하는 방법
12129정성태1/26/202029082.NET Framework: 882. C# - 키움 Open API+ 사용 시 Registry 등록 없이 KHOpenAPI.ocx 사용하는 방법 [3]
12128정성태1/26/202023207오류 유형: 591. The code execution cannot proceed because mfc100.dll was not found. Reinstalling the program may fix this problem.
... 61  62  63  64  65  66  67  68  69  70  71  72  73  74  [75]  ...