diff --git a/changelog.md b/changelog.md
index 6081c90b1eef4..bfd6a2db69934 100644
--- a/changelog.md
+++ b/changelog.md
@@ -206,6 +206,9 @@ provided by the operating system.
 - The `cstring` doesn't support `[]=` operator in JS backend.
 
 - nil dereference is not allowed at compile time. `cast[ptr int](nil)[]` is rejected at compile time.
+- `importcpp` procs now support free functions via `!` prefix:
+`proc freeFn(a: cint) {.importcpp: "!$1".}` maps to `void freeFn(int)`.
+likewise with `importcpp: "!freeFn"`.
 
 - `typetraits.distinctBase` now is identity instead of error for non distinct types.
 
diff --git a/compiler/ast.nim b/compiler/ast.nim
index 2b1f76e2d3632..f21b124458549 100644
--- a/compiler/ast.nim
+++ b/compiler/ast.nim
@@ -298,6 +298,9 @@ type
     sfUsedInFinallyOrExcept  # symbol is used inside an 'except' or 'finally'
     sfSingleUsedTemp  # For temporaries that we know will only be used once
     sfNoalias         # 'noalias' annotation, means C's 'restrict'
+    sfCompileToCpp
+      # skModule: compile the module as C++ code
+      # skType, skProc: C++ symbol
 
   TSymFlags* = set[TSymFlag]
 
@@ -321,7 +324,6 @@ const
   sfReorder* = sfForward
     # reordering pass is enabled
 
-  sfCompileToCpp* = sfInfixCall       # compile the module as C++ code
   sfCompileToObjc* = sfNamedParamCall # compile the module as Objective-C code
   sfExperimental* = sfOverriden       # module uses the .experimental switch
   sfGoto* = sfOverriden               # var is used for 'goto' code generation
@@ -1945,7 +1947,7 @@ proc canRaiseConservative*(fn: PNode): bool =
 
 proc canRaise*(fn: PNode): bool =
   if fn.kind == nkSym and (fn.sym.magic notin magicsThatCanRaise or
-      {sfImportc, sfInfixCall} * fn.sym.flags == {sfImportc} or
+      {sfImportc, sfCompileToCpp} * fn.sym.flags == {sfImportc} or
       sfGeneratedOp in fn.sym.flags):
     result = false
   elif fn.kind == nkSym and fn.sym.magic == mEcho:
diff --git a/compiler/ccgcalls.nim b/compiler/ccgcalls.nim
index 64b883087ad06..b53987647716a 100644
--- a/compiler/ccgcalls.nim
+++ b/compiler/ccgcalls.nim
@@ -281,7 +281,7 @@ proc genArg(p: BProc, n: PNode, param: PSym; call: PNode, needsTmp = false): Rop
     # means '*T'. See posix.nim for lots of examples that do that in the wild.
     let callee = call[0]
     if callee.kind == nkSym and
-        {sfImportc, sfInfixCall, sfCompilerProc} * callee.sym.flags == {sfImportc} and
+        {sfImportc, sfCompileToCpp, sfCompilerProc} * callee.sym.flags == {sfImportc} and
         {lfHeader, lfNoDecl} * callee.sym.loc.flags != {}:
       result = addrLoc(p.config, a)
     else:
diff --git a/compiler/ccgtypes.nim b/compiler/ccgtypes.nim
index 9c751b1ca1b9b..15598359699bd 100644
--- a/compiler/ccgtypes.nim
+++ b/compiler/ccgtypes.nim
@@ -201,8 +201,8 @@ proc isImportedType(t: PType): bool =
 
 proc isImportedCppType(t: PType): bool =
   let x = t.skipTypes(irrelevantForBackend)
-  result = (t.sym != nil and sfInfixCall in t.sym.flags) or
-           (x.sym != nil and sfInfixCall in x.sym.flags)
+  result = (t.sym != nil and sfCompileToCpp in t.sym.flags) or
+           (x.sym != nil and sfCompileToCpp in x.sym.flags)
 
 proc getTypeDescAux(m: BModule, origTyp: PType, check: var IntSet; kind: TSymKind): Rope
 
diff --git a/compiler/cgen.nim b/compiler/cgen.nim
index f870828662183..fc0fe7fdafe50 100644
--- a/compiler/cgen.nim
+++ b/compiler/cgen.nim
@@ -1099,7 +1099,7 @@ proc requiresExternC(m: BModule; sym: PSym): bool {.inline.} =
   result = (sfCompileToCpp in m.module.flags and
            sfCompileToCpp notin sym.getModule().flags and
            m.config.backend != backendCpp) or (
-           sym.flags * {sfInfixCall, sfCompilerProc, sfMangleCpp} == {} and
+           sym.flags * {sfCompileToCpp, sfCompilerProc, sfMangleCpp} == {} and
            sym.flags * {sfImportc, sfExportc} != {} and
            sym.magic == mNone and
            m.config.backend == backendCpp)
