Skip to content

Instantly share code, notes, and snippets.

@suntong
Last active December 25, 2025 08:10
Show Gist options
  • Select an option

  • Save suntong/14f87135b77efb30077c311aef3a161f to your computer and use it in GitHub Desktop.

Select an option

Save suntong/14f87135b77efb30077c311aef3a161f to your computer and use it in GitHub Desktop.

Course: Integrating Go with Legacy Systems (Calling Go from C++)

  • Audience: Intermediate Go Developers
  • Prerequisites: Basic knowledge of Go, C/C++ syntax, and CLI build tools (gcc/clang).
  • Focus: Creating Go shared libraries and consuming them safely from C and C++.

Module 1: The Foundation - Compiling Go to Shared Libraries

Objective

Understand how to compile Go code into a C-compatible shared object (.so / .dll) and header file using cgo.

Key Concepts

  1. package main: A library export must still be a main package.
  2. import "C": Enables cgo.
  3. //export SymbolName: Comment directive to expose functions to C.
  4. -buildmode=c-shared: The compiler flag that creates the artifact.

Code: golib.go

package main

import "C"
import "fmt"

// main is required but ignored in shared libraries
func main() {}

//export HelloFromGo
func HelloFromGo() {
	fmt.Println("Go: Hello from the Go Runtime!")
}

Lab 1: Build and Verify

  1. Compile the library:

    go build -o libgo.so -buildmode=c-shared golib.go

    Result: Generates libgo.so (binary) and libgo.h (C header).

  2. Inspect libgo.h to see how Go types map to C types.


Module 2: The Data Bridge - Strings and Memory

Objective

Learn to pass string data from C/C++ into Go.

Key Concepts

  • Immutability: Go strings are immutable and distinct from char*.
  • C.GoString: Converts a C char* to a native Go string (copies memory).
  • Memory Safety: Go's Garbage Collector (GC) does not see C memory; C does not see Go memory. Copies are usually required.

Code: printer.go

package main

import "C"
import "fmt"

func main() {}

//export PrintMessage
func PrintMessage(msg *C.char) {
    // Convert C string to Go string (Safe Copy)
    goStr := C.GoString(msg)
    fmt.Printf("Go received: %s\n", goStr)
}

Lab 2: The C Loader

Create loader.c to test the library.

#include "libgo.h" // Generated by Go
#include <stdio.h>

int main() {
    printf("C: Calling Go...\n");
    PrintMessage("Hello from C Land");
    printf("C: Finished.\n");
    return 0;
}

Build & Run:

go build -o libgo.so -buildmode=c-shared printer.go
gcc -o loader loader.c ./libgo.so
./loader

Module 3: Integration with C++

Objective

Wrap the C-compatible Go library in a C++ application.

Key Concepts

  • Name Mangling: C++ mangles function names. Go exports C symbols.
  • extern "C": Tells the C++ compiler to treat the Go header as raw C code, preventing name mangling mismatch.

Best Practice

Do not include libgo.h directly in multiple C++ headers. Create a wrapper class to isolate the C-style dependency.

Code: main.cpp

#include <iostream>

// extern "C" is mandatory for linking Go libraries
extern "C" {
    #include "libgo.h"
}

int main() {
    std::cout << "C++: Starting application..." << std::endl;
    
    // Pass a C-style string literal
    PrintMessage((char*)"Greetings from C++");
    
    std::cout << "C++: Done." << std::endl;
    return 0;
}

Module 4: Managing State with Handles (The "this" Pointer)

Objective

Maintain state in Go between C++ calls (e.g., a Go class instance).

The Problem

You cannot pass a Go pointer (like *MyStruct) directly to C++ and pass it back later. The Go Garbage Collector might move the struct, invalidating the pointer held by C++.

The Solution: cgo Handle

Use cgo.NewHandle (Go 1.17+) to map a Go object to a uintptr safe for C/C++.

Code: stateful.go

package main

import "C"
import (
	"fmt"
	"runtime/cgo"
)

type Worker struct {
	Name string
}

//export CreateWorker
func CreateWorker(name *C.char) C.uintptr_t {
	w := &Worker{Name: C.GoString(name)}
	// Return a handle (an integer ID)
	return C.uintptr_t(cgo.NewHandle(w))
}

//export DoWork
func DoWork(h C.uintptr_t) {
	handle := cgo.Handle(h)
	// Retrieve the Go object safely
	w := handle.Value().(*Worker)
	fmt.Printf("Go Worker [%s] is working.\n", w.Name)
}

//export DeleteWorker
func DeleteWorker(h C.uintptr_t) {
	cgo.Handle(h).Delete() // Allow GC to reclaim memory
}

func main() {}

Module 5: Safety and Concurrency