diff --git a/compiler/pragmas.nim b/compiler/pragmas.nim
index 8fbdd3579f380..b08404f27c21e 100644
--- a/compiler/pragmas.nim
+++ b/compiler/pragmas.nim
@@ -178,9 +178,19 @@ proc processImportCompilerProc(c: PContext; s: PSym, extname: string, info: TLin
   incl(s.loc.flags, lfImportCompilerProc)
 
 proc processImportCpp(c: PContext; s: PSym, extname: string, info: TLineInfo) =
+  var extname = extname
+  let isFreeFunction = extname.startsWith "!"
+  #[
+  example: `proc fun2(a: cstring): cint {.importcpp:"!fun2".}`
+  see more tests in tests/cpp/t12150.nim
+  ]#
+  if isFreeFunction:
+    extname = extname[1..^1]
+  else:
+    incl(s.flags, sfInfixCall)
   setExternName(c, s, extname, info)
   incl(s.flags, sfImportc)
-  incl(s.flags, sfInfixCall)
+  incl(s.flags, sfCompileToCpp)
   excl(s.flags, sfForward)
   if c.config.backend == backendC:
     let m = s.getModule()
diff --git a/compiler/sighashes.nim b/compiler/sighashes.nim
index 156bc66d79bdb..57136b90c024d 100644
--- a/compiler/sighashes.nim
+++ b/compiler/sighashes.nim
@@ -108,7 +108,7 @@ proc hashType(c: var MD5Context, t: PType; flags: set[ConsiderFlag]) =
     else:
       c.hashSym(t.sym)
   of tyGenericInst:
-    if sfInfixCall in t.base.sym.flags:
+    if sfCompileToCpp in t.base.sym.flags:
       # This is an imported C++ generic type.
       # We cannot trust the `lastSon` to hold a properly populated and unique
       # value for each instantiation, so we hash the generic parameters here:
diff --git a/compiler/types.nim b/compiler/types.nim
index a0d43ec095457..d181ceebed6fe 100644
--- a/compiler/types.nim
+++ b/compiler/types.nim
@@ -279,7 +279,7 @@ proc containsObject*(t: PType): bool =
 
 proc isObjectWithTypeFieldPredicate(t: PType): bool =
   result = t.kind == tyObject and t[0] == nil and
-      not (t.sym != nil and {sfPure, sfInfixCall} * t.sym.flags != {}) and
+      not (t.sym != nil and {sfPure, sfCompileToCpp} * t.sym.flags != {}) and
       tfFinal notin t.flags
 
 type
diff --git a/doc/manual.rst b/doc/manual.rst
index 4884db0e1b266..e0917f8013a22 100644
--- a/doc/manual.rst
+++ b/doc/manual.rst
@@ -7014,18 +7014,24 @@ language for maximum flexibility:
 - A dot following the hash ``#.`` indicates that the call should use C++'s dot
   or arrow notation.
 - An at symbol ``@`` is replaced by the remaining arguments, separated by commas.
+- An exclamation symbol ``!`` indicates a free (non-member) function.
 
 For example:
 
 .. code-block:: nim
+  // member function
   proc cppMethod(this: CppObj, a, b, c: cint) {.importcpp: "#.CppMethod(@)".}
   var x: ptr CppObj
   cppMethod(x[], 1, 2, 3)
+  // free function
+  proc freeFn(a: cint) {.importcpp: "!$1".} # or importcpp: "!freeFn"
+  freeFn(4)
 
 Produces:
 
 .. code-block:: C
-  x->CppMethod(1, 2, 3)
+  x->CppMethod(1, 2, 3);
+  freeFn(4);
 
 As a special rule to keep backward compatibility with older versions of the
 ``importcpp`` pragma, if there is no special pattern
diff --git a/tests/cpp/m12150.nim b/tests/cpp/m12150.nim
new file mode 100644
index 0000000000000..196c4a7499a8f
--- /dev/null
+++ b/tests/cpp/m12150.nim
@@ -0,0 +1,4 @@
+proc fun1(): cint {.exportcpp.} = 10
+proc fun2(a: cstring): cint {.exportcpp.} = 11
+proc fun2(): cint {.exportcpp.} = 12
+proc fun3(): cint {.exportc.} = 13
diff --git a/tests/cpp/t12150.nim b/tests/cpp/t12150.nim
new file mode 100644
index 0000000000000..5f6ff8b8ca198
--- /dev/null
+++ b/tests/cpp/t12150.nim
@@ -0,0 +1,18 @@
+discard """
+  targets: "cpp"
+"""
+
+proc fun1(): cint {.importcpp:"!$1".}
+proc fun2(a: cstring): cint {.importcpp:"!fun2".}
+proc fun2(): cint {.importcpp:"!$1".}
+proc fun2Aux(): cint {.importcpp:"!fun2".}
+proc fun3(): cint {.importc.}
+
+doAssert fun1() == 10
+doAssert fun2(nil) == 11
+doAssert fun2() == 12
+doAssert fun2Aux() == 12
+doAssert fun3() == 13
+
+import ./m12150
+