Critical Considerations

  1. Stack Size: C threads have large stacks. Go goroutines start small. The Go runtime handles the stack switching, but there is overhead (cgo overhead).
  2. Signal Handling: The Go runtime installs signal handlers (SIGSEGV, SIGINT). Be careful if your C++ app relies on custom signal handlers.
  3. Performance: C-to-Go calls are not free.
    • Bad: Calling Go inside a tight C++ loop 1 million times.
    • Good: C++ collecting data, batching it, and sending it to Go once.

Checklist

  • Always verify inputs (nil checks) on the Go side.
  • Ensure cgo.Handles are deleted, or you will leak memory.
  • Run binaries with GODEBUG=cgocheck=1 during development to catch illegal pointer passing.

Module 6: Final Project - The "Go-Powered Logger"

Scenario

A high-performance C++ simulation engine needs a modern, asynchronous logging service. We will write the logger in Go (utilizing its channels and string handling) and consume it from a C++ class.

Project Structure

/project
  ├── logger.go        (The Go Shared Library)
  ├── LoggerWrapper.h  (C++ Header)
  ├── LoggerWrapper.cpp(C++ Implementation)
  ├── main.cpp         (The Application)
  └── Makefile         (Build Automation)

1. The Go Library (logger.go)

package main

/*
#include <stdlib.h>
*/
import "C"
import (
	"fmt"
	"runtime/cgo"
	"sync"
	"time"
)

// LogSystem represents our Go object
type LogSystem struct {
	msgChan chan string
	wg      sync.WaitGroup
}

//export NewLogger
func NewLogger() C.uintptr_t {
	ls := &LogSystem{
		msgChan: make(chan string, 100),
	}

	// Start a background goroutine for non-blocking logging
	ls.wg.Add(1)
	go func() {
		defer ls.wg.Done()
		for msg := range ls.msgChan {
			// Simulate I/O
			fmt.Printf("[GO-LOG] %s - %s\n", time.Now().Format(time.TimeOnly), msg)
		}
	}()

	return C.uintptr_t(cgo.NewHandle(ls))
}

//export LogMessage
func LogMessage(h C.uintptr_t, cMsg *C.char) {
	handle := cgo.Handle(h)
	ls := handle.Value().(*LogSystem)
	
	// Copy string immediately so C++ can free its memory
	goMsg := C.GoString(cMsg)
	
	// Send to channel (non-blocking unless full)
	select {
	case ls.msgChan <- goMsg:
	default:
		fmt.Println("[GO-LOG] Dropped message, buffer full")
	}
}

//export CloseLogger
func CloseLogger(h C.uintptr_t) {
	handle := cgo.Handle(h)
	ls := handle.Value().(*LogSystem)

	close(ls.msgChan)
	ls.wg.Wait() // Wait for drain
	
	handle.Delete() // Clean up handle map
}

func main() {}

2. The C++ Wrapper Header (LoggerWrapper.h)

#pragma once
#include <string>
#include <cstdint>

// Forward declaration to avoid including go header here
extern "C" {
    typedef uintptr_t GoHandle;
}

class LoggerWrapper {
private:
    GoHandle handle;

public:
    LoggerWrapper();
    ~LoggerWrapper();
    void Log(const std::string& message);
};

3. The C++ Wrapper Implementation (LoggerWrapper.cpp)

#include "LoggerWrapper.h"
#include <vector>

// Include the Go-generated header strictly inside implementation
extern "C" {
    #include "liblogger.h"
}

LoggerWrapper::LoggerWrapper() {
    this->handle = NewLogger();
}

LoggerWrapper::~LoggerWrapper() {
    CloseLogger(this->handle);
}

void LoggerWrapper::Log(const std::string& message) {
    // Cast const char* to char* for the C interface
    LogMessage(this->handle, (char*)message.c_str());
}

4. The C++ Application (main.cpp)

#include <iostream>
#include "LoggerWrapper.h"

int main() {
    std::cout << "APP: Engine Initializing..." << std::endl;

    {
        // RAII: Logger is created here
        LoggerWrapper logger;

        logger.Log("System boot sequence started");
        logger.Log("Loading textures...");
        logger.Log("Connecting to database...");
    
        std::cout << "APP: Doing heavy C++ math..." << std::endl;
        
        // Logger destroyed when leaving scope
    }

    std::cout << "APP: Shutdown complete." << std::endl;
    return 0;
}

5. Build Script (Makefile)

all: clean build run

build:
	# 1. Compile Go into shared library
	go build -o liblogger.so -buildmode=c-shared logger.go

	# 2. Compile C++ with link to Go library
	# Note: -L. looks for library in current dir, -llogger links against liblogger.so
	# -Wl,-rpath=. tells the binary to look in current dir for .so at runtime
	g++ -o app main.cpp LoggerWrapper.cpp -L. -llogger -Wl,-rpath=.

run:
	./app

clean:
	rm -f liblogger.so liblogger.h app

Expected Output

APP: Engine Initializing...
[GO-LOG] 10:00:01 - System boot sequence started
[GO-LOG] 10:00:01 - Loading textures...
APP: Doing heavy C++ math...
[GO-LOG] 10:00:01 - Connecting to database...
APP: Shutdown complete.

Go ↔ C Integration: The "Single Shared Library" Pattern

This lesson plan focuses on a specific, high-value architectural pattern: creating a single shared library (libx.so) that contains both C "glue" code and Go business logic. This allows you to expose a clean, standard C API to consumers while hiding the Go implementation details and CGo type conversions internally.

Table of Contents

  1. Architecture Overview
  2. Step 1: The Go Backend (Internal Logic)
  3. Step 2: The C Interface (Internal Wrapper)
  4. Step 3: Building the Hybrid Library
  5. Step 4: The Consumer Application
  6. Step 5: Compilation & Execution

1. Architecture Overview

We will build libx.so. It is unique because it is built by the Go toolchain but includes a C file compiled directly into it.

  • External World (main.c): Sees standard C functions (e.g., LogString). Does not know Go exists.
  • Internal C Layer (interface.c): Translates standard C types to Go types.
  • Go Layer (backend.go): Performs the actual I/O.

2. Step 1: The Go Backend (Internal Logic)

Create backend.go. This file handles the file I/O. Note the function is exported, but we intend it to be called by our internal C neighbor, not directly by the end-user.

package main

import "C"

import (
	"log"
	"os"
	"sync"
)

var (
	mu   sync.Mutex
	file *os.File
)

//export InternalGoLogger
func InternalGoLogger(cMsg *C.char) {
	// 1. Convert C String to Go String immediately
	msg := C.GoString(cMsg)

	mu.Lock()
	defer mu.Unlock()

	// 2. Lazy init file
	if file == nil {
		var err error
		file, err = os.OpenFile("system.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			log.Println("Go: Error opening log:", err)
			return
		}
	}

	// 3. Write to file
	if _, err := file.WriteString(msg + "\n"); err != nil {
		log.Println("Go: Write error:", err)
	}
}

// Main is required for buildmode=c-shared
func main() {}

3. Step 2: The C Interface (Internal Wrapper)

Create interface.c in the same directory. This is the bridge.

Key Concept: #include "_cgo_export.h". This header does not exist on your disk yet. It is generated virtually by the go build command. It allows this C code to see functions marked with //export in the Go file.

#include <stdio.h>
#include "_cgo_export.h" // Auto-generated link to Go functions

// This is the clean C API we expose to the world
void LogString(const char* msg) {
    // We can add pure C logic here (pre-processing, validation)
    // printf("C Wrapper: Passing '%s' to Go...\n", msg);
    
    // Call the Go function (defined in _cgo_export.h)
    // Cast const char* to char* to match Go's generated signature
    InternalGoLogger((char*)msg);
}

4. Step 3: Building the Hybrid Library

When you run go build with C files in the directory, CGo automatically compiles and links them.

# Build the single shared object containing both Go and C parts
go build -o libx.so -buildmode=c-shared .

Artifacts generated:

  1. libx.so: The binary library.
  2. libx.h: A Go-generated header. Note: This header exports InternalGoLogger, but it does NOT automatically export LogString because LogString is pure C.

5. Step 4: The Consumer Application

Create main.c. This represents your legacy C++ app or C client. It simply needs to know LogString exists.

#include <stdio.h>

// Forward declaration of the function inside libx.so
// In production, you would provide a handwritten 'libx_api.h'
void LogString(const char* msg);

int main() {
    printf("--- Main C App Starting ---\n");

    // Pass standard C strings. No Go types visible here.
    LogString("Server initialized");
    LogString("Connection received from 192.168.1.1");
    LogString("Error: Low memory");

    printf("--- Main C App Finished ---\n");
    return 0;
}

6. Step 5: Compilation & Execution

Link the consumer main.c against the hybrid libx.so.

# 1. Compile the consumer
# -L. looks for libs in current dir, -lx links against libx.so
gcc -o app main.c -L. -lx -lpthread

# 2. Run
# LD_LIBRARY_PATH=. tells the OS where to find libx.so at runtime
export LD_LIBRARY_PATH=.
./app

Verification

Console Output:

--- Main C App Starting ---
--- Main C App Finished ---

File Content (system.log):

Server initialized
Connection received from 192.168.1.1
Error: Low memory

Why is this Best Practice?

  1. Isolation: The C++ application (main.c) never imports <libx.h> generated by Go. It doesn't need to know about GoInt, GoString, or GoSlice. It deals only with const char*.
  2. Safety: The potentially unsafe conversion (C.GoString) is contained entirely within libx.so where you control both sides of the bridge.
  3. Deployment: You ship exactly one file (libx.so) that handles its own dependencies.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment